// Vault data validator — catches forgotten page updates. bun only, zero deps. // CLI: bun tools/validate.ts [--fix] (--fix rewrites ISA progress from actual checkbox counts) // Also imported by server.ts for the live /validate page. import { readdirSync, readFileSync, statSync, existsSync, writeFileSync } from "fs"; import { join, normalize, basename } from "path"; export const VROOT = normalize(join(import.meta.dir, "..")); export type Finding = { level: "error" | "warn"; check: string; file: string; detail: string }; const ENUMS: Record = { confidence: ["high", "medium", "low", "speculative"], transmission: ["descent", "contact", "convergence", "unresolved"], url_verified: ["yes", "no", "not-online"], }; const REQUIRED: Record = { source: ["title", "type", "domain", "tier", "status", "created", "citation", "url_verified", "evidence_class"], claim: ["title", "type", "domain", "tier", "status", "created", "confidence", "counter_evidence"], motif: ["title", "type", "domain", "tier", "status", "created", "confidence", "counter_evidence", "transmission", "attestation_earliest"], tradition: ["title", "type", "domain", "tier", "status", "created", "confidence", "counter_evidence", "attestation_earliest"], synthesis: ["title", "type", "domain", "tier", "status", "created", "confidence", "counter_evidence", "thesis"], question: ["title", "type", "domain", "tier", "status", "created"], idea: ["title", "type", "status", "created"], }; const DOMAIN_DIRS = ["01_prehistoric", "02_mesopotamian", "03_egyptian", "04_indo_european", "05_abrahamic", "06_dharmic", "07_east_asian", "08_indigenous", "09_comparative"]; const MOTIF_SLUGS = ["great-flood", "dying-rising-god", "sky-father"]; function fm(src: string): Record { const m = src.match(/^---\n([\s\S]*?)\n---/); if (!m) return {}; const out: Record = {}; for (const line of m[1].split("\n")) { const kv = line.match(/^(\w[\w_]*):\s*(.*)$/); if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, "").replace(/\s+#.*$/, "").trim(); } return out; } function mdFiles(dir: string, acc: string[] = []): string[] { for (const e of readdirSync(dir)) { if (e.startsWith(".") || e === "node_modules") continue; const a = join(dir, e); if (statSync(a).isDirectory()) mdFiles(a, acc); else if (e.endsWith(".md")) acc.push(a); } return acc; } export function validate(fix = false): { findings: Finding[]; checked: number; fixed: string[] } { const findings: Finding[] = []; const fixed: string[] = []; const all = mdFiles(VROOT); const slugs = new Set(all.map(f => basename(f, ".md"))); for (const f of all) { const rel = f.slice(VROOT.length + 1); const src = readFileSync(f, "utf8"); const meta = fm(src); const inTier = /\/(1_sources|2_notes|3_synthesis)\//.test(rel) || rel.startsWith("00_meta/ideas/"); // 1. typed notes: required fields + enums + placeholders if (inTier) { if (!meta.type) { findings.push({ level: "error", check: "frontmatter", file: rel, detail: "missing type" }); continue; } const req = REQUIRED[meta.type]; if (!req) findings.push({ level: "warn", check: "frontmatter", file: rel, detail: `unknown type '${meta.type}'` }); else for (const k of req) if (!meta[k] || meta[k] === "[]" || meta[k] === '""') findings.push({ level: "error", check: "frontmatter", file: rel, detail: `missing/empty required field '${k}'` }); for (const [k, vals] of Object.entries(ENUMS)) if (meta[k] && !vals.includes(meta[k])) findings.push({ level: "error", check: "enum", file: rel, detail: `${k}='${meta[k]}' not in [${vals.join("|")}]` }); if (/YYYY-MM-DD/.test(src.slice(0, 600))) findings.push({ level: "error", check: "placeholder", file: rel, detail: "frontmatter still has YYYY-MM-DD placeholder" }); } // 2. wiki-links resolve — skip files that QUOTE link syntax (templates, audits, reviews, AGENTS); // idea specs get warnings (they sketch future notes), research notes get errors. const quotesSyntax = rel.startsWith("templates/") || rel === "AGENTS.md" || /^00_meta\/(BiasAudit|PeerReview)-/.test(rel); if (!quotesSyntax) for (const m of src.matchAll(/\[\[([^\]|#]+)\]\]/g)) { const t = m[1].trim(); if (!t || t === "…") continue; if (!slugs.has(t)) findings.push({ level: rel.startsWith("00_meta/ideas/") ? "warn" : "error", check: "wiki-link", file: rel, detail: `[[${t}]] has no matching note`, }); } // 3. relative md links resolve for (const m of src.matchAll(/\]\((?!https?:|#|javascript:)([^)]+\.md)\)/g)) { const target = normalize(join(f, "..", decodeURIComponent(m[1]))); if (!existsSync(target)) findings.push({ level: "error", check: "md-link", file: rel, detail: `broken link → ${m[1]}` }); } } // 4. INDEX reachability: every domain dir + every ideas file linked from INDEX const index = readFileSync(join(VROOT, "INDEX.md"), "utf8"); for (const d of DOMAIN_DIRS) if (!index.includes(`(${d}/)`) && !index.includes(`(${d})`)) findings.push({ level: "error", check: "index", file: "INDEX.md", detail: `domain ${d} not linked` }); if (existsSync(join(VROOT, "00_meta/ideas"))) for (const e of readdirSync(join(VROOT, "00_meta/ideas"))) if (e.endsWith(".md") && !index.includes(`00_meta/ideas/${e}`)) findings.push({ level: "error", check: "index", file: "INDEX.md", detail: `idea ${e} not linked from backlog` }); // 5. illustrations present for domains + motifs for (const n of [...DOMAIN_DIRS, ...MOTIF_SLUGS]) if (!existsSync(join(VROOT, `assets/illustrations/${n}.png`)) && !existsSync(join(VROOT, `assets/illustrations/${n}.svg`))) findings.push({ level: "warn", check: "illustration", file: `assets/illustrations/${n}`, detail: "no banner image" }); // 6. live-feedback files exist and are fresh-ish for (const f of ["00_meta/now.txt", "00_meta/loop-state.json", "00_meta/STATUS.md"]) if (!existsSync(join(VROOT, f))) findings.push({ level: "error", check: "live-docs", file: f, detail: "missing" }); // 7. ISA progress matches actual checkbox counts (the chronically-forgotten one) const isaPath = join(VROOT, "ISA.md"); const isa = readFileSync(isaPath, "utf8"); const x = (isa.match(/^- \[x\]/gm) || []).length; const dropped = (isa.match(/^- \[ \] ISC-\d+(\.\d+)?: \[DROPPED/gm) || []).length; const u = (isa.match(/^- \[ \]/gm) || []).length - dropped; const want = `${x}/${x + u}`; const have = (isa.match(/^progress: (.*)$/m) || [])[1]; if (have !== want) { if (fix) { writeFileSync(isaPath, isa.replace(/^progress: .*$/m, `progress: ${want}`)); fixed.push(`ISA progress ${have} → ${want}`); } else findings.push({ level: "error", check: "isa-progress", file: "ISA.md", detail: `frontmatter says ${have}, checkboxes say ${want}` }); } return { findings, checked: all.length, fixed }; } if (import.meta.main) { const fix = process.argv.includes("--fix"); const { findings, checked, fixed } = validate(fix); for (const f of findings) console.log(`${f.level.toUpperCase().padEnd(5)} ${f.check.padEnd(13)} ${f.file} — ${f.detail}`); for (const f of fixed) console.log(`FIXED ${f}`); const errs = findings.filter(f => f.level === "error").length; console.log(`\n${checked} files checked · ${errs} errors · ${findings.length - errs} warnings${fixed.length ? ` · ${fixed.length} fixed` : ""}`); process.exit(errs ? 1 : 0); }