Files
saqel/backend/scripts/verify_grade10_accepted.php

116 lines
5.1 KiB
PHP

<?php
declare(strict_types=1);
/**
* Read-only acceptance check for the Grade 10 candidate intake work.
*
* Asserts (no database, no network, no file writes):
* 1. The strict intake gate PASSES for manifest.accepted.json (0 errors).
* 2. The strict intake gate still BLOCKS manifest.candidate.json, and every
* remaining blocking error belongs to a quarantined file in review-queue.json.
* 3. Every accepted entry carries an explicit canonical resource_type and a
* source reference; non-lesson files are never typed as video lessons.
* 4. Quarantined files are present on disk, absent from the accepted manifest,
* and listed in review-queue.json.
* 5. The live student manifest (backend/storage/curriculum/manifest.json) is
* unmodified, and the promotion path contains no lessons-table writes and
* no published/approved status.
*
* Usage:
* php backend/scripts/verify_grade10_accepted.php
*/
$projectRoot = dirname(__DIR__, 2);
$failures = [];
function check(bool $condition, string $message): void
{
global $failures;
echo ($condition ? "PASS " : "FAIL ") . $message . PHP_EOL;
if (!$condition) {
$failures[] = $message;
}
}
function runGate(string $projectRoot, string $manifest): array
{
$out = shell_exec(
'php ' . escapeshellarg($projectRoot . '/backend/scripts/validate_grade10_candidate.php')
. ' --json --manifest=' . escapeshellarg($manifest) . ' 2>/dev/null'
);
$decoded = json_decode((string) $out, true);
return is_array($decoded) ? $decoded : [];
}
$accepted = runGate($projectRoot, 'manifest.accepted.json');
check(($accepted['status'] ?? null) === 'passed', 'gate passes for manifest.accepted.json');
check(count($accepted['errors'] ?? [null]) === 0, 'zero blocking errors in accepted package (369 entries)');
$candidate = runGate($projectRoot, 'manifest.candidate.json');
check(($candidate['status'] ?? null) === 'blocked', 'gate still blocks the full candidate set until humans resolve quarantine');
$queue = json_decode((string) file_get_contents(
$projectRoot . '/backend/storage/curriculum/_incoming/grade_10/review-queue.json'
), true);
$quarantined = array_column($queue['quarantined'] ?? [], 'file');
sort($quarantined);
check(count($quarantined) === 3, 'review-queue lists exactly 3 quarantined files');
$stray = [];
foreach ($candidate['errors'] ?? [] as $error) {
$ctx = json_encode($error['context'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$belongs = false;
foreach ($quarantined as $q) {
if (str_contains($ctx, $q)) {
$belongs = true;
break;
}
}
if (!$belongs && ($error['code'] ?? '') !== 'qa_claim_conflict') {
$stray[] = $error;
}
}
check($stray === [], 'every remaining blocking error traces to a quarantined file');
$acceptedManifest = json_decode((string) file_get_contents(
$projectRoot . '/backend/storage/curriculum/_incoming/grade_10/manifest.accepted.json'
), true);
$acceptedFiles = array_column($acceptedManifest['lessons'] ?? [], 'file');
check(count($acceptedFiles) === 369, 'accepted manifest holds 369 entries');
check(count(array_intersect($acceptedFiles, $quarantined)) === 0, 'quarantined files are absent from the accepted manifest');
$typeViolations = 0;
foreach ($accepted['summary']['resource_types'] ?? [] as $type => $count) {
if (!in_array($type, ['lesson', 'unit_review', 'worksheet', 'lab', 'project', 'special_resource'], true)) {
$typeViolations++;
}
}
check($typeViolations === 0, 'all resource_type values use the canonical vocabulary');
check(($accepted['summary']['resource_types']['lesson'] ?? 0) === 302, '302 lesson files; reviews/labs/projects are not video lessons');
foreach ($quarantined as $q) {
check(is_file($projectRoot . '/backend/storage/curriculum/_incoming/' . $q), 'quarantined file preserved on disk: ' . $q);
}
$liveManifestStatus = shell_exec('git -C ' . escapeshellarg($projectRoot) . ' status --porcelain -- backend/storage/curriculum/manifest.json 2>/dev/null');
check(trim((string) $liveManifestStatus) === '', 'live student manifest.json untouched');
$promote = (string) file_get_contents($projectRoot . '/backend/scripts/promote_grade10_candidate.php');
check(!preg_match('/\bINTO\s+lessons\b|\bUPDATE\s+lessons\b/i', $promote), 'promotion path never writes the lessons table');
$sqlLines = array_filter(
explode("\n", $promote),
static fn (string $line): bool => (bool) preg_match('/\b(UPDATE|INSERT|DELETE|SET)\b/i', $line)
);
$sqlText = implode("\n", $sqlLines);
check(!str_contains($sqlText, 'markdown_content'), 'promotion path never writes lessons.markdown_content');
check(str_contains($promote, "'draft'"), 'promotion bundles/assets are draft-only');
check(!preg_match("/status['\"]?\s*(=>|,)\s*['\"]published['\"]/i", $promote), 'promotion path contains no published status');
check(!preg_match("/review_status.*approved|['\"]approved['\"]/i", $promote), 'promotion path contains no approval');
if ($failures !== []) {
fwrite(STDERR, count($failures) . " acceptance check(s) failed.\n");
exit(1);
}
echo "All acceptance checks passed.\n";