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