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/lossless-process-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
category: added
type: minor
---

Expose optional lossless process capture with exact native exit codes and separate
stdout/stderr bytes while preserving existing transport and provider contracts.
18 changes: 18 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,24 @@ apply, including idempotent repeated boolean flags and both `--option=value`
and `--option value`. Behavioral tests exercise the default preset, real rule
overrides, help outside a checkout and invalid invocations.

### Optional lossless process capture

`ResultProcessRunner` extends the existing process interface with `captureResult()`.
The native transport returns an immutable `ProcessResult` containing the exact
exit code and separate stdout/stderr bytes, including non-UTF-8 and ANSI bytes.
Nonzero exits remain results; startup, timeout and invalid-bound failures retain
their native exceptions. Capture uses the same credential filtering, explicit
working directory, argument vector and finite timeout policy, without invoking
the streaming output sink.

Existing `ProcessRunner` implementations and provider overrides need no new
method. Existing `capture()` still throws on nonzero exits, and `run()` retains
its streaming behavior. The service provider and command output are unchanged.
This library capability does not parse tool output, infer a clean outcome or
expose CLI JSON. Callers must preserve unsupported output rather than extract a
JSON suffix, merge the two channels or treat empty diagnostics as success.
Check, fix and semantic-operation authority remain with their existing runners.

## Implementation quality

Keep methods focused on one responsibility and use early returns for rejected
Expand Down
11 changes: 10 additions & 1 deletion src/Process/NativeProcessRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Symfony\Component\Process\Process;

/** Native transport; callers own selection and operation policy. */
final class NativeProcessRunner implements ProcessRunner
final class NativeProcessRunner implements ResultProcessRunner
{
/** 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 All @@ -33,6 +33,15 @@ public function run(array $arguments, string $directory, float $timeout = 300):
return $process->run($this->write);
}

/** Captures both channels without invoking the streaming sink or interpreting native failure statuses. */
public function captureResult(array $arguments, string $directory, float $timeout = 300): ProcessResult
{
$process = $this->createProcess($arguments, $directory, $timeout);
$status = $process->run();

return new ProcessResult($status, $process->getOutput(), $process->getErrorOutput());
}

/** @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
{
Expand Down
16 changes: 16 additions & 0 deletions src/Process/ProcessResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace FastForward\CodeStandards\Process;

/** Retains one completed subprocess status and its separate, unmodified output channels. */
final readonly class ProcessResult
{
/**
* @param int $exitCode Native completed-process status; nonzero values remain ordinary results.
* @param string $stdout Exact stdout bytes, including control characters or invalid UTF-8.
* @param string $stderr Exact stderr bytes; no concatenation or diagnostic parsing occurs here.
*/
public function __construct(public int $exitCode, public string $stdout, public string $stderr) {}
}
21 changes: 21 additions & 0 deletions src/Process/ResultProcessRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace FastForward\CodeStandards\Process;

/** Optional lossless capture capability; existing ProcessRunner implementations remain valid. */
interface ResultProcessRunner extends ProcessRunner
{
/**
* Captures a completed process without streaming or throwing merely for a nonzero exit.
*
* @param list<string> $arguments Literal executable and argument vector, without shell interpolation.
* @param string $directory Explicit child working directory; the caller's directory remains unchanged.
* @param positive-float $timeout Finite maximum seconds using the same transport bounds as run().
* @return ProcessResult Exact native status and separate output bytes, without a clean/failure interpretation.
* @throws \InvalidArgumentException When the timeout is nonfinite or not positive.
* @throws \Symfony\Component\Process\Exception\RuntimeException When execution cannot complete, including startup or timeout failure.
*/
public function captureResult(array $arguments, string $directory, float $timeout = 300): ProcessResult;
}
77 changes: 75 additions & 2 deletions tests/Process/NativeProcessRunnerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace FastForward\CodeStandards\Tests\Process;

use FastForward\CodeStandards\Process\NativeProcessRunner;
use FastForward\CodeStandards\Process\ResultProcessRunner;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
Expand All @@ -16,7 +17,7 @@ final class NativeProcessRunnerTest extends TestCase
public function testFiniteTimeoutsApplyToCapturedAndStreamedProcesses(): void
{
$process = new NativeProcessRunner();
foreach (['capture', 'run'] as $method) {
foreach (['capture', 'run', 'captureResult'] as $method) {
try {
$process->{$method}([PHP_BINARY, '-r', 'usleep(250000);'], sys_get_temp_dir(), 0.01);
self::fail('A controlled long subprocess must time out.');
Expand All @@ -26,6 +27,7 @@ public function testFiniteTimeoutsApplyToCapturedAndStreamedProcesses(): void
}
$arguments = [PHP_BINARY, '-r', 'usleep(50000); echo "completed";'];
self::assertSame('completed', $process->capture($arguments, sys_get_temp_dir(), 2));
self::assertSame('completed', $process->captureResult($arguments, sys_get_temp_dir(), 2)->stdout);
$output = '';
$streamed = new NativeProcessRunner(static function (string $type, string $bytes) use (&$output): void {
$output .= $bytes;
Expand All @@ -37,7 +39,7 @@ public function testFiniteTimeoutsApplyToCapturedAndStreamedProcesses(): void
/** Zero, negative and nonfinite values cannot silently become Symfony's unlimited mode. */
public function testInvalidBoundsFailBeforeLaunchingEitherChannel(): void
{
foreach (['capture', 'run'] as $method) {
foreach (['capture', 'run', 'captureResult'] as $method) {
foreach ([0.0, -0.1, INF, -INF, NAN] as $timeout) {
try {
(new NativeProcessRunner())->{$method}([PHP_BINARY, '-r', 'echo "unexpected";'], sys_get_temp_dir(), $timeout);
Expand All @@ -48,4 +50,75 @@ public function testInvalidBoundsFailBeforeLaunchingEitherChannel(): void
}
}
}

/** Binary bytes and nonzero statuses remain separate data and never reach the streaming output sink. */
public function testResultCaptureRetainsExactBytesAndNativeStatuses(): void
{
$writes = [];
$runner = new NativeProcessRunner(static function (string $type, string $bytes) use (&$writes): void {
$writes[] = [$type, $bytes];
});
self::assertInstanceOf(ResultProcessRunner::class, $runner);
$source = <<<'PHP'
fwrite(STDOUT, "out\0\xFF\033[31m\n");
fwrite(STDERR, "err\0\xFE\033[0m\n");
exit((int) $argv[1]);
PHP;

foreach ([0, 2, 255] as $status) {
$result = $runner->captureResult([PHP_BINARY, '-r', $source, (string) $status], sys_get_temp_dir());
self::assertSame($status, $result->exitCode);
self::assertSame("out\0\xFF\033[31m\n", $result->stdout);
self::assertSame("err\0\xFE\033[0m\n", $result->stderr);
}

self::assertSame([], $writes);
}

/** Captured execution preserves the selected cwd/argv and the existing credential-removal policy. */
public function testResultCaptureUsesTheExistingProcessBoundary(): void
{
$before = getcwd();
$directory = realpath(sys_get_temp_dir());
self::assertNotFalse($directory);
$previous = [];
foreach (['GH_TOKEN', 'GITHUB_TOKEN', 'COMPOSER_AUTH'] as $name) {
$previous[$name] = getenv($name);
putenv($name . '=synthetic-test-value');
}

try {
$result = (new NativeProcessRunner())->captureResult([
PHP_BINARY, '-r',
'echo getcwd(), "\n", $argv[1]; foreach (["GH_TOKEN", "GITHUB_TOKEN", "COMPOSER_AUTH"] as $name) { if (false !== getenv($name)) { exit(19); } }',
'literal argument with spaces',
], $directory);
} finally {
foreach ($previous as $name => $value) {
putenv(false === $value ? $name : $name . '=' . $value);
}
}

self::assertSame(0, $result->exitCode);
self::assertSame($directory . "\nliteral argument with spaces", $result->stdout);
self::assertSame('', $result->stderr);
self::assertSame($before, getcwd());
}

/** Native startup errors propagate instead of becoming an invented successful process result. */
public function testResultCaptureDoesNotInventAResultForStartupFailure(): void
{
$this->expectException(\Symfony\Component\Process\Exception\RuntimeException::class);
(new NativeProcessRunner())->captureResult(
[PHP_BINARY, '-r', 'echo "unexpected";'],
sys_get_temp_dir() . '/code-standards-absent-' . bin2hex(random_bytes(16)),
);
}

/** Existing captured execution still throws on a completed nonzero process instead of changing its API. */
public function testLegacyCaptureRetainsItsFailureContract(): void
{
$this->expectException(\Symfony\Component\Process\Exception\ProcessFailedException::class);
(new NativeProcessRunner())->capture([PHP_BINARY, '-r', 'exit(2);'], sys_get_temp_dir());
}
}
Loading