From d395fbf0b6618dbbcb8885f78f0abf0240df2bfc Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Sat, 19 Sep 2026 09:16:30 +0100 Subject: [PATCH] Raise on RTK failure or swallowed output instead of reporting a saving --- docs/rtk.md | 4 +++ src/RtkProxy.php | 42 +++++++++++++++++++++++++++---- tests/Unit/RtkProxyTest.php | 50 +++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/docs/rtk.md b/docs/rtk.md index 86d6afe..4b86d01 100644 --- a/docs/rtk.md +++ b/docs/rtk.md @@ -50,6 +50,10 @@ Every call returns an immutable `OptimisationResult`: | `savingsPercent()` | saving as a percentage | | `strategy` | `rtk:pipe`, `rtk:pipe:`, or `rtk:command` | +## Failures + +A command RTK cannot handle throws a `RuntimeException` carrying its exit code and stderr. + ## Requirements The `rtk` binary must be on `PATH` at runtime (`brew install rtk`). It is **not** a Composer diff --git a/src/RtkProxy.php b/src/RtkProxy.php index 43901dc..79caf2a 100644 --- a/src/RtkProxy.php +++ b/src/RtkProxy.php @@ -66,6 +66,7 @@ public function optimise(string $content, array $options = []): OptimisationResu } $optimised = $this->execute(array_merge([$this->binary], $args), $content); + $this->assertNotSwallowed($content, $optimised, 'rtk pipe'); $strategy = isset($options['filter']) ? 'rtk:pipe:' . $options['filter'] : 'rtk:pipe'; return new OptimisationResult( @@ -85,12 +86,15 @@ public function optimise(string $content, array $options = []): OptimisationResu */ public function optimiseCommand(string $command, array $options = []): OptimisationResult { - $tokensBefore = ($options['measure'] ?? true) - ? $this->estimateTokens($this->execute(['sh', '-c', $command])) - : null; + $raw = ($options['measure'] ?? true) ? $this->execute(['sh', '-c', $command]) : null; + $tokensBefore = $raw === null ? null : $this->estimateTokens($raw); $optimised = $this->execute(['sh', '-c', $this->binary . ' ' . $this->withUltraCompact($command, $options)]); + if ($raw !== null) { + $this->assertNotSwallowed($raw, $optimised, $command); + } + return new OptimisationResult( $optimised, $tokensBefore, @@ -105,6 +109,24 @@ public function estimateTokens(string $content): int return $this->estimator->estimateTokens($content); } + /** + * Empty output for non-empty input is a failure, not a saving. + * + * @throws RuntimeException When RTK produced no output for input that had some + */ + private function assertNotSwallowed(string $input, string $optimised, string $what): void + { + if ($optimised !== '' || trim($input) === '') { + return; + } + + throw new RuntimeException(sprintf( + 'RTK returned no output for "%s" although the input had %d tokens.', + $what, + $this->estimateTokens($input), + )); + } + /** * Run a process, optionally feeding it stdin, and return its stdout. * @@ -116,7 +138,7 @@ public function estimateTokens(string $content): int * * @return string The process stdout (empty string on no output) * - * @throws RuntimeException When the process cannot be started + * @throws RuntimeException When the process cannot be started or exits non-zero */ protected function execute(array $argv, ?string $stdin = null): string { @@ -139,9 +161,19 @@ protected function execute(array $argv, ?string $stdin = null): string fclose($pipes[0]); $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); - proc_close($process); + $exitCode = proc_close($process); + + if ($exitCode !== 0) { + throw new RuntimeException(sprintf( + '"%s" exited with code %d: %s', + implode(' ', $argv), + $exitCode, + trim((string) $stderr) !== '' ? trim((string) $stderr) : 'no error output', + )); + } return $stdout === false ? '' : $stdout; } diff --git a/tests/Unit/RtkProxyTest.php b/tests/Unit/RtkProxyTest.php index 5738ed1..709fd24 100644 --- a/tests/Unit/RtkProxyTest.php +++ b/tests/Unit/RtkProxyTest.php @@ -17,6 +17,7 @@ use PapiAI\Core\HeuristicTokenEstimator; use PapiAI\Core\OptimisationResult; use PapiAI\Rtk\RtkProxy; +use RuntimeException; /** * Test subclass that stubs process execution so no real `rtk` binary runs. @@ -26,10 +27,18 @@ class TestableRtkProxy extends RtkProxy /** @var array, stdin: string|null}> */ public array $calls = []; + public bool $rtkReturnsNothing = false; + protected function execute(array $argv, ?string $stdin = null): string { $this->calls[] = ['argv' => $argv, 'stdin' => $stdin]; + $viaRtk = in_array('pipe', $argv, true) || str_starts_with($argv[2] ?? '', 'rtk '); + + if ($this->rtkReturnsNothing && $viaRtk) { + return ''; + } + // `rtk pipe` (argv contains 'pipe') if (in_array('pipe', $argv, true)) { return 'PIPED'; @@ -190,4 +199,45 @@ public function run(array $argv, ?string $stdin = null): string expect($proxy->estimateTokens('abcd'))->toBe(2); }); }); + describe('when RTK returns nothing', function () { + it('throws for a command that produced output', function () { + $this->proxy->rtkReturnsNothing = true; + + expect(fn () => $this->proxy->optimiseCommand('find src -exec wc -l {} +')) + ->toThrow(RuntimeException::class, 'returned no output'); + }); + + it('throws for piped content', function () { + $this->proxy->rtkReturnsNothing = true; + + expect(fn () => $this->proxy->optimise('some content worth keeping')) + ->toThrow(RuntimeException::class, 'returned no output'); + }); + + it('accepts empty output for empty input', function () { + $this->proxy->rtkReturnsNothing = true; + + $result = $this->proxy->optimise(''); + + expect($result->optimised)->toBe(''); + expect($result->tokensSaved())->toBe(0); + }); + + it('reports no saving for an unmeasured command', function () { + $this->proxy->rtkReturnsNothing = true; + + $result = $this->proxy->optimiseCommand('find src', ['measure' => false]); + + expect($result->savingsPercent())->toBeNull(); + }); + }); + + describe('process failures', function () { + it('throws with the exit code and stderr when the process fails', function () { + $proxy = new RtkProxy('sh'); + + expect(fn () => $proxy->optimiseCommand('echo boom >&2; exit 3', ['measure' => false])) + ->toThrow(RuntimeException::class, 'exited with code 3'); + }); + }); });