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////.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(?.*?)\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; } } exit($errors === [] ? 0 : 1); }