Skip to content

Commit 9f33c3a

Browse files
committed
fix(pm): a binary change to a tell surface is UNREAD, not narrow
`splitUnifiedDiff` wrote `additions: addedLines(patch).length` for every row, and `addedLines(null)` is empty — so a BINARY change stamped a count nobody took. `unreadFiles` skips a row that added nothing, so a binary edit to `packages/spec/api-surface/*.json` arriving through the local path reported `state: 'clean'`: the gate's own contract, declared and not enforced, inside the gate. The three states are now told apart by what the diff SAYS — a hunk gives the count, a `Binary files`/`GIT binary patch` marker gives `null` (UNKNOWN), and neither gives a real `0` (a mode-only change or a pure rename adds no line). `addedNothing` interprets both input paths in one place, so a MISSING count can never become a zero; GitHub's own `additions: 0` on a binary row is kept, because that reading was taken by something that can see the blob. The self-test pinned the two halves separately and never composed them, which is why it stayed green. Seven composed cases now drive splitUnifiedDiff into wideningRefusal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018dxq7YqsLDMeZDZ5AzsgJX
1 parent dabcb7e commit 9f33c3a

1 file changed

Lines changed: 119 additions & 18 deletions

File tree

scripts/pm/check-widening-tells.mjs

Lines changed: 119 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@
6767
* So a fixture whose added line merely LOOKS like a schema key is refused, and
6868
* the remedy is one word in the claim comment — never a weakened rule here.
6969
*
70+
* **An unread diff is not a narrow diff.** A file on a tell surface whose
71+
* content this gate could not read is reported as a GAP (exit 2), never folded
72+
* into the clean verdict — and the counting that decides it distinguishes a
73+
* count that was TAKEN from a count that is MISSING (`addedNothing`). The
74+
* failure this rule is written against is not hypothetical: the local diff
75+
* splitter briefly stamped `additions: 0` on binary rows, which made a binary
76+
* edit to a published-surface file read as clean through the very gate whose
77+
* contract this is.
78+
*
7079
* **It writes nothing and hangs no label.** Same call `check-clause2-carriers`
7180
* and `check-half-states` make: a checker that hung `needs:contract-review`
7281
* would be issuing the review verdict, which is 自查放行. ⛔ No new label and no
@@ -172,7 +181,8 @@ const ROOT = fileURLToPath(new URL('../..', import.meta.url));
172181
// must not red. A battery BELOW its floor means cases stopped running.
173182
const SELF_TEST_BATTERIES = Object.freeze({
174183
'the patch reader: added lines, and the line numbers they carry': 17,
175-
'the unified-diff splitter, for the local `git diff` path': 11,
184+
'the unified-diff splitter, for the local `git diff` path': 15,
185+
'the local path composed: an unread diff is not a narrow diff': 7,
176186
'the surfaces, imported rather than restated': 11,
177187
'T1 — a new key on a Zod object schema': 14,
178188
'T2 — a new member of a closed set': 13,
@@ -187,7 +197,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
187197

188198
// DELETING an entry silences that battery's floor exactly as effectively as
189199
// zeroing it, so the roster's own size is pinned too.
190-
const SELF_TEST_BATTERY_FLOOR = 12;
200+
const SELF_TEST_BATTERY_FLOOR = 13;
191201

192202
// The key an assertion is filed under when no battery is open. It is not a
193203
// declared battery, so it reds by the same set difference rather than silently
@@ -356,6 +366,18 @@ export function addedLines(patch) {
356366

357367
const DIFF_GIT = /^diff --git a\/(.+?) b\/(.+)$/;
358368

369+
/**
370+
* How `git diff` says "there is no text hunk because the content is BINARY".
371+
*
372+
* Both spellings, because both reach this reader: the default one-line
373+
* `Binary files a/x and b/x differ`, and the `GIT binary patch` block a
374+
* `--binary` diff emits instead. A file with neither marker AND no hunk is a
375+
* mode-only change or a pure rename — those really do add nothing, and telling
376+
* them apart from a binary is the whole point of reading the marker rather than
377+
* inferring from the missing hunk.
378+
*/
379+
const BINARY_MARKER = /^(?:Binary files .* differ|GIT binary patch)$/m;
380+
359381
/**
360382
* Split a whole `git diff` into the per-file rows this gate judges.
361383
*
@@ -364,10 +386,27 @@ const DIFF_GIT = /^diff --git a\/(.+?) b\/(.+)$/;
364386
* The row shape is GitHub's (`filename`, `status`, `patch`) so nothing
365387
* downstream can tell the two paths apart, which is what stops them drifting.
366388
*
367-
* A file with a `diff --git` header and no hunk yields `patch: null` — a
368-
* binary or mode-only change, which is UNREAD rather than empty. The caller
369-
* decides whether an unread file on a tell surface is a gap; ⛔ this function
370-
* never turns one into a clean reading.
389+
* A file with a `diff --git` header and no hunk yields `patch: null`. Whether
390+
* that is UNREAD or genuinely EMPTY is read off the diff itself, never guessed:
391+
* a `Binary files … differ` / `GIT binary patch` marker means git could not show
392+
* the content, so `additions` is `null` (UNKNOWN) and the caller reports a gap
393+
* on a tell surface; no marker and no hunk means a mode-only change or a pure
394+
* rename, which really did add nothing, so `additions` is `0` and the row is
395+
* clean. ⛔ This function never turns an unread file into a clean reading — and
396+
* the way it used to was by stamping `addedLines(null).length` on every row,
397+
* which wrote `0` for a binary change and let a binary edit to
398+
* `api-surface/*.json` pass as narrow.
399+
*
400+
* ## Why the two input paths differ here, and why that is not drift
401+
*
402+
* On the API path GitHub sends `additions: 0` for a binary row, and this file
403+
* KEEPS it: that zero is GitHub's own reading of its own object store, taken by
404+
* something that can see the blob. The local path has strictly less
405+
* information — `git diff` refused to show the content, and nothing downstream
406+
* can recover it — so it answers `null`. Same field, two producers, two
407+
* genuinely different states of knowledge; the asymmetry is *information
408+
* available*, not two readers drifting apart. `addedNothing` is where both are
409+
* interpreted, once.
371410
*/
372411
export function splitUnifiedDiff(text) {
373412
const rows = [];
@@ -376,11 +415,28 @@ export function splitUnifiedDiff(text) {
376415
const flush = () => {
377416
if (!current) return;
378417
const body = current.body.join('\n');
379-
const patch = HUNK_HEADER.test(body) || /\n@@ /.test(`\n${body}`) ? body : null;
418+
const hasHunk = HUNK_HEADER.test(body) || /\n@@ /.test(`\n${body}`);
419+
const patch = hasHunk ? body : null;
380420
// `additions` is carried so the local path answers "did this file add
381421
// anything" in the SAME field the API path answers it in — the gap
382422
// accounting below reads one field, not one per input path.
383-
rows.push({ filename: current.filename, status: current.status, patch, additions: addedLines(patch).length });
423+
//
424+
// ⛔ And it is a COUNT THAT WAS TAKEN, never a default. `addedLines(null)`
425+
// returns an empty array, so writing `addedLines(patch).length` for every
426+
// row would stamp `0` on a BINARY file — a number nobody counted — and the
427+
// gap accounting, which skips a row that added nothing, would then read a
428+
// binary change to a tell surface as CLEAN. That is the exact
429+
// declared-but-not-enforced shape this gate exists against, inside the gate
430+
// itself. So the three cases are told apart by what the diff SAYS:
431+
//
432+
// a hunk -> the count, taken from the hunk
433+
// a binary marker -> `null`, i.e. UNKNOWN — git did not show the content,
434+
// so this reader cannot say whether anything was added
435+
// neither -> `0`, a real reading: a mode-only change or a pure
436+
// rename adds no line, and git says so by emitting
437+
// no hunk AND no binary marker
438+
const additions = hasHunk ? addedLines(patch).length : BINARY_MARKER.test(body) ? null : 0;
439+
rows.push({ filename: current.filename, status: current.status, patch, additions });
384440
current = null;
385441
};
386442
for (const raw of text.split('\n')) {
@@ -497,31 +553,50 @@ export function tellsInFile(file, { repo = THIS_REPO } = {}) {
497553
return rows;
498554
}
499555

556+
/**
557+
* Did this row add NOTHING — as a fact this reader can point at?
558+
*
559+
* ⭐ The asymmetry is the safety property, and it is the same one the sibling's
560+
* `--pair-json` reader has: a MISSING count is not a zero. `additions: 0` is a
561+
* reading somebody took — GitHub's own on the API path, this file's hunk count
562+
* on the local one — and a row that added nothing owes no patch, so skipping it
563+
* keeps every rename and mode-only change out of the gap list. `null` or an
564+
* absent field is the ABSENCE of that reading, and an absent reading can never
565+
* be turned into "nothing was added" here.
566+
*
567+
* The one inference this function does make is narrow and named: a row whose
568+
* status is `renamed`, which carries NO count at all and NO patch, is a pure
569+
* rename. That shape only reaches this file from a hand-assembled document —
570+
* both real input paths carry a count — and a pure rename genuinely adds
571+
* nothing.
572+
*/
573+
export function addedNothing(file) {
574+
if (typeof file?.additions === 'number') return file.additions === 0;
575+
if (file?.additions != null) return false; // a non-number count is no count.
576+
return file?.status === 'renamed' && (file?.patch == null || file.patch === '');
577+
}
578+
500579
/**
501580
* A file this gate had to read and could not.
502581
*
503582
* Only a file ON a tell surface owes a patch: an unread `README.md` decides
504583
* nothing here, and reporting it would bury the readings that matter. A file
505-
* that IS on a surface and arrived with no patch is a gap, because "no added
506-
* line matched" and "no line was read" are the two states #4690 is about.
584+
* that IS on a surface, added something (or might have), and arrived with no
585+
* patch is a gap — because "no added line matched" and "no line was read" are
586+
* the two states this whole family keeps apart.
507587
*/
508588
export function unreadFiles(files, { repo = THIS_REPO } = {}) {
509589
const gaps = [];
510590
for (const file of files ?? []) {
511591
const filename = String(file?.filename ?? '');
512592
if (filename === '' || file?.status === 'removed') continue;
513-
// A file that ADDED NOTHING owes no patch. `additions: 0` is how both
514-
// input paths spell a pure rename and a mode-only change, and reporting
515-
// those as unread would bury the gaps that are real (a truncated patch on
516-
// a file that did add lines) under one every rename produces.
517-
if (typeof file?.additions === 'number' && file.additions === 0) continue;
518-
if (file?.status === 'renamed' && (file?.patch == null || file.patch === '')) continue;
593+
if (addedNothing(file)) continue;
594+
if (typeof file?.patch === 'string' && file.patch !== '') continue;
519595
const onSurface =
520596
(surfaceCovers(CONTRACT_SOURCE_SURFACES, filename, repo) && isContractSourceFile(filename)) ||
521597
surfaceCovers(PUBLISHED_SURFACES, filename, repo) ||
522598
surfaceCovers(REGISTRATION_SURFACES, filename, repo);
523599
if (!onSurface) continue;
524-
if (typeof file?.patch === 'string' && file.patch !== '') continue;
525600
gaps.push(filename);
526601
}
527602
return gaps;
@@ -783,6 +858,31 @@ export function selfTest() {
783858
t('…and null is what `unreadFiles` counts as a gap when it is on a surface', unreadFiles([{ filename: 'packages/spec/api-surface/kernel.json', patch: null }]).length === 1);
784859
t('⛔ a file OFF every surface with no patch is not a gap — it decides nothing here', unreadFiles([{ filename: 'README.md', patch: null }]).length === 0);
785860
t('the local path and the API path produce the same verdict on the same bytes', JSON.stringify(wideningTells(splitUnifiedDiff(twoFiles)).map((r) => r.tell)) === '["T1"]');
861+
// `additions` is a COUNT THAT WAS TAKEN. The three states below are told
862+
// apart by what the diff SAYS, and conflating them is what let a binary
863+
// change to a tell surface read as clean.
864+
t('⛔ a binary row carries additions `null` — UNKNOWN, never a fabricated 0', splitUnifiedDiff('diff --git a/i.png b/i.png\nBinary files a/i.png and b/i.png differ')[0]?.additions === null);
865+
t('…and the `GIT binary patch` spelling reads as UNKNOWN too', splitUnifiedDiff('diff --git a/i.png b/i.png\nGIT binary patch\nliteral 0\nHcmV?d00001')[0]?.additions === null);
866+
t('a MODE-ONLY change carries a real 0 — no hunk AND no binary marker is git saying nothing was added', splitUnifiedDiff('diff --git a/x b/x\nold mode 100644\nnew mode 100755')[0]?.additions === 0);
867+
t('a pure rename carries a real 0 for the same reason', splitUnifiedDiff('diff --git a/x b/y\nsimilarity index 100%\nrename from x\nrename to y')[0]?.additions === 0);
868+
869+
// -- the local path composed, end to end ----------------------------------
870+
//
871+
// The two halves below were each pinned separately before, and the defect
872+
// lived exactly between them: `patch: null` was asserted on one fixture and
873+
// "null is a gap" on a DIFFERENT, hand-built row that carried no `additions`
874+
// at all — so nothing drove a real binary row through `unreadFiles`. These
875+
// cases compose the actual functions, in the order a caller calls them.
876+
battery('the local path composed: an unread diff is not a narrow diff');
877+
const composed = (diff) => wideningRefusal({ declaration: 'no', files: splitUnifiedDiff(diff) });
878+
const BINARY_ON_SURFACE = 'diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json\nindex 111..222 100644\nBinary files a/packages/spec/api-surface/kernel.json and b/packages/spec/api-surface/kernel.json differ';
879+
t('⭐ a BINARY change to a tell surface reads INCOMPLETE, never clean', composed(BINARY_ON_SURFACE).state === 'incomplete');
880+
t('…and maps to exit 2, the one exit that must never be mistaken for 0', exitForRefusal(composed(BINARY_ON_SURFACE)) === EXIT_INCOMPLETE);
881+
t('…naming the file whose content could not be read', composed(BINARY_ON_SURFACE).gaps[0] === 'packages/spec/api-surface/kernel.json');
882+
t('⛔ but a binary change OFF every surface is clean — it decides nothing here', composed('diff --git a/docs/logo.png b/docs/logo.png\nBinary files a/docs/logo.png and b/docs/logo.png differ').state === 'clean');
883+
t('a MODE-ONLY change to a tell surface is clean — it really did add nothing', composed('diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json\nold mode 100644\nnew mode 100755').state === 'clean');
884+
t('addedNothing: a MISSING count is never a zero', addedNothing({ filename: 'x', additions: null }) === false && addedNothing({ filename: 'x' }) === false);
885+
t('…while a count that WAS taken is one', addedNothing({ filename: 'x', additions: 0 }) === true && addedNothing({ filename: 'x', additions: 3 }) === false);
786886

787887
// -- the surfaces ---------------------------------------------------------
788888
battery('the surfaces, imported rather than restated');
@@ -964,7 +1064,8 @@ export function selfTest() {
9641064
`✓ check-widening-tells self-test: ${cases.length} cases pass (the patch reader with its ` +
9651065
'line-number directions, the unified-diff splitter, the three imported/declared surfaces, the ' +
9661066
"four tells, #16448's four positive controls each with its file:line, its negative controls — " +
967-
'the same diffs with `yes`, and a removal-only diff with `no` — and the exit register).',
1067+
'the same diffs with `yes`, and a removal-only diff with `no` — the local path composed end ' +
1068+
'to end so a binary change to a tell surface cannot read as clean — and the exit register).',
9681069
);
9691070

9701071
selfTestReachedVerdict = true;

0 commit comments

Comments
 (0)