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
6 changes: 6 additions & 0 deletions .changelog/structured-check-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: added
type: minor
---

Add explicit JSON check reports with native ECS findings, syntax results, incomplete-batch accounting and bounded nonstreaming capture; preserve default text and explicit fix behavior.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ composer test
## 🛠️ Usage

`check` reports violations with a nonzero status and does not change source.
Use `check --all --format=json` for an explicit single-document machine report,
including native ECS findings, failure classification and remaining-work counts.
See the [structured check contract](docs/structured-check.rst). Text is the
default; `fix` uses text output.
The default preset stores its ECS cache beneath the system temporary directory,
in `fast-forward-code-standards/<SHA-256 of the executing checkout path>`.
Different checkouts cannot invalidate each other's cache. Native
Expand Down
16 changes: 13 additions & 3 deletions bin/ecs-worker
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@ declare(strict_types=1);

use Symplify\EasyCodingStandard\Console\Command\CheckCommand;
use Symplify\EasyCodingStandard\Console\Output\ConsoleOutputFormatter;
use Symplify\EasyCodingStandard\Console\Output\JsonOutputFormatter;
use Symplify\EasyCodingStandard\DependencyInjection\EasyCodingStandardContainerFactory;

// Internal process protocol: own autoloader, installed engine, config, mode, --, absolute files.
try {
if ($argc < 7 || '--' !== $argv[5] || !in_array($argv[4], ['--check', '--fix'], true)) {
$json = '--format=json' === ($argv[5] ?? null);
$separator = $json ? 6 : 5;
if (
$argc < $separator + 2
|| '--' !== $argv[$separator]
|| !in_array($argv[4], ['--check', '--fix'], true)
|| ($json && '--check' !== $argv[4])
) {
throw new InvalidArgumentException('ECS worker requires installation paths, an explicit mode and selected files.');
}
[$script, $autoload, $engine, $config, $mode] = array_slice($argv, 0, 5);
Expand All @@ -18,7 +26,7 @@ try {
throw new InvalidArgumentException('ECS worker requires readable absolute installation and config paths.');
}
}
$files = array_slice($argv, 6);
$files = array_slice($argv, $separator + 1);
foreach ($files as $file) {
if (!str_starts_with($file, '/')) {
throw new InvalidArgumentException('ECS worker requires absolute selected file paths.');
Expand All @@ -29,6 +37,8 @@ try {
require $autoload;
require_once $engine . '/vendor/squizlabs/php_codesniffer/autoload.php';

$format = $json ? JsonOutputFormatter::getName() : ConsoleOutputFormatter::NAME;

$container = (new EasyCodingStandardContainerFactory())->createFromArgv(['ecs', 'check', '--config=' . $config]);
$command = $container->make(CheckCommand::class);

Expand All @@ -42,7 +52,7 @@ try {
false,
false,
$config,
ConsoleOutputFormatter::NAME,
$format,
'',
'',
'',
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ Architecture, policy and evidence
rector
resolved-preset
static-analysis-evaluation
structured-check
twig-evaluation
101 changes: 101 additions & 0 deletions docs/structured-check.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
Structured check output
=======================

``check --format=json`` selects one nonstreaming JSON report on stdout. The default
``--format=text`` retains human output and existing exit behavior. ``fix`` does not
accept this option. Formatting and semantic refactoring remain separate operations.

.. code-block:: console

vendor/bin/code-standards check --all --format=json
vendor/bin/code-standards check --base=BASE_SHA --head=HEAD_SHA --format=json

Use existing complete immutable commits for ``BASE_SHA`` and ``HEAD_SHA``. Selection,
consumer ignore rules, explicit ECS configuration and PHP runtime requirements are
the same as text checking. JSON checking never requests ``--fix``. Executable
configuration is not sandboxed; use the same unprivileged environment as text mode.

Report contract
---------------

The report includes ``schema_version`` (1), ``command`` (``check``), ``status``, ``exit_code``, ``complete``,
``selection``, ``files``, ``errors`` and ``diagnostics.stderr``. ``files`` is an object
keyed by exact absolute paths. Native ``errors`` records retain line, file path,
message and checker identity; native ``diffs`` retain diff text and applied checkers.
No line numbers are guessed from a diff or a human PHP diagnostic.

``selection.files`` counts selected files; ``syntax_checked`` counts completed
native PHP syntax invocations; ``style_submitted`` counts files submitted in
completed, consistently reported ECS batches. The corresponding ``syntax_remaining``
and ``style_remaining`` counts identify unfinished work. ECS may reuse its cache or
honor consumer skips, so submitted is not a claim of fresh parsing of every file.

``complete`` means all selected syntax invocations and ECS batches completed with
consistent reports. Checking stops at the first nonzero syntax result, findings
batch or operational error, matching the bounded text-mode policy. A findings
report can be incomplete when later batches remain. An empty selection is complete
and clean without starting a checker.

.. list-table:: Result states
:header-rows: 1

* - Status
- Exit
- Meaning
* - ``clean``
- 0
- All selected work completed consistently without findings, or selection is empty.
* - ``findings``
- 2
- Native, consistently reported style violations or diffs; check ``complete``.
* - ``syntax-failed``
- Native nonzero status
- ``php -n -l`` did not validate a selected file; exact diagnostics are data, not parsed classifications.
* - ``operational-error``
- 1, or 2 for controlled selection failures
- Selection, execution, report validation or encoding could not establish a trustworthy result.

Controlled selection failures retain text-mode status 2, including invalid input,
missing configuration, unavailable Git history, a mismatched HEAD or a dirty tracked
checkout. Unexpected failures and checker/report errors use status 1.

Native stderr remains separate diagnostic data and is also written to stderr;
warnings alone do not imply failure. Operation errors additionally produce a fixed
stderr diagnostic. Invalid format names fail before execution with status 2 and
stderr output. Symfony binding/help errors and global quiet/silent flags retain
their native Console behavior; they are not JSON-report requests to an executed
check.

Validation and limits
---------------------

The isolated worker uses the tool's own autoloader and native ECS JSON formatter.
Custom process overrides must implement the optional ``LimitedResultProcessRunner``
capability for JSON checking. Existing ``ProcessRunner`` implementations retain
their text-mode contract; an unsupported JSON transport fails before a checker starts.
The parser checks the whole JSON document, native status, record shapes, selected
paths and counts. It does not strip banners or search for a later JSON fragment.
ECS totals can include system errors absent from its file inventory; mismatches
are operational errors, never clean results or ordinary style findings.

Each ECS batch contains at most 50 paths, with a 300-second execution timeout,
2 MiB retained stdout and 64 KiB retained stderr limits. Syntax invocations use
300 seconds and 64 KiB per channel. Combined retained diagnostics are limited to
64 KiB. Output is limited during native process callbacks; overflow fails instead
of returning a truncated report. Symfony's transient pipe reads and the child's
memory are outside this retained-output bound. Termination covers the owned
process; no sandbox or descendant-process isolation is promised.

Report strings must be representable as JSON without UTF-8 replacement. Invalid
native JSON, unsupported bytes or an oversized report fail objectively. A complete
machine report can be larger than human output because ECS JSON contains diffs;
``--no-diffs`` is not used as a false size guarantee. Native caches and explicit
consumer overrides remain active, as described in the configuration contract.

This format covers checking, not automatic fixes, Rector reports, type analysis or
agent detection. Select it explicitly; environment detection never grants write
authority or overrides the requested format.

The broader `structured-output contract (#10) <https://github.com/php-fast-forward/code-standards/issues/10>`__
also covers shared pretty/color controls and CWD/cache metadata. Those fields and
cross-command policies are outside this check schema.
5 changes: 5 additions & 0 deletions docs/usage/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ accept ``--directory`` and ``--config``. Symfony Console supports both
``--option=value`` and ``--option value``. See ``help check`` and ``help fix``
for the installed command definitions.

``check`` additionally accepts ``--format=text|json`` with text as the default.
The explicit `structured check contract <../structured-check.rst>`__ documents
single-document stdout, native findings, operational failures and partial coverage.
``fix`` retains text output and does not accept this option.

The separate opt-in ``refactor`` command uses an explicit native Rector
configuration's source paths rather than this Git changed-file selection.
See the `semantic refactoring contract <../rector.rst>`__ for required target
Expand Down
16 changes: 15 additions & 1 deletion src/Console/Command/CheckCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@

use FastForward\CodeStandards\Console\Input\ScanInput;
use FastForward\CodeStandards\Console\StandardsOperation;
use FastForward\CodeStandards\Console\StructuredCheckOperation;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Style\SymfonyStyle;

/** Exposes read-only checking as an invocable object adapted by Symfony. */
Expand All @@ -18,15 +20,27 @@
final readonly class CheckCommand
{
/** Injects the shared orchestration without parsing input or resolving a checkout. */
public function __construct(private StandardsOperation $operation) {}
public function __construct(private StandardsOperation $operation, private StructuredCheckOperation $structured) {}

/** Returns the exact check status; this entrypoint never grants write authority. */
public function __invoke(
SymfonyStyle $io,

#[MapInput]
ScanInput $settings,

#[Option(description: 'Output format: text or json. JSON is one nonstreaming check report.')]
string $format = 'text',
): int {
if ('json' === $format) {
return $this->structured->run($settings, $io);
}
if ('text' !== $format) {
$io->getErrorStyle()->error('Check format must be text or json.');

return 2;
}

return $this->operation->run($settings, $io, false);
}
}
70 changes: 70 additions & 0 deletions src/Console/StructuredCheckOperation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace FastForward\CodeStandards\Console;

use FastForward\CodeStandards\Console\Input\ScanInput;
use FastForward\CodeStandards\Filesystem\PhpFiles;
use FastForward\CodeStandards\Git\FileSelection;
use FastForward\CodeStandards\Project\ProjectScopeResolver;
use FastForward\CodeStandards\Standards\CheckResult;
use FastForward\CodeStandards\Standards\StructuredCheckRunner;
use InvalidArgumentException;
use JsonException;
use RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;

/** Presents one explicitly selected JSON check without human stdout or implicit source writes. */
final readonly class StructuredCheckOperation
{
/** Injects the same selection policy as text mode and a nonstreaming check runner. */
public function __construct(
private ProjectScopeResolver $projects,
private FileSelection $selection,
private PhpFiles $phpFiles,
private StructuredCheckRunner $standards,
) {}

/** Resolves one selection and writes exactly one JSON document after result classification. */
public function run(ScanInput $settings, SymfonyStyle $io): int
{
try {
$scope = $this->projects->resolve($settings->all, $settings->base, $settings->head, $settings->directory, $settings->config);
$paths = $this->selection->select($scope->directory, $scope->base, $scope->head, $scope->config);
$files = $this->phpFiles->filter($scope->directory, $paths);
$result = $this->standards->run($scope->directory, $files, $scope->config);
} catch (Throwable $exception) {
$exitCode = $exception instanceof InvalidArgumentException || $exception instanceof RuntimeException ? 2 : 1;
$result = new CheckResult('operational-error', $exitCode, false, 0, 0, 0, errors: [
['phase' => 'selection', 'message' => $exception->getMessage()],
]);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return $this->present($result, $io);
}

/** Rejects unrepresentable byte strings without silently replacing paths or truncating a report. */
private function present(CheckResult $result, SymfonyStyle $io): int
{
try {
$json = json_encode($result->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (JsonException) {
$result = new CheckResult('operational-error', 1, false, $result->selected, $result->syntaxChecked, $result->styleSubmitted, errors: [
['phase' => 'encoding', 'message' => 'Check output contains bytes that cannot be represented as JSON.'],
]);
$json = json_encode($result->toArray(), JSON_THROW_ON_ERROR);
}
if ([] !== $result->errors) {
$io->getErrorStyle()->writeln('Code Standards check did not complete successfully.', OutputInterface::OUTPUT_RAW);
}
if ('' !== $result->stderr) {
$io->getErrorStyle()->write($result->stderr, false, OutputInterface::OUTPUT_RAW);
}
$io->writeln($json, OutputInterface::OUTPUT_RAW);

return $result->exitCode;
}
}
25 changes: 25 additions & 0 deletions src/Process/LimitedResultProcessRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace FastForward\CodeStandards\Process;

/** Adds capture with explicit retained-output limits. */
interface LimitedResultProcessRunner extends ResultProcessRunner
{
/**
* Captures a completed result within caller-selected byte limits.
*
* Channels MUST remain separate and unmodified without streaming. Exceeding either
* positive limit MUST raise an exception rather than return truncated output.
*
* @param list<string> $arguments Executable followed by literal arguments.
* @param string $directory Child working directory.
* @param positive-int $stdoutLimit Maximum retained stdout bytes.
* @param positive-int $stderrLimit Maximum retained stderr bytes.
* @param float $timeout Positive, finite execution limit in seconds.
* @return ProcessResult Completed status and exact output within the limits.
* @throws \Throwable When execution cannot complete within the supplied bounds.
*/
public function captureLimitedResult(array $arguments, string $directory, int $stdoutLimit, int $stderrLimit, float $timeout = 300): ProcessResult;
}
42 changes: 39 additions & 3 deletions src/Process/NativeProcessRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
namespace FastForward\CodeStandards\Process;

use InvalidArgumentException;
use OverflowException;
use Symfony\Component\Process\Process;

/** Native transport; callers own selection and operation policy. */
final class NativeProcessRunner implements ResultProcessRunner
final class NativeProcessRunner implements LimitedResultProcessRunner
{
/** Both captured and streamed configuration subprocesses receive the same credential policy. */
private const array ENVIRONMENT = ['GH_TOKEN' => false, 'GITHUB_TOKEN' => false, 'COMPOSER_AUTH' => false];
Expand Down Expand Up @@ -42,11 +43,46 @@ public function captureResult(array $arguments, string $directory, float $timeou
return new ProcessResult($status, $process->getOutput(), $process->getErrorOutput());
}

/** Retains bounded channel bytes through Symfony's callback without its internal output buffers. */
public function captureLimitedResult(array $arguments, string $directory, int $stdoutLimit, int $stderrLimit, float $timeout = 300): ProcessResult
{
if ($stdoutLimit < 1 || $stderrLimit < 1) {
throw new InvalidArgumentException('Captured output limits must be positive byte counts.');
}

$process = $this->createProcess($arguments, $directory, $timeout);
$process->disableOutput();
$channels = [Process::OUT => '', Process::ERR => ''];
$limits = [Process::OUT => $stdoutLimit, Process::ERR => $stderrLimit];
$collect = true;

try {
$status = $process->run(static function (string $type, string $bytes) use (&$channels, $limits, &$collect): void {
if (!$collect) {
return;
}
if (strlen($bytes) > $limits[$type] - strlen($channels[$type])) {
$collect = false;
throw new OverflowException('Captured ' . $type . ' exceeded its byte limit.');
}

$channels[$type] .= $bytes;
});
} finally {
$collect = false;
$process->stop(0);
}

return new ProcessResult($status, $channels[Process::OUT], $channels[Process::ERR]);
}

/** @param list<string> $arguments Positive finite bounds never become Symfony's zero/null unlimited mode. */
private function createProcess(array $arguments, string $directory, float $timeout): Process
{
if (!is_finite($timeout)
|| $timeout <= 0) {
if (
!is_finite($timeout)
|| $timeout <= 0
) {
throw new InvalidArgumentException('Subprocess timeout must be a positive finite number of seconds.');
}

Expand Down
Loading
Loading