Skip to content

fix(pm): stop check-widening-tells T2 keying on a continuation operator, and price a false positive honestly - #16944

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-16822-widening-tells-direction
Sep 8, 2026
Merged

baozhoutao merged 1 commit into
mainfrom
claude/issue-16822-widening-tells-direction

Conversation

@claude

@claude claude Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Part of #16822

check:pm-widening-tells' T2 read two variables as widening evidence that carry no
information about widening. Both are repaired here, in the matcher, and the docblock
sentence that priced a false positive at "one word in the claim comment" is corrected.

Both instances, and why both

The card was filed on one instance; the os-sales comment reports a second with a
different failure mode, and repairing only the first would leave the sharper one
live and the card reading as closed.

instance 1 (objectui PR #8540) instance 2 (objectui PR #8544)
arm BARE_STRING_ELEMENT CLOSED_SET_OPENER
matched the first fragment of a +-concatenated prose string three genuine z.discriminatedUnion('type', [ declarations
spurious? yes — not a set member at all no — it saw exactly what it was built to see
honest escape respell the prose none

Instance 2 is not a false positive: the matcher fires on the constructor keyword
whether the rewrite widens or narrows, and a flat z.union → z.discriminatedUnion
conversion cannot widen (the discriminated form tries exactly one arm where the
flat form tried all ⇒ its accept set is a subset). So the two repairs are different
shapes, and neither is "T2 is wrong": SCHEMA_PROPERTY (T1), BARE_SCHEMA_ARM and
JSON_STRING_ROW declined correctly on both diffs and are untouched.

The instrument, reproduced before and after — on the real diffs, not fixtures

objectstack-ai/objectui was fetched read-only and the two PR diffs were driven
through C5's own input (splitUnifiedDiff → wideningRefusal({declaration:'no', repo:'objectstack-ai/objectui'})). Before, at the heads the card names:

=== pr8540 @ 63c9bd239 — 5 changed file(s) ===
state=refused  exit=4  tells=1
  ✗ T2 packages/types/src/zod/feedback.zod.ts:84 — a new member of a closed set …
    + 'RETIRED (objectui#8338, ADR-0049 enforce-or-remove) — `action` had two published faces whose '
=== pr8544 @ ae6886dde — 9 changed file(s) ===
state=refused  exit=4  tells=3
  ✗ T2 packages/types/src/zod/crud.zod.ts:210      + export const CRUDComponentSchema = z.discriminatedUnion('type', [
  ✗ T2 packages/types/src/zod/index.zod.ts:392     + export const AnyComponentSchema = z.discriminatedUnion('type', [
  ✗ T2 packages/types/src/zod/objectql.zod.ts:1277 + export const ObjectQLComponentSchema = z.discriminatedUnion('type', [

Row for row the four readings both comments report. After: state=clean exit=0 tells=0 on both, and no other row moved — the delta is exactly those four.

The eight-fragment arithmetic, which is the actual instrument. The message has
eight fragments and exactly one tell fired, because fragments 2-8 begin + '…' and
^[ \t]*' cannot match a leading +:

before after
as written (first fragment on its own line) 1 tell 0
the identical string, first fragment moved up onto the calling line 0 0

The two spellings of one string now agree. That is the whole claim: the tell keyed on
the accidental absence of a continuation operator, not on anything semantic.

What changed, and which direction each refinement fails in

Both refinements read only bytes the hunk already carries — an added line's
neighbours on the new-file side, and what the same hunk removed. ⛔ Neither
recovers block state and neither claims direction.
The docblock's warning stands
unweakened: a hunk is a fragment, this is a tell and never a proof.

  1. A fragment of a multi-line string concatenation is not a bare element. The
    continuation operator is now read on both sides (+ 'more' opening the next
    line, and 'prose ' + ending the previous one — both spellings are in the tree).
    Measured 2026-09-08 over packages/spec/src/**: of 6,035 lines matching the
    bare-element shape, 1,707 are followed by a + '…' continuation and 479
    follow a line ending in + — 2,186 prose fragments on the surface T2 polices.
    ⭐ Bounded in-place fix, named: this reading is a property of the LINE, not of
    one tell, so it is applied once — which also retires the same accidental variable
    on T4's bare-element arm, where the ledger's waiver rows put 116 measured prose
    continuations on a registry surface. Same defect class, same instrument, no new
    verification surface. ⛔ Only the bare-STRING shape is declined: the keyed head of
    the same prose (reason: 'Pre-gate synonym … ' +) still reads as T4, pinned.
  2. An opener that re-declares the same binding adds no value. An opener whose
    list opens on a later line declares no member; when the same hunk removes an
    opener with the identical binding prefix, the set already existed, and its
    members are still read one line each by the member tells.

Failure directions, stated rather than implied. Both decline only on positive
evidence: no neighbour, no paired removal, different hunk, different binding prefix ⇒
the tell still fires. The one quiet direction added is named in the header: a
closed-set MEMBER spelled as a multi-line concatenation ('be' / + 'ta',) is now
declined. Measured over packages/spec/src/** + packages/runtime/src/**: of 4,957
member lines in 401 array/closed-set blocks, the 116 multi-line concatenations are
all property VALUES (the ledger waiver rows' reason: prose); no member is spelled
that way.

:68 — the price, corrected

:68  * the remedy is one word in the claim comment — never a weakened rule here.

Replaced with the price actually charged: C5's exit-0 condition is "no tell, or the
declaration is not no
"
, so the only word that clears a false tell is flipping
Clause-②: no → yes — recording a widening that did not happen in a ledger read
later as evidence of direction, where it is indistinguishable from a real one.

⛔ The sentence's true half is restated, not dropped: a tell may be narrowed only on
evidence the hunk actually carries, and never in a way that trades a loud failure for
a quiet one.
⛔ Nothing here is a bypass: the #16349 ruling and the #16448 C5 row
stand, --pair's non-zero exit stays a hard block, and no suppression carrier, label
or claim-line syntax is introduced.

Ablation — the new cases can fail

Both refinements were disabled in turn, on disk, and the self-test re-run; the file
was restored from HEAD each time and the restoration proved by blob hash.

=== LEG: concat ===   on-disk blob 68ef435d…  (HEAD 6867b6e2…)   self-test exit: 1
  failing cases: 4    ✗ ⭐ an 8-fragment prose ARGUMENT is not a closed-set member …
  restored: git diff HEAD is EMPTY; blob 6867b6e2d765aa0d0ae3a824ff69ad398a70b6e4
=== LEG: opener ===   on-disk blob 4562d6dc…  (HEAD 6867b6e2…)   self-test exit: 1
  failing cases: 2    ✗ ⭐ an opener that RE-DECLARES the set the same hunk removed …
  restored: git diff HEAD is EMPTY; blob 6867b6e2d765aa0d0ae3a824ff69ad398a70b6e4

Gates

node scripts/pm/dispatch-gates.mjs --commands derived 35 families; all 35 run, all
exit 0, reconciled: ✓ dispatch-gates --ran: 35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED. Headline rows:

pnpm check:pm-widening-tells    exit=0   150 cases pass (131 before + 19 new)
pnpm check:pm-clause2-carriers  exit=0   299 cases pass
pnpm check:pm-dispatch-gates    exit=0
pnpm check:nul-bytes            exit=0
pnpm check:pm-governed-prose    exit=0

Lint is a declared narrowing, at 82c5899bbe: eslint --no-inline-config --format json over the 2 files this diff changes — 2 files linted, 0 errors, 0
warnings. Inspected population read from ESLint's own config resolution
(isPathIgnored over git ls-files): 6,379 files. Invariance: this repo runs one
eslint.config.mjs which enables no type-aware linting for any file (its own header
records the measurement), so a diff confined to these two files cannot move an
untouched file's verdict. The repo-wide pnpm lint is CI's run.

验收备注

  • skip-changeset: nothing published moves — the root package is private: true,
    no package's files[] ships scripts/, and scripts/pm/** is tooling.
  • ⚠️ File-surface deviation, declared. The claim allowed
    check-clause2-carriers.mjs "only if C5's exit condition must move". It does not
    move, and it did not. What was edited there is the twin of the repaired
    sentence
    in c5WideningTell's docblock — "a false positive costs the author one
    word in the claim comment" — in the file that actually prints the C5 row. Comment
    bytes only; needsWideningRead, pairWidening, c5WideningTell and the exit
    register are untouched, and its 299-case self-test is green. Leaving the twin would
    route the next reader down the path triage named.
  • ⚠️ Noted, not filed: REFUSAL_SENTENCE offers two doors ("re-declare yes or
    explain in the claim
    "), and C5's mechanical exit-0 condition honours only the
    first. Recorded here, ⛔ not changed — the sentence is quoted from pm gates: a "widening tell" check — a diff that ADDS a key / arm / branch to a schema or registration while its claim says Clause-②: no is refused at enqueue (mechanical control for the directional Clause-② ruling on #16349) #16448 and
    changing it is a ruling's business, not a matcher repair's.
  • ⚠️ The routing tension recorded on the card (scripts/pm/** → domain:skills vs
    this card's domain:devx) is untouched: no label was changed.
  • Reproduction note: objectui is read-only to this session (git reads, no API), so
    --pair 8540 / --pair 8544 were not re-run end to end; C5's diff half was
    driven directly on both PR diffs at the heads the card names.

🤖 Generated with Claude Code

https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU


Generated by Claude Code

…ator, and price a false positive honestly

T2 read two accidental variables as widening evidence, and #16822 measured
both against real diffs:

  * a bare-string line was a closed-set member whenever the NEXT line did not
    begin `+ '…'`. An eight-fragment prose argument produced exactly one tell —
    its first fragment — and moving that fragment onto the calling line made
    the identical string stop being a tell. The continuation operator is now
    read on both sides, so the two spellings agree.
  * `CLOSED_SET_OPENER` fired on the constructor keyword whether the rewrite
    widened the set or narrowed it. An opener whose list opens on a later line
    declares no member, so when the same hunk removes an opener with the
    identical binding prefix, the set already existed and its members — still
    read one line each — decide.

Both decline only on evidence the hunk carries; with no evidence the tell
still fires. Neither recovers block state and neither claims direction.

The docblock's "the remedy is one word in the claim comment — never a weakened
rule here" is replaced by the real price: the exit-0 condition is "no tell, or
the declaration is not `no`", so the only word that clears a false tell writes
a widening that did not happen into a governance ledger. Its true half — never
trade a loud failure for a quiet one — is restated as the standing rule. The
sibling's twin of the same sentence is corrected with it; C5's exit condition
does not move.

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 8, 2026
@github-actions github-actions Bot added the size/m label Sep 8, 2026
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT in substance — PR #16944, reviewed against the tree, ⛔ not against the report

Flip + arm withheld until the remaining legs report.

⭐ Both instances repaired, and that was the right call

The claim asked you to say whether you repaired one or both. You repaired both, and the reason is the one that matters: instance 2 had no honest escape, so fixing only instance 1 would have left the sharper failure live while the card read as closed.

⭐ And the repair for instance 2 is the right shape. It does not teach the matcher direction — a hunk cannot honestly carry that. It declines on positive evidence in the same hunk: an opener whose list opens on a later line declares no member, and when the same hunk removes an opener with the identical binding prefix, the set already existed. The members still decide, one line each.

What I verified myself, on the head

probe want got
'#16822 — the two accidental variables, and the evidence each one needs': 15 registered 1 1
isConcatenationFragment defined 1 1
rewritesExistingOpener defined 1 1
the old :68 sentence is gone 0 0
staleness control — that sentence was on origin/main 1 1
firing controls — BARE_STRING_ELEMENT and CLOSED_SET_OPENER both still declared 1, 1 1, 1
nonsense control 0 0

All ten removed lines account for: the :68 sentence (2), the old addedLines body absorbed into patchLines (5), the single-reader loop (2), and one roster string. ⛔ No matcher was deleted.

⭐ The negative controls are what make this landable

A suppression is only as good as the cases it refuses to suppress, and this self-test drives every one I would have asked for:

  • ⛔ a bare string with no continuation neighbour is still a tell — absence of evidence is not evidence
  • ⛔ a neighbour in a different hunk is not a neighbour — the hunk does not show the lines between
  • ⛔ ++ is an increment, never a concatenation
  • ⛔ only the bare-string shape declines; a keyed prose head keeps its own reading
  • ⛔ an arm the rewrite adds still fires — the members were never the suppressed part
  • ⛔ an opener with no paired removal still fires — a brand-new closed set is exactly what T2 is for
  • ⛔ a different binding prefix is not the same declaration — and adding export is itself a widening
  • ⛔ an opener carrying its members inline is never suppressed
  • ⛔ a removed opener in another hunk does not pair

⭐ Two of those go beyond the brief: export const X vs const X as a widening in its own right, and the inline-members case. Neither was asked for.

The instrument, closed in both directions

The card's arithmetic was: 8 fragments, 1 tell, and the identical string respelled gives 0. After: both spellings give 0 — the two readings of one string now agree. ⇒ the accidental variable is gone, which is the claim the arithmetic was there to make checkable. Both real objectui heads go refused/exit 4 → clean/exit 0, and the delta is exactly those four rows.

The declared breach — accepted, and it is comment bytes only

The claim allowed check-clause2-carriers.mjs "only if C5's exit condition must move". You read it, it did not move, and you edited it anyway — the twin of the repaired sentence, in the file that actually prints the C5 row. I read that patch: +9/−3, entirely inside one docblock, and it explicitly says «⛔ Nothing about this row is relaxed by saying so: the exit stays non-zero and stays a hard block.»

⇒ Accepted. Triage's instruction was that the sentence would otherwise route the next false positive back down the same path; leaving the twin intact in the louder of the two files would have defeated exactly that. ⭐ Declaring it and offering the single-hunk revert was the right way to hand me the choice.

⛔ What was not touched, correctly

#16349's directional rule, #16448's C5 row, and the hard non-zero --pair exit all stand. No suppression carrier, label or claim-line syntax was introduced. The one quiet direction the fix adds — a closed-set member spelled as a multi-line concatenation — is named in the file header with its measurement rather than buried.

Two residuals I want on the record

Checks

0 failures; six legs still running at review time. Flip + arm on green.


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

Development

Successfully merging this pull request may close these issues.

1 participant