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
116 lines
5 KiB
JavaScript
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}`;
|
|
}
|