231 lines
11 KiB
PHP
231 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Safe promotion path for the Grade 10 candidate intake bundle.
|
|
*
|
|
* _incoming/grade_10 ---> content_assets + publication_bundles (draft only)
|
|
*
|
|
* Safety rules (enforced by this script, not by convention):
|
|
* - Default mode is a read-only dry run: it re-runs the strict intake gate
|
|
* on the chosen manifest and prints the staging plan. It writes nothing.
|
|
* - --apply requires the gate to PASS for the chosen manifest, otherwise it
|
|
* refuses. The full candidate manifest is therefore un promotable until
|
|
* quarantined files are resolved by humans.
|
|
* - --apply only creates `draft` bundles/assets with `review_required` /
|
|
* `draft` review status. It never publishes, never approves rights, never
|
|
* writes lessons.markdown_content, and never touches the live manifest.json.
|
|
* - Files are staged content-addressed (sha256) so re-runs are idempotent.
|
|
* - No exercise, lab, simulation, or question generation happens here. Those
|
|
* require a published, reviewed bundle (see docs/GRADE10_CANDIDATE_INTAKE_GATE.md).
|
|
*
|
|
* Usage:
|
|
* php backend/scripts/promote_grade10_candidate.php
|
|
* php backend/scripts/promote_grade10_candidate.php --manifest=manifest.accepted.json
|
|
* php backend/scripts/promote_grade10_candidate.php --manifest=manifest.accepted.json --apply
|
|
*/
|
|
|
|
$apply = in_array('--apply', $argv, true);
|
|
$manifestName = 'manifest.accepted.json';
|
|
foreach ($argv as $arg) {
|
|
if (str_starts_with($arg, '--manifest=')) {
|
|
$manifestName = substr($arg, strlen('--manifest='));
|
|
}
|
|
}
|
|
|
|
$projectRoot = dirname(__DIR__, 2);
|
|
$intakeRoot = $projectRoot . '/backend/storage/curriculum/_incoming';
|
|
$candidateRoot = $intakeRoot . '/grade_10';
|
|
$stageRoot = $projectRoot . '/backend/storage/curriculum/_staging/grade_10';
|
|
$manifestPath = $candidateRoot . '/' . basename($manifestName);
|
|
$validator = $projectRoot . '/backend/scripts/validate_grade10_candidate.php';
|
|
|
|
// 1. Mandatory gate preflight: identical strict rules, no exceptions.
|
|
$phpBinary = defined('PHP_BINARY') && PHP_BINARY !== '' ? PHP_BINARY : 'php';
|
|
$gateJson = shell_exec(
|
|
escapeshellcmd($phpBinary) . ' ' . escapeshellarg($validator) . ' --json --manifest=' . escapeshellarg(basename($manifestName)) . ' 2>/dev/null'
|
|
);
|
|
$gate = json_decode((string) $gateJson, true);
|
|
if (!is_array($gate) || ($gate['status'] ?? null) !== 'passed') {
|
|
fwrite(STDERR, "Promotion refused: intake gate did not pass for {$manifestName}.\n");
|
|
if (is_array($gate) && !empty($gate['errors'])) {
|
|
fwrite(STDERR, sprintf("Gate reported %d blocking error(s):\n", count($gate['errors'])));
|
|
foreach (array_slice($gate['errors'], 0, 10) as $err) {
|
|
$contextFile = $err['context']['file'] ?? ($err['context']['path'] ?? '');
|
|
fwrite(STDERR, sprintf(" - [%s] %s%s\n", $err['code'] ?? 'error', $err['message'] ?? '', $contextFile ? " ({$contextFile})" : ''));
|
|
}
|
|
if (count($gate['errors']) > 10) {
|
|
fwrite(STDERR, sprintf(" ... and %d more error(s). Run: php backend/scripts/validate_grade10_candidate.php --manifest=%s\n", count($gate['errors']) - 10, basename($manifestName)));
|
|
}
|
|
} elseif (empty($gateJson)) {
|
|
fwrite(STDERR, "Preflight gate failed to execute or returned empty output.\n");
|
|
fwrite(STDERR, "Run the gate directly to see the exact issue:\n");
|
|
fwrite(STDERR, " php backend/scripts/validate_grade10_candidate.php --manifest=" . basename($manifestName) . "\n");
|
|
}
|
|
fwrite(STDERR, "Resolve review-queue.json items first; quarantined files stay unpublished.\n");
|
|
exit(1);
|
|
}
|
|
|
|
$manifest = json_decode((string) file_get_contents($manifestPath), true);
|
|
$entries = $manifest['lessons'] ?? [];
|
|
$curriculumVersion = (string) ($manifest['curriculum_version'] ?? 'unknown');
|
|
$bundleVersion = 'grade10-intake-2026-09-10';
|
|
|
|
$plan = [];
|
|
foreach ($entries as $entry) {
|
|
$relativePath = (string) ($entry['file'] ?? '');
|
|
if (!preg_match('#\Agrade_10/[a-z0-9_]+/semester_[12]/unit_[0-9]+/[a-z0-9_]+\.md\z#', $relativePath)) {
|
|
fwrite(STDERR, "Promotion refused: unsafe file path in manifest: {$relativePath}\n");
|
|
exit(1);
|
|
}
|
|
$abs = $intakeRoot . '/' . $relativePath;
|
|
$sha = hash_file('sha256', $abs);
|
|
$bytes = filesize($abs);
|
|
if ($sha === false || $bytes === false) {
|
|
fwrite(STDERR, "Promotion refused: unreadable file: {$relativePath}\n");
|
|
exit(1);
|
|
}
|
|
$frontmatter = parseFrontmatter((string) file_get_contents($abs));
|
|
$resourceType = (string) ($frontmatter['resource_type'] ?? '');
|
|
$source = $frontmatter['source'] ?? [];
|
|
$pages = is_array($source['pages'] ?? null) ? $source['pages'] : [];
|
|
$plan[] = [
|
|
'file' => $relativePath,
|
|
'grade_key' => $entry['grade_key'],
|
|
'subject_key' => $entry['subject_key'],
|
|
'semester_key' => $entry['semester_key'],
|
|
'unit_key' => $entry['unit_key'],
|
|
'lesson_key' => $entry['lesson_key'],
|
|
'title' => $entry['title'],
|
|
'resource_type' => $resourceType,
|
|
'source_reference' => 'books/' . ($source['original_pdf'] ?? '?') . '#pages-' . implode(',', $pages),
|
|
'sha256' => $sha,
|
|
'byte_size' => $bytes,
|
|
'storage_key' => sprintf('staged/grade_10/%s/%s', substr($sha, 0, 12), $relativePath),
|
|
'asset_type' => $resourceType === 'lesson' ? 'lesson_markdown' : 'supporting_markdown',
|
|
'bundle_role' => $resourceType === 'lesson' ? 'primary_lesson' : 'supporting_resource',
|
|
];
|
|
}
|
|
|
|
$report = [
|
|
'mode' => $apply ? 'apply' : 'dry_run',
|
|
'manifest' => basename($manifestName),
|
|
'curriculum_version' => $curriculumVersion,
|
|
'bundle_version' => $bundleVersion,
|
|
'bundle_status' => 'draft',
|
|
'asset_review_status' => 'draft',
|
|
'policy' => 'Draft staging only. No publication, no rights clearance, no lessons.markdown_content writes, no exercise/lab generation.',
|
|
'entries' => count($plan),
|
|
];
|
|
|
|
if (!$apply) {
|
|
$report['plan'] = $plan;
|
|
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
|
exit(0);
|
|
}
|
|
|
|
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
|
|
|
use App\Core\Database;
|
|
|
|
$pdo = Database::getConnection();
|
|
$pdo->beginTransaction();
|
|
try {
|
|
foreach ($plan as $row) {
|
|
$source = $intakeRoot . '/' . $row['file'];
|
|
$destination = $projectRoot . '/backend/storage/curriculum/' . $row['storage_key'];
|
|
if (!is_dir(dirname($destination)) && !mkdir(dirname($destination), 0750, true) && !is_dir(dirname($destination))) {
|
|
throw new RuntimeException('Unable to create staging directory.');
|
|
}
|
|
if (!copy($source, $destination) || !hash_equals($row['sha256'], hash_file('sha256', $destination))) {
|
|
throw new RuntimeException('Staged copy checksum mismatch: ' . $row['file']);
|
|
}
|
|
Database::query(
|
|
"INSERT INTO curriculum_lessons
|
|
(uuid, grade_key, subject_key, semester_key, unit_key, lesson_key, curriculum_version, title, source_manifest_path, source_status)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'unverified')
|
|
ON DUPLICATE KEY UPDATE title = VALUES(title), source_manifest_path = VALUES(source_manifest_path)",
|
|
[selfUuid(), $row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key'], $curriculumVersion, $row['title'], $row['file']]
|
|
);
|
|
$lessonId = (int) Database::selectOne(
|
|
'SELECT id FROM curriculum_lessons WHERE grade_key = ? AND subject_key = ? AND semester_key = ? AND unit_key = ? AND lesson_key = ? AND curriculum_version = ? LIMIT 1',
|
|
[$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key'], $curriculumVersion]
|
|
)['id'];
|
|
Database::query(
|
|
"INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status)
|
|
VALUES (?, ?, ?, 'draft')
|
|
ON DUPLICATE KEY UPDATE id = id",
|
|
[selfUuid(), $lessonId, $bundleVersion]
|
|
);
|
|
$bundleId = (int) Database::selectOne(
|
|
'SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND bundle_version = ? LIMIT 1',
|
|
[$lessonId, $bundleVersion]
|
|
)['id'];
|
|
Database::query(
|
|
"INSERT INTO content_assets (uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, source_reference, rights_status, review_status)
|
|
VALUES (?, ?, 'local', ?, 'text/markdown; charset=utf-8', ?, ?, ?, 'review_required', 'draft')
|
|
ON DUPLICATE KEY UPDATE byte_size = VALUES(byte_size), source_reference = VALUES(source_reference)",
|
|
[selfUuid(), $row['asset_type'], $row['storage_key'], $row['byte_size'], $row['sha256'], $row['source_reference']]
|
|
);
|
|
$assetId = (int) Database::selectOne(
|
|
'SELECT id FROM content_assets WHERE storage_driver = ? AND storage_key = ? AND sha256 = ? LIMIT 1',
|
|
['local', $row['storage_key'], $row['sha256']]
|
|
)['id'];
|
|
Database::query(
|
|
'INSERT IGNORE INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role) VALUES (?, ?, ?)',
|
|
[$bundleId, $assetId, $row['bundle_role']]
|
|
);
|
|
}
|
|
$pdo->commit();
|
|
$report['staged_draft_entries'] = count($plan);
|
|
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
fwrite(STDERR, 'Promotion rolled back: ' . $e->getMessage() . "\n");
|
|
exit(1);
|
|
}
|
|
|
|
function parseFrontmatter(string $markdown): array
|
|
{
|
|
if (!preg_match('/\A---\r?\n(?<yaml>.*?)\r?\n---\r?\n/s', $markdown, $match)) {
|
|
return [];
|
|
}
|
|
$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) {
|
|
$raw = trim($nestedMatch[2]);
|
|
if (str_starts_with($raw, '[') && str_ends_with($raw, ']')) {
|
|
$inner = trim(substr($raw, 1, -1));
|
|
$result[$section][$nestedMatch[1]] = $inner === ''
|
|
? []
|
|
: array_map(static fn (string $item): int|string => ctype_digit(trim($item)) ? (int) trim($item) : trim($item), explode(',', $inner));
|
|
} else {
|
|
$result[$section][$nestedMatch[1]] = trim($raw, '"');
|
|
}
|
|
continue;
|
|
}
|
|
if (preg_match('/^(\w+):\s*(.+)$/', $line, $scalarMatch)) {
|
|
$section = null;
|
|
$result[$scalarMatch[1]] = trim($scalarMatch[2], '"');
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
function selfUuid(): string
|
|
{
|
|
$bytes = random_bytes(16);
|
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
|
}
|