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,486 @@
1
+ """Similarity analysis — structural fingerprinting and duplicate detection.
2
+
3
+ Uses NetworkX graph algorithms (Jaccard coefficient on neighbor sets) combined
4
+ with AST structural features to detect similar/duplicate code.
5
+
6
+ Detects:
7
+ - Functions/methods with similar call patterns (Jaccard on callees)
8
+ - Similar read/write patterns (Jaccard on data-flow neighbors)
9
+ - Similar structural shape (AST node type distribution, nesting depth, control flow)
10
+ - Potential duplicated logic paths
11
+
12
+ Clustering uses nx.connected_components on a similarity threshold graph.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import math
19
+ from collections import Counter
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import networkx as nx
25
+
26
+ from interlinked.analyzer.graph import CodeGraph
27
+ from interlinked.models import NodeData, EdgeData, EdgeType, SymbolType
28
+
29
+
30
+ @dataclass
31
+ class StructuralFingerprint:
32
+ """A normalized feature vector describing the shape of a code symbol."""
33
+ node_id: str
34
+ name: str
35
+ qualified_name: str
36
+ symbol_type: SymbolType
37
+ # Structural features
38
+ arg_count: int = 0
39
+ arg_names: tuple[str, ...] = ()
40
+ return_annotation: str = ""
41
+ line_count: int = 0
42
+ # AST shape
43
+ ast_node_counts: dict[str, int] = field(default_factory=dict)
44
+ max_nesting_depth: int = 0
45
+ has_loops: bool = False
46
+ has_conditionals: bool = False
47
+ has_try_except: bool = False
48
+ has_yield: bool = False
49
+ has_await: bool = False
50
+ # Graph shape
51
+ callees: frozenset[str] = frozenset()
52
+ callers: frozenset[str] = frozenset()
53
+ reads: frozenset[str] = frozenset()
54
+ writes: frozenset[str] = frozenset()
55
+ # Source context
56
+ docstring: str = ""
57
+ source_snippet: str = ""
58
+
59
+
60
+ def analyze_similarity(graph: CodeGraph) -> None:
61
+ """Compute fingerprints for all functions/methods and store them on the nodes."""
62
+ all_nodes = graph.all_nodes(include_proposed=False)
63
+ functions = [
64
+ n for n in all_nodes
65
+ if n.symbol_type in (SymbolType.FUNCTION, SymbolType.METHOD)
66
+ ]
67
+
68
+ for node in functions:
69
+ fp = _compute_fingerprint(node, graph)
70
+ node.metadata["fingerprint"] = _fingerprint_to_dict(fp)
71
+
72
+ # Also fingerprint classes by their method signatures + shape
73
+ classes = [n for n in all_nodes if n.symbol_type == SymbolType.CLASS]
74
+ for node in classes:
75
+ fp = _compute_class_fingerprint(node, graph)
76
+ node.metadata["fingerprint"] = _fingerprint_to_dict(fp)
77
+
78
+
79
+ def find_duplicate_groups(
80
+ graph: CodeGraph,
81
+ threshold: float = 0.6,
82
+ scope: str | None = None,
83
+ ) -> list[dict]:
84
+ """Find groups of structurally similar functions.
85
+
86
+ Uses nx.connected_components on a similarity threshold graph to cluster.
87
+ Returns a list of groups, each containing similar symbols with scores.
88
+ """
89
+ all_nodes = graph.all_nodes(include_proposed=False)
90
+ targets = [
91
+ n for n in all_nodes
92
+ if n.symbol_type in (SymbolType.FUNCTION, SymbolType.METHOD)
93
+ and n.metadata.get("fingerprint")
94
+ ]
95
+
96
+ if scope:
97
+ targets = [n for n in targets if n.qualified_name.startswith(scope)]
98
+
99
+ # Pairwise comparison — build a similarity graph
100
+ sim_graph = nx.Graph()
101
+ pairs: dict[tuple[str, str], float] = {}
102
+ for i, a in enumerate(targets):
103
+ for j in range(i + 1, len(targets)):
104
+ b = targets[j]
105
+ score = _similarity_score(
106
+ a.metadata["fingerprint"],
107
+ b.metadata["fingerprint"],
108
+ )
109
+ if score >= threshold:
110
+ sim_graph.add_edge(a.id, b.id, weight=score)
111
+ pairs[(a.id, b.id)] = score
112
+
113
+ # Cluster using nx.connected_components
114
+ result = []
115
+ for component in nx.connected_components(sim_graph):
116
+ if len(component) < 2:
117
+ continue
118
+ members = []
119
+ for nid in component:
120
+ node = graph.get_node(nid)
121
+ if node:
122
+ members.append({
123
+ "id": node.id,
124
+ "name": node.name,
125
+ "qualified_name": node.qualified_name,
126
+ "file": node.file_path,
127
+ "lines": f"{node.line_start}-{node.line_end}",
128
+ "signature": node.signature or "",
129
+ "docstring": (node.docstring or "")[:200],
130
+ })
131
+ if len(members) >= 2:
132
+ group_scores = [
133
+ s for (a, b), s in pairs.items()
134
+ if a in component and b in component
135
+ ]
136
+ avg_score = sum(group_scores) / len(group_scores) if group_scores else 0
137
+ result.append({
138
+ "similarity": round(avg_score, 3),
139
+ "count": len(members),
140
+ "members": members,
141
+ })
142
+
143
+ result.sort(key=lambda g: g["similarity"], reverse=True)
144
+ return result
145
+
146
+
147
+ def find_similar_to(
148
+ graph: CodeGraph,
149
+ target_id: str,
150
+ threshold: float = 0.5,
151
+ ) -> list[dict]:
152
+ """Find symbols similar to a specific target."""
153
+ target_node = graph.get_node(target_id)
154
+ if not target_node or not target_node.metadata.get("fingerprint"):
155
+ return []
156
+
157
+ target_fp = target_node.metadata["fingerprint"]
158
+ all_nodes = graph.all_nodes(include_proposed=False)
159
+
160
+ results = []
161
+ for node in all_nodes:
162
+ if node.id == target_id:
163
+ continue
164
+ if not node.metadata.get("fingerprint"):
165
+ continue
166
+
167
+ score = _similarity_score(target_fp, node.metadata["fingerprint"])
168
+ if score >= threshold:
169
+ results.append({
170
+ "id": node.id,
171
+ "name": node.name,
172
+ "qualified_name": node.qualified_name,
173
+ "symbol_type": node.symbol_type.value,
174
+ "similarity": round(score, 3),
175
+ "file": node.file_path,
176
+ "signature": node.signature or "",
177
+ "docstring": (node.docstring or "")[:200],
178
+ })
179
+
180
+ results.sort(key=lambda r: r["similarity"], reverse=True)
181
+ return results
182
+
183
+
184
+ def get_rich_context(graph: CodeGraph, node: NodeData) -> dict:
185
+ """Get rich context for a symbol: source, docstring, connections, fingerprint."""
186
+ context: dict[str, Any] = {
187
+ "id": node.id,
188
+ "name": node.name,
189
+ "qualified_name": node.qualified_name,
190
+ "symbol_type": node.symbol_type.value,
191
+ "file": node.file_path,
192
+ "lines": f"{node.line_start}-{node.line_end}" if node.line_start else None,
193
+ "signature": node.signature,
194
+ "docstring": node.docstring,
195
+ "is_dead": node.is_dead,
196
+ }
197
+
198
+ # Source snippet
199
+ if node.file_path and node.line_start and node.line_end:
200
+ try:
201
+ lines = Path(node.file_path).read_text(encoding="utf-8", errors="replace").splitlines()
202
+ start = max(0, node.line_start - 1)
203
+ end = min(len(lines), node.line_end)
204
+ context["source"] = "\n".join(lines[start:end])
205
+ except Exception:
206
+ context["source"] = None
207
+ else:
208
+ context["source"] = None
209
+
210
+ # Comments above the function (look for comment block just before line_start)
211
+ if node.file_path and node.line_start:
212
+ try:
213
+ lines = Path(node.file_path).read_text(encoding="utf-8", errors="replace").splitlines()
214
+ comments = []
215
+ i = node.line_start - 2 # 0-indexed, line before
216
+ while i >= 0 and lines[i].strip().startswith("#"):
217
+ comments.insert(0, lines[i].strip())
218
+ i -= 1
219
+ context["preceding_comments"] = "\n".join(comments) if comments else None
220
+ except Exception:
221
+ context["preceding_comments"] = None
222
+ else:
223
+ context["preceding_comments"] = None
224
+
225
+ # Connections
226
+ callers = graph.callers_of(node.id)
227
+ callees = graph.callees_of(node.id)
228
+ context["callers"] = [{"id": n.id, "name": n.name} for n in callers[:20]]
229
+ context["callees"] = [{"id": n.id, "name": n.name} for n in callees[:20]]
230
+
231
+ # Fingerprint
232
+ context["fingerprint"] = node.metadata.get("fingerprint")
233
+
234
+ return context
235
+
236
+
237
+ # ── Internal: fingerprint computation ────────────────────────────────
238
+
239
+ def _compute_fingerprint(node: NodeData, graph: CodeGraph) -> StructuralFingerprint:
240
+ """Compute a structural fingerprint for a function/method."""
241
+ fp = StructuralFingerprint(
242
+ node_id=node.id,
243
+ name=node.name,
244
+ qualified_name=node.qualified_name,
245
+ symbol_type=node.symbol_type,
246
+ docstring=node.docstring or "",
247
+ )
248
+
249
+ # Parse the source to get AST shape
250
+ if node.file_path and node.line_start and node.line_end:
251
+ try:
252
+ source = Path(node.file_path).read_text(encoding="utf-8", errors="replace")
253
+ tree = ast.parse(source, filename=node.file_path)
254
+ func_node = _find_ast_node(tree, node.line_start)
255
+ if func_node:
256
+ _analyze_ast_shape(func_node, fp)
257
+ except Exception:
258
+ pass
259
+
260
+ # Graph-based features — use resolved qualified names for accurate comparison
261
+ G = graph._g
262
+ if node.id in G:
263
+ fp.callees = frozenset(
264
+ v for _, v, d in G.out_edges(node.id, data=True)
265
+ if d.get("edge_type") == "calls"
266
+ )
267
+ fp.callers = frozenset(
268
+ u for u, _, d in G.in_edges(node.id, data=True)
269
+ if d.get("edge_type") == "calls"
270
+ )
271
+ fp.reads = frozenset(
272
+ v for _, v, d in G.out_edges(node.id, data=True)
273
+ if d.get("edge_type") == "reads"
274
+ )
275
+ fp.writes = frozenset(
276
+ v for _, v, d in G.out_edges(node.id, data=True)
277
+ if d.get("edge_type") == "writes"
278
+ )
279
+
280
+ # Use PARAMETER child nodes from the graph (richer than re-parsing AST)
281
+ param_nodes = [
282
+ graph.get_node(v)
283
+ for _, v, d in G.out_edges(node.id, data=True)
284
+ if d.get("edge_type") == "contains"
285
+ and graph.get_node(v)
286
+ and graph.get_node(v).symbol_type == SymbolType.PARAMETER
287
+ ] if node.id in G else []
288
+ if param_nodes:
289
+ fp.arg_count = len([p for p in param_nodes if p.name not in ("self", "cls")])
290
+ fp.arg_names = tuple(p.name for p in param_nodes if p.name not in ("self", "cls"))
291
+
292
+ # Line count
293
+ if node.line_start and node.line_end:
294
+ fp.line_count = node.line_end - node.line_start + 1
295
+
296
+ # Source snippet for context
297
+ if node.file_path and node.line_start and node.line_end:
298
+ try:
299
+ lines = Path(node.file_path).read_text(encoding="utf-8", errors="replace").splitlines()
300
+ start = max(0, node.line_start - 1)
301
+ end = min(len(lines), min(node.line_end, node.line_start + 30))
302
+ fp.source_snippet = "\n".join(lines[start:end])
303
+ except Exception:
304
+ pass
305
+
306
+ return fp
307
+
308
+
309
+ def _compute_class_fingerprint(node: NodeData, graph: CodeGraph) -> StructuralFingerprint:
310
+ """Compute a fingerprint for a class based on its methods and structure."""
311
+ fp = StructuralFingerprint(
312
+ node_id=node.id,
313
+ name=node.name,
314
+ qualified_name=node.qualified_name,
315
+ symbol_type=node.symbol_type,
316
+ docstring=node.docstring or "",
317
+ )
318
+
319
+ # Get method names and count
320
+ methods = [
321
+ e.target for e in graph.edges_from(node.id, EdgeType.CONTAINS)
322
+ if graph.get_node(e.target) and
323
+ graph.get_node(e.target).symbol_type == SymbolType.METHOD
324
+ ]
325
+ fp.arg_count = len(methods)
326
+ fp.arg_names = tuple(sorted(m.split(".")[-1] for m in methods))
327
+
328
+ # Aggregate callees/callers across all methods
329
+ all_callees: set[str] = set()
330
+ all_callers: set[str] = set()
331
+ for mid in methods:
332
+ for e in graph.edges_from(mid, EdgeType.CALLS):
333
+ all_callees.add(e.target.split(".")[-1])
334
+ for e in graph.edges_to(mid, EdgeType.CALLS):
335
+ all_callers.add(e.source.split(".")[-1])
336
+
337
+ fp.callees = frozenset(all_callees)
338
+ fp.callers = frozenset(all_callers)
339
+
340
+ if node.line_start and node.line_end:
341
+ fp.line_count = node.line_end - node.line_start + 1
342
+
343
+ return fp
344
+
345
+
346
+ def _find_ast_node(tree: ast.Module, target_line: int) -> ast.AST | None:
347
+ """Find the AST node at a specific line number."""
348
+ for node in ast.walk(tree):
349
+ if hasattr(node, "lineno") and node.lineno == target_line:
350
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
351
+ return node
352
+ return None
353
+
354
+
355
+ def _analyze_ast_shape(node: ast.AST, fp: StructuralFingerprint) -> None:
356
+ """Analyze the AST shape of a function."""
357
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
358
+ args = node.args
359
+ fp.arg_count = len(args.args) + len(args.posonlyargs) + len(args.kwonlyargs)
360
+ fp.arg_names = tuple(a.arg for a in args.args if a.arg != "self")
361
+ if node.returns:
362
+ fp.return_annotation = ast.dump(node.returns)
363
+ fp.has_await = isinstance(node, ast.AsyncFunctionDef)
364
+
365
+ # Count AST node types and detect patterns
366
+ node_counts: Counter[str] = Counter()
367
+ max_depth = [0]
368
+
369
+ def _walk_depth(n: ast.AST, depth: int) -> None:
370
+ node_counts[type(n).__name__] += 1
371
+ max_depth[0] = max(max_depth[0], depth)
372
+
373
+ if isinstance(n, (ast.For, ast.While, ast.AsyncFor)):
374
+ fp.has_loops = True
375
+ if isinstance(n, (ast.If, ast.IfExp)):
376
+ fp.has_conditionals = True
377
+ if isinstance(n, (ast.Try, ast.ExceptHandler)):
378
+ fp.has_try_except = True
379
+ if isinstance(n, (ast.Yield, ast.YieldFrom)):
380
+ fp.has_yield = True
381
+ if isinstance(n, (ast.Await,)):
382
+ fp.has_await = True
383
+
384
+ for child in ast.iter_child_nodes(n):
385
+ _walk_depth(child, depth + 1)
386
+
387
+ _walk_depth(node, 0)
388
+ fp.ast_node_counts = dict(node_counts)
389
+ fp.max_nesting_depth = max_depth[0]
390
+
391
+
392
+ # ── Internal: similarity scoring ─────────────────────────────────────
393
+
394
+ def _similarity_score(fp_a: dict, fp_b: dict) -> float:
395
+ """Compute similarity between two fingerprint dicts. Returns 0.0-1.0."""
396
+ scores: list[tuple[float, float]] = [] # (score, weight)
397
+
398
+ # Argument pattern similarity
399
+ args_a = set(fp_a.get("arg_names", []))
400
+ args_b = set(fp_b.get("arg_names", []))
401
+ if args_a or args_b:
402
+ arg_sim = len(args_a & args_b) / max(len(args_a | args_b), 1)
403
+ scores.append((arg_sim, 2.0))
404
+
405
+ # Arg count similarity
406
+ ac_a = fp_a.get("arg_count", 0)
407
+ ac_b = fp_b.get("arg_count", 0)
408
+ if ac_a + ac_b > 0:
409
+ scores.append((1.0 - abs(ac_a - ac_b) / max(ac_a + ac_b, 1), 1.0))
410
+
411
+ # Line count similarity
412
+ lc_a = fp_a.get("line_count", 0)
413
+ lc_b = fp_b.get("line_count", 0)
414
+ if lc_a > 0 and lc_b > 0:
415
+ scores.append((1.0 - abs(lc_a - lc_b) / max(lc_a, lc_b), 1.0))
416
+
417
+ # AST shape similarity (cosine similarity of node type counts)
418
+ ast_a = fp_a.get("ast_node_counts", {})
419
+ ast_b = fp_b.get("ast_node_counts", {})
420
+ if ast_a and ast_b:
421
+ ast_sim = _cosine_similarity(ast_a, ast_b)
422
+ scores.append((ast_sim, 3.0)) # Heavy weight — this is the shape
423
+
424
+ # Control flow pattern match
425
+ flow_features = ["has_loops", "has_conditionals", "has_try_except", "has_yield", "has_await"]
426
+ flow_match = sum(1 for f in flow_features if fp_a.get(f) == fp_b.get(f))
427
+ scores.append((flow_match / len(flow_features), 1.5))
428
+
429
+ # Callee overlap (what they call)
430
+ callees_a = set(fp_a.get("callees", []))
431
+ callees_b = set(fp_b.get("callees", []))
432
+ if callees_a or callees_b:
433
+ callee_sim = len(callees_a & callees_b) / max(len(callees_a | callees_b), 1)
434
+ scores.append((callee_sim, 2.5)) # Strong signal
435
+
436
+ # Read/write variable overlap
437
+ reads_a = set(fp_a.get("reads", []))
438
+ reads_b = set(fp_b.get("reads", []))
439
+ if reads_a or reads_b:
440
+ read_sim = len(reads_a & reads_b) / max(len(reads_a | reads_b), 1)
441
+ scores.append((read_sim, 1.5))
442
+
443
+ # Nesting depth similarity
444
+ nd_a = fp_a.get("max_nesting_depth", 0)
445
+ nd_b = fp_b.get("max_nesting_depth", 0)
446
+ if nd_a > 0 or nd_b > 0:
447
+ scores.append((1.0 - abs(nd_a - nd_b) / max(nd_a, nd_b, 1), 0.5))
448
+
449
+ if not scores:
450
+ return 0.0
451
+
452
+ total_weight = sum(w for _, w in scores)
453
+ weighted_sum = sum(s * w for s, w in scores)
454
+ return weighted_sum / total_weight
455
+
456
+
457
+ def _cosine_similarity(a: dict[str, int], b: dict[str, int]) -> float:
458
+ """Cosine similarity between two sparse vectors."""
459
+ all_keys = set(a) | set(b)
460
+ dot = sum(a.get(k, 0) * b.get(k, 0) for k in all_keys)
461
+ mag_a = math.sqrt(sum(v * v for v in a.values()))
462
+ mag_b = math.sqrt(sum(v * v for v in b.values()))
463
+ if mag_a == 0 or mag_b == 0:
464
+ return 0.0
465
+ return dot / (mag_a * mag_b)
466
+
467
+
468
+ def _fingerprint_to_dict(fp: StructuralFingerprint) -> dict:
469
+ """Convert a fingerprint to a serializable dict."""
470
+ return {
471
+ "arg_count": fp.arg_count,
472
+ "arg_names": list(fp.arg_names),
473
+ "return_annotation": fp.return_annotation,
474
+ "line_count": fp.line_count,
475
+ "ast_node_counts": fp.ast_node_counts,
476
+ "max_nesting_depth": fp.max_nesting_depth,
477
+ "has_loops": fp.has_loops,
478
+ "has_conditionals": fp.has_conditionals,
479
+ "has_try_except": fp.has_try_except,
480
+ "has_yield": fp.has_yield,
481
+ "has_await": fp.has_await,
482
+ "callees": list(fp.callees),
483
+ "callers": list(fp.callers),
484
+ "reads": list(fp.reads),
485
+ "writes": list(fp.writes),
486
+ }
interlinked/cli.py ADDED
@@ -0,0 +1,136 @@
1
+ """CLI entry point — `interlinked analyze ./project` or `interlinked repl ./project`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import webbrowser
8
+ from pathlib import Path
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(
13
+ prog="interlinked",
14
+ description="Interlinked — A Python program topology explorer",
15
+ )
16
+ sub = parser.add_subparsers(dest="command")
17
+
18
+ # ── analyze (default: launch web UI) ─────────────────────────
19
+ analyze_p = sub.add_parser("analyze", help="Analyze a project and launch the web UI")
20
+ analyze_p.add_argument("path", type=str, help="Path to the Python project root")
21
+ analyze_p.add_argument("--port", type=int, default=8420, help="Port for the web server")
22
+ analyze_p.add_argument("--host", type=str, default="127.0.0.1", help="Host to bind to")
23
+ analyze_p.add_argument("--no-browser", action="store_true", help="Don't auto-open browser")
24
+
25
+ # ── repl ─────────────────────────────────────────────────────
26
+ repl_p = sub.add_parser("repl", help="Analyze and drop into interactive REPL")
27
+ repl_p.add_argument("path", type=str, help="Path to the Python project root")
28
+
29
+ # ── stats ────────────────────────────────────────────────────
30
+ stats_p = sub.add_parser("stats", help="Print project statistics and exit")
31
+ stats_p.add_argument("path", type=str, help="Path to the Python project root")
32
+
33
+ # ── mcp ───────────────────────────────────────────────────────
34
+ mcp_p = sub.add_parser("mcp", help="Run as an MCP server (stdio transport) for Windsurf/Claude Desktop")
35
+ mcp_p.add_argument("path", type=str, help="Path to the Python project root")
36
+
37
+ args = parser.parse_args()
38
+
39
+ if not args.command:
40
+ parser.print_help()
41
+ sys.exit(1)
42
+
43
+ project_path = Path(args.path).resolve()
44
+ if not project_path.exists():
45
+ print(f"Error: path '{project_path}' does not exist.")
46
+ sys.exit(1)
47
+
48
+ if args.command == "mcp":
49
+ _run_mcp(project_path)
50
+ return
51
+
52
+ # Build the graph
53
+ graph = _build_graph(project_path)
54
+
55
+ if args.command == "analyze":
56
+ _run_server(graph, args.host, args.port, not args.no_browser, project_path=str(project_path))
57
+ elif args.command == "repl":
58
+ _run_repl(graph)
59
+ elif args.command == "stats":
60
+ _print_stats(graph)
61
+
62
+
63
+ def _build_graph(project_path: Path):
64
+ """Parse the project and build the CodeGraph."""
65
+ from interlinked.analyzer.parser import parse_project
66
+ from interlinked.analyzer.graph import CodeGraph
67
+ from interlinked.analyzer.dead_code import detect_dead_code
68
+
69
+ print(f"Analyzing {project_path} ...")
70
+ nodes, edges = parse_project(project_path)
71
+ print(f" Found {len(nodes)} symbols, {len(edges)} relationships")
72
+
73
+ graph = CodeGraph()
74
+ graph.build_from(nodes, edges)
75
+
76
+ dead_ids = detect_dead_code(graph)
77
+ print(f" Detected {len(dead_ids)} potentially dead symbols")
78
+
79
+ # Structural fingerprinting for similarity/duplicate detection
80
+ try:
81
+ from interlinked.analyzer.similarity import analyze_similarity
82
+ analyze_similarity(graph)
83
+ print(f" Computed structural fingerprints for similarity analysis")
84
+ except Exception as e:
85
+ print(f" Warning: similarity analysis failed: {e}")
86
+
87
+ return graph
88
+
89
+
90
+ def _run_server(graph, host: str, port: int, open_browser: bool, project_path: str = "") -> None:
91
+ """Start the FastAPI web server."""
92
+ import uvicorn
93
+ from interlinked.visualizer.server import create_app
94
+
95
+ app = create_app(graph, initial_path=project_path)
96
+ url = f"http://{host}:{port}"
97
+ print(f"\n Interlinked running at {url}")
98
+ print(f" Press Ctrl+C to stop\n")
99
+
100
+ if open_browser:
101
+ webbrowser.open(url)
102
+
103
+ uvicorn.run(app, host=host, port=port, log_level="warning")
104
+
105
+
106
+ def _run_repl(graph) -> None:
107
+ """Start the interactive REPL."""
108
+ from interlinked.commander.repl import InterlinkedREPL
109
+ repl = InterlinkedREPL(graph)
110
+ repl.start()
111
+
112
+
113
+ def _run_mcp(project_path: Path) -> None:
114
+ """Run as an MCP server over stdio."""
115
+ import asyncio
116
+ from interlinked.mcp_server import run_mcp_stdio
117
+ asyncio.run(run_mcp_stdio(str(project_path)))
118
+
119
+
120
+ def _print_stats(graph) -> None:
121
+ """Print statistics and exit."""
122
+ from interlinked.commander.query import QueryEngine
123
+ engine = QueryEngine(graph)
124
+ stats = engine.stats()
125
+
126
+ print("\n╔══════════════════════════════════════════════════╗")
127
+ print("║ INTERLINKED — Project Stats ║")
128
+ print("╚══════════════════════════════════════════════════╝\n")
129
+ for key, value in stats.items():
130
+ label = key.replace("_", " ").title()
131
+ print(f" {label:.<30} {value}")
132
+ print()
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
@@ -0,0 +1,6 @@
1
+ """Commander — query DSL and LLM control interface."""
2
+
3
+ from interlinked.commander.query import QueryEngine
4
+ from interlinked.commander.repl import InterlinkedREPL
5
+
6
+ __all__ = ["QueryEngine", "InterlinkedREPL"]