ms-ai-architect/scripts/kb-update/lib/transform.mjs
Kjell Tore Guttormsen 48884b67a6 feat(ms-ai-architect): Sesjon 5 - transformasjonslag (lag 4, doc→KB-fil)
Lag 4 destillerer et hentet Microsoft Learn-dokument til en KB-referansefil. Speiler mønsteret fra decisions-io/verify-out: ren-funksjon-lib + tynn LLM-integrasjon + TDD. lib/transform.mjs SKRIVER ALDRI — returnerer content/verdict/sti; skriving skjer kun via operatør-gate etter lag 5.

Nøkkelfunn/-fiks: gammel prompt-template.md la kilder i en bunn-seksjon, men build-registry/kb-headers.parseSourceHeader skanner kun øverste 500 bytes → authority_source-dekning = 0 %. Lag 4 legger **Source:** i HEADER-blokka — forutsetningen for at lag 3 (fremtidig) kan backfille authority_source og lag-5 regel 3 fyrer.

- buildKbHeader({title,status,category,source,lastUpdated}): Status+Source obligatoriske (kaster ved mangel). - validateKbFile(content) → {valid,missing[]}; bruker parseSourceHeader (én sannhetskilde med build-registry). - buildChange(...): eksplisitt lag-4→lag-5-bro, mater classifyChange. - resolveTargetPath(tax,category,filename): ruter via taksonomi getCategorySkill; null=ukjent→gate.

Prompt: scripts/kb-update/transform-prompt.md (doc→KB; husformat fra prompt-template.md, status-påstander eksplisitte for lag-5-gaten). Wiret i commands/kb-update.md §3.d (ny godkjent URL→fetch→destillér→header→validate→buildChange→lag5→resolveTargetPath→gate→atomisk) + §4.d (oppdateringer passerer validateKbFile). eval.mjs eksporterer nå evalSkill for kriterium-testen.

Kriterium møtt: «regenerer 1 fil → eval-score ≥ baseline» (test-transform-criterion.test.mjs). TDD: 12 lib-tester (inkl. import-invariant: ingen write-utils) + 2 kriterium FØR kode. Tester: validate 239 · kb-update 111 (+16) · kb-eval 15 (+2) · kb-integrity 115/115.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REiKFhP4w6xGXXqWKpPCJJ
2026-06-19 22:31:26 +02:00

116 lines
5 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'];
/**
* 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}`;
}