interlinked-mapper 0.1.8__tar.gz → 0.2.0__tar.gz

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.
Files changed (26) hide show
  1. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/PKG-INFO +1 -1
  2. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/commander/llm.py +17 -0
  3. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/commander/query.py +182 -0
  4. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/mcp_server.py +54 -1
  5. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/visualizer/server.py +36 -49
  6. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked_mapper.egg-info/PKG-INFO +1 -1
  7. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/pyproject.toml +1 -1
  8. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/__init__.py +0 -0
  9. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/analyzer/__init__.py +0 -0
  10. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/analyzer/dead_code.py +0 -0
  11. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/analyzer/graph.py +0 -0
  12. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/analyzer/parser.py +0 -0
  13. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/analyzer/similarity.py +0 -0
  14. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/cli.py +0 -0
  15. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/commander/__init__.py +0 -0
  16. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/commander/repl.py +0 -0
  17. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/models.py +0 -0
  18. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/visualizer/__init__.py +0 -0
  19. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/visualizer/frontend/index.html +0 -0
  20. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked/visualizer/layouts.py +0 -0
  21. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked_mapper.egg-info/SOURCES.txt +0 -0
  22. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked_mapper.egg-info/dependency_links.txt +0 -0
  23. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked_mapper.egg-info/entry_points.txt +0 -0
  24. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked_mapper.egg-info/requires.txt +0 -0
  25. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/interlinked_mapper.egg-info/top_level.txt +0 -0
  26. {interlinked_mapper-0.1.8 → interlinked_mapper-0.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: interlinked-mapper
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: A Python program topology explorer — visualize the shape of your codebase
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/austerecryptid/interlinked
@@ -62,12 +62,16 @@ You control what they see by emitting Python commands against the `view` object.
62
62
 
63
63
  ### Queries
64
64
  - `view.query("dead functions")` — Find dead/uncalled code (highlights results)
65
+ - `view.query("dead functions in <scope>")` — Dead functions scoped to a module prefix, e.g. `"dead functions in engine.rules.ir_exec"`
66
+ - `view.query("dead classes in <scope>")` — Dead classes in a scope
65
67
  - `view.query("callers of <name>")` — Who calls this?
66
68
  - `view.query("callees of <name>")` — What does this call?
67
69
  - `view.query("parameters of <name>")` — Show function parameters
68
70
  - `view.query("returns of <name>")` — Show what a function returns (project symbols)
69
71
  - `view.query("external calls in <name>")` — Show external library calls from a module/class/function
70
72
  - `view.query("external calls")` — All external calls in the project
73
+ - `view.query("functions in <scope>")` — List all functions/methods under a module prefix, e.g. `"functions in engine.rules"`
74
+ - `view.query("classes in <scope>")` — List all classes under a module prefix
71
75
  - `view.query("modules")` / `view.query("classes")` / `view.query("functions")` / `view.query("parameters")` / `view.query("variables")`
72
76
  - `view.query("<search_term>")` — Fuzzy name search
73
77
 
@@ -82,6 +86,19 @@ You control what they see by emitting Python commands against the `view` object.
82
86
  - `view.path_between(source, target)` — Shortest dependency chain between two symbols.
83
87
  - `view.all_paths(source, target, max_depth=8)` — Every route between two symbols.
84
88
 
89
+ ### Cross-Module Edge Enumeration
90
+ - `view.edges_between(source_scope, target_scope=None, edge_types=None)` — List all edges leaving a module scope, grouped by target module. Essential for module isolation checks.
91
+ - `view.edges_between("engine.rules.resolver")` — ALL outgoing edges from resolver
92
+ - `view.edges_between("engine.rules.resolver", target_scope="engine.systems")` — Only edges going to engine.systems
93
+ - `view.edges_between("engine.rules", edge_types=["imports"])` — Only import edges
94
+ - `view.edges_between("engine.rules", edge_types=["calls", "imports"])` — Calls + imports
95
+
96
+ ### Reachability (Purity / Isolation Checks)
97
+ - `view.reachable(source, target, edge_types=None, max_depth=20)` — Check if target is reachable from source via specific edge types. Default: follows only call edges.
98
+ - `view.reachable("resolve_effects", "world.set_component")` — Is there ANY call path? Returns path if yes.
99
+ - `view.reachable("resolver.resolve", "db.commit", edge_types=["calls", "imports"])` — Check calls + imports
100
+ - Returns JSON: `{{"reachable": true/false, "path": [...], "short_path": "A → B → C"}}`
101
+
85
102
  ### Architecture Health
86
103
  - `view.find_cycles()` — Find and highlight circular dependencies (calls/imports).
87
104
  - `view.critical_nodes(top_n=20)` — Most important symbols by PageRank.
@@ -305,9 +305,26 @@ class QueryEngine:
305
305
  results = self.graph.functions_returning(type_hint)
306
306
 
307
307
  elif "dead" in expr or "uncalled" in expr:
308
+ # Support scoped dead queries: "dead functions in engine.rules"
309
+ scope = None
310
+ scope_match = re.search(r'(?:dead|uncalled)\s+\w+\s+in\s+(\S+)', expression, re.IGNORECASE)
311
+ if scope_match:
312
+ scope = scope_match.group(1)
313
+ # Support type filter: "dead functions" vs "dead classes" vs just "dead"
314
+ type_filter: set[SymbolType] | None = None
315
+ if "function" in expr or "method" in expr:
316
+ type_filter = {SymbolType.FUNCTION, SymbolType.METHOD}
317
+ elif "class" in expr:
318
+ type_filter = {SymbolType.CLASS}
319
+ elif "module" in expr:
320
+ type_filter = {SymbolType.MODULE}
321
+ elif "variable" in expr:
322
+ type_filter = {SymbolType.VARIABLE}
308
323
  results = [
309
324
  n for n in self.graph.all_nodes()
310
325
  if n.is_dead
326
+ and (type_filter is None or n.symbol_type in type_filter)
327
+ and (scope is None or n.qualified_name.startswith(scope))
311
328
  ]
312
329
 
313
330
  elif expr.startswith("imports of"):
@@ -316,22 +333,34 @@ class QueryEngine:
316
333
  return [e.model_dump() for e in edges]
317
334
 
318
335
  elif expr.startswith("modules"):
336
+ scope = self._parse_scope(expression, "modules")
319
337
  results = self.graph.nodes_by_type(SymbolType.MODULE)
338
+ if scope:
339
+ results = [n for n in results if n.qualified_name.startswith(scope)]
320
340
 
321
341
  elif expr.startswith("classes"):
342
+ scope = self._parse_scope(expression, "classes")
322
343
  results = self.graph.nodes_by_type(SymbolType.CLASS)
344
+ if scope:
345
+ results = [n for n in results if n.qualified_name.startswith(scope)]
323
346
 
324
347
  elif expr.startswith("functions") or expr.startswith("methods"):
348
+ scope = self._parse_scope(expression, "functions") or self._parse_scope(expression, "methods")
325
349
  results = (
326
350
  self.graph.nodes_by_type(SymbolType.FUNCTION)
327
351
  + self.graph.nodes_by_type(SymbolType.METHOD)
328
352
  )
353
+ if scope:
354
+ results = [n for n in results if n.qualified_name.startswith(scope)]
329
355
 
330
356
  elif expr.startswith("parameters") or expr.startswith("variables"):
357
+ scope = self._parse_scope(expression, "parameters") or self._parse_scope(expression, "variables")
331
358
  results = [
332
359
  n for n in self.graph.all_nodes()
333
360
  if n.symbol_type == (SymbolType.PARAMETER if "param" in expr else SymbolType.VARIABLE)
334
361
  ]
362
+ if scope:
363
+ results = [n for n in results if n.qualified_name.startswith(scope)]
335
364
 
336
365
  else:
337
366
  # Fuzzy name search
@@ -652,6 +681,11 @@ class QueryEngine:
652
681
 
653
682
  # ── Helper ───────────────────────────────────────────────────────
654
683
 
684
+ def _parse_scope(self, expression: str, keyword: str) -> str | None:
685
+ """Extract scope from 'keyword in scope' pattern, e.g. 'functions in engine.rules'."""
686
+ m = re.search(rf'{keyword}\s+in\s+(\S+)', expression, re.IGNORECASE)
687
+ return m.group(1) if m else None
688
+
655
689
  def _resolve_node(self, name: str) -> NodeData | None:
656
690
  """Resolve a name to a node, with fuzzy matching."""
657
691
  node = self.graph.get_node(name)
@@ -776,6 +810,154 @@ class QueryEngine:
776
810
  context = get_rich_context(self.graph, node)
777
811
  return json.dumps(context, indent=2, default=str)
778
812
 
813
+ # ── Cross-module edge enumeration ────────────────────────────────
814
+
815
+ def edges_between(
816
+ self,
817
+ source_scope: str,
818
+ target_scope: str | None = None,
819
+ edge_types: list[str] | None = None,
820
+ ) -> str:
821
+ """List all edges from one module scope to another (or to everything external).
822
+
823
+ Args:
824
+ source_scope: Qualified name prefix of the source module, e.g. "engine.rules.resolver".
825
+ target_scope: Optional target prefix. If omitted, shows all edges leaving source_scope.
826
+ edge_types: Optional filter, e.g. ["calls", "imports"]. Default: all types.
827
+
828
+ Returns JSON with edges grouped by target module.
829
+ """
830
+ et_filter = None
831
+ if edge_types:
832
+ et_filter = set()
833
+ for et_str in edge_types:
834
+ try:
835
+ et_filter.add(EdgeType(et_str))
836
+ except ValueError:
837
+ pass
838
+
839
+ all_edges = self.graph.all_edges(include_proposed=False)
840
+ source_nodes = {n.id for n in self.graph.all_nodes() if n.qualified_name.startswith(source_scope)}
841
+
842
+ edges_out: list[dict] = []
843
+ for e in all_edges:
844
+ if e.source not in source_nodes:
845
+ continue
846
+ if et_filter and e.edge_type not in et_filter:
847
+ continue
848
+ # Skip edges within the source scope (internal)
849
+ target_node = self.graph.get_node(e.target)
850
+ target_qname = target_node.qualified_name if target_node else e.target
851
+ if target_qname.startswith(source_scope):
852
+ continue
853
+ # If target_scope specified, filter to it
854
+ if target_scope and not target_qname.startswith(target_scope):
855
+ continue
856
+ edges_out.append({
857
+ "source": e.source,
858
+ "target": e.target,
859
+ "edge_type": e.edge_type.value,
860
+ "line": e.line,
861
+ })
862
+
863
+ # Group by target module (first two dotted components of target)
864
+ by_module: dict[str, list[dict]] = {}
865
+ for edge in edges_out:
866
+ tgt = edge["target"]
867
+ parts = tgt.split(".")
868
+ mod = ".".join(parts[:2]) if len(parts) > 1 else parts[0]
869
+ by_module.setdefault(mod, []).append(edge)
870
+
871
+ result = {
872
+ "source_scope": source_scope,
873
+ "target_scope": target_scope or "(all external)",
874
+ "total_edges": len(edges_out),
875
+ "by_target_module": {
876
+ mod: {"count": len(edges), "edges": edges}
877
+ for mod, edges in sorted(by_module.items(), key=lambda x: -len(x[1]))
878
+ },
879
+ }
880
+
881
+ # Highlight source nodes in view
882
+ self.state.highlighted_node_ids = list({e["source"] for e in edges_out})
883
+ self._notify()
884
+
885
+ return json.dumps(result, indent=2)
886
+
887
+ # ── Reachability ─────────────────────────────────────────────────
888
+
889
+ def reachable(
890
+ self,
891
+ source: str,
892
+ target: str,
893
+ edge_types: list[str] | None = None,
894
+ max_depth: int = 20,
895
+ ) -> str:
896
+ """Check if target is reachable from source via specific edge types.
897
+
898
+ By default follows only 'calls' edges — use for purity/isolation checks.
899
+ Returns the shortest path if reachable, or a clear "not reachable" message.
900
+
901
+ Args:
902
+ source: Qualified name of the source symbol.
903
+ target: Qualified name (or partial) of the target symbol.
904
+ edge_types: Edge types to traverse (default: ["calls"]).
905
+ max_depth: Maximum path length to search.
906
+ """
907
+ import networkx as nx
908
+
909
+ src_node = self._resolve_node(source)
910
+ tgt_node = self._resolve_node(target)
911
+ if not src_node:
912
+ return json.dumps({"reachable": False, "error": f"Source '{source}' not found."})
913
+ if not tgt_node:
914
+ return json.dumps({"reachable": False, "error": f"Target '{target}' not found."})
915
+
916
+ # Build edge-type-filtered subgraph
917
+ et_filter = set()
918
+ for et_str in (edge_types or ["calls"]):
919
+ try:
920
+ et_filter.add(EdgeType(et_str).value)
921
+ except ValueError:
922
+ pass
923
+ if not et_filter:
924
+ et_filter = {"calls"}
925
+
926
+ sub_edges = [
927
+ (u, v) for u, v, d in self.graph._g.edges(data=True)
928
+ if d.get("edge_type") in et_filter
929
+ ]
930
+ sub_g = nx.DiGraph()
931
+ sub_g.add_edges_from(sub_edges)
932
+
933
+ try:
934
+ path = nx.shortest_path(sub_g, src_node.id, tgt_node.id)
935
+ if len(path) - 1 > max_depth:
936
+ return json.dumps({"reachable": False, "reason": f"Path exists but exceeds max_depth={max_depth}."})
937
+
938
+ # Highlight the path
939
+ self.state.highlighted_node_ids = list(path)
940
+ self.state.trace_node_roles = {path[0]: "origin", path[-1]: "destination"}
941
+ for nid in path[1:-1]:
942
+ self.state.trace_node_roles[nid] = "passthrough"
943
+ self.state.trace_edge_roles = {}
944
+ self._notify()
945
+
946
+ short_path = " → ".join(n.split(".")[-1] for n in path)
947
+ return json.dumps({
948
+ "reachable": True,
949
+ "path_length": len(path) - 1,
950
+ "path": list(path),
951
+ "short_path": short_path,
952
+ }, indent=2)
953
+ except (nx.NetworkXNoPath, nx.NodeNotFound):
954
+ return json.dumps({
955
+ "reachable": False,
956
+ "source": src_node.qualified_name,
957
+ "target": tgt_node.qualified_name,
958
+ "edge_types": list(et_filter),
959
+ })
960
+
779
961
  # ── Display settings ─────────────────────────────────────────────
780
962
 
781
963
  @property
@@ -141,7 +141,7 @@ def create_mcp_server(project_path: str) -> Server:
141
141
  ),
142
142
  Tool(
143
143
  name="interlinked_query",
144
- description="Run a structured query against the codebase graph. Supports: 'dead functions', 'callers of X', 'callees of X', 'modules', 'classes', 'functions', or any search term for fuzzy name matching.",
144
+ description="Run a structured query against the codebase graph. Supports: 'callers of X', 'callees of X', 'dead functions', 'dead functions in <scope>', 'functions in <scope>', 'classes in <scope>', 'modules in <scope>', 'external calls in <scope>', 'imports of X', 'functions returning <type>', or any search term for fuzzy name matching. The 'in <scope>' suffix filters by qualified name prefix.",
145
145
  inputSchema={
146
146
  "type": "object",
147
147
  "properties": {
@@ -233,6 +233,33 @@ def create_mcp_server(project_path: str) -> Server:
233
233
  "required": ["path"],
234
234
  },
235
235
  ),
236
+ Tool(
237
+ name="interlinked_edges_between",
238
+ description="List all edges (calls, imports, etc.) from one module scope to another, or to everything outside the scope. Returns edges grouped by target module. Essential for module isolation checks.",
239
+ inputSchema={
240
+ "type": "object",
241
+ "properties": {
242
+ "source_scope": {"type": "string", "description": "Qualified name prefix of source, e.g. 'engine.rules.resolver'"},
243
+ "target_scope": {"type": "string", "description": "Optional: qualified name prefix of target to filter to, e.g. 'engine.systems'. Omit to see ALL outgoing edges."},
244
+ "edge_types": {"type": "array", "items": {"type": "string"}, "description": "Optional: edge types to include (calls, imports, inherits, reads, writes). Default: all."},
245
+ },
246
+ "required": ["source_scope"],
247
+ },
248
+ ),
249
+ Tool(
250
+ name="interlinked_reachable",
251
+ description="Check if there is any path from source to target following specific edge types (default: calls only). Use for purity contracts and isolation verification. Returns the shortest path if reachable.",
252
+ inputSchema={
253
+ "type": "object",
254
+ "properties": {
255
+ "source": {"type": "string", "description": "Qualified name of the source symbol"},
256
+ "target": {"type": "string", "description": "Qualified name (or partial) of the target symbol"},
257
+ "edge_types": {"type": "array", "items": {"type": "string"}, "description": "Edge types to traverse (default: ['calls'])"},
258
+ "max_depth": {"type": "integer", "description": "Maximum path length (default: 20)", "default": 20},
259
+ },
260
+ "required": ["source", "target"],
261
+ },
262
+ ),
236
263
  Tool(
237
264
  name="interlinked_reset",
238
265
  description="Reset all filters, focus, and highlights back to the default full-graph view.",
@@ -320,6 +347,17 @@ def _dispatch_via_server(name: str, args: dict[str, Any], server_url: str) -> st
320
347
  "interlinked_get_context": ("POST", "/api/get_context", {"target": args.get("target", "")}),
321
348
  "interlinked_command": ("POST", "/api/command", {"command": args.get("command", "")}),
322
349
  "interlinked_switch_project": ("POST", "/api/switch_project", {"path": args.get("path", "")}),
350
+ "interlinked_edges_between": ("POST", "/api/edges_between", {
351
+ "source_scope": args.get("source_scope", ""),
352
+ "target_scope": args.get("target_scope"),
353
+ "edge_types": args.get("edge_types"),
354
+ }),
355
+ "interlinked_reachable": ("POST", "/api/reachable", {
356
+ "source": args.get("source", ""),
357
+ "target": args.get("target", ""),
358
+ "edge_types": args.get("edge_types"),
359
+ "max_depth": args.get("max_depth", 20),
360
+ }),
323
361
  "interlinked_reset": ("POST", "/api/reset", {}),
324
362
  }
325
363
 
@@ -424,6 +462,21 @@ def _dispatch_tool(
424
462
  engine.reset_filter()
425
463
  return json.dumps(result, indent=2)
426
464
 
465
+ elif name == "interlinked_edges_between":
466
+ return engine.edges_between(
467
+ source_scope=args["source_scope"],
468
+ target_scope=args.get("target_scope"),
469
+ edge_types=args.get("edge_types"),
470
+ )
471
+
472
+ elif name == "interlinked_reachable":
473
+ return engine.reachable(
474
+ source=args["source"],
475
+ target=args["target"],
476
+ edge_types=args.get("edge_types"),
477
+ max_depth=args.get("max_depth", 20),
478
+ )
479
+
427
480
  elif name == "interlinked_reset":
428
481
  return engine.reset_filter()
429
482
 
@@ -16,7 +16,7 @@ from interlinked.analyzer.graph import CodeGraph
16
16
  from interlinked.commander.query import QueryEngine
17
17
  from interlinked.commander.llm import LLMAdapter, get_system_prompt
18
18
  from interlinked.visualizer.layouts import compute_layout
19
- from interlinked.models import ViewState
19
+ from interlinked.models import ViewState, NodeData, EdgeData
20
20
 
21
21
  FRONTEND_DIR = Path(__file__).parent / "frontend" / "dist"
22
22
 
@@ -59,19 +59,19 @@ def create_app(graph: CodeGraph, initial_path: str | None = None) -> FastAPI:
59
59
 
60
60
  # ── Broadcast view changes to all SSE clients ────────────────
61
61
 
62
+ def _layout_from_snap(snap: dict) -> dict:
63
+ """Compute layout from the snapshot's visible nodes/edges only."""
64
+ snap_nodes = [NodeData(**n) for n in snap.get("nodes", [])]
65
+ snap_edges = [EdgeData(**e) for e in snap.get("edges", [])]
66
+ return compute_layout(snap_nodes, snap_edges)
67
+
62
68
  def _snapshot_with_layout() -> dict:
63
69
  snap = engine.snapshot()
64
- snap["layout"] = compute_layout(
65
- [n for n in graph.all_nodes()],
66
- graph.all_edges(),
67
- )
70
+ snap["layout"] = _layout_from_snap(snap)
68
71
  return snap
69
72
 
70
73
  def on_view_change(snapshot: dict) -> None:
71
- snapshot["layout"] = compute_layout(
72
- [n for n in graph.all_nodes()],
73
- graph.all_edges(),
74
- )
74
+ snapshot["layout"] = _layout_from_snap(snapshot)
75
75
  msg = json.dumps({"type": "snapshot", "data": snapshot})
76
76
  dead: list[asyncio.Queue] = []
77
77
  for q in sse_queues:
@@ -115,11 +115,7 @@ def create_app(graph: CodeGraph, initial_path: str | None = None) -> FastAPI:
115
115
  @app.get("/api/snapshot")
116
116
  async def get_snapshot() -> JSONResponse:
117
117
  snap = engine.snapshot()
118
- layout = compute_layout(
119
- [n for n in graph.all_nodes()],
120
- graph.all_edges(),
121
- )
122
- snap["layout"] = layout
118
+ snap["layout"] = _layout_from_snap(snap)
123
119
  return JSONResponse(content=snap)
124
120
 
125
121
  @app.get("/api/stats")
@@ -168,11 +164,7 @@ def create_app(graph: CodeGraph, initial_path: str | None = None) -> FastAPI:
168
164
  return JSONResponse({"error": "No text provided"}, status_code=400)
169
165
  result = engine.nl(text)
170
166
  snap = engine.snapshot()
171
- layout = compute_layout(
172
- [n for n in graph.all_nodes()],
173
- graph.all_edges(),
174
- )
175
- snap["layout"] = layout
167
+ snap["layout"] = _layout_from_snap(snap)
176
168
  return JSONResponse({"result": result, "snapshot": snap})
177
169
 
178
170
  @app.post("/api/zoom")
@@ -199,11 +191,7 @@ def create_app(graph: CodeGraph, initial_path: str | None = None) -> FastAPI:
199
191
  expr = body.get("expression", "")
200
192
  results = engine.query(expr)
201
193
  snap = engine.snapshot()
202
- layout = compute_layout(
203
- [n for n in graph.all_nodes()],
204
- graph.all_edges(),
205
- )
206
- snap["layout"] = layout
194
+ snap["layout"] = _layout_from_snap(snap)
207
195
  return JSONResponse({"results": results, "snapshot": snap})
208
196
 
209
197
  @app.post("/api/propose")
@@ -231,11 +219,7 @@ def create_app(graph: CodeGraph, initial_path: str | None = None) -> FastAPI:
231
219
  edge_types = body.get("edge_types")
232
220
  result = engine.isolate(target, level=level, depth=depth, edge_types=edge_types)
233
221
  snap = engine.snapshot()
234
- layout = compute_layout(
235
- [n for n in graph.all_nodes()],
236
- graph.all_edges(),
237
- )
238
- snap["layout"] = layout
222
+ snap["layout"] = _layout_from_snap(snap)
239
223
  return JSONResponse({"result": result, "snapshot": snap})
240
224
 
241
225
  @app.post("/api/find_duplicates")
@@ -244,11 +228,7 @@ def create_app(graph: CodeGraph, initial_path: str | None = None) -> FastAPI:
244
228
  scope = body.get("scope")
245
229
  result = engine.find_duplicates(threshold=threshold, scope=scope)
246
230
  snap = engine.snapshot()
247
- layout = compute_layout(
248
- [n for n in graph.all_nodes()],
249
- graph.all_edges(),
250
- )
251
- snap["layout"] = layout
231
+ snap["layout"] = _layout_from_snap(snap)
252
232
  return JSONResponse({"result": result, "snapshot": snap})
253
233
 
254
234
  @app.post("/api/similar_to")
@@ -257,11 +237,7 @@ def create_app(graph: CodeGraph, initial_path: str | None = None) -> FastAPI:
257
237
  threshold = body.get("threshold", 0.5)
258
238
  result = engine.similar_to(target, threshold=threshold)
259
239
  snap = engine.snapshot()
260
- layout = compute_layout(
261
- [n for n in graph.all_nodes()],
262
- graph.all_edges(),
263
- )
264
- snap["layout"] = layout
240
+ snap["layout"] = _layout_from_snap(snap)
265
241
  return JSONResponse({"result": result, "snapshot": snap})
266
242
 
267
243
  @app.post("/api/get_context")
@@ -275,17 +251,32 @@ def create_app(graph: CodeGraph, initial_path: str | None = None) -> FastAPI:
275
251
  result = engine.reset_filter()
276
252
  return JSONResponse({"result": result})
277
253
 
254
+ @app.post("/api/edges_between")
255
+ async def edges_between(body: dict) -> JSONResponse:
256
+ result = engine.edges_between(
257
+ source_scope=body.get("source_scope", ""),
258
+ target_scope=body.get("target_scope"),
259
+ edge_types=body.get("edge_types"),
260
+ )
261
+ return JSONResponse(content=json.loads(result))
262
+
263
+ @app.post("/api/reachable")
264
+ async def reachable(body: dict) -> JSONResponse:
265
+ result = engine.reachable(
266
+ source=body.get("source", ""),
267
+ target=body.get("target", ""),
268
+ edge_types=body.get("edge_types"),
269
+ max_depth=body.get("max_depth", 20),
270
+ )
271
+ return JSONResponse(content=json.loads(result))
272
+
278
273
  @app.post("/api/trace_variable")
279
274
  async def trace_variable(body: dict) -> JSONResponse:
280
275
  var_name = body.get("variable", "")
281
276
  origin = body.get("origin")
282
277
  result = engine.trace_variable(var_name, origin)
283
278
  snap = engine.snapshot()
284
- layout = compute_layout(
285
- [n for n in graph.all_nodes()],
286
- graph.all_edges(),
287
- )
288
- snap["layout"] = layout
279
+ snap["layout"] = _layout_from_snap(snap)
289
280
  return JSONResponse({"result": result, "snapshot": snap})
290
281
 
291
282
  # ── LLM chat + settings ──────────────────────────────────────
@@ -333,11 +324,7 @@ def create_app(graph: CodeGraph, initial_path: str | None = None) -> FastAPI:
333
324
 
334
325
  # Always return fresh snapshot after commands have executed
335
326
  snap = engine.snapshot()
336
- layout = compute_layout(
337
- [n for n in graph.all_nodes()],
338
- graph.all_edges(),
339
- )
340
- snap["layout"] = layout
327
+ snap["layout"] = _layout_from_snap(snap)
341
328
  result["snapshot"] = snap
342
329
 
343
330
  return JSONResponse(result)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: interlinked-mapper
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: A Python program topology explorer — visualize the shape of your codebase
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/austerecryptid/interlinked
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "interlinked-mapper"
7
- version = "0.1.8"
7
+ version = "0.2.0"
8
8
  description = "A Python program topology explorer — visualize the shape of your codebase"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}