Repository navigation
feat(devx): accept a dotted member path in ADR-0087 type-surface-only references - #15724
Merged
Merged
Conversation
… 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
This was referenced Sep 5, 2026
os-zhuang
approved these changes
Sep 5, 2026
os-zhuang
marked this pull request as ready for review
September 5, 2026 10:23
os-zhuang
enabled auto-merge
September 5, 2026 10:23
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #15627
Unblocks #15451
A
type-surface-onlyreference was a bare identifier resolved to the first same-named definition in the file. Onpackages/client/src/index.tsthat is not a symbol identity: the file declaresget14 times anddelete10 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
parseSymbolRefnow acceptsPATH+#+ a dotted member path (IDENT, then any number of.IDENT), in addition to the bare identifier. It returnssymbol(the last segment — the member's own name),segments(the whole path) anddotted. 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) withmatchBracketfor the bodies:oauthtoapplicationsnarrows 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) orname = {(a class field holding one) — both spellings this SDK client actually uses. A leading dot is excluded, sothis.oauth = {reached through a receiver is not claimed.The member's definition is read by
memberDefinitionsIn, the extracted core ofmemberReturnAnnotation, which reads exactly these declaration shapes and no others: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-onlyrefuses a dotted path by name: it reads a top-level exported type declaration, which has no nesting to walk, and silently readinga.b.ResultasResultwould 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/mainatcd555587e— refused by the grammar, before any reading happens:Both bare readings are about members the two PRs never touched (the first of 14
gets and the first of 10deletes).After — the dotted path reads the annotation at the nested definition:
oauth.applications.deleteis 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 aboutdeletewhen the marker saidoauth.applications.deleteis the reading this card was filed on.The negative control — bare behaviour is unchanged:
still the first definition in the file, byte-identical to before.
And the named refusals, on the same real file:
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) andorganizations.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":ADR and gate move together
documentedCategoriesreads ADR-0087's own example lines, so the two are pinned to each other. Bothtype-surface-onlyexample blocks indocs/adr/0087-metadata-protocol-upgrade-contract.mdgain 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-testV3 (real ADR againstCATEGORIES, both directions) stays green, andcheck:adr-links/check:adr-symbol-anchors/check:adr-anchorsare 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 shippingscan()— 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), andruntime-interface-onlyrefusing a dotted path (D17).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.
The ablated victim readings are the point: they are not errors, they are well-formed wrong answers.
Gates
Derived on the final head
0a3b91735withnode 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. Includescheck: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:
Declared narrowings
pnpm lint: narrowed to the changed files. Receiving population read from eslint's own config, not guessed: this repo runs oneeslint.config.mjs, which never enables type-aware linting for any file (noparserOptions.project, no typed rules — stated in that config's own header). File count read from--format json: 1 file linted forscripts/check-adr-0087-registration.mjs, 0 errors 0 warnings; the ADR reportsFile 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.os-verify-lock.sh—VERDICT command-exit 0 · held the lock 592s. Two later attempts hitVERDICT queue-timeout (exit 99) · never acquired · waited 540sbehind 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 thecheck:*gates themselves, and what is read here is exit codes rather than wall-clock.NOT MEASURED
PREREQUISITE NOT MET(fresh worktree, nonode_modules). All nine were re-run afterpnpm install(and, forcheck:doc-formula-expressions, after building@objectstack/formulaand@objectstack/lint) and are counted only from those runs.check-cross-package-test-inputs,check-shard-attestation,check-test-completenesswith$RUNNER_TEMPargv) and cannot run outside CI.silentfor every card in the tree; 6 of them keep their roster underscripts/. Their silence is not a clearance in either direction.Scope
No changeset:
scripts/anddocs/adr/publish nothing from any package, soskip-changesetis applied.packages/client/src/index.tsis 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