#!/usr/bin/env node // check-cosmo-gate.mjs — the RATIFIED R13 persona gate (operator 2026-09-12, alt. A). // // Replaces the roadmap's `grep -rl "Cosmo" skills/*/references | wc -l -> 0`, which was // measured false twice over (it would delete 451 Azure Cosmos DB occurrences, and it is // unreachable under a heading-only scope because 132 persona occurrences live in prose, // tables and dialogue). Full derivation: lib/cosmo-persona.mjs header. // // THREE CLAUSES // G1 persona occurrences on markdown heading lines in the reference corpus = 0 // G2 every fragment link resolves to a real heading slug, except the anchors on the // committed exemption list (pre-existing, not caused by R13) // G3 product occurrences unchanged from the committed baseline, and each product-only // file still carries zero persona // // USAGE // node scripts/kb-update/check-cosmo-gate.mjs # check (exit 1 on fail) // node scripts/kb-update/check-cosmo-gate.mjs --emit-baseline # write the baseline JSON // node scripts/kb-update/check-cosmo-gate.mjs --validate-net # prove the net both ways // // --validate-net is not decoration. A gate whose null result has never been forced to // the other answer is not a measurement (memory decision-gate-must-be-validated-both- // ways): it injects a known-positive persona heading into a copy of a real corpus file // and asserts G1 FAILS, then asserts the known-negative product-only files PASS. import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join, dirname, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { globSync } from 'node:fs'; import { classifyCosmo, findDeadAnchors } from './lib/cosmo-persona.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); const BASELINE_PATH = join(__dirname, 'data', 'cosmo-gate-baseline.json'); /** Every reference file in the corpus, as repo-relative paths, sorted. */ function refFiles() { return globSync('skills/*/references/**/*.md', { cwd: PLUGIN_ROOT }).sort(); } /** Measure the whole corpus: persona buckets, product total, per-file detail. */ function measure() { const files = refFiles(); const totals = { heading: 0, toc: 0, prose: 0, product: 0 }; const headingFiles = new Set(); const perFile = {}; const deadAnchors = []; for (const rel of files) { const src = readFileSync(join(PLUGIN_ROOT, rel), 'utf8'); const c = classifyCosmo(src); totals.heading += c.persona.heading; totals.toc += c.persona.toc; totals.prose += c.persona.prose; totals.product += c.product; if (c.persona.heading > 0) headingFiles.add(rel); perFile[rel] = { persona: c.personaTotal, product: c.product }; for (const d of findDeadAnchors(src)) deadAnchors.push(`${rel}#${d.anchor}`); } return { files, totals, headingFiles: [...headingFiles].sort(), perFile, deadAnchors }; } /** Files carrying Azure Cosmos DB but no persona at all — must stay untouched. */ function productOnlyFiles(m) { return m.files.filter((f) => m.perFile[f].product > 0 && m.perFile[f].persona === 0); } function emitBaseline() { const m = measure(); const pof = productOnlyFiles(m); const baseline = { _comment: 'Ratified R13 persona-gate baseline. Measured before the heading transform; ' + 'G1 drives personaHeadings to 0, G2 and G3 must not move. Regenerate ONLY with a ' + 'ratified scope change — a silently moved baseline is a gate that proves nothing.', measured: new Date().toISOString().slice(0, 10), refFiles: m.files.length, personaHeadings: m.totals.heading, personaHeadingFiles: m.headingFiles.length, personaTocOccurrences: m.totals.toc, personaProseOccurrences: m.totals.prose, productOccurrences: m.totals.product, productOnlyFiles: pof, productOnlyFileCounts: Object.fromEntries(pof.map((f) => [f, m.perFile[f].product])), deadAnchorsExempt: m.deadAnchors, }; writeFileSync(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`, 'utf8'); console.log(`baseline skrevet: ${relative(PLUGIN_ROOT, BASELINE_PATH)}`); console.log(JSON.stringify(baseline, null, 2)); } function check() { if (!existsSync(BASELINE_PATH)) { console.error('FEIL: ingen baseline. Kjoer --emit-baseline foer transformen.'); process.exit(2); } const b = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')); const m = measure(); const fails = []; // --- G1: no persona left on any heading line --- if (m.totals.heading !== 0) { fails.push(`G1: ${m.totals.heading} persona-forekomster staar fortsatt paa heading-linjer ` + `i ${m.headingFiles.length} filer (baseline var ${b.personaHeadings})`); } // --- G2: no dead fragment links beyond the committed exemptions --- const exempt = new Set(b.deadAnchorsExempt); const newDead = m.deadAnchors.filter((d) => !exempt.has(d)); if (newDead.length) { fails.push(`G2: ${newDead.length} NYE doede fragmentlenker: ${newDead.slice(0, 10).join(', ')}`); } const healed = b.deadAnchorsExempt.filter((d) => !m.deadAnchors.includes(d)); // --- G3: product untouched --- if (m.totals.product !== b.productOccurrences) { fails.push(`G3: produkt-forekomster ${m.totals.product} != baseline ${b.productOccurrences} ` + `(differanse ${m.totals.product - b.productOccurrences})`); } for (const [f, n] of Object.entries(b.productOnlyFileCounts)) { const now = m.perFile[f]; if (!now) { fails.push(`G3: kun-produkt-fila ${f} finnes ikke lenger`); continue; } if (now.product !== n) fails.push(`G3: ${f} produkt ${now.product} != baseline ${n}`); if (now.persona !== 0) fails.push(`G3: ${f} har faatt ${now.persona} persona-forekomst(er)`); } console.log('=== R13 persona-gate (ratifisert 2026-09-12, alt. A) ==='); console.log(`ref-filer : ${m.files.length} (baseline ${b.refFiles})`); console.log(`G1 persona paa heading-linjer : ${m.totals.heading} (baseline ${b.personaHeadings})`); console.log(` persona i TOC-linjer : ${m.totals.toc} (baseline ${b.personaTocOccurrences})`); console.log(` persona i proesa/tabell : ${m.totals.prose} (baseline ${b.personaProseOccurrences}) ` + '— UTENFOR R13-scope, bokfoert til R13b/R14'); console.log(`G2 doede fragmentlenker : ${m.deadAnchors.length} ` + `(${exempt.size} unntatt, ${newDead.length} nye, ${healed.length} forsvunnet)`); console.log(`G3 produkt-forekomster : ${m.totals.product} (baseline ${b.productOccurrences})`); console.log(` kun-produkt-filer urørt : ${Object.keys(b.productOnlyFileCounts).length}`); if (fails.length) { console.log('\nGATE FAILED'); for (const f of fails) console.log(` x ${f}`); process.exit(1); } console.log('\nGATE PASSED'); } /** Force the gate to the other answer — a null result nobody could falsify is not one. */ function validateNet() { const m = measure(); let bad = 0; // Known-positive: inject a persona heading into a real corpus file's content and // assert the classifier sees it. If this cannot fail the gate, the gate is blind. const victim = m.files[0]; const injected = `${readFileSync(join(PLUGIN_ROOT, victim), 'utf8')}\n## For Cosmo\n`; const ic = classifyCosmo(injected); const base = classifyCosmo(readFileSync(join(PLUGIN_ROOT, victim), 'utf8')); if (ic.persona.heading !== base.persona.heading + 1) { console.log(`x KJENT-POSITIV: injisert "## For Cosmo" ble IKKE sett ` + `(${base.persona.heading} -> ${ic.persona.heading})`); bad++; } else { console.log(`ok KJENT-POSITIV: injisert "## For Cosmo" felte G1 ` + `(${base.persona.heading} -> ${ic.persona.heading}) i ${victim}`); } // Known-positive #2: the Norwegian genitive must also be caught, since it is the // case the letter-s heuristic gets wrong in BOTH directions. const gen = classifyCosmo('### Cosmos tonalitet\n'); if (gen.persona.heading !== 1) { console.log('x KJENT-POSITIV: genitiv "### Cosmos tonalitet" ikke sett'); bad++; } else console.log('ok KJENT-POSITIV: genitiv "### Cosmos tonalitet" felte G1'); // Known-negative: a product heading must NOT trip the gate. const prod = classifyCosmo('### Azure Cosmos DB for GraphRAG\n'); if (prod.personaTotal !== 0 || prod.product !== 1) { console.log(`x KJENT-NEGATIV: produkt-heading feilklassifisert ` + `(persona ${prod.personaTotal}, produkt ${prod.product})`); bad++; } else console.log('ok KJENT-NEGATIV: "### Azure Cosmos DB for GraphRAG" gir 0 persona, 1 produkt'); // Known-negative #2: the real product-only files must be clean of persona. for (const f of productOnlyFiles(m)) { const c = classifyCosmo(readFileSync(join(PLUGIN_ROOT, f), 'utf8')); if (c.personaTotal !== 0) { console.log(`x KJENT-NEGATIV: ${f} klassifisert med persona`); bad++; } else console.log(`ok KJENT-NEGATIV: ${f} — 0 persona, ${c.product} produkt`); } console.log(bad === 0 ? '\nNETTET VALIDERT BEGGE VEIER' : `\nNETT-VALIDERING FEILET (${bad})`); process.exit(bad === 0 ? 0 : 1); } const arg = process.argv[2]; if (arg === '--emit-baseline') emitBaseline(); else if (arg === '--validate-net') validateNet(); else check();