feat(go): index GoFrame g.Meta routes and bind them to controller methods (#747) (#957)

GoFrame's standard router binds routes reflectively (group.Bind(ctrl)): the path
and method live in a g.Meta struct tag on a request type, and the controller
method that serves it is matched by that request type at runtime — so there was
no path string and no edge from a route to its handler, and "where is this route
handled / where are routes bound to controllers?" could only be answered
lexically (issue #720's report).

- frameworks/goframe.ts: detect gogf/gf in go.mod, extract each path-bearing
  g.Meta into a route node (requires path:, so response mime:-only tags are
  skipped), encoding the package-qualified request type for the join.
- goframe-synthesizer.ts: join each route -> the controller method whose
  signature takes that request type — NOT by name (DeptSearchReq is served by
  List) — keyed pkg.Type to disambiguate the many identical bare names a large
  app defines one-per-module, with an addon-root tiebreak for cloned demo addons.
  Edge kind calls, provenance heuristic, synthesizedBy goframe-route, surfaced as
  a dynamic-dispatch hop in codegraph_explore.

Validated on real repos: gf-demo-user 7/7, gfast 65/68 (3 genuinely
handler-less), hotgo 242/247 (98%) — 100% precision (0 non-controller handlers,
0 core/addon cross-binding), node count stable. Agent A/B (gfast, sonnet/high,
2 runs/arm): with codegraph 1 explore call / 0 Read / ~20s vs without 7.5 Read
avg + grep-hunting for the non-existent literal route string / ~42s; same correct
answer.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-22 18:16:11 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 6459ead6aa
commit a89315645d
9 changed files with 531 additions and 0 deletions
+8
View File
@@ -1651,6 +1651,14 @@ export class ToolHandler {
registeredAt,
};
}
if (m?.synthesizedBy === 'goframe-route') {
const route = m.route ? `\`${String(m.route)}\`` : 'a route';
return {
label: `GoFrame route ${route} — reflective Bind → controller method (dynamic dispatch)`,
compact: `dynamic: GoFrame route ${m.route ? String(m.route) : ''}${at}`,
registeredAt,
};
}
// Generic fallback for any other synthesizer (redux-thunk, gin-middleware-chain,
// flutter-build, …): a synthesized hop must never read as a bare static `calls`.
// It's a dynamic-dispatch bridge — label it as one and keep its wiring site.
+3
View File
@@ -27,6 +27,7 @@ import type { ResolutionContext } from './types';
import { isGeneratedFile } from '../extraction/generated-detection';
import { stripCommentsForRegex } from './strip-comments';
import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
import { goframeRouteEdges } from './goframe-synthesizer';
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
@@ -2703,6 +2704,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
const sidekiqEdges = sidekiqDispatchEdges(ctx);
const laravelEdges = laravelEventEdges(ctx);
const cFnPtrEdges = cFnPointerDispatchEdges(queries, ctx);
const goframeEdges = goframeRouteEdges(ctx);
const merged: Edge[] = [];
const seen = new Set<string>();
@@ -2737,6 +2739,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
...sidekiqEdges,
...laravelEdges,
...cFnPtrEdges,
...goframeEdges,
]) {
const key = `${e.source}>${e.target}`;
if (seen.has(key)) continue;
+118
View File
@@ -0,0 +1,118 @@
/**
* GoFrame Framework Resolver (route metadata) issue #747.
*
* GoFrame's "standard router" binds routes reflectively, so there is no literal
* path string at a `.GET("/x", handler)` call site and no static edge from a
* route to the controller method that serves it. The structural facts live in
* two places, joined only at runtime by GoFrame:
*
* // api/user/v1/user_sign_in.go — the route lives in a struct tag on the request type
* type SignInReq struct {
* g.Meta `path:"/user/sign-in" method:"post" tags:"UserService" summary:"…"`
*
* }
* // internal/controller/user/user_v1_sign_in.go — the handler takes *that* request type
* func (c *ControllerV1) SignIn(ctx context.Context, req *v1.SignInReq) (res *v1.SignInRes, err error)
* // internal/cmd/cmd.go — reflective binding (no path, no handler name)
* group.Bind(user.NewV1())
*
* This resolver handles the FIRST half: it reads the `g.Meta` struct tag on a
* request type into a `route` node (`POST /user/sign-in`). The route handler
* EDGE is the genuinely reflective part the method name is NOT derivable from
* the request type (`DeptSearchReq` is served by `List`, `DeptAddReq` by `Add`),
* so the only reliable join is the request type appearing in the method's
* parameter signature. That whole-graph join is done by the companion
* `goframeRouteEdges` synthesizer, which reads the request type back out of the
* route node's qualifiedName.
*
* Honesty note: the route node carries the `g.Meta` path verbatim. The group
* prefix from `s.Group("/api", …)` / nested `group.Group("/v1", …)` is applied
* by reflective `Bind` at runtime and is deliberately NOT reconstructed here
* the discriminating, structural part is the per-route path + method.
*/
import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
/**
* A request type carrying a routable `g.Meta` tag. `g.Meta` is, by GoFrame
* convention, the first embedded field of the struct, so anchoring on
* `struct { g.Meta `` }` is both precise and cheap. Response types embed
* `g.Meta` too but tag it `mime:"…"` with no `path:` the path requirement
* below filters them out.
*/
const GOFRAME_META_RE = /\btype\s+([A-Z]\w*)\s+struct\s*\{\s*g\.Meta\s+`([^`]*)`/g;
const META_PATH_RE = /\bpath:"([^"]+)"/;
const META_METHOD_RE = /\bmethod:"([^"]+)"/;
const GO_PACKAGE_RE = /^\s*package\s+(\w+)/m;
/** Marker embedded in a route node's qualifiedName so the synthesizer can read
* back the request type to join on. The value after it is the package-qualified
* request type (`cash.ListReq`) the package disambiguates the many identical
* bare names (`ListReq`, `GetReq`) a large app defines, one per module. Falls
* back to the bare type when no `package` declaration is found. */
export const GOFRAME_ROUTE_MARKER = '::goframe-route:';
export const goframeResolver: FrameworkResolver = {
name: 'goframe',
languages: ['go'],
detect(context: ResolutionContext): boolean {
const goMod = context.readFile('go.mod');
// GoFrame is `github.com/gogf/gf` (v1) or `github.com/gogf/gf/v2` (v2).
return !!goMod && goMod.includes('github.com/gogf/gf');
},
extract(filePath, content) {
if (!filePath.endsWith('.go')) return { nodes: [], references: [] };
// Cheap reject: the file must mention g.Meta at all.
if (!content.includes('g.Meta')) return { nodes: [], references: [] };
const nodes: Node[] = [];
const now = Date.now();
const safe = stripCommentsForRegex(content, 'go');
const pkg = GO_PACKAGE_RE.exec(safe)?.[1];
GOFRAME_META_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = GOFRAME_META_RE.exec(safe)) !== null) {
const [, requestType, tag] = match;
const pathMatch = META_PATH_RE.exec(tag!);
if (!pathMatch) continue; // response `g.Meta `mime:…`` and other non-route metadata
const routePath = pathMatch[1]!;
const methodMatch = META_METHOD_RE.exec(tag!);
// GoFrame defaults to all methods when `method:` is omitted.
const method = methodMatch ? methodMatch[1]!.toUpperCase() : 'ANY';
const line = safe.slice(0, match.index).split('\n').length;
// The handler's signature qualifies the request type with its package
// (`req *cash.ListReq`); encode `pkg.Type` so the synthesizer can match it.
const joinKey = pkg ? `${pkg}.${requestType}` : requestType!;
nodes.push({
id: `route:${filePath}:${line}:${method}:${routePath}`,
kind: 'route',
name: `${method} ${routePath}`,
// The request type is the synthesizer's join key — encode it after the
// marker. The path stays human-readable in `name`.
qualifiedName: `${filePath}${GOFRAME_ROUTE_MARKER}${joinKey}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: match[0].length,
language: 'go',
updatedAt: now,
});
}
return { nodes, references: [] };
},
// The route → controller-method edge is reflective (request-type join across
// files) and is built by the `goframeRouteEdges` synthesizer after the graph
// is complete. This resolver creates no references of its own.
resolve(_ref: UnresolvedRef, _context: ResolutionContext): ResolvedRef | null {
return null;
},
};
+3
View File
@@ -19,6 +19,7 @@ import { railsResolver } from './ruby';
import { springResolver } from './java';
import { playResolver } from './play';
import { goResolver } from './go';
import { goframeResolver } from './goframe';
import { rustResolver } from './rust';
import { aspnetResolver } from './csharp';
import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
@@ -52,6 +53,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
playResolver,
// Go
goResolver,
goframeResolver,
// Rust
rustResolver,
// C#
@@ -136,6 +138,7 @@ export { railsResolver } from './ruby';
export { springResolver } from './java';
export { playResolver } from './play';
export { goResolver } from './go';
export { goframeResolver } from './goframe';
export { rustResolver } from './rust';
export { aspnetResolver } from './csharp';
export { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
+144
View File
@@ -0,0 +1,144 @@
/**
* GoFrame route controller-method dispatch synthesis (#747).
*
* GoFrame binds routes reflectively (`group.Bind(user.NewV1())`), so the route
* declared in a request type's `g.Meta` tag has no static edge to the method
* that serves it. The `goframeResolver` extract pass turns each `g.Meta` into a
* `route` node carrying its request type in the qualifiedName; this whole-graph
* pass closes the loop by joining each route to its handler.
*
* The join key is the REQUEST TYPE, not the method name GoFrame method names
* are free (`DeptSearchReq` is served by `List`, `DeptAddReq` by `Add`), so the
* only reliable link is the request type appearing in the handler's parameter
* signature:
*
* func (c *sysDeptController) Add(ctx context.Context, req *system.DeptAddReq) ()
* ^^^^^^^^^^^^^^^^ the join
*
* Go method nodes already carry that signature, so no source re-read is needed.
* Each synthesized edge is `kind:'calls'`, `provenance:'heuristic'`,
* `metadata.synthesizedBy:'goframe-route'` a reflective-dispatch bridge, so
* `codegraph_explore` surfaces it as a dynamic hop rather than a literal call,
* and the handler's callers list the route that reaches it. A project with no
* GoFrame routes is a no-op.
*/
import type { Edge, Node } from '../types';
import type { ResolutionContext } from './types';
import { GOFRAME_ROUTE_MARKER } from './frameworks/goframe';
const FANOUT_CAP = 2000; // backstop only; real apps are 1 route → 1 method.
/**
* Pointer-parameter types in a Go method signature, in both qualified and bare
* forms: `(ctx context.Context, req *cash.ListReq)` `["cash.ListReq",
* "ListReq"]`. The qualified form disambiguates the many identical bare names a
* large app defines (one `ListReq` per module); the bare form is the fallback
* for a same-package (unqualified) handler. The response pointer (`*cash.ListRes`)
* is captured too but never matches a request type, so it drops out of the join.
*/
function pointerParamTypes(sig: string): string[] {
const out: string[] = [];
const re = /\*\s*(?:(\w+)\.)?([A-Z]\w*)\b/g;
let m: RegExpExecArray | null;
while ((m = re.exec(sig)) !== null) {
if (m[1]) out.push(`${m[1]}.${m[2]}`);
out.push(m[2]!);
}
return out;
}
/** The addon/plugin module a path lives under (`addons/hgexample/` `hgexample`),
* or `''` for the core app. Large GoFrame apps ship demo addons that CLONE the
* whole module tree identical package names and request types so the package
* qualifier can't tell an addon's `config.GetReq` from core's. The addon root can. */
function addonRoot(p: string): string {
return /(?:^|\/)addons\/([^/]+)\//.exec(p)?.[1] ?? '';
}
/**
* Pick the one handler for a route from same-request-type candidates. Usually a
* single candidate. When several share the request type (a cloned addon module),
* keep controller-dir methods, then the one in the route's own module (core route
* core handler, addon route that addon's handler). Ambiguity left over no
* edge (silent beats wrong).
*/
function selectHandler(candidates: Node[], routeFile: string): Node | null {
if (candidates.length === 1) return candidates[0]!;
let cands = candidates.filter((h) => /\/controller(s)?\//.test(h.filePath));
if (cands.length === 0) cands = candidates;
if (cands.length === 1) return cands[0]!;
const ar = addonRoot(routeFile);
const sameModule = cands.filter((h) => addonRoot(h.filePath) === ar);
return sameModule.length === 1 ? sameModule[0]! : null;
}
export function goframeRouteEdges(ctx: ResolutionContext): Edge[] {
// Route nodes the goframe extractor created, keyed by their package-qualified
// request type (`cash.ListReq`). `wanted` holds every key a handler signature
// could match — the qualified form plus its bare type fallback.
const routesByReqType = new Map<string, Node[]>();
const wanted = new Set<string>();
for (const route of ctx.getNodesByKind('route')) {
if (route.language !== 'go') continue;
const marker = route.qualifiedName.indexOf(GOFRAME_ROUTE_MARKER);
if (marker < 0) continue;
const joinKey = route.qualifiedName.slice(marker + GOFRAME_ROUTE_MARKER.length);
if (!joinKey) continue;
let arr = routesByReqType.get(joinKey);
if (!arr) { arr = []; routesByReqType.set(joinKey, arr); }
arr.push(route);
wanted.add(joinKey);
const dot = joinKey.lastIndexOf('.');
if (dot >= 0) wanted.add(joinKey.slice(dot + 1)); // bare fallback
}
if (routesByReqType.size === 0) return [];
// Handler candidates: Go methods whose signature takes a wanted request type by
// pointer, indexed by every matching (qualified + bare) form so a route can
// match precisely on `pkg.Type` and fall back to the bare `Type`.
const handlersByKey = new Map<string, Node[]>();
for (const method of ctx.getNodesByKind('method')) {
if (method.language !== 'go' || !method.signature) continue;
for (const t of pointerParamTypes(method.signature)) {
if (!wanted.has(t)) continue;
let arr = handlersByKey.get(t);
if (!arr) { arr = []; handlersByKey.set(t, arr); }
arr.push(method);
}
}
const edges: Edge[] = [];
const seen = new Set<string>();
let added = 0;
for (const [joinKey, routes] of routesByReqType) {
const bare = joinKey.includes('.') ? joinKey.slice(joinKey.lastIndexOf('.') + 1) : joinKey;
// Precise package-qualified match first; bare type only as a fallback (covers
// a same-package handler or an aliased import where the bare name is unique).
const candidates = handlersByKey.get(joinKey) ?? handlersByKey.get(bare);
if (!candidates || candidates.length === 0) continue;
const requestType = bare;
for (const route of routes) {
const handler = selectHandler(candidates, route.filePath);
if (!handler || route.id === handler.id) continue;
const key = `${route.id}>${handler.id}`;
if (seen.has(key) || added >= FANOUT_CAP) continue;
seen.add(key);
edges.push({
source: route.id,
target: handler.id,
kind: 'calls',
line: route.startLine,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'goframe-route',
route: route.name,
requestType,
registeredAt: `${handler.filePath}:${handler.startLine}`,
},
});
added++;
}
}
return edges;
}