Files
fusion/scripts/check-fnxc-future-dates.mjs
gsxdsm 9ad3a2a93a fix(gate): name the offending FNXC stamp and which rule it broke (#3009)
**`main` is currently red**, so every open PR shows a failing Lint job
that is not its own fault. #3004 is how I found it — its gates all pass
in isolation and fail against main.

## Cause: an hour that does not exist

```
FNXC:OperatorScriptLaneAssumptions 2026-07-30-26:10
                                              ^^ hour 26
```

Four of them, across three files, from #2994.

## The part worth fixing is the message, not the stamps

This gate counts **two** defects — a date after today, and an impossible
clock time — but the failure text only ever explained the first:

```
scripts/reconcile-task-state-consistency.mjs: 2 future-dated FNXC stamp(s), baseline allows 0

A stamp dated after today (2026-07-31) records the change as happening in the future...
```

Every stamp in that file is dated `2026-07-30` or earlier — all valid
past dates. So the message sends you to inspect stamps that are fine,
and the natural conclusion is *the gate is broken*, not *the stamp is*.
I spent several minutes reproducing the regex by hand and getting
`future count = 0` before instrumenting the real script and finding
`hits += impossibleClockTimes(source)`.

A gate that detects the right defect and describes a different one is
worse than a slightly less sensitive gate, because it spends the
reader's trust. Now:

```
  scripts/reconcile-task-state-consistency.mjs
    FNXC:OperatorScriptLaneAssumptions 2026-07-30-26:10  (impossible clock time)
```

**Mutation-verified**: restoring one `26:10` stamp reproduces the
failure, and the message names it.

## The stamps: `2026-07-31-02:10`, not `23:59`

Hour 26 on the 30th is the informal spelling of 02:10 the next day.
Clamping to `23:59` would keep the file's stamps in a plausible order
but silently move the event; this preserves what the author meant.
Reversible either way — say the word if you would rather they were
clamped.

## The 176-file baseline drop is unrelated

`475 -> 183 known`. The clock crossed midnight, so yesterday's stamps
are no longer future-dated, and the ratchet auto-lowers on drops by
design. It rides along because the gate must leave a baseline matching
reality — an allowance nothing occupies is somewhere a real regression
can hide. It is not part of the fix.

## Verification

- `check:fnxc-future-dates` — exit 0 (was **exit 1 on main**)
- `check:lifecycle-columns`, `check:sql-column-literals`,
`check:inert-flag-seams`, `check:lane-wiring` — all exit 0
- eslint clean

## Worth someone's attention beyond this PR

`#2994` landed four impossible timestamps. The gate caught them, but
only after the clock crossed midnight changed which files it reported —
meaning the impossible-time check was live but effectively invisible
until it collided with an unrelated drop. It is worth asking whether
that check has ever produced a message anyone acted on before today.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:39:05 -07:00

249 lines
12 KiB
JavaScript

/*
FNXC:FnxcStampHygiene 2026-07-30-23:55:
FNXC STAMPS DATED IN THE FUTURE, FROZEN AT TODAY'S POPULATION.
AGENTS.md requires every FNXC comment to carry a `yyyy-MM-dd-hh:mm` stamp, and nothing checks it. The
only feedback loop is a reviewer noticing, and on 2026-07-30 alone reviewers caught FOUR future-dated
stamps across separate PRs (#2843, #2852, #2856, #2892). Every one was hand-written with nothing to
verify against.
A stamp dated after the change was written is not cosmetic. These comments are the project's record of
WHY code exists, and the census, the solutions docs and several review conventions read them
chronologically — "recorded 2026-07-31" next to a 2026-07-30 commit makes the ordering wrong for
exactly the reader the comment is for.
WHY A BASELINE RATCHET AND NOT A HARD FAIL. 84 source files already carry a future stamp, the furthest
nearly three months out. A gate that fails on all of them is unmergeable and would be turned off, and
mass-editing 84 files to satisfy a new check is churn nobody asked for. So the population is frozen:
a NEW future-dated stamp fails, an existing one does not, and a count that DROPS also fails so a fixed
file cannot leave a slot the surface silently regrows into. Same shape as the SQL column-literal gate.
WHY "FUTURE" AND NOT "MATCHES THE COMMIT DATE". A stamp legitimately predates its commit — work
written Monday and landed Wednesday is normal and correct. Only a date that has not happened yet is
unambiguously wrong, so that is the whole rule; it catches every case a reviewer has caught so far
without inventing a stricter one nobody follows.
*/
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)), "..");
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-07-31-03:40 (#2941 review): HYPHENS ARE PART OF THE REQUIRED FORM.
AGENTS.md specifies `FNXC:Area-of-product`, and the first matcher accepted only `[A-Za-z0-9_]+` — so
every hyphenated area, i.e. the documented spelling, was skipped entirely. The gate was blind to the
shape the rule actually prescribes, which is the worst possible subset to miss.
*/
const STAMP = /FNXC:[A-Za-z0-9_-]+\s+(\d{4}-\d{2}-\d{2})/g;
/*
FNXC:FnxcStampHygiene 2026-07-30-21:40:
THE HOUR WAS NEVER VALIDATED, so `2026-07-30-25:30` passed this gate.
`STAMP` captures only the date, and the future check compares that capture alone — a stamp could
carry any `hh:mm` at all. Four stamps on `main` already read `-24:40` or `-24:00`, and a fifth
`-25:30` arrived with the next PR. AGENTS.md specifies `yyyy-MM-dd-hh:mm`, where `hh` is a clock
hour, and the whole point of the stamp is to make the FNXC record a readable chronology; a time that
cannot exist quietly costs it that.
Counted per file alongside the future-dated population rather than as a separate gate, because it is
the same defect class — a stamp that does not describe a real moment — and one ratchet is cheaper to
keep honest than two.
*/
const STAMP_TIME = /FNXC:[A-Za-z0-9_-]+\s+\d{4}-\d{2}-\d{2}-(\d{2}):(\d{2})/g;
/** Hours 00-23, minutes 00-59. Returns the count of stamps whose clock time cannot exist. */
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;
}
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);
/* `.js`/`.cjs` too: FNXC comments live in plain-JS scripts as well, and omitting them let a
future-dated stamp land unseen in exactly the files this repo writes tooling in. */
/*
FNXC:FnxcStampHygiene 2026-07-30-00:00 (#2953 follow-up): EVERY FILE TYPE THAT CARRIES A STAMP.
The filter listed the types stamps were EXPECTED in, not the ones they OCCUR in, so the gate was
blind wherever the convention had spread on its own. `.sql` was the costly omission: migrations
carry a stamp recording when a schema change landed, they are the files where a wrong date
misleads most, and one of them held a stamp dated nearly three months out. `.css` had drifted
furthest by volume (1023 stamps across 123 files, from the dashboard CSS split). A gate whose
coverage is a guess about where authors write comments will always trail the authors.
*/
else if (/\.(tsx?|m?js|cjs|md|sql|css|html|ya?ml|json|sh)$/.test(full)) yield full;
}
}
/*
Today in the repo's LOCAL calendar; a stamp for today is fine, tomorrow is not.
FNXC:FnxcStampHygiene 2026-07-31-03:40 (#2941 review): `toISOString()` is UTC, so for anyone west of
Greenwich it rolls the date forward for part of each day — a stamp written correctly at 5pm in
California read as "tomorrow" and failed the gate. Authors write the local date, so the comparison
has to use the local one.
*/
const now = new Date();
const today = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
].join("-");
function scan() {
const counts = {};
for (const root of ROOTS) {
let base;
try { base = statSync(join(REPO, root)); } catch { continue; }
if (!base.isDirectory()) continue;
for (const file of walk(join(REPO, root))) {
const source = readFileSync(file, "utf8");
STAMP.lastIndex = 0;
let hits = 0;
for (const match of source.matchAll(STAMP)) if (match[1] > today) hits += 1;
hits += impossibleClockTimes(source);
if (hits > 0) counts[relative(REPO, file).split("\\").join("/")] = hits;
}
}
return counts;
}
const found = scan();
if (process.argv.includes("--update-baseline")) {
writeFileSync(BASELINE, `${JSON.stringify(found, null, 2)}\n`);
const total = Object.values(found).reduce((a, b) => a + b, 0);
console.log(`[check-fnxc-future-dates] baseline written: ${total} stamp(s) in ${Object.keys(found).length} file(s)`);
process.exit(0);
}
/*
FNXC:FnxcStampHygiene 2026-07-31-03:45 (#2941 review): VALIDATE THE SHAPE, not just the JSON.
The first version caught only a parse error, so `null`, an array, or a negative/NaN count reached the
comparison and either crashed with a stack trace or — worse — compared as `undefined` and silently
allowed everything. A ratchet whose baseline can be quietly neutered by a bad edit is not a ratchet.
*/
let baseline;
try {
baseline = JSON.parse(readFileSync(BASELINE, "utf8"));
} catch {
console.error("[check-fnxc-future-dates] missing or malformed baseline; run with --update-baseline");
process.exit(1);
}
if (baseline === null || typeof baseline !== "object" || Array.isArray(baseline)) {
console.error("[check-fnxc-future-dates] baseline must be a JSON object of file -> count");
process.exit(1);
}
for (const [file, count] of Object.entries(baseline)) {
/*
FNXC:FnxcStampHygiene 2026-07-30-23:55 (#2941 review): SAFE integer, not just integer.
`Number.isInteger(9007199254740992)` is true, but that value is past 2^53-1 where JavaScript stops
distinguishing adjacent integers — so it compares greater than any count this scanner can produce and
silently disables the ratchet for that file. A validator whose purpose is "this baseline cannot be
neutered by a bad edit" has to reject the value that neuters it most completely.
*/
if (!Number.isSafeInteger(count) || count < 0) {
console.error(`[check-fnxc-future-dates] baseline entry "${file}" must be a non-negative safe integer, got ${JSON.stringify(count)}`);
process.exit(1);
}
}
const problems = [];
const offendingFiles = [];
for (const [file, count] of Object.entries(found)) {
const allowed = baseline[file] ?? 0;
if (count > allowed) offendingFiles.push(file);
if (count > allowed) problems.push(` ${file}: ${count} future-dated FNXC stamp(s), baseline allows ${allowed}`);
}
/*
FNXC:FnxcStampHygiene 2026-07-30-23:20 (#2941 CI red — a ratchet whose own measurement moves with the
clock): A DROP TIGHTENS, IT DOES NOT FAIL.
I copied the drop-fails rule from the SQL ratchet without noticing that this population is not stable
the way that one is. "Is this stamp in the future" is answered against TODAY, so every date boundary
the runner crosses converts some future stamps into past ones and the count falls ON ITS OWN — no code
change involved. With drop-fails that guarantees a red gate on some later day, and it fired within
hours: the baseline was recorded at 2026-07-30 local while CI runs in UTC, already 2026-07-31.
Both sibling ratchets reached the same conclusion for the ordinary reason (the drop is rarely the
failing author's to fix). Here it is stronger still: nobody CAUSED the drop, so there is no author to
fix it. The ceiling follows the count down, says what it lowered, and exits 0; the RISE check — the
actual purpose, "no NEW future-dated stamp" — is untouched and still fails hard.
The rewritten baseline must be committed to take effect; in CI the write is discarded with the runner,
which is why the gate goes green rather than silently banking a stale allowance.
*/
const tightened = [];
for (const [file, allowed] of Object.entries(baseline)) {
const count = found[file] ?? 0;
if (count < allowed) tightened.push(` ${file}: ${allowed} -> ${count}`);
}
if (tightened.length > 0) {
for (const [file, allowed] of Object.entries(baseline)) {
const count = found[file] ?? 0;
if (count < allowed) { if (count === 0) delete baseline[file]; else baseline[file] = count; }
}
writeFileSync(BASELINE, `${JSON.stringify(baseline, null, 2)}\n`);
console.log(`[check-fnxc-future-dates] baseline TIGHTENED for ${tightened.length} file(s):`);
for (const line of tightened.sort()) console.log(line);
}
if (problems.length > 0) {
console.error("\n[check-fnxc-future-dates] FNXC stamp population changed:\n");
for (const line of problems.sort()) console.error(line);
/*
FNXC:FnxcStampHygiene 2026-07-31-07:45 (#3006 fixed the stamps; this fixes why they were hard to
find): NAME THE OFFENDING STAMP, AND WHICH RULE IT BROKE.
This gate counts TWO defects — a date after today, and an impossible clock time — but the failure
text only ever explained the first. Main went red on four `2026-07-30-26:10` stamps (hour 26) and
the message sent every reader to inspect `2026-07-30`, a perfectly valid past date. The gate had
detected the right thing and described a different one, so the natural conclusion was "the gate is
broken", not "the stamp is". Confirming otherwise took reproducing the regex by hand, getting zero,
and then instrumenting `scan()` to discover `hits += impossibleClockTimes(source)`.
A gate that misdescribes what it caught spends the reader's trust, which is worth more than the
one re-read of already-failing files that printing the real offenders costs.
*/
for (const file of offendingFiles) {
let source;
try { source = readFileSync(join(REPO, file), "utf8"); } catch { continue; }
const bad = [];
STAMP.lastIndex = 0;
for (const match of source.matchAll(STAMP)) if (match[1] > 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) {
console.error(`\n ${file}`);
for (const line of [...new Set(bad)]) console.error(` ${line}`);
}
}
console.error(
`\nA stamp dated after today (${today}) records the change as happening in the future, which makes\n`
+ "the FNXC record — the project's why-does-this-exist trail — read out of order. An hour above 23\n"
+ "or a minute above 59 is not a real time at all.\n"
+ "Use the current date and a real clock time. If a count went DOWN, re-record the baseline in the\n"
+ "same commit.\n",
);
process.exit(1);
}
const total = Object.values(found).reduce((a, b) => a + b, 0);
console.log(`[check-fnxc-future-dates] ${total} known future-dated stamp(s), none added.`);