feat(ms-ai-architect): insertToc + backfill-toc applier (Fase 1b-verktøy) — store-fil-TOC foldet inn i Cosmo-utfasing

1b-premisset (ikke-advisor storfiler er Cosmo-frie, trygt å TOC-e nå) er
motbevist mot ground truth: 11/12 har en `## …Cosmo…`-overskrift → en TOC
bygget fra `##`-overskriftene ville emittert ny Cosmo-anker (bryter «aldri
ny Cosmo-innhold»), og å nøytralisere overskriften er selve persona-fjerningen
(ikke en sidehandling — scope-guard). Store-fil-TOC (de 20 filene >800 linjer,
advisor + ikke-advisor) folder derfor inn i Cosmo-utfasingen. Denne committen
leverer kun det gjenbrukbare verktøyet; ingen ref-filer rørt.

- transform.insertToc: retrofitter `## Innhold` i en eksisterende storfil rett
  før første ikke-fence `## `-seksjon (samme slot som composeKbFile), robust mot
  de 3 legacy-header-variantene (med/uten `---`), idempotent, fence-aware.
- scripts/kb-update/backfill-toc.mjs: dry-run/--write applier (ren glue rundt
  insertToc; transform.mjs forblir write-fri — arkitektur-invariant).
- docs/cosmo-removal-brief: «Sammenfall»-seksjon dokumenterer fold-inn + bruk.
- TDD: +6 transform-tester. Suite 509/509 (kb-update 355 + kb-eval 154).
This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 11:43:30 +02:00
commit bfc79710c8
4 changed files with 179 additions and 0 deletions

View file

@ -0,0 +1,51 @@
#!/usr/bin/env node
// backfill-toc.mjs — Fase 1b applier: insert a `## Innhold` TOC into EXISTING large
// reference files via transform.insertToc. Dry-run by default; --write applies in place.
//
// transform.mjs stays pure (it never writes — architecture invariant); THIS script is
// the operator-gate-side applier, the same seam commands/kb-update.md occupies when it
// writes a composed file after the gate clears. All the logic lives in the tested
// insertToc (no-op on small/already-TOC'd files, body byte-identical) — this is glue.
//
// node scripts/kb-update/backfill-toc.mjs <file.md ...> # dry-run (report)
// node scripts/kb-update/backfill-toc.mjs --write <file.md ...> # apply in place
//
// Per file: would/write (+N lines, M sections) | skip (small or already has TOC).
// A file whose content insertToc leaves unchanged is never rewritten (no churn).
import { readFileSync, writeFileSync } from 'node:fs';
import { insertToc, buildToc } from './lib/transform.mjs';
const argv = process.argv.slice(2);
const write = argv.includes('--write');
const files = argv.filter((a) => a !== '--write');
if (files.length === 0) {
console.error('usage: backfill-toc.mjs [--write] <file.md ...>');
process.exit(2);
}
let changed = 0;
let skipped = 0;
for (const path of files) {
const before = readFileSync(path, 'utf8');
const after = insertToc(before);
if (after === before) {
skipped++;
console.log(`skip ${path} (small or already has TOC)`);
continue;
}
const addedLines = after.split('\n').length - before.split('\n').length;
const sections = (buildToc(before).match(/^- /gm) || []).length;
changed++;
if (write) {
writeFileSync(path, after);
console.log(`write ${path} (+${addedLines} lines, ${sections} sections)`);
} else {
console.log(`would ${path} (+${addedLines} lines, ${sections} sections)`);
}
}
const verb = write ? 'wrote' : 'would change';
const hint = write ? '' : ' — re-run with --write to apply';
console.log(`\n${verb} ${changed}, skipped ${skipped}${hint}`);

View file

@ -139,6 +139,41 @@ export function composeKbFile(meta, body) {
return `${header}\n${toc}\n${b}`;
}
/**
* Backfill a `## Innhold` TOC into an EXISTING large reference file that lacks one,
* placing it immediately before the first non-fenced `## ` section heading the same
* relative slot composeKbFile gives a freshly-generated file (TOC between the header
* preamble and the first content section), but robust to the varied legacy headers on
* disk: some carry a `---` rule after the metadata, some don't. This is the Fase 1b
* retrofit for the ~20 largest hand-written files; composeKbFile covers NEW files.
*
* No-op (returns content unchanged) when the file is small ( TOC_MIN_LINES) or already
* carries a TOC so it is idempotent and never churns a small file. The body below the
* insertion point is left byte-identical: only the `## Innhold` block is added.
*
* @param {string} content full existing file content
* @returns {string} content with a `## Innhold` block inserted, or unchanged
*/
export function insertToc(content) {
const s = String(content ?? '');
if (!isLargeContent(s) || hasToc(s)) return s;
const toc = buildToc(s);
if (!toc) return s; // no ## sections to list — nothing to anchor a TOC to
const lines = s.split('\n');
let inFence = false;
let idx = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\s*```/.test(lines[i])) { inFence = !inFence; continue; }
if (inFence) continue;
if (/^##\s+/.test(lines[i])) { idx = i; break; }
}
if (idx === -1) return s; // defensive: buildToc found a section we didn't — leave as-is
const before = lines.slice(0, idx);
while (before.length && before[before.length - 1].trim() === '') before.pop();
const block = ['', ...toc.replace(/\n$/, '').split('\n'), ''];
return [...before, ...block, ...lines.slice(idx)].join('\n');
}
const RE_TITLE = /^#\s+\S/m;
const RE_LAST_UPDATED = /\*\*Last updated:\*\*\s*[\d-]+/i;
const RE_STATUS = /\*\*Status:\*\*\s*\S/i;