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,428 @@
1
+ """FastAPI server — serves the frontend and provides REST + SSE APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+
15
+ from interlinked.analyzer.graph import CodeGraph
16
+ from interlinked.commander.query import QueryEngine
17
+ from interlinked.commander.llm import LLMAdapter, get_system_prompt
18
+ from interlinked.visualizer.layouts import compute_layout
19
+ from interlinked.models import ViewState
20
+
21
+ FRONTEND_DIR = Path(__file__).parent / "frontend" / "dist"
22
+
23
+
24
+ def _rebuild_graph(project_path: str, graph: CodeGraph) -> dict:
25
+ """Re-parse a project and rebuild the graph in-place. Returns stats."""
26
+ from interlinked.analyzer.parser import parse_project
27
+ from interlinked.analyzer.dead_code import detect_dead_code
28
+ from interlinked.analyzer.similarity import analyze_similarity
29
+
30
+ path = Path(project_path).resolve()
31
+ if not path.exists():
32
+ raise ValueError(f"Path does not exist: {path}")
33
+
34
+ nodes, edges = parse_project(str(path))
35
+ graph.build_from(nodes, edges)
36
+ dead = detect_dead_code(graph)
37
+ try:
38
+ analyze_similarity(graph)
39
+ except Exception:
40
+ pass
41
+ return {"path": str(path), "nodes": len(nodes), "edges": len(edges), "dead": len(dead)}
42
+
43
+
44
+ def create_app(graph: CodeGraph, initial_path: str | None = None) -> FastAPI:
45
+ """Create and configure the FastAPI application."""
46
+ app = FastAPI(title="Interlinked", version="0.1.0")
47
+ engine = QueryEngine(graph)
48
+ llm = LLMAdapter(engine)
49
+ app_state = {"project_path": initial_path or ""}
50
+ sse_queues: list[asyncio.Queue] = []
51
+
52
+ app.add_middleware(
53
+ CORSMiddleware,
54
+ allow_origins=["*"],
55
+ allow_methods=["*"],
56
+ allow_headers=["*"],
57
+ )
58
+
59
+ # ── Broadcast view changes to all SSE clients ────────────────
60
+
61
+ def _snapshot_with_layout() -> dict:
62
+ snap = engine.snapshot()
63
+ snap["layout"] = compute_layout(
64
+ [n for n in graph.all_nodes()],
65
+ graph.all_edges(),
66
+ )
67
+ return snap
68
+
69
+ def on_view_change(snapshot: dict) -> None:
70
+ snapshot["layout"] = compute_layout(
71
+ [n for n in graph.all_nodes()],
72
+ graph.all_edges(),
73
+ )
74
+ msg = json.dumps({"type": "snapshot", "data": snapshot})
75
+ dead: list[asyncio.Queue] = []
76
+ for q in sse_queues:
77
+ try:
78
+ q.put_nowait(msg)
79
+ except Exception:
80
+ dead.append(q)
81
+ for q in dead:
82
+ sse_queues.remove(q)
83
+
84
+ engine.on_change(on_view_change)
85
+
86
+ # ── REST endpoints ───────────────────────────────────────────
87
+
88
+ @app.get("/api/project")
89
+ async def get_project() -> JSONResponse:
90
+ return JSONResponse({"path": app_state["project_path"]})
91
+
92
+ @app.post("/api/switch_project")
93
+ async def switch_project(body: dict) -> JSONResponse:
94
+ """Switch to a different project. Re-parses and rebuilds the graph."""
95
+ project_path = body.get("path", "")
96
+ if not project_path:
97
+ return JSONResponse({"error": "No path provided"}, status_code=400)
98
+ try:
99
+ result = _rebuild_graph(project_path, graph)
100
+ app_state["project_path"] = result["path"]
101
+ engine.reset_filter()
102
+ from interlinked.models import ViewContext
103
+ engine.state.context = ViewContext(
104
+ what=f"Switched to project: {Path(result['path']).name}",
105
+ why=f"{result['nodes']} symbols, {result['edges']} edges, {result['dead']} dead",
106
+ where=result["path"],
107
+ source="system",
108
+ )
109
+ engine._notify()
110
+ return JSONResponse({"result": f"Switched to {result['path']}", **result})
111
+ except Exception as e:
112
+ return JSONResponse({"error": str(e)}, status_code=400)
113
+
114
+ @app.get("/api/snapshot")
115
+ async def get_snapshot() -> JSONResponse:
116
+ snap = engine.snapshot()
117
+ layout = compute_layout(
118
+ [n for n in graph.all_nodes()],
119
+ graph.all_edges(),
120
+ )
121
+ snap["layout"] = layout
122
+ return JSONResponse(content=snap)
123
+
124
+ @app.get("/api/stats")
125
+ async def get_stats() -> JSONResponse:
126
+ return JSONResponse(content=engine.stats())
127
+
128
+ @app.get("/api/health")
129
+ async def get_health() -> JSONResponse:
130
+ return JSONResponse(content=json.loads(engine.health()))
131
+
132
+ @app.post("/api/command")
133
+ async def run_command(body: dict) -> JSONResponse:
134
+ """Execute a command string against the QueryEngine.
135
+
136
+ Body: {"command": "view.zoom('module')"}
137
+ """
138
+ cmd = body.get("command", "")
139
+ if not cmd:
140
+ return JSONResponse({"error": "No command provided"}, status_code=400)
141
+
142
+ # Security: only allow access to the engine object
143
+ local_ns: dict[str, Any] = {"view": engine, "graph": graph}
144
+ try:
145
+ # Try as expression first
146
+ try:
147
+ result = eval(cmd, {"__builtins__": {}}, local_ns)
148
+ except SyntaxError:
149
+ exec(cmd, {"__builtins__": {}}, local_ns)
150
+ result = "OK"
151
+
152
+ # Serialize result
153
+ if hasattr(result, "model_dump"):
154
+ result = result.model_dump()
155
+ elif isinstance(result, list) and result and hasattr(result[0], "model_dump"):
156
+ result = [r.model_dump() for r in result]
157
+
158
+ return JSONResponse({"result": result})
159
+ except Exception as e:
160
+ return JSONResponse({"error": str(e)}, status_code=400)
161
+
162
+ @app.post("/api/nl")
163
+ async def natural_language(body: dict) -> JSONResponse:
164
+ """Natural language command."""
165
+ text = body.get("text", "")
166
+ if not text:
167
+ return JSONResponse({"error": "No text provided"}, status_code=400)
168
+ result = engine.nl(text)
169
+ snap = engine.snapshot()
170
+ layout = compute_layout(
171
+ [n for n in graph.all_nodes()],
172
+ graph.all_edges(),
173
+ )
174
+ snap["layout"] = layout
175
+ return JSONResponse({"result": result, "snapshot": snap})
176
+
177
+ @app.post("/api/zoom")
178
+ async def set_zoom(body: dict) -> JSONResponse:
179
+ level = body.get("level", "module")
180
+ result = engine.zoom(level)
181
+ return JSONResponse({"result": result})
182
+
183
+ @app.post("/api/edge_types")
184
+ async def set_edge_types(body: dict) -> JSONResponse:
185
+ edge_types = body.get("edge_types", [])
186
+ result = engine.set_edge_types(edge_types)
187
+ return JSONResponse({"result": result})
188
+
189
+ @app.post("/api/focus")
190
+ async def set_focus(body: dict) -> JSONResponse:
191
+ node_id = body.get("node_id", "")
192
+ depth = body.get("depth", 2)
193
+ result = engine.focus(node_id, depth)
194
+ return JSONResponse({"result": result})
195
+
196
+ @app.post("/api/query")
197
+ async def run_query(body: dict) -> JSONResponse:
198
+ expr = body.get("expression", "")
199
+ results = engine.query(expr)
200
+ snap = engine.snapshot()
201
+ layout = compute_layout(
202
+ [n for n in graph.all_nodes()],
203
+ graph.all_edges(),
204
+ )
205
+ snap["layout"] = layout
206
+ return JSONResponse({"results": results, "snapshot": snap})
207
+
208
+ @app.post("/api/propose")
209
+ async def propose_function(body: dict) -> JSONResponse:
210
+ result = engine.propose_function(
211
+ name=body.get("name", ""),
212
+ module=body.get("module", ""),
213
+ calls=body.get("calls"),
214
+ called_by=body.get("called_by"),
215
+ signature=body.get("signature"),
216
+ color=body.get("color"),
217
+ )
218
+ return JSONResponse({"result": result})
219
+
220
+ @app.post("/api/clear_proposed")
221
+ async def clear_proposed() -> JSONResponse:
222
+ result = engine.clear_proposed()
223
+ return JSONResponse({"result": result})
224
+
225
+ @app.post("/api/isolate")
226
+ async def isolate_target(body: dict) -> JSONResponse:
227
+ target = body.get("target", "")
228
+ level = body.get("level", "function")
229
+ depth = body.get("depth", 3)
230
+ edge_types = body.get("edge_types")
231
+ result = engine.isolate(target, level=level, depth=depth, edge_types=edge_types)
232
+ snap = engine.snapshot()
233
+ layout = compute_layout(
234
+ [n for n in graph.all_nodes()],
235
+ graph.all_edges(),
236
+ )
237
+ snap["layout"] = layout
238
+ return JSONResponse({"result": result, "snapshot": snap})
239
+
240
+ @app.post("/api/find_duplicates")
241
+ async def find_duplicates(body: dict) -> JSONResponse:
242
+ threshold = body.get("threshold", 0.6)
243
+ scope = body.get("scope")
244
+ result = engine.find_duplicates(threshold=threshold, scope=scope)
245
+ snap = engine.snapshot()
246
+ layout = compute_layout(
247
+ [n for n in graph.all_nodes()],
248
+ graph.all_edges(),
249
+ )
250
+ snap["layout"] = layout
251
+ return JSONResponse({"result": result, "snapshot": snap})
252
+
253
+ @app.post("/api/similar_to")
254
+ async def similar_to(body: dict) -> JSONResponse:
255
+ target = body.get("target", "")
256
+ threshold = body.get("threshold", 0.5)
257
+ result = engine.similar_to(target, threshold=threshold)
258
+ snap = engine.snapshot()
259
+ layout = compute_layout(
260
+ [n for n in graph.all_nodes()],
261
+ graph.all_edges(),
262
+ )
263
+ snap["layout"] = layout
264
+ return JSONResponse({"result": result, "snapshot": snap})
265
+
266
+ @app.post("/api/get_context")
267
+ async def get_context(body: dict) -> JSONResponse:
268
+ target = body.get("target", "")
269
+ result = engine.get_context(target)
270
+ return JSONResponse({"result": result})
271
+
272
+ @app.post("/api/reset")
273
+ async def reset_filters() -> JSONResponse:
274
+ result = engine.reset_filter()
275
+ return JSONResponse({"result": result})
276
+
277
+ @app.post("/api/trace_variable")
278
+ async def trace_variable(body: dict) -> JSONResponse:
279
+ var_name = body.get("variable", "")
280
+ origin = body.get("origin")
281
+ result = engine.trace_variable(var_name, origin)
282
+ snap = engine.snapshot()
283
+ layout = compute_layout(
284
+ [n for n in graph.all_nodes()],
285
+ graph.all_edges(),
286
+ )
287
+ snap["layout"] = layout
288
+ return JSONResponse({"result": result, "snapshot": snap})
289
+
290
+ # ── LLM chat + settings ──────────────────────────────────────
291
+
292
+ @app.post("/api/chat")
293
+ async def chat(body: dict) -> JSONResponse:
294
+ """Send a natural language message through the LLM adapter.
295
+
296
+ Body: {"message": "show me the analyzer module and everything that connects to it"}
297
+ Returns: {"explanation": str, "commands_run": [...], "results": [...], "snapshot": {...}}
298
+ """
299
+ message = body.get("message", "")
300
+ if not message:
301
+ return JSONResponse({"error": "No message provided"}, status_code=400)
302
+
303
+ result = await llm.chat(message)
304
+
305
+ # Set natural language context so the UI knows what it's looking at
306
+ from interlinked.models import ViewContext
307
+ explanation = result.get("explanation", "")
308
+ commands_run = result.get("commands_run", [])
309
+ # Derive "where" from highlighted nodes
310
+ highlighted = engine.state.highlighted_node_ids
311
+ if highlighted:
312
+ # Get the common scope prefix
313
+ parts = [h.rsplit(".", 1)[0] for h in highlighted[:10] if "." in h]
314
+ if parts:
315
+ common = parts[0]
316
+ for p in parts[1:]:
317
+ while not p.startswith(common) and "." in common:
318
+ common = common.rsplit(".", 1)[0]
319
+ where = common if common else ", ".join(h.split(".")[-1] for h in highlighted[:5])
320
+ else:
321
+ where = ", ".join(h.split(".")[-1] for h in highlighted[:5])
322
+ else:
323
+ where = ""
324
+
325
+ engine.state.context = ViewContext(
326
+ what=explanation,
327
+ why=message,
328
+ where=where,
329
+ source="llm",
330
+ )
331
+ engine._notify()
332
+
333
+ # Always return fresh snapshot after commands have executed
334
+ snap = engine.snapshot()
335
+ layout = compute_layout(
336
+ [n for n in graph.all_nodes()],
337
+ graph.all_edges(),
338
+ )
339
+ snap["layout"] = layout
340
+ result["snapshot"] = snap
341
+
342
+ return JSONResponse(result)
343
+
344
+ @app.get("/api/system-prompt")
345
+ async def system_prompt() -> JSONResponse:
346
+ """Return the system prompt that teaches an LLM how to drive the view.
347
+
348
+ Any external LLM agent can GET this to learn the full API.
349
+ """
350
+ return JSONResponse({"prompt": get_system_prompt(engine)})
351
+
352
+ @app.get("/api/settings")
353
+ async def get_settings() -> JSONResponse:
354
+ return JSONResponse({
355
+ "has_api_key": llm.is_configured,
356
+ "model": llm.model,
357
+ })
358
+
359
+ @app.post("/api/settings")
360
+ async def update_settings(body: dict) -> JSONResponse:
361
+ if "api_key" in body:
362
+ llm.set_api_key(body["api_key"])
363
+ if "model" in body:
364
+ llm.set_model(body["model"])
365
+ return JSONResponse({
366
+ "has_api_key": llm.is_configured,
367
+ "model": llm.model,
368
+ })
369
+
370
+ @app.post("/api/chat/clear")
371
+ async def clear_chat() -> JSONResponse:
372
+ llm.clear_history()
373
+ return JSONResponse({"result": "Chat history cleared."})
374
+
375
+ # ── SSE for live updates ─────────────────────────────────────
376
+
377
+ @app.get("/api/events")
378
+ async def sse_events(request: Request) -> StreamingResponse:
379
+ """Server-Sent Events stream. Pushes snapshot updates to all connected clients."""
380
+ q: asyncio.Queue = asyncio.Queue(maxsize=50)
381
+ sse_queues.append(q)
382
+
383
+ async def event_generator():
384
+ # Send initial snapshot
385
+ snap = _snapshot_with_layout()
386
+ yield f"data: {json.dumps({'type': 'snapshot', 'data': snap})}\n\n"
387
+ try:
388
+ while True:
389
+ if await request.is_disconnected():
390
+ break
391
+ try:
392
+ msg = await asyncio.wait_for(q.get(), timeout=30)
393
+ yield f"data: {msg}\n\n"
394
+ except asyncio.TimeoutError:
395
+ # Keepalive
396
+ yield ": keepalive\n\n"
397
+ finally:
398
+ if q in sse_queues:
399
+ sse_queues.remove(q)
400
+
401
+ return StreamingResponse(
402
+ event_generator(),
403
+ media_type="text/event-stream",
404
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
405
+ )
406
+
407
+ # ── Serve frontend (SPA fallback) ────────────────────────────
408
+
409
+ @app.get("/")
410
+ async def serve_index() -> HTMLResponse:
411
+ index_path = FRONTEND_DIR / "index.html"
412
+ if index_path.exists():
413
+ return HTMLResponse(content=index_path.read_text())
414
+ # Fallback: serve the embedded single-file frontend
415
+ return HTMLResponse(content=_get_embedded_frontend())
416
+
417
+ if FRONTEND_DIR.exists():
418
+ app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
419
+
420
+ return app
421
+
422
+
423
+ def _get_embedded_frontend() -> str:
424
+ """Return the single-file embedded frontend HTML."""
425
+ frontend_file = Path(__file__).parent / "frontend" / "index.html"
426
+ if frontend_file.exists():
427
+ return frontend_file.read_text()
428
+ return "<html><body><h1>Interlinked</h1><p>Frontend not found.</p></body></html>"
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: interlinked-mapper
3
+ Version: 0.1.0
4
+ Summary: A Python program topology explorer — visualize the shape of your codebase
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/austerecryptid/interlinked
7
+ Project-URL: Repository, https://github.com/austerecryptid/interlinked
8
+ Keywords: ast,code-analysis,topology,graph,visualization,mcp,networkx
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Topic :: Software Development :: Quality Assurance
12
+ Classifier: Topic :: Software Development :: Documentation
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Framework :: FastAPI
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: networkx>=3.1
22
+ Requires-Dist: fastapi>=0.110
23
+ Requires-Dist: uvicorn[standard]>=0.29
24
+ Requires-Dist: pydantic>=2.0
25
+ Requires-Dist: httpx>=0.27
26
+ Requires-Dist: mcp>=1.0
@@ -0,0 +1,21 @@
1
+ interlinked/__init__.py,sha256=n-e2URoZB6ggGZGUfp2iUF1b99Ik8b77-7kQLxdncAs,81
2
+ interlinked/cli.py,sha256=Txb31yJwBmM0qTG_2v1P4SoHzqZZzX1KlvT44EAkcRM,5354
3
+ interlinked/mcp_server.py,sha256=cPYQkom6GBMxO320dmf7DzFIMVKOI56JBlZyFRfKarY,14028
4
+ interlinked/models.py,sha256=8rnrUsWq4n3Gu3_c5PtLmLSGup_RVlEZzLTxwq1xXuA,3293
5
+ interlinked/analyzer/__init__.py,sha256=IUVK2g10kPb710x4GZ64BM1l46cXFUoPN28KQOKVgog,302
6
+ interlinked/analyzer/dead_code.py,sha256=Om4U8e7hYPLRtewRTEbY2u_3CDQ3jrzB88-QQyyiHno,5767
7
+ interlinked/analyzer/graph.py,sha256=zyEjTm9RdL95jNmQ5fNTV2Ni9YmfpLtGySvpN4He8lw,33789
8
+ interlinked/analyzer/parser.py,sha256=RQscrCC-gswyreHl-_XmZi4pWFMfzM88rXiHg_EMfl8,48798
9
+ interlinked/analyzer/similarity.py,sha256=F2eo2PorcKFdR3eqZU-NlUjUO38Gox_Z-5uJaJ9sbck,17599
10
+ interlinked/commander/__init__.py,sha256=u3mUgPptlhh3ieYuBw1c4Mcg03mqti6sCydNi1fMkeg,211
11
+ interlinked/commander/llm.py,sha256=OXwl_0ZDhUNditnIP0piKS4CPlv4rdaNw2m8ixzeu88,13864
12
+ interlinked/commander/query.py,sha256=xAgvek1NRRg7eGjyk1dUeVaUzGqES0EzF5jIzQA7bq4,43553
13
+ interlinked/commander/repl.py,sha256=_5Y-x8EVvbSuV2B8gKVJ2hxz30VjZSO0ZSUDkrDgHMo,2001
14
+ interlinked/visualizer/__init__.py,sha256=JbjxQIkzieTfneMTrD55D85dNQVbG_ApOON0WauWPxU,54
15
+ interlinked/visualizer/layouts.py,sha256=PQI-jX0xUk434opZ3YDGvpSlxuM1Z3VTpvpWK0LsUv0,5122
16
+ interlinked/visualizer/server.py,sha256=PKXWd-_YP37kNLpbrmnvryao1VvN7URxH8XaXHXbmU8,16005
17
+ interlinked_mapper-0.1.0.dist-info/METADATA,sha256=zyLHl3crVsKNWXrznX_6oQkKvq3iajREtXSnNYUfmu0,1106
18
+ interlinked_mapper-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
19
+ interlinked_mapper-0.1.0.dist-info/entry_points.txt,sha256=a_FXoNklFSXCPppdEZkxsyyV9FXYO63rPgerO06glTA,53
20
+ interlinked_mapper-0.1.0.dist-info/top_level.txt,sha256=yRUrFRu0dPsOlTUmNsnA7AziN-ITuSn4222SH7h7_uA,12
21
+ interlinked_mapper-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ interlinked = interlinked.cli:main
@@ -0,0 +1 @@
1
+ interlinked