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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
selector.py — Select which symbols to include in context, respecting token budget.
|
|
3
|
+
|
|
4
|
+
Key fix over original:
|
|
5
|
+
- Changed symbols are the ONLY unconditional include.
|
|
6
|
+
- Score >= 80 threshold no longer bypasses the token budget.
|
|
7
|
+
That rule caused direct callees (score ~90 after structural bonus)
|
|
8
|
+
to consume the entire budget before any co-change sibling candidates
|
|
9
|
+
(score ~38-50) were even evaluated. Precision suffered badly.
|
|
10
|
+
- Instead: rank strictly by score, apply budget universally, with one
|
|
11
|
+
exception: changed symbols always fit (they're the reason we're here).
|
|
12
|
+
- Added a per-symbol token cap so one giant function can't crowd out
|
|
13
|
+
ten relevant small ones.
|
|
14
|
+
|
|
15
|
+
Fix vs previous version:
|
|
16
|
+
The token cap logic was wrong: it counted a capped amount toward the
|
|
17
|
+
budget (250 tokens) but included the full symbol in the result, causing
|
|
18
|
+
silent overruns. The correct behavior is: if a symbol exceeds the cap,
|
|
19
|
+
SKIP IT ENTIRELY rather than including it at a lie. This means the
|
|
20
|
+
selector tries smaller candidates next instead of filling context with
|
|
21
|
+
one huge function and then claiming there's room for more.
|
|
22
|
+
|
|
23
|
+
Fix vs previous version (token-accounting mismatch):
|
|
24
|
+
The selector used to budget on token_count(symbol.code) — the bare
|
|
25
|
+
function body — while the compiler renders each symbol with a FILE:/
|
|
26
|
+
FUNCTION: header and a CALLERS/CALLEES relationship block on top of the
|
|
27
|
+
code, and reports tokens over that full rendered block. The gap between
|
|
28
|
+
what was budgeted and what was emitted produced a systematic 25-41%
|
|
29
|
+
overshoot of --max-tokens (reproduced on psf/black at every budget from
|
|
30
|
+
500 to 8000). Now, when the caller passes the call graph, each candidate
|
|
31
|
+
is measured with compiler.render_symbol_block() — the exact rendering the
|
|
32
|
+
compiler will emit — using a pessimistic empty selected_set so every
|
|
33
|
+
relationship entry counts the longer " [NOT IN CONTEXT]" tag. Without a
|
|
34
|
+
graph the old code-only behavior is preserved so existing library callers
|
|
35
|
+
don't silently change.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from typing import Callable, Dict, List, Optional, Set, Tuple
|
|
39
|
+
|
|
40
|
+
from ..models import Symbol
|
|
41
|
+
from .compiler import build_reverse_graph, relationship_cap, render_symbol_block
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# A single symbol can burn at most this fraction of the total budget.
|
|
45
|
+
# Prevents one huge function from crowding out ten relevant small ones.
|
|
46
|
+
MAX_SINGLE_SYMBOL_FRACTION = 0.25
|
|
47
|
+
|
|
48
|
+
# Largest-gap cutoff ("gap50" in the benchmarks): the relative-drop search
|
|
49
|
+
# window and the score floor below which a candidate is not retrievable at
|
|
50
|
+
# all. Both values are the exact ones measured in blend_loro.py
|
|
51
|
+
# eval_cutoff_policies (benchmarks/RIGOR_REPORT_2026-07.md §7).
|
|
52
|
+
GAP_CUTOFF_WINDOW = 50
|
|
53
|
+
GAP_SCORE_EPSILON = 1e-12
|
|
54
|
+
|
|
55
|
+
# Minimum ratio for the gap to fire. The original gap_cut_count always cut at
|
|
56
|
+
# the largest relative drop, even when that drop was 1.10x (noise, not a real
|
|
57
|
+
# break). On ContextBench, 99% of 624 missed gold symbols were gap_cut — many
|
|
58
|
+
# at rank 3-4 with scores 75-84, cut by a trivial 1.10x drop at rank 2.
|
|
59
|
+
# min_ratio=1.5 means the gap only fires when the drop is >= 50% — a real break.
|
|
60
|
+
GAP_MIN_RATIO = 1.5
|
|
61
|
+
|
|
62
|
+
# Minimum number of candidates the gap must keep. The original kept ~1-2 on
|
|
63
|
+
# ContextBench (out of 7000+ scored symbols), over-pruning to the point where
|
|
64
|
+
# direct callees at score 90 were cut. min_keep=10 ensures the gap never
|
|
65
|
+
# prunes below 10 candidates; the budget controls context size after that.
|
|
66
|
+
GAP_MIN_KEEP = 10
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def gap_cut_count(ranked_scores: List[float],
|
|
70
|
+
min_ratio: float = 1.0, min_keep: int = 0) -> int:
|
|
71
|
+
"""
|
|
72
|
+
How many leading candidates the largest-gap cutoff keeps.
|
|
73
|
+
|
|
74
|
+
`ranked_scores` must be positive scores sorted descending. The cut lands
|
|
75
|
+
at the largest relative drop (score[i] / score[i+1]) between consecutive
|
|
76
|
+
candidates within the first GAP_CUTOFF_WINDOW — the policy measured
|
|
77
|
+
F1-optimal on all five benchmark repos: ~4x the precision of fixed
|
|
78
|
+
top-20 at 6-9 retrieved symbols, for ~30% relative recall cost
|
|
79
|
+
(benchmarks/RIGOR_REPORT_2026-07.md §7). With fewer than 3 candidates
|
|
80
|
+
there is no distribution to read, so everything is kept.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
min_ratio: only cut if the largest relative drop >= this ratio.
|
|
84
|
+
Default 1.0 = always cut (original behavior). 1.5 = only cut
|
|
85
|
+
when the drop is >= 50% (filters noise drops like 1.10x).
|
|
86
|
+
min_keep: always keep at least this many candidates, even if the
|
|
87
|
+
gap would cut earlier. Default 0 = no minimum (original
|
|
88
|
+
behavior). 10 = never prune below 10 candidates; the budget
|
|
89
|
+
controls context size after that.
|
|
90
|
+
"""
|
|
91
|
+
n = len(ranked_scores)
|
|
92
|
+
if n < 3:
|
|
93
|
+
return n
|
|
94
|
+
head = ranked_scores[:GAP_CUTOFF_WINDOW]
|
|
95
|
+
best_i, best_ratio = 1, 0.0
|
|
96
|
+
for i in range(len(head) - 1):
|
|
97
|
+
ratio = head[i] / max(head[i + 1], GAP_SCORE_EPSILON)
|
|
98
|
+
if ratio > best_ratio:
|
|
99
|
+
best_ratio = ratio
|
|
100
|
+
best_i = i + 1
|
|
101
|
+
# Only apply the gap if the drop is significant enough to be a real break.
|
|
102
|
+
if best_ratio < min_ratio:
|
|
103
|
+
return n # no significant gap — keep all, let budget/top_k decide
|
|
104
|
+
# Never prune below min_keep candidates.
|
|
105
|
+
return max(best_i, min_keep)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def select_context(
|
|
109
|
+
symbols: Dict[str, Symbol],
|
|
110
|
+
scores: Dict[str, float],
|
|
111
|
+
changed: List[str],
|
|
112
|
+
max_tokens: Optional[int] = None,
|
|
113
|
+
token_counter: Optional[Callable[[str], int]] = None,
|
|
114
|
+
top_k: Optional[int] = None,
|
|
115
|
+
graph: Optional[Dict[str, List[str]]] = None,
|
|
116
|
+
reverse: Optional[Dict[str, Set[str]]] = None,
|
|
117
|
+
rel_cap: Optional[int] = None,
|
|
118
|
+
cutoff: Optional[str] = None,
|
|
119
|
+
gap_min_ratio: float = 1.0,
|
|
120
|
+
gap_min_keep: int = 0,
|
|
121
|
+
) -> Tuple[List[str], List[str]]:
|
|
122
|
+
"""
|
|
123
|
+
Select symbols for context based on scores and token budget.
|
|
124
|
+
|
|
125
|
+
Priority:
|
|
126
|
+
1. Changed symbols always included (no budget bypass for anything else)
|
|
127
|
+
2. All remaining symbols ranked by score, included until budget exhausted
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
token_counter: Optional callable text -> token count. Pass your
|
|
131
|
+
model's real tokenizer (e.g. tiktoken, Anthropic counting) when
|
|
132
|
+
enforcing a hard context-window limit; defaults to the
|
|
133
|
+
~4-chars-per-token heuristic, which is approximate.
|
|
134
|
+
top_k: Optional cap on the number of NON-changed symbols included,
|
|
135
|
+
applied on top of the token budget. The eval_v2 benchmark found
|
|
136
|
+
retrieval recall plateaus around 20 symbols per changed symbol
|
|
137
|
+
while precision keeps degrading, so a caller optimizing for
|
|
138
|
+
signal-to-noise should pass ~20 * len(changed).
|
|
139
|
+
graph: Optional call graph (id -> [dep ids]). When provided, each
|
|
140
|
+
candidate is budgeted at its FULL rendered size (headers +
|
|
141
|
+
relationship annotations + code) via render_symbol_block, which
|
|
142
|
+
is what the compiler actually emits. Omit for the legacy
|
|
143
|
+
code-only accounting (kept for backward compatibility, but it
|
|
144
|
+
undercounts and the compiled output will overshoot the budget).
|
|
145
|
+
reverse: Optional precomputed reverse graph (callee -> callers).
|
|
146
|
+
Derived from `graph` when absent.
|
|
147
|
+
rel_cap: Relationship-block entry cap used for size measurement;
|
|
148
|
+
defaults to compiler.relationship_cap(max_tokens) so selector
|
|
149
|
+
and compiler always measure the same rendering.
|
|
150
|
+
cutoff: Optional dynamic cutoff policy applied to the score ranking
|
|
151
|
+
BEFORE top_k and the token budget (the order the benchmark
|
|
152
|
+
measured). "gap" cuts at the largest relative score drop
|
|
153
|
+
(see gap_cut_count) and additionally drops zero-score
|
|
154
|
+
candidates, which the policy never retrieves. None/"topk"
|
|
155
|
+
keeps today's recall-first behavior. Note: the benchmark
|
|
156
|
+
measured the policy per single-changed-symbol query; with
|
|
157
|
+
multiple changed symbols it applies to the merged ranking.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
(selected_ids, dropped_ids)
|
|
161
|
+
dropped_ids: scored symbols that exist in `symbols` but were cut by
|
|
162
|
+
the token budget. The LLM is told about these explicitly.
|
|
163
|
+
"""
|
|
164
|
+
count = token_counter or _estimate_tokens
|
|
165
|
+
|
|
166
|
+
if cutoff not in (None, "topk", "gap"):
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"unknown cutoff policy {cutoff!r} — expected 'gap', 'topk', or None"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if not scores:
|
|
172
|
+
return list(changed), []
|
|
173
|
+
|
|
174
|
+
if graph is not None and reverse is None:
|
|
175
|
+
reverse = build_reverse_graph(graph)
|
|
176
|
+
if rel_cap is None:
|
|
177
|
+
rel_cap = relationship_cap(max_tokens)
|
|
178
|
+
|
|
179
|
+
def rendered_size(sym_id: str) -> int:
|
|
180
|
+
"""Tokens this symbol will actually cost in the compiled output."""
|
|
181
|
+
if graph is None:
|
|
182
|
+
return count(symbols[sym_id].code)
|
|
183
|
+
assert reverse is not None # derived from graph above when absent
|
|
184
|
+
# Empty selected_set = every relationship entry gets the longer
|
|
185
|
+
# " [NOT IN CONTEXT]" tag = safe upper bound on the real rendering.
|
|
186
|
+
return count(render_symbol_block(
|
|
187
|
+
sym_id, symbols, scores.get(sym_id, 0), graph, reverse,
|
|
188
|
+
set(), rel_cap=rel_cap,
|
|
189
|
+
))
|
|
190
|
+
|
|
191
|
+
per_sym_cap = int(max_tokens * MAX_SINGLE_SYMBOL_FRACTION) if max_tokens else None
|
|
192
|
+
|
|
193
|
+
changed_set = set(changed)
|
|
194
|
+
result: List[str] = []
|
|
195
|
+
dropped: List[str] = []
|
|
196
|
+
current_tokens = 0
|
|
197
|
+
|
|
198
|
+
# ── Pass 1: changed symbols always in, no budget check ───────────────
|
|
199
|
+
for sym_id in changed:
|
|
200
|
+
if sym_id in symbols:
|
|
201
|
+
result.append(sym_id)
|
|
202
|
+
current_tokens += rendered_size(sym_id)
|
|
203
|
+
|
|
204
|
+
# ── Pass 2: everything else ranked by score, budget-gated ────────────
|
|
205
|
+
scored = sorted(
|
|
206
|
+
((sid, sc) for sid, sc in scores.items() if sid not in changed_set),
|
|
207
|
+
key=lambda x: x[1],
|
|
208
|
+
reverse=True,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
gap_kept: Optional[Set[str]] = None
|
|
212
|
+
if cutoff == "gap":
|
|
213
|
+
candidates = [
|
|
214
|
+
(sid, sc) for sid, sc in scored
|
|
215
|
+
if sid in symbols and sc > GAP_SCORE_EPSILON
|
|
216
|
+
]
|
|
217
|
+
keep_n = gap_cut_count([sc for _, sc in candidates],
|
|
218
|
+
min_ratio=gap_min_ratio, min_keep=gap_min_keep)
|
|
219
|
+
gap_kept = {sid for sid, _ in candidates[:keep_n]}
|
|
220
|
+
|
|
221
|
+
included_non_changed = 0
|
|
222
|
+
for sym_id, score in scored:
|
|
223
|
+
if sym_id not in symbols:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
if gap_kept is not None and sym_id not in gap_kept:
|
|
227
|
+
dropped.append(sym_id)
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
if top_k is not None and included_non_changed >= top_k:
|
|
231
|
+
dropped.append(sym_id)
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
sym_tokens = rendered_size(sym_id)
|
|
235
|
+
|
|
236
|
+
# FIX: if the symbol exceeds the per-symbol cap, skip it entirely.
|
|
237
|
+
# Previous code counted the capped amount toward the budget but
|
|
238
|
+
# still included the full symbol, silently overrunning the budget
|
|
239
|
+
# and then continuing to include more symbols as if there were room.
|
|
240
|
+
# Skipping is correct: the budget should gate what's actually included.
|
|
241
|
+
if per_sym_cap is not None and sym_tokens > per_sym_cap:
|
|
242
|
+
dropped.append(sym_id)
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
if max_tokens is not None and current_tokens + sym_tokens > max_tokens:
|
|
246
|
+
dropped.append(sym_id)
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
result.append(sym_id)
|
|
250
|
+
included_non_changed += 1
|
|
251
|
+
current_tokens += sym_tokens
|
|
252
|
+
|
|
253
|
+
return result, dropped
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _estimate_tokens(text: str) -> int:
|
|
257
|
+
"""~4 chars per token (GPT approximation). Add 20% buffer for safety."""
|
|
258
|
+
return max(1, int(len(text) / 4 * 1.2))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""diff subpackage — git diff + state-based change detection."""
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
git_diff.py — Extract changed files/symbols from git diff output.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import List, Optional, Set
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_changed_files(
|
|
11
|
+
repo_path: str,
|
|
12
|
+
ref: str = "HEAD~1",
|
|
13
|
+
against: "Optional[str]" = None,
|
|
14
|
+
) -> List[str]:
|
|
15
|
+
"""
|
|
16
|
+
Get list of Python files changed between `ref` and `against`.
|
|
17
|
+
|
|
18
|
+
against=None (default): compare against the WORKING TREE, i.e. include
|
|
19
|
+
uncommitted changes (staged or not). This is `git diff <ref>` with no
|
|
20
|
+
second ref -- the same thing `git status`-style tools show you before
|
|
21
|
+
you commit.
|
|
22
|
+
against="HEAD" (or any other ref): compare two committed snapshots only;
|
|
23
|
+
uncommitted edits are invisible to this mode.
|
|
24
|
+
|
|
25
|
+
Returns list of relative paths like ["./src/auth.py", "./api/login.py"]
|
|
26
|
+
"""
|
|
27
|
+
repo_path = os.path.abspath(repo_path)
|
|
28
|
+
try:
|
|
29
|
+
cmd = ["git", "diff", "--name-only", "--diff-filter=ACMR", ref]
|
|
30
|
+
if against is not None:
|
|
31
|
+
cmd.append(against)
|
|
32
|
+
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
cmd,
|
|
35
|
+
cwd=repo_path,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
timeout=30,
|
|
39
|
+
)
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
from ..languages import indexable_extensions
|
|
44
|
+
exts = indexable_extensions()
|
|
45
|
+
|
|
46
|
+
files = []
|
|
47
|
+
for line in result.stdout.strip().split("\n"):
|
|
48
|
+
line = line.strip()
|
|
49
|
+
if line.endswith(exts):
|
|
50
|
+
files.append("./" + line)
|
|
51
|
+
return files
|
|
52
|
+
|
|
53
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_changed_lines(
|
|
58
|
+
repo_path: str,
|
|
59
|
+
filepath: str,
|
|
60
|
+
ref: str = "HEAD~1",
|
|
61
|
+
against: "Optional[str]" = None,
|
|
62
|
+
) -> Set[int]:
|
|
63
|
+
"""
|
|
64
|
+
Get set of changed line numbers for a specific file.
|
|
65
|
+
|
|
66
|
+
against=None (default): compare `ref` against the working tree,
|
|
67
|
+
including uncommitted edits. See get_changed_files for details.
|
|
68
|
+
|
|
69
|
+
Returns set of 1-indexed line numbers that were added or modified.
|
|
70
|
+
"""
|
|
71
|
+
repo_path = os.path.abspath(repo_path)
|
|
72
|
+
try:
|
|
73
|
+
cmd = ["git", "diff", "-U0", ref]
|
|
74
|
+
if against is not None:
|
|
75
|
+
cmd.append(against)
|
|
76
|
+
cmd += ["--", filepath.lstrip("./")]
|
|
77
|
+
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
cmd,
|
|
80
|
+
cwd=repo_path,
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
timeout=30,
|
|
84
|
+
)
|
|
85
|
+
if result.returncode != 0:
|
|
86
|
+
return set()
|
|
87
|
+
|
|
88
|
+
changed_lines: Set[int] = set()
|
|
89
|
+
for line in result.stdout.split("\n"):
|
|
90
|
+
if line.startswith("@@"):
|
|
91
|
+
# Parse @@ -old,count +new,count @@
|
|
92
|
+
parts = line.split()
|
|
93
|
+
for part in parts:
|
|
94
|
+
if part.startswith("+") and "," in part:
|
|
95
|
+
start_str, count_str = part[1:].split(",", 1)
|
|
96
|
+
start = int(start_str)
|
|
97
|
+
count = int(count_str)
|
|
98
|
+
for i in range(start, start + max(count, 1)):
|
|
99
|
+
changed_lines.add(i)
|
|
100
|
+
elif part.startswith("+") and part[1:].isdigit():
|
|
101
|
+
changed_lines.add(int(part[1:]))
|
|
102
|
+
|
|
103
|
+
return changed_lines
|
|
104
|
+
|
|
105
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
|
|
106
|
+
return set()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_patch_text(
|
|
110
|
+
repo_path: str,
|
|
111
|
+
filepath: str,
|
|
112
|
+
ref: str = "HEAD~1",
|
|
113
|
+
against: "Optional[str]" = None,
|
|
114
|
+
context_lines: int = 3,
|
|
115
|
+
) -> str:
|
|
116
|
+
"""
|
|
117
|
+
Return the raw unified-diff patch text for a single file between
|
|
118
|
+
`ref` and `against` (working tree if against=None).
|
|
119
|
+
|
|
120
|
+
Useful for files that no longer parse: you can't get a line-level
|
|
121
|
+
symbol diff out of AST comparison, but the raw patch still shows
|
|
122
|
+
exactly what text changed (e.g. a commented-out `class` line).
|
|
123
|
+
|
|
124
|
+
Returns "" if there's no diff, the file/ref doesn't exist, or git fails.
|
|
125
|
+
"""
|
|
126
|
+
repo_path = os.path.abspath(repo_path)
|
|
127
|
+
try:
|
|
128
|
+
cmd = ["git", "diff", f"-U{context_lines}", ref]
|
|
129
|
+
if against is not None:
|
|
130
|
+
cmd.append(against)
|
|
131
|
+
cmd += ["--", filepath.lstrip("./")]
|
|
132
|
+
|
|
133
|
+
result = subprocess.run(
|
|
134
|
+
cmd,
|
|
135
|
+
cwd=repo_path,
|
|
136
|
+
capture_output=True,
|
|
137
|
+
text=True,
|
|
138
|
+
timeout=30,
|
|
139
|
+
)
|
|
140
|
+
if result.returncode != 0:
|
|
141
|
+
return ""
|
|
142
|
+
return result.stdout
|
|
143
|
+
|
|
144
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
145
|
+
return ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def find_changed_symbols(
|
|
149
|
+
repo_path: str,
|
|
150
|
+
symbols: dict,
|
|
151
|
+
ref: str = "HEAD~1",
|
|
152
|
+
against: "Optional[str]" = None,
|
|
153
|
+
broken_files: Optional[List[str]] = None,
|
|
154
|
+
broken_file_patches: Optional[dict] = None,
|
|
155
|
+
known_broken_files: Optional[List[str]] = None,
|
|
156
|
+
) -> List[str]:
|
|
157
|
+
"""
|
|
158
|
+
Find which symbol IDs are affected by a git diff.
|
|
159
|
+
|
|
160
|
+
against=None (default): compares `ref` against the WORKING TREE, so
|
|
161
|
+
uncommitted edits are picked up. Pass against="HEAD" explicitly if you
|
|
162
|
+
only want committed changes.
|
|
163
|
+
|
|
164
|
+
Cross-references changed lines with symbol line ranges to find
|
|
165
|
+
exactly which functions/methods were modified.
|
|
166
|
+
|
|
167
|
+
`symbols` is the CURRENT index (built from the working tree / `against`
|
|
168
|
+
ref). A changed file that contributes zero entries to `symbols` is
|
|
169
|
+
ambiguous on its own -- it might have failed to parse (SyntaxError), or
|
|
170
|
+
it might just be a file with no function/method definitions (setup.py,
|
|
171
|
+
a constants module, an __init__.py with only imports, etc). To tell
|
|
172
|
+
these apart correctly, pass `known_broken_files` -- the ground-truth
|
|
173
|
+
list from extract_all_symbols()/RepositoryIndex.broken_files, which
|
|
174
|
+
only contains files that actually raised SyntaxError. Only files in
|
|
175
|
+
that list get the broken-file fallback treatment below; any other
|
|
176
|
+
zero-symbol file is treated as a normal (legitimately function-less)
|
|
177
|
+
file and simply contributes no changed symbols.
|
|
178
|
+
|
|
179
|
+
For files confirmed broken via `known_broken_files`:
|
|
180
|
+
- the file's relative path is appended to `broken_files` (if provided)
|
|
181
|
+
- we fall back to the symbol IDs that existed in the file at `ref`
|
|
182
|
+
(the prior, presumably-working revision) via `git show`, so the
|
|
183
|
+
change is still reported instead of silently disappearing.
|
|
184
|
+
- if `broken_file_patches` (a dict) is provided, it's populated with
|
|
185
|
+
{relative_file: raw_patch_text} -- the actual unified diff, since a
|
|
186
|
+
broken file can't be symbol-diffed and the patch is the only real
|
|
187
|
+
signal of what changed.
|
|
188
|
+
"""
|
|
189
|
+
known_broken = set(known_broken_files or ())
|
|
190
|
+
changed_files = get_changed_files(repo_path, ref, against)
|
|
191
|
+
if not changed_files:
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
changed_symbols = []
|
|
195
|
+
|
|
196
|
+
for sym_id, sym in symbols.items():
|
|
197
|
+
sym_file = "./" + os.path.relpath(sym.file, os.path.abspath(repo_path))
|
|
198
|
+
if sym_file not in changed_files:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
changed_lines = get_changed_lines(repo_path, sym_file, ref, against)
|
|
202
|
+
if not changed_lines:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
code_lines = sym.code.count("\n") + 1
|
|
206
|
+
sym_lines = set(range(sym.lineno, sym.lineno + code_lines))
|
|
207
|
+
|
|
208
|
+
if sym_lines & changed_lines:
|
|
209
|
+
changed_symbols.append(sym_id)
|
|
210
|
+
|
|
211
|
+
# Check for deleted symbols or broken files
|
|
212
|
+
for changed_file in changed_files:
|
|
213
|
+
# Handle broken files (SyntaxError)
|
|
214
|
+
if changed_file in known_broken:
|
|
215
|
+
if broken_files is not None and changed_file not in broken_files:
|
|
216
|
+
broken_files.append(changed_file)
|
|
217
|
+
|
|
218
|
+
if broken_file_patches is not None:
|
|
219
|
+
broken_file_patches[changed_file] = get_patch_text(
|
|
220
|
+
repo_path, changed_file, ref, against
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
prior_ids = _symbol_ids_at_ref(repo_path, changed_file, ref)
|
|
224
|
+
for p_id in prior_ids:
|
|
225
|
+
if p_id not in changed_symbols:
|
|
226
|
+
changed_symbols.append(p_id)
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# Handle valid files: find symbols that existed before but are gone now
|
|
230
|
+
prior_ids = _symbol_ids_at_ref(repo_path, changed_file, ref)
|
|
231
|
+
current_ids = {
|
|
232
|
+
sym_id for sym_id, sym in symbols.items()
|
|
233
|
+
if "./" + os.path.relpath(sym.file, os.path.abspath(repo_path)) == changed_file
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
deleted_ids = set(prior_ids) - current_ids
|
|
237
|
+
for d_id in deleted_ids:
|
|
238
|
+
if d_id not in changed_symbols:
|
|
239
|
+
changed_symbols.append(d_id)
|
|
240
|
+
|
|
241
|
+
return changed_symbols
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _symbol_ids_at_ref(repo_path: str, filepath: str, ref: str) -> List[str]:
|
|
245
|
+
"""
|
|
246
|
+
Best-effort: list symbol IDs ("./file.py:Name") that existed in
|
|
247
|
+
`filepath` at git ref `ref`, by parsing the file's contents at that
|
|
248
|
+
revision. Returns [] if the file didn't exist, wasn't valid Python
|
|
249
|
+
at that ref either, or git/parse fails for any reason.
|
|
250
|
+
"""
|
|
251
|
+
repo_path = os.path.abspath(repo_path)
|
|
252
|
+
try:
|
|
253
|
+
result = subprocess.run(
|
|
254
|
+
["git", "show", f"{ref}:{filepath.lstrip('./')}"],
|
|
255
|
+
cwd=repo_path,
|
|
256
|
+
capture_output=True,
|
|
257
|
+
text=True,
|
|
258
|
+
timeout=30,
|
|
259
|
+
)
|
|
260
|
+
if result.returncode != 0:
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
import ast as _ast
|
|
264
|
+
try:
|
|
265
|
+
tree = _ast.parse(result.stdout)
|
|
266
|
+
except SyntaxError:
|
|
267
|
+
return []
|
|
268
|
+
|
|
269
|
+
class _FuncVisitor(_ast.NodeVisitor):
|
|
270
|
+
def __init__(self):
|
|
271
|
+
self.class_stack = []
|
|
272
|
+
self.names = []
|
|
273
|
+
|
|
274
|
+
def visit_ClassDef(self, node):
|
|
275
|
+
self.class_stack.append(node.name)
|
|
276
|
+
self.generic_visit(node)
|
|
277
|
+
self.class_stack.pop()
|
|
278
|
+
|
|
279
|
+
def visit_FunctionDef(self, node):
|
|
280
|
+
self._add(node)
|
|
281
|
+
self.generic_visit(node)
|
|
282
|
+
|
|
283
|
+
def visit_AsyncFunctionDef(self, node):
|
|
284
|
+
self._add(node)
|
|
285
|
+
self.generic_visit(node)
|
|
286
|
+
|
|
287
|
+
def _add(self, node):
|
|
288
|
+
if self.class_stack:
|
|
289
|
+
self.names.append(f"{self.class_stack[-1]}.{node.name}")
|
|
290
|
+
else:
|
|
291
|
+
self.names.append(node.name)
|
|
292
|
+
|
|
293
|
+
visitor = _FuncVisitor()
|
|
294
|
+
visitor.visit(tree)
|
|
295
|
+
return [f"{filepath}:{name}" for name in visitor.names]
|
|
296
|
+
|
|
297
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
298
|
+
return []
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
state_manager.py — Snapshot-based change detection (no git required).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Dict, Iterable, Optional
|
|
8
|
+
|
|
9
|
+
from ..models import DiffResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _symbol_file(fn_id: str) -> str:
|
|
13
|
+
"""Extract the file portion of a 'file.py:Class.method' symbol id."""
|
|
14
|
+
return fn_id.split(":", 1)[0] if ":" in fn_id else fn_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compare_states(
|
|
18
|
+
previous: Dict[str, dict],
|
|
19
|
+
current: Dict[str, dict],
|
|
20
|
+
broken_files: Optional[Iterable[str]] = None,
|
|
21
|
+
) -> DiffResult:
|
|
22
|
+
"""
|
|
23
|
+
Compare two snapshots of repository functions.
|
|
24
|
+
|
|
25
|
+
Each snapshot is: {function_id: {"code": "..."}}
|
|
26
|
+
|
|
27
|
+
broken_files: relative paths (e.g. "./objects.py") of files that failed
|
|
28
|
+
to parse (SyntaxError) when building `current`. Symbols that previously
|
|
29
|
+
existed in one of these files are reported as `modified` rather than
|
|
30
|
+
`deleted` -- the function wasn't removed, the file just can't be parsed
|
|
31
|
+
right now. The file itself is also recorded in `broken_files` so callers
|
|
32
|
+
can flag it distinctly from a normal diff.
|
|
33
|
+
"""
|
|
34
|
+
broken_files = set(broken_files or ())
|
|
35
|
+
|
|
36
|
+
modified = []
|
|
37
|
+
added = []
|
|
38
|
+
deleted = []
|
|
39
|
+
|
|
40
|
+
for fn_id in previous:
|
|
41
|
+
if fn_id not in current:
|
|
42
|
+
if _symbol_file(fn_id) in broken_files:
|
|
43
|
+
# File failed to parse this run -- treat every symbol that
|
|
44
|
+
# used to live there as modified (not deleted), since the
|
|
45
|
+
# change (the syntax break) is exactly what needs review.
|
|
46
|
+
modified.append(fn_id)
|
|
47
|
+
else:
|
|
48
|
+
deleted.append(fn_id)
|
|
49
|
+
elif previous[fn_id] != current[fn_id]:
|
|
50
|
+
modified.append(fn_id)
|
|
51
|
+
|
|
52
|
+
for fn_id in current:
|
|
53
|
+
if fn_id not in previous:
|
|
54
|
+
added.append(fn_id)
|
|
55
|
+
|
|
56
|
+
return DiffResult(
|
|
57
|
+
modified=modified,
|
|
58
|
+
added=added,
|
|
59
|
+
deleted=deleted,
|
|
60
|
+
broken_files=sorted(broken_files),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_state(state: Dict, path: str = "diffcontext_state.json"):
|
|
65
|
+
"""Save function snapshot to disk."""
|
|
66
|
+
with open(path, "w") as f:
|
|
67
|
+
json.dump(state, f, indent=2)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_state(path: str = "diffcontext_state.json") -> Dict:
|
|
71
|
+
"""Load previous function snapshot from disk."""
|
|
72
|
+
if not os.path.exists(path):
|
|
73
|
+
return {}
|
|
74
|
+
with open(path, "r") as f:
|
|
75
|
+
return json.load(f)
|