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. diff --git a/scripts/pm/git-history.mjs b/scripts/pm/git-history.mjs index a6166a33233..838505ba352 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 | @@ -160,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 @@ -315,6 +343,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 +718,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 +761,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 +777,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 +790,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 +804,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`); @@ -879,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; @@ -1037,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