Skip to content

Commit 20ce212

Browse files
tae2089claude
andcommitted
feat: cross-namespace impact/flow analysis and list_cross_refs tool
Add a namespace-agnostic CrossNamespaceReader that reads nodes and edges by globally-unique id and merges resolved cross_refs rows into traversal as synthetic cross_ref edges. Existing BFS and flow-trace algorithms run unchanged on top of it. - get_impact_radius and trace_flow accept cross_namespace: true and traverse resolved ccg:// refs in both directions; impact results carry a namespace label in cross mode only. - New list_cross_refs tool exposes the repository-level dependency map (direction outbound/inbound/both, optional status filter). MCP tool count goes from 17 to 18. - The synthetic cross_ref edge kind is never persisted; the flow tracer accepts it as a continuation because regular stores never emit it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 13b4a7c commit 20ce212

12 files changed

Lines changed: 627 additions & 17 deletions

File tree

internal/adapters/inbound/mcp/deps.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,24 @@ type GraphToolsDeps struct {
109109
Reader analyze.GraphReadRepository
110110
}
111111

112+
// CrossRefLister exposes materialized cross-namespace references for listing tools.
113+
// @intent let handlers enumerate repository-level dependencies without a store implementation dependency.
114+
type CrossRefLister interface {
115+
ListOutboundCrossRefs(ctx context.Context, fromNamespace string) ([]graph.CrossRef, error)
116+
ListInboundCrossRefs(ctx context.Context, toNamespace string) ([]graph.CrossRef, error)
117+
}
118+
112119
// AnalysisToolsDeps owns bounded impact, flow, and git-change analysis dependencies.
113120
// @intent group only configured application analyzers and their read-model port.
121+
// @domainRule CrossImpact/CrossFlow/CrossRefs are optional; when nil the cross-namespace analysis surface reports itself unconfigured.
114122
type AnalysisToolsDeps struct {
115-
Impact ImpactAnalyzer
116-
Flow FlowTracer
117-
Changes ChangeAnalyzer
118-
Reader analyze.GraphReadRepository
123+
Impact ImpactAnalyzer
124+
Flow FlowTracer
125+
Changes ChangeAnalyzer
126+
Reader analyze.GraphReadRepository
127+
CrossImpact ImpactAnalyzer
128+
CrossFlow FlowTracer
129+
CrossRefs CrossRefLister
119130
}
120131

121132
// DocsToolsDeps owns DB-primary documentation retrieval.

internal/adapters/inbound/mcp/handler_analysis.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,19 @@ func (h *handlers) getImpactRadius(ctx context.Context, request mcp.CallToolRequ
116116
depth := request.GetInt("depth", 1)
117117
maxDepth := request.GetInt("max_depth", defaultImpactMaxDepth)
118118
maxNodes := request.GetInt("max_nodes", defaultImpactMaxNodes)
119+
crossNamespace := request.GetBool("cross_namespace", false)
119120

120-
log.InfoContext(ctx, "get_impact_radius called", append(obs.TraceLogArgs(ctx), "qualified_name", qn, "depth", depth)...)
121+
log.InfoContext(ctx, "get_impact_radius called", append(obs.TraceLogArgs(ctx), "qualified_name", qn, "depth", depth, "cross_namespace", crossNamespace)...)
121122

122-
if h.deps.Analysis.Impact == nil {
123+
analyzer := h.deps.Analysis.Impact
124+
if crossNamespace {
125+
analyzer = h.deps.Analysis.CrossImpact
126+
}
127+
if analyzer == nil {
123128
return mcp.NewToolResultError("ImpactAnalyzer not configured"), nil
124129
}
125130

126-
return finalizeToolResult(h.cachedExecute(ctx, "get_impact_radius:", map[string]any{"qualified_name": qn, "depth": depth, "max_depth": maxDepth, "max_nodes": maxNodes, "namespace": requestNamespace(request)}, func() (string, error) {
131+
return finalizeToolResult(h.cachedExecute(ctx, "get_impact_radius:", map[string]any{"qualified_name": qn, "depth": depth, "max_depth": maxDepth, "max_nodes": maxNodes, "namespace": requestNamespace(request), "cross_namespace": crossNamespace}, func() (string, error) {
127132
node, err := h.deps.Graph.Store.GetNode(ctx, qn)
128133
if err != nil {
129134
log.ErrorContext(ctx, "store error", append(obs.TraceLogArgs(ctx), "tool", "get_impact_radius", trace.SlogError(err))...)
@@ -134,7 +139,7 @@ func (h *handlers) getImpactRadius(ctx context.Context, request mcp.CallToolRequ
134139
return "", nodeNotFoundErr(qn)
135140
}
136141

137-
res, err := h.deps.Analysis.Impact.ImpactRadiusBounded(ctx, node.ID, depth, impactpkg.RadiusOptions{MaxDepth: maxDepth, MaxNodes: maxNodes})
142+
res, err := analyzer.ImpactRadiusBounded(ctx, node.ID, depth, impactpkg.RadiusOptions{MaxDepth: maxDepth, MaxNodes: maxNodes})
138143
if err != nil {
139144
log.ErrorContext(ctx, "impact analysis error", append(obs.TraceLogArgs(ctx), "node_id", node.ID, trace.SlogError(err))...)
140145
return "", trace.Wrap(err, "impact analysis error")
@@ -147,6 +152,9 @@ func (h *handlers) getImpactRadius(ctx context.Context, request mcp.CallToolRequ
147152
impactResult := make([]nodeSummary, len(nodes))
148153
for i, n := range nodes {
149154
impactResult[i] = nodeToSummary(n)
155+
if crossNamespace {
156+
impactResult[i].Namespace = n.Namespace
157+
}
150158
}
151159
result, err := marshalJSON(impactRadiusResponse{
152160
Nodes: impactResult,
@@ -179,9 +187,14 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) (
179187
return missingParamResult(err)
180188
}
181189

182-
log.InfoContext(ctx, "trace_flow called", append(obs.TraceLogArgs(ctx), "qualified_name", qn)...)
190+
crossNamespace := request.GetBool("cross_namespace", false)
191+
log.InfoContext(ctx, "trace_flow called", append(obs.TraceLogArgs(ctx), "qualified_name", qn, "cross_namespace", crossNamespace)...)
183192

184-
if h.deps.Analysis.Flow == nil {
193+
tracer := h.deps.Analysis.Flow
194+
if crossNamespace {
195+
tracer = h.deps.Analysis.CrossFlow
196+
}
197+
if tracer == nil {
185198
return mcp.NewToolResultError("FlowTracer not configured"), nil
186199
}
187200

@@ -192,6 +205,7 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) (
192205
"max_nodes": maxNodes,
193206
"include_fallback_calls": includeFallbackCalls,
194207
"namespace": requestNamespace(request),
208+
"cross_namespace": crossNamespace,
195209
}, func() (string, error) {
196210
node, err := h.deps.Graph.Store.GetNode(ctx, qn)
197211
if err != nil {
@@ -203,7 +217,7 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) (
203217
return "", nodeNotFoundErr(qn)
204218
}
205219

206-
res, err := h.deps.Analysis.Flow.TraceFlowBounded(ctx, node.ID, flowspkg.TraceOptions{MaxNodes: maxNodes, IncludeFallbackCalls: &includeFallbackCalls})
220+
res, err := tracer.TraceFlowBounded(ctx, node.ID, flowspkg.TraceOptions{MaxNodes: maxNodes, IncludeFallbackCalls: &includeFallbackCalls})
207221
if err != nil {
208222
log.ErrorContext(ctx, "trace error", append(obs.TraceLogArgs(ctx), "node_id", node.ID, trace.SlogError(err))...)
209223
return "", trace.Wrap(err, "trace error")
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"testing"
7+
8+
requestctx "github.com/tae2089/code-context-graph/internal/ctx"
9+
"github.com/tae2089/code-context-graph/internal/domain/graph"
10+
)
11+
12+
// seedCrossNamespaceDeps stores web.Login -> (cross ref) -> auth.ValidateToken -> auth.RecordAudit.
13+
func seedCrossNamespaceDeps(t *testing.T, deps *Deps) (webLogin, authValidate, authAudit uint) {
14+
t.Helper()
15+
st := testGraphStoreFor(deps)
16+
seed := func(namespace string, node graph.Node) uint {
17+
t.Helper()
18+
ctx := requestctx.WithNamespace(context.Background(), namespace)
19+
if err := st.UpsertNodes(ctx, []graph.Node{node}); err != nil {
20+
t.Fatalf("seed node: %v", err)
21+
}
22+
stored, err := st.GetNode(ctx, node.QualifiedName)
23+
if err != nil || stored == nil {
24+
t.Fatalf("load node %s: %v", node.QualifiedName, err)
25+
}
26+
return stored.ID
27+
}
28+
webLogin = seed("web", graph.Node{QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login", FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25})
29+
authValidate = seed("auth-svc", graph.Node{QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20})
30+
authAudit = seed("auth-svc", graph.Node{QualifiedName: "auth.RecordAudit", Kind: graph.NodeKindFunction, Name: "RecordAudit", FilePath: "internal/audit/audit.go", StartLine: 3, EndLine: 9})
31+
32+
authCtx := requestctx.WithNamespace(context.Background(), "auth-svc")
33+
if err := st.UpsertEdges(authCtx, []graph.Edge{{
34+
FromNodeID: authValidate, ToNodeID: authAudit, Kind: graph.EdgeKindCalls,
35+
FilePath: "internal/auth/token.go", Line: 15, Fingerprint: "calls:internal/auth/token.go:RecordAudit:15",
36+
}}); err != nil {
37+
t.Fatalf("seed edge: %v", err)
38+
}
39+
40+
resolved := authValidate
41+
if err := st.ReplaceCrossRefsFrom(context.Background(), "web", []graph.CrossRef{{
42+
FromNamespace: "web", FromNodeID: webLogin, Raw: "ccg://auth-svc/internal/auth/token.go#ValidateToken",
43+
ToNamespace: "auth-svc", ToPath: "internal/auth/token.go", ToSymbol: "ValidateToken",
44+
ResolvedNodeID: &resolved, Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation,
45+
}}); err != nil {
46+
t.Fatalf("seed cross ref: %v", err)
47+
}
48+
return webLogin, authValidate, authAudit
49+
}
50+
51+
func TestGetImpactRadius_CrossNamespace(t *testing.T) {
52+
deps := setupTestDeps(t)
53+
seedCrossNamespaceDeps(t, deps)
54+
55+
result := callTool(t, deps, "get_impact_radius", map[string]any{
56+
"qualified_name": "web.Login",
57+
"namespace": "web",
58+
"depth": 2,
59+
"cross_namespace": true,
60+
})
61+
var payload struct {
62+
Nodes []struct {
63+
QualifiedName string `json:"qualified_name"`
64+
Namespace string `json:"namespace"`
65+
} `json:"nodes"`
66+
}
67+
if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil {
68+
t.Fatalf("unmarshal: %v", err)
69+
}
70+
seen := map[string]string{}
71+
for _, n := range payload.Nodes {
72+
seen[n.QualifiedName] = n.Namespace
73+
}
74+
if seen["auth.ValidateToken"] != "auth-svc" {
75+
t.Fatalf("impact nodes = %v, want auth.ValidateToken labeled auth-svc", seen)
76+
}
77+
if seen["auth.RecordAudit"] != "auth-svc" {
78+
t.Fatalf("impact nodes = %v, want depth-2 traversal to continue inside auth-svc", seen)
79+
}
80+
}
81+
82+
func TestGetImpactRadius_CrossNamespaceReverse(t *testing.T) {
83+
deps := setupTestDeps(t)
84+
seedCrossNamespaceDeps(t, deps)
85+
86+
result := callTool(t, deps, "get_impact_radius", map[string]any{
87+
"qualified_name": "auth.ValidateToken",
88+
"namespace": "auth-svc",
89+
"depth": 1,
90+
"cross_namespace": true,
91+
})
92+
var payload struct {
93+
Nodes []struct {
94+
QualifiedName string `json:"qualified_name"`
95+
Namespace string `json:"namespace"`
96+
} `json:"nodes"`
97+
}
98+
if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil {
99+
t.Fatalf("unmarshal: %v", err)
100+
}
101+
found := false
102+
for _, n := range payload.Nodes {
103+
if n.QualifiedName == "web.Login" && n.Namespace == "web" {
104+
found = true
105+
}
106+
}
107+
if !found {
108+
t.Fatalf("reverse impact = %+v, want web.Login from web namespace", payload.Nodes)
109+
}
110+
}
111+
112+
func TestGetImpactRadius_DefaultStaysNamespaceScoped(t *testing.T) {
113+
deps := setupTestDeps(t)
114+
seedCrossNamespaceDeps(t, deps)
115+
116+
result := callTool(t, deps, "get_impact_radius", map[string]any{
117+
"qualified_name": "web.Login",
118+
"namespace": "web",
119+
"depth": 2,
120+
})
121+
var payload struct {
122+
Nodes []struct {
123+
QualifiedName string `json:"qualified_name"`
124+
} `json:"nodes"`
125+
}
126+
if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil {
127+
t.Fatalf("unmarshal: %v", err)
128+
}
129+
for _, n := range payload.Nodes {
130+
if n.QualifiedName == "auth.ValidateToken" {
131+
t.Fatal("default impact crossed namespaces without cross_namespace flag")
132+
}
133+
}
134+
}
135+
136+
func TestTraceFlow_CrossNamespace(t *testing.T) {
137+
deps := setupTestDeps(t)
138+
_, authValidate, authAudit := seedCrossNamespaceDeps(t, deps)
139+
140+
result := callTool(t, deps, "trace_flow", map[string]any{
141+
"qualified_name": "web.Login",
142+
"namespace": "web",
143+
"cross_namespace": true,
144+
})
145+
var payload struct {
146+
Members []struct {
147+
NodeID uint `json:"node_id"`
148+
} `json:"members"`
149+
}
150+
if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil {
151+
t.Fatalf("unmarshal: %v", err)
152+
}
153+
got := map[uint]bool{}
154+
for _, m := range payload.Members {
155+
got[m.NodeID] = true
156+
}
157+
if !got[authValidate] || !got[authAudit] {
158+
t.Fatalf("flow members = %+v, want continuation into auth-svc nodes %d and %d", payload.Members, authValidate, authAudit)
159+
}
160+
}
161+
162+
func TestListCrossRefs_Directions(t *testing.T) {
163+
deps := setupTestDeps(t)
164+
webLogin, authValidate, _ := seedCrossNamespaceDeps(t, deps)
165+
166+
outbound := callTool(t, deps, "list_cross_refs", map[string]any{"namespace": "web", "direction": "outbound"})
167+
var outPayload struct {
168+
Refs []struct {
169+
FromNamespace string `json:"from_namespace"`
170+
FromNodeID uint `json:"from_node_id"`
171+
ToNamespace string `json:"to_namespace"`
172+
ResolvedNodeID *uint `json:"resolved_node_id"`
173+
Status string `json:"status"`
174+
} `json:"refs"`
175+
}
176+
if err := json.Unmarshal([]byte(resultTextOf(t, outbound)), &outPayload); err != nil {
177+
t.Fatalf("unmarshal outbound: %v", err)
178+
}
179+
if len(outPayload.Refs) != 1 {
180+
t.Fatalf("outbound refs = %d, want 1", len(outPayload.Refs))
181+
}
182+
ref := outPayload.Refs[0]
183+
if ref.FromNodeID != webLogin || ref.ToNamespace != "auth-svc" || ref.Status != "resolved" || ref.ResolvedNodeID == nil || *ref.ResolvedNodeID != authValidate {
184+
t.Fatalf("outbound ref = %+v, want resolved web -> auth-svc link", ref)
185+
}
186+
187+
inbound := callTool(t, deps, "list_cross_refs", map[string]any{"namespace": "auth-svc", "direction": "inbound"})
188+
var inPayload struct {
189+
Refs []struct {
190+
FromNamespace string `json:"from_namespace"`
191+
} `json:"refs"`
192+
}
193+
if err := json.Unmarshal([]byte(resultTextOf(t, inbound)), &inPayload); err != nil {
194+
t.Fatalf("unmarshal inbound: %v", err)
195+
}
196+
if len(inPayload.Refs) != 1 || inPayload.Refs[0].FromNamespace != "web" {
197+
t.Fatalf("inbound refs = %+v, want one ref from web", inPayload.Refs)
198+
}
199+
}

0 commit comments

Comments
 (0)