Skip to content

Commit 16df17b

Browse files
claude[bot]claude
andauthored
fix(scripts): resolve a dotted member path's last segment at the region's top depth (#17001)
`check-adr-0087-registration`'s dotted `type-surface-only` walker narrowed the search to a nesting region's braces and then counted EVERY same-named definition inside it, at any depth. A DIRECT member of an object literal whose name recurs in a nested literal was therefore counted against itself, refused as AMBIGUOUS, and told to "name a deeper path that resolves to exactly one" -- an instruction that cannot be carried out, because the member already sits at the depth the path names. Such a member had no addressable spelling at all and could only be left out of the marker and described in unchecked prose, which is the failure the dotted grammar was widened to abolish. The last segment now resolves at the region's TOP DEPTH only: a definition whose name starts inside the region but inside a nested object literal opened within it belongs to the deeper path. This is a NARROWING of the candidate set, never a tie-break -- the direct member resolves because the nested one stopped being a candidate for its path, so "take the first" stays unreachable and every refusal stays loud. A collision at the path's own depth is still refused, now without prescribing a deeper path that does not exist. Measured on `packages/client/src/index.ts`: 5 of the 19 `organizations.*` refs went refused -> resolved, each to its own annotation; the other 14 did not move. Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9294959 commit 16df17b

1 file changed

Lines changed: 141 additions & 9 deletions

File tree

scripts/check-adr-0087-registration.mjs

Lines changed: 141 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
408408
'TSO-6048: THE REGRESSION PIN -- the founding case must never admit': 8,
409409
'TSO-N: the predicate set is pinned BY NAME, never by count': 3,
410410
'TSO-U: unit pins on predicate 4\'s readers': 30,
411-
'TSO-D (#15627): a DOTTED member path resolves through the object-literal nesting': 21,
411+
'TSO-D (#15627): a DOTTED member path resolves through the object-literal nesting': 27,
412412
'G6: a changeset that was ALREADY breaking at base is inherited': 1,
413413
'R15: a changeset RENAMED AND turned breaking in the same commit': 5,
414414
'G10: a PURE rename of an ALREADY-breaking stock changeset': 3,
@@ -2492,8 +2492,9 @@ export function memberReturnAnnotation(text, symbol) {
24922492
* Every DEFINITION of `symbol` whose name starts inside `[from, to)`, in file order.
24932493
*
24942494
* The shared core of `memberReturnAnnotation` (whole file, first hit wins — the
2495-
* behaviour bare references have always had) and of the dotted-path reader below
2496-
* (one nesting region, and a count that is refused unless it is exactly one).
2495+
* behaviour bare references have always had) and of the dotted-path reader below,
2496+
* which narrows this ANY-DEPTH result to the region's TOP DEPTH
2497+
* (`definitionsAtTopDepth`) and refuses unless exactly one candidate is left.
24972498
*
24982499
* The declaration shapes it reads, and nothing else:
24992500
*
@@ -2591,6 +2592,35 @@ function objectLiteralBodiesFor(structural, name, from, to) {
25912592
return out;
25922593
}
25932594

2595+
/**
2596+
* The subset of `defs` that sits at the TOP DEPTH of the region `[from, to)`.
2597+
*
2598+
* A dotted path's leading segments ARE the nesting the member sits in (the
2599+
* `parseSymbolRef` grammar), so a definition that starts inside `[from, to)` but
2600+
* inside a nested object literal OPENED within that region belongs to the deeper
2601+
* path, ⛔ never to this one. Without this filter a direct member whose name recurs
2602+
* in a nested literal has NO addressable spelling at all: it is counted against
2603+
* itself, refused as AMBIGUOUS, and told to "name a deeper path" -- an instruction
2604+
* it cannot carry out, because it already sits at the depth the path names (#16571).
2605+
*
2606+
* ⚠️ This is a NARROWING of the candidate set, never a tie-break. The direct member
2607+
* resolves because the nested one stopped being a candidate for this path, not
2608+
* because ties are now broken by position -- so a region holding SEVERAL same-named
2609+
* definitions at its own top depth is still refused, loudly, and a "take the first"
2610+
* reading is never reachable from here.
2611+
*
2612+
* `structural` MUST be the comment- and literal-masked projection, for the reason
2613+
* `matchBracket` gives. `IDENT` is passed as the literal's NAME because every named
2614+
* literal counts: the spans of literals nested inside those are contained in them,
2615+
* so their union is the union of the top-level ones and one pass suffices.
2616+
*
2617+
* @returns {{ index: number, annotation: string|null }[]} in file order, a subset.
2618+
*/
2619+
function definitionsAtTopDepth(structural, defs, from, to) {
2620+
const nested = objectLiteralBodiesFor(structural, IDENT, from, to);
2621+
return defs.filter((d) => !nested.some((b) => d.index > b.open && d.index < b.close));
2622+
}
2623+
25942624
/**
25952625
* The region of `text` a dotted path's LEADING segments narrow the search to.
25962626
*
@@ -2605,6 +2635,9 @@ function objectLiteralBodiesFor(structural, name, from, to) {
26052635
* one of them would be the "writable but wrong" reference this widening exists to
26062636
* avoid.
26072637
*
2638+
* This returns the REGION only. Which definitions inside it the LAST segment may
2639+
* name is `definitionsAtTopDepth`'s rule: the region's own depth, never deeper.
2640+
*
26082641
* @returns {{ ok: true, from: number, to: number }|{ ok: false, reason: string }}
26092642
*/
26102643
export function resolveMemberPath(text, segments) {
@@ -2634,8 +2667,15 @@ export function resolveMemberPath(text, segments) {
26342667
* A bare reference is `readDeclaredTypeSurface` verbatim -- unchanged behaviour,
26352668
* including its "first same-named definition" reading, which is what every marker
26362669
* written before #15627 means. A DOTTED reference is resolved through the nesting
2637-
* and read only inside it; a dotted path never names an exported `interface` /
2638-
* `type` / `class` / `enum`, so only the member branch applies to it.
2670+
* and read only inside it, at that nesting's OWN depth: `organizations.create` is
2671+
* the direct member and `organizations.teams.create` the nested one, and neither
2672+
* spelling can reach the other (#16571). A dotted path never names an exported
2673+
* `interface` / `type` / `class` / `enum`, so only the member branch applies to it.
2674+
*
2675+
* ⚠️ Every refusal below states something the author can ACT on. "Name a deeper
2676+
* path" is printed only where a deeper path exists to name; where the collision is
2677+
* at the path's own depth the message says THAT instead. A gate whose diagnosis is
2678+
* right but whose remedy cannot be carried out is the failure #16571 recorded.
26392679
*
26402680
* @returns {{ surface: {shape: string, type: string|null, erased: boolean}|null,
26412681
* refusal: string|null }} at most one of the two is non-null.
@@ -2655,14 +2695,32 @@ export function readTypeSurfaceRef(text, parsed) {
26552695
'definition sits inside it.',
26562696
};
26572697
}
2658-
if (defs.length > 1) {
2698+
// The path names the member at `parent`'s OWN depth, so only definitions there
2699+
// are candidates for it (#16571). Everything deeper belongs to a deeper path.
2700+
const here = definitionsAtTopDepth(masked, defs, region.from, region.to);
2701+
if (here.length > 1) {
2702+
// ⛔ Not "name a deeper path": these sit at the depth the path already names,
2703+
// so no deeper path exists to write. A gate whose remedy cannot be carried out
2704+
// is the defect #16571 was filed on -- the refusal stays loud, the fix text
2705+
// says what is actually wrong.
2706+
return {
2707+
surface: null,
2708+
refusal: `\`${spelling}\` is AMBIGUOUS: ${here.length} \`${parsed.symbol}\` definitions sit at the TOP ` +
2709+
`DEPTH of \`${parent}\`, so no deeper path distinguishes them.`,
2710+
};
2711+
}
2712+
if (here.length === 0) {
26592713
return {
26602714
surface: null,
2661-
refusal: `\`${spelling}\` is AMBIGUOUS: ${defs.length} \`${parsed.symbol}\` definitions sit inside ` +
2662-
`\`${parent}\`. Name a deeper path that resolves to exactly one.`,
2715+
refusal: defs.length > 1
2716+
? `\`${spelling}\` is AMBIGUOUS: ${defs.length} \`${parsed.symbol}\` definitions sit inside ` +
2717+
`\`${parent}\`, every one of them inside a nested object literal. Name a deeper path that ` +
2718+
'resolves to exactly one.'
2719+
: `\`${spelling}\` does not resolve: the one \`${parsed.symbol}\` definition inside \`${parent}\` ` +
2720+
'sits inside a nested object literal, not at the depth this path names. Name the deeper path.',
26632721
};
26642722
}
2665-
const { annotation } = defs[0];
2723+
const { annotation } = here[0];
26662724
return {
26672725
surface: annotation === null
26682726
? { shape: `\`${spelling}\`, which carries NO return annotation`, type: null, erased: true }
@@ -4878,6 +4936,80 @@ function selfTest() {
48784936
);
48794937
}
48804938

4939+
// -- #16571: the LAST segment resolves at the region's TOP DEPTH ------------
4940+
// Numbered after TSO-D17 but written HERE, against the two refusals they bound:
4941+
// TSO-D12/D13 pin what must STILL be refused, TSO-D18..D23 pin what must now
4942+
// RESOLVE and what must not have moved with it. Before this rule a DIRECT member
4943+
// whose name recurs in a nested literal was counted against itself and told to
4944+
// "name a deeper path" -- an instruction unsatisfiable at the depth it already
4945+
// sits at. Measured on the real `packages/client/src/index.ts` at the fix:
4946+
// 5 of #14314's 19 `organizations.*` refs went from refused to resolved, each to
4947+
// its own annotation, and the other 14 did not move.
4948+
// ⚠️ BOTH DIRECTIONS, in one battery. A one-direction pin cannot tell "fixed"
4949+
// from "every spelling now lands on the same definition", so TSO-D20 asserts the
4950+
// two readings are DIFFERENT, not merely that each is non-null.
4951+
{
4952+
// `organizations` now holds a DIRECT `create` and a nested `teams.create` --
4953+
// the #14314 shape. `list` is left alone so TSO-D13 keeps its own fixture.
4954+
const direct = DOT_CLIENT.replace(
4955+
' teams: {\n',
4956+
' create: async (): Promise<Organization> => {\n' +
4957+
" const res = await this.fetch('/organizations');\n" +
4958+
' return res.json();\n' +
4959+
' },\n' +
4960+
' teams: {\n' +
4961+
' create: async (): Promise<Team> => {\n' +
4962+
" const res = await this.fetch('/teams');\n" +
4963+
' return res.json();\n' +
4964+
' },\n',
4965+
);
4966+
const shallow = readRef(direct, DOT('organizations.create'));
4967+
const deep = readRef(direct, DOT('organizations.teams.create'));
4968+
assert(
4969+
shallow.refusal === null && shallow.surface?.type === 'Promise<Organization>',
4970+
`TSO-D18: a DIRECT member whose name recurs in a NESTED literal resolves to ITSELF -- before #16571 this was refused as AMBIGUOUS against \`organizations.teams.create\` and the printed remedy ("name a deeper path") could not be carried out, because the member already sits at the depth the path names. Got: ${JSON.stringify(shallow)}`,
4971+
);
4972+
assert(
4973+
deep.refusal === null && deep.surface?.type === 'Promise<Team>',
4974+
`TSO-D19: THE OTHER DIRECTION -- the nested spelling still reads the NESTED definition. The top-depth rule narrows what the SHALLOW path may name; it must take nothing away from the deeper one. Got: ${JSON.stringify(deep)}`,
4975+
);
4976+
assert(
4977+
shallow.surface?.type !== deep.surface?.type,
4978+
`TSO-D20: THE PIN THAT MAKES THE PAIR A READING -- the two spellings resolve to DIFFERENT definitions. Two green one-direction assertions are equally green when every path collapses onto one definition; this is the assertion that is not. Got: ${JSON.stringify([shallow, deep])}`,
4979+
);
4980+
{
4981+
const r = readRef(direct, DOT('organizations.list'));
4982+
assert(
4983+
r.surface === null && /AMBIGUOUS/.test(r.refusal ?? '') && /2 `list` definitions/.test(r.refusal ?? ''),
4984+
`TSO-D21: THE FIRING CONTROL -- on the SAME fixture, a name with NO top-depth definition and two nested ones is still refused with its count. The direct member resolves because the nested one stopped being a candidate for its path, ⛔ never because ties are now broken by position; if this ever resolves, the narrowing became "take the first". Got: ${JSON.stringify(r)}`,
4985+
);
4986+
}
4987+
{
4988+
const r = readRef(direct, DOT('organizations.nosuchmember'));
4989+
assert(
4990+
r.surface === null && /no `nosuchmember` definition sits inside it/.test(r.refusal ?? ''),
4991+
`TSO-D22: THE NONSENSE CONTROL -- a member that exists at NO depth is still refused by the same named finding, so TSO-D18's green is a reading of the fixture and not of an unconditionally-resolving reader. Got: ${JSON.stringify(r)}`,
4992+
);
4993+
}
4994+
}
4995+
{
4996+
// TWO definitions at `organizations`'s OWN depth: a class-field arrow and a
4997+
// second property of the same name. No deeper path can tell them apart, so the
4998+
// refusal must NOT print the deeper-path remedy #16571 was filed on.
4999+
const twiceAtTop = DOT_CLIENT.replace(
5000+
' teams: {\n',
5001+
' create: async (): Promise<Organization> => { return {} as Organization; },\n' +
5002+
' create: async (): Promise<Team> => { return {} as Team; },\n' +
5003+
' teams: {\n',
5004+
);
5005+
const r = readRef(twiceAtTop, DOT('organizations.create'));
5006+
assert(
5007+
r.surface === null && /AMBIGUOUS/.test(r.refusal ?? '') && /2 `create` definitions sit at the TOP/.test(r.refusal ?? '') &&
5008+
!/Name a deeper path/.test(r.refusal ?? ''),
5009+
`TSO-D23: a collision at the path's OWN depth is still refused LOUDLY -- and the message no longer prescribes a deeper path, because there is none to write. An unsatisfiable remedy is the defect #16571 recorded; a silent pick of one of the two would be strictly worse than either. Got: ${JSON.stringify(r)}`,
5010+
);
5011+
}
5012+
48815013
// -- end to end, through the shipping scan ----------------------------------
48825014
const DOT_BASE = DOT_CLIENT.replace('(clientId: string): Promise<OAuthApplication> =>', '(clientId: string) =>');
48835015
const DOT_BODY =

0 commit comments

Comments
 (0)