interlinked-mapper 0.3.6__tar.gz → 0.3.8__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.6 → interlinked_mapper-0.3.8}/PKG-INFO +1 -1
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/analyzer/dead_code.py +46 -3
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/analyzer/graph.py +43 -23
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/analyzer/parser.py +227 -18
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/commander/query.py +6 -2
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/mcp_server.py +6 -8
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked_mapper.egg-info/PKG-INFO +1 -1
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked_mapper.egg-info/SOURCES.txt +2 -1
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/pyproject.toml +1 -1
- interlinked_mapper-0.3.8/tests/test_accuracy.py +969 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/__init__.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/analyzer/__init__.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/analyzer/embeddings.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/analyzer/similarity.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/cli.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/commander/__init__.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/commander/llm.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/commander/repl.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/models.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/__init__.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/dist/assets/index-CyhrxsQU.css +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/dist/assets/index-Dh01aXoE.js +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/dist/index.html +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/index.html +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/index.html.d3-legacy +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/package-lock.json +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/package.json +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/src/App.tsx +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/src/graph/GraphCanvas.tsx +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/src/graph/nodePrograms.ts +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/src/index.css +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/src/main.tsx +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/src/state/graphStore.ts +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/src/state/sseClient.ts +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/src/theme.ts +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/src/types.ts +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/src/vite-env.d.ts +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/tsconfig.json +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/frontend/vite.config.ts +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/layouts.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked/visualizer/server.py +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked_mapper.egg-info/dependency_links.txt +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked_mapper.egg-info/entry_points.txt +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked_mapper.egg-info/requires.txt +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked_mapper.egg-info/top_level.txt +0 -0
- {interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/setup.cfg +0 -0
|
@@ -23,7 +23,9 @@ Additionally detects:
|
|
|
23
23
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
|
+
import ast
|
|
26
27
|
from collections import deque
|
|
28
|
+
from pathlib import Path
|
|
27
29
|
|
|
28
30
|
from interlinked.analyzer.graph import CodeGraph
|
|
29
31
|
from interlinked.models import EdgeType, SymbolType
|
|
@@ -76,15 +78,27 @@ def detect_dead_code(graph: CodeGraph) -> list[str]:
|
|
|
76
78
|
if base_short in _SERIALIZABLE_BASES:
|
|
77
79
|
serializable_class_ids.add(cls_id)
|
|
78
80
|
|
|
81
|
+
# ── Parse __all__ from module source files ──────────────────────
|
|
82
|
+
# Symbols listed in __all__ are public API — always alive.
|
|
83
|
+
all_exports: set[str] = set()
|
|
84
|
+
for n in all_nodes:
|
|
85
|
+
if n.symbol_type == SymbolType.MODULE and n.file_path:
|
|
86
|
+
exported = _parse_dunder_all(n.file_path)
|
|
87
|
+
for name in exported:
|
|
88
|
+
all_exports.add(f"{n.id}.{name}")
|
|
89
|
+
|
|
79
90
|
# ── Identify production entry points ──────────────────────────
|
|
80
91
|
# Modules are roots — their scope-level code runs on import.
|
|
81
92
|
# Dunder methods and framework hooks are implicitly invoked.
|
|
93
|
+
# Symbols in __all__ are public API exports.
|
|
82
94
|
entry_points: set[str] = set()
|
|
83
95
|
for n in all_nodes:
|
|
84
96
|
if n.symbol_type == SymbolType.MODULE:
|
|
85
97
|
entry_points.add(n.id)
|
|
86
98
|
elif n.name in _EXEMPT_NAMES:
|
|
87
99
|
entry_points.add(n.id)
|
|
100
|
+
elif n.id in all_exports:
|
|
101
|
+
entry_points.add(n.id)
|
|
88
102
|
|
|
89
103
|
# ── Forward BFS from production entry points ──────────────────
|
|
90
104
|
# When we reach a node, follow its calls/reads edges.
|
|
@@ -108,17 +122,20 @@ def detect_dead_code(graph: CodeGraph) -> list[str]:
|
|
|
108
122
|
if child not in reachable:
|
|
109
123
|
queue.append(child)
|
|
110
124
|
|
|
111
|
-
# ── Mark unreachable functions/methods as dead
|
|
125
|
+
# ── Mark unreachable functions/methods/classes as dead ──────────
|
|
112
126
|
dead: set[str] = set()
|
|
113
127
|
for n in all_nodes:
|
|
114
|
-
if n.symbol_type not in (SymbolType.FUNCTION, SymbolType.METHOD):
|
|
128
|
+
if n.symbol_type not in (SymbolType.FUNCTION, SymbolType.METHOD, SymbolType.CLASS):
|
|
115
129
|
continue
|
|
116
130
|
# Test functions are not dead — they're tests
|
|
117
|
-
if n.name.startswith("test_"):
|
|
131
|
+
if n.name.startswith("test_") or n.name.startswith("Test"):
|
|
118
132
|
continue
|
|
119
133
|
# Exempt names are never dead
|
|
120
134
|
if n.name in _EXEMPT_NAMES:
|
|
121
135
|
continue
|
|
136
|
+
# __all__ exports are never dead
|
|
137
|
+
if n.id in all_exports:
|
|
138
|
+
continue
|
|
122
139
|
# If not reachable from any production entry point → dead
|
|
123
140
|
if n.id not in reachable:
|
|
124
141
|
n.is_dead = True
|
|
@@ -200,3 +217,29 @@ def detect_dead_code(graph: CodeGraph) -> list[str]:
|
|
|
200
217
|
e.is_dead = True
|
|
201
218
|
|
|
202
219
|
return list(dead)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _parse_dunder_all(file_path: str) -> list[str]:
|
|
223
|
+
"""Extract names from a static ``__all__ = [...]`` assignment.
|
|
224
|
+
|
|
225
|
+
Only handles literal list/tuple assignments — dynamic __all__ (e.g.
|
|
226
|
+
comprehensions, += mutations) are not supported by design.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
source = Path(file_path).read_text(encoding="utf-8", errors="replace")
|
|
230
|
+
tree = ast.parse(source, filename=file_path)
|
|
231
|
+
except (SyntaxError, OSError):
|
|
232
|
+
return []
|
|
233
|
+
|
|
234
|
+
for node in ast.iter_child_nodes(tree):
|
|
235
|
+
if not isinstance(node, ast.Assign):
|
|
236
|
+
continue
|
|
237
|
+
for target in node.targets:
|
|
238
|
+
if isinstance(target, ast.Name) and target.id == "__all__":
|
|
239
|
+
if isinstance(node.value, (ast.List, ast.Tuple)):
|
|
240
|
+
return [
|
|
241
|
+
elt.value
|
|
242
|
+
for elt in node.value.elts
|
|
243
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
|
|
244
|
+
]
|
|
245
|
+
return []
|
|
@@ -144,22 +144,23 @@ class CodeGraph:
|
|
|
144
144
|
|
|
145
145
|
# Step 3: Build name index from ALL current nodes for edge resolution
|
|
146
146
|
all_nodes = self.all_nodes(include_proposed=False)
|
|
147
|
-
name_index: dict[str,
|
|
147
|
+
name_index: dict[str, set[str]] = {}
|
|
148
148
|
for n in all_nodes:
|
|
149
|
-
name_index.setdefault(n.name,
|
|
149
|
+
name_index.setdefault(n.name, set()).add(n.id)
|
|
150
150
|
parts = n.qualified_name.split(".")
|
|
151
151
|
for i in range(1, len(parts)):
|
|
152
152
|
suffix = ".".join(parts[i:])
|
|
153
|
-
name_index.setdefault(suffix,
|
|
153
|
+
name_index.setdefault(suffix, set()).add(n.id)
|
|
154
154
|
|
|
155
155
|
node_ids = {n.id for n in all_nodes}
|
|
156
156
|
|
|
157
|
-
# Step 4: Resolve and add new edges
|
|
157
|
+
# Step 4: Resolve and add new edges (skip external references)
|
|
158
158
|
added_edges: list[EdgeData] = []
|
|
159
159
|
for e in new_edges:
|
|
160
160
|
resolved = self._resolve_edge(e, node_ids, name_index)
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
if resolved.source in node_ids and resolved.target in node_ids:
|
|
162
|
+
self.add_edge(resolved)
|
|
163
|
+
added_edges.append(resolved)
|
|
163
164
|
|
|
164
165
|
return {
|
|
165
166
|
"removed_nodes": removed["removed_nodes"],
|
|
@@ -174,51 +175,70 @@ class CodeGraph:
|
|
|
174
175
|
for n in nodes:
|
|
175
176
|
self.add_node(n)
|
|
176
177
|
|
|
177
|
-
# Build a lookup: short name ->
|
|
178
|
-
|
|
178
|
+
# Build a lookup: short name -> set of qualified IDs
|
|
179
|
+
# Sets prevent duplicates from suffix indexing (which caused
|
|
180
|
+
# _resolve_edge to see len>1 for single-node names and bail).
|
|
181
|
+
name_index: dict[str, set[str]] = {}
|
|
179
182
|
for n in nodes:
|
|
180
|
-
name_index.setdefault(n.name,
|
|
183
|
+
name_index.setdefault(n.name, set()).add(n.id)
|
|
181
184
|
# Also index by qualified_name suffix fragments
|
|
182
185
|
# e.g. "graph.CodeGraph" for "analyzer.graph.CodeGraph"
|
|
183
186
|
parts = n.qualified_name.split(".")
|
|
184
187
|
for i in range(1, len(parts)):
|
|
185
188
|
suffix = ".".join(parts[i:])
|
|
186
|
-
name_index.setdefault(suffix,
|
|
189
|
+
name_index.setdefault(suffix, set()).add(n.id)
|
|
187
190
|
|
|
188
191
|
node_ids = {n.id for n in nodes}
|
|
189
192
|
|
|
190
193
|
for e in edges:
|
|
191
194
|
resolved = self._resolve_edge(e, node_ids, name_index)
|
|
195
|
+
# Source must be a known project node. Targets may be
|
|
196
|
+
# unresolved for CALLS/READS (inference gaps on untyped
|
|
197
|
+
# variables), but structural edges need both endpoints.
|
|
198
|
+
if resolved.source not in node_ids:
|
|
199
|
+
continue
|
|
192
200
|
self.add_edge(resolved)
|
|
193
201
|
|
|
194
202
|
@staticmethod
|
|
195
203
|
def _resolve_edge(
|
|
196
204
|
edge: EdgeData,
|
|
197
205
|
node_ids: set[str],
|
|
198
|
-
name_index: dict[str,
|
|
206
|
+
name_index: dict[str, set[str]],
|
|
199
207
|
) -> EdgeData:
|
|
200
208
|
"""Try to resolve unqualified source/target names to known node IDs."""
|
|
201
209
|
source = edge.source
|
|
202
210
|
target = edge.target
|
|
203
211
|
|
|
204
212
|
if source not in node_ids:
|
|
205
|
-
candidates = name_index.get(source,
|
|
213
|
+
candidates = name_index.get(source, set())
|
|
206
214
|
if len(candidates) == 1:
|
|
207
|
-
source = candidates
|
|
215
|
+
source = next(iter(candidates))
|
|
208
216
|
|
|
209
217
|
if target not in node_ids:
|
|
210
|
-
candidates = name_index.get(target,
|
|
218
|
+
candidates = name_index.get(target, set())
|
|
211
219
|
if len(candidates) == 1:
|
|
212
|
-
target = candidates
|
|
220
|
+
target = next(iter(candidates))
|
|
213
221
|
elif len(candidates) > 1:
|
|
214
|
-
#
|
|
215
|
-
|
|
216
|
-
for
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
+
# For CALLS edges, a bare name like `process()` in Python
|
|
223
|
+
# NEVER resolves to the same class — you need `self.process()`
|
|
224
|
+
# for that. Exclude the source itself to prevent self-call
|
|
225
|
+
# artifacts, and prefer module-level over class-level.
|
|
226
|
+
filtered = candidates - {source}
|
|
227
|
+
if not filtered:
|
|
228
|
+
filtered = candidates
|
|
229
|
+
|
|
230
|
+
# Extract the top-level module from the source
|
|
231
|
+
src_parts = source.split(".")
|
|
232
|
+
src_module = src_parts[0] if src_parts else source
|
|
233
|
+
|
|
234
|
+
# Score candidates: prefer same module, then shorter paths
|
|
235
|
+
# (module-level functions are shorter than class methods)
|
|
236
|
+
best = None
|
|
237
|
+
for c in filtered:
|
|
238
|
+
if c.startswith(src_module + "."):
|
|
239
|
+
if best is None or c.count(".") < best.count("."):
|
|
240
|
+
best = c
|
|
241
|
+
target = best or next(iter(filtered))
|
|
222
242
|
|
|
223
243
|
if source == edge.source and target == edge.target:
|
|
224
244
|
return edge
|
|
@@ -42,6 +42,22 @@ _BUILTINS: frozenset[str] = frozenset(dir(builtins)) | frozenset({
|
|
|
42
42
|
"__all__", "__spec__", "__loader__", "__package__", "__builtins__",
|
|
43
43
|
})
|
|
44
44
|
|
|
45
|
+
# Known higher-order call patterns where a positional argument is the
|
|
46
|
+
# callable being invoked. Maps (attr_suffix) -> positional index of
|
|
47
|
+
# the callable arg. Also supports keyword argument names via _CALLABLE_KWARGS.
|
|
48
|
+
_CALLABLE_ARG_INDEX: dict[str, int] = {
|
|
49
|
+
"run_in_executor": 1, # loop.run_in_executor(executor, fn, ...)
|
|
50
|
+
"submit": 1, # executor.submit(fn, ...)
|
|
51
|
+
"map": 0, # map(fn, iterable) / pool.map(fn, iterable)
|
|
52
|
+
"apply": 0, # pool.apply(fn, ...)
|
|
53
|
+
"apply_async": 0, # pool.apply_async(fn, ...)
|
|
54
|
+
"partial": 0, # functools.partial(fn, ...)
|
|
55
|
+
}
|
|
56
|
+
_CALLABLE_KWARGS: dict[str, str] = {
|
|
57
|
+
"Thread": "target", # threading.Thread(target=fn)
|
|
58
|
+
"Process": "target", # multiprocessing.Process(target=fn)
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
|
|
46
62
|
|
|
47
63
|
def parse_file(
|
|
@@ -74,7 +90,7 @@ def parse_file(
|
|
|
74
90
|
return [], []
|
|
75
91
|
|
|
76
92
|
# Pass 1: extract symbols and raw edges
|
|
77
|
-
nodes, edges = _extract_from_module(tree, source, module_qname, str(file_path))
|
|
93
|
+
nodes, edges, _aliases = _extract_from_module(tree, source, module_qname, str(file_path))
|
|
78
94
|
|
|
79
95
|
# Build combined node ID set and type index
|
|
80
96
|
node_ids = {n.id for n in nodes}
|
|
@@ -186,6 +202,7 @@ def parse_project(root: str | Path) -> tuple[list[NodeData], list[EdgeData]]:
|
|
|
186
202
|
root = Path(root).resolve()
|
|
187
203
|
nodes: list[NodeData] = []
|
|
188
204
|
edges: list[EdgeData] = []
|
|
205
|
+
all_import_aliases: dict[str, str] = {} # local_name -> import target
|
|
189
206
|
|
|
190
207
|
# Skip directories that contain third-party or non-project Python files.
|
|
191
208
|
# External references are resolved via import/AST analysis, not by parsing venv.
|
|
@@ -218,11 +235,12 @@ def parse_project(root: str | Path) -> tuple[list[NodeData], list[EdgeData]]:
|
|
|
218
235
|
module_qname = _path_to_module(rel_path)
|
|
219
236
|
trees.append((tree, module_qname, str(py_file)))
|
|
220
237
|
|
|
221
|
-
file_nodes, file_edges = _extract_from_module(
|
|
238
|
+
file_nodes, file_edges, file_aliases = _extract_from_module(
|
|
222
239
|
tree, source, module_qname, str(py_file)
|
|
223
240
|
)
|
|
224
241
|
nodes.extend(file_nodes)
|
|
225
242
|
edges.extend(file_edges)
|
|
243
|
+
all_import_aliases.update(file_aliases)
|
|
226
244
|
|
|
227
245
|
# Pass 2: type inference from annotations
|
|
228
246
|
node_ids = {n.id for n in nodes}
|
|
@@ -253,6 +271,22 @@ def parse_project(root: str | Path) -> tuple[list[NodeData], list[EdgeData]]:
|
|
|
253
271
|
suffix = ".".join(parts[i:])
|
|
254
272
|
name_index.setdefault(suffix, []).append(n.id)
|
|
255
273
|
|
|
274
|
+
# Inject import aliases into name_index so aliased names resolve
|
|
275
|
+
# through the same path as their real targets.
|
|
276
|
+
# e.g. alias "process_data" -> "tests.fixtures.shadowing.process"
|
|
277
|
+
# name_index already has "process" -> [shadowing.process, ...]
|
|
278
|
+
# We find the target's suffix in name_index and copy its candidates.
|
|
279
|
+
for alias_name, alias_target in all_import_aliases.items():
|
|
280
|
+
if alias_name in name_index:
|
|
281
|
+
continue # don't clobber real nodes
|
|
282
|
+
# Try the full target, then progressively shorter suffixes
|
|
283
|
+
target_parts = alias_target.split(".")
|
|
284
|
+
for i in range(len(target_parts)):
|
|
285
|
+
suffix = ".".join(target_parts[i:])
|
|
286
|
+
if suffix in name_index:
|
|
287
|
+
name_index[alias_name] = name_index[suffix]
|
|
288
|
+
break
|
|
289
|
+
|
|
256
290
|
# Pass 4: resolve all data-flow edges, progressive truncation, drop external
|
|
257
291
|
#
|
|
258
292
|
# Edge type handling:
|
|
@@ -264,8 +298,12 @@ def parse_project(root: str | Path) -> tuple[list[NodeData], list[EdgeData]]:
|
|
|
264
298
|
# CONTAINS / INHERITS — pass through unchanged.
|
|
265
299
|
resolved_edges: list[EdgeData] = []
|
|
266
300
|
for e in edges:
|
|
267
|
-
# Structural edges — always keep
|
|
301
|
+
# Structural edges — always keep, except external IMPORTS
|
|
268
302
|
if e.edge_type not in (EdgeType.READS, EdgeType.WRITES, EdgeType.CALLS, EdgeType.RETURNS):
|
|
303
|
+
if e.edge_type == EdgeType.IMPORTS and e.target not in node_ids:
|
|
304
|
+
# Drop imports to external modules (asyncio, typing, etc.)
|
|
305
|
+
if not any(nid.startswith(e.target + ".") or nid == e.target for nid in node_ids):
|
|
306
|
+
continue
|
|
269
307
|
resolved_edges.append(e)
|
|
270
308
|
continue
|
|
271
309
|
|
|
@@ -381,7 +419,7 @@ def _extract_from_module(
|
|
|
381
419
|
# Field(...) in Pydantic models, etc.
|
|
382
420
|
visitor._extract_scope_level_calls(tree, module_qname)
|
|
383
421
|
|
|
384
|
-
return nodes, edges
|
|
422
|
+
return nodes, edges, visitor._import_aliases
|
|
385
423
|
|
|
386
424
|
|
|
387
425
|
# ---------------------------------------------------------------------------
|
|
@@ -408,6 +446,7 @@ class _SymbolVisitor(ast.NodeVisitor):
|
|
|
408
446
|
self._edges = edges
|
|
409
447
|
self._scope_stack: list[str] = [module_qname]
|
|
410
448
|
self._node_ids: set[str] = set()
|
|
449
|
+
self._import_aliases: dict[str, str] = {} # local_name -> qualified target
|
|
411
450
|
|
|
412
451
|
@property
|
|
413
452
|
def _current_scope(self) -> str:
|
|
@@ -529,6 +568,8 @@ class _SymbolVisitor(ast.NodeVisitor):
|
|
|
529
568
|
source=self._module, target=alias.name,
|
|
530
569
|
edge_type=EdgeType.IMPORTS, line=node.lineno,
|
|
531
570
|
))
|
|
571
|
+
local_name = alias.asname or alias.name
|
|
572
|
+
self._import_aliases[local_name] = alias.name
|
|
532
573
|
|
|
533
574
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
534
575
|
base = node.module or ""
|
|
@@ -538,6 +579,9 @@ class _SymbolVisitor(ast.NodeVisitor):
|
|
|
538
579
|
source=self._module, target=target,
|
|
539
580
|
edge_type=EdgeType.IMPORTS, line=node.lineno,
|
|
540
581
|
))
|
|
582
|
+
# Track aliases: 'from X import Y as Z' -> Z maps to X.Y
|
|
583
|
+
local_name = alias.asname or alias.name
|
|
584
|
+
self._import_aliases[local_name] = target
|
|
541
585
|
|
|
542
586
|
# -- Assignments at module / class scope --------------------------------
|
|
543
587
|
|
|
@@ -654,10 +698,39 @@ class _SymbolVisitor(ast.NodeVisitor):
|
|
|
654
698
|
))
|
|
655
699
|
|
|
656
700
|
def _extract_calls(self, func_node: ast.AST, caller_qname: str) -> None:
|
|
657
|
-
"""Emit raw CALLS edges. Targets are unresolved (e.g. 'self.add_node').
|
|
701
|
+
"""Emit raw CALLS edges. Targets are unresolved (e.g. 'self.add_node').
|
|
702
|
+
|
|
703
|
+
Also detects known callable-passing patterns like
|
|
704
|
+
``loop.run_in_executor(None, fn)`` and ``Thread(target=fn)``
|
|
705
|
+
and emits an additional CALLS edge to the callable argument.
|
|
706
|
+
"""
|
|
658
707
|
for node in ast.walk(func_node):
|
|
659
708
|
if not isinstance(node, ast.Call):
|
|
660
709
|
continue
|
|
710
|
+
|
|
711
|
+
# Detect super().method() — Call(func=Attr(value=Call(func=Name('super'))))
|
|
712
|
+
if (isinstance(node.func, ast.Attribute)
|
|
713
|
+
and isinstance(node.func.value, ast.Call)
|
|
714
|
+
and isinstance(node.func.value.func, ast.Name)
|
|
715
|
+
and node.func.value.func.id == "super"):
|
|
716
|
+
method_name = node.func.attr
|
|
717
|
+
# Find the enclosing class and its base classes from
|
|
718
|
+
# already-emitted inherits edges
|
|
719
|
+
class_scope = self._class_scope()
|
|
720
|
+
if class_scope:
|
|
721
|
+
for edge in self._edges:
|
|
722
|
+
if edge.source == class_scope and edge.edge_type == EdgeType.INHERITS:
|
|
723
|
+
# Emit call to parent.method — resolution will
|
|
724
|
+
# match it to the actual qualified name
|
|
725
|
+
super_target = f"{edge.target}.{method_name}"
|
|
726
|
+
self._edges.append(EdgeData(
|
|
727
|
+
source=caller_qname, target=super_target,
|
|
728
|
+
edge_type=EdgeType.CALLS,
|
|
729
|
+
line=getattr(node, "lineno", None),
|
|
730
|
+
))
|
|
731
|
+
break # MRO: first base class
|
|
732
|
+
continue
|
|
733
|
+
|
|
661
734
|
callee = _name_from_node(node.func)
|
|
662
735
|
if not callee:
|
|
663
736
|
continue
|
|
@@ -687,6 +760,34 @@ class _SymbolVisitor(ast.NodeVisitor):
|
|
|
687
760
|
metadata=metadata,
|
|
688
761
|
))
|
|
689
762
|
|
|
763
|
+
# Detect callable-passing patterns and emit CALLS to the
|
|
764
|
+
# actual callable argument.
|
|
765
|
+
callee_tail = callee.rsplit(".", 1)[-1]
|
|
766
|
+
|
|
767
|
+
# Positional callable arg: e.g. run_in_executor(None, fn)
|
|
768
|
+
idx = _CALLABLE_ARG_INDEX.get(callee_tail)
|
|
769
|
+
if idx is not None and idx < len(node.args):
|
|
770
|
+
fn_name = _name_from_node(node.args[idx])
|
|
771
|
+
if fn_name and fn_name not in _BUILTINS:
|
|
772
|
+
self._edges.append(EdgeData(
|
|
773
|
+
source=caller_qname, target=fn_name,
|
|
774
|
+
edge_type=EdgeType.CALLS,
|
|
775
|
+
line=getattr(node, "lineno", None),
|
|
776
|
+
))
|
|
777
|
+
|
|
778
|
+
# Keyword callable arg: e.g. Thread(target=fn)
|
|
779
|
+
kw_param = _CALLABLE_KWARGS.get(callee_tail)
|
|
780
|
+
if kw_param:
|
|
781
|
+
for kw in node.keywords:
|
|
782
|
+
if kw.arg == kw_param:
|
|
783
|
+
fn_name = _name_from_node(kw.value)
|
|
784
|
+
if fn_name and fn_name not in _BUILTINS:
|
|
785
|
+
self._edges.append(EdgeData(
|
|
786
|
+
source=caller_qname, target=fn_name,
|
|
787
|
+
edge_type=EdgeType.CALLS,
|
|
788
|
+
line=getattr(node, "lineno", None),
|
|
789
|
+
))
|
|
790
|
+
|
|
690
791
|
def _extract_variable_access(self, func_node: ast.AST, scope_qname: str) -> None:
|
|
691
792
|
"""Emit raw READS/WRITES edges. Targets are unresolved."""
|
|
692
793
|
param_names: set[str] = set()
|
|
@@ -880,7 +981,12 @@ class _TypeInferencer:
|
|
|
880
981
|
return None
|
|
881
982
|
|
|
882
983
|
def _resolve_subscript_inner(self, ann: ast.AST) -> str | None:
|
|
883
|
-
"""For list[
|
|
984
|
+
"""For list[X], set[X], Generator[Y,S,R], Iterator[X], resolve the element type.
|
|
985
|
+
|
|
986
|
+
For single-arg subscripts (list[X], Iterator[X]): returns X.
|
|
987
|
+
For multi-arg subscripts: tries first element (Generator[Yield,...]),
|
|
988
|
+
then last (dict[K, V]).
|
|
989
|
+
"""
|
|
884
990
|
if isinstance(ann, ast.Subscript):
|
|
885
991
|
sl = ann.slice
|
|
886
992
|
if isinstance(sl, ast.Name):
|
|
@@ -888,8 +994,12 @@ class _TypeInferencer:
|
|
|
888
994
|
if isinstance(sl, ast.Attribute):
|
|
889
995
|
dotted = _name_from_node(sl)
|
|
890
996
|
return self._type_index.get(dotted) if dotted else None
|
|
891
|
-
# dict[K, V] -- return V for .values() iteration
|
|
892
997
|
if isinstance(sl, ast.Tuple) and len(sl.elts) >= 2:
|
|
998
|
+
# Try first element (Generator[Yield, Send, Return], Iterator[X])
|
|
999
|
+
first = self._resolve_annotation(sl.elts[0])
|
|
1000
|
+
if first:
|
|
1001
|
+
return first
|
|
1002
|
+
# Fall back to last element (dict[K, V])
|
|
893
1003
|
return self._resolve_annotation(sl.elts[-1])
|
|
894
1004
|
# Handle X | None wrapping
|
|
895
1005
|
if isinstance(ann, ast.BinOp) and isinstance(ann.op, ast.BitOr):
|
|
@@ -968,17 +1078,14 @@ class _TypeInferencer:
|
|
|
968
1078
|
self._var_types[(func_qname, target.attr)] = resolved
|
|
969
1079
|
|
|
970
1080
|
# Case B: method call — x = obj.method()
|
|
1081
|
+
# Also handles Class.classmethod() and Class.staticmethod()
|
|
971
1082
|
elif "." in callee and isinstance(target, ast.Name):
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
ret_type = self._resolve_annotation(ret_ann)
|
|
979
|
-
if ret_type:
|
|
980
|
-
self._var_types[(func_qname, target.id)] = ret_type
|
|
981
|
-
local_annotations[target.id] = ret_ann
|
|
1083
|
+
ret_ann = self._lookup_return_type(callee, func_qname)
|
|
1084
|
+
if ret_ann:
|
|
1085
|
+
ret_type = self._resolve_annotation(ret_ann)
|
|
1086
|
+
if ret_type:
|
|
1087
|
+
self._var_types[(func_qname, target.id)] = ret_type
|
|
1088
|
+
local_annotations[target.id] = ret_ann
|
|
982
1089
|
|
|
983
1090
|
# Case C: assignment type propagation
|
|
984
1091
|
# self.x = param or x = other_typed_var
|
|
@@ -1018,6 +1125,31 @@ class _TypeInferencer:
|
|
|
1018
1125
|
if elem_type:
|
|
1019
1126
|
self._var_types[(func_qname, gen.target.id)] = elem_type
|
|
1020
1127
|
|
|
1128
|
+
# Except-as variable typing:
|
|
1129
|
+
# `except AppError as e` -> e is typed as AppError
|
|
1130
|
+
elif isinstance(child, ast.ExceptHandler):
|
|
1131
|
+
if child.name and child.type:
|
|
1132
|
+
exc_name = _name_from_node(child.type)
|
|
1133
|
+
if exc_name:
|
|
1134
|
+
exc_class = self._type_index.get(exc_name)
|
|
1135
|
+
if exc_class:
|
|
1136
|
+
self._var_types[(func_qname, child.name)] = exc_class
|
|
1137
|
+
|
|
1138
|
+
# With / async-with as-variable typing:
|
|
1139
|
+
# `with X() as var` -> var's type is X.__enter__ return annotation
|
|
1140
|
+
# `async with X() as var` -> X.__aenter__ return annotation
|
|
1141
|
+
elif isinstance(child, (ast.With, ast.AsyncWith)):
|
|
1142
|
+
is_async = isinstance(child, ast.AsyncWith)
|
|
1143
|
+
for item in child.items:
|
|
1144
|
+
if item.optional_vars and isinstance(item.optional_vars, ast.Name):
|
|
1145
|
+
var_name = item.optional_vars.id
|
|
1146
|
+
cm_type = self._infer_context_manager_type(
|
|
1147
|
+
item.context_expr, func_qname, is_async,
|
|
1148
|
+
local_annotations, param_annotations,
|
|
1149
|
+
)
|
|
1150
|
+
if cm_type:
|
|
1151
|
+
self._var_types[(func_qname, var_name)] = cm_type
|
|
1152
|
+
|
|
1021
1153
|
def _infer_iter_element_type(
|
|
1022
1154
|
self,
|
|
1023
1155
|
it: ast.AST,
|
|
@@ -1040,7 +1172,7 @@ class _TypeInferencer:
|
|
|
1040
1172
|
if inner:
|
|
1041
1173
|
return inner
|
|
1042
1174
|
|
|
1043
|
-
# Case
|
|
1175
|
+
# Case 2a: for x in obj.method() -- resolve obj type, look up method return
|
|
1044
1176
|
if isinstance(it, ast.Call):
|
|
1045
1177
|
callee = _name_from_node(it.func)
|
|
1046
1178
|
if callee and "." in callee:
|
|
@@ -1054,6 +1186,14 @@ class _TypeInferencer:
|
|
|
1054
1186
|
if inner:
|
|
1055
1187
|
return inner
|
|
1056
1188
|
|
|
1189
|
+
# Case 2b: for x in fn() -- standalone function with Generator[X] return
|
|
1190
|
+
if callee:
|
|
1191
|
+
ret_ann = self._lookup_return_type(callee, func_qname)
|
|
1192
|
+
if ret_ann:
|
|
1193
|
+
inner = self._resolve_subscript_inner(ret_ann)
|
|
1194
|
+
if inner:
|
|
1195
|
+
return inner
|
|
1196
|
+
|
|
1057
1197
|
# Case 3: for x in obj.values() on dict[K, V]
|
|
1058
1198
|
if isinstance(it, ast.Call) and isinstance(it.func, ast.Attribute):
|
|
1059
1199
|
if it.func.attr == "values":
|
|
@@ -1068,6 +1208,75 @@ class _TypeInferencer:
|
|
|
1068
1208
|
|
|
1069
1209
|
return None
|
|
1070
1210
|
|
|
1211
|
+
def _lookup_return_type(self, callee: str, caller_qname: str) -> ast.AST | None:
|
|
1212
|
+
"""Look up a function's return type annotation by name.
|
|
1213
|
+
|
|
1214
|
+
Handles bare names (suffix match), qualified names (direct), and
|
|
1215
|
+
Class.method patterns (via type resolution).
|
|
1216
|
+
"""
|
|
1217
|
+
# Direct qualified name match
|
|
1218
|
+
ret = self._return_types.get(callee)
|
|
1219
|
+
if ret:
|
|
1220
|
+
return ret
|
|
1221
|
+
|
|
1222
|
+
# Dotted: Class.method or obj.method
|
|
1223
|
+
if "." in callee:
|
|
1224
|
+
obj_name, method = callee.rsplit(".", 1)
|
|
1225
|
+
cls = self._resolve_var_type(obj_name, caller_qname)
|
|
1226
|
+
if not cls:
|
|
1227
|
+
cls = self._type_index.get(obj_name)
|
|
1228
|
+
if cls:
|
|
1229
|
+
ret = self._return_types.get(f"{cls}.{method}")
|
|
1230
|
+
if ret:
|
|
1231
|
+
return ret
|
|
1232
|
+
|
|
1233
|
+
# Bare name: suffix match against _return_types keys
|
|
1234
|
+
suffix = "." + callee
|
|
1235
|
+
for qname, ann in self._return_types.items():
|
|
1236
|
+
if qname == callee or qname.endswith(suffix):
|
|
1237
|
+
return ann
|
|
1238
|
+
|
|
1239
|
+
return None
|
|
1240
|
+
|
|
1241
|
+
def _infer_context_manager_type(
|
|
1242
|
+
self,
|
|
1243
|
+
ctx_expr: ast.AST,
|
|
1244
|
+
func_qname: str,
|
|
1245
|
+
is_async: bool,
|
|
1246
|
+
local_annotations: dict[str, ast.AST],
|
|
1247
|
+
param_annotations: dict[str, ast.AST],
|
|
1248
|
+
) -> str | None:
|
|
1249
|
+
"""Infer the type of the `as` variable in a with/async-with statement.
|
|
1250
|
+
|
|
1251
|
+
`with X() as var` -> var's type is X.__enter__ return annotation.
|
|
1252
|
+
`async with X() as var` -> X.__aenter__ return annotation.
|
|
1253
|
+
`with expr as var` where expr is a typed local -> same logic.
|
|
1254
|
+
"""
|
|
1255
|
+
enter_method = "__aenter__" if is_async else "__enter__"
|
|
1256
|
+
|
|
1257
|
+
# Determine the context manager's class
|
|
1258
|
+
cm_class: str | None = None
|
|
1259
|
+
|
|
1260
|
+
if isinstance(ctx_expr, ast.Call):
|
|
1261
|
+
# `with SyncPool() as conn` or `with SyncPool(...) as conn`
|
|
1262
|
+
callee = _name_from_node(ctx_expr.func)
|
|
1263
|
+
if callee:
|
|
1264
|
+
cm_class = self._type_index.get(callee)
|
|
1265
|
+
elif isinstance(ctx_expr, ast.Name):
|
|
1266
|
+
# `with pool as conn` where pool is a typed variable
|
|
1267
|
+
cm_class = self._resolve_var_type(ctx_expr.id, func_qname)
|
|
1268
|
+
|
|
1269
|
+
if not cm_class:
|
|
1270
|
+
return None
|
|
1271
|
+
|
|
1272
|
+
# Look up __enter__/__aenter__ return annotation on the CM class
|
|
1273
|
+
enter_qname = f"{cm_class}.{enter_method}"
|
|
1274
|
+
ret_ann = self._return_types.get(enter_qname)
|
|
1275
|
+
if ret_ann:
|
|
1276
|
+
return self._resolve_annotation(ret_ann)
|
|
1277
|
+
|
|
1278
|
+
return None
|
|
1279
|
+
|
|
1071
1280
|
def _resolve_var_type(self, name: str, func_qname: str) -> str | None:
|
|
1072
1281
|
"""Look up a variable's type, handling 'self'/'cls', dotted chains, and scope walking.
|
|
1073
1282
|
|
|
@@ -243,11 +243,15 @@ class QueryEngine:
|
|
|
243
243
|
|
|
244
244
|
if expr.startswith("callers of"):
|
|
245
245
|
target = expression.split("callers of", 1)[1].strip()
|
|
246
|
-
|
|
246
|
+
node = self._resolve_node(target)
|
|
247
|
+
if node:
|
|
248
|
+
results = self.graph.callers_of(node.id)
|
|
247
249
|
|
|
248
250
|
elif expr.startswith("callees of"):
|
|
249
251
|
target = expression.split("callees of", 1)[1].strip()
|
|
250
|
-
|
|
252
|
+
node = self._resolve_node(target)
|
|
253
|
+
if node:
|
|
254
|
+
results = self.graph.callees_of(node.id)
|
|
251
255
|
|
|
252
256
|
elif expr.startswith("parameters of") or expr.startswith("params of"):
|
|
253
257
|
target = expression.split("of", 1)[1].strip()
|
|
@@ -108,16 +108,14 @@ def create_mcp_server(project_path: str) -> Server:
|
|
|
108
108
|
|
|
109
109
|
async def _ensure_ready() -> tuple[CodeGraph, QueryEngine]:
|
|
110
110
|
if not _state["ready"]:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
graph
|
|
111
|
+
# Start empty — require switch_project to load a project.
|
|
112
|
+
# Windsurf spawns MCP with unpredictable cwd, so we never
|
|
113
|
+
# auto-parse to avoid scanning / or ~.
|
|
114
|
+
graph = CodeGraph()
|
|
115
|
+
engine = QueryEngine(graph)
|
|
115
116
|
_state["graph"] = graph
|
|
116
117
|
_state["engine"] = engine
|
|
117
118
|
_state["ready"] = True
|
|
118
|
-
# Deferred: similarity + embeddings in single background thread
|
|
119
|
-
# Scheduled AFTER build returns so the tool response goes out first
|
|
120
|
-
_deferred_background_work(graph, engine, project_path)
|
|
121
119
|
return _state["graph"], _state["engine"]
|
|
122
120
|
|
|
123
121
|
# Pick up API key from env if available
|
|
@@ -256,7 +254,7 @@ def create_mcp_server(project_path: str) -> Server:
|
|
|
256
254
|
),
|
|
257
255
|
Tool(
|
|
258
256
|
name="interlinked_switch_project",
|
|
259
|
-
description="Switch to analyzing a different Python project. Re-parses the new project and rebuilds the entire graph.",
|
|
257
|
+
description="Switch to analyzing a different Python project. Re-parses the new project and rebuilds the entire graph. Must be called before any other tool — the server starts with no project loaded.",
|
|
260
258
|
inputSchema={
|
|
261
259
|
"type": "object",
|
|
262
260
|
"properties": {
|
{interlinked_mapper-0.3.6 → interlinked_mapper-0.3.8}/interlinked_mapper.egg-info/SOURCES.txt
RENAMED
|
@@ -40,4 +40,5 @@ interlinked_mapper.egg-info/SOURCES.txt
|
|
|
40
40
|
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
|
-
interlinked_mapper.egg-info/top_level.txt
|
|
43
|
+
interlinked_mapper.egg-info/top_level.txt
|
|
44
|
+
tests/test_accuracy.py
|