From 8519336a0ca6678eb7d0f519f7c46f57b63f767f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 23:31:54 -0400 Subject: [PATCH] fix(php-transformer): aggregate rejected artifact diagnostics --- .../src/ArtifactCompiler/ArtifactCompiler.php | 26 +++++++- .../ArtifactCompiler/ArtifactNormalizer.php | 63 ++++++++++++++++++- php-transformer/tests/contract/run.php | 4 +- .../site-artifact-report-consistency.json | 4 +- .../artifact-normalizer-source-budget.php | 55 ++++++++++++++++ 5 files changed, 148 insertions(+), 4 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 5a187fdff..77801da31 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -611,7 +611,7 @@ private function finalizeArtifact(array $artifact, array $reduction): Transforme $capturedDialogs = is_array($reduction['captured_dialogs'] ?? null) ? $reduction['captured_dialogs'] : array('diagnostics' => array(), 'projected_count' => 0); $entry = $this->entryFile($normalized['files'], $normalized['entrypoints']); $documents = is_array($reduction['source_documents'] ?? null) ? $reduction['source_documents'] : $this->compileSourceDocuments($normalized); - $diagnostics = array_merge($normalized['diagnostics'], $capturedDialogs['diagnostics'], $documents['diagnostics'], $this->svgAssetDiagnostics($normalized['files'])); + $diagnostics = array_merge($this->operatorFacingNormalizationDiagnostics($normalized['diagnostics']), $capturedDialogs['diagnostics'], $documents['diagnostics'], $this->svgAssetDiagnostics($normalized['files'])); if ( null === $entry && array() === $documents['documents'] ) { $diagnostics[] = $this->diagnostic('missing_entry_html', 'error', 'No HTML entry file was available to compile.'); @@ -923,6 +923,30 @@ private function finalizeArtifact(array $artifact, array $reduction): Transforme ); } + /** + * Detailed normalization warnings remain available on normalized artifacts + * and staged plans. Terminal results expose their bounded aggregate only. + * + * @param array> $diagnostics + * @return array> + */ + private function operatorFacingNormalizationDiagnostics(array $diagnostics): array + { + $rejectionCodes = array_fill_keys(array( + 'file_limit_exceeded', + 'unsafe_artifact_path', + 'invalid_payload_reference', + 'invalid_base64_content', + 'missing_file_payload', + 'artifact_file_too_large', + 'artifact_total_too_large', + ), true); + return array_values(array_filter( + $diagnostics, + static fn(array $diagnostic): bool => !isset($rejectionCodes[$diagnostic['code'] ?? '']) + )); + } + /** @param array> $assets */ private function cssAssetContent(array $assets): string { diff --git a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php index 483beca54..4f62bba3d 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php @@ -20,6 +20,10 @@ final class ArtifactNormalizer public const MAX_FILES = 5000; public const MAX_FILE_BYTES = 10485760; public const MAX_TOTAL_BYTES = 335544320; + private const MAX_REJECTION_SAMPLES = 10; + private const MAX_REJECTION_SAMPLE_PATH_BYTES = 256; + private const SAMPLE_ROLES = array('entry', 'document', 'stylesheet', 'script', 'image', 'audio', 'video', 'font', 'data', 'asset'); + private const SAMPLE_TYPES = array('html', 'css', 'js', 'jsx', 'tsx', 'json', 'markdown', 'mdx', 'blocks', 'asset'); /** * @param array $artifact @@ -32,6 +36,8 @@ public function normalize(array $artifact): array $files = array(); $entrypoints = array(); $rejected = 0; + $rejectionCounts = array(); + $rejectionSamples = array(); $bytes = 0; $truncationImpact = null; $seenPaths = array(); @@ -74,7 +80,10 @@ public function normalize(array $artifact): array foreach ( $rawFiles as $index => $file ) { if ( count($files) >= $limits['max_files'] ) { - ++$rejected; + $omitted = array_slice($rawFiles, $index); + $rejected += count($omitted); + $rejectionCounts['file_limit_exceeded'] = ($rejectionCounts['file_limit_exceeded'] ?? 0) + count($omitted); + $this->appendRejectionSamples($rejectionSamples, $omitted, 'file_limit_exceeded'); $truncationImpact = $this->truncationImpact(array_slice($rawFiles, $index), $files); $diagnostics[] = $this->diagnostic('file_limit_exceeded', 'warning', 'Additional artifact files were ignored because the file limit was reached.', array('max_files' => $limits['max_files'], 'truncation_impact' => $truncationImpact)); break; @@ -83,6 +92,7 @@ public function normalize(array $artifact): array $path = ArtifactPath::safeRelativePath((string) ($file['path'] ?? '')); if ( '' === $path ) { ++$rejected; + $this->recordRejection($rejectionCounts, $rejectionSamples, 'unsafe_artifact_path', $file, '', null); $diagnostics[] = $this->diagnostic('unsafe_artifact_path', 'warning', 'An artifact file was ignored because its path is empty, absolute, or escapes the artifact root.', array('index' => $index)); continue; } @@ -91,17 +101,22 @@ public function normalize(array $artifact): array $diagnostics = array_merge($diagnostics, $payload['diagnostics']); if ( ! $payload['accepted'] ) { ++$rejected; + foreach ($payload['diagnostics'] as $diagnostic) { + $this->recordRejection($rejectionCounts, $rejectionSamples, (string) ($diagnostic['code'] ?? 'invalid_artifact_payload'), $file, $path, $payload['bytes']); + } continue; } if ( $payload['bytes'] > $limits['max_file_bytes'] ) { ++$rejected; + $this->recordRejection($rejectionCounts, $rejectionSamples, 'artifact_file_too_large', $file, $path, $payload['bytes']); $diagnostics[] = $this->diagnostic('artifact_file_too_large', 'warning', 'An artifact file was ignored because it exceeds the per-file byte limit.', array('path' => $path, 'bytes' => $payload['bytes'], 'max_file_bytes' => $limits['max_file_bytes'])); continue; } if ( $bytes + $payload['bytes'] > $limits['max_total_bytes'] ) { ++$rejected; + $this->recordRejection($rejectionCounts, $rejectionSamples, 'artifact_total_too_large', $file, $path, $payload['bytes']); $diagnostics[] = $this->diagnostic('artifact_total_too_large', 'warning', 'An artifact file was ignored because the bundle byte limit was reached.', array('path' => $path, 'bytes' => $payload['bytes'], 'max_total_bytes' => $limits['max_total_bytes'])); continue; } @@ -215,6 +230,15 @@ public function normalize(array $artifact): array } unset($file); $sourceHash = $this->sourceHash($files, $runtimeDeclarations); + if ( 0 < $rejected ) { + ksort($rejectionCounts); + $diagnostics[] = $this->diagnostic('artifact_inputs_rejected', 'warning', 'One or more artifact inputs were ignored during normalization.', array( + 'rejected_count' => $rejected, + 'rejected_by_code' => $rejectionCounts, + 'samples' => $rejectionSamples, + 'samples_omitted' => max(0, $rejected - count($rejectionSamples)), + )); + } return array( 'files' => $files, 'diagnostics' => $this->dedupeDiagnostics($diagnostics), @@ -230,6 +254,43 @@ public function normalize(array $artifact): array ); } + /** @param array $counts @param array> $samples @param array $file */ + private function recordRejection(array &$counts, array &$samples, string $code, array $file, string $path, ?int $bytes): void + { + $counts[$code] = ($counts[$code] ?? 0) + 1; + if (count($samples) >= self::MAX_REJECTION_SAMPLES) return; + $samples[] = $this->rejectionSample($code, $file, $path, $bytes); + } + + /** @param array> $samples @param array> $files */ + private function appendRejectionSamples(array &$samples, array $files, string $code): void + { + foreach ($files as $file) { + if (count($samples) >= self::MAX_REJECTION_SAMPLES) return; + $path = ArtifactPath::safeRelativePath((string) ($file['path'] ?? '')); + $this->recordRejectionSample($samples, $code, $file, $path); + } + } + + /** @param array> $samples @param array $file */ + private function recordRejectionSample(array &$samples, string $code, array $file, string $path): void + { + $samples[] = $this->rejectionSample($code, $file, $path, null); + } + + /** @param array $file @return array */ + private function rejectionSample(string $code, array $file, string $path, ?int $bytes): array + { + $sample = array('code' => $code); + if ('' !== $path) $sample['path'] = substr($path, 0, self::MAX_REJECTION_SAMPLE_PATH_BYTES); + if (null !== $bytes) $sample['bytes'] = $bytes; + $role = $this->sanitizeKey((string) ($file['role'] ?? '')); + if (in_array($role, self::SAMPLE_ROLES, true)) $sample['declared_role'] = $role; + $type = $this->sanitizeKey((string) ($file['type'] ?? '')); + if (in_array($type, self::SAMPLE_TYPES, true)) $sample['declared_type'] = $type; + return $sample; + } + /** * Bounded evidence for files omitted solely because the file limit was * reached. Only references from admitted files can affect the compiled diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 24f2708d8..11af5df67 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -5341,7 +5341,9 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter )->toArray(); $assert('success_with_warnings' === $tooLarge['status'], 'oversized files are rejected with a warning status'); $assert(1 === ($tooLarge['source_reports']['artifact']['rejected_count'] ?? null), 'oversized file increments rejected count'); -$assert('artifact_file_too_large' === ($tooLarge['diagnostics'][0]['code'] ?? ''), 'oversized file diagnostic is exposed'); +$tooLargeAggregate = current(array_filter($tooLarge['diagnostics'], static fn(array $diagnostic): bool => 'artifact_inputs_rejected' === ($diagnostic['code'] ?? null))); +$assert(1 === ($tooLargeAggregate['context']['rejected_count'] ?? null) && 1 === ($tooLargeAggregate['context']['rejected_by_code']['artifact_file_too_large'] ?? null), 'oversized file rejection is exposed through the bounded aggregate diagnostic'); +$assert(!in_array('artifact_file_too_large', array_column($tooLarge['diagnostics'], 'code'), true), 'final diagnostics omit detailed per-rejected file warnings'); $negotiatedLimits = (new ArtifactNormalizer())->normalize(array( 'compiler_limits' => array( diff --git a/php-transformer/tests/fixtures/parity/site-artifact-report-consistency.json b/php-transformer/tests/fixtures/parity/site-artifact-report-consistency.json index 8568d716f..ba7711ef3 100644 --- a/php-transformer/tests/fixtures/parity/site-artifact-report-consistency.json +++ b/php-transformer/tests/fixtures/parity/site-artifact-report-consistency.json @@ -126,6 +126,8 @@ { "path": "source_reports.conversion_report.asset_refs", "assert": "count", "count": 5 }, { "path": "source_reports.conversion_report.presentation_gaps.0.path", "assert": "equals", "value": "public/assets/visual-repair.css" }, { "path": "documents.0.source_path", "assert": "equals", "value": "content/notes.md" }, - { "path": "diagnostics.0.code", "assert": "equals", "value": "unsafe_artifact_path" } + { "path": "diagnostics.0.code", "assert": "equals", "value": "artifact_inputs_rejected" }, + { "path": "diagnostics.0.context.rejected_count", "assert": "equals", "value": 1 }, + { "path": "diagnostics.0.context.rejected_by_code.unsafe_artifact_path", "assert": "equals", "value": 1 } ] } diff --git a/php-transformer/tests/unit/artifact-normalizer-source-budget.php b/php-transformer/tests/unit/artifact-normalizer-source-budget.php index 50c25a843..23c5047fc 100644 --- a/php-transformer/tests/unit/artifact-normalizer-source-budget.php +++ b/php-transformer/tests/unit/artifact-normalizer-source-budget.php @@ -89,4 +89,59 @@ ))->toArray(); $assert('gating_loss' === ($qualityResult['source_reports']['artifact']['truncation_impact']['completeness'] ?? null), 'Quality consumers receive the reachable truncation gating loss in the artifact report.'); +$oversizedResult = (new ArtifactCompiler())->compile(array( + 'compiler_limits' => array('max_file_bytes' => ArtifactNormalizer::MAX_FILE_BYTES), + 'files' => array( + array('path' => 'index.html', 'content' => '
Accepted
'), + array('path' => 'evidence.json', 'content' => str_repeat('x', 16230577), 'role' => 'evidence', 'type' => 'json'), + ), +))->toArray(); +$rejectionDiagnostic = null; +foreach ($oversizedResult['diagnostics'] as $diagnostic) { + if ('artifact_inputs_rejected' === ($diagnostic['code'] ?? null)) { + $rejectionDiagnostic = $diagnostic; + break; + } +} +$rejectionContext = $rejectionDiagnostic['context'] ?? array(); +$assert('success_with_warnings' === $oversizedResult['status'] && 1 === ($rejectionContext['rejected_count'] ?? null) && 1 === ($rejectionContext['rejected_by_code']['artifact_file_too_large'] ?? null), 'An ordinary compile persists a bounded final warning for an oversized artifact input.'); +$assert(array('code' => 'artifact_file_too_large', 'path' => 'evidence.json', 'bytes' => 16230577, 'declared_type' => 'json') === ($rejectionContext['samples'][0] ?? null) && 0 === ($rejectionContext['samples_omitted'] ?? null), 'The final warning retains only bounded generic artifact facts, not arbitrary declared metadata or rejected payload content.'); + +$manyDroppedFiles = array(array('path' => 'index.html', 'content' => '
Accepted
')); +for ($index = 0; $index < 12; ++$index) $manyDroppedFiles[] = array('path' => 'ancillary-' . $index . '.json', 'content' => '{}', 'role' => 'evidence', 'type' => 'json'); +$manyDropped = $normalizer->normalize(array('compiler_limits' => array('max_files' => 1), 'files' => $manyDroppedFiles)); +$manyDroppedSummary = current(array_filter($manyDropped['diagnostics'], static fn(array $diagnostic): bool => 'artifact_inputs_rejected' === ($diagnostic['code'] ?? null))); +$manyDroppedContext = $manyDroppedSummary['context'] ?? array(); +$assert(12 === ($manyDroppedContext['rejected_count'] ?? null) && 12 === ($manyDroppedContext['rejected_by_code']['file_limit_exceeded'] ?? null) && 10 === count($manyDroppedContext['samples'] ?? array()) && 2 === ($manyDroppedContext['samples_omitted'] ?? null), 'Many rejected inputs retain complete counts with bounded samples.'); + +$adversarialFiles = array(array('path' => 'index.html', 'content' => '
Accepted
')); +for ($index = 0; $index < 12; ++$index) { + $adversarialFiles[] = array( + 'path' => 'assets/' . str_repeat('p', 1024) . '-' . $index . '.json', + 'content' => '{}', + 'role' => str_repeat('untrusted-role-', 128), + 'type' => str_repeat('untrusted-type-', 128), + ); +} +$adversarialArtifact = array('entrypoint' => 'index.html', 'compiler_limits' => array('max_files' => 1), 'files' => $adversarialFiles); +$aggregate = static function (array $result): array { + foreach ($result['diagnostics'] as $diagnostic) { + if ('artifact_inputs_rejected' === ($diagnostic['code'] ?? null)) return $diagnostic['context']; + } + return array(); +}; +$direct = (new ArtifactCompiler())->compile($adversarialArtifact)->toArray(); +$directAggregate = $aggregate($direct); +$assert(12 === ($directAggregate['rejected_count'] ?? null) && array('file_limit_exceeded' => 12) === ($directAggregate['rejected_by_code'] ?? null) && 10 === count($directAggregate['samples'] ?? array()) && 2 === ($directAggregate['samples_omitted'] ?? null), 'Direct final diagnostics preserve exact aggregate counts while bounding samples.'); +$assert(array('rejected_count', 'rejected_by_code', 'samples', 'samples_omitted') === array_keys($directAggregate) && !array_filter($directAggregate['samples'], static fn(array $sample): bool => strlen((string) ($sample['path'] ?? '')) > 256 || isset($sample['declared_role']) || isset($sample['declared_type'])), 'Direct final samples retain only capped safe paths and no arbitrary caller metadata.'); +$assert(!array_filter($direct['diagnostics'], static fn(array $diagnostic): bool => in_array($diagnostic['code'] ?? '', array('file_limit_exceeded', 'unsafe_artifact_path', 'invalid_payload_reference', 'invalid_base64_content', 'missing_file_payload', 'artifact_file_too_large', 'artifact_total_too_large'), true)), 'Direct final diagnostics do not forward detailed per-rejected warnings.'); + +$stagedCompiler = new ArtifactCompiler(); +$stagedShared = $stagedCompiler->prepareShared($adversarialArtifact); +$stagedPages = $stagedCompiler->preparePages($adversarialArtifact, $stagedShared); +$stagedReceipts = $stagedCompiler->compilePreparedPages($stagedShared, $stagedPages); +$staged = $stagedCompiler->compose($stagedShared, $stagedReceipts)->toArray(); +$assert($directAggregate === $aggregate($staged), 'Direct and staged final results expose the same exact bounded rejection aggregate.'); +$assert(!array_filter($staged['diagnostics'], static fn(array $diagnostic): bool => in_array($diagnostic['code'] ?? '', array('file_limit_exceeded', 'unsafe_artifact_path', 'invalid_payload_reference', 'invalid_base64_content', 'missing_file_payload', 'artifact_file_too_large', 'artifact_total_too_large'), true)), 'Staged final diagnostics do not forward detailed per-rejected warnings.'); + echo "artifact normalizer source budget: ok\n";