validate-data.mjs (3940B)
1 /** 2 * `npm run validate:data` — validate the *committed* dataset without touching 3 * vendor/ or regenerating anything. CI runs this on every push so a hand-edit 4 * or a bad migration can never reach a deploy. 5 * 6 * Checks: every technique passes TechniqueSchema; ids are unique; every 7 * technique's toolId exists in tools.json; every tool has >= 1 technique; the 8 * facet counts in facets.json match the data; the vocabulary in 9 * src/lib/taxonomy.ts still agrees with technique-schema.mjs; the committed 10 * src/data/index-hash.json still describes this data (else `npm run build:index`). 11 */ 12 import { readFileSync } from 'node:fs'; 13 import { fileURLToPath } from 'node:url'; 14 import { dirname, join, resolve } from 'node:path'; 15 import { validateTechniques, PLATFORMS, SOURCES, CAPABILITIES } from './technique-schema.mjs'; 16 import { buildIndex } from './build-index.mjs'; 17 18 const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); 19 const read = (p) => JSON.parse(readFileSync(join(ROOT, p), 'utf8')); 20 21 const techniques = read('src/data/techniques.json'); 22 const tools = read('src/data/tools.json'); 23 const facets = read('src/data/facets.json'); 24 25 const problems = validateTechniques(techniques); 26 27 // Referential integrity. 28 const toolIds = new Set(tools.map((t) => t.id)); 29 const used = new Set(); 30 for (const t of techniques) { 31 used.add(t.toolId); 32 if (!toolIds.has(t.toolId)) problems.push(`orphan technique ${t.id}: toolId ${t.toolId} not in tools.json`); 33 } 34 for (const tool of tools) { 35 if (!used.has(tool.id)) problems.push(`orphan tool ${tool.id}: no techniques`); 36 } 37 const seenTools = new Set(); 38 for (const tool of tools) { 39 if (seenTools.has(tool.id)) problems.push(`duplicate tool id ${tool.id}`); 40 seenTools.add(tool.id); 41 } 42 43 // Facet counts must describe this exact dataset. 44 const c = facets.counts || {}; 45 if (c.techniques !== techniques.length) problems.push(`facets.counts.techniques=${c.techniques} but data has ${techniques.length}`); 46 if (c.tools !== tools.length) problems.push(`facets.counts.tools=${c.tools} but data has ${tools.length}`); 47 const added = techniques.filter((t) => t.added).length; 48 if (c.added !== added) problems.push(`facets.counts.added=${c.added} but data has ${added}`); 49 const bySource = {}; 50 for (const t of techniques) bySource[t.source] = (bySource[t.source] || 0) + 1; 51 for (const s of SOURCES) { 52 if ((c.bySource || {})[s] !== (bySource[s] || undefined) && !(bySource[s] === undefined && (c.bySource || {})[s] === undefined)) { 53 problems.push(`facets.counts.bySource.${s}=${(c.bySource || {})[s]} but data has ${bySource[s] || 0}`); 54 } 55 } 56 57 // The content-addressed list index must be the one this data produces. 58 try { 59 const idx = JSON.parse(readFileSync(join(ROOT, 'src/data/index-hash.json'), 'utf8')); 60 const fresh = buildIndex(techniques); 61 if (idx.hash !== fresh.hash || idx.file !== fresh.file) problems.push(`src/data/index-hash.json is stale (${idx.file} vs ${fresh.file}) — run npm run build:index`); 62 } catch (e) { 63 problems.push(`src/data/index-hash.json missing or unreadable — run npm run build:index (${e.message})`); 64 } 65 66 // Vocabulary drift between the data contract and the UI taxonomy. 67 const taxonomy = readFileSync(join(ROOT, 'src/lib/taxonomy.ts'), 'utf8'); 68 for (const [label, list] of [['platform', PLATFORMS], ['source', SOURCES], ['capability', CAPABILITIES]]) { 69 for (const v of list) { 70 if (!taxonomy.includes(`'${v}'`)) problems.push(`${label} '${v}' is in technique-schema.mjs but not in src/lib/taxonomy.ts`); 71 } 72 } 73 74 if (problems.length) { 75 console.error(`validate:data — ${problems.length} problem(s):`); 76 for (const p of problems.slice(0, 40)) console.error(` ✗ ${p}`); 77 if (problems.length > 40) console.error(` … and ${problems.length - 40} more`); 78 process.exit(1); 79 } 80 console.log(`validate:data — OK: ${techniques.length} techniques, ${tools.length} tools, ${added} NEW; schema, ids, tool links and facet counts all consistent.`);