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
2 changes: 1 addition & 1 deletion bin/mcp-rector-warm
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ if ($config !== null) {
// not here — to avoid polluting the MCP SDK before the server starts.

$server = Server::builder()
->setServerInfo('mcp-rector-warm', '0.3.0')
->setServerInfo('mcp-rector-warm', '0.4.0')
->setDiscovery(dirname(__DIR__), ['src'])
->build();

Expand Down
16 changes: 15 additions & 1 deletion src/RectorRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* Holds a warm Rector container + Application across multiple analyse calls.
* Boot happens lazily on first call; subsequent calls reuse the live container.
*/
final class RectorRunner
final class RectorRunner implements RunnerInterface
{
private ?object $application = null;
private ?object $container = null;
Expand All @@ -25,6 +25,20 @@ public function isWarm(): bool
return $this->application !== null;
}

/**
* Drop the warm container + application so the next run() boots fresh. Used to
* recover from warm-state corruption that resetReflectionState() cannot flush:
* PHPStan's NodeScopeResolver/reflection caches are not ResettableInterface
* services, so a class whose shape changed on disk between warm calls can yield
* a null scope deep in PHPStanNodeScopeResolver ("Call to a member function
* toMutatingScope() on null"). A fresh container is the only guaranteed reset.
*/
public function reboot(): void
{
$this->application = null;
$this->container = null;
}

/**
* Run a rector command. Returns ['exit_code' => int, 'output' => string, 'warm_boot' => bool].
*
Expand Down
50 changes: 49 additions & 1 deletion src/RectorTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,28 @@

final class RectorTool
{
private RectorRunner $runner;
private RunnerInterface $runner;

public function __construct(?RectorRunner $runner = null)
{
$this->runner = $runner ?? new RectorRunner();
}

/**
* Test seam: build a tool around an arbitrary runner (e.g. a double that
* simulates warm-state corruption). Kept separate from __construct so the MCP
* SDK's constructor autowiring still sees a concrete RectorRunner type.
*
* @internal
*/
public static function withRunner(RunnerInterface $runner): self
{
$tool = new self();
$tool->runner = $runner;

return $tool;
}

/**
* Run Rector on a path. Config + working dir are pinned at server startup (--working-dir, --config flags).
*
Expand Down Expand Up @@ -55,6 +70,21 @@ public function process(string $path, bool $dryRun = true): array
try {
return $this->runner->run($argv);
} catch (\Throwable $e) {
// A warm container can corrupt across edits: PHPStan's scope/reflection
// caches are not ResettableInterface, so a class whose shape changed on
// disk yields a null scope deep in PHPStanNodeScopeResolver
// ("toMutatingScope() on null"). It is not a real finding — a cold run on
// the same file passes. Reboot the container and retry once so the caller
// gets the correct (cold-quality) result instead of a false rector.error.
if ($this->runner->isWarm() && self::isRecoverableWarmCorruption($e)) {
$this->runner->reboot();
try {
return $this->runner->run($argv);
} catch (\Throwable $retryError) {
$e = $retryError;
}
}

return [
'exit_code' => -1,
'output' => '',
Expand All @@ -65,4 +95,22 @@ public function process(string $path, bool $dryRun = true): array
];
}
}

/**
* True for internal Rector/PHPStan errors that signal corrupted warm state
* rather than a genuine problem with the analysed code — recoverable by
* rebooting the container. Matched on message because the underlying type is a
* plain \Error (null method call) or Rector "System error".
*/
private static function isRecoverableWarmCorruption(\Throwable $error): bool
{
$message = $error->getMessage();
foreach (['toMutatingScope', 'must be resolved', 'System error'] as $needle) {
if (str_contains($message, $needle)) {
return true;
}
}

return false;
}
}
27 changes: 27 additions & 0 deletions src/RunnerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Dpt\McpRectorWarm;

/**
* Contract for the warm Rector runner. Extracted so RectorTool can be driven by
* a test double — in particular to exercise the reboot-and-retry recovery path
* without booting a real Rector container.
*/
interface RunnerInterface
{
/**
* @param list<string> $argv Rector CLI args including the binary name as $argv[0].
* @return array{exit_code: int, output: string, warm_boot: bool}
*/
public function run(array $argv): array;

public function isWarm(): bool;

/**
* Drop the warm container so the next run() boots a fresh one. Used to recover
* from warm-state corruption (stale PHPStan scope/reflection across edits).
*/
public function reboot(): void;
}
143 changes: 143 additions & 0 deletions tests/Unit/RectorToolRecoveryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

namespace Dpt\McpRectorWarm\Tests\Unit;

use Dpt\McpRectorWarm\RectorTool;
use Dpt\McpRectorWarm\RunnerInterface;
use PHPUnit\Framework\TestCase;

/**
* Recovery regression: when a warm run throws an internal scope/reflection
* corruption error ("Call to a member function toMutatingScope() on null" —
* stale PHPStan state across edits, not a real finding), RectorTool::process
* must reboot the runner and retry once on a fresh container instead of
* surfacing a false rector.error to the caller.
*/
final class RectorToolRecoveryTest extends TestCase
{
private string $cwdBackup;
private string $workDir;

protected function setUp(): void
{
$this->cwdBackup = getcwd() ?: '/';
$this->workDir = sys_get_temp_dir() . '/mcp-rector-recover-' . bin2hex(random_bytes(4));
mkdir($this->workDir, 0o700, true);
chdir($this->workDir);
}

protected function tearDown(): void
{
chdir($this->cwdBackup);
@unlink($this->workDir . '/inside.php');
@rmdir($this->workDir);
}

private function insideFile(): string
{
$inside = $this->workDir . '/inside.php';
file_put_contents($inside, "<?php\n\nclass Inside\n{\n}\n");
return $inside;
}

public function testRecoversFromWarmScopeCorruption(): void
{
$fake = new class implements RunnerInterface {
public int $runs = 0;
public int $reboots = 0;

public function run(array $argv): array
{
++$this->runs;
if ($this->runs === 1) {
throw new \Error('Call to a member function toMutatingScope() on null');
}
return ['exit_code' => 0, 'output' => '{"totals":{"errors":0}}', 'warm_boot' => false];
}

public function isWarm(): bool
{
return true;
}

public function reboot(): void
{
++$this->reboots;
}
};

$tool = RectorTool::withRunner($fake);
$result = $tool->process($this->insideFile(), true);

self::assertSame(0, $result['exit_code'], 'recovered run should succeed');
self::assertSame(2, $fake->runs, 'should retry exactly once');
self::assertSame(1, $fake->reboots, 'should reboot before retry');
self::assertArrayNotHasKey('error', $result, 'no false error surfaced after recovery');
}

public function testDoesNotRetryUnrelatedError(): void
{
$fake = new class implements RunnerInterface {
public int $runs = 0;
public int $reboots = 0;

public function run(array $argv): array
{
++$this->runs;
throw new \RuntimeException('disk full');
}

public function isWarm(): bool
{
return true;
}

public function reboot(): void
{
++$this->reboots;
}
};

$tool = RectorTool::withRunner($fake);
$result = $tool->process($this->insideFile(), true);

self::assertSame(-1, $result['exit_code']);
self::assertSame(1, $fake->runs, 'unrelated error must not be retried');
self::assertSame(0, $fake->reboots);
self::assertSame('disk full', $result['error'] ?? null);
}

public function testSurfacesErrorWhenRetryAlsoFails(): void
{
$fake = new class implements RunnerInterface {
public int $runs = 0;
public int $reboots = 0;

public function run(array $argv): array
{
++$this->runs;
throw new \Error('Call to a member function toMutatingScope() on null');
}

public function isWarm(): bool
{
return true;
}

public function reboot(): void
{
++$this->reboots;
}
};

$tool = RectorTool::withRunner($fake);
$result = $tool->process($this->insideFile(), true);

self::assertSame(-1, $result['exit_code']);
self::assertSame(2, $fake->runs, 'recoverable error retried once then gives up');
self::assertSame(1, $fake->reboots);
self::assertStringContainsString('toMutatingScope', $result['error'] ?? '');
}
}
Loading