interlinked-mapper 0.3.9__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.
Files changed (47) hide show
  1. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/PKG-INFO +1 -1
  2. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/analyzer/similarity.py +2 -2
  3. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/mcp_server.py +2 -11
  4. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/PKG-INFO +1 -1
  5. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/SOURCES.txt +2 -1
  6. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/pyproject.toml +1 -1
  7. interlinked_mapper-0.3.10/tests/test_query_completeness.py +506 -0
  8. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/__init__.py +0 -0
  9. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/analyzer/__init__.py +0 -0
  10. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/analyzer/dead_code.py +0 -0
  11. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/analyzer/embeddings.py +0 -0
  12. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/analyzer/graph.py +0 -0
  13. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/analyzer/parser.py +0 -0
  14. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/cli.py +0 -0
  15. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/commander/__init__.py +0 -0
  16. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/commander/llm.py +0 -0
  17. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/commander/query.py +0 -0
  18. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/commander/repl.py +0 -0
  19. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/models.py +0 -0
  20. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/__init__.py +0 -0
  21. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/dist/assets/index-CyhrxsQU.css +0 -0
  22. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/dist/assets/index-Dh01aXoE.js +0 -0
  23. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/dist/index.html +0 -0
  24. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/index.html +0 -0
  25. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/index.html.d3-legacy +0 -0
  26. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/package-lock.json +0 -0
  27. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/package.json +0 -0
  28. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/App.tsx +0 -0
  29. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/graph/GraphCanvas.tsx +0 -0
  30. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/graph/nodePrograms.ts +0 -0
  31. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/index.css +0 -0
  32. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/main.tsx +0 -0
  33. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/state/graphStore.ts +0 -0
  34. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/state/sseClient.ts +0 -0
  35. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/theme.ts +0 -0
  36. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/types.ts +0 -0
  37. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/src/vite-env.d.ts +0 -0
  38. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/tsconfig.json +0 -0
  39. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/frontend/vite.config.ts +0 -0
  40. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/layouts.py +0 -0
  41. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked/visualizer/server.py +0 -0
  42. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/dependency_links.txt +0 -0
  43. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/entry_points.txt +0 -0
  44. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/requires.txt +0 -0
  45. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/interlinked_mapper.egg-info/top_level.txt +0 -0
  46. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/setup.cfg +0 -0
  47. {interlinked_mapper-0.3.9 → interlinked_mapper-0.3.10}/tests/test_accuracy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: interlinked-mapper
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: A Python program topology explorer — visualize the shape of your codebase
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/austerecryptid/interlinked
@@ -254,8 +254,8 @@ 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[:20]]
258
- context["callees"] = [{"id": n.id, "name": n.name} for n in callees[:20]]
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
259
 
260
260
  # Fingerprint — slim version (drop ast_tree, minhash, ast_node_counts)
261
261
  fp = node.metadata.get("fingerprint")
@@ -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
- results = data["results"]
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
- results = data["results"]
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":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: interlinked-mapper
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: A Python program topology explorer — visualize the shape of your codebase
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/austerecryptid/interlinked
@@ -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.9"
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")