ms-ai-architect/scripts/kb-update/lib/transform.mjs
Kjell Tore Guttormsen 2240f1efdd feat(ms-ai-architect): generator-anti-regresjon Fase 1c — composeKbFile + TOC-gate
Nye ref-filer fodes best-practice-konforme sa neste KB-update ikke
reverserer TOC-arbeidet (write-path-regresjon). MA fore 1b.

- buildToc(body): deterministisk ## Innhold fra ##-seksjoner, GitHub-slugs,
  fence-aware, idempotent (lister aldri seg selv)
- composeKbFile(meta, body): generatoren — header + (store filer >100 linjer) TOC
  + brodtekst; erstatter bar buildKbHeader+body-konkatenering
- validateKbFile: stor fil uten TOC -> missing:['toc'] (write-path-gaten)
- Terskel 100 speiler eval.mjs N4_LARGE_FILE_LINES; bundet atferdsmessig via
  test som importerer ekte checkN4 (anti-regresjons-orakel), ikke cross-import
- Wiret composeKbFile inn i transform-prompt.md + kb-update.md (create + in-place)

TDD: +11 transform-tester. kb-update 349/349, kb-eval 154/154 (503 totalt).
Fikset pre-eksisterende skjor assertion i criterion-testen (stale
'Azure AI Foundry' -> 'AI Foundry'; produktet omdopt) — var rod for 1c.
2026-06-26 10:59:40 +02:00

206 lines
8.9 KiB
JavaScript

// 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}`;
}
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}`;
}