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.
Files changed (40) hide show
  1. diffcontext/__init__.py +233 -0
  2. diffcontext/_warn_once.py +112 -0
  3. diffcontext/cache.py +216 -0
  4. diffcontext/cli/__init__.py +655 -0
  5. diffcontext/context/__init__.py +1 -0
  6. diffcontext/context/compiler.py +643 -0
  7. diffcontext/context/selector.py +258 -0
  8. diffcontext/diff/__init__.py +1 -0
  9. diffcontext/diff/git_diff.py +298 -0
  10. diffcontext/diff/state_manager.py +75 -0
  11. diffcontext/graph_builder.py +1026 -0
  12. diffcontext/history.py +154 -0
  13. diffcontext/impact/__init__.py +1 -0
  14. diffcontext/impact/blast_radius.py +58 -0
  15. diffcontext/impact/scoring.py +223 -0
  16. diffcontext/impact/traversal.py +58 -0
  17. diffcontext/impact/visualizer.py +338 -0
  18. diffcontext/languages/__init__.py +80 -0
  19. diffcontext/languages/typescript.py +960 -0
  20. diffcontext/lexical.py +108 -0
  21. diffcontext/models.py +180 -0
  22. diffcontext/parser.py +183 -0
  23. diffcontext/pipeline.py +887 -0
  24. diffcontext/py.typed +0 -0
  25. diffcontext/rerank/__init__.py +17 -0
  26. diffcontext/rerank/features.py +356 -0
  27. diffcontext/rerank/model.py +175 -0
  28. diffcontext/resolver.py +288 -0
  29. diffcontext/scanner.py +153 -0
  30. diffcontext/symbols.py +254 -0
  31. diffcontext/verify/__init__.py +68 -0
  32. diffcontext/verify/cases.py +631 -0
  33. diffcontext/verify/history.py +396 -0
  34. diffcontext/verify/sufficiency.py +324 -0
  35. diffcontext-0.5.1.dist-info/METADATA +219 -0
  36. diffcontext-0.5.1.dist-info/RECORD +40 -0
  37. diffcontext-0.5.1.dist-info/WHEEL +5 -0
  38. diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
  39. diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
  40. 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