diff --git a/gitnexus/src/core/group/extractors/http-patterns/go.ts b/gitnexus/src/core/group/extractors/http-patterns/go.ts index d61f70ef4..fd007c163 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/go.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/go.ts @@ -21,9 +21,11 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; // ─── Provider: framework routing ────────────────────────────────────── // Matches `\w+\.GET(...)` etc. (gin and echo share this shape). // Captures the receiver, the HTTP method (field name), and the path literal -// — anchored as the FIRST argument (either Go string form; stringLiteral -// decodes both, as ingestion does) so the code can pick the handler out of -// the remaining arguments. Which argument that is depends on the framework: +// (either Go string form; stringLiteral decodes both, as ingestion does). The +// query does not anchor the path with `.`: tree-sitter counts comments as +// named children, so `GET(/* c */ "/p", h)` would fail the anchor. scan instead +// requires the path to be the first argument in code (comments skipped) and +// picks the handler out of the remaining code arguments. Which argument that is depends on the framework: // gin is `GET(path, middleware..., handler)` (last), echo is // `GET(path, handler, middleware...)` (first) — see readFrameworkImports and // the per-call choice in scan below. @@ -43,13 +45,17 @@ const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({ operand: (_) @receiver field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$")) arguments: (argument_list - . [(interpreted_string_literal) (raw_string_literal)] @path)) `, }, ], } satisfies LanguagePatterns>); +/** Named children that are code, not comments (tree-sitter names comments). */ +function codeChildren(node: Parser.SyntaxNode | null | undefined): Parser.SyntaxNode[] { + return node ? node.namedChildren.filter((c) => c.type !== 'comment') : []; +} + /** Argument forms a route handler may take. */ const HANDLER_ARG_TYPES: ReadonlySet = new Set([ 'identifier', @@ -137,7 +143,7 @@ function asGroupCall( return null; } const parent = fn.childForFieldName('operand'); - const first = node.childForFieldName('arguments')?.namedChildren[0]; + const first = codeChildren(node.childForFieldName('arguments'))[0]; // Only a string literal carries a prefix, and it must decode to the text // the runtime registers: stringLiteral applies Go unescaping (both `"…"` // with escapes and raw `` `…` `` strings). A non-literal argument (a @@ -155,15 +161,14 @@ function boundValue(stmt: Parser.SyntaxNode, name: string): Parser.SyntaxNode | ): Parser.SyntaxNode | null | undefined => { const i = names.findIndex((n) => n.type === 'identifier' && n.text === name); if (i < 0) return undefined; - return values?.namedChildren[i] ?? null; + return codeChildren(values)[i] ?? null; }; switch (stmt.type) { case 'short_var_declaration': case 'assignment_statement': - return pick( - stmt.childForFieldName('left')?.namedChildren ?? [], - stmt.childForFieldName('right'), - ); + return pick(codeChildren(stmt.childForFieldName('left')), stmt.childForFieldName('right')); + case 'var_spec': + return pick(stmt.childrenForFieldName('name'), stmt.childForFieldName('value')); case 'var_declaration': { const specs = stmt.namedChildren.flatMap((c) => c.type === 'var_spec_list' ? c.namedChildren : [c], @@ -180,6 +185,31 @@ function boundValue(stmt: Parser.SyntaxNode, name: string): Parser.SyntaxNode | } } +/** + * A binding whose value cannot be established statically: an earlier + * statement writes the name inside a nested scope (`{ g = r.Group("/new") }`, + * a branch or loop body), so which value reaches the use depends on control + * flow. Callers decline the route instead of picking the older binding. + */ +const CONFLICT = Symbol('conflicting-binding'); +type Binding = Parser.SyntaxNode | null | undefined | typeof CONFLICT; + +/** Whether `inner` lies within `outer`'s source span. */ +function within(outer: Parser.SyntaxNode | null, inner: Parser.SyntaxNode): boolean { + return !!outer && outer.startIndex <= inner.startIndex && inner.endIndex <= outer.endIndex; +} + +/** + * Whether `stmt` writes `name` with a plain assignment somewhere inside it + * (`g = …` in a nested block, branch, loop, or closure body). A nested `:=` + * declares a new variable and is not a write to the outer one. + */ +function writesNameInside(stmt: Parser.SyntaxNode, name: string): boolean { + return [stmt, ...stmt.descendantsOfType('assignment_statement')].some( + (a) => a.type === 'assignment_statement' && declaresName(a.childForFieldName('left'), name), + ); +} + /** Whether an identifier or expression_list (e.g. a range left side) declares `name`. */ function declaresName(node: Parser.SyntaxNode | null, name: string): boolean { if (!node) return false; @@ -199,7 +229,7 @@ function declaresName(node: Parser.SyntaxNode | null, name: string): boolean { * is statically unknown, so the walk stops instead of escaping to an outer * group), or is not bound in scope. */ -function findBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null { +function findBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null | typeof CONFLICT { return lookupBinding(ident) ?? null; } @@ -207,8 +237,10 @@ function findBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null { * findBinding's walk, keeping "declared without a traceable value" (null: a * `func` literal parameter, `var x T`, a select receive) apart from "not * declared before reaching the enclosing function declaration" (undefined). + * CONFLICT means a preceding statement writes the name in a nested scope, so + * the value at the use is control-flow dependent. */ -function lookupBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null | undefined { +function lookupBinding(ident: Parser.SyntaxNode): Binding { const name = ident.text; let child: Parser.SyntaxNode = ident; for (let node = ident.parent; node; child = node, node = node.parent) { @@ -220,6 +252,18 @@ function lookupBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null | und if (params.some((p) => p.text === name)) return null; continue; } + // Inside a grouped `var ( a = …; b = a.Group(…) )`, the specs before the + // one holding the use are already in scope; the current and later specs + // are not (`var g = g.Group(…)` reads the outer g). + if (node.type === 'var_spec_list') { + const specs = codeChildren(node); + const useIndex = specs.findIndex((s) => s.id === child.id); + for (const spec of specs.slice(0, useIndex).reverse()) { + const value = boundValue(spec, name); + if (value !== undefined) return value; + } + continue; + } if ( node.type === 'block' || node.type === 'expression_case' || @@ -242,37 +286,55 @@ function lookupBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null | und } } } - const stmts = node.namedChildren; + const stmts = codeChildren(node); const useIndex = stmts.findIndex((s) => s.id === child.id); for (const stmt of stmts.slice(0, useIndex).reverse()) { const value = boundValue(stmt, name); if (value !== undefined) return value; + // A write nested in an earlier statement (block, branch, loop) may or + // may not run before the use: the reaching value is unprovable. + if (writesNameInside(stmt, name)) return CONFLICT; } continue; } // Statement-scoped bindings enclose the use the same way Go scopes them. if (node.type === 'if_statement' || node.type === 'expression_switch_statement') { + // A use inside the initializer itself (`if g := g.Group(…); …`) reads + // the OUTER binding: the new one only scopes over what follows it. const init = node.childForFieldName('initializer'); - if (init) { + if (init && !within(init, ident)) { const value = boundValue(init, name); if (value !== undefined) return value; } continue; } if (node.type === 'for_statement') { - const clause = node.namedChildren[0]; + const clause = codeChildren(node)[0]; + // A write in the loop's post statement, condition, or body runs between + // iterations, so from the second pass on the body sees that value + // instead of the one it entered with (`for ; c; g = r.Group("/post")`): + // the prefix is control-flow dependent, so decline it. + if (within(node.childForFieldName('body'), ident)) { + const loopParts = [ + node.childForFieldName('body'), + clause?.type === 'for_clause' ? clause.childForFieldName('update') : null, + clause?.type === 'for_clause' ? clause.childForFieldName('condition') : null, + ]; + if (loopParts.some((part) => part && writesNameInside(part, name))) return CONFLICT; + } if (clause?.type === 'for_clause') { - // Only the initializer runs before the body: `condition` and - // `update` (`g = r.Group("/post")` in the post slot) evaluate after - // it, so they must not shadow what the body sees on entry. An absent - // initializer (`for ; c; i++`) binds nothing. + // Only the initializer binds before the body; an absent initializer + // (`for ; c; i++`) binds nothing. const init = clause.childForFieldName('initializer'); - if (init) { + if (init && !within(init, ident)) { const value = boundValue(init, name); if (value !== undefined) return value; } } else if (clause?.type === 'range_clause') { - if (declaresName(clause.childForFieldName('left'), name)) { + if ( + declaresName(clause.childForFieldName('left'), name) && + !within(clause.childForFieldName('right'), ident) + ) { return clause.childForFieldName('right') ?? null; } } @@ -284,8 +346,13 @@ function lookupBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null | und // matching how the ingestion-side route-bindings read it. The switched // value is the operand right after the guard list. const guard = node.childForFieldName('alias'); - if (guard?.type === 'expression_list' && declaresName(guard, name)) { - return node.namedChildren[1] ?? null; + const switched = codeChildren(node)[1] ?? null; + if ( + guard?.type === 'expression_list' && + declaresName(guard, name) && + !within(switched, ident) + ) { + return switched; } continue; } @@ -302,8 +369,9 @@ function lookupBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null | und * Provenance must still END at a constructor: parameters, unrelated packages' * `New()`, a local that shadows the echo import name, and anything else return * false so the caller keeps the conservative last-argument fallback instead of - * guessing. Returns null when the Group chain exceeds MAX_GROUP_DEPTH: the - * framework is then unprovable either way, so the caller declines the route. + * guessing. Returns null when the Group chain exceeds MAX_GROUP_DEPTH or a + * binding on it is control-flow dependent (CONFLICT): the framework is then + * unprovable either way, so the caller declines the route. */ function receiverBindsToEchoConstructor( receiver: Parser.SyntaxNode, @@ -314,6 +382,7 @@ function receiverBindsToEchoConstructor( // An identifier resolves through its binding; a chained `X.Group(…).Group(…)` // operand is already a call and is inspected as-is. const value = receiver.type === 'identifier' ? findBinding(receiver) : receiver; + if (value === CONFLICT) return null; if (value?.type !== 'call_expression') return false; const fn = value.childForFieldName('function'); if (fn?.type !== 'selector_expression') return false; @@ -356,12 +425,14 @@ function isLocalName(ident: Parser.SyntaxNode): boolean { /** * Joined `Group(...)` prefix of a route receiver; '' when it cannot be traced. - * Returns null when the chain exceeds MAX_GROUP_DEPTH: a partial prefix would - * silently drop the inner groups, so the caller declines the route instead. + * Returns null when the chain exceeds MAX_GROUP_DEPTH, or when a binding on it + * is control-flow dependent (CONFLICT): a partial or stale prefix would emit + * a wrong path, so the caller declines the route instead. */ function groupPrefix(receiver: Parser.SyntaxNode, depth = 0): string | null { if (depth > MAX_GROUP_DEPTH) return null; const value = receiver.type === 'identifier' ? findBinding(receiver) : receiver; + if (value === CONFLICT) return null; const group = value ? asGroupCall(value) : null; if (!group) return ''; const outer = groupPrefix(group.parent, depth + 1); @@ -486,7 +557,9 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { if (literalPath === null) continue; const argList = pathNode.parent; if (argList?.type !== 'argument_list') continue; - // The path is anchored first, so everything after it is a handler or + const args = codeChildren(argList); + if (args[0]?.id !== pathNode.id) continue; + // The path is the first code argument, so everything after it is a handler or // middleware candidate: echo's verb calls take the FIRST of those, gin's // the LAST (see FRAMEWORK_ROUTE_PATTERNS / readFrameworkImports). The // rule is chosen per call: an echo-only file is unambiguous; a @@ -494,7 +567,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { // provably traces to echo's constructor (directly or through enclosing // Group() calls) — everything else keeps the last-argument anchor, // gin's order and the safer default when the file proves nothing. - const rest = argList.namedChildren.slice(1); + const rest = args.slice(1); if (rest.length === 0) continue; const echoOrder = mixed ? receiverNode @@ -514,13 +587,15 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { // containment (like a consumer). A named handler keeps its name and // resolves by name; `line` is harmless there. For a method value or a // package-qualified function (`h.List`, `pkg.List`) that name is the - // field: the group layer resolves handlers by name alone, and the - // operand is usually a local variable rather than the receiver type. + // field, and the operand (usually a local variable, not the receiver + // type) does not prove where `List` is declared — so the detection is + // marked qualifiedHandler: resolve only to a repo-wide unique `List`, + // never to a same-named local method that merely shares the name. const isInlineHandler = handlerNode?.type === 'func_literal'; - const handlerName = - handlerNode?.type === 'selector_expression' - ? (handlerNode.childForFieldName('field')?.text ?? null) - : (handlerNode?.text ?? null); + const isQualified = handlerNode?.type === 'selector_expression'; + const handlerName = isQualified + ? (handlerNode.childForFieldName('field')?.text ?? null) + : (handlerNode?.text ?? null); out.push({ role: 'provider', framework: 'go-framework', @@ -529,10 +604,14 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { name: isInlineHandler ? null : handlerName, line: (handlerNode ?? pathNode).startPosition.row + 1, confidence: 0.8, - // An ambiguous in-file name resolves only when the file holds exactly - // one match (otherwise the route keeps a file-level anchor) rather than - // binding to whichever same-named method the graph lists first. - ...(!isInlineHandler && handlerName && (declaredNames.get(handlerName) ?? 0) > 1 + ...(isQualified ? { qualifiedHandler: true } : {}), + // A bare name declared more than once in this file (a function and a + // method of the same name) resolves only when the file holds exactly + // one match, rather than binding to whichever row the graph lists first. + ...(!isQualified && + !isInlineHandler && + handlerName && + (declaredNames.get(handlerName) ?? 0) > 1 ? { strictHandlerResolution: true } : {}), }); diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index 38c197704..bbd3fe1a2 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -58,6 +58,14 @@ export interface HttpDetection { handlerImport?: { name: string; module: string }; /** Resolve only from the registration file or exact import target; never guess repo-wide. */ strictHandlerResolution?: boolean; + /** + * The handler was designated through a qualifier the plugin cannot tie to a + * declaration (`recv.name`, `pkg.name`): `name` alone does not prove the + * handler lives in the registration file. Skip the file-scoped name lookup + * — a same-named but unrelated local symbol would win it — and accept only + * a repo-wide unique match, else keep the file-level fallback. + */ + qualifiedHandler?: boolean; /** * The plugin saw a provider handler designator but could not prove its owner. * Prevents the orchestrator from treating it as an anonymous inline handler diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index d116bc046..707ccdff7 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -629,11 +629,17 @@ export class HttpRouteExtractor implements ContractExtractor { if (d.strictHandlerResolution) return null; return resolveSymbolByNameUnique(d.handlerImport.name); } - const byName = d.strictHandlerResolution - ? resolveFileSymbolByNameUnique(syms, d.name) - : resolveSymbolByName(syms, d.name); - if (byName) return byName; - if (d.strictHandlerResolution) return null; + // A qualified designator (`recv.name`) does not prove the handler is + // declared in this file, so the file-first rung could bind an + // unrelated same-named local symbol: go straight to the unique + // repo-wide match. + if (!d.qualifiedHandler) { + const byName = d.strictHandlerResolution + ? resolveFileSymbolByNameUnique(syms, d.name) + : resolveSymbolByName(syms, d.name); + if (byName) return byName; + if (d.strictHandlerResolution) return null; + } const byGlobal = await resolveSymbolByNameUnique(d.name); if (byGlobal) return byGlobal; // A NAMED handler we could not resolve by name (neither file-scoped nor diff --git a/gitnexus/test/unit/group/go-gin-route-groups.test.ts b/gitnexus/test/unit/group/go-gin-route-groups.test.ts index 412743d15..5119d5896 100644 --- a/gitnexus/test/unit/group/go-gin-route-groups.test.ts +++ b/gitnexus/test/unit/group/go-gin-route-groups.test.ts @@ -14,7 +14,10 @@ import * as path from 'node:path'; import Parser from 'tree-sitter'; import Go from 'tree-sitter-go'; import { GO_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/go.js'; -import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js'; +import { + HttpRouteExtractor, + RESOLVE_BY_NAME_QUERY, +} from '../../../src/core/group/extractors/http-route-extractor.js'; import { runExactMatch } from '../../../src/core/group/matching.js'; import type { RepoHandle, StoredContract } from '../../../src/core/group/types.js'; @@ -394,9 +397,12 @@ func routes(r *gin.Engine, n int, subs []*gin.RouterGroup) { ]); }); - it('ignores a for-loop post assignment and only honors its initializer', () => { - // The post statement runs AFTER each body, so a `g = …` there must not - // shadow the group the body sees on entry; an initializer still does. + it('declines a route whose group a loop reassigns between iterations', () => { + // The post statement (or the body) runs between iterations, so from the + // second pass on the body sees the reassigned group rather than the one + // it entered with: the prefix is control-flow dependent, so the route is + // declined instead of emitting either value. A loop that never writes the + // name keeps its initializer binding. expect( providers(`package main func routes(r *gin.Engine, cond bool, n int) { @@ -404,19 +410,15 @@ func routes(r *gin.Engine, cond bool, n int) { for ; cond; g = r.Group("/post") { g.GET("/a", h.A) } - for i := 0; i < n; g = r.Group("/post2") { - g.GET("/b", h.B) - } for g := r.Group("/init"); ; g = r.Group("/post3") { g.GET("/c", h.C) } + for k := r.Group("/k"); cond; { + k.GET("/d", h.D) + } } `), - ).toEqual([ - { method: 'GET', path: '/old/a', name: 'A' }, - { method: 'GET', path: '/old/b', name: 'B' }, - { method: 'GET', path: '/init/c', name: 'C' }, - ]); + ).toEqual([{ method: 'GET', path: '/k/d', name: 'D' }]); }); it('accepts a raw-string (backtick) group prefix', () => { @@ -689,38 +691,138 @@ func routes() { ]); }); - it('requests in-file-unique resolution when the handler name is declared twice', () => { - // `h.List` and `o.List` both emit the field name `List`; with two `List` - // methods in this file, first-match resolution could bind either route to - // the wrong one, so both are marked strict. A unique name (`Show`) and a - // name defined elsewhere (`Remote`) keep the default resolution. + it('marks handler resolution by how the handler is designated', () => { + // `h.List` / `o.List` emit the field name `List`, but the operand does not + // prove where `List` is declared: they are qualifiedHandler (repo-wide + // unique match only, never a same-named local method). A bare name + // declared more than once in the file (`Show` as function and method) + // resolves only when unique in the file; a bare unique name keeps the + // default resolution. parser.setLanguage(Go); const flags = GO_HTTP_PLUGIN.scan( parser.parse(`package main import "github.com/gin-gonic/gin" type A struct{} -type B struct{} func (a *A) List(c *gin.Context) {} -func (b *B) List(c *gin.Context) {} func (a *A) Show(c *gin.Context) {} +func Show(c *gin.Context) {} +func Ping(c *gin.Context) {} func routes(r *gin.Engine, h *A, o *B) { r.GET("/a", h.List) r.GET("/b", o.List) - r.GET("/s", h.Show) - r.GET("/r", other.Remote) + r.GET("/s", Show) + r.GET("/p", Ping) } `), ) .filter((d) => d.role === 'provider') - .map((d) => [d.path, d.name, d.strictHandlerResolution ?? false]); + .map((d) => [ + d.path, + d.name, + d.qualifiedHandler ?? false, + d.strictHandlerResolution ?? false, + ]); expect(flags).toEqual([ - ['/a', 'List', true], - ['/b', 'List', true], - ['/s', 'Show', false], - ['/r', 'Remote', false], + ['/a', 'List', true, false], + ['/b', 'List', true, false], + ['/s', 'Show', false, true], + ['/p', 'Ping', false, false], + ]); + }); + + it('resolves grouped var specs that precede the use', () => { + // Earlier specs in a grouped `var (…)` are in scope for later ones; the + // ingestion side emits /api/admin/x for this, so both strategies agree. + expect( + providers(`package main +func routes(r *gin.Engine) { + var ( + api = r.Group("/api") + admin = api.Group("/admin") + ) + admin.GET("/x", handler) +} +`), + ).toEqual([{ method: 'GET', path: '/api/admin/x', name: 'handler' }]); + }); + + it('declines a route whose group is reassigned in an earlier nested scope', () => { + // `{ g = r.Group("/new") }` (or a branch) writes the outer g: which value + // reaches the use depends on control flow, and ingestion declines it too, + // so emitting the older `/old/x` would invent a route. A nested `:=` + // declares a new variable and leaves the outer binding intact. + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/old") + { g = r.Group("/new") } + g.GET("/x", handler) + k := r.Group("/k") + if cond { k = r.Group("/other") } + k.GET("/y", handler) + m := r.Group("/m") + { m := r.Group("/inner"); m.GET("/i", handler) } + m.GET("/z", handler) +} +`), + ).toEqual([ + { method: 'GET', path: '/inner/i', name: 'handler' }, + { method: 'GET', path: '/m/z', name: 'handler' }, + ]); + }); + + it('resolves a name used in its own statement initializer to the outer binding', () => { + // `if g := g.Group("/inner"); …` — the right-hand g is the OUTER group; + // the new g only scopes over what follows. Same for a for initializer. + expect( + providers(`package main +func routes(r *gin.Engine, enabled bool) { + g := r.Group("/api") + if g := g.Group("/inner"); enabled { + g.GET("/x", handler) + } + for g := g.Group("/loop"); enabled; { + g.GET("/y", handler) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/api/inner/x', name: 'handler' }, + { method: 'GET', path: '/api/loop/y', name: 'handler' }, + ]); + }); + + it('skips comments when locating the path and the handler', () => { + // tree-sitter names comments, so they must not count as arguments: a + // leading comment must not hide the path, and a comment must not be + // picked as the echo (first) or gin (last) handler. + expect( + providers(`package main +import "github.com/labstack/echo/v4" +func routes(e *echo.Echo) { + e.GET("/users", /* description */ users) + e.POST(/* description */ "/posts", posts /* trailing */) +} +`), + ).toEqual([ + { method: 'GET', path: '/users', name: 'users' }, + { method: 'POST', path: '/posts', name: 'posts' }, + ]); + expect( + providers(`package main +import "github.com/gin-gonic/gin" +func routes(r *gin.Engine) { + r.GET(/* description */ "/users", users /* trailing */) + g := r.Group(/* c */ "/api") + g.GET("/x", h.X) +} +`), + ).toEqual([ + { method: 'GET', path: '/users', name: 'users' }, + { method: 'GET', path: '/api/x', name: 'X' }, ]); }); @@ -756,6 +858,50 @@ describe('Go gin provider ↔ fetch() consumer pairing', () => { storagePath: path.join(repoPath, '.gitnexus'), }); + it('does not bind a qualified handler to an unrelated same-named local method', async () => { + // routes.go declares A.List; the route's handler is b.List, with B.List in + // b.go. The file-first name lookup would pick A.List, so a qualified + // handler skips it: a repo-wide unique List resolves, an ambiguous one + // keeps the file-level fallback (empty symbolUid) instead of A.List. + const repo = path.join(tmpDir, 'repo'); + fs.mkdirSync(repo, { recursive: true }); + fs.writeFileSync( + path.join(repo, 'routes.go'), + `package main + +import "github.com/gin-gonic/gin" + +type A struct{} + +func (a *A) List(c *gin.Context) {} + +func routes(r *gin.Engine, b *B) { + r.GET("/bs", b.List) +} +`, + ); + const aList = { + uid: 'Method:routes.go:A.List', + name: 'List', + filePath: 'routes.go', + startLine: 6, + endLine: 6, + labels: ['Method'], + }; + const bList = { uid: 'Method:b.go:B.List', name: 'List', filePath: 'b.go' }; + const run = async (repoWide: Record[]) => { + const db = async (query: string, params?: Record) => { + if (query === RESOLVE_BY_NAME_QUERY) return params?.name === 'List' ? repoWide : []; + if (query.includes('UNION ALL') && params?.filePath === 'routes.go') return [aList]; + return []; + }; + const out = await new HttpRouteExtractor().extract(db, repo, repoHandle(repo, 'repo')); + return out.find((c) => c.contractId === 'http::GET::/bs')?.symbolUid; + }; + expect(await run([aList, bList])).toBe(''); + expect(await run([bList])).toBe(bList.uid); + }); + it('cross-links a grouped method-value route to a ${API_BASE}-prefixed fetch', async () => { const backend = path.join(tmpDir, 'backend'); const web = path.join(tmpDir, 'web');