Files
44r0n7 0265afa054 chore: bootstrap lean sysadmin-chronicles repo
Import the runnable game code, content, docs, scripts, and repo guidance while leaving local agent state, dependency installs, build output, and backup copies out of the published tree.
2026-05-02 11:49:07 -04:00

475 lines
17 KiB
JavaScript

#!/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);