Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down Expand Up @@ -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<int,array<string,mixed>> $diagnostics
* @return array<int,array<string,mixed>>
*/
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<int,array<string,mixed>> $assets */
private function cssAssetContent(array $assets): string
{
Expand Down
63 changes: 62 additions & 1 deletion php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $artifact
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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),
Expand All @@ -230,6 +254,43 @@ public function normalize(array $artifact): array
);
}

/** @param array<string,int> $counts @param array<int,array<string,mixed>> $samples @param array<string,mixed> $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<int,array<string,mixed>> $samples @param array<int,array<string,mixed>> $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<int,array<string,mixed>> $samples @param array<string,mixed> $file */
private function recordRejectionSample(array &$samples, string $code, array $file, string $path): void
{
$samples[] = $this->rejectionSample($code, $file, $path, null);
}

/** @param array<string,mixed> $file @return array<string,mixed> */
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
Expand Down
4 changes: 3 additions & 1 deletion php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
]
}
55 changes: 55 additions & 0 deletions php-transformer/tests/unit/artifact-normalizer-source-budget.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<main>Accepted</main>'),
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' => '<main>Accepted</main>'));
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' => '<main>Accepted</main>'));
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";
Loading