Upgrades six PARTIAL modules to the gold-module standard (lesson/demonstration/ knowledge-check/guided-exercise/lab(s)/reflection), preserving every existing lab activity_id and scenario_id: root-06-m03 (jobs/cronjobs/daemonsets), root-07-m04 (ingress/networkpolicy), root-07-m02 (configuration/secrets, converted in place from a bare 5-lab bank), root-09-m02 (serviceaccounts/rbac), root-09-m03 (network/supply-chain security), root-10-m03 (troubleshooting framework). root-05-m01 (20-task kubectl-fluency bank) gets a minimal bookending lesson + reflection rather than a full redesign, since each task already carries its own explanation. root-12-m01, root-capstone-m01, and root-09-m04 are promoted to READY via documented exceptions (no new activities) where adding one would misrepresent their intentional design. Wires a new rootnode-security-assessment (exam, 60min, hint_policy disabled) using the existing Phase 3B assessment engine: 3 required hands-on CORE scenarios + 4 random-draw pools covering SecurityContext, RBAC, network policy, and the 7 previously-unwired CKS MCQs. Wires the previously-unwired readiness-probe-task scenario as an OPTIONAL practice lab in root-10-m01, using the existing is_required model. Adds root-11-m01-devops-platform-thinking — an honest, lesson-only ROOT 11 orientation module naming DevOps/platform engineering practices already exercised in ROOT 05-10/12, explicit that GitOps/CI-CD/IaC/observability need lab infrastructure this platform doesn't yet provide. No fake labs. Adds infra/scripts/validate-course-content.js: cross-checks the DB against scenario/content files for duplicate ids, orphans, invalid references, cycles, sequence collisions, instructor-notes leakage, and (new) self-paced content deferring explanation to a live instructor. 0 errors against the resulting 22 courses/modules, 163 activities, 98 scenario files. No application code changed — every gap was satisfiable with existing tables (course_modules, module_activities, assessment_definitions) and the existing content-delivery pipeline. Co-Authored-By: Claude Sonnet 5 <[email protected]>
189 lines
12 KiB
JavaScript
189 lines
12 KiB
JavaScript
#!/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); });
|