From 26a42359fd94e160d2cebe3dff064397ec4a484a Mon Sep 17 00:00:00 2001 From: dnnsjsk Date: Tue, 19 May 2026 07:51:45 +0200 Subject: [PATCH] Raise PHPStan to level 10 Tighten array shapes, generics, and narrowing helpers across src/ so the strictest level passes without baselines, ignores, or @var overrides. Adds phpstan/phpstan-phpunit so assertIsArray-style narrowing flows into reflection-based test helpers (invokeArrayMethod, invokeStringMethod, invokeInstance) used to type private-method probes. Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.json | 1 + composer.lock | 58 ++++++- phpstan.neon.dist | 5 +- src/Ast/AstMatchCodec.php | 2 +- src/Ast/AstSearcher.php | 11 +- src/Ast/PatternMatcher.php | 2 +- src/Cli/IndexApplication.php | 4 + src/FeatureMatrix/FeatureMatrixGenerator.php | 52 +++--- src/Index/AstCacheStore.php | 25 ++- src/Index/AstFactExtractor.php | 8 +- src/Index/AstFactQuery.php | 154 ++++++++++++------ src/Index/AstIndexStore.php | 25 ++- src/Index/IndexLifecycle.php | 3 +- src/Index/IndexSetLoader.php | 26 ++- src/Index/TextIndexStore.php | 79 ++++++++- src/Parallel/ResultCollector.php | 18 +- src/Parallel/Worker.php | 3 +- src/Parallel/WorkerPool.php | 21 ++- src/Support/CommandRunner.php | 37 +++-- src/Text/TextResultCodec.php | 28 +++- tests/Integration/CliAstRewriteTest.php | 3 + tests/Integration/CliTextOutputTest.php | 14 +- tests/Oracle/ActualCapture.php | 4 +- tests/Oracle/ComplianceReporter.php | 84 ++++++---- tests/Oracle/FlagParser.php | 49 +++--- tests/Oracle/OracleCapture.php | 13 +- tests/Oracle/OutputNormalizer.php | 62 +++++-- tests/Oracle/Scenario.php | 79 +++++++-- tests/Oracle/ScenarioComparator.php | 17 +- tests/Oracle/ScenarioRunner.php | 4 +- tests/Unit/Ast/AstCoverageTest.php | 9 +- tests/Unit/Benchmarks/BenchmarkRunnerTest.php | 35 ++-- tests/Unit/Cli/AstGrepApplicationTest.php | 18 +- tests/Unit/Cli/IndexApplicationTest.php | 32 ++-- tests/Unit/Cli/RipgrepApplicationTest.php | 18 +- .../FeatureMatrixGeneratorTest.php | 48 +++--- tests/Unit/Index/AstCacheBuilderTest.php | 1 + tests/Unit/Index/AstCacheStoreTest.php | 13 +- tests/Unit/Index/AstIndexBuilderTest.php | 1 + tests/Unit/Index/CachedAstSearcherTest.php | 16 +- tests/Unit/Index/IndexedAstSearcherTest.php | 18 +- tests/Unit/Index/IndexedTextSearcherTest.php | 20 ++- tests/Unit/Index/QueryCacheStoreTest.php | 19 ++- tests/Unit/Index/TextIndexBuilderTest.php | 2 + tests/Unit/Parallel/ResultCollectorTest.php | 4 +- tests/Unit/Parallel/WorkerTest.php | 2 +- tests/Unit/Text/TextSearcherTest.php | 61 +++++-- tests/Unit/Walker/FileWalkerTest.php | 4 +- tests/Unit/Walker/GitignoreFilterTest.php | 35 +++- 49 files changed, 911 insertions(+), 336 deletions(-) diff --git a/composer.json b/composer.json index 34eed8c..93a372e 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ }, "require-dev": { "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^13.1", "squizlabs/php_codesniffer": "^3.10" }, diff --git a/composer.lock b/composer.lock index 6459712..4fc8f81 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d39c9d74b696af7979bba1454789b11c", + "content-hash": "0d684a86d746f577e46410b3374fdfca", "packages": [ { "name": "nikic/php-parser", @@ -297,6 +297,62 @@ ], "time": "2026-04-01T09:25:14+00:00" }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.16", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.32" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + }, + "time": "2026-02-14T09:05:21+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "14.0.0", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 1773ccd..a9073da 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,8 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + parameters: - level: 8 + level: 10 treatPhpDocTypesAsCertain: false paths: - src diff --git a/src/Ast/AstMatchCodec.php b/src/Ast/AstMatchCodec.php index 12fb1ee..8d95555 100644 --- a/src/Ast/AstMatchCodec.php +++ b/src/Ast/AstMatchCodec.php @@ -103,7 +103,7 @@ private function encodeCaptureArray(array $captures): array } /** - * @param array $captures + * @param array $captures * @return array */ private function decodeCaptureArray(array $captures): array diff --git a/src/Ast/AstSearcher.php b/src/Ast/AstSearcher.php index 5931592..50a038c 100644 --- a/src/Ast/AstSearcher.php +++ b/src/Ast/AstSearcher.php @@ -133,7 +133,7 @@ public function compilePattern(string $pattern, AstSearchOptions $options): Patt /** * @param list $statements - * @param callable(): string|string|null $source + * @param (callable(): string)|string|null $source * @return list */ public function searchParsedStatements( @@ -153,9 +153,12 @@ public function searchParsedStatements( } if ($loadedSource === null) { - $loadedSource = is_callable($source) - ? (string) $source() - : (string) ($source ?? ''); + if (is_callable($source)) { + $invoked = $source(); + $loadedSource = is_string($invoked) ? $invoked : ''; + } else { + $loadedSource = $source ?? ''; + } } $matches[] = $this->createMatch($candidate, $captures, $loadedSource, $file); diff --git a/src/Ast/PatternMatcher.php b/src/Ast/PatternMatcher.php index f9bda62..20ad814 100644 --- a/src/Ast/PatternMatcher.php +++ b/src/Ast/PatternMatcher.php @@ -170,7 +170,7 @@ private function fingerprintNode(Node $node): string } /** - * @param array $subNodeValue + * @param array $subNodeValue * @return list */ private function serializeSubNodeArray(array $subNodeValue): array diff --git a/src/Cli/IndexApplication.php b/src/Cli/IndexApplication.php index 36231aa..b3bcf8b 100644 --- a/src/Cli/IndexApplication.php +++ b/src/Cli/IndexApplication.php @@ -2274,6 +2274,10 @@ private function directorySize(string $path): int ); foreach ($iterator as $entry) { + if (!$entry instanceof \SplFileInfo) { + continue; + } + $entrySize = $entry->getSize(); $size += is_int($entrySize) ? $entrySize : 0; } diff --git a/src/FeatureMatrix/FeatureMatrixGenerator.php b/src/FeatureMatrix/FeatureMatrixGenerator.php index fa48358..c584e2c 100644 --- a/src/FeatureMatrix/FeatureMatrixGenerator.php +++ b/src/FeatureMatrix/FeatureMatrixGenerator.php @@ -9,6 +9,31 @@ use Greph\Support\ProcessResult; use Greph\Support\ToolResolver; +/** + * @phpstan-type FeatureMatrixResult array{ + * status: string, + * command: ?list, + * exit_code: ?int, + * stdout: string, + * stderr: string, + * note: string + * } + * @phpstan-type FeatureMatrixRow array{ + * feature: string, + * notes: string, + * results: array + * } + * @phpstan-type FeatureMatrixSection array{ + * title: string, + * providers: list, + * rows: list + * } + * @phpstan-type FeatureMatrixReport array{ + * generated_at: string, + * root_path: string, + * sections: list + * } + */ final class FeatureMatrixGenerator { private const STATUS_PASS = 'Pass'; @@ -29,26 +54,7 @@ public function __construct( } /** - * @return array{ - * generated_at: string, - * root_path: string, - * sections: list, - * rows: list, - * exit_code: ?int, - * stdout: string, - * stderr: string, - * note: string - * }> - * }> - * }> - * } + * @return FeatureMatrixReport */ public function generate(): array { @@ -1509,7 +1515,7 @@ private function normalizeOutput(string $output, string $workspace): string } /** - * @param array $report + * @param FeatureMatrixReport $report */ public function renderMarkdown(array $report): string { @@ -1574,7 +1580,7 @@ private function escapeInline(string $value): string } /** - * @return array + * @return FeatureMatrixReport */ public function write(string $markdownPath, string $jsonPath): array { @@ -1888,7 +1894,7 @@ private static function expectGrephTextJson(ProcessResult $result): ?string } foreach ($decoded as $entry) { - if (($entry['file'] ?? null) === 'single.txt') { + if (is_array($entry) && ($entry['file'] ?? null) === 'single.txt') { return null; } } diff --git a/src/Index/AstCacheStore.php b/src/Index/AstCacheStore.php index 123ffa3..8adf2bb 100644 --- a/src/Index/AstCacheStore.php +++ b/src/Index/AstCacheStore.php @@ -105,13 +105,17 @@ public function load(string $indexPath): AstCache * cached: bool * }> $facts */ + $buildDuration = $metadata['buildDurationMs'] ?? 0.0; + $buildDurationMs = is_int($buildDuration) || is_float($buildDuration) ? (float) $buildDuration : 0.0; + $stringKeyedMetadata = self::stringKeyed($metadata); + return new AstCache( rootPath: $metadata['rootPath'], indexPath: Filesystem::normalizePath($indexPath), version: $metadata['version'], builtAt: $metadata['builtAt'], - buildDurationMs: (float) ($metadata['buildDurationMs'] ?? 0.0), - lifecycle: IndexLifecycle::fromMetadata($metadata), + buildDurationMs: $buildDurationMs, + lifecycle: IndexLifecycle::fromMetadata($stringKeyedMetadata), nextFileId: $metadata['nextFileId'], files: $files, facts: $facts, @@ -295,4 +299,21 @@ private function writeAtomic(string $path, mixed $payload): void throw new \RuntimeException(sprintf('Failed to finalize AST cache file: %s', $path)); } } + + /** + * @param array $array + * @return array + */ + private static function stringKeyed(array $array): array + { + $result = []; + + foreach ($array as $key => $value) { + if (is_string($key)) { + $result[$key] = $value; + } + } + + return $result; + } } diff --git a/src/Index/AstFactExtractor.php b/src/Index/AstFactExtractor.php index b90b7fd..5336847 100644 --- a/src/Index/AstFactExtractor.php +++ b/src/Index/AstFactExtractor.php @@ -278,7 +278,13 @@ private function isObjectOperator(mixed $token): bool private function tokenId(mixed $token): ?int { - return is_array($token) ? $token[0] : null; + if (!is_array($token)) { + return null; + } + + $id = $token[0] ?? null; + + return is_int($id) ? $id : null; } private function blocksFunctionCallClassification(mixed $previousToken): bool diff --git a/src/Index/AstFactQuery.php b/src/Index/AstFactQuery.php index 16a6399..c650ddf 100644 --- a/src/Index/AstFactQuery.php +++ b/src/Index/AstFactQuery.php @@ -11,20 +11,23 @@ use PhpParser\Node\Name; use PhpParser\Node\Stmt; +/** + * @phpstan-type FactSet array{ + * zero_arg_new: bool, + * long_array: bool, + * function_calls: list, + * method_calls: list, + * static_calls: list, + * new_targets: list, + * classes: list, + * interfaces: list, + * traits: list + * } + */ final class AstFactQuery { /** - * @param array, - * method_calls: list, - * static_calls: list, - * new_targets: list, - * classes: list, - * interfaces: list, - * traits: list - * }> $factsByFileId + * @param array $factsByFileId * @return array|null */ public function candidateIds(array $factsByFileId, Pattern $pattern): ?array @@ -47,74 +50,42 @@ public function candidateIds(array $factsByFileId, Pattern $pattern): ?array } /** - * @return (callable(array{ - * zero_arg_new: bool, - * long_array: bool, - * function_calls: list, - * method_calls: list, - * static_calls: list, - * new_targets: list, - * classes: list, - * interfaces: list, - * traits: list - * }): bool)|null + * @return (callable(FactSet): bool)|null */ public function predicate(Node $root): ?callable { if ($root instanceof Expr\Array_ && $this->isLongArraySyntax($root)) { - return static fn (array $facts): bool => $facts['long_array']; + return self::longArrayPredicate(); } if ($root instanceof Expr\New_ && $root->args === []) { $targetName = $root->class instanceof Name ? strtolower($root->class->toString()) : null; - return static function (array $facts) use ($targetName): bool { - if (!$facts['zero_arg_new']) { - return false; - } - - if ($targetName === null) { - return true; - } - - return in_array($targetName, $facts['new_targets'], true); - }; + return self::zeroArgNewPredicate($targetName); } if ($root instanceof Expr\FuncCall && $root->name instanceof Name) { - $name = strtolower($root->name->toString()); - - return static fn (array $facts): bool => in_array($name, $facts['function_calls'], true); + return self::functionCallPredicate(strtolower($root->name->toString())); } if (($root instanceof Expr\MethodCall || $root instanceof Expr\NullsafeMethodCall) && $root->name instanceof Identifier) { - $name = strtolower($root->name->toString()); - - return static fn (array $facts): bool => in_array($name, $facts['method_calls'], true); + return self::methodCallPredicate(strtolower($root->name->toString())); } if ($root instanceof Expr\StaticCall && $root->name instanceof Identifier) { - $name = strtolower($root->name->toString()); - - return static fn (array $facts): bool => in_array($name, $facts['static_calls'], true); + return self::staticCallPredicate(strtolower($root->name->toString())); } if ($root instanceof Stmt\Class_ && $root->name instanceof Identifier) { - $name = strtolower($root->name->toString()); - - return static fn (array $facts): bool => in_array($name, $facts['classes'], true); + return self::classPredicate(strtolower($root->name->toString())); } if ($root instanceof Stmt\Interface_ && $root->name instanceof Identifier) { - $name = strtolower($root->name->toString()); - - return static fn (array $facts): bool => in_array($name, $facts['interfaces'], true); + return self::interfacePredicate(strtolower($root->name->toString())); } if ($root instanceof Stmt\Trait_ && $root->name instanceof Identifier) { - $name = strtolower($root->name->toString()); - - return static fn (array $facts): bool => in_array($name, $facts['traits'], true); + return self::traitPredicate(strtolower($root->name->toString())); } return null; @@ -130,4 +101,83 @@ private function isLongArraySyntax(Expr\Array_ $node): bool return property_exists($node, 'kind') && $node->kind === Expr\Array_::KIND_LONG; } + + /** @return callable(FactSet): bool */ + private static function longArrayPredicate(): callable + { + return self::wrapPredicate(static fn (array $facts): bool => (bool) ($facts['long_array'] ?? false)); + } + + /** @return callable(FactSet): bool */ + private static function zeroArgNewPredicate(?string $targetName): callable + { + return self::wrapPredicate(static function (array $facts) use ($targetName): bool { + if (!($facts['zero_arg_new'] ?? false)) { + return false; + } + + if ($targetName === null) { + return true; + } + + $newTargets = $facts['new_targets'] ?? []; + + return is_array($newTargets) && in_array($targetName, $newTargets, true); + }); + } + + /** @return callable(FactSet): bool */ + private static function functionCallPredicate(string $name): callable + { + return self::haystackPredicate($name, 'function_calls'); + } + + /** @return callable(FactSet): bool */ + private static function methodCallPredicate(string $name): callable + { + return self::haystackPredicate($name, 'method_calls'); + } + + /** @return callable(FactSet): bool */ + private static function staticCallPredicate(string $name): callable + { + return self::haystackPredicate($name, 'static_calls'); + } + + /** @return callable(FactSet): bool */ + private static function classPredicate(string $name): callable + { + return self::haystackPredicate($name, 'classes'); + } + + /** @return callable(FactSet): bool */ + private static function interfacePredicate(string $name): callable + { + return self::haystackPredicate($name, 'interfaces'); + } + + /** @return callable(FactSet): bool */ + private static function traitPredicate(string $name): callable + { + return self::haystackPredicate($name, 'traits'); + } + + /** @return callable(FactSet): bool */ + private static function haystackPredicate(string $needle, string $factKey): callable + { + return self::wrapPredicate(static function (array $facts) use ($needle, $factKey): bool { + $haystack = $facts[$factKey] ?? []; + + return is_array($haystack) && in_array($needle, $haystack, true); + }); + } + + /** + * @param callable(array): bool $predicate + * @return callable(FactSet): bool + */ + private static function wrapPredicate(callable $predicate): callable + { + return static fn (array $facts): bool => $predicate($facts); + } } diff --git a/src/Index/AstIndexStore.php b/src/Index/AstIndexStore.php index 1a39cbb..12f4f9b 100644 --- a/src/Index/AstIndexStore.php +++ b/src/Index/AstIndexStore.php @@ -100,13 +100,17 @@ public function load(string $indexPath): AstIndex * traits: list * }> $facts */ + $buildDuration = $metadata['buildDurationMs'] ?? 0.0; + $buildDurationMs = is_int($buildDuration) || is_float($buildDuration) ? (float) $buildDuration : 0.0; + $stringKeyedMetadata = self::stringKeyed($metadata); + return new AstIndex( rootPath: $metadata['rootPath'], indexPath: Filesystem::normalizePath($indexPath), version: $metadata['version'], builtAt: $metadata['builtAt'], - buildDurationMs: (float) ($metadata['buildDurationMs'] ?? 0.0), - lifecycle: IndexLifecycle::fromMetadata($metadata), + buildDurationMs: $buildDurationMs, + lifecycle: IndexLifecycle::fromMetadata($stringKeyedMetadata), nextFileId: $metadata['nextFileId'], files: $files, facts: $facts, @@ -179,4 +183,21 @@ private function writeAtomic(string $path, mixed $payload): void throw new \RuntimeException(sprintf('Failed to finalize AST index file: %s', $path)); } } + + /** + * @param array $array + * @return array + */ + private static function stringKeyed(array $array): array + { + $result = []; + + foreach ($array as $key => $value) { + if (is_string($key)) { + $result[$key] = $value; + } + } + + return $result; + } } diff --git a/src/Index/IndexLifecycle.php b/src/Index/IndexLifecycle.php index bba7938..daad7b6 100644 --- a/src/Index/IndexLifecycle.php +++ b/src/Index/IndexLifecycle.php @@ -22,7 +22,8 @@ public function __construct( */ public static function fromMetadata(array $metadata): self { - $profile = IndexLifecycleProfile::tryFrom((string) ($metadata['lifecycle'] ?? '')) + $lifecycleValue = $metadata['lifecycle'] ?? ''; + $profile = IndexLifecycleProfile::tryFrom(is_string($lifecycleValue) ? $lifecycleValue : '') ?? IndexLifecycleProfile::ManualRefresh; $maxChangedFiles = is_int($metadata['maxChangedFiles'] ?? null) ? $metadata['maxChangedFiles'] diff --git a/src/Index/IndexSetLoader.php b/src/Index/IndexSetLoader.php index 27101ec..df80acb 100644 --- a/src/Index/IndexSetLoader.php +++ b/src/Index/IndexSetLoader.php @@ -78,24 +78,32 @@ public function load(?string $manifestPath = null): IndexSet throw new \RuntimeException(sprintf('Index set manifest entry "%s" is missing a valid root: %s', $entryName, $resolvedManifestPath)); } - $modeObject = IndexMode::tryFrom((string) $mode); + $modeString = is_string($mode) ? $mode : ''; + $modeObject = IndexMode::tryFrom($modeString); if ($modeObject === null) { - throw new \RuntimeException(sprintf('Index set manifest entry "%s" has unknown mode "%s": %s', $entryName, (string) $mode, $resolvedManifestPath)); + throw new \RuntimeException(sprintf('Index set manifest entry "%s" has unknown mode "%s": %s', $entryName, $modeString, $resolvedManifestPath)); } $rootPath = $this->resolvePath($basePath, $root); $indexPath = isset($entryPayload['index_dir']) && is_string($entryPayload['index_dir']) && $entryPayload['index_dir'] !== '' ? $this->resolvePath($basePath, $entryPayload['index_dir']) : $this->defaultIndexPath($modeObject, $rootPath); - $profile = isset($entryPayload['lifecycle']) && is_string($entryPayload['lifecycle']) - ? IndexLifecycleProfile::tryFrom($entryPayload['lifecycle']) + $lifecycleValue = $entryPayload['lifecycle'] ?? null; + $profile = is_string($lifecycleValue) + ? IndexLifecycleProfile::tryFrom($lifecycleValue) : null; - if (isset($entryPayload['lifecycle']) && $profile === null) { - throw new \RuntimeException(sprintf('Index set manifest entry "%s" has unknown lifecycle "%s": %s', $entryName, (string) $entryPayload['lifecycle'], $resolvedManifestPath)); + if ($lifecycleValue !== null && $profile === null) { + $lifecycleString = is_string($lifecycleValue) ? $lifecycleValue : ''; + + throw new \RuntimeException(sprintf('Index set manifest entry "%s" has unknown lifecycle "%s": %s', $entryName, $lifecycleString, $resolvedManifestPath)); } + $maxChangedFiles = $entryPayload['max_changed_files'] ?? IndexLifecycle::DEFAULT_MAX_CHANGED_FILES; + $maxChangedBytes = $entryPayload['max_changed_bytes'] ?? IndexLifecycle::DEFAULT_MAX_CHANGED_BYTES; + $priority = $entryPayload['priority'] ?? 0; + $entries[] = new IndexSetEntry( name: $entryName, rootPath: $rootPath, @@ -103,10 +111,10 @@ public function load(?string $manifestPath = null): IndexSet mode: $modeObject, lifecycle: new IndexLifecycle( profile: $profile ?? IndexLifecycleProfile::ManualRefresh, - maxChangedFiles: max(0, (int) ($entryPayload['max_changed_files'] ?? IndexLifecycle::DEFAULT_MAX_CHANGED_FILES)), - maxChangedBytes: max(0, (int) ($entryPayload['max_changed_bytes'] ?? IndexLifecycle::DEFAULT_MAX_CHANGED_BYTES)), + maxChangedFiles: max(0, is_int($maxChangedFiles) ? $maxChangedFiles : (int) (is_scalar($maxChangedFiles) ? $maxChangedFiles : 0)), + maxChangedBytes: max(0, is_int($maxChangedBytes) ? $maxChangedBytes : (int) (is_scalar($maxChangedBytes) ? $maxChangedBytes : 0)), ), - priority: (int) ($entryPayload['priority'] ?? 0), + priority: is_int($priority) ? $priority : (int) (is_scalar($priority) ? $priority : 0), enabled: !array_key_exists('enabled', $entryPayload) || (bool) $entryPayload['enabled'], ); $seenNames[$entryName] = true; diff --git a/src/Index/TextIndexStore.php b/src/Index/TextIndexStore.php index 979057b..33c99a6 100644 --- a/src/Index/TextIndexStore.php +++ b/src/Index/TextIndexStore.php @@ -82,8 +82,8 @@ public function load(string $indexPath, bool $includeForward = false, bool $incl if (is_file($forwardPath)) { $forward = $this->decodeFile($forwardPath); - } else { - $forward = $this->forwardFromLegacyFiles($files); + } elseif (is_array($files)) { + $forward = $this->forwardFromLegacyFiles(self::filesAsLegacyShape($files)); } if (is_file($wordForwardPath)) { @@ -111,19 +111,25 @@ public function load(string $indexPath, bool $includeForward = false, bool $incl /** @var list, o: int}> $files */ /** @var array> $postings */ + $buildDuration = $metadata['buildDurationMs'] ?? 0.0; + $buildDurationMs = is_int($buildDuration) || is_float($buildDuration) ? (float) $buildDuration : 0.0; + $stringKeyedMetadata = self::stringKeyed($metadata); + $forwardTyped = self::intKeyedListOfStrings($forward); + $wordForwardTyped = self::intKeyedListOfStrings($wordForward); + return new TextIndex( rootPath: $metadata['rootPath'], indexPath: Filesystem::normalizePath($indexPath), version: $metadata['version'], builtAt: $metadata['builtAt'], - buildDurationMs: (float) ($metadata['buildDurationMs'] ?? 0.0), - lifecycle: IndexLifecycle::fromMetadata($metadata), + buildDurationMs: $buildDurationMs, + lifecycle: IndexLifecycle::fromMetadata($stringKeyedMetadata), nextFileId: $metadata['nextFileId'], files: $files, postings: $postings, - forward: $forward, + forward: $forwardTyped, wordPostings: $wordPostings, - wordForward: $wordForward, + wordForward: $wordForwardTyped, ); } @@ -511,4 +517,65 @@ private function finalizePostingsDirectory(string $temporaryDirectory, string $p throw new \RuntimeException(sprintf('Failed to finalize index postings: %s', $indexPath)); } } + + /** + * @param array $array + * @return array + */ + private static function stringKeyed(array $array): array + { + $result = []; + + foreach ($array as $key => $value) { + if (is_string($key)) { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * @param array $array + * @return array> + */ + private static function intKeyedListOfStrings(array $array): array + { + $result = []; + + foreach ($array as $key => $value) { + if (!is_int($key) || !is_array($value)) { + continue; + } + + $items = []; + + foreach ($value as $item) { + if (is_string($item)) { + $items[] = $item; + } + } + + $result[$key] = $items; + } + + return $result; + } + + /** + * @param array $files + * @return list> + */ + private static function filesAsLegacyShape(array $files): array + { + $result = []; + + foreach ($files as $file) { + if (is_array($file)) { + $result[] = self::stringKeyed($file); + } + } + + return $result; + } } diff --git a/src/Parallel/ResultCollector.php b/src/Parallel/ResultCollector.php index 23b0176..c4e11ee 100644 --- a/src/Parallel/ResultCollector.php +++ b/src/Parallel/ResultCollector.php @@ -7,7 +7,7 @@ final class ResultCollector { /** - * @param list $workers + * @param list $workers * @param (callable(mixed): mixed)|null $resultDecoder * @return list */ @@ -23,7 +23,7 @@ public function collect(array $workers, ?callable $resultDecoder = null): array } /** - * @param array{pid: int, socket: mixed, tempPath?: string} $worker + * @param array{pid: int, socket: resource, tempPath?: string} $worker * @param (callable(mixed): mixed)|null $resultDecoder */ public function collectWorker(array $worker, bool $waitForExit = true, ?callable $resultDecoder = null): mixed @@ -54,12 +54,18 @@ public function collectWorker(array $worker, bool $waitForExit = true, ?callable throw new \RuntimeException(sprintf('Worker %d returned invalid output.', $worker['pid'])); } - if (isset($payload['error'], $payload['message'])) { + $error = $payload['error'] ?? null; + $message = $payload['message'] ?? null; + + if (is_string($error) && is_string($message)) { + $workerLabel = $payload['worker'] ?? '?'; + $workerLabel = is_scalar($workerLabel) ? (string) $workerLabel : '?'; + throw new \RuntimeException(sprintf( 'Worker %s failed with %s: %s', - (string) ($payload['worker'] ?? '?'), - $payload['error'], - $payload['message'], + $workerLabel, + $error, + $message, )); } diff --git a/src/Parallel/Worker.php b/src/Parallel/Worker.php index 6e060ab..525c470 100644 --- a/src/Parallel/Worker.php +++ b/src/Parallel/Worker.php @@ -23,9 +23,10 @@ public function __construct( /** * @param callable(FileList): mixed $task + * @param resource $socket * @param (callable(mixed): mixed)|null $resultEncoder */ - public function run(callable $task, mixed $socket, ?callable $resultEncoder = null): never + public function run(callable $task, $socket, ?callable $resultEncoder = null): never { $payload = []; $exitCode = 0; diff --git a/src/Parallel/WorkerPool.php b/src/Parallel/WorkerPool.php index e2ff48d..c1e9cfa 100644 --- a/src/Parallel/WorkerPool.php +++ b/src/Parallel/WorkerPool.php @@ -25,7 +25,7 @@ final class WorkerPool /** @var \Closure(): (string|false) */ private \Closure $tempFileFactory; - /** @var \Closure(string, string): mixed */ + /** @var \Closure(string, string): (resource|false) */ private \Closure $fileOpener; /** @@ -34,7 +34,7 @@ final class WorkerPool * @param \Closure(int, FileList): Worker|null $workerFactory * @param \Closure(int): int|null $wait * @param (\Closure(): (string|false))|null $tempFileFactory - * @param \Closure(string, string): mixed|null $fileOpener + * @param (\Closure(string, string): (resource|false))|null $fileOpener */ public function __construct( ?ResultCollector $resultCollector = null, @@ -63,17 +63,16 @@ public function __construct( /** @var \Closure(): (string|false) $resolvedTempFileFactory */ $resolvedTempFileFactory = $tempFileFactory ?? static fn (): string|false => tempnam(sys_get_temp_dir(), 'greph-worker-'); $this->tempFileFactory = $resolvedTempFileFactory; - /** @var \Closure(string, string): mixed $resolvedFileOpener */ - $resolvedFileOpener = $fileOpener ?? static fn (string $path, string $mode): mixed => fopen($path, $mode); - $this->fileOpener = $resolvedFileOpener; + $this->fileOpener = $fileOpener ?? static fn (string $path, string $mode) => fopen($path, $mode); } /** + * @template TResult * @param list $chunks - * @param callable(FileList): mixed $task - * @param (callable(mixed): mixed)|null $resultEncoder - * @param (callable(mixed): mixed)|null $resultDecoder - * @return list + * @param callable(FileList): TResult $task + * @param (callable(TResult): mixed)|null $resultEncoder + * @param (callable(mixed): TResult)|null $resultDecoder + * @return list */ public function map( array $chunks, @@ -150,7 +149,7 @@ public function map( /** * @param callable(FileList): mixed $task * @param (callable(mixed): mixed)|null $resultEncoder - * @return array{pid: int, socket: mixed} + * @return array{pid: int, socket: resource} */ private function startSocketWorker(int $index, FileList $chunk, callable $task, ?callable $resultEncoder = null): array { @@ -179,7 +178,7 @@ private function startSocketWorker(int $index, FileList $chunk, callable $task, /** * @param callable(FileList): mixed $task * @param (callable(mixed): mixed)|null $resultEncoder - * @return array{pid: int, socket: mixed, index: int, tempPath: string} + * @return array{pid: int, socket: resource, index: int, tempPath: string} */ private function startFileWorker(int $index, FileList $chunk, callable $task, ?callable $resultEncoder = null): array { diff --git a/src/Support/CommandRunner.php b/src/Support/CommandRunner.php index f7521ee..2fe0d23 100644 --- a/src/Support/CommandRunner.php +++ b/src/Support/CommandRunner.php @@ -14,17 +14,32 @@ final class CommandRunner */ public function __construct(?\Closure $processStarter = null) { - /** @var \Closure(list, array, array, ?string, array): (resource|false) $resolvedProcessStarter */ - $resolvedProcessStarter = $processStarter ?? static function ( - array $command, - array $descriptors, - array &$pipes, - ?string $workingDirectory, - array $processEnvironment, - ) { - return proc_open(array_values($command), $descriptors, $pipes, $workingDirectory, $processEnvironment); - }; - $this->processStarter = $resolvedProcessStarter; + $this->processStarter = $processStarter ?? self::defaultProcessStarter(); + } + + /** + * @return \Closure(list, array, array, ?string, array): (resource|false) + */ + private static function defaultProcessStarter(): \Closure + { + return self::runProcess(...); + } + + /** + * @param list $command + * @param array $descriptors + * @param array $pipes + * @param array $processEnvironment + * @return resource|false + */ + private static function runProcess( + array $command, + array $descriptors, + array &$pipes, + ?string $workingDirectory, + array $processEnvironment, + ) { + return proc_open($command, $descriptors, $pipes, $workingDirectory, $processEnvironment); } public static function processDisabled(): self diff --git a/src/Text/TextResultCodec.php b/src/Text/TextResultCodec.php index 336fb1e..f20fc08 100644 --- a/src/Text/TextResultCodec.php +++ b/src/Text/TextResultCodec.php @@ -75,19 +75,41 @@ public function decode(mixed $payload): array $file = $entry['f']; $matches = []; + $matchEntries = $entry['m'] ?? []; - foreach ($entry['m'] ?? [] as $matchEntry) { - if (!is_array($matchEntry) || !isset($matchEntry['l'], $matchEntry['c'], $matchEntry['t'])) { + if (!is_array($matchEntries)) { + throw new \RuntimeException('Worker returned invalid text match payload.'); + } + + foreach ($matchEntries as $matchEntry) { + if ( + !is_array($matchEntry) + || !isset($matchEntry['l'], $matchEntry['c'], $matchEntry['t']) + || !is_int($matchEntry['l']) + || !is_int($matchEntry['c']) + || !is_string($matchEntry['t']) + ) { throw new \RuntimeException('Worker returned invalid text match payload.'); } + $captureSource = $matchEntry['p'] ?? null; + $captures = []; + + if (is_array($captureSource)) { + foreach ($captureSource as $captureKey => $captureValue) { + if (is_string($captureValue)) { + $captures[$captureKey] = $captureValue; + } + } + } + $matches[] = new TextMatch( file: $file, line: $matchEntry['l'], column: $matchEntry['c'], content: $matchEntry['t'], matchedText: is_string($matchEntry['m'] ?? null) ? $matchEntry['m'] : '', - captures: is_array($matchEntry['p'] ?? null) ? $matchEntry['p'] : [], + captures: $captures, beforeContext: $this->decodeContext($matchEntry['b'] ?? []), afterContext: $this->decodeContext($matchEntry['a'] ?? []), ); diff --git a/tests/Integration/CliAstRewriteTest.php b/tests/Integration/CliAstRewriteTest.php index 9e7c3a7..17681e7 100644 --- a/tests/Integration/CliAstRewriteTest.php +++ b/tests/Integration/CliAstRewriteTest.php @@ -64,10 +64,13 @@ public function itEmitsStructuredJsonForAstMatches(): void $this->assertSame(0, $result['exit']); $payload = json_decode($result['stdout'], true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($payload); $this->assertCount(2, $payload); + self::assertIsArray($payload[0]); $this->assertSame('src/Search.php', $payload[0]['file']); $this->assertSame(3, $payload[0]['start_line']); $this->assertSame(3, $payload[0]['end_line']); + self::assertIsString($payload[0]['code']); $this->assertSame("dispatch(\$event)", trim($payload[0]['code'])); } diff --git a/tests/Integration/CliTextOutputTest.php b/tests/Integration/CliTextOutputTest.php index 0593102..1d65b0e 100644 --- a/tests/Integration/CliTextOutputTest.php +++ b/tests/Integration/CliTextOutputTest.php @@ -70,18 +70,24 @@ public function itEmitsStructuredJsonForTextMatches(): void $this->assertSame(0, $result['exit']); $payload = json_decode($result['stdout'], true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($payload); $this->assertCount(3, $payload); $payloadByFile = []; foreach ($payload as $entry) { + self::assertIsArray($entry); + self::assertIsString($entry['file']); $payloadByFile[$entry['file']] = $entry; } $this->assertEqualsCanonicalizing(['single.txt', 'src/app.php', 'src/readme.txt'], array_keys($payloadByFile)); - $this->assertCount(1, $payloadByFile['single.txt']['matches']); - $this->assertSame(2, $payloadByFile['single.txt']['matches'][0]['line']); - $this->assertSame(1, $payloadByFile['single.txt']['matches'][0]['column']); - $this->assertSame('needle', $payloadByFile['single.txt']['matches'][0]['matched_text']); + $singleMatches = $payloadByFile['single.txt']['matches']; + self::assertIsArray($singleMatches); + $this->assertCount(1, $singleMatches); + self::assertIsArray($singleMatches[0]); + $this->assertSame(2, $singleMatches[0]['line']); + $this->assertSame(1, $singleMatches[0]['column']); + $this->assertSame('needle', $singleMatches[0]['matched_text']); $this->assertSame([], $payloadByFile['src/app.php']['matches']); $this->assertSame([], $payloadByFile['src/readme.txt']['matches']); } diff --git a/tests/Oracle/ActualCapture.php b/tests/Oracle/ActualCapture.php index 373cdb8..dfac088 100644 --- a/tests/Oracle/ActualCapture.php +++ b/tests/Oracle/ActualCapture.php @@ -43,8 +43,8 @@ public function captureFromWorkspace(Scenario $scenario, string $workspaceRoot): $path = $scenario->actualDir() . '/' . $name; if (is_array($output)) { - Json::encodeFile($path, $output); - } else { + Json::encodeFile($path, array_values($output)); + } elseif (is_scalar($output) || $output === null) { file_put_contents($path, (string) $output); } } diff --git a/tests/Oracle/ComplianceReporter.php b/tests/Oracle/ComplianceReporter.php index 45e76e2..491c13f 100644 --- a/tests/Oracle/ComplianceReporter.php +++ b/tests/Oracle/ComplianceReporter.php @@ -7,6 +7,26 @@ use Greph\Support\Json; use Greph\Support\ToolResolver; +/** + * @phpstan-type ComplianceCategorySummary array{total: int, passing: int, failing: int} + * @phpstan-type ComplianceScenarioEntry array{ + * name: string, + * category: string, + * mode: string, + * has_oracle: bool, + * has_actual: bool, + * has_report: bool, + * pass: ?bool, + * failures: list + * } + * @phpstan-type ComplianceReport array{ + * generated_at: string, + * tools: array{grep: bool, rg: bool, sg: bool}, + * summary: array{total: int, passing: int, failing: int, missing_reports: int}, + * categories: array, + * scenarios: list + * } + */ final class ComplianceReporter { private ScenarioRepository $scenarioRepository; @@ -20,50 +40,48 @@ public function __construct(string $rootPath, ?ToolResolver $toolResolver = null } /** - * @return array + * @return ComplianceReport */ public function build(): array { $scenarios = $this->scenarioRepository->all(); - $report = [ - 'generated_at' => date(DATE_ATOM), - 'tools' => [ - 'grep' => true, - 'rg' => true, - 'sg' => $this->toolResolver->hasAstGrep(), - ], - 'summary' => [ - 'total' => count($scenarios), - 'passing' => 0, - 'failing' => 0, - 'missing_reports' => 0, - ], - 'categories' => [], - 'scenarios' => [], + $categories = []; + $scenarioEntries = []; + $summary = [ + 'total' => count($scenarios), + 'passing' => 0, + 'failing' => 0, + 'missing_reports' => 0, ]; foreach ($scenarios as $scenario) { $comparisonPath = $scenario->reportPath(); $comparison = is_file($comparisonPath) ? Json::decodeFile($comparisonPath) : null; - $pass = (bool) ($comparison['expectation']['pass'] ?? false); + $expectation = is_array($comparison) && isset($comparison['expectation']) && is_array($comparison['expectation']) + ? $comparison['expectation'] + : null; + $pass = $expectation !== null && ($expectation['pass'] ?? false) === true; + $failures = $expectation !== null && isset($expectation['failures']) && is_array($expectation['failures']) + ? array_values(array_filter($expectation['failures'], 'is_string')) + : []; if ($comparison === null) { - $report['summary']['missing_reports']++; + $summary['missing_reports']++; } elseif ($pass) { - $report['summary']['passing']++; + $summary['passing']++; } else { - $report['summary']['failing']++; + $summary['failing']++; } $category = $scenario->category(); - $report['categories'][$category] ??= ['total' => 0, 'passing' => 0, 'failing' => 0]; - $report['categories'][$category]['total']++; + $categories[$category] ??= ['total' => 0, 'passing' => 0, 'failing' => 0]; + $categories[$category]['total']++; if ($comparison !== null) { - $report['categories'][$category][$pass ? 'passing' : 'failing']++; + $categories[$category][$pass ? 'passing' : 'failing']++; } - $report['scenarios'][] = [ + $scenarioEntries[] = [ 'name' => $scenario->name, 'category' => $category, 'mode' => $scenario->mode(), @@ -71,17 +89,27 @@ public function build(): array 'has_actual' => $this->hasFiles($scenario->actualDir(), $scenario->expectedActualFiles()), 'has_report' => $comparison !== null, 'pass' => $comparison === null ? null : $pass, - 'failures' => $comparison['expectation']['failures'] ?? [], + 'failures' => $failures, ]; } - ksort($report['categories']); + ksort($categories); - return $report; + return [ + 'generated_at' => date(DATE_ATOM), + 'tools' => [ + 'grep' => true, + 'rg' => true, + 'sg' => $this->toolResolver->hasAstGrep(), + ], + 'summary' => $summary, + 'categories' => $categories, + 'scenarios' => $scenarioEntries, + ]; } /** - * @param array $report + * @param ComplianceReport $report */ public function renderText(array $report): string { diff --git a/tests/Oracle/FlagParser.php b/tests/Oracle/FlagParser.php index 3361ad7..9f8ff64 100644 --- a/tests/Oracle/FlagParser.php +++ b/tests/Oracle/FlagParser.php @@ -8,6 +8,31 @@ use Greph\Text\TextSearchOptions; use Greph\Walker\FileTypeFilter; +/** + * @phpstan-type FlagSet array{ + * fixedString: bool, + * caseInsensitive: bool, + * wholeWord: bool, + * invertMatch: bool, + * countOnly: bool, + * filesWithMatches: bool, + * filesWithoutMatches: bool, + * json: bool, + * noIgnore: bool, + * hidden: bool, + * glob: list, + * dryRun: bool, + * interactive: bool, + * jobs: int, + * maxCount: ?int, + * beforeContext: int, + * afterContext: int, + * context: ?int, + * type: list, + * typeNot: list, + * lang: string + * } + */ final class FlagParser { /** @var array> */ @@ -23,29 +48,7 @@ final class FlagParser ]; /** - * @return array{ - * fixedString: bool, - * caseInsensitive: bool, - * wholeWord: bool, - * invertMatch: bool, - * countOnly: bool, - * filesWithMatches: bool, - * filesWithoutMatches: bool, - * json: bool, - * noIgnore: bool, - * hidden: bool, - * glob: list, - * dryRun: bool, - * interactive: bool, - * jobs: int, - * maxCount: ?int, - * beforeContext: int, - * afterContext: int, - * context: ?int, - * type: list, - * typeNot: list, - * lang: string - * } + * @return FlagSet */ public function parse(Scenario $scenario): array { diff --git a/tests/Oracle/OracleCapture.php b/tests/Oracle/OracleCapture.php index 8feb4a8..3259895 100644 --- a/tests/Oracle/OracleCapture.php +++ b/tests/Oracle/OracleCapture.php @@ -9,6 +9,9 @@ use Greph\Support\Json; use Greph\Support\ToolResolver; +/** + * @phpstan-import-type FlagSet from FlagParser + */ final class OracleCapture { private CommandRunner $commandRunner; @@ -55,8 +58,8 @@ public function captureFromWorkspace(Scenario $scenario, string $workspaceRoot): $path = $scenario->oracleDir() . '/' . $name; if (is_array($output)) { - Json::encodeFile($path, $output); - } else { + Json::encodeFile($path, array_values($output)); + } elseif (is_scalar($output) || $output === null) { file_put_contents($path, (string) $output); } } @@ -72,7 +75,7 @@ public function captureFromWorkspace(Scenario $scenario, string $workspaceRoot): } /** - * @param array $flags + * @param FlagSet $flags * @return array */ private function captureTextOracle(Scenario $scenario, string $workspaceRoot, array $flags): array @@ -228,7 +231,7 @@ private function captureTextOracle(Scenario $scenario, string $workspaceRoot, ar } /** - * @param array $flags + * @param FlagSet $flags * @return array */ private function captureAstOracle(Scenario $scenario, string $workspaceRoot, array $flags): array @@ -275,7 +278,7 @@ private function captureAstOracle(Scenario $scenario, string $workspaceRoot, arr } /** - * @param array $flags + * @param FlagSet $flags * @return array */ private function captureRewriteOracle(Scenario $scenario, string $workspaceRoot, array $flags): array diff --git a/tests/Oracle/OutputNormalizer.php b/tests/Oracle/OutputNormalizer.php index 880c742..3487a36 100644 --- a/tests/Oracle/OutputNormalizer.php +++ b/tests/Oracle/OutputNormalizer.php @@ -143,13 +143,13 @@ public function parseRipgrepJson(string $raw): array continue; } - /** @var array $data */ - $data = (array) ($payload['data'] ?? []); - $file = (string) (($data['path']['text'] ?? '') ?: ($data['path']['bytes'] ?? '')); - $lineNumber = (int) ($data['line_number'] ?? 0); - $content = rtrim((string) ($data['lines']['text'] ?? ''), "\r\n"); - /** @var list> $submatches */ - $submatches = array_values(array_filter((array) ($data['submatches'] ?? []), 'is_array')); + $data = is_array($payload['data'] ?? null) ? $payload['data'] : []; + $pathField = is_array($data['path'] ?? null) ? $data['path'] : []; + $linesField = is_array($data['lines'] ?? null) ? $data['lines'] : []; + $file = self::asString($pathField['text'] ?? null) ?: self::asString($pathField['bytes'] ?? null); + $lineNumber = self::asInt($data['line_number'] ?? null); + $content = rtrim(self::asString($linesField['text'] ?? null), "\r\n"); + $submatches = array_values(array_filter(is_array($data['submatches'] ?? null) ? $data['submatches'] : [], 'is_array')); if ($submatches === []) { $matches[] = [ @@ -164,12 +164,13 @@ public function parseRipgrepJson(string $raw): array } foreach ($submatches as $submatch) { + $matchField = is_array($submatch['match'] ?? null) ? $submatch['match'] : []; $matches[] = [ 'file' => $file, 'line' => $lineNumber, - 'column' => ((int) ($submatch['start'] ?? 0)) + 1, + 'column' => self::asInt($submatch['start'] ?? null) + 1, 'content' => $content, - 'matched_text' => (string) (($submatch['match']['text'] ?? '') ?: ''), + 'matched_text' => self::asString($matchField['text'] ?? null), ]; } } @@ -196,13 +197,18 @@ public function parseAstGrepJson(string $raw): array continue; } + $range = is_array($match['range'] ?? null) ? $match['range'] : []; + $rangeStart = is_array($range['start'] ?? null) ? $range['start'] : []; + $rangeEnd = is_array($range['end'] ?? null) ? $range['end'] : []; + $byteOffset = is_array($range['byteOffset'] ?? null) ? $range['byteOffset'] : []; + $matches[] = [ - 'file' => (string) ($match['file'] ?? ''), - 'start_line' => ((int) (($match['range']['start']['line'] ?? 0))) + 1, - 'end_line' => ((int) (($match['range']['end']['line'] ?? 0))) + 1, - 'start_byte' => (int) ($match['range']['byteOffset']['start'] ?? 0), - 'end_byte' => (int) ($match['range']['byteOffset']['end'] ?? 0), - 'code' => (string) ($match['text'] ?? ''), + 'file' => self::asString($match['file'] ?? null), + 'start_line' => self::asInt($rangeStart['line'] ?? null) + 1, + 'end_line' => self::asInt($rangeEnd['line'] ?? null) + 1, + 'start_byte' => self::asInt($byteOffset['start'] ?? null), + 'end_byte' => self::asInt($byteOffset['end'] ?? null), + 'code' => self::asString($match['text'] ?? null), ]; } @@ -288,4 +294,30 @@ private function linesToText(array $lines): string { return $lines === [] ? '' : implode(PHP_EOL, $lines) . PHP_EOL; } + + private static function asString(mixed $value): string + { + if (is_string($value)) { + return $value; + } + + if (is_int($value) || is_float($value) || is_bool($value) || $value === null) { + return (string) $value; + } + + return ''; + } + + private static function asInt(mixed $value): int + { + if (is_int($value)) { + return $value; + } + + if (is_float($value) || is_string($value) || is_bool($value)) { + return (int) $value; + } + + return 0; + } } diff --git a/tests/Oracle/Scenario.php b/tests/Oracle/Scenario.php index 4be5252..52fdeb6 100644 --- a/tests/Oracle/Scenario.php +++ b/tests/Oracle/Scenario.php @@ -18,26 +18,32 @@ public function __construct( public function category(): string { - return (string) ($this->definition['category'] ?? ''); + return self::asString($this->definition['category'] ?? ''); } public function mode(): string { - return (string) ($this->definition['mode'] ?? match ($this->category()) { + $mode = $this->definition['mode'] ?? null; + + if (is_string($mode)) { + return $mode; + } + + return match ($this->category()) { 'ast' => 'ast', 'rewrite' => 'rewrite', default => 'text', - }); + }; } public function description(): string { - return (string) ($this->definition['description'] ?? ''); + return self::asString($this->definition['description'] ?? ''); } public function pattern(): string { - return (string) ($this->definition['pattern'] ?? ''); + return self::asString($this->definition['pattern'] ?? ''); } public function rewrite(): ?string @@ -49,7 +55,9 @@ public function rewrite(): ?string public function language(): string { - return (string) ($this->definition['lang'] ?? 'php'); + $lang = $this->definition['lang'] ?? 'php'; + + return is_string($lang) ? $lang : 'php'; } /** @@ -57,7 +65,21 @@ public function language(): string */ public function flags(): array { - return array_values(array_map('strval', (array) ($this->definition['flags'] ?? []))); + $flags = $this->definition['flags'] ?? []; + + if (!is_array($flags)) { + return []; + } + + $result = []; + + foreach ($flags as $flag) { + if (is_scalar($flag) || $flag === null) { + $result[] = (string) $flag; + } + } + + return $result; } /** @@ -68,10 +90,20 @@ public function paths(): array $paths = $this->definition['paths'] ?? null; if (is_array($paths) && $paths !== []) { - return array_values(array_map('strval', $paths)); + $result = []; + + foreach ($paths as $path) { + if (is_scalar($path) || $path === null) { + $result[] = (string) $path; + } + } + + return $result; } - return [(string) ($this->definition['path'] ?? 'setup')]; + $path = $this->definition['path'] ?? 'setup'; + + return [is_string($path) ? $path : 'setup']; } /** @@ -94,7 +126,21 @@ public function expectations(): array */ public function oracleDisagreement(): array { - return (array) ($this->definition['oracle_disagreement'] ?? []); + $disagreement = $this->definition['oracle_disagreement'] ?? []; + + if (!is_array($disagreement)) { + return []; + } + + $result = []; + + foreach ($disagreement as $key => $value) { + if (is_string($key)) { + $result[$key] = $value; + } + } + + return $result; } public function scenarioDir(): string @@ -176,4 +222,17 @@ public function evaluateReport(array $report): array 'failures' => $failures, ]; } + + private static function asString(mixed $value): string + { + if (is_string($value)) { + return $value; + } + + if (is_scalar($value) || $value === null) { + return (string) $value; + } + + return ''; + } } diff --git a/tests/Oracle/ScenarioComparator.php b/tests/Oracle/ScenarioComparator.php index a1765cf..9976055 100644 --- a/tests/Oracle/ScenarioComparator.php +++ b/tests/Oracle/ScenarioComparator.php @@ -131,7 +131,7 @@ private function canonicalize(array $value): string private function sortRecursively(array $value): array { foreach ($value as $key => $item) { - if (is_array($item)) { + if (is_array($item) && (array_is_list($item) || self::hasOnlyStringKeys($item))) { $value[$key] = $this->sortRecursively($item); } } @@ -152,4 +152,19 @@ private function sortRecursively(array $value): array return $value; } + + /** + * @param array $value + * @phpstan-assert-if-true array $value + */ + private static function hasOnlyStringKeys(array $value): bool + { + foreach (array_keys($value) as $key) { + if (!is_string($key)) { + return false; + } + } + + return true; + } } diff --git a/tests/Oracle/ScenarioRunner.php b/tests/Oracle/ScenarioRunner.php index 7ab3d46..7986ad8 100644 --- a/tests/Oracle/ScenarioRunner.php +++ b/tests/Oracle/ScenarioRunner.php @@ -77,8 +77,10 @@ public function run(Scenario $scenario, bool $refreshOracle = false): array Filesystem::ensureDirectory($scenario->reportsDir()); Json::encodeFile($scenario->reportPath(), $comparison); + $expectation = is_array($comparison['expectation'] ?? null) ? $comparison['expectation'] : []; + return [ - 'pass' => (bool) ($comparison['expectation']['pass'] ?? false), + 'pass' => ($expectation['pass'] ?? false) === true, 'oracle' => $oracleResult, 'actual' => $actualResult, 'comparison' => $comparison, diff --git a/tests/Unit/Ast/AstCoverageTest.php b/tests/Unit/Ast/AstCoverageTest.php index 4ff748b..9253f6f 100644 --- a/tests/Unit/Ast/AstCoverageTest.php +++ b/tests/Unit/Ast/AstCoverageTest.php @@ -61,7 +61,9 @@ public function itExercisesPatternMatcherScalarArrayAndCaptureBranches(): void $this->assertFalse($this->invokePrivateWithArgs($matcher, 'matchArray', [$variadicPattern, $variadicCandidate, &$variadicCaptures, 0, 0])); $this->assertSame(serialize([serialize(1), serialize('two')]), $this->invokePrivate($matcher, 'fingerprint', [1, 'two'])); $this->assertIsString($this->invokePrivate($matcher, 'fingerprint', new Name(['App', 'Service']))); - $this->assertCount(2, $this->invokePrivate($matcher, 'serializeSubNodeArray', [new Expr\Variable('value'), 'literal'])); + $serialized = $this->invokePrivate($matcher, 'serializeSubNodeArray', [new Expr\Variable('value'), 'literal']); + self::assertIsArray($serialized); + $this->assertCount(2, $serialized); $this->assertIsString($this->invokePrivate($matcher, 'fingerprint', new Expr\Array_([ new ArrayItem(new Expr\Variable('value')), ]))); @@ -170,7 +172,9 @@ public function itRendersExpressionAndStatementNodesWithoutFileOffsets(): void $this->assertSame('true', $match->code); $this->assertSame('true', $this->invokePrivate($searcher, 'renderNode', $expr)); - $this->assertStringContainsString('echo', $this->invokePrivate($searcher, 'renderNode', $statement)); + $rendered = $this->invokePrivate($searcher, 'renderNode', $statement); + self::assertIsString($rendered); + $this->assertStringContainsString('echo', $rendered); } #[Test] @@ -262,6 +266,7 @@ public function itMaterializesScalarArrayValuesAndHandlesParsersReturningNull(): [], ); + self::assertIsArray($materialized); $this->assertSame('literal', $materialized[0]); $this->assertInstanceOf(Expr\Variable::class, $materialized[1]); diff --git a/tests/Unit/Benchmarks/BenchmarkRunnerTest.php b/tests/Unit/Benchmarks/BenchmarkRunnerTest.php index 6a8dcd4..35b1f86 100644 --- a/tests/Unit/Benchmarks/BenchmarkRunnerTest.php +++ b/tests/Unit/Benchmarks/BenchmarkRunnerTest.php @@ -61,12 +61,12 @@ public function itUsesCanonicalSuiteCategoryOrdering(): void #[Test] public function itSeparatesRuntimeBuildAndColdStorePaths(): void { - $runtimeTextPath = $this->invokePrivate('textIndexPath', [['category' => 'indexed-text'], 'wordpress']); - $coldTextPath = $this->invokePrivate('textIndexPath', [['category' => 'indexed-text-cold'], 'wordpress']); - $manyTextPath = $this->invokePrivate('textIndexPath', [['category' => 'indexed-text-many'], 'wordpress']); - $setTextPath = $this->invokePrivate('textIndexPath', [['category' => 'indexed-set-text'], 'wordpress']); - $buildTextPath = $this->invokePrivate('textIndexPath', [['category' => 'indexed-build'], 'wordpress']); - $refreshTextPath = $this->invokePrivate('textIndexPath', [['category' => 'indexed-refresh'], 'wordpress']); + $runtimeTextPath = $this->invokePrivateString('textIndexPath', [['category' => 'indexed-text'], 'wordpress']); + $coldTextPath = $this->invokePrivateString('textIndexPath', [['category' => 'indexed-text-cold'], 'wordpress']); + $manyTextPath = $this->invokePrivateString('textIndexPath', [['category' => 'indexed-text-many'], 'wordpress']); + $setTextPath = $this->invokePrivateString('textIndexPath', [['category' => 'indexed-set-text'], 'wordpress']); + $buildTextPath = $this->invokePrivateString('textIndexPath', [['category' => 'indexed-build'], 'wordpress']); + $refreshTextPath = $this->invokePrivateString('textIndexPath', [['category' => 'indexed-refresh'], 'wordpress']); $this->assertStringContainsString('/build/benchmarks/indexes/runtime/wordpress', $runtimeTextPath); $this->assertStringContainsString('/build/benchmarks/indexes/cold/wordpress', $coldTextPath); @@ -80,12 +80,12 @@ public function itSeparatesRuntimeBuildAndColdStorePaths(): void $this->assertNotSame($runtimeTextPath, $buildTextPath); $this->assertNotSame($runtimeTextPath, $refreshTextPath); - $runtimeAstIndexPath = $this->invokePrivate('astIndexPath', [['category' => 'ast-indexed'], 'wordpress']); - $buildAstIndexPath = $this->invokePrivate('astIndexPath', [['category' => 'ast-indexed-build'], 'wordpress']); - $refreshAstIndexPath = $this->invokePrivate('astIndexPath', [['category' => 'ast-indexed-refresh'], 'wordpress']); - $runtimeAstCachePath = $this->invokePrivate('astCachePath', [['category' => 'ast-cached'], 'wordpress']); - $buildAstCachePath = $this->invokePrivate('astCachePath', [['category' => 'ast-cached-build'], 'wordpress']); - $refreshAstCachePath = $this->invokePrivate('astCachePath', [['category' => 'ast-cached-refresh'], 'wordpress']); + $runtimeAstIndexPath = $this->invokePrivateString('astIndexPath', [['category' => 'ast-indexed'], 'wordpress']); + $buildAstIndexPath = $this->invokePrivateString('astIndexPath', [['category' => 'ast-indexed-build'], 'wordpress']); + $refreshAstIndexPath = $this->invokePrivateString('astIndexPath', [['category' => 'ast-indexed-refresh'], 'wordpress']); + $runtimeAstCachePath = $this->invokePrivateString('astCachePath', [['category' => 'ast-cached'], 'wordpress']); + $buildAstCachePath = $this->invokePrivateString('astCachePath', [['category' => 'ast-cached-build'], 'wordpress']); + $refreshAstCachePath = $this->invokePrivateString('astCachePath', [['category' => 'ast-cached-refresh'], 'wordpress']); $this->assertStringContainsString('/build/benchmarks/ast-indexes/runtime/wordpress', $runtimeAstIndexPath); $this->assertStringContainsString('/build/benchmarks/ast-indexes/build/wordpress', $buildAstIndexPath); @@ -109,4 +109,15 @@ private function invokePrivate(string $method, array $arguments = []): mixed return $reflectionMethod->invokeArgs($this->runner, $arguments); } + + /** + * @param list $arguments + */ + private function invokePrivateString(string $method, array $arguments = []): string + { + $result = $this->invokePrivate($method, $arguments); + self::assertIsString($result); + + return $result; + } } diff --git a/tests/Unit/Cli/AstGrepApplicationTest.php b/tests/Unit/Cli/AstGrepApplicationTest.php index 83eeff8..f3d6d1d 100644 --- a/tests/Unit/Cli/AstGrepApplicationTest.php +++ b/tests/Unit/Cli/AstGrepApplicationTest.php @@ -249,8 +249,8 @@ public function itCoversAstGrepPrivateParsingAndFormattingBranches(): void $application = $this->newApplication()['application']; $missingPatternExit = $application->run(['sg', 'run']); - $parsedTerminated = $this->invokeMethod($application, 'parseArguments', ['--', 'src/App.php', 'notes.txt']); - $parsedDefaults = $this->invokeMethod( + $parsedTerminated = $this->invokeArrayMethod($application, 'parseArguments', ['--', 'src/App.php', 'notes.txt']); + $parsedDefaults = $this->invokeArrayMethod( $application, 'parseArguments', ['--type', 'php', '--type-not', 'txt', '--rewrite', '', '--pattern', 'dispatch($EVENT)'], @@ -322,9 +322,6 @@ private function readStream($stream): string return (string) stream_get_contents($stream); } - /** - * @return mixed - */ private function invokeMethod(object $object, string $method, mixed ...$arguments): mixed { $reflection = new \ReflectionMethod($object, $method); @@ -332,4 +329,15 @@ private function invokeMethod(object $object, string $method, mixed ...$argument return $reflection->invoke($object, ...$arguments); } + + /** + * @return array + */ + private function invokeArrayMethod(object $object, string $method, mixed ...$arguments): array + { + $result = $this->invokeMethod($object, $method, ...$arguments); + self::assertIsArray($result); + + return $result; + } } diff --git a/tests/Unit/Cli/IndexApplicationTest.php b/tests/Unit/Cli/IndexApplicationTest.php index a7ec28e..8abfbc8 100644 --- a/tests/Unit/Cli/IndexApplicationTest.php +++ b/tests/Unit/Cli/IndexApplicationTest.php @@ -486,37 +486,37 @@ public function itCoversIndexedPrivateParsingBranches(): void { $application = $this->newApplication()['application']; - $parsedFlags = $this->invokeMethod( + $parsedFlags = $this->invokeArrayMethod( $application, 'parseSearchArguments', ['-w', '-v', '-n', '--show-index-origin', '--trace-plan', '-A', '2', '-B', '1', '-C', '3', '--type', 'php', '--type-not', 'txt', 'needle'], ); - $parsedTerminated = $this->invokeMethod( + $parsedTerminated = $this->invokeArrayMethod( $application, 'parseSearchArguments', ['--', 'needle', 'single.txt', 'counts.txt'], ); - $parsedAst = $this->invokeMethod( + $parsedAst = $this->invokeArrayMethod( $application, 'parseAstSearchArguments', ['--json', '--hidden', '--show-index-origin', '--trace-plan', '--strict-parse', '-l', '--glob', '*.php', '--type', 'php', '--type-not', 'txt', '--index-dir', '.ast', '--lang', 'php', '-j', '4', '--fallback', 'scan', 'new $CLASS()', 'src/Ast.php'], ); - $parsedBuild = $this->invokeMethod( + $parsedBuild = $this->invokeArrayMethod( $application, 'parseBuildArguments', ['--index-dir', '.index', '--lifecycle', 'static', '--auto-refresh-max-files', '9', '--auto-refresh-max-bytes', '2048', '.'], ); - $parsedStats = $this->invokeMethod( + $parsedStats = $this->invokeArrayMethod( $application, 'parseStatsArguments', ['--index-dir', '.index', '--dry-refresh', '.'], ); - $parsedSet = $this->invokeMethod( + $parsedSet = $this->invokeArrayMethod( $application, 'parseSetCommandArguments', ['--manifest', '.greph-index-set.json', '--mode', 'ast-cache', '--index', 'plugin-cache', '--dry-refresh'], ); - $parsedSetSearch = $this->invokeMethod( + $parsedSetSearch = $this->invokeArrayMethod( $application, 'parseSetSearchArguments', ['--manifest', '.greph-index-set.json', '--mode', 'ast-index', '--index', 'plugin-ast', '--show-index-origin', 'new $CLASS()', 'src/Ast.php'], @@ -568,7 +568,9 @@ public function itCoversIndexedPrivateParsingBranches(): void $this->assertSame('ast-index', $parsedSetSearch['mode']); $this->assertSame(['plugin-ast'], $parsedSetSearch['indexes']); $this->assertTrue($parsedSetSearch['showIndexOrigin']); - $this->assertSame('new $CLASS()', $parsedSetSearch['search']['pattern']); + $search = $parsedSetSearch['search']; + self::assertIsArray($search); + $this->assertSame('new $CLASS()', $search['pattern']); $this->assertTrue($displayNames); } @@ -605,9 +607,6 @@ private function readStream($stream): string return (string) stream_get_contents($stream); } - /** - * @return mixed - */ private function invokeMethod(object $object, string $method, mixed ...$arguments): mixed { $reflection = new \ReflectionMethod($object, $method); @@ -615,4 +614,15 @@ private function invokeMethod(object $object, string $method, mixed ...$argument return $reflection->invoke($object, ...$arguments); } + + /** + * @return array + */ + private function invokeArrayMethod(object $object, string $method, mixed ...$arguments): array + { + $result = $this->invokeMethod($object, $method, ...$arguments); + self::assertIsArray($result); + + return $result; + } } diff --git a/tests/Unit/Cli/RipgrepApplicationTest.php b/tests/Unit/Cli/RipgrepApplicationTest.php index f620b6c..60514a5 100644 --- a/tests/Unit/Cli/RipgrepApplicationTest.php +++ b/tests/Unit/Cli/RipgrepApplicationTest.php @@ -220,12 +220,12 @@ public function itCoversRipgrepPrivateParsingAndFormattingBranches(): void { $application = $this->newApplication()['application']; - $parsedSearch = $this->invokeMethod( + $parsedSearch = $this->invokeArrayMethod( $application, 'parseSearchArguments', ['-n', '--no-follow', '-B', '2', '-C', '3', '--glob', '*.php', '--type', 'php', '--type-not', 'txt', 'needle', 'single.txt', 'counts.txt'], ); - $parsedFiles = $this->invokeMethod( + $parsedFiles = $this->invokeArrayMethod( $application, 'parseFilesArguments', ['--files', '--no-ignore', '-L'], @@ -336,9 +336,6 @@ private function readStream($stream): string return (string) stream_get_contents($stream); } - /** - * @return mixed - */ private function invokeMethod(object $object, string $method, mixed ...$arguments): mixed { $reflection = new \ReflectionMethod($object, $method); @@ -346,4 +343,15 @@ private function invokeMethod(object $object, string $method, mixed ...$argument return $reflection->invoke($object, ...$arguments); } + + /** + * @return array + */ + private function invokeArrayMethod(object $object, string $method, mixed ...$arguments): array + { + $result = $this->invokeMethod($object, $method, ...$arguments); + self::assertIsArray($result); + + return $result; + } } diff --git a/tests/Unit/FeatureMatrix/FeatureMatrixGeneratorTest.php b/tests/Unit/FeatureMatrix/FeatureMatrixGeneratorTest.php index c66eec5..022682a 100644 --- a/tests/Unit/FeatureMatrix/FeatureMatrixGeneratorTest.php +++ b/tests/Unit/FeatureMatrix/FeatureMatrixGeneratorTest.php @@ -28,13 +28,13 @@ public function itCoversWorkspaceProbeProviderAndWritePaths(): void toolResolver: new ToolResolver(static fn (string $candidate): ?string => null), ); - $unavailable = $this->invokeMethod( + $unavailable = $this->invokeArrayMethod( $generator, 'runWorkspaceProbe', null, static fn (): array => [], ); - $failure = $this->invokeMethod( + $failure = $this->invokeArrayMethod( $generator, 'runWorkspaceProbe', [PHP_BINARY], @@ -67,11 +67,11 @@ static function (ToolResolver $toolResolver): array { Workspace::remove($outputWorkspace); } - $this->assertSame('Unavailable', $unavailable['status']); - $this->assertSame('Provider command was not available in this environment.', $unavailable['note']); - $this->assertSame('Fail', $failure['status']); - $this->assertSame('boom', $failure['note']); - $this->assertSame([PHP_BINARY], $failure['command']); + $this->assertSame('Unavailable', $unavailable['status'] ?? null); + $this->assertSame('Provider command was not available in this environment.', $unavailable['note'] ?? null); + $this->assertSame('Fail', $failure['status'] ?? null); + $this->assertSame('boom', $failure['note'] ?? null); + $this->assertSame([PHP_BINARY], $failure['command'] ?? null); $this->assertSame(['php', 'tool'], $resolvedProvider); $this->assertNull($missingProvider); $this->assertArrayHasKey('generated_at', $report); @@ -94,6 +94,7 @@ public function itCoversNormalizationMarkdownAndValidatorHelpers(): void ); $markdown = $generator->renderMarkdown([ 'generated_at' => '2026-04-10T00:00:00Z', + 'root_path' => $this->rootPath, 'sections' => [[ 'title' => 'Helpers', 'providers' => ['one', 'two'], @@ -235,6 +236,7 @@ public function itCoversNormalizationMarkdownAndValidatorHelpers(): void 'bad-count', ); + self::assertIsString($normalized); $this->assertStringContainsString('/path ', $normalized); $this->assertStringContainsString('...[truncated]', $normalized); $this->assertStringContainsString('Fail
bad\|note wrapped', $markdown); @@ -474,38 +476,35 @@ public function itCoversIndexedProbeFailurePaths(): void $generator = new FeatureMatrixGenerator($this->rootPath); try { - $refreshBuildFailure = $this->invokeMethod( + $refreshBuildFailure = $this->invokeArrayMethod( $generator, 'runIndexedRefreshProbe', [PHP_BINARY, $buildFailureScript], ); - $searchBuildFailure = $this->invokeMethod( + $searchBuildFailure = $this->invokeArrayMethod( $generator, 'runIndexedSearchProbe', [PHP_BINARY, $buildFailureScript], ['search', '-F', 'needle', '.'], static fn (ProcessResult $result): ?string => null, ); - $refreshFailure = $this->invokeMethod( + $refreshFailure = $this->invokeArrayMethod( $generator, 'runIndexedRefreshProbe', [PHP_BINARY, $refreshFailureScript], ); - $this->assertSame('Fail', $refreshBuildFailure['status']); - $this->assertSame('Initial indexed build failed.', $refreshBuildFailure['note']); - $this->assertSame('Fail', $searchBuildFailure['status']); - $this->assertSame('Initial indexed build failed.', $searchBuildFailure['note']); - $this->assertSame('Fail', $refreshFailure['status']); - $this->assertSame('Indexed refresh failed.', $refreshFailure['note']); + $this->assertSame('Fail', $refreshBuildFailure['status'] ?? null); + $this->assertSame('Initial indexed build failed.', $refreshBuildFailure['note'] ?? null); + $this->assertSame('Fail', $searchBuildFailure['status'] ?? null); + $this->assertSame('Initial indexed build failed.', $searchBuildFailure['note'] ?? null); + $this->assertSame('Fail', $refreshFailure['status'] ?? null); + $this->assertSame('Indexed refresh failed.', $refreshFailure['note'] ?? null); } finally { Workspace::remove($workspace); } } - /** - * @return mixed - */ private function invokeMethod(object $object, string $method, mixed ...$arguments): mixed { $reflection = new \ReflectionMethod($object, $method); @@ -514,6 +513,17 @@ private function invokeMethod(object $object, string $method, mixed ...$argument return $reflection->invoke($object, ...$arguments); } + /** + * @return array + */ + private function invokeArrayMethod(object $object, string $method, mixed ...$arguments): array + { + $result = $this->invokeMethod($object, $method, ...$arguments); + self::assertIsArray($result); + + return $result; + } + /** * @return mixed */ diff --git a/tests/Unit/Index/AstCacheBuilderTest.php b/tests/Unit/Index/AstCacheBuilderTest.php index 0ef8854..cb06926 100644 --- a/tests/Unit/Index/AstCacheBuilderTest.php +++ b/tests/Unit/Index/AstCacheBuilderTest.php @@ -36,6 +36,7 @@ public function itRefreshesFromScratchAndCoversPrivateCacheBuilderHelpers(): voi $absoluteIndexPath = $this->invokeMethod($builder, 'resolveIndexPath', $this->workspace, '/tmp/custom-ast-cache'); $relativeIndexPath = $this->invokeMethod($builder, 'resolveIndexPath', $this->workspace, '.alt-cache'); $scannedFiles = $this->invokeMethod($builder, 'scanFiles', $this->workspace, $this->workspace . '/.greph-ast-cache'); + self::assertIsArray($scannedFiles); $missingFacts = $this->invokeMethod($builder, 'extractFacts', $this->workspace . '/missing.php'); $hidden = $this->invokeMethod($builder, 'isHiddenPath', '.hidden/Hidden.php'); $visible = $this->invokeMethod($builder, 'isHiddenPath', 'src/App.php'); diff --git a/tests/Unit/Index/AstCacheStoreTest.php b/tests/Unit/Index/AstCacheStoreTest.php index d0e544d..58e06f9 100644 --- a/tests/Unit/Index/AstCacheStoreTest.php +++ b/tests/Unit/Index/AstCacheStoreTest.php @@ -186,7 +186,7 @@ public function itCoversPrivateWriteTreeAndPruneBranches(): void { $store = new AstCacheStore(); $indexPath = $this->workspace . '/.greph-ast-cache'; - $treePath = $this->invokeMethod($store, 'treePath', $indexPath, 77); + $treePath = $this->invokeStringMethod($store, 'treePath', $indexPath, 77); $writePath = $indexPath . '/custom.phpbin'; $statements = [new Expression(new ConstFetch(new \PhpParser\Node\Name('true')))]; @@ -233,9 +233,6 @@ public function itCoversPrivateWriteTreeAndPruneBranches(): void $this->assertDirectoryDoesNotExist($this->workspace . '/missing-cache/trees'); } - /** - * @return mixed - */ private function invokeMethod(object $object, string $method, mixed ...$arguments): mixed { $reflection = new \ReflectionMethod($object, $method); @@ -243,4 +240,12 @@ private function invokeMethod(object $object, string $method, mixed ...$argument return $reflection->invoke($object, ...$arguments); } + + private function invokeStringMethod(object $object, string $method, mixed ...$arguments): string + { + $result = $this->invokeMethod($object, $method, ...$arguments); + self::assertIsString($result); + + return $result; + } } diff --git a/tests/Unit/Index/AstIndexBuilderTest.php b/tests/Unit/Index/AstIndexBuilderTest.php index 27e249f..982bb6d 100644 --- a/tests/Unit/Index/AstIndexBuilderTest.php +++ b/tests/Unit/Index/AstIndexBuilderTest.php @@ -36,6 +36,7 @@ public function itRefreshesFromScratchAndCoversPrivateBuilderHelpers(): void $absoluteIndexPath = $this->invokeMethod($builder, 'resolveIndexPath', $this->workspace, '/tmp/custom-ast-index'); $relativeIndexPath = $this->invokeMethod($builder, 'resolveIndexPath', $this->workspace, '.alt-index'); $scannedFiles = $this->invokeMethod($builder, 'scanFiles', $this->workspace, $this->workspace . '/.greph-ast-index'); + self::assertIsArray($scannedFiles); $missingFacts = $this->invokeMethod($builder, 'extractFacts', $this->workspace . '/missing.php'); $hidden = $this->invokeMethod($builder, 'isHiddenPath', '.hidden/Hidden.php'); $visible = $this->invokeMethod($builder, 'isHiddenPath', 'src/App.php'); diff --git a/tests/Unit/Index/CachedAstSearcherTest.php b/tests/Unit/Index/CachedAstSearcherTest.php index 4aee960..f22a0fa 100644 --- a/tests/Unit/Index/CachedAstSearcherTest.php +++ b/tests/Unit/Index/CachedAstSearcherTest.php @@ -93,7 +93,7 @@ public function itCoversCachedAstHelperBranches(): void ], $this->workspace, ); - $cachedMatches = $this->invokeMethod( + $cachedMatches = $this->invokeArrayMethod( $this->searcher, 'filterCachedMatches', [ @@ -247,9 +247,6 @@ private function createMatch(string $file): AstMatch return new AstMatch($file, $pattern->root, [], 1, 1, 0, 10, 'new Foo()'); } - /** - * @return mixed - */ private function invokeMethod(object $object, string $method, mixed ...$arguments): mixed { $reflection = new \ReflectionMethod($object, $method); @@ -257,4 +254,15 @@ private function invokeMethod(object $object, string $method, mixed ...$argument return $reflection->invoke($object, ...$arguments); } + + /** + * @return array + */ + private function invokeArrayMethod(object $object, string $method, mixed ...$arguments): array + { + $result = $this->invokeMethod($object, $method, ...$arguments); + self::assertIsArray($result); + + return $result; + } } diff --git a/tests/Unit/Index/IndexedAstSearcherTest.php b/tests/Unit/Index/IndexedAstSearcherTest.php index b422c98..179eef6 100644 --- a/tests/Unit/Index/IndexedAstSearcherTest.php +++ b/tests/Unit/Index/IndexedAstSearcherTest.php @@ -111,7 +111,7 @@ public function itCoversIndexedAstHelperBranches(): void ], $this->workspace, ); - $cachedMatches = $this->invokeMethod( + $cachedMatches = $this->invokeArrayMethod( $this->searcher, 'filterCachedMatches', [ @@ -120,7 +120,7 @@ public function itCoversIndexedAstHelperBranches(): void ], $selection, ); - $candidateIds = $this->invokeMethod($this->searcher, 'candidateIds', $index, $pattern); + $candidateIds = $this->invokeArrayMethod($this->searcher, 'candidateIds', $index, $pattern); $unsupportedCandidateIds = $this->invokeMethod($this->searcher, 'candidateIds', $index, $unsupportedPattern); $cacheSupported = $this->invokeMethod( $this->searcher, @@ -267,9 +267,6 @@ private function createMatch(string $file): AstMatch return new AstMatch($file, $pattern->root, [], 1, 1, 0, 10, 'new Foo()'); } - /** - * @return mixed - */ private function invokeMethod(object $object, string $method, mixed ...$arguments): mixed { $reflection = new \ReflectionMethod($object, $method); @@ -277,4 +274,15 @@ private function invokeMethod(object $object, string $method, mixed ...$argument return $reflection->invoke($object, ...$arguments); } + + /** + * @return array + */ + private function invokeArrayMethod(object $object, string $method, mixed ...$arguments): array + { + $result = $this->invokeMethod($object, $method, ...$arguments); + self::assertIsArray($result); + + return $result; + } } diff --git a/tests/Unit/Index/IndexedTextSearcherTest.php b/tests/Unit/Index/IndexedTextSearcherTest.php index b7fe24b..1224772 100644 --- a/tests/Unit/Index/IndexedTextSearcherTest.php +++ b/tests/Unit/Index/IndexedTextSearcherTest.php @@ -171,13 +171,13 @@ public function itCoversQueryPlanningHelpersAndFilters(): void $this->workspace . '/.greph-index', ['ab'], ); - $candidateIdsWithTrigrams = $this->invokeMethod( + $candidateIdsWithTrigrams = $this->invokeArrayMethod( $this->searcher, 'candidateIds', $this->workspace . '/.greph-index', ['function'], ); - $candidateIdsFromWords = $this->invokeMethod( + $candidateIdsFromWords = $this->invokeArrayMethod( $this->searcher, 'candidateIdsFromWordPostings', $this->workspace . '/.greph-index', @@ -235,7 +235,7 @@ public function itCoversQueryPlanningHelpersAndFilters(): void globPatterns: ['*.php'], ), ); - $selection = $this->invokeMethod( + $selection = $this->invokeArrayMethod( $this->searcher, 'buildSelection', [ @@ -420,9 +420,6 @@ public function itCachesWarmShortLiteralRootQueries(): void ); } - /** - * @return mixed - */ private function invokeMethod(object $object, string $method, mixed ...$arguments): mixed { $reflection = new \ReflectionMethod($object, $method); @@ -430,4 +427,15 @@ private function invokeMethod(object $object, string $method, mixed ...$argument return $reflection->invoke($object, ...$arguments); } + + /** + * @return array + */ + private function invokeArrayMethod(object $object, string $method, mixed ...$arguments): array + { + $result = $this->invokeMethod($object, $method, ...$arguments); + self::assertIsArray($result); + + return $result; + } } diff --git a/tests/Unit/Index/QueryCacheStoreTest.php b/tests/Unit/Index/QueryCacheStoreTest.php index 096afad..596eb67 100644 --- a/tests/Unit/Index/QueryCacheStoreTest.php +++ b/tests/Unit/Index/QueryCacheStoreTest.php @@ -103,7 +103,7 @@ public function itDetectsCorruptTextQueryCachePayloads(): void $this->assertNotNull($path); $rawPayload = file_get_contents($path); - $legacyPath = $this->invokeMethod($store, 'legacyCachePath', $index->indexPath, 'function', $options); + $legacyPath = $this->invokeStringMethod($store, 'legacyCachePath', $index->indexPath, 'function', $options); $this->assertNotFalse($rawPayload); unlink($path); @@ -178,7 +178,7 @@ public function itRoundTripsAndValidatesAstQueryCaches(): void $this->assertNotNull($path); $rawPayload = file_get_contents($path); - $legacyPath = $this->invokeMethod($store, 'legacyCachePath', $indexPath, 'array($$$ITEMS)', $options); + $legacyPath = $this->invokeStringMethod($store, 'legacyCachePath', $indexPath, 'array($$$ITEMS)', $options); $this->assertNotFalse($rawPayload); unlink($path); @@ -256,8 +256,8 @@ public function itCoversTextAndAstCacheWriteFailureBranches(): void $textOptions = new TextSearchOptions(fixedString: true); $astOptions = new AstSearchOptions(); $astIndexPath = $this->workspace . '/.greph-ast-index'; - $textPath = $this->invokeMethod($textStore, 'cachePath', $textIndex->indexPath, 'function', $textOptions); - $astPath = $this->invokeMethod($astStore, 'cachePath', $astIndexPath, 'array($$$ITEMS)', $astOptions); + $textPath = $this->invokeStringMethod($textStore, 'cachePath', $textIndex->indexPath, 'function', $textOptions); + $astPath = $this->invokeStringMethod($astStore, 'cachePath', $astIndexPath, 'array($$$ITEMS)', $astOptions); mkdir($textPath . '.tmp', 0777, true); @@ -298,9 +298,6 @@ public function itCoversTextAndAstCacheWriteFailureBranches(): void } } - /** - * @return mixed - */ private function invokeMethod(object $object, string $method, mixed ...$arguments): mixed { $reflection = new \ReflectionMethod($object, $method); @@ -309,6 +306,14 @@ private function invokeMethod(object $object, string $method, mixed ...$argument return $reflection->invoke($object, ...$arguments); } + private function invokeStringMethod(object $object, string $method, mixed ...$arguments): string + { + $result = $this->invokeMethod($object, $method, ...$arguments); + self::assertIsString($result); + + return $result; + } + private function textIndex(): TextIndex { return new TextIndex( diff --git a/tests/Unit/Index/TextIndexBuilderTest.php b/tests/Unit/Index/TextIndexBuilderTest.php index cc66879..6c9b3a8 100644 --- a/tests/Unit/Index/TextIndexBuilderTest.php +++ b/tests/Unit/Index/TextIndexBuilderTest.php @@ -106,7 +106,9 @@ public function itCoversPrivateIndexBuilderBranches(): void $absoluteIndexPath = $this->invokeMethod($builder, 'resolveIndexPath', $this->workspace, '/tmp/custom-text-index'); $relativeIndexPath = $this->invokeMethod($builder, 'resolveIndexPath', $this->workspace, '.alt-index'); $scannedFiles = $this->invokeMethod($builder, 'scanFiles', $this->workspace, $this->workspace . '/.greph-index'); + self::assertIsArray($scannedFiles); $postings = $this->invokeMethod($builder, 'buildPostings', [3 => ['ghi', 'abc'], 1 => ['abc', 'def']]); + self::assertIsArray($postings); $missingTerms = $this->invokeMethod($builder, 'extractFileTerms', $this->workspace . '/missing.txt'); $words = $this->invokeMethod($builder, 'extractFileWords', "invokeMethod($builder, 'isHiddenPath', '.hidden/Secret.php'); diff --git a/tests/Unit/Parallel/ResultCollectorTest.php b/tests/Unit/Parallel/ResultCollectorTest.php index a82246f..0320cd9 100644 --- a/tests/Unit/Parallel/ResultCollectorTest.php +++ b/tests/Unit/Parallel/ResultCollectorTest.php @@ -34,7 +34,7 @@ public function itAppliesTheResultDecoderWhenCollecting(): void $this->spawnWorker(serialize(['result' => 'first'])), ]; - $this->assertSame(['FIRST'], $collector->collect($workers, 'strtoupper')); + $this->assertSame(['FIRST'], $collector->collect($workers, static fn (mixed $value): string => strtoupper(is_string($value) ? $value : ''))); } #[Test] @@ -104,7 +104,7 @@ private function requirePcntl(): void } /** - * @return array{pid: int, socket: mixed} + * @return array{pid: int, socket: resource} */ private function spawnWorker(string $payload): array { diff --git a/tests/Unit/Parallel/WorkerTest.php b/tests/Unit/Parallel/WorkerTest.php index c31050e..5ac196d 100644 --- a/tests/Unit/Parallel/WorkerTest.php +++ b/tests/Unit/Parallel/WorkerTest.php @@ -67,7 +67,7 @@ static function (int $exitCode): never { ); try { - $worker->run(static fn (FileList $files): int => count($files), $writer, static fn (int $result): string => 'count:' . $result); + $worker->run(static fn (FileList $files): int => count($files), $writer, static fn (mixed $result): string => 'count:' . (is_int($result) ? $result : 0)); } catch (WorkerTermination $termination) { $this->assertSame(0, $termination->exitCode); } diff --git a/tests/Unit/Text/TextSearcherTest.php b/tests/Unit/Text/TextSearcherTest.php index fcf601e..30161ea 100644 --- a/tests/Unit/Text/TextSearcherTest.php +++ b/tests/Unit/Text/TextSearcherTest.php @@ -226,54 +226,61 @@ public function mayMatchContents(string $contents): bool } }; - $streamResult = $this->invokeMethod( + $streamResult = $this->invokeInstance( $searcher, 'searchFileWithoutContext', + TextFileResult::class, $this->workspace . '/count.txt', $matcher, new TextSearchOptions(countOnly: true, maxCount: 1), ); - $streamListResult = $this->invokeMethod( + $streamListResult = $this->invokeInstance( $searcher, 'searchFileWithoutContext', + TextFileResult::class, $this->workspace . '/count.txt', $matcher, new TextSearchOptions(filesWithMatches: true), ); - $streamMissingResult = $this->invokeMethod( + $streamMissingResult = $this->invokeInstance( $searcher, 'searchFileWithoutContext', + TextFileResult::class, $this->workspace . '/missing-stream.txt', $matcher, new TextSearchOptions(), ); - $contentsResult = $this->invokeMethod( + $contentsResult = $this->invokeInstance( $searcher, 'searchContentsWithoutContext', + TextFileResult::class, 'memory.txt', "miss\r\nmatch\r\nfinal match", $matcher, new TextSearchOptions(countOnly: true, maxCount: 1), ); - $contentsWithoutMatches = $this->invokeMethod( + $contentsWithoutMatches = $this->invokeInstance( $searcher, 'searchContentsWithoutContext', + TextFileResult::class, 'memory.txt', "alpha\nbeta", $matcher, new TextSearchOptions(filesWithoutMatches: true, invertMatch: true), ); - $quietStreamResult = $this->invokeMethod( + $quietStreamResult = $this->invokeInstance( $searcher, 'searchFileWithoutContext', + TextFileResult::class, $this->workspace . '/count.txt', $matcher, new TextSearchOptions(quiet: true), ); - $quietContentsResult = $this->invokeMethod( + $quietContentsResult = $this->invokeInstance( $searcher, 'searchContentsWithoutContext', + TextFileResult::class, 'memory.txt', "miss\r\nmatch\r\nfinal match", $matcher, @@ -351,33 +358,37 @@ public function mayMatchContents(string $contents): bool new TextSearchOptions(), ); - $regexPrefilterResult = $this->invokeMethod( + $regexPrefilterResult = $this->invokeInstance( $searcher, 'searchContentsByRegexPrefilter', + TextFileResult::class, 'memory.txt', '$foo = new Bar()', new RegexSearcher('\$foo = new [A-Za-z_][A-Za-z0-9_]*\(\)', false, false, 'new '), new TextSearchOptions(), ); - $regexPrefilterQuietResult = $this->invokeMethod( + $regexPrefilterQuietResult = $this->invokeInstance( $searcher, 'searchContentsByRegexPrefilter', + TextFileResult::class, 'memory.txt', '$foo = new Bar()', new RegexSearcher('\$foo = new [A-Za-z_][A-Za-z0-9_]*\(\)', false, false, 'new '), new TextSearchOptions(quiet: true), ); - $literalResult = $this->invokeMethod( + $literalResult = $this->invokeInstance( $searcher, 'searchContentsByLiteral', + TextFileResult::class, 'memory.txt', "needle\r\nneedle", new LiteralSearcher('needle'), new TextSearchOptions(countOnly: true, maxCount: 1, fixedString: true), ); - $anchoredLiteralResult = $this->invokeMethod( + $anchoredLiteralResult = $this->invokeInstance( $searcher, 'searchContentsByAnchoredLiteral', + TextFileResult::class, 'memory.txt', "function demo()\ncall(); more();\n}\n", new AnchoredLiteralSearcher(');', AnchoredLiteralSearcher::MODE_SUFFIX), @@ -416,32 +427,36 @@ public function mayMatchContents(string $contents): bool } }; - $streamResult = $this->invokeMethod( + $streamResult = $this->invokeInstance( $searcher, 'searchFileWithStreamWithoutContext', + TextFileResult::class, $this->workspace . '/context.txt', $matcher, new TextSearchOptions(maxCount: 1), ); - $contentsResult = $this->invokeMethod( + $contentsResult = $this->invokeInstance( $searcher, 'searchContentsWithoutContext', + TextFileResult::class, 'tail.txt', "alpha\ntail match", $matcher, new TextSearchOptions(maxCount: 1), ); - $contentsCountOnly = $this->invokeMethod( + $contentsCountOnly = $this->invokeInstance( $searcher, 'searchContentsWithoutContext', + TextFileResult::class, 'count.txt', "match\nmatch", $matcher, new TextSearchOptions(countOnly: true), ); - $streamCountOnly = $this->invokeMethod( + $streamCountOnly = $this->invokeInstance( $searcher, 'searchFileWithStreamWithoutContext', + TextFileResult::class, $this->workspace . '/count.txt', $matcher, new TextSearchOptions(countOnly: true), @@ -456,9 +471,6 @@ public function mayMatchContents(string $contents): bool $this->assertSame(2, $streamCountOnly->matchCount()); } - /** - * @return mixed - */ private function invokeMethod(object $object, string $method, mixed ...$arguments): mixed { $reflection = new \ReflectionMethod($object, $method); @@ -466,4 +478,17 @@ private function invokeMethod(object $object, string $method, mixed ...$argument return $reflection->invoke($object, ...$arguments); } + + /** + * @template T of object + * @param class-string $expectedClass + * @return T + */ + private function invokeInstance(object $object, string $method, string $expectedClass, mixed ...$arguments): object + { + $result = $this->invokeMethod($object, $method, ...$arguments); + self::assertInstanceOf($expectedClass, $result); + + return $result; + } } diff --git a/tests/Unit/Walker/FileWalkerTest.php b/tests/Unit/Walker/FileWalkerTest.php index 83ebafe..384c94b 100644 --- a/tests/Unit/Walker/FileWalkerTest.php +++ b/tests/Unit/Walker/FileWalkerTest.php @@ -130,7 +130,7 @@ public function itExercisesDirectoryTraversalAndInclusionInternals(): void ], ); - $this->assertSame([], $files); + $this->assertCount(0, $files); $visitedDirectories[$this->workspace . '/src'] = true; $this->invokePrivateWithArgs( @@ -145,7 +145,7 @@ public function itExercisesDirectoryTraversalAndInclusionInternals(): void &$visitedDirectories, ], ); - $this->assertSame([], $files); + $this->assertCount(0, $files); symlink($this->workspace . '/src/App.php', $this->workspace . '/src/AppLink.php'); $linkedFiles = $walker->walk($this->workspace, new WalkOptions(includeHidden: true, followSymlinks: false))->paths(); diff --git a/tests/Unit/Walker/GitignoreFilterTest.php b/tests/Unit/Walker/GitignoreFilterTest.php index ca89950..887cd2e 100644 --- a/tests/Unit/Walker/GitignoreFilterTest.php +++ b/tests/Unit/Walker/GitignoreFilterTest.php @@ -92,10 +92,10 @@ public function itExercisesRuleParsingMatchingAndGlobInternals(): void $this->invokePrivate($filter, 'parseRuleLine', '\!literal'), ); - $anchoredRule = $this->invokePrivate($filter, 'parseRuleLine', '/foo'); - $directoryRule = $this->invokePrivate($filter, 'parseRuleLine', 'build/'); - $nestedDirectoryRule = $this->invokePrivate($filter, 'parseRuleLine', 'src/build/'); - $slashRule = $this->invokePrivate($filter, 'parseRuleLine', 'nested/file?.[!o]'); + $anchoredRule = $this->invokePrivateArray($filter, 'parseRuleLine', '/foo'); + $directoryRule = $this->invokePrivateArray($filter, 'parseRuleLine', 'build/'); + $nestedDirectoryRule = $this->invokePrivateArray($filter, 'parseRuleLine', 'src/build/'); + $slashRule = $this->invokePrivateArray($filter, 'parseRuleLine', 'nested/file?.[!o]'); $this->assertTrue($anchoredRule['hasSlash']); $this->assertTrue($directoryRule['directoryOnly']); @@ -108,10 +108,10 @@ public function itExercisesRuleParsingMatchingAndGlobInternals(): void $this->assertSame(['src', 'src/build'], $this->invokePrivate($filter, 'directoryPrefixes', 'src/build/output.php', false)); $this->assertSame(['src', 'src/build'], $this->invokePrivate($filter, 'directoryPrefixes', 'src/build', true)); - $doubleStarDirectoryRegex = $this->invokePrivate($filter, 'globToRegex', '**/file?.[!o]'); - $doubleStarRegex = $this->invokePrivate($filter, 'globToRegex', 'foo**bar'); - $escapedRegex = $this->invokePrivate($filter, 'globToRegex', '\*.txt'); - $unclosedRegex = $this->invokePrivate($filter, 'globToRegex', 'file['); + $doubleStarDirectoryRegex = $this->invokePrivateString($filter, 'globToRegex', '**/file?.[!o]'); + $doubleStarRegex = $this->invokePrivateString($filter, 'globToRegex', 'foo**bar'); + $escapedRegex = $this->invokePrivateString($filter, 'globToRegex', '\*.txt'); + $unclosedRegex = $this->invokePrivateString($filter, 'globToRegex', 'file['); $this->assertSame(1, preg_match($doubleStarDirectoryRegex, 'src/file1.c')); $this->assertSame(1, preg_match($doubleStarRegex, 'foo/bar')); @@ -119,6 +119,25 @@ public function itExercisesRuleParsingMatchingAndGlobInternals(): void $this->assertSame(1, preg_match($unclosedRegex, 'file[')); } + /** + * @return array + */ + private function invokePrivateArray(object $object, string $method, mixed ...$arguments): array + { + $result = $this->invokePrivate($object, $method, ...$arguments); + self::assertIsArray($result); + + return $result; + } + + private function invokePrivateString(object $object, string $method, mixed ...$arguments): string + { + $result = $this->invokePrivate($object, $method, ...$arguments); + self::assertIsString($result); + + return $result; + } + private function invokePrivate(object $object, string $method, mixed ...$arguments): mixed { $reflection = new \ReflectionMethod($object, $method);