432 lines
19 KiB
PHP
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);
|
|
}
|