From b05ea8c8f386e9642760a5d5147e5c444173c522 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 30 Jun 2026 18:02:21 +0000 Subject: [PATCH] feat(cli): run the validation gate by default on compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compile` now runs the same gate as `check` — the generic validators plus PHPStan over the compiled output — before emitting, and fails the build (writing nothing) when the gate reports an error. A type argument that names no real class, and every other PHPStan-detectable error, now fails at compile time instead of slipping through to runtime. The soundness comes from PHPStan's real autoloader visibility, so a genuine plain-`.php` domain class used as a type argument is never false-rejected — no `.php`-scanning or autoload-map machinery is added to the transpiler. `--no-check` skips the gate and compiles directly (the previous behavior), for a fast iteration build when the gate has already been run separately. `--no-phpstan` runs only the generic validators; a missing PHPStan degrades to a non-failing warning. The gate logic is extracted into a shared `CheckGate` that both commands run, behind a `Gate` interface, with a shared `RendererFactory`. A skip-when-unchanged marker was considered — to spare no-op rebuilds the PHPStan cost — but dropped as unsound: its content-hash key can only proxy the gate's true dependency set, and the proxy is strictly narrower. It cannot capture the PHPStan config's transitive includes or the autoload universe the emitted code references, so it could skip a gate that would now fail and re-emit rejected output silently. 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). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 ++ ...-compile-runs-the-check-gate-by-default.md | 66 +++++++ docs/adr/README.md | 1 + docs/getting-started.md | 11 ++ src/Console/ApplicationConsole.php | 6 +- src/Console/Command/CheckCommand.php | 59 ++---- src/Console/Command/CompileCommand.php | 77 +++++++- src/Diagnostics/Renderer/RendererFactory.php | 22 +++ src/StaticAnalysis/CheckGate.php | 59 ++++++ src/StaticAnalysis/Gate.php | 29 +++ test/Console/CheckCommandPhpStanTest.php | 4 +- test/Console/CheckCommandTest.php | 3 +- test/Console/CompileCommandTest.php | 182 +++++++++++++++++- 13 files changed, 474 insertions(+), 56 deletions(-) create mode 100644 docs/adr/0021-compile-runs-the-check-gate-by-default.md create mode 100644 src/Diagnostics/Renderer/RendererFactory.php create mode 100644 src/StaticAnalysis/CheckGate.php create mode 100644 src/StaticAnalysis/Gate.php 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; + } +}