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,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)