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/extract-rector-report-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: changed
type: patch
---

Reuse native Rector preview report validation from the source library while preserving CLI output, native exits and optional-workflow classification and policy.
139 changes: 65 additions & 74 deletions .github/OptionalAnalysis/NativeReportParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
use DOMDocument;
use DOMElement;
use FastForward\CodeStandards\Process\ProcessResult;
use FastForward\CodeStandards\Refactoring\RectorJsonReportParser;
use InvalidArgumentException;
use JsonException;

/** Classifies qualified native schemas together with their exits; human output is never scraped. */
final class NativeReportParser
{
/** Reuses native Rector validation while keeping workflow classification and presentation here. */
public function __construct(private readonly RectorJsonReportParser $rectorReports) {}

/**
* Requires complete, consistent native XML before accepting exit 1 as findings.
*
Expand Down Expand Up @@ -55,7 +58,17 @@ public function magicNumbers(ProcessResult $process, array $selectedFiles): Anal
throw new InvalidArgumentException('PHPMND exit contradicts its complete native report.');
}

return new AnalysisResult('Magic Numbers', 0 === $count ? 'clean' : 'findings', count($selectedFiles), $count, $diagnostics, $process->stderr, $process->exitCode);
return new AnalysisResult(
tool: 'Magic Numbers',
status: 0 === $count ? 'clean' : 'findings',
selectedCount: count(
$selectedFiles,
),
findingCount: $count,
diagnostics: $diagnostics,
details: $process->stderr,
nativeExit: $process->exitCode,
);
}

/** Native Rector errors remain blocking even when the same report also proposes changes. */
Expand All @@ -64,51 +77,66 @@ public function rector(ProcessResult $process): AnalysisResult
if (strlen($process->stdout) > BoundedProcess::REPORT_BYTES) {
throw new InvalidArgumentException('Rector native report exceeds the supported size.');
}
try {
$report = json_decode($process->stdout, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new InvalidArgumentException('Rector did not return a complete native JSON document.', previous: $exception);
}
if (!is_array($report) || !isset($report['totals']) || !is_array($report['totals'])) {
throw new InvalidArgumentException('Rector JSON totals are unavailable.');
}
$changes = $this->jsonInteger($report['totals']['changed_files'] ?? null);
$errorCount = $this->jsonInteger($report['totals']['errors'] ?? null);
$errors = $this->list($report['errors'] ?? []);
if ($errorCount !== count($errors)) {
throw new InvalidArgumentException('Rector error total is inconsistent.');
}
$files = $this->list($report['changed_files'] ?? []);
if (count($files) !== $changes || count(array_filter($files, static fn(mixed $file): bool => is_string($file) && '' !== $file)) !== count($files)) {
throw new InvalidArgumentException('Rector changed-file list is inconsistent.');
}
$diagnostics = [...$this->rectorErrors($errors), ...$this->rectorChanges($this->list($report['file_diffs'] ?? []), $files)];
$report = $this->rectorReports->parse($process);
$changes = $report['totals']['changed_files'];
$errorCount = $report['totals']['errors'];
$diagnostics = [
...$this->rectorErrors($report['errors'] ?? []),
...$this->rectorChanges($report['changed_files'] ?? []),
];
if (0 !== $errorCount) {
if (1 !== $process->exitCode) {
throw new InvalidArgumentException('Rector error report contradicts its exit.');
}

return new AnalysisResult('Rector', 'operational-error', findingCount: $changes, diagnostics: $diagnostics, details: $errorCount . " native processing error(s).\n" . $process->stderr, nativeExit: $process->exitCode);
}
if ((0 === $changes && 0 !== $process->exitCode) || (0 !== $changes && 2 !== $process->exitCode)) {
throw new InvalidArgumentException('Rector preview exit contradicts its native totals.');
}
return new AnalysisResult('Rector', 0 === $changes ? 'clean' : 'findings', findingCount: $changes, diagnostics: $diagnostics, details: "Scope is the explicit native configuration, not the formatter changed-file list.\n" . $process->stderr, nativeExit: $process->exitCode);
return new AnalysisResult(
tool: 'Rector',
status: 'operational-error',
findingCount: $changes,
diagnostics: $diagnostics,
details: $errorCount . " native processing error(s).\n" . $process->stderr,
nativeExit: $process->exitCode,
);
}
return new AnalysisResult(
tool: 'Rector',
status: 0 === $changes ? 'clean' : 'findings',
findingCount: $changes,
diagnostics: $diagnostics,
details: "Scope is the explicit native configuration, not the formatter changed-file list.\n" . $process->stderr,
nativeExit: $process->exitCode,
);
}

/** Parses without external resolution/substitution, then rejects DTDs; CDATA remains ordinary source text. */
private function xml(string $output): DOMElement
{
if (strlen($output) > BoundedProcess::REPORT_BYTES || !extension_loaded('dom') || 1 !== preg_match('//u', $output) || str_contains($output, "\0")) {
throw new InvalidArgumentException('PHPMND requires UTF-8 native XML without DTD/entity declarations and an available DOM extension.');
if (
strlen($output) > BoundedProcess::REPORT_BYTES
|| !extension_loaded('dom')
|| 1 !== preg_match(
'//u',
$output,
)
|| str_contains(
$output,
"\0",
)
) {
throw new InvalidArgumentException(
'PHPMND requires UTF-8 native XML without DTD/entity declarations and an available DOM extension.',
);
}
$previous = libxml_use_internal_errors(true);
try {
$document = new DOMDocument();
$document->resolveExternals = false;
$document->substituteEntities = false;
$document->validateOnParse = false;
if (!$document->loadXML($output, LIBXML_NONET) || null !== $document->doctype || 'phpmnd' !== $document->documentElement?->tagName) {
if (
!$document->loadXML(
$output,
LIBXML_NONET,
)
|| null !== $document->doctype
|| 'phpmnd' !== $document->documentElement?->tagName
) {
throw new InvalidArgumentException('PHPMND did not return a complete native XML document.');
}
if ('' === $document->documentElement->getAttribute('version')) {
Expand Down Expand Up @@ -160,26 +188,6 @@ private function integer(string $value): int
return $integer;
}

/** Native JSON counts must be integers rather than coerced strings or floating point values. */
private function jsonInteger(mixed $value): int
{
if (!is_int($value) || $value < 0) {
throw new InvalidArgumentException('Rector JSON count is invalid.');
}

return $value;
}

/** @return list<mixed> Native ordered report entries, not an arbitrary keyed JSON object. */
private function list(mixed $value): array
{
if (!is_array($value) || !array_is_list($value)) {
throw new InvalidArgumentException('Rector JSON entries must be a list.');
}

return $value;
}

/**
* Retains actual native error locations; an unavailable path is not replaced with a guessed source file.
*
Expand All @@ -190,37 +198,20 @@ private function rectorErrors(array $errors): array
{
$diagnostics = [];
foreach ($errors as $error) {
if (
!is_array($error)
|| (isset($error['file'])
&& !is_string($error['file']))
|| !is_string($error['message'] ?? null)
|| (isset($error['line'])
&& (!is_int($error['line'])
|| $error['line'] < 1))
) {
throw new InvalidArgumentException('Rector JSON error location is invalid.');
}
$diagnostics[] = ['file' => $error['file'] ?? '[unavailable]', 'line' => $error['line'] ?? null, 'message' => $error['message']];
}

return $diagnostics;
}

/**
* Lists native changed files without interpreting diff text as line or AST identity.
* Lists validated native paths without inferring diff lines or AST identity.
*
* @param list<mixed> $changes Optional native file-diff records, validated without displaying source.
* @param list<string> $files The native changed-file list corresponding to the total.
* @param list<string> $files Native changed paths corresponding to the validated total.
* @return list<array{file: string, line: int|null, message: string}>
*/
private function rectorChanges(array $changes, array $files): array
private function rectorChanges(array $files): array
{
foreach ($changes as $change) {
if (!is_array($change) || !is_string($change['file'] ?? null) || !in_array($change['file'], $files, true) || !is_string($change['diff'] ?? null)) {
throw new InvalidArgumentException('Rector JSON change location is invalid.');
}
}
$diagnostics = [];
foreach ($files as $file) {
$diagnostics[] = ['file' => $file, 'line' => null, 'message' => 'Semantic changes proposed; the native JSON does not provide a line field.'];
Expand Down
18 changes: 18 additions & 0 deletions docs/process-contracts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,21 @@ Golden fixtures exercise descriptive contracts and inheritance markers through
read-only checking, explicit fixing and idempotence. A native Rector regression
changes array syntax while retaining the same documentation. Consumer-selected
semantic rules can change PHPDoc; this is not a claim about every configuration.

Native Rector report validation
-------------------------------

``Refactoring\RectorJsonReportParser::parse()`` validates a completed
``ProcessResult`` against the native Rector preview schema and returns the
decoded native report. Totals, native lists, error locations, diff paths and
the exit code must agree. Malformed or inconsistent reports raise
``InvalidArgumentException`` with the established native validation diagnostic.
Optional fields and native record ordering remain intact, including absent
empty lists and opaque diff text. No source line is inferred from a diff.

The caller owns capture budgets. The optional workflow keeps its existing
report-size limit before parsing, then maps validated native data into its
existing statuses, diagnostic presentation and ``report``/``fail`` policies.
The parser does not execute Rector or evaluate configuration. The public
``refactor --format=json`` command continues to stream its native preview
output with the same options and exits.
129 changes: 129 additions & 0 deletions src/Refactoring/RectorJsonReportParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

namespace FastForward\CodeStandards\Refactoring;

use FastForward\CodeStandards\Process\ProcessResult;
use InvalidArgumentException;
use JsonException;

/** Validates native Rector preview reports without applying workflow policy or rendering diagnostics. */
final class RectorJsonReportParser
{
/**
* Requires complete native data whose totals, locations and exit agree.
*
* @return array<string,mixed> The decoded native report, retaining all fields and native ordering.
* @throws InvalidArgumentException When the report is malformed or inconsistent with its completed exit.
*/
public function parse(ProcessResult $process): array
{
try {
$report = json_decode(json: $process->stdout, associative: true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new InvalidArgumentException(
message: 'Rector did not return a complete native JSON document.',
previous: $exception,
);
}
if (!is_array($report) || !isset($report['totals']) || !is_array($report['totals'])) {
throw new InvalidArgumentException('Rector JSON totals are unavailable.');
}
$changes = $this->jsonInteger($report['totals']['changed_files'] ?? null);
$errorCount = $this->jsonInteger($report['totals']['errors'] ?? null);
$errors = $this->list($report['errors'] ?? []);
if ($errorCount !== count($errors)) {
throw new InvalidArgumentException('Rector error total is inconsistent.');
}
$files = $this->list($report['changed_files'] ?? []);
if (
count($files) !== $changes
|| count(
array_filter($files, static fn(mixed $file): bool => is_string($file) && '' !== $file),
) !== count(
$files,
)
) {
throw new InvalidArgumentException('Rector changed-file list is inconsistent.');
}
$this->validateErrors($errors);
$this->validateChanges($this->list($report['file_diffs'] ?? []), $files);
if (0 !== $errorCount) {
if (1 !== $process->exitCode) {
throw new InvalidArgumentException('Rector error report contradicts its exit.');
}

return $report;
}
if ((0 === $changes && 0 !== $process->exitCode) || (0 !== $changes && 2 !== $process->exitCode)) {
throw new InvalidArgumentException('Rector preview exit contradicts its native totals.');
}

return $report;
}

/** Native JSON counts must be integers rather than coerced strings or floating point values. */
private function jsonInteger(mixed $value): int
{
if (!is_int($value) || $value < 0) {
throw new InvalidArgumentException('Rector JSON count is invalid.');
}

return $value;
}

/** @return list<mixed> Native ordered report entries, not an arbitrary keyed JSON object. */
private function list(mixed $value): array
{
if (!is_array($value) || !array_is_list($value)) {
throw new InvalidArgumentException('Rector JSON entries must be a list.');
}

return $value;
}

/** @param list<mixed> $errors Native error locations; absent locations remain absent. */
private function validateErrors(array $errors): void
{
foreach ($errors as $error) {
if (
!is_array($error)
|| (isset($error['file'])
&& !is_string($error['file']))
|| !is_string($error['message'] ?? null)
|| (isset($error['line'])
&& (!is_int($error['line'])
|| $error['line'] < 1))
) {
throw new InvalidArgumentException('Rector JSON error location is invalid.');
}
}
}

/**
* Validates native change records without inferring source locations from diff text.
*
* @param list<mixed> $changes Optional native file diffs.
* @param list<string> $files Changed paths reported by the same native invocation.
*/
private function validateChanges(array $changes, array $files): void
{
foreach ($changes as $change) {
if (
!is_array($change)
|| !is_string($change['file'] ?? null)
|| !in_array(
$change['file'],
$files,
true,
)
|| !is_string(
$change['diff'] ?? null,
)
) {
throw new InvalidArgumentException('Rector JSON change location is invalid.');
}
}
}
}
Loading
Loading