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
This commit is contained in:
Kjell Tore Guttormsen 2026-06-19 22:31:26 +02:00
commit 48884b67a6
6 changed files with 468 additions and 3 deletions

View file

@ -83,7 +83,14 @@ saveDecisions(led);
`decided_at` settes til dagens dato (caller-injisert — `recordDecision` er ren). `decisions.json` er tracket i git (overlever, til forskjell fra de genererte rapportene).
d. **For `approved`:** registrér URLen i `url-registry` (gated) så den fanges av polling heretter — via `lib/registry-io.mjs` `saveRegistry`. Bruk `suggested_skill`/`suggested_category` fra kandidaten. Deretter hentes innholdet og en KB-fil skrives i transformasjonslaget (lag 4, Sesjon 5) — for nå holder det å registrere + flagge for henting.
d. **For `approved`:** registrér URLen i `url-registry` (gated) så den fanges av polling heretter — via `lib/registry-io.mjs` `saveRegistry`. Bruk `suggested_skill`/`suggested_category` fra kandidaten. Deretter kjør **transformasjonslaget (lag 4)** for å lage KB-fila:
1. `microsoft_docs_fetch` på den godkjente URLen → kildedokument.
2. Destillér via `scripts/kb-update/transform-prompt.md` (doc→KB-fil; status-påstander holdes eksplisitte). Multi-agent parallell fan-out foreslås i produksjon (roadmap §71), speilet på `generate-skills`-mønsteret.
3. `content = buildKbHeader({title, status, category, source: <godkjent URL>, lastUpdated: <YYYY-MM>}) + body` (`lib/transform.mjs`). **`Status` + `Source` er obligatoriske** — `Source` i header-blokka (øverste 500 bytes) er det som lar lag 3 backfille `authority_source`.
4. `validateKbFile(content)` MÅ være `valid: true` før noe gates videre (ellers be modellen fylle manglende felt).
5. For hver status-/load-bearing-påstand: `buildChange({...})`**lag 5** `classifyChange(...)` (samme gate som §4 c2). Status-påstander er alltid `flagged`.
6. `resolveTargetPath(tax, category, filename)` → eierskill-sti via taksonomien (`null` = ukjent kategori → flagg for operatør, ikke skriv).
7. Først etter operatør-gate: atomisk skriving (`lib/atomic-write.mjs` + `lib/backup.mjs`). **`transform.mjs` skriver aldri selv** — verifisert av `tests/kb-update/test-transform.test.mjs` (import-invariant). Kriterium verifisert av `tests/kb-eval/test-transform-criterion.test.mjs` (regenerer 1 fil → eval ≥ baseline).
e. **Invariant:** `discover-new-urls.mjs` (deteksjon) skriver **aldri** ledgeren — den kun leser. Bare denne gaten skriver. Verifisert av `tests/kb-update/test-discover-invariant.test.mjs`.
@ -99,7 +106,7 @@ c2. **Verifisering-ut (lag 5) — FØR du skriver.** Hver kandidat-endring fra (
- **`auto-applied`** → trygt å ta inn i (d) (benign, ikke-status, ingen motbevis, autoritets-match).
- **Adversarial motbevis-panel (LLM-runtime):** for status-/load-bearing-påstander, kjør et lite panel som *prøver å motbevise* `new_value` mot den utpekte `authority_source`, og mat resultatene inn som `refutations[]` (`[{refuted, reason}]`). Multi-agent foreslås når lag 4/5 kjøres i produksjon (roadmap §71).
- **Invariant:** `verify-out.mjs` skriver aldri — den returnerer kun en verdict. Selve skrivingen skjer i (d), gated. Verifisert av `tests/kb-update/test-verify-out.test.mjs`.
d. **Oppdater fila:** `Edit` med endringer som er `auto-applied` eller eksplisitt godkjent av operatør i (c2). Behold "For Cosmo"-seksjonen og overordnet struktur. Oppdater `Last updated: YYYY-MM-DD`-header til dagens dato
d. **Oppdater fila:** `Edit` med endringer som er `auto-applied` eller eksplisitt godkjent av operatør i (c2). Behold "For Cosmo"-seksjonen og overordnet struktur. Oppdater `Last updated: YYYY-MM-DD`-header til dagens dato. **Lag-4-kontrakt:** kjør `validateKbFile(<ny fil-innhold>)` (`lib/transform.mjs`) før skriving — den skal være `valid: true`. Mangler fila et `**Source:**`-header (pre-lag-4-filer gjør det; dekning er 0 %), legg det til i header-blokka med den utpekte autoritets-URLen for hovedkilden, slik at lag 3 senere kan backfille `authority_source`.
e. **Committ:** `git add <fil>` + `git commit -m "chore(ms-ai-architect): refresh KB $(basename <fil>) [skip-docs]"` med mindre `--single-commit` ble gitt
### 5. Single-commit modus

View file

@ -156,7 +156,7 @@ function scanK9Hints(body) {
return { timeSensitiveTokenHits: tokens.length, sample: [...new Set(tokens)].slice(0, 12) };
}
function evalSkill(skillName) {
export function evalSkill(skillName) {
const skillDir = join(SKILLS_DIR, skillName);
const skillMd = join(skillDir, 'SKILL.md');
if (!existsSync(skillMd)) return null;

View file

@ -0,0 +1,116 @@
// 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}`;
}

View file

@ -0,0 +1,96 @@
# Doc → KB-fil destillasjonsprompt (lag 4)
Brukes av `commands/kb-update.md` (apply-flyten) for å destillere et **hentet** Microsoft
Learn-dokument til en KB-referansefil i pluginens husformat. Pares med
`scripts/kb-update/lib/transform.mjs`: den deterministiske header-byggeren
(`buildKbHeader`), kontrakt-valideringen (`validateKbFile`) og lag-5-broen
(`buildChange`). Skiller seg fra `scripts/skill-gen/prompt-template.md` (som
genererer fra MCP-research, fritt) ved at denne **destillerer én utpekt kilde** og
**forankrer** innholdet mot den.
## Hvorfor Source ligger i headeren
`build-registry`/`lib/kb-headers.mjs` skanner kun de **øverste 500 bytene** etter
`**Source:**`. Den gamle malen la kilder i en bunn-seksjon → usynlig for skanneren →
`authority_source`-dekning = 0 %. Lag 4 legger `**Source:**` i header-blokka. Det er
det som lar lag 3 (verifisering-INN, fremtidig) backfille `authority_source` og lag-5
regel 3 (autoritets-mismatch) faktisk fyre.
## Variabler (caller injiserer)
- `{{TITLE}}` — engelsk tittel (h1)
- `{{STATUS}}` — release-stage fra kilden: `GA` | `Public Preview` | `Private Preview` | `Announced` | `Deprecated` | `Retired`
- `{{CATEGORY}}` — taksonomi-kategorinøkkel (f.eks. `rag-architecture`) — bestemmer eierskill via `resolveTargetPath`
- `{{SOURCE_URL}}` — den **utpekte autoritetskilden** (kanonisk Learn-URL dokumentet ble hentet fra)
- `{{LAST_UPDATED}}``YYYY-MM` (dagens måned, caller-injisert)
- `{{FETCHED_DOC}}` — den hentede markdownen fra `microsoft_docs_fetch`
- `{{EXISTING_CONTEXT}}` — sammendrag av overlappende eksisterende ref-filer (unngå duplisering — K4)
## Header (deterministisk — IKKE la LLM-en skrive den)
Header-blokka bygges av `buildKbHeader({title, status, category, source, lastUpdated})`,
ikke av modellen. Den ser slik ut og MÅ stå først:
```markdown
# {{TITLE}}
**Last updated:** {{LAST_UPDATED}}
**Status:** {{STATUS}}
**Category:** {{CATEGORY}}
**Source:** {{SOURCE_URL}}
---
```
Modellen skriver **kun brødteksten under `---`**. Etterpå kjører
`validateKbFile(header + body)` og MÅ returnere `valid: true` (title + Last updated +
Status + Source til stede) før noe gates videre.
## Prompt (brødtekst)
```
Du er Cosmo Skyberg, senior Microsoft AI Solution Architect. Destillér kildedokumentet
under til en kunnskapsreferanse på norsk (engelske tekniske termer beholdes) for et
Claude Code-plugin som hjelper norsk offentlig sektor med Microsoft AI.
KILDE (utpekt autoritet — forankre alt mot denne, ikke mot generell kunnskap):
{{SOURCE_URL}}
KILDEDOKUMENT:
{{FETCHED_DOC}}
Skriv KUN brødteksten (under header-`---`). Seksjoner (alle påkrevd):
1. Introduksjon (2-3 avsnitt) — hva, hvorfor, plassering i MS-økosystemet
2. Kjernekomponenter / nøkkelegenskaper — tabeller for sammenligning
3. Arkitekturmønstre — 2-3 mønstre, når hvert passer, fordeler/ulemper
4. Beslutningsveiledning — «Velg X når …», vanlige feil, røde flagg
5. Integrasjon med Microsoft-stakken
6. Offentlig sektor (Norge) — GDPR, Schrems II, AI Act, datasuverenitet
7. Kostnad og lisensiering — prismodell på oversiktsnivå
8. For arkitekten (Cosmo) — 5-8 nøkkelspørsmål, fallgruver
9. Kilder og verifisering — pek på {{SOURCE_URL}} + evt. relaterte, konfidensnivå per seksjon
REGLER (strenge):
1. Forankring: hver faktapåstand skal kunne spores til kildedokumentet. Fyll ALDRI hull
med gjetninger — skriv «(uverifisert)» eller utelat.
2. STATUS-PÅSTANDER EKSPLISITT: enhver GA/preview/versjon/pris-/SLA-påstand skal stå som
en tydelig, isolerbar setning. Disse blir ALLTID operatør-gated av lag 5 (spec §21) —
ikke skjul dem i prosa, og ikke flat ut en nyansert status («delvis GA, resten preview»)
til en enkel «GA».
3. Norsk prosa, engelske tekniske termer. Tabeller over løpende tekst der det passer.
4. Balansert (fordeler OG ulemper), konkret over vagt, 200-400 linjer.
5. Ingen duplisering av: {{EXISTING_CONTEXT}}
Ingen innledning eller avslutningskommentar — kun markdown-brødteksten.
```
## Etter destillasjon (caller, gated)
1. `content = buildKbHeader({...}) + body`
2. `validateKbFile(content)` → må være `valid: true` (ellers: be modellen fikse manglende felt).
3. For hver status-/load-bearing-påstand: `buildChange({field, oldValue, newValue, sourceUrl, authoritySource})`
`classifyChange(...)` (lag 5). `flagged` → operatør avgjør; status-påstander er alltid `flagged`.
4. `resolveTargetPath(tax, category, filename)` → eierskill-sti (null = ukjent kategori → gate, ikke skriv).
5. Først etter operatør-gate: atomisk skriving (`lib/atomic-write.mjs` + `lib/backup.mjs`). Transform-libben skriver aldri selv.
```

View file

@ -0,0 +1,90 @@
// tests/kb-eval/test-transform-criterion.test.mjs
// Sesjon 5 ACCEPTANCE CRITERION (roadmap rad 5): "Regenerer 1 fil → eval-score ≥ baseline".
//
// Two halves, both runnable, no LLM:
// (1) The live deterministic eval for a target skill is ≥ the recorded baseline
// — the literal criterion, a regression guard over eval-baseline.json.
// (2) Regenerating one REAL reference file through the lag-4 pipeline (real body +
// a fresh buildKbHeader) yields a file that is valid, dated, source-anchored,
// routed back to the same skill dir, and preserves the body verbatim — so
// writing it in place is score-preserving (same path → same ref count → same
// deterministic eval). It is in fact strictly BETTER: the pre-lag-4 file has
// no **Source:** header (the 0%-coverage failure mode), the regenerated one does.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { evalSkill } from '../../scripts/kb-eval/eval.mjs';
import { buildKbHeader, validateKbFile, resolveTargetPath } from '../../scripts/kb-update/lib/transform.mjs';
import { loadTaxonomy } from '../../scripts/kb-update/lib/taxonomy.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const baseline = JSON.parse(
readFileSync(join(PLUGIN_ROOT, 'scripts/kb-eval/data/eval-baseline.json'), 'utf8'),
);
const TARGET = 'ms-ai-infrastructure';
const base = baseline.skills.find((s) => s.name === TARGET);
// --- (1) live deterministic eval ≥ baseline -----------------------------------
test(`live deterministic eval ≥ baseline for ${TARGET}`, () => {
const live = evalSkill(TARGET);
const dl = live.deterministic;
const db = base.deterministic;
// ref count not reduced
assert.ok(live.refFilesActual >= base.refFilesActual,
`refFilesActual regressed: ${live.refFilesActual} < ${base.refFilesActual}`);
// no pass→fail regression on any deterministic check (true≥true ok, false≥true fails)
assert.ok(dl.K2_descriptionFormat.pass >= db.K2_descriptionFormat.pass, 'K2 regressed');
assert.ok(dl.K3_bodyLength.pass >= db.K3_bodyLength.pass, 'K3 regressed');
assert.ok(dl.K5_progressiveDisclosure.pass >= db.K5_progressiveDisclosure.pass, 'K5 regressed');
assert.ok(dl.K6_routingTable.pass >= db.K6_routingTable.pass, 'K6 regressed');
assert.ok(dl.refCountConsistency.consistent >= db.refCountConsistency.consistent, 'refConsistency regressed');
});
// --- (2) regenerating one real file is score-preserving (and Source-improving) --
// Split a KB file into (header, body) at the first `---` rule line.
function splitBody(content) {
const m = content.match(/\n---\n/);
return m ? content.slice(m.index + m[0].length) : content;
}
test('regenerate 1 real file → valid, dated, source-anchored, body preserved, same path', () => {
const tax = loadTaxonomy();
const refRel = base.judgeInputs.refFileSample[0]; // a real repo-relative path
const original = readFileSync(join(PLUGIN_ROOT, refRel), 'utf8');
const body = splitBody(original);
// The pre-lag-4 file has no **Source:** header — the 0%-coverage failure mode.
assert.ok(validateKbFile(original).missing.includes('source'),
'expected the pre-lag-4 file to lack a header Source');
// category key + filename from the real path: skills/<skill>/references/<category>/<file>
const parts = refRel.split('/');
const category = parts[3];
const filename = basename(refRel);
const regenerated = buildKbHeader({
title: 'AI Foundry Disaster Recovery Planning',
status: 'GA',
category,
source: 'https://learn.microsoft.com/azure/ai-foundry/concepts/disaster-recovery',
lastUpdated: '2026-06',
}) + body;
// Strictly better than the original: now valid (Source present), still dated.
const v = validateKbFile(regenerated);
assert.equal(v.valid, true, `regenerated file invalid: ${v.missing}`);
// Body preserved verbatim — transformation does not drop content.
assert.ok(regenerated.includes(body), 'body not preserved');
assert.ok(regenerated.includes('Azure AI Foundry'), 'expected body content missing');
// Routes back to the SAME skill/category dir → in-place → count-preserving → eval-score preserved.
assert.equal(resolveTargetPath(tax, category, filename), refRel);
});

View file

@ -0,0 +1,156 @@
// tests/kb-update/test-transform.test.mjs
// TDD for lag 4 — TRANSFORMASJON (doc → KB-fil). lib/transform.mjs is a pure
// helper library (mirrors decisions-io / verify-out): it builds the deterministic
// KB-file header, validates the mandatory-field contract, routes the file to its
// owning skill via the taxonomy (lag 0), and constructs the lag-5 `change` object.
// It NEVER writes — applying the file is the operator gate's job (commands/kb-update.md).
//
// Why Source lives in the HEADER block: build-registry / kb-headers.parseSourceHeader
// scan only the top 500 bytes, so a source buried in a bottom section (as the old
// prompt-template did) is invisible → the 0% authority_source coverage. Putting
// **Source:** in the header is what lets lag 3 backfill authority_source and lag-5
// rule 3 (authority mismatch) eventually fire.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildKbHeader,
validateKbFile,
buildChange,
resolveTargetPath,
} from '../../scripts/kb-update/lib/transform.mjs';
import { parseSourceHeader } from '../../scripts/kb-update/lib/kb-headers.mjs';
import { classifyChange } from '../../scripts/kb-update/lib/verify-out.mjs';
import { loadTaxonomy } from '../../scripts/kb-update/lib/taxonomy.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const META = {
title: 'Azure AI Search - Hybrid Retrieval',
status: 'GA',
category: 'rag-architecture',
source: 'https://learn.microsoft.com/azure/search/hybrid-search-overview',
lastUpdated: '2026-06',
};
// --- buildKbHeader: mandatory fields, parseable by the real header scanners ----
test('buildKbHeader emits title, Last updated, Status, Source, Category in the header block', () => {
const h = buildKbHeader(META);
assert.match(h, /^# Azure AI Search - Hybrid Retrieval/m);
assert.match(h, /\*\*Last updated:\*\*\s*2026-06/);
assert.match(h, /\*\*Status:\*\*\s*GA/);
assert.match(h, /\*\*Category:\*\*\s*rag-architecture/);
assert.match(h, /\*\*Source:\*\*\s*https:\/\/learn\.microsoft\.com/);
});
test('buildKbHeader Source is within the top-500-byte region kb-headers scans', () => {
const h = buildKbHeader(META);
// The whole point: parseSourceHeader (top 500 bytes) must find it.
assert.equal(parseSourceHeader(h), META.source);
});
test('buildKbHeader throws when a mandatory field is missing (status-felt obligatorisk)', () => {
assert.throws(() => buildKbHeader({ ...META, status: undefined }), /status/i);
assert.throws(() => buildKbHeader({ ...META, source: '' }), /source/i);
assert.throws(() => buildKbHeader({ ...META, title: null }), /title/i);
assert.throws(() => buildKbHeader({ ...META, lastUpdated: undefined }), /last.?updated/i);
});
// --- validateKbFile: catches files missing the mandatory contract -------------
test('validateKbFile accepts a header-conformant file', () => {
const file = buildKbHeader(META) + '\n## Introduksjon\n\nNoe innhold.\n';
const r = validateKbFile(file);
assert.equal(r.valid, true);
assert.deepEqual(r.missing, []);
});
test('validateKbFile reports each missing mandatory field', () => {
// No Status, no Source, no Last updated, no title.
const r = validateKbFile('Bare brødtekst uten header.\n');
assert.equal(r.valid, false);
for (const f of ['title', 'last_updated', 'status', 'source']) {
assert.ok(r.missing.includes(f), `expected '${f}' in missing: ${r.missing}`);
}
});
test('validateKbFile flags a file with body-only source (the 0%-coverage failure mode)', () => {
// Source in a bottom section, NOT the header → invisible to top-500-byte scan.
const file =
'# Tittel\n\n**Last updated:** 2026-06\n**Status:** Preview\n\n---\n\n' +
'Mye innhold ...\n'.repeat(40) +
'\n## Kilder\n\n**Source:** https://learn.microsoft.com/x\n';
const r = validateKbFile(file);
assert.equal(r.valid, false);
assert.ok(r.missing.includes('source'));
});
// --- buildChange: the lag-4 → lag-5 contract ----------------------------------
test('buildChange produces a classifyChange-compatible object with safe defaults', () => {
const c = buildChange({
field: 'status',
oldValue: 'Preview',
newValue: 'GA',
sourceUrl: META.source,
});
assert.equal(c.field, 'status');
assert.equal(c.old_value, 'Preview');
assert.equal(c.new_value, 'GA');
assert.equal(c.source_url, META.source);
assert.equal(c.authority_source, null); // pre-lag-3 default
assert.deepEqual(c.refutations, []);
});
test('buildChange output flows through lag 5 — a status change is flagged, never auto-applied', () => {
const c = buildChange({ field: 'status', oldValue: 'Preview', newValue: 'GA', sourceUrl: META.source });
const verdict = classifyChange(c);
assert.equal(verdict.verdict, 'flagged');
assert.equal(verdict.status_claim, true);
});
test('buildChange output flows through lag 5 — a non-status change can auto-apply', () => {
const c = buildChange({
field: 'description',
oldValue: 'En kort beskrivelse.',
newValue: 'En litt lengre, oppdatert beskrivelse.',
sourceUrl: META.source,
});
assert.equal(classifyChange(c).verdict, 'auto-applied');
});
// --- resolveTargetPath: taxonomy-driven routing (replaces category-skill-map) --
test('resolveTargetPath routes via the taxonomy category_skill (disk-truth)', () => {
const tax = loadTaxonomy();
assert.equal(
resolveTargetPath(tax, 'rag-architecture', 'hybrid-retrieval.md'),
'skills/ms-ai-engineering/references/rag-architecture/hybrid-retrieval.md',
);
// a former divergence — taxonomy says advisor, not engineering
assert.equal(
resolveTargetPath(tax, 'prompt-engineering', 'few-shot.md'),
'skills/ms-ai-advisor/references/prompt-engineering/few-shot.md',
);
});
test('resolveTargetPath returns null for an unknown category (caller must gate)', () => {
const tax = loadTaxonomy();
assert.equal(resolveTargetPath(tax, 'does-not-exist', 'x.md'), null);
});
// --- ARCHITECTURE INVARIANT: transform.mjs is a pure lib, imports NO write-utils
test('transform.mjs imports NO write-utils (pure transformer, never writes)', () => {
const src = readFileSync(join(__dirname, '../../scripts/kb-update/lib/transform.mjs'), 'utf8');
const importLines = src.split('\n').filter((l) => /^\s*import\b/.test(l)).join('\n');
assert.doesNotMatch(importLines, /\bsaveDecisions\b/);
assert.doesNotMatch(importLines, /\bsaveRegistry\b/);
assert.doesNotMatch(importLines, /\bsaveReport\b/);
assert.doesNotMatch(importLines, /from\s+'[^']*atomic-write[^']*'/);
assert.doesNotMatch(importLines, /from\s+'[^']*\/backup\.mjs'/);
});