// 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']; /** * 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` ); } 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, and a Source header in the scannable region. Used by the gate * before a transformed file may be written. * * @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'); 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}`; }