From 4e3f565d57afdedd58d84dfffe06dd219bfbdad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Tue, 27 Oct 2026 00:42:54 -0300 Subject: [PATCH 1/4] feat: enforce multiline boolean condition layout --- .changelog/multiline-boolean-conditions.md | 6 + .github/ReleaseChannelUpdater.php | 54 ++-- README.md | 17 +- docs/policy.md | 4 +- docs/resolved-preset.md | 40 ++- .../fast-forward-code-standards/SKILL.md | 6 + src/Config/ECSConfig.php | 24 +- src/Git/FileSelection.php | 3 +- src/Sniff/MultilineBooleanConditionSniff.php | 124 +++++++++ tests/Config/PresetTest.php | 52 +++- .../multiline-condition-expected.php.inc | 19 ++ .../multiline-condition-input.php.inc | 16 ++ tests/Fixtures/preset-rules.json | 71 +++++ tests/Integration/MultilineConditionTest.php | 257 ++++++++++++++++++ .../ReleaseChannel/ReleaseChannelFixture.php | 9 +- 15 files changed, 659 insertions(+), 43 deletions(-) create mode 100644 .changelog/multiline-boolean-conditions.md create mode 100644 src/Sniff/MultilineBooleanConditionSniff.php create mode 100644 tests/Fixtures/multiline-condition-expected.php.inc create mode 100644 tests/Fixtures/multiline-condition-input.php.inc create mode 100644 tests/Integration/MultilineConditionTest.php diff --git a/.changelog/multiline-boolean-conditions.md b/.changelog/multiline-boolean-conditions.md new file mode 100644 index 0000000..3299ab6 --- /dev/null +++ b/.changelog/multiline-boolean-conditions.md @@ -0,0 +1,6 @@ +--- +category: added +type: minor +--- + +Check and fix one symbolic boolean part per line in existing multiline if, elseif, while and do-while conditions through an owned native ECS Sniff. Preserve single-line conditions, function/arrow/class expressions and native spacing/skip overrides. Fingerprint owned policy source for cache invalidation and record the Sniff separately from native fixer risk classifications. diff --git a/.github/ReleaseChannelUpdater.php b/.github/ReleaseChannelUpdater.php index f4bb960..1884976 100644 --- a/.github/ReleaseChannelUpdater.php +++ b/.github/ReleaseChannelUpdater.php @@ -39,10 +39,14 @@ public function __construct( public function update(string $repository, string $tag, string $sha): array { $selected = self::version($tag); - if (null === $selected || '0' !== $selected['major'] || 'php-fast-forward/code-standards' !== $repository || !self::sha($sha) || 1 !== preg_match( - '~^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$~D', - $repository, - )) { + if (null === $selected + || '0' !== $selected['major'] + || 'php-fast-forward/code-standards' !== $repository + || !self::sha($sha) + || 1 !== preg_match( + '~^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$~D', + $repository, + )) { throw new RuntimeException('Select an exact repository, complete stable release tag and full commit SHA.'); } @@ -204,7 +208,8 @@ private function revalidate( throw new RuntimeException('Release or conflicting-tag evidence changed before the channel update.'); } - if (null !== $previous && (!self::contains($finalCatalog, $previous) + if (null !== $previous + && (!self::contains($finalCatalog, $previous) || $this->commit($repository, $previous['tag_name']) !== $current['sha'])) { throw new RuntimeException('The previous channel release evidence changed before the update.'); } @@ -280,7 +285,8 @@ private function reference(string $repository, string $ref): ?array if (null === $data) { return null; } - if ('refs/' . $ref !== ($data['ref'] ?? null) || !self::sha($data['object']['sha'] ?? null) + if ('refs/' . $ref !== ($data['ref'] ?? null) + || !self::sha($data['object']['sha'] ?? null) || !is_string($data['object']['type'] ?? null)) { throw new RuntimeException('Malformed exact Git reference evidence.'); } @@ -361,8 +367,10 @@ private function catalog(string $repository, string $major): array throw new RuntimeException('Malformed release catalog.'); } foreach ($data as $release) { - if (!is_array($release) || !is_bool($release['draft'] ?? null) - || !is_bool($release['prerelease'] ?? null) || !is_string($release['tag_name'] ?? null)) { + if (!is_array($release) + || !is_bool($release['draft'] ?? null) + || !is_bool($release['prerelease'] ?? null) + || !is_string($release['tag_name'] ?? null)) { throw new RuntimeException('Partial release catalog metadata cannot establish the latest version.'); } if ($release['draft'] || $release['prerelease']) { @@ -393,10 +401,11 @@ private function catalog(string $repository, string $major): array $left['tag_name'], )), ); - if (count($releases) > 1 && 0 === self::compare( - self::version($releases[0]['tag_name']), - self::version($releases[1]['tag_name']), - )) { + if (count($releases) > 1 + && 0 === self::compare( + self::version($releases[0]['tag_name']), + self::version($releases[1]['tag_name']), + )) { throw new RuntimeException('Build-only release ties cannot choose a unique channel destination.'); } @@ -461,10 +470,11 @@ private static function published(array $release): bool */ private static function publicationTime(mixed $value): bool { - if (!is_string($value) || 1 !== preg_match( - '/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/D', - $value, - )) { + if (!is_string($value) + || 1 !== preg_match( + '/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/D', + $value, + )) { return false; } $date = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:s\Z', $value, new DateTimeZone('UTC')); @@ -481,11 +491,13 @@ private static function publicationTime(mixed $value): bool */ private static function version(mixed $tag): ?array { - if (!is_string($tag) || strlen($tag) > 128 || 1 !== preg_match( - '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/D', - $tag, - $matches, - )) { + if (!is_string($tag) + || strlen($tag) > 128 + || 1 !== preg_match( + '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/D', + $tag, + $matches, + )) { return null; } diff --git a/README.md b/README.md index 510ed57..a7bf555 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,10 @@ composer test `check` reports violations with a nonzero status and does not change source. The default preset stores its ECS cache beneath the system temporary directory, -in `fast-forward-code-standards/`. -Different checkouts cannot invalidate each other's cache. Native +in `fast-forward-code-standards//`. +The fingerprint includes the installed preset and owned checker paths and source +bytes. Different checkouts cannot invalidate each other's cache, and a changed +owned rule cannot reuse an earlier passing result. 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. @@ -114,6 +116,17 @@ both ECS and a competing PHP-CS-Fixer pass. Standalone ECS users may import the same library preset and set their own paths. CLI selection uses the scope below; ECS's configured paths do not expand the CLI's explicit selected file list. +The preset adds an owned whitespace policy to native PER 3.0: in an already +multiline `if`, `elseif`, `while` or `do-while` condition, each symbolic `&&` and +`||` starts its own line. The policy includes grouped expressions, call arguments +and array indexes within that condition; function, arrow and anonymous-class +declarations remain opaque. Single-line conditions and other expression contexts +retain their native rules. This is a project policy, not an additional PER claim. +The native ECS `withSpacing()` callback controls indentation and line endings. +Consumers can explicitly skip `FastForward\CodeStandards\Sniff\MultilineBooleanConditionSniff` +through `withSkip()`; that native method replaces the skip list, so preserve any +other intended preservation skips. See [the resolved preset](docs/resolved-preset.md). + The formatter worker uses ECS's public factory and command APIs with this tool's own Composer installation. An isolated tool does not implicitly load the target checkout's `vendor/autoload.php`. Explicit PHP configuration retains the checkout diff --git a/docs/policy.md b/docs/policy.md index 49ea750..e71bdb8 100644 --- a/docs/policy.md +++ b/docs/policy.md @@ -68,7 +68,9 @@ candidate engines and their analysis scopes without deciding domain ownership. The current preset replaces prepared PSR-12 with ECS 13.3.8's native non-risky PER Coding Style 3.0 set, then applies the owned preservation skips and explicit spacing overrides. It does not combine duplicate PSR/PER/common/docblock sets. -The resolved snapshot records 63 rules:20 added and5 removed from the earlier 48-rule +The resolved snapshot records 63 native fixers plus one explicitly classified, +owned whitespace-only Sniff. The native PER transition added 20 and removed five +fixers from the earlier 48-rule snapshot, with explicit option changes captured for review. PER supersedes several older checker choices; the two snapshots are not identical contracts. No migration or risky rule is enabled. See the resolved preset and PHPDoc golden diff --git a/docs/resolved-preset.md b/docs/resolved-preset.md index c11979b..4197a01 100644 --- a/docs/resolved-preset.md +++ b/docs/resolved-preset.md @@ -1,7 +1,8 @@ # Resolved PER preset Snapshot: ECS 13.3.8 from this repository’s Composer lock. The compiled container -contains 63 distinct PHP-CS-Fixer rules, all reporting `isRisky() === false`. +contains 63 distinct PHP-CS-Fixer rules, all reporting `isRisky() === false`, +and one owned PHPCS Sniff: 64 checkers in total. This is the actual Code Standards preset, not a resolved DevTools or CoiSA preset. The fixture `tests/Fixtures/preset-rules.json` records the installed ECS version @@ -10,7 +11,12 @@ classification after container boot/skip passes. Unspecified options retain the fixer's defaults at that exact version; the table does not expand those defaults. `tests/Config/PresetTest.php` rebuilds that configuration and compares it exactly. It reads this installation's Composer metadata and verifies every fixer's source -path inside that exact ECS package. This avoids querying the global +path inside that exact ECS package. The owned Sniff has its own exact class/source +allowlist; an unknown default Sniff fails the inventory test. PHPCS has no native +`isRisky()` contract: the snapshot records `risky: null`, checker `type: sniff` +and the explicit reviewed classification `owned-whitespace-only`, rather than +inventing a native false value. Fixer entries retain their native risk checks. +This avoids querying the global `InstalledVersions` registry after the bundled ECS autoloader has registered its own graph: locally that changed the reported ECS version from `13.3.8` to `dev-main`. Installation separation alone does not isolate that global registry. @@ -89,6 +95,36 @@ must not be presented as proof that broader profiles are safe. Native ECS builde `withSkip()` replaces the builder’s skip list, while configured rules of the same class use the last configuration. Consumers own their explicit overrides. +## Owned multiline boolean policy + +`FastForward\CodeStandards\Sniff\MultilineBooleanConditionSniff` participates in +the existing ECS PHPCS pass. It uses the installed PHPCS public token/scope +metadata, not another parser, dependency or formatter pass. It changes whitespace +only and reports a failing diagnostic if a required native expression boundary +cannot be established. Its source is `src/Sniff/MultilineBooleanConditionSniff.php`. + +An `if`, `elseif`, `while` or `do-while` condition whose opening and closing +parentheses occupy different lines must put each symbolic `&&` and `||` on its +own line. This includes operators inside grouped expressions, call arguments and +array indexes. Function/closure/arrow and anonymous-class declarations are +opaque; separately nested conditional statements are checked independently. +Single-line conditions, expressions outside those conditions and keyword +`and`/`or`/`xor` retain their native policy. This extension does not claim that +PER requires one boolean part per line. + +The native ECS spacing service supplies two/four-space or tab indentation and +LF/CRLF endings after the consumer callback. Native `withSkip()` can disable the +owned checker explicitly; it replaces the skip list, so callers preserve their +other intentional skips. The default cache fingerprints both installed policy +source paths and bytes, in addition to checkout isolation. An explicit +`withCache()` override remains authoritative. + +CLI regressions cover the reported mixed-line guard, groups/calls/indexes, +declaration boundaries, comments/PHPDoc/strings, native overrides, no-write check, +token-content equivalence and second-pass idempotence. A disposable installation +test changes only its owned checker source and verifies that unchanged consumer +source is rechecked without deleting another checkout's cache. + Golden fixtures prove formatting, check-without-writes, exact fixed bytes and second-pass idempotence. Preservation covers author/package/typed-parameter/ void-return documentation, property visibility, mutable DateTime construction diff --git a/resources/skills/fast-forward-code-standards/SKILL.md b/resources/skills/fast-forward-code-standards/SKILL.md index 8255eb1..89251ae 100644 --- a/resources/skills/fast-forward-code-standards/SKILL.md +++ b/resources/skills/fast-forward-code-standards/SKILL.md @@ -22,6 +22,12 @@ metadata: 4. Select a local ECS configuration explicitly with `--config=ecs.php` and keep imports/overrides. Configuration is executable PHP: never load it with a privileged token or on a privileged target event. No incidental dependency update. + The owned multiline-condition policy splits symbolic `&&`/`||` in existing + multiline if/elseif/while/do-while conditions, including argument/group/index + expressions but excluding function/arrow/anonymous-class declarations. It + preserves single-line conditions and uses native `withSpacing()` overrides. + A deliberate `withSkip()` of `MultilineBooleanConditionSniff` is supported; + preserve the other intended skips because the native method replaces its list. 5. Only when fixing is requested run `code-standards fix --all`, inspect the diff and rerun check. Do not commit/push automatically. Semantic migrations need a separate reviewed procedure; none are enabled by this initial package. diff --git a/src/Config/ECSConfig.php b/src/Config/ECSConfig.php index 81d6701..a1e39d9 100644 --- a/src/Config/ECSConfig.php +++ b/src/Config/ECSConfig.php @@ -4,6 +4,7 @@ namespace FastForward\CodeStandards\Config; +use FastForward\CodeStandards\Sniff\MultilineBooleanConditionSniff; use PhpCsFixer\Fixer\FunctionNotation\MethodArgumentSpaceFixer; use PhpCsFixer\Fixer\Phpdoc\GeneralPhpdocAnnotationRemoveFixer; use PhpCsFixer\Fixer\Phpdoc\NoEmptyPhpdocFixer; @@ -21,12 +22,13 @@ final class ECSConfig * * @param callable(ECSConfigBuilder):void|null $customize Native rule imports, skips or overrides; null uses the preset unchanged. * @return ECSConfigBuilder The native builder, ready to return from an explicit ECS configuration file. - * @throws RuntimeException When the executing checkout cannot be identified for cache isolation. + * @throws RuntimeException When the checkout or owned policy source cannot be identified for cache isolation. */ public static function configure(?callable $customize = null): ECSConfigBuilder { $config = (new ECSConfigBuilder()) ->withPreparedSets(perCs: true) + ->withRules([MultilineBooleanConditionSniff::class]) ->withSkip([ NoSuperfluousPhpdocTagsFixer::class, NoEmptyPhpdocFixer::class, @@ -44,7 +46,7 @@ public static function configure(?callable $customize = null): ECSConfigBuilder return $config; } - /** Keeps ECS invalidation within the executing checkout's temporary cache; native overrides still run last. */ + /** Keeps each checkout and owned policy revision in its own temporary cache; native overrides still run last. */ private static function cacheDirectory(): string { $directory = getcwd(); @@ -52,6 +54,22 @@ private static function cacheDirectory(): string throw new RuntimeException('Cannot determine the checkout directory for the ECS cache.'); } - return sys_get_temp_dir() . '/fast-forward-code-standards/' . hash('sha256', $directory); + return sys_get_temp_dir() . '/fast-forward-code-standards/' . hash('sha256', $directory) . '/' . self::policyFingerprint(); + } + + /** Includes both source bytes and installation paths so an unchanged consumer cannot retain an older owned rule result. */ + private static function policyFingerprint(): string + { + $sources = []; + foreach ([__FILE__, dirname(__DIR__) . '/Sniff/MultilineBooleanConditionSniff.php'] as $path) { + $hash = hash_file('sha256', $path); + if (false === $hash) { + throw new RuntimeException('Cannot fingerprint formatter policy source: ' . $path); + } + + $sources[$path] = $hash; + } + + return hash('sha256', serialize($sources)); } } diff --git a/src/Git/FileSelection.php b/src/Git/FileSelection.php index 5dd24c1..9d44c71 100644 --- a/src/Git/FileSelection.php +++ b/src/Git/FileSelection.php @@ -44,7 +44,8 @@ public function select(string $directory, ?string $base, ?string $head, ?string ], $directory)); foreach ($allChanges as $path) { // Case-folding hosts can load mixed-case policy names; an extra full scan elsewhere is conservative. - if ((null !== $config && $directory . '/' . $path === $config) + if ((null !== $config + && $directory . '/' . $path === $config) || 1 === preg_match('~(^|/)(composer\.(json|lock)|ecs[^/]*\.php|\.editorconfig|\.gitignore)$|^src/Config/|^config/~i', $path)) { return $this->withoutIgnored($directory, $this->paths($this->process->capture(['git', 'ls-files', '-z'], $directory))); } diff --git a/src/Sniff/MultilineBooleanConditionSniff.php b/src/Sniff/MultilineBooleanConditionSniff.php new file mode 100644 index 0000000..897da2b --- /dev/null +++ b/src/Sniff/MultilineBooleanConditionSniff.php @@ -0,0 +1,124 @@ + The while token covers both while and do-while conditions. */ + public function register(): array + { + return [T_IF, T_ELSEIF, T_WHILE]; + } + + /** Checks only an existing multiline condition; application code is never evaluated. */ + public function process(File $phpcsFile, int $stackPtr): void + { + $tokens = $phpcsFile->getTokens(); + $range = $this->conditionRange($phpcsFile, $tokens, $stackPtr); + if (null === $range) { + return; + } + + [$open, $close] = $range; + $indent = $this->baseIndent($tokens, $stackPtr); + $outerDepth = count($tokens[$open]['nested_parenthesis'] ?? []); + + for ($index = $open + 1; $index < $close; ++$index) { + if (in_array($tokens[$index]['code'], [T_FUNCTION, T_CLOSURE, T_FN, T_ANON_CLASS], true)) { + $end = $this->declarationEnd($phpcsFile, $tokens, $index, $close); + if (null === $end) { + return; + } + + $index = $end; + continue; + } + + if (in_array($tokens[$index]['code'], [T_BOOLEAN_OR, T_BOOLEAN_AND], true)) { + $depth = max(1, count($tokens[$index]['nested_parenthesis'] ?? []) - $outerDepth); + $this->splitInlineOperator($phpcsFile, $tokens, $index, $open, $indent, $depth); + } + } + } + + /** + * @param array> $tokens Native PHPCS token records. + * @return array{int, int}|null Validated parentheses, or no eligible condition. + */ + private function conditionRange(File $file, array $tokens, int $index): ?array + { + if (!isset($tokens[$index]['parenthesis_opener'], $tokens[$index]['parenthesis_closer'])) { + $file->addError('Cannot determine the native condition boundary.', $index, 'MissingConditionBoundary'); + + return null; + } + + $open = $tokens[$index]['parenthesis_opener']; + $close = $tokens[$index]['parenthesis_closer']; + + return $tokens[$open]['line'] === $tokens[$close]['line'] ? null : [$open, $close]; + } + + /** + * @param array> $tokens Native scopes, including arrow bodies without braces. + * @return int|null The declaration's inclusive end; an unknown boundary is a visible failing diagnostic. + */ + private function declarationEnd(File $file, array $tokens, int $index, int $conditionEnd): ?int + { + $end = $tokens[$index]['scope_closer'] ?? null; + if (null === $end || $end <= $index || $end > $conditionEnd) { + $file->addError('Cannot determine the native function/class expression boundary.', $index, 'MissingExpressionBoundary'); + + return null; + } + + return $end; + } + + /** + * @param array> $tokens Original token positions remain stable within the fix changeset. + * @param string $indent Existing statement indentation, retained before native continuation spacing. + * @param int $depth Native parenthesis nesting relative to the condition opener. + */ + private function splitInlineOperator(File $file, array $tokens, int $index, int $open, string $indent, int $depth): void + { + $previous = $file->findPrevious(T_WHITESPACE, $index - 1, $open, true); + if (false === $previous || $tokens[$previous]['line'] < $tokens[$index]['line']) { + return; + } + + if (!$file->addFixableError('Each symbolic boolean part in a multiline condition must start on its own line.', $index, 'InlineBooleanPart')) { + return; + } + + $whitespace = $this->spacing->getLineEnding() . $indent . str_repeat($this->spacing->getIndent(), $depth); + $file->fixer->beginChangeset(); + if (T_WHITESPACE === $tokens[$index - 1]['code']) { + $file->fixer->replaceToken($index - 1, $whitespace); + } else { + $file->fixer->addContentBefore($index, $whitespace); + } + $file->fixer->endChangeset(); + } + + /** @param array> $tokens Statement-line whitespace, including literal tabs. */ + private function baseIndent(array $tokens, int $index): string + { + $start = $index; + while ($start > 0 && $tokens[$start - 1]['line'] === $tokens[$index]['line']) { + --$start; + } + + return T_WHITESPACE === $tokens[$start]['code'] ? rtrim($tokens[$start]['content'], "\r\n") : ''; + } +} diff --git a/tests/Config/PresetTest.php b/tests/Config/PresetTest.php index a7babb2..aab6bc3 100644 --- a/tests/Config/PresetTest.php +++ b/tests/Config/PresetTest.php @@ -4,8 +4,11 @@ namespace FastForward\CodeStandards\Tests\Config; +use FastForward\CodeStandards\Sniff\MultilineBooleanConditionSniff; +use PHP_CodeSniffer\Sniffs\Sniff; use PhpCsFixer\Fixer\FixerInterface; use PHPUnit\Framework\TestCase; +use Symplify\EasyCodingStandard\Config\ECSConfig; use Symplify\EasyCodingStandard\DependencyInjection\ServiceContainerFactory; /** Captures the actual compiled preset, including upstream configuration and risky-rule classification. */ @@ -24,10 +27,28 @@ public function testCompiledPresetMatchesReviewedRules(): void $container = (new ServiceContainerFactory())->create([dirname(__DIR__, 2) . '/config/ecs.php']); $container->boot(); $configuration = $container->getCheckerConfiguration(); - $rules = []; + $rules = $this->fixerRules($container, $configuration, $packageRoot) + $this->sniffRules($container, $configuration); + ksort($rules); + $expected = json_decode(file_get_contents(__DIR__ . '/../Fixtures/preset-rules.json'), true, flags: JSON_THROW_ON_ERROR); + self::assertSame($expected, [ + 'ecs_version' => $package['version'], + 'ecs_reference' => $package['source']['reference'], + 'rules' => $rules, + ]); + self::assertCount(count($container->getCheckerClasses()), $rules, 'Every active checker must appear in this snapshot.'); + } + + /** + * @param array $configuration Effective native options after skip/boot passes. + * @return array> Native risky classification remains mandatory for every fixer. + */ + private function fixerRules(ECSConfig $container, array $configuration, string $packageRoot): array + { + $rules = []; foreach ($container->findByContract(FixerInterface::class) as $fixer) { $rules[$fixer->getName()] = [ + 'type' => 'fixer', 'class' => $fixer::class, 'configuration' => $configuration[$fixer::class], 'risky' => $fixer->isRisky(), @@ -36,13 +57,26 @@ public function testCompiledPresetMatchesReviewedRules(): void self::assertStringStartsWith($packageRoot . '/', (new \ReflectionClass($fixer))->getFileName()); } - ksort($rules); - $expected = json_decode(file_get_contents(__DIR__ . '/../Fixtures/preset-rules.json'), true, flags: JSON_THROW_ON_ERROR); - self::assertSame($expected, [ - 'ecs_version' => $package['version'], - 'ecs_reference' => $package['source']['reference'], - 'rules' => $rules, - ]); - self::assertCount(count($container->getCheckerClasses()), $rules, 'Every active checker must appear in this snapshot.'); + return $rules; + } + + /** + * @param array $configuration Effective options, including the absence of owned scalar options. + * @return array> PHPCS has no native isRisky method; only this behaviorally tested class is allowed. + */ + private function sniffRules(ECSConfig $container, array $configuration): array + { + $sniffs = array_values($container->findByContract(Sniff::class)); + self::assertSame([MultilineBooleanConditionSniff::class], array_map(static fn(Sniff $sniff): string => $sniff::class, $sniffs), 'Unknown default Sniffs require an explicit policy review.'); + self::assertSame(realpath(dirname(__DIR__, 2) . '/src/Sniff/MultilineBooleanConditionSniff.php'), (new \ReflectionClass($sniffs[0]))->getFileName()); + + return [MultilineBooleanConditionSniff::class => [ + 'type' => 'sniff', + 'class' => MultilineBooleanConditionSniff::class, + 'configuration' => $configuration[MultilineBooleanConditionSniff::class], + 'risky' => null, + 'risk_policy' => 'owned-whitespace-only', + 'source' => 'src/Sniff/MultilineBooleanConditionSniff.php', + ]]; } } diff --git a/tests/Fixtures/multiline-condition-expected.php.inc b/tests/Fixtures/multiline-condition-expected.php.inc new file mode 100644 index 0000000..932c6f1 --- /dev/null +++ b/tests/Fixtures/multiline-condition-expected.php.inc @@ -0,0 +1,19 @@ +directory = realpath(sys_get_temp_dir()) . '/standards-condition-' . bin2hex(random_bytes(8)); + (new Filesystem())->mkdir($this->directory . '/.build/tmp'); + file_put_contents($this->directory . '/.gitignore', "/.build/\n"); + $this->git(['init', '-q']); + } + + /** Removes only the fixture exclusively allocated by this test. */ + protected function tearDown(): void + { + (new Filesystem())->remove($this->directory); + } + + /** The exact mixed-line guard reported in PR55 must fail check without writing and fix deterministically. */ + public function testDefaultCheckRejectsMixedBooleanLayout(): void + { + $source = file_get_contents(__DIR__ . '/../Fixtures/multiline-condition-input.php.inc'); + file_put_contents($this->directory . '/source.php', $source); + $this->git(['add', 'source.php']); + + $check = $this->cli(['check', '--all']); + + self::assertSame(2, $check->getExitCode(), $check->getOutput() . $check->getErrorOutput()); + self::assertSame($source, file_get_contents($this->directory . '/source.php')); + self::assertSame(0, $this->cli(['fix', '--all'])->getExitCode()); + $fixed = file_get_contents($this->directory . '/source.php'); + self::assertSame(file_get_contents(__DIR__ . '/../Fixtures/multiline-condition-expected.php.inc'), $fixed); + self::assertSame(0, $this->cli(['check', '--all'])->getExitCode()); + self::assertSame(0, $this->cli(['fix', '--all'])->getExitCode()); + self::assertSame($fixed, file_get_contents($this->directory . '/source.php')); + } + + /** @return iterable}> Owned source-only fixtures and deliberate scope exclusions. */ + public static function conditions(): iterable + { + yield 'if' => ["if (\$a || \$b\n || \$c\n) { return; }", true, []]; + yield 'elseif' => ["if (\$a) { return; } elseif (\$a || \$b\n || \$c\n) { return; }", true, []]; + yield 'while' => ["while (\$a || \$b\n || \$c\n) { break; }", true, []]; + yield 'do-while' => ["do { break; } while (\$a || \$b\n || \$c\n);", true, []]; + yield 'mixed grouping' => ["if (\$a || (\$b && \$c)\n || \$d\n) { return; }", true, []]; + yield 'multiline call cannot mask inline parts' => ["if (check(\n \$first,\n \$second,\n \$third,\n \$fourth,\n) || \$a || \$b || \$c\n || \$d\n) { return; }", true, []]; + yield 'call argument belongs to the condition' => ["if (check(\$a || \$b)\n || \$outer\n) { return; }", true, []]; + yield 'array index belongs to the condition' => ["if (\$values[\$a || \$b]\n || \$outer\n) { return; }", true, []]; + yield 'arrow expression is opaque' => ["if ((fn() => (\$a || \$b) && \$inner)() || \$c\n || \$d\n) { return; }", true, ['fn() => ($a || $b) && $inner']]; + yield 'nested arrow expressions are opaque' => ["if ((fn() => fn() => \$a || \$b)() || \$c\n || \$d\n) { return; }", true, ['fn() => fn() => $a || $b']]; + yield 'closure body is opaque' => ["if ((function () use (\$a, \$b) { return \$a || \$b; })() || \$c\n || \$d\n) { return; }", true, ['return $a || $b;']]; + yield 'anonymous class body is opaque' => ["if (new class { public function value() { return \$this->a || \$this->b; } } || \$outer\n || \$last\n) { return; }", true, ['return $this->a || $this->b;']]; + yield 'descriptions comments and strings' => ["/** Useful description and {@inheritDoc}. */\nif (check('|| && text') /* keep exactly */ || \$b // keep line note\n || \$c\n) { return; }", true, ['/** Useful description and {@inheritDoc}. */', '/* keep exactly */', '// keep line note', "'|| && text'"]]; + yield 'single-line condition remains single-line' => ['if ($a || $b && $c) { return; }', false, ['if ($a || $b && $c)']]; + yield 'arrow-only condition contains no eligible outer operator' => ["if (\n fn() => \$a || \$b\n) { return; }", false, ['fn() => $a || $b']]; + yield 'logical keywords are not this policy' => ["if (\$a or \$b\n or \$c\n) { return; }", false, []]; + yield 'non-condition expression remains native policy' => ["\$result = \$a || \$b\n || \$c;", false, []]; + } + + /** + * Proves the new policy rather than unrelated PER differences; baseline uses an explicit native skip. + * + * @param list $preserved Exact information or opaque body content that the new policy must retain. + */ + #[DataProvider('conditions')] + public function testConditionScopesPreserveTokensAndReachStableOutput(string $body, bool $mustChange, array $preserved): void + { + $before = $this->normalizedSource("cli(['check', '--all']); + + self::assertSame($mustChange ? 2 : 0, $check->getExitCode(), $check->getOutput() . $check->getErrorOutput()); + self::assertSame($before, file_get_contents($this->directory . '/source.php')); + self::assertSame(0, $this->cli(['fix', '--all'])->getExitCode()); + $after = file_get_contents($this->directory . '/source.php'); + self::assertSame($mustChange, $before !== $after); + self::assertSame($this->significantTokens($before), $this->significantTokens($after)); + foreach ($preserved as $text) { + self::assertStringContainsString($text, $after); + } + + self::assertSame(0, $this->cli(['check', '--all'])->getExitCode()); + self::assertSame(0, $this->cli(['fix', '--all'])->getExitCode()); + self::assertSame($after, file_get_contents($this->directory . '/source.php')); + } + + /** @return iterable All native ECS indentation/line-ending combinations. */ + public static function spacing(): iterable + { + foreach (['two spaces' => ' ', 'four spaces' => ' ', 'tabs' => "\t"] as $name => $indent) { + yield $name . ' LF' => [$indent, "\n"]; + yield $name . ' CRLF' => [$indent, "\r\n"]; + } + } + + /** The injected native spacing value must reflect the consumer callback, not a separate Sniff default. */ + #[DataProvider('spacing')] + public function testNativeSpacingOverrideRemainsAuthoritative(string $indent, string $lineEnding): void + { + $source = file_get_contents(__DIR__ . '/../Fixtures/multiline-condition-input.php.inc'); + $source = $this->withSpacing($source, $indent, $lineEnding); + file_put_contents($this->directory . '/source.php', $source); + $this->git(['add', 'source.php']); + $this->configure('$config->withSpacing(indentation: ' . var_export($indent, true) . ', lineEnding: ' . var_export($lineEnding, true) . ');'); + $arguments = ['--all', '--config=.build/ecs.php']; + $check = $this->cli(['check', ...$arguments]); + + self::assertSame(2, $check->getExitCode(), $check->getOutput() . $check->getErrorOutput()); + self::assertSame($source, file_get_contents($this->directory . '/source.php')); + self::assertSame(0, $this->cli(['fix', ...$arguments])->getExitCode()); + $fixed = file_get_contents($this->directory . '/source.php'); + $golden = file_get_contents(__DIR__ . '/../Fixtures/multiline-condition-expected.php.inc'); + self::assertSame($this->withSpacing($golden, $indent, $lineEnding), $fixed); + self::assertSame(0, $this->cli(['check', ...$arguments])->getExitCode()); + self::assertSame(0, $this->cli(['fix', ...$arguments])->getExitCode()); + self::assertSame($fixed, file_get_contents($this->directory . '/source.php')); + } + + /** Consumer rule skips remain native, explicit and last in precedence. */ + public function testNativeSkipCanDisableTheOwnedPolicy(): void + { + $source = file_get_contents(__DIR__ . '/../Fixtures/multiline-condition-input.php.inc'); + file_put_contents($this->directory . '/source.php', $source); + $this->git(['add', 'source.php']); + $this->configure('$config->withSkip([FastForward\\CodeStandards\\Sniff\\MultilineBooleanConditionSniff::class]);'); + foreach (['check', 'fix', 'check'] as $mode) { + $result = $this->cli([$mode, '--all', '--config=.build/ecs.php']); + self::assertSame(0, $result->getExitCode(), $result->getOutput() . $result->getErrorOutput()); + self::assertSame($source, file_get_contents($this->directory . '/source.php')); + } + } + + /** Existing continuation offsets remain native policy; this Sniff only separates inline operators. */ + public function testExistingContinuationOffsetIsNotSilentlyRealigned(): void + { + $source = file_get_contents(__DIR__ . '/../Fixtures/multiline-condition-input.php.inc'); + file_put_contents($this->directory . '/source.php', $source); + $this->git(['add', 'source.php']); + $spacing = '$config->withSpacing(indentation: " ");'; + $arguments = ['--all', '--config=.build/ecs.php']; + $this->configure($spacing . '$config->withSkip([FastForward\\CodeStandards\\Sniff\\MultilineBooleanConditionSniff::class]);'); + self::assertSame(0, $this->cli(['fix', ...$arguments])->getExitCode()); + $native = file_get_contents($this->directory . '/source.php'); + self::assertStringContainsString("\n || !is_readable(\$vendorDirectory", $native); + + $this->configure($spacing); + self::assertSame(2, $this->cli(['check', ...$arguments])->getExitCode()); + self::assertSame($native, file_get_contents($this->directory . '/source.php')); + self::assertSame(0, $this->cli(['fix', ...$arguments])->getExitCode()); + $fixed = file_get_contents($this->directory . '/source.php'); + self::assertStringContainsString("\n || !is_readable(\$vendorDirectory", $fixed); + self::assertSame($this->significantTokens($native), $this->significantTokens($fixed)); + self::assertSame(0, $this->cli(['check', ...$arguments])->getExitCode()); + self::assertSame(0, $this->cli(['fix', ...$arguments])->getExitCode()); + self::assertSame($fixed, file_get_contents($this->directory . '/source.php')); + } + + /** Source changes in an installed owned checker invalidate unchanged consumer files without clearing other caches. */ + public function testOwnedRuleRevisionInvalidatesDefaultCache(): void + { + $root = dirname(__DIR__, 2); + $copy = $this->directory . '/.build/tool'; + $filesystem = new Filesystem(); + $filesystem->mkdir([$copy . '/src/Config', $copy . '/src/Sniff', $copy . '/vendor/symplify', $copy . '/vendor/bin']); + copy($root . '/src/Config/ECSConfig.php', $copy . '/src/Config/ECSConfig.php'); + copy($root . '/src/Sniff/MultilineBooleanConditionSniff.php', $copy . '/src/Sniff/MultilineBooleanConditionSniff.php'); + symlink(realpath($root . '/vendor/symplify/easy-coding-standard'), $copy . '/vendor/symplify/easy-coding-standard'); + file_put_contents($copy . '/vendor/autoload.php', 'addPsr4("FastForward\\\\CodeStandards\\\\", ' . var_export($copy . '/src', true) . '); $loader->register(true);'); + file_put_contents($copy . '/vendor/bin/code-standards', 'directory . '/source.php', $source); + $this->git(['add', 'source.php']); + $command = [PHP_BINARY, $copy . '/vendor/bin/code-standards', 'check', '--all']; + $first = $this->process($command); + self::assertSame(0, $first->getExitCode(), $first->getOutput() . $first->getErrorOutput()); + + // Replace only the disposable installation's checker, retaining its class/options and the consumer source. + file_put_contents($copy . '/src/Sniff/MultilineBooleanConditionSniff.php', 'addError("owned policy revision", $index, "SyntheticRevision"); } }'); + $second = $this->process($command); + self::assertSame(2, $second->getExitCode(), $second->getOutput() . $second->getErrorOutput()); + self::assertStringContainsString('owned policy revision', $second->getOutput()); + self::assertSame($source, file_get_contents($this->directory . '/source.php')); + } + + /** Normalizes only native upstream style first so later differences belong to the owned rule. */ + private function normalizedSource(string $source): string + { + file_put_contents($this->directory . '/source.php', $source); + $this->git(['add', 'source.php']); + $this->configure('$config->withSkip([FastForward\\CodeStandards\\Sniff\\MultilineBooleanConditionSniff::class]);'); + foreach (['fix', 'check'] as $mode) { + $result = $this->cli([$mode, '--all', '--config=.build/ecs.php']); + self::assertSame(0, $result->getExitCode(), $result->getOutput() . $result->getErrorOutput()); + } + + return file_get_contents($this->directory . '/source.php'); + } + + /** @return list Token content/kind equality excludes only whitespace and line-number metadata. */ + private function significantTokens(string $source): array + { + $tokens = array_filter(token_get_all($source, TOKEN_PARSE), static fn($token): bool => !is_array($token) || T_WHITESPACE !== $token[0]); + + return array_values(array_map(static fn($token) => is_array($token) ? [$token[0], $token[1]] : $token, $tokens)); + } + + /** Expresses each existing fixture indentation level using the consumer's native spacing. */ + private function withSpacing(string $source, string $indent, string $lineEnding): string + { + $source = preg_replace_callback('/^(?: {4})+/m', static fn(array $match): string => str_repeat($indent, intdiv(strlen($match[0]), 4)), $source); + + return str_replace("\n", $lineEnding, $source); + } + + /** Writes explicitly selected trusted consumer configuration under the ignored fixture build directory. */ + private function configure(string $body): void + { + file_put_contents($this->directory . '/.build/ecs.php', ' $arguments Explicit user-visible CLI arguments. */ + private function cli(array $arguments): Process + { + return $this->process([PHP_BINARY, dirname(__DIR__, 2) . '/bin/code-standards', ...$arguments]); + } + + /** @param list $command Executable and separate arguments for this exclusive fixture. */ + private function process(array $command): Process + { + $process = new Process($command, $this->directory, ['TMPDIR' => $this->directory . '/.build/tmp'], timeout: 60); + $process->run(); + + return $process; + } + + /** @param list $arguments Git operations scoped exclusively to this synthetic repository. */ + private function git(array $arguments): void + { + (new Process(['git', ...$arguments], $this->directory, timeout: 30))->mustRun(); + } +} diff --git a/tests/ReleaseChannel/ReleaseChannelFixture.php b/tests/ReleaseChannel/ReleaseChannelFixture.php index d33bc5c..41e4001 100644 --- a/tests/ReleaseChannel/ReleaseChannelFixture.php +++ b/tests/ReleaseChannel/ReleaseChannelFixture.php @@ -166,10 +166,11 @@ private function respond(string $method, string $url, array $options): MockRespo $current = $this->git(['show-ref', '--verify', '--hash', $change['name']], true); if (null !== $this->mutationErrors) { $data = ['errors' => $this->mutationErrors]; - } elseif (null !== $current && null === $this->git( - ['merge-base', '--is-ancestor', $current, $change['afterOid']], - true, - )) { + } elseif (null !== $current + && null === $this->git( + ['merge-base', '--is-ancestor', $current, $change['afterOid']], + true, + )) { $data = ['errors' => [['message' => 'Synthetic non-fast-forward rejection']]]; } elseif (null === $this->git( ['update-ref', $change['name'], $change['afterOid'], $change['beforeOid']], From 4cbdfe1e91b95944fa1025f838276f799a0501e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 13:43:02 -0300 Subject: [PATCH 2/4] style: apply multiline policy to integrated workflow guards --- .github/OptionalAnalysis/AnalysisResult.php | 13 +++++++++---- .github/OptionalAnalysis/BoundedProcess.php | 6 ++++-- .../OptionalAnalysis/NativeReportParser.php | 18 +++++++++++++----- .github/OptionalAnalysis/WorkflowInput.php | 6 ++++-- bin/ecs-worker | 3 ++- 5 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.github/OptionalAnalysis/AnalysisResult.php b/.github/OptionalAnalysis/AnalysisResult.php index 8d91034..68fbf27 100644 --- a/.github/OptionalAnalysis/AnalysisResult.php +++ b/.github/OptionalAnalysis/AnalysisResult.php @@ -29,11 +29,16 @@ public function __construct( public string $details = '', public ?int $nativeExit = null, ) { - if (!in_array($tool, ['Magic Numbers', 'Rector'], true) + if ( + !in_array($tool, ['Magic Numbers', 'Rector'], true) || !in_array($status, ['disabled', 'not-executed', 'clean', 'findings', 'operational-error'], true) - || (null !== $selectedCount && $selectedCount < 0) - || (null !== $findingCount && $findingCount < 0) - || (null !== $nativeExit && $nativeExit < 0)) { + || (null !== $selectedCount + && $selectedCount < 0) + || (null !== $findingCount + && $findingCount < 0) + || (null !== $nativeExit + && $nativeExit < 0) + ) { throw new InvalidArgumentException('Invalid optional-analysis result.'); } } diff --git a/.github/OptionalAnalysis/BoundedProcess.php b/.github/OptionalAnalysis/BoundedProcess.php index d07e71f..6e44b46 100644 --- a/.github/OptionalAnalysis/BoundedProcess.php +++ b/.github/OptionalAnalysis/BoundedProcess.php @@ -96,11 +96,13 @@ private function assertPrerequisites(float $timeout): void if (!is_finite($timeout) || $timeout <= 0 || $timeout > self::TIMEOUT_SECONDS) { throw new InvalidArgumentException('Optional analyzer timeout must be positive, finite and at most 300 seconds.'); } - if ('Linux' !== PHP_OS_FAMILY + if ( + 'Linux' !== PHP_OS_FAMILY || !is_executable('/usr/bin/setsid') || !function_exists('posix_getpgid') || !function_exists('posix_getsid') - || !function_exists('posix_kill')) { + || !function_exists('posix_kill') + ) { throw new RuntimeException('Optional workflow execution requires hosted Linux, setsid and PHP POSIX process-group support.'); } } diff --git a/.github/OptionalAnalysis/NativeReportParser.php b/.github/OptionalAnalysis/NativeReportParser.php index 6ecd1b8..010f820 100644 --- a/.github/OptionalAnalysis/NativeReportParser.php +++ b/.github/OptionalAnalysis/NativeReportParser.php @@ -135,8 +135,11 @@ private function children(DOMElement $parent, string $name): array continue; } - if (XML_COMMENT_NODE === $child->nodeType - || (XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent))) { + if ( + XML_COMMENT_NODE === $child->nodeType + || (XML_TEXT_NODE === $child->nodeType + && '' === trim($child->textContent)) + ) { continue; } @@ -187,10 +190,15 @@ private function rectorErrors(array $errors): array { $diagnostics = []; foreach ($errors as $error) { - if (!is_array($error) - || (isset($error['file']) && !is_string($error['file'])) + if ( + !is_array($error) + || (isset($error['file']) + && !is_string($error['file'])) || !is_string($error['message'] ?? null) - || (isset($error['line']) && (!is_int($error['line']) || $error['line'] < 1))) { + || (isset($error['line']) + && (!is_int($error['line']) + || $error['line'] < 1)) + ) { throw new InvalidArgumentException('Rector JSON error location is invalid.'); } $diagnostics[] = ['file' => $error['file'] ?? '[unavailable]', 'line' => $error['line'] ?? null, 'message' => $error['message']]; diff --git a/.github/OptionalAnalysis/WorkflowInput.php b/.github/OptionalAnalysis/WorkflowInput.php index 5f633c9..9729fc3 100644 --- a/.github/OptionalAnalysis/WorkflowInput.php +++ b/.github/OptionalAnalysis/WorkflowInput.php @@ -20,9 +20,11 @@ public function __construct( public string $rectorPolicy, public string $rectorConfig, ) { - if (!str_starts_with($directory, '/') + if ( + !str_starts_with($directory, '/') || 1 !== preg_match('/\A[a-fA-F0-9]{40}\z/D', $base) - || 1 !== preg_match('/\A[a-fA-F0-9]{40}\z/D', $head)) { + || 1 !== preg_match('/\A[a-fA-F0-9]{40}\z/D', $head) + ) { throw new InvalidArgumentException('Optional analyses require an absolute consumer directory and exact base/head SHAs.'); } foreach ([$magicNumbersPolicy, $rectorPolicy] as $policy) { diff --git a/bin/ecs-worker b/bin/ecs-worker index f7d3e96..8735cab 100644 --- a/bin/ecs-worker +++ b/bin/ecs-worker @@ -16,7 +16,8 @@ try { $argc < $separator + 2 || '--' !== $argv[$separator] || !in_array($argv[4], ['--check', '--fix'], true) - || ($json && '--check' !== $argv[4]) + || ($json + && '--check' !== $argv[4]) ) { throw new InvalidArgumentException('ECS worker requires installation paths, an explicit mode and selected files.'); } From f2614f1f6d2813c6546eede2e30df90076f18904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 13:47:00 -0300 Subject: [PATCH 3/4] style: align Twig evaluation guard with multiline policy --- tests/Evaluation/Twig/evaluate.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Evaluation/Twig/evaluate.php b/tests/Evaluation/Twig/evaluate.php index 09a5b1c..10b9e1e 100644 --- a/tests/Evaluation/Twig/evaluate.php +++ b/tests/Evaluation/Twig/evaluate.php @@ -77,8 +77,12 @@ function evaluate(string $autoload, string $directory, string $config, string $m $autoload = realpath($autoload); $repository = realpath(dirname(__DIR__, 3)); $temporary = realpath(getenv('TWIG_EVALUATION_TEMP') ?: sys_get_temp_dir()); - if (false === $temporary || !is_dir($temporary) || str_starts_with($temporary . '/', $repository . '/') - || str_starts_with($autoload, $repository . '/')) { + if ( + false === $temporary + || !is_dir($temporary) + || str_starts_with($temporary . '/', $repository . '/') + || str_starts_with($autoload, $repository . '/') + ) { throw new InvalidArgumentException('Evaluation runtime and temporary fixtures must be provisioned outside the checkout.'); } require $autoload; From 4bad533e7ccb03f875c9a3a49c4c698497ca7616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 28 Oct 2026 14:02:54 -0300 Subject: [PATCH 4/4] docs: summarize multiline condition helpers --- src/Sniff/MultilineBooleanConditionSniff.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Sniff/MultilineBooleanConditionSniff.php b/src/Sniff/MultilineBooleanConditionSniff.php index 897da2b..f2cba58 100644 --- a/src/Sniff/MultilineBooleanConditionSniff.php +++ b/src/Sniff/MultilineBooleanConditionSniff.php @@ -52,6 +52,8 @@ public function process(File $phpcsFile, int $stackPtr): void } /** + * Finds the bounds of an existing multiline condition. + * * @param array> $tokens Native PHPCS token records. * @return array{int, int}|null Validated parentheses, or no eligible condition. */ @@ -70,6 +72,8 @@ private function conditionRange(File $file, array $tokens, int $index): ?array } /** + * Locates the end of an opaque function or class expression. + * * @param array> $tokens Native scopes, including arrow bodies without braces. * @return int|null The declaration's inclusive end; an unknown boundary is a visible failing diagnostic. */ @@ -86,6 +90,8 @@ private function declarationEnd(File $file, array $tokens, int $index, int $cond } /** + * Moves an inline boolean operator to its own continuation line. + * * @param array> $tokens Original token positions remain stable within the fix changeset. * @param string $indent Existing statement indentation, retained before native continuation spacing. * @param int $depth Native parenthesis nesting relative to the condition opener. @@ -111,7 +117,11 @@ private function splitInlineOperator(File $file, array $tokens, int $index, int $file->fixer->endChangeset(); } - /** @param array> $tokens Statement-line whitespace, including literal tabs. */ + /** + * Reads the statement's existing leading indentation. + * + * @param array> $tokens Statement-line whitespace, including literal tabs. + */ private function baseIndent(array $tokens, int $index): string { $start = $index;