ms-ai-architect/scripts/kb-update/lib/taxonomy.mjs
Kjell Tore Guttormsen e47fc9bd59 feat(ms-ai-architect): Sesjon 20 — B3 create_skill (dvelende, konstruktiv op) [skip-docs]
B3 + Spor B (krav 5+6) KOMPLETT. create_skill er speilbildet av de
destruktive ops: merge/saner/retire beviser "ingen kuratert verdi TAPT";
create beviser "ingen sub-bar skill FØDT" (kvalitetsgulv). Ingen ny
domene-skill shippet (svar 3) — mekanismen bevist, ikke brukt.

- planCreateSkill (ren): genererer scaffold + selv-validerer mot eval.mjs
  sine EGNE checkers (K2/K3/K5/K6/refTall + K10) — rubrikken har én kilde.
  Guardrail ok iff alle deterministiske K passerer + K10<terskel + navn ledig.
- TEST_DOMAIN_SPEC: kanonisk dvelende-scaffold (born compliant, K5 ratio 1.0).
- runCreatePlan (uren): gated dry-run, 0 skriving til skills/.
- applyApprovedAction create-gren: re-plan fersk → revalider (drift+kollisjon)
  → skriv scaffold + persister taksonomi-tillegg → flipp ledger.
- applyCategoryAdditions (taxonomy.mjs): additiv, klobrer aldri eksisterende eier.
- CLI create-verb i plan-skill-op + apply-skill-op.

TDD: ny test-skill-ops-create.test.mjs (16). kb-eval 84→100.
Suiter uendret: validate 239 · kb-update 139 · kb-integrity 192/192.
Deterministisk eval K1–K10 for de 5 ekte: 0 regresjon. 0 ekte skills/-mutasjon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:37:19 +02:00

148 lines
5.7 KiB
JavaScript

// taxonomy.mjs — Loader + accessors for data/domain-taxonomy.json (lag 0).
// Zero dependencies. The taxonomy is the single source of truth for in-domain
// classification: which sitemaps to poll, which URLs are relevant, which
// skill/category owns content, and update priority. poll-sitemaps,
// discover-new-urls and report-changes READ this instead of embedding copies.
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_DATA_DIR = join(__dirname, '..', 'data');
/**
* Load the domain taxonomy from disk.
* @param {string} [dataDir] — defaults to ../data/ relative to lib/
* @returns {object} parsed taxonomy
*/
export function loadTaxonomy(dataDir = DEFAULT_DATA_DIR) {
const path = join(dataDir, 'domain-taxonomy.json');
return JSON.parse(readFileSync(path, 'utf8'));
}
/**
* Save the domain taxonomy atomically (write to .tmp, then rename) — mirrors
* saveDecisions. The taxonomy is lag-0 truth and is normally read-only; the ONE
* writer is the gated merge-apply path (Sesjon 19), which repoints category
* ownership from an absorbed skill to its absorber. Detection scripts must NOT
* import this.
* @param {object} tax — taxonomy object to persist
* @param {string} [dataDir]
*/
export function saveTaxonomy(tax, dataDir = DEFAULT_DATA_DIR) {
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
const path = join(dataDir, 'domain-taxonomy.json');
const tmp = path + '.tmp';
writeFileSync(tmp, JSON.stringify(tax, null, 2) + '\n', 'utf8');
renameSync(tmp, path);
}
/**
* Repoint category ownership for a skill merge. Pure — returns a new taxonomy,
* mutates nothing. For each { category, from, to }, the category's owner is
* changed to `to` ONLY when it currently equals `from` (defensive: a category
* already repointed, or absent, is a no-op — never clobbered). All other fields
* survive untouched.
* @param {object} tax
* @param {Array<{category: string, from: string, to: string}>} reassignments
* @returns {object} new taxonomy
*/
export function applyCategoryReassignments(tax, reassignments) {
const next = { ...tax.category_skill };
for (const { category, from, to } of reassignments) {
if (next[category] === from) next[category] = to;
}
return { ...tax, category_skill: next };
}
/**
* Add category ownership for a NEW skill (create_skill, Sesjon 20). Pure —
* returns a new taxonomy, mutates nothing. For each { category, skill }, the
* category is assigned to `skill` ONLY when it is currently UNOWNED (absent):
* an existing owner is never clobbered (a create must not steal a sibling's
* category — that would be a merge, a different gated op). Additive counterpart
* to applyCategoryReassignments: reassign repoints an owned category, add claims
* an unowned one. The same gated apply-path is the only writer.
* @param {object} tax
* @param {Array<{category: string, skill: string}>} additions
* @returns {object} new taxonomy
*/
export function applyCategoryAdditions(tax, additions) {
const next = { ...tax.category_skill };
for (const { category, skill } of additions) {
if (next[category] === undefined) next[category] = skill;
}
return { ...tax, category_skill: next };
}
/**
* Target sitemap child-name prefixes to scan/poll.
* @param {object} tax
* @returns {string[]}
*/
export function getSitemapPrefixes(tax) {
return tax.sitemap_prefixes;
}
/**
* Canonical owning skill for a KB category (physical-disk truth).
* @param {object} tax
* @param {string} category
* @returns {string|null} skill directory name, or null if unknown
*/
export function getCategorySkill(tax, category) {
return tax.category_skill[category] ?? null;
}
/**
* Build a URL classifier mirroring discover-new-urls semantics:
* exclude wins; first include match → { skill, category } with skill DERIVED
* from the canonical category_skill map (no embedded skill assignments).
* @param {object} tax
* @returns {(url: string) => ({skill: string|null, category: string}|null)}
*/
export function makeClassifier(tax) {
const include = tax.relevance.include.map((r) => ({
re: new RegExp(r.pattern),
category: r.category,
}));
const exclude = tax.relevance.exclude.map((p) => new RegExp(p));
return function classifyUrl(url) {
if (exclude.some((re) => re.test(url))) return null;
for (const rule of include) {
if (rule.re.test(url)) {
return { skill: tax.category_skill[rule.category] ?? null, category: rule.category };
}
}
return null;
};
}
/**
* Build a file-priority function mirroring report-changes getFilePriority:
* ordered regex rules over the lowercased path; first match wins; else default.
*
* A file with NO "Last updated:" header (hasDate=false) cannot have its currency
* verified, so it lands in the dedicated `dateless` tier ('unverified') instead
* of inheriting a path-based priority — otherwise dateless files flood the
* critical bucket and drown the genuinely-stale critical files (carry-forward B).
* hasDate defaults to true so existing single-arg callers keep path-based scoring.
* @param {object} tax
* @returns {(filePath: string, hasDate?: boolean) => string}
*/
export function makePriorityFn(tax) {
const rules = tax.priority.rules.map((r) => ({ re: new RegExp(r.match), priority: r.priority }));
const fallback = tax.priority.default;
const datelessTier = tax.priority.dateless ?? fallback;
return function getFilePriority(filePath, hasDate = true) {
if (!hasDate) return datelessTier;
const lower = filePath.toLowerCase();
for (const r of rules) {
if (r.re.test(lower)) return r.priority;
}
return fallback;
};
}