#!/usr/bin/env node /** * validate-content.js * Sysadmin Chronicles content validation tool. * * Run from project root: * node tools/content/validate-content.js * node tools/content/validate-content.js --quests-only * node tools/content/validate-content.js --verbose * * Exit code 0 = no errors. Exit code 1 = validation errors found. * * Checks performed: * - All JSON files parse correctly * - No duplicate IDs across content domains * - Every world flag referenced anywhere exists in world_flags.json * - Every required_vms entry maps to a known VM profile * - Every blast_radius entry maps to a known incident ID * - Every ticket_id in a quest maps to an existing ticket * - Every linked_quest in a ticket maps to an existing quest * - Branch priorities within a quest are unique (no ties) * - Every follow_up_incident maps to an existing incident file * - Every series_id has at least 2 members * - clue_fingerprint evidence uses valid rule types * - file_absent and file_owner_is_not are accepted rule types (OI-010 resolved) * - Package-manager-specific paths/commands match the authored VM distro */ const fs = require("fs"); const path = require("path"); // --------------------------------------------------------------------------- // CONFIG // --------------------------------------------------------------------------- const CONTENT_ROOT = path.resolve(__dirname, "../../content"); const QUESTS_DIR = path.join(CONTENT_ROOT, "quests"); const TICKETS_DIR = path.join(CONTENT_ROOT, "tickets"); const INCIDENTS_DIR= path.join(CONTENT_ROOT, "incidents"); const DIALOGUE_DIR = path.join(CONTENT_ROOT, "dialogue"); const FLAGS_FILE = path.join(CONTENT_ROOT, "world_flags", "world_flags.json"); const VM_PROFILES_DIR = path.join(CONTENT_ROOT, "vm_profiles"); const PRESSURE_PROFILES_DIR = path.join(CONTENT_ROOT, "pressure_profiles"); const VALID_RULE_TYPES = new Set([ // Standard validation rule types (used in objectives and solution branches) "file_exists", "file_absent", "file_contains", "file_mode", "file_owner", "file_owner_is_not", "directory_exists", "service_state", "service_enabled", "process_running", "process_user", "port_listening", "package_installed", "mount_present", "disk_usage_below", "disk_usage_above", "command_assert", "log_contains", "and", "or", "not", // Advisory clue_fingerprint-only types (descriptive evidence, not used in branch validation) // These document what evidence EXISTS in the VM baseline — not evaluated at runtime. "service_state_is", "service_enabled_is", "file_size_above", "file_size_below", ]); const VALID_NARRATIVE_PHASES = new Set([ "normal_work", "unease", "suspicion", "investigation", "conflict", "resolution" ]); const args = process.argv.slice(2); const verbose = args.includes("--verbose"); const questsOnly = args.includes("--quests-only"); // --------------------------------------------------------------------------- // LOAD HELPERS // --------------------------------------------------------------------------- let errors = 0; let warnings = 0; function err(msg) { console.error(` ❌ ERROR: ${msg}`); errors++; } function warn(msg) { console.warn(` ⚠ WARN: ${msg}`); warnings++; } function ok(msg) { if (verbose) console.log(` ✓ ${msg}`); } function loadJson(filePath) { try { const text = fs.readFileSync(filePath, "utf8"); return JSON.parse(text); } catch (e) { err(`JSON parse error in ${path.relative(CONTENT_ROOT, filePath)}: ${e.message}`); return null; } } function loadDir(dirPath, idField = "id") { const result = {}; if (!fs.existsSync(dirPath)) { warn(`Directory not found: ${dirPath}`); return result; } for (const fname of fs.readdirSync(dirPath)) { if (!fname.endsWith(".json") || fname.startsWith(".")) continue; // Skip split artifacts. Pending splits still deserve a warning; completed // split archives and backups are intentional and should stay quiet. if (fname.includes("SPLIT_PENDING")) { warn(`Skipping non-standard file: ${fname}`); continue; } if (fname.includes("SPLIT_DONE") || fname.includes(".bak")) { continue; } const data = loadJson(path.join(dirPath, fname)); if (!data) continue; if (Array.isArray(data)) { warn(`${fname} is an array — expected single object with '${idField}' field. Split into individual files.`); continue; } const id = data[idField]; if (!id) { err(`Missing '${idField}' field in ${fname}`); continue; } if (result[id]) { err(`Duplicate ID '${id}' in ${fname} (already seen)`); } result[id] = { data, fname }; } return result; } // --------------------------------------------------------------------------- // RULE TYPE VALIDATOR (recursive) // --------------------------------------------------------------------------- function validateRuleTypes(rule, context) { if (!rule || typeof rule !== "object") return; const type = rule.type; if (!type) { err(`${context}: rule missing 'type' field`); return; } if (!VALID_RULE_TYPES.has(type)) { err(`${context}: unknown rule type '${type}'`); return; } // Recurse into composite rules if (type === "and" || type === "or") { for (const sub of (rule.rules || [])) { validateRuleTypes(sub, context); } } if (type === "not") { validateRuleTypes(rule.rule, context); } } // --------------------------------------------------------------------------- // COLLECT ALL FLAG REFERENCES IN A RULE (recursive) // --------------------------------------------------------------------------- function collectFlagRefs(obj, refs = new Set()) { if (!obj || typeof obj !== "object") return refs; if (Array.isArray(obj)) { for (const item of obj) collectFlagRefs(item, refs); return refs; } for (const [k, v] of Object.entries(obj)) { if (k === "world_flags" && Array.isArray(v)) { for (const f of v) refs.add(f); } else if (k === "trigger" && typeof v === "string" && v.startsWith("world_flag:")) { refs.add(v.slice("world_flag:".length)); } else if (typeof v === "object") { collectFlagRefs(v, refs); } } return refs; } function distroForVm(vmId, vmProfiles) { return vmProfiles[vmId]?.data?.distro || ""; } function validateVmSpecificSemantics(rule, context, vmProfiles) { if (!rule || typeof rule !== "object") return; if (Array.isArray(rule)) { for (const item of rule) validateVmSpecificSemantics(item, context, vmProfiles); return; } const vmId = typeof rule.vm === "string" ? rule.vm : ""; const distro = distroForVm(vmId, vmProfiles); const pathValue = typeof rule.path === "string" ? rule.path : ""; const commandValue = typeof rule.command === "string" ? rule.command : ""; if (vmId && distro) { const pathCtx = `${context} (${vmId}/${distro})`; if (pathValue === "/etc/pacman.conf" && distro !== "arch") { err(`${pathCtx}: /etc/pacman.conf is Arch-specific but VM distro is '${distro}'`); } if (pathValue.startsWith("/etc/apt/") && distro === "arch") { err(`${pathCtx}: /etc/apt/* is Debian/Ubuntu-specific but VM distro is 'arch'`); } if (commandValue.includes("pacman") && distro !== "arch") { err(`${pathCtx}: command references pacman but VM distro is '${distro}'`); } if ((commandValue.includes("apt ") || commandValue.includes("apt-get") || commandValue.includes("dpkg ")) && distro === "arch") { err(`${pathCtx}: command references apt/dpkg but VM distro is 'arch'`); } } for (const value of Object.values(rule)) { if (value && typeof value === "object") { validateVmSpecificSemantics(value, context, vmProfiles); } } } // --------------------------------------------------------------------------- // MAIN VALIDATION // --------------------------------------------------------------------------- console.log("═══════════════════════════════════════"); console.log(" Sysadmin Chronicles Content Validator"); console.log("═══════════════════════════════════════\n"); // Load all content console.log("Loading content..."); const quests = loadDir(QUESTS_DIR); const tickets = loadDir(TICKETS_DIR); const incidents = loadDir(INCIDENTS_DIR); const dialogue = loadDir(DIALOGUE_DIR); const vmProfiles= loadDir(VM_PROFILES_DIR); const pressureProfiles = loadDir(PRESSURE_PROFILES_DIR); const flagsRaw = loadJson(FLAGS_FILE); // flags field may be an Array (list of {id, ...}) or a Dict (id -> {...}) // Normalize to Dict keyed by id for validation lookups. let flagRegistry = {}; if (flagsRaw) { const raw = flagsRaw.flags || flagsRaw; if (Array.isArray(raw)) { for (const f of raw) { if (f.id) flagRegistry[f.id] = f; } } else if (typeof raw === "object") { flagRegistry = raw; } } console.log(` Quests: ${Object.keys(quests).length}, Tickets: ${Object.keys(tickets).length}, Incidents: ${Object.keys(incidents).length}, Dialogue: ${Object.keys(dialogue).length}, Pressure Profiles: ${Object.keys(pressureProfiles).length}\n`); // --------------------------------------------------------------------------- // QUEST VALIDATION // --------------------------------------------------------------------------- console.log("Validating quests..."); for (const [qid, { data: quest, fname }] of Object.entries(quests)) { const ctx = `Quest ${qid} (${fname})`; // required_vms for (const vmId of (quest.required_vms || [])) { if (!vmProfiles[vmId]) err(`${ctx}: required_vms references unknown VM profile '${vmId}'`); } // baseline_snapshot if (!quest.baseline_snapshot || typeof quest.baseline_snapshot !== 'string' || !quest.baseline_snapshot.trim()) { err(`${ctx}: missing or empty 'baseline_snapshot' field`); } // ticket_id if (quest.ticket_id && !tickets[quest.ticket_id]) { err(`${ctx}: ticket_id '${quest.ticket_id}' not found in tickets/`); } // pressure_profile if (quest.pressure_profile && !pressureProfiles[quest.pressure_profile]) { err(`${ctx}: pressure_profile '${quest.pressure_profile}' not found in pressure_profiles/`); } // blast_radius for (const incId of (quest.blast_radius || [])) { if (!incidents[incId]) warn(`${ctx}: blast_radius references '${incId}' which doesn't exist yet`); } // Branch priority uniqueness const branches = quest.solution_branches || []; const priorities = new Set(); for (const branch of branches) { const p = branch.priority; if (priorities.has(p)) { err(`${ctx}: duplicate branch priority ${p} — each branch must have a unique priority`); } priorities.add(p); // follow_up_incident if (branch.follow_up_incident && !incidents[branch.follow_up_incident]) { err(`${ctx}: branch '${branch.id}' follow_up_incident '${branch.follow_up_incident}' not found`); } // Validate branch validation rules validateRuleTypes(branch.validation, `${ctx} branch '${branch.id}'`); } // clue_fingerprint evidence rule types const evidence = (quest.clue_fingerprint || {}).evidence || []; for (const ev of evidence) { if (!VALID_RULE_TYPES.has(ev.type)) { err(`${ctx}: clue_fingerprint evidence uses unknown rule type '${ev.type}'`); } validateVmSpecificSemantics(ev, `${ctx} clue_fingerprint`, vmProfiles); } // Objective rule types for (const obj of (quest.objectives || [])) { validateRuleTypes(obj.validation, `${ctx} objective '${obj.id}'`); validateVmSpecificSemantics(obj.validation, `${ctx} objective '${obj.id}'`, vmProfiles); } for (const branch of branches) { validateVmSpecificSemantics(branch.validation, `${ctx} branch '${branch.id}'`, vmProfiles); } // narrative_phase if (!quest.narrative_phase) { warn(`${ctx}: missing 'narrative_phase' field`); } else if (!VALID_NARRATIVE_PHASES.has(quest.narrative_phase)) { err(`${ctx}: unknown narrative_phase '${quest.narrative_phase}'`); } // behavior_impact if (quest.behavior_impact !== undefined) { for (const [branchKey, impact] of Object.entries(quest.behavior_impact)) { for (const field of ['curiosity_delta', 'obedience_delta', 'risk_delta', 'suspicion_delta']) { if (impact[field] !== undefined && typeof impact[field] !== 'number') { err(`${ctx}: behavior_impact[${branchKey}].${field} must be a number`); } } } } // hidden_hook shape if (quest.hidden_hook !== undefined && quest.hidden_hook !== null) { if (typeof quest.hidden_hook.id !== 'string') { err(`${ctx}: hidden_hook.id must be a string`); } } // access_requirements if (quest.access_requirements?.minimum_access) { for (const [vmId] of Object.entries(quest.access_requirements.minimum_access)) { if (!vmProfiles[vmId]) { err(`${ctx}: access_requirements.minimum_access references unknown VM '${vmId}'`); } } } ok(`${ctx}: OK`); } if (!questsOnly) { // --------------------------------------------------------------------------- // TICKET VALIDATION // --------------------------------------------------------------------------- console.log("\nValidating tickets..."); for (const [tid, { data: ticket, fname }] of Object.entries(tickets)) { const ctx = `Ticket ${tid} (${fname})`; if (ticket.linked_quest && !quests[ticket.linked_quest]) { warn(`${ctx}: linked_quest '${ticket.linked_quest}' not found`); } if (ticket.initial_priority === undefined) { err(`${ctx}: missing 'initial_priority'`); } if (ticket.current_priority === undefined) { err(`${ctx}: missing 'current_priority'`); } if (ticket.initial_priority !== ticket.current_priority) { warn(`${ctx}: initial_priority != current_priority at authoring time`); } ok(`${ctx}: OK`); } // --------------------------------------------------------------------------- // INCIDENT VALIDATION // --------------------------------------------------------------------------- console.log("\nValidating incidents..."); for (const [iid, { data: incident, fname }] of Object.entries(incidents)) { const ctx = `Incident ${iid} (${fname})`; if (!Array.isArray(incident.blast_radius_quests)) { err(`${ctx}: missing 'blast_radius_quests' (must be present, can be [])`); } if (!Array.isArray(incident.blast_radius_incidents)) { err(`${ctx}: missing 'blast_radius_incidents' (must be present, can be [])`); } if (incident.resolution_requirements?.validation) { validateRuleTypes(incident.resolution_requirements.validation, `${ctx} resolution_requirements`); validateVmSpecificSemantics(incident.resolution_requirements.validation, `${ctx} resolution_requirements`, vmProfiles); } ok(`${ctx}: OK`); } // --------------------------------------------------------------------------- // DIALOGUE VALIDATION // --------------------------------------------------------------------------- console.log("\nValidating dialogue..."); const seriesMembers = {}; for (const [did, { data: dlg, fname }] of Object.entries(dialogue)) { const ctx = `Dialogue ${did} (${fname})`; if (dlg.series_id) { if (dlg.series_position === undefined) { err(`${ctx}: has series_id '${dlg.series_id}' but missing series_position`); } if (!seriesMembers[dlg.series_id]) seriesMembers[dlg.series_id] = []; seriesMembers[dlg.series_id].push(did); } // world_flag trigger if (dlg.trigger && dlg.trigger.startsWith("world_flag:")) { const flagId = dlg.trigger.slice("world_flag:".length); if (!flagRegistry[flagId]) { err(`${ctx}: trigger flag '${flagId}' not in world_flags registry`); } } ok(`${ctx}: OK`); } // series membership count check for (const [sid, members] of Object.entries(seriesMembers)) { if (members.length < 2) { warn(`Series '${sid}' has only 1 member (${members[0]}) — needs 2+ for series tracking`); } } // --------------------------------------------------------------------------- // WORLD FLAG CROSS-REFERENCE // --------------------------------------------------------------------------- console.log("\nValidating world flag references..."); const allContent = [ ...Object.values(quests).map(v => v.data), ...Object.values(tickets).map(v => v.data), ...Object.values(incidents).map(v => v.data), ...Object.values(dialogue).map(v => v.data), ]; const referencedFlags = new Set(); for (const item of allContent) { collectFlagRefs(item, referencedFlags); } for (const flagId of referencedFlags) { if (!flagRegistry[flagId]) { err(`World flag '${flagId}' is referenced in content but not declared in world_flags.json`); } } } // --------------------------------------------------------------------------- // SUMMARY // --------------------------------------------------------------------------- console.log("\n═══════════════════════════════════════"); if (errors === 0 && warnings === 0) { console.log(" ✅ All content valid. Zero errors, zero warnings."); } else { if (errors > 0) console.log(` ❌ ${errors} error(s) found.`); if (warnings > 0) console.log(` ⚠ ${warnings} warning(s) found.`); } console.log("═══════════════════════════════════════\n"); process.exit(errors > 0 ? 1 : 0);