Skip to content

Commit 55e6f14

Browse files
fix(pm): git-history hands git the instant its coverage proof reads, so a bare date no longer shrinks the window with the hour (#21657)
Fixes #21601 Clause-②: no ## What was wrong `scripts/pm/git-history.mjs` proved a window with one reading of `--since` and counted it with another: - The coverage proof reads `--since` with `Date.parse`. ECMA-262 reads a date-only `YYYY-MM-DD` as that day's `00:00:00Z`. - `windowArgs()` passed the same string to git verbatim. git's approxidate fills a missing time of day from the current wall clock. - The receipt printed `since` cut to its first ten characters, so it named the wide window while git counted the narrow one. So `--since=2026-09-30` answered fewer commits the later in the day it ran, at exit 0. The receipt was identical on every run. The bare date is the spelling the usage text recommends. Reproduced on `a7ab047cf6` with the tool as it stood at `1968d5e8`. git's clock was injected through `GIT_TEST_DATE_NOW`, the clock git's approxidate reads: | invocation (old tool) | git's clock | answer | |:--|:--|--:| | `count --since=2026-09-30 --ref=a7ab047cf6` | 00:30Z | 472 | | same | 12:45Z | **410**, the card's first reading | | same | 15:17Z | **393**, the card's second reading | | `count --since=2026-09-30T00:00:00Z --ref=a7ab047cf6` | any | 474 | Injecting the two clock times the card recorded gives its two numbers exactly. That confirms the mechanism instead of inferring it. ## The fix (the triage ruling: normalise, do not refuse) - New exported `windowInstant(raw)` returns the complete UTC instant that `Date.parse` places `raw` at. It returns null when `Date.parse` cannot place `raw`. - A bare date becomes `YYYY-MM-DDT00:00:00Z`. - A complete instant already in `Z` with whole seconds comes back byte-identical. - An offset is restated in `Z`, and milliseconds survive. That is the shape `--days` already produced. - `resolveSince()` returns that instant instead of the raw string. The proof, git and the receipt now read one value. An unplaceable `--since` is still refused as usage, exactly as before. - `--until` gets the same normalisation through a new `resolveUntil()`. A bare `2026-10-01` now means "before that day's 00:00:00Z" at every hour. - No coverage proof reads `--until`. So a spelling `Date.parse` cannot place, such as `now`, still reaches git as given, and the receipt prints it as given. - The ruling forbade a new refusal, and none was added. - The receipt and the refusal print the full normalised instant (`since 2026-09-30T00:00:00Z`) instead of the first ten characters. The window a receipt names is now the window git counted. This includes `--days`: before, its receipt printed only the date while git counted from the time of day. **One deliberate widening of the suggested route.** The dispatch suggested matching only the regex `^\d{4}-\d{2}-\d{2}$`. This PR normalises every placeable spelling through `Date.parse` instead. The same mechanism bites other spellings. Measured on a scratch fixture at 00:27Z, where one commit landed at 00:10Z on the day: | `--since=` | raw git | `Date.parse` places it at | |:--|--:|:--| | `2026-09-30` | 4 (drops 00:10Z) | 2026-09-30T00:00:00Z | | `Sep 30 2026` | 4 | 2026-09-30T00:00:00Z | | `2026/09/30` | 4 | 2026-09-30T00:00:00Z | | `30 Sep 2026` | 4 | 2026-09-30T00:00:00Z | | `2026-09-30T00:00:00Z` | 5 | 2026-09-30T00:00:00Z | A regex would have fixed one row of five. Handing git the instant the proof read fixes the whole class with the same code size. `scripts/pm/check-governed-merges.mjs`'s own `parseSince()` already hands git `new Date(Date.parse(arg)).toISOString()`, so this is the repo's existing spelling, not a new one. This rests on the bounded in-place-fix exemption: same defect class, same function, same file, the same gate family, and no other claim on the file. ## Consumers, one line each Read at `1968d5e8` with `git grep -l git-history -- scripts/`, plus the files the dispatch listed. - `scripts/check-engine-split-ratio.mjs`: **not affected**. It imports `historyHorizon()`, which takes `sinceMs` and is untouched, and runs its own `git log --since` with a complete `toISOString()` instant. `--self-test` and `--days 90` are green on this branch. - `scripts/check-ratchet-remedy-authority.mjs`: **not affected**. The only reference is a comment naming "a git-history helper" in a roster note. No call. `pnpm check:ratchet-remedy-authority` is green. - `scripts/check-step-collectors.mjs`: **not affected**. A header comment cites the past red self-test. It reads `lint.yml` step commands, which this PR does not change. `--self-test` is green. - `scripts/collect-release-notes.sh`: **not affected in any count**. It calls `ensure --no-fetch` with complete instants (`%cI` of the previous ref; `2026-06-20T00:00:00Z` in its self-test), and `ensure` counts nothing. - The refusal block it pastes into release notes now reads `window: since` with the full instant instead of ten characters. An offset `%cI` is restated in `Z`. - Its self-test greps `WITHHELD`, `shallow floor:` and `unshallow`, none of which moved. `--self-test` is green. - `scripts/pm/changeset-deadline-census.mjs`: **not affected**. It calls `historyHorizon()` with `sinceMs` taken from the card's `created_at`, with no CLI and no git window. `--self-test` is green. - `scripts/pm/check-governed-merges.mjs`: **not affected**. It imports `historyHorizon()`, and its own `parseSince()` already normalises the way this PR does. `--self-test` is green. - `scripts/pm/check-half-states.mjs`: **not affected**. Two comments mirror this file's unknown-option refusal wording, which is unchanged. `--self-test` is green. - `scripts/pm/check-harness-current.mjs`: **not affected**. It imports `isShallow` and `touchIsProvable` (the `touch` path) and reads no window. `--self-test` is green. - Also hit by the grep: - `scripts/pm/check-widening-tells.mjs` has comments citing a past `ensure --days=30` reading. That was a complete instant, so it is unaffected. - `scripts/pm/dispatch-gates.mjs` is frozen and was not edited. It names this tool's self-test command, which is unchanged. - `.github/workflows/lint.yml` runs the same `node scripts/pm/git-history.mjs --self-test` line, which now runs 79 cases (63 before). ## The two past counts, re-taken with the fixed tool **(a) #5930's T2** (the os-dev report's bare-date reading, 410). ```text $ node scripts/pm/git-history.mjs count --since=2026-09-30 --ref=a7ab047cf6 474 method: git rev-list --count --first-parent a7ab047 since 2026-09-30T00:00:00Z · floor 2026-09-20 · tip 2026-10-03 · floor already predates the window (no fetch) $ node scripts/pm/git-history.mjs count --since=2026-09-30T00:00:00Z --ref=a7ab047cf6 474 method: git rev-list --count --first-parent a7ab047 since 2026-09-30T00:00:00Z · floor 2026-09-20 · tip 2026-10-03 · floor already predates the window (no fetch) ``` The bare form also answers 474 with git's clock injected at 12:45Z and at 15:17Z. Old 410 and 393 become new 474, which equals the explicit-instant answer the design doc already cites at section 2.3's T2 row. The note on #5930 is the seat's to write. This PR does not comment there. **(b) `docs/design/predicate-compilation-convergence.md` §2.3** (`log --since=2026-08-14 --ref=3711e0b763`, 5,364 in the doc). Lines are counted from a file redirect, never a pipe; see Acceptance notes. ```text $ node scripts/pm/git-history.mjs log --since=2026-08-14 --ref=3711e0b763 # shallow container, before any deepen exit 2, stdout empty ⛔ git-history REFUSES to answer — history is truncated inside the window and '3711e0b763' names no remote to deepen from (remotes here: origin). ref: 3711e0b window: since 2026-08-14T00:00:00Z $ node scripts/pm/git-history.mjs ensure --since=2026-08-14 --ref=origin/main # the tool's own additive deepen ✓ history covers the window — method: git rev-list --count --first-parent origin/main since 2026-08-14T00:00:00Z · floor 2026-08-07 · tip 2026-10-04 · fetch --shallow-since=2026-08-07T00:00:00.000Z origin main $ node scripts/pm/git-history.mjs log --since=2026-08-14 --ref=3711e0b763 > out; wc -l out 5455 method: git log --first-parent 3711e0b since 2026-08-14T00:00:00Z · floor 2026-08-07 · tip 2026-09-29 · floor already predates the window (no fetch) ``` The explicit `--since=2026-08-14T00:00:00Z` gives the same 5,455. The old tool, bare date, reproduces the doc's figure: | git's clock | answer | |:--|--:| | 00:30Z | 5452 | | 12:00Z | 5412 | | 22:10Z | 5365 | | **23:00Z** | **5364** | | 23:50Z | 5363 | The doc's run read the window from late on 2026-08-14 and dropped the 91 commits that landed earlier that day. Both citing lines (:154 and :684) now carry a note with the re-taken 5,455. The §2.3 table is left as measured over the 5,364, and the note says so. ⛔ No silent correction. ## Reverse verification (fix committed first; every leg through `scripts/ablation-replace.mjs`) | leg | anchor, then replacement | on-disk proof | self-test | |:--|:--|:--|:--| | since | ` return instant;` replaced by ` return opts.since;` in `resolveSince()` | anchor 1 to 0, blob `838505ba` to `f01295eb` | **6 FAILED** | | until | ` return instant === null ? opts.until : instant;` replaced by ` return opts.until;` | anchor 1 to 0, blob `838505ba` to `1bc863b6` | **2 FAILED** | The six reds of the since leg: 1. The bare `--since` window gives 21 at 06:00Z and 21 at 18:00Z. Ablated, it gave 21 and 20. 2. The receipt names `since 2026-06-20T00:00:00Z`. 3. `2026/06/20` is normalised. 4. The card's shallow shape answers 3. Ablated, it gave 2. 5. `log` returns 21 lines. 6. The refusal names the instant. The two reds of the until leg are the bare `--until` count (20 at both hours; ablated, 20 at 06:00Z and 21 at 18:00Z) and its receipt. Both legs restored with blob equal to HEAD (`838505ba`) and an empty `git diff HEAD`. After the legs, the tree at HEAD runs `git-history --self-test: all cases passed.` with 79 cases. ## Self-test registration - New battery `bare dates: the instant the proof reads is the instant git counts`, declared in `SELF_TEST_BATTERIES` with a floor of 16. - The roster floor `SELF_TEST_BATTERY_FLOOR` moves from 4 to 5, and the `selfTestReachedVerdict` handshake is untouched. - The battery runs every pin at two clocks injected through `GIT_TEST_DATE_NOW`, 06:00Z and 18:00Z, with `TZ=UTC`, so it is red at every hour of the day. - Its three BASELINE cases first prove that the injected clock reaches raw git's approxidate: 21 versus 20 for the bare `--since`, 20 versus 21 for the bare `--until`, and 20 for `2026/06/20`. So the pins after them cannot pass because the clock was ignored. - The complete-instant cases of `real repos` are unchanged. One case of the new battery asserts that `windowInstant()` hands each of their edges back byte-identical. The only edit inside `real repos` is one comment sentence. ## Gates (at `558f43fe6`, the last code commit) Derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the merge base, with no paths passed. The derivation gave 34 commands. Against the dispatch's list it adds `pnpm check:doc-authoring` and `pnpm --filter @objectstack/lint run check:doc-formula-expressions`, both from the doc edit. | command | exit | the gate's own verdict line (abridged) | |:--|--:|:--| | `node scripts/pm/git-history.mjs --self-test` | 0 | `git-history --self-test: all cases passed.` (79 cases) | | `node scripts/check-ci-filter-parity.mjs` | 0 | `OK: all 20 build input(s) turbo.json declares outside the packages …` | | `node scripts/check-closing-keyword-parity.mjs` | 0 | `check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords …)` | | `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 | `✓ … 40 assertions, 5 mutations of the shipped parsers each driven to red.` | | `node scripts/check-comment-mask-corpus.mjs` | 0 | `✓ comment-mask corpus sweep …: 8119 files, 0 disagree, 0 unparseable` | | `node scripts/check-declaration-mirrors.mjs` | 0 | `OK: 10 hand-written declaration(s) agree with their modules …` | | `node scripts/check-declaration-mirrors.mjs --self-test` | 0 | `All 29 self-test cases passed.` | | `node scripts/check-engine-split-ratio.mjs --days 90` | 0 | ratio 98.4%, `history horizon: shallow clone, oldest visible commit 2026-06-29 (predates the window)`. The first run exited 2 (shallow refusal, floor 2026-08-07); after the tool's own deepen it answered | | `node scripts/check-engine-split-ratio.mjs --self-test` | 0 | `check-engine-split-ratio --self-test: all cases passed.` | | `node scripts/check-scripts-symbol-anchors.mjs` | 0 | `✅ check-scripts-symbol-anchors: 3757 anchors across 282 scripts resolve …` | | `node scripts/check-scripts-symbol-anchors.mjs --self-test` | 0 | `✅ … --self-test: every finding class provoked …` | | `node scripts/check-self-test-wired.mjs` | 0 | `✓ check-self-test-wired: every one of the 232 script(s) CI runs that ship a --self-test has that self-test run by CI …` | | `node scripts/check-self-test-wired.mjs --self-test` | 0 | `check-self-test-wired --self-test: 3 live ledger row(s) verified …` | | `node scripts/check-self-test-workflow-commands.mjs` | 0 | `✓ … no self-test CI runs prints a line the Actions runner would parse as a workflow command.` | | `node scripts/check-self-test-workflow-commands.mjs --self-test` | 0 | `… --self-test: both measured parse rules pinned …` | | `node scripts/check-whole-set-label-write.mjs` | 0 | `✓ check-whole-set-label-write: 0 violations …` | | `node scripts/check-whole-set-label-write.mjs --self-test` | 0 | `✓ … all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch)` | | `node scripts/pm/bare-root-worklist.mjs --self-test` | 0 | `OK self-test: 81 live row(s) … none stale, none missing, none contradicted …` | | `pnpm --filter @objectstack/lint run check:doc-formula-expressions` | 0 | `✓ check:doc-formula-expressions: 22 record-scoped formula example(s) across 460 files … judged clean`. The first run exited 3 (PREREQUISITE NOT MET, formula and lint not built); after the build it prescribes it passed | | `pnpm check:agent-test-spelling` | 0 | `✓ check-agent-test-spelling: 0 violations …` | | `pnpm check:bash32-floor` | 0 | `real tree: 32 shell file(s) … 0 finding(s)` | | `pnpm check:cli-command-ids` | 0 | `✓ check-cli-command-ids: 65 module(s) … examined` | | `pnpm check:cross-package-test-inputs` | 0 | `OK: 30 package(s) read outside themselves, all declared …` | | `pnpm check:doc-authoring` | 0 | `✓ doc authoring guard: 407 files clean — no bare metadata literals.` | | `pnpm check:driver-memory-census` | 0 | `check-driver-memory-census: OK …` | | `pnpm check:entry-guard` | 0 | `✓ check:entry-guard: 282 scripts/ file(s) — every entry guard goes through invoked-as.mjs …` | | `pnpm check:gitlink-declared` | 0 | `check-gitlink-declared: OK …` | | `pnpm check:nul-bytes` | 0 | `check-nul-bytes: OK (scanned 10023 text file(s) … no raw ASCII control bytes).` | | `pnpm check:parse-guard` | 0 | `✓ check:parse-guard: 281 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.` | | `pnpm check:pnpm-filter-targets` | 0 | `✓ check:pnpm-filter-targets: 155/209 --filter occurrence(s) … resolve …` | | `pnpm check:ratchet-remedy-authority` | 0 | `OK check-ratchet-remedy-authority: 272 scripts swept …` | | `pnpm check:refd-timer-probe` | 0 | `OK check-refd-timer-probe: 8114 source file(s) swept …` | | `pnpm check:watch-hint-literal` | 0 | `✓ check-watch-hint-literal: 72 declaration(s) across 4 rostered name(s) …` | | `pnpm check:pm-dispatch-gates` (detached, waited on by pid) | 0 | `✓ dispatch-gates self-test: 1976 cases pass.` and `the battery took 1249.0s on this box` | `node scripts/pm/dispatch-gates.mjs --ran ran.list` answered `✓ dispatch-gates --ran: 34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED (a DERIVED zero …)`. The consumers' own self-tests were also run, because this script is their dependency: | command | exit | verdict line | |:--|--:|:--| | `bash scripts/collect-release-notes.sh --self-test` | 0 | `collect-release-notes --self-test: all cases passed.` | | `node scripts/pm/check-harness-current.mjs --self-test` | 0 | `check-harness-current --self-test: all 26 cases passed.` | | `node scripts/pm/changeset-deadline-census.mjs --self-test` | 0 | `✓ changeset-deadline-census --self-test: all cases passed across 5 batteries …` | | `node scripts/check-step-collectors.mjs --self-test` | 0 | `✓ check-step-collectors --self-test: 191 assertions, 6 block(s) driven under a real bash -e.` | | `node scripts/pm/check-governed-merges.mjs --self-test` | 0 | `✓ check-governed-merges --self-test: 454 assertions …` | | `node scripts/pm/check-half-states.mjs --self-test` | 0 | `✓ check-half-states self-test: 4912 cases pass. …` | Lint is a proven narrowing, not a full run: - Population: `ESLint.isPathIgnored('scripts/pm/git-history.mjs')` is false, from the repo's own `eslint.config.mjs`. - File count: `eslint --no-inline-config --format json` returned 1 file with 0 errors and 0 warnings. - Invariance: the calculated config has no `parserOptions.project` or `projectService`, and the config's own comment states type-aware linting is never enabled. So this diff cannot move any untouched file's verdict. - The `.md` file is outside eslint's population, which has no markdown config. The full `pnpm lint` is left to CI. No package is touched, so there is no package build or test step. No changeset: `scripts/pm/**` and `docs/design/**` publish nothing. ## Acceptance notes - **Reported for filing (class a): `git-history.mjs log` truncates its answer when stdout is a pipe.** - Evidence: `node scripts/pm/git-history.mjs log --since=2026-08-14T00:00:00Z --ref=3711e0b763 | wc -l` printed 346 three times in a row with `PIPESTATUS[0]` 0. The same command redirected to a file gives 5,455. - Likely mechanism: `process.stdout.write()` of the whole answer followed by `process.exit()`. 346 lines is about one 64 KiB pipe buffer. - The earlier injected-clock readings that came out as 715 and 346 were this, not the window. The counts above come from a file. - Not fixed here: a different mechanism from this card, outside the ruling. It is listed in the os-dev report for the seat to file. - The no-remote refusal prints `shallow floor: unknown`. `ensureWindowCovered()`'s no-remote return carries no `boundaries`, although it read them. The refusal itself is correct; only the floor line is uninformative. An observation, not filed. - Re-taking (b) required the tool's own additive deepen of the shared clone (`fetch --shallow-since=2026-08-07`), and the `check-engine-split-ratio --days 90` gate required a second one (`--shallow-since=2026-06-29`). Both went through `git-history.mjs ensure`, so neither could shorten history. --- _Generated by [Claude Code](https://claude.ai/code/session_01CB6W87z22K2yjUCDyVrJRk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 506fb6d commit 55e6f14

2 files changed

Lines changed: 194 additions & 13 deletions

File tree

‎docs/design/predicate-compilation-convergence.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ Inside one package the contrast is sharper still. service-analytics keeps the #5
151151

152152
### 2.3 How often the tax is paid
153153

154-
`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:
154+
`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:
155155

156156
| edited face files | commits |
157157
|---|---|
@@ -681,7 +681,7 @@ This is a proposal. The build decision is the maintainer's.
681681
**Counts.**
682682

683683
- **Face files per commit:** `git show --name-only` against the 15-file list of §1.1.
684-
- **The window:** `node scripts/pm/git-history.mjs log --since=2026-08-14 --ref=3711e0b763`. Its receipt reads "complete clone (no fetch)".
684+
- **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.
685685
- **Polarity copies:** non-comment lines from `nullValueSatisfiesOperator` to the next top-level declaration after `nullSafeNegationOperand`.
686686
- **Whole-day call sites:** `git grep -c -E "nextUtcCalendarDay\(|isUnboundedAbove\("` over non-test `src`.
687687
- **Case-set wiring:** `git grep -l` over non-spec test files.

‎scripts/pm/git-history.mjs‎

Lines changed: 192 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,33 @@
122122
* the shallow FILE, so it needs no common-dir resolution from a linked
123123
* worktree, and it catches a graft from any source.
124124
*
125+
* ## The fourth trap, no graft at all: a bare date is read at NOW's time of day
126+
*
127+
* The coverage proof places `--since` with `Date.parse`, and ECMA-262 reads a
128+
* date-only `YYYY-MM-DD` as that day's 00:00:00Z. git places the SAME string
129+
* with its approxidate, which fills a missing time of day from the CURRENT
130+
* WALL CLOCK. Passed through verbatim, `--since=2026-09-30` therefore proved
131+
* one window, counted a narrower one that shrank as the day went on, and
132+
* printed the wider one in its receipt — at exit 0, on exactly the spelling
133+
* the usage text recommends (#21601). Measured on ref a7ab047cf6, the clock
134+
* injected through git's own `GIT_TEST_DATE_NOW`:
135+
*
136+
* | `count --since=…` on a7ab047cf6 | git's clock | answer |
137+
* |---------------------------------|-------------|--------|
138+
* | `2026-09-30` (bare) | 00:30Z | 472 |
139+
* | `2026-09-30` (bare) | 12:45Z | 410 |
140+
* | `2026-09-30` (bare) | 15:17Z | 393 |
141+
* | `2026-09-30T00:00:00Z` | any | 474 |
142+
*
143+
* The 410 and the 393 are the two answers the filing card recorded by hand at
144+
* those hours; the receipt was identical on all four rows. Every other
145+
* spelling git approxidates fails the same way (`Sep 30 2026`, `2026/09/30`:
146+
* a midnight to `Date.parse`, now's time of day to git), and `--until` has the
147+
* mirror image. So a window edge is normalised ONCE, by `windowInstant()`, to
148+
* the complete UTC instant `Date.parse` already put it at; that instant is
149+
* what git is handed and what the receipt prints, and a complete instant is
150+
* parsed exactly and never consults the clock.
151+
*
125152
* ## Cost, measured — because a tool nobody runs fixes nothing
126153
*
127154
* | case | wall |
@@ -160,12 +187,13 @@ const SELF_TEST_BATTERIES = Object.freeze({
160187
'pure decisions': 10,
161188
'real repos': 15,
162189
'historyHorizon: the read-only reading the #9902 adopters call': 12,
190+
'bare dates: the instant the proof reads is the instant git counts': 16,
163191
'touch: the provenance reading a shallow clone fabricates': 26,
164192
});
165193

166194
// DELETING an entry silences that battery's floor exactly as effectively as
167195
// zeroing it, so the roster's own size is pinned too.
168-
const SELF_TEST_BATTERY_FLOOR = 4;
196+
const SELF_TEST_BATTERY_FLOOR = 5;
169197

170198
// The key an assertion is filed under when no battery is open. It is not a
171199
// 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
315343

316344
// ── answering ────────────────────────────────────────────────────────────────
317345

346+
/**
347+
* A window edge as the ONE instant every reader of it uses — the coverage
348+
* proof, git, and the receipt (the fourth trap in the header).
349+
*
350+
* `Date.parse` decides the instant, because the proof already reads it that
351+
* way: a bare `2026-09-30` is that day's 00:00:00Z. It comes back as a COMPLETE
352+
* UTC instant, the one spelling git parses exactly instead of approxidating
353+
* against the current time of day. A complete instant comes back as the same
354+
* instant — byte-identical when already in `Z` with whole seconds; an offset
355+
* is restated in `Z`; whole seconds drop the `.000` — so the self-test's
356+
* complete-instant windows reach git exactly as they did before.
357+
*
358+
* @param {string} raw the edge as given on the command line
359+
* @returns {string|null} the complete instant, or null when `Date.parse` cannot place `raw`
360+
*/
361+
export function windowInstant(raw) {
362+
const ms = Date.parse(raw);
363+
if (!Number.isFinite(ms)) return null;
364+
return new Date(ms).toISOString().replace(/\.000Z$/, 'Z');
365+
}
366+
318367
function windowArgs({ since, until }) {
319368
const args = [`--since=${since}`];
320369
if (until) args.push(`--until=${until}`);
@@ -669,12 +718,28 @@ function resolveSince(opts) {
669718
return new Date(Date.now() - opts.days * 24 * 60 * 60 * 1000).toISOString();
670719
}
671720
if (opts.since === undefined) usage('--since=<date> or --days=<n> is required');
672-
const ms = Date.parse(opts.since);
721+
const instant = windowInstant(opts.since);
673722
// Refuse what cannot be compared to a boundary rather than guessing: git
674723
// accepts "30 days ago", but a window this tool cannot place on a timeline is
675724
// a window whose coverage it cannot prove.
676-
if (!Number.isFinite(ms)) usage(`--since=${opts.since} is not a date this tool can place (use YYYY-MM-DD, or --days=<n>)`);
677-
return opts.since;
725+
if (instant === null) usage(`--since=${opts.since} is not a date this tool can place (use YYYY-MM-DD, or --days=<n>)`);
726+
// What is returned is the instant the proof reads, never the raw string: git
727+
// reads a bare YYYY-MM-DD at the current time of day (the fourth trap).
728+
return instant;
729+
}
730+
731+
/**
732+
* `--until`, normalised the way `--since` is: a bare `2026-10-01` reaches git
733+
* as `2026-10-01T00:00:00Z` — "before that day began" at every hour, where the
734+
* raw string meant "before now's time of day on it". No coverage proof reads
735+
* `--until`, so a spelling `Date.parse` cannot place still reaches git exactly
736+
* as given, the way it always has, and the receipt prints it as given: the
737+
* ruling was to normalise the recommended input, not to add a refusal.
738+
*/
739+
function resolveUntil(opts) {
740+
if (opts.until === undefined) return undefined;
741+
const instant = windowInstant(opts.until);
742+
return instant === null ? opts.until : instant;
678743
}
679744

680745
function main(argv) {
@@ -696,6 +761,7 @@ function main(argv) {
696761
if (cmd === 'touch') return touchMain(opts);
697762

698763
const since = resolveSince(opts);
764+
const until = resolveUntil(opts);
699765
const sinceMs = Date.parse(since);
700766
const cwd = opts.cwd || process.cwd();
701767

@@ -711,8 +777,8 @@ function main(argv) {
711777
if (!ensured.covered) {
712778
process.stderr.write(
713779
`⛔ git-history REFUSES to answer — ${ensured.reason}.\n` +
714-
` ref: ${opts.ref} window: since ${String(since).slice(0, 10)}` +
715-
`${opts.until ? ` until ${opts.until}` : ''}\n` +
780+
` ref: ${opts.ref} window: since ${since}` +
781+
`${until ? ` until ${until}` : ''}\n` +
716782
` shallow floor: ${describeFloor(ensured.boundaries)} (the oldest commit this clone can see on that ref)\n` +
717783
`${ensured.steps.length ? ` tried: ${ensured.steps.join(' · ')}\n` : ''}` +
718784
` Any number derived here would be real, plausible and WRONG — the missing\n` +
@@ -724,8 +790,8 @@ function main(argv) {
724790

725791
const receipt =
726792
`method: ${cmd === 'log' ? 'git log' : 'git rev-list --count'}` +
727-
`${opts.firstParent ? ' --first-parent' : ''} ${opts.ref} since ${String(since).slice(0, 10)}` +
728-
`${opts.until ? ` until ${opts.until}` : ''}` +
793+
`${opts.firstParent ? ' --first-parent' : ''} ${opts.ref} since ${since}` +
794+
`${until ? ` until ${until}` : ''}` +
729795
`${opts.paths.length ? ` -- ${opts.paths.join(' ')}` : ''}` +
730796
` · floor ${describeFloor(ensured.boundaries)} · tip ${refTip(cwd, opts.ref)} · ${ensured.steps.join(' · ')}`;
731797

@@ -738,8 +804,8 @@ function main(argv) {
738804
const pathArgs = opts.paths.length ? ['--', ...opts.paths] : [];
739805
const out =
740806
cmd === 'count'
741-
? git(['rev-list', '--count', ...fp, ...windowArgs({ since, until: opts.until }), opts.ref, ...pathArgs], { cwd })
742-
: git(['log', ...fp, `--format=${opts.format}`, ...windowArgs({ since, until: opts.until }), opts.ref, ...pathArgs], { cwd });
807+
? git(['rev-list', '--count', ...fp, ...windowArgs({ since, until }), opts.ref, ...pathArgs], { cwd })
808+
: git(['log', ...fp, `--format=${opts.format}`, ...windowArgs({ since, until }), opts.ref, ...pathArgs], { cwd });
743809

744810
process.stdout.write(out.endsWith('\n') ? out : `${out}\n`);
745811
process.stderr.write(`${receipt}\n`);
@@ -879,7 +945,10 @@ function selfTest() {
879945
// additionally leaves 12 h — half the fixture's daily cadence, the widest gap
880946
// available — between it and the nearest commit stamp. `collect-release-notes.sh
881947
// --self-test`, which runs over an identical fixture in the same `lint.yml`
882-
// step, has always spelled its window this way.
948+
// step, has always spelled its window this way. The CLI now normalises a bare
949+
// date itself (`windowInstant()`), but the BASELINE below hands these edges to
950+
// raw git, which still approxidates; the 'bare dates' battery pins the
951+
// normalisation at two injected clocks.
883952
battery('real repos');
884953
const FIXTURE_EPOCH = '2026-06-01T12:00:00Z';
885954
const FIXTURE_COMMITS = 40;
@@ -1037,6 +1106,118 @@ function selfTest() {
10371106
t('ensure proves coverage and prints no number', ens.code === 0 && ens.stdout.trim() === '',
10381107
JSON.stringify(ens));
10391108

1109+
// ── bare dates: the instant the proof reads is the instant git counts ───
1110+
// The fourth trap in the header. git reads a bare YYYY-MM-DD at the CURRENT
1111+
// time of day; the proof reads it as that day's 00:00:00Z. So both clocks
1112+
// here are INJECTED, through git's own `GIT_TEST_DATE_NOW` (epoch seconds,
1113+
// the clock its approxidate consults), and every pin is taken at a morning
1114+
// AND an evening: a pin read at one wall-clock hour is the shape that kept
1115+
// this battery's predecessor green before noon and red after it. `TZ` is
1116+
// pinned as well, because git approxidates a bare date in LOCAL time and
1117+
// each BASELINE has to form the same way on every runner. The BASELINEs
1118+
// come first and prove the injected clock reaches git, so a pin that holds
1119+
// below holds because the tool normalised, not because the clock was ignored.
1120+
battery('bare dates: the instant the proof reads is the instant git counts');
1121+
const BARE_SINCE = WINDOW_SINCE.slice(0, 10); // 2026-06-20; c19 landed that day at 12:00Z
1122+
const BARE_NARROW = NARROW_SINCE.slice(0, 10); // 2026-07-08; c37 landed that day at 12:00Z
1123+
const BARE_UNTIL = '2026-07-10'; // c39, the fixture's last commit, landed that day at 12:00Z
1124+
const MORNING = '2026-07-15T06:00:00Z';
1125+
const EVENING = '2026-07-15T18:00:00Z';
1126+
const clockEnv = (nowIso) => ({
1127+
...process.env,
1128+
TZ: 'UTC',
1129+
GIT_TEST_DATE_NOW: String(Math.floor(Date.parse(nowIso) / 1000)),
1130+
});
1131+
const runCliAt = (nowIso, args, cwd) => {
1132+
const r = spawnSync(process.execPath, [self, ...args, `--cwd=${cwd}`], {
1133+
cwd,
1134+
encoding: 'utf8',
1135+
stdio: ['ignore', 'pipe', 'pipe'],
1136+
env: clockEnv(nowIso),
1137+
});
1138+
return { stdout: String(r.stdout || ''), stderr: String(r.stderr || ''), code: r.status };
1139+
};
1140+
const rawCountAt = (nowIso, windowFlags, cwd) => execFileSync(
1141+
'git', ['rev-list', '--count', '--first-parent', ...windowFlags, 'origin/main'],
1142+
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: clockEnv(nowIso) },
1143+
).trim();
1144+
1145+
t('windowInstant reads a bare date as that day\'s 00:00:00Z — the instant Date.parse already gave the proof',
1146+
windowInstant(BARE_SINCE) === WINDOW_SINCE, String(windowInstant(BARE_SINCE)));
1147+
t('windowInstant hands every complete-instant edge of the battery above back BYTE-IDENTICAL, so those '
1148+
+ 'windows reach git exactly as they did before normalising existed',
1149+
[WINDOW_SINCE, WINDOW_UNTIL, NARROW_SINCE].every((e) => windowInstant(e) === e),
1150+
JSON.stringify([WINDOW_SINCE, WINDOW_UNTIL, NARROW_SINCE].map(windowInstant)));
1151+
t('an offset is restated in Z and milliseconds survive (the --days shape), so the instant never moves',
1152+
windowInstant('2026-06-20T08:00:00+08:00') === WINDOW_SINCE
1153+
&& windowInstant('2026-06-20T00:00:00.250Z') === '2026-06-20T00:00:00.250Z');
1154+
t('a spelling Date.parse cannot place is null, never guessed — resolveSince refuses it as usage, as before',
1155+
windowInstant('30 days ago') === null);
1156+
1157+
const rawMorning = rawCountAt(MORNING, [`--since=${BARE_SINCE}`, `--until=${WINDOW_UNTIL}`], full);
1158+
const rawEvening = rawCountAt(EVENING, [`--since=${BARE_SINCE}`, `--until=${WINDOW_UNTIL}`], full);
1159+
t('BASELINE — raw git counts the bare --since window as 21 at 06:00Z and 20 at 18:00Z: the injected clock '
1160+
+ 'reaches its approxidate, and the day\'s 12:00Z commit falls out after noon (the defect, reproduced)',
1161+
rawMorning === '21' && rawEvening === '20', `morning ${rawMorning} evening ${rawEvening}`);
1162+
const rawUntilMorning = rawCountAt(MORNING, [`--since=${WINDOW_SINCE}`, `--until=${BARE_UNTIL}`], full);
1163+
const rawUntilEvening = rawCountAt(EVENING, [`--since=${WINDOW_SINCE}`, `--until=${BARE_UNTIL}`], full);
1164+
t('BASELINE — raw git counts the bare --until window the other way round, 20 at 06:00Z and 21 at 18:00Z',
1165+
rawUntilMorning === '20' && rawUntilEvening === '21', `morning ${rawUntilMorning} evening ${rawUntilEvening}`);
1166+
const rawSlashEvening = rawCountAt(EVENING, ['--since=2026/06/20', `--until=${WINDOW_UNTIL}`], full);
1167+
t('BASELINE — the bare date is one spelling of the trap, not the trap: raw git reads `2026/06/20` at '
1168+
+ '18:00Z as well, and answers 20',
1169+
rawSlashEvening === '20', `evening ${rawSlashEvening}`);
1170+
1171+
const bareMorning = runCliAt(MORNING, ['count', `--since=${BARE_SINCE}`, `--until=${WINDOW_UNTIL}`], full);
1172+
const bareEvening = runCliAt(EVENING, ['count', `--since=${BARE_SINCE}`, `--until=${WINDOW_UNTIL}`], full);
1173+
const explicitEvening = runCliAt(EVENING, ['count', `--since=${WINDOW_SINCE}`, `--until=${WINDOW_UNTIL}`], full);
1174+
t('the tool answers the bare --since window with 21 at 06:00Z AND at 18:00Z — the explicit-instant answer, '
1175+
+ 'whatever the time of day',
1176+
bareMorning.code === 0 && bareEvening.code === 0 && bareMorning.stdout.trim() === '21'
1177+
&& bareEvening.stdout.trim() === '21' && explicitEvening.stdout.trim() === '21',
1178+
JSON.stringify({ bareMorning, bareEvening, explicitEvening }));
1179+
t('and its receipt names the instant git counted from, not the bare date it was given',
1180+
bareEvening.stderr.includes(` since ${WINDOW_SINCE} until ${WINDOW_UNTIL} · `)
1181+
&& !bareEvening.stderr.includes(` since ${BARE_SINCE} `), bareEvening.stderr);
1182+
1183+
const untilMorning = runCliAt(MORNING, ['count', `--since=${WINDOW_SINCE}`, `--until=${BARE_UNTIL}`], full);
1184+
const untilEvening = runCliAt(EVENING, ['count', `--since=${WINDOW_SINCE}`, `--until=${BARE_UNTIL}`], full);
1185+
const untilExplicit = runCliAt(EVENING, ['count', `--since=${WINDOW_SINCE}`, `--until=${BARE_UNTIL}T00:00:00Z`], full);
1186+
t('a bare --until is that day\'s 00:00:00Z too: 20 at both hours, the explicit-instant answer',
1187+
untilMorning.stdout.trim() === '20' && untilEvening.stdout.trim() === '20' && untilExplicit.stdout.trim() === '20',
1188+
JSON.stringify({ untilMorning, untilEvening, untilExplicit }));
1189+
t('and the receipt names that instant as the until edge',
1190+
untilEvening.stderr.includes(` until ${BARE_UNTIL}T00:00:00Z · `), untilEvening.stderr);
1191+
1192+
const slashEvening = runCliAt(EVENING, ['count', '--since=2026/06/20', `--until=${WINDOW_UNTIL}`], full);
1193+
t('every spelling Date.parse places is normalised the same way: `2026/06/20` answers 21 at 18:00Z and its '
1194+
+ 'receipt names 2026-06-20T00:00:00Z',
1195+
slashEvening.code === 0 && slashEvening.stdout.trim() === '21'
1196+
&& slashEvening.stderr.includes(` since ${WINDOW_SINCE} `), JSON.stringify(slashEvening));
1197+
1198+
const shallowEvening = runCliAt(EVENING, ['count', `--since=${BARE_NARROW}`, `--until=${WINDOW_UNTIL}`, '--no-fetch'], shallowDeep);
1199+
const rawShallowEvening = rawCountAt(EVENING, [`--since=${BARE_NARROW}`, `--until=${WINDOW_UNTIL}`], shallowDeep);
1200+
t('the filing card\'s shape — a shallow clone whose floor predates the window, asked after noon — answers 3 '
1201+
+ 'without fetching, the complete-instant answer of the battery above, where the raw bare date counts 2',
1202+
shallowEvening.code === 0 && shallowEvening.stdout.trim() === narrow.stdout.trim()
1203+
&& shallowEvening.stdout.trim() === '3' && /no fetch/.test(shallowEvening.stderr) && rawShallowEvening === '2',
1204+
JSON.stringify({ shallowEvening, rawShallowEvening }));
1205+
1206+
const logEvening = runCliAt(EVENING, ['log', `--since=${BARE_SINCE}`, `--until=${WINDOW_UNTIL}`, '--format=%H'], full);
1207+
t('`log` reads the same normalised window as `count`: 21 lines at 18:00Z',
1208+
logEvening.code === 0 && logEvening.stdout.trim().split('\n').length === 21, JSON.stringify(logEvening));
1209+
1210+
const refusedEvening = runCliAt(EVENING, ['count', `--since=${BARE_SINCE}`, '--no-fetch'], shallowDeep);
1211+
t('a refusal names the instant too, with exit 2 and EMPTY stdout',
1212+
refusedEvening.code === 2 && refusedEvening.stdout.trim() === ''
1213+
&& refusedEvening.stderr.includes(`window: since ${WINDOW_SINCE}`), JSON.stringify(refusedEvening));
1214+
1215+
const untilNow = runCliAt(EVENING, ['count', `--since=${WINDOW_SINCE}`, '--until=now'], full);
1216+
t('an --until Date.parse cannot place still reaches git as given, and the receipt prints it as given — '
1217+
+ 'normalising added no refusal',
1218+
untilNow.code === 0 && untilNow.stdout.trim() === '21' && untilNow.stderr.includes(' until now · '),
1219+
JSON.stringify(untilNow));
1220+
10401221
// ── touch: the provenance reading a shallow clone fabricates ────────────
10411222
// The fixture's `charter.md` was last touched at c2; every shallow clone
10421223
// cut below floors above it. Measured on real history the same way: a

0 commit comments

Comments
 (0)