fix(group): join gin/echo route-group prefixes and accept method-value handlers in Go HTTP providers (#3458)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

This commit is contained in:
rgb-vgx 2026-10-04 18:55:41 +07:00 • committed by GitHub
parent 1a5d88391c
commit 504bff7102
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1573 additions and 81 deletions

View file

@ -1,27 +1,38 @@
import type Parser from 'tree-sitter';
import Go from 'tree-sitter-go';
import { goImportPackageName } from '../../../ingestion/languages/go/import-package-name.js';
import { stringLiteral } from '../../../ingestion/route-extractors/go-shared.js';
import {
compilePatterns,
runCompiledPatterns,
unquoteLiteral,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
/**
* Go HTTP plugin. Handles:
* - gin / echo / chi framework routing — `r.GET("/path", handler)`
* - gin / echo framework routing — `r.GET("/path", handler)`, including
* prefixes from route groups bound in the same function (`r.Group("/api")`)
* - net/http stdlib — `http.HandleFunc("/path", handler)`
* - net/http consumer — `http.Get(...)`, `http.NewRequest("METHOD", ...)`
* - resty consumer — `client.R().Delete("/path")`
*/
// ─── Provider: framework routing ──────────────────────────────────────
// Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape).
// Captures the HTTP method (field name), path literal, and the handler —
// anchored to the LAST argument (`@handler .`) so a variadic middleware
// chain (`r.GET("/x", mw, handler)`, gin/echo/chi style) binds the real
// handler, not a middleware identifier (which would otherwise over-match
// and attach the route to the wrong symbol — see #2276 review).
// Matches `\w+\.GET(...)` etc. (gin and echo share this shape).
// Captures the receiver, the HTTP method (field name), and the path literal
// (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.
// The handler must be an identifier, an inline func literal, or a method
// value / package-qualified function (`h.ListUsers`, `handlers.ListUsers`);
// anything else there means the call cannot be attributed to a symbol, so it
// is dropped rather than guessed (variadic-middleware over-match, #2276).
const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
name: 'go-framework-route',
language: Go,
@ -31,16 +42,431 @@ const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
query: `
(call_expression
function: (selector_expression
operand: (_) @receiver
field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$"))
arguments: (argument_list
(interpreted_string_literal) @path
[(identifier) (func_literal)] @handler
.))
[(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',
'func_literal',
'selector_expression',
]);
/**
* The file's framework import aliases: which local qualifiers resolve to
* echo and to gin. Matched on the import path rather than the local name, so
* an aliased import still counts; an unaliased import is keyed by its
* conventional package name (`goImportPackageName`). `_` and `.` imports bind
* no qualifier this file can route through. An empty set means the file
* proves nothing about that framework. Echo's verb calls take the handler as
* the FIRST argument after the path (`GET(path, handler, middleware...)`),
* gin's as the LAST (`GET(path, middleware..., handler)`) — `scan` picks the
* rule per call from these sets.
*/
function readFrameworkImports(root: Parser.SyntaxNode): {
echo: Set<string>;
gin: Set<string>;
} {
// Imports sit only at file scope; skip bodies.
const specs = root.namedChildren
.filter((node) => node.type === 'import_declaration')
.flatMap((decl) => decl.descendantsOfType('import_spec'));
const echo = new Set<string>();
const gin = new Set<string>();
for (const spec of specs) {
const importPath = stringLiteral(spec.childForFieldName('path'));
if (importPath === null) continue;
const local = spec.childForFieldName('name')?.text ?? goImportPackageName(importPath);
if (local === '_' || local === '.') continue;
if (importPath.includes('labstack/echo')) echo.add(local);
else if (importPath.includes('gin-gonic/gin')) gin.add(local);
}
return { echo, gin };
}
// ─── Route groups: `v1 := r.Group("/api/v1")` ─────────────────────────
// gin (`*gin.RouterGroup`) and echo (`*echo.Group`) routes registered on a
// group inherit every enclosing `Group(prefix)`. The prefix is recovered by
// walking the route's receiver back through its bindings, lexically, inside
// the enclosing function declaration only: a group handed to another
// function (`registerAdmin(v1)`) arrives as a parameter and contributes no
// prefix there, and a receiver bound to anything but a literal-prefix
// `Group(...)` call contributes none either — the route keeps its literal path.
// Statement-scoped bindings count too: an `if`/`switch` initializer, a `for`
// clause (including `range`), a type-switch guard, and declarations inside a
// switch case all scope over their statement the same way Go scopes them, so
// they shadow an outer group of the same name instead of being skipped. A
// select case's receive binding (`case g := <-ch:`) stops the walk entirely:
// what arrives from the channel is statically unknown, so the route keeps its
// literal path rather than inheriting an outer group.
const MAX_GROUP_DEPTH = 32;
function joinRoutePath(prefix: string, relative: string): string {
let joined = relative;
if (prefix && relative) {
joined = `${prefix.replace(/\/+$/, '')}/${relative.replace(/^\/+/, '')}`;
} else if (prefix) {
joined = prefix;
}
// Collapse duplicate slashes on the FINAL result — every return branch, not
// just the join — because ingestion's normalizeExtractedRoutePath collapses
// all "//" while the downstream contract-id normalizer does not: a path
// that keeps "//" would split into two contract ids across the strategies.
const collapsed = joined.replace(/\/+/g, '/');
// Force a leading "/" for the same reason: ingestion's
// normalizeExtractedRoutePath always adds one, while normalizeHttpPath (the
// shared contract-id normalizer) does not — a literal "x" or a slashless
// Group("api") prefix would emit `...::x` here and `...::/x` there. Gin
// also refuses a registration path that does not start with "/".
return collapsed.startsWith('/') ? collapsed : `/${collapsed}`;
}
/** `parent.Group("/p", mw...)` → its receiver and literal prefix; null otherwise. */
function asGroupCall(
node: Parser.SyntaxNode,
): { parent: Parser.SyntaxNode; prefix: string } | null {
if (node.type !== 'call_expression') return null;
const fn = node.childForFieldName('function');
if (fn?.type !== 'selector_expression' || fn.childForFieldName('field')?.text !== 'Group') {
return null;
}
const parent = fn.childForFieldName('operand');
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
// variable, concatenation) or an undecodable string contributes no prefix.
const prefix = first ? stringLiteral(first) : null;
if (!parent || prefix === null) return null;
return { parent, prefix };
}
/** The expression `name` is assigned by `stmt` (`:=`, `=`, or `var`), if any. */
function boundValue(stmt: Parser.SyntaxNode, name: string): Parser.SyntaxNode | null | undefined {
const pick = (
names: Parser.SyntaxNode[],
values: Parser.SyntaxNode | null,
): Parser.SyntaxNode | null | undefined => {
const i = names.findIndex((n) => n.type === 'identifier' && n.text === name);
if (i < 0) return undefined;
return codeChildren(values)[i] ?? null;
};
switch (stmt.type) {
case 'short_var_declaration':
case 'assignment_statement':
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],
);
for (const spec of specs) {
if (spec.type !== 'var_spec') continue;
const value = pick(spec.childrenForFieldName('name'), spec.childForFieldName('value'));
if (value !== undefined) return value;
}
return undefined;
}
default:
return undefined;
}
}
/**
* 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');
/** The name is a parameter (or method receiver): its declaration carries a static type. */
interface ParamBinding {
param: Parser.SyntaxNode;
}
type Binding = Parser.SyntaxNode | null | undefined | typeof CONFLICT | ParamBinding;
function isParamBinding(b: Binding): b is ParamBinding {
return typeof b === 'object' && b !== null && 'param' in b;
}
/** The parameter_declaration of `fn` (parameters or method receiver) declaring `name`. */
function paramDeclaring(fn: Parser.SyntaxNode, name: string): Parser.SyntaxNode | null {
for (const list of [fn.childForFieldName('parameters'), fn.childForFieldName('receiver')]) {
for (const decl of codeChildren(list)) {
if (decl.type !== 'parameter_declaration' && decl.type !== 'variadic_parameter_declaration') {
continue;
}
if (decl.childrenForFieldName('name').some((n) => n.text === name)) return decl;
}
}
return null;
}
/** 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;
if (node.type === 'identifier') return node.text === name;
return node.namedChildren.some((n) => n.type === 'identifier' && n.text === name);
}
/**
* The value last bound to identifier `ident` before its use: the nearest
* binding site in the enclosing scopes, walking outward — preceding statements
* in blocks and switch/select cases (`expression_case`/`type_case`/
* `communication_case`/`default_case` act as statement containers), then
* statement-scoped bindings (`if`/`switch` initializers, `for` clauses
* including `range`, type-switch guards), up to the enclosing function
* declaration. Returns null when the name is a parameter, is bound without a
* value, is received from a channel by a select case head (the received value
* 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 | typeof CONFLICT {
const binding = lookupBinding(ident);
return isParamBinding(binding) ? null : (binding ?? 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. A parameter or method
* receiver of the enclosing function returns its declaration (ParamBinding):
* no value, but a static type.
*/
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) {
if (
node.type === 'function_declaration' ||
node.type === 'method_declaration' ||
node.type === 'func_literal'
) {
const param = paramDeclaring(node, name);
if (param) return { param };
if (node.type === 'func_literal') continue;
return undefined;
}
// 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' ||
node.type === 'type_case' ||
node.type === 'communication_case' ||
node.type === 'default_case'
) {
// A select case head can rebind the name (`case g := <-ch:` or
// `case g = <-ch:`); the received value is statically unknown, so the
// walk must STOP with no traceable value — the same decline the
// ingestion-side route bindings record (value null) — instead of
// escaping to an outer group of the same name.
if (node.type === 'communication_case') {
for (const head of node.children) {
if (
head.type === 'receive_statement' &&
declaresName(head.childForFieldName('left'), name)
) {
return null;
}
}
}
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 && !within(init, ident)) {
const value = boundValue(init, name);
if (value !== undefined) return value;
}
continue;
}
if (node.type === 'for_statement') {
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 binds before the body; an absent initializer
// (`for ; c; i++`) binds nothing.
const init = clause.childForFieldName('initializer');
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) &&
!within(clause.childForFieldName('right'), ident)
) {
return clause.childForFieldName('right') ?? null;
}
}
continue;
}
if (node.type === 'type_switch_statement') {
// `switch g := x.(type)` — the guard list is the `alias` field, which
// only the `:=` form has (bare `switch x.(type)` parses with none),
// 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');
const switched = codeChildren(node)[1] ?? null;
if (
guard?.type === 'expression_list' &&
declaresName(guard, name) &&
!within(switched, ident)
) {
return switched;
}
continue;
}
}
return undefined;
}
/**
* Whether a mixed-import file's route receiver traces back to echo's
* constructor — `e := echo.New()` / `echo.Default()` with `echo` resolving to
* one of the file's verified echo import aliases — either directly or through
* enclosing `Group(...)` calls (`users := api.Group(…)` ← `api := e.Group(…)`
* ← `echo.New()`), the normal shape of grouped routes (review #7, #10).
* A parameter or method receiver counts by its declared type instead:
* `e *echo.Echo` / `g *echo.Group` (with `echo` one of those aliases) proves
* echo; any other type — gin's, or one the file cannot tie to echo — does not.
* 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 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,
echoAliases: ReadonlySet<string>,
depth = 0,
): boolean | null {
if (depth > MAX_GROUP_DEPTH) return null;
// 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' ? lookupBinding(receiver) : receiver;
if (value === CONFLICT) return null;
if (isParamBinding(value))
return isEchoRouterType(value.param.childForFieldName('type'), echoAliases);
if (value?.type !== 'call_expression') return false;
const fn = value.childForFieldName('function');
if (fn?.type !== 'selector_expression') return false;
const field = fn.childForFieldName('field')?.text;
const operand = fn.childForFieldName('operand');
if (!operand) return false;
if (field === 'New' || field === 'Default') {
return operand.type === 'identifier' && echoAliases.has(operand.text) && !isLocalName(operand);
}
if (field === 'Group') {
return receiverBindsToEchoConstructor(operand, echoAliases, depth + 1);
}
return false;
}
/** `*echo.Echo` / `echo.Echo` / `*echo.Group` with `echo` a verified echo import alias. */
function isEchoRouterType(
type: Parser.SyntaxNode | null,
echoAliases: ReadonlySet<string>,
): boolean {
const named = type?.type === 'pointer_type' ? codeChildren(type)[0] : type;
if (named?.type !== 'qualified_type') return false;
const pkg = named.childForFieldName('package')?.text;
const typeName = named.childForFieldName('name')?.text;
return !!pkg && echoAliases.has(pkg) && (typeName === 'Echo' || typeName === 'Group');
}
/**
* Whether `ident` names a local value rather than an imported package: a
* declaration in scope (with or without a value — `var echo Factory` shadows
* too) or a parameter/receiver of an enclosing function. Go lets either
* shadow a package qualifier (`func f(echo *Factory) { echo.New() }`).
*/
function isLocalName(ident: Parser.SyntaxNode): boolean {
// lookupBinding covers parameters and receivers too (ParamBinding).
return lookupBinding(ident) !== undefined;
}
/**
* Joined `Group(...)` prefix of a route receiver; '' when it cannot be traced.
* 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);
return outer === null ? null : joinRoutePath(outer, group.prefix);
}
// ─── Provider: net/http `http.HandleFunc("/p", handler)` ─────────────
const HANDLE_FUNC_PATTERNS = compilePatterns({
name: 'go-handle-func',
@ -135,27 +561,87 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
scan(tree) {
const out: HttpDetection[] = [];
// Framework providers: r.GET/POST/... with handler identifier
// Framework providers: r.GET/POST/... on an engine or (nested) route group
const imports = readFrameworkImports(tree.rootNode);
const echoOnly = imports.echo.size > 0 && imports.gin.size === 0;
const mixed = imports.echo.size > 0 && imports.gin.size > 0;
// Handler names declared more than once in this file (`(h *A) List` and
// `(o *B) List`): the emitted name is field-only, so these must resolve
// only when unique in the file instead of taking the first same-named row.
const declaredNames = new Map<string, number>();
for (const decl of tree.rootNode.descendantsOfType([
'function_declaration',
'method_declaration',
])) {
const declName = decl.childForFieldName('name')?.text;
if (declName) declaredNames.set(declName, (declaredNames.get(declName) ?? 0) + 1);
}
for (const match of runCompiledPatterns(FRAMEWORK_ROUTE_PATTERNS, tree)) {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
const handlerNode = match.captures.handler;
const receiverNode = match.captures.receiver;
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
const literalPath = stringLiteral(pathNode);
if (literalPath === null) continue;
const argList = pathNode.parent;
if (argList?.type !== 'argument_list') continue;
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
// mixed-import file takes the first argument only when the receiver
// 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 = args.slice(1);
if (rest.length === 0) continue;
const echoOrder = mixed
? receiverNode
? receiverBindsToEchoConstructor(receiverNode, imports.echo)
: false
: echoOnly;
// A Group chain deeper than MAX_GROUP_DEPTH proves neither the full
// prefix nor the framework order: decline rather than emit a guess.
if (echoOrder === null) continue;
const prefix = receiverNode ? groupPrefix(receiverNode) : '';
if (prefix === null) continue;
const handlerNode = echoOrder ? rest[0] : rest[rest.length - 1];
if (!HANDLER_ARG_TYPES.has(handlerNode.type)) continue;
const path = receiverNode ? joinRoutePath(prefix, literalPath) : literalPath;
// An inline `func(){…}` handler has no name → emit `name: null` and a
// `line` so it resolves to its containing/closure symbol by line-span
// containment (like a consumer). A named identifier handler keeps its
// name and resolves by name; `line` is harmless there.
// 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, 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 isQualified = handlerNode?.type === 'selector_expression';
const handlerName = isQualified
? (handlerNode.childForFieldName('field')?.text ?? null)
: (handlerNode?.text ?? null);
out.push({
role: 'provider',
framework: 'go-framework',
method: methodNode.text.toUpperCase(),
path,
name: isInlineHandler ? null : (handlerNode?.text ?? null),
name: isInlineHandler ? null : handlerName,
line: (handlerNode ?? pathNode).startPosition.row + 1,
confidence: 0.8,
...(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 }
: {}),
});
}
@ -164,7 +650,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
const pathNode = match.captures.path;
const handlerNode = match.captures.handler;
if (!pathNode) continue;
const path = unquoteLiteral(pathNode.text);
const path = stringLiteral(pathNode);
if (path === null) continue;
// Inline `func(){…}` handler → resolve by containment (see go-framework
// note above); a named handler resolves by name.
@ -187,7 +673,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
if (!fnNode || !pathNode) continue;
const httpMethod = HTTP_CLIENT_METHOD_TO_HTTP[fnNode.text];
if (!httpMethod) continue;
const path = unquoteLiteral(pathNode.text);
const path = stringLiteral(pathNode);
if (path === null) continue;
out.push({
role: 'consumer',
@ -205,8 +691,8 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const method = unquoteLiteral(methodNode.text);
const path = unquoteLiteral(pathNode.text);
const method = stringLiteral(methodNode);
const path = stringLiteral(pathNode);
if (method === null || path === null) continue;
out.push({
role: 'consumer',
@ -224,7 +710,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
const path = stringLiteral(pathNode);
if (path === null) continue;
out.push({
role: 'consumer',

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

@ -26,6 +26,7 @@
import type Parser from 'tree-sitter';
import { goImportPackageName } from '../languages/go/import-package-name.js';
import { GoRouteBindings, type GoRouteBinding } from '../languages/go/route-bindings.js';
import { stringLiteral } from './go-shared.js';
import { normalizeExtractedRoutePath } from './route-path.js';
import type { SyntaxNode } from 'tree-sitter';
import type { ExtractedDecoratorRoute, RouteHandlerReceiver } from '../workers/parse-worker.js';
@ -58,59 +59,6 @@ interface Framework {
const FUNCTION_TYPE_LIST = ['function_declaration', 'method_declaration', 'func_literal'];
const FUNCTION_TYPES: ReadonlySet<string> = new Set(FUNCTION_TYPE_LIST);
function stringLiteral(node: SyntaxNode | null | undefined): string | null {
if (!node || node.hasError) return null;
const body = node.text.slice(1, -1);
// Go discards carriage returns in raw strings, including CRLF source files.
if (node.type === 'raw_string_literal') return body.replace(/\r/g, '');
if (node.type !== 'interpreted_string_literal') return null;
if (!body.includes('\\')) return body;
const simple: Readonly<Record<string, string>> = {
a: '\x07',
b: '\b',
f: '\f',
n: '\n',
r: '\r',
t: '\t',
v: '\v',
'\\': '\\',
'"': '"',
};
const chunks: Buffer[] = [];
const tokens =
/\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g;
let consumed = 0;
for (const match of body.matchAll(tokens)) {
if (match.index !== consumed) return null;
const token = match[0];
consumed += token.length;
if (!token.startsWith('\\')) {
chunks.push(Buffer.from(token));
} else if (simple[token[1]] !== undefined) {
chunks.push(Buffer.from(simple[token[1]]));
} else {
const octal = /[0-7]/.test(token[1]);
const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16);
if (octal || token[1] === 'x') {
// Octal and hex escapes encode bytes, not Unicode code points.
if (value > 255) return null;
chunks.push(Buffer.from([value]));
} else {
if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null;
chunks.push(Buffer.from(String.fromCodePoint(value)));
}
}
}
if (consumed !== body.length) return null;
try {
// Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL.
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
} catch {
return null;
}
}
/** The framework this file routes with, when exactly one is imported. */
function readImports(root: SyntaxNode): {
readonly framework: Framework | null;

View file

@ -0,0 +1,75 @@
import type { SyntaxNode } from 'tree-sitter';
/**
* Go string-literal decoding shared by the Go route extractors on both
* layers: the ingestion extractor (`go-gin-echo.ts`, Strategy A) and the
* group-mode HTTP plugin (`group/extractors/http-patterns/go.ts`,
* Strategy B). Both sides must produce the SAME text for a route literal,
* or the same route lands under two different contract ids that never
* collide in the merge — a wrong-path duplicate that survives alongside
* the graph's correct entry.
*
* Go's semantics, per `strconv.Unquote`:
* - interpreted (`"…"`) strings decode escapes (`\n`, `\x2f`, `ሴ`,
* `\101`, …); hex and octal escapes encode BYTES, not code points;
* - raw (`` `…` ``) strings have no escapes (a backslash is literal),
* and Go discards carriage returns in them, including CRLF source files;
* - a string that cannot be decoded to valid UTF-8 text (invalid escape,
* non-UTF-8 bytes) returns null — callers decline instead of guessing,
* since a URL cannot carry those bytes losslessly.
*
* Nodes that are not string literals (an identifier prefix, a rune
* literal, an errored subtree) also return null.
*/
export function stringLiteral(node: SyntaxNode | null | undefined): string | null {
if (!node || node.hasError) return null;
const body = node.text.slice(1, -1);
// Go discards carriage returns in raw strings, including CRLF source files.
if (node.type === 'raw_string_literal') return body.replace(/\r/g, '');
if (node.type !== 'interpreted_string_literal') return null;
if (!body.includes('\\')) return body;
const simple: Readonly<Record<string, string>> = {
a: '\x07',
b: '\b',
f: '\f',
n: '\n',
r: '\r',
t: '\t',
v: '\v',
'\\': '\\',
'"': '"',
};
const chunks: Buffer[] = [];
const tokens =
/\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g;
let consumed = 0;
for (const match of body.matchAll(tokens)) {
if (match.index !== consumed) return null;
const token = match[0];
consumed += token.length;
if (!token.startsWith('\\')) {
chunks.push(Buffer.from(token));
} else if (simple[token[1]] !== undefined) {
chunks.push(Buffer.from(simple[token[1]]));
} else {
const octal = /[0-7]/.test(token[1]);
const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16);
if (octal || token[1] === 'x') {
// Octal and hex escapes encode bytes, not Unicode code points.
if (value > 255) return null;
chunks.push(Buffer.from([value]));
} else {
if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null;
chunks.push(Buffer.from(String.fromCodePoint(value)));
}
}
}
if (consumed !== body.length) return null;
try {
// Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL.
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
} catch {
return null;
}
}

View file

@ -0,0 +1,969 @@
/**
* Group HTTP-contract layer: Go gin/echo framework routes registered through
* route groups and method-value handlers. Exercises `GO_HTTP_PLUGIN.scan`
* directly with a real tree-sitter parser, asserting the FULL registered path
* (every enclosing `x := y.Group("/p")` prefix joined in) and the handler name
* the group layer resolves by. The last block runs the real extractor on a Go
* provider repo and a fetch() consumer repo and pairs them with `runExactMatch`.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
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,
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';
const parser = new Parser();
interface Provider {
method: string;
path: string;
name: string | null;
}
function providers(src: string): Provider[] {
parser.setLanguage(Go);
return GO_HTTP_PLUGIN.scan(parser.parse(src))
.filter((d) => d.role === 'provider')
.map(({ method, path: p, name }) => ({ method, path: p, name }));
}
// Mirrors the shapes of a real gin `RegisterRoutes`: an engine-level group,
// `{ }` blocks, nested groups three levels deep, empty-prefix groups used only
// to attach middleware, method-value / package-func / identifier / inline
// handlers, and variadic middleware before the handler.
const GIN_ROUTES = `package handlers
import "github.com/gin-gonic/gin"
func RegisterRoutes(r *gin.Engine, svc *service.Service) {
playerHandler := NewPlayerHandler(svc.Player)
matchHandler := NewMatchHandler(svc.Match)
r.GET("/ping", pingHandle)
v1 := r.Group("/api/v1")
{
v1.GET("/health", func(c *gin.Context) { c.Status(200) })
v1.GET("/players", playerHandler.GetPlayersHandle)
v1.POST("/upload/avatar", UploadAvatarHandle)
v1.GET("/exports", exports.ListExportsHandle)
v1.PATCH("/players/:playerId", middleware.AuthRequired(svc.Auth), playerHandler.UpdatePlayerHandle)
admin := v1.Group("/admin")
admin.Use(middleware.AdminRequired(svc.AdminControl))
{
admin.POST("/seasons/:seasonId/rounds/:roundId/unfinalize", matchHandler.UnfinalizeRoundHandle)
newsAdmin := admin.Group("/news")
{
newsAdmin.DELETE("/:id", newsHandler.DeleteNewsHandle)
}
knockoutAdmin := admin.Group("", middleware.KnockoutGuard())
knockoutAdmin.POST("/seasons/:seasonId/knockout", knockoutHandler.CreateKnockoutHandle)
adminOnly := admin.Group("")
adminOnly.PUT("/seasons/:seasonId/streak-config", streakHandler.SaveStreakConfigHandle)
}
}
}
`;
describe('GO_HTTP_PLUGIN — gin route groups', () => {
const got = providers(GIN_ROUTES);
const find = (method: string, p: string) => got.find((d) => d.method === method && d.path === p);
it('emits exactly one provider per registered route (Use() and Group() are not routes)', () => {
expect(got).toHaveLength(10);
});
it('keeps a route on the engine root, outside any group, at its literal path', () => {
expect(find('GET', '/ping')).toEqual({ method: 'GET', path: '/ping', name: 'pingHandle' });
});
it('prefixes an inline func_literal handler and leaves it unnamed', () => {
expect(find('GET', '/api/v1/health')).toEqual({
method: 'GET',
path: '/api/v1/health',
name: null,
});
});
it('accepts a method-value handler and names it by its field', () => {
expect(find('GET', '/api/v1/players')?.name).toBe('GetPlayersHandle');
});
it('accepts a package-qualified function handler and names it by its field', () => {
expect(find('GET', '/api/v1/exports')?.name).toBe('ListExportsHandle');
});
it('keeps an identifier handler', () => {
expect(find('POST', '/api/v1/upload/avatar')?.name).toBe('UploadAvatarHandle');
});
it('binds the last argument, not the variadic middleware before it', () => {
expect(find('PATCH', '/api/v1/players/:playerId')?.name).toBe('UpdatePlayerHandle');
});
it('joins a nested group inside a { } block', () => {
expect(find('POST', '/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize')?.name).toBe(
'UnfinalizeRoundHandle',
);
});
it('joins groups nested three levels deep', () => {
expect(find('DELETE', '/api/v1/admin/news/:id')?.name).toBe('DeleteNewsHandle');
});
it('treats an empty-prefix group (with or without middleware) as its parent prefix', () => {
expect(find('POST', '/api/v1/admin/seasons/:seasonId/knockout')?.name).toBe(
'CreateKnockoutHandle',
);
expect(find('PUT', '/api/v1/admin/seasons/:seasonId/streak-config')?.name).toBe(
'SaveStreakConfigHandle',
);
});
});
describe('GO_HTTP_PLUGIN — group binding edge cases', () => {
it('follows plain `=` assignment and `var x = …` declarations', () => {
const got = providers(`package main
func routes(r *gin.Engine) {
var api *gin.RouterGroup
api = r.Group("/v2")
api.GET("/a", h.A)
var ops = api.Group("/ops")
ops.GET("/b", h.B)
}
`);
expect(got).toEqual([
{ method: 'GET', path: '/v2/a', name: 'A' },
{ method: 'GET', path: '/v2/ops/b', name: 'B' },
]);
});
it('joins a Group() call chained directly onto the route call', () => {
expect(
providers(`package main
func routes(r *gin.Engine) {
r.Group("/inline").GET("/y", h.Y)
}
`),
).toEqual([{ method: 'GET', path: '/inline/y', name: 'Y' }]);
});
it('uses the binding visible at the call site when a name is reused in sibling blocks', () => {
expect(
providers(`package main
func routes(r *gin.Engine) {
{
g := r.Group("/a")
g.GET("/x", h.AX)
}
{
g := r.Group("/b")
g.GET("/x", h.BX)
}
}
`),
).toEqual([
{ method: 'GET', path: '/a/x', name: 'AX' },
{ method: 'GET', path: '/b/x', name: 'BX' },
]);
});
it('uses the latest assignment that precedes the route, not one after it', () => {
expect(
providers(`package main
func routes(r *gin.Engine) {
g := r.Group("/first")
g.GET("/x", h.X)
g = r.Group("/second")
g.GET("/y", h.Y)
}
`),
).toEqual([
{ method: 'GET', path: '/first/x', name: 'X' },
{ method: 'GET', path: '/second/y', name: 'Y' },
]);
});
it('resolves a group captured by a closure registered inside the function', () => {
expect(
providers(`package main
func routes(r *gin.Engine) {
v1 := r.Group("/api/v1")
register := func() {
v1.GET("/inner", h.Inner)
}
register()
}
`),
).toEqual([{ method: 'GET', path: '/api/v1/inner', name: 'Inner' }]);
});
it('keeps the literal path when the receiver is a parameter (group passed from another function)', () => {
expect(
providers(`package main
func registerAdmin(g *gin.RouterGroup) {
g.GET("/extra", extraHandle)
}
`),
).toEqual([{ method: 'GET', path: '/extra', name: 'extraHandle' }]);
});
it('keeps the literal path when the receiver is bound to something other than Group()', () => {
expect(
providers(`package main
func routes() {
g := newRouter("/ignored")
g.GET("/x", h.X)
}
`),
).toEqual([{ method: 'GET', path: '/x', name: 'X' }]);
});
it('does not resolve a group bound in a different function', () => {
expect(
providers(`package main
func a(r *gin.Engine) {
g := r.Group("/a")
g.GET("/in-a", h.A)
}
func b(g *gin.RouterGroup) {
g.GET("/in-b", h.B)
}
`),
).toEqual([
{ method: 'GET', path: '/a/in-a', name: 'A' },
{ method: 'GET', path: '/in-b', name: 'B' },
]);
});
it('ignores a Group() whose prefix is not a string literal and keeps the route literal', () => {
expect(
providers(`package main
func routes(r *gin.Engine) {
g := r.Group(prefix)
g.GET("/x", h.X)
}
`),
).toEqual([{ method: 'GET', path: '/x', name: 'X' }]);
});
it('uses the group bound by an if initializer in the body and in the else branch', () => {
expect(
providers(`package main
func routes(r *gin.Engine, cond bool) {
g := r.Group("/outer")
if g := r.Group("/inner"); cond {
g.GET("/x", h.X)
} else {
g.GET("/y", h.Y)
}
}
`),
).toEqual([
{ method: 'GET', path: '/inner/x', name: 'X' },
{ method: 'GET', path: '/inner/y', name: 'Y' },
]);
});
it('keeps the outer group when an if initializer binds a different name', () => {
expect(
providers(`package main
func routes(r *gin.Engine, cond bool) {
g := r.Group("/outer")
if x := prepare(); cond {
g.GET("/x", h.X)
}
}
`),
).toEqual([{ method: 'GET', path: '/outer/x', name: 'X' }]);
});
it('stops at an if initializer bound to something other than Group()', () => {
expect(
providers(`package main
func routes(r *gin.Engine, cond bool) {
g := r.Group("/outer")
if g := build(); cond {
g.GET("/x", h.X)
}
}
`),
).toEqual([{ method: 'GET', path: '/x', name: 'X' }]);
});
it('uses a switch initializer group and a group declared inside a case clause', () => {
expect(
providers(`package main
func routes(r *gin.Engine, cond bool) {
g := r.Group("/outer")
switch g := r.Group("/s"); g != nil {
case cond:
g.GET("/x", h.X)
}
switch {
case cond:
g := r.Group("/case")
g.GET("/y", h.Y)
}
}
`),
).toEqual([
{ method: 'GET', path: '/s/x', name: 'X' },
{ method: 'GET', path: '/case/y', name: 'Y' },
]);
});
it('stops at a type-switch guard binding and resolves groups declared in a type case', () => {
expect(
providers(`package main
func routes(r *gin.Engine, anyVal any) {
g := r.Group("/outer")
switch g := anyVal.(type) {
case *Router:
g.GET("/x", h.X)
}
switch anyVal.(type) {
case interface{}:
g := r.Group("/t")
g.GET("/y", h.Y)
}
}
`),
).toEqual([
{ method: 'GET', path: '/x', name: 'X' },
{ method: 'GET', path: '/t/y', name: 'Y' },
]);
});
it('stops at a select receive binding instead of inheriting an outer group', () => {
// The value received from a channel is statically unknown, so a case that
// rebinds `g` must shadow the outer group with "nothing traceable" (the
// route keeps its literal path) — not leak `/outer` into the id. An
// unrebound case still inherits, and a default clause declaring its own
// group shadows like any other statement list.
expect(
providers(`package main
func routes(r *gin.Engine, ch chan *gin.RouterGroup) {
g := r.Group("/outer")
select {
case g := <-ch:
g.GET("/x", h.X)
}
select {
case <-ch:
g.GET("/y", h.Y)
}
select {
default:
g := r.Group("/d")
g.GET("/z", h.Z)
}
}
`),
).toEqual([
{ method: 'GET', path: '/x', name: 'X' },
{ method: 'GET', path: '/outer/y', name: 'Y' },
{ method: 'GET', path: '/d/z', name: 'Z' },
]);
});
it('keeps the outer group through a plain for init and stops at a range shadow binding', () => {
expect(
providers(`package main
func routes(r *gin.Engine, n int, subs []*gin.RouterGroup) {
g := r.Group("/outer")
for i := 0; i < n; i++ {
g.GET("/i", h.I)
}
for _, g := range subs {
g.GET("/r", h.R)
}
for g := r.Group("/loop"); ; {
g.GET("/l", h.L)
}
}
`),
).toEqual([
{ method: 'GET', path: '/outer/i', name: 'I' },
{ method: 'GET', path: '/r', name: 'R' },
{ method: 'GET', path: '/loop/l', name: 'L' },
]);
});
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) {
g := r.Group("/old")
for ; cond; g = r.Group("/post") {
g.GET("/a", h.A)
}
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: '/k/d', name: 'D' }]);
});
it('accepts a raw-string (backtick) group prefix', () => {
expect(
providers(`package main
func routes(r *gin.Engine) {
g := r.Group(\`/api\`)
g.GET("/x", h.X)
}
`),
).toEqual([{ method: 'GET', path: '/api/x', name: 'X' }]);
});
it('accepts a raw-string (backtick) route path, as ingestion does', () => {
// Ingestion (Strategy A) decodes both Go string forms for the route path;
// the group layer must emit the same contract for a backtick path, both
// on a bound group and on a chained `Group(...).GET(...)` receiver.
expect(
providers(`package main
func routes(r *gin.Engine) {
g := r.Group("/api")
g.GET(\`/health\`, h.Health)
r.Group("/v1").POST(\`/raw\\x2fy\`, h.Raw)
}
`),
).toEqual([
{ method: 'GET', path: '/api/health', name: 'Health' },
{ method: 'POST', path: '/v1/raw\\x2fy', name: 'Raw' },
]);
});
it('decodes Go string escapes in group prefixes and route paths', () => {
// "/api\x2fv1" and "/health\x2fcheck" are `/api/v1` and `/health/check`
// once Go processes the escapes — the id must match the registered URL.
expect(
providers(`package main
func routes(r *gin.Engine) {
g := r.Group("/api\\x2fv1")
g.GET("/health\\x2fcheck", h.H)
}
`),
).toEqual([{ method: 'GET', path: '/api/v1/health/check', name: 'H' }]);
});
it('leaves escapes literal inside a raw-string (backtick) prefix', () => {
// Go raw strings process no escapes: the prefix is `/raw\x2fy`, backslash included.
expect(
providers(`package main
func routes(r *gin.Engine) {
g := r.Group(\`/raw\\x2fy\`)
g.GET("/z", h.Z)
}
`),
).toEqual([{ method: 'GET', path: '/raw\\x2fy/z', name: 'Z' }]);
});
it('collapses duplicate slashes so the path matches ingestion normalization', () => {
// normalizeExtractedRoutePath collapses every "//" run; the downstream
// contract-id normalizer does not — a path keeping "//" would get a
// different id than the graph's route node for the same route.
expect(
providers(`package main
func routes(r *gin.Engine) {
g := r.Group("/api//v1")
g.GET("/a//b", h.A)
r.GET("//root", h.R)
r.GET("/ok", h.Ok)
}
`),
).toEqual([
{ method: 'GET', path: '/api/v1/a/b', name: 'A' },
{ method: 'GET', path: '/root', name: 'R' },
{ method: 'GET', path: '/ok', name: 'Ok' },
]);
});
it('forces a leading slash on slashless literals and group prefixes', () => {
// Ingestion's normalizeExtractedRoutePath always adds a leading "/" but
// normalizeHttpPath (the shared contract-id normalizer) does not — a path
// like "x" would become `http::GET::x` here and `http::GET::/x` there,
// splitting the id across the two strategies. Gin likewise panics when a
// route is registered without one.
expect(
providers(`package main
func routes(r *gin.Engine) {
r.GET("x", h.X)
g := r.Group("api")
g.GET("y", h.Y)
r.GET("", h.Root)
}
`),
).toEqual([
{ method: 'GET', path: '/x', name: 'X' },
{ method: 'GET', path: '/api/y', name: 'Y' },
{ method: 'GET', path: '/', name: 'Root' },
]);
});
it("binds echo's handler (first argument) when the file imports echo only", () => {
// echo is `GET(path, handler, middleware...)` — the handler is second,
// not last, so a middleware selector must not become the route's name.
expect(
providers(`package main
import "github.com/labstack/echo/v4"
func routes(e *echo.Echo) {
e.GET("/x", h.Handler, auth.Middleware)
e.POST("/y", handlerID)
e.PUT("/z", func(c echo.Context) error { return nil })
}
`),
).toEqual([
{ method: 'GET', path: '/x', name: 'Handler' },
{ method: 'POST', path: '/y', name: 'handlerID' },
{ method: 'PUT', path: '/z', name: null },
]);
});
it('picks the handler order from a typed receiver parameter in a mixed-import file', () => {
// A parameter's static type proves its framework: `*echo.Echo` /
// `*echo.Group` take echo's order (handler FIRST), gin's types and types
// the file cannot tie to echo keep the last-argument fallback. Method
// receivers and func-literal parameters count the same way.
expect(
providers(`package main
import (
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
)
func routes(e *echo.Echo, g *echo.Group, r *gin.RouterGroup, x *Router) {
e.GET("/e", h.Handler, auth.Middleware)
g.GET("/g", h.Handler, auth.Middleware)
r.GET("/r", auth.Middleware, h.Handler)
x.GET("/x", h.Handler, auth.Middleware)
register := func(sub *echo.Group) {
sub.GET("/f", h.Handler, auth.Middleware)
}
_ = register
}
type Server struct{}
func (s *Server) routes(api *echo.Group) {
api.GET("/m", h.Handler, auth.Middleware)
}
`),
).toEqual([
{ method: 'GET', path: '/e', name: 'Handler' },
{ method: 'GET', path: '/g', name: 'Handler' },
{ method: 'GET', path: '/r', name: 'Handler' },
{ method: 'GET', path: '/x', name: 'Middleware' },
{ method: 'GET', path: '/f', name: 'Handler' },
{ method: 'GET', path: '/m', name: 'Handler' },
]);
});
it('picks the handler order per receiver constructor in a mixed-import file', () => {
// Mixed imports are ambiguous at file scope, but `e := echo.New()` proves
// this call follows echo's order (handler FIRST after the path) and
// `r := gin.Default()` proves gin's (handler LAST).
expect(
providers(`package main
import (
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
)
func routes() {
e := echo.New()
r := gin.Default()
e.GET("/x", h.Handler, auth.Middleware)
r.POST("/y", auth.Middleware, h.Post)
}
`),
).toEqual([
{ method: 'GET', path: '/x', name: 'Handler' },
{ method: 'POST', path: '/y', name: 'Post' },
]);
});
it('does not treat an unrelated New() as a framework constructor', () => {
// `wrapper.New()` is neither echo's nor gin's constructor: no proof →
// conservative last-argument fallback, not a guessed echo order.
expect(
providers(`package main
import (
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
)
func routes() {
e := wrapper.New()
e.GET("/x", h.Handler, auth.Middleware)
}
`),
).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]);
});
it('does not mistake a local shadowing the echo import for its constructor', () => {
// A parameter named `echo` shadows the package qualifier: `echo.New()`
// is then a method on that value, not echo's constructor, so the mixed
// file keeps the conservative last-argument fallback.
expect(
providers(`package main
import (
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
)
func routes(echo *Factory) {
e := echo.New()
e.GET("/x", h.Handler, auth.Middleware)
}
`),
).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]);
});
it('does not mistake a value-less local declaration of echo for the import', () => {
// `var echo Factory` declares a local without an initializer; it still
// shadows the package qualifier, so `echo.New()` proves nothing.
expect(
providers(`package main
import (
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
)
func routes() {
var echo Factory
e := echo.New()
e.GET("/x", h.Handler, auth.Middleware)
}
`),
).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]);
});
it('declines a route whose Group chain exceeds the depth cap', () => {
// Past MAX_GROUP_DEPTH (32) the full prefix — and, in a mixed file, the
// framework order — is unprovable: emitting the outer prefixes alone (or
// gin's handler order for an echo chain) would be a silent guess.
const chain = (ctor: string, imports: string) => {
const lines = [`g0 := ${ctor}`];
for (let i = 1; i <= 34; i++) lines.push(`g${i} := g${i - 1}.Group("/p${i}")`);
return `package main
${imports}
func routes() {
${lines.join('\n\t')}
g34.GET("/x", h.Handler, auth.Middleware)
g2.GET("/y", h.Handler, auth.Middleware)
}
`;
};
expect(providers(chain('gin.Default()', 'import "github.com/gin-gonic/gin"'))).toEqual([
{ method: 'GET', path: '/p1/p2/y', name: 'Middleware' },
]);
const mixed = `import (
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
)`;
expect(providers(chain('echo.New()', mixed))).toEqual([
{ method: 'GET', path: '/p1/p2/y', name: 'Handler' },
]);
});
it('traces a grouped receiver back to its framework constructor in a mixed file', () => {
// The normal grouped shape: `users := api.Group(…)` ← `api := e.Group(…)`
// ← `echo.New()` proves echo order through the Group chain, while the
// gin chain resolves to gin.Default() and keeps the last-argument rule.
expect(
providers(`package main
import (
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
)
func routes() {
e := echo.New()
api := e.Group("/api")
users := api.Group("/users")
users.GET("/:id", h.Handler, auth.Middleware)
r := gin.Default()
v1 := r.Group("/v1")
v1.POST("/x", auth.Middleware, h.Post)
}
`),
).toEqual([
{ method: 'GET', path: '/api/users/:id', name: 'Handler' },
{ method: 'POST', path: '/v1/x', name: 'Post' },
]);
});
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{}
func (a *A) 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", Show)
r.GET("/p", Ping)
}
`),
)
.filter((d) => d.role === 'provider')
.map((d) => [
d.path,
d.name,
d.qualifiedHandler ?? false,
d.strictHandlerResolution ?? false,
]);
expect(flags).toEqual([
['/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' },
]);
});
it('applies the same group logic to echo', () => {
expect(
providers(`package main
func main() {
e := echo.New()
api := e.Group("/api")
users := api.Group("/users")
users.GET("/:id", userHandler.Get)
}
`),
).toEqual([{ method: 'GET', path: '/api/users/:id', name: 'Get' }]);
});
});
describe('Go gin provider ↔ fetch() consumer pairing', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-gin-groups-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
const repoHandle = (repoPath: string, id: string): RepoHandle => ({
id,
path: id,
repoPath,
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');
fs.mkdirSync(path.join(backend, 'internal/handlers'), { recursive: true });
fs.mkdirSync(path.join(web, 'src/pages'), { recursive: true });
fs.writeFileSync(path.join(backend, 'internal/handlers/routes.go'), GIN_ROUTES);
fs.writeFileSync(
path.join(web, 'src/pages/AdminMatchesPage.tsx'),
`const API_BASE = import.meta.env.VITE_API_BASE;
export async function handleUnfinalize(seasonId: string, roundId: string) {
await fetch(\`\${API_BASE}/api/v1/admin/seasons/\${seasonId}/rounds/\${roundId}/unfinalize\`, {
method: 'POST',
});
}
`,
);
const extractor = new HttpRouteExtractor();
const contracts: StoredContract[] = [
...(await extractor.extract(null, backend, repoHandle(backend, 'backend'))).map((c) => ({
...c,
repo: 'backend',
})),
...(await extractor.extract(null, web, repoHandle(web, 'web'))).map((c) => ({
...c,
repo: 'web',
})),
];
const { matched } = runExactMatch(contracts);
const link = matched.find(
(l) => l.contractId === 'http::POST::/api/v1/admin/seasons/{param}/rounds/{param}/unfinalize',
);
expect(link).toMatchObject({
from: { repo: 'web', symbolRef: { filePath: 'src/pages/AdminMatchesPage.tsx' } },
to: {
repo: 'backend',
symbolRef: { filePath: 'internal/handlers/routes.go', name: 'UnfinalizeRoundHandle' },
},
matchType: 'exact',
});
});
});