Merge-apply = den tyngste destruktive op-en: eksekver en operatør-godkjent merge_skills-entry → faktisk cross-skill skills/-mutasjon. Tre mekanismer som sanitize/retire ikke trengte: kuratert relokasjon (ikke arkiv), taksonomi-persistering, retire-av-absorbert. - taxonomy.mjs (ny persist): saveTaxonomy (atomisk .tmp+rename, speiler saveDecisions) + ren applyCategoryReassignments (repointer KUN der eier === from; absent/allerede-flyttet = no-op). Lag-0 er normalt read-only; ENESTE writer = gated merge-apply. - applyApprovedAction merge-gren (skill-ops.mjs): re-plan fersk → revalidér (kollisjon → guardrail.ok=false → abort) → flytt absorbed-refs → absorber (moveFile; flyttingen ER bevaringen set-equality beviser) → persister taksonomi-repoint fra FERSK plan (drift-sikker) → arkivér absorbed-SKILL.md FØR rmdir → flipp ledger approved→applied. Description-forsoning forblir MANUELL (planner-kontrakt: apply rører aldri absorber-SKILL.md). archiveMove→moveFile (generisk: arkiv + kuratert flytt). - CLI apply-skill-op.mjs merge <absorber> <absorbed> — lookup via actionKey (merge_skills:<sortert par>); default PREVIEW, --apply = dobbel-gate. TDD, tmpdir-fixtures: 0 ekte skills/-mutasjon. Ny test-skill-ops-merge-apply (7) + 2 taksonomi-tester; fjernet foreldet merge-throws fra S18-fila. Tester: kb-eval 78→84, kb-update 137→139; validate 239 · kb-integrity 192/192 uendret. eval --json deterministisk uendret (K10 eng+infra FAIL, øvrige PASS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
8.5 KiB
JavaScript
167 lines
8.5 KiB
JavaScript
// tests/kb-update/test-taxonomy.test.mjs
|
|
// Unit tests for scripts/kb-update/lib/taxonomy.mjs + data/domain-taxonomy.json.
|
|
// The taxonomy is the single source of truth (lag 0) consolidating the four
|
|
// previously-divergent taxonomies: poll TARGET_PREFIXES, discover INCLUDE/EXCLUDE,
|
|
// category-skill-map.json, and report-changes getFilePriority. These tests pin the
|
|
// behaviour the refactored scripts must reproduce, plus the no-divergence invariant.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
loadTaxonomy,
|
|
saveTaxonomy,
|
|
getSitemapPrefixes,
|
|
getCategorySkill,
|
|
makeClassifier,
|
|
makePriorityFn,
|
|
applyCategoryReassignments,
|
|
} from '../../scripts/kb-update/lib/taxonomy.mjs';
|
|
|
|
const tax = loadTaxonomy();
|
|
|
|
// --- (a) sitemap prefixes — poll/discover parity ---
|
|
test('sitemap_prefixes — 18 entries with poll/discover parity', () => {
|
|
const prefixes = getSitemapPrefixes(tax);
|
|
assert.equal(prefixes.length, 18);
|
|
// poll superset includes the 6 that discover previously lacked
|
|
for (const p of ['microsoftteams_en-us_', 'sharepoint_en-us_', 'microsoft-365_en-us_',
|
|
'training_en-us_', 'cloud-computing_en-us_', 'privacy_en-us_']) {
|
|
assert.ok(prefixes.includes(p), `missing prefix ${p}`);
|
|
}
|
|
assert.ok(prefixes.includes('azure_en-us_'));
|
|
// dotnet is deliberately excluded (75 sitemaps, 12 matches)
|
|
assert.ok(!prefixes.includes('dotnet_en-us_'));
|
|
});
|
|
|
|
// --- (c) category → skill — PHYSICAL DISK is canonical ---
|
|
test('category_skill — disk-truth canonical (the 4 formerly stale entries)', () => {
|
|
// category-skill-map.json had these as ms-ai-engineering; disk says otherwise.
|
|
assert.equal(getCategorySkill(tax, 'monitoring-observability'), 'ms-ai-governance');
|
|
assert.equal(getCategorySkill(tax, 'copilot-extensibility'), 'ms-ai-advisor');
|
|
assert.equal(getCategorySkill(tax, 'prompt-engineering'), 'ms-ai-advisor');
|
|
assert.equal(getCategorySkill(tax, 'performance-scalability'), 'ms-ai-security');
|
|
// unchanged ones
|
|
assert.equal(getCategorySkill(tax, 'rag-architecture'), 'ms-ai-engineering');
|
|
assert.equal(getCategorySkill(tax, 'responsible-ai'), 'ms-ai-governance');
|
|
assert.equal(getCategorySkill(tax, 'cost-optimization'), 'ms-ai-security');
|
|
// unknown category → null
|
|
assert.equal(getCategorySkill(tax, 'does-not-exist'), null);
|
|
});
|
|
|
|
// --- (b) classifyUrl — reproduces discover's classification exactly ---
|
|
test('classifyUrl — include rules map to correct {skill, category}', () => {
|
|
const classify = makeClassifier(tax);
|
|
assert.deepEqual(
|
|
classify('https://learn.microsoft.com/azure/ai-foundry/openai/how-to/manage-costs'),
|
|
{ skill: 'ms-ai-engineering', category: 'azure-ai-services' }
|
|
);
|
|
// monitoring → governance (was a divergence; derivation reproduces discover's original)
|
|
assert.deepEqual(
|
|
classify('https://learn.microsoft.com/azure/azure-monitor/logs/foo'),
|
|
{ skill: 'ms-ai-governance', category: 'monitoring-observability' }
|
|
);
|
|
// copilot-studio → advisor (was a divergence; derivation reproduces discover's original)
|
|
assert.deepEqual(
|
|
classify('https://learn.microsoft.com/microsoft-copilot-studio/fundamentals-what-is'),
|
|
{ skill: 'ms-ai-advisor', category: 'copilot-extensibility' }
|
|
);
|
|
// alternation pattern
|
|
assert.deepEqual(
|
|
classify('https://learn.microsoft.com/security/benchmark/azure/foo'),
|
|
{ skill: 'ms-ai-security', category: 'ai-security-engineering' }
|
|
);
|
|
});
|
|
|
|
test('classifyUrl — exclude wins over include and non-matches return null', () => {
|
|
const classify = makeClassifier(tax);
|
|
// /samples/ excluded even though /azure/ai-foundry/ would include
|
|
assert.equal(classify('https://learn.microsoft.com/azure/ai-foundry/samples/quickstart'), null);
|
|
// /training/ excluded
|
|
assert.equal(classify('https://learn.microsoft.com/training/modules/intro'), null);
|
|
// unrelated path
|
|
assert.equal(classify('https://learn.microsoft.com/azure/virtual-machines/overview'), null);
|
|
});
|
|
|
|
test('INVARIANT — every include category resolves and skill === category_skill[category]', () => {
|
|
const classify = makeClassifier(tax);
|
|
for (const rule of tax.relevance.include) {
|
|
const skill = getCategorySkill(tax, rule.category);
|
|
assert.ok(skill, `include category ${rule.category} absent from category_skill`);
|
|
}
|
|
// probe each rule via a synthetic URL and assert no skill/category divergence
|
|
const probes = [
|
|
['https://learn.microsoft.com/azure/ai-foundry/x', 'azure-ai-services'],
|
|
['https://learn.microsoft.com/azure/machine-learning/x', 'mlops-genaiops'],
|
|
['https://learn.microsoft.com/azure/search/x', 'rag-architecture'],
|
|
['https://learn.microsoft.com/azure/azure-monitor/x', 'monitoring-observability'],
|
|
['https://learn.microsoft.com/azure/well-architected/x', 'architecture'],
|
|
['https://learn.microsoft.com/microsoft-copilot-studio/x', 'copilot-extensibility'],
|
|
['https://learn.microsoft.com/purview/x', 'responsible-ai'],
|
|
['https://learn.microsoft.com/agent-framework/x', 'agent-orchestration'],
|
|
['https://learn.microsoft.com/azure/cosmos-db/x', 'data-engineering'],
|
|
];
|
|
for (const [url, expectedCat] of probes) {
|
|
const r = classify(url);
|
|
assert.ok(r, `no classification for ${url}`);
|
|
assert.equal(r.category, expectedCat);
|
|
assert.equal(r.skill, getCategorySkill(tax, r.category), `divergence at ${url}`);
|
|
}
|
|
});
|
|
|
|
// --- (d) getFilePriority — reproduces report-changes ordering exactly ---
|
|
test('getFilePriority — pattern → priority, first match wins', () => {
|
|
const prio = makePriorityFn(tax);
|
|
assert.equal(prio('skills/ms-ai-security/references/cost-optimization/budget.md'), 'critical');
|
|
assert.equal(prio('skills/ms-ai-governance/references/responsible-ai/fairness.md'), 'high');
|
|
// 'governance' substring → high
|
|
assert.equal(prio('skills/ms-ai-governance/references/norwegian-public-sector-governance/x.md'), 'high');
|
|
assert.equal(prio('skills/ms-ai-advisor/references/platforms/copilot-studio.md'), 'medium');
|
|
// no pattern → low
|
|
assert.equal(prio('skills/ms-ai-engineering/references/data-engineering/cosmos.md'), 'low');
|
|
});
|
|
|
|
test('getFilePriority — order: cost (critical) outranks governance (high)', () => {
|
|
const prio = makePriorityFn(tax);
|
|
// contains both 'cost' and 'governance' — critical rule is evaluated first
|
|
assert.equal(prio('skills/x/references/cost-governance-tradeoffs.md'), 'critical');
|
|
});
|
|
|
|
// --- (e) taxonomy persistence (Sesjon 19, B3 merge-apply) ---
|
|
// merge-apply repoints every category owned by the absorbed skill to the
|
|
// absorber and must PERSIST that to domain-taxonomy.json. Two new primitives:
|
|
// a pure reassign helper + an atomic save mirroring saveDecisions.
|
|
|
|
test('applyCategoryReassignments — repoints only matching from->to; pure (no mutation)', () => {
|
|
const before = JSON.parse(JSON.stringify(tax.category_skill));
|
|
const next = applyCategoryReassignments(tax, [
|
|
{ category: 'rag-architecture', from: 'ms-ai-engineering', to: 'ms-ai-advisor' }, // matches → repointed
|
|
{ category: 'responsible-ai', from: 'ms-ai-NONMATCH', to: 'ms-ai-advisor' }, // current != from → no-op
|
|
{ category: 'does-not-exist', from: 'whatever', to: 'ms-ai-advisor' }, // absent category → no-op
|
|
]);
|
|
assert.equal(next.category_skill['rag-architecture'], 'ms-ai-advisor', 'matching entry repointed');
|
|
assert.equal(next.category_skill['responsible-ai'], 'ms-ai-governance', 'non-matching from is a no-op');
|
|
assert.equal(next.category_skill['does-not-exist'], undefined, 'absent category stays absent');
|
|
// purity: original object + its nested map are untouched, a fresh object is returned
|
|
assert.deepEqual(tax.category_skill, before);
|
|
assert.notEqual(next, tax);
|
|
assert.notEqual(next.category_skill, tax.category_skill);
|
|
});
|
|
|
|
test('saveTaxonomy + loadTaxonomy — atomic round-trip preserves content', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'tax-save-'));
|
|
try {
|
|
const next = applyCategoryReassignments(tax, [
|
|
{ category: 'rag-architecture', from: 'ms-ai-engineering', to: 'ms-ai-advisor' },
|
|
]);
|
|
saveTaxonomy(next, dir);
|
|
const reloaded = loadTaxonomy(dir);
|
|
assert.equal(getCategorySkill(reloaded, 'rag-architecture'), 'ms-ai-advisor', 'reassignment persisted');
|
|
assert.equal(getCategorySkill(reloaded, 'responsible-ai'), 'ms-ai-governance', 'untouched entries survive');
|
|
assert.equal(reloaded.version, tax.version, 'non-category_skill fields survive the round-trip');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|