Skip to content

fix(scripts): a dotted member path's last segment resolves at the region's top depth - #17001

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-16571-dotted-path-top-depth
Sep 9, 2026
Merged

fix(scripts): a dotted member path's last segment resolves at the region's top depth#17001
baozhoutao merged 2 commits into
mainfrom
claude/issue-16571-dotted-path-top-depth

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Part of #16571

The card is fully implemented here; the relation is Part of and not a closing keyword because
landing and the card's own closure are the PM seat's, per its dispatch. Nothing of the card's
acceptance is left over for a follow-up.

Notation in this body: a TypeScript type argument is written with SQUARE brackets
(Promise[Organization] is the generic Promise of Organization), and a path
placeholder is written as the bare word PATH. GitHub's body sanitizer deletes
tag-shaped angle-bracket tokens outright, fenced code included, so the real spelling
would not survive the write.

The defect: the gate broke its own declared grammar

parseSymbolRef's header declares that a member which is not unique in a file is named
"as a DOTTED MEMBER PATH through the object-literal nesting the member sits in", and the refusal
in readTypeSurfaceRef told the author to "Name a deeper path that resolves to exactly one."

resolveMemberPath narrowed the search to the named nesting's braces, and memberDefinitionsIn
then collected every same-named definition inside that region at any depth. A direct member
whose name recurs in a nested literal was therefore counted against itself:

type-surface-only packages/client/src/index.ts#organizations.create
  -> `organizations.create` is AMBIGUOUS: 2 `create` definitions sit inside `organizations`.
     Name a deeper path that resolves to exactly one.

The two definitions are organizations.create (direct) and organizations.teams.create (nested).
Only the nested one was addressable. For the direct one the printed remedy is unsatisfiable — it
already sits at the depth the path names, and no deeper path exists to write. Such a member could
only be left out of the marker and described in unchecked prose, which is exactly the failure mode
the dotted grammar was widened to abolish.

The fix: the last segment resolves at the region's TOP DEPTH

New definitionsAtTopDepth(structural, defs, from, to) — a definition whose name starts inside
[from, to) but inside a nested object literal opened within that region belongs to the deeper
path, not to this one. objectLiteralBodiesFor already computes every named literal's
[open, close], so it is reused with IDENT as the name: every named literal counts, and the spans
of literals nested inside those are contained in them, so one pass suffices.

Two properties this deliberately keeps:

  • It is a NARROWING of the candidate set, never a tie-break. The direct member resolves because
    the nested one stopped being a candidate for its path
    , not because ties are now broken by
    position. "Take the first" stays unreachable from here.
  • Every loud refusal stays loud. A segment opening several literals is still refused
    (resolveMemberPath, TSO-D12, untouched). A name with no top-depth definition and several nested
    ones is still refused with its count and still told to name a deeper path — because there a deeper
    path does exist (TSO-D13, untouched).

One refusal changed on purpose: a collision at the path's own depth is still refused, but no
longer prescribes a deeper path that cannot be written —
`X.y` is AMBIGUOUS: N `y` definitions sit at the TOP DEPTH of `X`, so no deeper path distinguishes them. This is the same contract breach the card was filed on, at the branch the
card did not reach; it is repaired in the same pass rather than left as a second card.

Acceptance

Both directions pinned, in one battery (TSO-D18 .. TSO-D23)

Placed beside TSO-D12/TSO-D13, on a fixture where organizations holds a direct create and a
nested teams.create. list is left alone so TSO-D13 keeps its own fixture.

case asserts
TSO-D18 organizations.create resolves to the direct member (Promise[Organization])
TSO-D19 organizations.teams.create resolves to the nested member (Promise[Team])
TSO-D20 the two readings are different — the assertion a one-direction pin cannot make
TSO-D21 firing control: on the same fixture organizations.list (0 top-depth, 2 nested) is still refused with its count
TSO-D22 nonsense control: organizations.nosuchmember is still refused by the same named finding
TSO-D23 two definitions at organizations's own depth are still refused, and the message does not say "Name a deeper path"

Battery floor ratcheted 21 -> 27 (measured, not assumed: the floor was temporarily set to 999 and
the gate printed registered 27 case(s)). SELF_TEST_BATTERY_FLOOR (roster size, 50) unchanged.

$ node scripts/check-adr-0087-registration.mjs --self-test
✓ check-adr-0087-registration --self-test: 338 assertions over real temp git repos (real scan()/assertInputs() path)   # exit 0

Re-measured by symbol on the current tree — NOT the card's number

The card measured 5-of-19 at b66530f33c; that SHA and its line numbers have drifted. Re-measured
by symbol against packages/client/src/index.ts at merge base fe2b7554a5, over #14314's nineteen
organizations.* members (the 14 its marker spells plus the 5 its prose names as unspellable):

refused resolved
before 5 14
after 0 19

The card's 5-of-19 reproduces exactly. The five are organizations.create, .update,
.listMembers, .delete, .removeMember; after the fix each resolves to its own annotation —
OrganizationCreateResult, OrganizationEchoWire, OrganizationMembersPage, OrganizationWire,
OrganizationRemoveMemberResult — matching #14314's own table row for row, and the other fourteen
did not move. organizations.teams.create still reads OrganizationTeamWire, so the pair is
distinct on the real file too, not only in the fixture.

Controls beside that zero, on the same real file and the same run:

FIRING CONTROL   organizations.nosuchmember      -> REFUSED (`organizations` is declared, but no
                                                    `nosuchmember` definition sits inside it)
NONSENSE CONTROL zzznotathing.alsonot.create     -> REFUSED (no `zzznotathing` object literal is
                                                    declared at the top of the file)

Both refuse identically before and after, so the 0 above is a reading and not a reader that
resolves everything.

Ablation — the pins can fail, proved on disk

Both legs mutate the committed file, prove the mutation reached the disk by grep-counting the
anchor, run the gate, and restore with git checkout HEAD -- PATH verified by blob-hash equality
against git rev-parse HEAD:PATH plus an empty git diff HEAD. Both scripts carry
trap RESTORE EXIT INT TERM with absolute paths. Neither leg is left in the tree.

Ablation 1 — remove the narrowing (const here = definitionsAtTopDepth(...) -> const here = defs):

ON-DISK PROOF: anchor 'const here = definitionsAtTopDepth' before=1 after=0 ; injected marker=1
mutated blob 463c292ad1... != HEAD blob 7e8f331aaf...
ABLATED self-test exit = 1 -- 1 failure(s):  TSO-D18
RESTORED: hash 7e8f331aaf... == HEAD blob; git diff HEAD empty

Ablation 2 — a "collapse" reader (return defs.filter(...) -> return defs.slice(-1), i.e.
every path takes the deepest same-named definition):

ON-DISK PROOF: filter-anchor before=1 after=0 ; injected marker=1 ; blob 58ae9e2fc6...
COLLAPSE self-test exit = 1 -- 5 failure(s): TSO-D13, TSO-D18, TSO-D20, TSO-D21, TSO-D23
RESTORED: 7e8f331aaf... == HEAD blob; git diff HEAD empty

Ablation 2 is the one that earns TSO-D20 its place: under a collapse reader TSO-D19 stays green
while TSO-D18/D20 go red. A one-direction pin really would have passed a reader in which every
spelling lands on the same definition — the acceptance criterion, measured rather than asserted.

Verification

Gate families derived mechanically from the change set rather than listed by hand:

$ node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
dispatch-gates: gate list derived from the tree of 'objectstack-ai/objectstack' at commit ...
  change set derived from git -- 1 path(s) vs merge base fe2b7554a of 'origin/main' and HEAD
  · scripts/check-adr-0087-registration.mjs
--commands: 34 command(s)

All 34 run locally, all exit 0 — including check:adr-0087-registration --base origin/main,
check:scripts-symbol-anchors, check:comment-mask-corpus, check:declaration-mirrors,
check:self-test-wired, check:changeset-gate-self-tests, check:ratchet-remedy-authority,
check:nul-bytes and check:parse-guard. The tool's own caveats are carried, not swallowed: 10
families declare a population too wide to place, 2 take a value from the workflow
(NOT MEASURED — shard attestation and test completeness), 8 apply only once a changeset path
exists, and 45 artifact rosters score silent for every card. None of those is read as a clearance;
CI owns them.

origin/main moved while this ran, so the branch was merged forward and the derivation re-taken on
the merged tree: the stale-tree warning clears, the change set is still the same single path, and the
34-command list is byte-identical to the one that was run. Every ratchet-bearing and self-test family
was then re-run on the final head 6c4b549123 and stayed green.

Lint is the WHOLE repo, not a narrowing. The full pnpm lint surface fits in one foreground run
here, so no scoping argument is needed: eslint . --no-inline-config --format json at 6c4b549123
linted 6,392 files, 0 errors, 0 warnings, exit 0, and the changed file is present in that
population (verified by filtering the JSON report for its path, exactly one entry, 0/0).

No control characters: pnpm check:nul-bytes exits 0, and an independent scan of the changed file
finds none (grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', exit 1, zero bytes of output).

No changeset: measured, not assumed

scripts/** at the repo root is on the fast track — the root package is private: true with no
files[], and no package under packages/** names check-adr-0087-registration in its files[]
(grep over every package.json, zero hits). Nothing published moves, so the PR carries
skip-changeset rather than a .changeset/*.md. pnpm check:objectui-changeset exits 0.

This PR also does not touch #14314's changeset, which is where the defect was hit and is not
part of this card. With this fix landed, the five members it describes in prose become spellable and
whoever owns that card may move them into the marker — that is a separate edit on a separate card.

验收备注

Out-of-scope observations, filed nowhere and recorded here for the accepting seat:

What a reviewer should check

  1. definitionsAtTopDepth narrows, it does not tie-break — read it beside TSO-D21, which is the
    assertion that would go green if it ever did.
  2. TSO-D12 and TSO-D13 are byte-identical to origin/main; the diff adds cases beside them and
    changes neither.
  3. The here.length > 1 message intentionally drops the "Name a deeper path" remedy. TSO-D23 pins
    its absence, not merely the refusal.

Generated by Claude Code

…on's top depth

`check-adr-0087-registration`'s dotted `type-surface-only` walker narrowed the
search to a nesting region's braces and then counted EVERY same-named definition
inside it, at any depth. A DIRECT member of an object literal whose name recurs
in a nested literal was therefore counted against itself, refused as AMBIGUOUS,
and told to "name a deeper path that resolves to exactly one" -- an instruction
that cannot be carried out, because the member already sits at the depth the path
names. Such a member had no addressable spelling at all and could only be left
out of the marker and described in unchecked prose, which is the failure the
dotted grammar was widened to abolish.

The last segment now resolves at the region's TOP DEPTH only: a definition whose
name starts inside the region but inside a nested object literal opened within it
belongs to the deeper path. This is a NARROWING of the candidate set, never a
tie-break -- the direct member resolves because the nested one stopped being a
candidate for its path, so "take the first" stays unreachable and every refusal
stays loud. A collision at the path's own depth is still refused, now without
prescribing a deeper path that does not exist.

Measured on `packages/client/src/index.ts`: 5 of the 19 `organizations.*` refs
went refused -> resolved, each to its own annotation; the other 14 did not move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
@github-actions github-actions Bot added the size/m label Sep 9, 2026
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 9, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 9, 2026 02:00
@baozhoutao
baozhoutao enabled auto-merge September 9, 2026 02:00
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit 16df17b Sep 9, 2026
36 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-16571-dotted-path-top-depth branch September 9, 2026 02:22
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.

2 participants