interlinked-mapper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- interlinked/__init__.py +3 -0
- interlinked/analyzer/__init__.py +7 -0
- interlinked/analyzer/dead_code.py +137 -0
- interlinked/analyzer/graph.py +822 -0
- interlinked/analyzer/parser.py +1141 -0
- interlinked/analyzer/similarity.py +486 -0
- interlinked/cli.py +136 -0
- interlinked/commander/__init__.py +6 -0
- interlinked/commander/llm.py +304 -0
- interlinked/commander/query.py +966 -0
- interlinked/commander/repl.py +50 -0
- interlinked/mcp_server.py +324 -0
- interlinked/models.py +107 -0
- interlinked/visualizer/__init__.py +1 -0
- interlinked/visualizer/layouts.py +181 -0
- interlinked/visualizer/server.py +428 -0
- interlinked_mapper-0.1.0.dist-info/METADATA +26 -0
- interlinked_mapper-0.1.0.dist-info/RECORD +21 -0
- interlinked_mapper-0.1.0.dist-info/WHEEL +5 -0
- interlinked_mapper-0.1.0.dist-info/entry_points.txt +2 -0
- interlinked_mapper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Interactive REPL for controlling the visualization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import code
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from interlinked.analyzer.graph import CodeGraph
|
|
10
|
+
from interlinked.commander.query import QueryEngine
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InterlinkedREPL:
|
|
14
|
+
"""Drops into an interactive Python REPL with the QueryEngine as `view`."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, graph: CodeGraph) -> None:
|
|
17
|
+
self.graph = graph
|
|
18
|
+
self.view = QueryEngine(graph)
|
|
19
|
+
|
|
20
|
+
def start(self) -> None:
|
|
21
|
+
"""Launch the interactive REPL."""
|
|
22
|
+
banner = (
|
|
23
|
+
"\n"
|
|
24
|
+
"╔══════════════════════════════════════════════════╗\n"
|
|
25
|
+
"║ INTERLINKED — Topology Explorer ║\n"
|
|
26
|
+
"╚══════════════════════════════════════════════════╝\n"
|
|
27
|
+
"\n"
|
|
28
|
+
" Available objects:\n"
|
|
29
|
+
" view — QueryEngine (main control interface)\n"
|
|
30
|
+
" graph — CodeGraph (raw graph access)\n"
|
|
31
|
+
"\n"
|
|
32
|
+
" Quick start:\n"
|
|
33
|
+
" view.stats() # summary\n"
|
|
34
|
+
" view.zoom('module') # zoom level\n"
|
|
35
|
+
" view.focus('my_module') # focus on node\n"
|
|
36
|
+
" view.query('dead functions') # find dead code\n"
|
|
37
|
+
" view.trace_variable('config') # trace a var\n"
|
|
38
|
+
" view.nl('show me uncalled functions') # natural language\n"
|
|
39
|
+
"\n"
|
|
40
|
+
" Type help(view) for full API documentation.\n"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
local_ns = {
|
|
44
|
+
"view": self.view,
|
|
45
|
+
"graph": self.graph,
|
|
46
|
+
"QueryEngine": QueryEngine,
|
|
47
|
+
"CodeGraph": CodeGraph,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
code.interact(banner=banner, local=local_ns, exitmsg="Goodbye.")
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""MCP Server for Interlinked — exposes the full view API as MCP tools.
|
|
2
|
+
|
|
3
|
+
This allows Windsurf, Claude Desktop, or any MCP-compatible client to
|
|
4
|
+
drive the Interlinked visualization directly.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
interlinked mcp ./my_project # stdio transport
|
|
8
|
+
interlinked mcp ./my_project --port 8421 # SSE transport
|
|
9
|
+
|
|
10
|
+
MCP config example (for .windsurf/mcp_config.json or claude_desktop_config.json):
|
|
11
|
+
{
|
|
12
|
+
"mcpServers": {
|
|
13
|
+
"interlinked": {
|
|
14
|
+
"command": "/path/to/.venv/bin/interlinked",
|
|
15
|
+
"args": ["mcp", "/path/to/project"],
|
|
16
|
+
"env": {
|
|
17
|
+
"ANTHROPIC_API_KEY": "sk-ant-..."
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from mcp.server import Server
|
|
32
|
+
from mcp.server.stdio import stdio_server
|
|
33
|
+
from mcp.types import Tool, TextContent
|
|
34
|
+
|
|
35
|
+
from interlinked.analyzer.parser import parse_project
|
|
36
|
+
from interlinked.analyzer.graph import CodeGraph
|
|
37
|
+
from interlinked.analyzer.dead_code import detect_dead_code
|
|
38
|
+
from interlinked.commander.query import QueryEngine
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_graph(project_path: str) -> tuple[CodeGraph, QueryEngine]:
|
|
42
|
+
"""Parse a project and build the graph + query engine."""
|
|
43
|
+
nodes, edges = parse_project(project_path)
|
|
44
|
+
graph = CodeGraph()
|
|
45
|
+
graph.build_from(nodes, edges)
|
|
46
|
+
detect_dead_code(graph)
|
|
47
|
+
|
|
48
|
+
# Run similarity analysis if available
|
|
49
|
+
try:
|
|
50
|
+
from interlinked.analyzer.similarity import analyze_similarity
|
|
51
|
+
analyze_similarity(graph)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
engine = QueryEngine(graph)
|
|
56
|
+
return graph, engine
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_mcp_server(project_path: str) -> Server:
|
|
60
|
+
"""Create an MCP server with all Interlinked tools."""
|
|
61
|
+
graph, engine = build_graph(project_path)
|
|
62
|
+
|
|
63
|
+
# Pick up API key from env if available
|
|
64
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
|
65
|
+
|
|
66
|
+
server = Server("interlinked")
|
|
67
|
+
|
|
68
|
+
@server.list_tools()
|
|
69
|
+
async def list_tools() -> list[Tool]:
|
|
70
|
+
return [
|
|
71
|
+
Tool(
|
|
72
|
+
name="interlinked_stats",
|
|
73
|
+
description="Get summary statistics about the analyzed Python project: node counts, edge counts, dead code count.",
|
|
74
|
+
inputSchema={"type": "object", "properties": {}, "required": []},
|
|
75
|
+
),
|
|
76
|
+
Tool(
|
|
77
|
+
name="interlinked_isolate",
|
|
78
|
+
description="Isolate a module, class, or function and show it plus everything that connects to it. This is the primary command for exploring a codebase. The target can be a partial name.",
|
|
79
|
+
inputSchema={
|
|
80
|
+
"type": "object",
|
|
81
|
+
"properties": {
|
|
82
|
+
"target": {"type": "string", "description": "Name or partial name of the module/class/function to isolate"},
|
|
83
|
+
"level": {"type": "string", "enum": ["module", "class", "function", "variable", "all"], "description": "Zoom level", "default": "function"},
|
|
84
|
+
"depth": {"type": "integer", "description": "How many hops of connections to show", "default": 3},
|
|
85
|
+
"edge_types": {"type": "array", "items": {"type": "string"}, "description": "Optional: only follow these edge types (calls, imports, inherits, reads, writes)"},
|
|
86
|
+
},
|
|
87
|
+
"required": ["target"],
|
|
88
|
+
},
|
|
89
|
+
),
|
|
90
|
+
Tool(
|
|
91
|
+
name="interlinked_zoom",
|
|
92
|
+
description="Set the zoom level of the visualization: 'module' (high-level), 'class' (mid-level), or 'function' (detailed).",
|
|
93
|
+
inputSchema={
|
|
94
|
+
"type": "object",
|
|
95
|
+
"properties": {
|
|
96
|
+
"level": {"type": "string", "enum": ["module", "class", "function", "variable", "all"]},
|
|
97
|
+
},
|
|
98
|
+
"required": ["level"],
|
|
99
|
+
},
|
|
100
|
+
),
|
|
101
|
+
Tool(
|
|
102
|
+
name="interlinked_focus",
|
|
103
|
+
description="Focus the visualization on a specific node and its neighborhood within N hops.",
|
|
104
|
+
inputSchema={
|
|
105
|
+
"type": "object",
|
|
106
|
+
"properties": {
|
|
107
|
+
"node_id": {"type": "string", "description": "Qualified name of the node to focus on"},
|
|
108
|
+
"depth": {"type": "integer", "description": "Number of hops to show", "default": 2},
|
|
109
|
+
},
|
|
110
|
+
"required": ["node_id"],
|
|
111
|
+
},
|
|
112
|
+
),
|
|
113
|
+
Tool(
|
|
114
|
+
name="interlinked_query",
|
|
115
|
+
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.",
|
|
116
|
+
inputSchema={
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": {
|
|
119
|
+
"expression": {"type": "string", "description": "Query expression"},
|
|
120
|
+
},
|
|
121
|
+
"required": ["expression"],
|
|
122
|
+
},
|
|
123
|
+
),
|
|
124
|
+
Tool(
|
|
125
|
+
name="interlinked_trace_variable",
|
|
126
|
+
description="Trace a variable's path through reads and writes across the codebase.",
|
|
127
|
+
inputSchema={
|
|
128
|
+
"type": "object",
|
|
129
|
+
"properties": {
|
|
130
|
+
"variable": {"type": "string", "description": "Variable name to trace"},
|
|
131
|
+
"origin": {"type": "string", "description": "Optional: qualified name of the origin scope"},
|
|
132
|
+
},
|
|
133
|
+
"required": ["variable"],
|
|
134
|
+
},
|
|
135
|
+
),
|
|
136
|
+
Tool(
|
|
137
|
+
name="interlinked_propose_function",
|
|
138
|
+
description="Add a hypothetical function to the graph to visualize where it would connect. Shown in green to distinguish from real code.",
|
|
139
|
+
inputSchema={
|
|
140
|
+
"type": "object",
|
|
141
|
+
"properties": {
|
|
142
|
+
"name": {"type": "string", "description": "Function name"},
|
|
143
|
+
"module": {"type": "string", "description": "Module to place it in"},
|
|
144
|
+
"calls": {"type": "array", "items": {"type": "string"}, "description": "Functions this would call"},
|
|
145
|
+
"called_by": {"type": "array", "items": {"type": "string"}, "description": "Functions that would call this"},
|
|
146
|
+
},
|
|
147
|
+
"required": ["name", "module"],
|
|
148
|
+
},
|
|
149
|
+
),
|
|
150
|
+
Tool(
|
|
151
|
+
name="interlinked_find_duplicates",
|
|
152
|
+
description="Find functions/methods with similar structure, signatures, or logic paths — potential duplicated functionality. Returns groups of similar symbols with similarity scores.",
|
|
153
|
+
inputSchema={
|
|
154
|
+
"type": "object",
|
|
155
|
+
"properties": {
|
|
156
|
+
"threshold": {"type": "number", "description": "Similarity threshold 0.0-1.0 (default 0.6)", "default": 0.6},
|
|
157
|
+
"scope": {"type": "string", "description": "Optional: limit search to symbols under this prefix"},
|
|
158
|
+
},
|
|
159
|
+
"required": [],
|
|
160
|
+
},
|
|
161
|
+
),
|
|
162
|
+
Tool(
|
|
163
|
+
name="interlinked_similar_to",
|
|
164
|
+
description="Find functions/classes structurally similar to a given symbol. Useful for finding duplicated or redundant functionality.",
|
|
165
|
+
inputSchema={
|
|
166
|
+
"type": "object",
|
|
167
|
+
"properties": {
|
|
168
|
+
"target": {"type": "string", "description": "Name or partial name of the symbol to compare against"},
|
|
169
|
+
"threshold": {"type": "number", "description": "Similarity threshold 0.0-1.0", "default": 0.5},
|
|
170
|
+
},
|
|
171
|
+
"required": ["target"],
|
|
172
|
+
},
|
|
173
|
+
),
|
|
174
|
+
Tool(
|
|
175
|
+
name="interlinked_get_context",
|
|
176
|
+
description="Get rich context about a symbol: source code, docstring, signature, comments, connections, and structural fingerprint. Useful for understanding what a function does before comparing.",
|
|
177
|
+
inputSchema={
|
|
178
|
+
"type": "object",
|
|
179
|
+
"properties": {
|
|
180
|
+
"target": {"type": "string", "description": "Name or partial name of the symbol"},
|
|
181
|
+
},
|
|
182
|
+
"required": ["target"],
|
|
183
|
+
},
|
|
184
|
+
),
|
|
185
|
+
Tool(
|
|
186
|
+
name="interlinked_command",
|
|
187
|
+
description="Execute a raw Python command against the view API. For advanced usage. The `view` object is the QueryEngine and `graph` is the CodeGraph.",
|
|
188
|
+
inputSchema={
|
|
189
|
+
"type": "object",
|
|
190
|
+
"properties": {
|
|
191
|
+
"command": {"type": "string", "description": "Python expression to evaluate, e.g. view.isolate('analyzer')"},
|
|
192
|
+
},
|
|
193
|
+
"required": ["command"],
|
|
194
|
+
},
|
|
195
|
+
),
|
|
196
|
+
Tool(
|
|
197
|
+
name="interlinked_switch_project",
|
|
198
|
+
description="Switch to analyzing a different Python project. Re-parses the new project and rebuilds the entire graph.",
|
|
199
|
+
inputSchema={
|
|
200
|
+
"type": "object",
|
|
201
|
+
"properties": {
|
|
202
|
+
"path": {"type": "string", "description": "Absolute or relative path to the Python project root"},
|
|
203
|
+
},
|
|
204
|
+
"required": ["path"],
|
|
205
|
+
},
|
|
206
|
+
),
|
|
207
|
+
Tool(
|
|
208
|
+
name="interlinked_reset",
|
|
209
|
+
description="Reset all filters, focus, and highlights back to the default full-graph view.",
|
|
210
|
+
inputSchema={"type": "object", "properties": {}, "required": []},
|
|
211
|
+
),
|
|
212
|
+
Tool(
|
|
213
|
+
name="interlinked_set_api_key",
|
|
214
|
+
description="Set the Anthropic API key for the built-in chat pilot. This is stored in memory only, not written to disk.",
|
|
215
|
+
inputSchema={
|
|
216
|
+
"type": "object",
|
|
217
|
+
"properties": {
|
|
218
|
+
"api_key": {"type": "string", "description": "Anthropic API key (sk-ant-...)"},
|
|
219
|
+
},
|
|
220
|
+
"required": ["api_key"],
|
|
221
|
+
},
|
|
222
|
+
),
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
@server.call_tool()
|
|
226
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
227
|
+
nonlocal api_key
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
result = _dispatch_tool(name, arguments, engine, graph, api_key)
|
|
231
|
+
if name == "interlinked_set_api_key":
|
|
232
|
+
api_key = arguments.get("api_key", "")
|
|
233
|
+
os.environ["ANTHROPIC_API_KEY"] = api_key
|
|
234
|
+
result = f"API key {'set' if api_key else 'cleared'}."
|
|
235
|
+
return [TextContent(type="text", text=str(result))]
|
|
236
|
+
except Exception as e:
|
|
237
|
+
return [TextContent(type="text", text=f"Error: {e}")]
|
|
238
|
+
|
|
239
|
+
return server
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _dispatch_tool(
|
|
243
|
+
name: str, args: dict[str, Any],
|
|
244
|
+
engine: QueryEngine, graph: CodeGraph, api_key: str,
|
|
245
|
+
) -> str:
|
|
246
|
+
"""Route a tool call to the appropriate engine method."""
|
|
247
|
+
|
|
248
|
+
if name == "interlinked_stats":
|
|
249
|
+
stats = engine.stats()
|
|
250
|
+
return json.dumps(stats, indent=2)
|
|
251
|
+
|
|
252
|
+
elif name == "interlinked_isolate":
|
|
253
|
+
return engine.isolate(
|
|
254
|
+
target=args["target"],
|
|
255
|
+
level=args.get("level", "function"),
|
|
256
|
+
depth=args.get("depth", 3),
|
|
257
|
+
edge_types=args.get("edge_types"),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
elif name == "interlinked_zoom":
|
|
261
|
+
return engine.zoom(args["level"])
|
|
262
|
+
|
|
263
|
+
elif name == "interlinked_focus":
|
|
264
|
+
return engine.focus(args["node_id"], depth=args.get("depth", 2))
|
|
265
|
+
|
|
266
|
+
elif name == "interlinked_query":
|
|
267
|
+
results = engine.query(args["expression"])
|
|
268
|
+
if len(results) > 20:
|
|
269
|
+
summary = f"Found {len(results)} results. Showing first 20:\n"
|
|
270
|
+
return summary + json.dumps(results[:20], indent=2)
|
|
271
|
+
return json.dumps(results, indent=2)
|
|
272
|
+
|
|
273
|
+
elif name == "interlinked_trace_variable":
|
|
274
|
+
return engine.trace_variable(args["variable"], args.get("origin"))
|
|
275
|
+
|
|
276
|
+
elif name == "interlinked_propose_function":
|
|
277
|
+
return engine.propose_function(
|
|
278
|
+
name=args["name"], module=args["module"],
|
|
279
|
+
calls=args.get("calls"), called_by=args.get("called_by"),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
elif name == "interlinked_find_duplicates":
|
|
283
|
+
threshold = args.get("threshold", 0.6)
|
|
284
|
+
scope = args.get("scope")
|
|
285
|
+
return engine.find_duplicates(threshold=threshold, scope=scope)
|
|
286
|
+
|
|
287
|
+
elif name == "interlinked_similar_to":
|
|
288
|
+
return engine.similar_to(args["target"], threshold=args.get("threshold", 0.5))
|
|
289
|
+
|
|
290
|
+
elif name == "interlinked_get_context":
|
|
291
|
+
return engine.get_context(args["target"])
|
|
292
|
+
|
|
293
|
+
elif name == "interlinked_command":
|
|
294
|
+
cmd = args["command"]
|
|
295
|
+
local_ns: dict[str, Any] = {"view": engine, "graph": graph}
|
|
296
|
+
try:
|
|
297
|
+
result = eval(cmd, {"__builtins__": {}}, local_ns)
|
|
298
|
+
except SyntaxError:
|
|
299
|
+
exec(cmd, {"__builtins__": {}}, local_ns)
|
|
300
|
+
result = "OK"
|
|
301
|
+
if hasattr(result, "model_dump"):
|
|
302
|
+
return json.dumps(result.model_dump(), indent=2)
|
|
303
|
+
return str(result)
|
|
304
|
+
|
|
305
|
+
elif name == "interlinked_switch_project":
|
|
306
|
+
from interlinked.visualizer.server import _rebuild_graph
|
|
307
|
+
result = _rebuild_graph(args["path"], graph)
|
|
308
|
+
engine.reset_filter()
|
|
309
|
+
return json.dumps(result, indent=2)
|
|
310
|
+
|
|
311
|
+
elif name == "interlinked_reset":
|
|
312
|
+
return engine.reset_filter()
|
|
313
|
+
|
|
314
|
+
elif name == "interlinked_set_api_key":
|
|
315
|
+
return "" # handled in call_tool
|
|
316
|
+
|
|
317
|
+
return f"Unknown tool: {name}"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def run_mcp_stdio(project_path: str) -> None:
|
|
321
|
+
"""Run the MCP server over stdio."""
|
|
322
|
+
server = create_mcp_server(project_path)
|
|
323
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
324
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
interlinked/models.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Shared data models for Interlinked."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SymbolType(str, enum.Enum):
|
|
12
|
+
MODULE = "module"
|
|
13
|
+
CLASS = "class"
|
|
14
|
+
FUNCTION = "function"
|
|
15
|
+
METHOD = "method"
|
|
16
|
+
VARIABLE = "variable"
|
|
17
|
+
PARAMETER = "parameter"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EdgeType(str, enum.Enum):
|
|
21
|
+
CALLS = "calls"
|
|
22
|
+
IMPORTS = "imports"
|
|
23
|
+
INHERITS = "inherits"
|
|
24
|
+
READS = "reads"
|
|
25
|
+
WRITES = "writes"
|
|
26
|
+
CONTAINS = "contains" # module contains class, class contains method, etc.
|
|
27
|
+
PROPOSED = "proposed"
|
|
28
|
+
RETURNS = "returns" # function returns value to caller
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NodeData(BaseModel):
|
|
32
|
+
id: str
|
|
33
|
+
name: str
|
|
34
|
+
qualified_name: str
|
|
35
|
+
symbol_type: SymbolType
|
|
36
|
+
file_path: str | None = None
|
|
37
|
+
line_start: int | None = None
|
|
38
|
+
line_end: int | None = None
|
|
39
|
+
docstring: str | None = None
|
|
40
|
+
signature: str | None = None
|
|
41
|
+
is_dead: bool = False
|
|
42
|
+
is_proposed: bool = False
|
|
43
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EdgeData(BaseModel):
|
|
47
|
+
source: str
|
|
48
|
+
target: str
|
|
49
|
+
edge_type: EdgeType
|
|
50
|
+
is_dead: bool = False
|
|
51
|
+
is_proposed: bool = False
|
|
52
|
+
line: int | None = None # line where the reference occurs
|
|
53
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ColorScheme(BaseModel):
|
|
57
|
+
healthy: str = "#4a90d9"
|
|
58
|
+
dead_link: str = "#e74c3c"
|
|
59
|
+
proposed: str = "#2ecc71"
|
|
60
|
+
highlighted: str = "#f1c40f"
|
|
61
|
+
contains: str = "#95a5a6"
|
|
62
|
+
inherits: str = "#9b59b6"
|
|
63
|
+
imports: str = "#3498db"
|
|
64
|
+
calls: str = "#e67e22"
|
|
65
|
+
reads: str = "#1abc9c"
|
|
66
|
+
writes: str = "#e91e63"
|
|
67
|
+
module_bg: str = "#2c3e50"
|
|
68
|
+
class_bg: str = "#34495e"
|
|
69
|
+
function_bg: str = "#4a6fa5"
|
|
70
|
+
variable_bg: str = "#7f8c8d"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ViewContext(BaseModel):
|
|
74
|
+
"""Natural language context for what the user is currently looking at."""
|
|
75
|
+
what: str = "" # What is being shown
|
|
76
|
+
why: str = "" # Why this view was chosen
|
|
77
|
+
where: str = "" # Which part of the codebase (scope/modules/symbols)
|
|
78
|
+
source: str = "" # Who set this context: "llm", "command", "trace", ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ViewState(BaseModel):
|
|
82
|
+
"""Current state of the visualization — what the user sees."""
|
|
83
|
+
zoom_level: str = "function" # module, class, function
|
|
84
|
+
focus_node: str | None = None
|
|
85
|
+
focus_depth: int = 2
|
|
86
|
+
visible_node_ids: list[str] = Field(default_factory=list)
|
|
87
|
+
visible_edge_types: list[EdgeType] = Field(
|
|
88
|
+
default_factory=lambda: list(EdgeType)
|
|
89
|
+
)
|
|
90
|
+
highlighted_node_ids: list[str] = Field(default_factory=list)
|
|
91
|
+
highlighted_edge_ids: list[tuple[str, str]] = Field(default_factory=list)
|
|
92
|
+
# Trace roles: node_id -> "origin" | "mutator" | "passthrough" | "destination"
|
|
93
|
+
trace_node_roles: dict[str, str] = Field(default_factory=dict)
|
|
94
|
+
# Trace edge roles: "src|tgt" -> "write" | "read"
|
|
95
|
+
trace_edge_roles: dict[str, str] = Field(default_factory=dict)
|
|
96
|
+
colors: ColorScheme = Field(default_factory=ColorScheme)
|
|
97
|
+
show_dead: bool = True
|
|
98
|
+
show_proposed: bool = True
|
|
99
|
+
filter_expression: str | None = None
|
|
100
|
+
context: ViewContext = Field(default_factory=ViewContext)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class GraphSnapshot(BaseModel):
|
|
104
|
+
"""Serializable snapshot of the graph for the frontend."""
|
|
105
|
+
nodes: list[NodeData]
|
|
106
|
+
edges: list[EdgeData]
|
|
107
|
+
view: ViewState
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Visualizer — FastAPI server and web frontend."""
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Graph layout algorithms — compute node positions for the frontend.
|
|
2
|
+
|
|
3
|
+
Uses a pure-Python force-directed layout so numpy is not required.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import random
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from interlinked.models import NodeData, EdgeData, SymbolType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _circular_layout(nodes: list[NodeData]) -> dict[str, tuple[float, float]]:
|
|
16
|
+
"""Arrange nodes in a circle."""
|
|
17
|
+
n = len(nodes)
|
|
18
|
+
if n == 0:
|
|
19
|
+
return {}
|
|
20
|
+
pos: dict[str, tuple[float, float]] = {}
|
|
21
|
+
for i, node in enumerate(nodes):
|
|
22
|
+
angle = 2 * math.pi * i / n
|
|
23
|
+
pos[node.id] = (math.cos(angle), math.sin(angle))
|
|
24
|
+
return pos
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compute_layout(
|
|
28
|
+
nodes: list[NodeData],
|
|
29
|
+
edges: list[EdgeData],
|
|
30
|
+
algorithm: str = "force",
|
|
31
|
+
width: float = 1200,
|
|
32
|
+
height: float = 800,
|
|
33
|
+
) -> dict[str, dict[str, float]]:
|
|
34
|
+
"""Compute x,y positions for each node.
|
|
35
|
+
|
|
36
|
+
Returns: {node_id: {"x": float, "y": float}}
|
|
37
|
+
"""
|
|
38
|
+
if not nodes:
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
if algorithm == "hierarchical":
|
|
42
|
+
pos = _hierarchical_layout(nodes, edges)
|
|
43
|
+
elif algorithm == "circular":
|
|
44
|
+
pos = _circular_layout(nodes)
|
|
45
|
+
else:
|
|
46
|
+
pos = _force_layout(nodes, edges, iterations=80)
|
|
47
|
+
|
|
48
|
+
# Scale to canvas dimensions
|
|
49
|
+
result: dict[str, dict[str, float]] = {}
|
|
50
|
+
if pos:
|
|
51
|
+
xs = [p[0] for p in pos.values()]
|
|
52
|
+
ys = [p[1] for p in pos.values()]
|
|
53
|
+
min_x, max_x = min(xs), max(xs)
|
|
54
|
+
min_y, max_y = min(ys), max(ys)
|
|
55
|
+
range_x = max_x - min_x or 1
|
|
56
|
+
range_y = max_y - min_y or 1
|
|
57
|
+
margin = 80
|
|
58
|
+
|
|
59
|
+
for nid, (x, y) in pos.items():
|
|
60
|
+
result[nid] = {
|
|
61
|
+
"x": margin + ((x - min_x) / range_x) * (width - 2 * margin),
|
|
62
|
+
"y": margin + ((y - min_y) / range_y) * (height - 2 * margin),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _force_layout(
|
|
69
|
+
nodes: list[NodeData],
|
|
70
|
+
edges: list[EdgeData],
|
|
71
|
+
iterations: int = 80,
|
|
72
|
+
) -> dict[str, tuple[float, float]]:
|
|
73
|
+
"""Pure-Python Fruchterman-Reingold force-directed layout."""
|
|
74
|
+
rng = random.Random(42)
|
|
75
|
+
node_ids = [n.id for n in nodes]
|
|
76
|
+
n = len(node_ids)
|
|
77
|
+
if n == 0:
|
|
78
|
+
return {}
|
|
79
|
+
if n == 1:
|
|
80
|
+
return {node_ids[0]: (0.0, 0.0)}
|
|
81
|
+
|
|
82
|
+
idx = {nid: i for i, nid in enumerate(node_ids)}
|
|
83
|
+
id_set = set(node_ids)
|
|
84
|
+
|
|
85
|
+
# Initial random positions
|
|
86
|
+
px = [rng.uniform(-1.0, 1.0) for _ in range(n)]
|
|
87
|
+
py = [rng.uniform(-1.0, 1.0) for _ in range(n)]
|
|
88
|
+
|
|
89
|
+
# Build adjacency
|
|
90
|
+
adj: list[list[int]] = [[] for _ in range(n)]
|
|
91
|
+
for e in edges:
|
|
92
|
+
if e.source in id_set and e.target in id_set:
|
|
93
|
+
si, ti = idx[e.source], idx[e.target]
|
|
94
|
+
adj[si].append(ti)
|
|
95
|
+
adj[ti].append(si)
|
|
96
|
+
|
|
97
|
+
area = 4.0 * n
|
|
98
|
+
k = math.sqrt(area / max(n, 1))
|
|
99
|
+
temp = 1.0
|
|
100
|
+
|
|
101
|
+
for iteration in range(iterations):
|
|
102
|
+
# Repulsive forces between all pairs
|
|
103
|
+
dx = [0.0] * n
|
|
104
|
+
dy = [0.0] * n
|
|
105
|
+
|
|
106
|
+
for i in range(n):
|
|
107
|
+
for j in range(i + 1, n):
|
|
108
|
+
ddx = px[i] - px[j]
|
|
109
|
+
ddy = py[i] - py[j]
|
|
110
|
+
dist = math.sqrt(ddx * ddx + ddy * ddy) or 0.001
|
|
111
|
+
force = (k * k) / dist
|
|
112
|
+
fx = (ddx / dist) * force
|
|
113
|
+
fy = (ddy / dist) * force
|
|
114
|
+
dx[i] += fx
|
|
115
|
+
dy[i] += fy
|
|
116
|
+
dx[j] -= fx
|
|
117
|
+
dy[j] -= fy
|
|
118
|
+
|
|
119
|
+
# Attractive forces along edges
|
|
120
|
+
for i in range(n):
|
|
121
|
+
for j in adj[i]:
|
|
122
|
+
if j <= i:
|
|
123
|
+
continue
|
|
124
|
+
ddx = px[i] - px[j]
|
|
125
|
+
ddy = py[i] - py[j]
|
|
126
|
+
dist = math.sqrt(ddx * ddx + ddy * ddy) or 0.001
|
|
127
|
+
force = (dist * dist) / k
|
|
128
|
+
fx = (ddx / dist) * force
|
|
129
|
+
fy = (ddy / dist) * force
|
|
130
|
+
dx[i] -= fx
|
|
131
|
+
dy[i] -= fy
|
|
132
|
+
dx[j] += fx
|
|
133
|
+
dy[j] += fy
|
|
134
|
+
|
|
135
|
+
# Apply with temperature
|
|
136
|
+
for i in range(n):
|
|
137
|
+
disp = math.sqrt(dx[i] * dx[i] + dy[i] * dy[i]) or 0.001
|
|
138
|
+
scale = min(disp, temp) / disp
|
|
139
|
+
px[i] += dx[i] * scale
|
|
140
|
+
py[i] += dy[i] * scale
|
|
141
|
+
|
|
142
|
+
temp *= 0.95 # cooling
|
|
143
|
+
|
|
144
|
+
return {node_ids[i]: (px[i], py[i]) for i in range(n)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _hierarchical_layout(
|
|
148
|
+
nodes: list[NodeData], edges: list[EdgeData]
|
|
149
|
+
) -> dict[str, tuple[float, float]]:
|
|
150
|
+
"""Simple hierarchical layout: modules at top, classes below, functions at bottom."""
|
|
151
|
+
layers: dict[SymbolType, list[str]] = {
|
|
152
|
+
SymbolType.MODULE: [],
|
|
153
|
+
SymbolType.CLASS: [],
|
|
154
|
+
SymbolType.FUNCTION: [],
|
|
155
|
+
SymbolType.METHOD: [],
|
|
156
|
+
SymbolType.VARIABLE: [],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for n in nodes:
|
|
160
|
+
layers[n.symbol_type].append(n.id)
|
|
161
|
+
|
|
162
|
+
pos: dict[str, tuple[float, float]] = {}
|
|
163
|
+
layer_order = [
|
|
164
|
+
SymbolType.MODULE,
|
|
165
|
+
SymbolType.CLASS,
|
|
166
|
+
SymbolType.FUNCTION,
|
|
167
|
+
SymbolType.METHOD,
|
|
168
|
+
SymbolType.VARIABLE,
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
y = 0.0
|
|
172
|
+
for sym_type in layer_order:
|
|
173
|
+
ids = layers[sym_type]
|
|
174
|
+
if not ids:
|
|
175
|
+
continue
|
|
176
|
+
for i, nid in enumerate(ids):
|
|
177
|
+
x = (i + 1) / (len(ids) + 1)
|
|
178
|
+
pos[nid] = (x, y)
|
|
179
|
+
y += 1.0
|
|
180
|
+
|
|
181
|
+
return pos
|