Skip to content

fix(check-adr-0087): walk a dotted member path through a class body - #17776

Draft
os-bill wants to merge 4 commits into
mainfrom
claude/issue-17279-type-surface-only-class-member-path
Draft

fix(check-adr-0087): walk a dotted member path through a class body#17776
os-bill wants to merge 4 commits into
mainfrom
claude/issue-17279-type-surface-only-class-member-path

Conversation

@os-bill

@os-bill os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17279

The defect

PR #15724 closed #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 #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 #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".

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 394fd83bc3a303c3, 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.findOneno `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.nosuchno `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 标记里的点分成员路径,原先只能穿过对象字面量;现在也能穿过类体,于是声明在类方法上的类型收窄终于能在它自己的标记里被点名。裸标识符的含义一个字节都没动。

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

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

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

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

Verification

  • node scripts/check-adr-0087-registration.mjs --self-test355 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 docs(spec): name the node slot in the structural-condition ruling and its ADR-0087 entry #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

`resolveMemberPath` narrowed each leading segment through an object literal
only, so a member declared on a class had no dotted spelling: the walk answered
"no `X` object literal is declared" and the bare fallback resolved to the first
same-named definition in the file.

Segments now resolve through an object literal OR a class declaration, counted
as one union so a name opening both is refused as AMBIGUOUS rather than picked
between. The top-depth rule and every bare reference are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…or a class

The anchored invariant on `scripts/check-adr-0087-registration.mjs` requires the
ADR half of any widening in the same PR. The addendum described the walk as
object-literal nesting; it now names both containers, and the author-facing
remedy the gate prints shows the class spelling alongside the literal one.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…w prose

`check:scripts-symbol-anchors` reads `engine.ts#findOne` as a real anchor and
finds no tracked file at that path. The two new citations now use the full
repo-relative path, or an elided one that is not anchor-shaped.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation labels Sep 12, 2026
@os-bill os-bill added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 12, 2026 — with Claude
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