/** * knowledge-refresh — deterministic freshness core for the best-practices register. * * The "living" half of the v5.7 living knowledge base (Chunk 3). This module is the * PURE, deterministic part of the hybrid `/config-audit knowledge-refresh` motor: given a * register and an injected reference date, it classifies each entry as `fresh` or `stale` * by the age of its `source.verified` stamp. It NEVER touches the network and NEVER writes * — candidate discovery (polling CC changelog + Anthropic blog) and the human-approved * writes live in the command layer (Verifiseringsplikt: no unverified claim is auto-written). * * `referenceDate` is injected (not read from the clock here) so the function is fully * deterministic and unit-testable; the CLI passes today's date. See * docs/v5.7-optimization-lens-plan.md. */ /** Default re-verify cadence: a confirmed best-practice older than this needs a re-check. */ export const STALE_AFTER_DAYS_DEFAULT = 90; /** * Default evidence cadence: when the NEWEST `published` date across an entry's * sources is older than this, the entry is flagged even if recently re-verified. * Re-verifying the old source does not clear it — only a newer source does * (the BP-SUB-001 defect class: green stamp, substantially outdated evidence). */ export const EVIDENCE_STALE_AFTER_DAYS_DEFAULT = 365; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const DAY_MS = 86_400_000; /** * Normalize a reference date (Date or YYYY-MM-DD string) to a UTC-midnight {iso, ms}. * Throws TypeError on anything else — the reference date is required and must be valid. */ function normalizeReferenceDate(value) { let iso; if (value instanceof Date) { if (Number.isNaN(value.getTime())) throw new TypeError('referenceDate is an invalid Date'); iso = value.toISOString().slice(0, 10); } else if (typeof value === 'string' && DATE_RE.test(value)) { iso = value; } else { throw new TypeError('referenceDate must be a Date or a YYYY-MM-DD string'); } const ms = Date.parse(`${iso}T00:00:00Z`); if (Number.isNaN(ms)) throw new TypeError(`referenceDate is not a real calendar date: ${iso}`); return { iso, ms }; } /** Parse an entry's `source.verified` to UTC-midnight ms, or null if missing/unparseable. */ function verifiedMs(entry) { const v = entry && entry.source && entry.source.verified; if (typeof v !== 'string' || !DATE_RE.test(v)) return null; const ms = Date.parse(`${v}T00:00:00Z`); return Number.isNaN(ms) ? null : ms; } /** Parse a YYYY-MM-DD string to UTC-midnight ms, or null. */ function dateMs(v) { if (typeof v !== 'string' || !DATE_RE.test(v)) return null; const ms = Date.parse(`${v}T00:00:00Z`); return Number.isNaN(ms) ? null : ms; } /** * Newest `published` across the primary `source` and any corroborating `sources[]`. * Null when no source carries a parseable published date (the evidence-age rule * is then silent for that entry — it cannot judge evidence it cannot date). */ function newestEvidenceMs(entry) { const candidates = []; if (entry && entry.source) candidates.push(entry.source); if (entry && Array.isArray(entry.sources)) candidates.push(...entry.sources); let newest = null; for (const s of candidates) { const ms = dateMs(s && s.published); if (ms !== null && (newest === null || ms > newest)) newest = ms; } return newest; } /** * Classify every register entry as fresh or stale by the age of its source.verified stamp. * * @param {{entries:object[]}} register * @param {{ referenceDate: string|Date, staleAfterDays?: number, evidenceStaleAfterDays?: number }} opts * @returns {{ * referenceDate: string, * staleAfterDays: number, * evidenceStaleAfterDays: number, * stale: Array<{id:string, verified:string|undefined, ageDays:number|null, url:string|undefined, claim:string|undefined, reasons:string[]}>, * fresh: Array<{id:string, verified:string|undefined, ageDays:number}>, * counts: { total:number, stale:number, fresh:number } * }} */ export function assessFreshness(register, opts = {}) { const ref = normalizeReferenceDate(opts.referenceDate); const staleAfterDays = typeof opts.staleAfterDays === 'number' ? opts.staleAfterDays : STALE_AFTER_DAYS_DEFAULT; const evidenceStaleAfterDays = typeof opts.evidenceStaleAfterDays === 'number' ? opts.evidenceStaleAfterDays : EVIDENCE_STALE_AFTER_DAYS_DEFAULT; const entries = (register && Array.isArray(register.entries)) ? register.entries : []; const stale = []; const fresh = []; for (const e of entries) { const verified = e && e.source ? e.source.verified : undefined; const vms = verifiedMs(e); const reasons = []; let ageDays = null; if (vms === null) { // No re-checkable date → needs attention. reasons.push('no-verified-date'); } else { ageDays = Math.floor((ref.ms - vms) / DAY_MS); if (ageDays > staleAfterDays) reasons.push('verified-age'); } // A source explicitly marked as superseded is stale no matter how fresh the // verified stamp is — the stamp certifies the OLD source. if (e && e.source && e.source.supersededBy) reasons.push('superseded'); // Evidence age: keyed on the newest published date across all sources, so a // re-read of the old source never clears it — only newer evidence does. const evMs = newestEvidenceMs(e); if (evMs !== null && Math.floor((ref.ms - evMs) / DAY_MS) > evidenceStaleAfterDays) { reasons.push('evidence-age'); } if (reasons.length > 0) { stale.push({ id: e && e.id, verified, ageDays, url: e && e.source && e.source.url, claim: e && e.claim, reasons }); } else { fresh.push({ id: e.id, verified, ageDays }); } } return { referenceDate: ref.iso, staleAfterDays, evidenceStaleAfterDays, stale, fresh, counts: { total: entries.length, stale: stale.length, fresh: fresh.length }, }; }