#!/usr/bin/env node // Phase 6C §24 — programmatic content validation: fails loudly if the course/module/activity // content model has any structural defect. Run against a live Postgres (same connection the // Platform API uses) plus the repo's own scenarios/data, scenarios/rootnode-path.json, and // content/root/ files — cross-checks the database against the filesystem rather than trusting // either alone. Exit code 1 on any failure; safe to wire into CI later, not built as CI here // (no CI system exists in this repo to wire into). // // Usage: PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \ // node infra/scripts/validate-course-content.js const fs = require('fs'); const path = require('path'); const REPO_ROOT = path.join(__dirname, '../..'); // Reuses platform/api's own `pg` dependency rather than adding a new package.json/node_modules // just for this script — same "no new dependency where an existing one already covers it" // convention the rest of this codebase follows. const { Client } = require(path.join(REPO_ROOT, 'platform/api/node_modules/pg')); const DB_URL = process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform'; let errors = 0; function fail(msg) { console.error(` FAIL — ${msg}`); errors++; } function ok(msg) { console.log(` ok — ${msg}`); } async function main() { const client = new Client({ connectionString: DB_URL }); await client.connect(); const scenarioIds = new Set(fs.readdirSync(path.join(REPO_ROOT, 'scenarios/data')).filter((f) => f.endsWith('.json')).map((f) => f.replace('.json', ''))); const assessmentIds = new Set((await client.query('SELECT assessment_definition_id FROM assessment_definitions')).rows.map((r) => r.assessment_definition_id)); const courses = (await client.query('SELECT * FROM courses')).rows; const modules = (await client.query('SELECT * FROM course_modules')).rows; const activities = (await client.query('SELECT * FROM module_activities')).rows; const courseIds = new Set(courses.map((c) => c.course_id)); const moduleIds = new Set(modules.map((m) => m.module_id)); const activityIds = new Set(activities.map((a) => a.activity_id)); console.log(`Loaded ${courses.length} courses, ${modules.length} modules, ${activities.length} activities, ${scenarioIds.size} scenario files.\n`); // ── No duplicate IDs (PK already enforces this at the DB level, but verify the exported // JS-level arrays too, catching an accidental cross-table id collision the DB doesn't check) ─ for (const [label, ids] of [['course', courses.map((c) => c.course_id)], ['module', modules.map((m) => m.module_id)], ['activity', activities.map((a) => a.activity_id)]]) { const seen = new Set(); const dupes = new Set(); for (const id of ids) { if (seen.has(id)) dupes.add(id); seen.add(id); } if (dupes.size) fail(`duplicate ${label} id(s): ${[...dupes].join(', ')}`); else ok(`no duplicate ${label} ids`); } // ── No orphan modules/activities (referencing a course/module that doesn't exist) ──────── const orphanModules = modules.filter((m) => !courseIds.has(m.course_id)); if (orphanModules.length) fail(`orphan module(s) referencing a missing course: ${orphanModules.map((m) => m.module_id).join(', ')}`); else ok('no orphan modules'); const orphanActivities = activities.filter((a) => !moduleIds.has(a.module_id)); if (orphanActivities.length) fail(`orphan activity(ies) referencing a missing module: ${orphanActivities.map((a) => a.activity_id).join(', ')}`); else ok('no orphan activities'); // ── No invalid scenario/assessment references ──────────────────────────────────────────── const badScenarioRefs = activities.filter((a) => a.scenario_id && !scenarioIds.has(a.scenario_id)); if (badScenarioRefs.length) fail(`activity(ies) referencing a missing scenario file: ${badScenarioRefs.map((a) => `${a.activity_id}->${a.scenario_id}`).join(', ')}`); else ok('every lab activity scenario_id resolves to a real scenario file'); const badAssessmentRefs = activities.filter((a) => a.assessment_definition_id && !assessmentIds.has(a.assessment_definition_id)); if (badAssessmentRefs.length) fail(`activity(ies) referencing a missing assessment_definition: ${badAssessmentRefs.map((a) => `${a.activity_id}->${a.assessment_definition_id}`).join(', ')}`); else ok('every assessment/capstone activity resolves to a real assessment_definition'); // ── No impossible prerequisite references (a module/course pointing at an id that doesn't // exist at all — distinct from Phase 6A's deliberate "PLANNED = vacuously satisfied" rule, // this catches a genuine TYPO in a prerequisite id) ────────────────────────────────────── for (const c of courses) { for (const prereq of c.prerequisites || []) { if (!courseIds.has(prereq)) fail(`course ${c.course_id} lists a prerequisite that doesn't exist: ${prereq}`); } } for (const m of modules) { for (const prereq of m.prerequisites || []) { if (!moduleIds.has(prereq)) fail(`module ${m.module_id} lists a prerequisite that doesn't exist: ${prereq}`); } } ok('every course/module prerequisite id resolves to a real course/module (or was already reported above)'); // ── No course-tree cycles (a module/course that is its own transitive prerequisite) ────── function hasCycle(items, idKey) { const byId = new Map(items.map((i) => [i[idKey], i])); const state = new Map(); // id -> 'visiting' | 'done' let cyclic = null; function visit(id, chain) { if (cyclic) return; const item = byId.get(id); if (!item) return; if (state.get(id) === 'visiting') { cyclic = [...chain, id]; return; } if (state.get(id) === 'done') return; state.set(id, 'visiting'); for (const prereq of item.prerequisites || []) visit(prereq, [...chain, id]); state.set(id, 'done'); } for (const item of items) visit(item[idKey], []); return cyclic; } const courseCycle = hasCycle(courses, 'course_id'); if (courseCycle) fail(`course prerequisite cycle: ${courseCycle.join(' -> ')}`); else ok('no course prerequisite cycles'); const moduleCycle = hasCycle(modules, 'module_id'); if (moduleCycle) fail(`module prerequisite cycle: ${moduleCycle.join(' -> ')}`); else ok('no module prerequisite cycles'); // ── No sequence collisions within one module/course (two siblings sharing a sequence // number is a real authoring bug — found and fixed once already in Phase 6B, see // 023_phase6b_curriculum.sql's ROOT 07 m02/m04 comment) ────────────────────────────────── function checkSequenceCollisions(items, groupKey, label) { const groups = new Map(); for (const item of items) { const key = item[groupKey]; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(item.sequence); } let collided = 0; for (const [key, seqs] of groups) { const dupes = seqs.filter((s, i) => seqs.indexOf(s) !== i); if (dupes.length) { fail(`${label} sequence collision in ${key}: sequence ${[...new Set(dupes)].join(',')} used more than once`); collided++; } } if (!collided) ok(`no ${label} sequence collisions`); } checkSequenceCollisions(modules, 'course_id', 'module'); checkSequenceCollisions(activities, 'module_id', 'activity'); // ── Instructor notes never leak to students (content.js's own splitContent, exercised // directly here rather than trusted). content.js resolves content_path against // CONTENT_ROOT_DIR if set, defaulting to one level up from its own src/ (correct only INSIDE // the Docker image, where the Dockerfile COPYs content/ next to src/) — running it directly // on the host here needs this override so it resolves against the real repo root instead. process.env.CONTENT_ROOT_DIR = REPO_ROOT; const { loadContent } = require(path.join(REPO_ROOT, 'platform/api/src/content.js')); let leakChecked = 0, leaked = 0; for (const a of activities) { if (!a.content_path) continue; const c = loadContent(a.content_path); leakChecked++; if (c && c.shared && c.shared.includes('## Instructor Notes')) { fail(`${a.activity_id}: 'shared' (student-visible) content still contains the Instructor Notes heading — split failed`); leaked++; } } if (!leaked) ok(`instructor notes correctly excluded from student-visible content in all ${leakChecked} activities with a content_path`); // ── No content_path referencing a missing file ─────────────────────────────────────────── let missingContent = 0; for (const a of activities) { if (!a.content_path) continue; if (!fs.existsSync(path.join(REPO_ROOT, a.content_path))) { fail(`${a.activity_id}: content_path does not exist on disk: ${a.content_path}`); missingContent++; } } if (!missingContent) ok('every content_path resolves to a real file on disk'); // ── A 'lab' activity must have a scenario_id; an 'assessment'/'capstone' must have an // assessment_definition_id (the DB CHECK constraints already enforce this — verified here // too so this script fails loudly even against a hand-edited row that somehow bypassed it) ─ const labsMissingScenario = activities.filter((a) => a.activity_type === 'lab' && !a.scenario_id); if (labsMissingScenario.length) fail(`lab activity(ies) with no scenario_id: ${labsMissingScenario.map((a) => a.activity_id).join(', ')}`); else ok('every lab activity has a scenario_id'); const assessmentsMissingDef = activities.filter((a) => ['assessment', 'capstone'].includes(a.activity_type) && !a.assessment_definition_id); if (assessmentsMissingDef.length) fail(`assessment/capstone activity(ies) with no assessment_definition_id: ${assessmentsMissingDef.map((a) => a.activity_id).join(', ')}`); else ok('every assessment/capstone activity has an assessment_definition_id'); // ── Section 16/17 — a module marked READY (self-paced-sufficient) must not defer explanation // to a live instructor in its student-visible content. Scans the actual 'shared' half of // content.js's real split (never the instructor-notes half, which is explicitly ALLOWED to // say "explain live"), and only against READY modules — instructor-led-only PARTIAL modules // (see docs/ROOT_PILOT_CURRICULUM.md) are not held to the self-paced bar. ─────────────────── const readyModuleIds = new Set(modules.filter((m) => m.status === 'ready').map((m) => m.module_id)); const deferralPhrases = [/your instructor will explain/i, /ask your instructor/i, /your instructor will cover/i]; let deferralsFound = 0; for (const a of activities) { if (!a.content_path || !readyModuleIds.has(a.module_id)) continue; const c = loadContent(a.content_path); if (!c || !c.shared) continue; if (deferralPhrases.some((re) => re.test(c.shared))) { fail(`${a.activity_id}: self-paced-marked module's student-visible content defers to a live instructor`); deferralsFound++; } } if (!deferralsFound) ok('no READY module\'s student-visible content defers explanation to a live instructor'); // ── Duplicate scenario mapping — flagged, not failed, since Phase 6A/6B both document // DELIBERATE reuse (rootnode-probe-port-mismatch in both the gold module and Real Ops; // pod-security-context/rbac-clusterrole as documented independent-practice duplicates). This // prints the list for a human to confirm against docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md's // 'duplicate concepts identified' section rather than failing the build. ─────────────────── const scenarioUsage = new Map(); for (const a of activities) { if (!a.scenario_id) continue; if (!scenarioUsage.has(a.scenario_id)) scenarioUsage.set(a.scenario_id, []); scenarioUsage.get(a.scenario_id).push(a.activity_id); } const reused = [...scenarioUsage.entries()].filter(([, ids]) => ids.length > 1); console.log(` info — ${reused.length} scenario(s) intentionally reused across multiple activities (see docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md):`); for (const [sid, ids] of reused) console.log(` ${sid}: ${ids.join(', ')}`); await client.end(); console.log(`\n${errors === 0 ? 'PASS' : 'FAIL'} — ${errors} error(s)`); process.exit(errors ? 1 : 0); } main().catch((e) => { console.error('FATAL', e); process.exit(1); });