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.<field>.<method>() and .get(IX).<method>() 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
This commit is contained in:
haozhe.yang 2026-07-01 18:32:46 +08:00
parent ddb3a7609f
commit b64539ddd7
4 changed files with 469 additions and 106 deletions

View file

@ -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.<name>.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.<field>.<method>()` call sites back to the correct ctor edge.
*/
function readCtor(cls: ClassDeclaration): {
ctorDeps: { token: string; line: number }[];
eventBusFields: Map<string, string>;
injectedFields: Map<string, string>;
} {
const ctorDeps: { token: string; line: number }[] = [];
const eventBusFields = new Map<string, string>();
const injectedFields = new Map<string, string>();
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 '<ctor>';
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 `<field ${p.getName()}>`;
}
if (kind === SyntaxKind.ClassDeclaration) return undefined;
cur = cur.getParent();
}
return undefined;
}
/**
* When a `.get(IToken)` call is immediately chained with a method call
* `<expr>.get(IX).<method>(...)` 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:
* - `<expr>.get(IToken)` accessor edge
* - `this.<busField>.publish/...` publish/subscribe/emit/on edges
* - `<expr>.get(IToken)[.method(...)]` accessor edge (with optional `toMethod`)
* - `this.<injectedField>.<method>(...)` 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<string, string>,
injectedFields: Map<string, string>,
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: <accessor>.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.<field>` or `<field>` receivers where the field holds an
// event bus. `<expr>.<field>.<method>()` patterns are ignored: those come
// up rarely, and we would need the type checker to be sure.
// Case 2: <receiver>.<method>(...) where receiver is a DI-injected field.
// Detect `this.<field>` (the common form) and a bare `<field>` 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 {

View file

@ -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.
* `<ctor>` for the constructor, `get <name>` / `set <name>` for accessors,
* `<field <name>>` 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 `<accessor>.get(IX).<method>()`.
* 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 {

View file

@ -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<string, unknown> {
service: ServiceNode;
selected: boolean;
dim: boolean;
ports: ServicePortsInfo;
}
const EVENT_KINDS: Set<EdgeKind> = 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<string, ServicePortsInfo> {
const acc = new Map<string, { in: Set<string>; out: Set<string> }>();
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<string, ServicePortsInfo>();
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<Node<ServiceNodeData>>): 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 (
<div
style={{
background: bg,
color: 'white',
padding: '6px 10px',
borderRadius: 6,
border: selected ? '2px solid #ffdf5d' : '1px solid rgba(0,0,0,0.4)',
boxShadow: selected ? '0 0 0 3px rgba(255,223,93,0.25)' : 'none',
@ -48,33 +125,139 @@ function ServiceNodeView({ data }: NodeProps<Node<ServiceNodeData>>): 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',
}}
>
<Handle type="target" position={Position.Right} style={{ background: '#555' }} />
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span
{/* Fallback handles at the header for refs with no method attribution
(raw ctor param declarations, un-chained `.get(IX)` lookups). */}
<Handle
id="default-target"
type="target"
position={Position.Right}
style={{ background: '#555', top: HEADER_HEIGHT / 2 }}
/>
<Handle
id="default-source"
type="source"
position={Position.Left}
style={{ background: '#555', top: HEADER_HEIGHT / 2 }}
/>
{/* Header */}
<div style={{ padding: '6px 10px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span
style={{
fontSize: 9,
padding: '1px 5px',
background: 'rgba(0,0,0,0.35)',
borderRadius: 3,
}}
>
{SCOPE_STYLE[service.scope].badge}
</span>
{/* Impl is the primary label that's the actual class the container
constructs; the token is a secondary identity shown below. */}
<span
style={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{service.impl}
</span>
</div>
<div style={{ fontSize: 10, opacity: 0.65, marginTop: 2, fontStyle: 'italic' }}>
{service.token}
</div>
<div style={{ fontSize: 10, opacity: 0.75, marginTop: 2 }}>{service.domain}</div>
</div>
{rowCount > 0 && (
<div
style={{
fontSize: 9,
padding: '1px 5px',
background: 'rgba(0,0,0,0.35)',
borderRadius: 3,
borderTop: '1px solid rgba(0,0,0,0.25)',
background: 'rgba(0,0,0,0.15)',
padding: `${PORTS_PAD_TOP}px 0`,
}}
>
{SCOPE_STYLE[service.scope].badge}
</span>
{/* Impl is the primary label that's the actual class the container
constructs; the token is a secondary identity shown below. */}
<span style={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{service.impl}
</span>
</div>
<div style={{ fontSize: 10, opacity: 0.65, marginTop: 2, fontStyle: 'italic' }}>
{service.token}
</div>
<div style={{ fontSize: 10, opacity: 0.75, marginTop: 2 }}>{service.domain}</div>
<Handle type="source" position={Position.Left} style={{ background: '#555' }} />
{Array.from({ length: rowCount }, (_, i) => {
const out = ports.outPorts[i];
const inn = ports.inPorts[i];
return (
<div
key={i}
style={{
// `position: relative` anchors the row's Handles to the
// row itself; React Flow measures the dot's centre from
// this box, so alignment tracks the label automatically
// — no hardcoded pixel offsets to drift out of sync.
position: 'relative',
height: PORT_ROW_HEIGHT,
}}
>
{/* 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 && (
<Handle
id={`out:${out}`}
type="source"
position={Position.Left}
style={{ background: '#f6c896' }}
/>
)}
{inn !== undefined && (
<Handle
id={`in:${inn}`}
type="target"
position={Position.Right}
style={{ background: '#a8c8f6' }}
/>
)}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
height: '100%',
padding: '0 10px',
fontSize: 10,
}}
>
<span
style={{
flex: 1,
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#fbe4c8',
}}
>
{out ?? ''}
</span>
<span
style={{
flex: 1,
textAlign: 'right',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#c8e0fb',
}}
>
{inn ?? ''}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
@ -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<string>();
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 (
<div
key={`${e.from}::${e.kind}::${e.to}`}
style={{ padding: '3px 0', display: 'flex', alignItems: 'center', gap: 6 }}
>
<span
style={{
display: 'inline-block',
width: 10,
height: 3,
borderTop: `${EDGE_STYLE[e.kind].dashed ? '2px dashed' : '2px solid'} ${
EDGE_STYLE[e.kind].color
}`,
}}
/>
<span style={{ color: '#7d8590', fontSize: 10, minWidth: 62 }}>{e.kind}</span>
<span
style={{
fontFamily: 'ui-monospace, monospace',
fontSize: 11,
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
}}
>
{label}
</span>
<span style={{ color: '#7d8590', fontSize: 10 }}>×{e.refs.length}</span>
<div key={`${e.from}::${e.kind}::${e.to}`} style={{ padding: '3px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 3,
borderTop: `${EDGE_STYLE[e.kind].dashed ? '2px dashed' : '2px solid'} ${
EDGE_STYLE[e.kind].color
}`,
}}
/>
<span style={{ color: '#7d8590', fontSize: 10, minWidth: 62 }}>{e.kind}</span>
<span
style={{
fontFamily: 'ui-monospace, monospace',
fontSize: 11,
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
}}
>
{label}
</span>
<span style={{ color: '#7d8590', fontSize: 10 }}>×{e.refs.length}</span>
</div>
{methodRefs.length > 0 && (
<details style={{ marginLeft: 20, marginTop: 2 }}>
<summary
style={{
fontSize: 10,
color: '#8b949e',
cursor: 'pointer',
listStyle: 'revert',
}}
>
{methodRefs.length} call{methodRefs.length === 1 ? '' : 's'}
</summary>
<div style={{ marginTop: 3 }}>
{methodRefs.map((r, i) => (
<div
key={`${r.file}:${r.line}:${i}`}
style={{
fontFamily: 'ui-monospace, monospace',
fontSize: 10,
color: '#a5b0bc',
padding: '1px 0',
}}
>
{direction === 'out' ? (
<>
<span>{r.fromMethod ?? '?'}</span>
<span style={{ color: '#6e7681' }}>{' → '}</span>
<span style={{ color: '#e6edf3' }}>{r.toMethod ?? '?'}</span>
</>
) : (
<>
<span style={{ color: '#e6edf3' }}>{r.fromMethod ?? '?'}</span>
<span style={{ color: '#6e7681' }}>{' → '}</span>
<span>{r.toMethod ?? '?'}</span>
</>
)}
<span style={{ color: '#6e7681' }}>{` (:${r.line})`}</span>
</div>
))}
</div>
</details>
)}
</div>
);
})}

View file

@ -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<string>();
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 };