interlinked-mapper 0.3.8__tar.gz → 0.3.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/PKG-INFO +1 -1
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/analyzer/graph.py +27 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/analyzer/similarity.py +10 -5
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/commander/query.py +28 -2
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/mcp_server.py +2 -11
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/PKG-INFO +1 -1
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/SOURCES.txt +2 -1
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/pyproject.toml +1 -1
- interlinked_mapper-0.3.10/tests/test_query_completeness.py +506 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/__init__.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/analyzer/__init__.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/analyzer/dead_code.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/analyzer/embeddings.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/analyzer/parser.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/cli.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/commander/__init__.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/commander/llm.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/commander/repl.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/models.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/__init__.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/dist/assets/index-CyhrxsQU.css +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/dist/assets/index-Dh01aXoE.js +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/dist/index.html +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/index.html +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/index.html.d3-legacy +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/package-lock.json +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/package.json +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/App.tsx +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/graph/GraphCanvas.tsx +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/graph/nodePrograms.ts +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/index.css +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/main.tsx +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/state/graphStore.ts +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/state/sseClient.ts +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/theme.ts +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/types.ts +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/vite-env.d.ts +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/tsconfig.json +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/vite.config.ts +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/layouts.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/server.py +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/dependency_links.txt +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/entry_points.txt +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/requires.txt +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/top_level.txt +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/setup.cfg +0 -0
- {interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/tests/test_accuracy.py +0 -0
|
@@ -12,6 +12,27 @@ from interlinked.models import (
|
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
# Method names so common on builtins (dict, list, set, str, etc.) that resolving
|
|
16
|
+
# them to project symbols by bare-name matching is almost always a false positive.
|
|
17
|
+
# e.g. `op_dict.items()` should NOT resolve to `ActionCost.items`.
|
|
18
|
+
_BUILTIN_METHOD_NAMES: frozenset[str] = frozenset({
|
|
19
|
+
# dict
|
|
20
|
+
"items", "keys", "values", "get", "pop", "update", "setdefault",
|
|
21
|
+
"clear", "copy",
|
|
22
|
+
# list / sequence
|
|
23
|
+
"append", "extend", "insert", "remove", "sort", "reverse",
|
|
24
|
+
"count", "index",
|
|
25
|
+
# set
|
|
26
|
+
"add", "discard", "union", "intersection", "difference",
|
|
27
|
+
"issubset", "issuperset",
|
|
28
|
+
# str
|
|
29
|
+
"strip", "split", "join", "replace", "startswith", "endswith",
|
|
30
|
+
"lower", "upper", "format", "encode", "decode",
|
|
31
|
+
# general
|
|
32
|
+
"close", "read", "write", "flush", "seek", "tell",
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
|
|
15
36
|
class CodeGraph:
|
|
16
37
|
"""The core graph structure representing an entire Python project.
|
|
17
38
|
|
|
@@ -215,6 +236,12 @@ class CodeGraph:
|
|
|
215
236
|
source = next(iter(candidates))
|
|
216
237
|
|
|
217
238
|
if target not in node_ids:
|
|
239
|
+
# Never resolve bare builtin method names — they match too
|
|
240
|
+
# broadly (e.g. "items" matching ActionCost.items when the
|
|
241
|
+
# actual call is dict.items()).
|
|
242
|
+
if target in _BUILTIN_METHOD_NAMES:
|
|
243
|
+
return edge
|
|
244
|
+
|
|
218
245
|
candidates = name_index.get(target, set())
|
|
219
246
|
if len(candidates) == 1:
|
|
220
247
|
target = next(iter(candidates))
|
|
@@ -254,11 +254,16 @@ def get_rich_context(graph: CodeGraph, node: NodeData) -> dict:
|
|
|
254
254
|
# Connections
|
|
255
255
|
callers = graph.callers_of(node.id)
|
|
256
256
|
callees = graph.callees_of(node.id)
|
|
257
|
-
context["callers"] = [{"id": n.id, "name": n.name} for n in callers
|
|
258
|
-
context["callees"] = [{"id": n.id, "name": n.name} for n in callees
|
|
259
|
-
|
|
260
|
-
# Fingerprint
|
|
261
|
-
|
|
257
|
+
context["callers"] = [{"id": n.id, "name": n.name} for n in callers]
|
|
258
|
+
context["callees"] = [{"id": n.id, "name": n.name} for n in callees]
|
|
259
|
+
|
|
260
|
+
# Fingerprint — slim version (drop ast_tree, minhash, ast_node_counts)
|
|
261
|
+
fp = node.metadata.get("fingerprint")
|
|
262
|
+
if fp and isinstance(fp, dict):
|
|
263
|
+
_heavy = {"ast_tree", "minhash", "ast_node_counts", "source_snippet"}
|
|
264
|
+
context["fingerprint"] = {k: v for k, v in fp.items() if k not in _heavy}
|
|
265
|
+
else:
|
|
266
|
+
context["fingerprint"] = fp
|
|
262
267
|
|
|
263
268
|
return context
|
|
264
269
|
|
|
@@ -12,6 +12,30 @@ from interlinked.models import (
|
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
# Fields in node.metadata["fingerprint"] that are huge and useless in
|
|
16
|
+
# serialized query/snapshot output. Kept in memory for similarity scoring.
|
|
17
|
+
_FINGERPRINT_HEAVY_KEYS = frozenset({
|
|
18
|
+
"ast_tree", "minhash", "ast_node_counts",
|
|
19
|
+
"source_snippet",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _slim_node_dict(d: dict) -> dict:
|
|
24
|
+
"""Strip heavy fingerprint fields from a serialized NodeData dict."""
|
|
25
|
+
meta = d.get("metadata")
|
|
26
|
+
if not meta or "fingerprint" not in meta:
|
|
27
|
+
return d
|
|
28
|
+
fp = meta["fingerprint"]
|
|
29
|
+
if not isinstance(fp, dict):
|
|
30
|
+
return d
|
|
31
|
+
d = d.copy()
|
|
32
|
+
d["metadata"] = meta.copy()
|
|
33
|
+
d["metadata"]["fingerprint"] = {
|
|
34
|
+
k: v for k, v in fp.items() if k not in _FINGERPRINT_HEAVY_KEYS
|
|
35
|
+
}
|
|
36
|
+
return d
|
|
37
|
+
|
|
38
|
+
|
|
15
39
|
class QueryEngine:
|
|
16
40
|
"""Provides a high-level API for querying and manipulating the code graph.
|
|
17
41
|
|
|
@@ -407,7 +431,7 @@ class QueryEngine:
|
|
|
407
431
|
|
|
408
432
|
self._notify()
|
|
409
433
|
|
|
410
|
-
return [r.model_dump() for r in results]
|
|
434
|
+
return [_slim_node_dict(r.model_dump()) for r in results]
|
|
411
435
|
|
|
412
436
|
def trace_variable(self, var_name: str, origin: str | None = None) -> str:
|
|
413
437
|
"""Trace a variable's path through reads/writes and highlight it.
|
|
@@ -1205,7 +1229,9 @@ class QueryEngine:
|
|
|
1205
1229
|
|
|
1206
1230
|
def snapshot(self) -> dict:
|
|
1207
1231
|
"""Get the current graph snapshot as a dict (for JSON serialization)."""
|
|
1208
|
-
|
|
1232
|
+
snap = self.graph.snapshot(self.state).model_dump()
|
|
1233
|
+
snap["nodes"] = [_slim_node_dict(n) for n in snap.get("nodes", [])]
|
|
1234
|
+
return snap
|
|
1209
1235
|
|
|
1210
1236
|
# ── Stats ────────────────────────────────────────────────────────
|
|
1211
1237
|
|
|
@@ -563,10 +563,7 @@ def _dispatch_via_server(name: str, args: dict[str, Any], server_url: str) -> st
|
|
|
563
563
|
return json.dumps(result, indent=2)
|
|
564
564
|
return str(result)
|
|
565
565
|
if "results" in data:
|
|
566
|
-
|
|
567
|
-
if len(results) > 20:
|
|
568
|
-
return f"Found {len(results)} results. Showing first 20:\n" + json.dumps(results[:20], indent=2)
|
|
569
|
-
return json.dumps(results, indent=2)
|
|
566
|
+
return json.dumps(data["results"], indent=2)
|
|
570
567
|
if "error" in data:
|
|
571
568
|
return f"Error: {data['error']}"
|
|
572
569
|
return json.dumps(data, indent=2)
|
|
@@ -654,10 +651,7 @@ async def _async_dispatch_via_server(name: str, args: dict[str, Any], server_url
|
|
|
654
651
|
return json.dumps(result, indent=2)
|
|
655
652
|
return str(result)
|
|
656
653
|
if "results" in data:
|
|
657
|
-
|
|
658
|
-
if len(results) > 20:
|
|
659
|
-
return f"Found {len(results)} results. Showing first 20:\n" + json.dumps(results[:20], indent=2)
|
|
660
|
-
return json.dumps(results, indent=2)
|
|
654
|
+
return json.dumps(data["results"], indent=2)
|
|
661
655
|
if "error" in data:
|
|
662
656
|
return f"Error: {data['error']}"
|
|
663
657
|
return json.dumps(data, indent=2)
|
|
@@ -692,9 +686,6 @@ def _dispatch_tool(
|
|
|
692
686
|
|
|
693
687
|
elif name == "interlinked_query":
|
|
694
688
|
results = engine.query(args["expression"])
|
|
695
|
-
if len(results) > 20:
|
|
696
|
-
summary = f"Found {len(results)} results. Showing first 20:\n"
|
|
697
|
-
return summary + json.dumps(results[:20], indent=2)
|
|
698
689
|
return json.dumps(results, indent=2)
|
|
699
690
|
|
|
700
691
|
elif name == "interlinked_trace_variable":
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/SOURCES.txt
RENAMED
|
@@ -41,4 +41,5 @@ interlinked_mapper.egg-info/dependency_links.txt
|
|
|
41
41
|
interlinked_mapper.egg-info/entry_points.txt
|
|
42
42
|
interlinked_mapper.egg-info/requires.txt
|
|
43
43
|
interlinked_mapper.egg-info/top_level.txt
|
|
44
|
-
tests/test_accuracy.py
|
|
44
|
+
tests/test_accuracy.py
|
|
45
|
+
tests/test_query_completeness.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "interlinked-mapper"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.10"
|
|
8
8
|
description = "A Python program topology explorer — visualize the shape of your codebase"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Tests that every query type returns FULL untruncated results.
|
|
2
|
+
|
|
3
|
+
No result set should ever be silently capped or truncated.
|
|
4
|
+
Every query path in QueryEngine.query() and every MCP dispatch path
|
|
5
|
+
must return all matching results.
|
|
6
|
+
|
|
7
|
+
Run: pytest tests/test_query_completeness.py -v
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from interlinked.analyzer.parser import parse_project
|
|
18
|
+
from interlinked.analyzer.graph import CodeGraph
|
|
19
|
+
from interlinked.analyzer.dead_code import detect_dead_code
|
|
20
|
+
from interlinked.commander.query import QueryEngine, _slim_node_dict
|
|
21
|
+
from interlinked.models import SymbolType, EdgeType, NodeData
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build() -> tuple[CodeGraph, QueryEngine]:
|
|
28
|
+
nodes, edges = parse_project(str(FIXTURES))
|
|
29
|
+
graph = CodeGraph()
|
|
30
|
+
graph.build_from(nodes, edges)
|
|
31
|
+
detect_dead_code(graph)
|
|
32
|
+
engine = QueryEngine(graph)
|
|
33
|
+
return graph, engine
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
37
|
+
# 1. QUERY COMPLETENESS — every query type returns all results
|
|
38
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestQueryCompleteness:
|
|
42
|
+
"""Every query type must return the full result set, never truncated."""
|
|
43
|
+
|
|
44
|
+
@pytest.fixture(autouse=True)
|
|
45
|
+
def setup(self):
|
|
46
|
+
self.graph, self.engine = _build()
|
|
47
|
+
|
|
48
|
+
def _count_by_type(self, sym_type: SymbolType) -> int:
|
|
49
|
+
return len(self.graph.nodes_by_type(sym_type))
|
|
50
|
+
|
|
51
|
+
def _count_dead(self, type_filter: set[SymbolType] | None = None) -> int:
|
|
52
|
+
return len([
|
|
53
|
+
n for n in self.graph.all_nodes()
|
|
54
|
+
if n.is_dead
|
|
55
|
+
and (type_filter is None or n.symbol_type in type_filter)
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
# ── "functions" / "methods" ──────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def test_functions_query_returns_all(self):
|
|
61
|
+
results = self.engine.query("functions")
|
|
62
|
+
expected = (
|
|
63
|
+
self._count_by_type(SymbolType.FUNCTION)
|
|
64
|
+
+ self._count_by_type(SymbolType.METHOD)
|
|
65
|
+
)
|
|
66
|
+
assert len(results) == expected, \
|
|
67
|
+
f"'functions' query returned {len(results)}, expected {expected}"
|
|
68
|
+
|
|
69
|
+
def test_methods_query_returns_all(self):
|
|
70
|
+
results = self.engine.query("methods")
|
|
71
|
+
expected = (
|
|
72
|
+
self._count_by_type(SymbolType.FUNCTION)
|
|
73
|
+
+ self._count_by_type(SymbolType.METHOD)
|
|
74
|
+
)
|
|
75
|
+
assert len(results) == expected, \
|
|
76
|
+
f"'methods' query returned {len(results)}, expected {expected}"
|
|
77
|
+
|
|
78
|
+
# ── "classes" ────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def test_classes_query_returns_all(self):
|
|
81
|
+
results = self.engine.query("classes")
|
|
82
|
+
expected = self._count_by_type(SymbolType.CLASS)
|
|
83
|
+
assert len(results) == expected, \
|
|
84
|
+
f"'classes' query returned {len(results)}, expected {expected}"
|
|
85
|
+
|
|
86
|
+
# ── "modules" ────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def test_modules_query_returns_all(self):
|
|
89
|
+
results = self.engine.query("modules")
|
|
90
|
+
expected = self._count_by_type(SymbolType.MODULE)
|
|
91
|
+
assert len(results) == expected, \
|
|
92
|
+
f"'modules' query returned {len(results)}, expected {expected}"
|
|
93
|
+
|
|
94
|
+
# ── "variables" ──────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def test_variables_query_returns_all(self):
|
|
97
|
+
results = self.engine.query("variables")
|
|
98
|
+
expected = len([
|
|
99
|
+
n for n in self.graph.all_nodes()
|
|
100
|
+
if n.symbol_type == SymbolType.VARIABLE
|
|
101
|
+
])
|
|
102
|
+
assert len(results) == expected, \
|
|
103
|
+
f"'variables' query returned {len(results)}, expected {expected}"
|
|
104
|
+
|
|
105
|
+
# ── "parameters" ─────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def test_parameters_query_returns_all(self):
|
|
108
|
+
results = self.engine.query("parameters")
|
|
109
|
+
expected = len([
|
|
110
|
+
n for n in self.graph.all_nodes()
|
|
111
|
+
if n.symbol_type == SymbolType.PARAMETER
|
|
112
|
+
])
|
|
113
|
+
assert len(results) == expected, \
|
|
114
|
+
f"'parameters' query returned {len(results)}, expected {expected}"
|
|
115
|
+
|
|
116
|
+
# ── "dead functions" ─────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def test_dead_functions_returns_all(self):
|
|
119
|
+
results = self.engine.query("dead functions")
|
|
120
|
+
expected = self._count_dead({SymbolType.FUNCTION, SymbolType.METHOD})
|
|
121
|
+
assert len(results) == expected, \
|
|
122
|
+
f"'dead functions' returned {len(results)}, expected {expected}"
|
|
123
|
+
|
|
124
|
+
def test_dead_returns_all(self):
|
|
125
|
+
results = self.engine.query("dead")
|
|
126
|
+
expected = self._count_dead()
|
|
127
|
+
assert len(results) == expected, \
|
|
128
|
+
f"'dead' returned {len(results)}, expected {expected}"
|
|
129
|
+
|
|
130
|
+
# ── "callers of X" ───────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def test_callers_of_returns_all(self):
|
|
133
|
+
# Pick a node that has callers
|
|
134
|
+
for node in self.graph.all_nodes():
|
|
135
|
+
callers = self.graph.callers_of(node.id)
|
|
136
|
+
if len(callers) >= 2:
|
|
137
|
+
results = self.engine.query(f"callers of {node.qualified_name}")
|
|
138
|
+
assert len(results) == len(callers), \
|
|
139
|
+
f"'callers of {node.name}' returned {len(results)}, expected {len(callers)}"
|
|
140
|
+
return
|
|
141
|
+
pytest.skip("No node with >=2 callers found in fixtures")
|
|
142
|
+
|
|
143
|
+
# ── "callees of X" ───────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
def test_callees_of_returns_all(self):
|
|
146
|
+
for node in self.graph.all_nodes():
|
|
147
|
+
callees = self.graph.callees_of(node.id)
|
|
148
|
+
if len(callees) >= 2:
|
|
149
|
+
results = self.engine.query(f"callees of {node.qualified_name}")
|
|
150
|
+
assert len(results) == len(callees), \
|
|
151
|
+
f"'callees of {node.name}' returned {len(results)}, expected {len(callees)}"
|
|
152
|
+
return
|
|
153
|
+
pytest.skip("No node with >=2 callees found in fixtures")
|
|
154
|
+
|
|
155
|
+
# ── "parameters of X" ────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def test_parameters_of_returns_all(self):
|
|
158
|
+
for node in self.graph.all_nodes():
|
|
159
|
+
if node.symbol_type not in (SymbolType.FUNCTION, SymbolType.METHOD):
|
|
160
|
+
continue
|
|
161
|
+
G = self.graph._g
|
|
162
|
+
if node.id not in G:
|
|
163
|
+
continue
|
|
164
|
+
param_ids = [
|
|
165
|
+
v for _, v, d in G.out_edges(node.id, data=True)
|
|
166
|
+
if d.get("edge_type") == "contains"
|
|
167
|
+
and self.graph.get_node(v)
|
|
168
|
+
and self.graph.get_node(v).symbol_type == SymbolType.PARAMETER
|
|
169
|
+
]
|
|
170
|
+
if len(param_ids) >= 2:
|
|
171
|
+
results = self.engine.query(f"parameters of {node.qualified_name}")
|
|
172
|
+
assert len(results) == len(param_ids), \
|
|
173
|
+
f"'parameters of {node.name}' returned {len(results)}, expected {len(param_ids)}"
|
|
174
|
+
return
|
|
175
|
+
pytest.skip("No function with >=2 params found in fixtures")
|
|
176
|
+
|
|
177
|
+
# ── "returns of X" ───────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
def test_returns_of_returns_all(self):
|
|
180
|
+
for node in self.graph.all_nodes():
|
|
181
|
+
edges = self.graph.edges_from(node.id, EdgeType.RETURNS)
|
|
182
|
+
ret_nodes = [self.graph.get_node(e.target) for e in edges if self.graph.get_node(e.target)]
|
|
183
|
+
if len(ret_nodes) >= 1:
|
|
184
|
+
results = self.engine.query(f"returns of {node.qualified_name}")
|
|
185
|
+
assert len(results) == len(ret_nodes), \
|
|
186
|
+
f"'returns of {node.name}' returned {len(results)}, expected {len(ret_nodes)}"
|
|
187
|
+
return
|
|
188
|
+
pytest.skip("No function with return edges found in fixtures")
|
|
189
|
+
|
|
190
|
+
# ── "imports of X" ───────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
def test_imports_of_returns_all(self):
|
|
193
|
+
for node in self.graph.all_nodes():
|
|
194
|
+
if node.symbol_type != SymbolType.MODULE:
|
|
195
|
+
continue
|
|
196
|
+
edges = self.graph.edges_from(node.id, EdgeType.IMPORTS)
|
|
197
|
+
if len(edges) >= 1:
|
|
198
|
+
results = self.engine.query(f"imports of {node.qualified_name}")
|
|
199
|
+
assert len(results) == len(edges), \
|
|
200
|
+
f"'imports of {node.name}' returned {len(results)}, expected {len(edges)}"
|
|
201
|
+
return
|
|
202
|
+
pytest.skip("No module with imports found in fixtures")
|
|
203
|
+
|
|
204
|
+
# ── "external calls" ─────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def test_external_calls_returns_all(self):
|
|
207
|
+
node_ids = {n.id for n in self.graph.all_nodes()}
|
|
208
|
+
expected = [
|
|
209
|
+
e for e in self.graph.all_edges()
|
|
210
|
+
if e.edge_type == EdgeType.CALLS and e.target not in node_ids
|
|
211
|
+
]
|
|
212
|
+
results = self.engine.query("external calls")
|
|
213
|
+
assert len(results) == len(expected), \
|
|
214
|
+
f"'external calls' returned {len(results)}, expected {len(expected)}"
|
|
215
|
+
|
|
216
|
+
# ── scoped queries ───────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
def test_scoped_functions_returns_all(self):
|
|
219
|
+
# Find a module with multiple functions
|
|
220
|
+
modules = self.graph.nodes_by_type(SymbolType.MODULE)
|
|
221
|
+
for mod in modules:
|
|
222
|
+
scope = mod.qualified_name
|
|
223
|
+
expected = [
|
|
224
|
+
n for n in self.graph.all_nodes()
|
|
225
|
+
if n.symbol_type in (SymbolType.FUNCTION, SymbolType.METHOD)
|
|
226
|
+
and n.qualified_name.startswith(scope)
|
|
227
|
+
]
|
|
228
|
+
if len(expected) >= 3:
|
|
229
|
+
results = self.engine.query(f"functions in {scope}")
|
|
230
|
+
assert len(results) == len(expected), \
|
|
231
|
+
f"'functions in {scope}' returned {len(results)}, expected {len(expected)}"
|
|
232
|
+
return
|
|
233
|
+
pytest.skip("No module with >=3 functions found")
|
|
234
|
+
|
|
235
|
+
def test_scoped_classes_returns_all(self):
|
|
236
|
+
modules = self.graph.nodes_by_type(SymbolType.MODULE)
|
|
237
|
+
for mod in modules:
|
|
238
|
+
scope = mod.qualified_name
|
|
239
|
+
expected = [
|
|
240
|
+
n for n in self.graph.nodes_by_type(SymbolType.CLASS)
|
|
241
|
+
if n.qualified_name.startswith(scope)
|
|
242
|
+
]
|
|
243
|
+
if len(expected) >= 2:
|
|
244
|
+
results = self.engine.query(f"classes in {scope}")
|
|
245
|
+
assert len(results) == len(expected), \
|
|
246
|
+
f"'classes in {scope}' returned {len(results)}, expected {len(expected)}"
|
|
247
|
+
return
|
|
248
|
+
pytest.skip("No module with >=2 classes found")
|
|
249
|
+
|
|
250
|
+
# ── fuzzy name search ────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def test_fuzzy_search_returns_all(self):
|
|
253
|
+
# Search for a common substring
|
|
254
|
+
term = "process"
|
|
255
|
+
expected = [
|
|
256
|
+
n for n in self.graph.all_nodes()
|
|
257
|
+
if term in n.qualified_name.lower() or term in n.name.lower()
|
|
258
|
+
]
|
|
259
|
+
results = self.engine.query(term)
|
|
260
|
+
assert len(results) == len(expected), \
|
|
261
|
+
f"fuzzy search '{term}' returned {len(results)}, expected {len(expected)}"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
265
|
+
# 2. MCP DISPATCH — no truncation in the MCP layer
|
|
266
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TestMCPNoTruncation:
|
|
270
|
+
"""MCP _dispatch_tool must never truncate query results."""
|
|
271
|
+
|
|
272
|
+
@pytest.fixture(autouse=True)
|
|
273
|
+
def setup(self):
|
|
274
|
+
self.graph, self.engine = _build()
|
|
275
|
+
|
|
276
|
+
def _mcp_query(self, expression: str) -> list:
|
|
277
|
+
from interlinked.mcp_server import _dispatch_tool
|
|
278
|
+
self.engine.reset_filter()
|
|
279
|
+
raw = _dispatch_tool(
|
|
280
|
+
"interlinked_query",
|
|
281
|
+
{"expression": expression},
|
|
282
|
+
self.engine, self.graph, ""
|
|
283
|
+
)
|
|
284
|
+
return json.loads(raw)
|
|
285
|
+
|
|
286
|
+
def test_mcp_functions_not_truncated(self):
|
|
287
|
+
self.engine.reset_filter()
|
|
288
|
+
engine_results = self.engine.query("functions")
|
|
289
|
+
mcp_results = self._mcp_query("functions")
|
|
290
|
+
assert len(mcp_results) == len(engine_results), \
|
|
291
|
+
f"MCP truncated: {len(mcp_results)} vs engine {len(engine_results)}"
|
|
292
|
+
|
|
293
|
+
def test_mcp_classes_not_truncated(self):
|
|
294
|
+
self.engine.reset_filter()
|
|
295
|
+
engine_results = self.engine.query("classes")
|
|
296
|
+
mcp_results = self._mcp_query("classes")
|
|
297
|
+
assert len(mcp_results) == len(engine_results)
|
|
298
|
+
|
|
299
|
+
def test_mcp_modules_not_truncated(self):
|
|
300
|
+
self.engine.reset_filter()
|
|
301
|
+
engine_results = self.engine.query("modules")
|
|
302
|
+
mcp_results = self._mcp_query("modules")
|
|
303
|
+
assert len(mcp_results) == len(engine_results)
|
|
304
|
+
|
|
305
|
+
def test_mcp_dead_not_truncated(self):
|
|
306
|
+
self.engine.reset_filter()
|
|
307
|
+
engine_results = self.engine.query("dead")
|
|
308
|
+
mcp_results = self._mcp_query("dead")
|
|
309
|
+
assert len(mcp_results) == len(engine_results)
|
|
310
|
+
|
|
311
|
+
def test_mcp_variables_not_truncated(self):
|
|
312
|
+
self.engine.reset_filter()
|
|
313
|
+
engine_results = self.engine.query("variables")
|
|
314
|
+
mcp_results = self._mcp_query("variables")
|
|
315
|
+
assert len(mcp_results) == len(engine_results)
|
|
316
|
+
|
|
317
|
+
def test_mcp_parameters_not_truncated(self):
|
|
318
|
+
self.engine.reset_filter()
|
|
319
|
+
engine_results = self.engine.query("parameters")
|
|
320
|
+
mcp_results = self._mcp_query("parameters")
|
|
321
|
+
assert len(mcp_results) == len(engine_results)
|
|
322
|
+
|
|
323
|
+
def test_mcp_external_calls_not_truncated(self):
|
|
324
|
+
self.engine.reset_filter()
|
|
325
|
+
engine_results = self.engine.query("external calls")
|
|
326
|
+
mcp_results = self._mcp_query("external calls")
|
|
327
|
+
assert len(mcp_results) == len(engine_results)
|
|
328
|
+
|
|
329
|
+
def test_mcp_callers_not_truncated(self):
|
|
330
|
+
# Find a node with callers
|
|
331
|
+
for node in self.graph.all_nodes():
|
|
332
|
+
callers = self.graph.callers_of(node.id)
|
|
333
|
+
if len(callers) >= 2:
|
|
334
|
+
self.engine.reset_filter()
|
|
335
|
+
engine_results = self.engine.query(f"callers of {node.qualified_name}")
|
|
336
|
+
mcp_results = self._mcp_query(f"callers of {node.qualified_name}")
|
|
337
|
+
assert len(mcp_results) == len(engine_results), \
|
|
338
|
+
f"MCP truncated callers: {len(mcp_results)} vs {len(engine_results)}"
|
|
339
|
+
return
|
|
340
|
+
pytest.skip("No node with >=2 callers")
|
|
341
|
+
|
|
342
|
+
def test_mcp_callees_not_truncated(self):
|
|
343
|
+
for node in self.graph.all_nodes():
|
|
344
|
+
callees = self.graph.callees_of(node.id)
|
|
345
|
+
if len(callees) >= 2:
|
|
346
|
+
self.engine.reset_filter()
|
|
347
|
+
engine_results = self.engine.query(f"callees of {node.qualified_name}")
|
|
348
|
+
mcp_results = self._mcp_query(f"callees of {node.qualified_name}")
|
|
349
|
+
assert len(mcp_results) == len(engine_results), \
|
|
350
|
+
f"MCP truncated callees: {len(mcp_results)} vs {len(engine_results)}"
|
|
351
|
+
return
|
|
352
|
+
pytest.skip("No node with >=2 callees")
|
|
353
|
+
|
|
354
|
+
def test_mcp_fuzzy_search_not_truncated(self):
|
|
355
|
+
self.engine.reset_filter()
|
|
356
|
+
engine_results = self.engine.query("process")
|
|
357
|
+
mcp_results = self._mcp_query("process")
|
|
358
|
+
assert len(mcp_results) == len(engine_results)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
362
|
+
# 3. SLIM NODE — fingerprint stripping doesn't lose essential fields
|
|
363
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class TestSlimNode:
|
|
367
|
+
"""_slim_node_dict strips heavy fields but preserves everything else."""
|
|
368
|
+
|
|
369
|
+
def test_no_fingerprint_passthrough(self):
|
|
370
|
+
d = {"id": "a.b", "metadata": {}}
|
|
371
|
+
assert _slim_node_dict(d) == d
|
|
372
|
+
|
|
373
|
+
def test_strips_heavy_keys(self):
|
|
374
|
+
d = {
|
|
375
|
+
"id": "a.b",
|
|
376
|
+
"metadata": {
|
|
377
|
+
"fingerprint": {
|
|
378
|
+
"arg_count": 3,
|
|
379
|
+
"has_loops": True,
|
|
380
|
+
"ast_tree": ("FunctionDef", (("Return", ()),)),
|
|
381
|
+
"minhash": tuple(range(100)),
|
|
382
|
+
"ast_node_counts": {"FunctionDef": 1, "Return": 1},
|
|
383
|
+
"source_snippet": "def foo(): return 1",
|
|
384
|
+
"callees": ["bar"],
|
|
385
|
+
"callers": ["baz"],
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
result = _slim_node_dict(d)
|
|
390
|
+
fp = result["metadata"]["fingerprint"]
|
|
391
|
+
assert "ast_tree" not in fp
|
|
392
|
+
assert "minhash" not in fp
|
|
393
|
+
assert "ast_node_counts" not in fp
|
|
394
|
+
assert "source_snippet" not in fp
|
|
395
|
+
# Preserved fields
|
|
396
|
+
assert fp["arg_count"] == 3
|
|
397
|
+
assert fp["has_loops"] is True
|
|
398
|
+
assert fp["callees"] == ["bar"]
|
|
399
|
+
assert fp["callers"] == ["baz"]
|
|
400
|
+
|
|
401
|
+
def test_does_not_mutate_original(self):
|
|
402
|
+
d = {
|
|
403
|
+
"id": "a.b",
|
|
404
|
+
"metadata": {
|
|
405
|
+
"fingerprint": {
|
|
406
|
+
"arg_count": 3,
|
|
407
|
+
"minhash": (1, 2, 3),
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
_slim_node_dict(d)
|
|
412
|
+
assert "minhash" in d["metadata"]["fingerprint"], \
|
|
413
|
+
"_slim_node_dict must not mutate the original dict"
|
|
414
|
+
|
|
415
|
+
def test_snapshot_nodes_are_slim(self):
|
|
416
|
+
"""engine.snapshot() nodes must not contain heavy fingerprint fields."""
|
|
417
|
+
graph, engine = _build()
|
|
418
|
+
# Run similarity to populate fingerprints
|
|
419
|
+
try:
|
|
420
|
+
from interlinked.analyzer.similarity import analyze_similarity
|
|
421
|
+
analyze_similarity(graph)
|
|
422
|
+
except Exception:
|
|
423
|
+
pytest.skip("similarity module not available")
|
|
424
|
+
|
|
425
|
+
snap = engine.snapshot()
|
|
426
|
+
heavy_keys = {"ast_tree", "minhash", "ast_node_counts", "source_snippet"}
|
|
427
|
+
for node in snap["nodes"]:
|
|
428
|
+
fp = node.get("metadata", {}).get("fingerprint")
|
|
429
|
+
if fp:
|
|
430
|
+
found = heavy_keys & set(fp.keys())
|
|
431
|
+
assert not found, \
|
|
432
|
+
f"Node {node['id']} snapshot contains heavy keys: {found}"
|
|
433
|
+
|
|
434
|
+
def test_query_results_are_slim(self):
|
|
435
|
+
"""engine.query() results must not contain heavy fingerprint fields."""
|
|
436
|
+
graph, engine = _build()
|
|
437
|
+
try:
|
|
438
|
+
from interlinked.analyzer.similarity import analyze_similarity
|
|
439
|
+
analyze_similarity(graph)
|
|
440
|
+
except Exception:
|
|
441
|
+
pytest.skip("similarity module not available")
|
|
442
|
+
|
|
443
|
+
results = engine.query("functions")
|
|
444
|
+
heavy_keys = {"ast_tree", "minhash", "ast_node_counts", "source_snippet"}
|
|
445
|
+
for node in results:
|
|
446
|
+
fp = node.get("metadata", {}).get("fingerprint")
|
|
447
|
+
if fp:
|
|
448
|
+
found = heavy_keys & set(fp.keys())
|
|
449
|
+
assert not found, \
|
|
450
|
+
f"Query result {node['id']} contains heavy keys: {found}"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
454
|
+
# 4. GET_CONTEXT — callers/callees not truncated
|
|
455
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class TestGetContextCompleteness:
|
|
459
|
+
"""get_context must return full callers/callees lists."""
|
|
460
|
+
|
|
461
|
+
@pytest.fixture(autouse=True)
|
|
462
|
+
def setup(self):
|
|
463
|
+
self.graph, self.engine = _build()
|
|
464
|
+
|
|
465
|
+
def test_callers_not_capped(self):
|
|
466
|
+
for node in self.graph.all_nodes():
|
|
467
|
+
callers = self.graph.callers_of(node.id)
|
|
468
|
+
if len(callers) >= 2:
|
|
469
|
+
ctx = self.engine.get_context(node.qualified_name)
|
|
470
|
+
ctx_data = json.loads(ctx) if isinstance(ctx, str) else ctx
|
|
471
|
+
assert len(ctx_data["callers"]) == len(callers), \
|
|
472
|
+
f"get_context callers capped: {len(ctx_data['callers'])} vs {len(callers)}"
|
|
473
|
+
return
|
|
474
|
+
pytest.skip("No node with >=2 callers")
|
|
475
|
+
|
|
476
|
+
def test_callees_not_capped(self):
|
|
477
|
+
for node in self.graph.all_nodes():
|
|
478
|
+
callees = self.graph.callees_of(node.id)
|
|
479
|
+
if len(callees) >= 2:
|
|
480
|
+
ctx = self.engine.get_context(node.qualified_name)
|
|
481
|
+
ctx_data = json.loads(ctx) if isinstance(ctx, str) else ctx
|
|
482
|
+
assert len(ctx_data["callees"]) == len(callees), \
|
|
483
|
+
f"get_context callees capped: {len(ctx_data['callees'])} vs {len(callees)}"
|
|
484
|
+
return
|
|
485
|
+
pytest.skip("No node with >=2 callees")
|
|
486
|
+
|
|
487
|
+
def test_fingerprint_slim_in_context(self):
|
|
488
|
+
"""get_context fingerprint must not include heavy fields."""
|
|
489
|
+
try:
|
|
490
|
+
from interlinked.analyzer.similarity import analyze_similarity
|
|
491
|
+
analyze_similarity(self.graph)
|
|
492
|
+
except Exception:
|
|
493
|
+
pytest.skip("similarity module not available")
|
|
494
|
+
|
|
495
|
+
for node in self.graph.all_nodes():
|
|
496
|
+
if node.metadata.get("fingerprint"):
|
|
497
|
+
ctx = self.engine.get_context(node.qualified_name)
|
|
498
|
+
ctx_data = json.loads(ctx) if isinstance(ctx, str) else ctx
|
|
499
|
+
fp = ctx_data.get("fingerprint")
|
|
500
|
+
if fp:
|
|
501
|
+
heavy = {"ast_tree", "minhash", "ast_node_counts", "source_snippet"}
|
|
502
|
+
found = heavy & set(fp.keys())
|
|
503
|
+
assert not found, \
|
|
504
|
+
f"get_context fingerprint has heavy keys: {found}"
|
|
505
|
+
return
|
|
506
|
+
pytest.skip("No node with fingerprint")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/index.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/package.json
RENAMED
|
File without changes
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/App.tsx
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/index.css
RENAMED
|
File without changes
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/main.tsx
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/theme.ts
RENAMED
|
File without changes
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/types.ts
RENAMED
|
File without changes
|
|
File without changes
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/tsconfig.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/requires.txt
RENAMED
|
File without changes
|
{interlinked_mapper-0.3.8 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|