From 72f5f8e51aa278544bfa3c35ec9836b75d4e0549 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 00:03:02 -0700 Subject: [PATCH] fix(gate): the FNXC stamp gate never validated the hour, so 25:30 passed (#2995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../__tests__/task-list-board-columns.test.ts | 2 +- packages/cli/src/commands/task.ts | 4 +-- .../recover-stale-blocked-by.test.mjs | 2 +- scripts/check-fnxc-future-dates.mjs | 27 +++++++++++++++++++ scripts/check-lane-wiring.mjs | 2 +- scripts/recover-stale-blocked-by.mjs | 2 +- 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/task-list-board-columns.test.ts b/packages/cli/src/__tests__/task-list-board-columns.test.ts index 7cd12f1a39..5f6b35d8b5 100644 --- a/packages/cli/src/__tests__/task-list-board-columns.test.ts +++ b/packages/cli/src/__tests__/task-list-board-columns.test.ts @@ -1,5 +1,5 @@ /* -FNXC:CliBoardVocabulary 2026-07-30-24:40: +FNXC:CliBoardVocabulary 2026-07-30-23:40: THE INVARIANT: `fn task list` prints every card, whatever its board calls the lane. `runTaskList` iterated the six-id `COLUMNS` constant and filtered `t.column === col`, so a card in a diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 0910150670..d449ca0bf4 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -26,7 +26,7 @@ function columnLabel(column: ColumnId): string { } /* -FNXC:CliBoardVocabulary 2026-07-30-24:40: +FNXC:CliBoardVocabulary 2026-07-30-23:40: The lanes `fn task list` prints, derived from the CARDS rather than from the legacy enum. `runTaskList` iterated the six-id `COLUMNS` constant and filtered `t.column === col`, so a task in a @@ -633,7 +633,7 @@ export async function runTaskList(projectName?: string) { } /* - FNXC:CliBoardVocabulary 2026-07-30-24:40: + FNXC:CliBoardVocabulary 2026-07-30-23:40: Iterate the columns the BOARD has, not the legacy six — a renamed card was not printed AT ALL. This loop ran `for (const col of COLUMNS)` and filtered `t.column === col`, so any task in a diff --git a/scripts/__tests__/recover-stale-blocked-by.test.mjs b/scripts/__tests__/recover-stale-blocked-by.test.mjs index fdbb4b57e3..7c3e85c125 100644 --- a/scripts/__tests__/recover-stale-blocked-by.test.mjs +++ b/scripts/__tests__/recover-stale-blocked-by.test.mjs @@ -119,7 +119,7 @@ test("treats soft-deleted blockers as missing and never plans for deleted depend }); /* -FNXC:OperatorScriptLaneAssumptions 2026-07-30-25:30: +FNXC:OperatorScriptLaneAssumptions 2026-07-30-23:30: THE INVARIANT: a board this script cannot reason about is REPORTED, never silently skipped. Every lane test in the planner is a legacy id, and the candidate gate is `row.column !== "todo"`, so a diff --git a/scripts/check-fnxc-future-dates.mjs b/scripts/check-fnxc-future-dates.mjs index 3cd1710b60..52e38a2ae6 100644 --- a/scripts/check-fnxc-future-dates.mjs +++ b/scripts/check-fnxc-future-dates.mjs @@ -41,6 +41,32 @@ 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; @@ -87,6 +113,7 @@ function scan() { 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; } } diff --git a/scripts/check-lane-wiring.mjs b/scripts/check-lane-wiring.mjs index 6491e2a737..af4921df1d 100644 --- a/scripts/check-lane-wiring.mjs +++ b/scripts/check-lane-wiring.mjs @@ -15,7 +15,7 @@ import { findLaneAcceptingFunctions, findUnwiredCallSites } from "./lib/lane-wir const ROOT = process.cwd(); const BASELINE = join(ROOT, "scripts/lib/lane-wiring-baseline.json"); /* -FNXC:WorkflowLifecycleColumns 2026-07-30-24:00: +FNXC:WorkflowLifecycleColumns 2026-07-30-23:00: `packages/dashboard/app` and `plugins` are scanned, and `.tsx` counts — their absence was the blind spot the OLDER guard already learned about and this one re-opened. diff --git a/scripts/recover-stale-blocked-by.mjs b/scripts/recover-stale-blocked-by.mjs index 068496ecda..5e1678e098 100644 --- a/scripts/recover-stale-blocked-by.mjs +++ b/scripts/recover-stale-blocked-by.mjs @@ -66,7 +66,7 @@ function isTerminalColumn(column) { } /* -FNXC:OperatorScriptLaneAssumptions 2026-07-30-25:30: +FNXC:OperatorScriptLaneAssumptions 2026-07-30-23:30: Refuse to look healthy on a board this script cannot reason about. Every lane test here is a legacy id: `isTerminalColumn` is done/archived, "active" is