Ren lib full-pass-worklist.mjs (classifyFullPassDue + buildFullPassReport, skriver aldri til disk) + CLI report-full-pass-worklist.mjs (gitignored full-pass-worklist.json). Lastmod-UAVHENGIG: gater paa Port 1-kontrakt (type:reference + source) + cadence-alder paa Verified, fanger drift-klassen inkrementell bommer (staleness-recall 0/40). Buckets: never-verified / cadence-due / fresh / unsourced / unmigrated / out-of-scope. Arbeidsliste (operatoer-valgt design), IKKE inline judge: enumererer + rangerer, operatoer kickstarter judging separat (par.5 ikke auto-lansert, par.4c aldri auto-fiks). Ekte kjoering: 389 ref-filer alle unmigrated = dormant til Spor 1-migrering. Suite 637/637.
179 lines
7.4 KiB
JavaScript
179 lines
7.4 KiB
JavaScript
// 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<string,string>} 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,
|
||
};
|
||
}
|