Files
fusion/scripts/check-fnxc-future-dates.mjs
gsxdsm dfe050e8d4 FN-8640: add FNXC stamp anomaly advisory
Add a non-blocking census for implausible future-dated FNXC stamps.

- Classify tolerated future stamps by timezone plausibility and report notable anomalies.
- Add injectable gate seams and coverage for advisory, report, baseline, and discovery behavior.
- Document the advisory and preserve read-only check-mode baseline handling.

Files changed:
 docs/testing.md                                    |   4 +
 scripts/__tests__/check-fnxc-future-dates.test.mjs | 185 ++++++++
 scripts/check-fnxc-future-dates.mjs                | 495 +++++++++++----------
 3 files changed, 439 insertions(+), 245 deletions(-)

Fusion-Task-Id: FN-8640

Fusion-Task-Lineage: 7e57feb0-95e2-46b9-a1cf-9bd93c40d8e0

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-31 19:03:08 -07:00

316 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
FNXC:FnxcStampHygiene 2026-08-01-01:17:
The baseline ratchet answers whether the future-dated population grew, not whether an individual
stamp is plausible. That lets a 79-day-out stamp remain tolerated indefinitely. This advisory makes
that distinction visible without failing: 111 existing stamps belong to other authors, and a bulk
rewrite would create the churn this gate is meant to avoid.
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, relative, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export const ROOTS = ["packages", "scripts", "docs"];
const BASELINE = join(REPO, "scripts", "lib", "fnxc-future-dates-baseline.json");
/* Build output and vendored bundles are generated; their stamps are copies of the source ones. */
const SKIP_DIRS = new Set(["node_modules", "dist", ".gate-bundle", "coverage", "build", ".next"]);
/*
FNXC:FnxcStampHygiene 2026-08-01-01:17:
Hyphens are part of the required `FNXC:Area-of-product` form, so this one shared matcher remains the
source of both ratchet and advisory records. A second pattern would let the two reports disagree.
*/
export const STAMP = /FNXC:([A-Za-z0-9_-]+)\s+(\d{4}-\d{2}-\d{2})/g;
export const STAMP_TIME = /FNXC:[A-Za-z0-9_-]+\s+\d{4}-\d{2}-\d{2}-(\d{2}):(\d{2})/g;
/*
FNXC:FnxcStampHygiene 2026-08-01-01:17:
26 hours is wider than the UTC+14 maximum offset, so a stamp inside a day-and-a-bit can be an
author-local date and needs no action.
*/
export const TIMEZONE_PLAUSIBLE_HOURS = 26;
/*
FNXC:FnxcStampHygiene 2026-08-01-01:17:
48 hours is the deliberately conservative headline threshold: nothing inside two days is called out
loudly. The 26–48 hour suspect band still counts the source measurement's >26h population rather
than silently dropping it.
*/
export const IMPLAUSIBLE_HOURS = 48;
/** Returns a valid UTC-midnight date for a YYYY-MM-DD value, or null without throwing. */
function parseStampDate(stampDate) {
if (typeof stampDate !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(stampDate)) return null;
const parsed = new Date(`${stampDate}T00:00:00.000Z`);
return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== stampDate ? null : parsed;
}
/** Returns the UTC hours a date-only stamp is ahead of the reference, or null when it is invalid. */
export function hoursAhead(stampDate, referenceDate) {
const stamp = parseStampDate(stampDate);
const reference = referenceDate instanceof Date ? referenceDate : new Date(referenceDate);
if (!stamp || Number.isNaN(reference.getTime())) return null;
return (stamp.getTime() - reference.getTime()) / (60 * 60 * 1000);
}
/** Classifies every parseable stamp; invalid dates are non-anomalies rather than scanner failures. */
export function classifyFutureStamp(stampDate, referenceDate) {
const hours = hoursAhead(stampDate, referenceDate);
if (hours === null || hours <= 0) return "past-or-today";
if (hours <= TIMEZONE_PLAUSIBLE_HOURS) return "timezone-plausible";
if (hours <= IMPLAUSIBLE_HOURS) return "suspect";
return "implausible";
}
/** The stamp an author should paste, in the project's `yyyy-MM-dd-HH:mm` form, in UTC. */
export function nowStamp(now = new Date()) {
const p = (n) => String(n).padStart(2, "0");
return `${now.getUTCFullYear()}-${p(now.getUTCMonth() + 1)}-${p(now.getUTCDate())}-${p(now.getUTCHours())}:${p(now.getUTCMinutes())}`;
}
export function impossibleClockTimes(source) {
let bad = 0;
STAMP_TIME.lastIndex = 0;
for (const match of source.matchAll(STAMP_TIME)) {
if (Number(match[1]) > 23 || Number(match[2]) > 59) bad += 1;
}
return bad;
}
export function* walk(dir) {
for (const entry of readdirSync(dir)) {
if (SKIP_DIRS.has(entry)) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) yield* walk(full);
else if (/\.(tsx?|m?js|cjs|md|sql|css|html|ya?ml|json|sh)$/.test(full)) yield full;
}
}
/** Preserves the existing later-of-local-and-UTC calendar bound for an injectable clock. */
export function todayFor(now = new Date()) {
const localToday = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
].join("-");
const utcToday = now.toISOString().slice(0, 10);
return { localToday, utcToday, today: localToday > utcToday ? localToday : utcToday };
}
/**
* Scans exactly the historical roots. `root` is a repository root, not a directory to broaden.
*/
export function scan({ root = REPO, referenceDate = new Date(`${todayFor().today}T00:00:00.000Z`) } = {}) {
const counts = {};
const records = [];
const impossibleRecords = [];
const scannedFiles = [];
for (const scanRoot of ROOTS) {
const directory = join(root, scanRoot);
try { if (!statSync(directory).isDirectory()) continue; } catch { continue; }
for (const file of walk(directory)) {
const fileKey = relative(root, file).split("\\").join("/");
scannedFiles.push(fileKey);
const source = readFileSync(file, "utf8");
let hits = 0;
STAMP.lastIndex = 0;
for (const match of source.matchAll(STAMP)) {
const [area, stamp] = [match[1], match[2]];
const hours = hoursAhead(stamp, referenceDate);
const band = classifyFutureStamp(stamp, referenceDate);
if (hours !== null && hours > 0) {
hits += 1;
records.push({ file: fileKey, area, stamp, hoursAhead: hours, daysAhead: hours / 24, band });
}
}
STAMP_TIME.lastIndex = 0;
for (const match of source.matchAll(STAMP_TIME)) {
if (Number(match[1]) > 23 || Number(match[2]) > 59) {
hits += 1;
impossibleRecords.push({ file: fileKey, stamp: match[0] });
}
}
if (hits > 0) counts[fileKey] = hits;
}
}
scannedFiles.sort();
return { counts, records, impossibleRecords, scannedFiles };
}
function byBand(records) {
return {
timezonePlausible: records.filter((record) => record.band === "timezone-plausible").length,
suspect: records.filter((record) => record.band === "suspect").length,
implausible: records.filter((record) => record.band === "implausible").length,
};
}
function advisoryLines(records, bands) {
if (bands.suspect + bands.implausible === 0) return [];
const lines = [
"[check-fnxc-future-dates] advisory (informational only; does not fail the build):",
` future-dated total: ${records.length}`,
` timezone-plausible (<=26h; explainable by author timezone — no action needed): ${bands.timezonePlausible}`,
` suspect (>26h, <=48h): ${bands.suspect}`,
` implausible (>48h): ${bands.implausible}`,
];
const offenders = records.filter((record) => record.band === "implausible")
.sort((a, b) => b.daysAhead - a.daysAhead || a.file.localeCompare(b.file)).slice(0, 10);
if (offenders.length > 0) {
lines.push(" worst implausible offenders:");
for (const record of offenders) {
lines.push(` ${record.file} — FNXC:${record.area} ${record.stamp} (${record.daysAhead.toFixed(1)} days ahead)`);
}
}
return lines;
}
function reportLines(records, impossibleRecords, bands) {
const lines = ["[check-fnxc-future-dates] anomaly census (read-only; informational only):"];
const anomalies = records.filter((record) => record.band === "suspect" || record.band === "implausible")
.sort((a, b) => a.file.localeCompare(b.file) || b.daysAhead - a.daysAhead);
let currentFile;
for (const record of anomalies) {
if (record.file !== currentFile) {
currentFile = record.file;
lines.push(` ${currentFile}`);
}
lines.push(` FNXC:${record.area} ${record.stamp} — ${record.band} (${record.daysAhead.toFixed(1)} days ahead)`);
}
if (anomalies.length === 0) lines.push(" no suspect or implausible stamps");
if (impossibleRecords.length > 0) {
lines.push(" impossible clock times (informational):");
for (const record of impossibleRecords) lines.push(` ${record.file} — ${record.stamp}`);
}
const areas = new Map();
for (const record of anomalies) areas.set(record.area, (areas.get(record.area) ?? 0) + 1);
lines.push(" anomaly counts by FNXC area:");
for (const [area, count] of [...areas].sort(([a], [b]) => a.localeCompare(b))) lines.push(` FNXC:${area}: ${count}`);
lines.push(` band totals: timezone-plausible=${bands.timezonePlausible}, suspect=${bands.suspect}, implausible=${bands.implausible}`);
return lines;
}
function readBaseline(baselinePath) {
let baseline;
try { baseline = JSON.parse(readFileSync(baselinePath, "utf8")); } catch {
return { error: "[check-fnxc-future-dates] missing or malformed baseline; run with --update-baseline" };
}
if (baseline === null || typeof baseline !== "object" || Array.isArray(baseline)) {
return { error: "[check-fnxc-future-dates] baseline must be a JSON object of file -> count" };
}
for (const [file, count] of Object.entries(baseline)) {
if (!Number.isSafeInteger(count) || count < 0) {
return { error: `[check-fnxc-future-dates] baseline entry "${file}" must be a non-negative safe integer, got ${JSON.stringify(count)}` };
}
}
return { baseline };
}
function writeBaseline(baselinePath, baseline) {
writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`);
}
/**
* Runs the historical ratchet plus its non-blocking plausibility advisory.
*
* FNXC:FnxcStampHygiene 2026-08-01-01:17:
* The injected root, baseline, and clock make the real gate testable without changing discovery or
* the later-of-local-and-UTC bound. Report mode is intentionally read-only so operators can census a
* tolerated population without banking, tightening, or failing it.
*/
export function runGate({ root = REPO, baselinePath = join(root, "scripts", "lib", "fnxc-future-dates-baseline.json"), now = new Date(), mode = "check" } = {}) {
const { today } = todayFor(now);
const referenceDate = new Date(`${today}T00:00:00.000Z`);
const { counts: found, records, impossibleRecords, scannedFiles } = scan({ root, referenceDate });
const bands = byBand(records);
const result = {
exitCode: 0,
futureTotal: records.length,
byBand: bands,
records,
scannedFiles,
baselineWritten: false,
lines: [],
};
if (mode === "report") {
if (readBaseline(baselinePath).error) result.lines.push("[check-fnxc-future-dates] baseline unavailable — census only");
result.lines.push(...reportLines(records, impossibleRecords, bands));
return result;
}
if (mode === "update") {
writeBaseline(baselinePath, found);
result.baselineWritten = true;
const total = Object.values(found).reduce((a, b) => a + b, 0);
result.lines.push(`[check-fnxc-future-dates] baseline written: ${total} stamp(s) in ${Object.keys(found).length} file(s)`);
return result;
}
const loaded = readBaseline(baselinePath);
if (loaded.error) {
result.exitCode = 1;
result.lines.push(loaded.error);
return result;
}
const baseline = loaded.baseline;
const problems = [];
const offendingFiles = [];
for (const [file, count] of Object.entries(found)) {
const allowed = baseline[file] ?? 0;
if (count > allowed) {
offendingFiles.push(file);
problems.push(` ${file}: ${count} future-dated FNXC stamp(s), baseline allows ${allowed}`);
}
}
const tightened = [];
for (const [file, allowed] of Object.entries(baseline)) {
const count = found[file] ?? 0;
if (count < allowed) tightened.push(` ${file}: ${allowed} -> ${count}`);
}
/*
FNXC:FnxcStampHygiene 2026-08-01-01:17:
Check mode must remain read-only: an aging stamp can lower the census without an author action.
Report the available tightening, but require the explicit update mode to record it.
*/
if (tightened.length > 0) {
result.lines.push(
`[check-fnxc-future-dates] baseline CAN BE TIGHTENED for ${tightened.length} file(s):`,
...tightened.sort(),
" run `pnpm check:fnxc-future-dates --update-baseline` to record it (one commit, one author).",
);
}
if (problems.length > 0) {
result.exitCode = 1;
result.lines.push("", "[check-fnxc-future-dates] FNXC stamp population changed:", "", ...problems.sort());
for (const file of offendingFiles) {
const source = readFileSync(join(root, file), "utf8");
const bad = [];
STAMP.lastIndex = 0;
for (const match of source.matchAll(STAMP)) if (classifyFutureStamp(match[2], referenceDate) !== "past-or-today") bad.push(`${match[0]} (dated after today)`);
STAMP_TIME.lastIndex = 0;
for (const match of source.matchAll(STAMP_TIME)) if (Number(match[1]) > 23 || Number(match[2]) > 59) bad.push(`${match[0]} (impossible clock time)`);
if (bad.length > 0) result.lines.push("", ` ${file}`, ...[...new Set(bad)].map((line) => ` ${line}`));
}
result.lines.push(
`\nA stamp dated after today (${today}) records the change as happening in the future, which makes`,
"the FNXC record — the project's why-does-this-exist trail — read out of order. An hour above 23",
"or a minute above 59 is not a real time at all.",
"Use the current date and a real clock time. If a count went DOWN, re-record the baseline in the",
`same commit.\nCurrent UTC stamp to use: ${nowStamp(now)}\n`,
);
return result;
}
const total = Object.values(found).reduce((a, b) => a + b, 0);
result.lines.push(`[check-fnxc-future-dates] ${total} known future-dated stamp(s), none added.`);
result.lines.push(...advisoryLines(records, bands));
return result;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const mode = process.argv.includes("--report-anomalies") ? "report" : process.argv.includes("--update-baseline") ? "update" : "check";
const result = runGate({ mode });
for (const line of result.lines) console.log(line);
process.exit(result.exitCode);
}