- backup.mjs restore(relPaths): SCOPED per-fil-rollback erstatter hel-tre rmSync(srcDir)+cpSync. Ruller kun tilbake kjøringens egne skriv; parallelle økters skriv til søsken-filer overlever; intet destruktivt rm→cp-vindu. Nyskapte filer slettes; prior bytes gjenopprettes atomisk. - migrate-corpus.mjs: returnert restore() er nå en null-arg closure bundet til kjøringens skrive- liste (public API uendret) → scoped. detectStaleRollback aborterer ved forrige krasj-sentinel; cleanupOldBackups pruner backups forbi retention etter vellykket batch. - 5 drivere (backfill-status/-category, dedup-plain-header, relabel-dato/-dialect): writeFileSync → atomicWriteSync (crash-safe tmp+rename) + recovery-kontrakt i header. - backfill-category.mjs: refaktorert importerbar (isMain-guard + run()/planCategoryBackfill/ categoryForFile/FOLDER_CATEGORY-eksporter, parameterisert root) → testbar uten scan-side-effekt. - Tester (+16): 6 scoped-restore (parallel-preservering, ny-fil-sletting, throws-guard) erstatter 2 hel-tre; test-backfill-category (frosset taxonomi + hermetisk plan); test-driver-atomic-writes (5 drivere); migrate stale-abort + cleanup-wiring. Suite 859→875 exit 0. validate-plugin 250/0.
322 lines
14 KiB
JavaScript
322 lines
14 KiB
JavaScript
// tests/kb-update/test-migrate-apply.test.mjs
|
|
// Integration tests for scripts/kb-update/migrate-corpus.mjs — the unified Spor 1
|
|
// applier (Step 7). Hermetic: every case runs against a fresh mkdtemp corpus via the
|
|
// injected `--root`/`backupRoot`, so nothing ever writes to the real skills/ tree.
|
|
//
|
|
// Covers the Step-7 acceptance set: idempotency, advisor-exclusion, throw-guard,
|
|
// atomic (no *.tmp.* leftover + import-grep), backup+restore round-trip, dry-run
|
|
// default byte-identity, body-after-`---` identity + one `## Innhold`, post-write
|
|
// parse assertion (500B truncation), and normalize→insert ordering.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
existsSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
planFileMutation,
|
|
migrateCorpus,
|
|
} from '../../scripts/kb-update/migrate-corpus.mjs';
|
|
import {
|
|
parseTypeHeader,
|
|
parseSourceHeader,
|
|
parseVerifiedHeader,
|
|
} from '../../scripts/kb-update/lib/kb-headers.mjs';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const SRC = join(HERE, '..', '..', 'scripts', 'kb-update', 'migrate-corpus.mjs');
|
|
|
|
function withTmp(fn) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'migrate-test-'));
|
|
try {
|
|
return fn(dir);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** Materialize a corpus under `root` from a {relpath: content} map. */
|
|
function makeCorpus(root, files) {
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
const path = join(root, rel);
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, content, 'utf8');
|
|
}
|
|
}
|
|
|
|
/** Read every file under `root` into a {relpath: content} map (posix keys). */
|
|
function readAll(root) {
|
|
const out = {};
|
|
function walk(dir, prefix) {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) walk(join(dir, entry.name), rel);
|
|
else if (entry.isFile()) out[rel] = readFileSync(join(dir, entry.name), 'utf8');
|
|
}
|
|
}
|
|
walk(root, '');
|
|
return out;
|
|
}
|
|
|
|
/** Canonical `skills/…` manifest key for a corpus-relative path. */
|
|
const key = (rel) => `skills/${rel}`;
|
|
|
|
const REF_SMALL = `# Azure AI Foundry Overview
|
|
|
|
**Category:** Engineering
|
|
**Status:** Active
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
Foundry is a platform.
|
|
|
|
## Details
|
|
|
|
More detail here.
|
|
`;
|
|
|
|
const LARGE_SECTIONS = Array.from({ length: 8 }, (_, i) =>
|
|
`## Section ${i + 1}\n\n${'body paragraph line.\n'.repeat(14)}`,
|
|
).join('\n');
|
|
const REF_LARGE = `# Big Reference
|
|
|
|
**Category:** Engineering
|
|
**Status:** Active
|
|
|
|
---
|
|
|
|
${LARGE_SECTIONS}`;
|
|
|
|
const REF_STALE_VERIFIED = `# Stale Verified File
|
|
|
|
**Category:** Engineering
|
|
**Status:** Active
|
|
**Verified:** MCP 2026-06
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
Body.
|
|
`;
|
|
|
|
test('planFileMutation — reference entry stamps Type + Source; body after --- byte-identical', () => {
|
|
const entry = { type: 'reference', source: 'https://learn.microsoft.com/azure/ai-foundry' };
|
|
const plan = planFileMutation(key('ms-ai-engineering/references/a.md'), REF_SMALL, entry);
|
|
assert.equal(plan.changed, true);
|
|
assert.equal(parseTypeHeader(plan.content), 'reference');
|
|
assert.equal(parseSourceHeader(plan.content), entry.source);
|
|
// Body from the first `---` rule onward is untouched (header-only insertion; small file → no TOC).
|
|
const marker = '\n---\n';
|
|
assert.equal(plan.content.slice(plan.content.indexOf(marker)), REF_SMALL.slice(REF_SMALL.indexOf(marker)));
|
|
});
|
|
|
|
test('planFileMutation — deferred reference (no source) gets Type only, never Source', () => {
|
|
const entry = { type: 'reference', deferred: true, deferReason: 'no-ms-citation' };
|
|
const plan = planFileMutation(key('ms-ai-governance/references/d.md'), REF_SMALL, entry);
|
|
assert.equal(parseTypeHeader(plan.content), 'reference');
|
|
assert.equal(parseSourceHeader(plan.content), null);
|
|
assert.equal(plan.expectSource, false);
|
|
});
|
|
|
|
test('planFileMutation — throw-guard fires on an advisor target path', () => {
|
|
const entry = { type: 'reference', source: 'https://learn.microsoft.com/x' };
|
|
assert.throws(
|
|
() => planFileMutation('skills/ms-ai-advisor/references/architecture/cost-models.md', REF_SMALL, entry),
|
|
/advisor/i,
|
|
);
|
|
});
|
|
|
|
test('planFileMutation — normalize then insert applied in order on a **Verified:** MCP fixture', () => {
|
|
const entry = { type: 'reference', source: 'https://learn.microsoft.com/azure/x' };
|
|
const plan = planFileMutation(key('ms-ai-security/references/v.md'), REF_STALE_VERIFIED, entry);
|
|
// Stale header Verified stripped (normalize ran) AND Type inserted (insert ran).
|
|
assert.equal(parseVerifiedHeader(plan.content), null);
|
|
assert.equal(parseTypeHeader(plan.content), 'reference');
|
|
assert.equal(parseSourceHeader(plan.content), entry.source);
|
|
});
|
|
|
|
test('planFileMutation — already-migrated content is a no-op (idempotent)', () => {
|
|
const entry = { type: 'reference', source: 'https://learn.microsoft.com/azure/ai-foundry' };
|
|
const once = planFileMutation(key('ms-ai-engineering/references/a.md'), REF_SMALL, entry);
|
|
const twice = planFileMutation(key('ms-ai-engineering/references/a.md'), once.content, entry);
|
|
assert.equal(twice.changed, false);
|
|
assert.equal(twice.content, once.content);
|
|
});
|
|
|
|
test('migrateCorpus — dry-run (default) leaves the corpus byte-identical on disk', () => {
|
|
withTmp((tmp) => {
|
|
const root = join(tmp, 'skills');
|
|
makeCorpus(root, { 'ms-ai-engineering/references/a.md': REF_SMALL });
|
|
const before = readAll(root);
|
|
const manifest = { [key('ms-ai-engineering/references/a.md')]: { type: 'reference', source: 'https://learn.microsoft.com/x' } };
|
|
const report = migrateCorpus({ root, backupRoot: join(tmp, '.kb-backup'), manifest, write: false });
|
|
assert.equal(report.write, false);
|
|
assert.ok(report.counts.changed > 0, 'dry-run still reports the pending change');
|
|
assert.equal(report.backupPath, null);
|
|
assert.deepEqual(readAll(root), before);
|
|
});
|
|
});
|
|
|
|
test('migrateCorpus — --write stamps non-advisor files and leaves advisor byte-identical', () => {
|
|
withTmp((tmp) => {
|
|
const root = join(tmp, 'skills');
|
|
makeCorpus(root, {
|
|
'ms-ai-engineering/references/a.md': REF_SMALL,
|
|
'ms-ai-advisor/references/architecture/cost-models.md': REF_SMALL,
|
|
});
|
|
const advisorBefore = readFileSync(join(root, 'ms-ai-advisor/references/architecture/cost-models.md'), 'utf8');
|
|
const manifest = {
|
|
[key('ms-ai-engineering/references/a.md')]: { type: 'reference', source: 'https://learn.microsoft.com/x' },
|
|
// Advisor entry is present in the real manifest (deferred), but the walker must never reach it.
|
|
[key('ms-ai-advisor/references/architecture/cost-models.md')]: { type: 'reference', deferred: true, deferReason: 'advisor-scope' },
|
|
};
|
|
const report = migrateCorpus({ root, backupRoot: join(tmp, '.kb-backup'), manifest, write: true });
|
|
assert.equal(report.counts.written, 1);
|
|
// Non-advisor mutated.
|
|
const eng = readFileSync(join(root, 'ms-ai-engineering/references/a.md'), 'utf8');
|
|
assert.equal(parseTypeHeader(eng), 'reference');
|
|
assert.equal(parseSourceHeader(eng), 'https://learn.microsoft.com/x');
|
|
// Advisor untouched — byte-identical.
|
|
assert.equal(readFileSync(join(root, 'ms-ai-advisor/references/architecture/cost-models.md'), 'utf8'), advisorBefore);
|
|
});
|
|
});
|
|
|
|
test('migrateCorpus — second --write run writes 0 files (idempotent on disk)', () => {
|
|
withTmp((tmp) => {
|
|
const root = join(tmp, 'skills');
|
|
makeCorpus(root, { 'ms-ai-engineering/references/a.md': REF_SMALL });
|
|
const manifest = { [key('ms-ai-engineering/references/a.md')]: { type: 'reference', source: 'https://learn.microsoft.com/x' } };
|
|
const opts = { root, backupRoot: join(tmp, '.kb-backup'), manifest, write: true };
|
|
const first = migrateCorpus(opts);
|
|
assert.equal(first.counts.written, 1);
|
|
const second = migrateCorpus(opts);
|
|
assert.equal(second.counts.written, 0);
|
|
assert.equal(second.counts.changed, 0);
|
|
});
|
|
});
|
|
|
|
test('migrateCorpus — atomic write leaves no *.tmp.* leftover', () => {
|
|
withTmp((tmp) => {
|
|
const root = join(tmp, 'skills');
|
|
makeCorpus(root, { 'ms-ai-engineering/references/a.md': REF_SMALL });
|
|
const manifest = { [key('ms-ai-engineering/references/a.md')]: { type: 'reference', source: 'https://learn.microsoft.com/x' } };
|
|
migrateCorpus({ root, backupRoot: join(tmp, '.kb-backup'), manifest, write: true });
|
|
const names = Object.keys(readAll(root));
|
|
assert.equal(names.some((n) => /\.tmp\./.test(n)), false, 'no atomic-write temp file survived');
|
|
});
|
|
});
|
|
|
|
test('migrate-corpus source — uses atomicWriteSync, never bare writeFileSync', () => {
|
|
const src = readFileSync(SRC, 'utf8');
|
|
assert.match(src, /atomicWriteSync/, 'imports/uses the atomic writer');
|
|
assert.doesNotMatch(src, /\bwriteFileSync\b/, 'never calls writeFileSync directly for corpus writes');
|
|
assert.match(src, /ms-ai-advisor/, 'carries the advisor fence');
|
|
});
|
|
|
|
test('migrateCorpus — backup exists and restore() round-trips byte-identity', () => {
|
|
withTmp((tmp) => {
|
|
const root = join(tmp, 'skills');
|
|
makeCorpus(root, {
|
|
'ms-ai-engineering/references/a.md': REF_SMALL,
|
|
'ms-ai-security/references/b.md': REF_STALE_VERIFIED,
|
|
});
|
|
const original = readAll(root);
|
|
const manifest = {
|
|
[key('ms-ai-engineering/references/a.md')]: { type: 'reference', source: 'https://learn.microsoft.com/x' },
|
|
[key('ms-ai-security/references/b.md')]: { type: 'reference', source: 'https://learn.microsoft.com/y' },
|
|
};
|
|
const report = migrateCorpus({ root, backupRoot: join(tmp, '.kb-backup'), manifest, write: true });
|
|
assert.ok(report.backupPath && existsSync(report.backupPath), 'backup snapshot exists');
|
|
assert.equal(typeof report.restore, 'function');
|
|
// Corpus is mutated now…
|
|
assert.notDeepEqual(readAll(root), original);
|
|
// …restore brings it back byte-identical.
|
|
report.restore();
|
|
assert.deepEqual(readAll(root), original);
|
|
});
|
|
});
|
|
|
|
test('migrateCorpus — large file gains exactly one ## Innhold, sections preserved', () => {
|
|
withTmp((tmp) => {
|
|
const root = join(tmp, 'skills');
|
|
makeCorpus(root, { 'ms-ai-engineering/references/big.md': REF_LARGE });
|
|
const manifest = { [key('ms-ai-engineering/references/big.md')]: { type: 'reference', source: 'https://learn.microsoft.com/x' } };
|
|
migrateCorpus({ root, backupRoot: join(tmp, '.kb-backup'), manifest, write: true });
|
|
const out = readFileSync(join(root, 'ms-ai-engineering/references/big.md'), 'utf8');
|
|
assert.equal((out.match(/^## Innhold$/gm) || []).length, 1, 'exactly one TOC heading');
|
|
assert.equal(parseTypeHeader(out), 'reference');
|
|
// All original sections survive.
|
|
for (let i = 1; i <= 8; i++) assert.match(out, new RegExp(`^## Section ${i}$`, 'm'));
|
|
});
|
|
});
|
|
|
|
test('migrateCorpus — post-write parse assertion catches a deep-preamble (>500B) file', () => {
|
|
withTmp((tmp) => {
|
|
const root = join(tmp, 'skills');
|
|
// A title long enough that the inserted **Type:** line lands past the 500-byte
|
|
// scan window → parseTypeHeader returns null on re-read → the applier must throw.
|
|
const deep = `# ${'A'.repeat(490)}\n\n**Category:** Engineering\n\n## Section\n\nBody.\n`;
|
|
makeCorpus(root, { 'ms-ai-engineering/references/deep.md': deep });
|
|
const original = readAll(root);
|
|
const manifest = { [key('ms-ai-engineering/references/deep.md')]: { type: 'reference', source: 'https://learn.microsoft.com/x' } };
|
|
assert.throws(
|
|
() => migrateCorpus({ root, backupRoot: join(tmp, '.kb-backup'), manifest, write: true }),
|
|
/truncat|500|header/i,
|
|
);
|
|
// The applier restored the corpus on the failed assertion — no partial mutation left behind.
|
|
assert.deepEqual(readAll(root), original);
|
|
});
|
|
});
|
|
|
|
// --- RX-OPS2: backup hygiene wired into the applier ---
|
|
|
|
test('migrateCorpus — aborts when a stale rollback sentinel is present (prior crash)', () => {
|
|
withTmp((tmp) => {
|
|
const root = join(tmp, 'skills');
|
|
const backupRoot = join(tmp, '.kb-backup');
|
|
makeCorpus(root, { 'ms-ai-engineering/references/a.md': REF_SMALL });
|
|
mkdirSync(backupRoot, { recursive: true });
|
|
// A previous run crashed mid-restore and left its sentinel behind.
|
|
writeFileSync(join(backupRoot, '.rollback-in-progress'), JSON.stringify({ started_at: '2026-07-01T00:00:00Z' }), 'utf8');
|
|
const manifest = { [key('ms-ai-engineering/references/a.md')]: { type: 'reference', source: 'https://learn.microsoft.com/x' } };
|
|
assert.throws(
|
|
() => migrateCorpus({ root, backupRoot, manifest, write: true }),
|
|
/stale|rollback|recover/i,
|
|
);
|
|
// Aborted before any write — corpus untouched.
|
|
assert.equal(readFileSync(join(root, 'ms-ai-engineering/references/a.md'), 'utf8'), REF_SMALL);
|
|
});
|
|
});
|
|
|
|
test('migrateCorpus — prunes backups older than retention after a successful write', () => {
|
|
withTmp((tmp) => {
|
|
const root = join(tmp, 'skills');
|
|
const backupRoot = join(tmp, '.kb-backup');
|
|
makeCorpus(root, { 'ms-ai-engineering/references/a.md': REF_SMALL });
|
|
// Seed an old backup dir (>7 days) that cleanup must remove after a good run.
|
|
const oldDir = join(backupRoot, '2026-01-01T00-00-00');
|
|
mkdirSync(oldDir, { recursive: true });
|
|
writeFileSync(
|
|
join(oldDir, '.backup-meta.json'),
|
|
JSON.stringify({ created_at: '2026-01-01T00:00:00.000Z', schema_version: 1 }),
|
|
'utf8',
|
|
);
|
|
const manifest = { [key('ms-ai-engineering/references/a.md')]: { type: 'reference', source: 'https://learn.microsoft.com/x' } };
|
|
const report = migrateCorpus({ root, backupRoot, manifest, write: true });
|
|
assert.equal(report.counts.written, 1);
|
|
assert.equal(existsSync(oldDir), false, 'stale-age backup pruned after successful write');
|
|
assert.ok(existsSync(report.backupPath), "this run's fresh backup retained");
|
|
});
|
|
});
|