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
7 changes: 7 additions & 0 deletions .changelog/verify-fix-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
category: fixed
type: patch
---

Verify each explicit formatter batch with a read-only check and propagate residual
violations rather than reporting successful completion of a partially normalized file.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ Different checkouts cannot invalidate each other's cache. Native
`withCache()` overrides remain supported and apply last; share cache directories
only when their invalidation and concurrent use are intentionally coordinated.
`fix` explicitly applies ECS fixes. It does not commit, push or update dependencies.
After each successful formatter batch, a read-only check verifies the result.
Any remaining violation or tool error is returned as a nonzero status. A formatter
pass can expose another rule, such as EOF normalization preceding opening-tag
spacing; inspect the partial diff before explicitly requesting another fix.
Recheck and inspect the diff afterward; failures can leave already applied fixes.
An empty PHP selection is a successful no-op. Invalid options, configuration paths,
Git history or head mismatch fail closed with exit 2. Syntax/style tool failures,
Expand Down
6 changes: 5 additions & 1 deletion src/Standards/StandardsRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@ public function run(string $directory, array $files, string $config, bool $fix):

// Bounded batches avoid ARG_MAX. Stop at the first failure; never report partial success.
foreach (array_chunk($files, 50) as $batch) {
$command = [PHP_BINARY, $this->ecs, 'check', '--config=' . $config, '--no-progress-bar', '--no-interaction'];
$check = [PHP_BINARY, $this->ecs, 'check', '--config=' . $config, '--no-progress-bar', '--no-interaction'];
$command = $check;
if ($fix) {
$command[] = '--fix';
}
$status = $this->process->run([...$command, '--', ...$batch], $directory);
if (0 !== $status) {
return $status;
}
if ($fix && 0 !== $status = $this->process->run([...$check, '--', ...$batch], $directory)) {
return $status;
}
}

return 0;
Expand Down
20 changes: 20 additions & 0 deletions tests/Integration/StandardsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,26 @@ public function testDefaultCachesDoNotInvalidateAnotherCheckout(): void
self::assertSame('project A cache', file_get_contents($cache . '/owned-marker'));
}

/** A successful explicit fix must not hide a remaining violation after an EOF-sensitive formatter pass. */
public function testSuccessfulFixCannotHideRemainingStyleViolations(): void
{
$this->put('source.php', '<?php echo 1;');
$this->commit();
$fix = $this->cli(['fix', '--all']);
$after = file_get_contents($this->directory . '/source.php');
$check = $this->cli(['check', '--all']);

self::assertNotSame(0, $check->getExitCode(), $check->getOutput() . $check->getErrorOutput());
self::assertSame($check->getExitCode(), $fix->getExitCode(), $fix->getOutput() . $check->getOutput());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self::assertSame($after, file_get_contents($this->directory . '/source.php'));
self::assertSame(0, $this->cli(['fix', '--all'])->getExitCode());
$clean = file_get_contents($this->directory . '/source.php');

self::assertSame(0, $this->cli(['check', '--all'])->getExitCode());
self::assertSame(0, $this->cli(['fix', '--all'])->getExitCode());
self::assertSame($clean, file_get_contents($this->directory . '/source.php'));
}

/** Writes a synthetic fixture beneath the disposable test root, creating its parents. */
private function put(string $path, string $content): void
{
Expand Down
27 changes: 24 additions & 3 deletions tests/Standards/StandardsRunnerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ public function testBatchesAreBoundedAndEcsFailureIsNotSwallowed(): void
self::assertCount(57, $process->commands[101]);
}

/** Propagates residual style failures after explicit fix without silently applying another write pass. */
public function testFixSuccessIsVerifiedWithoutAnotherWritePass(): void
{
$process = new RecordingProcess(verificationStatus: 19);
$runner = new StandardsRunner($process, '/tools/ecs');

self::assertSame(19, $runner->run('/repo', ['/repo/a b.php'], '/config', true));
self::assertCount(3, $process->commands);
self::assertContains('--fix', $process->commands[1]);
self::assertNotContains('--fix', $process->commands[2]);
self::assertSame('/repo/a b.php', $process->commands[2][array_key_last($process->commands[2])]);
}

/** Rejects malformed source before writes and makes empty selection a subprocess-free no-op. */
public function testSyntaxFailurePreventsFixAndNoFilesExecutesNothing(): void
{
Expand All @@ -51,8 +64,11 @@ final class RecordingProcess implements ProcessRunner
public array $commands = [];

/** Configures deterministic transport outcomes without executing real tools. */
public function __construct(private readonly int $ecsStatus = 0, private readonly int $syntaxStatus = 0)
{
public function __construct(
private readonly int $ecsStatus = 0,
private readonly int $syntaxStatus = 0,
private readonly ?int $verificationStatus = null,
) {
}

/** Fails if standards orchestration unexpectedly performs a capture operation. */
Expand All @@ -66,6 +82,11 @@ public function run(array $arguments, string $directory): int
{
$this->commands[] = $arguments;

return in_array('-l', $arguments, true) ? $this->syntaxStatus : $this->ecsStatus;
if (in_array('-l', $arguments, true)) {
return $this->syntaxStatus;
}

return !in_array('--fix', $arguments, true) && null !== $this->verificationStatus
? $this->verificationStatus : $this->ecsStatus;
}
}
Loading