interlinked-mapper 0.3.4__tar.gz → 0.3.6__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 (45) hide show
  1. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/PKG-INFO +1 -1
  2. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/analyzer/parser.py +15 -1
  3. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/mcp_server.py +178 -49
  4. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked_mapper.egg-info/PKG-INFO +1 -1
  5. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/pyproject.toml +1 -1
  6. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/__init__.py +0 -0
  7. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/analyzer/__init__.py +0 -0
  8. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/analyzer/dead_code.py +0 -0
  9. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/analyzer/embeddings.py +0 -0
  10. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/analyzer/graph.py +0 -0
  11. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/analyzer/similarity.py +0 -0
  12. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/cli.py +0 -0
  13. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/commander/__init__.py +0 -0
  14. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/commander/llm.py +0 -0
  15. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/commander/query.py +0 -0
  16. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/commander/repl.py +0 -0
  17. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/models.py +0 -0
  18. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/__init__.py +0 -0
  19. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/dist/assets/index-CyhrxsQU.css +0 -0
  20. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/dist/assets/index-Dh01aXoE.js +0 -0
  21. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/dist/index.html +0 -0
  22. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/index.html +0 -0
  23. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/index.html.d3-legacy +0 -0
  24. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/package-lock.json +0 -0
  25. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/package.json +0 -0
  26. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/src/App.tsx +0 -0
  27. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/src/graph/GraphCanvas.tsx +0 -0
  28. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/src/graph/nodePrograms.ts +0 -0
  29. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/src/index.css +0 -0
  30. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/src/main.tsx +0 -0
  31. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/src/state/graphStore.ts +0 -0
  32. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/src/state/sseClient.ts +0 -0
  33. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/src/theme.ts +0 -0
  34. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/src/types.ts +0 -0
  35. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/src/vite-env.d.ts +0 -0
  36. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/tsconfig.json +0 -0
  37. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/frontend/vite.config.ts +0 -0
  38. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/layouts.py +0 -0
  39. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked/visualizer/server.py +0 -0
  40. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked_mapper.egg-info/SOURCES.txt +0 -0
  41. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked_mapper.egg-info/dependency_links.txt +0 -0
  42. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked_mapper.egg-info/entry_points.txt +0 -0
  43. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked_mapper.egg-info/requires.txt +0 -0
  44. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/interlinked_mapper.egg-info/top_level.txt +0 -0
  45. {interlinked_mapper-0.3.4 → interlinked_mapper-0.3.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: interlinked-mapper
3
- Version: 0.3.4
3
+ Version: 0.3.6
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
@@ -187,7 +187,21 @@ def parse_project(root: str | Path) -> tuple[list[NodeData], list[EdgeData]]:
187
187
  nodes: list[NodeData] = []
188
188
  edges: list[EdgeData] = []
189
189
 
190
- py_files = sorted(root.rglob("*.py"))
190
+ # Skip directories that contain third-party or non-project Python files.
191
+ # External references are resolved via import/AST analysis, not by parsing venv.
192
+ _SKIP_DIRS = frozenset({
193
+ ".venv", "venv", "env", ".env",
194
+ "node_modules",
195
+ ".git",
196
+ "__pycache__",
197
+ ".tox", ".nox", ".eggs",
198
+ ".mypy_cache", ".ruff_cache", ".pytest_cache",
199
+ "build", "dist", ".build",
200
+ })
201
+ py_files = sorted(
202
+ p for p in root.rglob("*.py")
203
+ if not any(part in _SKIP_DIRS for part in p.relative_to(root).parts)
204
+ )
191
205
 
192
206
  # Pass 1: extract all symbols and raw (unresolved) edges
193
207
  trees: list[tuple[ast.Module, str, str]] = []
@@ -41,47 +41,46 @@ from interlinked.analyzer.dead_code import detect_dead_code
41
41
  from interlinked.commander.query import QueryEngine
42
42
 
43
43
 
44
+ def _deferred_background_work(graph: CodeGraph, engine: QueryEngine, project_path: str) -> None:
45
+ """Run similarity + embeddings in a SINGLE background thread (non-blocking).
46
+
47
+ Mirrors the REST API pattern: never spawn competing CPU threads that
48
+ fight over the GIL with the main event loop. Everything heavy runs
49
+ sequentially in one thread.
50
+ """
51
+ import threading
52
+
53
+ def _work():
54
+ # 1. Similarity fingerprinting (CPU-bound)
55
+ try:
56
+ from interlinked.analyzer.similarity import analyze_similarity
57
+ analyze_similarity(graph)
58
+ except Exception:
59
+ pass
60
+
61
+ # 2. Embedding build (spawns its own single thread internally via build_async)
62
+ try:
63
+ from interlinked.visualizer.server import _start_embedding_build
64
+ _start_embedding_build(graph, project_path, engine)
65
+ except Exception:
66
+ pass
67
+
68
+ threading.Thread(target=_work, daemon=True, name="mcp-background").start()
69
+
70
+
44
71
  def build_graph(project_path: str) -> tuple[CodeGraph, QueryEngine]:
45
- """Parse a project and build the graph + query engine."""
72
+ """Parse a project and build the graph + query engine.
73
+
74
+ Returns immediately after parsing + dead code detection.
75
+ Similarity and embeddings are NOT started here — they are deferred
76
+ to avoid GIL contention with the caller.
77
+ """
46
78
  nodes, edges = parse_project(project_path)
47
79
  graph = CodeGraph()
48
80
  graph.build_from(nodes, edges)
49
81
  detect_dead_code(graph)
50
82
 
51
- # Run similarity analysis if available
52
- try:
53
- from interlinked.analyzer.similarity import analyze_similarity
54
- analyze_similarity(graph)
55
- except Exception:
56
- pass
57
-
58
83
  engine = QueryEngine(graph)
59
-
60
- # Start embedding build in background (if torch/transformers installed)
61
- try:
62
- from interlinked.analyzer.embeddings import EmbeddingIndex, is_available
63
- if is_available():
64
- from pathlib import Path
65
- emb_index = EmbeddingIndex(project_path)
66
- engine._embedding_index = emb_index
67
- # Collect function sources
68
- from interlinked.models import SymbolType
69
- functions = []
70
- for node in graph.all_nodes(include_proposed=False):
71
- if node.symbol_type not in (SymbolType.FUNCTION, SymbolType.METHOD):
72
- continue
73
- if node.file_path and node.line_start and node.line_end:
74
- try:
75
- lines = Path(node.file_path).read_text(encoding="utf-8", errors="replace").splitlines()
76
- source = "\n".join(lines[max(0, node.line_start - 1):min(len(lines), node.line_end)])
77
- if source:
78
- functions.append({"id": node.id, "source": source})
79
- except Exception:
80
- pass
81
- emb_index.build_async(functions)
82
- except Exception:
83
- pass
84
-
85
84
  return graph, engine
86
85
 
87
86
 
@@ -95,24 +94,30 @@ def create_mcp_server(project_path: str) -> Server:
95
94
  # Lazy state — built on first tool call (only if no web server is running)
96
95
  _state: dict[str, Any] = {"graph": None, "engine": None, "ready": False}
97
96
 
98
- def _check_server(port: int = 8420) -> str | None:
97
+ async def _check_server(port: int = 8420) -> str | None:
99
98
  """Check if the web visualizer is running right now. Returns base URL or None."""
100
99
  url = f"http://127.0.0.1:{port}"
101
100
  try:
102
- r = httpx.get(f"{url}/api/stats", timeout=1.0)
103
- if r.status_code == 200:
104
- return url
101
+ async with httpx.AsyncClient() as client:
102
+ r = await client.get(f"{url}/api/stats", timeout=1.0)
103
+ if r.status_code == 200:
104
+ return url
105
105
  except Exception:
106
106
  pass
107
107
  return None
108
108
 
109
- def _ensure_ready() -> tuple[CodeGraph, QueryEngine]:
109
+ async def _ensure_ready() -> tuple[CodeGraph, QueryEngine]:
110
110
  if not _state["ready"]:
111
+ import asyncio
111
112
  print(f"Analyzing {project_path} ...", file=sys.stderr)
112
- graph, engine = build_graph(project_path)
113
+ loop = asyncio.get_running_loop()
114
+ graph, engine = await loop.run_in_executor(None, build_graph, project_path)
113
115
  _state["graph"] = graph
114
116
  _state["engine"] = engine
115
117
  _state["ready"] = True
118
+ # Deferred: similarity + embeddings in single background thread
119
+ # Scheduled AFTER build returns so the tool response goes out first
120
+ _deferred_background_work(graph, engine, project_path)
116
121
  return _state["graph"], _state["engine"]
117
122
 
118
123
  # Pick up API key from env if available
@@ -341,7 +346,7 @@ def create_mcp_server(project_path: str) -> Server:
341
346
  try:
342
347
  # Handle ui_status first — it doesn't need graph or server proxy
343
348
  if name == "interlinked_ui_status":
344
- server_url = _check_server()
349
+ server_url = await _check_server()
345
350
  if server_url:
346
351
  result = json.dumps({
347
352
  "running": True,
@@ -358,20 +363,49 @@ def create_mcp_server(project_path: str) -> Server:
358
363
 
359
364
  # Handle start_ui — needs to spawn the server process
360
365
  if name == "interlinked_start_ui":
366
+ import asyncio
361
367
  port = arguments.get("port", 8420)
362
- result = _start_ui(project_path, port)
368
+ loop = asyncio.get_running_loop()
369
+ result = await loop.run_in_executor(None, _start_ui, project_path, port)
363
370
  return [TextContent(type="text", text=result)]
364
371
 
365
372
  # Try proxying through the running web server first
366
- server_url = _check_server()
373
+ server_url = await _check_server()
367
374
  if server_url:
368
- result = _dispatch_via_server(name, arguments, server_url)
375
+ result = await _async_dispatch_via_server(name, arguments, server_url)
369
376
  if result is not None:
370
377
  return [TextContent(type="text", text=str(result))]
371
378
 
372
- # Fall back to direct graph mode
373
- graph, engine = _ensure_ready()
374
- result = _dispatch_tool(name, arguments, engine, graph, api_key)
379
+ # switch_project: skip _ensure_ready (would double-parse), rebuild directly
380
+ import asyncio
381
+ loop = asyncio.get_running_loop()
382
+
383
+ if name == "interlinked_switch_project":
384
+ from interlinked.visualizer.server import _rebuild_graph
385
+
386
+ def _do_switch():
387
+ g = _state.get("graph")
388
+ if g is None:
389
+ g = CodeGraph()
390
+ return _rebuild_graph(arguments["path"], g, run_similarity=False), g
391
+
392
+ result_dict, graph = await loop.run_in_executor(None, _do_switch)
393
+ engine = _state.get("engine")
394
+ if engine is None:
395
+ engine = QueryEngine(graph)
396
+ engine.reset_filter()
397
+ _state["graph"] = graph
398
+ _state["engine"] = engine
399
+ _state["ready"] = True
400
+ # Deferred: single background thread AFTER response sent
401
+ _deferred_background_work(graph, engine, arguments["path"])
402
+ return [TextContent(type="text", text=json.dumps(result_dict, indent=2))]
403
+
404
+ # All other tools: ensure graph is built first
405
+ graph, engine = await _ensure_ready()
406
+ result = await loop.run_in_executor(
407
+ None, _dispatch_tool, name, arguments, engine, graph, api_key
408
+ )
375
409
  if name == "interlinked_set_api_key":
376
410
  api_key = arguments.get("api_key", "")
377
411
  os.environ["ANTHROPIC_API_KEY"] = api_key
@@ -520,7 +554,8 @@ def _dispatch_via_server(name: str, args: dict[str, Any], server_url: str) -> st
520
554
  if method == "GET":
521
555
  r = httpx.get(url, timeout=30.0)
522
556
  else:
523
- r = httpx.post(url, json=body, timeout=30.0)
557
+ timeout = 120.0 if "switch_project" in path else 30.0
558
+ r = httpx.post(url, json=body, timeout=timeout)
524
559
  data = r.json()
525
560
 
526
561
  # Extract the most useful part of the response for the LLM
@@ -542,6 +577,97 @@ def _dispatch_via_server(name: str, args: dict[str, Any], server_url: str) -> st
542
577
  return None
543
578
 
544
579
 
580
+ async def _async_dispatch_via_server(name: str, args: dict[str, Any], server_url: str) -> str | None:
581
+ """Async proxy — uses httpx.AsyncClient so the MCP event loop isn't blocked."""
582
+ # Reuse the same endpoint mapping
583
+ _TOOL_TO_ENDPOINT: dict[str, tuple[str, str, dict]] = {
584
+ "interlinked_stats": ("GET", "/api/stats", {}),
585
+ "interlinked_isolate": ("POST", "/api/isolate", {
586
+ "target": args.get("target", ""),
587
+ "level": args.get("level", "function"),
588
+ "depth": args.get("depth", 3),
589
+ "edge_types": args.get("edge_types"),
590
+ }),
591
+ "interlinked_zoom": ("POST", "/api/zoom", {"level": args.get("level", "module")}),
592
+ "interlinked_focus": ("POST", "/api/focus", {
593
+ "node_id": args.get("node_id", ""),
594
+ "depth": args.get("depth", 2),
595
+ }),
596
+ "interlinked_query": ("POST", "/api/query", {"expression": args.get("expression", "")}),
597
+ "interlinked_trace_variable": ("POST", "/api/trace_variable", {
598
+ "variable": args.get("variable", ""),
599
+ "origin": args.get("origin"),
600
+ }),
601
+ "interlinked_propose_function": ("POST", "/api/propose", {
602
+ "name": args.get("name", ""),
603
+ "module": args.get("module", ""),
604
+ "calls": args.get("calls"),
605
+ "called_by": args.get("called_by"),
606
+ }),
607
+ "interlinked_find_duplicates": ("POST", "/api/find_duplicates", {
608
+ "threshold": args.get("threshold", 0.6),
609
+ "scope": args.get("scope"),
610
+ "kind": args.get("kind"),
611
+ }),
612
+ "interlinked_similar_to": ("POST", "/api/similar_to", {
613
+ "target": args.get("target", ""),
614
+ "threshold": args.get("threshold", 0.5),
615
+ }),
616
+ "interlinked_get_context": ("POST", "/api/get_context", {"target": args.get("target", "")}),
617
+ "interlinked_command": ("POST", "/api/command", {"command": args.get("command", "")}),
618
+ "interlinked_switch_project": ("POST", "/api/switch_project", {"path": args.get("path", "")}),
619
+ "interlinked_edges_between": ("POST", "/api/edges_between", {
620
+ "source_scope": args.get("source_scope", ""),
621
+ "target_scope": args.get("target_scope"),
622
+ "edge_types": args.get("edge_types"),
623
+ }),
624
+ "interlinked_reachable": ("POST", "/api/reachable", {
625
+ "source": args.get("source", ""),
626
+ "target": args.get("target", ""),
627
+ "edge_types": args.get("edge_types"),
628
+ "max_depth": args.get("max_depth", 20),
629
+ }),
630
+ "interlinked_reset": ("POST", "/api/reset", {}),
631
+ "interlinked_set_context": ("POST", "/api/set_context", {
632
+ "what": args.get("what", ""),
633
+ "why": args.get("why", ""),
634
+ "where": args.get("where", ""),
635
+ }),
636
+ }
637
+
638
+ if name not in _TOOL_TO_ENDPOINT:
639
+ return None
640
+
641
+ method, path, body = _TOOL_TO_ENDPOINT[name]
642
+ url = f"{server_url}{path}"
643
+
644
+ try:
645
+ async with httpx.AsyncClient() as client:
646
+ timeout = 120.0 if "switch_project" in path else 30.0
647
+ if method == "GET":
648
+ r = await client.get(url, timeout=timeout)
649
+ else:
650
+ r = await client.post(url, json=body, timeout=timeout)
651
+ data = r.json()
652
+
653
+ if "result" in data:
654
+ result = data["result"]
655
+ if isinstance(result, (dict, list)):
656
+ return json.dumps(result, indent=2)
657
+ return str(result)
658
+ if "results" in data:
659
+ results = data["results"]
660
+ if len(results) > 20:
661
+ return f"Found {len(results)} results. Showing first 20:\n" + json.dumps(results[:20], indent=2)
662
+ return json.dumps(results, indent=2)
663
+ if "error" in data:
664
+ return f"Error: {data['error']}"
665
+ return json.dumps(data, indent=2)
666
+ except Exception as e:
667
+ print(f"Async server proxy failed for {name}: {e}", file=sys.stderr)
668
+ return None
669
+
670
+
545
671
  def _dispatch_tool(
546
672
  name: str, args: dict[str, Any],
547
673
  engine: QueryEngine, graph: CodeGraph, api_key: str,
@@ -621,8 +747,11 @@ def _dispatch_tool(
621
747
 
622
748
  elif name == "interlinked_switch_project":
623
749
  from interlinked.visualizer.server import _rebuild_graph
624
- result = _rebuild_graph(args["path"], graph)
750
+ result = _rebuild_graph(args["path"], graph, run_similarity=False)
625
751
  engine.reset_filter()
752
+ # Deferred: similarity + embeddings in a single background thread
753
+ # (matches REST API: never run competing CPU threads)
754
+ _deferred_background_work(graph, engine, args["path"])
626
755
  return json.dumps(result, indent=2)
627
756
 
628
757
  elif name == "interlinked_edges_between":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: interlinked-mapper
3
- Version: 0.3.4
3
+ Version: 0.3.6
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.3.4"
7
+ version = "0.3.6"
8
8
  description = "A Python program topology explorer — visualize the shape of your codebase"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}