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/pipeline.py
ADDED
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pipeline.py — The main DiffContext pipeline.
|
|
3
|
+
|
|
4
|
+
Connects all stages: parse -> graph -> diff -> blast radius -> score -> select -> compile
|
|
5
|
+
|
|
6
|
+
Key fixes vs original:
|
|
7
|
+
- expanded_deps is now passed into compute_impact_scores so those symbols
|
|
8
|
+
are actually scored (previously they were collected but never used).
|
|
9
|
+
- Graph is built with a single pass and cached import maps (see graph_builder).
|
|
10
|
+
- warn_unknown_symbols is called before any scoring to surface typos early.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import difflib
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from typing import Callable, Dict, List, Optional, Set
|
|
18
|
+
|
|
19
|
+
from .impact.scoring import ScoringConfig
|
|
20
|
+
|
|
21
|
+
from .models import (
|
|
22
|
+
RepositoryIndex, ImpactResult, ContextPackage, Symbol,
|
|
23
|
+
)
|
|
24
|
+
from .languages import available_adapters, discover_files
|
|
25
|
+
from .parser import extract_symbols
|
|
26
|
+
from .scanner import find_python_files, first_excluded_dir
|
|
27
|
+
from .cache import SymbolCache, get_file_hash, hash_source, repo_state_hash
|
|
28
|
+
from .resolver import build_import_map
|
|
29
|
+
from .graph_builder import build_repository_graph
|
|
30
|
+
from ._warn_once import warn_syntax_error_once, check_and_warn_encoding, WarnState
|
|
31
|
+
from .impact.blast_radius import get_blast_radius
|
|
32
|
+
from .impact.scoring import compute_impact_scores
|
|
33
|
+
from .impact.traversal import expand_dependencies
|
|
34
|
+
from .context.selector import select_context
|
|
35
|
+
from .context.compiler import compile_context
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _suggest_similar_symbol(unknown_id: str, known_ids) -> Optional[str]:
|
|
41
|
+
"""
|
|
42
|
+
Fuzzy-match an unknown symbol ID against known ones (typo correction).
|
|
43
|
+
|
|
44
|
+
Not plain difflib.get_close_matches over full IDs: symbol IDs share
|
|
45
|
+
long path prefixes, so difflib's quick-ratio prefilters pass nearly
|
|
46
|
+
every candidate and it computes a full ratio against the whole index
|
|
47
|
+
per unknown symbol (measured: ~2s per unknown on a 9k-symbol index —
|
|
48
|
+
dominated `verify --from-history` on a repo whose recent commits
|
|
49
|
+
renamed benchmark symbols). Instead, match on the name part:
|
|
50
|
+
1. exact name match first — a symbol that moved files (the common
|
|
51
|
+
churn case) resolves with a dict lookup, no fuzz at all;
|
|
52
|
+
2. otherwise fuzz over UNIQUE name parts only — short strings with
|
|
53
|
+
no shared prefixes, where the prefilters actually discriminate —
|
|
54
|
+
then pick the closest full ID within the winning name.
|
|
55
|
+
"""
|
|
56
|
+
known_list = list(known_ids)
|
|
57
|
+
name = unknown_id.rsplit(":", 1)[1] if ":" in unknown_id else unknown_id
|
|
58
|
+
|
|
59
|
+
by_name: dict = {}
|
|
60
|
+
for k in known_list:
|
|
61
|
+
by_name.setdefault(k.rsplit(":", 1)[-1], []).append(k)
|
|
62
|
+
|
|
63
|
+
same_name = by_name.get(name)
|
|
64
|
+
if same_name:
|
|
65
|
+
# moved/renamed file — pick the closest full ID among exact
|
|
66
|
+
# name matches (tiny pool, full difflib is fine here)
|
|
67
|
+
matches = difflib.get_close_matches(unknown_id, same_name, n=1, cutoff=0.0)
|
|
68
|
+
return matches[0]
|
|
69
|
+
|
|
70
|
+
# Typo in the name part: fuzz over unique names only. Short strings
|
|
71
|
+
# without shared path prefixes let difflib's quick-ratio prefilters
|
|
72
|
+
# actually discriminate, and the pool is unique names, not every ID.
|
|
73
|
+
close = difflib.get_close_matches(name, by_name.keys(), n=1, cutoff=0.6)
|
|
74
|
+
if not close:
|
|
75
|
+
return None
|
|
76
|
+
matches = difflib.get_close_matches(unknown_id, by_name[close[0]], n=1, cutoff=0.0)
|
|
77
|
+
return matches[0] if matches else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_and_parse(
|
|
81
|
+
filename: str,
|
|
82
|
+
repo_path: str,
|
|
83
|
+
broken_files: List[str],
|
|
84
|
+
warn_state: Optional[WarnState] = None,
|
|
85
|
+
):
|
|
86
|
+
"""
|
|
87
|
+
Read + parse one file exactly once. Returns (rel_file, source, tree,
|
|
88
|
+
content_hash); tree is None (and rel_file is appended to broken_files)
|
|
89
|
+
on SyntaxError.
|
|
90
|
+
"""
|
|
91
|
+
rel_file = "./" + os.path.relpath(filename, repo_path)
|
|
92
|
+
with open(filename, "rb") as f:
|
|
93
|
+
raw = f.read()
|
|
94
|
+
check_and_warn_encoding(logger, filename, raw, state=warn_state)
|
|
95
|
+
source = raw.decode("utf-8", errors="ignore")
|
|
96
|
+
try:
|
|
97
|
+
tree = ast.parse(source)
|
|
98
|
+
except SyntaxError as e:
|
|
99
|
+
warn_syntax_error_once(logger, filename, e, state=warn_state)
|
|
100
|
+
broken_files.append(rel_file)
|
|
101
|
+
return rel_file, source, None, hash_source(raw)
|
|
102
|
+
return rel_file, source, tree, hash_source(raw)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def index_repository(
|
|
106
|
+
repo_path: str, include: Optional[Set[str]] = None,
|
|
107
|
+
) -> RepositoryIndex:
|
|
108
|
+
"""
|
|
109
|
+
Phase 1: Parse repository and build dependency graph.
|
|
110
|
+
|
|
111
|
+
Each file is read and parsed exactly ONCE per process (symbol
|
|
112
|
+
extraction, import maps, and the graph builder all share the same AST).
|
|
113
|
+
The finished graph is persisted content-addressed (keyed by the combined
|
|
114
|
+
hash of every file), so re-indexing an unchanged repo — even from a new
|
|
115
|
+
process — skips parsing and graph construction entirely.
|
|
116
|
+
|
|
117
|
+
`include` is a set of directory names to index despite the default
|
|
118
|
+
exclusions (scanner.EXCLUDED_DIRS — tests/, benchmarks/, docs/, ...).
|
|
119
|
+
Pass {"benchmarks"} to index benchmarks/ too; the cache key covers the
|
|
120
|
+
include set, so an index built with and without --include do not collide.
|
|
121
|
+
|
|
122
|
+
Returns a RepositoryIndex with all symbols, the call graph, and the
|
|
123
|
+
list of files (if any) that failed to parse due to a SyntaxError. The
|
|
124
|
+
returned index supports in-process incremental updates via
|
|
125
|
+
`index.update([...])`.
|
|
126
|
+
"""
|
|
127
|
+
repo_path = os.path.abspath(repo_path)
|
|
128
|
+
db_path = os.path.join(repo_path, ".diffcontext_cache.db")
|
|
129
|
+
|
|
130
|
+
files = find_python_files(repo_path, include)
|
|
131
|
+
|
|
132
|
+
# Optional language adapters (languages/): each contributes its own
|
|
133
|
+
# files, symbols, and edges. Absent extras mean empty dicts here and
|
|
134
|
+
# a pipeline identical to the Python-only one.
|
|
135
|
+
adapter_files: Dict[object, List[str]] = {}
|
|
136
|
+
for adapter in available_adapters():
|
|
137
|
+
found = discover_files(adapter, repo_path)
|
|
138
|
+
if found:
|
|
139
|
+
adapter_files[adapter] = found
|
|
140
|
+
|
|
141
|
+
# Session-scoped warn de-dup: one indexing session's warnings must not
|
|
142
|
+
# suppress another's in a long-lived process serving many repos.
|
|
143
|
+
warn_state = WarnState()
|
|
144
|
+
broken_files: List[str] = []
|
|
145
|
+
file_trees: Optional[Dict[str, ast.Module]] = None
|
|
146
|
+
import_maps: Optional[Dict[str, Dict[str, str]]] = None
|
|
147
|
+
lang_graphs: Optional[Dict[str, Dict[str, List[str]]]] = None
|
|
148
|
+
|
|
149
|
+
with SymbolCache(db_path) as cache:
|
|
150
|
+
# Read + hash every file (one disk pass). Parsing is deferred until
|
|
151
|
+
# we know the graph cache missed — on a hit, no file is parsed at
|
|
152
|
+
# all and symbols come straight from the symbol cache. The state
|
|
153
|
+
# hash covers adapter-language files too: a .ts edit must miss the
|
|
154
|
+
# cached graph exactly like a .py edit.
|
|
155
|
+
raw_bytes: Dict[str, bytes] = {} # rel -> file contents (.py)
|
|
156
|
+
rel_to_abs: Dict[str, str] = {}
|
|
157
|
+
file_hashes: Dict[str, str] = {}
|
|
158
|
+
for filename in files:
|
|
159
|
+
rel = "./" + os.path.relpath(filename, repo_path)
|
|
160
|
+
with open(filename, "rb") as f:
|
|
161
|
+
raw = f.read()
|
|
162
|
+
raw_bytes[rel] = raw
|
|
163
|
+
rel_to_abs[rel] = filename
|
|
164
|
+
file_hashes[rel] = hash_source(raw)
|
|
165
|
+
|
|
166
|
+
lang_sources: Dict[object, Dict[str, str]] = {} # adapter -> rel -> text
|
|
167
|
+
lang_rel_to_abs: Dict[str, str] = {}
|
|
168
|
+
for adapter, afiles in adapter_files.items():
|
|
169
|
+
sources: Dict[str, str] = {}
|
|
170
|
+
for filename in afiles:
|
|
171
|
+
rel = "./" + os.path.relpath(filename, repo_path)
|
|
172
|
+
with open(filename, "rb") as f:
|
|
173
|
+
raw = f.read()
|
|
174
|
+
file_hashes[rel] = hash_source(raw)
|
|
175
|
+
lang_rel_to_abs[rel] = filename
|
|
176
|
+
sources[rel] = raw.decode("utf-8", errors="ignore")
|
|
177
|
+
lang_sources[adapter] = sources
|
|
178
|
+
|
|
179
|
+
state_hash = repo_state_hash(file_hashes)
|
|
180
|
+
cached = cache.get_graph(state_hash)
|
|
181
|
+
|
|
182
|
+
symbols: Dict[str, Symbol] = {}
|
|
183
|
+
|
|
184
|
+
if cached is not None:
|
|
185
|
+
# Warm path: graph and broken-file list restored from cache;
|
|
186
|
+
# symbols served from the symbol cache (same content hashes, so
|
|
187
|
+
# every lookup is a hit — zero parsing).
|
|
188
|
+
graph, broken_files = cached
|
|
189
|
+
for rel, filename in rel_to_abs.items():
|
|
190
|
+
def _parse(path):
|
|
191
|
+
return extract_symbols(path, repo_path)
|
|
192
|
+
symbols.update(cache.get_or_parse(
|
|
193
|
+
filename, _parse, known_hash=file_hashes[rel]
|
|
194
|
+
))
|
|
195
|
+
for adapter, sources in lang_sources.items():
|
|
196
|
+
for rel, text in sources.items():
|
|
197
|
+
def _parse_lang(path, _src=text, _ad=adapter):
|
|
198
|
+
return _ad.extract_file_symbols(path, repo_path, _src)
|
|
199
|
+
symbols.update(cache.get_or_parse(
|
|
200
|
+
lang_rel_to_abs[rel], _parse_lang,
|
|
201
|
+
known_hash=file_hashes[rel],
|
|
202
|
+
))
|
|
203
|
+
else:
|
|
204
|
+
# Cold path: parse each file exactly once; symbol extraction,
|
|
205
|
+
# import maps, and the graph builder all share the same AST.
|
|
206
|
+
parsed: Dict[str, tuple] = {} # rel -> (abs, source, tree)
|
|
207
|
+
for rel, raw in raw_bytes.items():
|
|
208
|
+
filename = rel_to_abs[rel]
|
|
209
|
+
check_and_warn_encoding(logger, filename, raw, state=warn_state)
|
|
210
|
+
source = raw.decode("utf-8", errors="ignore")
|
|
211
|
+
try:
|
|
212
|
+
tree = ast.parse(source)
|
|
213
|
+
except SyntaxError as e:
|
|
214
|
+
warn_syntax_error_once(logger, filename, e, state=warn_state)
|
|
215
|
+
broken_files.append(rel)
|
|
216
|
+
continue
|
|
217
|
+
parsed[rel] = (filename, source, tree)
|
|
218
|
+
|
|
219
|
+
for rel, (filename, source, tree) in parsed.items():
|
|
220
|
+
def _parse(path, _src=source, _tree=tree):
|
|
221
|
+
return extract_symbols(path, repo_path, source=_src, tree=_tree)
|
|
222
|
+
symbols.update(cache.get_or_parse(
|
|
223
|
+
filename, _parse, known_hash=file_hashes[rel]
|
|
224
|
+
))
|
|
225
|
+
|
|
226
|
+
file_trees = {rel: t for rel, (_f, _s, t) in parsed.items()}
|
|
227
|
+
import_maps = {
|
|
228
|
+
rel: build_import_map(f, repo_path, tree=t)
|
|
229
|
+
for rel, (f, _s, t) in parsed.items()
|
|
230
|
+
}
|
|
231
|
+
graph = build_repository_graph(
|
|
232
|
+
repo_path,
|
|
233
|
+
functions=symbols,
|
|
234
|
+
file_trees=file_trees,
|
|
235
|
+
import_maps=import_maps,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
lang_graphs = {}
|
|
239
|
+
for adapter, sources in lang_sources.items():
|
|
240
|
+
for rel, text in sources.items():
|
|
241
|
+
def _parse_lang(path, _src=text, _ad=adapter):
|
|
242
|
+
return _ad.extract_file_symbols(path, repo_path, _src)
|
|
243
|
+
symbols.update(cache.get_or_parse(
|
|
244
|
+
lang_rel_to_abs[rel], _parse_lang,
|
|
245
|
+
known_hash=file_hashes[rel],
|
|
246
|
+
))
|
|
247
|
+
edges = adapter.build_language_graph(repo_path, sources)
|
|
248
|
+
lang_graphs[adapter.name] = edges
|
|
249
|
+
graph.update(edges) # id namespaces are disjoint by file ext
|
|
250
|
+
|
|
251
|
+
cache.put_graph(state_hash, graph, broken_files)
|
|
252
|
+
|
|
253
|
+
index = RepositoryIndex(symbols=symbols, graph=graph, broken_files=broken_files)
|
|
254
|
+
# Incremental-update state (private; used by index.update()).
|
|
255
|
+
index._repo_path = repo_path
|
|
256
|
+
index._file_trees = file_trees # None on graph-cache hit (lazy)
|
|
257
|
+
index._import_maps = import_maps # None on graph-cache hit (lazy)
|
|
258
|
+
index._lang_graphs = lang_graphs # None on graph-cache hit (lazy)
|
|
259
|
+
index._warn_state = warn_state
|
|
260
|
+
index._include = include # scanner dirs to keep despite defaults
|
|
261
|
+
return index
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _ensure_trees(index: RepositoryIndex) -> None:
|
|
265
|
+
"""Materialize per-file ASTs/import maps if the index was loaded from
|
|
266
|
+
the graph cache (which stores no trees). One-time cost, then reused."""
|
|
267
|
+
if index._file_trees is not None:
|
|
268
|
+
return
|
|
269
|
+
repo_path = index._repo_path
|
|
270
|
+
include = getattr(index, "_include", None)
|
|
271
|
+
broken: List[str] = []
|
|
272
|
+
trees: Dict[str, ast.Module] = {}
|
|
273
|
+
for filename in find_python_files(repo_path, include):
|
|
274
|
+
rel, _source, tree, _h = _read_and_parse(
|
|
275
|
+
filename, repo_path, broken, warn_state=index._warn_state
|
|
276
|
+
)
|
|
277
|
+
if tree is not None:
|
|
278
|
+
trees[rel] = tree
|
|
279
|
+
index._file_trees = trees
|
|
280
|
+
index._import_maps = {
|
|
281
|
+
rel: build_import_map(os.path.join(repo_path, rel[2:]), repo_path, tree=t)
|
|
282
|
+
for rel, t in trees.items()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _normalize_changed_files(repo_path: str, changed_files: List[str]) -> list:
|
|
287
|
+
"""Normalize user-supplied paths to (abs_path, "./rel") pairs."""
|
|
288
|
+
normalized = []
|
|
289
|
+
for path in changed_files:
|
|
290
|
+
abs_path = path if os.path.isabs(path) else os.path.join(repo_path, path.lstrip("./"))
|
|
291
|
+
abs_path = os.path.abspath(abs_path)
|
|
292
|
+
rel = "./" + os.path.relpath(abs_path, repo_path)
|
|
293
|
+
normalized.append((abs_path, rel))
|
|
294
|
+
return normalized
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _needs_full_map_rebuild(py_normalized, trees, broken_files) -> bool:
|
|
298
|
+
"""An added or deleted module can change how OTHER files' imports
|
|
299
|
+
resolve; an edited __init__.py can change re-export resolution.
|
|
300
|
+
In those cases every import map must be rebuilt; otherwise only the
|
|
301
|
+
changed files' maps."""
|
|
302
|
+
return any(
|
|
303
|
+
not os.path.exists(abs_path) # deleted
|
|
304
|
+
or rel not in trees and rel not in broken_files # created
|
|
305
|
+
or os.path.basename(rel) == "__init__.py"
|
|
306
|
+
for abs_path, rel in py_normalized
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _refresh_changed_python_files(index, cache, py_normalized, full_map_rebuild):
|
|
311
|
+
"""Drop each changed file's previous symbols/trees/maps, then re-read,
|
|
312
|
+
re-parse, and re-extract it (deleted files are only dropped)."""
|
|
313
|
+
repo_path = index._repo_path
|
|
314
|
+
trees = index._file_trees
|
|
315
|
+
import_maps = index._import_maps
|
|
316
|
+
for abs_path, rel in py_normalized:
|
|
317
|
+
stale_ids = [sid for sid in index.symbols if sid.startswith(rel + ":")]
|
|
318
|
+
for sid in stale_ids:
|
|
319
|
+
del index.symbols[sid]
|
|
320
|
+
trees.pop(rel, None)
|
|
321
|
+
import_maps.pop(rel, None)
|
|
322
|
+
if rel in index.broken_files:
|
|
323
|
+
index.broken_files.remove(rel)
|
|
324
|
+
|
|
325
|
+
if not os.path.exists(abs_path):
|
|
326
|
+
continue # deleted file: nothing to re-add
|
|
327
|
+
|
|
328
|
+
broken: List[str] = []
|
|
329
|
+
_rel, source, tree, _h = _read_and_parse(
|
|
330
|
+
abs_path, repo_path, broken, warn_state=index._warn_state
|
|
331
|
+
)
|
|
332
|
+
if tree is None:
|
|
333
|
+
index.broken_files.extend(broken)
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
trees[rel] = tree
|
|
337
|
+
def _parse(path, _src=source, _tree=tree):
|
|
338
|
+
return extract_symbols(path, repo_path, source=_src, tree=_tree)
|
|
339
|
+
index.symbols.update(cache.get_or_parse(abs_path, _parse))
|
|
340
|
+
if not full_map_rebuild:
|
|
341
|
+
import_maps[rel] = build_import_map(abs_path, repo_path, tree=tree)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _rebuild_all_import_maps(index) -> None:
|
|
345
|
+
repo_path = index._repo_path
|
|
346
|
+
index._import_maps = {
|
|
347
|
+
rel: build_import_map(
|
|
348
|
+
os.path.join(repo_path, rel[2:]), repo_path, tree=t
|
|
349
|
+
)
|
|
350
|
+
for rel, t in index._file_trees.items()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _update_language_parts(index, cache, lang_changed_rels) -> Dict[str, str]:
|
|
355
|
+
"""
|
|
356
|
+
Rebuild language-adapter graph parts. Import/barrel effects are
|
|
357
|
+
cross-file, so when one of an adapter's files changed its whole part
|
|
358
|
+
is rebuilt (a full tree-sitter pass is cheap); unchanged parts are
|
|
359
|
+
reused from the previous build. A warm-started index (graph-cache
|
|
360
|
+
hit) has no cached parts and rebuilds them once here.
|
|
361
|
+
|
|
362
|
+
Returns rel -> content hash for every adapter-language file, for the
|
|
363
|
+
repo state hash.
|
|
364
|
+
"""
|
|
365
|
+
repo_path = index._repo_path
|
|
366
|
+
lang_graphs = index._lang_graphs if index._lang_graphs is not None else {}
|
|
367
|
+
adapter_file_hashes: Dict[str, str] = {}
|
|
368
|
+
for adapter in available_adapters():
|
|
369
|
+
exts = tuple(adapter.extensions)
|
|
370
|
+
raws: Dict[str, bytes] = {}
|
|
371
|
+
for fpath in discover_files(adapter, repo_path):
|
|
372
|
+
rel = "./" + os.path.relpath(fpath, repo_path)
|
|
373
|
+
with open(fpath, "rb") as fh:
|
|
374
|
+
raw = fh.read()
|
|
375
|
+
adapter_file_hashes[rel] = hash_source(raw)
|
|
376
|
+
raws[rel] = raw
|
|
377
|
+
|
|
378
|
+
rebuild = index._lang_graphs is None or any(
|
|
379
|
+
r.endswith(exts) for r in lang_changed_rels
|
|
380
|
+
)
|
|
381
|
+
had_symbols = any(
|
|
382
|
+
sid.split(":", 1)[0].endswith(exts) for sid in index.symbols
|
|
383
|
+
)
|
|
384
|
+
if not rebuild or not (raws or had_symbols):
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
stale = [
|
|
388
|
+
sid for sid in index.symbols
|
|
389
|
+
if sid.split(":", 1)[0].endswith(exts)
|
|
390
|
+
]
|
|
391
|
+
for sid in stale:
|
|
392
|
+
del index.symbols[sid]
|
|
393
|
+
|
|
394
|
+
sources: Dict[str, str] = {}
|
|
395
|
+
for rel, raw in raws.items():
|
|
396
|
+
text = raw.decode("utf-8", errors="ignore")
|
|
397
|
+
sources[rel] = text
|
|
398
|
+
def _parse_lang(path, _src=text, _ad=adapter):
|
|
399
|
+
return _ad.extract_file_symbols(path, repo_path, _src)
|
|
400
|
+
index.symbols.update(cache.get_or_parse(
|
|
401
|
+
os.path.join(repo_path, rel[2:]), _parse_lang,
|
|
402
|
+
known_hash=adapter_file_hashes[rel],
|
|
403
|
+
))
|
|
404
|
+
lang_graphs[adapter.name] = adapter.build_language_graph(
|
|
405
|
+
repo_path, sources
|
|
406
|
+
)
|
|
407
|
+
index._lang_graphs = lang_graphs
|
|
408
|
+
for edges in lang_graphs.values():
|
|
409
|
+
index.graph.update(edges)
|
|
410
|
+
return adapter_file_hashes
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _persist_graph_cache(cache, index, adapter_file_hashes) -> None:
|
|
414
|
+
"""Persist the new state so future processes get a warm start too."""
|
|
415
|
+
repo_path = index._repo_path
|
|
416
|
+
include = getattr(index, "_include", None)
|
|
417
|
+
file_hashes = {
|
|
418
|
+
"./" + os.path.relpath(f, repo_path): get_file_hash(f)
|
|
419
|
+
for f in find_python_files(repo_path, include)
|
|
420
|
+
}
|
|
421
|
+
file_hashes.update(adapter_file_hashes)
|
|
422
|
+
cache.put_graph(repo_state_hash(file_hashes), index.graph, list(index.broken_files))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def update_index(index: RepositoryIndex, changed_files: List[str]) -> RepositoryIndex:
|
|
426
|
+
"""
|
|
427
|
+
Incrementally update an index after `changed_files` were edited,
|
|
428
|
+
created, or deleted — without re-reading or re-parsing any other file.
|
|
429
|
+
|
|
430
|
+
Only the changed files are re-parsed; their symbols are re-extracted
|
|
431
|
+
(and re-cached), their import maps rebuilt, and the graph is then
|
|
432
|
+
rebuilt from the in-memory ASTs. Edge resolution is repo-wide (cross-
|
|
433
|
+
file edges make per-edge incrementality unsound), but all file I/O and
|
|
434
|
+
parsing is strictly limited to the changed files.
|
|
435
|
+
|
|
436
|
+
Accepts absolute paths, or paths relative to the repo root (with or
|
|
437
|
+
without the "./" prefix). Returns the same index, mutated in place.
|
|
438
|
+
"""
|
|
439
|
+
if index._repo_path is None:
|
|
440
|
+
raise ValueError(
|
|
441
|
+
"This index does not support update(): it was not created by "
|
|
442
|
+
"index_repository()."
|
|
443
|
+
)
|
|
444
|
+
repo_path = index._repo_path
|
|
445
|
+
db_path = os.path.join(repo_path, ".diffcontext_cache.db")
|
|
446
|
+
|
|
447
|
+
_ensure_trees(index)
|
|
448
|
+
|
|
449
|
+
normalized = _normalize_changed_files(repo_path, changed_files)
|
|
450
|
+
# Adapter-language files (".ts" etc.) are handled by their adapter —
|
|
451
|
+
# the per-file Python flow must not try to ast.parse them.
|
|
452
|
+
py_normalized = [(a, r) for a, r in normalized if r.endswith(".py")]
|
|
453
|
+
lang_changed_rels = [r for _a, r in normalized if not r.endswith(".py")]
|
|
454
|
+
|
|
455
|
+
full_map_rebuild = _needs_full_map_rebuild(
|
|
456
|
+
py_normalized, index._file_trees, index.broken_files
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
with SymbolCache(db_path) as cache:
|
|
460
|
+
_refresh_changed_python_files(index, cache, py_normalized, full_map_rebuild)
|
|
461
|
+
if full_map_rebuild:
|
|
462
|
+
_rebuild_all_import_maps(index)
|
|
463
|
+
|
|
464
|
+
# Symbols changed: the cached BM25 index no longer matches them.
|
|
465
|
+
index._lexical = None
|
|
466
|
+
|
|
467
|
+
# Rebuild graph from in-memory state (no file I/O, no parsing);
|
|
468
|
+
# the cached reverse graph goes stale with it.
|
|
469
|
+
index._reverse_graph = None
|
|
470
|
+
index.graph = build_repository_graph(
|
|
471
|
+
repo_path,
|
|
472
|
+
functions=index.symbols,
|
|
473
|
+
file_trees=index._file_trees,
|
|
474
|
+
import_maps=index._import_maps,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
adapter_file_hashes = _update_language_parts(index, cache, lang_changed_rels)
|
|
478
|
+
_persist_graph_cache(cache, index, adapter_file_hashes)
|
|
479
|
+
|
|
480
|
+
return index
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def warn_unknown_symbols(index: RepositoryIndex, changed_symbols: List[str]) -> List[str]:
|
|
484
|
+
"""
|
|
485
|
+
Check `changed_symbols` against the index and warn about any that don't
|
|
486
|
+
actually exist. Returns the list of unknown symbol IDs.
|
|
487
|
+
|
|
488
|
+
Three cases, in priority order:
|
|
489
|
+
1. The symbol's FILE exists on disk but lies in a directory excluded
|
|
490
|
+
from indexing by default (tests/, benchmarks/, docs/, ...). This is
|
|
491
|
+
the most common real-world miss and the most actionable: say so
|
|
492
|
+
specifically and tell the user to re-run with `--include <dir>`,
|
|
493
|
+
instead of the generic "typo, renamed, or deleted" message that
|
|
494
|
+
sends them looking for a typo that doesn't exist.
|
|
495
|
+
2. A close name match exists (a moved/renamed symbol): suggest it.
|
|
496
|
+
3. None of the above: the symbol is a genuine typo, was deleted, or
|
|
497
|
+
the file failed to parse. Surface that, plus a hint that an
|
|
498
|
+
excluded directory is one possible cause.
|
|
499
|
+
"""
|
|
500
|
+
repo_path = getattr(index, "_repo_path", None)
|
|
501
|
+
index_include = getattr(index, "_include", None)
|
|
502
|
+
unknown = [s for s in changed_symbols if s not in index.graph and s not in index.symbols]
|
|
503
|
+
for sym_id in unknown:
|
|
504
|
+
file_part = sym_id.split(":", 1)[0]
|
|
505
|
+
rel = file_part[2:] if file_part.startswith("./") else file_part
|
|
506
|
+
excluded_dir = first_excluded_dir(file_part, index_include)
|
|
507
|
+
file_exists = (
|
|
508
|
+
repo_path is not None
|
|
509
|
+
and os.path.isfile(os.path.join(repo_path, rel))
|
|
510
|
+
)
|
|
511
|
+
if excluded_dir is not None and file_exists:
|
|
512
|
+
logger.warning(
|
|
513
|
+
"\033[93m'%s' was not found in the index because '%s' is "
|
|
514
|
+
"excluded from indexing by default (along with tests/, "
|
|
515
|
+
"benchmarks/, docs/, ...). Re-run with `--include %s` to "
|
|
516
|
+
"index it. Its blast radius will show as empty, which does "
|
|
517
|
+
"NOT mean the real symbol has no impact.\033[0m",
|
|
518
|
+
sym_id, file_part, excluded_dir,
|
|
519
|
+
)
|
|
520
|
+
continue
|
|
521
|
+
suggestion = _suggest_similar_symbol(sym_id, index.symbols.keys())
|
|
522
|
+
if suggestion:
|
|
523
|
+
logger.warning(
|
|
524
|
+
"\033[93m'%s' was not found in the index -- did you mean '%s'? "
|
|
525
|
+
"Its blast radius will show as empty, which does NOT mean "
|
|
526
|
+
"the real symbol has no impact.\033[0m",
|
|
527
|
+
sym_id, suggestion,
|
|
528
|
+
)
|
|
529
|
+
else:
|
|
530
|
+
logger.warning(
|
|
531
|
+
"\033[93m'%s' was not found in the index (typo, renamed, or "
|
|
532
|
+
"deleted symbol?). Its blast radius will show as empty, "
|
|
533
|
+
"which does NOT mean the real symbol has no impact. If the "
|
|
534
|
+
"file is in a directory excluded from indexing (tests/, "
|
|
535
|
+
"benchmarks/, docs/), re-run with `--include <dir>`.\033[0m",
|
|
536
|
+
sym_id,
|
|
537
|
+
)
|
|
538
|
+
return unknown
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _normalize_scores(scores: Dict[str, float]) -> Dict[str, float]:
|
|
542
|
+
"""Min-max normalize a score dict to [0, 1]."""
|
|
543
|
+
if not scores:
|
|
544
|
+
return {}
|
|
545
|
+
lo, hi = min(scores.values()), max(scores.values())
|
|
546
|
+
if hi == lo:
|
|
547
|
+
return {k: 0.5 for k in scores}
|
|
548
|
+
return {k: (v - lo) / (hi - lo) for k, v in scores.items()}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# Hybrid blend weights (graph, lexical/BM25, same-file). These are the
|
|
552
|
+
# leave-one-repo-out-validated values from the 2026-07 rigor pass
|
|
553
|
+
# (benchmarks/RIGOR_REPORT_2026-07.md §3): the original same-repo-tuned
|
|
554
|
+
# (0.5, 0.35, 0.15) over-weighted the graph; every LORO fold selects a
|
|
555
|
+
# BM25-heavier blend, and (0.3, 0.5, 0.2) beat the old weights on 4/5
|
|
556
|
+
# held-out folds (+1.2 to +2.4 recall points, individually n.s.) while
|
|
557
|
+
# staying within ±1.1 points on four repos never used for any selection.
|
|
558
|
+
# Change only with benchmark evidence.
|
|
559
|
+
HYBRID_WEIGHTS = (0.3, 0.5, 0.2)
|
|
560
|
+
|
|
561
|
+
# Number of graph-scored candidates at which graph confidence saturates
|
|
562
|
+
# to 1.0 for the adaptive blend. Below it, graph weight is scaled down
|
|
563
|
+
# proportionally and the freed weight moves to BM25 — a sparse blast
|
|
564
|
+
# radius means the graph has little to say and lexical similarity is the
|
|
565
|
+
# better bet (the measured "thematic siblings" blind spot).
|
|
566
|
+
ADAPTIVE_GRAPH_SATURATION = 8
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _adaptive_weights(n_graph_candidates: int, weights=HYBRID_WEIGHTS):
|
|
570
|
+
"""Shift weight from the graph signal to BM25 when the graph produced
|
|
571
|
+
few candidates. With >= ADAPTIVE_GRAPH_SATURATION graph candidates the
|
|
572
|
+
result equals `weights` exactly (no behavior change on well-connected
|
|
573
|
+
changes)."""
|
|
574
|
+
w_graph, w_lex, w_file = weights
|
|
575
|
+
confidence = min(1.0, n_graph_candidates / ADAPTIVE_GRAPH_SATURATION)
|
|
576
|
+
w_graph_eff = w_graph * confidence
|
|
577
|
+
return (w_graph_eff, w_lex + (w_graph - w_graph_eff), w_file)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _blend_hybrid(
|
|
581
|
+
index: RepositoryIndex,
|
|
582
|
+
changed_symbols: List[str],
|
|
583
|
+
graph_scores: Dict[str, float],
|
|
584
|
+
weights=HYBRID_WEIGHTS,
|
|
585
|
+
adaptive: bool = True,
|
|
586
|
+
history_scores: Optional[Dict[str, float]] = None,
|
|
587
|
+
history_weight: float = 0.15,
|
|
588
|
+
) -> Dict[str, float]:
|
|
589
|
+
"""
|
|
590
|
+
Blend graph impact scores with BM25 and same-file signals.
|
|
591
|
+
|
|
592
|
+
Changed symbols keep their original (top) score; every other candidate
|
|
593
|
+
gets `100 * (w_g*graph + w_b*bm25 + w_f*samefile)` where each signal is
|
|
594
|
+
min-max normalized to [0, 1]. A symbol with no call-graph connection to
|
|
595
|
+
the change can still surface through lexical similarity or co-location —
|
|
596
|
+
the two failure modes where the graph alone is blind.
|
|
597
|
+
|
|
598
|
+
With `adaptive=True` (default) the graph weight is scaled by graph
|
|
599
|
+
confidence: when the blast radius produced few candidates, the freed
|
|
600
|
+
weight moves to BM25 (see _adaptive_weights). On well-connected
|
|
601
|
+
changes the weights are exactly `weights` — no behavior change.
|
|
602
|
+
|
|
603
|
+
`history_scores` (per-file git co-change association in [0, 1], from
|
|
604
|
+
diffcontext.history) is an optional fourth signal: every symbol in a
|
|
605
|
+
file that historically co-changed with the changed files gets
|
|
606
|
+
`history_weight * association` added — the only signal that can reach
|
|
607
|
+
co-change partners with no structural or lexical connection at all.
|
|
608
|
+
"""
|
|
609
|
+
from .lexical import get_lexical_index
|
|
610
|
+
|
|
611
|
+
changed_set = set(changed_symbols)
|
|
612
|
+
changed_in_index = [s for s in changed_symbols if s in index.symbols]
|
|
613
|
+
if not changed_in_index:
|
|
614
|
+
return graph_scores
|
|
615
|
+
|
|
616
|
+
graph_norm = _normalize_scores(
|
|
617
|
+
{s: sc for s, sc in graph_scores.items() if s not in changed_set}
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
if adaptive:
|
|
621
|
+
weights = _adaptive_weights(len(graph_norm), weights)
|
|
622
|
+
w_graph, w_lex, w_file = weights
|
|
623
|
+
|
|
624
|
+
# Lexical: max BM25 score against any changed symbol's code
|
|
625
|
+
lex_raw: Dict[str, float] = {}
|
|
626
|
+
lexical_index = get_lexical_index(index)
|
|
627
|
+
for sym_id in changed_in_index:
|
|
628
|
+
for sid, sc in lexical_index.scores_for(index.symbols[sym_id].code).items():
|
|
629
|
+
if sid not in changed_set and sc > lex_raw.get(sid, 0.0):
|
|
630
|
+
lex_raw[sid] = sc
|
|
631
|
+
lex_norm = _normalize_scores(lex_raw)
|
|
632
|
+
|
|
633
|
+
changed_files = {s.split(":")[0] for s in changed_in_index}
|
|
634
|
+
|
|
635
|
+
history_files = {
|
|
636
|
+
f for f, sc in (history_scores or {}).items() if sc > 0.0
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
blended: Dict[str, float] = {}
|
|
640
|
+
candidates = set(graph_norm) | set(lex_norm)
|
|
641
|
+
candidates.update(
|
|
642
|
+
sid for sid in index.symbols
|
|
643
|
+
if sid.split(":")[0] in changed_files and sid not in changed_set
|
|
644
|
+
)
|
|
645
|
+
if history_files:
|
|
646
|
+
candidates.update(
|
|
647
|
+
sid for sid in index.symbols
|
|
648
|
+
if sid.split(":")[0] in history_files and sid not in changed_set
|
|
649
|
+
)
|
|
650
|
+
for sid in candidates:
|
|
651
|
+
score = w_graph * graph_norm.get(sid, 0.0) + w_lex * lex_norm.get(sid, 0.0)
|
|
652
|
+
sid_file = sid.split(":")[0]
|
|
653
|
+
if sid_file in changed_files:
|
|
654
|
+
score += w_file
|
|
655
|
+
if history_scores:
|
|
656
|
+
score += history_weight * history_scores.get(sid_file, 0.0)
|
|
657
|
+
blended[sid] = 100.0 * score
|
|
658
|
+
|
|
659
|
+
# Changed symbols keep their unblended score so they stay ranked on top.
|
|
660
|
+
for sym_id in changed_symbols:
|
|
661
|
+
if sym_id in graph_scores:
|
|
662
|
+
blended[sym_id] = graph_scores[sym_id]
|
|
663
|
+
return blended
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _apply_dep_boost(
|
|
667
|
+
index: RepositoryIndex,
|
|
668
|
+
scores: Dict[str, float],
|
|
669
|
+
changed: List[str],
|
|
670
|
+
boost: float,
|
|
671
|
+
) -> Dict[str, float]:
|
|
672
|
+
"""Boost direct callees/callers/siblings of changed symbols so they
|
|
673
|
+
survive the gap cutoff — a precision-preserving recall gain.
|
|
674
|
+
|
|
675
|
+
Adds `boost` to the score of every non-changed symbol that is a direct
|
|
676
|
+
callee, direct caller, or sibling (shares a caller with a changed symbol)
|
|
677
|
+
of any changed symbol. Import-consumer and weak/reference edges get NO
|
|
678
|
+
boost — the ContextBench diagnosis shows they are rarely gold.
|
|
679
|
+
|
|
680
|
+
Returns a new scores dict; the input is not mutated. When boost=0 the
|
|
681
|
+
caller should skip this function entirely (zero overhead).
|
|
682
|
+
"""
|
|
683
|
+
graph = index.graph
|
|
684
|
+
reverse = index.reverse_graph
|
|
685
|
+
seed_set = set(changed)
|
|
686
|
+
boosted = dict(scores)
|
|
687
|
+
|
|
688
|
+
for sid in scores:
|
|
689
|
+
if sid in seed_set:
|
|
690
|
+
continue
|
|
691
|
+
is_callee = any(sid in graph.get(s, []) for s in changed)
|
|
692
|
+
is_caller = any(sid in reverse.get(s, set()) for s in changed)
|
|
693
|
+
is_sibling = False
|
|
694
|
+
for s in changed:
|
|
695
|
+
for caller in reverse.get(s, set()):
|
|
696
|
+
if sid in graph.get(caller, []) and sid != s:
|
|
697
|
+
is_sibling = True
|
|
698
|
+
break
|
|
699
|
+
if is_sibling:
|
|
700
|
+
break
|
|
701
|
+
if is_callee or is_caller or is_sibling:
|
|
702
|
+
boosted[sid] = boosted[sid] + boost
|
|
703
|
+
|
|
704
|
+
return boosted
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def analyze_impact(
|
|
708
|
+
index: RepositoryIndex,
|
|
709
|
+
changed_symbols: List[str],
|
|
710
|
+
max_depth: Optional[int] = 2,
|
|
711
|
+
scoring_config: Optional["ScoringConfig"] = None,
|
|
712
|
+
hybrid: bool = True,
|
|
713
|
+
adaptive: bool = True,
|
|
714
|
+
history: Optional[object] = None,
|
|
715
|
+
) -> ImpactResult:
|
|
716
|
+
"""
|
|
717
|
+
Phase 2: Given changed symbols, compute blast radius and impact scores.
|
|
718
|
+
|
|
719
|
+
By default scores are the hybrid blend of call-graph impact, BM25
|
|
720
|
+
lexical similarity, and same-file co-location — the configuration that
|
|
721
|
+
won the eval_v2 benchmark on every repo tested. Pass hybrid=False for
|
|
722
|
+
the graph-only signal (e.g. for blast-radius verification, where only
|
|
723
|
+
real call edges should count).
|
|
724
|
+
|
|
725
|
+
adaptive: scale the graph weight by graph confidence — when the blast
|
|
726
|
+
radius produced few candidates, the freed weight moves to BM25.
|
|
727
|
+
Identical to the fixed blend on well-connected changes.
|
|
728
|
+
history: optional diffcontext.history.CoChangeIndex. When given, git
|
|
729
|
+
co-change association is blended as a fourth signal — the only
|
|
730
|
+
signal that can reach co-change partners with no structural or
|
|
731
|
+
lexical connection (the measured cross-subsystem ceiling).
|
|
732
|
+
|
|
733
|
+
Fix: expanded_deps is now passed into compute_impact_scores so those
|
|
734
|
+
nodes are actually scored. Previously they were computed and discarded.
|
|
735
|
+
"""
|
|
736
|
+
warn_unknown_symbols(index, changed_symbols)
|
|
737
|
+
|
|
738
|
+
# ── Blast radius (reverse graph / callers) ────────────────────────────
|
|
739
|
+
reverse = index.reverse_graph
|
|
740
|
+
blast_radii: Dict[str, List[str]] = {}
|
|
741
|
+
all_blast: List[str] = []
|
|
742
|
+
|
|
743
|
+
for sym_id in changed_symbols:
|
|
744
|
+
if sym_id in index.graph:
|
|
745
|
+
radius = get_blast_radius(index.graph, sym_id, reverse=reverse)
|
|
746
|
+
blast_radii[sym_id] = radius
|
|
747
|
+
all_blast.extend(radius)
|
|
748
|
+
|
|
749
|
+
# ── Forward dependency expansion ──────────────────────────────────────
|
|
750
|
+
# Seed with changed + blast so we also pull in what callers depend on.
|
|
751
|
+
deps = expand_dependencies(
|
|
752
|
+
index.graph,
|
|
753
|
+
changed_symbols + all_blast,
|
|
754
|
+
max_depth=max_depth,
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
# ── Impact scoring ────────────────────────────────────────────────────
|
|
758
|
+
# FIX: pass expanded deps so they get scored (previously ignored).
|
|
759
|
+
scores = compute_impact_scores(
|
|
760
|
+
index.graph,
|
|
761
|
+
changed_symbols,
|
|
762
|
+
blast_radii,
|
|
763
|
+
expanded_deps=deps,
|
|
764
|
+
reverse=reverse,
|
|
765
|
+
config=scoring_config,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# ── Hybrid blend (graph + BM25 + same-file [+ history]) ──────────────
|
|
769
|
+
if hybrid:
|
|
770
|
+
history_scores = (
|
|
771
|
+
history.scores_for_symbols(changed_symbols)
|
|
772
|
+
if history is not None else None
|
|
773
|
+
)
|
|
774
|
+
scores = _blend_hybrid(
|
|
775
|
+
index, changed_symbols, scores,
|
|
776
|
+
adaptive=adaptive, history_scores=history_scores,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
return ImpactResult(
|
|
780
|
+
changed=changed_symbols,
|
|
781
|
+
blast_radius=list(set(all_blast)),
|
|
782
|
+
dependencies=deps,
|
|
783
|
+
scores=scores,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def compile(
|
|
788
|
+
index: RepositoryIndex,
|
|
789
|
+
impact: ImpactResult,
|
|
790
|
+
max_tokens: Optional[int] = 10000,
|
|
791
|
+
notes: Optional[str] = None,
|
|
792
|
+
token_counter: Optional[Callable[[str], int]] = None,
|
|
793
|
+
scoring_config: Optional["ScoringConfig"] = None,
|
|
794
|
+
top_k: Optional[int] = None,
|
|
795
|
+
cutoff: Optional[str] = None,
|
|
796
|
+
gap_min_ratio: float = 1.0,
|
|
797
|
+
gap_min_keep: int = 0,
|
|
798
|
+
dep_boost: float = 0.0,
|
|
799
|
+
) -> ContextPackage:
|
|
800
|
+
"""
|
|
801
|
+
Phase 3: Select symbols and compile into LLM context.
|
|
802
|
+
|
|
803
|
+
Args:
|
|
804
|
+
token_counter: Optional text -> token count callable. Pass your
|
|
805
|
+
model's real tokenizer when enforcing a hard window
|
|
806
|
+
limit; defaults to the ~4-chars/token heuristic.
|
|
807
|
+
scoring_config: The ScoringConfig used in analyze_impact (if any),
|
|
808
|
+
so the meta-header describes the actual run.
|
|
809
|
+
top_k: Optional cap on non-changed symbols, applied on top
|
|
810
|
+
of the token budget (see select_context; ~20 per
|
|
811
|
+
changed symbol is the benchmarked sweet spot).
|
|
812
|
+
cutoff: "gap" applies the largest-gap dynamic cutoff before
|
|
813
|
+
top_k and the budget — the measured precision
|
|
814
|
+
operating point (~4x top-20 precision at 6-9
|
|
815
|
+
symbols, ~30% relative recall cost; see
|
|
816
|
+
benchmarks/RIGOR_REPORT_2026-07.md §7). Default
|
|
817
|
+
None keeps recall-first top-k selection.
|
|
818
|
+
gap_min_ratio: Only fire the gap cutoff when the largest relative
|
|
819
|
+
score drop >= this ratio (default 1.0 = always fire,
|
|
820
|
+
the original behavior). 1.5 = only cut on a real 50%+
|
|
821
|
+
break, not on noise like 1.10x.
|
|
822
|
+
gap_min_keep: Always keep at least this many candidates past the
|
|
823
|
+
gap (default 0 = no minimum, the original behavior).
|
|
824
|
+
10 = never prune below 10; the budget controls size.
|
|
825
|
+
dep_boost: Boost direct callees/callers/siblings of changed
|
|
826
|
+
symbols by this amount BEFORE the gap cutoff, so
|
|
827
|
+
structurally important symbols survive the precision
|
|
828
|
+
lever. Default 0 = no boost (original behavior).
|
|
829
|
+
20 = the ContextBench-measured sweet spot
|
|
830
|
+
(+6.4% recall, +10.2% sym_recall, precision 0.391
|
|
831
|
+
vs 0.431 baseline, tokens +15%). See
|
|
832
|
+
benchmarks/contextbench/results/ablation_dep_boost.
|
|
833
|
+
"""
|
|
834
|
+
# Apply dependency-type boost BEFORE selection (the key experiment).
|
|
835
|
+
# Boosts direct callees, callers, and siblings of changed symbols so
|
|
836
|
+
# they survive the gap cutoff — a precision-preserving recall gain.
|
|
837
|
+
if dep_boost > 0:
|
|
838
|
+
boosted_scores = _apply_dep_boost(
|
|
839
|
+
index, impact.scores, impact.changed, dep_boost,
|
|
840
|
+
)
|
|
841
|
+
else:
|
|
842
|
+
boosted_scores = impact.scores
|
|
843
|
+
|
|
844
|
+
selected, dropped = select_context(
|
|
845
|
+
index.symbols,
|
|
846
|
+
boosted_scores,
|
|
847
|
+
impact.changed,
|
|
848
|
+
max_tokens=max_tokens,
|
|
849
|
+
token_counter=token_counter,
|
|
850
|
+
top_k=top_k,
|
|
851
|
+
graph=index.graph,
|
|
852
|
+
reverse=index.reverse_graph,
|
|
853
|
+
cutoff=cutoff,
|
|
854
|
+
gap_min_ratio=gap_min_ratio,
|
|
855
|
+
gap_min_keep=gap_min_keep,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
return compile_context(
|
|
859
|
+
index.symbols,
|
|
860
|
+
selected,
|
|
861
|
+
impact.changed,
|
|
862
|
+
impact.scores,
|
|
863
|
+
graph=index.graph,
|
|
864
|
+
reverse=index.reverse_graph,
|
|
865
|
+
dropped_ids=dropped,
|
|
866
|
+
skipped_files=index.broken_files,
|
|
867
|
+
notes=notes,
|
|
868
|
+
token_counter=token_counter,
|
|
869
|
+
scoring_config=scoring_config,
|
|
870
|
+
max_tokens=max_tokens,
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def run_pipeline(
|
|
875
|
+
repo_path: str,
|
|
876
|
+
changed_symbols: List[str],
|
|
877
|
+
max_depth: Optional[int] = 2,
|
|
878
|
+
max_tokens: Optional[int] = 10000,
|
|
879
|
+
cutoff: Optional[str] = None,
|
|
880
|
+
) -> ContextPackage:
|
|
881
|
+
"""
|
|
882
|
+
Full pipeline in one call:
|
|
883
|
+
repo_path + changed_symbols -> ContextPackage
|
|
884
|
+
"""
|
|
885
|
+
index = index_repository(repo_path)
|
|
886
|
+
impact = analyze_impact(index, changed_symbols, max_depth=max_depth)
|
|
887
|
+
return compile(index, impact, max_tokens=max_tokens, cutoff=cutoff)
|