Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5d04c86
feat(diagnostics): add Diagnostic value-object model for the check gate
math3usmartins Jun 17, 2026
b9e1173
feat(check): collect generic bound violations via an optional diagnos…
math3usmartins Jun 17, 2026
598a3fb
feat(check): add a validate-only pass that collects generic errors
math3usmartins Jun 17, 2026
6f86e03
refactor(check): move variance-position validation to a collectable p…
math3usmartins Jun 17, 2026
90450e8
refactor(check): make inner-variance composition collectable
math3usmartins Jun 17, 2026
c4e6815
refactor(check): run variance-position before defaults; tidy docblocks
math3usmartins Jun 17, 2026
7e85687
feat(check): add Text, JSON, and GitHub diagnostic renderers
math3usmartins Jun 17, 2026
b077a4c
test(check): pin GitHub renderer percent/carriage-return escaping
math3usmartins Jun 17, 2026
3034f64
feat(check): add the `xphp check` command with per-file parse resilience
math3usmartins Jun 17, 2026
21f855b
refactor(check): read source files outside the parse try/catch
math3usmartins Jun 17, 2026
d552742
docs: document the `xphp check` diagnostics gate
math3usmartins Jun 17, 2026
c34b0a1
docs: clarify that `xphp check` is not yet a substitute for `xphp com…
math3usmartins Jun 18, 2026
078f9d9
feat(check): collect method/function/closure-level generic errors
math3usmartins Jun 18, 2026
1892b73
test(check): cover the static-closure collect path; document new codes
math3usmartins Jun 18, 2026
d008c05
build(phpstan): raise analysis to level 9 and fix the surfaced findings
math3usmartins Jun 18, 2026
984c2fd
ci: self-test the `xphp check` gate end-to-end (+ PHAR smoke)
math3usmartins Jun 18, 2026
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
24 changes: 24 additions & 0 deletions .github/workflows/ci-core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ jobs:
- name: Run PHPStan
run: make lint/phpstan

check:
# End-to-end self-test of the `xphp check` gate: runs the real bin/xphp
# binary against the check fixtures and asserts the 0/1/2 exit contract.
# The in-process CheckCommandTest can't observe the shipped binary's
# process exit code or its rendered stdout, so this covers that gap.
name: xphp check (self-test)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup PHP 8.4
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: dom, json, mbstring, tokenizer
coverage: none
tools: composer:v2

- name: Install dependencies
uses: ramsey/composer-install@v3

- name: Run xphp check self-test
run: make test/check

infection:
name: Mutation testing
runs-on: ubuntu-latest
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ jobs:
# release fails BEFORE the asset is uploaded.
run: php dist/xphp.phar list

- name: Smoke-test `check` on the PHAR
# Exercise the released artifact as a real gate: same 0/1/2
# exit-contract assertions as CI, but against dist/xphp.phar
# instead of bin/xphp. Fails the release before upload if the
# packaged binary can't validate sources.
run: make test/check XPHP_BIN="php dist/xphp.phar"

- name: Compute SHA256 sum
# `sha256sum` outputs `<hex> <filename>`; running it from
# the dist/ directory keeps the filename relative so users
Expand Down
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ lint/phpstan:
test/mutation:
php -d memory_limit=-1 vendor/bin/infection --show-mutations=max --threads=max --min-covered-msi=95

.PHONY: test/check
# End-to-end self-test of the `check` gate: runs the real bin/xphp binary
# against the check fixtures and asserts the 0/1/2 exit contract plus that the
# text/json/github renderers all emit. Reused by release.yml against the built
# PHAR (override XPHP_BIN="php dist/xphp.phar"). Complements the in-process
# CheckCommandTest, which can't observe the shipped binary's process exit code.
test/check:
sh test/smoke/check.sh

# Humbug Box is the standard tool for compiling a Composer-managed
# PHP project into a single self-contained PHAR. Pinned to a known-
# good release (Box 4.6.6 supports PHP 8.4) so a new Box version
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,17 @@ compile. `dist/` holds your rewritten code; `.xphp-cache/Generated/`
holds the specialized classes. Both can be gitignored and rebuilt
in CI.

To validate generics without emitting anything — a CI gate that reports
every bound/variance/etc. problem with a `file:line`:

```bash
vendor/bin/xphp check src # exit 1 if any error; --format=text|json|github
```

`check` runs all of xphp's generic validation (the specialization-loop guards
aside); you still run `compile` to emit the PHP. See
[Errors and diagnostics](docs/errors.md#xphp-check--validate-without-emitting).

## See also

- [Getting started](docs/getting-started.md) -- full walkthrough including PSR-4 details, runtime semantics, and what the generated PHP looks like
Expand Down
57 changes: 57 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,63 @@ Every compile-time rejection from xphp lists the error message
verbatim below, paired with the docs section that explains the
constraint. Search this page for the text your compile output shows.

## `xphp check` — validate without emitting

`vendor/bin/xphp check <source-dir>` validates your generic `.xphp`
code and reports **every** problem below as a structured diagnostic —
each with a `file:line` location — instead of aborting on the first.
It writes no output (no `dist/`, no cache); it's a pure gate, ideal
for CI.

```bash
vendor/bin/xphp check src
```

Output formats via `--format`:

| Format | Use |
|--------|-----|
| `text` (default) | human-readable, one block per problem |
| `json` | machine-readable; stable `{ "diagnostics": [ … ] }` shape for tooling |
| `github` | GitHub Actions annotations (`::error file=…,line=…::…`) so problems show inline on a PR |

Exit codes: **0** (clean), **1** (at least one error), **2** (bad
source directory or unknown `--format`). A file that fails to parse is
reported and skipped, so the rest of the tree is still checked in the
same run.

### Diagnostic codes

The `json` and `github` formats tag each diagnostic with a stable code:

| Code | Meaning |
|------|---------|
| `xphp.bound_violation` | a concrete type argument doesn't satisfy its parameter's bound |
| `xphp.default_bound_violation` | a parameter's default doesn't satisfy its own bound |
| `xphp.missing_type_argument` | a required type argument was omitted and has no default |
| `xphp.variance_position` | a `+T`/`-T` parameter appears in a position its variance forbids |
| `xphp.inner_variance` | variance is violated through another generic's slot (composition) |
| `xphp.undefined_template` | a generic was instantiated but never declared |
| `xphp.duplicate_generic_function` | the same generic function is declared in two files |
| `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) |
| `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) |
| `xphp.parse_error` | the file isn't valid PHP after the generic strip pass |

> **Scope.** `xphp check` runs every generic *validation* check `xphp compile`
> does — class/interface/trait-level **and** method/function/closure-level
> (bounds, variance, defaults, missing/duplicate generics, unsupported closures).
> By design it does not run the specialization loop or emit code, so the two
> runaway/config guards — the nested-specialization **depth cap** and the
> **hash-collision** check — surface only at `xphp compile` (they aren't type
> errors). You still run `xphp compile` to produce the PHP; `check` is the fast
> validation gate in front of it.

In CI (GitHub Actions), one step gates the build and annotates the diff:

```yaml
- run: vendor/bin/xphp check src --format=github
```

## Quick index

| If the message contains... | Read |
Expand Down
2 changes: 1 addition & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
parameters:
level: 7
level: 9
paths:
- src
25 changes: 13 additions & 12 deletions src/Console/ApplicationConsole.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PhpParser\ParserFactory;
use PhpParser\PrettyPrinter\Standard as StandardPrinter;
use Symfony\Component\Console\Application;
use XPHP\Console\Command\CheckCommand;
use XPHP\Console\Command\CompileCommand;
use XPHP\FileSystem\FileFinder;
use XPHP\FileSystem\FileReader;
Expand Down Expand Up @@ -35,17 +36,17 @@ public function __construct(
$phpParser = (new ParserFactory())->createForHostVersion();
$printer = new StandardPrinter();

$this->addCommand(new CompileCommand(
$fileFinder,
new Compiler(
$fileReader,
$fileWriter,
new XphpSourceParser($phpParser),
new Specializer(),
new SpecializedClassGenerator($printer, $fileWriter),
$printer,
$hashLength,
),
));
$compiler = new Compiler(
$fileReader,
$fileWriter,
new XphpSourceParser($phpParser),
new Specializer(),
new SpecializedClassGenerator($printer, $fileWriter),
$printer,
$hashLength,
);

$this->addCommand(new CompileCommand($fileFinder, $compiler));
$this->addCommand(new CheckCommand($fileFinder, $compiler));
}
}
85 changes: 85 additions & 0 deletions src/Console/Command/CheckCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace XPHP\Console\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use XPHP\Diagnostics\Renderer\DiagnosticRenderer;
use XPHP\Diagnostics\Renderer\GithubRenderer;
use XPHP\Diagnostics\Renderer\JsonRenderer;
use XPHP\Diagnostics\Renderer\TextRenderer;
use XPHP\FileSystem\FileFinder;
use XPHP\Transpiler\Monomorphize\Compiler;

/**
* `xphp check <source> [--format=text|json|github]`
*
* Validates the generic code without emitting any output, reporting every
* diagnostic in one run. Exit codes: 0 = clean, 1 = at least one error-severity
* diagnostic, 2 = operational failure (bad source dir or unknown format).
*/
#[AsCommand('check', 'Validate generic .xphp code and report diagnostics without emitting output')]
final class CheckCommand extends Command
{
public function __construct(
private readonly FileFinder $fileFinder,
private readonly Compiler $compiler,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->addArgument('source', InputArgument::REQUIRED, 'Directory containing .xphp source files')
->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format: text, json, or github', 'text');
}

protected function execute(
InputInterface $input,
OutputInterface $output,
): int {
// getArgument()/getOption() are typed `mixed`; these are scalar inputs (a required
// argument and an option with a string default), so they are always strings — narrow
// rather than blind-cast (PHPStan level 9 rejects casting mixed).
$sourceArg = $input->getArgument('source');
$sourceDir = is_string($sourceArg) ? $sourceArg : '';
if (!is_dir($sourceDir)) {
$output->writeln("<error>Source directory not found: {$sourceDir}</error>");
return self::INVALID;
}

$formatOption = $input->getOption('format');
$renderer = $this->rendererFor(is_string($formatOption) ? $formatOption : '');
if ($renderer === null) {
$output->writeln('<error>Unknown format (expected: text, json, github)</error>');
return self::INVALID;
}

$sources = $this->fileFinder
->find($sourceDir)
->filter(static fn (string $filepath): bool => str_ends_with($filepath, '.xphp'));

$diagnostics = $this->compiler->check($sources);

$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,
};
}
}
11 changes: 8 additions & 3 deletions src/Console/Command/CompileCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,14 @@ public function execute(
InputInterface $input,
OutputInterface $output,
): int {
$sourceDir = (string) $input->getArgument('source');
$targetDir = (string) $input->getArgument('target');
$cacheDir = (string) $input->getArgument('cache');
// getArgument() is typed `mixed`; these are scalar args (a required one and two with
// string defaults), so they are always strings — narrow rather than blind-cast.
$sourceArg = $input->getArgument('source');
$targetArg = $input->getArgument('target');
$cacheArg = $input->getArgument('cache');
$sourceDir = is_string($sourceArg) ? $sourceArg : '';
$targetDir = is_string($targetArg) ? $targetArg : 'dist';
$cacheDir = is_string($cacheArg) ? $cacheArg : '.xphp-cache';

if (!is_dir($sourceDir)) {
$output->writeln("<error>Source directory not found: {$sourceDir}</error>");
Expand Down
31 changes: 31 additions & 0 deletions src/Diagnostics/Diagnostic.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace XPHP\Diagnostics;

/**
* A single diagnostic emitted by `xphp check`.
*
* - `code` is a stable machine identifier (e.g. `xphp.bound_violation`,
* `xphp.variance_position`) that tooling can match on.
* - `location` is the originating `.xphp` position, or `null` when a finding
* has no single source location.
* - `triggeredBy` names the concrete instantiation (e.g. `App\Box<int>`) that
* surfaced a finding inside a template body — only set for Phase-2 (PHPStan)
* diagnostics mapped back to a declaration.
*
* Immutable.
*/
final readonly class Diagnostic
{
public function __construct(
public Severity $severity,
public string $code,
public string $message,
public ?SourceLocation $location = null,
public ?string $triggeredBy = null,
public DiagnosticSource $source = DiagnosticSource::Xphp,
) {
}
}
51 changes: 51 additions & 0 deletions src/Diagnostics/DiagnosticCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace XPHP\Diagnostics;

/**
* Accumulates {@see Diagnostic}s during a check run.
*
* Mutable by design — the single sink threaded through the validation phase.
* `xphp compile` does NOT use a collector (validators throw on the first error,
* as before); `xphp check` passes one so every diagnostic of a validation phase
* is gathered instead of aborting on the first.
*/
final class DiagnosticCollector
{
/** @var list<Diagnostic> */
private array $diagnostics = [];

public function add(Diagnostic $diagnostic): void
{
$this->diagnostics[] = $diagnostic;
}

/**
* @return list<Diagnostic> In insertion order.
*/
public function all(): array
{
return $this->diagnostics;
}

/**
* True iff any collected diagnostic fails the gate (Error severity).
*/
public function hasErrors(): bool
{
foreach ($this->diagnostics as $diagnostic) {
if ($diagnostic->severity->isFailing()) {
return true;
}
}

return false;
}

public function count(): int
{
return count($this->diagnostics);
}
}
16 changes: 16 additions & 0 deletions src/Diagnostics/DiagnosticSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace XPHP\Diagnostics;

/**
* Origin of a {@see Diagnostic}: xphp's own generic checks, or (Phase 2) a
* PHPStan finding mapped back onto `.xphp` source. String-backed for renderer
* output and so the JSON contract carries a stable label.
*/
enum DiagnosticSource: string
{
case Xphp = 'xphp';
case PhpStan = 'phpstan';
}
Loading
Loading