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.
@@ -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
+ }