Files
saqel/backend/scripts/validate_grade10_candidate.php
T

432 lines
19 KiB
PHP

<?php
declare(strict_types=1);
/**
* Read-only gate for the Grade 10 curriculum intake bundle.
*
* It deliberately does not connect to MySQL, move files, or publish content.
* A non-zero exit code means the candidate must remain in _incoming.
*
* Usage:
* php backend/scripts/validate_grade10_candidate.php
* php backend/scripts/validate_grade10_candidate.php --json
* php backend/scripts/validate_grade10_candidate.php --json --manifest=manifest.accepted.json
*
* The optional --manifest flag gates a named package (e.g. an accepted subset)
* with the exact same strict rules. It does not relax any rule.
*/
$jsonOutput = in_array('--json', $argv, true);
$manifestOverride = null;
foreach ($argv as $arg) {
if (str_starts_with($arg, '--manifest=')) {
$manifestOverride = substr($arg, strlen('--manifest='));
}
}
$projectRoot = dirname(__DIR__, 2);
$intakeRoot = $projectRoot . '/backend/storage/curriculum/_incoming';
$candidateRoot = $intakeRoot . '/grade_10';
$manifestPath = $manifestOverride !== null && $manifestOverride !== ''
? (str_starts_with($manifestOverride, '/') ? $manifestOverride : $candidateRoot . '/' . ltrim($manifestOverride, '/'))
: $candidateRoot . '/manifest.candidate.json';
$qaReportPath = $candidateRoot . '/qa-report.json';
$booksRoot = $projectRoot . '/books';
$issues = [];
$summary = [
'manifest' => basename($manifestPath),
'manifest_entries' => 0,
'markdown_files' => 0,
'content_types' => [],
'resource_types' => [],
'source_references_checked' => 0,
'qa_claim_conflicts' => [],
];
const RESOURCE_TYPES = ['lesson', 'unit_review', 'worksheet', 'lab', 'project', 'special_resource'];
const REVIEW_STATUSES = ['needs_human_review', 'in_human_review', 'verified_authentic', 'rejected'];
if (!is_file($manifestPath)) {
fail('manifest_missing', 'Candidate manifest is missing.', ['path' => $manifestPath]);
finish();
}
$manifest = json_decode((string) file_get_contents($manifestPath), true);
if (!is_array($manifest) || !is_array($manifest['lessons'] ?? null)) {
fail('manifest_invalid', 'Candidate manifest must contain a lessons array.', ['path' => $manifestPath]);
finish();
}
$entries = $manifest['lessons'];
$summary['manifest_entries'] = count($entries);
$entryPaths = [];
$entryIds = [];
$bodyHashes = [];
$actualMarkdown = [];
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($candidateRoot, FilesystemIterator::SKIP_DOTS)) as $file) {
if ($file->isFile() && strtolower($file->getExtension()) === 'md') {
$actualMarkdown[] = relativePath($file->getPathname(), $intakeRoot);
}
}
sort($actualMarkdown);
$summary['markdown_files'] = count($actualMarkdown);
foreach ($entries as $index => $entry) {
if (!is_array($entry)) {
fail('manifest_entry_invalid', 'A manifest lesson entry is not an object.', ['index' => $index]);
continue;
}
$relativePath = (string) ($entry['file'] ?? '');
$entryId = (string) ($entry['id'] ?? '');
if ($relativePath === '' || $entryId === '') {
fail('manifest_identity_missing', 'A manifest entry is missing id or file.', ['index' => $index]);
continue;
}
$entryPaths[$relativePath][] = $index;
$entryIds[$entryId][] = $index;
$path = resolveWithin($intakeRoot, $relativePath);
if ($path === null || !is_file($path)) {
fail('markdown_missing', 'Manifest file does not exist inside intake storage.', ['file' => $relativePath]);
continue;
}
$type = contentType($relativePath);
$summary['content_types'][$type] = ($summary['content_types'][$type] ?? 0) + 1;
$markdown = (string) file_get_contents($path);
$frontmatter = parseFrontmatter($markdown);
if ($frontmatter === null) {
fail('frontmatter_missing', 'Markdown must start with canonical YAML frontmatter.', ['file' => $relativePath]);
continue;
}
$expectedKeys = ['grade_key', 'subject_key', 'semester_key', 'unit_key', 'lesson_key', 'curriculum_version'];
foreach ($expectedKeys as $key) {
if (($frontmatter[$key] ?? null) !== ($entry[$key] ?? null)) {
fail('frontmatter_identity_mismatch', 'Frontmatter identity does not match the manifest.', [
'file' => $relativePath,
'field' => $key,
'manifest' => $entry[$key] ?? null,
'frontmatter' => $frontmatter[$key] ?? null,
]);
}
}
if (($frontmatter['title'] ?? null) !== ($entry['title'] ?? null)) {
warn('title_mismatch', 'Frontmatter title differs from manifest title.', ['file' => $relativePath]);
}
if (!is_string($frontmatter['title'] ?? null) || trim((string) ($frontmatter['title'] ?? '')) === '') {
fail('title_missing', 'Canonical frontmatter title is required.', ['file' => $relativePath]);
}
$resourceType = $frontmatter['resource_type'] ?? null;
if (!is_string($resourceType) || $resourceType === '') {
fail('resource_type_missing', 'Explicit resource_type is required: lesson, unit_review, worksheet, lab, project, or special_resource.', ['file' => $relativePath]);
} elseif (!in_array($resourceType, RESOURCE_TYPES, true)) {
fail('resource_type_invalid', 'resource_type must use the canonical vocabulary.', ['file' => $relativePath, 'resource_type' => $resourceType]);
} else {
$summary['resource_types'][$resourceType] = ($summary['resource_types'][$resourceType] ?? 0) + 1;
$stem = pathinfo($relativePath, PATHINFO_FILENAME);
$isLessonFile = str_starts_with($stem, 'lesson_');
if ($isLessonFile && $resourceType !== 'lesson') {
fail('resource_type_filename_conflict', 'A lesson_NN file must be typed lesson, not a review/lab/project.', ['file' => $relativePath, 'resource_type' => $resourceType]);
}
if (!$isLessonFile && $resourceType === 'lesson') {
fail('resource_type_filename_conflict', 'A review/lab/project/worksheet file must never be typed as a video lesson.', ['file' => $relativePath, 'resource_type' => $resourceType]);
}
}
$pathSegments = explode('/', $relativePath);
if (count($pathSegments) === 5) {
[$pathGrade, $pathSubject, $pathSemester, $pathUnit, $pathFile] = $pathSegments;
$pathLesson = pathinfo($pathFile, PATHINFO_FILENAME);
$pathIdentity = [
'grade_key' => $pathGrade,
'subject_key' => $pathSubject,
'semester_key' => $pathSemester,
'unit_key' => $pathUnit,
'lesson_key' => $pathLesson,
];
foreach ($pathIdentity as $key => $pathValue) {
if (($entry[$key] ?? null) !== $pathValue) {
fail('path_identity_mismatch', 'Manifest identity does not match the file path.', [
'file' => $relativePath,
'field' => $key,
'manifest' => $entry[$key] ?? null,
'path' => $pathValue,
]);
}
if (($frontmatter[$key] ?? null) !== $pathValue) {
fail('path_identity_mismatch', 'Frontmatter identity does not match the file path.', [
'file' => $relativePath,
'field' => $key,
'frontmatter' => $frontmatter[$key] ?? null,
'path' => $pathValue,
]);
}
}
} else {
fail('path_identity_mismatch', 'Candidate file must live at grade_10/<subject>/<semester>/<unit>/<lesson>.md.', ['file' => $relativePath]);
}
$source = $frontmatter['source'] ?? null;
if (!is_array($source) || !is_string($source['original_pdf'] ?? null) || !is_array($source['pages'] ?? null)) {
fail('source_metadata_missing', 'Canonical source.original_pdf and source.pages are required.', ['file' => $relativePath]);
} else {
$method = $source['extraction_method'] ?? null;
$reviewStatus = $source['extraction_review_status'] ?? null;
if (!is_string($method) || trim($method) === '' || !is_string($reviewStatus) || trim($reviewStatus) === '') {
fail('source_extraction_missing', 'source.extraction_method and source.extraction_review_status are required.', ['file' => $relativePath]);
} elseif (!in_array($reviewStatus, REVIEW_STATUSES, true)) {
warn('source_review_status_unknown', 'Unknown extraction review status; use the canonical vocabulary.', ['file' => $relativePath, 'status' => $reviewStatus]);
} elseif ($reviewStatus === 'verified_authentic') {
warn('authenticity_unverified', 'File claims verified_authentic without a recorded reviewer; academic review must confirm it.', ['file' => $relativePath]);
}
validatePages($source['original_pdf'], $source['pages'], $relativePath);
}
$content = lessonContent($markdown);
if ($content === '') {
fail('lesson_content_empty', 'The lesson content section is empty.', ['file' => $relativePath]);
} else {
$bodyHashes[hash('sha256', normalizeBody($content))][] = $relativePath;
}
if (preg_match('/[\x{202A}-\x{202E}\x{2066}-\x{2069}]/u', $markdown)) {
warn('bidirectional_control_characters', 'Markdown contains invisible bidirectional control characters.', ['file' => $relativePath]);
}
if (str_contains($markdown, "�")) {
warn('replacement_character', 'Markdown contains replacement characters from failed extraction.', ['file' => $relativePath]);
}
if (str_contains($markdown, 'نتاجات التعلم المستخلصة من أهداف ومحاور الدرس')) {
warn('generic_learning_outcomes', 'Learning outcomes are a placeholder, not lesson-specific outcomes.', ['file' => $relativePath]);
}
}
foreach ($entryPaths as $path => $indexes) {
if (count($indexes) > 1) {
fail('manifest_path_duplicate', 'More than one manifest entry uses the same file path.', ['file' => $path, 'indexes' => $indexes]);
}
}
foreach ($entryIds as $id => $indexes) {
if (count($indexes) > 1) {
fail('manifest_id_duplicate', 'More than one manifest entry uses the same ID.', ['id' => $id, 'indexes' => $indexes]);
}
}
$listedPaths = array_keys($entryPaths);
sort($listedPaths);
$isSubsetMode = basename($manifestPath) !== 'manifest.candidate.json';
foreach (array_diff($actualMarkdown, $listedPaths) as $path) {
if ($isSubsetMode) {
warn('markdown_not_in_scope', 'Markdown exists on disk but is outside the gated package; it must stay unpublished until reviewed.', ['file' => $path]);
} else {
fail('markdown_unlisted', 'Markdown exists in the candidate directory but is absent from the manifest.', ['file' => $path]);
}
}
foreach (array_diff($listedPaths, $actualMarkdown) as $path) {
fail('markdown_missing', 'Manifest references a missing Markdown file.', ['file' => $path]);
}
foreach ($bodyHashes as $hash => $paths) {
if (count($paths) > 1) {
fail('lesson_body_duplicate', 'Two candidate entries have the same normalized lesson content.', ['files' => $paths]);
}
}
if (is_file($qaReportPath)) {
$qa = json_decode((string) file_get_contents($qaReportPath), true);
$claims = $qa['validation'] ?? [];
$frontmatterErrors = countIssues('frontmatter_missing') + countIssues('frontmatter_identity_mismatch');
$emptyErrors = countIssues('lesson_content_empty');
if (($claims['valid_yaml_frontmatters'] ?? null) === true && $frontmatterErrors > 0) {
$summary['qa_claim_conflicts'][] = 'valid_yaml_frontmatters';
fail('qa_claim_conflict', 'QA says all frontmatters are valid, but the validator found incompatible metadata.', ['claim' => 'valid_yaml_frontmatters', 'errors' => $frontmatterErrors]);
}
if (($claims['no_empty_files'] ?? null) === true && $emptyErrors > 0) {
$summary['qa_claim_conflicts'][] = 'no_empty_files';
fail('qa_claim_conflict', 'QA says no files are empty, but the validator found empty lesson content.', ['claim' => 'no_empty_files', 'errors' => $emptyErrors]);
}
}
finish();
function parseFrontmatter(string $markdown): ?array
{
// Do not use PCRE \R here: in non-Unicode mode it may treat bytes inside
// Arabic UTF-8 text as line separators. Candidate files use LF/CRLF.
if (!preg_match('/\A---\r?\n(?<yaml>.*?)\r?\n---\r?\n/s', $markdown, $match)) {
return null;
}
$result = [];
$section = null;
foreach (preg_split('/\r?\n/', $match['yaml']) as $line) {
if (preg_match('/^(\w+):\s*$/', $line, $sectionMatch)) {
$section = $sectionMatch[1];
$result[$section] = [];
continue;
}
if (preg_match('/^\s{2}(\w+):\s*(.+)$/', $line, $nestedMatch) && $section !== null) {
$result[$section][$nestedMatch[1]] = parseYamlValue($nestedMatch[2]);
continue;
}
if (preg_match('/^(\w+):\s*(.+)$/', $line, $scalarMatch)) {
$section = null;
$result[$scalarMatch[1]] = parseYamlValue($scalarMatch[2]);
}
}
return $result;
}
function parseYamlValue(string $value): mixed
{
$value = trim($value);
if ((str_starts_with($value, '"') && str_ends_with($value, '"')) || (str_starts_with($value, "'") && str_ends_with($value, "'"))) {
return substr($value, 1, -1);
}
if (str_starts_with($value, '[') && str_ends_with($value, ']')) {
$items = trim(substr($value, 1, -1));
return $items === '' ? [] : array_map(static fn (string $item): int|string => ctype_digit(trim($item)) ? (int) trim($item) : trim($item), explode(',', $items));
}
return ctype_digit($value) ? (int) $value : $value;
}
function validatePages(string $pdfName, array $pages, string $file): void
{
global $booksRoot, $summary;
if ($pages === []) {
fail('source_pages_empty', 'source.pages must list at least one source page.', ['file' => $file]);
return;
}
foreach ($pages as $page) {
if (!is_int($page) || $page < 1) {
fail('source_page_invalid', 'source.pages must contain positive integers.', ['file' => $file, 'page' => $page]);
return;
}
}
if (count(array_unique($pages)) !== count($pages)) {
warn('source_pages_unsorted', 'source.pages contains duplicates; keep one sorted entry per page.', ['file' => $file]);
}
$sorted = $pages;
sort($sorted);
if ($sorted !== $pages) {
warn('source_pages_unsorted', 'source.pages should be sorted ascending.', ['file' => $file]);
}
$pdfPath = $booksRoot . '/' . $pdfName;
if (!is_file($pdfPath)) {
fail('source_pdf_missing', 'Referenced source PDF is not available for verification.', ['file' => $file, 'pdf' => $pdfName, 'expected_path' => $pdfPath]);
return;
}
$pageCount = pdfPageCount($pdfPath);
if ($pageCount === null) {
fail('source_pdf_unreadable', 'Unable to determine page count for source PDF.', ['file' => $file, 'pdf' => $pdfName]);
return;
}
$summary['source_references_checked']++;
foreach ($pages as $page) {
if (!is_int($page) || $page < 1 || $page > $pageCount) {
fail('source_page_out_of_range', 'Referenced page is outside the source PDF.', ['file' => $file, 'pdf' => $pdfName, 'page' => $page, 'page_count' => $pageCount]);
}
}
}
function pdfPageCount(string $path): ?int
{
$output = [];
$status = 0;
exec('pdfinfo ' . escapeshellarg($path) . ' 2>/dev/null', $output, $status);
if ($status !== 0) {
return null;
}
foreach ($output as $line) {
if (preg_match('/^Pages:\s+(\d+)$/', $line, $match)) {
return (int) $match[1];
}
}
return null;
}
function lessonContent(string $markdown): string
{
if (!str_contains($markdown, '## محتوى الدرس')) {
return trim(preg_replace('/\A---\r?\n.*?\r?\n---\r?\n/s', '', $markdown) ?? '');
}
$afterHeading = explode('## محتوى الدرس', $markdown, 2)[1];
$beforeNextHeading = preg_split('/\r?\n## /', $afterHeading, 2)[0] ?? '';
return trim($beforeNextHeading);
}
function normalizeBody(string $content): string
{
return preg_replace('/\s+/u', ' ', trim($content)) ?? '';
}
function contentType(string $path): string
{
$name = pathinfo($path, PATHINFO_FILENAME);
return match (true) {
str_starts_with($name, 'lesson_') => 'lesson',
str_contains($name, 'review'), str_contains($name, 'exam'), str_contains($name, 'test') => 'review_or_exam',
str_contains($name, 'lab') => 'lab',
str_contains($name, 'project'), str_contains($name, 'intro') => 'project_or_intro',
str_contains($name, 'summary') => 'summary',
default => 'special_resource',
};
}
function resolveWithin(string $root, string $relativePath): ?string
{
$rootPath = realpath($root);
$candidate = $rootPath ? realpath($rootPath . '/' . ltrim($relativePath, '/')) : false;
$prefix = $rootPath ? rtrim($rootPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : '';
return $candidate && $prefix !== '' && str_starts_with($candidate, $prefix) ? $candidate : null;
}
function relativePath(string $path, string $root): string
{
return ltrim(substr($path, strlen(rtrim($root, DIRECTORY_SEPARATOR))), DIRECTORY_SEPARATOR);
}
function fail(string $code, string $message, array $context = []): void
{
global $issues;
$issues[] = ['severity' => 'error', 'code' => $code, 'message' => $message, 'context' => $context];
}
function warn(string $code, string $message, array $context = []): void
{
global $issues;
$issues[] = ['severity' => 'warning', 'code' => $code, 'message' => $message, 'context' => $context];
}
function countIssues(string $code): int
{
global $issues;
return count(array_filter($issues, static fn (array $issue): bool => $issue['code'] === $code));
}
function finish(): never
{
global $issues, $summary, $jsonOutput;
$errors = array_values(array_filter($issues, static fn (array $issue): bool => $issue['severity'] === 'error'));
$warnings = array_values(array_filter($issues, static fn (array $issue): bool => $issue['severity'] === 'warning'));
$result = ['status' => $errors === [] ? 'passed' : 'blocked', 'summary' => $summary + ['errors' => count($errors), 'warnings' => count($warnings)], 'errors' => $errors, 'warnings' => $warnings];
if ($jsonOutput) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE) . PHP_EOL;
} else {
echo sprintf("Candidate intake: %s — %d errors, %d warnings\n", $result['status'], count($errors), count($warnings));
foreach ($errors as $issue) {
echo '[ERROR] ' . $issue['code'] . ' — ' . $issue['message'] . PHP_EOL;
}
foreach ($warnings as $issue) {
echo '[WARN] ' . $issue['code'] . ' — ' . $issue['message'] . PHP_EOL;
}
echo PHP_EOL . sprintf("=== INTAKE RESULT: %s (%d errors, %d warnings) ===", strtoupper($result['status']), count($errors), count($warnings)) . PHP_EOL;
}
exit($errors === [] ? 0 : 1);
}