feat(ms-ai-architect): Spor 3 Port 3 (P3d) — periodisk full-pass worklist (kadens-judge enumerering) [skip-docs]
Ren lib full-pass-worklist.mjs (classifyFullPassDue + buildFullPassReport, skriver aldri til disk) + CLI report-full-pass-worklist.mjs (gitignored full-pass-worklist.json). Lastmod-UAVHENGIG: gater paa Port 1-kontrakt (type:reference + source) + cadence-alder paa Verified, fanger drift-klassen inkrementell bommer (staleness-recall 0/40). Buckets: never-verified / cadence-due / fresh / unsourced / unmigrated / out-of-scope. Arbeidsliste (operatoer-valgt design), IKKE inline judge: enumererer + rangerer, operatoer kickstarter judging separat (par.5 ikke auto-lansert, par.4c aldri auto-fiks). Ekte kjoering: 389 ref-filer alle unmigrated = dormant til Spor 1-migrering. Suite 637/637.
This commit is contained in:
parent
3d6fab12f7
commit
a1d72fadc0
3 changed files with 485 additions and 0 deletions
179
scripts/kb-update/lib/full-pass-worklist.mjs
Normal file
179
scripts/kb-update/lib/full-pass-worklist.mjs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
// full-pass-worklist.mjs — Spor 3 Port 3, PERIODIC full-pass (P3d). A PURE
|
||||||
|
// worklist generator: it computes which sourced `reference` files are DUE for a
|
||||||
|
// full-pass re-judge by cadence, and NEVER touches disk (mirrors verified-
|
||||||
|
// staleness.mjs — the I/O lives in the CLI).
|
||||||
|
//
|
||||||
|
// Why a full-pass on top of the incremental pass: the incremental pass only
|
||||||
|
// re-judges files whose source sitemap_lastmod moved past **Verified:**, so it
|
||||||
|
// only catches lastmod-VISIBLE drift. MS can change a page without bumping its
|
||||||
|
// sitemap lastmod, and the base-rate measurement showed staleness-recall 0/40.
|
||||||
|
// The full-pass is therefore deliberately LASTMOD-INDEPENDENT: it gates on the
|
||||||
|
// Port 1 contract (type:reference + a Source URL to anchor against) and on how
|
||||||
|
// long since the **Verified:** stamp, NOT on lastmod. buildFileSourceMap (which
|
||||||
|
// skips sources lacking a lastmod) is reused only to ANNOTATE entries.
|
||||||
|
//
|
||||||
|
// Operator-chosen design (2026-06-29): a WORKLIST, not an inline judge. This
|
||||||
|
// produces the ranked list of files due for re-judge; the operator kicks off
|
||||||
|
// the actual judging separately. Rationale: §5 "ikke auto-lansert", §4c "aldri
|
||||||
|
// auto-fiks", and the honest ~2700-fetch/multi-session full-pass cost forecloses
|
||||||
|
// an in-session run. Flag → human-confirm → fix → bump **Verified:** stays the
|
||||||
|
// loop; this file only enumerates and ranks.
|
||||||
|
//
|
||||||
|
// Scope = the Port 1 frontmatter contract (same as the incremental pass):
|
||||||
|
// - type 'reference' WITH a source → in full-pass scope (judgeable)
|
||||||
|
// - type 'reference' WITHOUT a source → unsourced (contract breach; no
|
||||||
|
// anchor → not listed, counted)
|
||||||
|
// - type template|methodology|regulatory → out-of-scope (exempt)
|
||||||
|
// - no Type header (legacy corpus) → unmigrated (Spor 1 territory)
|
||||||
|
|
||||||
|
const REFERENCE_TYPE = 'reference';
|
||||||
|
const OUT_OF_SCOPE_TYPES = new Set(['template', 'methodology', 'regulatory']);
|
||||||
|
|
||||||
|
/** YYYY-MM → YYYY-MM-01; full date as-is. Mirrors verified-staleness' normalizeDate. */
|
||||||
|
function normalizeDate(d) {
|
||||||
|
if (!d) return null;
|
||||||
|
return d.length === 7 ? d + '-01' : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a normalized YYYY-MM-DD into a UTC epoch (deterministic — explicit input). */
|
||||||
|
function toUTC(yyyymmdd) {
|
||||||
|
const [y, m, day] = yyyymmdd.split('-').map(Number);
|
||||||
|
return Date.UTC(y, m - 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
/** today − n days as YYYY-MM-DD (the cadence threshold; verified before it is due). */
|
||||||
|
function subtractDays(today, n) {
|
||||||
|
return new Date(toUTC(today) - n * DAY_MS).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole days from `earlier` to `later` (both YYYY-MM-DD). */
|
||||||
|
function diffDays(later, earlier) {
|
||||||
|
return Math.round((toUTC(later) - toUTC(earlier)) / DAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const REASON_ORDER = { 'never-verified': 0, 'cadence-due': 1 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify reference files for full-pass due-ness by verification cadence.
|
||||||
|
*
|
||||||
|
* @param {Array<{path: string, type: string|null, source: string|null,
|
||||||
|
* verified: string|null, newestSourceLastmod?: string|null}>} files
|
||||||
|
* @param {{today: string, maxAgeDays: number}} opts — today as YYYY-MM-DD
|
||||||
|
* (injected; Date.now is impure), maxAgeDays = cadence window in days
|
||||||
|
* @returns {{worklist: Array<{path,reason,verified,ageDays,source,newestSourceLastmod}>,
|
||||||
|
* counts: {total,due,fresh,neverVerified,cadenceDue,unsourced,unmigrated,outOfScope}}}
|
||||||
|
*/
|
||||||
|
export function classifyFullPassDue(files, { today, maxAgeDays }) {
|
||||||
|
const list = Array.isArray(files) ? files : [];
|
||||||
|
const threshold = subtractDays(today, maxAgeDays);
|
||||||
|
const worklist = [];
|
||||||
|
const counts = {
|
||||||
|
total: list.length, due: 0, fresh: 0,
|
||||||
|
neverVerified: 0, cadenceDue: 0, unsourced: 0, unmigrated: 0, outOfScope: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const file of list) {
|
||||||
|
const type = file.type == null ? null : String(file.type).toLowerCase();
|
||||||
|
|
||||||
|
// Out of correctness scope — template/methodology/regulatory are exempt.
|
||||||
|
if (type && OUT_OF_SCOPE_TYPES.has(type)) {
|
||||||
|
counts.outOfScope++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Type header — legacy file predating the Port 1 contract (Spor 1 migrates it).
|
||||||
|
if (type !== REFERENCE_TYPE) {
|
||||||
|
counts.unmigrated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared reference but no Source URL → nothing to anchor a full-pass against.
|
||||||
|
// A create-guard (Port 2) breach; counted so the gap is an honest number.
|
||||||
|
if (!file.source) {
|
||||||
|
counts.unsourced++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
path: file.path,
|
||||||
|
verified: file.verified ?? null,
|
||||||
|
source: file.source,
|
||||||
|
newestSourceLastmod: file.newestSourceLastmod ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Never verified → highest priority (sourced but never confirmed against source).
|
||||||
|
if (file.verified == null) {
|
||||||
|
counts.neverVerified++;
|
||||||
|
counts.due++;
|
||||||
|
worklist.push({ ...entry, reason: 'never-verified', ageDays: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cadence-due: last verified strictly before the threshold (today − maxAgeDays).
|
||||||
|
// Strict < so a file verified exactly maxAgeDays ago is still fresh — same
|
||||||
|
// boundary convention as the incremental pass.
|
||||||
|
const verifiedNorm = normalizeDate(file.verified);
|
||||||
|
if (verifiedNorm < threshold) {
|
||||||
|
counts.cadenceDue++;
|
||||||
|
counts.due++;
|
||||||
|
worklist.push({ ...entry, reason: 'cadence-due', ageDays: diffDays(today, verifiedNorm) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
counts.fresh++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// never-verified first, then cadence-due by oldest verification (highest drift risk).
|
||||||
|
worklist.sort((a, b) => {
|
||||||
|
const r = (REASON_ORDER[a.reason] ?? 9) - (REASON_ORDER[b.reason] ?? 9);
|
||||||
|
if (r !== 0) return r;
|
||||||
|
const va = normalizeDate(a.verified) ?? '';
|
||||||
|
const vb = normalizeDate(b.verified) ?? '';
|
||||||
|
if (va !== vb) return va.localeCompare(vb); // oldest verified first
|
||||||
|
return String(a.path).localeCompare(String(b.path)); // stable tiebreak
|
||||||
|
});
|
||||||
|
|
||||||
|
return { worklist, counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the full-pass worklist report. PURE: the caller injects the corpus
|
||||||
|
* paths, a header reader, and the registry source map (annotation only). The CLI
|
||||||
|
* wraps this with a corpus walk + a readFileSync-backed reader + saveReport.
|
||||||
|
*
|
||||||
|
* @param {string[]} paths — corpus reference-file paths (PLUGIN_ROOT-relative)
|
||||||
|
* @param {(path: string) => ({type, source, verified}|null)} headerReader
|
||||||
|
* returns the file's Port 1 headers, or null if the file is absent (deleted) —
|
||||||
|
* absent files are skipped entirely, not counted.
|
||||||
|
* @param {Map<string,string>} sourceMap — refFile → newest sitemap_lastmod
|
||||||
|
* (from buildFileSourceMap; annotation only, never gates due-ness)
|
||||||
|
* @param {{today: string, maxAgeDays: number}} opts
|
||||||
|
* @returns {{generated_at, cadence_max_age_days, scope, counts, worklist}}
|
||||||
|
*/
|
||||||
|
export function buildFullPassReport(paths, headerReader, sourceMap, { today, maxAgeDays }) {
|
||||||
|
const map = sourceMap instanceof Map ? sourceMap : new Map();
|
||||||
|
const files = [];
|
||||||
|
for (const path of paths) {
|
||||||
|
const headers = headerReader(path);
|
||||||
|
if (!headers) continue; // deleted on disk — registry hygiene, not a due signal
|
||||||
|
files.push({
|
||||||
|
path,
|
||||||
|
type: headers.type ?? null,
|
||||||
|
source: headers.source ?? null,
|
||||||
|
verified: headers.verified ?? null,
|
||||||
|
newestSourceLastmod: map.get(path) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { worklist, counts } = classifyFullPassDue(files, { today, maxAgeDays });
|
||||||
|
|
||||||
|
return {
|
||||||
|
generated_at: today,
|
||||||
|
cadence_max_age_days: maxAgeDays,
|
||||||
|
scope: 'full-pass (sourced reference files due by verification cadence)',
|
||||||
|
counts,
|
||||||
|
worklist,
|
||||||
|
};
|
||||||
|
}
|
||||||
109
scripts/kb-update/report-full-pass-worklist.mjs
Normal file
109
scripts/kb-update/report-full-pass-worklist.mjs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// report-full-pass-worklist.mjs — Spor 3 Port 3, PERIODIC full-pass (P3d).
|
||||||
|
// Enumerates the sourced `reference` files DUE for a cadence full-pass re-judge
|
||||||
|
// and writes a ranked worklist. Unlike the incremental pass (which keys off
|
||||||
|
// sitemap_lastmod), the full-pass is lastmod-INDEPENDENT: it gates on the Port 1
|
||||||
|
// contract (type:reference + a Source URL) and on how long since **Verified:**,
|
||||||
|
// catching the drift class the incremental pass misses (staleness-recall 0/40).
|
||||||
|
//
|
||||||
|
// Operator-chosen design (2026-06-29): a WORKLIST, not an inline judge. This
|
||||||
|
// script ONLY enumerates and ranks; the operator kicks off the actual judging
|
||||||
|
// separately (§5 "ikke auto-lansert", §4c "aldri auto-fiks", and the honest
|
||||||
|
// ~2700-fetch/multi-session full-pass cost forecloses an in-session run). Run on
|
||||||
|
// the KB-refresh cadence, NOT on SessionStart. All logic lives in
|
||||||
|
// lib/full-pass-worklist.mjs (pure, unit-tested); this file is I/O glue
|
||||||
|
// mirroring report-verified-staleness.mjs.
|
||||||
|
//
|
||||||
|
// Usage: node report-full-pass-worklist.mjs [--max-age-days N] [--json]
|
||||||
|
|
||||||
|
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { loadRegistry, saveReport } from './lib/registry-io.mjs';
|
||||||
|
import { parseTypeHeader, parseVerifiedHeader, parseSourceHeader } from './lib/kb-headers.mjs';
|
||||||
|
import { buildFileSourceMap } from './lib/verified-staleness.mjs';
|
||||||
|
import { buildFullPassReport } from './lib/full-pass-worklist.mjs';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
||||||
|
const DATA_DIR = join(__dirname, 'data');
|
||||||
|
const SKILLS_DIR = join(PLUGIN_ROOT, 'skills');
|
||||||
|
|
||||||
|
const jsonOnly = process.argv.includes('--json');
|
||||||
|
const ageIdx = process.argv.indexOf('--max-age-days');
|
||||||
|
const MAX_AGE_DAYS = ageIdx !== -1 ? Number(process.argv[ageIdx + 1]) : 90; // quarterly default
|
||||||
|
|
||||||
|
if (!Number.isFinite(MAX_AGE_DAYS) || MAX_AGE_DAYS < 0) {
|
||||||
|
console.error('--max-age-days must be a non-negative number.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively collect .md paths under dir, returned PLUGIN_ROOT-relative so they
|
||||||
|
// match the registry's reference_files keys (mirrors eval.mjs' listMarkdown).
|
||||||
|
function listMarkdownRel(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(...listMarkdownRel(p));
|
||||||
|
else if (e.isFile() && e.name.endsWith('.md')) out.push(p.slice(PLUGIN_ROOT.length + 1));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reference corpus = skills/<skill>/references/**/*.md ONLY (not SKILL.md or
|
||||||
|
// other skill files). Walking each skill's references/ dir keeps the count
|
||||||
|
// honest at the 389-file corpus the correctness scope is defined over.
|
||||||
|
function listReferenceCorpus() {
|
||||||
|
const out = [];
|
||||||
|
if (!existsSync(SKILLS_DIR)) return out;
|
||||||
|
for (const skill of readdirSync(SKILLS_DIR, { withFileTypes: true })) {
|
||||||
|
if (!skill.isDirectory()) continue;
|
||||||
|
out.push(...listMarkdownRel(join(SKILLS_DIR, skill.name, 'references')));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a reference file's Port 1 headers, or null if gone on disk (the report
|
||||||
|
// skips it — registry hygiene, not a due signal). Only the top 500 bytes hold
|
||||||
|
// the bold-label header region all three parsers scan.
|
||||||
|
function headerReader(refFile) {
|
||||||
|
const full = join(PLUGIN_ROOT, refFile);
|
||||||
|
if (!existsSync(full)) return null;
|
||||||
|
const head = readFileSync(full, 'utf8').slice(0, 500);
|
||||||
|
return { type: parseTypeHeader(head), source: parseSourceHeader(head), verified: parseVerifiedHeader(head) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = loadRegistry(DATA_DIR);
|
||||||
|
const sourceMap = buildFileSourceMap(registry); // annotation only — never gates due-ness
|
||||||
|
const paths = listReferenceCorpus();
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const report = buildFullPassReport(paths, headerReader, sourceMap, { today, maxAgeDays: MAX_AGE_DAYS });
|
||||||
|
saveReport('full-pass-worklist.json', report, DATA_DIR);
|
||||||
|
|
||||||
|
if (jsonOnly) {
|
||||||
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
||||||
|
} else {
|
||||||
|
const c = report.counts;
|
||||||
|
console.log(`\n=== Full-pass Worklist (${report.generated_at}) — cadence ${MAX_AGE_DAYS}d ===`);
|
||||||
|
console.log(
|
||||||
|
`Reference files: ${c.total} ` +
|
||||||
|
`(due: ${c.due} [never-verified: ${c.neverVerified}, cadence-due: ${c.cadenceDue}], ` +
|
||||||
|
`fresh: ${c.fresh}, unsourced: ${c.unsourced}, unmigrated: ${c.unmigrated}, out-of-scope: ${c.outOfScope})`,
|
||||||
|
);
|
||||||
|
if (report.worklist.length > 0) {
|
||||||
|
console.log(`\nDue for full-pass re-judge (${report.worklist.length}, ranked):`);
|
||||||
|
for (const e of report.worklist.slice(0, 30)) {
|
||||||
|
const age = e.ageDays == null ? 'never verified' : `verified ${e.ageDays}d ago`;
|
||||||
|
console.log(` [${e.reason}] ${e.path}`);
|
||||||
|
console.log(` ${age} source: ${e.source}`);
|
||||||
|
}
|
||||||
|
if (report.worklist.length > 30) console.log(` ... and ${report.worklist.length - 30} more`);
|
||||||
|
console.log('\nNext: kick off the judging separately (cadence run / Voyage). Each flag → human-confirm against source → fix → bump **Verified:** (never auto-fix).');
|
||||||
|
} else if (c.unmigrated > 0 && c.due === 0 && c.fresh === 0) {
|
||||||
|
console.log('\nNo sourced reference files yet — full-pass is dormant until Spor 1 migration backfills Source/Verified.');
|
||||||
|
} else {
|
||||||
|
console.log('\nNo files due for full-pass: every sourced reference file was verified within the cadence window.');
|
||||||
|
}
|
||||||
|
}
|
||||||
197
tests/kb-update/test-full-pass-worklist.test.mjs
Normal file
197
tests/kb-update/test-full-pass-worklist.test.mjs
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
// tests/kb-update/test-full-pass-worklist.test.mjs
|
||||||
|
// Spor 3 Port 3 (kadens-judge) — PERIODIC full-pass WORKLIST (P3d).
|
||||||
|
//
|
||||||
|
// The incremental pass (P3a) only re-judges files whose source sitemap_lastmod
|
||||||
|
// moved past **Verified:** — it catches lastmod-VISIBLE drift. The full-pass
|
||||||
|
// catches the class staleness misses (base-rate showed staleness-recall 0/40):
|
||||||
|
// MS can change a page without bumping its sitemap lastmod. So the full-pass is
|
||||||
|
// deliberately LASTMOD-INDEPENDENT — it gates on the Port 1 frontmatter contract
|
||||||
|
// (type:reference + a Source URL to anchor against) and on cadence age of the
|
||||||
|
// **Verified:** stamp, NOT on lastmod. buildFileSourceMap (which skips sources
|
||||||
|
// without a lastmod) is reused only to ANNOTATE entries, never to gate them.
|
||||||
|
//
|
||||||
|
// classifyFullPassDue is PURE (mirrors classifyVerifiedStaleness): no disk, today
|
||||||
|
// + cadence injected. The operator-chosen design is a WORKLIST (not an inline
|
||||||
|
// judge): this produces the ranked list of files due for re-judge; the operator
|
||||||
|
// kicks off the actual judging separately (§5 "ikke auto-lansert", §4c "aldri
|
||||||
|
// auto-fiks", and the honest ~2700-fetch/multi-session cost forecloses inline).
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
classifyFullPassDue,
|
||||||
|
buildFullPassReport,
|
||||||
|
} from '../../scripts/kb-update/lib/full-pass-worklist.mjs';
|
||||||
|
|
||||||
|
const TODAY = '2026-06-29';
|
||||||
|
const MAX_AGE = 90; // threshold = 2026-03-31 (verified strictly before → cadence-due)
|
||||||
|
|
||||||
|
const f = (over) => ({
|
||||||
|
path: 'skills/x/references/a.md',
|
||||||
|
type: 'reference',
|
||||||
|
source: 'https://learn.microsoft.com/x',
|
||||||
|
verified: '2026-06-01',
|
||||||
|
newestSourceLastmod: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opts = { today: TODAY, maxAgeDays: MAX_AGE };
|
||||||
|
|
||||||
|
// --- classifyFullPassDue: core scope + cadence classification ---
|
||||||
|
|
||||||
|
test('reference + source + never verified → due, reason never-verified', () => {
|
||||||
|
const out = classifyFullPassDue([f({ verified: null })], opts);
|
||||||
|
assert.equal(out.worklist.length, 1);
|
||||||
|
assert.equal(out.worklist[0].reason, 'never-verified');
|
||||||
|
assert.equal(out.worklist[0].ageDays, null);
|
||||||
|
assert.equal(out.counts.neverVerified, 1);
|
||||||
|
assert.equal(out.counts.due, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reference + source + verified OLDER than cadence window → due, reason cadence-due', () => {
|
||||||
|
const out = classifyFullPassDue([f({ verified: '2026-01-01' })], opts);
|
||||||
|
assert.equal(out.worklist.length, 1);
|
||||||
|
assert.equal(out.worklist[0].reason, 'cadence-due');
|
||||||
|
assert.equal(out.worklist[0].ageDays, 179); // 2026-01-01 → 2026-06-29
|
||||||
|
assert.equal(out.counts.cadenceDue, 1);
|
||||||
|
assert.equal(out.counts.due, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reference + source + verified WITHIN cadence window → fresh, not in worklist', () => {
|
||||||
|
const out = classifyFullPassDue([f({ verified: '2026-06-01' })], opts);
|
||||||
|
assert.equal(out.worklist.length, 0);
|
||||||
|
assert.equal(out.counts.fresh, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verified EXACTLY at threshold (today − maxAgeDays) → fresh (strict <, mirrors incremental)', () => {
|
||||||
|
// 2026-06-29 minus 90 days = 2026-03-31
|
||||||
|
const out = classifyFullPassDue([f({ verified: '2026-03-31' })], opts);
|
||||||
|
assert.equal(out.worklist.length, 0);
|
||||||
|
assert.equal(out.counts.fresh, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verified one day before threshold → cadence-due', () => {
|
||||||
|
const out = classifyFullPassDue([f({ verified: '2026-03-30' })], opts);
|
||||||
|
assert.equal(out.worklist.length, 1);
|
||||||
|
assert.equal(out.worklist[0].reason, 'cadence-due');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reference declared but NO source → unsourced (cannot anchor full-pass), counted not listed', () => {
|
||||||
|
const out = classifyFullPassDue([f({ source: null, verified: null })], opts);
|
||||||
|
assert.equal(out.worklist.length, 0);
|
||||||
|
assert.equal(out.counts.unsourced, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('template / methodology / regulatory → out-of-scope, never listed', () => {
|
||||||
|
const out = classifyFullPassDue([
|
||||||
|
f({ type: 'template', source: null, verified: null }),
|
||||||
|
f({ type: 'methodology', source: null, verified: null }),
|
||||||
|
f({ type: 'regulatory', source: null, verified: null }),
|
||||||
|
], opts);
|
||||||
|
assert.equal(out.worklist.length, 0);
|
||||||
|
assert.equal(out.counts.outOfScope, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no Type header (legacy corpus) → unmigrated, counted but not listed (Spor 1 territory)', () => {
|
||||||
|
const out = classifyFullPassDue([f({ type: null, source: null, verified: null })], opts);
|
||||||
|
assert.equal(out.worklist.length, 0);
|
||||||
|
assert.equal(out.counts.unmigrated, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('YYYY-MM verified normalizes to -01 for the cadence boundary', () => {
|
||||||
|
// 2026-03 → 2026-03-01, which is before the 2026-03-31 threshold → due
|
||||||
|
const out = classifyFullPassDue([f({ verified: '2026-03' })], opts);
|
||||||
|
assert.equal(out.worklist.length, 1);
|
||||||
|
assert.equal(out.worklist[0].reason, 'cadence-due');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worklist entry carries path, reason, verified, ageDays, source for the human', () => {
|
||||||
|
const out = classifyFullPassDue([
|
||||||
|
f({ path: 'skills/s/references/p.md', verified: '2026-02-01', source: 'https://learn.microsoft.com/p' }),
|
||||||
|
], opts);
|
||||||
|
const e = out.worklist[0];
|
||||||
|
assert.equal(e.path, 'skills/s/references/p.md');
|
||||||
|
assert.equal(e.reason, 'cadence-due');
|
||||||
|
assert.equal(e.verified, '2026-02-01');
|
||||||
|
assert.equal(e.source, 'https://learn.microsoft.com/p');
|
||||||
|
assert.equal(typeof e.ageDays, 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ranking: never-verified before cadence-due; within cadence-due oldest verified first', () => {
|
||||||
|
const out = classifyFullPassDue([
|
||||||
|
f({ path: 'b-recent-stale.md', verified: '2026-02-01' }), // cadence-due, 148d
|
||||||
|
f({ path: 'a-never.md', verified: null }), // never-verified
|
||||||
|
f({ path: 'c-oldest-stale.md', verified: '2026-01-01' }), // cadence-due, 179d
|
||||||
|
], opts);
|
||||||
|
assert.deepEqual(out.worklist.map((e) => e.path), ['a-never.md', 'c-oldest-stale.md', 'b-recent-stale.md']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mixed corpus aggregates counts; total = input length; due = worklist length', () => {
|
||||||
|
const out = classifyFullPassDue([
|
||||||
|
f({ verified: '2026-01-01' }), // cadence-due
|
||||||
|
f({ verified: null }), // never-verified
|
||||||
|
f({ verified: '2026-06-20' }), // fresh
|
||||||
|
f({ source: null, verified: null }), // unsourced
|
||||||
|
f({ type: 'template', source: null, verified: null }), // out-of-scope
|
||||||
|
f({ type: null, source: null, verified: null }), // unmigrated
|
||||||
|
], opts);
|
||||||
|
assert.equal(out.counts.total, 6);
|
||||||
|
assert.equal(out.counts.cadenceDue, 1);
|
||||||
|
assert.equal(out.counts.neverVerified, 1);
|
||||||
|
assert.equal(out.counts.fresh, 1);
|
||||||
|
assert.equal(out.counts.unsourced, 1);
|
||||||
|
assert.equal(out.counts.outOfScope, 1);
|
||||||
|
assert.equal(out.counts.unmigrated, 1);
|
||||||
|
assert.equal(out.counts.due, 2);
|
||||||
|
assert.equal(out.worklist.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty input → empty worklist, zeroed counts', () => {
|
||||||
|
const out = classifyFullPassDue([], opts);
|
||||||
|
assert.equal(out.worklist.length, 0);
|
||||||
|
assert.equal(out.counts.total, 0);
|
||||||
|
assert.equal(out.counts.due, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- buildFullPassReport: assemble the report (pure; corpus paths + reader injected) ---
|
||||||
|
|
||||||
|
const SOURCE_MAP = new Map([
|
||||||
|
['due.md', '2026-06-15'], // annotation only — does NOT affect due-ness
|
||||||
|
['fresh.md', '2026-01-01'],
|
||||||
|
]);
|
||||||
|
const PATHS = ['due.md', 'fresh.md', 'gone.md', 'legacy.md'];
|
||||||
|
const READER = (p) => ({
|
||||||
|
'due.md': { type: 'reference', source: 'https://learn.microsoft.com/due', verified: '2026-01-01' },
|
||||||
|
'fresh.md': { type: 'reference', source: 'https://learn.microsoft.com/fresh', verified: '2026-06-20' },
|
||||||
|
'gone.md': null, // deleted on disk
|
||||||
|
'legacy.md': { type: null, source: null, verified: null },
|
||||||
|
}[p] ?? null);
|
||||||
|
|
||||||
|
test('buildFullPassReport — stamps metadata, joins headers, classifies by cadence', () => {
|
||||||
|
const r = buildFullPassReport(PATHS, READER, SOURCE_MAP, opts);
|
||||||
|
assert.equal(r.generated_at, TODAY);
|
||||||
|
assert.equal(r.cadence_max_age_days, MAX_AGE);
|
||||||
|
assert.equal(r.counts.due, 1);
|
||||||
|
assert.equal(r.counts.fresh, 1);
|
||||||
|
assert.equal(r.counts.unmigrated, 1);
|
||||||
|
assert.equal(r.worklist.length, 1);
|
||||||
|
assert.equal(r.worklist[0].path, 'due.md');
|
||||||
|
assert.equal(r.worklist[0].reason, 'cadence-due');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildFullPassReport — annotates worklist entry with newestSourceLastmod from the source map', () => {
|
||||||
|
const r = buildFullPassReport(PATHS, READER, SOURCE_MAP, opts);
|
||||||
|
assert.equal(r.worklist[0].newestSourceLastmod, '2026-06-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildFullPassReport — deleted files (reader null) skipped entirely, not counted', () => {
|
||||||
|
const r = buildFullPassReport(PATHS, READER, SOURCE_MAP, opts);
|
||||||
|
assert.equal(r.counts.total, 3); // gone.md skipped
|
||||||
|
assert.ok(!r.worklist.some((e) => e.path === 'gone.md'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildFullPassReport — no source map still works (annotation null)', () => {
|
||||||
|
const r = buildFullPassReport(['due.md'], READER, new Map(), opts);
|
||||||
|
assert.equal(r.worklist.length, 1);
|
||||||
|
assert.equal(r.worklist[0].newestSourceLastmod, null);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue