// 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, parseTypeHeader, parseVerifiedHeader, parseVerifiedByHeader, } from './kb-headers.mjs'; import { getCategorySkill } from './taxonomy.mjs'; // Base mandatory header fields for EVERY file type. Status is obligatory per spec // ("status-felt obligatorisk"). const REQUIRED_BASE_META = ['title', 'status', 'category', 'lastUpdated']; // Spor 3 Port 1 — the frontmatter-contract `type`. Only `reference` files are in the // correctness scope (they carry a Microsoft source we verify against). Template / // methodology / regulatory are out of scope and carry NO Microsoft source. const VALID_TYPES = ['reference', 'template', 'methodology', 'regulatory']; const DEFAULT_TYPE = 'reference'; // Extra fields a `reference` file MUST carry to be "born verified" (Spor 3 Port 2): // `source` lets lag-5 authority checks fire; `verified`/`verified_by` record that the // content cleared the judge before it shipped. A reference file without these is the // drift-introducing failure mode the create-guard exists to stop. const REQUIRED_REFERENCE_META = ['source', 'verified', 'verified_by']; // The current GATE-PASS claim judge (judge-claim-prompt-v3.1, adopted G1 2026-06-30). // A version *label string* — integer major + optional minor — so provenance names the // adopted judge exactly (v3 was a distinct, rejected version). stampVerifiedMeta records // `verified_by: judge-v` unless the caller overrides or marks `human`. const JUDGE_VERSION = '3.1'; // A Microsoft Learn / docs authority URL — a non-reference file may NOT carry one. const MS_LEARN_RE = /\b(?:learn|docs)\.microsoft\.com/i; const DATE_RE = /^\d{4}-\d{2}(?:-\d{2})?$/; // The top-of-file region the kb-headers parsers scan. MIRRORS kb-headers.mjs:6 // (HEADER_REGION_BYTES) — kept local rather than imported so this lib's only // kb-headers dependency stays the read-only parser functions (same discipline as // TOC_MIN_LINES mirroring eval.mjs). insertHeaderFields / normalizeStaleVerified use it // to keep the fields they insert — and the stale Verified they strip — inside the window // those parsers actually read. const HEADER_REGION_BYTES = 500; // 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, Source and the Port 1 contract * fields (Type / Verified / Verified by) live IN the header (top 500 bytes) so * kb-headers.* 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%. * * Type-aware create-guard (Spor 3 Port 2): a `reference` file MUST carry * source + verified + verified_by (it is "born verified"); a non-reference file * (template/methodology/regulatory) is out of correctness scope and may NOT carry a * Microsoft Learn source. Either violation is a hard throw — the create-time gate. * * @param {{title: string, status: string, category: string, lastUpdated: string, * type?: string, source?: string, verified?: string, verified_by?: string}} meta * @returns {string} the header block, ending with the `---` rule + trailing newline * @throws if the type is unknown, a mandatory field is missing, or a non-reference * file carries a Microsoft Learn source */ export function buildKbHeader(meta) { const m = meta ?? {}; const type = m.type ?? DEFAULT_TYPE; if (!VALID_TYPES.includes(type)) { throw new Error(`buildKbHeader: invalid type '${type}' (expected ${VALID_TYPES.join('|')})`); } const isReference = type === 'reference'; const empty = (k) => { const v = m[k]; return v === undefined || v === null || String(v).trim() === ''; }; const required = isReference ? [...REQUIRED_BASE_META, ...REQUIRED_REFERENCE_META] : REQUIRED_BASE_META; const missing = required.filter(empty); if (missing.length) { throw new Error(`buildKbHeader: missing mandatory field(s): ${missing.join(', ')}`); } if (!isReference && !empty('source') && MS_LEARN_RE.test(String(m.source))) { throw new Error( `buildKbHeader: a ${type} file may not carry a Microsoft Learn source (out of correctness scope)`, ); } let header = `# ${m.title}\n\n` + `**Last updated:** ${m.lastUpdated}\n` + `**Status:** ${m.status}\n` + `**Category:** ${m.category}\n` + `**Type:** ${type}\n`; if (!empty('source')) header += `**Source:** ${m.source}\n`; if (!empty('verified')) header += `**Verified:** ${m.verified}\n`; if (!empty('verified_by')) header += `**Verified by:** ${m.verified_by}\n`; return `${header}\n---\n`; } /** * Format the `verified_by` judge-version label. Returns the WHOLE label (`judge-v3.1`). * Absence (undefined) → the current JUDGE_VERSION default; otherwise the value — an integer * major (`3` → judge-v3) or a `major.minor` string/float (`'3.2'`/`3.2` → judge-v3.2) — is * validated against a strict numeric-label regex and honoured. Mirrors the DATE_RE throw * discipline (stampVerifiedMeta): a malformed label THROWS rather than silently falling back * to the default, so a caller bug surfaces instead of shipping a mislabelled provenance stamp * into a public file. * * @param {number|string} [judgeVersion] — integer major or `major.minor`; absent → default * @returns {string} the `judge-v