Просмотр исходного кода

feat(resolution): Java/Kotlin interface & abstract dispatch synthesis

A call through an injected interface (Spring @Autowired svc.list()) or an abstract base dead-ended at the interface method — no static edge to the implementation — so request->service->impl flows broke at the DI boundary. Adds interfaceOverrideEdges: for each class implementing an interface (or extending an abstract base), synthesize interface/base-method -> same-name override 'calls' edges (JVM-gated, capped per class, overload-aware), with an 'interface-impl' trace label. trace + callees now follow the flow into the implementation.

Validated on spring-mall: 310 synth edges, node count unchanged (edges only); trace(PmsProductController.getList, PmsProductServiceImpl.list) connects in 3 hops (controller -> service interface -> impl) where it previously dead-ended at the interface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 месяц назад
Родитель
Сommit
98baf41b73
3 измененных файлов с 77 добавлено и 1 удалено
  1. 8 0
      CHANGELOG.md
  2. 7 0
      src/mcp/tools.ts
  3. 62 1
      src/resolution/callback-synthesizer.ts

+ 8 - 0
CHANGELOG.md

@@ -16,6 +16,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   the short form, so `trace` and `codegraph_trace` are equivalent. Lets you
   constrain an agent to a minimal surface (or A/B-test tool selection) without
   editing the client's MCP config. Inert by default.
+- **Java/Kotlin interface & abstract dispatch is now traceable.** A call through
+  an injected interface (Spring `@Autowired FooService svc; svc.list()`) or an
+  abstract base previously dead-ended at the interface method — there's no static
+  edge to the implementation — so request→service→impl flows broke at the DI
+  boundary. CodeGraph now synthesizes interface/base-method → implementing-override
+  edges, so `codegraph_trace` and `codegraph_callees` follow the flow into the
+  implementation (e.g. controller → service interface → service impl). JVM-gated,
+  capped per class, overload-aware.
 
 ### Changed
 - **`codegraph_trace` now returns a self-contained flow dossier.** Each hop on

+ 7 - 0
src/mcp/tools.ts

@@ -1183,6 +1183,13 @@ export class ToolHandler {
         registeredAt,
       };
     }
+    if (m?.synthesizedBy === 'interface-impl') {
+      return {
+        label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
+        compact: `dynamic: interface → impl${at}`,
+        registeredAt,
+      };
+    }
     return null;
   }
 

+ 62 - 1
src/resolution/callback-synthesizer.ts

@@ -326,6 +326,66 @@ function cppOverrideEdges(queries: QueryBuilder): Edge[] {
   return edges;
 }
 
+/**
+ * Phase 5.5: interface / abstract dispatch (Java, Kotlin). A call through an
+ * injected interface (`@Autowired FooService svc; svc.list()`) or an abstract
+ * base dispatches at runtime to the implementing class's override — a vtable
+ * indirection with no static call edge — so a request→service flow stops at the
+ * interface method. Bridge it like cpp-override: for each class that
+ * `implements` an interface (or `extends` an abstract base), link each
+ * base/interface method → the class's same-name method (the override) so
+ * trace/callees reach the implementation. Over-approximation accepted
+ * (reachability-correct); capped per class, gated to JVM languages.
+ */
+const IFACE_OVERRIDE_LANGS = new Set(['java', 'kotlin']);
+function interfaceOverrideEdges(queries: QueryBuilder): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const methodsOf = (classId: string): Node[] =>
+    queries
+      .getOutgoingEdges(classId, ['contains'])
+      .map((e) => queries.getNodeById(e.target))
+      .filter((n): n is Node => !!n && n.kind === 'method');
+  for (const cls of queries.getNodesByKind('class')) {
+    const implMethods = methodsOf(cls.id).filter((n) => IFACE_OVERRIDE_LANGS.has(n.language));
+    if (implMethods.length === 0) continue;
+    for (const sup of queries.getOutgoingEdges(cls.id, ['implements', 'extends'])) {
+      const base = queries.getNodeById(sup.target);
+      if (!base || !IFACE_OVERRIDE_LANGS.has(base.language) || base.id === cls.id) continue;
+      // Group impl methods by name to handle OVERLOADS: an interface `list()` and
+      // `list(params)` are distinct nodes and a call may resolve to either, so
+      // link every base overload → every same-name impl overload (keying by name
+      // alone would drop all but one and miss the resolved overload).
+      const implByName = new Map<string, Node[]>();
+      for (const m of implMethods) {
+        const arr = implByName.get(m.name);
+        if (arr) arr.push(m); else implByName.set(m.name, [m]);
+      }
+      let added = 0;
+      for (const bm of methodsOf(base.id)) {
+        if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+        for (const m of implByName.get(bm.name) ?? []) {
+          if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+          if (bm.id === m.id) continue;
+          const key = `${bm.id}>${m.id}`;
+          if (seen.has(key)) continue;
+          seen.add(key);
+          edges.push({
+            source: bm.id,
+            target: m.id,
+            kind: 'calls',
+            line: bm.startLine,
+            provenance: 'heuristic',
+            metadata: { synthesizedBy: 'interface-impl', via: m.name, registeredAt: `${m.filePath}:${m.startLine}` },
+          });
+          added++;
+        }
+      }
+    }
+  }
+  return edges;
+}
+
 /**
  * Phase 5: React JSX child rendering. A component that returns `<Child .../>`
  * mounts Child — React calls it — but JSX instantiation isn't a static call edge,
@@ -473,10 +533,11 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const vueEdges = vueTemplateEdges(ctx);
   const flutterEdges = flutterBuildEdges(queries, ctx);
   const cppEdges = cppOverrideEdges(queries);
+  const ifaceEdges = interfaceOverrideEdges(queries);
 
   const merged: Edge[] = [];
   const seen = new Set<string>();
-  for (const e of [...fieldEdges, ...emitterEdges, ...renderEdges, ...jsxEdges, ...vueEdges, ...flutterEdges, ...cppEdges]) {
+  for (const e of [...fieldEdges, ...emitterEdges, ...renderEdges, ...jsxEdges, ...vueEdges, ...flutterEdges, ...cppEdges, ...ifaceEdges]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;
     seen.add(key);