diff --git a/phpunit.xml b/phpunit.xml index 5ab78a9..7cc5ad2 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,7 +14,7 @@ src/bootstrap.php - src/internal/Cli + src/Internal/Cli \ No newline at end of file diff --git a/src/Internal/Cli/CommandRunner.php b/src/Internal/Cli/CommandRunner.php index 9ed3571..eeb195b 100644 --- a/src/Internal/Cli/CommandRunner.php +++ b/src/Internal/Cli/CommandRunner.php @@ -9,6 +9,7 @@ final class CommandRunner private const KNOWN_COMMANDS = [ 'config:init', 'report', + 'report:merge', 'report:clear', 'cache:clear', 'cache:warm', @@ -47,6 +48,10 @@ public static function run(array $args, $outputStream = STDOUT, $errorStream = S return (new ReportCommand())->execute($args, $outputStream, $errorStream); } + if ($firstArg === 'report:merge') { + return (new ReportMergeCommand())->execute($args, $outputStream, $errorStream); + } + if ($firstArg === 'report:clear') { return (new ReportClearCommand())->execute($args, $outputStream, $errorStream); } diff --git a/src/Internal/Cli/HelpCommand.php b/src/Internal/Cli/HelpCommand.php index 35b49b1..8d4e8d4 100644 --- a/src/Internal/Cli/HelpCommand.php +++ b/src/Internal/Cli/HelpCommand.php @@ -16,6 +16,7 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE fwrite($outputStream, ' ' . $c('COMMANDS', 'yellow') . "\n"); fwrite($outputStream, ' ' . $c('config:init', 'green') . " Generate default typephp.php configuration file\n"); fwrite($outputStream, ' ' . $c('report', 'green') . " Display audit report summary in terminal\n"); + fwrite($outputStream, ' ' . $c('report:merge', 'green') . " Merge multiple JSON report files into a unified report\n"); fwrite($outputStream, ' ' . $c('report:clear', 'green') . " Delete generated report file and shards\n"); fwrite($outputStream, ' ' . $c('cache:clear', 'green') . " Clear all cached transformed files\n"); fwrite($outputStream, ' ' . $c('cache:warm', 'green') . " Pre-transform and warm up cache for included files\n"); @@ -24,6 +25,7 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE fwrite($outputStream, ' ' . $c('EXAMPLES', 'yellow') . "\n"); fwrite($outputStream, " vendor/bin/typephp config:init\n"); fwrite($outputStream, " vendor/bin/typephp report\n"); + fwrite($outputStream, " vendor/bin/typephp report:merge module1.json module2.json --output=all.json\n"); fwrite($outputStream, " vendor/bin/typephp index.php\n"); fwrite($outputStream, " vendor/bin/typephp cache:rebuild\n\n"); diff --git a/src/Internal/Cli/ReportCommand.php b/src/Internal/Cli/ReportCommand.php index 55fedbd..e11ef30 100644 --- a/src/Internal/Cli/ReportCommand.php +++ b/src/Internal/Cli/ReportCommand.php @@ -81,12 +81,24 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE $target = isset($item['target']) && \is_string($item['target']) ? $item['target'] : ''; $expected = isset($item['expected']) && \is_string($item['expected']) ? $item['expected'] : ''; $given = isset($item['given']) && \is_string($item['given']) ? $item['given'] : ''; + $count = isset($item['count']) && \is_int($item['count']) ? $item['count'] : 1; + $caller = isset($item['caller']) && \is_string($item['caller']) ? $item['caller'] : null; + $declaredIn = isset($item['declared_in']) && \is_string($item['declared_in']) ? $item['declared_in'] : null; $lineFormatted = str_pad("Line {$line}", 10); $kindFormatted = str_pad($kind, 11); $targetFormatted = str_pad($target, 12); + $countFormatted = $count > 1 ? $c(" (x{$count})", 'yellow') : ''; - fwrite($outputStream, ' ' . $c($lineFormatted, 'gray') . ' ' . $c($kindFormatted, 'yellow') . ' ' . $c($targetFormatted, 'cyan') . ' expected ' . $c($expected, 'green') . ', ' . $c($given, 'red') . "\n"); + fwrite($outputStream, ' ' . $c($lineFormatted, 'gray') . ' ' . $c($kindFormatted, 'yellow') . ' ' . $c($targetFormatted, 'cyan') . ' expected ' . $c($expected, 'green') . ', ' . $c($given, 'red') . $countFormatted . "\n"); + + if ($caller !== null) { + fwrite($outputStream, ' ' . $c('↳ caller:', 'gray') . ' ' . $c($caller, 'gray') . "\n"); + } + + if ($declaredIn !== null) { + fwrite($outputStream, ' ' . $c('↳ declared in:', 'gray') . ' ' . $c($declaredIn, 'gray') . "\n"); + } } fwrite($outputStream, "\n"); diff --git a/src/Internal/Cli/ReportMergeCommand.php b/src/Internal/Cli/ReportMergeCommand.php new file mode 100644 index 0000000..8b0e918 --- /dev/null +++ b/src/Internal/Cli/ReportMergeCommand.php @@ -0,0 +1,132 @@ + [--output=]\n\n"); + + return 1; + } + + /** @var array $mergedViolations */ + $mergedViolations = []; + $validFileCount = 0; + + foreach ($inputFiles as $file) { + if (! file_exists($file)) { + fwrite($errorStream, ' ' . $c('!', 'yellow') . ' Warning: Report file ' . $c('"' . $file . '"', 'bold') . " not found, skipping.\n"); + + continue; + } + + $rawContent = @file_get_contents($file); + if ($rawContent === false || trim($rawContent) === '') { + continue; + } + + /** @var array{violations?: list>}|null $data */ + $data = json_decode($rawContent, true); + if (! \is_array($data) || ! isset($data['violations']) || ! \is_array($data['violations'])) { + continue; + } + + $validFileCount++; + + foreach ($data['violations'] as $v) { + if (\is_array($v)) { + $record = ViolationRecord::fromArray($v); + if ($record !== null) { + $hash = $record->getHash(); + if (isset($mergedViolations[$hash])) { + $mergedViolations[$hash] = $mergedViolations[$hash]->withIncrementedCount($record->count); + } else { + $mergedViolations[$hash] = $record; + } + } + } + } + } + + $violationsList = array_values($mergedViolations); + $filesAffected = []; + foreach ($violationsList as $v) { + $filesAffected[$v->file] = true; + } + + $document = [ + 'version' => '1.0', + 'generated_at' => gmdate('c'), + 'summary' => [ + 'total_violations' => \count($violationsList), + 'files_affected' => \count($filesAffected), + ], + 'violations' => $violationsList, + ]; + + $json = json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + if ($outputFile !== null && $json !== false) { + $outputDir = \dirname($outputFile); + if (! is_dir($outputDir)) { + @mkdir($outputDir, 0777, true); + } + + file_put_contents($outputFile, $json); + + fwrite($outputStream, "\n " . $c(' TYPEPHP ', 'badge') . ' ' . $c('Report Merge', 'bold') . "\n\n"); + fwrite($outputStream, ' ' . $c('✓', 'green') . ' Merged ' . $c((string) $validFileCount, 'bold') . ' report file(s) into ' . $c('"' . $outputFile . '"', 'bold') . ' (' . $c((string) \count($violationsList), 'bold') . " unique violation(s)).\n\n"); + + return 0; + } + + if ($json !== false) { + fwrite($outputStream, $json . "\n"); + } + + return 0; + } +} diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index b1856f3..9c777dd 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -594,7 +594,7 @@ public static function parseMagicPropertyContract(string $className, string $pro * * @return array{doc: string, declaringClass: \ReflectionClass}|null */ - private static function findDeclaredPropertyDoc(\ReflectionClass $refClass, string $propertyName): ?array + public static function findDeclaredPropertyDoc(\ReflectionClass $refClass, string $propertyName): ?array { $current = $refClass; while ($current !== false) { @@ -1227,14 +1227,9 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar foreach ($classHierarchy as $hierClass) { $hierClassName = $hierClass->getName(); - $fileName = $hierClass->getFileName(); $stubDoc = StubManager::getClassDoc($hierClassName); - - if ($stubDoc === null && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) { - continue; - } - $classDoc = $stubDoc ?? $hierClass->getDocComment(); + if ($classDoc !== false && $classDoc !== null) { $classPhpDocNode = DocblockExtractor::parseDocString($classDoc); diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index fcbd4a4..c0b7462 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -30,7 +30,6 @@ use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Util\ClassNameValidator; use TypePHP\Internal\Util\Config; -use TypePHP\Internal\Util\FileFilter; use TypePHP\Internal\Util\StubManager; use WeakMap; @@ -1033,14 +1032,6 @@ private static function computeClassInheritedBindings(string $actualClassName): $classHierarchy = HierarchyResolver::getClassHierarchy($ref); foreach ($classHierarchy as $hierClass) { - $fileName = $hierClass->getFileName(); - $hierClassName = $hierClass->getName(); - $stubDoc = StubManager::getClassDoc($hierClassName); - - if ($stubDoc === null && $hierClass->getName() !== $actualClassName && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) { - continue; - } - $docsToInspect = self::collectDocsForClassHierarchyMember($hierClass); foreach ($docsToInspect as $rawDoc) { diff --git a/src/Internal/Reporting/ViolationCollector.php b/src/Internal/Reporting/ViolationCollector.php index c30c330..3b2429a 100644 --- a/src/Internal/Reporting/ViolationCollector.php +++ b/src/Internal/Reporting/ViolationCollector.php @@ -4,7 +4,12 @@ namespace TypePHP\Internal\Reporting; +use ReflectionClass; +use ReflectionFunction; +use Throwable; use TypePHP\Internal\Diagnostic\ErrorMessage; +use TypePHP\Internal\Docblock\DocblockParser; +use TypePHP\Internal\Resolver\HierarchyResolver; use TypePHP\Internal\Util\Config; /** @@ -26,6 +31,13 @@ final class ViolationCollector */ private static array $warnedHashes = []; + /** + * In-memory cache for property declaration lines: [$file][$propName] => ?int. + * + * @var array> + */ + private static array $propertyLineCache = []; + private static bool $shutdownRegistered = false; /** @@ -35,6 +47,7 @@ public static function reset(): void { self::$violations = []; self::$warnedHashes = []; + self::$propertyLineCache = []; } /** @@ -49,7 +62,8 @@ public static function handle( ?string $file = null, ?int $line = null, ?string $function = null, - ?string $target = null + ?string $target = null, + ?string $declaredIn = null ): mixed { $mode = Config::getOnViolation(); @@ -58,9 +72,19 @@ public static function handle( } $message = $error->getMessage(); - [$resolvedFile, $resolvedLine] = self::resolveCallSite($file, $line, $kind); + [$resolvedFile, $resolvedLine, $resolvedCaller] = self::resolveCallSite($file, $line, $kind); $parsed = self::parseMetadata($message, $kind, $function, $target); + $resolvedDeclaredIn = self::resolveDeclaredIn( + $declaredIn, + $parsed['function'], + $kind, + $parsed['target'], + $resolvedFile, + $resolvedLine, + $message + ); + $record = new ViolationRecord( file: self::normalizeRelativePath($resolvedFile), line: $resolvedLine, @@ -69,7 +93,10 @@ function: $parsed['function'], target: $parsed['target'], expected: $parsed['expected'], given: $parsed['given'], - message: $message + message: $message, + count: 1, + caller: $resolvedCaller, + declaredIn: $resolvedDeclaredIn ); $hash = $record->getHash(); @@ -107,11 +134,15 @@ private static function emitWarningOnce(string $hash, string $message, string $f } /** - * Records violation and ensures shutdown exporter is registered. + * Records violation or increments occurrence count if previously encountered. */ private static function recordViolation(string $hash, ViolationRecord $record): void { - self::$violations[$hash] = $record; + if (isset(self::$violations[$hash])) { + self::$violations[$hash] = self::$violations[$hash]->withIncrementedCount(); + } else { + self::$violations[$hash] = $record; + } if (! self::$shutdownRegistered) { self::registerShutdownHandler(); @@ -136,6 +167,7 @@ public static function clear(): void { self::$violations = []; self::$warnedHashes = []; + self::$propertyLineCache = []; } /** @@ -238,46 +270,102 @@ public static function exportReport(?string $filePath = null): ?string return null; } - $allViolations = self::$violations; $reportDir = \dirname($targetFile); $shardDir = $reportDir . '/.typephp-shards'; - if (is_dir($shardDir)) { - $shardFiles = glob($shardDir . '/shard_*.json'); - if ($shardFiles !== false) { - foreach ($shardFiles as $sFile) { - $raw = @file_get_contents($sFile); - if ($raw !== false) { - /** @var list>|null $decoded */ - $decoded = json_decode($raw, true); - if (\is_array($decoded)) { - foreach ($decoded as $item) { - if (\is_array($item)) { - $record = ViolationRecord::fromArray($item); - if ($record !== null) { - $allViolations[$record->getHash()] = $record; - } - } - } - } - } - @unlink($sFile); - } - } - @rmdir($shardDir); - } + $allViolations = self::consolidateShards(self::$violations, $shardDir); if (! is_dir($reportDir)) { @mkdir($reportDir, 0777, true); } - $violationsList = array_values($allViolations); + $document = self::buildReportDocument($allViolations); + $json = json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + if ($json === false || @file_put_contents($targetFile, $json) === false) { + return null; + } + + return $targetFile; + } + + /** + * @param array $baseViolations + * + * @return array + */ + private static function consolidateShards(array $baseViolations, string $shardDir): array + { + if (! is_dir($shardDir)) { + return $baseViolations; + } + + $shardFiles = glob($shardDir . '/shard_*.json'); + if ($shardFiles === false) { + @rmdir($shardDir); + + return $baseViolations; + } + + foreach ($shardFiles as $sFile) { + $raw = @file_get_contents($sFile); + if ($raw !== false) { + $baseViolations = self::mergeShardContent($baseViolations, $raw); + } + @unlink($sFile); + } + + @rmdir($shardDir); + + return $baseViolations; + } + + /** + * @param array $allViolations + * + * @return array + */ + private static function mergeShardContent(array $allViolations, string $raw): array + { + /** @var list>|null $decoded */ + $decoded = json_decode($raw, true); + if (! \is_array($decoded)) { + return $allViolations; + } + + foreach ($decoded as $item) { + if (! \is_array($item)) { + continue; + } + + $record = ViolationRecord::fromArray($item); + if ($record === null) { + continue; + } + + $h = $record->getHash(); + $allViolations[$h] = isset($allViolations[$h]) + ? $allViolations[$h]->withIncrementedCount($record->count) + : $record; + } + + return $allViolations; + } + + /** + * @param array $violations + * + * @return array{version: string, generated_at: string, summary: array{total_violations: int, files_affected: int}, violations: list} + */ + private static function buildReportDocument(array $violations): array + { + $violationsList = array_values($violations); $filesAffected = []; foreach ($violationsList as $v) { $filesAffected[$v->file] = true; } - $document = [ + return [ 'version' => '1.0', 'generated_at' => gmdate('c'), 'summary' => [ @@ -286,27 +374,231 @@ public static function exportReport(?string $filePath = null): ?string ], 'violations' => $violationsList, ]; + } - $json = json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if ($json !== false) { - @file_put_contents($targetFile, $json); + /** + * Resolves the declaration origin (file and line of the DocBlock). + */ + private static function resolveDeclaredIn( + ?string $explicitDeclaredIn, + string $function, + string $kind, + string $target, + string $resolvedFile, + int $resolvedLine, + string $message + ): ?string { + if ($explicitDeclaredIn !== null && $explicitDeclaredIn !== '') { + return self::normalizeRelativePath($explicitDeclaredIn); + } + + $genericDecl = self::resolveGenericBoundDeclaration($message); + if ($genericDecl !== null) { + return $genericDecl; + } + + if ($kind === 'variable') { + return self::normalizeRelativePath($resolvedFile) . ':' . $resolvedLine; + } + + try { + if ($kind === 'property' || str_contains($function, '::$')) { + return self::resolvePropertyDeclaration($function); + } + + if (str_contains($function, '::')) { + return self::resolveMethodDeclaration($function); + } + + return self::resolveFunctionDeclaration($function); + } catch (Throwable) { + return null; + } + } + + private static function resolveGenericBoundDeclaration(string $message): ?string + { + $hasPattern = preg_match('/does not satisfy upper bound .+? in ([a-zA-Z0-9_\\\\]+)/', $message, $m) === 1 + || preg_match('/of template .+? in ([a-zA-Z0-9_\\\\]+)/', $message, $m) === 1; + + if (! $hasPattern) { + return null; + } + + $className = trim($m[1]); + if (! (class_exists($className) || interface_exists($className) || trait_exists($className))) { + return null; + } + + try { + /** @var class-string $className */ + $refClass = new ReflectionClass($className); + $file = $refClass->getFileName(); + if ($file === false) { + return null; + } + + $startLine = $refClass->getStartLine(); + + return self::normalizeRelativePath($file) . ':' . ($startLine !== false ? $startLine : 1); + } catch (Throwable) { + return null; + } + } + + private static function resolvePropertyDeclaration(string $function): ?string + { + $rawTarget = str_replace('::$', '::', $function); + if (! str_contains($rawTarget, '::')) { + return null; + } + + [$className, $propName] = explode('::', $rawTarget, 2); + $propName = ltrim($propName, '$'); + + if (! (class_exists($className) || trait_exists($className) || interface_exists($className))) { + return null; + } + + /** @var class-string $className */ + $refClass = new ReflectionClass($className); + $resolved = DocblockParser::findDeclaredPropertyDoc($refClass, $propName); + if ($resolved === null || ! isset($resolved['declaringClass'])) { + return null; + } - return $targetFile; + $declaringClass = $resolved['declaringClass']; + $file = $declaringClass->getFileName(); + if ($file === false) { + return null; + } + + $propertyLine = self::findPropertyDeclarationLine($file, $propName); + $startLine = $declaringClass->getStartLine(); + $line = $propertyLine ?? ($startLine !== false ? $startLine : 1); + + return self::normalizeRelativePath($file) . ':' . $line; + } + + private static function findPropertyDeclarationLine(string $file, string $propName): ?int + { + if (isset(self::$propertyLineCache[$file][$propName]) || \array_key_exists($propName, self::$propertyLineCache[$file] ?? [])) { + return self::$propertyLineCache[$file][$propName]; + } + + if (! file_exists($file)) { + return self::$propertyLineCache[$file][$propName] = null; + } + + $source = @file_get_contents($file); + if ($source === false || ! str_contains($source, '$' . $propName)) { + return self::$propertyLineCache[$file][$propName] = null; + } + + try { + $tokens = \PhpToken::tokenize($source); + $count = \count($tokens); + $hasModifier = false; + + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; + + if (\in_array($token->id, [T_PUBLIC, T_PROTECTED, T_PRIVATE, T_VAR, T_READONLY], true)) { + $hasModifier = true; + } elseif ($token->id === T_FUNCTION || $token->text === ';' || $token->text === '{') { + $hasModifier = false; + } + + if ($hasModifier && $token->id === T_VARIABLE && $token->text === '$' . $propName) { + return self::$propertyLineCache[$file][$propName] = $token->line; + } + } + } catch (Throwable) { + return self::$propertyLineCache[$file][$propName] = null; + } + + return self::$propertyLineCache[$file][$propName] = null; + } + + private static function resolveMethodDeclaration(string $function): ?string + { + [$className, $methodName] = explode('::', $function, 2); + if (! (class_exists($className) || trait_exists($className) || interface_exists($className))) { + return null; } - return null; + /** @var class-string $className */ + $refClass = new ReflectionClass($className); + if (! $refClass->hasMethod($methodName)) { + return null; + } + + $refMethod = $refClass->getMethod($methodName); + $hierarchy = HierarchyResolver::getMethodHierarchy($refMethod); + + foreach ($hierarchy as $hierMethod) { + $doc = $hierMethod->getDocComment(); + if ($doc !== false && $doc !== null) { + $file = $hierMethod->getFileName(); + if ($file !== false) { + return self::normalizeRelativePath($file) . ':' . $hierMethod->getStartLine(); + } + } + } + + $file = $refMethod->getFileName(); + + return $file !== false ? self::normalizeRelativePath($file) . ':' . $refMethod->getStartLine() : null; + } + + private static function resolveFunctionDeclaration(string $function): ?string + { + if ($function === '' || ! \function_exists($function)) { + return null; + } + + $refFunc = new ReflectionFunction($function); + $file = $refFunc->getFileName(); + + return $file !== false ? self::normalizeRelativePath($file) . ':' . $refFunc->getStartLine() : null; } /** - * @return array{0: string, 1: int} + * @return array{0: string, 1: int, 2: ?string} */ private static function resolveCallSite(?string $explicitFile, ?int $explicitLine, string $kind = 'parameter'): array { + $appFrames = self::collectApplicationFrames(); + if ($explicitFile !== null && $explicitFile !== '' && $explicitLine !== null && $explicitLine > 0) { - return [$explicitFile, $explicitLine]; + $callerLabel = self::formatCallerLabel($appFrames, 0); + + return [$explicitFile, $explicitLine, $callerLabel]; + } + + if (($kind === 'parameter' || $kind === 'callback') && \count($appFrames) >= 2) { + $f = $appFrames[1]; + $callerLabel = self::formatCallerLabel($appFrames, 1); + + return [$f['file'], $f['line'], $callerLabel]; + } + + if ($appFrames !== []) { + $f = $appFrames[0]; + $callerLabel = self::formatCallerLabel($appFrames, 1); + + return [$f['file'], $f['line'], $callerLabel]; } - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10); + return [$explicitFile ?? 'unknown', $explicitLine ?? 1, null]; + } + + /** + * @return list + */ + private static function collectApplicationFrames(): array + { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 12); $appFrames = []; foreach ($trace as $frame) { @@ -315,23 +607,45 @@ private static function resolveCallSite(?string $explicitFile, ?int $explicitLin } $normalizedFile = str_replace('\\', '/', $frame['file']); - if (str_contains($normalizedFile, 'src/Internal/') || str_contains($normalizedFile, 'bin/typephp')) { + if ( + str_contains($normalizedFile, 'src/Internal/') + || str_contains($normalizedFile, 'bin/typephp') + || str_contains($normalizedFile, 'vendor/phpunit/') + || str_contains($normalizedFile, 'vendor/pestphp/') + ) { continue; } $l = isset($frame['line']) && \is_int($frame['line']) ? $frame['line'] : 1; - $appFrames[] = [$frame['file'], $l]; - } + $func = $frame['function']; + $class = isset($frame['class']) && \is_string($frame['class']) ? $frame['class'] . '::' : ''; + $callLabel = $class . $func; + if ($callLabel !== '' && ! str_ends_with($callLabel, '()')) { + $callLabel .= '()'; + } - if (($kind === 'parameter' || $kind === 'callback') && \count($appFrames) >= 2) { - return $appFrames[1]; + $appFrames[] = [ + 'file' => $frame['file'], + 'line' => $l, + 'caller' => $callLabel !== '' ? $callLabel : null, + ]; } - if ($appFrames !== []) { - return $appFrames[0]; + return $appFrames; + } + + /** + * @param list $appFrames + */ + private static function formatCallerLabel(array $appFrames, int $index): ?string + { + if (! isset($appFrames[$index])) { + return null; } - return [$explicitFile !== null ? $explicitFile : 'unknown', $explicitLine !== null ? $explicitLine : 1]; + $f = $appFrames[$index]; + + return self::normalizeRelativePath($f['file']) . ':' . $f['line'] . ($f['caller'] !== null ? " ({$f['caller']})" : ''); } /** @@ -339,46 +653,13 @@ private static function resolveCallSite(?string $explicitFile, ?int $explicitLin */ private static function parseMetadata(string $message, string $kind, ?string $explicitFunction, ?string $explicitTarget): array { - $function = $explicitFunction !== null ? $explicitFunction : ''; - $target = $explicitTarget !== null ? $explicitTarget : ''; - $expected = ''; - $given = ''; - - if ($function === '') { - if (preg_match('/^([a-zA-Z0-9_\\\\]+(?:::[a-zA-Z0-9_\x80-\xff]+)?)\(\):/', $message, $m) === 1) { - $function = trim($m[1]); - } elseif (preg_match('/^Property\s+([a-zA-Z0-9_\\\\]+(?:::\$[a-zA-Z0-9_\x80-\xff]+)?)/', $message, $m) === 1) { - $function = trim($m[1]); - } elseif (preg_match('/^([^:]+):/', $message, $m) === 1) { - $function = trim($m[1]); - } - } + $function = $explicitFunction ?? self::extractFunctionFromMessage($message); + $target = $explicitTarget ?? self::extractTargetFromMessage($message, $kind); - if (preg_match('/(?:Argument|Parameter)\s+(\$[a-zA-Z0-9_]+)/', $message, $m) === 1 && $target === '') { - $target = $m[1]; - } elseif (preg_match('/(?:Property)\s+[^$]*(\$[a-zA-Z0-9_]+)/', $message, $m) === 1 && $target === '') { - $target = $m[1]; - } elseif (preg_match('/(?:Variable)\s+(\$[a-zA-Z0-9_]+)/', $message, $m) === 1 && $target === '') { - $target = $m[1]; - } elseif (str_contains($message, 'Return value') && $target === '') { - $target = 'return'; - } + [$expected, $given, $inferredFunction] = self::extractExpectedAndGiven($message); - if (preg_match('/must be of type (.+?),\s*(.+?)\s*(?:given|returned)$/', $message, $m) === 1) { - $expected = trim($m[1]); - $given = trim($m[2]); - } elseif (preg_match('/must be (.+?),\s*(.+?)\s*(?:given|returned)$/', $message, $m) === 1) { - $expected = trim($m[1]); - $given = trim($m[2]); - } elseif (preg_match('/is missing required (?:key|property)\s*\'([^\']+)\'/', $message, $m) === 1) { - $expected = "required '{$m[1]}'"; - $given = 'missing'; - } elseif (preg_match('/contains unsealed unexpected key\s*\'([^\']+)\'/', $message, $m) === 1) { - $expected = 'sealed shape'; - $given = "unexpected key '{$m[1]}'"; - } elseif (preg_match('/expects (.+?),\s*but\s*(.+?)\s*was\s*(?:given|returned)/', $message, $m) === 1) { - $expected = trim($m[1]); - $given = trim($m[2]); + if ($function === '' && $inferredFunction !== null) { + $function = $inferredFunction; } return [ @@ -388,4 +669,67 @@ private static function parseMetadata(string $message, string $kind, ?string $ex 'given' => $given !== '' ? $given : 'invalid value', ]; } + + private static function extractFunctionFromMessage(string $message): string + { + if (preg_match('/^([a-zA-Z0-9_\\\\]+(?:::[a-zA-Z0-9_\x80-\xff]+)?)\(\):/', $message, $m) === 1) { + return trim($m[1]); + } + if (preg_match('/^Property\s+([a-zA-Z0-9_\\\\]+(?:::\$[a-zA-Z0-9_\x80-\xff]+)?)/', $message, $m) === 1) { + return trim($m[1]); + } + if (preg_match('/^([^:]+):/', $message, $m) === 1) { + return trim($m[1]); + } + + return ''; + } + + private static function extractTargetFromMessage(string $message, string $kind): string + { + if (preg_match('/(?:Argument|Parameter)\s+(\$[a-zA-Z0-9_]+)/', $message, $m) === 1) { + return $m[1]; + } + if (preg_match('/(?:Property)\s+[^$]*(\$[a-zA-Z0-9_]+)/', $message, $m) === 1) { + return $m[1]; + } + if (preg_match('/(?:Variable)\s+(\$[a-zA-Z0-9_]+)/', $message, $m) === 1) { + return $m[1]; + } + if (str_contains($message, 'Return value')) { + return 'return'; + } + + return $kind; + } + + /** + * @return array{0: string, 1: string, 2: ?string} + */ + private static function extractExpectedAndGiven(string $message): array + { + if (preg_match('/Generic type argument (.+?) does not satisfy upper bound (.+?) of template ([a-zA-Z0-9_]+) in ([a-zA-Z0-9_\\\\]+)/', $message, $m) === 1) { + return [trim($m[2]), 'type argument ' . trim($m[1]), trim($m[4])]; + } + if (preg_match('/must be of type (.+?),\s*(.+?)\s*(?:given|returned)$/', $message, $m) === 1) { + return [trim($m[1]), trim($m[2]), null]; + } + if (preg_match('/must be (.+?),\s*(.+?)\s*(?:given|returned)$/', $message, $m) === 1) { + return [trim($m[1]), trim($m[2]), null]; + } + if (preg_match('/is missing required (?:key|property)\s*\'([^\']+)\'/', $message, $m) === 1) { + return ["required '{$m[1]}'", 'missing', null]; + } + if (preg_match('/contains unsealed unexpected key\s*\'([^\']+)\'/', $message, $m) === 1) { + return ['sealed shape', "unexpected key '{$m[1]}'", null]; + } + if (preg_match('/contains unexpected property\s*\'([^\']+)\'/', $message, $m) === 1) { + return ['sealed shape', "unexpected property '{$m[1]}'", null]; + } + if (preg_match('/expects (.+?),\s*but\s*(.+?)\s*was\s*(?:given|returned)/', $message, $m) === 1) { + return [trim($m[1]), trim($m[2]), null]; + } + + return ['', '', null]; + } } diff --git a/src/Internal/Reporting/ViolationRecord.php b/src/Internal/Reporting/ViolationRecord.php index 00ccd2d..4ceb3a8 100644 --- a/src/Internal/Reporting/ViolationRecord.php +++ b/src/Internal/Reporting/ViolationRecord.php @@ -9,20 +9,23 @@ /** * @internal Immutable value object representing a single type contract violation. */ -final readonly class ViolationRecord implements JsonSerializable +final class ViolationRecord implements JsonSerializable { /** * @param 'parameter'|'return'|'property'|'variable'|'param-out'|'self-out'|'callback'|'yield'|'send' $kind */ public function __construct( - public string $file, - public int $line, - public string $function, - public string $kind, - public string $target, - public string $expected, - public string $given, - public string $message + public readonly string $file, + public readonly int $line, + public readonly string $function, + public readonly string $kind, + public readonly string $target, + public readonly string $expected, + public readonly string $given, + public readonly string $message, + public readonly int $count = 1, + public readonly ?string $caller = null, + public readonly ?string $declaredIn = null ) { } @@ -34,6 +37,26 @@ public function getHash(): string return hash('xxh128', "{$this->file}:{$this->line}:{$this->function}:{$this->kind}:{$this->target}:{$this->expected}:{$this->given}"); } + /** + * Returns a new instance with the count incremented by the specified amount. + */ + public function withIncrementedCount(int $amount = 1): self + { + return new self( + file: $this->file, + line: $this->line, + function: $this->function, + kind: $this->kind, + target: $this->target, + expected: $this->expected, + given: $this->given, + message: $this->message, + count: $this->count + $amount, + caller: $this->caller, + declaredIn: $this->declaredIn + ); + } + /** * @return array{ * file: string, @@ -43,6 +66,9 @@ public function getHash(): string * target: string, * expected: string, * given: string, + * count: int, + * caller: ?string, + * declared_in: ?string, * message: string * } */ @@ -56,6 +82,9 @@ public function toArray(): array 'target' => $this->target, 'expected' => $this->expected, 'given' => $this->given, + 'count' => $this->count, + 'caller' => $this->caller, + 'declared_in' => $this->declaredIn, 'message' => $this->message, ]; } @@ -85,6 +114,12 @@ public static function fromArray(array $data): ?self /** @var 'parameter'|'return'|'property'|'variable'|'param-out'|'self-out'|'callback'|'yield'|'send' $kind */ $kind = isset($data['kind']) && \is_string($data['kind']) ? $data['kind'] : 'parameter'; + $count = isset($data['count']) && \is_int($data['count']) && $data['count'] > 0 ? $data['count'] : 1; + $caller = isset($data['caller']) && \is_string($data['caller']) ? $data['caller'] : null; + $declaredIn = isset($data['declared_in']) && \is_string($data['declared_in']) + ? $data['declared_in'] + : (isset($data['declaredIn']) && \is_string($data['declaredIn']) ? $data['declaredIn'] : null); + return new self( file: $data['file'], line: (int) $data['line'], @@ -93,7 +128,10 @@ function: isset($data['function']) && \is_string($data['function']) ? $data['fun target: isset($data['target']) && \is_string($data['target']) ? $data['target'] : 'unknown', expected: isset($data['expected']) && \is_string($data['expected']) ? $data['expected'] : 'valid type', given: isset($data['given']) && \is_string($data['given']) ? $data['given'] : 'invalid value', - message: $data['message'] + message: $data['message'], + count: $count, + caller: $caller, + declaredIn: $declaredIn ); } } diff --git a/tests/Feature/ViolationReportingTest.php b/tests/Feature/ViolationReportingTest.php index 51c8005..eebb203 100644 --- a/tests/Feature/ViolationReportingTest.php +++ b/tests/Feature/ViolationReportingTest.php @@ -17,6 +17,19 @@ require_once __DIR__ . '/../Fixtures/PropertyHooks/ReportingHookedFixture.php'; } +trait ReportingBaseTrait +{ + /** + * @var positive-int + */ + public int $traitScore = 5; +} + +class ReportingInheritedModel +{ + use ReportingBaseTrait; +} + class ReportingModelFixture { /** @@ -162,6 +175,8 @@ function testReportingSensitiveParamFunc( ->and($violations[0]->kind)->toBe('parameter') ->and($violations[0]->expected)->toBe('positive-int') ->and($violations[0]->given)->toBe('negative int (-10)') + ->and($violations[0]->count)->toBe(1) + ->and($violations[0]->caller)->toContain('ViolationReportingTest.php') ; }); @@ -177,6 +192,8 @@ function testReportingSensitiveParamFunc( ->and($violations[0]->kind)->toBe('parameter') ->and($violations[0]->function)->toContain('formatUser') ->and($violations[0]->target)->toBe('$id') + ->and($violations[0]->count)->toBe(1) + ->and($violations[0]->caller)->toContain('ViolationReportingTest.php') ; }); }); @@ -193,6 +210,7 @@ function testReportingSensitiveParamFunc( ->and($violations[0]->kind)->toBe('return') ->and($violations[0]->target)->toBe('return') ->and($violations[0]->expected)->toBe('positive-int') + ->and($violations[0]->count)->toBe(1) ; }); @@ -208,6 +226,7 @@ function testReportingSensitiveParamFunc( ->and($violations[0]->kind)->toBe('return') ->and($violations[0]->expected)->toBe('positive-int') ->and($violations[0]->given)->toBe('negative int (-999)') + ->and($violations[0]->count)->toBe(1) ; }); }); @@ -225,6 +244,7 @@ function testReportingSensitiveParamFunc( ->and($violations[0]->kind)->toBe('property') ->and($violations[0]->target)->toBe('$score') ->and($violations[0]->expected)->toBe('positive-int') + ->and($violations[0]->count)->toBe(1) ; }); @@ -238,6 +258,7 @@ function testReportingSensitiveParamFunc( ->and($violations[0]->kind)->toBe('property') ->and($violations[0]->target)->toBe('$category') ->and($violations[0]->expected)->toBe('non-empty-string') + ->and($violations[0]->count)->toBe(1) ; }); }); @@ -255,6 +276,7 @@ function testReportingSensitiveParamFunc( ->and($violations[0]->kind)->toBe('variable') ->and($violations[0]->target)->toBe('$userCount') ->and($violations[0]->expected)->toBe('positive-int') + ->and($violations[0]->count)->toBe(1) ; }); }); @@ -273,6 +295,7 @@ function testReportingSensitiveParamFunc( ->and($violations[0]->kind)->toBe('param-out') ->and($violations[0]->target)->toBe('$code') ->and($violations[0]->expected)->toBe('positive-int') + ->and($violations[0]->count)->toBe(1) ; }); }); @@ -289,6 +312,7 @@ function testReportingSensitiveParamFunc( $violations = TypePHP::getViolations(); expect($violations)->not()->toBeEmpty() ->and($violations[0]->kind)->toBe('callback') + ->and($violations[0]->count)->toBe(1) ; }); }); @@ -330,6 +354,7 @@ function testReportingSensitiveParamFunc( expect($violations)->toHaveCount(1) ->and($violations[0]->kind)->toBe('parameter') ->and($violations[0]->expected)->toContain(Dog::class) + ->and($violations[0]->count)->toBe(1) ; }); }); @@ -354,6 +379,7 @@ function testReportingSensitiveParamFunc( ->and($violations[0]->kind)->toBe('parameter') ->and($violations[0]->expected)->toBe('positive-int') ->and($violations[0]->given)->toBe('negative int (-10)') + ->and($violations[0]->count)->toBe(1) ; }); }); @@ -376,6 +402,7 @@ function testReportingSensitiveParamFunc( expect($violations)->toHaveCount(1) ->and($violations[0]->given)->toBe('string') ->and($violations[0]->given)->not()->toContain('super_secret') + ->and($violations[0]->count)->toBe(1) ; }); }); @@ -397,12 +424,13 @@ function testReportingSensitiveParamFunc( $violations = TypePHP::getViolations(); expect($violations)->not()->toBeEmpty() ->and($violations[0]->kind)->toBe('property') + ->and($violations[0]->count)->toBe(1) ; }); }); - describe('High-Volume Loop Stress Testing & Deduplication', function () { - test('records exactly 1 violation in memory when loop executes 5,000 failing calls', function () { + describe('High-Volume Loop Stress Testing & Occurrence Counting', function () { + test('records exactly 1 violation in memory with count 5,000 when loop executes 5,000 failing calls', function () { Config::set(['on_violation' => 'report']); for ($i = 0; $i < 5000; $i++) { @@ -412,15 +440,18 @@ function testReportingSensitiveParamFunc( $violations = TypePHP::getViolations(); expect($violations)->toHaveCount(1) ->and($violations[0]->expected)->toBe('positive-int') + ->and($violations[0]->count)->toBe(5000) ; }); - test('emits E_USER_WARNING exactly once when loop executes 5,000 failing calls in warn mode', function () { + test('emits E_USER_WARNING exactly once with call site location when loop executes 5,000 failing calls in warn mode', function () { Config::set(['on_violation' => 'warn']); $warnCount = 0; - set_error_handler(function () use (&$warnCount): bool { + $warningMsg = ''; + set_error_handler(function (int $errno, string $errstr) use (&$warnCount, &$warningMsg): bool { $warnCount++; + $warningMsg = $errstr; return true; }); @@ -433,12 +464,14 @@ function testReportingSensitiveParamFunc( restore_error_handler(); } - expect($warnCount)->toBe(1); + expect($warnCount)->toBe(1) + ->and($warningMsg)->toContain('ViolationReportingTest.php') + ; }); }); - describe('JSON Report Export & Corrupted Shard Recovery', function () { - test('exports structured JSON report matching document schema via TypePHP::exportReport', function () { + describe('JSON Report Export & Enhanced Metadata Schema', function () { + test('exports structured JSON report matching document schema with count, caller, and declared_in', function () { $tempDir = sys_get_temp_dir() . '/typephp_feature_report_' . uniqid(); mkdir($tempDir, 0777, true); $reportFilePath = $tempDir . '/typephp-report.json'; @@ -468,6 +501,10 @@ function testReportingSensitiveParamFunc( ->and($doc['summary']['total_violations'])->toBe(3) ->and($doc['summary']['files_affected'])->toBeGreaterThanOrEqual(1) ->and($doc['violations'])->toHaveCount(3) + ->and($doc['violations'][0])->toHaveKey('count') + ->and($doc['violations'][0])->toHaveKey('caller') + ->and($doc['violations'][0])->toHaveKey('declared_in') + ->and($doc['violations'][0]['count'])->toBe(1) ; @unlink($reportFilePath); diff --git a/tests/Fixtures/SplitInclude/Commands/FieldBuilder.php b/tests/Fixtures/SplitInclude/Commands/FieldBuilder.php new file mode 100644 index 0000000..176c87d --- /dev/null +++ b/tests/Fixtures/SplitInclude/Commands/FieldBuilder.php @@ -0,0 +1,23 @@ +entity = $entity; + + return $field; + } + + public function probe(InputField $field): void + { + $field->count = -1; + } +} diff --git a/tests/Fixtures/SplitInclude/Entities/Project.php b/tests/Fixtures/SplitInclude/Entities/Project.php new file mode 100644 index 0000000..fadc147 --- /dev/null +++ b/tests/Fixtures/SplitInclude/Entities/Project.php @@ -0,0 +1,9 @@ + + */ + public int $count = 0; +} diff --git a/tests/Fixtures/SplitInclude/Fields/InputField.php b/tests/Fixtures/SplitInclude/Fields/InputField.php new file mode 100644 index 0000000..b5c6419 --- /dev/null +++ b/tests/Fixtures/SplitInclude/Fields/InputField.php @@ -0,0 +1,14 @@ + + */ +abstract class InputField extends FieldBase +{ +} diff --git a/tests/Fixtures/SplitInclude/Fields/TextInputField.php b/tests/Fixtures/SplitInclude/Fields/TextInputField.php new file mode 100644 index 0000000..44c7ff6 --- /dev/null +++ b/tests/Fixtures/SplitInclude/Fields/TextInputField.php @@ -0,0 +1,9 @@ +toBe(0) ->and($output)->toContain('USAGE') ->and($output)->toContain('report') + ->and($output)->toContain('report:merge') ; }); @@ -48,6 +49,156 @@ ; }); + test('routes report:merge command and writes merged output file with aggregated counts', function () { + $tempDir = sys_get_temp_dir() . '/typephp_cli_merge_' . uniqid(); + mkdir($tempDir, 0777, true); + + $file1 = $tempDir . '/mod1.json'; + $file2 = $tempDir . '/mod2.json'; + $outFile = $tempDir . '/merged.json'; + + $data1 = [ + 'version' => '1.0', + 'summary' => ['total_violations' => 1], + 'violations' => [ + [ + 'file' => 'src/Order.php', + 'line' => 10, + 'function' => 'App\\Order::pay', + 'kind' => 'parameter', + 'target' => '$id', + 'expected' => 'positive-int', + 'given' => 'int (-1)', + 'count' => 5, + 'caller' => 'tests/OrderTest.php:20', + 'message' => 'err', + ], + ], + ]; + + $data2 = [ + 'version' => '1.0', + 'summary' => ['total_violations' => 1], + 'violations' => [ + [ + 'file' => 'src/Order.php', + 'line' => 10, + 'function' => 'App\\Order::pay', + 'kind' => 'parameter', + 'target' => '$id', + 'expected' => 'positive-int', + 'given' => 'int (-1)', + 'count' => 15, + 'caller' => 'tests/OrderTest.php:20', + 'message' => 'err', + ], + ], + ]; + + file_put_contents($file1, json_encode($data1)); + file_put_contents($file2, json_encode($data2)); + + $stream = fopen('php://memory', 'r+'); + + try { + $exitCode = CommandRunner::run(['report:merge', $file1, $file2, "--output={$outFile}"], $stream, $stream); + + rewind($stream); + $rawOutput = (string) stream_get_contents($stream); + $output = (string) preg_replace('/\x1b\[[0-9;]*m/', '', $rawOutput); + + expect($exitCode)->toBe(0) + ->and($output)->toContain('Merged 2 report file(s)') + ->and(file_exists($outFile))->toBeTrue() + ; + + $mergedData = json_decode((string) file_get_contents($outFile), true); + + expect($mergedData['summary']['total_violations'])->toBe(1) + ->and($mergedData['violations'][0]['count'])->toBe(20) + ; + } finally { + fclose($stream); + @unlink($file1); + @unlink($file2); + @unlink($outFile); + @rmdir($tempDir); + } + }); + + test('routes report:merge command without output option and streams JSON to output stream', function () { + $tempDir = sys_get_temp_dir() . '/typephp_cli_merge_stdout_' . uniqid(); + mkdir($tempDir, 0777, true); + + $file1 = $tempDir . '/mod1.json'; + + $data = [ + 'version' => '1.0', + 'summary' => ['total_violations' => 1], + 'violations' => [ + [ + 'file' => 'src/User.php', + 'line' => 12, + 'function' => 'App\\User::find', + 'kind' => 'parameter', + 'target' => '$id', + 'expected' => 'positive-int', + 'given' => 'int (0)', + 'count' => 2, + 'message' => 'err', + ], + ], + ]; + + file_put_contents($file1, json_encode($data)); + + $stream = fopen('php://memory', 'r+'); + + try { + $exitCode = CommandRunner::run(['report:merge', $file1], $stream, $stream); + + rewind($stream); + $output = (string) stream_get_contents($stream); + + expect($exitCode)->toBe(0) + ->and($output)->toContain('"version": "1.0"') + ->and($output)->toContain('"file": "src/User.php"') + ; + } finally { + fclose($stream); + @unlink($file1); + @rmdir($tempDir); + } + }); + + test('routes report:merge command error when no files provided', function () { + $stream = fopen('php://memory', 'r+'); + $exitCode = CommandRunner::run(['report:merge'], $stream, $stream); + + rewind($stream); + $output = (string) stream_get_contents($stream); + fclose($stream); + + expect($exitCode)->toBe(1) + ->and($output)->toContain('No input report files specified') + ; + }); + + test('routes report:merge command warning when file does not exist', function () { + $stream = fopen('php://memory', 'r+'); + $missingFile = sys_get_temp_dir() . '/missing_report_' . uniqid() . '.json'; + + $exitCode = CommandRunner::run(['report:merge', $missingFile], $stream, $stream); + + rewind($stream); + $output = (string) stream_get_contents($stream); + fclose($stream); + + expect($exitCode)->toBe(0) + ->and($output)->toContain('not found, skipping') + ; + }); + test('routes report command and displays formatted violations with exit code 1', function () { $tempDir = sys_get_temp_dir() . '/typephp_cli_report_' . uniqid(); mkdir($tempDir, 0777, true); @@ -66,6 +217,7 @@ 'target' => '$total', 'expected' => 'positive-int', 'given' => 'negative int (-50)', + 'count' => 1, 'message' => 'Argument $total must be of type positive-int, negative int (-50) given', ], ], @@ -97,6 +249,58 @@ } }); + test('routes report command and displays occurrence count and caller breadcrumbs', function () { + $tempDir = sys_get_temp_dir() . '/typephp_cli_report_caller_' . uniqid(); + mkdir($tempDir, 0777, true); + $reportFile = $tempDir . '/typephp-report.json'; + + $reportData = [ + 'version' => '1.0', + 'generated_at' => gmdate('c'), + 'summary' => ['total_violations' => 1, 'files_affected' => 1], + 'violations' => [ + [ + 'file' => 'src/Services/OrderService.php', + 'line' => 42, + 'function' => 'App\\Services\\OrderService::checkout', + 'kind' => 'parameter', + 'target' => '$total', + 'expected' => 'positive-int', + 'given' => 'negative int (-50)', + 'count' => 412, + 'caller' => 'tests/Feature/OrderTest.php:100 (Tests\\OrderTest::testCheckout)', + 'declared_in' => 'src/Contracts/OrderInterface.php:15', + 'message' => 'Argument $total must be of type positive-int, negative int (-50) given', + ], + ], + ]; + + file_put_contents($reportFile, json_encode($reportData)); + Config::set(['report_file' => $reportFile]); + + $stream = fopen('php://memory', 'r+'); + + try { + $exitCode = CommandRunner::run(['report'], $stream, $stream); + + rewind($stream); + $rawOutput = (string) stream_get_contents($stream); + $output = (string) preg_replace('/\x1b\[[0-9;]*m/', '', $rawOutput); + + expect($exitCode)->toBe(1) + ->and($output)->toContain('(x412)') + ->and($output)->toContain('↳ caller:') + ->and($output)->toContain('tests/Feature/OrderTest.php:100') + ->and($output)->toContain('↳ declared in:') + ->and($output)->toContain('src/Contracts/OrderInterface.php:15') + ; + } finally { + fclose($stream); + @unlink($reportFile); + @rmdir($tempDir); + } + }); + test('routes config:init command successfully', function () { $stream = fopen('php://memory', 'r+'); $exitCode = CommandRunner::run(['config:init'], $stream, $stream); diff --git a/tests/Internal/Reporting/ViolationCollectorTest.php b/tests/Internal/Reporting/ViolationCollectorTest.php index 862be86..d70fd81 100644 --- a/tests/Internal/Reporting/ViolationCollectorTest.php +++ b/tests/Internal/Reporting/ViolationCollectorTest.php @@ -10,7 +10,7 @@ use TypePHP\Internal\Util\Config; describe('ViolationRecord Value Object', function () { - test('instantiates with complete metadata and generates deterministic hash', function () { + test('instantiates with complete metadata including count, caller, and declared_in', function () { $record = new ViolationRecord( file: 'src/Services/PaymentService.php', line: 42, @@ -19,7 +19,10 @@ function: 'App\\Services\\PaymentService::charge', target: '$amount', expected: 'positive-int', given: 'negative int (-50)', - message: 'App\\Services\\PaymentService::charge(): Argument $amount must be of type positive-int, negative int (-50) given' + message: 'App\\Services\\PaymentService::charge(): Argument $amount must be of type positive-int, negative int (-50) given', + count: 3, + caller: 'tests/Feature/OrderTest.php:100 (Tests\\OrderTest::testCheckout)', + declaredIn: 'src/Contracts/PaymentInterface.php:15' ); expect($record->file)->toBe('src/Services/PaymentService.php') @@ -29,12 +32,14 @@ function: 'App\\Services\\PaymentService::charge', ->and($record->target)->toBe('$amount') ->and($record->expected)->toBe('positive-int') ->and($record->given)->toBe('negative int (-50)') - ->and($record->getHash())->toBeString() + ->and($record->count)->toBe(3) + ->and($record->caller)->toBe('tests/Feature/OrderTest.php:100 (Tests\\OrderTest::testCheckout)') + ->and($record->declaredIn)->toBe('src/Contracts/PaymentInterface.php:15') ->and(\strlen($record->getHash()))->toBe(32) ; }); - test('serializes to array and json matching document schema', function () { + test('serializes to array and json matching new schema fields', function () { $record = new ViolationRecord( file: 'src/Models/User.php', line: 10, @@ -43,7 +48,10 @@ function: 'App\\Models\\User::setName', target: '$name', expected: 'non-empty-string', given: "empty string ('')", - message: "Argument \$name must be of type non-empty-string, empty string ('') given" + message: "Argument \$name must be of type non-empty-string, empty string ('') given", + count: 1, + caller: 'src/Controllers/UserController.php:25', + declaredIn: 'src/Traits/NameTrait.php:8' ); $array = $record->toArray(); @@ -55,17 +63,21 @@ function: 'App\\Models\\User::setName', 'target' => '$name', 'expected' => 'non-empty-string', 'given' => "empty string ('')", + 'count' => 1, + 'caller' => 'src/Controllers/UserController.php:25', + 'declared_in' => 'src/Traits/NameTrait.php:8', 'message' => "Argument \$name must be of type non-empty-string, empty string ('') given", ]); $json = json_encode($record, JSON_UNESCAPED_SLASHES); expect($json)->toBeString() - ->and($json)->toContain('"file":"src/Models/User.php"') - ->and($json)->toContain('"target":"$name"') + ->and($json)->toContain('"count":1') + ->and($json)->toContain('"caller":"src/Controllers/UserController.php:25"') + ->and($json)->toContain('"declared_in":"src/Traits/NameTrait.php:8"') ; }); - test('fromArray reconstitutes a ViolationRecord and handles fallbacks on missing data', function () { + test('fromArray reconstitutes a ViolationRecord with count, caller, and declared_in', function () { $data = [ 'file' => 'src/Order.php', 'line' => 25, @@ -74,18 +86,117 @@ function: 'App\\Models\\User::setName', 'target' => 'return', 'expected' => 'bool', 'given' => 'int (0)', + 'count' => 12, + 'caller' => 'src/Command/RunOrder.php:50', + 'declared_in' => 'src/Contracts/OrderInterface.php:12', 'message' => 'Return value must be of type bool, int (0) returned', ]; $record = ViolationRecord::fromArray($data); expect($record)->not()->toBeNull() ->and($record?->file)->toBe('src/Order.php') - ->and($record?->line)->toBe(25) - ->and($record?->kind)->toBe('return') + ->and($record?->count)->toBe(12) + ->and($record?->caller)->toBe('src/Command/RunOrder.php:50') + ->and($record?->declaredIn)->toBe('src/Contracts/OrderInterface.php:12') ; + }); + + test('withIncrementedCount creates a new immutable record with updated count', function () { + $record = new ViolationRecord( + file: 'src/File.php', + line: 1, + function: 'test', + kind: 'parameter', + target: '$x', + expected: 'int', + given: 'string', + message: 'err', + count: 1 + ); + + $incremented = $record->withIncrementedCount(4); + expect($incremented->count)->toBe(5) + ->and($record->count)->toBe(1) + ; + }); +}); - expect(ViolationRecord::fromArray([]))->toBeNull(); - expect(ViolationRecord::fromArray(['file' => 'src/File.php']))->toBeNull(); +describe('ViolationCollector Count & Origin Tracking', function () { + beforeEach(function () { + Config::reset(); + ViolationCollector::reset(); + }); + + afterEach(function () { + Config::reset(); + ViolationCollector::reset(); + }); + + test('increments violation count when identical violation occurs repeatedly in loops', function () { + Config::set(['on_violation' => 'report']); + $err = new ErrorMessage('Argument $id must be of type positive-int, negative int (-1) given'); + + for ($i = 0; $i < 25; $i++) { + ViolationCollector::handle($err, 'parameter', 42, 'src/Services/Item.php', 10); + } + + $violations = ViolationCollector::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->count)->toBe(25) + ; + }); + + test('sums occurrence counts when merging parallel worker shards into master report', function () { + $tempDir = sys_get_temp_dir() . '/typephp_shard_count_test_' . uniqid(); + $shardDir = $tempDir . '/.typephp-shards'; + mkdir($shardDir, 0777, true); + $reportFile = $tempDir . '/typephp-report.json'; + + $shard1 = [ + [ + 'file' => 'src/Payment.php', + 'line' => 20, + 'function' => 'App\\Payment::charge', + 'kind' => 'parameter', + 'target' => '$amount', + 'expected' => 'positive-int', + 'given' => 'negative int (-10)', + 'count' => 10, + 'message' => 'Argument $amount must be of type positive-int, negative int (-10) given', + ], + ]; + + $shard2 = [ + [ + 'file' => 'src/Payment.php', + 'line' => 20, + 'function' => 'App\\Payment::charge', + 'kind' => 'parameter', + 'target' => '$amount', + 'expected' => 'positive-int', + 'given' => 'negative int (-10)', + 'count' => 15, + 'message' => 'Argument $amount must be of type positive-int, negative int (-10) given', + ], + ]; + + file_put_contents($shardDir . '/shard_1.json', json_encode($shard1)); + file_put_contents($shardDir . '/shard_2.json', json_encode($shard2)); + + try { + ViolationCollector::exportReport($reportFile); + + $doc = json_decode((string) file_get_contents($reportFile), true); + + expect($doc['summary']['total_violations'])->toBe(1) + ->and($doc['violations'][0]['count'])->toBe(25) + ; + } finally { + if (file_exists($reportFile)) { + @unlink($reportFile); + } + @rmdir($tempDir); + } }); }); diff --git a/tests/TypeChecking/Generics/SplitIncludeTemplatePropertyTest.php b/tests/TypeChecking/Generics/SplitIncludeTemplatePropertyTest.php new file mode 100644 index 0000000..21f2658 --- /dev/null +++ b/tests/TypeChecking/Generics/SplitIncludeTemplatePropertyTest.php @@ -0,0 +1,36 @@ + [ + 'tests/Fixtures/SplitInclude/Commands/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + $builder = new FieldBuilder(); + + expect(fn () => $builder->probe(new TextInputField())) + ->toThrow(TypeError::class, 'must be >= 0') + ; + + $field = $builder->build(TextInputField::class, new Project()); + + expect($field)->toBeInstanceOf(TextInputField::class) + ->and($field->entity)->toBeInstanceOf(Project::class) + ; + }); +});