From f193d6c19ad5ee0fef6f8937eba264722ac329db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:32:16 +0000 Subject: [PATCH 1/3] fix(pm): git-history hands git the instant its coverage proof reads A bare YYYY-MM-DD given to --since was passed to git verbatim, and git's approxidate fills the missing time of day from the current wall clock, while the coverage proof read the same string with Date.parse as that day's 00:00:00Z. The tool counted a narrower window than it proved, the answer shrank as the day went on, and the receipt named the wider window. windowInstant() now normalises every --since (and every placeable --until) to the complete UTC instant Date.parse puts it at; that instant is what git is handed and what the receipt and the refusal print. A complete instant comes back unchanged; an unplaceable --until still reaches git as given. Claude-Session: https://claude.ai/code/session_01CB6W87z22K2yjUCDyVrJRk Co-authored-by: Claude --- scripts/pm/git-history.mjs | 83 +++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/scripts/pm/git-history.mjs b/scripts/pm/git-history.mjs index a6166a33233..52dde922e90 100644 --- a/scripts/pm/git-history.mjs +++ b/scripts/pm/git-history.mjs @@ -122,6 +122,33 @@ * the shallow FILE, so it needs no common-dir resolution from a linked * worktree, and it catches a graft from any source. * + * ## The fourth trap, no graft at all: a bare date is read at NOW's time of day + * + * The coverage proof places `--since` with `Date.parse`, and ECMA-262 reads a + * date-only `YYYY-MM-DD` as that day's 00:00:00Z. git places the SAME string + * with its approxidate, which fills a missing time of day from the CURRENT + * WALL CLOCK. Passed through verbatim, `--since=2026-09-30` therefore proved + * one window, counted a narrower one that shrank as the day went on, and + * printed the wider one in its receipt — at exit 0, on exactly the spelling + * the usage text recommends (#21601). Measured on ref a7ab047cf6, the clock + * injected through git's own `GIT_TEST_DATE_NOW`: + * + * | `count --since=…` on a7ab047cf6 | git's clock | answer | + * |---------------------------------|-------------|--------| + * | `2026-09-30` (bare) | 00:30Z | 472 | + * | `2026-09-30` (bare) | 12:45Z | 410 | + * | `2026-09-30` (bare) | 15:17Z | 393 | + * | `2026-09-30T00:00:00Z` | any | 474 | + * + * The 410 and the 393 are the two answers the filing card recorded by hand at + * those hours; the receipt was identical on all four rows. Every other + * spelling git approxidates fails the same way (`Sep 30 2026`, `2026/09/30`: + * a midnight to `Date.parse`, now's time of day to git), and `--until` has the + * mirror image. So a window edge is normalised ONCE, by `windowInstant()`, to + * the complete UTC instant `Date.parse` already put it at; that instant is + * what git is handed and what the receipt prints, and a complete instant is + * parsed exactly and never consults the clock. + * * ## Cost, measured — because a tool nobody runs fixes nothing * * | case | wall | @@ -315,6 +342,27 @@ export function ensureWindowCovered({ cwd, ref, sinceMs, allowFetch = true, allo // ── answering ──────────────────────────────────────────────────────────────── +/** + * A window edge as the ONE instant every reader of it uses — the coverage + * proof, git, and the receipt (the fourth trap in the header). + * + * `Date.parse` decides the instant, because the proof already reads it that + * way: a bare `2026-09-30` is that day's 00:00:00Z. It comes back as a COMPLETE + * UTC instant, the one spelling git parses exactly instead of approxidating + * against the current time of day. A complete instant comes back as the same + * instant — byte-identical when already in `Z` with whole seconds; an offset + * is restated in `Z`; whole seconds drop the `.000` — so the self-test's + * complete-instant windows reach git exactly as they did before. + * + * @param {string} raw the edge as given on the command line + * @returns {string|null} the complete instant, or null when `Date.parse` cannot place `raw` + */ +export function windowInstant(raw) { + const ms = Date.parse(raw); + if (!Number.isFinite(ms)) return null; + return new Date(ms).toISOString().replace(/\.000Z$/, 'Z'); +} + function windowArgs({ since, until }) { const args = [`--since=${since}`]; if (until) args.push(`--until=${until}`); @@ -669,12 +717,28 @@ function resolveSince(opts) { return new Date(Date.now() - opts.days * 24 * 60 * 60 * 1000).toISOString(); } if (opts.since === undefined) usage('--since= or --days= is required'); - const ms = Date.parse(opts.since); + const instant = windowInstant(opts.since); // Refuse what cannot be compared to a boundary rather than guessing: git // accepts "30 days ago", but a window this tool cannot place on a timeline is // a window whose coverage it cannot prove. - if (!Number.isFinite(ms)) usage(`--since=${opts.since} is not a date this tool can place (use YYYY-MM-DD, or --days=)`); - return opts.since; + if (instant === null) usage(`--since=${opts.since} is not a date this tool can place (use YYYY-MM-DD, or --days=)`); + // What is returned is the instant the proof reads, never the raw string: git + // reads a bare YYYY-MM-DD at the current time of day (the fourth trap). + return instant; +} + +/** + * `--until`, normalised the way `--since` is: a bare `2026-10-01` reaches git + * as `2026-10-01T00:00:00Z` — "before that day began" at every hour, where the + * raw string meant "before now's time of day on it". No coverage proof reads + * `--until`, so a spelling `Date.parse` cannot place still reaches git exactly + * as given, the way it always has, and the receipt prints it as given: the + * ruling was to normalise the recommended input, not to add a refusal. + */ +function resolveUntil(opts) { + if (opts.until === undefined) return undefined; + const instant = windowInstant(opts.until); + return instant === null ? opts.until : instant; } function main(argv) { @@ -696,6 +760,7 @@ function main(argv) { if (cmd === 'touch') return touchMain(opts); const since = resolveSince(opts); + const until = resolveUntil(opts); const sinceMs = Date.parse(since); const cwd = opts.cwd || process.cwd(); @@ -711,8 +776,8 @@ function main(argv) { if (!ensured.covered) { process.stderr.write( `⛔ git-history REFUSES to answer — ${ensured.reason}.\n` + - ` ref: ${opts.ref} window: since ${String(since).slice(0, 10)}` + - `${opts.until ? ` until ${opts.until}` : ''}\n` + + ` ref: ${opts.ref} window: since ${since}` + + `${until ? ` until ${until}` : ''}\n` + ` shallow floor: ${describeFloor(ensured.boundaries)} (the oldest commit this clone can see on that ref)\n` + `${ensured.steps.length ? ` tried: ${ensured.steps.join(' · ')}\n` : ''}` + ` Any number derived here would be real, plausible and WRONG — the missing\n` + @@ -724,8 +789,8 @@ function main(argv) { const receipt = `method: ${cmd === 'log' ? 'git log' : 'git rev-list --count'}` + - `${opts.firstParent ? ' --first-parent' : ''} ${opts.ref} since ${String(since).slice(0, 10)}` + - `${opts.until ? ` until ${opts.until}` : ''}` + + `${opts.firstParent ? ' --first-parent' : ''} ${opts.ref} since ${since}` + + `${until ? ` until ${until}` : ''}` + `${opts.paths.length ? ` -- ${opts.paths.join(' ')}` : ''}` + ` · floor ${describeFloor(ensured.boundaries)} · tip ${refTip(cwd, opts.ref)} · ${ensured.steps.join(' · ')}`; @@ -738,8 +803,8 @@ function main(argv) { const pathArgs = opts.paths.length ? ['--', ...opts.paths] : []; const out = cmd === 'count' - ? git(['rev-list', '--count', ...fp, ...windowArgs({ since, until: opts.until }), opts.ref, ...pathArgs], { cwd }) - : git(['log', ...fp, `--format=${opts.format}`, ...windowArgs({ since, until: opts.until }), opts.ref, ...pathArgs], { cwd }); + ? git(['rev-list', '--count', ...fp, ...windowArgs({ since, until }), opts.ref, ...pathArgs], { cwd }) + : git(['log', ...fp, `--format=${opts.format}`, ...windowArgs({ since, until }), opts.ref, ...pathArgs], { cwd }); process.stdout.write(out.endsWith('\n') ? out : `${out}\n`); process.stderr.write(`${receipt}\n`); From 9e61bac3fce968141834e920ae3ce591e888a0b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:34:22 +0000 Subject: [PATCH 2/3] test(pm): git-history self-test pins the bare-date window at two injected clocks A new battery, floored at 16 cases, runs every bare-date pin at 06:00Z and at 18:00Z through git's own GIT_TEST_DATE_NOW, behind baselines proving the injected clock reaches raw git's approxidate: the bare --since and --until windows give the explicit-instant answer at both hours, the receipt and the refusal name the instant, and the complete-instant cases above are untouched. The roster floor moves from 4 to 5 batteries. Claude-Session: https://claude.ai/code/session_01CB6W87z22K2yjUCDyVrJRk Co-authored-by: Claude --- scripts/pm/git-history.mjs | 120 ++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/scripts/pm/git-history.mjs b/scripts/pm/git-history.mjs index 52dde922e90..838505ba352 100644 --- a/scripts/pm/git-history.mjs +++ b/scripts/pm/git-history.mjs @@ -187,12 +187,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'pure decisions': 10, 'real repos': 15, 'historyHorizon: the read-only reading the #9902 adopters call': 12, + 'bare dates: the instant the proof reads is the instant git counts': 16, 'touch: the provenance reading a shallow clone fabricates': 26, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 4; +const SELF_TEST_BATTERY_FLOOR = 5; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -944,7 +945,10 @@ function selfTest() { // additionally leaves 12 h — half the fixture's daily cadence, the widest gap // available — between it and the nearest commit stamp. `collect-release-notes.sh // --self-test`, which runs over an identical fixture in the same `lint.yml` - // step, has always spelled its window this way. + // step, has always spelled its window this way. The CLI now normalises a bare + // date itself (`windowInstant()`), but the BASELINE below hands these edges to + // raw git, which still approxidates; the 'bare dates' battery pins the + // normalisation at two injected clocks. battery('real repos'); const FIXTURE_EPOCH = '2026-06-01T12:00:00Z'; const FIXTURE_COMMITS = 40; @@ -1102,6 +1106,118 @@ function selfTest() { t('ensure proves coverage and prints no number', ens.code === 0 && ens.stdout.trim() === '', JSON.stringify(ens)); + // ── bare dates: the instant the proof reads is the instant git counts ─── + // The fourth trap in the header. git reads a bare YYYY-MM-DD at the CURRENT + // time of day; the proof reads it as that day's 00:00:00Z. So both clocks + // here are INJECTED, through git's own `GIT_TEST_DATE_NOW` (epoch seconds, + // the clock its approxidate consults), and every pin is taken at a morning + // AND an evening: a pin read at one wall-clock hour is the shape that kept + // this battery's predecessor green before noon and red after it. `TZ` is + // pinned as well, because git approxidates a bare date in LOCAL time and + // each BASELINE has to form the same way on every runner. The BASELINEs + // come first and prove the injected clock reaches git, so a pin that holds + // below holds because the tool normalised, not because the clock was ignored. + battery('bare dates: the instant the proof reads is the instant git counts'); + const BARE_SINCE = WINDOW_SINCE.slice(0, 10); // 2026-06-20; c19 landed that day at 12:00Z + const BARE_NARROW = NARROW_SINCE.slice(0, 10); // 2026-07-08; c37 landed that day at 12:00Z + const BARE_UNTIL = '2026-07-10'; // c39, the fixture's last commit, landed that day at 12:00Z + const MORNING = '2026-07-15T06:00:00Z'; + const EVENING = '2026-07-15T18:00:00Z'; + const clockEnv = (nowIso) => ({ + ...process.env, + TZ: 'UTC', + GIT_TEST_DATE_NOW: String(Math.floor(Date.parse(nowIso) / 1000)), + }); + const runCliAt = (nowIso, args, cwd) => { + const r = spawnSync(process.execPath, [self, ...args, `--cwd=${cwd}`], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: clockEnv(nowIso), + }); + return { stdout: String(r.stdout || ''), stderr: String(r.stderr || ''), code: r.status }; + }; + const rawCountAt = (nowIso, windowFlags, cwd) => execFileSync( + 'git', ['rev-list', '--count', '--first-parent', ...windowFlags, 'origin/main'], + { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: clockEnv(nowIso) }, + ).trim(); + + t('windowInstant reads a bare date as that day\'s 00:00:00Z — the instant Date.parse already gave the proof', + windowInstant(BARE_SINCE) === WINDOW_SINCE, String(windowInstant(BARE_SINCE))); + t('windowInstant hands every complete-instant edge of the battery above back BYTE-IDENTICAL, so those ' + + 'windows reach git exactly as they did before normalising existed', + [WINDOW_SINCE, WINDOW_UNTIL, NARROW_SINCE].every((e) => windowInstant(e) === e), + JSON.stringify([WINDOW_SINCE, WINDOW_UNTIL, NARROW_SINCE].map(windowInstant))); + t('an offset is restated in Z and milliseconds survive (the --days shape), so the instant never moves', + windowInstant('2026-06-20T08:00:00+08:00') === WINDOW_SINCE + && windowInstant('2026-06-20T00:00:00.250Z') === '2026-06-20T00:00:00.250Z'); + t('a spelling Date.parse cannot place is null, never guessed — resolveSince refuses it as usage, as before', + windowInstant('30 days ago') === null); + + const rawMorning = rawCountAt(MORNING, [`--since=${BARE_SINCE}`, `--until=${WINDOW_UNTIL}`], full); + const rawEvening = rawCountAt(EVENING, [`--since=${BARE_SINCE}`, `--until=${WINDOW_UNTIL}`], full); + t('BASELINE — raw git counts the bare --since window as 21 at 06:00Z and 20 at 18:00Z: the injected clock ' + + 'reaches its approxidate, and the day\'s 12:00Z commit falls out after noon (the defect, reproduced)', + rawMorning === '21' && rawEvening === '20', `morning ${rawMorning} evening ${rawEvening}`); + const rawUntilMorning = rawCountAt(MORNING, [`--since=${WINDOW_SINCE}`, `--until=${BARE_UNTIL}`], full); + const rawUntilEvening = rawCountAt(EVENING, [`--since=${WINDOW_SINCE}`, `--until=${BARE_UNTIL}`], full); + t('BASELINE — raw git counts the bare --until window the other way round, 20 at 06:00Z and 21 at 18:00Z', + rawUntilMorning === '20' && rawUntilEvening === '21', `morning ${rawUntilMorning} evening ${rawUntilEvening}`); + const rawSlashEvening = rawCountAt(EVENING, ['--since=2026/06/20', `--until=${WINDOW_UNTIL}`], full); + t('BASELINE — the bare date is one spelling of the trap, not the trap: raw git reads `2026/06/20` at ' + + '18:00Z as well, and answers 20', + rawSlashEvening === '20', `evening ${rawSlashEvening}`); + + const bareMorning = runCliAt(MORNING, ['count', `--since=${BARE_SINCE}`, `--until=${WINDOW_UNTIL}`], full); + const bareEvening = runCliAt(EVENING, ['count', `--since=${BARE_SINCE}`, `--until=${WINDOW_UNTIL}`], full); + const explicitEvening = runCliAt(EVENING, ['count', `--since=${WINDOW_SINCE}`, `--until=${WINDOW_UNTIL}`], full); + t('the tool answers the bare --since window with 21 at 06:00Z AND at 18:00Z — the explicit-instant answer, ' + + 'whatever the time of day', + bareMorning.code === 0 && bareEvening.code === 0 && bareMorning.stdout.trim() === '21' + && bareEvening.stdout.trim() === '21' && explicitEvening.stdout.trim() === '21', + JSON.stringify({ bareMorning, bareEvening, explicitEvening })); + t('and its receipt names the instant git counted from, not the bare date it was given', + bareEvening.stderr.includes(` since ${WINDOW_SINCE} until ${WINDOW_UNTIL} · `) + && !bareEvening.stderr.includes(` since ${BARE_SINCE} `), bareEvening.stderr); + + const untilMorning = runCliAt(MORNING, ['count', `--since=${WINDOW_SINCE}`, `--until=${BARE_UNTIL}`], full); + const untilEvening = runCliAt(EVENING, ['count', `--since=${WINDOW_SINCE}`, `--until=${BARE_UNTIL}`], full); + const untilExplicit = runCliAt(EVENING, ['count', `--since=${WINDOW_SINCE}`, `--until=${BARE_UNTIL}T00:00:00Z`], full); + t('a bare --until is that day\'s 00:00:00Z too: 20 at both hours, the explicit-instant answer', + untilMorning.stdout.trim() === '20' && untilEvening.stdout.trim() === '20' && untilExplicit.stdout.trim() === '20', + JSON.stringify({ untilMorning, untilEvening, untilExplicit })); + t('and the receipt names that instant as the until edge', + untilEvening.stderr.includes(` until ${BARE_UNTIL}T00:00:00Z · `), untilEvening.stderr); + + const slashEvening = runCliAt(EVENING, ['count', '--since=2026/06/20', `--until=${WINDOW_UNTIL}`], full); + t('every spelling Date.parse places is normalised the same way: `2026/06/20` answers 21 at 18:00Z and its ' + + 'receipt names 2026-06-20T00:00:00Z', + slashEvening.code === 0 && slashEvening.stdout.trim() === '21' + && slashEvening.stderr.includes(` since ${WINDOW_SINCE} `), JSON.stringify(slashEvening)); + + const shallowEvening = runCliAt(EVENING, ['count', `--since=${BARE_NARROW}`, `--until=${WINDOW_UNTIL}`, '--no-fetch'], shallowDeep); + const rawShallowEvening = rawCountAt(EVENING, [`--since=${BARE_NARROW}`, `--until=${WINDOW_UNTIL}`], shallowDeep); + t('the filing card\'s shape — a shallow clone whose floor predates the window, asked after noon — answers 3 ' + + 'without fetching, the complete-instant answer of the battery above, where the raw bare date counts 2', + shallowEvening.code === 0 && shallowEvening.stdout.trim() === narrow.stdout.trim() + && shallowEvening.stdout.trim() === '3' && /no fetch/.test(shallowEvening.stderr) && rawShallowEvening === '2', + JSON.stringify({ shallowEvening, rawShallowEvening })); + + const logEvening = runCliAt(EVENING, ['log', `--since=${BARE_SINCE}`, `--until=${WINDOW_UNTIL}`, '--format=%H'], full); + t('`log` reads the same normalised window as `count`: 21 lines at 18:00Z', + logEvening.code === 0 && logEvening.stdout.trim().split('\n').length === 21, JSON.stringify(logEvening)); + + const refusedEvening = runCliAt(EVENING, ['count', `--since=${BARE_SINCE}`, '--no-fetch'], shallowDeep); + t('a refusal names the instant too, with exit 2 and EMPTY stdout', + refusedEvening.code === 2 && refusedEvening.stdout.trim() === '' + && refusedEvening.stderr.includes(`window: since ${WINDOW_SINCE}`), JSON.stringify(refusedEvening)); + + const untilNow = runCliAt(EVENING, ['count', `--since=${WINDOW_SINCE}`, '--until=now'], full); + t('an --until Date.parse cannot place still reaches git as given, and the receipt prints it as given — ' + + 'normalising added no refusal', + untilNow.code === 0 && untilNow.stdout.trim() === '21' && untilNow.stderr.includes(' until now · '), + JSON.stringify(untilNow)); + // ── touch: the provenance reading a shallow clone fabricates ──────────── // The fixture's `charter.md` was last touched at c2; every shallow clone // cut below floors above it. Measured on real history the same way: a From 558f43fe69e12d15b9dd71b7559ef23c72ef28f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:37:50 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(design):=20predicate=20convergence=20n?= =?UTF-8?q?otes=20the=20re-taken=20=C2=A72.3=20window=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-08-14 window count went through a bare date, which git-history then passed to git verbatim and git read at the time of day of the run. Re-taken with the fixed tool the window holds 5,455 commits, not 5,364; the old tool reproduces 5,364 exactly with git's clock at 23:00Z. Both citing lines carry the note; the counts below the first stay as measured over the 5,364. Claude-Session: https://claude.ai/code/session_01CB6W87z22K2yjUCDyVrJRk Co-authored-by: Claude --- docs/design/predicate-compilation-convergence.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/predicate-compilation-convergence.md b/docs/design/predicate-compilation-convergence.md index 2024c457f25..57f75d3c5c9 100644 --- a/docs/design/predicate-compilation-convergence.md +++ b/docs/design/predicate-compilation-convergence.md @@ -151,7 +151,7 @@ Inside one package the contrast is sharper still. service-analytics keeps the #5 ### 2.3 How often the tax is paid -`node scripts/pm/git-history.mjs log --since=2026-08-14 --ref=3711e0b763` gave 5,364 first-parent commits. The receipt reads "complete clone (no fetch)". Of those commits: +`node scripts/pm/git-history.mjs log --since=2026-08-14 --ref=3711e0b763` gave 5,364 first-parent commits. The receipt reads "complete clone (no fetch)". Note (#21601): the tool then passed the bare date to git verbatim, and git read it at the time of day of the run. Re-taken with the fixed tool, the window holds 5,455 commits (receipt `since 2026-08-14T00:00:00Z · floor 2026-08-07 · tip 2026-09-29 · floor already predates the window (no fetch)`), and the old tool gives exactly 5,364 with git's clock set to 23:00Z, so the counts below miss the 91 commits of 2026-08-14 before about that hour. Of those 5,364 commits: | edited face files | commits | |---|---| @@ -681,7 +681,7 @@ This is a proposal. The build decision is the maintainer's. **Counts.** - **Face files per commit:** `git show --name-only` against the 15-file list of §1.1. -- **The window:** `node scripts/pm/git-history.mjs log --since=2026-08-14 --ref=3711e0b763`. Its receipt reads "complete clone (no fetch)". +- **The window:** `node scripts/pm/git-history.mjs log --since=2026-08-14 --ref=3711e0b763`. Its receipt reads "complete clone (no fetch)". Note (#21601): that run read the bare date at the time of day it ran; the fixed tool answers 5,455 commits for this window, not §2.3's 5,364. - **Polarity copies:** non-comment lines from `nullValueSatisfiesOperator` to the next top-level declaration after `nullSafeNegationOperand`. - **Whole-day call sites:** `git grep -c -E "nextUtcCalendarDay\(|isUnboundedAbove\("` over non-test `src`. - **Case-set wiring:** `git grep -l` over non-spec test files.