// transform.mjs — Lag 4: TRANSFORMASJON (doc → KB-fil). Pure helper library that // turns an approved, fetched Microsoft Learn document into a KB reference file in // the plugin's house format. It is the deterministic spine the LLM destillation // (commands/kb-update.md, transform-prompt.md) hangs on: // // buildKbHeader — the mandatory header block (Status + Source obligatory) // validateKbFile — enforce the mandatory-field contract on any candidate file // buildChange — construct the lag-5 `change` object (the lag-4 → lag-5 seam) // resolveTargetPath — route the file to its owning skill via the taxonomy (lag 0) // // ARCHITECTURE INVARIANT (spec §83): this lib NEVER writes. It returns content, // verdicts and paths; applying a file is the operator gate's job, after lag 5 // (verify-out) has cleared it. Mirrors decisions-io / verify-out: pure functions, // no write-util imports. The only dependency is kb-headers (read-only parser) so // validateKbFile uses the SAME source detection as build-registry — one source of // truth for "what counts as a Source header" (the top-500-byte region). import { parseSourceHeader } from './kb-headers.mjs'; import { getCategorySkill } from './taxonomy.mjs'; // Mandatory header fields. Status is obligatory per spec ("status-felt obligatorisk"); // Source is what lets lag 3 backfill authority_source and lag-5 rule 3 eventually fire. const REQUIRED_META = ['title', 'status', 'category', 'source', 'lastUpdated']; // A reference file longer than this many lines should carry a table of contents // (best-practices §Structure). MIRRORS eval.mjs N4_LARGE_FILE_LINES — kept in sync // behaviourally by test-transform's checkN4 parity assertion, NOT by importing across // the kb-update ↔ kb-eval boundary (transform.mjs's only dependency stays kb-headers). const TOC_MIN_LINES = 100; /** Line count counted exactly as checkN4 does (trailing blank lines ignored). */ function lineCount(content) { return String(content ?? '').replace(/\n+$/, '').split('\n').length; } /** Is this file large enough to require a TOC? (mirror of checkN4's "large" filter) */ function isLargeContent(content) { return lineCount(content) > TOC_MIN_LINES; } /** * Does the content already carry a TOC? Mirror of eval.mjs hasToc (internal there): * an explicit TOC heading, OR a cluster (≥3) of in-page anchor links near the top. */ function hasToc(content) { const s = String(content ?? ''); if (/^#{1,3}\s*(table of contents|contents|innhold(?:sfortegnelse)?|toc)\b/im.test(s)) return true; const head = s.split('\n').slice(0, 40).join('\n'); return (head.match(/\]\(#/g) || []).length >= 3; } /** GitHub-style heading slug: lowercase, drop punctuation (keep letters/digits/_/-), * spaces → hyphens. Unicode-aware so Norwegian æ/ø/å survive in anchors. */ function slugify(heading) { return String(heading ?? '') .toLowerCase() .replace(/[^\p{L}\p{N}\s_-]/gu, '') .trim() .replace(/\s/g, '-'); } /** * Build the deterministic KB-file header block. Status and Source live IN the * header (top 500 bytes) so kb-headers.parseSourceHeader and the "Last updated:" * scanner can see them — the old bottom-of-file source section was invisible to * those scanners, which is why authority_source coverage was 0%. * * @param {{title: string, status: string, category: string, source: string, * lastUpdated: string}} meta * @returns {string} the header block, ending with the `---` rule + trailing newline * @throws if any mandatory field is missing/empty */ export function buildKbHeader(meta) { const m = meta ?? {}; const missing = REQUIRED_META.filter((k) => { const v = m[k]; return v === undefined || v === null || String(v).trim() === ''; }); if (missing.length) { throw new Error(`buildKbHeader: missing mandatory field(s): ${missing.join(', ')}`); } return ( `# ${m.title}\n\n` + `**Last updated:** ${m.lastUpdated}\n` + `**Status:** ${m.status}\n` + `**Category:** ${m.category}\n` + `**Source:** ${m.source}\n\n` + `---\n` ); } /** * Build a deterministic `## Innhold` table of contents from a body's level-2 (`##`) * section headings — the plugin house style (matches the existing TOC'd files and the * heading eval.mjs hasToc recognises). The LLM should NOT hand-author anchor slugs; * this generates GitHub-compatible ones. Headings inside ``` code fences are ignored, * and the `## Innhold` heading never lists itself (so re-running is idempotent). * * @param {string} body — the file body (under the header `---`) * @returns {string} a `## Innhold` block ending in a newline, or '' if there are no * `##` sections to list */ export function buildToc(body) { const lines = String(body ?? '').split('\n'); const titles = []; let inFence = false; for (const line of lines) { if (/^\s*```/.test(line)) { inFence = !inFence; continue; } if (inFence) continue; const m = /^##\s+(.+?)\s*$/.exec(line); // level-2 only (not # title, not ### sub) if (!m) continue; const title = m[1].trim(); if (/^innhold(?:sfortegnelse)?$/i.test(title)) continue; // never list the TOC itself titles.push(title); } if (titles.length === 0) return ''; const items = titles.map((t) => `- [${t}](#${slugify(t)})`).join('\n'); return `## Innhold\n\n${items}\n`; } /** * Assemble a complete KB file from header meta + body: the deterministic generator the * write-path uses (replaces the bare `buildKbHeader(meta) + body` concatenation). A * large file (>100 lines, per N4) is born with a `## Innhold` TOC inserted after the * header so regeneration never strips the TOC back out (the Fase 1c regression guard). * Idempotent: a body that already carries a TOC is not double-TOC'd. * * @param {object} meta — same shape as buildKbHeader * @param {string} body — the distilled body (under the header `---`) * @returns {string} the full file content */ export function composeKbFile(meta, body) { const header = buildKbHeader(meta); const b = String(body ?? ''); const full = header + b; if (!isLargeContent(full) || hasToc(full)) return full; const toc = buildToc(b); if (!toc) return full; 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; /** * Validate that a candidate KB file carries the mandatory contract: a title, a * "Last updated:" header (so it is never dateless → never the 'unverified' bucket), * a Status field, a Source header in the scannable region, and — for a large file * (>100 lines, per N4) — a table of contents. The TOC clause is the write-path * regression gate: a regenerated large file that lost its TOC fails here and the * caller re-prompts, so KB-update can never silently strip a TOC back out. * * @param {string} content — full file content * @returns {{valid: boolean, missing: string[]}} */ export function validateKbFile(content) { const s = String(content ?? ''); const missing = []; if (!RE_TITLE.test(s)) missing.push('title'); if (!RE_LAST_UPDATED.test(s)) missing.push('last_updated'); if (!RE_STATUS.test(s)) missing.push('status'); if (parseSourceHeader(s) === null) missing.push('source'); if (isLargeContent(s) && !hasToc(s)) missing.push('toc'); return { valid: missing.length === 0, missing }; } /** * Construct the lag-5 `change` object from a transformation result — the explicit, * documented seam between lag 4 and lag 5. Decouples lag 4's field names from * verify-out's, and supplies the safe defaults lag 5 expects (no refutations yet; * authority_source null until lag 3 backfills it). * * @param {{field: string, oldValue?: string, newValue: string, sourceUrl?: string, * authoritySource?: string|null, refutations?: Array}} t * @returns {{field: string, old_value: string, new_value: string, * source_url: string|null, authority_source: string|null, refutations: Array}} */ export function buildChange(t) { const o = t ?? {}; return { field: o.field ?? '', old_value: o.oldValue ?? '', new_value: o.newValue ?? '', source_url: o.sourceUrl ?? null, authority_source: o.authoritySource ?? null, refutations: Array.isArray(o.refutations) ? o.refutations : [], }; } /** * Resolve where a transformed file belongs, routing via the taxonomy's canonical * category → skill map (physical-disk truth — this is what replaced * category-skill-map.json). Returns null for an unknown category so the caller * must gate rather than silently misroute. * * @param {object} tax — loaded domain taxonomy * @param {string} category * @param {string} filename — kebab-case .md filename * @returns {string|null} repo-relative path, or null if the category is unknown */ export function resolveTargetPath(tax, category, filename) { const skill = getCategorySkill(tax, category); if (!skill) return null; return `skills/${skill}/references/${category}/${filename}`; }