mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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
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:
parent
1a5d88391c
commit
504bff7102
6 changed files with 1573 additions and 81 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
75
gitnexus/src/core/ingestion/route-extractors/go-shared.ts
Normal file
75
gitnexus/src/core/ingestion/route-extractors/go-shared.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
969
gitnexus/test/unit/group/go-gin-route-groups.test.ts
Normal file
969
gitnexus/test/unit/group/go-gin-route-groups.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue