From dfa8198058d873f95df915e7af1c575dba529da7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 17:43:05 +0000 Subject: [PATCH] fix(pm): read a widening tell as a NET member/key delta, so a replaced line stops reading as an addition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two tells the card reproduced fire on added LINES and could not see that a line replaced an equivalent one: PR #16941 (T2, a form `description:` prose rewrite on a file with no closed set in it) and PR #16968 (T1, a Zod key whose `.describe()` text grew, keys 32 -> 32). Neither moved an accept set, and the C5 row neither raised could be cleared except by declaring `Clause-②: yes` on a change that does not widen. `tellsInFile` now reads a REPLACEMENT BUDGET per change block, per tell kind: each removed line carrying a member or key of kind K buys one added line of kind K the right not to be reported, spent in patch order, so a block that adds more than it removed still reports the surplus with its own file:line. The unit is the change block, never the hunk, so an unrelated removal three context lines away cannot pay for a real addition. Openers and prose fragments pay for nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU --- scripts/pm/check-widening-tells.mjs | 287 ++++++++++++++++++++++++++-- 1 file changed, 270 insertions(+), 17 deletions(-) diff --git a/scripts/pm/check-widening-tells.mjs b/scripts/pm/check-widening-tells.mjs index 6867b6e2d7..b85a2e8404 100644 --- a/scripts/pm/check-widening-tells.mjs +++ b/scripts/pm/check-widening-tells.mjs @@ -157,6 +157,59 @@ * new claim-line syntax exist because of this file — #16448 forbids both, and * the reader it uses is the sibling's existing `Clause-②:` reader. * + * ## The third accidental variable #16943 removed — a REPLACED line + * + * Both refinements above read a hunk's bytes and still judged one added line at + * a time, so neither could see the commonest thing a diff does: put a line back + * where an equivalent one stood. Two live pairs reproduced that independently, + * on different tells and different file kinds — PR #16941, a form's + * `description:` prose rewritten (T2, on a file containing no `z.enum`, no + * union and no `as const` at all, members 4 -> 4 and 7 -> 7), and PR #16968, a + * Zod key whose `.describe()` text grew (T1, keys 32 -> 32, the declared type + * unchanged). Neither diff moved an accept set, and neither row could be + * CLEARED: `c5WideningTell(pair, repo)` reads only the pair and the repo, so + * "explain in the claim" moves no exit code and the single input that flips the + * number is a `Clause-②: yes` that is false. ⚠️ A gate whose green is reachable + * only by lying is worse than one that is merely wrong — and #16822's own note + * above already says where a demonstrated false positive is repaired: HERE, in + * the matcher. + * + * ⛔ The fix is NOT a wider tolerance for string literals. That was the card's + * own prohibition and it is the right one: a tell that stopped firing on real + * closed-set additions would be the more expensive failure, and tightening T2's + * pattern could not have stopped T1 anyway — two tells, one root. The reading + * added instead is a NET DELTA, per change block, per tell kind: every removed + * line that carried a member or a key of kind K buys ONE added line of kind K + * the right not to be reported, spent in patch order, so a block that adds more + * than it removed still reports the SURPLUS with its own file:line. A genuine + * addition has no removal to pay for it; that is the whole sensitivity + * guarantee, and it is a property of the arithmetic rather than of a pattern. + * + * The unit is the change BLOCK — a maximal run of consecutive non-context lines + * inside one hunk, which is git's own spelling of "these lines replaced those". + * ⛔ Never the hunk: a hunk carries three context lines each side and routinely + * holds an unrelated removal at one end and a real addition at the other. The + * `FILE_SCHEMA_KEY` fixture in `--self-test` is exactly that shape and must + * keep firing — pairing across it would buy silence with the wrong coin. + * + * Two things deliberately do not pay: a closed-set OPENER (it declares no + * member — #16822 established that, and `rewritesExistingOpener` is the reading + * that judges an opener), and a removed line #16822 already declines on the OLD + * side, since deleted prose is not a member either. + * + * ⚠️ The quiet direction this buys, stated rather than left to be discovered: a + * one-for-one member RENAME inside an existing set now declines. Nothing in a + * hunk distinguishes a renamed member from a reworded string, and the ruling + * this implements is replacement-vs-net-addition, not spelling. Measured over + * the 82 commits touching these surfaces in this tree's history: of 715 change + * blocks that add a member or key line, 674 pay nothing and are untouched, 7 + * are partly paid and still report their surplus, and 34 now decline — 22 of + * those keep the identifier of the key they rewrote and all 12 of the rest are + * prose / `.describe()` rewrites. Not one is a member rename. What still + * catches a rename that slips past: `check:api-surface` on any exported name it + * moves, `check:authorable-surface` on any authorable key, and the ADR-0087 + * registries — instruments a rename must move and a rewording cannot. + * * ## Where the surfaces come from — imported, never hand-copied * * The contract SOURCE surface is `SUSPECT_TIER_GLOBS`, imported from @@ -302,6 +355,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'T1 — a new key on a Zod object schema': 14, 'T2 — a new member of a closed set': 13, '#16822 — the two accidental variables, and the evidence each one needs': 15, + '#16943 — the net member/key delta: a replaced line is not a net addition': 23, 'T3 — a new row in a published entry point': 8, 'T4 — a new registration in a registry': 10, '#16448 acceptance: the four positive controls, each with its file:line': 8, @@ -313,7 +367,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // 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 = 13; +const SELF_TEST_BATTERY_FLOOR = 14; // 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 @@ -712,6 +766,73 @@ const JSON_STRING_ROW = /^[ \t]*"/; const REGISTRATION_ROW = /^[ \t]*(?:'[^']*'|"[^"]*")[ \t]*,[ \t]*(?:\/\/.*)?$|^[ \t]*(?:'[^']+'|"[^"]+"|[A-Za-z_$][\w$]*)[ \t]*:[ \t]*\S/; +/** + * The MEMBER or KEY shape one line carries on these surfaces, or `null`. + * + * The same shapes `tellsInFile` reads, in the same precedence order, extracted + * so ONE classifier answers for an added line and for a removed one. #16943's + * net-delta reading is a comparison between the two sides, and a comparison + * whose sides are classified by two different code paths is a comparison of two + * different questions — the drift this family punishes one register over. + * + * ⛔ A closed-set OPENER is deliberately NOT a member here. An opener-only line + * declares no member (#16822 established that and dropped the tell it used to + * carry), so counting it would let a `z.union([` -> `z.enum([` rewrite pay for a + * member the same block really did add. + */ +export function memberTellKind(text, { onContractSource = false, onPublished = false, onRegistry = false } = {}) { + const s = String(text ?? ''); + if (COMMENT_LINE.test(s)) return null; + if (onRegistry && REGISTRATION_ROW.test(s)) return 'T4'; + if (onContractSource && SCHEMA_PROPERTY.test(s)) return 'T1'; + if (onContractSource && (BARE_STRING_ELEMENT.test(s) || BARE_SCHEMA_ARM.test(s))) return 'T2'; + if (onPublished && JSON_STRING_ROW.test(s)) return 'T3'; + return null; +} + +/** + * The CHANGE BLOCKS of one `patchLines` reading — maximal runs of consecutive + * non-context lines inside one hunk. + * + * ⭐ This is git's own spelling of "these lines replaced those": a unified diff + * emits a contiguous edit as one removed run followed by its added run, and a + * context line between two edits means the file keeps a line between them, so + * they are two edits and not one replacement. + * + * ⛔ The block, never the HUNK, is the unit — and the difference is not + * cosmetic. A hunk carries three lines of context on each side, so it routinely + * holds an unrelated removal at one end and a real addition at the other; the + * `FILE_SCHEMA_KEY` fixture in `--self-test` is exactly that shape (a key ADDED + * at :44 and a DIFFERENT key removed two lines later, with context between) and + * it must keep firing. Pairing across a hunk would pay for the new key with a + * removal that has nothing to do with it — a silence bought with the wrong + * coin, which is the failure direction this file refuses. + * + * Blocks never span hunks: `patchLines` carries a hunk index precisely so no + * adjacency reading can cross a boundary where the real file's lines are + * missing. + * + * @param {{ kind: string, hunk: number }[]} lines — a `patchLines` reading + * @returns {number[][]} each block's indices INTO `lines`, in patch order + */ +export function changeBlocks(lines) { + const blocks = []; + let current = null; + for (let i = 0; i < (lines?.length ?? 0); i += 1) { + const r = lines[i]; + if (r.kind === 'context') { + current = null; + continue; + } + if (current === null || current.hunk !== r.hunk) { + current = { hunk: r.hunk, indices: [] }; + blocks.push(current); + } + current.indices.push(i); + } + return blocks.map((b) => b.indices); +} + /** * Every tell one file's added lines carry. * @@ -725,9 +846,28 @@ export function tellsInFile(file, { repo = THIS_REPO } = {}) { if (file?.status === 'removed') return []; // a deleted file adds nothing. const rows = []; const lines = patchLines(file?.patch); - // The NEW file's lines, in order — added and context, which is what - // "the line before / after this one" means to the author who opens the file. - const newFile = lines.filter((r) => r.kind !== 'removed'); + // The two SIDES of the patch, each in file order. The new file's lines are + // added + context — "the line before / after this one" as the author who + // opens the file means it — and the old file's are removed + context, the + // same reading taken against the file the diff replaced. Both sides are built + // because #16822's fragment rule must judge a removed line by its OWN + // neighbours: a prose fragment on the old side is not a member either, and + // counting it would let deleted prose pay for an added member. + const newFile = []; + const oldFile = []; + const newAt = new Map(); + const oldAt = new Map(); + for (let i = 0; i < lines.length; i += 1) { + const r = lines[i]; + if (r.kind !== 'removed') { + if (r.kind === 'added') newAt.set(i, newFile.length); + newFile.push(r); + } + if (r.kind !== 'added') { + if (r.kind === 'removed') oldAt.set(i, oldFile.length); + oldFile.push(r); + } + } // What each hunk REPLACED, keyed by hunk so no reading crosses a boundary. const removedByHunk = new Map(); for (const r of lines) { @@ -735,16 +875,40 @@ export function tellsInFile(file, { repo = THIS_REPO } = {}) { if (!removedByHunk.has(r.hunk)) removedByHunk.set(r.hunk, []); removedByHunk.get(r.hunk).push(r.text); } - const neighbour = (i, step) => { - const n = newFile[i + step]; - return n && n.hunk === newFile[i].hunk ? n.text : null; + const neighbourOn = (side, idx, step) => { + const n = side[idx + step]; + return n && n.hunk === side[idx].hunk ? n.text : null; }; + const fragmentOn = (side, idx) => + typeof idx === 'number' && isConcatenationFragment(neighbourOn(side, idx, -1), neighbourOn(side, idx, 1)); const onContractSource = surfaceCovers(CONTRACT_SOURCE_SURFACES, filename, repo) && isContractSourceFile(filename); const onPublished = surfaceCovers(PUBLISHED_SURFACES, filename, repo); const onRegistry = surfaceCovers(REGISTRATION_SURFACES, filename, repo); - for (let i = 0; i < newFile.length; i += 1) { - if (newFile[i].kind !== 'added') continue; - const { line, text } = newFile[i]; + const surfaces = { onContractSource, onPublished, onRegistry }; + // #16943 — the REPLACEMENT budget, one per change block, per tell kind. + // + // Every removed line in the block that carried a member or a key of kind K + // buys ONE added line of kind K the right not to be reported: that added line + // did not grow the accept set, it took the place of something that was + // already in it. The budget is spent in patch order, so when a block adds + // MORE than it removed the SURPLUS lines — the ones no removal paid for — + // still fire, with their own file:line. That surplus is the whole sensitivity + // guarantee: a genuine addition has no removal to pay for it. + const budgetOfLine = new Map(); + for (const block of changeBlocks(lines)) { + const budget = new Map(); + for (const i of block) { + const r = lines[i]; + if (r.kind !== 'removed') continue; + if (BARE_STRING_ELEMENT.test(r.text) && fragmentOn(oldFile, oldAt.get(i))) continue; + const kind = memberTellKind(r.text, surfaces); + if (kind !== null) budget.set(kind, (budget.get(kind) ?? 0) + 1); + } + for (const i of block) budgetOfLine.set(i, budget); + } + for (let i = 0; i < lines.length; i += 1) { + if (lines[i].kind !== 'added') continue; + const { line, text, hunk } = lines[i]; if (COMMENT_LINE.test(text)) continue; // #16822 — a line that is one FRAGMENT of a multi-line string // concatenation is not a bare element of anything: not a member of a @@ -753,29 +917,43 @@ export function tellsInFile(file, { repo = THIS_REPO } = {}) { // of a continuation operator; the header states the one quiet direction // this buys. ⛔ Only the bare-STRING shape is declined — a keyed line // (`reason: 'prose ' +`) is a different reading and keeps its own tells. - if (BARE_STRING_ELEMENT.test(text) && isConcatenationFragment(neighbour(i, -1), neighbour(i, 1))) continue; + if (BARE_STRING_ELEMENT.test(text) && fragmentOn(newFile, newAt.get(i))) continue; const at = { file: filename, line, text: text.trim().slice(0, 160) }; + const kind = memberTellKind(text, surfaces); + // #16943 — a member or key this block REPLACED is not a net addition. + // + // ⛔ A line that DECLARES a closed set is never spent against the budget, + // however it also reads: an opener carries a declaration, not a member, and + // #16822's `rewritesExistingOpener` is the reading that judges it. + if (kind !== null && !CLOSED_SET_OPENER.test(text)) { + const budget = budgetOfLine.get(i); + const paid = budget?.get(kind) ?? 0; + if (paid > 0) { + budget.set(kind, paid - 1); + continue; + } + } // A DECLARED registry is read as a registry first. Its files also sit on // the contract source surface (two of the three live under // `packages/spec/src/**`), and a ledger code read as "a member of a closed // set" would be true but less useful than the reading that names the // register it was added to. One line is one row, never one per surface. - if (onRegistry && REGISTRATION_ROW.test(text)) { + if (kind === 'T4') { rows.push({ tell: 'T4', ...at, why: 'a new registration in a registry / catalog — what the runtime accepts grows with no schema file moving' }); continue; } - if (onContractSource && SCHEMA_PROPERTY.test(text)) { + if (kind === 'T1') { rows.push({ tell: 'T1', ...at, why: 'a new key on a Zod object schema — the accept set gains a spelling an author may now write' }); continue; } // #16822 — an opener that re-declares a set the same hunk removed adds no // member; the members are read below, one line each. - const opener = CLOSED_SET_OPENER.test(text) && !rewritesExistingOpener(text, removedByHunk.get(newFile[i].hunk)); - if (onContractSource && (opener || BARE_STRING_ELEMENT.test(text) || BARE_SCHEMA_ARM.test(text))) { + const opener = CLOSED_SET_OPENER.test(text) && !rewritesExistingOpener(text, removedByHunk.get(hunk)); + if (onContractSource && (opener || kind === 'T2')) { rows.push({ tell: 'T2', ...at, why: 'a new member of a closed set (z.enum / union / an `as const` array) — the accept set gains a value' }); continue; } - if (onPublished && JSON_STRING_ROW.test(text)) { + if (kind === 'T3') { rows.push({ tell: 'T3', ...at, why: 'a new row in a published entry point\'s export listing — the public surface grows (ADR-0059)' }); continue; } @@ -1209,6 +1387,80 @@ export function selfTest() { t('⛔ an opener carrying its members INLINE is not an opener-only line, so it is never suppressed', tells(rewrite('export const X = z.enum([', "export const X = z.enum(['a', 'b']);"))[0]?.tell === 'T2'); t('⛔ a removed opener in ANOTHER hunk does not pair — the evidence must be where the reader can see it', tells({ filename: 'packages/spec/src/a.zod.ts', patch: "@@ -3,1 +3,0 @@\n-export const X = z.union([\n@@ -90,0 +90,1 @@\n+export const X = z.discriminatedUnion('type', [" })[0]?.tell === 'T2'); + // -- #16943: the net member/key delta ------------------------------------- + // + // Both live instances the card measured, each reduced to the shape that made + // it fire and nothing else, plus the surplus case that is the whole + // sensitivity guarantee. ⭐ The two fixtures below are the two RECORDED + // pairs' own bytes (prose abridged, structure verbatim): PR #16941 on a form + // `description:` value, PR #16968 on a Zod key whose `.describe()` moved. + battery('#16943 — the net member/key delta: a replaced line is not a net addition'); + const LIVE_T2_PAIR = { + filename: 'packages/spec/src/security/permission.form.ts', + status: 'modified', + patch: [ + '@@ -21,7 +27,7 @@ export const permissionForm = defineForm({', + ' {', + " label: 'Identity',", + ' description:', + "- 'Permission Sets stack on top of a Profile to grant additional access. …',", + "+ 'Permission sets are the only capability container: a user gets the union of every set they hold. …',", + ' columns: 2,', + ' fields: [', + ].join('\n'), + }; + const LIVE_T1_PAIR = { + filename: 'packages/spec/src/ui/dashboard.zod.ts', + status: 'modified', + patch: [ + '@@ -800,8 +800,29 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({', + ' ', + '- /** Field name to filter on */', + "- field: z.string().describe('Field name to filter on'),", + '+ /**', + '+ * Field name to filter on — at the authoring layer it resolves against', + "+ * the object behind each bound widget's dataset (`dataset.object`).", + '+ */', + "+ field: z.string().describe('Field name to filter on — at the authoring layer it resolves …'),", + ' ', + ].join('\n'), + }; + t('⭐ the live T2 pair — a `description:` VALUE replaced in place is not a new member of a closed set', tells(LIVE_T2_PAIR).length === 0); + t('⭐ the live T1 pair — a key whose `.describe()` was rewritten is not a new key on the schema', tells(LIVE_T1_PAIR).length === 0); + t('…and the two together read CLEAN end to end, which is the exit code the card could not reach', wideningRefusal({ declaration: 'no', files: [LIVE_T2_PAIR, LIVE_T1_PAIR] }).state === 'clean'); + const surplus = { + filename: 'packages/spec/src/kernel/plugin.zod.ts', + status: 'modified', + patch: "@@ -95,3 +95,4 @@\n 'core',\n- 'legacy',\n+ 'legacy_renamed',\n+ 'workflow',\n 'ui',", + }; + t('⭐ a block that removes ONE member and adds TWO reports exactly one — the surplus is the net addition', tells(surplus).length === 1); + t('…and the row it reports is the line no removal paid for', at(surplus)[0] === 'packages/spec/src/kernel/plugin.zod.ts:97'); + t('⛔ a member added with NO removal in its block still fires — a genuine addition has nothing to pay with', tells({ filename: 'packages/spec/src/a.zod.ts', patch: patchOf(3, "+ 'workflow',") })[0]?.tell === 'T2'); + t('⛔ a removal in a DIFFERENT change block does not pay — a context line between two edits means two edits', tells({ filename: 'packages/spec/src/a.zod.ts', patch: "@@ -95,4 +95,4 @@\n- 'legacy',\n 'core',\n 'ui',\n+ 'workflow'," }).length === 1); + t('⛔ nor a removal in another HUNK — the file\'s lines between them are not shown', tells({ filename: 'packages/spec/src/a.zod.ts', patch: "@@ -95,1 +95,0 @@\n- 'legacy',\n@@ -300,0 +299,1 @@\n+ 'workflow'," }).length === 1); + t('⛔ the budget is per KIND — a removed closed-set member does not pay for an added schema KEY', tells({ filename: 'packages/spec/src/a.zod.ts', patch: "@@ -95,2 +95,2 @@\n- 'legacy',\n+ extra: z.string()," })[0]?.tell === 'T1'); + t('⛔ a removed COMMENT pays for nothing — it was never a member', tells({ filename: 'packages/spec/src/a.zod.ts', patch: "@@ -95,2 +95,2 @@\n- // 'legacy',\n+ 'workflow'," }).length === 1); + t('⛔ #16822 is read on the OLD side too: a removed prose FRAGMENT pays for nothing', tells({ filename: 'packages/spec/src/a.zod.ts', patch: "@@ -95,3 +95,2 @@\n- 'half a sentence '\n- + 'and the rest',\n+ 'workflow'," }).length === 1); + t('⛔ an OPENER is not a member: a `z.union([` → `z.enum([` rewrite cannot pay for the arm it adds', tells({ filename: 'packages/spec/src/a.zod.ts', patch: "@@ -207,3 +207,4 @@\n-export const X = z.union([\n+export const X = z.enum([\n+ 'workflow',\n ArmSchema," }).length === 1); + t('⭐ FILE_SCHEMA_KEY still fires — its removal sits across a context line, so it is a different edit', at(FILE_SCHEMA_KEY)[0] === 'packages/spec/src/kernel/manifest.zod.ts:44'); + t('a replaced REGISTRY row is not a new registration — the T4 half of the same root cause', tells({ filename: 'packages/spec/src/api/error-code-ledger.zod.ts', patch: "@@ -140,2 +140,2 @@\n- reason: 'the wording this row carried before',\n+ reason: 'the wording it carries now'," }).length === 0); + t('…and a replaced row in a published listing is not a new export — the T3 half', tells({ filename: 'packages/spec/api-surface/kernel.json', patch: '@@ -14,2 +14,2 @@\n- "PluginSchema (const)",\n+ "PluginSchema (type)",' }).length === 0); + t('⛔ but a published listing that removes one row and adds two still reports the surplus', tells({ filename: 'packages/spec/api-surface/kernel.json', patch: '@@ -14,3 +14,4 @@\n- "Gone (const)",\n+ "Renamed (const)",\n+ "WorkflowPluginSchema (const)",\n "Kept (const)",' }).length === 1); + // ⚠️ The quiet direction, asserted rather than described so the next reader + // meets it here instead of discovering it. A one-for-one member RENAME inside + // an existing set now declines: the block's member count did not move, and + // nothing in the hunk distinguishes a renamed member from a reworded string. + // What still catches it: `check:api-surface` on any exported type it moves, + // `check:authorable-surface` on any authorable key, and the ADR-0087 + // registries — all three of which a rename must move and a rewording cannot. + t('⚠️ QUIET DIRECTION — a one-for-one member rename declines; this case exists so the cost is read, not discovered', tells({ filename: 'packages/spec/src/a.zod.ts', patch: "@@ -95,2 +95,2 @@\n- 'legacy',\n+ 'legacy_renamed'," }).length === 0); + t('`changeBlocks` splits on a context line — two edits, never one replacement', changeBlocks(patchLines("@@ -95,4 +95,4 @@\n- 'a',\n ctx\n+ 'b',")).length === 2); + t('…and never spans a hunk boundary', changeBlocks(patchLines("@@ -95,1 +95,0 @@\n- 'a',\n@@ -300,0 +299,1 @@\n+ 'b',")).length === 2); + t('…while one removed run and its added run are ONE block', changeBlocks(patchLines("@@ -95,3 +95,3 @@\n- 'a',\n- 'b',\n+ 'c',")).length === 1); + t('`memberTellKind` reads a schema key as T1 and a bare element as T2 on the contract surface', memberTellKind(' extra: z.string(),', { onContractSource: true }) === 'T1' && memberTellKind(" 'workflow',", { onContractSource: true }) === 'T2'); + t('⛔ …and an OPENER as neither — an opener-only line declares no member', memberTellKind('export const X = z.union([', { onContractSource: true }) === null); + t('⛔ …and nothing at all off every surface', memberTellKind(' extra: z.string(),', {}) === null); + // -- T3 -------------------------------------------------------------------- battery('T3 — a new row in a published entry point'); t('a new export row is a tell', tells(FILE_API_SURFACE)[0]?.tell === 'T3'); @@ -1341,7 +1593,8 @@ export function selfTest() { console.log( `✓ check-widening-tells self-test: ${cases.length} cases pass (the patch reader with its ` + 'line-number directions, the unified-diff splitter, the three imported/declared surfaces, the ' + - 'four tells, the two accidental variables #16822 removed and the evidence each declines on, ' + + 'four tells, the two accidental variables #16822 removed and the evidence each declines on, ' + + 'the #16943 net member/key delta with its surplus rule and the quiet direction it buys, ' + "#16448's four positive controls each with its file:line, its negative controls — " + 'the same diffs with `yes`, and a removal-only diff with `no` — the local path composed end ' + 'to end so a binary change to a tell surface cannot read as clean — and the exit register).',