diffcontext 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffcontext/__init__.py +233 -0
- diffcontext/_warn_once.py +112 -0
- diffcontext/cache.py +216 -0
- diffcontext/cli/__init__.py +655 -0
- diffcontext/context/__init__.py +1 -0
- diffcontext/context/compiler.py +643 -0
- diffcontext/context/selector.py +258 -0
- diffcontext/diff/__init__.py +1 -0
- diffcontext/diff/git_diff.py +298 -0
- diffcontext/diff/state_manager.py +75 -0
- diffcontext/graph_builder.py +1026 -0
- diffcontext/history.py +154 -0
- diffcontext/impact/__init__.py +1 -0
- diffcontext/impact/blast_radius.py +58 -0
- diffcontext/impact/scoring.py +223 -0
- diffcontext/impact/traversal.py +58 -0
- diffcontext/impact/visualizer.py +338 -0
- diffcontext/languages/__init__.py +80 -0
- diffcontext/languages/typescript.py +960 -0
- diffcontext/lexical.py +108 -0
- diffcontext/models.py +180 -0
- diffcontext/parser.py +183 -0
- diffcontext/pipeline.py +887 -0
- diffcontext/py.typed +0 -0
- diffcontext/rerank/__init__.py +17 -0
- diffcontext/rerank/features.py +356 -0
- diffcontext/rerank/model.py +175 -0
- diffcontext/resolver.py +288 -0
- diffcontext/scanner.py +153 -0
- diffcontext/symbols.py +254 -0
- diffcontext/verify/__init__.py +68 -0
- diffcontext/verify/cases.py +631 -0
- diffcontext/verify/history.py +396 -0
- diffcontext/verify/sufficiency.py +324 -0
- diffcontext-0.5.1.dist-info/METADATA +219 -0
- diffcontext-0.5.1.dist-info/RECORD +40 -0
- diffcontext-0.5.1.dist-info/WHEEL +5 -0
- diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
- diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
- diffcontext-0.5.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
"""
|
|
2
|
+
graph_builder.py — Build the full dependency graph for a Python repository.
|
|
3
|
+
|
|
4
|
+
Edge types (v2):
|
|
5
|
+
1. Direct call edges — f() calls g() → f→g
|
|
6
|
+
2. Inheritance override edges — Child.method → Parent.method (child only)
|
|
7
|
+
3. Shared-import consumer edges— files co-importing same module (capped ≤10)
|
|
8
|
+
4. Decorator edges — @decorator applied to a function → fn→decorator
|
|
9
|
+
5. Annotated return-type edges — def f() -> MyClass: ... → f→MyClass.__init__
|
|
10
|
+
6. Same-directory sibling edges— one representative per file, light connectivity
|
|
11
|
+
7. Dispatch-sibling override edges — same method name across subclasses of one
|
|
12
|
+
base (≤6 per family), covering duck-typed dispatch with no parent method
|
|
13
|
+
|
|
14
|
+
Performance:
|
|
15
|
+
- Import maps built ONCE in pre-pass.
|
|
16
|
+
- _resolve_owner_type results memoized (manual dict cache).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
from typing import Dict, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
from .scanner import find_python_files
|
|
25
|
+
from .parser import extract_all_symbols
|
|
26
|
+
from .resolver import build_import_map
|
|
27
|
+
from .symbols import (
|
|
28
|
+
extract_attribute_ownerships,
|
|
29
|
+
extract_local_var_types,
|
|
30
|
+
extract_param_types,
|
|
31
|
+
_iter_statements,
|
|
32
|
+
)
|
|
33
|
+
from ._warn_once import warn_syntax_error_once, check_and_warn_encoding
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Fan-out caps — keep shared-import and sibling edges from becoming mega-hubs
|
|
38
|
+
_SHARED_IMPORT_MAX_CONSUMERS = 10 # skip if >10 files share the same import
|
|
39
|
+
_SAME_DIR_MAX_FILES = 20 # skip same-dir bonus if directory is huge
|
|
40
|
+
_DECORATOR_EDGE_MAX = 6 # max decorator edges per function (guards chains)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_repository_graph(
|
|
44
|
+
repo_path: str,
|
|
45
|
+
functions: Optional[Dict[str, object]] = None,
|
|
46
|
+
file_trees: Optional[Dict[str, ast.Module]] = None,
|
|
47
|
+
import_maps: Optional[Dict[str, Dict[str, str]]] = None,
|
|
48
|
+
) -> Dict[str, List[str]]:
|
|
49
|
+
"""
|
|
50
|
+
Build the complete call graph for a repository.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
repo_path: Repository root.
|
|
54
|
+
functions: Pre-extracted symbol table (id -> Symbol). Extracted
|
|
55
|
+
fresh when None.
|
|
56
|
+
file_trees: Pre-parsed ASTs keyed by relative file ("./x.py").
|
|
57
|
+
When provided, no file is read or parsed here — this is
|
|
58
|
+
how the pipeline avoids double-parsing every file.
|
|
59
|
+
import_maps: Pre-built import maps keyed by relative file. Built
|
|
60
|
+
from `file_trees` when None.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
dict mapping function_id -> [list of called function_ids]
|
|
64
|
+
"""
|
|
65
|
+
repo_path = os.path.abspath(repo_path)
|
|
66
|
+
|
|
67
|
+
if functions is None:
|
|
68
|
+
functions = extract_all_symbols(repo_path)
|
|
69
|
+
function_ids = set(functions)
|
|
70
|
+
|
|
71
|
+
ids_by_file, methods_by_class = _group_symbol_ids(functions)
|
|
72
|
+
|
|
73
|
+
# ── pre-pass: per-file ASTs, import maps, class registry ─────────────
|
|
74
|
+
if file_trees is None:
|
|
75
|
+
file_trees = _parse_repo_files(repo_path)
|
|
76
|
+
if import_maps is None:
|
|
77
|
+
import_maps = {}
|
|
78
|
+
_ensure_import_maps(file_trees, import_maps, repo_path)
|
|
79
|
+
|
|
80
|
+
(class_registry, classes_by_file, inheritance,
|
|
81
|
+
factory_returns, module_var_types) = _scan_module_level(file_trees)
|
|
82
|
+
|
|
83
|
+
# ── Resolution cache ──────────────────────────────────────────────────
|
|
84
|
+
# _resolve_owner_type is called O(symbols * calls_per_function) times.
|
|
85
|
+
# The same (qualifier, bare_name, rel_file) triple recurs constantly on
|
|
86
|
+
# large repos. Cache the result to avoid redundant work.
|
|
87
|
+
_resolve_cache: Dict[Tuple, Optional[str]] = {}
|
|
88
|
+
|
|
89
|
+
def _cached_resolve_owner_type(qualifier, bare_name, relative_file, import_map, _seen=None):
|
|
90
|
+
key = (qualifier, bare_name, relative_file)
|
|
91
|
+
if key in _resolve_cache:
|
|
92
|
+
return _resolve_cache[key]
|
|
93
|
+
result = _resolve_owner_type(
|
|
94
|
+
qualifier, bare_name, relative_file, import_map,
|
|
95
|
+
class_registry, factory_returns, import_maps, repo_path,
|
|
96
|
+
_seen=_seen,
|
|
97
|
+
classes_by_file=classes_by_file,
|
|
98
|
+
)
|
|
99
|
+
_resolve_cache[key] = result
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
attribute_owners = _build_attribute_owners(
|
|
103
|
+
file_trees, import_maps, module_var_types, repo_path,
|
|
104
|
+
class_registry, classes_by_file, factory_returns,
|
|
105
|
+
_cached_resolve_owner_type,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
graph: Dict[str, List[str]] = {}
|
|
109
|
+
_build_call_edges(
|
|
110
|
+
graph, file_trees, import_maps, ids_by_file, functions, function_ids,
|
|
111
|
+
repo_path, attribute_owners, inheritance, class_registry,
|
|
112
|
+
factory_returns, classes_by_file, _cached_resolve_owner_type,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Resolved once, used by all inheritance phases (1A, 1F, 1G below);
|
|
116
|
+
# resolution inputs don't change between them.
|
|
117
|
+
override_pairs, dispatch_groups = _resolve_inheritance_structures(
|
|
118
|
+
inheritance, methods_by_class, function_ids, import_maps,
|
|
119
|
+
class_registry, factory_returns, repo_path, classes_by_file,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
_add_override_edges(graph, override_pairs)
|
|
123
|
+
_add_decorator_edges(
|
|
124
|
+
graph, file_trees, import_maps, ids_by_file,
|
|
125
|
+
functions, function_ids, repo_path,
|
|
126
|
+
)
|
|
127
|
+
file_groups = _build_file_groups(function_ids, functions)
|
|
128
|
+
_add_shared_import_edges(graph, import_maps, file_groups, repo_path)
|
|
129
|
+
_add_same_directory_edges(graph, file_groups)
|
|
130
|
+
_add_window_edges(graph, file_groups)
|
|
131
|
+
_add_parent_child_edges(graph, override_pairs)
|
|
132
|
+
_add_sibling_override_edges(graph, dispatch_groups)
|
|
133
|
+
|
|
134
|
+
return graph
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── Build phases (extracted from build_repository_graph) ──────────────────
|
|
138
|
+
|
|
139
|
+
def _group_symbol_ids(functions):
|
|
140
|
+
"""
|
|
141
|
+
Group symbol ids once, by file and by class.
|
|
142
|
+
|
|
143
|
+
Returns (ids_by_file, methods_by_class):
|
|
144
|
+
ids_by_file: rel_file -> {name: fid}
|
|
145
|
+
methods_by_class: "file:Class" -> [(fid, method_name)]
|
|
146
|
+
|
|
147
|
+
The per-file and per-class lookups used to rescan every id with
|
|
148
|
+
startswith — per file, per decorator, and per inheritance pair
|
|
149
|
+
(measured: 48M startswith calls, ~11s of a 41s cold build on django).
|
|
150
|
+
"""
|
|
151
|
+
ids_by_file: Dict[str, Dict[str, str]] = {}
|
|
152
|
+
methods_by_class: Dict[str, List[Tuple[str, str]]] = {}
|
|
153
|
+
for fid in functions:
|
|
154
|
+
fid_file, fid_name = fid.split(":", 1)
|
|
155
|
+
ids_by_file.setdefault(fid_file, {})[fid_name] = fid
|
|
156
|
+
if "." in fid_name:
|
|
157
|
+
cls_part, meth_part = fid_name.split(".", 1)
|
|
158
|
+
methods_by_class.setdefault(f"{fid_file}:{cls_part}", []).append((fid, meth_part))
|
|
159
|
+
return ids_by_file, methods_by_class
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_repo_files(repo_path):
|
|
163
|
+
"""Read and parse every Python file: rel_file ("./x.py") -> ast.Module."""
|
|
164
|
+
file_trees: Dict[str, ast.Module] = {}
|
|
165
|
+
for filename in find_python_files(repo_path):
|
|
166
|
+
with open(filename, "rb") as f:
|
|
167
|
+
raw = f.read()
|
|
168
|
+
check_and_warn_encoding(logger, filename, raw)
|
|
169
|
+
source = raw.decode("utf-8", errors="ignore")
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
tree = ast.parse(source)
|
|
173
|
+
except SyntaxError as e:
|
|
174
|
+
warn_syntax_error_once(logger, filename, e)
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
file_trees["./" + os.path.relpath(filename, repo_path)] = tree
|
|
178
|
+
return file_trees
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _ensure_import_maps(file_trees, import_maps, repo_path):
|
|
182
|
+
"""Build the import map for any file that doesn't have one yet."""
|
|
183
|
+
for relative_file, tree in file_trees.items():
|
|
184
|
+
if relative_file not in import_maps:
|
|
185
|
+
abs_file = os.path.join(repo_path, relative_file[2:])
|
|
186
|
+
import_maps[relative_file] = build_import_map(
|
|
187
|
+
abs_file, repo_path, tree=tree
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _call_type_ref(func):
|
|
192
|
+
"""(qualifier, bare_name) for the callee of `x = SomeClass(...)`."""
|
|
193
|
+
if isinstance(func, ast.Name):
|
|
194
|
+
return (None, func.id)
|
|
195
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
196
|
+
return (func.value.id, func.attr)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _class_bases(node):
|
|
201
|
+
"""(qualifier, bare_name) refs for a ClassDef's base classes."""
|
|
202
|
+
bases = []
|
|
203
|
+
for b in node.bases:
|
|
204
|
+
if isinstance(b, ast.Name):
|
|
205
|
+
bases.append((None, b.id))
|
|
206
|
+
elif isinstance(b, ast.Attribute) and isinstance(b.value, ast.Name):
|
|
207
|
+
bases.append((b.value.id, b.attr))
|
|
208
|
+
elif isinstance(b, ast.Attribute):
|
|
209
|
+
bases.append((None, b.attr))
|
|
210
|
+
return bases
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _register_factory_return(node, relative_file, factory_returns):
|
|
214
|
+
"""Record the type a module-level function returns, if resolvable from
|
|
215
|
+
its return statements or (as fallback) its return annotation."""
|
|
216
|
+
ref = _find_return_type(node)
|
|
217
|
+
if ref:
|
|
218
|
+
factory_returns[f"{relative_file}:{node.name}"] = ref
|
|
219
|
+
ann_ref = _find_annotated_return(node)
|
|
220
|
+
if ann_ref and not ref:
|
|
221
|
+
factory_returns[f"{relative_file}:{node.name}"] = ann_ref
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _module_var_bindings(node):
|
|
225
|
+
"""
|
|
226
|
+
Yield (var_name, type_ref) for module-level constructor assignments:
|
|
227
|
+
|
|
228
|
+
app = Flask(__name__) (Assign)
|
|
229
|
+
db: SQLAlchemy = SQLAlchemy(app) (AnnAssign)
|
|
230
|
+
"""
|
|
231
|
+
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
|
|
232
|
+
type_ref = _call_type_ref(node.value.func)
|
|
233
|
+
if type_ref:
|
|
234
|
+
for tgt in node.targets:
|
|
235
|
+
if isinstance(tgt, ast.Name):
|
|
236
|
+
yield tgt.id, type_ref
|
|
237
|
+
elif isinstance(node, ast.AnnAssign) and node.value and isinstance(node.value, ast.Call):
|
|
238
|
+
type_ref = _call_type_ref(node.value.func)
|
|
239
|
+
if type_ref and isinstance(node.target, ast.Name):
|
|
240
|
+
yield node.target.id, type_ref
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _scan_module_level(file_trees):
|
|
244
|
+
"""
|
|
245
|
+
One pass over every module's top-level statements. Returns:
|
|
246
|
+
|
|
247
|
+
class_registry: class_name -> [rel_file, ...]
|
|
248
|
+
classes_by_file: rel_file -> [class_name, ...]
|
|
249
|
+
inheritance: "rel_file:ClassName" -> bases
|
|
250
|
+
factory_returns: "rel_file:func" -> (qualifier, type)
|
|
251
|
+
module_var_types: rel_file -> {var_name: (qualifier, bare_name)}
|
|
252
|
+
"""
|
|
253
|
+
class_registry: Dict[str, List[str]] = {}
|
|
254
|
+
classes_by_file: Dict[str, List[str]] = {}
|
|
255
|
+
inheritance: Dict[str, List[Tuple]] = {}
|
|
256
|
+
factory_returns: Dict[str, Tuple] = {}
|
|
257
|
+
module_var_types: Dict[str, Dict[str, Tuple]] = {}
|
|
258
|
+
|
|
259
|
+
for relative_file, tree in file_trees.items():
|
|
260
|
+
mvt: Dict[str, Tuple] = {}
|
|
261
|
+
for node in tree.body:
|
|
262
|
+
if isinstance(node, ast.ClassDef):
|
|
263
|
+
class_registry.setdefault(node.name, []).append(relative_file)
|
|
264
|
+
classes_by_file.setdefault(relative_file, []).append(node.name)
|
|
265
|
+
inheritance[f"{relative_file}:{node.name}"] = _class_bases(node)
|
|
266
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
267
|
+
_register_factory_return(node, relative_file, factory_returns)
|
|
268
|
+
else:
|
|
269
|
+
for var_name, type_ref in _module_var_bindings(node):
|
|
270
|
+
mvt[var_name] = type_ref
|
|
271
|
+
if mvt:
|
|
272
|
+
module_var_types[relative_file] = mvt
|
|
273
|
+
|
|
274
|
+
return (class_registry, classes_by_file, inheritance,
|
|
275
|
+
factory_returns, module_var_types)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _build_attribute_owners(
|
|
279
|
+
file_trees, import_maps, module_var_types, repo_path,
|
|
280
|
+
class_registry, classes_by_file, factory_returns, cached_resolve,
|
|
281
|
+
):
|
|
282
|
+
"""
|
|
283
|
+
Map "rel_file:Class.attr" -> owning class id for every attribute whose
|
|
284
|
+
type could be resolved. Module-level vars are registered too (keyed
|
|
285
|
+
"rel_file:var_name") so that `app.run()` in a function body resolves
|
|
286
|
+
to Flask.run.
|
|
287
|
+
"""
|
|
288
|
+
attribute_owners: Dict[str, str] = {}
|
|
289
|
+
|
|
290
|
+
for rel_file, mvt in module_var_types.items():
|
|
291
|
+
import_map = import_maps[rel_file]
|
|
292
|
+
for var_name, type_ref in mvt.items():
|
|
293
|
+
qualifier, bare_name = type_ref
|
|
294
|
+
resolved = _resolve_owner_type(
|
|
295
|
+
qualifier, bare_name, rel_file, import_map,
|
|
296
|
+
class_registry, factory_returns, import_maps, repo_path,
|
|
297
|
+
classes_by_file=classes_by_file,
|
|
298
|
+
)
|
|
299
|
+
if resolved:
|
|
300
|
+
attribute_owners[f"{rel_file}:{var_name}"] = resolved
|
|
301
|
+
|
|
302
|
+
for relative_file, tree in file_trees.items():
|
|
303
|
+
import_map = import_maps[relative_file]
|
|
304
|
+
raw_own = extract_attribute_ownerships(tree)
|
|
305
|
+
|
|
306
|
+
for key, type_ref in raw_own.items():
|
|
307
|
+
if type_ref is None:
|
|
308
|
+
continue
|
|
309
|
+
qualifier, bare_name = type_ref
|
|
310
|
+
resolved = cached_resolve(qualifier, bare_name, relative_file, import_map)
|
|
311
|
+
if resolved:
|
|
312
|
+
attribute_owners[f"{relative_file}:{key}"] = resolved
|
|
313
|
+
|
|
314
|
+
return attribute_owners
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _build_call_edges(
|
|
318
|
+
graph, file_trees, import_maps, ids_by_file, functions, function_ids,
|
|
319
|
+
repo_path, attribute_owners, inheritance, class_registry,
|
|
320
|
+
factory_returns, classes_by_file, cached_resolve,
|
|
321
|
+
):
|
|
322
|
+
"""Direct call edges plus function-reference-as-argument edges."""
|
|
323
|
+
for relative_file, tree in file_trees.items():
|
|
324
|
+
import_map = import_maps[relative_file]
|
|
325
|
+
local_name_to_id = ids_by_file.get(relative_file, {})
|
|
326
|
+
for fn_node, is_method, class_name in _collect_function_nodes(tree):
|
|
327
|
+
_add_function_call_edges(
|
|
328
|
+
graph, fn_node, is_method, class_name, relative_file,
|
|
329
|
+
local_name_to_id, import_map, functions, function_ids,
|
|
330
|
+
repo_path, attribute_owners, inheritance, class_registry,
|
|
331
|
+
factory_returns, import_maps, classes_by_file, cached_resolve,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _add_function_call_edges(
|
|
336
|
+
graph, fn_node, is_method, class_name, relative_file,
|
|
337
|
+
local_name_to_id, import_map, functions, function_ids,
|
|
338
|
+
repo_path, attribute_owners, inheritance, class_registry,
|
|
339
|
+
factory_returns, import_maps, classes_by_file, cached_resolve,
|
|
340
|
+
):
|
|
341
|
+
"""Edges out of one function: every call it makes, plus function
|
|
342
|
+
references it passes as arguments."""
|
|
343
|
+
function_name = f"{class_name}.{fn_node.name}" if class_name else fn_node.name
|
|
344
|
+
function_id = f"{relative_file}:{function_name}"
|
|
345
|
+
|
|
346
|
+
graph.setdefault(function_id, [])
|
|
347
|
+
|
|
348
|
+
param_types = extract_param_types(fn_node)
|
|
349
|
+
local_var_types = {**param_types, **extract_local_var_types(fn_node, param_types)}
|
|
350
|
+
param_names = _all_param_names(fn_node)
|
|
351
|
+
|
|
352
|
+
for child in ast.walk(fn_node):
|
|
353
|
+
if not isinstance(child, ast.Call):
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
dep = _resolve_call(
|
|
357
|
+
child, is_method, class_name, relative_file,
|
|
358
|
+
local_name_to_id, import_map, functions, function_ids,
|
|
359
|
+
repo_path, attribute_owners, inheritance, class_registry,
|
|
360
|
+
factory_returns, import_maps, local_var_types,
|
|
361
|
+
classes_by_file, cached_resolve,
|
|
362
|
+
)
|
|
363
|
+
if dep and dep != function_id and dep not in graph[function_id]:
|
|
364
|
+
graph[function_id].append(dep)
|
|
365
|
+
|
|
366
|
+
_add_arg_reference_edges(
|
|
367
|
+
graph, child, function_id, param_names, local_var_types,
|
|
368
|
+
is_method, class_name, relative_file, local_name_to_id,
|
|
369
|
+
import_map, functions, function_ids, repo_path,
|
|
370
|
+
attribute_owners, inheritance, class_registry,
|
|
371
|
+
factory_returns, import_maps, classes_by_file, cached_resolve,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _add_arg_reference_edges(
|
|
376
|
+
graph, call_node, function_id, param_names, local_var_types,
|
|
377
|
+
is_method, class_name, relative_file, local_name_to_id,
|
|
378
|
+
import_map, functions, function_ids, repo_path,
|
|
379
|
+
attribute_owners, inheritance, class_registry,
|
|
380
|
+
factory_returns, import_maps, classes_by_file, cached_resolve,
|
|
381
|
+
):
|
|
382
|
+
"""Function references passed as arguments are dependencies too:
|
|
383
|
+
`partial(black.format_file_contents, ...)` in blackd never calls the
|
|
384
|
+
function, but a change to it absolutely lands in blackd's blast
|
|
385
|
+
radius. Covers positional and keyword args (`sorted(xs, key=fn)`)."""
|
|
386
|
+
for arg in list(call_node.args) + [kw.value for kw in call_node.keywords]:
|
|
387
|
+
if isinstance(arg, ast.Name):
|
|
388
|
+
# Parameters/locals shadow module-level functions —
|
|
389
|
+
# `run(task)` where task is a param is not a
|
|
390
|
+
# reference to a same-named function.
|
|
391
|
+
if arg.id in param_names or arg.id in local_var_types:
|
|
392
|
+
continue
|
|
393
|
+
elif not isinstance(arg, ast.Attribute):
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
ref = _resolve_func_expr(
|
|
397
|
+
arg, is_method, class_name, relative_file,
|
|
398
|
+
local_name_to_id, import_map, functions, function_ids,
|
|
399
|
+
repo_path, attribute_owners, inheritance, class_registry,
|
|
400
|
+
factory_returns, import_maps, local_var_types,
|
|
401
|
+
classes_by_file, cached_resolve,
|
|
402
|
+
)
|
|
403
|
+
if ref and ref != function_id and ref not in graph[function_id]:
|
|
404
|
+
graph[function_id].append(ref)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _resolve_inheritance_structures(
|
|
408
|
+
inheritance, methods_by_class, function_ids, import_maps,
|
|
409
|
+
class_registry, factory_returns, repo_path, classes_by_file,
|
|
410
|
+
):
|
|
411
|
+
"""
|
|
412
|
+
One resolution pass over every class's base references. Returns
|
|
413
|
+
(override_pairs, dispatch_groups):
|
|
414
|
+
|
|
415
|
+
override_pairs: [(child_method_id, parent_method_id)] for every
|
|
416
|
+
method overriding a same-named method that EXISTS
|
|
417
|
+
on a resolvable base class.
|
|
418
|
+
dispatch_groups: {(base_key, method_name): [method_id, ...]} for
|
|
419
|
+
every non-dunder method defined by a subclass of
|
|
420
|
+
base_key — whether or not the base defines it.
|
|
421
|
+
Captures duck-typed dispatch families (e.g. per-
|
|
422
|
+
backend implementations of the same operation)
|
|
423
|
+
where no parent method exists to route through.
|
|
424
|
+
"""
|
|
425
|
+
override_pairs: List[Tuple[str, str]] = []
|
|
426
|
+
dispatch_groups: Dict[Tuple[str, str], List[str]] = {}
|
|
427
|
+
for child_key, bases in inheritance.items():
|
|
428
|
+
child_file, _child_class = child_key.split(":", 1)
|
|
429
|
+
for qualifier, base_name in bases:
|
|
430
|
+
base_owner = _resolve_owner_type(
|
|
431
|
+
qualifier, base_name, child_file,
|
|
432
|
+
import_maps.get(child_file, {}),
|
|
433
|
+
class_registry, factory_returns, import_maps, repo_path,
|
|
434
|
+
classes_by_file=classes_by_file,
|
|
435
|
+
)
|
|
436
|
+
if not base_owner:
|
|
437
|
+
continue
|
|
438
|
+
for fid, method_name in methods_by_class.get(child_key, []):
|
|
439
|
+
parent_method = f"{base_owner}.{method_name}"
|
|
440
|
+
if parent_method in function_ids and parent_method != fid:
|
|
441
|
+
override_pairs.append((fid, parent_method))
|
|
442
|
+
# Dunders (__init__, __repr__, ...) are defined by nearly
|
|
443
|
+
# every subclass — grouping them yields noise, not dispatch.
|
|
444
|
+
if not method_name.startswith("__"):
|
|
445
|
+
dispatch_groups.setdefault(
|
|
446
|
+
(base_owner, method_name), []
|
|
447
|
+
).append(fid)
|
|
448
|
+
return override_pairs, dispatch_groups
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _add_override_edges(graph, override_pairs):
|
|
452
|
+
"""Phase 1A: inheritance override edges. When ChildClass overrides
|
|
453
|
+
ParentClass.method, add the child → parent edge. Only child → parent
|
|
454
|
+
direction: "if I changed the child, show me the parent contract I
|
|
455
|
+
might be violating." The reverse (parent → all 400 children) would
|
|
456
|
+
create mega-hubs that destroy ranking."""
|
|
457
|
+
for fid, parent_method in override_pairs:
|
|
458
|
+
if parent_method not in graph.get(fid, []):
|
|
459
|
+
graph.setdefault(fid, []).append(parent_method)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _resolve_decorator_ref(
|
|
463
|
+
deco, relative_file, ids_by_file, import_map,
|
|
464
|
+
functions, function_ids, repo_path,
|
|
465
|
+
):
|
|
466
|
+
"""Function id a decorator expression refers to, or None.
|
|
467
|
+
Handles @plain_name, @module.name, and @name(args)."""
|
|
468
|
+
deco_func = deco.func if isinstance(deco, ast.Call) else deco
|
|
469
|
+
if isinstance(deco_func, ast.Name):
|
|
470
|
+
return _lookup(
|
|
471
|
+
deco_func.id,
|
|
472
|
+
ids_by_file.get(relative_file, {}),
|
|
473
|
+
import_map, functions, repo_path,
|
|
474
|
+
)
|
|
475
|
+
if isinstance(deco_func, ast.Attribute) and isinstance(deco_func.value, ast.Name):
|
|
476
|
+
if deco_func.value.id in import_map:
|
|
477
|
+
src = import_map[deco_func.value.id]
|
|
478
|
+
rel_src = "./" + os.path.relpath(src, repo_path)
|
|
479
|
+
dep = f"{rel_src}:{deco_func.attr}"
|
|
480
|
+
if dep in function_ids:
|
|
481
|
+
return dep
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _add_decorator_edges(
|
|
486
|
+
graph, file_trees, import_maps, ids_by_file,
|
|
487
|
+
functions, function_ids, repo_path,
|
|
488
|
+
):
|
|
489
|
+
"""Phase 1B: decorator edges. A function decorated with @my_decorator
|
|
490
|
+
has an implicit dependency on my_decorator. This is one of the
|
|
491
|
+
strongest co-change signals: if the decorator changes, all its
|
|
492
|
+
callsites likely change too."""
|
|
493
|
+
for relative_file, tree in file_trees.items():
|
|
494
|
+
import_map = import_maps[relative_file]
|
|
495
|
+
for fn_node, _is_method, _class_name in _collect_function_nodes(tree):
|
|
496
|
+
added = 0
|
|
497
|
+
for deco in fn_node.decorator_list:
|
|
498
|
+
if added >= _DECORATOR_EDGE_MAX:
|
|
499
|
+
break
|
|
500
|
+
dep = _resolve_decorator_ref(
|
|
501
|
+
deco, relative_file, ids_by_file, import_map,
|
|
502
|
+
functions, function_ids, repo_path,
|
|
503
|
+
)
|
|
504
|
+
if not dep:
|
|
505
|
+
continue
|
|
506
|
+
fn_name_local = (
|
|
507
|
+
f"{_class_name}.{fn_node.name}"
|
|
508
|
+
if _class_name else fn_node.name
|
|
509
|
+
)
|
|
510
|
+
fid = f"{relative_file}:{fn_name_local}"
|
|
511
|
+
if fid in function_ids and dep != fid and dep not in graph.get(fid, []):
|
|
512
|
+
graph.setdefault(fid, []).append(dep)
|
|
513
|
+
added += 1
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _build_file_groups(function_ids, functions):
|
|
517
|
+
"""
|
|
518
|
+
rel_file -> [fids], sorted by line number within each file: window
|
|
519
|
+
edges genuinely follow definition order, and representative picks
|
|
520
|
+
(syms[0]) are deterministic. Iteration is over sorted(function_ids)
|
|
521
|
+
so the dict *key* order — the order Phases 1C-1E walk files when
|
|
522
|
+
emitting edges — is hash-seed independent too; sorting only the
|
|
523
|
+
within-file lists still left edge order varying between runs.
|
|
524
|
+
"""
|
|
525
|
+
file_groups: Dict[str, List[str]] = {}
|
|
526
|
+
for fid in sorted(function_ids):
|
|
527
|
+
ffile = fid.split(":")[0]
|
|
528
|
+
file_groups.setdefault(ffile, []).append(fid)
|
|
529
|
+
for ffile in file_groups:
|
|
530
|
+
file_groups[ffile].sort(
|
|
531
|
+
key=lambda fid: (getattr(functions.get(fid), "lineno", 0) or 0, fid)
|
|
532
|
+
)
|
|
533
|
+
return file_groups
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _connect_pair(graph, a, b):
|
|
537
|
+
"""Add an undirected co-change edge (both directions, deduplicated)."""
|
|
538
|
+
if b not in graph.get(a, []):
|
|
539
|
+
graph.setdefault(a, []).append(b)
|
|
540
|
+
if a not in graph.get(b, []):
|
|
541
|
+
graph.setdefault(b, []).append(a)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _connect_file_representatives(graph, files, file_groups):
|
|
545
|
+
"""Connect one representative symbol per file, pairwise."""
|
|
546
|
+
representatives = []
|
|
547
|
+
for cfile in files:
|
|
548
|
+
rep_syms = file_groups.get(cfile, [])
|
|
549
|
+
if rep_syms:
|
|
550
|
+
representatives.append(rep_syms[0]) # one rep per file
|
|
551
|
+
for i, a in enumerate(representatives):
|
|
552
|
+
for b in representatives[i + 1:]:
|
|
553
|
+
_connect_pair(graph, a, b)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _add_shared_import_edges(graph, import_maps, file_groups, repo_path):
|
|
557
|
+
"""Phase 1C: shared-import consumer edges. If file_a and file_b both
|
|
558
|
+
import from the same internal module, functions in file_a and file_b
|
|
559
|
+
are likely to co-change when that module changes. Create edges between
|
|
560
|
+
representatives of those files, skipping modules with more than
|
|
561
|
+
_SHARED_IMPORT_MAX_CONSUMERS consumers."""
|
|
562
|
+
import_consumers: Dict[str, List[str]] = {}
|
|
563
|
+
for rel_file, imap in import_maps.items():
|
|
564
|
+
for _local_name, abs_path in imap.items():
|
|
565
|
+
# Only track internal (in-repo) imports
|
|
566
|
+
rel_imported = "./" + os.path.relpath(abs_path, repo_path)
|
|
567
|
+
if not rel_imported.startswith("./"):
|
|
568
|
+
continue
|
|
569
|
+
import_consumers.setdefault(rel_imported, []).append(rel_file)
|
|
570
|
+
|
|
571
|
+
for _imported_mod, consumer_files in import_consumers.items():
|
|
572
|
+
consumer_files = list(dict.fromkeys(consumer_files)) # deduplicate, preserve order
|
|
573
|
+
if len(consumer_files) < 2 or len(consumer_files) > _SHARED_IMPORT_MAX_CONSUMERS:
|
|
574
|
+
continue
|
|
575
|
+
_connect_file_representatives(graph, consumer_files, file_groups)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _add_same_directory_edges(graph, file_groups):
|
|
579
|
+
"""Phase 1D: same-directory sibling edges. Files in the same package
|
|
580
|
+
directory tend to co-change (tests ↔ impl, models ↔ serializers,
|
|
581
|
+
etc.). Connect one representative per file to one representative from
|
|
582
|
+
every other file in the same directory. Cap: skip directories with
|
|
583
|
+
>_SAME_DIR_MAX_FILES Python files to avoid linking unrelated utility
|
|
584
|
+
grab-bags."""
|
|
585
|
+
dir_files: Dict[str, List[str]] = {}
|
|
586
|
+
for rel_file in file_groups:
|
|
587
|
+
dir_part = os.path.dirname(rel_file)
|
|
588
|
+
dir_files.setdefault(dir_part, []).append(rel_file)
|
|
589
|
+
|
|
590
|
+
for _dir, dir_file_list in dir_files.items():
|
|
591
|
+
if len(dir_file_list) < 2 or len(dir_file_list) > _SAME_DIR_MAX_FILES:
|
|
592
|
+
continue
|
|
593
|
+
_connect_file_representatives(graph, dir_file_list, file_groups)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _add_window_edges(graph, file_groups):
|
|
597
|
+
"""Phase 1E: sliding-window within-file edges. Functions defined near
|
|
598
|
+
each other in the same file are empirically the strongest co-change
|
|
599
|
+
signal after direct calls. A sliding window of WINDOW_SIZE links each
|
|
600
|
+
function to its closest neighbours in definition order. Cap: skip
|
|
601
|
+
files with >FILE_WINDOW_MAX_SYMS symbols (e.g. a 600-function
|
|
602
|
+
god-file would create O(n*w) noise edges)."""
|
|
603
|
+
WINDOW_SIZE = 3 # each function linked to ±3 neighbours
|
|
604
|
+
FILE_WINDOW_MAX_SYMS = 60 # skip window edges in very large files
|
|
605
|
+
|
|
606
|
+
for rel_file, syms_in_file in file_groups.items():
|
|
607
|
+
if len(syms_in_file) < 2 or len(syms_in_file) > FILE_WINDOW_MAX_SYMS:
|
|
608
|
+
continue
|
|
609
|
+
for i, a in enumerate(syms_in_file):
|
|
610
|
+
for j in range(i + 1, min(i + 1 + WINDOW_SIZE, len(syms_in_file))):
|
|
611
|
+
_connect_pair(graph, a, syms_in_file[j])
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _add_parent_child_edges(graph, override_pairs):
|
|
615
|
+
"""Phase 1F: light parent→child inheritance edges. Child→parent
|
|
616
|
+
already exists (Phase 1A). For parents with FEW children
|
|
617
|
+
(≤PARENT_CHILD_MAX_CHILDREN), also add parent→child so that a change
|
|
618
|
+
to the parent method surfaces its direct overriders. We skip parents
|
|
619
|
+
with many children to avoid creating mega-hubs (e.g. BaseModel in
|
|
620
|
+
pydantic has 400+ subclasses — those would destroy ranking)."""
|
|
621
|
+
PARENT_CHILD_MAX_CHILDREN = 8
|
|
622
|
+
|
|
623
|
+
parent_to_children: Dict[str, List[str]] = {}
|
|
624
|
+
for fid, parent_method in override_pairs:
|
|
625
|
+
parent_to_children.setdefault(parent_method, []).append(fid)
|
|
626
|
+
|
|
627
|
+
for parent_method, children in parent_to_children.items():
|
|
628
|
+
if len(children) > PARENT_CHILD_MAX_CHILDREN:
|
|
629
|
+
continue # too many — would create a mega-hub
|
|
630
|
+
for child_fid in children:
|
|
631
|
+
if child_fid not in graph.get(parent_method, []):
|
|
632
|
+
graph.setdefault(parent_method, []).append(child_fid)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
_SIBLING_OVERRIDE_MAX_GROUP = 6 # skip dispatch families larger than this
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _add_sibling_override_edges(graph, dispatch_groups):
|
|
639
|
+
"""Phase 1G: dispatch-sibling override edges. Two subclasses of the
|
|
640
|
+
same base that define the same method name form a dispatch pair —
|
|
641
|
+
change one backend's implementation and its siblings are the code
|
|
642
|
+
most likely to co-change (a measured blind spot: the eval_v2
|
|
643
|
+
backend/dispatch failure bucket). Unlike Phase 1A/1F these edges do
|
|
644
|
+
not require the base class to define the method at all, which is the
|
|
645
|
+
common duck-typed-dispatch shape. Pairwise and bidirectional, but
|
|
646
|
+
only for small families: a method defined by dozens of subclasses
|
|
647
|
+
(visit_* on every AST visitor) is a hub, not a dispatch pair, and
|
|
648
|
+
would destroy ranking."""
|
|
649
|
+
for (_base, _name), members in dispatch_groups.items():
|
|
650
|
+
uniq = list(dict.fromkeys(members))
|
|
651
|
+
if len(uniq) < 2 or len(uniq) > _SIBLING_OVERRIDE_MAX_GROUP:
|
|
652
|
+
continue
|
|
653
|
+
for i, a in enumerate(uniq):
|
|
654
|
+
for b in uniq[i + 1:]:
|
|
655
|
+
_connect_pair(graph, a, b)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
|
659
|
+
|
|
660
|
+
def _collect_function_nodes(tree):
|
|
661
|
+
"""
|
|
662
|
+
Return list of (function_node, is_method, class_name) for EVERY function
|
|
663
|
+
in the file, including nested functions and closures.
|
|
664
|
+
|
|
665
|
+
Mirrors what parser.py's _collect_functions does so that the graph covers
|
|
666
|
+
every symbol the parser emits (previously missed ~30-40% of nodes) —
|
|
667
|
+
with one known gap: this collector does not descend into if/try/with
|
|
668
|
+
blocks, so a `def` under `if TYPE_CHECKING:` or `try/except ImportError`
|
|
669
|
+
is parsed as a symbol but gets no graph edges.
|
|
670
|
+
"""
|
|
671
|
+
result = []
|
|
672
|
+
_collect_recursive(tree.body, class_stack=[], result=result)
|
|
673
|
+
return result
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _collect_recursive(stmts, class_stack, result):
|
|
677
|
+
"""Recursively collect function nodes from a list of statements."""
|
|
678
|
+
for node in stmts:
|
|
679
|
+
if isinstance(node, ast.ClassDef):
|
|
680
|
+
class_stack.append(node.name)
|
|
681
|
+
_collect_recursive(node.body, class_stack, result)
|
|
682
|
+
class_stack.pop()
|
|
683
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
684
|
+
if class_stack:
|
|
685
|
+
class_name = ".".join(class_stack)
|
|
686
|
+
is_method = True
|
|
687
|
+
else:
|
|
688
|
+
class_name = None
|
|
689
|
+
is_method = False
|
|
690
|
+
result.append((node, is_method, class_name))
|
|
691
|
+
# Also recurse into the function body to catch closures/nested funcs
|
|
692
|
+
_collect_recursive(node.body, class_stack, result)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _find_return_type(node):
|
|
696
|
+
found = None
|
|
697
|
+
for stmt in _iter_statements(node.body):
|
|
698
|
+
if isinstance(stmt, ast.Return) and stmt.value is not None:
|
|
699
|
+
if not isinstance(stmt.value, ast.Call):
|
|
700
|
+
return None
|
|
701
|
+
func = stmt.value.func
|
|
702
|
+
if isinstance(func, ast.Name):
|
|
703
|
+
ref = (None, func.id)
|
|
704
|
+
elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
705
|
+
ref = (func.value.id, func.attr)
|
|
706
|
+
elif isinstance(func, ast.Attribute):
|
|
707
|
+
ref = (None, func.attr)
|
|
708
|
+
else:
|
|
709
|
+
return None
|
|
710
|
+
if found is None:
|
|
711
|
+
found = ref
|
|
712
|
+
elif found != ref:
|
|
713
|
+
return None
|
|
714
|
+
return found
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _find_annotated_return(node):
|
|
718
|
+
"""
|
|
719
|
+
Extract (qualifier, bare_name) from a PEP-3107 return annotation.
|
|
720
|
+
|
|
721
|
+
def f() -> MyClass: ... -> (None, "MyClass")
|
|
722
|
+
def f() -> module.MyClass: ... -> ("module", "MyClass")
|
|
723
|
+
|
|
724
|
+
Skips primitive annotations (str, int, bool, None, etc.) and generics
|
|
725
|
+
like List[X] where the outer type is not a class we own.
|
|
726
|
+
"""
|
|
727
|
+
PRIMITIVES = frozenset({
|
|
728
|
+
"str", "int", "float", "bool", "bytes", "None",
|
|
729
|
+
"list", "dict", "set", "tuple", "Any", "Optional",
|
|
730
|
+
"List", "Dict", "Set", "Tuple", "Iterator", "Generator",
|
|
731
|
+
"Iterable", "Sequence", "Mapping", "Type", "Union",
|
|
732
|
+
})
|
|
733
|
+
ann = node.returns
|
|
734
|
+
if ann is None:
|
|
735
|
+
return None
|
|
736
|
+
if isinstance(ann, ast.Constant):
|
|
737
|
+
return None
|
|
738
|
+
if isinstance(ann, ast.Name):
|
|
739
|
+
if ann.id in PRIMITIVES:
|
|
740
|
+
return None
|
|
741
|
+
return (None, ann.id)
|
|
742
|
+
if isinstance(ann, ast.Attribute) and isinstance(ann.value, ast.Name):
|
|
743
|
+
if ann.attr in PRIMITIVES:
|
|
744
|
+
return None
|
|
745
|
+
return (ann.value.id, ann.attr)
|
|
746
|
+
# Subscript like Optional[Router] — unwrap one level
|
|
747
|
+
if isinstance(ann, ast.Subscript):
|
|
748
|
+
return _find_annotated_return(
|
|
749
|
+
type("_Stub", (), {"returns": ann.slice})() # type: ignore[arg-type]
|
|
750
|
+
)
|
|
751
|
+
return None
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _resolve_owner_type(
|
|
755
|
+
qualifier, bare_name, relative_file, import_map,
|
|
756
|
+
class_registry, factory_returns, import_maps, repo_path, _seen=None,
|
|
757
|
+
classes_by_file=None,
|
|
758
|
+
):
|
|
759
|
+
if _seen is None:
|
|
760
|
+
_seen = set()
|
|
761
|
+
|
|
762
|
+
cache_key = (qualifier, bare_name, relative_file)
|
|
763
|
+
if cache_key in _seen:
|
|
764
|
+
return None
|
|
765
|
+
_seen.add(cache_key)
|
|
766
|
+
|
|
767
|
+
if qualifier:
|
|
768
|
+
if qualifier not in import_map:
|
|
769
|
+
return None
|
|
770
|
+
target_file = "./" + os.path.relpath(import_map[qualifier], repo_path)
|
|
771
|
+
|
|
772
|
+
if bare_name in class_registry:
|
|
773
|
+
if target_file in class_registry[bare_name]:
|
|
774
|
+
return f"{target_file}:{bare_name}"
|
|
775
|
+
if target_file.endswith("__init__.py"):
|
|
776
|
+
target_dir = target_file[:-12]
|
|
777
|
+
for cand_file in class_registry[bare_name]:
|
|
778
|
+
if cand_file.startswith(target_dir + "/"):
|
|
779
|
+
return f"{cand_file}:{bare_name}"
|
|
780
|
+
|
|
781
|
+
factory_key = f"{target_file}:{bare_name}"
|
|
782
|
+
if factory_key in factory_returns:
|
|
783
|
+
return _resolve_owner_type(
|
|
784
|
+
*factory_returns[factory_key], target_file,
|
|
785
|
+
import_maps.get(target_file, {}), class_registry,
|
|
786
|
+
factory_returns, import_maps, repo_path, _seen,
|
|
787
|
+
classes_by_file,
|
|
788
|
+
)
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
# bare name
|
|
792
|
+
if bare_name in class_registry and relative_file in class_registry[bare_name]:
|
|
793
|
+
return f"{relative_file}:{bare_name}"
|
|
794
|
+
|
|
795
|
+
if bare_name in import_map:
|
|
796
|
+
target_file = "./" + os.path.relpath(import_map[bare_name], repo_path)
|
|
797
|
+
|
|
798
|
+
if bare_name in class_registry:
|
|
799
|
+
if target_file in class_registry[bare_name]:
|
|
800
|
+
return f"{target_file}:{bare_name}"
|
|
801
|
+
if target_file.endswith("__init__.py"):
|
|
802
|
+
target_dir = target_file[:-12]
|
|
803
|
+
for cand_file in class_registry[bare_name]:
|
|
804
|
+
if cand_file.startswith(target_dir + "/"):
|
|
805
|
+
return f"{cand_file}:{bare_name}"
|
|
806
|
+
elif classes_by_file:
|
|
807
|
+
file_classes = classes_by_file.get(target_file, [])
|
|
808
|
+
if len(file_classes) == 1:
|
|
809
|
+
return f"{target_file}:{file_classes[0]}"
|
|
810
|
+
|
|
811
|
+
factory_key = f"{target_file}:{bare_name}"
|
|
812
|
+
if factory_key in factory_returns:
|
|
813
|
+
return _resolve_owner_type(
|
|
814
|
+
*factory_returns[factory_key], target_file,
|
|
815
|
+
import_maps.get(target_file, {}), class_registry,
|
|
816
|
+
factory_returns, import_maps, repo_path, _seen,
|
|
817
|
+
classes_by_file,
|
|
818
|
+
)
|
|
819
|
+
return None
|
|
820
|
+
|
|
821
|
+
factory_key = f"{relative_file}:{bare_name}"
|
|
822
|
+
if factory_key in factory_returns:
|
|
823
|
+
return _resolve_owner_type(
|
|
824
|
+
*factory_returns[factory_key], relative_file,
|
|
825
|
+
import_map, class_registry, factory_returns,
|
|
826
|
+
import_maps, repo_path, _seen,
|
|
827
|
+
classes_by_file=classes_by_file,
|
|
828
|
+
)
|
|
829
|
+
return None
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _resolve_owner_of_expr(
|
|
833
|
+
node, class_name, relative_file, is_method, attribute_owners,
|
|
834
|
+
local_var_types=None, import_map=None, class_registry=None,
|
|
835
|
+
factory_returns=None, import_maps=None, repo_path=None,
|
|
836
|
+
classes_by_file=None, _cached_resolve=None,
|
|
837
|
+
):
|
|
838
|
+
if isinstance(node, ast.Name) and node.id == "self" and is_method and class_name:
|
|
839
|
+
return f"{relative_file}:{class_name}"
|
|
840
|
+
|
|
841
|
+
if (
|
|
842
|
+
isinstance(node, ast.Name)
|
|
843
|
+
and local_var_types
|
|
844
|
+
and node.id in local_var_types
|
|
845
|
+
and import_map is not None
|
|
846
|
+
):
|
|
847
|
+
qualifier, bare_name = local_var_types[node.id]
|
|
848
|
+
if _cached_resolve:
|
|
849
|
+
return _cached_resolve(qualifier, bare_name, relative_file, import_map)
|
|
850
|
+
return _resolve_owner_type(
|
|
851
|
+
qualifier, bare_name, relative_file, import_map,
|
|
852
|
+
class_registry or {}, factory_returns or {},
|
|
853
|
+
import_maps or {}, repo_path or "",
|
|
854
|
+
classes_by_file=classes_by_file,
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
# Module-level variable fallback: `app = Flask()` at module scope.
|
|
858
|
+
# attribute_owners stores these as "{rel_file}:{var_name}".
|
|
859
|
+
if isinstance(node, ast.Name) and attribute_owners:
|
|
860
|
+
module_key = f"{relative_file}:{node.id}"
|
|
861
|
+
if module_key in attribute_owners:
|
|
862
|
+
return attribute_owners[module_key]
|
|
863
|
+
|
|
864
|
+
if isinstance(node, ast.Attribute):
|
|
865
|
+
base_owner = _resolve_owner_of_expr(
|
|
866
|
+
node.value, class_name, relative_file, is_method, attribute_owners,
|
|
867
|
+
local_var_types, import_map, class_registry,
|
|
868
|
+
factory_returns, import_maps, repo_path,
|
|
869
|
+
classes_by_file, _cached_resolve,
|
|
870
|
+
)
|
|
871
|
+
if base_owner is None:
|
|
872
|
+
return None
|
|
873
|
+
return attribute_owners.get(f"{base_owner}.{node.attr}")
|
|
874
|
+
|
|
875
|
+
return None
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _resolve_via_inheritance(
|
|
879
|
+
owner_type, method_name, inheritance, class_registry,
|
|
880
|
+
import_maps, function_ids, repo_path, _seen=None,
|
|
881
|
+
classes_by_file=None,
|
|
882
|
+
):
|
|
883
|
+
if _seen is None:
|
|
884
|
+
_seen = set()
|
|
885
|
+
if owner_type in _seen:
|
|
886
|
+
return None
|
|
887
|
+
_seen.add(owner_type)
|
|
888
|
+
|
|
889
|
+
rel_file, class_name = owner_type.split(":", 1)
|
|
890
|
+
import_map = import_maps.get(rel_file, {})
|
|
891
|
+
|
|
892
|
+
for qualifier, base_name in inheritance.get(owner_type, []):
|
|
893
|
+
base_owner = _resolve_owner_type(
|
|
894
|
+
qualifier, base_name, rel_file, import_map,
|
|
895
|
+
class_registry, {}, import_maps, repo_path,
|
|
896
|
+
classes_by_file=classes_by_file,
|
|
897
|
+
)
|
|
898
|
+
if not base_owner:
|
|
899
|
+
continue
|
|
900
|
+
|
|
901
|
+
candidate = f"{base_owner}.{method_name}"
|
|
902
|
+
if candidate in function_ids:
|
|
903
|
+
return candidate
|
|
904
|
+
|
|
905
|
+
deeper = _resolve_via_inheritance(
|
|
906
|
+
base_owner, method_name, inheritance, class_registry,
|
|
907
|
+
import_maps, function_ids, repo_path, _seen,
|
|
908
|
+
classes_by_file,
|
|
909
|
+
)
|
|
910
|
+
if deeper:
|
|
911
|
+
return deeper
|
|
912
|
+
|
|
913
|
+
return None
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _resolve_call(
|
|
917
|
+
call_node, is_method, class_name, relative_file,
|
|
918
|
+
local_name_to_id, import_map, all_functions, function_ids,
|
|
919
|
+
repo_path, attribute_owners, inheritance, class_registry,
|
|
920
|
+
factory_returns, import_maps, local_var_types=None,
|
|
921
|
+
classes_by_file=None, _cached_resolve=None,
|
|
922
|
+
):
|
|
923
|
+
return _resolve_func_expr(
|
|
924
|
+
call_node.func, is_method, class_name, relative_file,
|
|
925
|
+
local_name_to_id, import_map, all_functions, function_ids,
|
|
926
|
+
repo_path, attribute_owners, inheritance, class_registry,
|
|
927
|
+
factory_returns, import_maps, local_var_types,
|
|
928
|
+
classes_by_file, _cached_resolve,
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _resolve_func_expr(
|
|
933
|
+
func, is_method, class_name, relative_file,
|
|
934
|
+
local_name_to_id, import_map, all_functions, function_ids,
|
|
935
|
+
repo_path, attribute_owners, inheritance, class_registry,
|
|
936
|
+
factory_returns, import_maps, local_var_types=None,
|
|
937
|
+
classes_by_file=None, _cached_resolve=None,
|
|
938
|
+
):
|
|
939
|
+
"""Resolve a Name/Attribute expression that denotes a function — either
|
|
940
|
+
the callee of a Call node or a function reference passed as an argument
|
|
941
|
+
(`partial(black.format_file_contents, ...)`, `sorted(xs, key=fn)`)."""
|
|
942
|
+
|
|
943
|
+
if isinstance(func, ast.Name):
|
|
944
|
+
return _lookup(func.id, local_name_to_id, import_map, all_functions, repo_path)
|
|
945
|
+
|
|
946
|
+
if isinstance(func, ast.Attribute):
|
|
947
|
+
method_name = func.attr
|
|
948
|
+
|
|
949
|
+
owner_type = _resolve_owner_of_expr(
|
|
950
|
+
func.value, class_name, relative_file, is_method, attribute_owners,
|
|
951
|
+
local_var_types, import_map, class_registry,
|
|
952
|
+
factory_returns, import_maps, repo_path,
|
|
953
|
+
classes_by_file, _cached_resolve,
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
if owner_type:
|
|
957
|
+
candidate = f"{owner_type}.{method_name}"
|
|
958
|
+
if candidate in function_ids:
|
|
959
|
+
return candidate
|
|
960
|
+
|
|
961
|
+
resolved = _resolve_via_inheritance(
|
|
962
|
+
owner_type, method_name, inheritance, class_registry,
|
|
963
|
+
import_maps, function_ids, repo_path,
|
|
964
|
+
classes_by_file=classes_by_file,
|
|
965
|
+
)
|
|
966
|
+
if resolved:
|
|
967
|
+
return resolved
|
|
968
|
+
return None
|
|
969
|
+
|
|
970
|
+
# Module-attribute call: `black.format_file_contents(...)` after
|
|
971
|
+
# `import black`, or `mypkg.core.fn(...)` after `import mypkg.core`.
|
|
972
|
+
dotted = _dotted_module_name(func.value)
|
|
973
|
+
if dotted and dotted in import_map:
|
|
974
|
+
source_file = import_map[dotted]
|
|
975
|
+
relative_source = "./" + os.path.relpath(source_file, repo_path)
|
|
976
|
+
candidate = f"{relative_source}:{method_name}"
|
|
977
|
+
if candidate in all_functions:
|
|
978
|
+
return candidate
|
|
979
|
+
# __init__ transparency: the attribute may be re-exported from a
|
|
980
|
+
# submodule (`black.parse_ast` lives in black/parsing.py). The
|
|
981
|
+
# __init__.py's own import map IS its re-export table — no
|
|
982
|
+
# re-parsing needed.
|
|
983
|
+
if relative_source.endswith("__init__.py"):
|
|
984
|
+
init_map = import_maps.get(relative_source, {})
|
|
985
|
+
target = init_map.get(method_name)
|
|
986
|
+
if target:
|
|
987
|
+
real_source = "./" + os.path.relpath(target, repo_path)
|
|
988
|
+
candidate = f"{real_source}:{method_name}"
|
|
989
|
+
if candidate in all_functions:
|
|
990
|
+
return candidate
|
|
991
|
+
|
|
992
|
+
return None
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _all_param_names(fn_node):
|
|
996
|
+
"""Every parameter name of a function, including * / ** and kw-only."""
|
|
997
|
+
a = fn_node.args
|
|
998
|
+
names = {p.arg for p in a.args + a.posonlyargs + a.kwonlyargs}
|
|
999
|
+
if a.vararg:
|
|
1000
|
+
names.add(a.vararg.arg)
|
|
1001
|
+
if a.kwarg:
|
|
1002
|
+
names.add(a.kwarg.arg)
|
|
1003
|
+
return names
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _dotted_module_name(expr):
|
|
1007
|
+
"""`Name(a)` → "a"; `Attribute(Name(a), b)` → "a.b"; anything else → None."""
|
|
1008
|
+
if isinstance(expr, ast.Name):
|
|
1009
|
+
return expr.id
|
|
1010
|
+
if isinstance(expr, ast.Attribute):
|
|
1011
|
+
base = _dotted_module_name(expr.value)
|
|
1012
|
+
if base:
|
|
1013
|
+
return f"{base}.{expr.attr}"
|
|
1014
|
+
return None
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _lookup(called_name, local_name_to_id, import_map, all_functions, repo_path):
|
|
1018
|
+
if called_name in local_name_to_id:
|
|
1019
|
+
return local_name_to_id[called_name]
|
|
1020
|
+
if called_name in import_map:
|
|
1021
|
+
source_file = import_map[called_name]
|
|
1022
|
+
relative_source = "./" + os.path.relpath(source_file, repo_path)
|
|
1023
|
+
candidate = f"{relative_source}:{called_name}"
|
|
1024
|
+
if candidate in all_functions:
|
|
1025
|
+
return candidate
|
|
1026
|
+
return None
|