From b64539ddd77c2c1ce166a465503a5881c35c4f49 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 1 Jul 2026 18:32:46 +0800 Subject: [PATCH] feat(dep-graph): attribute edges to source and target methods - analyzer: record fromMethod/toMethod on each EdgeRef, generalize event-bus field detection to all DI-injected ctor fields, and attribute this..() and .get(IX).() call sites - analyzer: seed IAgentScopeContext framework binding so Agent-scope edges resolve instead of showing up as unresolved - web: render per-method in/out ports on nodes, route each edge to the matching method handle, size nodes by port count, and show an expandable call list in the edge panel --- .../scripts/dep-graph/analyzer/analyze.ts | 154 +++++-- .../scripts/dep-graph/analyzer/types.ts | 18 + .../scripts/dep-graph/web/src/GraphView.tsx | 386 +++++++++++++++--- .../scripts/dep-graph/web/src/layout-dagre.ts | 17 +- 4 files changed, 469 insertions(+), 106 deletions(-) diff --git a/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts b/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts index b425268c8..4e640f9f8 100644 --- a/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts +++ b/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts @@ -22,6 +22,7 @@ import { fileURLToPath } from 'node:url'; import { type CallExpression, type ClassDeclaration, + type Node, type ParameterDeclaration, Project, type SourceFile, @@ -66,6 +67,7 @@ const FRAMEWORK_BINDINGS: readonly { token: string; scope: ServiceScope; impl: s { token: 'ILogOptions', scope: 'App', impl: 'LogOptions' }, { token: 'IBootstrapOptions', scope: 'App', impl: 'BootstrapOptions' }, { token: 'ISessionContext', scope: 'Session', impl: 'SessionContext' }, + { token: 'IAgentScopeContext', scope: 'Agent', impl: 'AgentScopeContext' }, ]; /** @@ -141,7 +143,7 @@ function pushEdge( const key = edgeKey(fromId, toId, kind); const existing = acc.edges.get(key); if (existing) { - if (!existing.refs.some((r) => r.file === ref.file && r.line === ref.line)) { + if (!existing.refs.some((r) => sameRef(r, ref))) { existing.refs.push(ref); } return; @@ -158,6 +160,15 @@ function pushEdge( if (!target) acc.unknownRefs.add(token); } +function sameRef(a: EdgeRef, b: EdgeRef): boolean { + return ( + a.file === b.file && + a.line === b.line && + (a.fromMethod ?? '') === (b.fromMethod ?? '') && + (a.toMethod ?? '') === (b.toMethod ?? '') + ); +} + /** * Extract the token identifier from a `registerScopedService(...)` call. * Returns `undefined` if the call doesn't match the expected shape. @@ -257,40 +268,37 @@ function collectServices(sourceFiles: SourceFile[]): { /** * From a class ctor, list `{decorator, param}` for every `@IToken`-decorated - * parameter, in declaration order. Also returns the "event bus fields": params - * whose declared type is `IEventService` or `IAgentEventSinkService`, keyed - * by field/param name so we can find `this..publish(...)` etc. + * parameter, in declaration order. Also returns the "injected fields": params + * lifted to a class field via a visibility modifier, keyed by field name and + * mapped to the token they're bound to. That map lets pass 2 attribute + * `this..()` call sites back to the correct ctor edge. */ function readCtor(cls: ClassDeclaration): { ctorDeps: { token: string; line: number }[]; - eventBusFields: Map; + injectedFields: Map; } { const ctorDeps: { token: string; line: number }[] = []; - const eventBusFields = new Map(); + const injectedFields = new Map(); const ctors = cls.getConstructors(); - if (ctors.length === 0) return { ctorDeps, eventBusFields }; + if (ctors.length === 0) return { ctorDeps, injectedFields }; const ctor = ctors[0]; for (const param of ctor.getParameters()) { const decorators = param.getDecorators(); + let paramToken: string | undefined; for (const dec of decorators) { const decName = dec.getName(); if (!decName.startsWith('I')) continue; ctorDeps.push({ token: decName, line: dec.getStartLineNumber() }); + paramToken = decName; } - // Field type — for detecting event-bus fields regardless of decorator. - const typeNode = param.getTypeNode(); - if (typeNode) { - const typeText = typeNode.getText(); - if (EVENT_BUS_TOKENS.has(typeText)) { - const name = fieldNameOf(param); - if (name) eventBusFields.set(name, typeText); - } - } + if (paramToken === undefined) continue; + const fieldName = fieldNameOf(param); + if (fieldName) injectedFields.set(fieldName, paramToken); } - return { ctorDeps, eventBusFields }; + return { ctorDeps, injectedFields }; } /** @@ -307,15 +315,70 @@ function fieldNameOf(param: ParameterDeclaration): string | undefined { return undefined; } +/** + * Walk parents from `node` to the nearest class-body scope so we can label + * a call site by the source method that contains it. Arrow functions and + * `function` expressions are transparent — we want the surrounding method, + * not the closure. Returns `undefined` when the call sits directly in a + * class body but outside any declared member (rare — decorators, etc.). + */ +function enclosingMethodName(node: Node): string | undefined { + let cur: Node | undefined = node.getParent(); + while (cur) { + const kind = cur.getKind(); + if (kind === SyntaxKind.MethodDeclaration) { + const m = cur.asKindOrThrow(SyntaxKind.MethodDeclaration); + return m.getName(); + } + if (kind === SyntaxKind.Constructor) return ''; + if (kind === SyntaxKind.GetAccessor) { + const g = cur.asKindOrThrow(SyntaxKind.GetAccessor); + return `get ${g.getName()}`; + } + if (kind === SyntaxKind.SetAccessor) { + const s = cur.asKindOrThrow(SyntaxKind.SetAccessor); + return `set ${s.getName()}`; + } + if (kind === SyntaxKind.PropertyDeclaration) { + const p = cur.asKindOrThrow(SyntaxKind.PropertyDeclaration); + return ``; + } + if (kind === SyntaxKind.ClassDeclaration) return undefined; + cur = cur.getParent(); + } + return undefined; +} + +/** + * When a `.get(IToken)` call is immediately chained with a method call — + * `.get(IX).(...)` — return that method name. This is the + * only accessor pattern we can attribute without a type checker; results + * stored in a local variable are indistinguishable from other locals and + * would need dataflow tracking to follow. + */ +function chainedMethodName(getCall: CallExpression): string | undefined { + const parent = getCall.getParent(); + if (!parent || parent.getKind() !== SyntaxKind.PropertyAccessExpression) return undefined; + const pae = parent.asKindOrThrow(SyntaxKind.PropertyAccessExpression); + if (pae.getExpression() !== getCall) return undefined; + const grandparent = pae.getParent(); + if (!grandparent || grandparent.getKind() !== SyntaxKind.CallExpression) return undefined; + const outer = grandparent.asKindOrThrow(SyntaxKind.CallExpression); + if (outer.getExpression() !== pae) return undefined; + return pae.getName(); +} + /** * Pass 2 — for a given impl class, walk method bodies and detect: - * - `.get(IToken)` → accessor edge - * - `this..publish/...` → publish/subscribe/emit/on edges + * - `.get(IToken)[.method(...)]` → accessor edge (with optional `toMethod`) + * - `this..(...)` → attach method info to the ctor + * edge for that field, or emit an event-bus edge when the field's token + * is an event bus (`publish` / `subscribe` / `emit` / `on`). */ function collectRuntimeEdges( cls: ClassDeclaration, source: ServiceNode, - eventBusFields: Map, + injectedFields: Map, acc: EdgeAccumulator, ): void { const filePath = relFromRepo(cls.getSourceFile().getFilePath()); @@ -326,8 +389,11 @@ function collectRuntimeEdges( const pae = callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression); const methodName = pae.getName(); const line = call.getStartLineNumber(); - const ref: EdgeRef = { file: filePath, line }; + const fromMethod = enclosingMethodName(call); + const baseRef: EdgeRef = { file: filePath, line }; + if (fromMethod !== undefined) baseRef.fromMethod = fromMethod; + // Case 1: .get(IX)[.method(...)] if (methodName === 'get') { const args = call.getArguments(); if (args.length === 0) continue; @@ -337,29 +403,47 @@ function collectRuntimeEdges( if (!tokenName.startsWith('I')) continue; // Ignore self-references — a service asking the accessor for itself. if (tokenName === source.token) continue; + const toMethod = chainedMethodName(call); + const ref: EdgeRef = { ...baseRef }; + if (toMethod !== undefined) ref.toMethod = toMethod; pushEdge(acc, source.id, source, tokenName, 'accessor', ref); continue; } - const edgeKind = EVENT_METHOD_KIND[methodName]; - if (edgeKind === undefined) continue; - - // Detect `this.` or `` receivers where the field holds an - // event bus. `..()` patterns are ignored: those come - // up rarely, and we would need the type checker to be sure. + // Case 2: .(...) where receiver is a DI-injected field. + // Detect `this.` (the common form) and a bare `` identifier + // (rare — event-bus code historically supported it; kept for parity). const receiver = pae.getExpression(); - let busToken: string | undefined; + let fieldName: string | undefined; if (receiver.getKind() === SyntaxKind.PropertyAccessExpression) { const inner = receiver.asKindOrThrow(SyntaxKind.PropertyAccessExpression); if (inner.getExpression().getKind() === SyntaxKind.ThisKeyword) { - busToken = eventBusFields.get(inner.getName()); + fieldName = inner.getName(); } } else if (receiver.getKind() === SyntaxKind.Identifier) { - busToken = eventBusFields.get(receiver.getText()); + fieldName = receiver.getText(); } - if (!busToken) continue; - if (busToken === source.token) continue; - pushEdge(acc, source.id, source, busToken, edgeKind, ref); + if (fieldName === undefined) continue; + + const fieldToken = injectedFields.get(fieldName); + if (fieldToken === undefined) continue; + if (fieldToken === source.token) continue; + + // Event-bus fields: keep the specialised publish/subscribe/emit/on edge + // kind; the method name is already carried by the kind so we don't + // duplicate it in `toMethod`. + if (EVENT_BUS_TOKENS.has(fieldToken)) { + const eventKind = EVENT_METHOD_KIND[methodName]; + if (eventKind === undefined) continue; + pushEdge(acc, source.id, source, fieldToken, eventKind, baseRef); + continue; + } + + // Regular DI field — attach method-call info to the ctor edge. The ctor + // param declaration ref (pushed in the outer loop below) has no + // `toMethod`; this ref does, so both survive the dedup. + const ref: EdgeRef = { ...baseRef, toMethod: methodName }; + pushEdge(acc, source.id, source, fieldToken, 'ctor', ref); } } @@ -421,7 +505,7 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} for (const svc of services) { const cls = implClasses.get(svc.impl); if (!cls) continue; - const { ctorDeps, eventBusFields } = readCtor(cls); + const { ctorDeps, injectedFields } = readCtor(cls); const filePath = relFromRepo(cls.getSourceFile().getFilePath()); for (const dep of ctorDeps) { // Self-refs happen when a service also declares a param typed as @@ -429,7 +513,7 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} if (dep.token === svc.token) continue; pushEdge(acc, svc.id, svc, dep.token, 'ctor', { file: filePath, line: dep.line }); } - collectRuntimeEdges(cls, svc, eventBusFields, acc); + collectRuntimeEdges(cls, svc, injectedFields, acc); } return { diff --git a/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts b/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts index 788f1f274..8971f4567 100644 --- a/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts +++ b/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts @@ -47,6 +47,24 @@ export interface EdgeRef { /** Repo-relative path where the reference occurs. */ file: string; line: number; + /** + * Method on the source impl that contains this reference — the caller. + * `` for the constructor, `get ` / `set ` for accessors, + * `>` for a property initializer, or the plain method name. + * Absent for the ctor-param declaration refs and for refs the analyzer + * couldn't attribute to a named scope. + */ + fromMethod?: string; + /** + * Method invoked on the target service at this ref site. + * - `ctor` edge: the method the source calls on the injected field, + * e.g. `this.log.error(...)` → `error`. + * - `accessor` edge: the method chained on `.get(IX).()`. + * Absent for the pure declaration ref (the ctor param), for the pure + * lookup ref (a `get()` whose result is stored rather than called), and + * for event-bus edges where the method name is already the edge kind. + */ + toMethod?: string; } export interface Edge { diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx b/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx index 11986d16d..c58376095 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx @@ -13,11 +13,31 @@ import { import '@xyflow/react/dist/style.css'; import { useMemo } from 'react'; -import type { Edge, Graph, ServiceNode } from '../../analyzer/types'; +import type { Edge, EdgeKind, Graph, ServiceNode } from '../../analyzer/types'; import type { FilterState } from './Filters'; import { layoutDagre } from './layout-dagre'; import { EDGE_STYLE, SCOPE_STYLE } from './style'; +/** Fixed node width so port rows have a stable horizontal box. */ +const NODE_WIDTH = 300; +/** Height of the header block (impl / token / domain lines + padding). */ +const HEADER_HEIGHT = 68; +/** Per-port row height. Must stay in sync with the CSS below. */ +const PORT_ROW_HEIGHT = 18; +/** Vertical padding between the header divider and the first port row. */ +const PORTS_PAD_TOP = 4; + +/** + * Per-node method port lists. `outPorts` are methods on this service that + * make calls into a dependency (they anchor the source end of edges leaving + * this node); `inPorts` are methods on this service that other services + * call into (they anchor the target end of edges entering this node). + */ +interface ServicePortsInfo { + inPorts: string[]; + outPorts: string[]; +} + interface GraphViewProps { graph: Graph; filters: FilterState; @@ -30,17 +50,74 @@ interface ServiceNodeData extends Record { service: ServiceNode; selected: boolean; dim: boolean; + ports: ServicePortsInfo; +} + +const EVENT_KINDS: Set = new Set(['publish', 'subscribe', 'emit', 'on']); + +/** + * The method name that an edge terminates at on the target node. For plain + * calls this is `ref.toMethod`; for event-bus edges, where the call is + * `bus.publish(...)` etc., the method name is already carried by the edge + * kind so we surface it as the effective toMethod so the target node grows + * a matching port row. + */ +function effectiveToMethod(kind: EdgeKind, refTo: string | undefined): string | undefined { + if (refTo !== undefined) return refTo; + if (EVENT_KINDS.has(kind)) return kind; + return undefined; +} + +/** + * Build the port lists per node from a set of edges. + * + * The port list depends on which edges are actually rendered — filtering out + * a whole edge kind, for example, also hides the port rows it would have + * populated. Computing this off the filtered edges keeps the node from + * showing dangling ports with nothing connected. + */ +function computeServicePorts( + services: ServiceNode[], + edges: Edge[], +): Map { + const acc = new Map; out: Set }>(); + for (const s of services) { + acc.set(s.id, { in: new Set(), out: new Set() }); + } + for (const e of edges) { + const src = acc.get(e.from); + const dst = acc.get(e.to); + for (const ref of e.refs) { + const toMethod = effectiveToMethod(e.kind, ref.toMethod); + if (ref.fromMethod !== undefined && src) src.out.add(ref.fromMethod); + if (toMethod !== undefined && dst) dst.in.add(toMethod); + } + } + const result = new Map(); + for (const [id, sets] of acc) { + result.set(id, { + inPorts: [...sets.in].sort(), + outPorts: [...sets.out].sort(), + }); + } + return result; +} + +function nodeHeight(ports: ServicePortsInfo): number { + const rows = Math.max(ports.inPorts.length, ports.outPorts.length); + if (rows === 0) return HEADER_HEIGHT; + return HEADER_HEIGHT + PORTS_PAD_TOP + rows * PORT_ROW_HEIGHT + PORTS_PAD_TOP; } function ServiceNodeView({ data }: NodeProps>): JSX.Element { - const { service, selected, dim } = data; + const { service, selected, dim, ports } = data; const bg = SCOPE_STYLE[service.scope].color; + const rowCount = Math.max(ports.inPorts.length, ports.outPorts.length); return (
>): JSX.Elemen fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', opacity: dim ? 0.18 : 1, - minWidth: 200, - maxWidth: 240, + width: NODE_WIDTH, + position: 'relative', }} > - -
- + + + {/* Header */} +
+
+ + {SCOPE_STYLE[service.scope].badge} + + {/* Impl is the primary label — that's the actual class the container + constructs; the token is a secondary identity shown below. */} + + {service.impl} + +
+
+ {service.token} +
+
{service.domain}
+
+ + {rowCount > 0 && ( +
- {SCOPE_STYLE[service.scope].badge} - - {/* Impl is the primary label — that's the actual class the container - constructs; the token is a secondary identity shown below. */} - - {service.impl} - -
-
- {service.token} -
-
{service.domain}
- + {Array.from({ length: rowCount }, (_, i) => { + const out = ports.outPorts[i]; + const inn = ports.inPorts[i]; + return ( +
+ {/* Handles live directly on the row (no `overflow: hidden` + ancestor), so React Flow's default translate(-50%, -50%) + positions the dot straddling the node's border. */} + {out !== undefined && ( + + )} + {inn !== undefined && ( + + )} +
+ + {out ?? ''} + + + {inn ?? ''} + +
+
+ ); + })} +
+ )}
); } @@ -155,6 +338,10 @@ export function GraphView({ (e) => visibleIds.has(e.from) && visibleIds.has(e.to), ); + // Ports depend on the *rendered* edges: a port with no visible edge is + // dead weight on the node, so we compute after filter+visibility. + const ports = computeServicePorts(visibleServices, finalEdges); + // Neighbours of the selected node — used to dim non-related ones. const highlighted = new Set(); if (selectedId) { @@ -167,6 +354,10 @@ export function GraphView({ const layout = layoutDagre(visibleServices, finalEdges, { groupByScope: filters.groupByScope, + nodeSize: (id) => { + const p = ports.get(id); + return { width: NODE_WIDTH, height: p ? nodeHeight(p) : HEADER_HEIGHT }; + }, }); const pos = layout.positions; @@ -179,6 +370,7 @@ export function GraphView({ service, selected: service.id === selectedId, dim: selectedId !== undefined && !highlighted.has(service.id), + ports: ports.get(service.id) ?? { inPorts: [], outPorts: [] }, }, }), ); @@ -201,24 +393,42 @@ export function GraphView({ } } - const rfEdges: RFEdge[] = finalEdges.map((e) => { + const rfEdges: RFEdge[] = []; + for (const e of finalEdges) { const style = EDGE_STYLE[e.kind]; const isHighlighted = selectedId !== undefined && (e.from === selectedId || e.to === selectedId); - return { - id: `${e.from}::${e.kind}::${e.to}`, - source: e.from, - target: e.to, - label: undefined, - style: { - stroke: style.color, - strokeWidth: isHighlighted ? 2.2 : 1.2, - strokeDasharray: style.dashed ? '4 3' : undefined, - opacity: selectedId !== undefined ? (isHighlighted ? 1 : 0.1) : 0.75, - }, - animated: false, - }; - }); + // Group refs by (fromMethod, effectiveToMethod) so identical method + // pairs on different lines collapse into a single arrow between the + // same two handles instead of stacking. + const pairs = new Map< + string, + { fromMethod: string | undefined; toMethod: string | undefined } + >(); + for (const ref of e.refs) { + const toMethod = effectiveToMethod(e.kind, ref.toMethod); + const key = `${ref.fromMethod ?? ''}|${toMethod ?? ''}`; + if (!pairs.has(key)) pairs.set(key, { fromMethod: ref.fromMethod, toMethod }); + } + for (const [key, pair] of pairs) { + const sourceHandle = pair.fromMethod ? `out:${pair.fromMethod}` : 'default-source'; + const targetHandle = pair.toMethod ? `in:${pair.toMethod}` : 'default-target'; + rfEdges.push({ + id: `${e.from}::${e.kind}::${e.to}::${key}`, + source: e.from, + target: e.to, + sourceHandle, + targetHandle, + style: { + stroke: style.color, + strokeWidth: isHighlighted ? 2.2 : 1.2, + strokeDasharray: style.dashed ? '4 3' : undefined, + opacity: selectedId !== undefined ? (isHighlighted ? 1 : 0.1) : 0.75, + }, + animated: false, + }); + } + } const selectedService = selectedId ? graph.services.find((s) => s.id === selectedId) @@ -368,34 +578,76 @@ function EdgeList({ title, edges, direction, byId }: EdgeListProps): JSX.Element const peerId = direction === 'out' ? e.to : e.from; const peer = byId.get(peerId); const label = peer ? `${peer.impl} (${peer.token})` : peerId; + const methodRefs = e.refs.filter((r) => r.toMethod !== undefined || r.fromMethod !== undefined); return ( -
- - {e.kind} - - {label} - - ×{e.refs.length} +
+
+ + {e.kind} + + {label} + + ×{e.refs.length} +
+ {methodRefs.length > 0 && ( +
+ + {methodRefs.length} call{methodRefs.length === 1 ? '' : 's'} + +
+ {methodRefs.map((r, i) => ( +
+ {direction === 'out' ? ( + <> + {r.fromMethod ?? '?'} + {' → '} + {r.toMethod ?? '?'} + + ) : ( + <> + {r.fromMethod ?? '?'} + {' → '} + {r.toMethod ?? '?'} + + )} + {` (:${r.line})`} +
+ ))} +
+
+ )}
); })} diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/layout-dagre.ts b/packages/agent-core-v2/scripts/dep-graph/web/src/layout-dagre.ts index e410f2205..d73d9b9a6 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/layout-dagre.ts +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/layout-dagre.ts @@ -2,7 +2,7 @@ import Dagre from '@dagrejs/dagre'; import type { Edge, ServiceNode, ServiceScope } from '../../analyzer/types'; -/** Rough node box used for dagre spacing; must approximately match the CSS in GraphView. */ +/** Fallback node box used when the caller doesn't supply per-node dimensions. */ const NODE_WIDTH = 220; const NODE_HEIGHT = 48; @@ -29,6 +29,13 @@ export interface LayoutOptions { * whole set. */ groupByScope?: boolean; + /** + * Per-node dimensions. Returned dagre positions match the box the caller + * declares here, which lets nodes with per-method port rows request more + * vertical space so their neighbours don't collide with the extra rows. + * Missing entries fall back to `(NODE_WIDTH, NODE_HEIGHT)`. + */ + nodeSize?: (id: string) => { width: number; height: number }; } export interface ScopeBand { @@ -149,9 +156,10 @@ function runDagre( const known = new Set(); for (const s of services) { const isolated = (degree.get(s.id) ?? 0) === 0; + const size = options.nodeSize?.(s.id) ?? { width: NODE_WIDTH, height: NODE_HEIGHT }; g.setNode(s.id, { - width: NODE_WIDTH, - height: NODE_HEIGHT, + width: size.width, + height: size.height, ...(isolated ? { rank: 'max' } : {}), }); known.add(s.id); @@ -171,8 +179,9 @@ function runDagre( for (const s of services) { const n = g.node(s.id); if (!n) continue; + const size = options.nodeSize?.(s.id) ?? { width: NODE_WIDTH, height: NODE_HEIGHT }; // Dagre returns center coordinates; React Flow uses top-left. - positions.set(s.id, { x: n.x - NODE_WIDTH / 2, y: n.y - NODE_HEIGHT / 2 }); + positions.set(s.id, { x: n.x - size.width / 2, y: n.y - size.height / 2 }); } const { width = 0, height = 0 } = g.graph(); return { positions, width, height };