Skip to content

fix(pm): read a widening tell as a NET member/key delta, so a replaced line stops reading as an addition - #17216

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-16943-widening-tell-net-delta
Sep 9, 2026
Merged

fix(pm): read a widening tell as a NET member/key delta, so a replaced line stops reading as an addition#17216
baozhoutao merged 1 commit into
mainfrom
claude/issue-16943-widening-tell-net-delta

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #16943

Clause-②: no — this diff changes one PM gate script's classification of a diff. It adds no key, no arm, no export and no registration on any contract, published or registry surface; the only files it touches are scripts/pm/check-widening-tells.mjs and its in-file self-test, which publish nothing from any released package.

The defect, and the root cause the ruling adopted

check-clause2-carriers --pair N raises a C5 row from the widening tells computed in the sibling check-widening-tells.mjs. Those tells read added lines, one at a time, and had no way to see that a line replaced an equivalent one. Two live pairs reproduced that independently — different tells, different file kinds:

pair tell what the diff actually did readings on the card
PR #16941 T2 a form description: prose value rewritten the three constructs T2 names (z.enum / union / as const) do not occur in permission.form.ts at all; sections 4 -> 4, fields 7 -> 7; positive control defineForm = 2 fires
PR #16968 T1 a Zod key whose .describe() text grew keys 32 -> 32, declared type unchanged, seven zero-controls; positive control describe( = 1 fires

Neither row could be cleared honestly: c5WideningTell(pair, repo) reads only the pair and the repo, so the remedy sentence's "explain in the claim" moves no exit code and the only input that flips the number is a false Clause-②: yes. This PR fixes the predicate. ⛔ It changes no card's and no PR's clause-② declaration, so it does not trip the re-grade trigger triage wrote down.

What was built — a net member/key delta, not a wider pattern

⛔ Not option 1 (tighten T2). Tightening T2 cannot stop T1: two tells reproduced one root cause on different code shapes. ⛔ And not a wider tolerance for string literals — a tell that stopped firing on real closed-set additions would be the more expensive failure.

tellsInFile now carries a replacement budget, one per change block, per tell kind:

New exported helpers: memberTellKind (one classifier answering for an added line and a removed one, so the two sides of the comparison cannot drift) and changeBlocks.

Regression on BOTH recorded live pairs, from the real /pulls/N/files bytes

run before (origin/main a9550bf) after
--declaration no --files on PR #16941 exit 4, 1 row: packages/spec/src/security/permission.form.ts:30 (T2) exit 0, clean, 6 changed files read
--declaration no --files on PR #16968 exit 4, 1 row: packages/spec/src/ui/dashboard.zod.ts:825 (T1) exit 0, clean, 3 changed files read

Both file listings were fetched from the API and fed to the gate unmodified; the before column was taken from origin/main's copy of the script running on the same bytes.

Positive controls — a REAL widening still fires

Real landed commits, read as real git diff bytes, so "green" cannot mean the tell stopped working:

control what it really is before after
613bfbd3db on error-code-ledger.zod.ts fourteen new error codes registered 16 rows, exit 4 16 rows, exit 4
625b0c32b4 on platform-tool-names.ts five new members inserted into an existing closed list 5 rows, exit 4 5 rows, exit 4
233222e2ed on analytics.zod.ts two new closed sets declared, one ledger row, plus a dateRange: key whose schema was replaced 4 rows, exit 4 3 rows, exit 4

The one row that drops in the third control is the T1 on analytics.zod.ts:440: the key dateRange was already writable and the block replaced its value. The two genuine closed-set declarations and the ledger registration still fire, and the verdict is still refused.

The self-test also carries the surplus rule directly: a block removing one member and adding two reports exactly one, at the line no removal paid for.

Ablation

Mutated the budget check away (if (false ...) in front of the suppression), proved the mutation reached disk, and read the result:

anchor occurrences BEFORE mutation: 1     marker BEFORE: 0
anchor occurrences AFTER  mutation: 0     marker AFTER:  1
on-disk proof: blob b85a2e8404ad0cf4fb48f676959d0d6fdf1c1091 -> 6a9e25d8fba586526e55a5591b73896f92832e42
self-test exit=1 — 9 of 173 cases failed (the live-pair cases, the surplus cases, the T3/T4 cases and the quiet-direction case)
PR 16941 exit=4 rows=1        PR 16968 exit=4 rows=1

Restore leg: git checkout HEAD -- ... (never a bare git checkout --), then blob b85a2e8404ad0cf4fb48f676959d0d6fdf1c1091 equals HEAD's, ABLATED_16943 occurrences back to 0, git diff HEAD empty and git status --porcelain empty. The mutation script carried a trap ... EXIT INT TERM on an absolute path throughout.

The quiet direction, measured 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 in this tree's history that touch these surfaces — 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;
  • 34 now decline — 22 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. This paragraph is in the file's header too, so the next reader meets it there.

The landing surface, corrected

The dispatch named scripts/pm/check-clause2-carriers.mjs and its self-test. The C5 row is raised there, but the T1/T2 classification it renders lives entirely in scripts/pm/check-widening-tells.mjs, which the carriers file imports — there is one implementation, not two. So the fix lands in the sibling; check-clause2-carriers.mjs is unmodified, and its own self-test still passes (its C5 fixtures are addition-only and unaffected). ⛔ The region PR #17201 landed 20 minutes before this branch was cut (hasInlineClause2Key, the near-miss reason field, c2DeclarationUnreadable's missing branch) is in the other file and is untouched.

Verification

  • node scripts/pm/check-widening-tells.mjs --self-test173 cases pass, up from 150 on origin/main; the new battery #16943 — the net member/key delta: a replaced line is not a net addition declares a floor of 23 and the roster floor moves 13 -> 14 for the one battery added.
  • Gates derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on the branch tip and reconciled with --ran; counts are in the PR thread's report comment.
  • ESLint, narrowed and declared: eslint scripts/pm/check-widening-tells.mjs --no-inline-config --format json reports 1 file linted, 0 errors, 0 warnings. The narrowing excludes nothing, because this repo runs one eslint.config.mjs that never enables type-aware linting — no parserOptions.project anywhere in it (grep count 0) and no typed @typescript-eslint rules, a property the config's own header records and measures — so an edit to this file cannot move the verdict on any file it does not contain.
  • No changeset: scripts/pm/** is repo tooling. The root manifest is private: true, and no published package's files[] names a repo-root scripts path, so this diff publishes nothing from any released package.

验收备注

Noted, not filed:

  • The card's option 3 — "make the documented remedy real", a marker in the claim comment that C5 actually reads — is not implemented, and the ruling did not ask for it. This PR removes the pressure for the two reproduced classes, but a demonstrated false positive on some other shape still has no on-record clearing route, and REFUSAL_SENTENCE still offers "explain in the claim" as a door that moves no exit code (the file's header already records that at length). 承接者: whoever meets a third instance — triage's own re-grade trigger already routes that to p1.
  • SELF_TEST_BATTERY_FLOOR sits one below the roster's actual size, so exactly one battery can be deleted without redding the floor it pins. Left as found and moved 13 -> 14 to preserve that relationship rather than tighten an invariant this card did not measure. 承接者: the next PR that adds or audits a battery in this file.

Generated by Claude Code

…d line stops reading as an addition

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 9, 2026
@github-actions github-actions Bot added the size/m label Sep 9, 2026
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Gate counts, at the branch tip dfa8198058

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 32 families for this change set (scripts/pm/check-widening-tells.mjs, 1 path). All 32 were run; all 32 exit 0. Reconciliation:

✓ dispatch-gates --ran: 32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED.

0 UNRUN. The first derivation printed STALE TREE (a .github/workflows/pr-automation.yml change it derives from had landed on main), so origin/main was re-fetched — a9550bf -> 3c5f3c5 — and the derivation re-run: byte-identical, no family added or removed. The stale change is prose describing check-changeset-no-major.mjs's level axis, which stands down on a PR declaring Clause-②: no.

Also run, outside the derived set:

  • node scripts/pm/check-widening-tells.mjs --self-test173 cases pass (150 on origin/main).
  • node scripts/pm/check-clause2-carriers.mjs --self-test319 cases pass; that file is unmodified.
  • node scripts/pm/check-clause2-carriers.mjs --pair 17216exit 0 on this PR's own pair.

Every exit code above was captured before any pipe. CI convergence on this PR is not waited on here.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants