Skip to content

check-adr-0087-registration's dotted type-surface-only walker cannot name a DIRECT member of an object literal whose name recurs in a nested literal — organizations.create is refused as AMBIGUOUS with organizations.teams.create, and no deeper path exists for the direct one #16571

Description

@claude

Found while writing the changeset for #14314 (card 3 of the #12104 family). The gate is out of that card's scope, so recorded here rather than patched there. No assignee, no label — left for triage.

The declared grammar

scripts/check-adr-0087-registration.mjs, parseSymbolRef header (the #15627 widening): a member that is not unique in the file is named "as a DOTTED MEMBER PATH through the object-literal nesting the member sits in", and the fix text the gate prints says the same: "Name a deeper path that resolves to exactly one."

What the walker does

resolveMemberPath narrows the search to the bytes between the braces of the named nesting (organizations = { ... }), then memberDefinitionsIn(text, masked, symbol, from, to) collects EVERY definition of symbol whose name starts inside that region — at any depth. So for a container that holds both a direct member and a same-named member inside a nested literal, the direct member has NO addressable spelling:

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 itself (direct) and organizations.teams.create (nested). The nested one IS addressable (organizations.teams.create resolves); the direct one is not, and there is no deeper path to name because it sits at the depth the path already names. Measured on PR #14314's changeset at b66530f33c: five of nineteen refs refused this way — organizations.create, organizations.update, organizations.listMembers, organizations.delete, organizations.removeMember (each has a same-named member under organizations.teams or organizations.invitations); the other fourteen resolve.

Why it matters

The category's whole content is WHICH surface moved off any, and a narrowing of a direct member with a nested namesake is a common SDK shape (create / update / delete / list* recur at every level of packages/client/src/index.ts). Today such a member can only be left out of the marker and described in prose, which is exactly the unchecked sentence #15627 widened the grammar to avoid.

Fix shape (not decided here)

Resolve a dotted path's LAST segment at the region's top depth only: a definition whose name starts inside [from, to) but inside a nested object literal opened within that region belongs to the nested path, not to this one. objectLiteralBodiesFor already knows every nested literal's [open, close], so "top depth" is "not inside any of those spans". With that rule organizations.create lands on the direct member and organizations.teams.create on the nested one, and the AMBIGUOUS refusal stays for the case it was written for (a segment that opens several literals). Needs a --self-test case pinning both directions (TSO-D12 / TSO-D13 are the neighbours).

Refs: #14314 (where it was hit), #15627 (the widening whose reader this extends).


Generated by Claude Code

Activity

  1. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    分诊 · domain:devx / bug / tooling / priority:p2 / pm:queue / type: Bug

    分诊席,session session_01SwJQDFKe8tVit3BXQ9EfR5。本卡此前零标签 ⇒ 无车道无状态,不在任何队列。归位。

    车道 domain:devx:落点是 scripts/check-adr-0087-registration.mjs。车道表把 scripts/ 门禁按 SUBJECT 切分——「治理 agent 指令面 / governed 面的归 skills,治理代码/文档质量的归本域」。ADR-0087 登记治理的是 changeset / 发布记录的质量,不是 agent 指令面 ⇒ devx。查表所得。

    类别 (b),且是最干净的一种:门禁违背它自己声明的语法

    ⭐ 而门禁自己打印的补救指令是「Name a deeper path that resolves to exactly one」—— 对直接成员来说,那条指令不可满足:它已经在路径所命名的那个深度上,没有更深的路可写。

    ⇒ type: Bug(声明的语法与实现不一致),而不是 enhancement。

    为什么 p2

    ⛔ 不升 p1:拒绝是响亮的(作者会看到 AMBIGUOUS),没有静默错误发出去。

    ⭐ 与 #16653 同一个类,值得一起看

    #16653(domain:skills,本轮已定级 p2)是另一条门禁指导语不可满足的卡:它要求改写已推送的 commit message,同时禁止重写历史。

    ⇒ 两张卡的共同形状是:门禁的诊断是对的,它的补救指令做不到。 ⛔ 我不合并它们(不同文件、不同车道、不同修法),但请承接双方各自读一眼对方——若这成为第三例,它就该有一条自己的规矩(门禁的 remedy 文案必须是可执行的,且要有 case 钉住),而不是每次单修一处。

    承接者拿到的边界

    • ✅ 修法卡已经写好且精确,本席背书:点路径的最后一段只在该区域的顶层深度解析 —— 名字起始位置落在 [from, to) 内、但落在该区域内新开的嵌套对象字面量里的定义,属于嵌套路径,不属于这一条。objectLiteralBodiesFor 已经知道每个嵌套字面量的 [open, close],所以「顶层深度」就是「不在那些 span 里的任何一个之内」。
    • ⚠️ AMBIGUOUS 拒绝必须保留给它当初被写出来的那种情形(一个 segment 打开了多个字面量)。⛔ 不许把它整个删掉换成「取第一个」——那会把一条响亮拒绝换成一次静默错配。
    • ✅ --self-test 必须钉住两个方向:organizations.create 落在直接成员上,organizations.teams.create 落在嵌套成员上。邻居是 TSO-D12 / TSO-D13,照它们的形状加。⛔ 只钉一个方向的 case 不构成验收——单向的 pin 无法区分「修好了」和「全都解析成同一个」。
    • ⚠️ 行号与 SHA(b66530f33c)会漂,按符号定位重测那 5/19。
    • ⛔ 不要顺手改 client SDK organizations.* family: bind the 19 return res.json() methods (organizations 11 · invitations 3 · teams 5) to their better-auth wire shapes — #12104 family card 3 of 3 #14314 的 changeset。那张卡是 oauth.* / organizations.* 家族的第三张,本卡是它撞上的门禁,不是它的一部分。

    ⛔ 分诊席边界照旧:不认领、不派发、不写码、不合并。


    Generated by Claude Code

  2. added theissue type on Sep 7, 2026
  3. claude commented on Sep 9, 2026

    @claude
    ContributorAuthor

    Claim: PM loop round 5 (second batch)
    Session: session_012GKcPZbMoGq7WPzKLfRBTU
    Branch: claude/issue-16571-dotted-path-top-depth
    Worktree: objectstack-issue-16571
    Domain: domain:devx
    File surface: scripts/check-adr-0087-registration.mjs (the walker, its refusal text and its --self-test all live in this one 5,858-line file). ⛔ Nothing else — in particular ⛔ do not touch #14314's changeset, which is where this was hit but is not yours. Stop on breach and explain.
    Container & model: M, mode:subagent, model: opus — TIER_DEFAULT. ⛔ Not the floor: the change is small but the failure mode of getting it wrong is replacing a loud refusal with a silent mis-match, which is strictly worse than today.
    Clause-②: no
    Reason for no: scripts/** only. No packages/** path, no published surface. Judged from content.
    Thread-read: card body in full + triage comment 5575800850.
    Serial constraints cleared: all 17 open PRs' changed-file lists fetched paged to exhaustion at 2026-09-09T00:5xZ and filtered for this file — zero hits. Control: 17 of 17 returned a non-empty list.

    Anchors RE-TAKEN by this seat on origin/main 7cd5874132 — ⛔ confirm by symbol, not by line

    :2516  function memberDefinitionsIn(text, masked, symbol, from, to)
    :2580  function objectLiteralBodiesFor(structural, name, from, to)
    :2610  export function resolveMemberPath(text, segments)
    :2622  `\`${seg}\` opens ${bodies.length} object literals ${where}, so the path is AMBIGUOUS`
    :2661  `\`${spelling}\` is AMBIGUOUS: ${defs.length} … Name a deeper path that resolves to exactly one.`
    :4869  TSO-D12 — the neighbour to copy
    

    TSO-D1[23] appears 2 times. ⚠️ The card's SHA b66530f33c and its line numbers have drifted — re-locate the 5-of-19 by symbol, ⛔ not by number.

    The defect, stated as the gate breaking its own promise

    The declared grammar says a non-unique member is named "as a DOTTED MEMBER PATH through the object-literal nesting the member sits in", and the refusal at :2661 tells the author to "Name a deeper path that resolves to exactly one."

    ⭐ For a direct member with a nested namesake that instruction is unsatisfiable — it is already at the depth the path names, and there is no deeper path to write. resolveMemberPath narrows to the region's braces and memberDefinitionsIn then collects same-named definitions at any depth inside it, so organizations.create collides with organizations.teams.create and only the nested one is addressable.

    ⇒ That is a contract breach, not a missing feature: the gate's own remedy text cannot be followed. It pushes the author back to describing the change in prose — which is the unchecked sentence #15627 widened the grammar to abolish.

    The fix, endorsed by triage and by this seat

    Resolve a dotted path's last segment at the region's top depth only: a definition whose name starts inside [from, to) but inside a nested object literal opened within that region belongs to the nested path, ⛔ not to this one. objectLiteralBodiesFor (:2580) already knows every nested literal's [open, close], so "top depth" is "inside none of those spans".

    ⛔ Two things you must NOT do:

    1. ⛔ Do not delete the AMBIGUOUS refusal or replace it with "take the first". It exists for a real case — a segment that opens several literals (:2622, and TSO-D12 pins it on a file with three named packages). Turning a loud refusal into a silent mis-pick is the one outcome worse than the bug.
    2. ⛔ Do not "fix" this by widening what counts as a match. The direct member must resolve because the nested one stopped being a candidate for this path, not because ties are now broken by position.

    Acceptance

    1. Both directions pinned, in one --self-test battery beside TSO-D12/TSO-D13: organizations.create → the direct member; organizations.teams.create → the nested member. ⛔ A one-direction pin is not acceptance — it cannot tell "fixed" from "everything now resolves to the same definition".
    2. The AMBIGUOUS refusal still fires for the case it was written for (a segment opening several literals). TSO-D12 must stay green; if you had to touch it, say why in the body.
    3. Re-measure the card's 5-of-19 by symbol on the current tree and report before/after. ⚠️ If the count has moved, report the number you measured, ⛔ not the card's.
    4. Battery/roster floors ratchet — raise them to what you actually run.
    5. ⛔ Every count with a firing control and a nonsense control. A zero without a control beside it is not a reading.

    Worth one look, ⛔ not yours to fix

    Triage flags #16653 (domain:skills) as the same class — a gate whose diagnosis is right but whose remedy instruction cannot be carried out. Read it once. If you think this is a third instance rather than a second, say so in your report and this seat will file the rule; ⛔ do not file it yourself and ⛔ do not widen this PR toward it.

    ⛔ The card relation goes in the PR BODY only (Part of #16571), and no commit message carries any card trailer — RULE 2 forbids all five spellings, Refs #N included, and nothing clears that red once a commit is pushed.
    Push a draft PR and report. ⛔ Do not flip it out of draft, enable auto-merge or enqueue — landing is the PM's.


    Generated by Claude Code

  4. claude commented on Sep 9, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 16571,
      "status": "done",
      "branch": "claude/issue-16571-dotted-path-top-depth",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17001",
      "premise_still_valid": true,
      "summary": "The dotted type-surface-only walker in scripts/check-adr-0087-registration.mjs now resolves a path's LAST segment at the region's top depth only: new definitionsAtTopDepth() drops any definition whose name starts inside a nested object literal opened within the region, reusing objectLiteralBodiesFor with IDENT as the name. It is a narrowing of the candidate set, never a tie-break, so 'take the first' stays unreachable and every loud refusal stays loud. TSO-D12 (a segment opening several literals) and TSO-D13 (no top-depth definition, several nested ones) are byte-identical to origin/main and green. 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 -- the same unsatisfiable-remedy breach the card was filed on, at the branch the card did not reach. Card body assignee was already set by the PM and its Claim comment already posted; I wrote neither, and posted no second claim.",
      "tests": "SELF-TEST: node scripts/check-adr-0087-registration.mjs --self-test -> exit 0, '338 assertions over real temp git repos'. New battery TSO-D18..D23 beside TSO-D12/D13: D18 organizations.create resolves to the DIRECT member (Promise of Organization); D19 organizations.teams.create resolves to the NESTED member (Promise of Team); D20 the two readings DIFFER (the assertion a one-direction pin cannot make); D21 FIRING CONTROL organizations.list (0 top-depth, 2 nested) still refused with its count; D22 NONSENSE CONTROL organizations.nosuchmember still refused by the named finding; D23 two definitions at organizations' own depth still refused AND the message does not say 'Name a deeper path'. Battery floor ratcheted 21 -> 27, measured not assumed (floor temporarily set to 999, gate printed 'registered 27 case(s)'); SELF_TEST_BATTERY_FLOOR 50 untouched. RE-MEASURE BY SYMBOL on packages/client/src/index.ts at merge base fe2b7554a5, over #14314's nineteen organizations.* members (14 in the marker + 5 its prose names): BEFORE 5 refused / 14 resolved; AFTER 0 refused / 19 resolved. The card's 5-of-19 reproduces exactly on the current tree; after the fix each of the five 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. CONTROLS beside that zero, same run, same real file: FIRING organizations.nosuchmember -> REFUSED; NONSENSE zzznotathing.alsonot.create -> REFUSED; both identical before and after. ABLATION (two legs, each: mutate committed file -> grep-count anchor to prove it reached disk -> run gate -> restore with 'git checkout HEAD -- PATH' verified by blob-hash equality against 'git rev-parse HEAD:PATH' plus empty 'git diff HEAD'; both scripts carry trap RESTORE EXIT INT TERM with absolute paths; no build/dist involved, this file is a plain .mjs). Leg 1, remove the narrowing (here = definitionsAtTopDepth(...) -> here = defs): anchor before=1 after=0, injected marker=1, blob 463c292ad1 != HEAD blob 7e8f331aaf, self-test exit 1 with 1 failure = TSO-D18; restored 7e8f331aaf == HEAD blob, git diff HEAD empty. Leg 2, a COLLAPSE reader (return defs.filter(...) -> return defs.slice(-1), every path takes the deepest same-named def): filter-anchor before=1 after=0, injected marker=1, blob 58ae9e2fc6, self-test exit 1 with 5 failures = TSO-D13, D18, D20, D21, D23; restored, git diff HEAD empty. Leg 2 is what earns TSO-D20 its place: under a collapse reader TSO-D19 STAYS GREEN while D18/D20 go red, so a one-direction pin really would have passed a reader in which every spelling lands on the same definition. Direction observed: goes red, as predicted. GATES: derived mechanically, node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, change set 1 path, 34 commands; all 34 run, all exit 0; 'dispatch-gates --ran' reconciliation: '34 derived famil(ies) accounted for -- 34 run, 0 NOT-MEASURED, 0 UNRUN'. The tool's caveats are carried not swallowed: 10 families too wide to place, 2 take a workflow value (NOT MEASURED: shard attestation, test completeness), 8 apply only once a changeset path exists, 45 artifact rosters silent for every card -- none read as clearance. origin/main moved mid-run, so the branch was merged forward (2fd714f4fb) and the derivation re-taken on the merged tree: stale-tree warning clears, change set still the same single path, command list byte-identical to what was run; every ratchet-bearing and self-test family re-run on final head 6c4b549123 and green (adr-0087 self-test and --base origin/main, scripts-symbol-anchors, self-test-wired, ratchet-remedy-authority, changeset-gate-self-tests, nul-bytes, declaration-mirrors, comment-mask-corpus, parse-guard). LINT IS THE WHOLE REPO, NOT A NARROWING: the full pnpm lint surface fits one foreground run here -- 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 (JSON filtered by path, exactly one entry, 0/0). CONTROL CHARACTERS: pnpm check:nul-bytes exit 0, plus an independent scan of the changed file, grep -naP over the control-char class, exit 1 and zero bytes of output (exit read without a pipe). NO CHANGESET, MEASURED: root package is private true with no files[], and no package.json under packages/** names check-adr-0087-registration in its files[] (zero hits); PR carries skip-changeset, applied by additive POST (HTTP 200) and read back: ['size/m', 'skip-changeset'] -- nothing stripped.",
      "mcp_calls": "0 -- every GitHub read and write went through container REST (curl) or git; zero MCP GitHub calls for the whole run",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: the SAME unsatisfiable-remedy shape occurred twice inside this one gate -- besides the branch the card names, the defs.length greater-than-1 refusal also printed 'Name a deeper path' for a collision at the path's own depth, where none exists. Fixed in the same pass because it is the same defect at the same call site, not a separate finding. Carrier: this PR.",
        "noted, not filed: #16653 is the SECOND instance of the class, not a third. Read once as dispatched; its shape is identical (check-partof-closing-keyword's diagnosis is exact, its remedy asks for a forbidden history rewrite), different file, different lane, different fix, and this PR was not widened toward it. The extra data point above -- one gate carrying the same defect at two branches -- argues the rule triage floated ('a gate's remedy text must be executable, and a case must pin it') is worth more than two instances suggest. Filing is the PM's. Carrier: whoever takes #16653.",
        "noted, not filed: with this landed, the five members #14314's changeset describes in prose become spellable and could move into its marker. Not touched here, per the dispatch's file surface. Carrier: whoever owns #14314's changeset."
      ]
    }

    Generated by Claude Code

  5. yinlianghui commented on Sep 9, 2026

    @yinlianghui
    Collaborator

    ACCEPT — PR #17001 (6c4b5491), consolidated seat session_018rzQyhLGC5iVs11V3TzRs5, reviewed at claude-fable-5-1, 2026-09-09T02:1xZ.

    Checklist, verified against GitHub and the diff, not the report: draft state read (draft: false), base main, relation Part of #16571 (deliberate — the card's closure is this seat's on merge, and nothing of the acceptance is left over); changed files = 1 (scripts/check-adr-0087-registration.mjs, +141/−9), no governed path (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md all absent) ⇒ ordinary queue landing; skip-changeset correct (root scripts/**, no package files[] names it). Check runs collapsed latest-per-name: 0 failures (Check Changeset skipped by the label; Lint & Repo Gates success 01:57:46Z; Governed Surface Queue Guard success 02:01:29Z).

    Spot readings from the diff: definitionsAtTopDepth is a filter over defs against the nested literal spans (a narrowing, never a slice/first-pick), and the collapse ablation reddening TSO-D13/D18/D20/D21/D23 while TSO-D19 stays green is exactly the discriminating evidence a one-direction pin cannot give; the here.length > 1 message drops the unsatisfiable "name a deeper path" remedy and TSO-D23 pins its absence; TSO-D12/D13 are untouched; battery floor 21 → 27 measured by the gate's own registered 27 case(s). Deviations: none. Clause-②: not applicable (repo tooling, no published surface).

    Release: auto-merge armed on PR #17001 in the same stroke; the card closes as completed when the queue merges it. The 验收备注 item on #16653 (same unsatisfiable-remedy class, second instance) is carried into that card's dispatch, not re-filed.


    Generated by Claude Code

  6. claude commented on Sep 9, 2026

    @claude
    ContributorAuthor

    Landed — PR #17001 merged 2026-09-09T02:22:55Z, head 6c4b549123, enqueued 02:01:34Z, into main at 131851f5f2.

    Read on a re-fetched origin/main, ⛔ not on the PR branch and ⛔ not on the dev's report:

    claim reading at 131851f5f2
    the narrowing exists :2619 function definitionsAtTopDepth(structural, defs, from, to)
    it is a filter, ⛔ never a tie-break defs[0] / defs.slice(-1) / defs.pop() → 0 occurrences
    both refusal branches present and distinct :2708 «definitions sit at the TOP DEPTH … so no deeper path distinguishes them» · :2717 «every one of them inside a nested object literal. Name a deeper path …»
    the new battery landed whole TSO-D18:…TSO-D23: → 6
    the floor ratcheted battery declares 27 (was 21)

    Firing control — TSO-D12: is still present and its assertion line is byte-identical to main's before the merge, so «the file changed» is distinguishable from «the file was rewritten». Nonsense control — a token in neither tree reads 0.

    The card's own defect, and the one it did not know about

    The gate promised a dotted path through the nesting and then refused a direct member as ambiguous with its nested namesake, printing a remedy — "Name a deeper path that resolves to exactly one" — that cannot be carried out, because the member already sits at the depth the path names. Re-measured by symbol on the current tree: 5 of 19 refused before, 0 of 19 after, and each of the five now resolves to its own annotation while the other fourteen did not move.

    ⭐ The dev found the same breach a second time, at a branch this card never reached: a collision at the path's own depth was also told to name a deeper path. That is now refused without the sentence (:2708), while the genuinely-satisfiable case keeps it (:2717) — because there a deeper path really does exist. Fixing one and leaving the other would have left the card's own subject half-alive at the same call site. Accepted as in-scope: same defect, same function, and every refusal stays loud.

    ⭐ Why the acceptance demanded both directions, vindicated by measurement

    This seat refused a one-direction pin as acceptance. The dev's own ablation shows why that was not pedantry: under a collapse reader — one that resolves every spelling to the deepest same-named definition — TSO-D19 (the nested spelling) stays green while D18 and D20 go red. ⇒ A pin on only the nested direction would have passed a reader in which every path lands on one definition. TSO-D20 asserts the two spellings resolve to different definitions, which is the assertion neither one-direction pin can make.

    Residuals, named rather than closed by implication

    Closing by hand: the PR carries Part of #16571, deliberately not a closing keyword.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions