Files
fusion/scripts/check-fnxc-future-dates.mjs
gsxdsm 72f5f8e51a fix(gate): the FNXC stamp gate never validated the hour, so 25:30 passed (#2995)
`check-fnxc-future-dates.mjs` validates the **date** portion of a stamp
and never looks at the clock time:

```js
const STAMP = /FNXC:[A-Za-z0-9_-]+\s+(\d{4}-\d{2}-\d{2})/g;
…
for (const match of source.matchAll(STAMP)) if (match[1] > today) hits += 1;
```

The capture stops before the hour, so a stamp may carry **any** `hh:mm`
and pass. Found while pre-flighting #2992, whose new comments read
`2026-07-30-25:30`.

## It is not one typo

Four stamps **already on `main`** carry a clock time that cannot exist:

```
packages/cli/src/__tests__/task-list-board-columns.test.ts:2     -24:40
packages/cli/src/commands/task.ts:29                             -24:40
packages/cli/src/commands/task.ts:636                            -24:40
scripts/check-lane-wiring.mjs:18                                 -24:00
```

Three separate authors, so this is the gate's blind spot rather than one
person's slip — and #2992 adds two more, which is how I noticed.

AGENTS.md specifies `yyyy-MM-dd-hh:mm`. The stamp's whole purpose is to
make the FNXC record a readable chronology of *why* code exists; a
timestamp that cannot exist quietly costs it that, and nothing was going
to catch it.

## The fix

Hours `00-23`, minutes `00-59`, counted per file **alongside** the
future-dated population rather than as a separate gate — same defect
class (a stamp that does not describe a real moment), and one ratchet is
cheaper to keep honest than two.

**Mutations, both directions:**

| stamp | result |
|---|---|
| `2026-07-30-25:00` | **flagged** |
| `2026-07-30-23:75` | **flagged** |
| clean tree | `475 known future-dated stamp(s), none added`, exit 0 |

## On the four existing stamps

Normalized by clamping the impossible hour to `23`, minutes preserved,
so relative ordering within each file survives. **That is a
normalization with a stated rule, not a claim about the true minute** —
`-24:40` most plausibly meant "just past midnight", but writing
`2026-07-31-00:40` would be future-dated against today's local calendar
and fail the very gate this PR extends. Clamping keeps every stamp real,
ordered, and non-future; the exact minute was already unrecoverable.

**Verified:** FNXC gate exit 0, lane-wiring gate exit 0,
`task-list-board-columns` 5/5, lint clean.

Comment-only changes to the CLI files (stamp text inside FNXC blocks),
so no behaviour change and no changeset.

Noted separately on #2992 so its two new stamps get corrected there
rather than landing and immediately failing this gate.

---------

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

216 lines
11 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 = [];
for (const [file, count] of Object.entries(found)) {
const allowed = baseline[file] ?? 0;
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] future-dated FNXC stamp population changed:\n");
for (const line of problems.sort()) 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.\n"
+ "Use the current date. If a count went DOWN, re-record the baseline in the 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.`);