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