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
diffcontext/history.py ADDED
@@ -0,0 +1,154 @@
1
+ """
2
+ history.py — git co-change history as a retrieval signal.
3
+
4
+ The failure taxonomy (benchmarks/EVAL_V2_REPORT.md) shows a hard ceiling
5
+ for every static signal: cross-subsystem conceptual links (a settings
6
+ flag and the security check that reads it) have no call edge, no lexical
7
+ overlap, and no co-location — graph, BM25, and hybrid all scored 0/20 on
8
+ that bucket. The only signal that CAN see those pairs is the repository's
9
+ own history: files that changed together before tend to change together
10
+ again (Zimmermann et al.'s classic co-change result, here as a retrieval
11
+ signal rather than a recommender).
12
+
13
+ Design constraints honored:
14
+ * Zero runtime dependencies — plain `git log` via subprocess.
15
+ * Graceful degradation — no git repo / no git binary / timeout produce
16
+ an EMPTY index (scores_for_files returns {}), never an exception.
17
+ * File-level granularity — symbol-level history is noisy and expensive
18
+ to mine (rename/move tracking); file-level association is the
19
+ literature-standard compromise. The blend spreads a file's score to
20
+ the symbols inside it.
21
+ * Leakage control for evaluation — `exclude_commits` lets a benchmark
22
+ mine history WITHOUT the commits it is evaluating on. Scoring a
23
+ commit's co-change partners with an index that already contains that
24
+ very commit would be train-on-test leakage; the eval harness passes
25
+ every mined eval commit here.
26
+
27
+ Association score: for a changed file c and candidate file f,
28
+ assoc(f | c) = cochange_count(f, c) / change_count(c)
29
+ i.e. the empirical probability that a commit touching c also touched f,
30
+ maxed over all changed files. Pairs seen fewer than `min_cochanges`
31
+ times are ignored (a single shared commit is usually coincidence).
32
+ Sweeping commits (> max_files_per_commit files) are skipped entirely —
33
+ mass renames and formatting passes assert nothing about relatedness.
34
+ """
35
+
36
+ import logging
37
+ import os
38
+ import subprocess
39
+ from collections import Counter
40
+ from typing import Dict, Iterable, Optional, Set
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ DEFAULT_MAX_COMMITS = 3000 # how much history to mine
45
+ MAX_FILES_PER_COMMIT = 25 # skip sweeping commits (mechanical churn)
46
+ DEFAULT_MIN_COCHANGES = 2 # pairs must co-occur at least this often
47
+
48
+
49
+ class CoChangeIndex:
50
+ """
51
+ File-level co-change statistics mined from `git log`.
52
+
53
+ Usage:
54
+ cci = CoChangeIndex("/path/to/repo")
55
+ scores = cci.scores_for_files(["./src/auth.py"])
56
+ # {"./src/tokens.py": 0.42, ...} association in [0, 1]
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ repo_path: str,
62
+ max_commits: int = DEFAULT_MAX_COMMITS,
63
+ max_files_per_commit: int = MAX_FILES_PER_COMMIT,
64
+ min_cochanges: int = DEFAULT_MIN_COCHANGES,
65
+ exclude_commits: Optional[Set[str]] = None,
66
+ ):
67
+ self.repo_path = os.path.abspath(repo_path)
68
+ self.min_cochanges = min_cochanges
69
+ self.pair_counts: Dict[str, Counter] = {} # "./a.py" -> {"./b.py": n}
70
+ self.file_counts: Counter = Counter() # "./a.py" -> n commits touching it
71
+ self.mined_commits = 0
72
+
73
+ # Excluded hashes matched on their first 10 chars so both full and
74
+ # abbreviated hashes (as eval harnesses store them) work.
75
+ excluded = {h[:10] for h in (exclude_commits or set())}
76
+
77
+ commits = self._read_history(max_commits)
78
+ for commit_hash, files in commits:
79
+ if commit_hash[:10] in excluded:
80
+ continue
81
+ files = [f for f in files if f.endswith(".py")]
82
+ if len(files) < 2 or len(files) > max_files_per_commit:
83
+ # <2: nothing co-changed; >cap: sweeping mechanical commit
84
+ if files:
85
+ self.mined_commits += 1
86
+ for f in files:
87
+ self.file_counts["./" + f] += 1
88
+ continue
89
+ self.mined_commits += 1
90
+ rels = ["./" + f for f in files]
91
+ for f in rels:
92
+ self.file_counts[f] += 1
93
+ for i, a in enumerate(rels):
94
+ counter_a = self.pair_counts.setdefault(a, Counter())
95
+ for b in rels[i + 1:]:
96
+ counter_a[b] += 1
97
+ self.pair_counts.setdefault(b, Counter())[a] += 1
98
+
99
+ def _read_history(self, max_commits: int):
100
+ """[(full_hash, [file, ...]), ...] — empty list on any failure."""
101
+ try:
102
+ res = subprocess.run(
103
+ ["git", "log", f"--max-count={max_commits}", "--no-merges",
104
+ "--name-only", "--format=%x00%H"],
105
+ cwd=self.repo_path, capture_output=True, text=True, timeout=120,
106
+ )
107
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
108
+ logger.warning("co-change mining skipped: git log failed (%s)", e)
109
+ return []
110
+ if res.returncode != 0:
111
+ logger.warning(
112
+ "co-change mining skipped: git log exited %d", res.returncode
113
+ )
114
+ return []
115
+
116
+ commits = []
117
+ for block in res.stdout.split("\x00"):
118
+ if not block.strip():
119
+ continue
120
+ lines = block.strip().split("\n")
121
+ commit_hash = lines[0].strip()
122
+ files = [ln.strip() for ln in lines[1:] if ln.strip()]
123
+ commits.append((commit_hash, files))
124
+ return commits
125
+
126
+ def scores_for_files(self, changed_files: Iterable[str]) -> Dict[str, float]:
127
+ """
128
+ Association score in [0, 1] for every file that historically
129
+ co-changed with any of `changed_files` (max over the changed
130
+ files). Accepts "./rel/path.py" or "rel/path.py". The changed
131
+ files themselves are excluded from the result.
132
+ """
133
+ changed = {
134
+ f if f.startswith("./") else "./" + f for f in changed_files
135
+ }
136
+ out: Dict[str, float] = {}
137
+ for c in changed:
138
+ total = self.file_counts.get(c, 0)
139
+ if total <= 0:
140
+ continue
141
+ for f, n in self.pair_counts.get(c, Counter()).items():
142
+ if f in changed or n < self.min_cochanges:
143
+ continue
144
+ assoc = n / total
145
+ if assoc > out.get(f, 0.0):
146
+ out[f] = assoc
147
+ return out
148
+
149
+ def scores_for_symbols(self, changed_symbols: Iterable[str]) -> Dict[str, float]:
150
+ """Convenience: same as scores_for_files, keyed off the file part
151
+ of changed symbol IDs ("./a.py:fn" -> "./a.py")."""
152
+ return self.scores_for_files(
153
+ {s.split(":")[0] for s in changed_symbols}
154
+ )
@@ -0,0 +1 @@
1
+ """impact subpackage — blast radius, scoring, traversal."""
@@ -0,0 +1,58 @@
1
+ """
2
+ blast_radius.py — Find all symbols transitively affected by a change.
3
+
4
+ Given a changed symbol, walks the REVERSE graph (callers) to find
5
+ everything that depends on it.
6
+
7
+ Performance fix: accept an optional pre-built reverse graph so the caller
8
+ can build it once and reuse it across multiple symbols instead of
9
+ rebuilding it O(N) times.
10
+ """
11
+
12
+ from typing import Dict, List, Optional, Set
13
+
14
+
15
+ def build_reverse_graph(graph: Dict[str, List[str]]) -> Dict[str, Set[str]]:
16
+ """Build the reverse (caller) graph once; reuse for many queries."""
17
+ reverse: Dict[str, Set[str]] = {}
18
+ for caller, callees in graph.items():
19
+ for callee in callees:
20
+ reverse.setdefault(callee, set()).add(caller)
21
+ return reverse
22
+
23
+
24
+ def get_blast_radius(
25
+ graph: Dict[str, List[str]],
26
+ changed_symbol: str,
27
+ reverse: Optional[Dict[str, Set[str]]] = None,
28
+ ) -> List[str]:
29
+ """
30
+ All functions that (transitively) call changed_symbol.
31
+
32
+ Args:
33
+ graph: Forward call graph (caller -> [callees]).
34
+ changed_symbol: The symbol whose blast radius we want.
35
+ reverse: Pre-built reverse graph. If None it is built here
36
+ (correct but wasteful when called in a loop).
37
+
38
+ Uses iterative DFS with cycle detection.
39
+ """
40
+ if reverse is None:
41
+ reverse = build_reverse_graph(graph)
42
+
43
+ affected: List[str] = []
44
+ visited: Set[str] = set()
45
+
46
+ # Iterative DFS on reverse graph
47
+ stack = [changed_symbol]
48
+ visited.add(changed_symbol)
49
+
50
+ while stack:
51
+ current = stack.pop()
52
+ for caller in reverse.get(current, ()):
53
+ if caller not in visited:
54
+ visited.add(caller)
55
+ affected.append(caller)
56
+ stack.append(caller)
57
+
58
+ return affected
@@ -0,0 +1,223 @@
1
+ """
2
+ scoring.py — Impact scoring for prioritizing symbols in context.
3
+
4
+ Algorithm: Bidirectional decay propagation from changed symbols.
5
+
6
+ Forward (callees): score decays by CALLEE_DECAY per hop
7
+ Backward (callers): score decays by CALLER_DECAY per hop
8
+
9
+ Structural bonuses:
10
+ - Sibling bonus: shares a caller with a changed symbol (co-change signal),
11
+ weighted by 1/caller_outdegree so hub callers don’t flood the pool,
12
+ and log-scaled before capping to dampen compounding across many hubs.
13
+ - Structural bonus: log2(1 + indegree)*3 + log2(1 + outdegree),
14
+ hard-capped at STRUCT_MAX so mega-hub nodes (outdegree=400) don’t
15
+ accumulate +800 and crowd out genuinely co-changed code.
16
+
17
+ Changes vs v1:
18
+ 1. Structural bonus capped (STRUCT_MAX=15). Uncapped bonus turned hubs
19
+ into permanent top-scorers regardless of actual co-change signal.
20
+ 2. BFS propagation cutoff raised 5.0 → 8.0. Large repos (transformers)
21
+ have long paths; 5.0 prematurely cut valid propagation chains.
22
+ 3. Sibling bonus log-scaled before accumulation to dampen compounding.
23
+ """
24
+
25
+ import math
26
+ from collections import deque
27
+ from dataclasses import dataclass
28
+ from typing import Dict, List, Optional, Set
29
+
30
+
31
+ # Tunable constants — the module-level values are the tuned defaults; pass a
32
+ # ScoringConfig to compute_impact_scores() to override per call instead of
33
+ # editing these.
34
+ CHANGED_SCORE = 100.0
35
+ CALLEE_DECAY = 0.65 # lowered: callees are less likely to co-change
36
+ CALLER_DECAY = 0.85 # raised: callers co-change more than callees
37
+ CALLEE_BASE = 90.0 # direct callee of changed symbol
38
+ CALLER_BASE = 85.0 # direct caller
39
+ SIBLING_BASE = 60.0 # base for sibling contribution (divided by caller_outdegree)
40
+ SIBLING_MAX = 80.0 # cap: a symbol can’t accumulate more than this from siblings
41
+ BLAST_BASE = 30.0 # in blast_radii but not reached by BFS
42
+ MAX_HOPS = 8 # propagation depth limit
43
+ BFS_CUTOFF = 8.0 # stop propagating a path when score drops below this
44
+ STRUCT_MAX = 15.0 # hard cap on structural bonus (log-scaled)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ScoringConfig:
49
+ """
50
+ Tunable weights for impact scoring. A harness running different loop
51
+ kinds (broad refactor check vs. narrow bug fix) can pass its own config
52
+ instead of editing module constants; benchmark ablations can sweep
53
+ configs without touching source.
54
+
55
+ Defaults are read from the module-level constants at construction time,
56
+ so existing tuning (and tests that monkeypatch the constants) keep
57
+ working unchanged.
58
+ """
59
+ changed_score: float = None # type: ignore[assignment]
60
+ callee_decay: float = None # type: ignore[assignment]
61
+ caller_decay: float = None # type: ignore[assignment]
62
+ callee_base: float = None # type: ignore[assignment]
63
+ caller_base: float = None # type: ignore[assignment]
64
+ sibling_base: float = None # type: ignore[assignment]
65
+ sibling_max: float = None # type: ignore[assignment]
66
+ blast_base: float = None # type: ignore[assignment]
67
+ max_hops: int = None # type: ignore[assignment]
68
+ bfs_cutoff: float = None # type: ignore[assignment]
69
+ struct_max: float = None # type: ignore[assignment]
70
+
71
+ def __post_init__(self):
72
+ defaults = {
73
+ "changed_score": CHANGED_SCORE, "callee_decay": CALLEE_DECAY,
74
+ "caller_decay": CALLER_DECAY, "callee_base": CALLEE_BASE,
75
+ "caller_base": CALLER_BASE, "sibling_base": SIBLING_BASE,
76
+ "sibling_max": SIBLING_MAX, "blast_base": BLAST_BASE,
77
+ "max_hops": MAX_HOPS, "bfs_cutoff": BFS_CUTOFF,
78
+ "struct_max": STRUCT_MAX,
79
+ }
80
+ for name, default in defaults.items():
81
+ if getattr(self, name) is None:
82
+ object.__setattr__(self, name, default)
83
+
84
+
85
+ def describe_scoring_basis(config: "Optional[ScoringConfig]" = None) -> str:
86
+ """
87
+ One-line, human/LLM-readable summary of the live scoring parameters.
88
+
89
+ Consumed by the context compiler's meta-header so the description can
90
+ never drift from the actual algorithm again (it used to be hardcoded
91
+ prose that went stale when constants changed). Pass the same
92
+ ScoringConfig used for scoring to describe a non-default run.
93
+ """
94
+ cfg = config if config is not None else ScoringConfig()
95
+ return (
96
+ f"changed={cfg.changed_score:.0f} "
97
+ f"| direct_callee={cfg.callee_base:.0f} | direct_caller={cfg.caller_base:.0f} "
98
+ f"| 2hop_callee={cfg.callee_base * cfg.callee_decay:.1f} "
99
+ f"| 2hop_caller={cfg.caller_base * cfg.caller_decay:.1f} "
100
+ f"| struct bonus=log2-scaled, capped at {cfg.struct_max:.0f}"
101
+ )
102
+
103
+
104
+ def compute_impact_scores(
105
+ graph: Dict[str, List[str]],
106
+ changed_symbols: List[str],
107
+ blast_radii: Dict[str, List[str]],
108
+ expanded_deps: List[str] = None,
109
+ reverse: Optional[Dict[str, Set[str]]] = None,
110
+ config: Optional[ScoringConfig] = None,
111
+ ) -> Dict[str, float]:
112
+ """
113
+ Score every symbol's relevance to understanding the change.
114
+
115
+ Args:
116
+ graph: Forward call graph.
117
+ changed_symbols: Symbols that were modified.
118
+ blast_radii: Pre-computed blast radii per changed symbol.
119
+ expanded_deps: Symbols reachable by forward dependency expansion.
120
+ reverse: Pre-built reverse graph (built internally if None).
121
+ config: Scoring weights; module-constant defaults if None.
122
+
123
+ Returns dict of symbol_id -> score (higher = more important).
124
+ """
125
+ cfg = config if config is not None else ScoringConfig()
126
+ # Build reverse graph once (or reuse caller's)
127
+ if reverse is None:
128
+ reverse = {}
129
+ for caller, callees in graph.items():
130
+ for callee in callees:
131
+ reverse.setdefault(callee, set()).add(caller)
132
+
133
+ changed_set = set(changed_symbols)
134
+ scores: Dict[str, float] = {}
135
+
136
+ # ── 1. Changed symbols = 100 ──────────────────────────────────────────
137
+ for sym in changed_symbols:
138
+ scores[sym] = cfg.changed_score
139
+
140
+ # ── 2. Forward BFS (callees) with decay ──────────────────────────────
141
+ queue: deque = deque()
142
+ for sym in changed_symbols:
143
+ for callee in graph.get(sym, []):
144
+ if callee not in changed_set:
145
+ queue.append((callee, cfg.callee_base, 1))
146
+
147
+ visited_fwd: Set[str] = set(changed_symbols)
148
+ while queue:
149
+ node, score, hop = queue.popleft()
150
+ if node in visited_fwd or hop > cfg.max_hops:
151
+ continue
152
+ visited_fwd.add(node)
153
+ scores[node] = max(scores.get(node, 0.0), score)
154
+ next_score = score * cfg.callee_decay
155
+ if next_score >= cfg.bfs_cutoff:
156
+ for callee in graph.get(node, []):
157
+ if callee not in visited_fwd:
158
+ queue.append((callee, next_score, hop + 1))
159
+
160
+ # ── 3. Backward BFS (callers) with decay ─────────────────────────────
161
+ queue2: deque = deque()
162
+ for sym in changed_symbols:
163
+ for caller in reverse.get(sym, set()):
164
+ if caller not in changed_set:
165
+ queue2.append((caller, cfg.caller_base, 1))
166
+
167
+ visited_bwd: Set[str] = set(changed_symbols)
168
+ while queue2:
169
+ node, score, hop = queue2.popleft()
170
+ if node in visited_bwd or hop > cfg.max_hops:
171
+ continue
172
+ visited_bwd.add(node)
173
+ scores[node] = max(scores.get(node, 0.0), score)
174
+ next_score = score * cfg.caller_decay
175
+ if next_score >= cfg.bfs_cutoff:
176
+ for caller in reverse.get(node, set()):
177
+ if caller not in visited_bwd:
178
+ queue2.append((caller, next_score, hop + 1))
179
+
180
+ # ── 4. Specificity-weighted sibling bonus ────────────────────────────
181
+ sibling_accumulator: Dict[str, float] = {}
182
+
183
+ for sym in changed_symbols:
184
+ for caller in reverse.get(sym, set()):
185
+ caller_callees = graph.get(caller, [])
186
+ caller_outdegree = len(caller_callees)
187
+ if caller_outdegree <= 1:
188
+ continue
189
+ raw_contribution = cfg.sibling_base / caller_outdegree
190
+ contribution = math.log2(1.0 + raw_contribution)
191
+ for sibling in caller_callees:
192
+ if sibling not in changed_set and sibling != sym:
193
+ sibling_accumulator[sibling] = (
194
+ sibling_accumulator.get(sibling, 0.0) + contribution
195
+ )
196
+
197
+ for sibling, bonus in sibling_accumulator.items():
198
+ capped_bonus = min(bonus, cfg.sibling_max)
199
+ scores[sibling] = scores.get(sibling, 0.0) + capped_bonus
200
+
201
+ # ── 5. Expanded deps: give them a meaningful score ────────────────────
202
+ if expanded_deps:
203
+ for sym in expanded_deps:
204
+ if sym not in scores:
205
+ scores[sym] = cfg.blast_base
206
+
207
+ # ── 6. Remaining blast radius symbols not yet scored ─────────────────
208
+ for sym, radius in blast_radii.items():
209
+ for affected in radius:
210
+ if affected not in scores:
211
+ scores[affected] = cfg.blast_base
212
+
213
+ # ── 7. Structural bonus: log-scaled, hard-capped ──────────────────────────
214
+ # Previous formula (indegree*2 + outdegree) was unbounded: a hub with
215
+ # indegree=50 got +100 structural bonus, drowning all co-change signal.
216
+ # log2(1+degree) grows slowly and the cfg.struct_max cap prevents run-away.
217
+ for sym in scores:
218
+ indegree = len(reverse.get(sym, set()))
219
+ outdegree = len(graph.get(sym, []))
220
+ struct_bonus = math.log2(1 + indegree) * 3 + math.log2(1 + outdegree)
221
+ scores[sym] += min(struct_bonus, cfg.struct_max)
222
+
223
+ return scores
@@ -0,0 +1,58 @@
1
+ """
2
+ traversal.py — Dependency expansion (forward graph walk).
3
+
4
+ Given selected symbols, walk forward edges to include their callees.
5
+ Supports both unbounded DFS and bounded BFS.
6
+ """
7
+
8
+ from typing import Dict, List, Optional, Set
9
+
10
+
11
+ def expand_dependencies(
12
+ graph: Dict[str, List[str]],
13
+ selected_symbols: List[str],
14
+ max_depth: Optional[int] = None,
15
+ ) -> List[str]:
16
+ """
17
+ Walk forward edges from selected_symbols.
18
+
19
+ max_depth=None -> full transitive closure (iterative DFS)
20
+ max_depth=N -> only nodes reachable within N hops (BFS)
21
+ """
22
+ visited: Set[str] = set()
23
+ result: List[str] = []
24
+
25
+ if max_depth is None:
26
+ # Iterative DFS — safe on large repos (no recursion limit)
27
+ stack = list(selected_symbols)
28
+ while stack:
29
+ func = stack.pop()
30
+ if func in visited:
31
+ continue
32
+ visited.add(func)
33
+ result.append(func)
34
+ for dep in reversed(graph.get(func, [])):
35
+ if dep not in visited:
36
+ stack.append(dep)
37
+ return result
38
+
39
+ # Bounded BFS
40
+ frontier = list(selected_symbols)
41
+ for func in frontier:
42
+ if func not in visited:
43
+ visited.add(func)
44
+ result.append(func)
45
+
46
+ depth = 0
47
+ while frontier and depth < max_depth:
48
+ next_frontier = []
49
+ for func in frontier:
50
+ for dep in graph.get(func, []):
51
+ if dep not in visited:
52
+ visited.add(dep)
53
+ result.append(dep)
54
+ next_frontier.append(dep)
55
+ frontier = next_frontier
56
+ depth += 1
57
+
58
+ return result