From 504bff7102e39f45649917396f44ee84b5efb8cc Mon Sep 17 00:00:00 2001 From: rgb-vgx Date: Sun, 4 Oct 2026 18:55:41 +0700 Subject: [PATCH] fix(group): join gin/echo route-group prefixes and accept method-value handlers in Go HTTP providers (#3458) --- .../core/group/extractors/http-patterns/go.ts | 532 +++++++++- .../group/extractors/http-patterns/types.ts | 8 + .../group/extractors/http-route-extractor.ts | 16 +- .../ingestion/route-extractors/go-gin-echo.ts | 54 +- .../ingestion/route-extractors/go-shared.ts | 75 ++ .../unit/group/go-gin-route-groups.test.ts | 969 ++++++++++++++++++ 6 files changed, 1573 insertions(+), 81 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/go-shared.ts create mode 100644 gitnexus/test/unit/group/go-gin-route-groups.test.ts diff --git a/gitnexus/src/core/group/extractors/http-patterns/go.ts b/gitnexus/src/core/group/extractors/http-patterns/go.ts index ebf87f0e1..6d29de5fe 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/go.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/go.ts @@ -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>); +/** 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', + '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; + gin: Set; +} { + // 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(); + const gin = new Set(); + 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, + 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, +): 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(); + 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', 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/src/core/ingestion/route-extractors/go-gin-echo.ts b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts index f7cdd1c64..e77b1b82b 100644 --- a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts +++ b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts @@ -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 = 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> = { - 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; diff --git a/gitnexus/src/core/ingestion/route-extractors/go-shared.ts b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts new file mode 100644 index 000000000..a2d13faef --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts @@ -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> = { + 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; + } +} diff --git a/gitnexus/test/unit/group/go-gin-route-groups.test.ts b/gitnexus/test/unit/group/go-gin-route-groups.test.ts new file mode 100644 index 000000000..bdb5bde56 --- /dev/null +++ b/gitnexus/test/unit/group/go-gin-route-groups.test.ts @@ -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[]) => { + 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'); + 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', + }); + }); +});