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

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.sh — VERDICT 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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…bjectstack-ai#17776)

Fixes objectstack-ai#17279

## The defect

PR objectstack-ai#15724 closed objectstack-ai#15627 by widening a `type-surface-only` reference to a
**dotted member path**, so an author can name a nested member instead of
a bare identifier. The walker it added narrows each leading segment
through an **object literal only**. A member declared on a **class**
therefore has no dotted spelling, and the bare fallback is "the first
same-named definition in the file" — which is the defect objectstack-ai#15627 was
filed on, surviving its own fix on the container kind that fix did not
cover.

Reproduced at the merge base on the card's own evidence,
`packages/objectql/src/engine.ts` (read-only here, not edited):

| spelling | before | after |
|---|---|---|
| `packages/objectql/src/engine.ts#delete` (bare) | `Promise<boolean \|
number>` — `ObjectQL.delete`, which the card's diff never touched |
unchanged |
| `packages/objectql/src/engine.ts#ObjectRepository.delete` | refused:
``no `ObjectRepository` object literal (`ObjectRepository: {` or
`ObjectRepository = {`) is declared at the top of the file`` |
`Promise<any>` |
| `packages/objectql/src/engine.ts#ObjectRepository.findOne` | same
refusal | `Promise<Record<string, any> \| null>` |
| `packages/objectql/src/engine.ts#ObjectRepository.update` | same
refusal | `Promise<Record<string, any> \| number \| null>` |

The first two rows are the load-bearing pair: the two spellings reach
**different members** (`Promise<boolean | number>` vs `Promise<any>`),
so the bare one is not a usable substitute — it answers a true sentence
about the wrong member.

**Lit control for that probe:** the object-literal case objectstack-ai#15724 rescued,
`packages/client/src/index.ts#oauth.applications.get`, resolves to
`Promise<OAuthApplication>` both before and after. A probe that found
nothing for classes was one that could find something for literals.

## ⭐ The population, established before fixing (triage's gate)

Triage asked how many published narrowings sit on class members today,
and made the answer decide how this PR is written. **It is not zero.**

Method: enumerate every `adr-0087: not-required (type-surface-only …)`
marker in the tree with `git grep` (markers are copied verbatim from
`.changeset/*.md` into each package's `CHANGELOG.md`, so the tree at
HEAD carries the published ones too), split the comma-separated
reference lists, then classify each named symbol's innermost container
by walking class / interface / enum bodies and named object literals
over the repo's own comment- and literal-masked projection.

**Space searched:** every tracked file at HEAD — which is where markers
live: `.changeset/*.md` (4 files), `packages/*/CHANGELOG.md` (4 files),
`docs/adr/0087-*.md` and this gate's own fixtures. Placeholder
references (`path/to/file.ts#Symbol` and the angle-bracket forms) were
excluded.

**Result — 42 real references:**

| container of the named symbol | count |
|---|---|
| object literal | 30 |
| **class** | **10** |
| top level | 1 |
| not a member definition (a top-level type name) | 1 |

The ten class members:

- `packages/drivers/driver-sql/src/sql-driver.ts#aggregate`,
`#bulkCreate`, `#create`, `#findOne`, `#update` — all on `class
SqlDriver`
- `packages/drivers/driver-turso/src/turso-driver.ts#aggregate`,
`#bulkCreate`, `#create`, `#findOne`, `#update` — all on `class
TursoDriver`

⭐ **Every one of the ten is written BARELY, and not one of them could
have been written dotted.** Each resolves today only because that name
happens to be unique in its file (measured: 1 same-named definition
each); the dotted spelling `SqlDriver.findOne` was refused for all ten
with the object-literal message. So the category has been in live use on
class members all along, addressable only by an accident of naming — and
on the one file where the accident does not hold, `engine.ts`, the
member had no spelling at all.

**Lit controls for the census probe** (a zero is a reading only if the
instrument could have come back the other way):

- class: `packages/objectql/src/engine.ts#findOne` → 2 definitions,
`class ObjectQL` (L9844) and `class ObjectRepository` (L15072). Lit.
- object literal: `packages/client/src/index.ts#oauth.applications.get`
→ 14 definitions across nested literals inside `class
ObjectStackClient`. Lit.
- top level: `scripts/check-adr-0087-registration.mjs#parseSymbolRef` →
1, TOP LEVEL. Lit.

⇒ The gap is **live, not latent**. This PR unblocks the `engine.ts`
shape outright, and gives the ten existing references a spelling that
survives a same-named member being added above them.

## The change

`resolveMemberPath` walks each leading segment through
`containerBodiesFor`, the union of `objectLiteralBodiesFor` and the new
`classBodiesFor`. A union, not two passes: a segment naming **one
literal and one class** is AMBIGUOUS exactly as two literals are, and
counting the kinds separately would let it through as "one of each".

- `class`, `export class`, `export default class`, `declare` and
`abstract` prefixes; the body is found with the existing
`declarationBodyStart`, so `extends Base<{ a: 1 }>` does not send the
walk into a type argument.
- A **class expression** (`const X = class { … }`) is deliberately not
walked — that name belongs to the binding, not the class.
- `definitionsAtTopDepth` now treats any nested container as deeper
nesting, so the objectstack-ai#16571 top-depth rule holds inside a class body too.
- Refusal texts and the author-facing `fix:` remedy name both container
shapes, and the remedy now prints a class example beside the literal
one. An author who cannot see the spelling in the remedy cannot write
it.

**Bare references are untouched.** Nothing about what the marker means
changed, and nothing beyond naming a class member was widened.

## ⭐ Ablation — it can fail, and it still refuses what it should

Removing the class limb from the union (`containerBodiesFor` back to
object literals only), proven on disk before reading any result — anchor
occurrences 1 → 0, blob `394fd83b` → `c3a303c3`, restored afterwards and
verified byte-identical to `HEAD` — turns **9 assertions red**:

- `TSO-C1` reads back the exact original refusal: ``no
`ObjectRepository` object literal or class (…) is declared at the top of
the file.``
- `TSO-C12` (end to end, through `scan()`) fails as ``[predicate 4]
cannot be resolved at HEAD in packages/objectql/src/engine.ts``.
- ⭐ `TSO-C9`, the union pin, does not merely fail — it **silently
resolves**: `dual.findOne` returns `Promise<Lit>` instead of refusing,
i.e. the ablated walker picks one of two real candidates. That is the
"writable but wrong" reference the whole dotted grammar exists to
prevent, and it is why the union is counted as one set.

Still refused **after** the change, so the gate did not simply become
permissive:

- `NoSuchClass.findOne` → ``no `NoSuchClass` object literal or class
(`NoSuchClass: {`, `NoSuchClass = {` or `class NoSuchClass {`) is
declared at the top of the file`` (and end to end, `TSO-C14`).
- `ObjectRepository.nosuch` → ``no `nosuch` definition sits inside it``
— never resolved outward to the same-named member on the other class.
- two classes of one name → ``opens 2 classes …, so the path is
AMBIGUOUS``.
- one literal + one class → ``opens 2 object literals and class bodies
…, so the path is AMBIGUOUS``.
- a class expression → refused, never guessed.

**Whole-tree regression control:** all **42 of 42** live references read
**byte-identically** before and after — 0 moved, 0 newly resolved. The
ten bare class-member references in particular did not move.

## Changeset — measured, not assumed

`skip-changeset`. Measurement, with both controls, on a real build:

- **Subject:** the new identifiers `containerBodiesFor` /
`classBodiesFor` / `describeBodies` / `BODY_PLURALS` occur 3 / 2 / 2 / 2
times in the changed source and **0 times** in any built `dist`.
- **Positive control:** `OpenAIEmbedder`, a symbol that ships → **58**
occurrences in `packages/plugins/embedder-openai/dist`. The probe finds
shipped text.
- **Negative control:** `createOpenAIEmbedder presets`, which exists
only in that package's test file → 1 in `src/__tests__`, **0** in
`dist`. The probe separates shipped from unshipped.
- **Structural half:** the root `package.json` is `private: true`, and
**0 of 70** non-private manifests has a directory containing either
changed path, so no `files[]` can reach `scripts/**` or `docs/adr/**`.

⇒ Nothing published moves.

- **Clause-②: no** — this PR puts no new key on any published payload.

## ⛔ Governed surface — the maintainer merges this by hand

The diff touches `docs/adr/0087-metadata-protocol-upgrade-contract.md`,
and `docs/adr/**` is in today's register (`docs/adr/** · .claude/** ·
skills/** · AGENTS.md · CLAUDE.md`). The ADR edit is not optional: the
anchor record for this script states *"Never widen the exemptions here
without the ADR half in the same PR"*, and the addendum described the
walk as object-literal nesting. ⛔ No seat merges, queues, or arms
auto-merge on this PR.

## 维护者速读(草稿)

**改了什么** — `type-surface-only`
标记里的点分成员路径,原先只能穿过对象字面量;现在也能穿过类体,于是声明在类方法上的类型收窄终于能在它自己的标记里被点名。裸标识符的含义一个字节都没动。

**为什么改** — 这是 objectstack-ai#15627 的残留:它的修复只覆盖了一种容器。树上已有 **10
条**活的引用落在类成员上(`SqlDriver` / `TursoDriver` 各 5
条),全部靠"这个名字在该文件里恰好唯一"才能解析;一旦不唯一(`engine.ts`
就是),作者只剩两条路——写一个指向别的成员的错标记,或者丢掉 `**BREAKING**` 去躲开判据。后者正是 objectstack-ai#13080 记录的侵蚀。

**风险与代价(含回滚)** — 风险面是这一个门禁脚本的解析行为。全树 42
条现存引用改动前后逐条比对**完全一致**,消融显示去掉类分支会让 9 条断言转红、其中 1 条会变成"静默挑一个"。回滚 = revert 本
PR,门禁回到今天的行为,无数据迁移、无发布物变动。

**席位意见** — (留空,待维护者)

**你要做的** — 本 PR 触及 `docs/adr/**`(受管面),⛔ 不进合并队列、不开自动合并,需要你手动合并或给出授权的
APPROVED 评审。

## Verification

- `node scripts/check-adr-0087-registration.mjs --self-test` → **355
assertions**, exit 0 (338 at the merge base; +17 in the new `TSO-C`
battery, registered at its floor in `SELF_TEST_BATTERIES`).
- `node scripts/check-adr-0087-registration.mjs --base origin/main` →
exit 0.
- Derived family via `node scripts/pm/dispatch-gates.mjs --commands` on
the merged head: **43 commands — 42 run and green, 1 NOT MEASURED.**
- `check:scripts-symbol-anchors` caught a real regression of mine
(abbreviated `engine.ts#findOne` prose read as a live anchor) — fixed in
its own commit and re-run green.
- `check:doc-formula-expressions` first exited **3 (PREREQUISITE NOT MET
— nothing measured)** because `@objectstack/formula` was unbuilt; after
`turbo run build --filter=@objectstack/formula
--filter=@objectstack/lint` it exits 0.
- ⊘ **NOT MEASURED: `pnpm check:pm-dispatch-gates`** — its self-test did
not reach a verdict inside this container's foreground window across
four attempts (still running after 11 minutes, no output advancing), so
it is **declared to CI** rather than reported as green. It grades the
dispatch-gates checker's own fixtures, not this diff, and this diff
touches no `scripts/pm/` path. ⛔ Read this as unmeasured, not as passed.
- `main` merged once (`0a88a800bd`, which brought PR objectstack-ai#17761's
`packages/spec/src/migrations/**` changeset) and everything above
re-measured on the merged head: self-test 355/0, census unchanged at 10
of 42, whole-tree comparison still 42 of 42 identical.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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