Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/design/predicate-compilation-convergence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand Down Expand Up @@ -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.
Expand Down
203 changes: 192 additions & 11 deletions scripts/pm/git-history.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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=<date> or --days=<n> 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=<n>)`);
return opts.since;
if (instant === null) usage(`--since=${opts.since} is not a date this tool can place (use YYYY-MM-DD, or --days=<n>)`);
// 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) {
Expand All @@ -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();

Expand All @@ -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` +
Expand All @@ -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(' · ')}`;

Expand All @@ -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`);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading