fix(group): address maintainer review of Go route-group scanning

- Grouped `var ( a = …; b = a.Group(…) )`: earlier specs are in scope for
  later ones (boundValue handles var_spec; the walk checks preceding specs).
- A write to the group in an earlier nested scope, or between loop
  iterations (post statement, condition, body), makes the binding
  control-flow dependent: the route is declined (CONFLICT) instead of
  emitting the older prefix. A nested `:=` is a new variable, not a write.
- A name used inside its own if/switch/for/range/type-switch initializer
  resolves to the outer binding instead of recursing into itself.
- Comments are not arguments: the framework query no longer anchors the
  path with `.`, scan requires the path to be the first code argument and
  picks the handler from code arguments only (also in Group prefixes).
- Qualified handlers (`b.List`) set the new HttpDetection.qualifiedHandler:
  the orchestrator skips the file-first name lookup, which could bind an
  unrelated same-named local method, and accepts only a repo-wide unique
  match, else the file-level fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rgb-vgx 2026-10-04 17:48:36 +07:00
parent c60af8247b
commit be78d99ff8
4 changed files with 309 additions and 70 deletions

View file

@ -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<Record<string, never>>);
/** 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<string> = 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 }
: {}),
});

View file

@ -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

View file

@ -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

View file

@ -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<string, unknown>[]) => {
const db = async (query: string, params?: Record<string, unknown>) => {
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');