Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Nonexistent>()`) 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
Expand Down
66 changes: 66 additions & 0 deletions docs/adr/0021-compile-runs-the-check-gate-by-default.md
Original file line number Diff line number Diff line change
@@ -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::<Nonexistent>()`), 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::<Book>()`, 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 (`<cacheDir>/.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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
11 changes: 11 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/Console/ApplicationConsole.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
}
59 changes: 17 additions & 42 deletions src/Console/Command/CheckCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <source> [--format=text|json|github] [--no-phpstan]
Expand All @@ -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();
}
Expand All @@ -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('<error>Unknown format (expected: text, json, github)</error>');
return self::INVALID;
Expand All @@ -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,
};
}
}
77 changes: 70 additions & 7 deletions src/Console/Command/CompileCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,32 @@
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 [<source> [<target> [<cache>]]] [--config=PATH] [--target=DIR] [--cache=DIR]`
* `xphp compile [<source> [<target> [<cache>]]] [--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 <xphp.json>` (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 <xphp.json>` (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
{
public function __construct(
private readonly SourceResolver $sourceResolver,
private readonly Compiler $compiler,
private readonly Gate $gate,
) {
parent::__construct();
}
Expand All @@ -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(
Expand All @@ -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) {
Expand All @@ -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('<error>Unknown format (expected: text, json, github)</error>');
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,
Expand Down
22 changes: 22 additions & 0 deletions src/Diagnostics/Renderer/RendererFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace XPHP\Diagnostics\Renderer;

/**
* Maps a `--format` option to its {@see DiagnosticRenderer}, shared by `check` and `compile` so the two
* commands render diagnostics identically. Returns null for an unknown format (the caller reports it).
*/
final readonly class RendererFactory
{
public static function for(string $format): ?DiagnosticRenderer
{
return match ($format) {
'text' => new TextRenderer(),
'json' => new JsonRenderer(),
'github' => new GithubRenderer(),
default => null,
};
}
}
Loading
Loading