fix(group): decline over-deep Go group chains and shadowed echo names

- groupPrefix / receiverBindsToEchoConstructor return null past
  MAX_GROUP_DEPTH; scan declines the route instead of emitting a
  truncated prefix or falling back to gin's handler order for an
  echo chain.
- The echo constructor check rejects an `echo` qualifier shadowed by a
  local binding or an enclosing function's parameter/receiver.
- The gin integration fixture now imports gin like a real file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rgb-vgx 2026-10-04 15:23:36 +07:00
parent dd1ed19c6e
commit 4221b40fb3
2 changed files with 96 additions and 12 deletions

View file

@ -288,15 +288,17 @@ function findBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null {
* enclosing `Group(...)` calls (`users := api.Group(…)` ← `api := e.Group(…)`
* ← `echo.New()`), the normal shape of grouped routes (review #7, #10).
* Provenance must still END at a constructor: parameters, unrelated packages'
* `New()`, and anything else return false so the caller keeps the
* conservative last-argument fallback instead of guessing.
* `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.
*/
function receiverBindsToEchoConstructor(
receiver: Parser.SyntaxNode,
echoAliases: ReadonlySet<string>,
depth = 0,
): boolean {
if (depth > MAX_GROUP_DEPTH) return false;
): 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' ? findBinding(receiver) : receiver;
@ -307,7 +309,7 @@ function receiverBindsToEchoConstructor(
const operand = fn.childForFieldName('operand');
if (!operand) return false;
if (field === 'New' || field === 'Default') {
return operand.type === 'identifier' && echoAliases.has(operand.text);
return operand.type === 'identifier' && echoAliases.has(operand.text) && !isLocalName(operand);
}
if (field === 'Group') {
return receiverBindsToEchoConstructor(operand, echoAliases, depth + 1);
@ -315,13 +317,42 @@ function receiverBindsToEchoConstructor(
return false;
}
/** Joined `Group(...)` prefix of a route receiver; '' when it cannot be traced. */
function groupPrefix(receiver: Parser.SyntaxNode, depth = 0): string {
if (depth > MAX_GROUP_DEPTH) return '';
/**
* Whether `ident` names a local value rather than an imported package: a
* binding in scope 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 {
if (findBinding(ident) !== null) return true;
for (let node = ident.parent; node; node = node.parent) {
if (
node.type !== 'func_literal' &&
node.type !== 'function_declaration' &&
node.type !== 'method_declaration'
) {
continue;
}
const lists = [node.childForFieldName('parameters'), node.childForFieldName('receiver')];
for (const list of lists) {
if (list?.descendantsOfType('identifier').some((p) => p.text === ident.text)) return true;
}
if (node.type !== 'func_literal') return false;
}
return false;
}
/**
* 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.
*/
function groupPrefix(receiver: Parser.SyntaxNode, depth = 0): string | null {
if (depth > MAX_GROUP_DEPTH) return null;
const value = receiver.type === 'identifier' ? findBinding(receiver) : receiver;
const group = value ? asGroupCall(value) : null;
if (!group) return '';
return joinRoutePath(groupPrefix(group.parent, depth + 1), group.prefix);
const outer = groupPrefix(group.parent, depth + 1);
return outer === null ? null : joinRoutePath(outer, group.prefix);
}
// ─── Provider: net/http `http.HandleFunc("/p", handler)` ─────────────
@ -446,11 +477,14 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
? 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(groupPrefix(receiverNode), literalPath)
: literalPath;
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 handler keeps its name and

View file

@ -39,6 +39,8 @@ function providers(src: string): Provider[] {
// 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)
@ -575,6 +577,54 @@ func routes() {
).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('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