diff --git a/CHANGELOG.md b/CHANGELOG.md index 4feb8aa..be9e0a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 **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. - 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. diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index ff1abb5..d77dcac 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -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'; describe('rustResolver.extract', () => { diff --git a/__tests__/goframe.test.ts b/__tests__/goframe.test.ts new file mode 100644 index 0000000..3d37337 --- /dev/null +++ b/__tests__/goframe.test.ts @@ -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/'); + }); +}); diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index 8a5bb72..f5f1022 100644 --- a/docs/design/dynamic-dispatch-coverage-playbook.md +++ b/docs/design/dynamic-dispatch-coverage-playbook.md @@ -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 | 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 | 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 | | 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..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 → `` SQL | R (XML extract) + S (Java↔XML synthesizer) | ✅ **XML mapper as first-class language** (#389) — `src/extraction/mybatis-extractor.ts` parses files containing ``; emits one method-shaped node per statement qualified `::` + `` fragments + `` references. Non-mapper XML (pom, log4j) → file node only. `mybatisJavaXmlEdges` synthesizer indexes Java methods by `::` 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 `` via unqualified refid; MyBatis Plus dynamic methods (`BaseMapper` CRUD inherited from framework, not in project); annotation-driven mappers (`@Select("SELECT ...")` on Java methods — the SQL lives in the annotation, not XML) | diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 303c850..b33abbd 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -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. diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index cbadd7c..ec4649b 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -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(); @@ -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; diff --git a/src/resolution/frameworks/goframe.ts b/src/resolution/frameworks/goframe.ts new file mode 100644 index 0000000..6127972 --- /dev/null +++ b/src/resolution/frameworks/goframe.ts @@ -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; + }, +}; diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 4fc3c3a..a224cdb 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -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'; diff --git a/src/resolution/goframe-synthesizer.ts b/src/resolution/goframe-synthesizer.ts new file mode 100644 index 0000000..4575572 --- /dev/null +++ b/src/resolution/goframe-synthesizer.ts @@ -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(); + const wanted = new Set(); + 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(); + 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(); + 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; +}