diff --git a/CHANGELOG.md b/CHANGELOG.md index f9946d1..5a3b547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,17 @@ _In progress on this branch — content still accumulating; date set at tag time ### Changed +- **`xphp compile` runs the validation gate by default.** Compile now runs the same + gate as `xphp check` (the generic validators plus PHPStan over the compiled output) + *before* emitting, and fails the build — emitting nothing — when the gate reports an + error. So a type argument that names no real class (`new Box::()`) and + every other PHPStan-detectable error now fails at compile time instead of slipping + through to runtime. PHPStan's real autoloader visibility makes this sound — a genuine + plain-`.php` domain class used as a type argument is never false-rejected. For a fast + iteration build, `--no-check` skips the gate and compiles directly (the previous + behavior). `--no-phpstan` runs only the generic validators; a missing PHPStan degrades + to a non-failing warning. + See [ADR-0021](docs/adr/0021-compile-runs-the-check-gate-by-default.md). - **BREAKING — a `final` variant class is now rejected.** A `final class Box<+T>` previously compiled, with the generated specialization silently dropping `final` so the `extends` subtype edge between specializations could land — which made diff --git a/docs/adr/0021-compile-runs-the-check-gate-by-default.md b/docs/adr/0021-compile-runs-the-check-gate-by-default.md new file mode 100644 index 0000000..1eb71c2 --- /dev/null +++ b/docs/adr/0021-compile-runs-the-check-gate-by-default.md @@ -0,0 +1,66 @@ +# 21. `xphp compile` runs the check gate by default + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +`xphp compile` and `xphp check` were independent commands. `compile` transpiles `.xphp` → `.php`; `check` +runs the generic validators **and** PHPStan over the compiled output ([ADR-0009](0009-phpstan-over-compiled-output.md)) +and is the authoritative "is this program sound?" gate. Because `compile` did not run that gate, a class of +errors slipped through it silently and surfaced only at runtime — most notably a **type argument that names +no real class** (`new Box::()`), which `compile` emitted as a reference to a phantom FQN +(`\App\Nonexistent`), fataling when the code ran. + +Earlier attempts to catch this *inside* the transpiler were rejected: a hard error on an unresolved type +argument, and a fallback that scanned the project's `.php` files, both **false-rejected valid code** — a +plain-`.php` domain class used as a type argument (`new Producer::()`, intentionally supported) is +indistinguishable from a typo when the transpiler only sees its own source roots and cannot read the autoload +map. The transpiler is the wrong place to answer "does this class exist?"; PHPStan, running with the project's +real autoloader, already answers it soundly. + +The question: how should `compile` stop emitting code that the gate would reject, without re-implementing +(unsoundly) the existence check PHPStan already does, and without taxing the fast edit/compile loop with a +multi-second PHPStan run on every build? + +## Decision + +**`compile` runs the same gate as `check` by default, before emitting anything**, and fails the build +(emitting nothing) when the gate reports an error. The shared gate is `CheckGate` (the generic validators +plus, on a clean pass, the PHPStan layer); `compile` and `check` both run it. One escape valve keeps it cheap: + +- **`--no-check`** skips the gate entirely and compiles directly (the previous behavior) — for a fast local + iteration build when the gate has already been run separately. + +A missing PHPStan degrades to a non-failing warning (the build proceeds), matching `check`. `--no-phpstan` +runs only the generic validators (still a real gate, just without the PHPStan layer). + +### A skip-when-unchanged marker was considered and rejected as unsound + +An earlier draft added a content-hash marker (`/.check-ok`) that skipped the gate when the inputs +hashed identically to the last green run, to spare no-op rebuilds the multi-second PHPStan floor. It was +dropped: the marker's key can only ever be a *proxy* for the gate's true dependency set, and the proxy is +strictly narrower than the real set. The gate's verdict also depends on the PHPStan config's transitive +`includes:` closure and on the whole autoload universe the emitted code can reference — neither of which the +key can cheaply capture. So the marker could report "current" for an input whose gate result had actually +changed, **skip a gate that would now fail, and re-emit rejected output at exit 0** — silently re-opening the +exact runtime-fatal hole the gate exists to close. A slower-but-sound default beats a fast one that +occasionally lies. Fast warm rebuilds are deferred to a sound mechanism (reusing PHPStan's own result cache, +which tracks its config and analyzed-file set) rather than a home-grown skip. + +## Consequences + +- **Good:** a typo'd or undeclared type argument (and every other PHPStan-detectable error) now fails at + `compile`, not at runtime — "compile error over runtime error" holds for the default build. The soundness + comes from PHPStan's real autoloader visibility, so no valid plain-`.php` domain class is ever + false-rejected, and the transpiler gains no unsound `.php`-scanning machinery. +- **Good:** `--no-check` is always available for a guaranteed-fast build when the gate has already been run. +- **Trade-off:** the default `compile` now pays the PHPStan floor on every build (≈10× a bare compile on a + small project), since the unsound skip marker was dropped. This is the deliberate safety/speed trade; + `--no-check` bounds its reach for local iteration. +- **Trade-off:** `compile` now depends on PHPStan being resolvable for the full gate (it degrades to a + warning when absent), where before it had no such dependency. +- **Deferred:** fast warm rebuilds via a *sound* cache — reusing PHPStan's own result cache (stabilize the + representative emission paths and persist its cache directory) so it re-analyzes only what changed, without + a home-grown skip that could false-hit. A single-emit optimization (run PHPStan over the real emitted output + instead of a separately compiled representative set) and a `--require-phpstan` strict mode are further + follow-ups. diff --git a/docs/adr/README.md b/docs/adr/README.md index bd1ee60..3140591 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -38,3 +38,4 @@ should be added here as a new numbered file; copy | [0018](0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md) | Grounding a method-generic bound that references an enclosing class type parameter | Accepted | | [0019](0019-trait-members-are-not-modeled-in-the-type-hierarchy.md) | Trait-imported members are not modeled in the type hierarchy | Accepted | | [0020](0020-diagnose-and-restructure-self-reintroducing-specialization.md) | Diagnose and restructure self-reintroducing specialization (erased seam deferred) | Accepted | +| [0021](0021-compile-runs-the-check-gate-by-default.md) | `xphp compile` runs the check gate by default | Accepted | diff --git a/docs/getting-started.md b/docs/getting-started.md index 9cd7350..f220712 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -113,6 +113,17 @@ After the compile completes you'll have: Both `dist/` and `.xphp-cache/` can be gitignored — they're generated artifacts your CI/CD pipeline rebuilds on every deploy. +**Safe by default:** `compile` runs the same validation gate as +[`check`](#) — the generic validators plus PHPStan over the compiled +output — *before* emitting, and fails the build (writing nothing) if it +finds an error, so a typo'd or undeclared type never reaches runtime. For a +fast iteration build — when you've already run `check` and just want to +re-emit — skip the gate with `--no-check`: + +```bash +vendor/bin/xphp compile src dist .xphp-cache --no-check # transpile only, no gate +``` + ### The recommended project setup: an `xphp.json` manifest The single-directory form above is the quickest way to compile one diff --git a/src/Console/ApplicationConsole.php b/src/Console/ApplicationConsole.php index 7554aeb..a55b0b0 100644 --- a/src/Console/ApplicationConsole.php +++ b/src/Console/ApplicationConsole.php @@ -14,6 +14,7 @@ use XPHP\FileSystem\FileFinder; use XPHP\FileSystem\FileReader; use XPHP\FileSystem\FileWriter; +use XPHP\StaticAnalysis\CheckGate; use XPHP\StaticAnalysis\StaticAnalysisGate; use XPHP\Transpiler\Monomorphize\Compiler; use XPHP\Transpiler\Monomorphize\Registry; @@ -50,8 +51,9 @@ public function __construct( ); $sourceResolver = new SourceResolver($fileFinder, new ManifestResolver($fileReader, $fileFinder)); + $gate = new CheckGate($compiler, new StaticAnalysisGate($compiler)); - $this->addCommand(new CompileCommand($sourceResolver, $compiler)); - $this->addCommand(new CheckCommand($sourceResolver, $compiler, new StaticAnalysisGate($compiler))); + $this->addCommand(new CompileCommand($sourceResolver, $compiler, $gate)); + $this->addCommand(new CheckCommand($sourceResolver, $gate)); } } diff --git a/src/Console/Command/CheckCommand.php b/src/Console/Command/CheckCommand.php index bab3320..366ce29 100644 --- a/src/Console/Command/CheckCommand.php +++ b/src/Console/Command/CheckCommand.php @@ -12,12 +12,8 @@ use Symfony\Component\Console\Output\OutputInterface; use RuntimeException; use XPHP\Config\SourceResolver; -use XPHP\Diagnostics\Renderer\DiagnosticRenderer; -use XPHP\Diagnostics\Renderer\GithubRenderer; -use XPHP\Diagnostics\Renderer\JsonRenderer; -use XPHP\Diagnostics\Renderer\TextRenderer; -use XPHP\StaticAnalysis\StaticAnalysisGate; -use XPHP\Transpiler\Monomorphize\Compiler; +use XPHP\Diagnostics\Renderer\RendererFactory; +use XPHP\StaticAnalysis\Gate; /** * `xphp check [--format=text|json|github] [--no-phpstan] @@ -36,8 +32,7 @@ final class CheckCommand extends Command { public function __construct( private readonly SourceResolver $sourceResolver, - private readonly Compiler $compiler, - private readonly StaticAnalysisGate $staticAnalysisGate, + private readonly Gate $gate, ) { parent::__construct(); } @@ -63,7 +58,7 @@ protected function execute( $configOpt = $input->getOption('config'); $formatOption = $input->getOption('format'); - $renderer = $this->rendererFor(is_string($formatOption) ? $formatOption : ''); + $renderer = RendererFactory::for(is_string($formatOption) ? $formatOption : ''); if ($renderer === null) { $output->writeln('Unknown format (expected: text, json, github)'); return self::INVALID; @@ -84,42 +79,22 @@ protected function execute( return self::INVALID; } - $sources = $resolved->files; - $diagnostics = $this->compiler->check($sources); - - // Only layer PHPStan on when the generic checks pass: invalid generics can't be - // compiled to the concrete output PHPStan needs, and reporting both at once would - // just be noise on top of the real (generic) errors. - if (!$diagnostics->hasErrors() && $input->getOption('no-phpstan') !== true) { - $binOption = $input->getOption('phpstan-bin'); - $configOption = $input->getOption('phpstan-config'); - $findings = $this->staticAnalysisGate->analyze( - $sources, - // @infection-ignore-all -- rootByFile is authoritative for the temp-workspace emit; - // this scalar base is an unused fallback, and PHPStan resolves by symbol not path. - is_string($sourceArg) ? $sourceArg : '', - $cwd, - is_string($binOption) ? $binOption : null, - is_string($configOption) ? $configOption : null, - $resolved->rootByFile, - ); - foreach ($findings as $finding) { - $diagnostics->add($finding); - } - } + $binOption = $input->getOption('phpstan-bin'); + $configOption = $input->getOption('phpstan-config'); + $diagnostics = $this->gate->run( + $resolved->files, + // @infection-ignore-all -- rootByFile is authoritative for the temp-workspace emit; + // this scalar base is an unused fallback, and PHPStan resolves by symbol not path. + is_string($sourceArg) ? $sourceArg : '', + $cwd, + $input->getOption('no-phpstan') !== true, + is_string($binOption) ? $binOption : null, + is_string($configOption) ? $configOption : null, + $resolved->rootByFile, + ); $output->write($renderer->render($diagnostics->all())); return $diagnostics->hasErrors() ? self::FAILURE : self::SUCCESS; } - - private function rendererFor(string $format): ?DiagnosticRenderer - { - return match ($format) { - 'text' => new TextRenderer(), - 'json' => new JsonRenderer(), - 'github' => new GithubRenderer(), - default => null, - }; - } } diff --git a/src/Console/Command/CompileCommand.php b/src/Console/Command/CompileCommand.php index a775e9e..29dcd65 100644 --- a/src/Console/Command/CompileCommand.php +++ b/src/Console/Command/CompileCommand.php @@ -11,16 +11,24 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use XPHP\Config\ResolvedSources; use XPHP\Config\SourceResolver; +use XPHP\Diagnostics\Renderer\RendererFactory; +use XPHP\StaticAnalysis\Gate; use XPHP\Transpiler\Monomorphize\Compiler; /** - * `xphp compile [ [ []]] [--config=PATH] [--target=DIR] [--cache=DIR]` + * `xphp compile [ [ []]] [--config=PATH] [--target=DIR] [--cache=DIR] + * [--no-check] [--no-phpstan] [--phpstan-bin=PATH] [--phpstan-config=PATH] [--format=…]` * * Single-dir form (back-compatible): `xphp compile src dist cache`. Manifest form: omit the source - * and supply `--config ` (or run where an `xphp.json` is auto-detected) to compile a - * package together with its declared sources and transitively-included packages in one pass. + * and supply `--config ` (or run where an `xphp.json` is auto-detected). * Output dirs resolve as: `--target`/`--cache` option > manifest value > positional arg > default. + * + * Safe by default: compile runs the same validation gate as `xphp check` (generic validators + PHPStan over + * the compiled output) FIRST, and emits nothing if the gate reports an error — so a typo'd type or an + * undeclared class fails the build at compile time instead of slipping through to runtime. `--no-check` + * skips the gate (fast, today's behavior) for a trusted iteration build. */ #[AsCommand('compile')] final class CompileCommand extends Command @@ -28,6 +36,7 @@ final class CompileCommand extends Command public function __construct( private readonly SourceResolver $sourceResolver, private readonly Compiler $compiler, + private readonly Gate $gate, ) { parent::__construct(); } @@ -40,7 +49,12 @@ protected function configure(): void ->addArgument('cache', InputArgument::OPTIONAL, 'Directory for generated specialized classes (default: .xphp-cache)') ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to an xphp.json manifest (or a dir containing one)') ->addOption('target', null, InputOption::VALUE_REQUIRED, 'Emit dir (overrides the manifest and positional arg)') - ->addOption('cache', null, InputOption::VALUE_REQUIRED, 'Cache dir (overrides the manifest and positional arg)'); + ->addOption('cache', null, InputOption::VALUE_REQUIRED, 'Cache dir (overrides the manifest and positional arg)') + ->addOption('no-check', null, InputOption::VALUE_NONE, 'Skip the validation gate and compile directly (faster; no typo/type safety net)') + ->addOption('no-phpstan', null, InputOption::VALUE_NONE, 'Run only the generic validators in the gate, skipping the PHPStan pass') + ->addOption('phpstan-bin', null, InputOption::VALUE_REQUIRED, 'Path to the PHPStan binary (default: vendor/bin/phpstan, then $PATH)') + ->addOption('phpstan-config', null, InputOption::VALUE_REQUIRED, 'Path to the PHPStan config (default: auto-detect phpstan.neon[.dist])') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Diagnostic output format: text, json, or github', 'text'); } protected function execute( @@ -64,8 +78,7 @@ protected function execute( // Output dirs: option > (manifest value | positional arg) > default. The manifest value and // the positional arg never coexist (positional target/cache require a positional source, - // which manifest mode lacks), so each mode picks its own fallback — keeping every step - // reachable rather than chaining a dead manifest-vs-positional link. + // which manifest mode lacks), so each mode picks its own fallback. $targetOpt = self::stringOrNull($input->getOption('target')); $cacheOpt = self::stringOrNull($input->getOption('cache')); if ($sourceArg !== null) { @@ -78,8 +91,58 @@ protected function execute( // `$resolved->rootByFile` is authoritative for emit paths; the scalar base is only a // fallback for any unmapped file (none here), so the source arg (or empty) suffices. - // @infection-ignore-all -- rootByFile covers every file, so the scalar base is never read. + // @infection-ignore-all -- rootByFile maps EVERY file (single-dir mirrors $dir==$sourceArg; + // manifest populates each file), so $base is a never-consulted fallback: `$sourceArg ?? ''` + // and `''` emit to identical paths. Same equivalence annotated at SourceResolver::fromDirectory. $base = $sourceArg ?? ''; + + // --no-check: compile directly (today's behavior), trusting that the gate has been run separately. + if ($input->getOption('no-check') === true) { + return $this->emit($resolved, $base, $target, $cache, $output); + } + + $formatOption = $input->getOption('format'); + $renderer = RendererFactory::for(is_string($formatOption) ? $formatOption : ''); + if ($renderer === null) { + $output->writeln('Unknown format (expected: text, json, github)'); + return self::INVALID; + } + + $runPhpStan = $input->getOption('no-phpstan') !== true; + $phpstanBin = self::stringOrNull($input->getOption('phpstan-bin')); + $phpstanConfigOpt = self::stringOrNull($input->getOption('phpstan-config')); + + $diagnostics = $this->gate->run( + $resolved->files, + $base, + $cwd, + $runPhpStan, + $phpstanBin, + $phpstanConfigOpt, + $resolved->rootByFile, + ); + + if ($diagnostics->hasErrors()) { + // Emit nothing: the gate runs before any output is written, so there is nothing to roll back. + $output->write($renderer->render($diagnostics->all())); + return self::FAILURE; + } + + // Surface non-error diagnostics (e.g. PHPStan unavailable) but proceed to compile. + if ($diagnostics->all() !== []) { + $output->write($renderer->render($diagnostics->all())); + } + + return $this->emit($resolved, $base, $target, $cache, $output); + } + + private function emit( + ResolvedSources $resolved, + string $base, + string $target, + string $cache, + OutputInterface $output, + ): int { $result = $this->compiler->compile( $resolved->files, $base, diff --git a/src/Diagnostics/Renderer/RendererFactory.php b/src/Diagnostics/Renderer/RendererFactory.php new file mode 100644 index 0000000..0172a61 --- /dev/null +++ b/src/Diagnostics/Renderer/RendererFactory.php @@ -0,0 +1,22 @@ + new TextRenderer(), + 'json' => new JsonRenderer(), + 'github' => new GithubRenderer(), + default => null, + }; + } +} diff --git a/src/StaticAnalysis/CheckGate.php b/src/StaticAnalysis/CheckGate.php new file mode 100644 index 0000000..a272415 --- /dev/null +++ b/src/StaticAnalysis/CheckGate.php @@ -0,0 +1,59 @@ + $rootByFile + */ + public function run( + FilepathArray $sources, + string $sourceDir, + string $workingDir, + bool $runPhpStan, + ?string $phpstanBin, + ?string $phpstanConfig, + ?array $rootByFile, + ): DiagnosticCollector { + $diagnostics = $this->compiler->check($sources); + + if (!$diagnostics->hasErrors() && $runPhpStan) { + $findings = $this->staticAnalysisGate->analyze( + $sources, + $sourceDir, + $workingDir, + $phpstanBin, + $phpstanConfig, + $rootByFile, + ); + foreach ($findings as $finding) { + $diagnostics->add($finding); + } + } + + return $diagnostics; + } +} diff --git a/src/StaticAnalysis/Gate.php b/src/StaticAnalysis/Gate.php new file mode 100644 index 0000000..7df227e --- /dev/null +++ b/src/StaticAnalysis/Gate.php @@ -0,0 +1,29 @@ + $rootByFile + */ + public function run( + FilepathArray $sources, + string $sourceDir, + string $workingDir, + bool $runPhpStan, + ?string $phpstanBin, + ?string $phpstanConfig, + ?array $rootByFile, + ): DiagnosticCollector; +} diff --git a/test/Console/CheckCommandPhpStanTest.php b/test/Console/CheckCommandPhpStanTest.php index cc9c346..645ac77 100644 --- a/test/Console/CheckCommandPhpStanTest.php +++ b/test/Console/CheckCommandPhpStanTest.php @@ -15,6 +15,7 @@ use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; +use XPHP\StaticAnalysis\CheckGate; use XPHP\StaticAnalysis\StaticAnalysisGate; use XPHP\Transpiler\Monomorphize\Compiler; use XPHP\Transpiler\Monomorphize\SpecializedClassGenerator; @@ -163,8 +164,7 @@ private function tester(): CommandTester new NativeFileFinder(), new ManifestResolver(new NativeFileReader(), new NativeFileFinder()), ), - $compiler, - new StaticAnalysisGate($compiler), + new CheckGate($compiler, new StaticAnalysisGate($compiler)), ), ); } diff --git a/test/Console/CheckCommandTest.php b/test/Console/CheckCommandTest.php index 0a5b3a2..65ac295 100644 --- a/test/Console/CheckCommandTest.php +++ b/test/Console/CheckCommandTest.php @@ -14,6 +14,7 @@ use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; +use XPHP\StaticAnalysis\CheckGate; use XPHP\StaticAnalysis\StaticAnalysisGate; use XPHP\Transpiler\Monomorphize\Compiler; use XPHP\Transpiler\Monomorphize\Specializer; @@ -131,7 +132,7 @@ private function tester(): CommandTester ); return new CommandTester( - new CheckCommand($sourceResolver, $compiler, new StaticAnalysisGate($compiler)), + new CheckCommand($sourceResolver, new CheckGate($compiler, new StaticAnalysisGate($compiler))), ); } diff --git a/test/Console/CompileCommandTest.php b/test/Console/CompileCommandTest.php index 2901dc7..543ffac 100644 --- a/test/Console/CompileCommandTest.php +++ b/test/Console/CompileCommandTest.php @@ -6,14 +6,22 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; use XPHP\Config\ManifestResolver; use XPHP\Config\SourceResolver; +use XPHP\Diagnostics\Diagnostic; +use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; +use XPHP\FileSystem\FilepathArray; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; +use XPHP\StaticAnalysis\CheckGate; +use XPHP\StaticAnalysis\Gate; +use XPHP\StaticAnalysis\StaticAnalysisGate; use XPHP\Transpiler\Monomorphize\Compiler; use XPHP\Transpiler\Monomorphize\SpecializedClassGenerator; use XPHP\Transpiler\Monomorphize\Specializer; @@ -217,7 +225,120 @@ public function testMissingSingleDirIsAClearFailure(): void // --- helpers --- - private function tester(): CommandTester + public function testGateErrorFailsCompileAndEmitsNothing(): void + { + mkdir($this->work . '/src', 0o755, true); + file_put_contents($this->work . '/src/Plain.xphp', "tester($gate); + $exit = $tester->execute([ + 'source' => $this->work . '/src', + 'target' => $this->work . '/dist', + 'cache' => $this->work . '/cache', + ]); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('gate failed', $tester->getDisplay()); + self::assertSame(1, $gate->calls, 'the gate runs once'); + self::assertFileDoesNotExist($this->work . '/dist/Plain.php', 'a failed gate emits nothing'); + } + + public function testNoCheckSkipsGateAndCompilesEvenWhenItWouldFail(): void + { + mkdir($this->work . '/src', 0o755, true); + file_put_contents($this->work . '/src/Plain.xphp', "tester($gate)->execute([ + 'source' => $this->work . '/src', + 'target' => $this->work . '/dist', + 'cache' => $this->work . '/cache', + '--no-check' => true, + ]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertSame(0, $gate->calls, '--no-check never invokes the gate'); + self::assertFileExists($this->work . '/dist/Plain.php'); + } + + public function testCleanGateRunsEveryCompileWithoutASkipMarker(): void + { + mkdir($this->work . '/src', 0o755, true); + file_put_contents($this->work . '/src/Plain.xphp', " $this->work . '/src', + 'target' => $this->work . '/dist', + 'cache' => $this->work . '/cache', + ]; + + // The gate is always run: there is no skip path that could pass off stale output as validated. + $gate = new RecordingGate(); + $this->tester($gate)->execute($args); + $this->tester($gate)->execute($args); + self::assertSame(2, $gate->calls, 'the gate runs on every compile (no skip marker)'); + self::assertFileDoesNotExist($this->work . '/cache/.check-ok', 'no skip marker is written'); + } + + public function testCleanGateWithWarningStillCompilesAndRendersTheWarning(): void + { + mkdir($this->work . '/src', 0o755, true); + file_put_contents($this->work . '/src/Plain.xphp', "tester($gate); + $exit = $tester->execute([ + 'source' => $this->work . '/src', + 'target' => $this->work . '/dist', + 'cache' => $this->work . '/cache', + ]); + + self::assertSame(Command::SUCCESS, $exit, 'a non-error diagnostic does not fail the build'); + self::assertStringContainsString('gate warned', $tester->getDisplay(), 'the warning is surfaced'); + self::assertStringContainsString('Compiled', $tester->getDisplay()); + self::assertFileExists($this->work . '/dist/Plain.php'); + } + + #[Group('phpstan')] + public function testRealGateFailsCompileOnUndeclaredTypeArgument(): void + { + $bin = realpath(__DIR__ . '/../../vendor/bin/phpstan'); + if ($bin === false) { + self::markTestSkipped('phpstan binary not installed (vendor/bin/phpstan)'); + } + mkdir($this->work . '/src', 0o755, true); + file_put_contents($this->work . '/src/Box.xphp', " { public function __construct(public readonly T \$v) {} }\n"); + file_put_contents($this->work . '/src/Use.xphp', "(new \\stdClass());\n"); + + $exit = $this->tester($this->realGate())->execute([ + 'source' => $this->work . '/src', + 'target' => $this->work . '/dist', + 'cache' => $this->work . '/cache', + '--phpstan-bin' => $bin, + ]); + + self::assertSame(Command::FAILURE, $exit, 'a type argument that resolves to no real class fails the gate'); + self::assertFileDoesNotExist($this->work . '/dist/Use.php', 'nothing is emitted when the gate fails'); + } + + private function realGate(): Gate + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + + return new CheckGate($compiler, new StaticAnalysisGate($compiler)); + } + + private function tester(?Gate $gate = null): CommandTester { $phpParser = (new ParserFactory())->createForHostVersion(); $printer = new StandardPrinter(); @@ -235,7 +356,27 @@ private function tester(): CommandTester new ManifestResolver(new NativeFileReader(), new NativeFileFinder()), ); - return new CommandTester(new CompileCommand($sourceResolver, $compiler)); + return new CommandTester( + new CompileCommand($sourceResolver, $compiler, $gate ?? self::cleanGate()), + ); + } + + /** A gate that always passes — isolates the compile-mechanics tests from the real validators/PHPStan. */ + private static function cleanGate(): Gate + { + return new class implements Gate { + public function run( + FilepathArray $sources, + string $sourceDir, + string $workingDir, + bool $runPhpStan, + ?string $phpstanBin, + ?string $phpstanConfig, + ?array $rootByFile, + ): DiagnosticCollector { + return new DiagnosticCollector(); + } + }; } /** @param array $files relative path => content */ @@ -296,3 +437,40 @@ private static function rrmdir(string $dir): void rmdir($dir); } } + +/** + * A {@see Gate} double for the compile-orchestration tests: counts invocations and, when `fail` is set, + * returns one error-severity diagnostic — so the tests exercise the gate/flag logic without the real + * validators or PHPStan. + */ +final class RecordingGate implements Gate +{ + public int $calls = 0; + + public function __construct( + private readonly bool $fail = false, + private readonly bool $warn = false, + ) { + } + + public function run( + FilepathArray $sources, + string $sourceDir, + string $workingDir, + bool $runPhpStan, + ?string $phpstanBin, + ?string $phpstanConfig, + ?array $rootByFile, + ): DiagnosticCollector { + $this->calls++; + $collector = new DiagnosticCollector(); + if ($this->fail) { + $collector->add(new Diagnostic(Severity::Error, 'test.gate_error', 'gate failed')); + } + if ($this->warn) { + $collector->add(new Diagnostic(Severity::Warning, 'test.gate_warning', 'gate warned')); + } + + return $collector; + } +}