fix(group): preserve full paths for nested Kotlin constants (#3059)

Key nested objects and companions by their full enclosing type path so same-file, imported, and bare nested references resolve consistently.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergo Magyar 2026-08-28 12:33:52 +00:00
parent 97a84eeaae
commit face59ae1c
4 changed files with 78 additions and 26 deletions

View file

@ -313,22 +313,24 @@ function classifyPathArgument(expr: Parser.SyntaxNode): PathArgumentPrefix {
} }
/** /**
* Type declarations enclosing `node`, innermost first, by declared name. * Type declarations enclosing `node`, innermost first, by qualified type path.
* *
* The scope a bare constant in a route annotation is resolved against; passed to * The scope a bare constant in a route annotation is resolved against; passed to
* `foldKotlinOperands`, which applies it. Collects `class_declaration` (including * `foldKotlinOperands`, which applies it. Collects `class_declaration` (including
* interfaces) and `object_declaration`. A `companion_object` adds no link of * interfaces) and `object_declaration`. A `companion_object` adds no link of
* its own members are keyed under the enclosing class one hop up. Skips * its own members are keyed under the enclosing class one hop up. For a node
* unnamed types rather than guessing. * inside `Outer.Inner`, returns `['Outer.Inner', 'Outer']`, matching the keys
* produced by `extractKotlinModuleConstants`. Skips unnamed types rather than
* guessing.
*/ */
function kotlinEnclosingTypeNames(node: Parser.SyntaxNode): string[] { function kotlinEnclosingTypeNames(node: Parser.SyntaxNode): string[] {
const out: string[] = []; const simpleNames: string[] = [];
for (let cur = node.parent; cur; cur = cur.parent) { for (let cur = node.parent; cur; cur = cur.parent) {
if (cur.type !== 'class_declaration' && cur.type !== 'object_declaration') continue; if (cur.type !== 'class_declaration' && cur.type !== 'object_declaration') continue;
const ident = cur.children.find((c) => c.type === 'type_identifier'); const ident = cur.children.find((c) => c.type === 'type_identifier');
if (ident) out.push(unquoteKotlinIdentifier(ident.text)); if (ident) simpleNames.push(unquoteKotlinIdentifier(ident.text));
} }
return out; return simpleNames.map((_, index) => simpleNames.slice(index).reverse().join('.'));
} }
// ─── Kotlin OkHttp builder verb-walk (parity with java-static-path.ts) ── // ─── Kotlin OkHttp builder verb-walk (parity with java-static-path.ts) ──

View file

@ -574,11 +574,12 @@ function initializerOf(property: Parser.SyntaxNode): Parser.SyntaxNode | null {
interface KotlinConstDeclaration { interface KotlinConstDeclaration {
/** The declaration's simple name. */ /** The declaration's simple name. */
readonly name: string; readonly name: string;
/** `<DeclaringType>.<NAME>`, or null for a top-level declaration. */ /** `<Qualified.DeclaringType>.<NAME>`, or null for a top-level declaration. */
readonly qualified: string | null; readonly qualified: string | null;
/** /**
* The qualified-key prefixes in LEXICAL scope for this declaration's * The qualified-key prefixes in LEXICAL scope for this declaration's
* initializer, innermost first (`['Inner', 'Outer']`). Empty at file level. * initializer, innermost first (`['Outer.Inner', 'Outer']`). Empty at file
* level.
*/ */
readonly scopes: readonly string[]; readonly scopes: readonly string[];
/** /**
@ -757,6 +758,16 @@ export function extractKotlinModuleConstants(tree: Parser.Tree): KotlinModuleCon
return ident ? unquoteKotlinIdentifier(ident.text) : null; return ident ? unquoteKotlinIdentifier(ident.text) : null;
}; };
/** Append one simple type name to its enclosing qualified type path. */
const nestedTypeName = (enclosingType: string | null, name: string | null): string | null => {
if (name === null) return enclosingType;
return enclosingType === null ? name : `${enclosingType}.${name}`;
};
/** Prepend a qualified scope unless it is already the innermost scope. */
const withScope = (scope: string | null, scopes: readonly string[]): readonly string[] =>
scope === null || scopes[0] === scope ? scopes : [scope, ...scopes];
const walkDeclarations = ( const walkDeclarations = (
node: Parser.SyntaxNode, node: Parser.SyntaxNode,
enclosingType: string | null, enclosingType: string | null,
@ -767,11 +778,13 @@ export function extractKotlinModuleConstants(tree: Parser.Tree): KotlinModuleCon
const name = typeNameOf(child); const name = typeNameOf(child);
const body = bodyOf(child); const body = bodyOf(child);
if (!body) continue; if (!body) continue;
// Members are reachable only as `A.NAME`; inside the body, `NAME` alone // Carry the full path: a nested object member is `Outer.Inner.NAME`, not
// means this object's member and nothing else, hence the pushed scope. // `Inner.NAME`. Inside the body a bare name searches that qualified
const inner = name === null ? scopes : [name, ...scopes]; // scope first, then each enclosing type.
collectProperties(body, name, inner, false, false); const declaredType = nestedTypeName(enclosingType, name);
walkDeclarations(body, name, inner); const inner = withScope(declaredType, scopes);
collectProperties(body, declaredType, inner, false, false);
walkDeclarations(body, declaredType, inner);
continue; continue;
} }
if (child.type === 'companion_object') { if (child.type === 'companion_object') {
@ -782,7 +795,7 @@ export function extractKotlinModuleConstants(tree: Parser.Tree): KotlinModuleCon
// simple name is bound inside that class body only, which is a SCOPE and // simple name is bound inside that class body only, which is a SCOPE and
// not a file-level key: it is reached from the reference site by // not a file-level key: it is reached from the reference site by
// `qualifyKotlinRefInEnclosingTypes`, through this same `Holder.NAME`. // `qualifyKotlinRefInEnclosingTypes`, through this same `Holder.NAME`.
const inner = enclosingType === null ? scopes : [enclosingType, ...scopes]; const inner = withScope(enclosingType, scopes);
collectProperties(body, enclosingType, inner, false, true); collectProperties(body, enclosingType, inner, false, true);
walkDeclarations(body, enclosingType, inner); walkDeclarations(body, enclosingType, inner);
continue; continue;
@ -792,7 +805,8 @@ export function extractKotlinModuleConstants(tree: Parser.Tree): KotlinModuleCon
// only its nested objects and companion contribute constants. // only its nested objects and companion contribute constants.
const name = typeNameOf(child); const name = typeNameOf(child);
const body = bodyOf(child); const body = bodyOf(child);
if (body) walkDeclarations(body, name, scopes); const declaredType = nestedTypeName(enclosingType, name);
if (body) walkDeclarations(body, declaredType, withScope(declaredType, scopes));
continue; continue;
} }
walkDeclarations(child, enclosingType, scopes); walkDeclarations(child, enclosingType, scopes);
@ -1056,12 +1070,13 @@ function foldOperands(
* bare when none does the reference-site twin of the `qualifyRef` that * bare when none does the reference-site twin of the `qualifyRef` that
* {@link extractKotlinModuleConstants} applies to sibling initializers. * {@link extractKotlinModuleConstants} applies to sibling initializers.
* *
* `enclosingTypes` is the chain of type declarations the reference sits inside, * `enclosingTypes` is the chain of qualified type paths the reference sits
* INNERMOST FIRST (`['Inner', 'Outer']`). A companion member is keyed * inside, INNERMOST FIRST (`['Outer.Inner', 'Outer']`). A companion member is
* `<EnclosingClass>.<NAME>` and is bound unqualified exactly within that class * keyed `<EnclosingClass>.<NAME>` and is bound unqualified exactly within that
* body including its nested types, which is why the whole chain is walked and * class body including its nested types, which is why the whole chain is
* not just the innermost link. An `object`'s own members are in scope inside its * walked and not just the innermost link. An `object`'s own members are in scope
* body under the same `<Owner>.<NAME>` key, so the same walk covers both. * inside its body under the same `<Owner>.<NAME>` key, so the same walk covers
* both.
* *
* Innermost-first, and BEFORE the file-level maps the fold consults next, is * Innermost-first, and BEFORE the file-level maps the fold consults next, is
* Kotlin's own order: a companion member shadows a same-named top-level * Kotlin's own order: a companion member shadows a same-named top-level

View file

@ -761,6 +761,30 @@ class OrderController {
).toEqual(['GET /api/v1/orders']); ).toEqual(['GET /api/v1/orders']);
}); });
it('resolves a bare companion constant inside a nested class', () => {
// Declaration extraction and reference-site qualification must agree on
// the full owner path. `ORDERS` here means `Outer.Inner.ORDERS`, not the
// nonexistent top-level `Inner.ORDERS`.
expect(
providers({
[CONTROLLER]: `package com.example.app.web
@RestController
class Outer {
class Inner {
companion object {
const val ORDERS = "/nested/orders"
}
@GetMapping(ORDERS)
fun list() {}
}
}
`,
}),
).toEqual(['GET /nested/orders']);
});
it('folds through the file that declares the package, not one whose path imitates it', () => { it('folds through the file that declares the package, not one whose path imitates it', () => {
// The decoy's PATH ends with the imported FQN, but it declares // The decoy's PATH ends with the imported FQN, but it declares
// `package x.com.example.app.api` — a different declaration. Choosing the // `package x.com.example.app.api` — a different declaration. Choosing the

View file

@ -793,18 +793,23 @@ class Outer {
`, `,
}); });
expect( expect(
foldKotlinOperands(key, [{ kind: 'ref', name: 'ORDERS' }], repo, ['Inner', 'Outer']), foldKotlinOperands(key, [{ kind: 'ref', name: 'ORDERS' }], repo, ['Outer.Inner', 'Outer']),
).toBe('/inner'); ).toBe('/inner');
expect( expect(
foldKotlinOperands(key, [{ kind: 'ref', name: 'ONLY_OUTER' }], repo, ['Inner', 'Outer']), foldKotlinOperands(key, [{ kind: 'ref', name: 'ONLY_OUTER' }], repo, [
'Outer.Inner',
'Outer',
]),
).toBe('/only-outer'); ).toBe('/only-outer');
}); });
it('resolves a nested object member through the enclosing object', () => { it('keys a nested object by its full enclosing type path', () => {
// `Inner`'s initializer names `P`, which `Inner` does not declare and // `Inner`'s initializer names `P`, which `Inner` does not declare and
// `Outer` does; the scope chain is walked innermost-first, so it means // `Outer` does; the scope chain is walked innermost-first, so it means
// `Outer.P` — not the same-named member of the unrelated `Other`. // `Outer.P` — not the same-named member of the unrelated `Other`. The
// declaration itself is reachable as `Outer.Inner.Q`, never `Inner.Q`.
const key = 'src/main/kotlin/com/example/app/api/Nested.kt'; const key = 'src/main/kotlin/com/example/app/api/Nested.kt';
const controllerKey = 'src/main/kotlin/com/example/app/web/Controller.kt';
const repo = repoOf({ const repo = repoOf({
[key]: `package com.example.app.api [key]: `package com.example.app.api
@ -818,9 +823,15 @@ object Outer {
const val Q = P + "/q" const val Q = P + "/q"
} }
} }
`,
[controllerKey]: `package com.example.app.web
import com.example.app.api.Outer
`, `,
}); });
expect(resolveKotlinConstant(key, 'Inner.Q', repo)).toBe('/right/q'); expect(resolveKotlinConstant(key, 'Outer.Inner.Q', repo)).toBe('/right/q');
expect(resolveKotlinConstant(controllerKey, 'Outer.Inner.Q', repo)).toBe('/right/q');
expect(resolveKotlinConstant(key, 'Inner.Q', repo)).toBeNull();
}); });
it('does not fall through to a file-level constant for an unfoldable sibling', () => { it('does not fall through to a file-level constant for an unfoldable sibling', () => {