// 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 course classifier for the Learn Platform API course-detection spore * (C3). course_products maps an in-domain product slug → category; the owning * skill is DERIVED from category_skill (never stored redundantly), exactly as * makeClassifier derives skill for URL classification — so the slug→skill path * cannot diverge from the canonical category ownership. The FIRST in-domain * product in the list wins; a course whose products are all out-of-domain (no * slug in course_products) returns null and is therefore not a lead. * @param {object} tax * @returns {(products: string[]) => ({skill: string|null, category: string}|null)} */ export function makeCourseClassifier(tax) { const map = tax.course_products ?? {}; return function classifyCourse(products) { if (!Array.isArray(products)) return null; for (const slug of products) { const category = map[slug]; if (category) { return { skill: tax.category_skill[category] ?? null, 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; }; }