ms-ai-architect/tests/kb-update/test-transform.test.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

253 lines
11 KiB
JavaScript

// 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,
buildToc,
composeKbFile,
} 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';
// The N4 (TOC-in-large-files) check is the cross-subsystem best-practice contract.
// Fase 1c births files that satisfy it; we assert parity against the REAL check so
// a future drift in N4's definition fails this test rather than silently diverging.
import { checkN4 } from '../../scripts/kb-eval/eval.mjs';
// A body long enough that header+body exceeds N4's 100-line "large file" threshold,
// carrying real ## section headings the TOC must pick up.
const LARGE_BODY =
'## Introduksjon\n\nNoe innledning.\n\n' +
'## Kjernekomponenter\n\n' +
'Mye innhold ...\n'.repeat(110) +
'\n## Beslutningsveiledning\n\nVelg X når ...\n';
const SHORT_BODY = '## Introduksjon\n\nKort.\n';
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);
});
// --- buildToc: deterministic TOC from the body's ## section headings -----------
test('buildToc emits a ## Innhold heading with anchor links for each ## section', () => {
const toc = buildToc('## Introduksjon\n\ntekst\n\n## Kostnad og lisensiering\n\ntekst\n');
assert.match(toc, /^##\s+Innhold/m);
assert.match(toc, /- \[Introduksjon\]\(#introduksjon\)/);
assert.match(toc, /- \[Kostnad og lisensiering\]\(#kostnad-og-lisensiering\)/);
});
test('buildToc slugifies GitHub-style: lowercase, punctuation stripped, spaces→hyphens', () => {
const toc = buildToc('## 1. Introduksjon & Oversikt\n\ntekst\n');
assert.match(toc, /- \[1\. Introduksjon & Oversikt\]\(#1-introduksjon--oversikt\)/);
});
test('buildToc returns empty string when the body has no ## sections', () => {
assert.equal(buildToc('Bare brødtekst, ingen overskrifter.\n'), '');
assert.equal(buildToc('# Bare en H1\n\ntekst\n'), ''); // H1 is the header title, not a section
});
test('buildToc never lists the Innhold heading itself (idempotent re-runs)', () => {
const body = '## Innhold\n\n- [Introduksjon](#introduksjon)\n\n## Introduksjon\n\ntekst\n';
const toc = buildToc(body);
// exactly one anchor — to Introduksjon — never an anchor back to Innhold
assert.doesNotMatch(toc, /\(#innhold\)/);
assert.match(toc, /\(#introduksjon\)/);
});
// --- composeKbFile: the deterministic generator (header + TOC-if-large + body) --
test('composeKbFile births a large file with canonical header AND a TOC', () => {
const file = composeKbFile(META, LARGE_BODY);
assert.match(file, /^# Azure AI Search - Hybrid Retrieval/m); // canonical header first
assert.equal(parseSourceHeader(file), META.source); // Source in top-500-byte region
assert.match(file, /^##\s+Innhold/m); // TOC present
assert.equal(validateKbFile(file).valid, true);
});
test('composeKbFile output for a large file passes the REAL N4 check (anti-regression oracle)', () => {
const file = composeKbFile(META, LARGE_BODY);
const n4 = checkN4([{ path: 'skills/x/references/y/z.md', content: file }]);
assert.equal(n4.largeFiles, 1);
assert.equal(n4.withToc, 1);
assert.equal(n4.pass, true);
});
test('composeKbFile does NOT add a TOC to a small file (TOC is for large files only)', () => {
const file = composeKbFile(META, SHORT_BODY);
assert.doesNotMatch(file, /^##\s+Innhold/m);
assert.equal(validateKbFile(file).valid, true);
});
test('composeKbFile is idempotent: a body that already carries a TOC is not double-TOCd', () => {
const once = composeKbFile(META, LARGE_BODY);
const body = once.slice(once.indexOf('---\n') + 4); // strip header, keep TOC+body
const twice = composeKbFile(META, body);
assert.equal((twice.match(/^##\s+Innhold/gm) || []).length, 1);
assert.equal(validateKbFile(twice).valid, true);
});
// --- validateKbFile: large files must carry a TOC (the write-path regression gate)
test('validateKbFile flags a large file missing a TOC', () => {
const file = buildKbHeader(META) + '\n' + 'En linje med innhold.\n'.repeat(120);
const r = validateKbFile(file);
assert.equal(r.valid, false);
assert.ok(r.missing.includes('toc'), `expected 'toc' in missing: ${r.missing}`);
});
test('validateKbFile accepts a large file that carries a TOC', () => {
const file = composeKbFile(META, LARGE_BODY);
const r = validateKbFile(file);
assert.equal(r.valid, true);
assert.ok(!r.missing.includes('toc'));
});
test('validateKbFile does NOT require a TOC for a small file', () => {
const file = buildKbHeader(META) + '\n## Introduksjon\n\nKort innhold.\n';
const r = validateKbFile(file);
assert.equal(r.valid, true);
assert.ok(!r.missing.includes('toc'));
});
// --- 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'/);
});