interlinked-mapper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- interlinked/__init__.py +3 -0
- interlinked/analyzer/__init__.py +7 -0
- interlinked/analyzer/dead_code.py +137 -0
- interlinked/analyzer/graph.py +822 -0
- interlinked/analyzer/parser.py +1141 -0
- interlinked/analyzer/similarity.py +486 -0
- interlinked/cli.py +136 -0
- interlinked/commander/__init__.py +6 -0
- interlinked/commander/llm.py +304 -0
- interlinked/commander/query.py +966 -0
- interlinked/commander/repl.py +50 -0
- interlinked/mcp_server.py +324 -0
- interlinked/models.py +107 -0
- interlinked/visualizer/__init__.py +1 -0
- interlinked/visualizer/layouts.py +181 -0
- interlinked/visualizer/server.py +428 -0
- interlinked_mapper-0.1.0.dist-info/METADATA +26 -0
- interlinked_mapper-0.1.0.dist-info/RECORD +21 -0
- interlinked_mapper-0.1.0.dist-info/WHEEL +5 -0
- interlinked_mapper-0.1.0.dist-info/entry_points.txt +2 -0
- interlinked_mapper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
"""QueryEngine — the DSL that LLMs and humans use to manipulate the view."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from interlinked.analyzer.graph import CodeGraph
|
|
10
|
+
from interlinked.models import (
|
|
11
|
+
EdgeType, SymbolType, ViewState, ViewContext, ColorScheme, NodeData, EdgeData,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class QueryEngine:
|
|
16
|
+
"""Provides a high-level API for querying and manipulating the code graph.
|
|
17
|
+
|
|
18
|
+
This is the object exposed as `view` in the REPL.
|
|
19
|
+
An LLM emits Python calls against this API to control the visualization.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, graph: CodeGraph) -> None:
|
|
23
|
+
self.graph = graph
|
|
24
|
+
self.state = ViewState()
|
|
25
|
+
self._change_callbacks: list[Any] = []
|
|
26
|
+
|
|
27
|
+
def on_change(self, callback: Any) -> None:
|
|
28
|
+
"""Register a callback fired whenever the view state changes."""
|
|
29
|
+
self._change_callbacks.append(callback)
|
|
30
|
+
|
|
31
|
+
def _notify(self) -> None:
|
|
32
|
+
for cb in self._change_callbacks:
|
|
33
|
+
cb(self.snapshot())
|
|
34
|
+
|
|
35
|
+
# ── Zoom / Focus ─────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
def zoom(self, level: str) -> str:
|
|
38
|
+
"""Set zoom level: 'module', 'class', 'function', 'variable', or 'all'."""
|
|
39
|
+
valid = ("module", "class", "function", "variable", "all")
|
|
40
|
+
if level not in valid:
|
|
41
|
+
return f"Invalid zoom level: {level}. Use one of: {', '.join(valid)}."
|
|
42
|
+
self.state.zoom_level = level
|
|
43
|
+
self._notify()
|
|
44
|
+
return f"Zoom set to {level} level."
|
|
45
|
+
|
|
46
|
+
def focus(self, node_id: str, depth: int = 2) -> str:
|
|
47
|
+
"""Focus the view on a specific node and its neighborhood."""
|
|
48
|
+
node = self.graph.get_node(node_id)
|
|
49
|
+
if not node:
|
|
50
|
+
# Try fuzzy match
|
|
51
|
+
matches = [
|
|
52
|
+
n for n in self.graph.all_nodes()
|
|
53
|
+
if node_id.lower() in n.qualified_name.lower()
|
|
54
|
+
]
|
|
55
|
+
if len(matches) == 1:
|
|
56
|
+
node = matches[0]
|
|
57
|
+
node_id = node.id
|
|
58
|
+
elif matches:
|
|
59
|
+
names = [m.qualified_name for m in matches[:10]]
|
|
60
|
+
return f"Ambiguous. Did you mean one of: {', '.join(names)}?"
|
|
61
|
+
else:
|
|
62
|
+
return f"Node '{node_id}' not found."
|
|
63
|
+
|
|
64
|
+
self.state.focus_node = node_id
|
|
65
|
+
self.state.focus_depth = depth
|
|
66
|
+
self._notify()
|
|
67
|
+
return f"Focused on {node_id} (depth={depth})."
|
|
68
|
+
|
|
69
|
+
def unfocus(self) -> str:
|
|
70
|
+
"""Remove focus — show the full graph at current zoom level."""
|
|
71
|
+
self.state.focus_node = None
|
|
72
|
+
self._notify()
|
|
73
|
+
return "Focus cleared."
|
|
74
|
+
|
|
75
|
+
# ── Isolate (the core LLM use-case) ──────────────────────────────
|
|
76
|
+
|
|
77
|
+
def isolate(
|
|
78
|
+
self,
|
|
79
|
+
target: str,
|
|
80
|
+
level: str = "function",
|
|
81
|
+
depth: int = 3,
|
|
82
|
+
edge_types: list[str] | None = None,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Isolate a module/class/service and show it + everything that connects to it.
|
|
85
|
+
|
|
86
|
+
This is the primary command an LLM uses to walk a human through the codebase.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
target: Name or partial name of the module/class/function to isolate.
|
|
90
|
+
level: Zoom level — 'module', 'class', or 'function'.
|
|
91
|
+
depth: How many hops of connections to show.
|
|
92
|
+
edge_types: Which relationship types to follow (default: all).
|
|
93
|
+
|
|
94
|
+
Examples:
|
|
95
|
+
view.isolate('analyzer.parser') # show parser and connections
|
|
96
|
+
view.isolate('CodeGraph', level='function') # show all methods + what calls them
|
|
97
|
+
view.isolate('visualizer', level='module', depth=1)# show module + direct dependencies
|
|
98
|
+
view.isolate('QueryEngine', level='function', depth=2, edge_types=['calls'])
|
|
99
|
+
"""
|
|
100
|
+
# Resolve the target name
|
|
101
|
+
node = self.graph.get_node(target)
|
|
102
|
+
if not node:
|
|
103
|
+
matches = [
|
|
104
|
+
n for n in self.graph.all_nodes()
|
|
105
|
+
if target.lower() in n.qualified_name.lower()
|
|
106
|
+
]
|
|
107
|
+
if not matches:
|
|
108
|
+
return f"No symbol matching '{target}' found."
|
|
109
|
+
if len(matches) == 1:
|
|
110
|
+
node = matches[0]
|
|
111
|
+
else:
|
|
112
|
+
# Pick the broadest match (shortest qualified name = higher-level symbol)
|
|
113
|
+
node = min(matches, key=lambda n: len(n.qualified_name))
|
|
114
|
+
|
|
115
|
+
# Resolve edge types
|
|
116
|
+
et_filter = None
|
|
117
|
+
if edge_types:
|
|
118
|
+
et_filter = []
|
|
119
|
+
for et_str in edge_types:
|
|
120
|
+
try:
|
|
121
|
+
et_filter.append(EdgeType(et_str))
|
|
122
|
+
except ValueError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
# Get all descendants (things contained within the target)
|
|
126
|
+
all_nodes = self.graph.all_nodes()
|
|
127
|
+
internal_ids = {
|
|
128
|
+
n.id for n in all_nodes
|
|
129
|
+
if n.qualified_name.startswith(node.qualified_name)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Get the subgraph around the target — includes external connections
|
|
133
|
+
sub_nodes, sub_edges = self.graph.subgraph_around(
|
|
134
|
+
node.id, depth=depth, edge_types=et_filter
|
|
135
|
+
)
|
|
136
|
+
sub_ids = {n.id for n in sub_nodes}
|
|
137
|
+
|
|
138
|
+
# Combine: everything internal + everything in the neighborhood
|
|
139
|
+
visible_ids = internal_ids | sub_ids
|
|
140
|
+
|
|
141
|
+
# Set view state
|
|
142
|
+
self.state.zoom_level = level
|
|
143
|
+
self.state.focus_node = None # We use visible_node_ids instead
|
|
144
|
+
self.state.visible_node_ids = list(visible_ids)
|
|
145
|
+
self.state.highlighted_node_ids = list(internal_ids) # Highlight the target
|
|
146
|
+
if et_filter:
|
|
147
|
+
self.state.visible_edge_types = et_filter
|
|
148
|
+
else:
|
|
149
|
+
self.state.visible_edge_types = list(EdgeType)
|
|
150
|
+
|
|
151
|
+
self._notify()
|
|
152
|
+
|
|
153
|
+
n_internal = len(internal_ids)
|
|
154
|
+
n_external = len(visible_ids - internal_ids)
|
|
155
|
+
return (
|
|
156
|
+
f"Isolated '{node.qualified_name}' at {level} level: "
|
|
157
|
+
f"{n_internal} internal symbols, {n_external} external connections (depth={depth})."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def show(self, target: str, level: str = "function", depth: int = 2) -> str:
|
|
161
|
+
"""Shorthand for isolate — show me this thing and what connects to it."""
|
|
162
|
+
return self.isolate(target, level=level, depth=depth)
|
|
163
|
+
|
|
164
|
+
# ── Filtering ────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def filter(
|
|
167
|
+
self,
|
|
168
|
+
edge_type: str | None = None,
|
|
169
|
+
symbol_type: str | None = None,
|
|
170
|
+
min_depth: int | None = None,
|
|
171
|
+
max_depth: int | None = None,
|
|
172
|
+
name_pattern: str | None = None,
|
|
173
|
+
) -> str:
|
|
174
|
+
"""Filter the visible graph by various criteria."""
|
|
175
|
+
if edge_type:
|
|
176
|
+
try:
|
|
177
|
+
et = EdgeType(edge_type)
|
|
178
|
+
self.state.visible_edge_types = [et]
|
|
179
|
+
except ValueError:
|
|
180
|
+
return f"Unknown edge type: {edge_type}. Options: {[e.value for e in EdgeType]}"
|
|
181
|
+
|
|
182
|
+
if name_pattern:
|
|
183
|
+
regex = re.compile(name_pattern, re.IGNORECASE)
|
|
184
|
+
matching = [
|
|
185
|
+
n for n in self.graph.all_nodes()
|
|
186
|
+
if regex.search(n.qualified_name) or regex.search(n.name)
|
|
187
|
+
]
|
|
188
|
+
self.state.visible_node_ids = [n.id for n in matching]
|
|
189
|
+
else:
|
|
190
|
+
self.state.visible_node_ids = []
|
|
191
|
+
|
|
192
|
+
self._notify()
|
|
193
|
+
parts = []
|
|
194
|
+
if edge_type:
|
|
195
|
+
parts.append(f"edge_type={edge_type}")
|
|
196
|
+
if name_pattern:
|
|
197
|
+
parts.append(f"name_pattern={name_pattern}")
|
|
198
|
+
return f"Filter applied: {', '.join(parts) if parts else 'reset'}."
|
|
199
|
+
|
|
200
|
+
def set_edge_types(self, edge_types: list[str]) -> str:
|
|
201
|
+
"""Set which edge types are visible. Pass list of edge type strings."""
|
|
202
|
+
valid = []
|
|
203
|
+
for et in edge_types:
|
|
204
|
+
try:
|
|
205
|
+
valid.append(EdgeType(et))
|
|
206
|
+
except ValueError:
|
|
207
|
+
pass
|
|
208
|
+
self.state.visible_edge_types = valid if valid else list(EdgeType)
|
|
209
|
+
self._notify()
|
|
210
|
+
return f"Visible edge types: {[e.value for e in self.state.visible_edge_types]}"
|
|
211
|
+
|
|
212
|
+
def reset_filter(self) -> str:
|
|
213
|
+
"""Reset all filters to show everything."""
|
|
214
|
+
self.state.visible_node_ids = []
|
|
215
|
+
self.state.visible_edge_types = list(EdgeType)
|
|
216
|
+
self.state.focus_node = None
|
|
217
|
+
self.state.filter_expression = None
|
|
218
|
+
self.state.highlighted_node_ids = []
|
|
219
|
+
self.state.trace_node_roles = {}
|
|
220
|
+
self.state.trace_edge_roles = {}
|
|
221
|
+
self._notify()
|
|
222
|
+
return "All filters reset."
|
|
223
|
+
|
|
224
|
+
# ── Queries ──────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
def query(self, expression: str) -> list[dict]:
|
|
227
|
+
"""Run a structured query against the graph.
|
|
228
|
+
|
|
229
|
+
Examples:
|
|
230
|
+
view.query("functions returning List[str]")
|
|
231
|
+
view.query("dead functions")
|
|
232
|
+
view.query("uncalled")
|
|
233
|
+
view.query("callers of MyClass.my_method")
|
|
234
|
+
view.query("callees of main")
|
|
235
|
+
view.query("imports of analyzer.parser")
|
|
236
|
+
view.query("parameters of parse_project")
|
|
237
|
+
view.query("returns of build_from")
|
|
238
|
+
view.query("external calls in analyzer.parser")
|
|
239
|
+
"""
|
|
240
|
+
expr = expression.lower().strip()
|
|
241
|
+
results: list[NodeData] = []
|
|
242
|
+
|
|
243
|
+
if expr.startswith("callers of"):
|
|
244
|
+
target = expression.split("callers of", 1)[1].strip()
|
|
245
|
+
results = self.graph.callers_of(target)
|
|
246
|
+
|
|
247
|
+
elif expr.startswith("callees of"):
|
|
248
|
+
target = expression.split("callees of", 1)[1].strip()
|
|
249
|
+
results = self.graph.callees_of(target)
|
|
250
|
+
|
|
251
|
+
elif expr.startswith("parameters of") or expr.startswith("params of"):
|
|
252
|
+
target = expression.split("of", 1)[1].strip()
|
|
253
|
+
node = self._resolve_node(target)
|
|
254
|
+
if node:
|
|
255
|
+
G = self.graph._g
|
|
256
|
+
if node.id in G:
|
|
257
|
+
param_ids = [
|
|
258
|
+
v for _, v, d in G.out_edges(node.id, data=True)
|
|
259
|
+
if d.get("edge_type") == "contains"
|
|
260
|
+
and self.graph.get_node(v)
|
|
261
|
+
and self.graph.get_node(v).symbol_type == SymbolType.PARAMETER
|
|
262
|
+
]
|
|
263
|
+
results = [self.graph.get_node(p) for p in param_ids if self.graph.get_node(p)]
|
|
264
|
+
|
|
265
|
+
elif expr.startswith("returns of"):
|
|
266
|
+
target = expression.split("returns of", 1)[1].strip()
|
|
267
|
+
node = self._resolve_node(target)
|
|
268
|
+
if node:
|
|
269
|
+
edges = self.graph.edges_from(node.id, EdgeType.RETURNS)
|
|
270
|
+
ret_nodes = [self.graph.get_node(e.target) for e in edges if self.graph.get_node(e.target)]
|
|
271
|
+
results = ret_nodes
|
|
272
|
+
|
|
273
|
+
elif expr.startswith("external calls"):
|
|
274
|
+
# "external calls in X" or "external calls of X"
|
|
275
|
+
rest = expr.split("external calls", 1)[1].strip()
|
|
276
|
+
rest = rest.lstrip("in ").lstrip("of ").strip()
|
|
277
|
+
node_ids = {n.id for n in self.graph.all_nodes()}
|
|
278
|
+
G = self.graph._g
|
|
279
|
+
if rest:
|
|
280
|
+
# Scope to a module/class/function
|
|
281
|
+
scope_node = self._resolve_node(rest)
|
|
282
|
+
scope_prefix = scope_node.qualified_name if scope_node else rest
|
|
283
|
+
ext_calls = []
|
|
284
|
+
for e in self.graph.all_edges():
|
|
285
|
+
if e.edge_type != EdgeType.CALLS:
|
|
286
|
+
continue
|
|
287
|
+
if e.target in node_ids:
|
|
288
|
+
continue
|
|
289
|
+
if e.source.startswith(scope_prefix):
|
|
290
|
+
ext_calls.append({"source": e.source, "target": e.target, "line": e.line})
|
|
291
|
+
self.state.highlighted_node_ids = list({c["source"] for c in ext_calls})
|
|
292
|
+
self._notify()
|
|
293
|
+
return ext_calls
|
|
294
|
+
else:
|
|
295
|
+
# All external calls
|
|
296
|
+
ext_calls = [
|
|
297
|
+
{"source": e.source, "target": e.target, "line": e.line}
|
|
298
|
+
for e in self.graph.all_edges()
|
|
299
|
+
if e.edge_type == EdgeType.CALLS and e.target not in node_ids
|
|
300
|
+
]
|
|
301
|
+
return ext_calls
|
|
302
|
+
|
|
303
|
+
elif expr.startswith("functions returning"):
|
|
304
|
+
type_hint = expression.split("returning", 1)[1].strip()
|
|
305
|
+
results = self.graph.functions_returning(type_hint)
|
|
306
|
+
|
|
307
|
+
elif "dead" in expr or "uncalled" in expr:
|
|
308
|
+
results = [
|
|
309
|
+
n for n in self.graph.all_nodes()
|
|
310
|
+
if n.is_dead
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
elif expr.startswith("imports of"):
|
|
314
|
+
target = expression.split("imports of", 1)[1].strip()
|
|
315
|
+
edges = self.graph.edges_from(target, EdgeType.IMPORTS)
|
|
316
|
+
return [e.model_dump() for e in edges]
|
|
317
|
+
|
|
318
|
+
elif expr.startswith("modules"):
|
|
319
|
+
results = self.graph.nodes_by_type(SymbolType.MODULE)
|
|
320
|
+
|
|
321
|
+
elif expr.startswith("classes"):
|
|
322
|
+
results = self.graph.nodes_by_type(SymbolType.CLASS)
|
|
323
|
+
|
|
324
|
+
elif expr.startswith("functions") or expr.startswith("methods"):
|
|
325
|
+
results = (
|
|
326
|
+
self.graph.nodes_by_type(SymbolType.FUNCTION)
|
|
327
|
+
+ self.graph.nodes_by_type(SymbolType.METHOD)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
elif expr.startswith("parameters") or expr.startswith("variables"):
|
|
331
|
+
results = [
|
|
332
|
+
n for n in self.graph.all_nodes()
|
|
333
|
+
if n.symbol_type == (SymbolType.PARAMETER if "param" in expr else SymbolType.VARIABLE)
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
else:
|
|
337
|
+
# Fuzzy name search
|
|
338
|
+
results = [
|
|
339
|
+
n for n in self.graph.all_nodes()
|
|
340
|
+
if expr in n.qualified_name.lower() or expr in n.name.lower()
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
# Highlight results in the view
|
|
344
|
+
self.state.highlighted_node_ids = [n.id for n in results]
|
|
345
|
+
self._notify()
|
|
346
|
+
|
|
347
|
+
return [r.model_dump() for r in results]
|
|
348
|
+
|
|
349
|
+
def trace_variable(self, var_name: str, origin: str | None = None) -> str:
|
|
350
|
+
"""Trace a variable's path through reads/writes and highlight it.
|
|
351
|
+
|
|
352
|
+
Nodes are color-coded by role: origin (first write), mutator (subsequent writes),
|
|
353
|
+
passthrough (reads then passes along), destination (terminal reader).
|
|
354
|
+
Edges are colored by type: write (mutation) vs read (non-mutating).
|
|
355
|
+
"""
|
|
356
|
+
nodes, edges, node_roles, edge_roles = self.graph.trace_variable(var_name, origin)
|
|
357
|
+
self.state.highlighted_node_ids = [n.id for n in nodes]
|
|
358
|
+
self.state.trace_node_roles = node_roles
|
|
359
|
+
self.state.trace_edge_roles = edge_roles
|
|
360
|
+
|
|
361
|
+
role_counts = {}
|
|
362
|
+
for r in node_roles.values():
|
|
363
|
+
role_counts[r] = role_counts.get(r, 0) + 1
|
|
364
|
+
role_str = ", ".join(f"{v} {k}" for k, v in role_counts.items())
|
|
365
|
+
|
|
366
|
+
origins = [nid.split('.')[-1] for nid, r in node_roles.items() if r == 'origin']
|
|
367
|
+
dests = [nid.split('.')[-1] for nid, r in node_roles.items() if r == 'destination']
|
|
368
|
+
self.state.context = ViewContext(
|
|
369
|
+
what=f"Data flow trace of '{var_name}' — {len(nodes)} symbols, {role_str}",
|
|
370
|
+
why=f"Tracking how '{var_name}' is written, read, and passed through the codebase",
|
|
371
|
+
where=f"Origins: {', '.join(origins[:5])}. Destinations: {', '.join(dests[:5])}",
|
|
372
|
+
source="trace",
|
|
373
|
+
)
|
|
374
|
+
self._notify()
|
|
375
|
+
return f"Traced '{var_name}': {len(nodes)} nodes, {len(edges)} edges. Roles: {role_str}."
|
|
376
|
+
|
|
377
|
+
# ── Tracing (Phase 1c) ─────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
def trace_function(self, name: str) -> str:
|
|
380
|
+
"""Trace a function's full call chain — everything that calls it and everything it calls.
|
|
381
|
+
|
|
382
|
+
The target function is green (origin), callers are yellow (passthrough),
|
|
383
|
+
callees are white (destination). Edges show direction of calls.
|
|
384
|
+
"""
|
|
385
|
+
node = self._resolve_node(name)
|
|
386
|
+
if not node:
|
|
387
|
+
return f"No symbol matching '{name}' found."
|
|
388
|
+
|
|
389
|
+
nodes, edges, node_roles, edge_roles = self.graph.trace_function(node.id)
|
|
390
|
+
self.state.highlighted_node_ids = [n.id for n in nodes]
|
|
391
|
+
self.state.trace_node_roles = node_roles
|
|
392
|
+
self.state.trace_edge_roles = edge_roles
|
|
393
|
+
|
|
394
|
+
role_counts = {}
|
|
395
|
+
for r in node_roles.values():
|
|
396
|
+
role_counts[r] = role_counts.get(r, 0) + 1
|
|
397
|
+
role_str = ", ".join(f"{v} {k}" for k, v in role_counts.items())
|
|
398
|
+
|
|
399
|
+
callers = [nid.split('.')[-1] for nid, r in node_roles.items() if r == 'passthrough']
|
|
400
|
+
callees = [nid.split('.')[-1] for nid, r in node_roles.items() if r == 'destination']
|
|
401
|
+
self.state.context = ViewContext(
|
|
402
|
+
what=f"Call chain of '{node.name}' — {len(nodes)} symbols in chain",
|
|
403
|
+
why=f"Full upstream callers and downstream callees of '{node.qualified_name}'",
|
|
404
|
+
where=f"Callers: {', '.join(callers[:5])}. Callees: {', '.join(callees[:5])}",
|
|
405
|
+
source="trace",
|
|
406
|
+
)
|
|
407
|
+
self._notify()
|
|
408
|
+
return f"Traced '{node.qualified_name}': {len(nodes)} nodes, {len(edges)} edges. {role_str}."
|
|
409
|
+
|
|
410
|
+
def trace_call_chain(self, source: str, target: str, max_depth: int = 8) -> str:
|
|
411
|
+
"""Find all call paths from source to target function.
|
|
412
|
+
|
|
413
|
+
Shows every route through the call graph from A to B.
|
|
414
|
+
Source is green (origin), target is white (destination),
|
|
415
|
+
intermediates are yellow (passthrough).
|
|
416
|
+
"""
|
|
417
|
+
src_node = self._resolve_node(source)
|
|
418
|
+
tgt_node = self._resolve_node(target)
|
|
419
|
+
if not src_node:
|
|
420
|
+
return f"Source '{source}' not found."
|
|
421
|
+
if not tgt_node:
|
|
422
|
+
return f"Target '{target}' not found."
|
|
423
|
+
|
|
424
|
+
nodes, edges, node_roles, edge_roles = self.graph.trace_call_chain(
|
|
425
|
+
src_node.id, tgt_node.id, max_depth=max_depth
|
|
426
|
+
)
|
|
427
|
+
if not nodes:
|
|
428
|
+
return f"No call path found between '{src_node.qualified_name}' and '{tgt_node.qualified_name}'."
|
|
429
|
+
|
|
430
|
+
self.state.highlighted_node_ids = [n.id for n in nodes]
|
|
431
|
+
self.state.trace_node_roles = node_roles
|
|
432
|
+
self.state.trace_edge_roles = edge_roles
|
|
433
|
+
self._notify()
|
|
434
|
+
return (
|
|
435
|
+
f"Found call chain: {len(nodes)} nodes, {len(edges)} edges "
|
|
436
|
+
f"between '{src_node.qualified_name}' and '{tgt_node.qualified_name}'."
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# ── Impact & Dependency (Phase 2) ────────────────────────────────
|
|
440
|
+
|
|
441
|
+
def impact_of(self, name: str) -> str:
|
|
442
|
+
"""Show everything downstream — if I change this, what's affected?
|
|
443
|
+
|
|
444
|
+
Highlights the blast radius of changing a symbol.
|
|
445
|
+
"""
|
|
446
|
+
node = self._resolve_node(name)
|
|
447
|
+
if not node:
|
|
448
|
+
return f"No symbol matching '{name}' found."
|
|
449
|
+
|
|
450
|
+
affected = self.graph.impact_of(node.id)
|
|
451
|
+
all_ids = [node.id] + list(affected)
|
|
452
|
+
self.state.highlighted_node_ids = all_ids
|
|
453
|
+
self.state.trace_node_roles = {node.id: "origin"}
|
|
454
|
+
self.state.trace_node_roles.update({nid: "destination" for nid in affected})
|
|
455
|
+
self.state.trace_edge_roles = {}
|
|
456
|
+
affected_names = [nid.split('.')[-1] for nid in list(affected)[:5]]
|
|
457
|
+
self.state.context = ViewContext(
|
|
458
|
+
what=f"Blast radius of '{node.name}' — {len(affected)} downstream symbols affected",
|
|
459
|
+
why=f"Everything that would break if '{node.qualified_name}' changes",
|
|
460
|
+
where=f"Affected: {', '.join(affected_names)}{'...' if len(affected) > 5 else ''}",
|
|
461
|
+
source="trace",
|
|
462
|
+
)
|
|
463
|
+
self._notify()
|
|
464
|
+
return f"Impact of '{node.qualified_name}': {len(affected)} downstream symbols affected."
|
|
465
|
+
|
|
466
|
+
def depends_on(self, name: str) -> str:
|
|
467
|
+
"""Show everything upstream — what does this symbol depend on?
|
|
468
|
+
|
|
469
|
+
Highlights all dependencies feeding into this symbol.
|
|
470
|
+
"""
|
|
471
|
+
node = self._resolve_node(name)
|
|
472
|
+
if not node:
|
|
473
|
+
return f"No symbol matching '{name}' found."
|
|
474
|
+
|
|
475
|
+
deps = self.graph.feeds_into(node.id)
|
|
476
|
+
all_ids = [node.id] + list(deps)
|
|
477
|
+
self.state.highlighted_node_ids = all_ids
|
|
478
|
+
self.state.trace_node_roles = {node.id: "destination"}
|
|
479
|
+
self.state.trace_node_roles.update({nid: "origin" for nid in deps})
|
|
480
|
+
self.state.trace_edge_roles = {}
|
|
481
|
+
dep_names = [nid.split('.')[-1] for nid in list(deps)[:5]]
|
|
482
|
+
self.state.context = ViewContext(
|
|
483
|
+
what=f"Dependencies of '{node.name}' — {len(deps)} upstream symbols",
|
|
484
|
+
why=f"Everything '{node.qualified_name}' depends on",
|
|
485
|
+
where=f"Depends on: {', '.join(dep_names)}{'...' if len(deps) > 5 else ''}",
|
|
486
|
+
source="trace",
|
|
487
|
+
)
|
|
488
|
+
self._notify()
|
|
489
|
+
return f"'{node.qualified_name}' depends on {len(deps)} upstream symbols."
|
|
490
|
+
|
|
491
|
+
def path_between(self, source: str, target: str) -> str:
|
|
492
|
+
"""Show the shortest dependency chain between two symbols."""
|
|
493
|
+
src_node = self._resolve_node(source)
|
|
494
|
+
tgt_node = self._resolve_node(target)
|
|
495
|
+
if not src_node:
|
|
496
|
+
return f"Source '{source}' not found."
|
|
497
|
+
if not tgt_node:
|
|
498
|
+
return f"Target '{target}' not found."
|
|
499
|
+
|
|
500
|
+
path = self.graph.path_between(src_node.id, tgt_node.id)
|
|
501
|
+
if not path:
|
|
502
|
+
return f"No path between '{src_node.qualified_name}' and '{tgt_node.qualified_name}'."
|
|
503
|
+
|
|
504
|
+
self.state.highlighted_node_ids = list(path)
|
|
505
|
+
self.state.trace_node_roles = {
|
|
506
|
+
path[0]: "origin",
|
|
507
|
+
path[-1]: "destination",
|
|
508
|
+
}
|
|
509
|
+
for nid in path[1:-1]:
|
|
510
|
+
self.state.trace_node_roles[nid] = "passthrough"
|
|
511
|
+
self.state.trace_edge_roles = {}
|
|
512
|
+
self._notify()
|
|
513
|
+
|
|
514
|
+
short_path = " → ".join(n.split(".")[-1] for n in path)
|
|
515
|
+
return f"Shortest path ({len(path)} hops): {short_path}"
|
|
516
|
+
|
|
517
|
+
def all_paths(self, source: str, target: str, max_depth: int = 8) -> str:
|
|
518
|
+
"""Show every route between two symbols."""
|
|
519
|
+
src_node = self._resolve_node(source)
|
|
520
|
+
tgt_node = self._resolve_node(target)
|
|
521
|
+
if not src_node:
|
|
522
|
+
return f"Source '{source}' not found."
|
|
523
|
+
if not tgt_node:
|
|
524
|
+
return f"Target '{target}' not found."
|
|
525
|
+
|
|
526
|
+
paths = self.graph.all_paths_between(src_node.id, tgt_node.id, max_depth=max_depth)
|
|
527
|
+
if not paths:
|
|
528
|
+
return f"No paths between '{src_node.qualified_name}' and '{tgt_node.qualified_name}'."
|
|
529
|
+
|
|
530
|
+
all_nodes: set[str] = set()
|
|
531
|
+
for p in paths:
|
|
532
|
+
all_nodes.update(p)
|
|
533
|
+
|
|
534
|
+
self.state.highlighted_node_ids = list(all_nodes)
|
|
535
|
+
self.state.trace_node_roles = {src_node.id: "origin", tgt_node.id: "destination"}
|
|
536
|
+
for nid in all_nodes - {src_node.id, tgt_node.id}:
|
|
537
|
+
self.state.trace_node_roles[nid] = "passthrough"
|
|
538
|
+
self.state.trace_edge_roles = {}
|
|
539
|
+
self._notify()
|
|
540
|
+
return f"Found {len(paths)} paths between '{src_node.qualified_name}' and '{tgt_node.qualified_name}', {len(all_nodes)} nodes involved."
|
|
541
|
+
|
|
542
|
+
# ── Architecture Health (Phase 3) ────────────────────────────────
|
|
543
|
+
|
|
544
|
+
def find_cycles(self) -> str:
|
|
545
|
+
"""Find and highlight circular dependencies."""
|
|
546
|
+
cycles = self.graph.find_cycles()
|
|
547
|
+
if not cycles:
|
|
548
|
+
return "No circular dependencies found."
|
|
549
|
+
|
|
550
|
+
all_ids: set[str] = set()
|
|
551
|
+
for cycle in cycles:
|
|
552
|
+
all_ids.update(cycle)
|
|
553
|
+
|
|
554
|
+
self.state.highlighted_node_ids = list(all_ids)
|
|
555
|
+
self.state.trace_node_roles = {nid: "mutator" for nid in all_ids}
|
|
556
|
+
self.state.trace_edge_roles = {}
|
|
557
|
+
self._notify()
|
|
558
|
+
return f"Found {len(cycles)} circular dependencies involving {len(all_ids)} symbols."
|
|
559
|
+
|
|
560
|
+
def critical_nodes(self, top_n: int = 20) -> str:
|
|
561
|
+
"""Highlight the most important symbols by PageRank.
|
|
562
|
+
|
|
563
|
+
These are the nodes that, if removed, would most disrupt the codebase.
|
|
564
|
+
"""
|
|
565
|
+
ranked = self.graph.critical_nodes(top_n=top_n)
|
|
566
|
+
if not ranked:
|
|
567
|
+
return "Could not compute critical nodes."
|
|
568
|
+
|
|
569
|
+
ids = [nid for nid, _ in ranked]
|
|
570
|
+
self.state.highlighted_node_ids = ids
|
|
571
|
+
self.state.trace_node_roles = {}
|
|
572
|
+
self.state.trace_edge_roles = {}
|
|
573
|
+
self._notify()
|
|
574
|
+
|
|
575
|
+
top5 = ", ".join(f"{nid.split('.')[-1]} ({score:.4f})" for nid, score in ranked[:5])
|
|
576
|
+
return f"Top {len(ranked)} critical nodes. Top 5: {top5}"
|
|
577
|
+
|
|
578
|
+
def bottlenecks(self, top_n: int = 20) -> str:
|
|
579
|
+
"""Highlight bottleneck nodes — everything flows through these.
|
|
580
|
+
|
|
581
|
+
High betweenness centrality = coupling hotspot.
|
|
582
|
+
"""
|
|
583
|
+
ranked = self.graph.bottlenecks(top_n=top_n)
|
|
584
|
+
if not ranked:
|
|
585
|
+
return "Could not compute bottlenecks."
|
|
586
|
+
|
|
587
|
+
ids = [nid for nid, score in ranked if score > 0]
|
|
588
|
+
self.state.highlighted_node_ids = ids
|
|
589
|
+
self.state.trace_node_roles = {nid: "mutator" for nid in ids}
|
|
590
|
+
self.state.trace_edge_roles = {}
|
|
591
|
+
self._notify()
|
|
592
|
+
|
|
593
|
+
top5 = ", ".join(f"{nid.split('.')[-1]} ({score:.4f})" for nid, score in ranked[:5])
|
|
594
|
+
return f"Top {len(ids)} bottlenecks. Top 5: {top5}"
|
|
595
|
+
|
|
596
|
+
def coupling(self, module_a: str, module_b: str) -> str:
|
|
597
|
+
"""Show coupling between two modules — all cross-module edges."""
|
|
598
|
+
result = self.graph.coupling_between(module_a, module_b)
|
|
599
|
+
if result["edge_count"] == 0:
|
|
600
|
+
return f"No direct coupling between '{module_a}' and '{module_b}'."
|
|
601
|
+
|
|
602
|
+
all_ids: set[str] = set()
|
|
603
|
+
for e in result["edges"]:
|
|
604
|
+
all_ids.add(e["source"])
|
|
605
|
+
all_ids.add(e["target"])
|
|
606
|
+
|
|
607
|
+
self.state.highlighted_node_ids = list(all_ids)
|
|
608
|
+
self.state.trace_node_roles = {}
|
|
609
|
+
self.state.trace_edge_roles = {}
|
|
610
|
+
self._notify()
|
|
611
|
+
return f"Coupling between '{module_a}' and '{module_b}': {result['edge_count']} cross-module edges."
|
|
612
|
+
|
|
613
|
+
def health(self) -> str:
|
|
614
|
+
"""Full architecture health report."""
|
|
615
|
+
cycles = self.graph.find_cycles()
|
|
616
|
+
critical = self.graph.critical_nodes(top_n=5)
|
|
617
|
+
bn = self.graph.bottlenecks(top_n=5)
|
|
618
|
+
coupled = self.graph.most_coupled(top_n=5)
|
|
619
|
+
clusters = self.graph.find_clusters()
|
|
620
|
+
circ = self.graph.circular_clusters()
|
|
621
|
+
dead = self.graph.truly_dead()
|
|
622
|
+
total = self.graph.node_count
|
|
623
|
+
|
|
624
|
+
# Resolution quality and external dependency summary
|
|
625
|
+
all_nodes = self.graph.all_nodes(include_proposed=False)
|
|
626
|
+
all_edges = self.graph.all_edges(include_proposed=False)
|
|
627
|
+
node_ids = {n.id for n in all_nodes}
|
|
628
|
+
rw_edges = [e for e in all_edges if e.edge_type in (EdgeType.READS, EdgeType.WRITES)]
|
|
629
|
+
rw_resolved = sum(1 for e in rw_edges if e.target in node_ids)
|
|
630
|
+
ext_calls = [e for e in all_edges if e.edge_type == EdgeType.CALLS and e.target not in node_ids]
|
|
631
|
+
ext_targets = {}
|
|
632
|
+
for e in ext_calls:
|
|
633
|
+
root = e.target.split(".")[0]
|
|
634
|
+
ext_targets[root] = ext_targets.get(root, 0) + 1
|
|
635
|
+
|
|
636
|
+
report = {
|
|
637
|
+
"total_nodes": total,
|
|
638
|
+
"total_edges": self.graph.edge_count,
|
|
639
|
+
"data_flow_resolution": f"{rw_resolved}/{len(rw_edges)} ({round(rw_resolved / max(len(rw_edges), 1) * 100)}%)",
|
|
640
|
+
"external_calls": len(ext_calls),
|
|
641
|
+
"external_dependencies": dict(sorted(ext_targets.items(), key=lambda x: -x[1])[:10]),
|
|
642
|
+
"circular_dependencies": len(cycles),
|
|
643
|
+
"circular_clusters": len(circ),
|
|
644
|
+
"disconnected_clusters": len(clusters),
|
|
645
|
+
"truly_dead_symbols": len(dead),
|
|
646
|
+
"dead_pct": round(len(dead) / max(total, 1) * 100, 1),
|
|
647
|
+
"top_critical": [{"name": n, "score": round(s, 4)} for n, s in critical],
|
|
648
|
+
"top_bottlenecks": [{"name": n, "score": round(s, 4)} for n, s in bn],
|
|
649
|
+
"top_coupled": [{"name": n, "degree": d} for n, d in coupled],
|
|
650
|
+
}
|
|
651
|
+
return json.dumps(report, indent=2)
|
|
652
|
+
|
|
653
|
+
# ── Helper ───────────────────────────────────────────────────────
|
|
654
|
+
|
|
655
|
+
def _resolve_node(self, name: str) -> NodeData | None:
|
|
656
|
+
"""Resolve a name to a node, with fuzzy matching."""
|
|
657
|
+
node = self.graph.get_node(name)
|
|
658
|
+
if node:
|
|
659
|
+
return node
|
|
660
|
+
matches = [
|
|
661
|
+
n for n in self.graph.all_nodes()
|
|
662
|
+
if name.lower() in n.qualified_name.lower()
|
|
663
|
+
]
|
|
664
|
+
if len(matches) == 1:
|
|
665
|
+
return matches[0]
|
|
666
|
+
if matches:
|
|
667
|
+
return min(matches, key=lambda n: len(n.qualified_name))
|
|
668
|
+
return None
|
|
669
|
+
|
|
670
|
+
# ── Proposals (hypotheticals) ────────────────────────────────────
|
|
671
|
+
|
|
672
|
+
def propose_function(
|
|
673
|
+
self,
|
|
674
|
+
name: str,
|
|
675
|
+
module: str,
|
|
676
|
+
calls: list[str] | None = None,
|
|
677
|
+
called_by: list[str] | None = None,
|
|
678
|
+
signature: str | None = None,
|
|
679
|
+
color: str | None = None,
|
|
680
|
+
) -> str:
|
|
681
|
+
"""Add a hypothetical function to see where it would connect."""
|
|
682
|
+
node = self.graph.propose_function(
|
|
683
|
+
name=name, module=module,
|
|
684
|
+
calls=calls, called_by=called_by,
|
|
685
|
+
signature=signature,
|
|
686
|
+
)
|
|
687
|
+
if color:
|
|
688
|
+
self.state.colors.proposed = color
|
|
689
|
+
self._notify()
|
|
690
|
+
return f"Proposed function '{node.qualified_name}' added to graph."
|
|
691
|
+
|
|
692
|
+
def clear_proposed(self) -> str:
|
|
693
|
+
"""Remove all proposed/hypothetical elements."""
|
|
694
|
+
self.graph.clear_proposed()
|
|
695
|
+
self._notify()
|
|
696
|
+
return "All proposed elements cleared."
|
|
697
|
+
|
|
698
|
+
# ── Similarity / Duplication ──────────────────────────────────────
|
|
699
|
+
|
|
700
|
+
def find_duplicates(self, threshold: float = 0.6, scope: str | None = None) -> str:
|
|
701
|
+
"""Find groups of structurally similar functions — potential duplicated functionality.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
threshold: Similarity threshold 0.0-1.0 (default 0.6). Lower = more results.
|
|
705
|
+
scope: Optional prefix to limit search, e.g. "analyzer" or "commander.query".
|
|
706
|
+
|
|
707
|
+
Returns JSON with groups of similar symbols, sorted by similarity score.
|
|
708
|
+
"""
|
|
709
|
+
from interlinked.analyzer.similarity import find_duplicate_groups
|
|
710
|
+
groups = find_duplicate_groups(self.graph, threshold=threshold, scope=scope)
|
|
711
|
+
|
|
712
|
+
# Highlight all members of duplicate groups in the view
|
|
713
|
+
all_ids = []
|
|
714
|
+
for group in groups:
|
|
715
|
+
for member in group["members"]:
|
|
716
|
+
all_ids.append(member["id"])
|
|
717
|
+
self.state.highlighted_node_ids = all_ids
|
|
718
|
+
self._notify()
|
|
719
|
+
|
|
720
|
+
if not groups:
|
|
721
|
+
return json.dumps({"message": f"No duplicate groups found at threshold {threshold}.", "groups": []})
|
|
722
|
+
return json.dumps({"message": f"Found {len(groups)} groups of similar symbols.", "groups": groups}, indent=2)
|
|
723
|
+
|
|
724
|
+
def similar_to(self, target: str, threshold: float = 0.5) -> str:
|
|
725
|
+
"""Find functions/classes structurally similar to a given symbol.
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
target: Name or partial name of the symbol to compare against.
|
|
729
|
+
threshold: Similarity threshold 0.0-1.0.
|
|
730
|
+
"""
|
|
731
|
+
from interlinked.analyzer.similarity import find_similar_to
|
|
732
|
+
|
|
733
|
+
# Resolve target
|
|
734
|
+
node = self.graph.get_node(target)
|
|
735
|
+
if not node:
|
|
736
|
+
matches = [
|
|
737
|
+
n for n in self.graph.all_nodes()
|
|
738
|
+
if target.lower() in n.qualified_name.lower()
|
|
739
|
+
]
|
|
740
|
+
if not matches:
|
|
741
|
+
return json.dumps({"error": f"No symbol matching '{target}' found."})
|
|
742
|
+
node = min(matches, key=lambda n: len(n.qualified_name))
|
|
743
|
+
|
|
744
|
+
results = find_similar_to(self.graph, node.id, threshold=threshold)
|
|
745
|
+
|
|
746
|
+
# Highlight similar symbols
|
|
747
|
+
self.state.highlighted_node_ids = [node.id] + [r["id"] for r in results]
|
|
748
|
+
self._notify()
|
|
749
|
+
|
|
750
|
+
if not results:
|
|
751
|
+
return json.dumps({"message": f"No symbols similar to '{node.qualified_name}' at threshold {threshold}.", "results": []})
|
|
752
|
+
return json.dumps({
|
|
753
|
+
"message": f"Found {len(results)} symbols similar to '{node.qualified_name}'.",
|
|
754
|
+
"target": node.qualified_name,
|
|
755
|
+
"results": results,
|
|
756
|
+
}, indent=2)
|
|
757
|
+
|
|
758
|
+
def get_context(self, target: str) -> str:
|
|
759
|
+
"""Get rich context for a symbol: source, docstring, comments, connections, fingerprint.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
target: Name or partial name of the symbol.
|
|
763
|
+
"""
|
|
764
|
+
from interlinked.analyzer.similarity import get_rich_context
|
|
765
|
+
|
|
766
|
+
node = self.graph.get_node(target)
|
|
767
|
+
if not node:
|
|
768
|
+
matches = [
|
|
769
|
+
n for n in self.graph.all_nodes()
|
|
770
|
+
if target.lower() in n.qualified_name.lower()
|
|
771
|
+
]
|
|
772
|
+
if not matches:
|
|
773
|
+
return json.dumps({"error": f"No symbol matching '{target}' found."})
|
|
774
|
+
node = min(matches, key=lambda n: len(n.qualified_name))
|
|
775
|
+
|
|
776
|
+
context = get_rich_context(self.graph, node)
|
|
777
|
+
return json.dumps(context, indent=2, default=str)
|
|
778
|
+
|
|
779
|
+
# ── Display settings ─────────────────────────────────────────────
|
|
780
|
+
|
|
781
|
+
@property
|
|
782
|
+
def colors(self) -> ColorScheme:
|
|
783
|
+
return self.state.colors
|
|
784
|
+
|
|
785
|
+
@colors.setter
|
|
786
|
+
def colors(self, scheme: ColorScheme) -> None:
|
|
787
|
+
self.state.colors = scheme
|
|
788
|
+
self._notify()
|
|
789
|
+
|
|
790
|
+
def set_color(self, key: str, value: str) -> str:
|
|
791
|
+
"""Set a specific color in the scheme, e.g. set_color('dead_link', '#ff0000')."""
|
|
792
|
+
if hasattr(self.state.colors, key):
|
|
793
|
+
setattr(self.state.colors, key, value)
|
|
794
|
+
self._notify()
|
|
795
|
+
return f"Color '{key}' set to {value}."
|
|
796
|
+
return f"Unknown color key: {key}. Available: {list(ColorScheme.model_fields.keys())}"
|
|
797
|
+
|
|
798
|
+
def show_dead(self, visible: bool = True) -> str:
|
|
799
|
+
self.state.show_dead = visible
|
|
800
|
+
self._notify()
|
|
801
|
+
return f"Dead code visibility: {visible}."
|
|
802
|
+
|
|
803
|
+
def show_proposed(self, visible: bool = True) -> str:
|
|
804
|
+
self.state.show_proposed = visible
|
|
805
|
+
self._notify()
|
|
806
|
+
return f"Proposed elements visibility: {visible}."
|
|
807
|
+
|
|
808
|
+
# ── Natural language (parsed to commands) ────────────────────────
|
|
809
|
+
|
|
810
|
+
def nl(self, text: str) -> str:
|
|
811
|
+
"""Natural-language command parser.
|
|
812
|
+
|
|
813
|
+
Maps common phrasings to structured commands.
|
|
814
|
+
This is designed so an LLM can simply describe what it wants to show
|
|
815
|
+
and the view updates accordingly.
|
|
816
|
+
"""
|
|
817
|
+
t = text.lower().strip()
|
|
818
|
+
|
|
819
|
+
# ── Isolate / show me commands (primary LLM use-case) ────────
|
|
820
|
+
isolate_match = re.search(
|
|
821
|
+
r'(?:isolate|show\s+(?:me\s+)?|display|examine|look\s+at)\s+'
|
|
822
|
+
r'(?:the\s+)?(.+?)(?:\s+at\s+(?:the\s+)?(module|class|function|variable)\s+level)?'
|
|
823
|
+
r'(?:\s+(?:with\s+)?depth\s*(?:=|of)?\s*(\d+))?$',
|
|
824
|
+
t
|
|
825
|
+
)
|
|
826
|
+
if isolate_match:
|
|
827
|
+
target = isolate_match.group(1).strip().strip("'\"")
|
|
828
|
+
level = isolate_match.group(2) or "function"
|
|
829
|
+
depth = int(isolate_match.group(3)) if isolate_match.group(3) else 3
|
|
830
|
+
# Clean up target — remove trailing "and connections" etc.
|
|
831
|
+
target = re.sub(r'\s+and\s+(?:its\s+)?(?:connections|everything|dependencies).*', '', target)
|
|
832
|
+
target = re.sub(r'\s+(?:module|class|service|component)$', '', target)
|
|
833
|
+
return self.isolate(target, level=level, depth=depth)
|
|
834
|
+
|
|
835
|
+
# ── Impact / blast radius ────────────────────────────────────
|
|
836
|
+
impact_match = re.search(
|
|
837
|
+
r'(?:impact|blast\s+radius|what\s+(?:happens|breaks|changes)\s+if\s+(?:I\s+)?(?:change|modify|remove|delete))\s+'
|
|
838
|
+
r'(?:of\s+)?["\']?(\S+)["\']?', t
|
|
839
|
+
)
|
|
840
|
+
if impact_match:
|
|
841
|
+
return self.impact_of(impact_match.group(1))
|
|
842
|
+
|
|
843
|
+
# ── Dependencies / what feeds into ───────────────────────────
|
|
844
|
+
dep_match = re.search(
|
|
845
|
+
r'(?:what\s+does\s+(.+?)\s+depend\s+on|dependencies?\s+(?:of|for)\s+(.+?)(?:\s|$)|depends\s+on\s+(.+?)(?:\s|$))', t
|
|
846
|
+
)
|
|
847
|
+
if dep_match:
|
|
848
|
+
target = (dep_match.group(1) or dep_match.group(2) or dep_match.group(3)).strip().strip("'\"")
|
|
849
|
+
return self.depends_on(target)
|
|
850
|
+
|
|
851
|
+
# ── Path between / how does X connect to Y ──────────────────
|
|
852
|
+
path_match = re.search(
|
|
853
|
+
r'(?:path|route|connection|how\s+does\s+.+?\s+connect)\s+'
|
|
854
|
+
r'(?:between|from)\s+["\']?(\S+?)["\']?\s+(?:and|to)\s+["\']?(\S+)["\']?', t
|
|
855
|
+
)
|
|
856
|
+
if path_match:
|
|
857
|
+
return self.path_between(path_match.group(1), path_match.group(2))
|
|
858
|
+
|
|
859
|
+
# ── Cycles / circular dependencies ───────────────────────────
|
|
860
|
+
if "cycle" in t or "circular" in t:
|
|
861
|
+
return self.find_cycles()
|
|
862
|
+
|
|
863
|
+
# ── Critical / important nodes ───────────────────────────────
|
|
864
|
+
if "critical" in t or ("most" in t and "important" in t):
|
|
865
|
+
return self.critical_nodes()
|
|
866
|
+
|
|
867
|
+
# ── Bottlenecks ──────────────────────────────────────────────
|
|
868
|
+
if "bottleneck" in t or "coupling hotspot" in t:
|
|
869
|
+
return self.bottlenecks()
|
|
870
|
+
|
|
871
|
+
# ── Health check ─────────────────────────────────────────────
|
|
872
|
+
if "health" in t and ("check" in t or "report" in t or t == "health"):
|
|
873
|
+
return self.health()
|
|
874
|
+
|
|
875
|
+
# ── Coupling between modules ─────────────────────────────────
|
|
876
|
+
coupling_match = re.search(
|
|
877
|
+
r'coupling\s+(?:between\s+)?["\']?(\S+?)["\']?\s+(?:and|to|with)\s+["\']?(\S+)["\']?', t
|
|
878
|
+
)
|
|
879
|
+
if coupling_match:
|
|
880
|
+
return self.coupling(coupling_match.group(1), coupling_match.group(2))
|
|
881
|
+
|
|
882
|
+
# ── Dead / uncalled ──────────────────────────────────────────
|
|
883
|
+
if "uncalled" in t or "never called" in t or ("dead" in t and "code" in t):
|
|
884
|
+
results = self.query("dead functions")
|
|
885
|
+
return f"Found {len(results)} dead/uncalled functions. Highlighted in view."
|
|
886
|
+
|
|
887
|
+
# ── Tracing ──────────────────────────────────────────────────
|
|
888
|
+
if "full path" in t or "trace" in t:
|
|
889
|
+
words = text.split()
|
|
890
|
+
# trace function X
|
|
891
|
+
for i, w in enumerate(words):
|
|
892
|
+
if w.lower() == "function" and i + 1 < len(words):
|
|
893
|
+
return self.trace_function(words[i + 1].strip("'\""))
|
|
894
|
+
# trace call chain from X to Y
|
|
895
|
+
chain_match = re.search(r'call\s+chain\s+(?:from\s+)?["\']?(\S+?)["\']?\s+(?:to)\s+["\']?(\S+)["\']?', t)
|
|
896
|
+
if chain_match:
|
|
897
|
+
return self.trace_call_chain(chain_match.group(1), chain_match.group(2))
|
|
898
|
+
# trace variable X
|
|
899
|
+
for i, w in enumerate(words):
|
|
900
|
+
if w.lower() in ("variable", "var"):
|
|
901
|
+
if i + 1 < len(words):
|
|
902
|
+
return self.trace_variable(words[i + 1].strip("'\""))
|
|
903
|
+
match = re.search(r"['\"](\w+)['\"]", text)
|
|
904
|
+
if match:
|
|
905
|
+
return self.trace_variable(match.group(1))
|
|
906
|
+
return "Could not determine what to trace. Use: view.trace_variable('name'), view.trace_function('name'), or view.trace_call_chain('from', 'to')"
|
|
907
|
+
|
|
908
|
+
# ── Zoom ─────────────────────────────────────────────────────
|
|
909
|
+
if "zoom" in t:
|
|
910
|
+
for level in ("module", "class", "function"):
|
|
911
|
+
if level in t:
|
|
912
|
+
return self.zoom(level)
|
|
913
|
+
|
|
914
|
+
# ── Focus ────────────────────────────────────────────────────
|
|
915
|
+
if "focus" in t:
|
|
916
|
+
match = re.search(r"focus\s+(?:on\s+)?(\S+)", t)
|
|
917
|
+
if match:
|
|
918
|
+
return self.focus(match.group(1))
|
|
919
|
+
|
|
920
|
+
# ── Return type search ───────────────────────────────────────
|
|
921
|
+
if "return" in t:
|
|
922
|
+
match = re.search(r"return(?:s|ing)?\s+(.+)", t)
|
|
923
|
+
if match:
|
|
924
|
+
results = self.query(f"functions returning {match.group(1)}")
|
|
925
|
+
return f"Found {len(results)} functions. Highlighted."
|
|
926
|
+
|
|
927
|
+
# ── Reset ────────────────────────────────────────────────────
|
|
928
|
+
if "reset" in t or "clear" in t:
|
|
929
|
+
return self.reset_filter()
|
|
930
|
+
|
|
931
|
+
# ── Fallback: treat as search ────────────────────────────────
|
|
932
|
+
results = self.query(text)
|
|
933
|
+
return f"Search for '{text}': {len(results)} results. Highlighted in view."
|
|
934
|
+
|
|
935
|
+
# ── Snapshot ─────────────────────────────────────────────────────
|
|
936
|
+
|
|
937
|
+
def snapshot(self) -> dict:
|
|
938
|
+
"""Get the current graph snapshot as a dict (for JSON serialization)."""
|
|
939
|
+
return self.graph.snapshot(self.state).model_dump()
|
|
940
|
+
|
|
941
|
+
# ── Stats ────────────────────────────────────────────────────────
|
|
942
|
+
|
|
943
|
+
def stats(self) -> dict:
|
|
944
|
+
"""Return summary statistics about the graph."""
|
|
945
|
+
all_nodes = self.graph.all_nodes(include_proposed=False)
|
|
946
|
+
all_edges = self.graph.all_edges(include_proposed=False)
|
|
947
|
+
node_ids = {n.id for n in all_nodes}
|
|
948
|
+
|
|
949
|
+
# Count external calls (CALLS edges to non-project targets)
|
|
950
|
+
ext_calls = sum(
|
|
951
|
+
1 for e in all_edges
|
|
952
|
+
if e.edge_type == EdgeType.CALLS and e.target not in node_ids
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
return {
|
|
956
|
+
"total_nodes": len(all_nodes),
|
|
957
|
+
"modules": len([n for n in all_nodes if n.symbol_type == SymbolType.MODULE]),
|
|
958
|
+
"classes": len([n for n in all_nodes if n.symbol_type == SymbolType.CLASS]),
|
|
959
|
+
"functions": len([n for n in all_nodes if n.symbol_type == SymbolType.FUNCTION]),
|
|
960
|
+
"methods": len([n for n in all_nodes if n.symbol_type == SymbolType.METHOD]),
|
|
961
|
+
"variables": len([n for n in all_nodes if n.symbol_type == SymbolType.VARIABLE]),
|
|
962
|
+
"parameters": len([n for n in all_nodes if n.symbol_type == SymbolType.PARAMETER]),
|
|
963
|
+
"dead_nodes": len([n for n in all_nodes if n.is_dead]),
|
|
964
|
+
"total_edges": self.graph.edge_count,
|
|
965
|
+
"external_calls": ext_calls,
|
|
966
|
+
}
|