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,396 @@
|
|
|
1
|
+
"""
|
|
2
|
+
history.py — Extract REAL ground truth from git co-change history.
|
|
3
|
+
|
|
4
|
+
Strategy:
|
|
5
|
+
For a given commit, the ground truth is:
|
|
6
|
+
"Which OTHER functions were modified in the SAME commit?"
|
|
7
|
+
|
|
8
|
+
If a developer changed function A and function B together,
|
|
9
|
+
that's evidence they are related. This is external ground truth —
|
|
10
|
+
it comes from human behavior, not from our graph.
|
|
11
|
+
|
|
12
|
+
We then test: given function A as "changed", does DiffContext
|
|
13
|
+
retrieve function B?
|
|
14
|
+
|
|
15
|
+
This is the ONLY honest way to evaluate a context retrieval system
|
|
16
|
+
without an LLM in the loop.
|
|
17
|
+
|
|
18
|
+
This module is the canonical home of the extractor (it ships with the
|
|
19
|
+
installed package so `diffcontext verify --from-history` works anywhere);
|
|
20
|
+
benchmarks/ground_truth.py re-exports it for the research scripts.
|
|
21
|
+
|
|
22
|
+
FIXES preserved from the benchmarks version:
|
|
23
|
+
BUG 1 (critical): _find_functions_at_lines() was reading the file from
|
|
24
|
+
disk (HEAD state), but the changed_lines come from `git diff` at a
|
|
25
|
+
specific commit. For repos with significant history, the file may look
|
|
26
|
+
completely different today. Lines get misattributed to wrong functions.
|
|
27
|
+
Fix: use `git show <hash>:<path>` to get the file AS IT WAS at the
|
|
28
|
+
commit being analyzed.
|
|
29
|
+
|
|
30
|
+
BUG 2 (evaluation bias): extract_cochange_cases() always set
|
|
31
|
+
query_symbol = all_changed_symbols[0], i.e. the first function in the
|
|
32
|
+
first changed file. This biases evaluation toward whichever files git
|
|
33
|
+
returns first. Fix: generate one case per changed symbol (round-robin
|
|
34
|
+
expansion), so every function gets an equal chance to be the query.
|
|
35
|
+
|
|
36
|
+
BUG 3 (selection bias): max_commits=200 was enough for popular repos
|
|
37
|
+
but small repos (flask, click) may have few qualifying commits. Raised
|
|
38
|
+
to 500 and also relax the min_files filter to min_files=1 (allowing
|
|
39
|
+
single-file commits where multiple functions changed within one file).
|
|
40
|
+
|
|
41
|
+
BUG 4 (case inflation): The old code ran subprocess.run for EVERY
|
|
42
|
+
commit twice — once in _get_commits_with_multi_file_changes and once
|
|
43
|
+
inside extract_cochange_cases. Fixed by collapsing into a single pass.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
import ast
|
|
47
|
+
import os
|
|
48
|
+
import subprocess
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
from typing import List, Optional, Set
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Mechanical-refactor thresholds. A commit at or above either of these is a
|
|
54
|
+
# rename/reformat/API sweep rather than a unit of related work: its "co-change
|
|
55
|
+
# set" is an artifact of the sweep, and it can hold more symbols than any
|
|
56
|
+
# retriever is allowed to return, so recall on it is capped below 1.0 by
|
|
57
|
+
# construction. These MUST match benchmarks/eval_v2_hardened.py
|
|
58
|
+
# (NOISY_SYMBOLS / NOISY_FILES) — the published per-repo numbers exclude these
|
|
59
|
+
# commits, so mining them here would make a user's own measurement
|
|
60
|
+
# systematically worse than the table they are comparing it against.
|
|
61
|
+
NOISY_SYMBOLS = 20 # >= this many changed symbols
|
|
62
|
+
NOISY_FILES = 10 # >= this many changed source files
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class CoChangeCase:
|
|
67
|
+
"""A single ground truth test case from git history."""
|
|
68
|
+
commit_hash: str
|
|
69
|
+
commit_msg: str
|
|
70
|
+
changed_files: List[str] # relative paths
|
|
71
|
+
changed_symbols: List[str] # ALL function IDs that were modified
|
|
72
|
+
# For testing: one symbol is the query, the rest are ground truth
|
|
73
|
+
query_symbol: str = ""
|
|
74
|
+
ground_truth_symbols: List[str] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class SkippedCommit:
|
|
79
|
+
"""A commit excluded from mining, and why — so the drop is never silent."""
|
|
80
|
+
commit_hash: str
|
|
81
|
+
commit_msg: str
|
|
82
|
+
reason: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_changed_line_ranges(
|
|
86
|
+
repo_path: str,
|
|
87
|
+
commit_hash: str,
|
|
88
|
+
filepath: str,
|
|
89
|
+
) -> Set[int]:
|
|
90
|
+
"""Get the NEW-FILE line numbers that were added/modified in a commit."""
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["git", "diff", "-U0", f"{commit_hash}~1", commit_hash, "--", filepath],
|
|
94
|
+
cwd=repo_path,
|
|
95
|
+
capture_output=True,
|
|
96
|
+
text=True,
|
|
97
|
+
timeout=10,
|
|
98
|
+
)
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
return set()
|
|
101
|
+
|
|
102
|
+
changed_lines: Set[int] = set()
|
|
103
|
+
for line in result.stdout.split("\n"):
|
|
104
|
+
if line.startswith("@@"):
|
|
105
|
+
parts = line.split()
|
|
106
|
+
for part in parts:
|
|
107
|
+
if part.startswith("+") and not part.startswith("+++"):
|
|
108
|
+
try:
|
|
109
|
+
if "," in part:
|
|
110
|
+
start_str, count_str = part[1:].split(",", 1)
|
|
111
|
+
start = int(start_str)
|
|
112
|
+
count = int(count_str)
|
|
113
|
+
for i in range(start, start + max(count, 1)):
|
|
114
|
+
changed_lines.add(i)
|
|
115
|
+
else:
|
|
116
|
+
val = part[1:]
|
|
117
|
+
if val.isdigit():
|
|
118
|
+
changed_lines.add(int(val))
|
|
119
|
+
except ValueError:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
return changed_lines
|
|
123
|
+
|
|
124
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
125
|
+
return set()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _get_source_at_commit(
|
|
129
|
+
repo_path: str,
|
|
130
|
+
commit_hash: str,
|
|
131
|
+
filepath: str,
|
|
132
|
+
) -> Optional[str]:
|
|
133
|
+
"""
|
|
134
|
+
Return the file's source code AS IT WAS at commit_hash.
|
|
135
|
+
|
|
136
|
+
This is the critical fix: `git show <hash>:<path>` gives us the exact
|
|
137
|
+
file content that the diff line numbers refer to, not the current HEAD
|
|
138
|
+
version which may have changed substantially since that commit.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
result = subprocess.run(
|
|
142
|
+
["git", "show", f"{commit_hash}:./{filepath}"],
|
|
143
|
+
cwd=repo_path,
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
timeout=10,
|
|
147
|
+
)
|
|
148
|
+
if result.returncode != 0:
|
|
149
|
+
return None
|
|
150
|
+
return result.stdout
|
|
151
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _find_parent_class(tree: ast.AST, target_node: ast.AST) -> Optional[str]:
|
|
156
|
+
"""Find the (possibly nested) class name that directly contains target_node."""
|
|
157
|
+
for node in ast.walk(tree):
|
|
158
|
+
if isinstance(node, ast.ClassDef):
|
|
159
|
+
for child in node.body:
|
|
160
|
+
if child is target_node:
|
|
161
|
+
return node.name
|
|
162
|
+
if isinstance(child, ast.ClassDef):
|
|
163
|
+
for grandchild in child.body:
|
|
164
|
+
if grandchild is target_node:
|
|
165
|
+
return f"{node.name}.{child.name}"
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _find_functions_at_lines(
|
|
170
|
+
filepath: str,
|
|
171
|
+
changed_lines: Set[int],
|
|
172
|
+
repo_path: str,
|
|
173
|
+
commit_hash: str,
|
|
174
|
+
) -> List[str]:
|
|
175
|
+
"""
|
|
176
|
+
Find which function IDs contain the changed lines.
|
|
177
|
+
|
|
178
|
+
Reads the file AS IT WAS at commit_hash using `git show`, not the
|
|
179
|
+
current HEAD state, so diff line numbers map to the right functions.
|
|
180
|
+
"""
|
|
181
|
+
if not changed_lines:
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
source = _get_source_at_commit(repo_path, commit_hash, filepath)
|
|
185
|
+
if source is None:
|
|
186
|
+
return []
|
|
187
|
+
|
|
188
|
+
# Adapter languages: extract symbols from the historical source text
|
|
189
|
+
# via the language adapter, map lines through Symbol.lineno + length.
|
|
190
|
+
if not filepath.endswith(".py"):
|
|
191
|
+
from ..languages import adapter_for_path
|
|
192
|
+
adapter = adapter_for_path(filepath)
|
|
193
|
+
if adapter is None:
|
|
194
|
+
return []
|
|
195
|
+
symbols = adapter.extract_file_symbols(
|
|
196
|
+
os.path.join(repo_path, filepath), repo_path, source
|
|
197
|
+
)
|
|
198
|
+
results = []
|
|
199
|
+
for sym in symbols.values():
|
|
200
|
+
end = sym.lineno + max(len(sym.code.splitlines()) - 1, 0)
|
|
201
|
+
if set(range(sym.lineno, end + 1)) & changed_lines:
|
|
202
|
+
results.append(sym.id)
|
|
203
|
+
return results
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
tree = ast.parse(source)
|
|
207
|
+
except SyntaxError:
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
relative_file = "./" + filepath
|
|
211
|
+
results = []
|
|
212
|
+
|
|
213
|
+
for node in ast.walk(tree):
|
|
214
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
215
|
+
class_name = _find_parent_class(tree, node)
|
|
216
|
+
name = f"{class_name}.{node.name}" if class_name else node.name
|
|
217
|
+
func_id = f"{relative_file}:{name}"
|
|
218
|
+
|
|
219
|
+
end_lineno = getattr(node, "end_lineno", node.lineno + 10)
|
|
220
|
+
func_lines = set(range(node.lineno, end_lineno + 1))
|
|
221
|
+
|
|
222
|
+
if func_lines & changed_lines:
|
|
223
|
+
results.append(func_id)
|
|
224
|
+
|
|
225
|
+
return results
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def extract_cochange_cases(
|
|
229
|
+
repo_path: str,
|
|
230
|
+
max_cases: int = 50,
|
|
231
|
+
min_symbols_per_commit: int = 2,
|
|
232
|
+
noisy_symbols: Optional[int] = NOISY_SYMBOLS,
|
|
233
|
+
noisy_files: Optional[int] = NOISY_FILES,
|
|
234
|
+
skipped_out: Optional[List[SkippedCommit]] = None,
|
|
235
|
+
) -> List[CoChangeCase]:
|
|
236
|
+
"""
|
|
237
|
+
Extract real co-change test cases from git history.
|
|
238
|
+
|
|
239
|
+
Each case: one query symbol, all other co-changed symbols = ground truth.
|
|
240
|
+
One case is generated PER CHANGED SYMBOL (not per commit) to avoid
|
|
241
|
+
"first symbol always = query" selection bias; duplicate queries across
|
|
242
|
+
commits are skipped.
|
|
243
|
+
|
|
244
|
+
Mechanical refactors are excluded: a commit touching >= noisy_symbols
|
|
245
|
+
symbols or >= noisy_files files is a sweep, not a unit of related work.
|
|
246
|
+
Pass None to either to keep them. Excluded commits are appended to
|
|
247
|
+
skipped_out (as SkippedCommit) when a list is supplied, so a caller can
|
|
248
|
+
report the drop rather than hiding it.
|
|
249
|
+
|
|
250
|
+
NOTE: results recorded before this filter existed (eval_v1 and
|
|
251
|
+
benchmark_runner runs) mined unfiltered and are not comparable.
|
|
252
|
+
"""
|
|
253
|
+
repo_path = os.path.abspath(repo_path)
|
|
254
|
+
|
|
255
|
+
# Single-pass commit scan: get all commits, then process them
|
|
256
|
+
try:
|
|
257
|
+
log_result = subprocess.run(
|
|
258
|
+
[
|
|
259
|
+
"git", "log",
|
|
260
|
+
"--max-count=500",
|
|
261
|
+
"--format=%H|%s",
|
|
262
|
+
"--no-merges",
|
|
263
|
+
"--diff-filter=M", # only modifications (not pure adds/deletes)
|
|
264
|
+
],
|
|
265
|
+
cwd=repo_path,
|
|
266
|
+
capture_output=True,
|
|
267
|
+
text=True,
|
|
268
|
+
timeout=30,
|
|
269
|
+
)
|
|
270
|
+
if log_result.returncode != 0:
|
|
271
|
+
return []
|
|
272
|
+
raw_commits = [
|
|
273
|
+
line.split("|", 1)
|
|
274
|
+
for line in log_result.stdout.strip().split("\n")
|
|
275
|
+
if "|" in line
|
|
276
|
+
]
|
|
277
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
278
|
+
return []
|
|
279
|
+
|
|
280
|
+
cases: List[CoChangeCase] = []
|
|
281
|
+
seen_queries: Set[str] = set() # avoid duplicate (query, gt_frozenset) pairs
|
|
282
|
+
|
|
283
|
+
for commit_hash, commit_msg in raw_commits:
|
|
284
|
+
if len(cases) >= max_cases:
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
# Get changed Python source files in this commit
|
|
288
|
+
try:
|
|
289
|
+
files_result = subprocess.run(
|
|
290
|
+
["git", "diff", "--name-only", "--relative", "--diff-filter=M",
|
|
291
|
+
f"{commit_hash}~1", commit_hash],
|
|
292
|
+
cwd=repo_path,
|
|
293
|
+
capture_output=True,
|
|
294
|
+
text=True,
|
|
295
|
+
timeout=10,
|
|
296
|
+
)
|
|
297
|
+
if files_result.returncode != 0:
|
|
298
|
+
continue
|
|
299
|
+
except subprocess.TimeoutExpired:
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
from ..languages import indexable_extensions
|
|
303
|
+
_exts = indexable_extensions()
|
|
304
|
+
py_files = [
|
|
305
|
+
f for f in files_result.stdout.strip().split("\n")
|
|
306
|
+
if f.endswith(_exts)
|
|
307
|
+
and "/test" not in f.lower()
|
|
308
|
+
and "/tests/" not in f.lower()
|
|
309
|
+
and "test_" not in os.path.basename(f)
|
|
310
|
+
and ".test." not in os.path.basename(f) # foo.test.ts convention
|
|
311
|
+
and ".spec." not in os.path.basename(f) # foo.spec.ts convention
|
|
312
|
+
and f.strip()
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
if not py_files:
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
# Sweeping change: cheap to detect, so check before parsing anything.
|
|
319
|
+
if noisy_files is not None and len(py_files) >= noisy_files:
|
|
320
|
+
if skipped_out is not None:
|
|
321
|
+
skipped_out.append(SkippedCommit(
|
|
322
|
+
commit_hash=commit_hash[:8],
|
|
323
|
+
commit_msg=commit_msg[:80],
|
|
324
|
+
reason="{} files changed (likely sweeping change)".format(
|
|
325
|
+
len(py_files)),
|
|
326
|
+
))
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
# For each changed file, find which functions were actually modified
|
|
330
|
+
all_changed_symbols: List[str] = []
|
|
331
|
+
for filepath in py_files:
|
|
332
|
+
changed_lines = _get_changed_line_ranges(repo_path, commit_hash, filepath)
|
|
333
|
+
if not changed_lines:
|
|
334
|
+
continue
|
|
335
|
+
syms = _find_functions_at_lines(
|
|
336
|
+
filepath, changed_lines, repo_path, commit_hash
|
|
337
|
+
)
|
|
338
|
+
all_changed_symbols.extend(syms)
|
|
339
|
+
|
|
340
|
+
# Deduplicate (same function can match multiple diffs in the same commit)
|
|
341
|
+
all_changed_symbols = list(dict.fromkeys(all_changed_symbols))
|
|
342
|
+
|
|
343
|
+
if len(all_changed_symbols) < min_symbols_per_commit:
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
# Mechanical refactor: the co-change set is an artifact of the sweep,
|
|
347
|
+
# and it can exceed the number of symbols any retriever may return.
|
|
348
|
+
if noisy_symbols is not None and len(all_changed_symbols) >= noisy_symbols:
|
|
349
|
+
if skipped_out is not None:
|
|
350
|
+
skipped_out.append(SkippedCommit(
|
|
351
|
+
commit_hash=commit_hash[:8],
|
|
352
|
+
commit_msg=commit_msg[:80],
|
|
353
|
+
reason="{} symbols changed (likely mechanical refactor)".format(
|
|
354
|
+
len(all_changed_symbols)),
|
|
355
|
+
))
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
# Generate one case per changed symbol, not just one per commit.
|
|
359
|
+
# This eliminates the "first symbol always = query" selection bias.
|
|
360
|
+
for i, query_sym in enumerate(all_changed_symbols):
|
|
361
|
+
if len(cases) >= max_cases:
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
gt_syms = [s for j, s in enumerate(all_changed_symbols) if j != i]
|
|
365
|
+
if not gt_syms:
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
# Dedup: skip if we've already seen this exact query from any commit
|
|
369
|
+
if query_sym in seen_queries:
|
|
370
|
+
continue
|
|
371
|
+
seen_queries.add(query_sym)
|
|
372
|
+
|
|
373
|
+
case = CoChangeCase(
|
|
374
|
+
commit_hash=commit_hash[:8],
|
|
375
|
+
commit_msg=commit_msg[:80],
|
|
376
|
+
changed_files=["./{}".format(f) for f in py_files],
|
|
377
|
+
changed_symbols=all_changed_symbols,
|
|
378
|
+
query_symbol=query_sym,
|
|
379
|
+
ground_truth_symbols=gt_syms,
|
|
380
|
+
)
|
|
381
|
+
cases.append(case)
|
|
382
|
+
|
|
383
|
+
return cases
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
import sys
|
|
388
|
+
repo = sys.argv[1] if len(sys.argv) > 1 else "."
|
|
389
|
+
cases = extract_cochange_cases(repo, max_cases=50)
|
|
390
|
+
print(f"Found {len(cases)} co-change test cases")
|
|
391
|
+
for case in cases[:5]:
|
|
392
|
+
print(f"\n Commit: {case.commit_hash} — {case.commit_msg}")
|
|
393
|
+
print(f" Query: {case.query_symbol}")
|
|
394
|
+
print(f" Ground truth ({len(case.ground_truth_symbols)}):")
|
|
395
|
+
for gt in case.ground_truth_symbols[:5]:
|
|
396
|
+
print(f" {gt}")
|