feat(ms-ai-architect): Spor 3 Port 3 (inkrementell) — stale-since-verified klassifiserer + CLI [skip-docs]
Port 3s billige, lastmod-synlige halvdel av kadens-judgen. Forutsetningen kom med Port 2: filene bærer nå **Verified:**, så «sitemap_lastmod > verified»- sammenligningen er endelig mulig. lib/verified-staleness.mjs (rent, speiler verify-out — skriver aldri til disk): - classifyVerifiedStaleness: reference-scope (Port 1-kontrakt). Buckets: stale-since-verified (kilde endret strikt etter verify → flagg), unverified (type=reference men verified=null, kontraktbrudd → flagg), unmigrated (type=null legacy → Spor 1-bro, telles), out-of-scope (template/methodology/ regulatory), fresh. YYYY-MM→-01-normalisering, strikt > (lik dato = fresh). - buildFileSourceMap: registry-inversjon (URL→filer ⇒ fil→nyeste lastmod), kun tracked + lastmod. Delt med full-pass (P3d). - buildStalenessReport: join header↔lastmod, klassifiser, sorter (stale før unverified, nyeste drift først); reader injisert (testbar uten disk). report-verified-staleness.mjs: tynn I/O-glue (speiler report-changes), skriver gitignored verified-staleness-report.json. Kjøres på KB-refresh-kadens, IKKE SessionStart (hooken bare LESER cachen — P3b). Ekte kjøring: 306 ref-filer m/ tracked kilder, alle unmigrated (Port 1 ikke backfilllet til legacy-korpus ennå = Spor 1). Mekanismen rapporterer ærlig grunntilstand korrekt. TDD (Iron Law): 18 nye tester. Suite 604/604.
This commit is contained in:
parent
5a0ba1fc9e
commit
fea5df5e68
3 changed files with 409 additions and 0 deletions
146
scripts/kb-update/lib/verified-staleness.mjs
Normal file
146
scripts/kb-update/lib/verified-staleness.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// verified-staleness.mjs — Spor 3 Port 3, INCREMENTAL pass (the cheap, lastmod-
|
||||
// visible half of the cadence-judge). A PURE classifier: it computes which
|
||||
// `reference` files have a source that changed AFTER they were last verified,
|
||||
// and NEVER touches disk (mirrors verify-out.mjs — the I/O lives in the CLI).
|
||||
//
|
||||
// The expensive periodic full-pass (P3d) catches the drift class this misses
|
||||
// (base-rate showed staleness-recall 0/40); this incremental pass is the cheap
|
||||
// floor that catches what the sitemap already tells us. The reporting floor
|
||||
// (P3b SessionStart signal) reads this classifier's output.
|
||||
//
|
||||
// Scope = the Port 1 frontmatter contract:
|
||||
// - type 'reference' → in correctness scope
|
||||
// - type template|methodology|regulatory → out-of-scope (exempt)
|
||||
// - no Type header (legacy corpus) → unmigrated (Spor 1 territory)
|
||||
//
|
||||
// Conservative by design (mirrors verify-out): when in doubt, flag. A YYYY-MM
|
||||
// verify date normalizes to the 1st (so a mid-month source change still counts
|
||||
// as stale), and the comparison is strict `>` so an equal date is fresh — the
|
||||
// same convention report-changes uses for **Last updated:**.
|
||||
|
||||
const REFERENCE_TYPE = 'reference';
|
||||
const OUT_OF_SCOPE_TYPES = new Set(['template', 'methodology', 'regulatory']);
|
||||
|
||||
/**
|
||||
* Invert the URL registry into a per-reference-file newest-source-lastmod map.
|
||||
* Mirrors report-changes' grouping loop, but pure so both the incremental pass
|
||||
* and the full-pass (P3d) share one inversion. Only `tracked` URLs that carry a
|
||||
* sitemap_lastmod contribute (a missing lastmod or non-tracked status is not a
|
||||
* currency signal).
|
||||
*
|
||||
* @param {object} registry — url-registry.json shape ({ urls: { url: entry } })
|
||||
* @returns {Map<string, string>} refFilePath → newest sitemap_lastmod (YYYY-MM-DD)
|
||||
*/
|
||||
export function buildFileSourceMap(registry) {
|
||||
const map = new Map();
|
||||
const urls = (registry && registry.urls) || {};
|
||||
for (const entry of Object.values(urls)) {
|
||||
if (!entry || entry.status !== 'tracked' || !entry.sitemap_lastmod) continue;
|
||||
for (const refFile of entry.reference_files || []) {
|
||||
const cur = map.get(refFile);
|
||||
if (!cur || entry.sitemap_lastmod > cur) map.set(refFile, entry.sitemap_lastmod);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** YYYY-MM → YYYY-MM-01; full date as-is. Mirrors report-changes' parseLastUpdated. */
|
||||
function normalizeDate(d) {
|
||||
if (!d) return null;
|
||||
return d.length === 7 ? d + '-01' : d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify reference files for lastmod-visible drift since last verification.
|
||||
*
|
||||
* @param {Array<{path: string, type: string|null, verified: string|null,
|
||||
* newestSourceLastmod: string|null}>} files
|
||||
* - type: Port 1 Type header (lowercased), or null if absent
|
||||
* - verified: Port 1 Verified date (YYYY-MM|YYYY-MM-DD), or null
|
||||
* - newestSourceLastmod: newest sitemap_lastmod across the file's tracked
|
||||
* source URLs (YYYY-MM-DD), or null if no tracked source
|
||||
* @returns {{flagged: Array<{path,reason,verified,newestSourceLastmod}>,
|
||||
* counts: {total,fresh,stale,unverified,unmigrated,outOfScope}}}
|
||||
*/
|
||||
export function classifyVerifiedStaleness(files) {
|
||||
const list = Array.isArray(files) ? files : [];
|
||||
const flagged = [];
|
||||
const counts = { total: list.length, fresh: 0, stale: 0, unverified: 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. Not judged
|
||||
// here (Spor 1 migrates it); counted so the gap is an honest number.
|
||||
if (type !== REFERENCE_TYPE) {
|
||||
counts.unmigrated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Declared reference. The contract requires born-verified; a missing verify
|
||||
// date is a breach create-guard should have caught → flag for attention.
|
||||
if (file.verified == null) {
|
||||
counts.unverified++;
|
||||
flagged.push({ path: file.path, reason: 'unverified', verified: null, newestSourceLastmod: file.newestSourceLastmod ?? null });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lastmod-visible drift: source changed strictly after we verified.
|
||||
const verifiedNorm = normalizeDate(file.verified);
|
||||
if (file.newestSourceLastmod && file.newestSourceLastmod > verifiedNorm) {
|
||||
counts.stale++;
|
||||
flagged.push({ path: file.path, reason: 'stale-since-verified', verified: file.verified, newestSourceLastmod: file.newestSourceLastmod });
|
||||
continue;
|
||||
}
|
||||
|
||||
counts.fresh++;
|
||||
}
|
||||
|
||||
return { flagged, counts };
|
||||
}
|
||||
|
||||
const REASON_ORDER = { 'stale-since-verified': 0, unverified: 1 };
|
||||
|
||||
/**
|
||||
* Assemble the full incremental staleness report. PURE: the caller injects a
|
||||
* header reader so the registry inversion + classification + shaping stay
|
||||
* testable without disk. The CLI wraps this with loadRegistry + a readFileSync-
|
||||
* backed reader + saveReport.
|
||||
*
|
||||
* @param {object} registry — url-registry.json shape
|
||||
* @param {(path: string) => ({type: string|null, verified: string|null}|null)} headerReader
|
||||
* returns the file's Type/Verified headers, or null if the file is absent
|
||||
* (deleted on disk) — absent files are skipped entirely, not counted.
|
||||
* @param {{today: string}} opts — today as YYYY-MM-DD (injected; Date.now is impure)
|
||||
* @returns {{generated_at, last_poll, scope, counts, flagged}}
|
||||
*/
|
||||
export function buildStalenessReport(registry, headerReader, { today }) {
|
||||
const sourceMap = buildFileSourceMap(registry);
|
||||
const files = [];
|
||||
for (const [path, newestSourceLastmod] of sourceMap) {
|
||||
const headers = headerReader(path);
|
||||
if (!headers) continue; // deleted on disk — registry hygiene, not staleness
|
||||
files.push({ path, type: headers.type ?? null, verified: headers.verified ?? null, newestSourceLastmod });
|
||||
}
|
||||
|
||||
const { flagged, counts } = classifyVerifiedStaleness(files);
|
||||
flagged.sort((a, b) => {
|
||||
const r = (REASON_ORDER[a.reason] ?? 9) - (REASON_ORDER[b.reason] ?? 9);
|
||||
if (r !== 0) return r;
|
||||
return String(b.newestSourceLastmod ?? '').localeCompare(String(a.newestSourceLastmod ?? ''));
|
||||
});
|
||||
|
||||
return {
|
||||
generated_at: today,
|
||||
last_poll: (registry && registry.last_poll) || null,
|
||||
scope: 'incremental (reference files with tracked sources)',
|
||||
counts,
|
||||
flagged,
|
||||
};
|
||||
}
|
||||
68
scripts/kb-update/report-verified-staleness.mjs
Normal file
68
scripts/kb-update/report-verified-staleness.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#!/usr/bin/env node
|
||||
// report-verified-staleness.mjs — Spor 3 Port 3, INCREMENTAL cadence pass.
|
||||
// Compares each tracked source's sitemap_lastmod to the reference file's
|
||||
// **Verified:** header (the Port 1 born-verified stamp) and reports the files
|
||||
// whose source changed AFTER they were last verified — the cheap, lastmod-
|
||||
// visible drift the periodic full-pass (P3d) complements.
|
||||
//
|
||||
// Run on the KB-refresh cadence, NOT on SessionStart (the SessionStart hook
|
||||
// only READS the cached report this writes — the reporting floor, P3b). All
|
||||
// logic lives in lib/verified-staleness.mjs (pure, unit-tested); this file is
|
||||
// I/O glue mirroring report-changes.mjs.
|
||||
//
|
||||
// Usage: node report-verified-staleness.mjs [--json]
|
||||
|
||||
import { readFileSync, existsSync } 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 } from './lib/kb-headers.mjs';
|
||||
import { buildStalenessReport } from './lib/verified-staleness.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
||||
const DATA_DIR = join(__dirname, 'data');
|
||||
const jsonOnly = process.argv.includes('--json');
|
||||
|
||||
// Read a reference file's Port 1 headers, or null if the file is gone on disk
|
||||
// (registry hygiene, not a staleness signal — buildStalenessReport skips it).
|
||||
// Only the top 500 bytes are read; both header parsers scan that region.
|
||||
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), verified: parseVerifiedHeader(head) };
|
||||
}
|
||||
|
||||
const registry = loadRegistry(DATA_DIR);
|
||||
if (!registry.last_poll) {
|
||||
console.error('Registry has not been polled yet. Run poll-sitemaps.mjs first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const report = buildStalenessReport(registry, headerReader, { today });
|
||||
saveReport('verified-staleness-report.json', report, DATA_DIR);
|
||||
|
||||
if (jsonOnly) {
|
||||
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
||||
} else {
|
||||
const c = report.counts;
|
||||
console.log(`\n=== Verified-staleness Report (${report.generated_at}) — incremental ===`);
|
||||
console.log(`Sources last polled: ${report.last_poll}`);
|
||||
console.log(
|
||||
`Reference files w/ tracked sources: ${c.total} ` +
|
||||
`(fresh: ${c.fresh}, stale-since-verified: ${c.stale}, unverified: ${c.unverified}, unmigrated: ${c.unmigrated}, out-of-scope: ${c.outOfScope})`,
|
||||
);
|
||||
if (report.flagged.length > 0) {
|
||||
console.log(`\nFlagged for re-judge (${report.flagged.length}):`);
|
||||
for (const f of report.flagged.slice(0, 30)) {
|
||||
console.log(` [${f.reason}] ${f.path}`);
|
||||
console.log(` verified: ${f.verified ?? '—'} source changed: ${f.newestSourceLastmod ?? '—'}`);
|
||||
}
|
||||
if (report.flagged.length > 30) console.log(` ... and ${report.flagged.length - 30} more`);
|
||||
console.log('\nNext: human-confirm each flag against its source, fix, then bump **Verified:** (never auto-fix).');
|
||||
} else {
|
||||
console.log('\nNo lastmod-visible drift since last verification.');
|
||||
}
|
||||
}
|
||||
195
tests/kb-update/test-verified-staleness.test.mjs
Normal file
195
tests/kb-update/test-verified-staleness.test.mjs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// tests/kb-update/test-verified-staleness.test.mjs
|
||||
// Spor 3 Port 3 (kadens-judge) — INCREMENTAL stale-since-verified classifier.
|
||||
//
|
||||
// classifyVerifiedStaleness is a PURE function (mirrors verify-out.mjs: it never
|
||||
// touches disk). It answers the cheap, lastmod-visible question Port 3's
|
||||
// incremental pass needs: "which reference files have a source that changed
|
||||
// AFTER we last verified them?" The expensive full-pass (P3d) catches the
|
||||
// drift class this misses. The reporting floor (P3b) reads its output.
|
||||
//
|
||||
// Scope is the Port 1 contract: only `type: reference` files are in correctness
|
||||
// scope. template/methodology/regulatory are exempt (out-of-scope). Files with
|
||||
// no Type header are `unmigrated` (legacy corpus — Spor 1 territory, not judged
|
||||
// here but counted so the migration gap is an honest number).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
classifyVerifiedStaleness,
|
||||
buildFileSourceMap,
|
||||
buildStalenessReport,
|
||||
} from '../../scripts/kb-update/lib/verified-staleness.mjs';
|
||||
|
||||
const f = (over) => ({
|
||||
path: 'skills/x/references/a.md',
|
||||
type: 'reference',
|
||||
verified: '2026-06-01',
|
||||
newestSourceLastmod: '2026-05-01',
|
||||
...over,
|
||||
});
|
||||
|
||||
test('reference + source changed AFTER verified → flagged stale-since-verified', () => {
|
||||
const out = classifyVerifiedStaleness([f({ verified: '2026-06-01', newestSourceLastmod: '2026-06-15' })]);
|
||||
assert.equal(out.flagged.length, 1);
|
||||
assert.equal(out.flagged[0].reason, 'stale-since-verified');
|
||||
assert.equal(out.counts.stale, 1);
|
||||
});
|
||||
|
||||
test('reference + source older than verified → fresh, not flagged', () => {
|
||||
const out = classifyVerifiedStaleness([f({ verified: '2026-06-01', newestSourceLastmod: '2026-05-01' })]);
|
||||
assert.equal(out.flagged.length, 0);
|
||||
assert.equal(out.counts.fresh, 1);
|
||||
});
|
||||
|
||||
test('reference + source lastmod EQUALS verified → fresh (strict >, mirrors report-changes)', () => {
|
||||
const out = classifyVerifiedStaleness([f({ verified: '2026-06-15', newestSourceLastmod: '2026-06-15' })]);
|
||||
assert.equal(out.flagged.length, 0);
|
||||
assert.equal(out.counts.fresh, 1);
|
||||
});
|
||||
|
||||
test('reference declared but verified missing → flagged unverified (contract breach)', () => {
|
||||
const out = classifyVerifiedStaleness([f({ verified: null, newestSourceLastmod: '2026-05-01' })]);
|
||||
assert.equal(out.flagged.length, 1);
|
||||
assert.equal(out.flagged[0].reason, 'unverified');
|
||||
assert.equal(out.counts.unverified, 1);
|
||||
});
|
||||
|
||||
test('reference + verified set + NO tracked source → fresh (incremental is lastmod-only; full-pass covers)', () => {
|
||||
const out = classifyVerifiedStaleness([f({ verified: '2026-06-01', newestSourceLastmod: null })]);
|
||||
assert.equal(out.flagged.length, 0);
|
||||
assert.equal(out.counts.fresh, 1);
|
||||
});
|
||||
|
||||
test('template / methodology / regulatory → out-of-scope, never flagged', () => {
|
||||
const out = classifyVerifiedStaleness([
|
||||
f({ type: 'template', verified: null }),
|
||||
f({ type: 'methodology', verified: null }),
|
||||
f({ type: 'regulatory', verified: null }),
|
||||
]);
|
||||
assert.equal(out.flagged.length, 0);
|
||||
assert.equal(out.counts.outOfScope, 3);
|
||||
});
|
||||
|
||||
test('no Type header (legacy) → unmigrated, counted but not flagged', () => {
|
||||
const out = classifyVerifiedStaleness([f({ type: null, verified: null, newestSourceLastmod: '2026-06-15' })]);
|
||||
assert.equal(out.flagged.length, 0);
|
||||
assert.equal(out.counts.unmigrated, 1);
|
||||
});
|
||||
|
||||
test('YYYY-MM verified normalizes to -01 → June-15 source is stale against June-month verify', () => {
|
||||
const out = classifyVerifiedStaleness([f({ verified: '2026-06', newestSourceLastmod: '2026-06-15' })]);
|
||||
assert.equal(out.flagged.length, 1);
|
||||
assert.equal(out.flagged[0].reason, 'stale-since-verified');
|
||||
});
|
||||
|
||||
test('flagged carries path + verified + newestSourceLastmod for the report/floor', () => {
|
||||
const out = classifyVerifiedStaleness([
|
||||
f({ path: 'skills/s/references/p.md', verified: '2026-01-01', newestSourceLastmod: '2026-06-15' }),
|
||||
]);
|
||||
const e = out.flagged[0];
|
||||
assert.equal(e.path, 'skills/s/references/p.md');
|
||||
assert.equal(e.verified, '2026-01-01');
|
||||
assert.equal(e.newestSourceLastmod, '2026-06-15');
|
||||
});
|
||||
|
||||
test('mixed corpus aggregates counts; total = input length', () => {
|
||||
const out = classifyVerifiedStaleness([
|
||||
f({ verified: '2026-01-01', newestSourceLastmod: '2026-06-15' }), // stale
|
||||
f({ verified: '2026-06-01', newestSourceLastmod: '2026-05-01' }), // fresh
|
||||
f({ verified: null }), // unverified
|
||||
f({ type: 'template', verified: null }), // out-of-scope
|
||||
f({ type: null, verified: null }), // unmigrated
|
||||
]);
|
||||
assert.equal(out.counts.total, 5);
|
||||
assert.equal(out.counts.stale, 1);
|
||||
assert.equal(out.counts.fresh, 1);
|
||||
assert.equal(out.counts.unverified, 1);
|
||||
assert.equal(out.counts.outOfScope, 1);
|
||||
assert.equal(out.counts.unmigrated, 1);
|
||||
assert.equal(out.flagged.length, 2); // stale + unverified
|
||||
});
|
||||
|
||||
test('empty input → empty flagged, zeroed counts', () => {
|
||||
const out = classifyVerifiedStaleness([]);
|
||||
assert.equal(out.flagged.length, 0);
|
||||
assert.equal(out.counts.total, 0);
|
||||
assert.equal(out.counts.stale, 0);
|
||||
});
|
||||
|
||||
// --- buildFileSourceMap: invert the registry (URL→files ⇒ file→newest lastmod) ---
|
||||
// Mirrors report-changes' grouping loop, but extracted as a PURE helper so the
|
||||
// incremental pass and the full-pass (P3d) share one inversion.
|
||||
|
||||
test('buildFileSourceMap — single tracked URL maps its file to the lastmod', () => {
|
||||
const reg = { urls: { 'https://x': { status: 'tracked', sitemap_lastmod: '2026-06-01', reference_files: ['a.md'] } } };
|
||||
const m = buildFileSourceMap(reg);
|
||||
assert.equal(m.get('a.md'), '2026-06-01');
|
||||
});
|
||||
|
||||
test('buildFileSourceMap — multiple URLs for one file → newest lastmod wins', () => {
|
||||
const reg = { urls: {
|
||||
'https://x': { status: 'tracked', sitemap_lastmod: '2026-03-01', reference_files: ['a.md'] },
|
||||
'https://y': { status: 'tracked', sitemap_lastmod: '2026-06-15', reference_files: ['a.md'] },
|
||||
'https://z': { status: 'tracked', sitemap_lastmod: '2026-05-01', reference_files: ['a.md'] },
|
||||
} };
|
||||
assert.equal(buildFileSourceMap(reg).get('a.md'), '2026-06-15');
|
||||
});
|
||||
|
||||
test('buildFileSourceMap — non-tracked status and missing lastmod are skipped', () => {
|
||||
const reg = { urls: {
|
||||
'https://x': { status: 'not_in_sitemap', sitemap_lastmod: '2026-06-01', reference_files: ['a.md'] },
|
||||
'https://y': { status: 'tracked', sitemap_lastmod: null, reference_files: ['a.md'] },
|
||||
} };
|
||||
assert.equal(buildFileSourceMap(reg).has('a.md'), false);
|
||||
});
|
||||
|
||||
test('buildFileSourceMap — empty / garbage registry → empty map', () => {
|
||||
assert.equal(buildFileSourceMap({}).size, 0);
|
||||
assert.equal(buildFileSourceMap(null).size, 0);
|
||||
assert.equal(buildFileSourceMap({ urls: {} }).size, 0);
|
||||
});
|
||||
|
||||
// --- buildStalenessReport: assemble the full incremental report (pure; reader injected) ---
|
||||
|
||||
const REG = { last_poll: '2026-06-23T18:09:30Z', urls: {
|
||||
'https://x': { status: 'tracked', sitemap_lastmod: '2026-06-15', reference_files: ['stale.md'] },
|
||||
'https://y': { status: 'tracked', sitemap_lastmod: '2026-01-01', reference_files: ['fresh.md'] },
|
||||
'https://z': { status: 'tracked', sitemap_lastmod: '2026-06-10', reference_files: ['gone.md'] },
|
||||
} };
|
||||
const READER = (p) => ({
|
||||
'stale.md': { type: 'reference', verified: '2026-02-01' },
|
||||
'fresh.md': { type: 'reference', verified: '2026-06-01' },
|
||||
'gone.md': null, // deleted on disk — reader signals absence
|
||||
}[p] ?? null);
|
||||
|
||||
test('buildStalenessReport — stamps metadata, joins headers to lastmod, classifies', () => {
|
||||
const r = buildStalenessReport(REG, READER, { today: '2026-06-29' });
|
||||
assert.equal(r.generated_at, '2026-06-29');
|
||||
assert.equal(r.last_poll, '2026-06-23T18:09:30Z');
|
||||
assert.equal(r.counts.stale, 1);
|
||||
assert.equal(r.counts.fresh, 1);
|
||||
assert.equal(r.flagged.length, 1);
|
||||
assert.equal(r.flagged[0].path, 'stale.md');
|
||||
assert.equal(r.flagged[0].reason, 'stale-since-verified');
|
||||
});
|
||||
|
||||
test('buildStalenessReport — files the reader returns null for (deleted) are skipped entirely', () => {
|
||||
const r = buildStalenessReport(REG, READER, { today: '2026-06-29' });
|
||||
assert.equal(r.counts.total, 2); // gone.md skipped, not counted
|
||||
assert.ok(!r.flagged.some((f) => f.path === 'gone.md'));
|
||||
});
|
||||
|
||||
test('buildStalenessReport — flagged sorted: stale-since-verified before unverified, newest drift first', () => {
|
||||
const reg = { last_poll: null, urls: {
|
||||
'https://a': { status: 'tracked', sitemap_lastmod: '2026-06-10', reference_files: ['s-old.md'] },
|
||||
'https://b': { status: 'tracked', sitemap_lastmod: '2026-06-20', reference_files: ['s-new.md'] },
|
||||
'https://c': { status: 'tracked', sitemap_lastmod: '2026-06-15', reference_files: ['u.md'] },
|
||||
} };
|
||||
const reader = (p) => ({
|
||||
's-old.md': { type: 'reference', verified: '2026-01-01' },
|
||||
's-new.md': { type: 'reference', verified: '2026-01-01' },
|
||||
'u.md': { type: 'reference', verified: null },
|
||||
}[p] ?? null);
|
||||
const r = buildStalenessReport(reg, reader, { today: '2026-06-29' });
|
||||
assert.deepEqual(r.flagged.map((f) => f.path), ['s-new.md', 's-old.md', 'u.md']);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue