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
diffcontext/resolver.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
resolver.py — Resolve Python import statements to filesystem paths.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from collections import deque
|
|
9
|
+
from typing import Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from ._warn_once import warn_syntax_error_once, check_and_warn_encoding
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Import statements can only appear in statement blocks — never inside an
|
|
17
|
+
# expression — so import scanning walks only these fields instead of every
|
|
18
|
+
# AST node (expressions dominate node count ~10:1). Field order mirrors the
|
|
19
|
+
# AST's own field order so import precedence matches a full BFS walk.
|
|
20
|
+
_STMT_BLOCK_FIELDS = ("body", "handlers", "orelse", "finalbody", "cases")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _iter_import_nodes(tree: "ast.Module"):
|
|
24
|
+
"""Yield every Import/ImportFrom in the tree, in BFS document order,
|
|
25
|
+
without descending into expression subtrees."""
|
|
26
|
+
queue = deque(tree.body)
|
|
27
|
+
while queue:
|
|
28
|
+
node = queue.popleft()
|
|
29
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
30
|
+
yield node
|
|
31
|
+
continue
|
|
32
|
+
for field in _STMT_BLOCK_FIELDS:
|
|
33
|
+
block = getattr(node, field, None)
|
|
34
|
+
if block:
|
|
35
|
+
queue.extend(block)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_import_map(
|
|
39
|
+
filename: str,
|
|
40
|
+
repo_path: str,
|
|
41
|
+
tree: "Optional[ast.Module]" = None,
|
|
42
|
+
) -> Dict[str, str]:
|
|
43
|
+
"""
|
|
44
|
+
Parse imports in a file and resolve them to absolute paths.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
dict: local_name -> absolute_path_of_source_file
|
|
48
|
+
e.g. {"helper": "/repo/utils.py", "Session": "/repo/sessions.py"}
|
|
49
|
+
|
|
50
|
+
__init__.py transparency:
|
|
51
|
+
When an import resolves to a package __init__.py, we scan that
|
|
52
|
+
__init__.py for re-export statements (`from .sub import Name`) and
|
|
53
|
+
follow them to the actual definition file. This means:
|
|
54
|
+
|
|
55
|
+
from flask import Flask
|
|
56
|
+
# resolves to src/flask/__init__.py
|
|
57
|
+
# __init__.py has: from .app import Flask
|
|
58
|
+
# final result: src/flask/app.py ← correct
|
|
59
|
+
"""
|
|
60
|
+
# Accept a pre-parsed AST to avoid re-reading and re-parsing the file
|
|
61
|
+
# (the pipeline parses each file exactly once and shares the tree).
|
|
62
|
+
if tree is None:
|
|
63
|
+
with open(filename, "rb") as f:
|
|
64
|
+
raw = f.read()
|
|
65
|
+
check_and_warn_encoding(logger, filename, raw)
|
|
66
|
+
source = raw.decode("utf-8", errors="ignore")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
tree = ast.parse(source)
|
|
70
|
+
except SyntaxError as e:
|
|
71
|
+
warn_syntax_error_once(logger, filename, e)
|
|
72
|
+
return {}
|
|
73
|
+
|
|
74
|
+
imports: Dict[str, str] = {}
|
|
75
|
+
file_dir = os.path.dirname(os.path.abspath(filename))
|
|
76
|
+
repo_abs = os.path.abspath(repo_path)
|
|
77
|
+
|
|
78
|
+
for node in _iter_import_nodes(tree):
|
|
79
|
+
|
|
80
|
+
if isinstance(node, ast.ImportFrom):
|
|
81
|
+
module = node.module or ""
|
|
82
|
+
level = node.level
|
|
83
|
+
|
|
84
|
+
if level > 0:
|
|
85
|
+
# Relative import: from .utils import helper
|
|
86
|
+
base = file_dir
|
|
87
|
+
for _ in range(level - 1):
|
|
88
|
+
base = os.path.dirname(base)
|
|
89
|
+
module_path = os.path.join(base, module.replace(".", os.sep))
|
|
90
|
+
resolved = _resolve_module_path(module_path)
|
|
91
|
+
else:
|
|
92
|
+
# Absolute import: from requests.utils import helper
|
|
93
|
+
# Resolved against every source root (repo root, then src/):
|
|
94
|
+
# black, flask, and most modern PyPI projects keep their
|
|
95
|
+
# packages under src/, where a repo-root-only lookup finds
|
|
96
|
+
# nothing and silently drops every edge into the package.
|
|
97
|
+
module_path, resolved = _resolve_absolute_module(
|
|
98
|
+
module, repo_abs
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
for alias in node.names:
|
|
102
|
+
local_name = alias.asname or alias.name
|
|
103
|
+
|
|
104
|
+
# Check if this is a submodule import (from package import module)
|
|
105
|
+
submodule_path = os.path.join(module_path, alias.name)
|
|
106
|
+
submodule_resolved = _resolve_module_path(submodule_path)
|
|
107
|
+
|
|
108
|
+
if submodule_resolved:
|
|
109
|
+
imports[local_name] = submodule_resolved
|
|
110
|
+
elif resolved:
|
|
111
|
+
# __init__.py transparency: follow re-exports one level
|
|
112
|
+
if resolved.endswith("__init__.py"):
|
|
113
|
+
real = _follow_init_reexport(resolved, alias.name, repo_abs)
|
|
114
|
+
imports[local_name] = real if real else resolved
|
|
115
|
+
else:
|
|
116
|
+
imports[local_name] = resolved
|
|
117
|
+
|
|
118
|
+
elif isinstance(node, ast.Import):
|
|
119
|
+
for alias in node.names:
|
|
120
|
+
top_module = alias.name.split(".")[0]
|
|
121
|
+
|
|
122
|
+
if alias.asname:
|
|
123
|
+
# `import a.b as ab` — ab refers to the submodule a.b
|
|
124
|
+
_mp, resolved = _resolve_absolute_module(alias.name, repo_abs)
|
|
125
|
+
if not resolved:
|
|
126
|
+
sibling = os.path.join(
|
|
127
|
+
file_dir, alias.name.replace(".", os.sep)
|
|
128
|
+
)
|
|
129
|
+
resolved = _resolve_module_path(sibling)
|
|
130
|
+
if resolved:
|
|
131
|
+
imports[alias.asname] = resolved
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# `import a` / `import a.b` — the bound local name is the TOP
|
|
135
|
+
# package `a` (binding it to a/b.py, as previously done, sent
|
|
136
|
+
# `a.other()` calls into the wrong file).
|
|
137
|
+
_mp, resolved = _resolve_absolute_module(top_module, repo_abs)
|
|
138
|
+
if not resolved:
|
|
139
|
+
# Bare `import x` — try the importing file's own directory
|
|
140
|
+
sibling_path = os.path.join(
|
|
141
|
+
file_dir, top_module.replace(".", os.sep)
|
|
142
|
+
)
|
|
143
|
+
resolved = _resolve_module_path(sibling_path)
|
|
144
|
+
if resolved:
|
|
145
|
+
imports[top_module] = resolved
|
|
146
|
+
|
|
147
|
+
# `import a.b` also makes `a.b.fn()` callable — record the
|
|
148
|
+
# full dotted path (dots can't collide with identifiers).
|
|
149
|
+
if "." in alias.name:
|
|
150
|
+
_mp, sub_resolved = _resolve_absolute_module(
|
|
151
|
+
alias.name, repo_abs
|
|
152
|
+
)
|
|
153
|
+
if sub_resolved:
|
|
154
|
+
imports[alias.name] = sub_resolved
|
|
155
|
+
|
|
156
|
+
return imports
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Conventional source roots tried, in order, when resolving absolute
|
|
160
|
+
# imports. "" is the repo root itself (flat layout); "src" is the
|
|
161
|
+
# setuptools src-layout used by black, flask, requests, and most modern
|
|
162
|
+
# PyPI projects.
|
|
163
|
+
_SOURCE_ROOT_NAMES = ("", "src")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _resolve_absolute_module(module: str, repo_abs: str):
|
|
167
|
+
"""
|
|
168
|
+
Resolve a dotted absolute module name against each candidate source
|
|
169
|
+
root of the repository.
|
|
170
|
+
|
|
171
|
+
Returns (module_path, resolved_file):
|
|
172
|
+
module_path — directory-ish path for the module under the root that
|
|
173
|
+
matched (used for submodule probing by the caller);
|
|
174
|
+
falls back to the repo-root join when nothing matched.
|
|
175
|
+
resolved_file — the module's .py / package __init__.py, or None.
|
|
176
|
+
"""
|
|
177
|
+
rel = module.replace(".", os.sep)
|
|
178
|
+
fallback = os.path.join(repo_abs, rel)
|
|
179
|
+
for root_name in _SOURCE_ROOT_NAMES:
|
|
180
|
+
root = os.path.join(repo_abs, root_name) if root_name else repo_abs
|
|
181
|
+
candidate = os.path.join(root, rel)
|
|
182
|
+
resolved = _resolve_module_path(candidate)
|
|
183
|
+
if resolved:
|
|
184
|
+
return candidate, resolved
|
|
185
|
+
# Namespace package (no __init__.py): a real directory still lets
|
|
186
|
+
# the caller find `from pkg import submodule` targets inside it.
|
|
187
|
+
if os.path.isdir(candidate):
|
|
188
|
+
return candidate, None
|
|
189
|
+
return fallback, None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Session cache of parsed __init__.py re-export specs, keyed by absolute
|
|
193
|
+
# path and validated by (mtime_ns, size) so an edited file is re-read. A
|
|
194
|
+
# flagship package's __init__.py (django.db.models, flask) is consulted by
|
|
195
|
+
# hundreds of importing files per cold index; without this each consult
|
|
196
|
+
# re-read and re-parsed it from disk.
|
|
197
|
+
_init_export_cache: "Dict[str, Tuple[Tuple[int, int], Optional[Dict[str, List[Tuple[str, int, str]]]]]]" = {}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _init_export_specs(
|
|
201
|
+
init_path: str,
|
|
202
|
+
) -> "Optional[Dict[str, List[Tuple[str, int, str]]]]":
|
|
203
|
+
"""
|
|
204
|
+
exported_name -> [(module, level, original_name), ...] for every
|
|
205
|
+
`from X import Y` in the file, in document order. None when the file
|
|
206
|
+
is unreadable or unparsable.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
st = os.stat(init_path)
|
|
210
|
+
stat_key = (st.st_mtime_ns, st.st_size)
|
|
211
|
+
except OSError:
|
|
212
|
+
return None
|
|
213
|
+
cached = _init_export_cache.get(init_path)
|
|
214
|
+
if cached is not None and cached[0] == stat_key:
|
|
215
|
+
return cached[1]
|
|
216
|
+
|
|
217
|
+
specs: "Optional[Dict[str, List[Tuple[str, int, str]]]]"
|
|
218
|
+
try:
|
|
219
|
+
with open(init_path, "rb") as f:
|
|
220
|
+
raw = f.read()
|
|
221
|
+
tree = ast.parse(raw.decode("utf-8", errors="ignore"))
|
|
222
|
+
except (OSError, SyntaxError):
|
|
223
|
+
specs = None
|
|
224
|
+
else:
|
|
225
|
+
specs = {}
|
|
226
|
+
for node in _iter_import_nodes(tree):
|
|
227
|
+
if not isinstance(node, ast.ImportFrom):
|
|
228
|
+
continue
|
|
229
|
+
for alias in node.names:
|
|
230
|
+
exported = alias.asname or alias.name
|
|
231
|
+
specs.setdefault(exported, []).append(
|
|
232
|
+
(node.module or "", node.level, alias.name)
|
|
233
|
+
)
|
|
234
|
+
_init_export_cache[init_path] = (stat_key, specs)
|
|
235
|
+
return specs
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _follow_init_reexport(init_path: str, name: str, repo_abs: str) -> Optional[str]:
|
|
239
|
+
"""
|
|
240
|
+
Given a package __init__.py and a name imported from it, check whether
|
|
241
|
+
__init__.py re-exports that name from a submodule.
|
|
242
|
+
|
|
243
|
+
Example:
|
|
244
|
+
__init__.py contains: from .app import Flask
|
|
245
|
+
name = "Flask"
|
|
246
|
+
→ returns /abs/path/to/app.py
|
|
247
|
+
|
|
248
|
+
Both relative (`from .app import Flask`, flask style) and absolute
|
|
249
|
+
(`from black.parsing import parse_ast`, black style) re-exports are
|
|
250
|
+
followed. Only follows one level (no recursive re-export chasing) to
|
|
251
|
+
stay fast. Returns None if the name is not re-exported or the
|
|
252
|
+
submodule can't be found.
|
|
253
|
+
"""
|
|
254
|
+
specs = _init_export_specs(init_path)
|
|
255
|
+
if not specs:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
init_dir = os.path.dirname(init_path)
|
|
259
|
+
|
|
260
|
+
for module, level, original_name in specs.get(name, ()):
|
|
261
|
+
if level > 0:
|
|
262
|
+
# Relative re-export: from .sub import X
|
|
263
|
+
base = init_dir
|
|
264
|
+
for _ in range(level - 1):
|
|
265
|
+
base = os.path.dirname(base)
|
|
266
|
+
sub_path = os.path.join(base, module.replace(".", os.sep))
|
|
267
|
+
else:
|
|
268
|
+
# Absolute re-export: from black.parsing import X
|
|
269
|
+
sub_path, _resolved = _resolve_absolute_module(module, repo_abs)
|
|
270
|
+
# Check if the original name itself is a submodule
|
|
271
|
+
submodule_path = os.path.join(sub_path, original_name)
|
|
272
|
+
resolved = _resolve_module_path(submodule_path) or _resolve_module_path(sub_path)
|
|
273
|
+
if resolved and resolved != os.path.normpath(init_path):
|
|
274
|
+
return resolved
|
|
275
|
+
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _resolve_module_path(module_path: str) -> Optional[str]:
|
|
280
|
+
"""Try to find a .py file or package __init__.py for a given path."""
|
|
281
|
+
candidates = [
|
|
282
|
+
module_path + ".py",
|
|
283
|
+
os.path.join(module_path, "__init__.py"),
|
|
284
|
+
]
|
|
285
|
+
for candidate in candidates:
|
|
286
|
+
if os.path.isfile(candidate):
|
|
287
|
+
return os.path.normpath(candidate)
|
|
288
|
+
return None
|
diffcontext/scanner.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
scanner.py — Discover source files in a repository.
|
|
3
|
+
|
|
4
|
+
Python always; other languages via the optional adapters in languages/
|
|
5
|
+
(each adapter contributes its extensions to discovery only when its
|
|
6
|
+
runtime deps are installed).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from typing import List, Optional, Set, Tuple
|
|
12
|
+
|
|
13
|
+
EXCLUDED_DIRS: Set[str] = {
|
|
14
|
+
"__pycache__",
|
|
15
|
+
".git",
|
|
16
|
+
".tox",
|
|
17
|
+
".mypy_cache",
|
|
18
|
+
".pytest_cache",
|
|
19
|
+
"venv",
|
|
20
|
+
".venv",
|
|
21
|
+
"env",
|
|
22
|
+
"node_modules",
|
|
23
|
+
"experimental",
|
|
24
|
+
"examples",
|
|
25
|
+
"docs",
|
|
26
|
+
"tests",
|
|
27
|
+
"test",
|
|
28
|
+
"benchmarks",
|
|
29
|
+
"datasets",
|
|
30
|
+
"dist",
|
|
31
|
+
"build",
|
|
32
|
+
"egg-info",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Directories excluded from indexing by default. These are deliberately
|
|
36
|
+
# NOT retrieval candidates: tests/, benchmarks/, docs/ are tracked in git
|
|
37
|
+
# but rarely the "code that matters for a change", and indexing them would
|
|
38
|
+
# both bloat the graph and drown real blast-radius signal in test scaffolding.
|
|
39
|
+
#
|
|
40
|
+
# This is the single biggest practical gotcha: a commit that spans an
|
|
41
|
+
# excluded dir (e.g. benchmarks/) produces "was not found in the index"
|
|
42
|
+
# warnings for every changed symbol in that dir, and the changed file is
|
|
43
|
+
# omitted from the context entirely — the tool looks broken when it is
|
|
44
|
+
# merely mis-scoped. Override with `--include <dir>...` (see
|
|
45
|
+
# find_source_files / the CLI) to index an excluded dir; .gitignore still
|
|
46
|
+
# applies on top (a gitignored dir is not indexed even with --include).
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_excluded_dir(name: str, include: Optional[Set[str]] = None) -> bool:
|
|
50
|
+
"""True if directory `name` should be pruned, unless it is in `include`
|
|
51
|
+
(a set of directory names to keep despite the default exclusions)."""
|
|
52
|
+
if include and name in include:
|
|
53
|
+
return False
|
|
54
|
+
return name in EXCLUDED_DIRS or name.endswith(".egg-info")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _excluded(rel_path: str, include: Optional[Set[str]] = None) -> bool:
|
|
58
|
+
"""True if any directory component of rel_path is excluded (and not
|
|
59
|
+
overridden by `include`)."""
|
|
60
|
+
parts = rel_path.replace(os.sep, "/").split("/")[:-1]
|
|
61
|
+
return any(_is_excluded_dir(p, include) for p in parts)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def first_excluded_dir(
|
|
65
|
+
rel_path: str, include: Optional[Set[str]] = None,
|
|
66
|
+
) -> Optional[str]:
|
|
67
|
+
"""Return the first directory component of `rel_path` that is excluded
|
|
68
|
+
(and not overridden by `include`), else None.
|
|
69
|
+
|
|
70
|
+
Used by warn_unknown_symbols to distinguish "your changed symbol's file
|
|
71
|
+
is outside the indexed tree" (actionable: re-run with --include) from
|
|
72
|
+
"typo / renamed / deleted" (a different kind of mistake)."""
|
|
73
|
+
parts = rel_path.replace(os.sep, "/").split("/")[:-1]
|
|
74
|
+
for p in parts:
|
|
75
|
+
if _is_excluded_dir(p, include):
|
|
76
|
+
return p
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _git_source_files(
|
|
81
|
+
root_dir: str, extensions: "Tuple[str, ...]",
|
|
82
|
+
include: Optional[Set[str]] = None,
|
|
83
|
+
) -> Optional[List[str]]:
|
|
84
|
+
"""
|
|
85
|
+
Enumerate matching files via git: tracked + untracked-but-not-ignored.
|
|
86
|
+
|
|
87
|
+
This makes indexing respect .gitignore, so vendored checkouts (e.g. a
|
|
88
|
+
cloned benchmark repo) never pollute the index — a hardcoded dir list
|
|
89
|
+
can't anticipate those. Returns None outside a git work tree or if git
|
|
90
|
+
is unavailable, so the caller falls back to the filesystem walk.
|
|
91
|
+
|
|
92
|
+
`include` overrides the hardcoded EXCLUDED_DIRS (e.g. {"benchmarks"}
|
|
93
|
+
keeps benchmarks/ even though it is excluded by default). It does NOT
|
|
94
|
+
override .gitignore — a gitignored dir is still omitted by git ls-files.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
out = subprocess.run(
|
|
98
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
|
99
|
+
cwd=root_dir, capture_output=True, timeout=15,
|
|
100
|
+
)
|
|
101
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
102
|
+
return None
|
|
103
|
+
if out.returncode != 0:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
matched = []
|
|
107
|
+
for rel in out.stdout.decode("utf-8", "replace").split("\0"):
|
|
108
|
+
if not rel.endswith(extensions) or _excluded(rel, include):
|
|
109
|
+
continue
|
|
110
|
+
full = os.path.join(root_dir, rel)
|
|
111
|
+
# --cached lists tracked files even after deletion from disk
|
|
112
|
+
if os.path.isfile(full):
|
|
113
|
+
matched.append(full)
|
|
114
|
+
return matched
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def find_source_files(
|
|
118
|
+
root_dir: str, extensions: "Tuple[str, ...]",
|
|
119
|
+
include: Optional[Set[str]] = None,
|
|
120
|
+
) -> List[str]:
|
|
121
|
+
"""
|
|
122
|
+
Return paths of files matching `extensions`: .gitignore-aware via git
|
|
123
|
+
when root_dir is inside a git work tree, else a tree walk. Both paths
|
|
124
|
+
skip EXCLUDED_DIRS (deliberate exclusions like tests/ and docs/ that
|
|
125
|
+
are tracked in git but not useful retrieval candidates).
|
|
126
|
+
|
|
127
|
+
`include` is a set of directory names to KEEP despite the default
|
|
128
|
+
exclusions (e.g. {"benchmarks", "tests"} indexes those dirs too).
|
|
129
|
+
Matching is by directory-name component anywhere in the tree, so
|
|
130
|
+
`--include tests` un-excludes both top-level tests/ and any nested
|
|
131
|
+
dir named tests/. .gitignore still applies: a gitignored dir is not
|
|
132
|
+
indexed even when named in `include`.
|
|
133
|
+
"""
|
|
134
|
+
git_files = _git_source_files(root_dir, extensions, include)
|
|
135
|
+
if git_files is not None:
|
|
136
|
+
return git_files
|
|
137
|
+
|
|
138
|
+
matched = []
|
|
139
|
+
for root, dirs, files in os.walk(root_dir):
|
|
140
|
+
dirs[:] = [d for d in dirs if not _is_excluded_dir(d, include)]
|
|
141
|
+
|
|
142
|
+
for f in files:
|
|
143
|
+
if f.endswith(extensions):
|
|
144
|
+
matched.append(os.path.join(root, f))
|
|
145
|
+
|
|
146
|
+
return matched
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def find_python_files(
|
|
150
|
+
root_dir: str, include: Optional[Set[str]] = None,
|
|
151
|
+
) -> List[str]:
|
|
152
|
+
"""Return list of .py file paths (see find_source_files)."""
|
|
153
|
+
return find_source_files(root_dir, (".py",), include)
|
diffcontext/symbols.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
symbols.py — Attribute ownership extraction for resolving self.attr.method() chains.
|
|
3
|
+
|
|
4
|
+
Given a class with `self.router = APIRouter()`, this module figures out that
|
|
5
|
+
`self.router` has type `APIRouter`, enabling cross-file method resolution.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from typing import Dict, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _iter_statements(body):
|
|
13
|
+
"""Yield statements in source order, recursing into if/for/while/with/try."""
|
|
14
|
+
for stmt in body:
|
|
15
|
+
yield stmt
|
|
16
|
+
|
|
17
|
+
if isinstance(stmt, (ast.If, ast.For, ast.AsyncFor, ast.While)):
|
|
18
|
+
yield from _iter_statements(stmt.body)
|
|
19
|
+
yield from _iter_statements(stmt.orelse)
|
|
20
|
+
|
|
21
|
+
elif isinstance(stmt, (ast.With, ast.AsyncWith)):
|
|
22
|
+
yield from _iter_statements(stmt.body)
|
|
23
|
+
|
|
24
|
+
elif isinstance(stmt, ast.Try):
|
|
25
|
+
yield from _iter_statements(stmt.body)
|
|
26
|
+
for handler in stmt.handlers:
|
|
27
|
+
yield from _iter_statements(handler.body)
|
|
28
|
+
yield from _iter_statements(stmt.orelse)
|
|
29
|
+
yield from _iter_statements(stmt.finalbody)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _extract_annotation_name(annotation) -> Optional[Tuple[Optional[str], str]]:
|
|
33
|
+
"""
|
|
34
|
+
Returns (qualifier, bare_name) or None.
|
|
35
|
+
|
|
36
|
+
Router -> (None, "Router")
|
|
37
|
+
Optional[Router]-> (None, "Router") <- unwrap subscript
|
|
38
|
+
routing.Router -> ("routing", "Router")
|
|
39
|
+
"""
|
|
40
|
+
if isinstance(annotation, ast.Name):
|
|
41
|
+
return (None, annotation.id)
|
|
42
|
+
|
|
43
|
+
if isinstance(annotation, ast.Attribute) and isinstance(annotation.value, ast.Name):
|
|
44
|
+
return (annotation.value.id, annotation.attr)
|
|
45
|
+
|
|
46
|
+
# Optional[Router], List[Router], etc. — unwrap one level
|
|
47
|
+
if isinstance(annotation, ast.Subscript):
|
|
48
|
+
return _extract_annotation_name(annotation.slice)
|
|
49
|
+
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_call_owner(value) -> Optional[Tuple[Optional[str], str]]:
|
|
54
|
+
"""
|
|
55
|
+
Returns (qualifier, bare_name) or None.
|
|
56
|
+
|
|
57
|
+
APIRouter() -> (None, "APIRouter")
|
|
58
|
+
routing.APIRouter() -> ("routing", "APIRouter")
|
|
59
|
+
make_helper() -> (None, "make_helper")
|
|
60
|
+
"""
|
|
61
|
+
if not isinstance(value, ast.Call):
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
func = value.func
|
|
65
|
+
|
|
66
|
+
if isinstance(func, ast.Name):
|
|
67
|
+
return (None, func.id)
|
|
68
|
+
|
|
69
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
70
|
+
return (func.value.id, func.attr)
|
|
71
|
+
|
|
72
|
+
if isinstance(func, ast.Attribute):
|
|
73
|
+
return (None, func.attr)
|
|
74
|
+
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _extract_assign_owner(value, local_var_types=None, param_types=None):
|
|
79
|
+
"""
|
|
80
|
+
Handles non-Call RHS cases that still indicate a type:
|
|
81
|
+
self.x = router or Router() -> BoolOp -> try each operand
|
|
82
|
+
self.x = router -> bare Name -> no type info (return None)
|
|
83
|
+
"""
|
|
84
|
+
if local_var_types is None:
|
|
85
|
+
local_var_types = {}
|
|
86
|
+
if param_types is None:
|
|
87
|
+
param_types = {}
|
|
88
|
+
|
|
89
|
+
if isinstance(value, ast.Call):
|
|
90
|
+
return _extract_call_owner(value)
|
|
91
|
+
|
|
92
|
+
if isinstance(value, ast.Name):
|
|
93
|
+
if value.id in param_types:
|
|
94
|
+
return param_types[value.id]
|
|
95
|
+
if value.id in local_var_types:
|
|
96
|
+
return local_var_types[value.id]
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
if isinstance(value, ast.BoolOp):
|
|
100
|
+
call_result = None
|
|
101
|
+
name_result = None
|
|
102
|
+
for operand in value.values:
|
|
103
|
+
ref = _extract_assign_owner(operand, local_var_types, param_types)
|
|
104
|
+
if ref is None:
|
|
105
|
+
continue
|
|
106
|
+
if isinstance(operand, ast.Call):
|
|
107
|
+
call_result = call_result or ref
|
|
108
|
+
else:
|
|
109
|
+
name_result = name_result or ref
|
|
110
|
+
return call_result or name_result
|
|
111
|
+
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def extract_local_var_types(
|
|
116
|
+
fn_node,
|
|
117
|
+
param_types: Optional[Dict[str, Tuple[Optional[str], str]]] = None,
|
|
118
|
+
) -> Dict[str, Tuple[Optional[str], str]]:
|
|
119
|
+
"""
|
|
120
|
+
Track local variable -> type assignments within a SINGLE function body,
|
|
121
|
+
free function or method alike:
|
|
122
|
+
|
|
123
|
+
h = Helper() -> {"h": (None, "Helper")}
|
|
124
|
+
r: routing.Router = ... -> {"r": ("routing", "Router")}
|
|
125
|
+
x = make_helper() -> chases factory functions the same way
|
|
126
|
+
attribute ownership tracking does
|
|
127
|
+
(resolution of the factory return type
|
|
128
|
+
itself happens later, in graph_builder)
|
|
129
|
+
|
|
130
|
+
This is the free-function counterpart to the self.attr tracking that
|
|
131
|
+
extract_attribute_ownerships does for class bodies. It exists because
|
|
132
|
+
a huge fraction of real code instantiates a class in a local variable
|
|
133
|
+
inside a plain function (not as a self.attr) and immediately calls a
|
|
134
|
+
method on it -- e.g.:
|
|
135
|
+
|
|
136
|
+
def run():
|
|
137
|
+
h = Handler()
|
|
138
|
+
return h.process()
|
|
139
|
+
|
|
140
|
+
Without this, `h.process()` can never resolve: there's nowhere that
|
|
141
|
+
records "h" has type "Handler". extract_attribute_ownerships alone
|
|
142
|
+
can't help, because it only ever looks inside ast.ClassDef bodies.
|
|
143
|
+
|
|
144
|
+
Returns: {local_var_name: (qualifier, bare_type_name)}
|
|
145
|
+
"""
|
|
146
|
+
if param_types is None:
|
|
147
|
+
param_types = {}
|
|
148
|
+
|
|
149
|
+
local_var_types: Dict[str, Tuple[Optional[str], str]] = {}
|
|
150
|
+
|
|
151
|
+
for stmt in _iter_statements(fn_node.body):
|
|
152
|
+
|
|
153
|
+
if isinstance(stmt, ast.AnnAssign):
|
|
154
|
+
target = stmt.target
|
|
155
|
+
if isinstance(target, ast.Name):
|
|
156
|
+
ref = _extract_annotation_name(stmt.annotation)
|
|
157
|
+
if ref:
|
|
158
|
+
local_var_types[target.id] = ref
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if isinstance(stmt, ast.Assign):
|
|
162
|
+
for target in stmt.targets:
|
|
163
|
+
if isinstance(target, ast.Name):
|
|
164
|
+
ref = _extract_assign_owner(
|
|
165
|
+
stmt.value, local_var_types, param_types
|
|
166
|
+
)
|
|
167
|
+
if ref:
|
|
168
|
+
local_var_types[target.id] = ref
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
return local_var_types
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def extract_param_types(fn_node) -> Dict[str, Tuple[Optional[str], str]]:
|
|
175
|
+
"""
|
|
176
|
+
Map annotated parameter names to their (qualifier, bare_name) type, e.g.
|
|
177
|
+
|
|
178
|
+
def f(router: routing.Router, name: str): ...
|
|
179
|
+
|
|
180
|
+
-> {"router": ("routing", "Router")} (unannotated/non-type params skipped)
|
|
181
|
+
"""
|
|
182
|
+
param_types: Dict[str, Tuple[Optional[str], str]] = {}
|
|
183
|
+
for arg in fn_node.args.args:
|
|
184
|
+
if arg.annotation is not None:
|
|
185
|
+
ref = _extract_annotation_name(arg.annotation)
|
|
186
|
+
if ref:
|
|
187
|
+
param_types[arg.arg] = ref
|
|
188
|
+
return param_types
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def extract_attribute_ownerships(tree) -> Dict[str, Tuple[Optional[str], str]]:
|
|
192
|
+
"""
|
|
193
|
+
Extracts mappings like:
|
|
194
|
+
self.router: routing.Router = routing.Router()
|
|
195
|
+
self.router = Router()
|
|
196
|
+
self.router2 = router # typed constructor param
|
|
197
|
+
h = Helper(); self.helper = h # local var tracking
|
|
198
|
+
self.router3 = router or Router()
|
|
199
|
+
self.opt_router: Optional[Router] = None
|
|
200
|
+
|
|
201
|
+
Returns: {"FastAPI.router": (qualifier, type_name), ...}
|
|
202
|
+
"""
|
|
203
|
+
ownerships: Dict[str, Tuple[Optional[str], str]] = {}
|
|
204
|
+
|
|
205
|
+
for node in tree.body:
|
|
206
|
+
if not isinstance(node, ast.ClassDef):
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
class_name = node.name
|
|
210
|
+
|
|
211
|
+
for item in node.body:
|
|
212
|
+
if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
param_types = extract_param_types(item)
|
|
216
|
+
|
|
217
|
+
local_var_types = {}
|
|
218
|
+
|
|
219
|
+
for stmt in _iter_statements(item.body):
|
|
220
|
+
|
|
221
|
+
if isinstance(stmt, ast.AnnAssign):
|
|
222
|
+
target = stmt.target
|
|
223
|
+
if (
|
|
224
|
+
isinstance(target, ast.Attribute)
|
|
225
|
+
and isinstance(target.value, ast.Name)
|
|
226
|
+
and target.value.id == "self"
|
|
227
|
+
):
|
|
228
|
+
ref = _extract_annotation_name(stmt.annotation)
|
|
229
|
+
if ref:
|
|
230
|
+
ownerships[f"{class_name}.{target.attr}"] = ref
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
if isinstance(stmt, ast.Assign):
|
|
234
|
+
for target in stmt.targets:
|
|
235
|
+
if (
|
|
236
|
+
isinstance(target, ast.Attribute)
|
|
237
|
+
and isinstance(target.value, ast.Name)
|
|
238
|
+
and target.value.id == "self"
|
|
239
|
+
):
|
|
240
|
+
ref = _extract_assign_owner(
|
|
241
|
+
stmt.value, local_var_types, param_types
|
|
242
|
+
)
|
|
243
|
+
if ref:
|
|
244
|
+
ownerships[f"{class_name}.{target.attr}"] = ref
|
|
245
|
+
|
|
246
|
+
elif isinstance(target, ast.Name):
|
|
247
|
+
ref = _extract_assign_owner(
|
|
248
|
+
stmt.value, local_var_types, param_types
|
|
249
|
+
)
|
|
250
|
+
if ref:
|
|
251
|
+
local_var_types[target.id] = ref
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
return ownerships
|