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,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()
|