// 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} 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, }; }