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/normative-interface-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: fixed
type: patch
---

Document the process interfaces' normative execution, output and failure contracts, explain capture/run/result selection, and preserve descriptive PHPDoc and inheritance markers through qualified native formatting and refactoring.
1 change: 1 addition & 0 deletions docs/api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ API and extensions
:maxdepth: 1

configuration
../process-contracts
71 changes: 71 additions & 0 deletions docs/process-contracts.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
Process transport contracts
===========================

``ProcessRunner`` distinguishes successful output capture from command status.
``ResultProcessRunner`` optionally adds separate raw output channels and status.

Normative language
------------------

Uppercase ``MUST``, ``MUST NOT``, ``SHOULD``, ``SHOULD NOT`` and ``MAY`` follow
`RFC 2119 <https://www.rfc-editor.org/rfc/rfc2119>`__ and
`RFC 8174 <https://www.rfc-editor.org/rfc/rfc8174>`__; lowercase words retain
their ordinary meaning. Use requirement terms sparingly for observable contracts.
Interface descriptions state semantic obligations, not implementation details.

Choosing an operation
---------------------

=================== =========================== =============================
Operation Return value Completed nonzero exit
=================== =========================== =============================
``capture`` Successful stdout Raises an exception
``run`` Completed exit status Returned unchanged
``captureResult`` Status, stdout and stderr Returned as data
=================== =========================== =============================

Use ``capture`` for output consumed as data, such as a Git path list or Rector
JSON inventory. Use ``run`` to propagate tool status, with diagnostics permitted.
Use ``captureResult`` for raw stdout/stderr and status without streaming; a
nonzero status remains data. Failure before completion raises an exception.

.. code-block:: php

$paths = $process->capture(['git', 'ls-files', '-z'], $directory);
$status = $process->run([PHP_BINARY, '-n', '-l', $file], $directory);

if (!$process instanceof \FastForward\CodeStandards\Process\ResultProcessRunner) {
throw new \RuntimeException('This transport does not support result capture.');
}

$result = $process->captureResult([PHP_BINARY, '-n', '-l', $file], $directory);
// Interpret $result->exitCode, stdout and stderr according to the selected tool.

The examples assume an injected transport, existing working directory and
selected source file. Check the optional capability before using an arbitrary
``ProcessRunner`` override. Result capture does not parse ECS/Rector reports.

Native transport
----------------

``NativeProcessRunner`` uses Symfony Process. It rejects invalid timeouts before
launch and keeps the parent's working directory unchanged. Declared defaults
are 120 seconds for ``capture`` and 300 for ``run``/``captureResult``.

Its ``run`` forwards raw stdout/stderr chunks to the optional sink; without one
it is silent. Capture operations do not use the sink. Symfony retains buffers,
so sink delivery is not a bounded-memory guarantee. Execution and sink exceptions
can propagate.

The native child environment excludes ``GH_TOKEN``, ``GITHUB_TOKEN`` and
``COMPOSER_AUTH``. This is not a sandbox or an empty environment. Callers own
command authority: an explicitly selected shell, application or configuration
can execute code and change files. Capturing output does not make it read-only.

Preservation and extension
--------------------------

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.
25 changes: 20 additions & 5 deletions src/Process/ProcessRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,33 @@

namespace FastForward\CodeStandards\Process;

/** Executes bounded argument arrays without shell interpolation. */
/** Executes bounded commands with literal arguments. */
interface ProcessRunner
{
/**
* @param list<string> $arguments Returns stdout or throws on failure.
* @param positive-float $timeout Finite maximum seconds for this subprocess; defaults retain Git's existing limit.
* Captures successful standard output.
*
* A nonzero exit MUST raise an exception rather than return successful output.
*
* @param list<string> $arguments Executable followed by literal arguments, without shell interpolation.
* @param string $directory Child working directory.
* @param float $timeout Positive, finite execution limit in seconds.
* @return string Successful stdout, excluding stderr diagnostics.
* @throws \Throwable When execution cannot complete successfully.
*/
public function capture(array $arguments, string $directory, float $timeout = 120): string;

/**
* @param list<string> $arguments Streams diagnostics and returns the actual exit code.
* @param positive-float $timeout Finite maximum seconds for this subprocess; default formatting limit is unchanged.
* Runs a command and returns its exit status.
*
* Completed statuses, including nonzero ones, MUST be returned unchanged.
* Diagnostics MAY be emitted; failure before completion MUST raise an exception.
*
* @param list<string> $arguments Executable followed by literal arguments, without shell interpolation.
* @param string $directory Child working directory.
* @param float $timeout Positive, finite execution limit in seconds.
* @return int Completed command status; interpreting tool-specific outcomes belongs to the caller.
* @throws \Throwable When execution cannot complete.
*/
public function run(array $arguments, string $directory, float $timeout = 300): int;
}
18 changes: 10 additions & 8 deletions src/Process/ResultProcessRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@

namespace FastForward\CodeStandards\Process;

/** Optional lossless capture capability; existing ProcessRunner implementations remain valid. */
/** Adds optional capture of output and status as data. */
interface ResultProcessRunner extends ProcessRunner
{
/**
* Captures a completed process without streaming or throwing merely for a nonzero exit.
* Captures output and status as data.
*
* @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.
* Stdout and stderr MUST remain separate, unmodified byte strings, without streaming.
* A completed nonzero status MUST remain data; failure before completion MUST raise an exception.
*
* @param list<string> $arguments Executable followed by literal arguments, without shell interpolation.
* @param string $directory Child working directory, leaving the caller's unchanged.
* @param float $timeout Positive, finite execution limit in seconds.
* @return ProcessResult Completed result, without interpreting diagnostics or classifying success.
* @throws \Throwable When execution cannot complete or the timeout is invalid.
*/
public function captureResult(array $arguments, string $directory, float $timeout = 300): ProcessResult;
}
34 changes: 34 additions & 0 deletions tests/Fixtures/normative-contract.after.php.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Fixture;

/** Keeps semantic requirements visible at the interface boundary. */
interface NormativeContract
{
/**
* Returns the supplied value as a one-element list.
*
* Implementations MUST preserve the supplied value and MUST NOT accept negative input.
*
* @param int $value Nonnegative value whose identity the result retains.
* @return list<int> Exactly one unchanged value, without normalization.
* @throws \InvalidArgumentException When the supplied value is negative.
*/
public function values(int $value): array;
}

/** Supplies the inherited contract without duplicating its requirements. */
final class NormativeImplementation implements NormativeContract
{
/** {@inheritDoc} */
public function values(int $value): array
{
if ($value < 0) {
throw new \InvalidArgumentException('Value must not be negative.');
}

return [$value];
}
}
34 changes: 34 additions & 0 deletions tests/Fixtures/normative-contract.before.php.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Fixture;

/** Keeps semantic requirements visible at the interface boundary. */
interface NormativeContract
{
/**
* Returns the supplied value as a one-element list.
*
* Implementations MUST preserve the supplied value and MUST NOT accept negative input.
*
* @param int $value Nonnegative value whose identity the result retains.
* @return list<int> Exactly one unchanged value, without normalization.
* @throws \InvalidArgumentException When the supplied value is negative.
*/
public function values(int $value): array;
}

/** Supplies the inherited contract without duplicating its requirements. */
final class NormativeImplementation implements NormativeContract
{
/** {@inheritDoc} */
public function values(int $value): array
{
if ($value < 0) {
throw new \InvalidArgumentException('Value must not be negative.');
}

return array($value);
}
}
24 changes: 24 additions & 0 deletions tests/Integration/RectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ public function testPreviewApplyAndSecondPassWithExplicitNativeConfiguration():
self::assertSame($config, file_get_contents($this->directory . '/policy.php'));
}

/** Preserves interface documentation during refactoring. */
public function testNativeArrayRulePreservesNormativeInterfaceDocumentation(): void
{
$fixtures = dirname(__DIR__) . '/Fixtures/';
$source = file_get_contents($fixtures . 'normative-contract.before.php.inc');
$expected = file_get_contents($fixtures . 'normative-contract.after.php.inc');
self::assertIsString($source);
self::assertIsString($expected);
file_put_contents($this->directory . '/source.php', $source);
$this->configuration('\\Rector\\Php54\\Rector\\Array_\\LongArrayToShortArrayRector', 70400);
$before = $this->ownedBytes();

$preview = $this->cli();
self::assertSame(2, $preview->getExitCode(), $preview->getOutput() . $preview->getErrorOutput());
self::assertSame($before, $this->ownedBytes());
$apply = $this->cli(['--apply']);
self::assertSame(0, $apply->getExitCode(), $apply->getOutput() . $apply->getErrorOutput());
self::assertSame($expected, file_get_contents($this->directory . '/source.php'));
$after = $this->ownedBytes();
self::assertSame(0, $this->cli()->getExitCode());
self::assertSame(0, $this->cli(['--apply'])->getExitCode());
self::assertSame($after, $this->ownedBytes());
}

/** Empty native custom config is rejected before Rector's successful-but-writing initializer. */
public function testEmptyConfigDoesNotGenerateRectorFile(): void
{
Expand Down
2 changes: 1 addition & 1 deletion tests/Integration/StandardsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ public function testGoldenPresetIsReadOnlyUntilFixAndPreservesContracts(): void
$fixtures = dirname(__DIR__) . '/Fixtures/';
$original = [];

foreach (['style', 'preservation', 'documentation'] as $name) {
foreach (['style', 'preservation', 'documentation', 'normative-contract'] as $name) {
$original[$name] = file_get_contents($fixtures . $name . '.before.php.inc');
$this->put($name . '.php', $original[$name]);
}
Expand Down
Loading