202 lines
7.3 KiB
PHP
202 lines
7.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Symfony\Component\Yaml\Yaml;
|
|
use Tests\Support\OpsUx\SourceFileScanner;
|
|
|
|
it('keeps covered derived-state consumers on declared access paths without ad hoc caches', function (): void {
|
|
$root = SourceFileScanner::projectRoot();
|
|
$contractPath = $root.'/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml';
|
|
|
|
/** @var array<string, mixed> $contract */
|
|
$contract = Yaml::parseFile($contractPath);
|
|
$declarations = $contract['x-derived-state-consumers'] ?? [];
|
|
|
|
expect($declarations)->toBeArray()->not->toBeEmpty();
|
|
|
|
$allowedFamilies = [
|
|
'artifact_truth',
|
|
'operation_ux_guidance',
|
|
'operation_ux_explanation',
|
|
'related_navigation_primary',
|
|
'related_navigation_detail',
|
|
'related_navigation_header',
|
|
];
|
|
$allowedAccessPatterns = ['row_safe', 'page_safe', 'direct_once'];
|
|
$allowedFreshnessPolicies = ['request_stable', 'invalidate_after_mutation', 'no_reuse'];
|
|
$cachePattern = '/static\s+array\s+\$[A-Za-z0-9_]*cache\b/i';
|
|
$violations = [];
|
|
|
|
foreach ($declarations as $index => $declaration) {
|
|
if (! is_array($declaration)) {
|
|
$violations[] = [
|
|
'surface' => 'contract',
|
|
'message' => sprintf('Declaration %d must be an object.', $index),
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
$surface = trim((string) ($declaration['surface'] ?? ''));
|
|
$family = trim((string) ($declaration['family'] ?? ''));
|
|
$variant = trim((string) ($declaration['variant'] ?? ''));
|
|
$accessPattern = trim((string) ($declaration['accessPattern'] ?? ''));
|
|
$freshnessPolicy = trim((string) ($declaration['freshnessPolicy'] ?? ''));
|
|
$scopeInputs = $declaration['scopeInputs'] ?? null;
|
|
$guardScope = $declaration['guardScope'] ?? null;
|
|
$requiredMarkers = array_values(array_filter(
|
|
$declaration['requiredMarkers'] ?? [],
|
|
static fn (mixed $marker): bool => is_string($marker) && trim($marker) !== '',
|
|
));
|
|
$maxOccurrences = $declaration['maxOccurrences'] ?? [];
|
|
|
|
if ($surface === '' || $variant === '') {
|
|
$violations[] = [
|
|
'surface' => $surface !== '' ? $surface : 'contract',
|
|
'message' => 'Each declaration must provide non-empty surface and variant values.',
|
|
];
|
|
}
|
|
|
|
if (! in_array($family, $allowedFamilies, true)) {
|
|
$violations[] = [
|
|
'surface' => $surface !== '' ? $surface : 'contract',
|
|
'message' => sprintf('Unsupported family "%s".', $family),
|
|
];
|
|
}
|
|
|
|
if (! in_array($accessPattern, $allowedAccessPatterns, true)) {
|
|
$violations[] = [
|
|
'surface' => $surface !== '' ? $surface : 'contract',
|
|
'message' => sprintf('Unsupported accessPattern "%s".', $accessPattern),
|
|
];
|
|
}
|
|
|
|
if (! in_array($freshnessPolicy, $allowedFreshnessPolicies, true)) {
|
|
$violations[] = [
|
|
'surface' => $surface !== '' ? $surface : 'contract',
|
|
'message' => sprintf('Unsupported freshnessPolicy "%s".', $freshnessPolicy),
|
|
];
|
|
}
|
|
|
|
if (! is_array($scopeInputs) || $scopeInputs === []) {
|
|
$violations[] = [
|
|
'surface' => $surface !== '' ? $surface : 'contract',
|
|
'message' => 'Each declaration must include at least one scope input.',
|
|
];
|
|
}
|
|
|
|
if (! is_array($guardScope) || $guardScope === []) {
|
|
$violations[] = [
|
|
'surface' => $surface !== '' ? $surface : 'contract',
|
|
'message' => 'Each declaration must include at least one guardScope path.',
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
foreach ($guardScope as $relativePath) {
|
|
$relativePath = trim((string) $relativePath);
|
|
$absolutePath = $root.'/'.$relativePath;
|
|
|
|
if ($relativePath === '' || ! is_file($absolutePath)) {
|
|
$violations[] = [
|
|
'surface' => $surface !== '' ? $surface : 'contract',
|
|
'message' => sprintf('Missing guardScope file "%s".', $relativePath),
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
$source = SourceFileScanner::read($absolutePath);
|
|
|
|
foreach ($requiredMarkers as $marker) {
|
|
if (str_contains($source, $marker)) {
|
|
continue;
|
|
}
|
|
|
|
$violations[] = [
|
|
'surface' => $surface,
|
|
'file' => SourceFileScanner::relativePath($absolutePath),
|
|
'message' => sprintf('Missing required marker "%s".', $marker),
|
|
];
|
|
}
|
|
|
|
if (preg_match($cachePattern, $source, $match, PREG_OFFSET_CAPTURE) === 1) {
|
|
$offset = $match[0][1];
|
|
$line = substr_count(substr($source, 0, is_int($offset) ? $offset : 0), "\n") + 1;
|
|
|
|
$violations[] = [
|
|
'surface' => $surface,
|
|
'file' => SourceFileScanner::relativePath($absolutePath),
|
|
'line' => $line,
|
|
'message' => 'Ad hoc local cache detected in guarded surface.',
|
|
'snippet' => SourceFileScanner::snippet($source, $line),
|
|
];
|
|
}
|
|
|
|
if (! is_array($maxOccurrences)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($maxOccurrences as $occurrenceRule) {
|
|
if (! is_array($occurrenceRule)) {
|
|
continue;
|
|
}
|
|
|
|
$needle = trim((string) ($occurrenceRule['needle'] ?? ''));
|
|
$max = (int) ($occurrenceRule['max'] ?? -1);
|
|
|
|
if ($needle === '' || $max < 0) {
|
|
continue;
|
|
}
|
|
|
|
$actual = substr_count($source, $needle);
|
|
|
|
if ($actual <= $max) {
|
|
continue;
|
|
}
|
|
|
|
$offset = strpos($source, $needle);
|
|
$line = $offset === false ? 1 : substr_count(substr($source, 0, $offset), "\n") + 1;
|
|
|
|
$violations[] = [
|
|
'surface' => $surface,
|
|
'file' => SourceFileScanner::relativePath($absolutePath),
|
|
'line' => $line,
|
|
'message' => sprintf('Found %d occurrences of "%s"; expected at most %d.', $actual, $needle, $max),
|
|
'snippet' => SourceFileScanner::snippet($source, $line),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($violations !== []) {
|
|
$messages = array_map(static function (array $violation): string {
|
|
$location = $violation['surface'] ?? 'contract';
|
|
|
|
if (isset($violation['file'])) {
|
|
$location .= ' @ '.$violation['file'];
|
|
}
|
|
|
|
if (isset($violation['line'])) {
|
|
$location .= ':'.$violation['line'];
|
|
}
|
|
|
|
$message = $location."\n".$violation['message'];
|
|
|
|
if (isset($violation['snippet'])) {
|
|
$message .= "\n".$violation['snippet'];
|
|
}
|
|
|
|
return $message;
|
|
}, $violations);
|
|
|
|
$this->fail(
|
|
"Derived-state consumer guard violations found:\n\n".implode("\n\n", $messages)
|
|
);
|
|
}
|
|
|
|
expect($violations)->toBe([]);
|
|
});
|