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.
146 lines
6.3 KiB
JavaScript
146 lines
6.3 KiB
JavaScript
// 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,
|
|
};
|
|
}
|