// 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'/); });