From 5ad4ed025ccd53b327382c5d74db9c44846bb375 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 20 Jun 2026 11:34:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(ms-ai-architect):=20Sesjon=2014=20?= =?UTF-8?q?=E2=80=94=20B1=20fullf=C3=B8rt=20(coverage/gap=20+=20bloat/stal?= =?UTF-8?q?e)=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utvider scripts/kb-eval/detect-skill-lifecycle.mjs med to deterministiske detektorer (B1 nå komplett, 3 seksjoner i skill-lifecycle-report.json). Skriver ALDRI til skills/ — kun rapport; kandidater mater decisions.json + gate (B3). Intern kb-eval-tooling: ingen brukervendt kommando/agent/skill/hook endret. Detektor 2 — coverage/gap (innen-domene): taksonomi category_skill (deklarert eierskap) vs fysisk disk-mappetelling. Klasser gap/thin/orphan/misowned. Empirisk: 21 deklarerte kat, 20 dekket, 1 gap (security-scoring — deklarert men ingen disk-mappe; innhold bor i ai-security-engineering = fantom-rutekat), 2 tynne (development=1, platforms=5). Detektor 3 — bloat/stale (per skill): K3-margin (eval.checkK3 body vs 500) + dateless ref-andel (mangler Last updated:-header = uverifiserbar ferskhet). Disk-only/deterministisk; poll-avledet staleness (change-report) bevisst utenfor. Empirisk: 0 split-kandidater, 1 saner (ms-ai-governance 4/78 dateless). TDD: 8 nye tester (coverage 4, stale-primitiv 1, bloat 3). kb-eval 23->31. Gate: validate 239, kb-update 122, kb-eval 31, kb-integrity 192/192 — grønn. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01REiKFhP4w6xGXXqWKpPCJJ --- scripts/kb-eval/detect-skill-lifecycle.mjs | 248 +++++++++++++++++- .../test-skill-lifecycle-detect.test.mjs | 139 +++++++++- 2 files changed, 379 insertions(+), 8 deletions(-) diff --git a/scripts/kb-eval/detect-skill-lifecycle.mjs b/scripts/kb-eval/detect-skill-lifecycle.mjs index cb049c6..f91dd3a 100644 --- a/scripts/kb-eval/detect-skill-lifecycle.mjs +++ b/scripts/kb-eval/detect-skill-lifecycle.mjs @@ -17,6 +17,15 @@ // The eng<->infra pair (Azure-deployment boundary) is surfaced as focusPair — // the operator-designated first target for B1. // +// Sesjon 14 adds two more detectors to the same report (all deterministic): +// [2] COVERAGE/GAP — taxonomy.category_skill (declared ownership) vs physical +// disk folder counts. Surfaces gap (owned, 0 files), thin (< threshold), +// orphan (undeclared folder), misowned (wrong owner). In-domain only — +// no new domains proposed. +// [3] BLOAT/STALE — per-skill K3-margin (eval.checkK3 bodyLines vs 500) plus +// the dateless reference fraction (refs lacking a "Last updated:" header = +// unverifiable currency). Flags split-/sanitize-candidates. +// // Usage: // node scripts/kb-eval/detect-skill-lifecycle.mjs # human summary // node scripts/kb-eval/detect-skill-lifecycle.mjs --json # machine output @@ -25,21 +34,46 @@ // Zero dependencies. Reuses eval.mjs extractors + kb-update atomic-write. import { readFileSync, readdirSync, existsSync, mkdirSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { join, dirname, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { splitFrontmatter, extractDescription } from './eval.mjs'; +import { splitFrontmatter, extractDescription, checkK3 } from './eval.mjs'; +import { loadTaxonomy } from '../kb-update/lib/taxonomy.mjs'; import { atomicWriteJson } from '../kb-update/lib/atomic-write.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); const SKILLS_DIR = join(PLUGIN_ROOT, 'skills'); const DATA_DIR = join(__dirname, 'data'); +const TAX_DATA_DIR = join(__dirname, '..', 'kb-update', 'data'); const PROMPTS_FILE = join(DATA_DIR, 'k1-trigger-prompts.json'); const OUT_FILE = join(DATA_DIR, 'skill-lifecycle-report.json'); // Operator-designated B1 focus boundary. const FOCUS_PAIR = ['ms-ai-engineering', 'ms-ai-infrastructure']; +// --- S14 detector thresholds (named, documented) ------------------------- +// K3 hard body-length limit (mirrors eval.mjs K3_MAX_BODY_LINES — single source +// of bodyLines is eval.checkK3; this constant only computes the margin). +const K3_MAX_BODY_LINES = 500; +// A declared category with fewer than this many reference files is "thin" +// relative to the ~19-file median across the 20 on-disk categories. +const THIN_REF_COUNT = 10; +// Flag a SKILL.md as a split-candidate when its body is within this many lines +// of the K3 limit (i.e. margin < 50 -> body > 450). +const BLOAT_MARGIN_MIN = 50; +// Flag a skill as a sanitize-candidate when more than this fraction of its +// reference files lack a verifiable "Last updated:" header (unverifiable currency). +const DATELESS_WARN_RATIO = 0.05; + +// Mirrors report-changes.mjs LAST_UPDATED_PATTERNS (kept local: report-changes +// runs main() at import time, so it cannot be imported safely). Currency-header +// forms accepted across the KB. +const LAST_UPDATED_PATTERNS = [ + /\*\*Last updated:\*\*\s*([\d-]+)/i, + /\*\*Sist (?:oppdatert|verifisert):\*\*\s*([\d-]+)/i, + /\*\*Dato:\*\*\s*([\d-]+)/i, +]; + // Function words (no + en) of length >= 3. Tokens < 3 chars are dropped anyway, // so this list only needs the longer connectives. Domain nouns are NOT here — // df-weighting handles common domain vocabulary instead. @@ -155,6 +189,183 @@ export function computeOverlapFromInputs(descriptionsBySkill, promptSet) { }; } +// =========================================================================== +// S14 — Detector 2: coverage/gap (in-domain) — taxonomy vs physical disk +// =========================================================================== + +/** + * Pure core: in-domain coverage. Compares the declared ownership map + * (taxonomy.category_skill) against physical disk folder counts. NO new domains + * are proposed — this only surfaces gaps WITHIN the already-covered domain. + * gap = category declared but 0 files on disk under its owner + * thin = declared + covered, but < thinThreshold files + * orphan = a disk folder whose category is not declared anywhere + * misowned = a disk folder sitting under a skill other than its declared owner + * @param {Record} categorySkill category -> owning skill + * @param {Record>} diskCounts skill -> {category: count} + */ +export function computeCoverageFromInputs(categorySkill, diskCounts, { thinThreshold = THIN_REF_COUNT } = {}) { + const categories = []; + for (const category of Object.keys(categorySkill).sort()) { + const owningSkill = categorySkill[category]; + const onDiskCount = (diskCounts[owningSkill] && diskCounts[owningSkill][category]) || 0; + const covered = onDiskCount > 0; + const thin = covered && onDiskCount < thinThreshold; + const status = !covered ? 'gap' : thin ? 'thin' : 'ok'; + categories.push({ category, owningSkill, onDiskCount, covered, thin, status }); + } + + const orphans = []; + const misowned = []; + for (const skill of Object.keys(diskCounts).sort()) { + for (const category of Object.keys(diskCounts[skill]).sort()) { + const declaredOwner = categorySkill[category]; + if (declaredOwner === undefined) { + orphans.push({ skill, category, onDiskCount: diskCounts[skill][category] }); + } else if (declaredOwner !== skill) { + misowned.push({ skill, category, declaredOwner, onDiskCount: diskCounts[skill][category] }); + } + } + } + + const gaps = categories.filter((c) => c.status === 'gap'); + const thinCats = categories.filter((c) => c.status === 'thin'); + return { + method: + 'in-domain coverage: taxonomy.category_skill (declared ownership) vs physical-disk folder counts. ' + + 'gap = owned but 0 files; thin = owned but < thinThreshold files; ' + + 'orphan = disk folder with no declared owner; misowned = folder under a skill other than its declared owner. ' + + 'No new domains proposed — gaps are within the already-covered domain only.', + thinThreshold, + categories, + gaps, + thin: thinCats, + orphans, + misowned, + summary: { + declaredCategories: categories.length, + covered: categories.filter((c) => c.covered).length, + gaps: gaps.length, + thin: thinCats.length, + orphans: orphans.length, + misowned: misowned.length, + }, + }; +} + +// =========================================================================== +// S14 — Detector 3: bloat/stale — K3-margin + dateless reference fraction +// =========================================================================== + +/** + * Parse a "Last updated:" currency header from leading text. Mirrors + * report-changes.parseLastUpdated: YYYY-MM normalizes to YYYY-MM-01; returns + * null when no recognized header is present (unverifiable currency). + */ +export function extractLastUpdated(text) { + const head = text.slice(0, 500); + for (const re of LAST_UPDATED_PATTERNS) { + const m = head.match(re); + if (m) { + const raw = m[1].trim(); + return raw.length === 7 ? raw + '-01' : raw; + } + } + return null; +} + +/** + * Pure core: per-skill bloat (K3-margin) + stale (dateless reference fraction). + * bloatCandidate = body within bloatMarginMin lines of the K3 limit -> split + * staleCandidate = > datelessWarnRatio of refs lack a "Last updated:" header + * Stale here means "unverifiable currency" (disk-only, deterministic). Genuine + * poll-derived obsolescence is a separate, registry-dependent signal that lives + * in change-report.json — intentionally out of this deterministic detector. + * @param {Record} skillInputs + */ +export function computeBloatFromInputs(skillInputs, { bloatMarginMin = BLOAT_MARGIN_MIN, datelessWarnRatio = DATELESS_WARN_RATIO } = {}) { + const skills = []; + for (const name of Object.keys(skillInputs).sort()) { + const { bodyLines, refTotal, refDateless, datelessFiles = [] } = skillInputs[name]; + const k3Margin = K3_MAX_BODY_LINES - bodyLines; + const bloatCandidate = k3Margin < bloatMarginMin; + const datelessRatio = refTotal > 0 ? Number((refDateless / refTotal).toFixed(4)) : 0; + const staleCandidate = datelessRatio > datelessWarnRatio; + skills.push({ name, bodyLines, k3Margin, bloatCandidate, refTotal, refDateless, datelessRatio, staleCandidate, datelessFiles }); + } + return { + method: + 'bloat = eval.checkK3 bodyLines vs K3 limit (' + K3_MAX_BODY_LINES + '); margin < bloatMarginMin -> split-candidate. ' + + 'stale = fraction of reference files lacking a verifiable "Last updated:" header (unverifiable currency, disk-only). ' + + 'Poll-derived obsolescence (change-report.json) is a complementary registry-dependent signal, not computed here.', + thresholds: { k3MaxBodyLines: K3_MAX_BODY_LINES, bloatMarginMin, datelessWarnRatio }, + skills, + summary: { + bloatCandidates: skills.filter((s) => s.bloatCandidate).map((s) => s.name), + staleCandidates: skills.filter((s) => s.staleCandidate).map((s) => s.name), + }, + }; +} + +/** Recursively count .md files under a directory. */ +function countMarkdown(dir) { + let n = 0; + if (!existsSync(dir)) return n; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) n += countMarkdown(p); + else if (e.isFile() && e.name.endsWith('.md')) n++; + } + return n; +} + +/** Recursively list .md file paths under a directory. */ +function listMarkdown(dir) { + const out = []; + if (!existsSync(dir)) return out; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) out.push(...listMarkdown(p)); + else if (e.isFile() && e.name.endsWith('.md')) out.push(p); + } + return out; +} + +/** Physical disk truth: { skill -> { category -> mdFileCount } } (read-only). */ +export function loadDiskCounts() { + const out = {}; + for (const e of readdirSync(SKILLS_DIR, { withFileTypes: true })) { + if (!e.isDirectory()) continue; + const refDir = join(SKILLS_DIR, e.name, 'references'); + if (!existsSync(refDir)) continue; + const cats = {}; + for (const c of readdirSync(refDir, { withFileTypes: true })) { + if (c.isDirectory()) cats[c.name] = countMarkdown(join(refDir, c.name)); + } + out[e.name] = cats; + } + return out; +} + +/** Per-skill bloat/stale inputs read from disk (read-only). */ +export function loadBloatInputs() { + const out = {}; + for (const e of readdirSync(SKILLS_DIR, { withFileTypes: true })) { + if (!e.isDirectory()) continue; + const skillMd = join(SKILLS_DIR, e.name, 'SKILL.md'); + if (!existsSync(skillMd)) continue; + const { body } = splitFrontmatter(readFileSync(skillMd, 'utf8')); + const bodyLines = checkK3(body).bodyLines; + const refFiles = listMarkdown(join(SKILLS_DIR, e.name, 'references')); + const datelessFiles = []; + for (const f of refFiles) { + if (extractLastUpdated(readFileSync(f, 'utf8')) === null) datelessFiles.push(relative(PLUGIN_ROOT, f)); + } + out[e.name] = { bodyLines, refTotal: refFiles.length, refDateless: datelessFiles.length, datelessFiles }; + } + return out; +} + /** Read the five SKILL.md descriptions from disk. */ function loadDescriptions() { const out = {}; @@ -170,11 +381,14 @@ function loadDescriptions() { function buildReport() { const promptSet = JSON.parse(readFileSync(PROMPTS_FILE, 'utf8')); const descriptions = loadDescriptions(); + const taxonomy = loadTaxonomy(TAX_DATA_DIR); return { rubric: 'skill-lifecycle', phase: 'B1', note: 'Detection only — never writes to skills/. Candidates feed decisions.json + operator-gate (B3).', overlap: computeOverlapFromInputs(descriptions, promptSet), + coverage: computeCoverageFromInputs(taxonomy.category_skill, loadDiskCounts()), + bloat: computeBloatFromInputs(loadBloatInputs()), }; } @@ -195,15 +409,39 @@ function main() { } const o = report.overlap; - console.log(`\nSkill-livssyklus — B1 overlap-detektor (${o.pairs.length} par)\n`); - console.log('Par (sortert på combined = grensetension + df-vektet leksikalsk):\n'); + console.log(`\nSkill-livssyklus — B1 deteksjon (overlap · coverage · bloat)\n`); + console.log(`[1] Overlap — ${o.pairs.length} par (sortert på combined = grensetension + df-vektet leksikalsk):\n`); for (const p of o.pairs) { const lex = p.lexical.shared.length ? p.lexical.shared.join(', ') : '—'; const focus = p.key === pairKey(...FOCUS_PAIR) ? ' ◀ FOCUS (Azure-deployment)' : ''; console.log(` ${p.combined.toFixed(2).padStart(6)} ${p.pair.join(' / ')}${focus}`); console.log(` tension=${p.boundaryTension} lex(w=${p.lexical.weightedScore}, jac=${p.lexical.jaccard}) delt: ${lex}`); } - console.log(`\nFocus-par: ${o.focusPair ? o.focusPair.pair.join(' <-> ') : '(ingen)'} — ${o.focusPairReason}`); + console.log(`\n Focus-par: ${o.focusPair ? o.focusPair.pair.join(' <-> ') : '(ingen)'} — ${o.focusPairReason}`); + + const c = report.coverage; + console.log(`\n[2] Coverage/gap — ${c.summary.declaredCategories} deklarerte kategorier (terskel tynn < ${c.thinThreshold}):`); + console.log(` dekket=${c.summary.covered} gap=${c.summary.gaps} tynn=${c.summary.thin} orphan=${c.summary.orphans} misowned=${c.summary.misowned}`); + if (c.gaps.length) { + console.log(' GAP (eid, 0 filer på disk):'); + for (const g of c.gaps) console.log(` • ${g.category} → ${g.owningSkill}`); + } + if (c.thin.length) { + console.log(' TYNN (eid, men få filer):'); + for (const t of c.thin) console.log(` • ${t.category} → ${t.owningSkill} (${t.onDiskCount})`); + } + for (const x of c.orphans) console.log(` ⚠ orphan: ${x.skill}/${x.category} (${x.onDiskCount}) — ikke deklarert`); + for (const x of c.misowned) console.log(` ⚠ misowned: ${x.skill}/${x.category} — deklarert eier=${x.declaredOwner}`); + + const b = report.bloat; + console.log(`\n[3] Bloat/stale — per skill (K3-grense ${b.thresholds.k3MaxBodyLines}; split-kandidat margin < ${b.thresholds.bloatMarginMin}; saner-kandidat dateless > ${b.thresholds.datelessWarnRatio}):`); + for (const s of b.skills) { + const flags = [s.bloatCandidate ? 'SPLIT' : '', s.staleCandidate ? 'SANER' : ''].filter(Boolean).join('+') || 'ok'; + console.log(` ${s.name.padEnd(22)} body=${String(s.bodyLines).padStart(3)} (margin ${String(s.k3Margin).padStart(3)}) dateless=${s.refDateless}/${s.refTotal} (${s.datelessRatio}) [${flags}]`); + } + if (b.summary.bloatCandidates.length) console.log(` Split-kandidater: ${b.summary.bloatCandidates.join(', ')}`); + if (b.summary.staleCandidates.length) console.log(` Saner-kandidater: ${b.summary.staleCandidates.join(', ')}`); + console.log('\n(Rapport skrives med --write til data/skill-lifecycle-report.json; aldri til skills/.)\n'); } diff --git a/tests/kb-eval/test-skill-lifecycle-detect.test.mjs b/tests/kb-eval/test-skill-lifecycle-detect.test.mjs index 19b196a..b1d8f8d 100644 --- a/tests/kb-eval/test-skill-lifecycle-detect.test.mjs +++ b/tests/kb-eval/test-skill-lifecycle-detect.test.mjs @@ -1,10 +1,12 @@ -// test-skill-lifecycle-detect.test.mjs — Spor B / B1 overlap-detektor. +// test-skill-lifecycle-detect.test.mjs — Spor B / B1 detectors. // TDD: written before scripts/kb-eval/detect-skill-lifecycle.mjs exists. // -// The overlap detector is DETERMINISTIC and combines two signals: +// Detector 1 (S13, OVERLAP) is DETERMINISTIC and combines two signals: // (1) operator-curated boundary-tension graph (k1-trigger-prompts.json belongs_to) // (2) df-weighted lexical trigger-surface overlap (down-weights domain-common tokens) -// It must NEVER write to skills/ — it only produces a report. +// Detectors 2+3 (S14): coverage/gap (taxonomy vs disk, in-domain only) and +// bloat/stale (K3-margin per skill + dateless reference-file fraction). +// All detectors must NEVER write to skills/ — they only produce a report. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -20,11 +22,19 @@ import { pairKey, boundaryTensionMatrix, computeOverlapFromInputs, + // S14 — coverage/gap + bloat/stale + computeCoverageFromInputs, + computeBloatFromInputs, + extractLastUpdated, + loadDiskCounts, + loadBloatInputs, } from '../../scripts/kb-eval/detect-skill-lifecycle.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROMPTS_PATH = join(__dirname, '..', '..', 'scripts', 'kb-eval', 'data', 'k1-trigger-prompts.json'); const promptSet = JSON.parse(readFileSync(PROMPTS_PATH, 'utf8')); +const TAX_PATH = join(__dirname, '..', '..', 'scripts', 'kb-update', 'data', 'domain-taxonomy.json'); +const taxonomy = JSON.parse(readFileSync(TAX_PATH, 'utf8')); // --------------------------------------------------------------------------- // tokenize @@ -192,3 +202,126 @@ test('computeOverlapFromInputs: 10 pairs, eng<->infra focusPair, sorted, determi // determinism: identical output across runs assert.deepEqual(r1, r2); }); + +// =========================================================================== +// S14 — Detector 2: coverage/gap (in-domain) — taxonomy vs physical disk +// =========================================================================== +test('computeCoverageFromInputs: gap / thin / ok classification', () => { + const categorySkill = { + 'rag-architecture': 'eng', + development: 'advisor', + 'security-scoring': 'sec', + }; + const diskCounts = { + eng: { 'rag-architecture': 28 }, + advisor: { development: 1 }, + sec: {}, // security-scoring declared but no folder on disk + }; + const r = computeCoverageFromInputs(categorySkill, diskCounts, { thinThreshold: 10 }); + const byCat = Object.fromEntries(r.categories.map((c) => [c.category, c])); + assert.equal(byCat['rag-architecture'].status, 'ok'); + assert.equal(byCat['rag-architecture'].covered, true); + assert.equal(byCat.development.status, 'thin'); + assert.equal(byCat.development.covered, true); + assert.equal(byCat['security-scoring'].status, 'gap'); + assert.equal(byCat['security-scoring'].covered, false); + assert.equal(byCat['security-scoring'].onDiskCount, 0); + assert.deepEqual(r.gaps.map((g) => g.category), ['security-scoring']); + assert.deepEqual(r.thin.map((t) => t.category), ['development']); +}); + +test('computeCoverageFromInputs: detects orphan + misowned disk folders', () => { + const categorySkill = { 'rag-architecture': 'eng' }; + const diskCounts = { + eng: { 'rag-architecture': 28, 'mystery-folder': 3 }, // mystery -> orphan (undeclared) + advisor: { 'rag-architecture': 4 }, // declared owner is eng -> misowned + }; + const r = computeCoverageFromInputs(categorySkill, diskCounts, { thinThreshold: 10 }); + assert.deepEqual(r.orphans, [{ skill: 'eng', category: 'mystery-folder', onDiskCount: 3 }]); + assert.deepEqual(r.misowned, [ + { skill: 'advisor', category: 'rag-architecture', declaredOwner: 'eng', onDiskCount: 4 }, + ]); +}); + +test('computeCoverageFromInputs: deterministic, sorted, summary counts', () => { + const categorySkill = { b: 'x', a: 'x', c: 'y' }; + const diskCounts = { x: { a: 20, b: 0 }, y: { c: 2 } }; + const r1 = computeCoverageFromInputs(categorySkill, diskCounts, { thinThreshold: 10 }); + const r2 = computeCoverageFromInputs(categorySkill, diskCounts, { thinThreshold: 10 }); + assert.deepEqual(r1, r2); + assert.deepEqual(r1.categories.map((c) => c.category), ['a', 'b', 'c']); // sorted + assert.equal(r1.summary.declaredCategories, 3); + assert.equal(r1.summary.gaps, 1); // b: 0 files + assert.equal(r1.summary.thin, 1); // c: 2 files + assert.equal(r1.summary.covered, 2); // a, c +}); + +test('coverage on real taxonomy + disk: declared-count matches, security-scoring gap, development thin', () => { + const disk = loadDiskCounts(); + const r = computeCoverageFromInputs(taxonomy.category_skill, disk); + assert.equal(r.summary.declaredCategories, Object.keys(taxonomy.category_skill).length); + // security-scoring is declared (ms-ai-security) but has no folder on disk -> gap + assert.ok(r.gaps.some((g) => g.category === 'security-scoring'), 'security-scoring gap surfaced'); + // development has a single reference file -> thin + assert.ok(r.thin.some((t) => t.category === 'development'), 'development thin surfaced'); +}); + +// =========================================================================== +// S14 — Detector 3: bloat/stale — K3-margin + dateless reference fraction +// =========================================================================== +test('extractLastUpdated: parses header variants, normalizes YYYY-MM, null when absent', () => { + assert.equal(extractLastUpdated('# Title\n\n**Last updated:** 2026-03-14\n'), '2026-03-14'); + assert.equal(extractLastUpdated('**Sist oppdatert:** 2025-11\n'), '2025-11-01'); // YYYY-MM -> -01 + assert.equal(extractLastUpdated('**Sist verifisert:** 2026-01-09\n'), '2026-01-09'); + assert.equal(extractLastUpdated('**Dato:** 2024-12-31\n'), '2024-12-31'); + assert.equal(extractLastUpdated('# No header here\nbody text'), null); +}); + +test('computeBloatFromInputs: margins, ratios, candidate flags', () => { + const inputs = { + big: { bodyLines: 470, refTotal: 10, refDateless: 0, datelessFiles: [] }, + stale: { bodyLines: 100, refTotal: 20, refDateless: 4, datelessFiles: ['a.md', 'b.md', 'c.md', 'd.md'] }, + healthy: { bodyLines: 200, refTotal: 50, refDateless: 0, datelessFiles: [] }, + }; + const r = computeBloatFromInputs(inputs, { bloatMarginMin: 50, datelessWarnRatio: 0.05 }); + const by = Object.fromEntries(r.skills.map((s) => [s.name, s])); + assert.equal(by.big.k3Margin, 30); // 500 - 470 + assert.equal(by.big.bloatCandidate, true); // margin 30 < 50 + assert.equal(by.big.staleCandidate, false); + assert.equal(by.stale.datelessRatio, 0.2); // 4 / 20 + assert.equal(by.stale.staleCandidate, true); // 0.2 > 0.05 + assert.equal(by.stale.bloatCandidate, false); // margin 400 + assert.deepEqual(by.stale.datelessFiles, ['a.md', 'b.md', 'c.md', 'd.md']); + assert.equal(by.healthy.bloatCandidate, false); + assert.equal(by.healthy.staleCandidate, false); + assert.deepEqual(r.summary.bloatCandidates, ['big']); + assert.deepEqual(r.summary.staleCandidates, ['stale']); +}); + +test('computeBloatFromInputs: deterministic, sorted by name, no div-by-zero', () => { + const inputs = { + z: { bodyLines: 10, refTotal: 0, refDateless: 0, datelessFiles: [] }, + a: { bodyLines: 10, refTotal: 0, refDateless: 0, datelessFiles: [] }, + }; + const r1 = computeBloatFromInputs(inputs); + const r2 = computeBloatFromInputs(inputs); + assert.deepEqual(r1, r2); + assert.deepEqual(r1.skills.map((s) => s.name), ['a', 'z']); // sorted + assert.equal(r1.skills[0].datelessRatio, 0); // refTotal 0 -> ratio 0, not NaN +}); + +test('bloat on real disk: 5 skills, ratios in [0,1], deterministic, dateless count matches file list', () => { + const inputs = loadBloatInputs(); + const r1 = computeBloatFromInputs(inputs); + const r2 = computeBloatFromInputs(inputs); + assert.deepEqual(r1, r2); + assert.deepEqual( + r1.skills.map((s) => s.name).sort(), + ['ms-ai-advisor', 'ms-ai-engineering', 'ms-ai-governance', 'ms-ai-infrastructure', 'ms-ai-security'], + ); + for (const s of r1.skills) { + assert.ok(s.bodyLines > 0, `${s.name} has body lines`); + assert.ok(s.datelessRatio >= 0 && s.datelessRatio <= 1, `${s.name} ratio in range`); + assert.equal(s.refDateless, s.datelessFiles.length, `${s.name} dateless count matches list`); + } +});