From face59ae1c993b9b2e313b6606794ef50e877f59 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Fri, 28 Aug 2026 12:33:52 +0000 Subject: [PATCH] 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 --- .../group/extractors/http-patterns/kotlin.ts | 14 +++--- .../route-extractors/kotlin-const-resolver.ts | 45 ++++++++++++------- .../group/kotlin-const-route-fold.test.ts | 24 ++++++++++ .../unit/kotlin-route-const-resolver.test.ts | 21 ++++++--- 4 files changed, 78 insertions(+), 26 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index eb2e7e1a1..593c7b53f 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -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 * `foldKotlinOperands`, which applies it. Collects `class_declaration` (including * interfaces) and `object_declaration`. A `companion_object` adds no link of - * its own — members are keyed under the enclosing class one hop up. Skips - * unnamed types rather than guessing. + * its own — members are keyed under the enclosing class one hop up. For a node + * 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[] { - const out: string[] = []; + const simpleNames: string[] = []; for (let cur = node.parent; cur; cur = cur.parent) { if (cur.type !== 'class_declaration' && cur.type !== 'object_declaration') continue; 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) ── diff --git a/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts b/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts index a161de224..97bef8403 100644 --- a/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts +++ b/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts @@ -574,11 +574,12 @@ function initializerOf(property: Parser.SyntaxNode): Parser.SyntaxNode | null { interface KotlinConstDeclaration { /** The declaration's simple name. */ readonly name: string; - /** `.`, or null for a top-level declaration. */ + /** `.`, or null for a top-level declaration. */ readonly qualified: string | null; /** * 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[]; /** @@ -757,6 +758,16 @@ export function extractKotlinModuleConstants(tree: Parser.Tree): KotlinModuleCon 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 = ( node: Parser.SyntaxNode, enclosingType: string | null, @@ -767,11 +778,13 @@ export function extractKotlinModuleConstants(tree: Parser.Tree): KotlinModuleCon const name = typeNameOf(child); const body = bodyOf(child); if (!body) continue; - // Members are reachable only as `A.NAME`; inside the body, `NAME` alone - // means this object's member and nothing else, hence the pushed scope. - const inner = name === null ? scopes : [name, ...scopes]; - collectProperties(body, name, inner, false, false); - walkDeclarations(body, name, inner); + // Carry the full path: a nested object member is `Outer.Inner.NAME`, not + // `Inner.NAME`. Inside the body a bare name searches that qualified + // scope first, then each enclosing type. + const declaredType = nestedTypeName(enclosingType, name); + const inner = withScope(declaredType, scopes); + collectProperties(body, declaredType, inner, false, false); + walkDeclarations(body, declaredType, inner); continue; } 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 // not a file-level key: it is reached from the reference site by // `qualifyKotlinRefInEnclosingTypes`, through this same `Holder.NAME`. - const inner = enclosingType === null ? scopes : [enclosingType, ...scopes]; + const inner = withScope(enclosingType, scopes); collectProperties(body, enclosingType, inner, false, true); walkDeclarations(body, enclosingType, inner); continue; @@ -792,7 +805,8 @@ export function extractKotlinModuleConstants(tree: Parser.Tree): KotlinModuleCon // only its nested objects and companion contribute constants. const name = typeNameOf(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; } walkDeclarations(child, enclosingType, scopes); @@ -1056,12 +1070,13 @@ function foldOperands( * bare when none does — the reference-site twin of the `qualifyRef` that * {@link extractKotlinModuleConstants} applies to sibling initializers. * - * `enclosingTypes` is the chain of type declarations the reference sits inside, - * INNERMOST FIRST (`['Inner', 'Outer']`). A companion member is keyed - * `.` and is bound unqualified exactly within that class - * body — including its nested types, which is why the whole chain is walked and - * not just the innermost link. An `object`'s own members are in scope inside its - * body under the same `.` key, so the same walk covers both. + * `enclosingTypes` is the chain of qualified type paths the reference sits + * inside, INNERMOST FIRST (`['Outer.Inner', 'Outer']`). A companion member is + * keyed `.` and is bound unqualified exactly within that + * class body — including its nested types, which is why the whole chain is + * walked and not just the innermost link. An `object`'s own members are in scope + * inside its body under the same `.` key, so the same walk covers + * both. * * 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 diff --git a/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts b/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts index e6d83b20a..0d5cc2a63 100644 --- a/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts +++ b/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts @@ -761,6 +761,30 @@ class OrderController { ).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', () => { // The decoy's PATH ends with the imported FQN, but it declares // `package x.com.example.app.api` — a different declaration. Choosing the diff --git a/gitnexus/test/unit/kotlin-route-const-resolver.test.ts b/gitnexus/test/unit/kotlin-route-const-resolver.test.ts index d2f222c1f..7ee3f055f 100644 --- a/gitnexus/test/unit/kotlin-route-const-resolver.test.ts +++ b/gitnexus/test/unit/kotlin-route-const-resolver.test.ts @@ -793,18 +793,23 @@ class Outer { `, }); expect( - foldKotlinOperands(key, [{ kind: 'ref', name: 'ORDERS' }], repo, ['Inner', 'Outer']), + foldKotlinOperands(key, [{ kind: 'ref', name: 'ORDERS' }], repo, ['Outer.Inner', 'Outer']), ).toBe('/inner'); 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'); }); - 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 // `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 controllerKey = 'src/main/kotlin/com/example/app/web/Controller.kt'; const repo = repoOf({ [key]: `package com.example.app.api @@ -818,9 +823,15 @@ object Outer { 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', () => {