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:
co-authored by
Claude Opus 4.8
parent
6459ead6aa
commit
a89315645d
@@ -23,6 +23,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
- `codegraph_explore` now follows **Laravel events** in PHP. An `event(new OrderShipped($order))` call now links to every listener that handles it — each listener's `handle()` method, usually a separate `app/Listeners/` class — so "what reacts to this event?" traces from the dispatch straight into the listener bodies. Listeners are found both ways Laravel registers them: by a typed `handle(OrderShipped $event)` (auto-discovery, including a `handle(A|B $event)` union that listens for two events) and by the `protected $listen` map in your `EventServiceProvider` (which also catches a listener whose `handle()` has no type-hint). One event fans out to all its listeners, and queued jobs — dispatched via `::dispatch()` rather than `event()` — are correctly left out.
|
- `codegraph_explore` now follows **Laravel events** in PHP. An `event(new OrderShipped($order))` call now links to every listener that handles it — each listener's `handle()` method, usually a separate `app/Listeners/` class — so "what reacts to this event?" traces from the dispatch straight into the listener bodies. Listeners are found both ways Laravel registers them: by a typed `handle(OrderShipped $event)` (auto-discovery, including a `handle(A|B $event)` union that listens for two events) and by the `protected $listen` map in your `EventServiceProvider` (which also catches a listener whose `handle()` has no type-hint). One event fans out to all its listeners, and queued jobs — dispatched via `::dispatch()` rather than `event()` — are correctly left out.
|
||||||
- CodeGraph now understands **Lombok**-generated methods in Java. `@Getter`, `@Setter`, `@Data`, `@Value`, and `@Builder` generate getters, setters, `builder()`, `equals`/`hashCode`/`toString`, and the `@Slf4j` `log` field at compile time, so those methods never appear in the source — and a `user.getName()`, `User.builder()`, or `log.info(...)` call used to resolve to nothing, silently breaking call-chain analysis (the agent would conclude the method didn't exist and reconstruct it by hand). Those members are now indexed from the annotations and fields, so they appear in `codegraph search` and `codegraph_explore`/`codegraph_node`, and callers trace through them like any hand-written method. They're marked as Lombok-generated so they read as generated, not hand-written; a method you write yourself is never overridden, static fields get no accessor, and a class without Lombok is unaffected. Thanks @git87663849. (#912)
|
- CodeGraph now understands **Lombok**-generated methods in Java. `@Getter`, `@Setter`, `@Data`, `@Value`, and `@Builder` generate getters, setters, `builder()`, `equals`/`hashCode`/`toString`, and the `@Slf4j` `log` field at compile time, so those methods never appear in the source — and a `user.getName()`, `User.builder()`, or `log.info(...)` call used to resolve to nothing, silently breaking call-chain analysis (the agent would conclude the method didn't exist and reconstruct it by hand). Those members are now indexed from the annotations and fields, so they appear in `codegraph search` and `codegraph_explore`/`codegraph_node`, and callers trace through them like any hand-written method. They're marked as Lombok-generated so they read as generated, not hand-written; a method you write yourself is never overridden, static fields get no accessor, and a class without Lombok is unaffected. Thanks @git87663849. (#912)
|
||||||
- `codegraph_explore` now follows **C and C++ function-pointer dispatch**. C does polymorphism with function pointers: a struct carries a function-pointer field, concrete functions are registered into it through a table (`static struct cmd commands[] = {{"add", cmd_add}, …}`), a designated initializer (`.handler = on_open`), or an assignment, and the code dispatches indirectly (`p->fn(argv)`). None of that was visible to analysis — the indirect call resolved to nothing, so `git`'s command runner looked like it called nothing and a vtable's implementations had no callers. CodeGraph now links the dispatch site to the registered handlers, keyed by the struct field, so "what runs when this dispatches?" traces from `p->fn(...)` into every function registered for that field. This covers the command-table idiom (git, redis) and the ops-struct/vtable idiom (curl's content-encoders, protocol handlers), including the case where a generic hook slot is reassigned from a registry (`h->func = found->fn`). It stays precise — distinct function-pointer fields don't cross-link, a plain data field is never treated as a dispatch, and a project without function-pointer dispatch is unaffected. (#932)
|
- `codegraph_explore` now follows **C and C++ function-pointer dispatch**. C does polymorphism with function pointers: a struct carries a function-pointer field, concrete functions are registered into it through a table (`static struct cmd commands[] = {{"add", cmd_add}, …}`), a designated initializer (`.handler = on_open`), or an assignment, and the code dispatches indirectly (`p->fn(argv)`). None of that was visible to analysis — the indirect call resolved to nothing, so `git`'s command runner looked like it called nothing and a vtable's implementations had no callers. CodeGraph now links the dispatch site to the registered handlers, keyed by the struct field, so "what runs when this dispatches?" traces from `p->fn(...)` into every function registered for that field. This covers the command-table idiom (git, redis) and the ops-struct/vtable idiom (curl's content-encoders, protocol handlers), including the case where a generic hook slot is reassigned from a registry (`h->func = found->fn`). It stays precise — distinct function-pointer fields don't cross-link, a plain data field is never treated as a dispatch, and a project without function-pointer dispatch is unaffected. (#932)
|
||||||
|
- `codegraph_explore` now follows **GoFrame** route bindings in Go. GoFrame's standard router wires routes reflectively: the path and method live in a `g.Meta` struct tag on a request type (`` g.Meta `path:"/user/sign-in" method:"post"` ``), the controller method that serves it is matched by that request type, and the two are joined at runtime by `group.Bind(...)` — so there was no path string and no edge from a route to its handler, and "where is `/user/sign-in` handled?" or "where are the routes bound to controllers?" could only be answered by reading. CodeGraph now indexes each `g.Meta` route as a real route node and links it to the controller method whose signature takes that request type, so a route resolves to its handler structurally in one `codegraph_explore` call. The link is by request type, not method name — so it's correct even when the two differ (a `DeptSearchReq` served by a `List` method); it tells apart the many identical request types a large app defines one-per-module (`cash.ListReq` vs `order.ListReq`) by package, including cloned addon modules; and a route whose handler isn't present is left unlinked rather than guessed. (#747)
|
||||||
|
|
||||||
- `codegraph_explore` now surfaces the right code in large multi-layer projects. When you ask a backend-flow question in a repo that pairs an API server with a big frontend that mirrors the same domain words — say an `app/` admin UI sitting over an `api/` server — the server-side file that genuinely matches several of your query's terms is no longer pushed out of the results by the larger, more interconnected frontend layer. A file corroborated by two or more distinct query terms is now kept in the answer even when a denser unrelated layer would otherwise crowd it out, so "how does X read items / handle the request" returns the service or handler that does the work instead of a wall of frontend views. Single-layer projects are unaffected; set `CODEGRAPH_RANK_NO_MULTITERM=1` to revert to the previous ranking.
|
- `codegraph_explore` now surfaces the right code in large multi-layer projects. When you ask a backend-flow question in a repo that pairs an API server with a big frontend that mirrors the same domain words — say an `app/` admin UI sitting over an `api/` server — the server-side file that genuinely matches several of your query's terms is no longer pushed out of the results by the larger, more interconnected frontend layer. A file corroborated by two or more distinct query terms is now kept in the answer even when a denser unrelated layer would otherwise crowd it out, so "how does X read items / handle the request" returns the service or handler that does the work instead of a wall of frontend views. Single-layer projects are unaffected; set `CODEGRAPH_RANK_NO_MULTITERM=1` to revert to the previous ranking.
|
||||||
- Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, and Pascal/Delphi now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off.
|
- Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, and Pascal/Delphi now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off.
|
||||||
|
|||||||
@@ -944,6 +944,78 @@ describe('goResolver.extract', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { goframeResolver } from '../src/resolution/frameworks/goframe';
|
||||||
|
|
||||||
|
describe('goframeResolver', () => {
|
||||||
|
it('detects GoFrame from a gogf/gf dependency in go.mod', () => {
|
||||||
|
const ctx: any = {
|
||||||
|
readFile: (f: string) =>
|
||||||
|
f === 'go.mod' ? 'module example.com/app\nrequire github.com/gogf/gf/v2 v2.7.0\n' : null,
|
||||||
|
};
|
||||||
|
expect(goframeResolver.detect(ctx)).toBe(true);
|
||||||
|
const noGf: any = { readFile: (f: string) => (f === 'go.mod' ? 'module example.com/app\n' : null) };
|
||||||
|
expect(goframeResolver.detect(noGf)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts a route node from a g.Meta request struct (method upper-cased)', () => {
|
||||||
|
const src = `package v1
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
type SignInReq struct {
|
||||||
|
g.Meta \`path:"/user/sign-in" method:"post" tags:"User" summary:"Sign in"\`
|
||||||
|
Passport string
|
||||||
|
}
|
||||||
|
type SignInRes struct{}
|
||||||
|
`;
|
||||||
|
const { nodes } = goframeResolver.extract!('api/user/v1/user_sign_in.go', src);
|
||||||
|
expect(nodes).toHaveLength(1);
|
||||||
|
expect(nodes[0].kind).toBe('route');
|
||||||
|
expect(nodes[0].name).toBe('POST /user/sign-in');
|
||||||
|
// The package-qualified request type is encoded for the synthesizer join.
|
||||||
|
expect(nodes[0].qualifiedName).toContain('::goframe-route:v1.SignInReq');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is independent of g.Meta tag attribute order', () => {
|
||||||
|
const src = `type DeptSearchReq struct {
|
||||||
|
g.Meta \`path:"/dept/list" tags:"Dept" method:"get" summary:"列表"\`
|
||||||
|
}`;
|
||||||
|
const { nodes } = goframeResolver.extract!('api/system/dept.go', src);
|
||||||
|
expect(nodes[0].name).toBe('GET /dept/list');
|
||||||
|
expect(nodes[0].qualifiedName).toContain('::goframe-route:DeptSearchReq');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a response g.Meta that has no path (mime-only) and other non-route metadata', () => {
|
||||||
|
const src = `type ListRes struct {
|
||||||
|
g.Meta \`mime:"application/json"\`
|
||||||
|
Items []string
|
||||||
|
}`;
|
||||||
|
const { nodes } = goframeResolver.extract!('api/x.go', src);
|
||||||
|
expect(nodes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults method to ANY when method: is omitted', () => {
|
||||||
|
const src = `type PingReq struct {
|
||||||
|
g.Meta \`path:"/ping"\`
|
||||||
|
}`;
|
||||||
|
const { nodes } = goframeResolver.extract!('api/ping.go', src);
|
||||||
|
expect(nodes[0].name).toBe('ANY /ping');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts every request struct in a multi-route api file', () => {
|
||||||
|
const src = `type DeptListReq struct { g.Meta \`path:"/dept/list" method:"get"\` }
|
||||||
|
type DeptListRes struct { g.Meta \`mime:"application/json"\` }
|
||||||
|
type DeptAddReq struct { g.Meta \`path:"/dept/add" method:"post"\` }
|
||||||
|
type DeptAddRes struct {}
|
||||||
|
`;
|
||||||
|
const { nodes } = goframeResolver.extract!('api/dept.go', src);
|
||||||
|
expect(nodes.map((n) => n.name).sort()).toEqual(['GET /dept/list', 'POST /dept/add']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for a non-go file or a file without g.Meta', () => {
|
||||||
|
expect(goframeResolver.extract!('main.ts', 'const x = 1').nodes).toHaveLength(0);
|
||||||
|
expect(goframeResolver.extract!('main.go', 'package main\nfunc main() {}\n').nodes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
import { rustResolver } from '../src/resolution/frameworks/rust';
|
import { rustResolver } from '../src/resolution/frameworks/rust';
|
||||||
|
|
||||||
describe('rustResolver.extract', () => {
|
describe('rustResolver.extract', () => {
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* GoFrame route → controller-method coverage (#747), end to end.
|
||||||
|
*
|
||||||
|
* GoFrame binds routes reflectively, so the route declared in a request type's
|
||||||
|
* `g.Meta` tag has no static edge to the controller method that serves it, and
|
||||||
|
* the method name is NOT derivable from the request type (`DeptSearchReq` is
|
||||||
|
* served by `List`). This indexes a fixture through the full pipeline and
|
||||||
|
* checks: the `g.Meta` tags become route nodes; each route joins to its handler
|
||||||
|
* by the request type in the method signature (the naming-mismatch case
|
||||||
|
* included); a response (`mime`-only) `g.Meta` makes no route; a route with no
|
||||||
|
* handler is left unlinked (silent beats wrong); and the response type never
|
||||||
|
* produces a spurious edge.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import { CodeGraph } from '../src';
|
||||||
|
|
||||||
|
describe('GoFrame route synthesizer', () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'goframe-')); });
|
||||||
|
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
it('joins each g.Meta route to its controller method by the request-type signature', async () => {
|
||||||
|
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/app\n\nrequire github.com/gogf/gf/v2 v2.7.0\n');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(dir, 'api', 'system'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'api', 'system', 'dept.go'),
|
||||||
|
`package system
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
type DeptSearchReq struct {
|
||||||
|
g.Meta \`path:"/dept/list" tags:"Dept" method:"get" summary:"list"\`
|
||||||
|
DeptName string
|
||||||
|
}
|
||||||
|
type DeptSearchRes struct {
|
||||||
|
g.Meta \`mime:"application/json"\`
|
||||||
|
List []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeptAddReq struct {
|
||||||
|
g.Meta \`path:"/dept/add" method:"post"\`
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
type DeptAddRes struct{}
|
||||||
|
|
||||||
|
// A declared route whose handler does not exist in this codebase.
|
||||||
|
type OrphanReq struct {
|
||||||
|
g.Meta \`path:"/orphan" method:"get"\`
|
||||||
|
}
|
||||||
|
type OrphanRes struct{}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(dir, 'internal', 'controller'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'internal', 'controller', 'dept.go'),
|
||||||
|
`package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"example.com/app/api/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sysDeptController struct{}
|
||||||
|
|
||||||
|
// NB: method name (List) differs from the request type (DeptSearchReq) — the join
|
||||||
|
// must be by signature, not name.
|
||||||
|
func (c *sysDeptController) List(ctx context.Context, req *system.DeptSearchReq) (res *system.DeptSearchRes, err error) {
|
||||||
|
return helper(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *sysDeptController) Add(ctx context.Context, req *system.DeptAddReq) (res *system.DeptAddRes, err error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the response type but takes no request type — must NOT be linked.
|
||||||
|
func helper(ctx context.Context) (res *system.DeptSearchRes, err error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
const cg = await CodeGraph.init(dir, { silent: true });
|
||||||
|
await cg.indexAll();
|
||||||
|
const db = (cg as any).db.db;
|
||||||
|
|
||||||
|
const routes = db.prepare(`SELECT name FROM nodes WHERE kind='route' ORDER BY name`).all();
|
||||||
|
const edges = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT json_extract(e.metadata,'$.route') route, json_extract(e.metadata,'$.requestType') reqType,
|
||||||
|
e.kind, t.name target_name, t.kind target_kind
|
||||||
|
FROM edges e JOIN nodes t ON t.id = e.target
|
||||||
|
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'goframe-route'
|
||||||
|
ORDER BY route`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
cg.close?.();
|
||||||
|
|
||||||
|
// Three routes from path-bearing g.Meta; the mime-only response g.Meta makes none.
|
||||||
|
expect(routes.map((r: any) => r.name)).toEqual(['GET /dept/list', 'GET /orphan', 'POST /dept/add']);
|
||||||
|
|
||||||
|
// Two route→handler edges — the orphan route stays unlinked (silent beats wrong).
|
||||||
|
expect(edges).toHaveLength(2);
|
||||||
|
const byRoute = Object.fromEntries(edges.map((e: any) => [e.route, e]));
|
||||||
|
|
||||||
|
// Naming mismatch resolved by signature: GET /dept/list → List.
|
||||||
|
expect(byRoute['GET /dept/list'].target_name).toBe('List');
|
||||||
|
expect(byRoute['GET /dept/list'].reqType).toBe('DeptSearchReq');
|
||||||
|
expect(byRoute['POST /dept/add'].target_name).toBe('Add');
|
||||||
|
|
||||||
|
// It is a dynamic-dispatch `calls` hop to a real method, never to the helper.
|
||||||
|
expect(edges.every((e: any) => e.kind === 'calls' && e.target_kind === 'method')).toBe(true);
|
||||||
|
expect(edges.some((e: any) => e.target_name === 'helper')).toBe(false);
|
||||||
|
expect(byRoute['GET /orphan']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disambiguates identical bare request types across modules by package qualifier', async () => {
|
||||||
|
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/app\n\nrequire github.com/gogf/gf/v2 v2.7.0\n');
|
||||||
|
|
||||||
|
// Two modules that BOTH define `type ListReq struct` — the collision a large
|
||||||
|
// GoFrame app has dozens of. The package qualifier in the handler signature
|
||||||
|
// (`*cash.ListReq` vs `*order.ListReq`) is the only thing that tells them apart.
|
||||||
|
for (const mod of ['cash', 'order']) {
|
||||||
|
fs.mkdirSync(path.join(dir, 'api', mod), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'api', mod, `${mod}.go`),
|
||||||
|
`package ${mod}
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
type ListReq struct {
|
||||||
|
g.Meta \`path:"/${mod}/list" method:"get"\`
|
||||||
|
}
|
||||||
|
type ListRes struct{}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.join(dir, 'internal', 'controller', mod), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'internal', 'controller', mod, `${mod}.go`),
|
||||||
|
`package ${mod}
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"example.com/app/api/${mod}"
|
||||||
|
)
|
||||||
|
|
||||||
|
type c${mod} struct{}
|
||||||
|
|
||||||
|
func (c *c${mod}) List(ctx context.Context, req *${mod}.ListReq) (res *${mod}.ListRes, err error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cg = await CodeGraph.init(dir, { silent: true });
|
||||||
|
await cg.indexAll();
|
||||||
|
const db = (cg as any).db.db;
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT json_extract(e.metadata,'$.route') route, t.file_path handler_file
|
||||||
|
FROM edges e JOIN nodes t ON t.id = e.target
|
||||||
|
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'goframe-route'
|
||||||
|
ORDER BY route`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
cg.close?.();
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
// Each route binds to ITS OWN module's handler, never the other's.
|
||||||
|
const byRoute = Object.fromEntries(rows.map((r: any) => [r.route, r.handler_file]));
|
||||||
|
expect(byRoute['GET /cash/list']).toContain('controller/cash/');
|
||||||
|
expect(byRoute['GET /order/list']).toContain('controller/order/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -252,6 +252,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
|
|||||||
| Python | Django / DRF (views) | url → view → model | R + X | ✅ url→view (`path`/`url`/`as_view`) + **DRF `router.register`→ViewSet** (realworld S / wagtail M / saleor L); ORM QuerySet→SQL (prior work). 🔬 signals (`post_save`→receiver), DRF viewset CRUD actions (inherited), saleor GraphQL resolvers |
|
| Python | Django / DRF (views) | url → view → model | R + X | ✅ url→view (`path`/`url`/`as_view`) + **DRF `router.register`→ViewSet** (realworld S / wagtail M / saleor L); ORM QuerySet→SQL (prior work). 🔬 signals (`post_save`→receiver), DRF viewset CRUD actions (inherited), saleor GraphQL resolvers |
|
||||||
| Python | Flask / FastAPI | request → route → handler → dependency | R + X | ✅ **Flask: handler resolved across intervening decorators (`@login_required`) + stacked `@x.route` lines** (microblog S 6→27, redash L decorator routes 6/6); **FastAPI: empty-path router-root routes `@router.get("")` incl. multi-line** (realworld S 12→20 / Netflix dispatch L **290/290 100%**) + **bare-name builtin guard** — a handler named after a Python builtin method (`index`/`get`/`update`/`count`…) was filtered as a builtin and lost its route→handler edge. + **Flask-RESTful `add_resource(Resource,'/x')` → Resource class** (redash 6→**77**) + **tuple `methods=('GET',)`** (was mislabeled GET) + **broadened detection** (requirements/Pipfile/setup + subdir app-factory entrypoints — flask-realworld 0→**19**). 🔬 FastAPI `Depends()` dependency edges (light validation) |
|
| Python | Flask / FastAPI | request → route → handler → dependency | R + X | ✅ **Flask: handler resolved across intervening decorators (`@login_required`) + stacked `@x.route` lines** (microblog S 6→27, redash L decorator routes 6/6); **FastAPI: empty-path router-root routes `@router.get("")` incl. multi-line** (realworld S 12→20 / Netflix dispatch L **290/290 100%**) + **bare-name builtin guard** — a handler named after a Python builtin method (`index`/`get`/`update`/`count`…) was filtered as a builtin and lost its route→handler edge. + **Flask-RESTful `add_resource(Resource,'/x')` → Resource class** (redash 6→**77**) + **tuple `methods=('GET',)`** (was mislabeled GET) + **broadened detection** (requirements/Pipfile/setup + subdir app-factory entrypoints — flask-realworld 0→**19**). 🔬 FastAPI `Depends()` dependency edges (light validation) |
|
||||||
| Go | Gin / chi / gorilla/mux / net-http | request → route → handler → service; middleware chain (`Use`→`Next`) | S + X | ✅ **routes on ANY group var** (`v1.GET`, `PublicGroup.GET`) not just `r/router` (gin-vue-admin S→M 4→259 / realworld S / gitness L) — was missing all group-routed apps; named handlers resolve precisely. **gorilla/mux confirmed covered** by the any-receiver `HandleFunc`/`Handle` handling (subrouter-var `s.HandleFunc(...)` + namespaced handlers; `.Methods()` chain ignored). + **gin middleware-chain synthesizer** (`ginMiddlewareChainEdges`): gin runs its entire chain through one dynamic line — `(*Context).Next` does `c.handlers[c.index](c)`, a slice-index dispatch tree-sitter can't resolve, so `callees(Next)` dead-ended at the `len()` helper (`safeInt8`) and the agent rabbit-holed re-querying it. Find the dispatcher (a Go method invoking a `handlers` slice by index) and link it → every HandlerFunc registered via `.Use`/`.GET`/…/`.Handle`; gated on the dispatcher existing (inert on non-gin Go repos), named handlers only (closures skipped), capped. gin L: `callees(Next)` now surfaces `Logger`/`Recovery`/`ErrorLogger`+handlers (node count stable 2,544; 5 precise edges with `registeredAt` wiring sites). **Agent A/B (headless median-of-4, Opus 4.8): gin flipped from codegraph −58% cost / −129% time (the rabbit-hole, incl. a stray `Workflow` mis-fire on 2/4 WITH runs) → +7% cost / +35% tokens / +8% time / 38% tool calls, all 4 WITH runs clean (0 Read/Grep/Bash, no Workflow, no duplicate calls).** 🔬 inline `func(c){}` handlers (anonymous, body lost); subrouter/`PathPrefix` path-prefix not prepended (label only); gitness chi custom (26/321) |
|
| Go | Gin / chi / gorilla/mux / net-http | request → route → handler → service; middleware chain (`Use`→`Next`) | S + X | ✅ **routes on ANY group var** (`v1.GET`, `PublicGroup.GET`) not just `r/router` (gin-vue-admin S→M 4→259 / realworld S / gitness L) — was missing all group-routed apps; named handlers resolve precisely. **gorilla/mux confirmed covered** by the any-receiver `HandleFunc`/`Handle` handling (subrouter-var `s.HandleFunc(...)` + namespaced handlers; `.Methods()` chain ignored). + **gin middleware-chain synthesizer** (`ginMiddlewareChainEdges`): gin runs its entire chain through one dynamic line — `(*Context).Next` does `c.handlers[c.index](c)`, a slice-index dispatch tree-sitter can't resolve, so `callees(Next)` dead-ended at the `len()` helper (`safeInt8`) and the agent rabbit-holed re-querying it. Find the dispatcher (a Go method invoking a `handlers` slice by index) and link it → every HandlerFunc registered via `.Use`/`.GET`/…/`.Handle`; gated on the dispatcher existing (inert on non-gin Go repos), named handlers only (closures skipped), capped. gin L: `callees(Next)` now surfaces `Logger`/`Recovery`/`ErrorLogger`+handlers (node count stable 2,544; 5 precise edges with `registeredAt` wiring sites). **Agent A/B (headless median-of-4, Opus 4.8): gin flipped from codegraph −58% cost / −129% time (the rabbit-hole, incl. a stray `Workflow` mis-fire on 2/4 WITH runs) → +7% cost / +35% tokens / +8% time / 38% tool calls, all 4 WITH runs clean (0 Read/Grep/Bash, no Workflow, no duplicate calls).** 🔬 inline `func(c){}` handlers (anonymous, body lost); subrouter/`PathPrefix` path-prefix not prepended (label only); gitness chi custom (26/321) |
|
||||||
|
| Go | GoFrame (standard router) | request-type `g.Meta` route → controller method (reflective `group.Bind`) | R (extract) + S | ✅ **GoFrame `g.Meta` route coverage** (#747) — extractor (`frameworks/goframe.ts`, detect `gogf/gf` in go.mod) turns each `` g.Meta `path:.. method:..` `` request-type tag into a `route` node (requires `path:`, so a response `mime:`-only `g.Meta` is skipped), encoding the **package-qualified** request type in qualifiedName. `goframeRouteEdges` synthesizer joins route → the controller method whose **signature** takes that request type — NOT by name (`DeptSearchReq` is served by `List`, `GetDictReq` by `GetDictData`) — keyed `pkg.Type` to separate the dozens of identical bare names a big app defines one-per-module (`cash.ListReq` vs `order.ListReq`), with an **addon-root tiebreak** so a cloned demo addon (`addons/hgexample/`) binds within itself and never cross-links to core. Validated: gf-demo-user S **7/7**, gfast M **65/68** (3 genuinely handler-less DbInit), hotgo L (697 files) **242/247 (98%), 100% precision** (0 non-controller handlers, 0 core/addon cross-binding); node count stable on re-index; surfaces in the handler's caller trail (`POST /dept/add` → `Add` via `goframe-route`). **Agent A/B (gfast M, sonnet/high, 2 runs/arm): WITH = 1 `codegraph_explore` / 0 Read / 0 Grep / ~20s / correct; WITHOUT = 7.5 Read avg + grep-hunting for the non-existent literal `/dept/add` string + re-reading sys_dept.go up to 12× / ~42s — reads eliminated, −83% tool calls, 2.1× faster, cost a wash; both arms reach the same correct call path.** 🔬 group prefix from reflective `Bind` not prepended (route shows the `g.Meta` path, not `/system/dept/list`); the 4×-cloned `index` sub-packages inside one addon are left unlinked (needs import-path resolution, not just package name) |
|
||||||
| Rust | Axum / actix / Rocket | request → route → handler | R + X | ✅ **Axum chained methods + namespaced handlers** — `.route("/x", get(h1).post(h2))` emitted only the first method+handler, and `get(mod::handler)` captured the module not the fn (realworld-axum S **12→19, 19/19**); balanced-paren scan + per-method nodes + last-`::`-segment handler. **Rocket attribute macros 550/556 (99%)** (Rocket repo L) — already strong. crates.io named axum routes resolve (6/8; rest are closures/var handlers; its API is mostly the utoipa `routes!` macro = frontier). Cargo-workspace module resolution (prior work). **actix builder API** `web::resource("/x").route(web::get().to(h))` / `.to(h)` / App `.route("/x", web::get().to(h))` (actix-examples **51→128 routes, 35→112 resolved**) — was the dominant actix style and fully missed (the handler is in `.to(h)`, not `get(h)`). 🔬 actix `web::scope("/api")` prefix (not prepended to nested resource paths) + anonymous `.to` closure handlers |
|
| Rust | Axum / actix / Rocket | request → route → handler | R + X | ✅ **Axum chained methods + namespaced handlers** — `.route("/x", get(h1).post(h2))` emitted only the first method+handler, and `get(mod::handler)` captured the module not the fn (realworld-axum S **12→19, 19/19**); balanced-paren scan + per-method nodes + last-`::`-segment handler. **Rocket attribute macros 550/556 (99%)** (Rocket repo L) — already strong. crates.io named axum routes resolve (6/8; rest are closures/var handlers; its API is mostly the utoipa `routes!` macro = frontier). Cargo-workspace module resolution (prior work). **actix builder API** `web::resource("/x").route(web::get().to(h))` / `.to(h)` / App `.route("/x", web::get().to(h))` (actix-examples **51→128 routes, 35→112 resolved**) — was the dominant actix style and fully missed (the handler is in `.to(h)`, not `get(h)`). 🔬 actix `web::scope("/api")` prefix (not prepended to nested resource paths) + anonymous `.to` closure handlers |
|
||||||
| Java | Spring | request → @RestController → @Autowired service → repo | R + X | ✅ **bare `@GetMapping`/`@PostMapping` + class `@RequestMapping` prefix join → route→method** (realworld S / mall M / halo L) — was missing all path-less method mappings; DI controller→service resolves (name + dir) + **interface→impl dispatch synthesizer** (`interfaceOverrideEdges`: a class's `implements`/`extends` → link each interface/base method → its same-name override; JVM-gated, capped, **overload-aware**; mall **310** / halo **734** synth edges, node count unchanged) so trace follows controller→service-**interface**→**impl** instead of dead-ending at the abstract method — `trace("PmsProductController.getList","PmsProductServiceImpl.list")` connects in **3 hops** (probe-validated). + **field-injected concrete-bean trace** (#389): `this.<field>.method()` strips the `this.` receiver at extraction, and the resolver looks up the receiver name in the enclosing class's field declarations to get the declared type, then resolves the method on it — closes the controller→bean hop when the field-name doesn't capitalize to the type (`@Resource(name="userBO") UserBO userbo` → `userbo.toLogin2()` reaches `UserBO.toLogin2`). + **`@Value("${k}")` / `@ConfigurationProperties(prefix="X")` → application.{yml,yaml,properties}** binding with Spring's relaxed binding (kebab↔camel↔snake), incl. `${k:default}`. mall-tiny S: 11/11 `@Value` resolved. ⚠️ **agent A/B null** (n=2: the agent went context→explore→Read and never invoked `trace`, so the synth edges weren't exercised — adoption-gated, the recurring wall; see `docs/benchmarks/call-sequence-analysis.md`). The fix is correct + improves trace/callees/impact/context connectivity regardless; agent-visible read reduction needs trace adoption. 🔬 Spring Data JPA derived queries (`findByEmail`) — metaprogramming frontier; `@PropertySource` external files; Spring Cloud Config; mapper-class simple-name collisions across packages (dropped to avoid mis-resolution) |
|
| Java | Spring | request → @RestController → @Autowired service → repo | R + X | ✅ **bare `@GetMapping`/`@PostMapping` + class `@RequestMapping` prefix join → route→method** (realworld S / mall M / halo L) — was missing all path-less method mappings; DI controller→service resolves (name + dir) + **interface→impl dispatch synthesizer** (`interfaceOverrideEdges`: a class's `implements`/`extends` → link each interface/base method → its same-name override; JVM-gated, capped, **overload-aware**; mall **310** / halo **734** synth edges, node count unchanged) so trace follows controller→service-**interface**→**impl** instead of dead-ending at the abstract method — `trace("PmsProductController.getList","PmsProductServiceImpl.list")` connects in **3 hops** (probe-validated). + **field-injected concrete-bean trace** (#389): `this.<field>.method()` strips the `this.` receiver at extraction, and the resolver looks up the receiver name in the enclosing class's field declarations to get the declared type, then resolves the method on it — closes the controller→bean hop when the field-name doesn't capitalize to the type (`@Resource(name="userBO") UserBO userbo` → `userbo.toLogin2()` reaches `UserBO.toLogin2`). + **`@Value("${k}")` / `@ConfigurationProperties(prefix="X")` → application.{yml,yaml,properties}** binding with Spring's relaxed binding (kebab↔camel↔snake), incl. `${k:default}`. mall-tiny S: 11/11 `@Value` resolved. ⚠️ **agent A/B null** (n=2: the agent went context→explore→Read and never invoked `trace`, so the synth edges weren't exercised — adoption-gated, the recurring wall; see `docs/benchmarks/call-sequence-analysis.md`). The fix is correct + improves trace/callees/impact/context connectivity regardless; agent-visible read reduction needs trace adoption. 🔬 Spring Data JPA derived queries (`findByEmail`) — metaprogramming frontier; `@PropertySource` external files; Spring Cloud Config; mapper-class simple-name collisions across packages (dropped to avoid mis-resolution) |
|
||||||
| Java | MyBatis (XML mappers) | DAO interface method → `<select\|insert\|update\|delete id="X">` SQL | R (XML extract) + S (Java↔XML synthesizer) | ✅ **XML mapper as first-class language** (#389) — `src/extraction/mybatis-extractor.ts` parses files containing `<mapper namespace="...">`; emits one method-shaped node per statement qualified `<namespace>::<id>` + `<sql id="X">` fragments + `<include refid>` references. Non-mapper XML (pom, log4j) → file node only. `mybatisJavaXmlEdges` synthesizer indexes Java methods by `<ClassName>::<methodName>` and joins to XML qualified names by suffix-match — ambiguous simple-name collisions dropped (precision over recall). mall-tiny S **6/6 custom-SQL mapper methods bridge** to their XML statements; full enterprise chain `trace(controller.action → mapper.method-xml)` connects across controller / service-iface / impl / mapper / XML. 🔬 cross-mapper `<include>` via unqualified refid; MyBatis Plus dynamic methods (`BaseMapper<T>` CRUD inherited from framework, not in project); annotation-driven mappers (`@Select("SELECT ...")` on Java methods — the SQL lives in the annotation, not XML) |
|
| Java | MyBatis (XML mappers) | DAO interface method → `<select\|insert\|update\|delete id="X">` SQL | R (XML extract) + S (Java↔XML synthesizer) | ✅ **XML mapper as first-class language** (#389) — `src/extraction/mybatis-extractor.ts` parses files containing `<mapper namespace="...">`; emits one method-shaped node per statement qualified `<namespace>::<id>` + `<sql id="X">` fragments + `<include refid>` references. Non-mapper XML (pom, log4j) → file node only. `mybatisJavaXmlEdges` synthesizer indexes Java methods by `<ClassName>::<methodName>` and joins to XML qualified names by suffix-match — ambiguous simple-name collisions dropped (precision over recall). mall-tiny S **6/6 custom-SQL mapper methods bridge** to their XML statements; full enterprise chain `trace(controller.action → mapper.method-xml)` connects across controller / service-iface / impl / mapper / XML. 🔬 cross-mapper `<include>` via unqualified refid; MyBatis Plus dynamic methods (`BaseMapper<T>` CRUD inherited from framework, not in project); annotation-driven mappers (`@Select("SELECT ...")` on Java methods — the SQL lives in the annotation, not XML) |
|
||||||
|
|||||||
@@ -1651,6 +1651,14 @@ export class ToolHandler {
|
|||||||
registeredAt,
|
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,
|
// Generic fallback for any other synthesizer (redux-thunk, gin-middleware-chain,
|
||||||
// flutter-build, …): a synthesized hop must never read as a bare static `calls`.
|
// 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.
|
// It's a dynamic-dispatch bridge — label it as one and keep its wiring site.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { ResolutionContext } from './types';
|
|||||||
import { isGeneratedFile } from '../extraction/generated-detection';
|
import { isGeneratedFile } from '../extraction/generated-detection';
|
||||||
import { stripCommentsForRegex } from './strip-comments';
|
import { stripCommentsForRegex } from './strip-comments';
|
||||||
import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
|
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 REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
|
||||||
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
|
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 sidekiqEdges = sidekiqDispatchEdges(ctx);
|
||||||
const laravelEdges = laravelEventEdges(ctx);
|
const laravelEdges = laravelEventEdges(ctx);
|
||||||
const cFnPtrEdges = cFnPointerDispatchEdges(queries, ctx);
|
const cFnPtrEdges = cFnPointerDispatchEdges(queries, ctx);
|
||||||
|
const goframeEdges = goframeRouteEdges(ctx);
|
||||||
|
|
||||||
const merged: Edge[] = [];
|
const merged: Edge[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -2737,6 +2739,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
|||||||
...sidekiqEdges,
|
...sidekiqEdges,
|
||||||
...laravelEdges,
|
...laravelEdges,
|
||||||
...cFnPtrEdges,
|
...cFnPtrEdges,
|
||||||
|
...goframeEdges,
|
||||||
]) {
|
]) {
|
||||||
const key = `${e.source}>${e.target}`;
|
const key = `${e.source}>${e.target}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ import { railsResolver } from './ruby';
|
|||||||
import { springResolver } from './java';
|
import { springResolver } from './java';
|
||||||
import { playResolver } from './play';
|
import { playResolver } from './play';
|
||||||
import { goResolver } from './go';
|
import { goResolver } from './go';
|
||||||
|
import { goframeResolver } from './goframe';
|
||||||
import { rustResolver } from './rust';
|
import { rustResolver } from './rust';
|
||||||
import { aspnetResolver } from './csharp';
|
import { aspnetResolver } from './csharp';
|
||||||
import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
|
import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
|
||||||
@@ -52,6 +53,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
|
|||||||
playResolver,
|
playResolver,
|
||||||
// Go
|
// Go
|
||||||
goResolver,
|
goResolver,
|
||||||
|
goframeResolver,
|
||||||
// Rust
|
// Rust
|
||||||
rustResolver,
|
rustResolver,
|
||||||
// C#
|
// C#
|
||||||
@@ -136,6 +138,7 @@ export { railsResolver } from './ruby';
|
|||||||
export { springResolver } from './java';
|
export { springResolver } from './java';
|
||||||
export { playResolver } from './play';
|
export { playResolver } from './play';
|
||||||
export { goResolver } from './go';
|
export { goResolver } from './go';
|
||||||
|
export { goframeResolver } from './goframe';
|
||||||
export { rustResolver } from './rust';
|
export { rustResolver } from './rust';
|
||||||
export { aspnetResolver } from './csharp';
|
export { aspnetResolver } from './csharp';
|
||||||
export { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
|
export { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user