assessFreshness aged only the entry's own source.verified stamp, so an entry re-verified against an old source stayed green while a newer source sat unnoticed (BP-SUB-001 was stamped 2026-07-31, a week after the superseding-grade article of 2026-07-24 was published). The stamp certifies the old source; it says nothing about the evidence. - entries may carry corroborating sources[] with published dates - new evidence-age rule: stale when the NEWEST published date across all sources exceeds evidenceStaleAfterDays (default 365); re-verifying the old source never clears it, only newer evidence does - source.supersededBy marks a replaced source: stale regardless of stamp - stale items now carry reasons[] (verified-age / no-verified-date / superseded / evidence-age) - BP-SUB-001 gains the 2026-07-24 context-engineering article as a verified corroborating source (near-verbatim coverage). NOT added to BP-MECH-*/BP-SIZE-001: own verification found no mechanism-choice or size-limit content in the article, contrary to the brief's assumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019NeaMRXVGzh9oSwigJDjE9
148 lines
5.8 KiB
JavaScript
148 lines
5.8 KiB
JavaScript
/**
|
|
* 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 },
|
|
};
|
|
}
|