Skip to content

feat(devx): accept a dotted member path in ADR-0087 type-surface-only references - #15724

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-15627-adr-0087-dotted-member-refs
Sep 5, 2026
Merged

feat(devx): accept a dotted member path in ADR-0087 type-surface-only references#15724
os-zhuang merged 1 commit into
mainfrom
claude/issue-15627-adr-0087-dotted-member-refs

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15627

Unblocks #15451

A type-surface-only reference was a bare identifier resolved to the first same-named definition in the file. On packages/client/src/index.ts that is not a symbol identity: the file declares get 14 times and delete 10 times. So the members PR #15445 and PR #15451 actually narrowed had no addressable spelling at all, and the gate answered a true sentence about a member the diff never touched. On #15451 that closed the category outright — there was no other member to name — so a p1 declaration was blocked by the reference grammar rather than by any judgement about the claim.

Grammar

parseSymbolRef now accepts PATH + # + a dotted member path (IDENT, then any number of .IDENT), in addition to the bare identifier. It returns symbol (the last segment — the member's own name), segments (the whole path) and dotted. An empty segment (a..b, a., .a) is still refused: this is a dotted IDENT chain, not "anything with a dot in it". Bare identifiers keep their exact previous behaviour, first-same-name reading included — that is what every marker written before this means.

Reader — structural, never a line number

⛔ A wider grammar over the old first-same-name reader would be writable but wrong, which is strictly worse than the refusal it replaces. So the reader moved in the same commit.

A dotted reference is resolved by walking the object-literal nesting it names, from the top of the file, on the shared comment- and literal-masked projection (scripts/js-comment-mask.mjs, offsets preserved) with matchBracket for the bodies: oauth to applications narrows the search to the bytes between that literal's braces, and the member is looked for there.

Parent segments are matched as name: { (an object-literal property) or name = { (a class field holding one) — both spellings this SDK client actually uses. A leading dot is excluded, so this.oauth = { reached through a receiver is not claimed.

The member's definition is read by memberDefinitionsIn, the extracted core of memberReturnAnnotation, which reads exactly these declaration shapes and no others:

  name(...)              method shorthand / function-ish
  name: (...)            an arrow property
  name = (...)           a class field holding an arrow
  name: async (...)      the `async` spelling of either
  name: TYPEPARAMS(...)  a generic type-parameter list ahead of the params

followed, after the balanced parameter list, by a return annotation, an arrow or a body brace. A (...) with none of those after it is a call, not a declaration.

Zero candidates, or more than one, is reported by name and refused — never guessed at. ⛔ A line number is never the disambiguator: the two measurements on the card, one day apart, disagree about every line number in that file.

runtime-interface-only refuses a dotted path by name: it reads a top-level exported type declaration, which has no nesting to walk, and silently reading a.b.Result as Result would be the same defect one category over.

Red/green on the REAL victims

packages/client/src/index.ts (read-only here), driven through the module's exported functions.

Before, on origin/main at cd555587e — refused by the grammar, before any reading happens:

parseSymbolRef("packages/client/src/index.ts#oauth.applications.get")    -> null
parseSymbolRef("packages/client/src/index.ts#oauth.applications.delete") -> null

readDeclaredTypeSurface(text,'get')    -> { shape: '`get`, which carries NO return annotation',    type: null, erased: true }
readDeclaredTypeSurface(text,'delete') -> { shape: '`delete`, which carries NO return annotation', type: null, erased: true }

Both bare readings are about members the two PRs never touched (the first of 14 gets and the first of 10 deletes).

After — the dotted path reads the annotation at the nested definition:

#oauth.applications.get
  -> { shape: 'the return annotation of `oauth.applications.get`',
       type: 'Promise<OAuthApplication>', erased: false }

#oauth.applications.delete
  -> { shape: '`oauth.applications.delete`, which carries NO return annotation',
       type: null, erased: true }

oauth.applications.delete is unannotated at this tip — that is what it reads, quoted as measured; #15451 is the PR that annotates it, and its base-side reading is exactly this. Note the verdict now names the dotted spelling: a message about delete when the marker said oauth.applications.delete is the reading this card was filed on.

The negative control — bare behaviour is unchanged:

parseSymbolRef("…#get") -> { symbol: 'get', segments: ['get'], dotted: false }
readTypeSurfaceRef(text, that)
  -> { surface: { shape: '`get`, which carries NO return annotation', … }, refusal: null }

still the first definition in the file, byte-identical to before.

And the named refusals, on the same real file:

#oauth.applications.nosuch
  -> `oauth.applications.nosuch` does not resolve: `oauth.applications` is declared,
     but no `nosuch` definition sits inside it.
#packages.list
  -> `packages.list` does not resolve: `packages` opens 3 object literals at the top
     of the file, so the path is AMBIGUOUS.
#organizations.list
  -> `organizations.list` is AMBIGUOUS: 3 `list` definitions sit inside `organizations`.
     Name a deeper path that resolves to exactly one.

Not a one-off for oauth.applications.*

Any depth, and same-named members under different parents resolve separately. On the real file, organizations.invitations.list (line 2947 today) and organizations.teams.list (3062) each resolve to their own definition — the shape #14313 (auth.*) and #14314 (organizations.*) bind. The self-test pins it on a fixture where the two carry different annotations, so "both resolved" is distinguishable from "both landed on the same one":

organizations.invitations.list -> Promise<Invitation[]>
organizations.teams.list       -> Promise<Team[]>

ADR and gate move together

documentedCategories reads ADR-0087's own example lines, so the two are pinned to each other. Both type-surface-only example blocks in docs/adr/0087-metadata-protocol-upgrade-contract.md gain the dotted spelling as an alternative line (pure insertions — no existing line is rewritten), and predicate 4's prose gains a paragraph stating the grammar, the structural resolution, the line-number prohibition and the zero/several refusal. check-adr-0087-registration --self-test V3 (real ADR against CATEGORIES, both directions) stays green, and check:adr-links / check:adr-symbol-anchors / check:adr-anchors are green.

Self-test

New battery TSO-D (#15627), 21 assertions: the grammar half (D1-D4), the reader half with the negative control (D5-D8), the two-parents case (D9), the four refusals (D10-D13), and four end-to-end legs through the shipping scan() — the dotted marker green (D14), the bare spelling still landing on the untouched first definition and refusing (D15), an unresolvable dotted path refused by predicate 4 (D16), and runtime-interface-only refusing a dotted path (D17).

before:  ✓ check-adr-0087-registration --self-test: 304 assertions …
after:   ✓ check-adr-0087-registration --self-test: 325 assertions …

Roster floor raised 48 to 49, with the new battery declared at 21.

Ablation

Trap-guarded, absolute paths, both legs proven on disk. Mutation: keep the widened grammar, revert the resolver to the first-same-name reader — the exact shape the card forbids.

HEAD blob = 8d688da0713bfd7200e30fc49b5d424ba033e047
anchor count = 1 -> removed-text 0 / injected-text 1 ; mutated blob b0870f0bc… (differs: yes)

ABLATED   self-test exit 1 — 11 failures: TSO-D6, D7, D8, D9, D10, D11, D12, D13, D14, D16 (x2)
ABLATED   real victims:  #oauth.applications.get -> `get`, which carries NO return annotation
                         #oauth.applications.nosuch -> { surface: null, refusal: null }   (silently no answer)
                         #organizations.list -> the return annotation of `list`  (a DIFFERENT member's)

RESTORE   removed-text 1 / injected-text 0 ; restored blob 8d688da0713bfd7200e30fc49b5d424ba033e047
          == HEAD blob, and `git diff HEAD -- TARGET` empty
RESTORED  self-test exit 0 — 325 assertions
RESTORED  real victims: Promise<OAuthApplication> / NO return annotation / the three named refusals

The ablated victim readings are the point: they are not errors, they are well-formed wrong answers.

Gates

Derived on the final head 0a3b91735 with node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack (family byte-identical to the earlier derivation): 40 commands, all exit 0, exit codes captured before any pipe. Includes check:adr-0087-registration (self-test and --base origin/main), check:changeset-gate-self-tests, check:adr-anchors, check:adr-links, check:adr-symbol-anchors, check:comment-mask-corpus, check:self-test-wired, check:parse-guard, check:entry-guard, check:doc-authoring, check:pm-governed-merges, check:nul-bytes, check:pm-dispatch-gates.

Verdict lines quoted from the final-head runs:

✓ check-adr-0087-registration --self-test: 325 assertions over real temp git repos (real scan()/assertInputs() path)
✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (0 non-breaking changeset(s) seen).
check-nul-bytes: OK (scanned 7592 text file(s) -- 7592 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).
✓ dispatch-gates self-test: 1445 cases pass.

Declared narrowings

  • Repo-wide pnpm lint: narrowed to the changed files. Receiving population read from eslint's own config, not guessed: this repo runs one eslint.config.mjs, which never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated in that config's own header). File count read from --format json: 1 file linted for scripts/check-adr-0087-registration.mjs, 0 errors 0 warnings; the ADR reports File ignored because no matching configuration was supplied, so eslint's own config excludes it. Invariance: with no type-aware linting, this diff cannot move the verdict on any file it does not touch. CI runs the full sweep regardless.
  • Shared verify lock: the heavy leg (the 40-command family, self-test included) ran under os-verify-lock.shVERDICT command-exit 0 · held the lock 592s. Two later attempts hit VERDICT queue-timeout (exit 99) · never acquired · waited 540s behind another card's holder (recorded as NOT MEASURED, not as red). The remaining ten single-file node gates and the ablation were then run unlocked — the lock excludes only other locked runs, never the check:* gates themselves, and what is read here is exit codes rather than wall-clock.

NOT MEASURED

  • The first pass of nine gates returned PREREQUISITE NOT MET (fresh worktree, no node_modules). All nine were re-run after pnpm install (and, for check:doc-formula-expressions, after building @objectstack/formula and @objectstack/lint) and are counted only from those runs.
  • Three families in the derived list take a value from the workflow (check-cross-package-test-inputs, check-shard-attestation, check-test-completeness with $RUNNER_TEMP argv) and cannot run outside CI.
  • 37 artifact-roster families score silent for every card in the tree; 6 of them keep their roster under scripts/. Their silence is not a clearance in either direction.

Scope

No changeset: scripts/ and docs/adr/ publish nothing from any package, so skip-changeset is applied. packages/client/src/index.ts is read-only here. Adjacent but deliberately not folded in: #15489 (landed) and #14502.

Governed surface (docs/adr/**): draft, human merge — review requested from os-zhuang and hotlong

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

… references

A `type-surface-only` reference was a bare identifier resolved to the FIRST
same-named definition in the file. On `packages/client/src/index.ts` that is not
a symbol identity: the file declares `get` 14 times and `delete` 10 times, so the
members two PRs actually narrowed had no addressable spelling at all, and the gate
answered a true sentence about a member the diff never touched. One card's whole
category was closed by the reference grammar rather than by a judgement about the
claim.

The grammar now also accepts a dotted member path, and the READER was widened in
the same commit: a dotted reference is resolved STRUCTURALLY through the
object-literal nesting it names, walked from the top of the file over the shared
comment- and literal-masked projection with offsets preserved, and the member's
definition is taken from inside the resolved body. Zero candidates, or more than
one, is reported by name and refused -- never guessed at. A line number is never
the disambiguator; this file's line numbers were measured to rot within one day.

Bare identifiers keep their exact previous behaviour, including the first-same-name
reading, which is what every marker written before this means. `runtime-interface-only`
refuses a dotted path by name: it reads a top-level type declaration, which has no
nesting to walk.

ADR-0087's marker grammar and this gate's accepted grammar are pinned to each other,
so both example blocks and the predicate-4 prose move in the same commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@os-zhuang
os-zhuang marked this pull request as ready for review September 5, 2026 10:23
@os-zhuang
os-zhuang enabled auto-merge September 5, 2026 10:23
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 21476d7 Sep 5, 2026
36 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-15627-adr-0087-dotted-member-refs branch September 5, 2026 10:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants