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