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,338 @@
1
+ """
2
+ visualizer.py — Visual blast radius renderer.
3
+
4
+ Renders the blast radius as a colored, indented tree showing:
5
+ - The changed symbol at the root
6
+ - Direct callers (who calls this?)
7
+ - Direct callees (what does this call?)
8
+ - Transitive impact propagation
9
+ - Proof chains: the actual code line creating each edge
10
+
11
+ Designed for terminal output with ANSI colors.
12
+ """
13
+
14
+ from typing import Dict, List, Optional, Set
15
+
16
+
17
+ # ANSI color codes
18
+ class _C:
19
+ RED = "\033[91m"
20
+ YELLOW = "\033[93m"
21
+ GREEN = "\033[92m"
22
+ CYAN = "\033[96m"
23
+ MAGENTA = "\033[95m"
24
+ BLUE = "\033[94m"
25
+ DIM = "\033[2m"
26
+ BOLD = "\033[1m"
27
+ RESET = "\033[0m"
28
+ WHITE = "\033[97m"
29
+
30
+
31
+ def render_blast_radius(
32
+ graph: Dict[str, List[str]],
33
+ changed_symbols: List[str],
34
+ symbols: dict,
35
+ max_depth: int = 3,
36
+ show_proof: bool = False,
37
+ repo_path: str = "",
38
+ ) -> str:
39
+ """
40
+ Render a visual tree of the blast radius for changed symbols.
41
+
42
+ Returns a formatted string ready for terminal output.
43
+ """
44
+ # Build reverse graph (callers)
45
+ reverse: Dict[str, Set[str]] = {}
46
+ for caller, callees in graph.items():
47
+ for callee in callees:
48
+ reverse.setdefault(callee, set()).add(caller)
49
+
50
+ lines: List[str] = []
51
+
52
+ lines.append("")
53
+ lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
54
+ lines.append(f"{_C.BOLD}{_C.WHITE} BLAST RADIUS ANALYSIS{_C.RESET}")
55
+ lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
56
+
57
+ for sym_id in changed_symbols:
58
+ lines.append("")
59
+ lines.append(f" {_C.BOLD}{_C.RED}⚡ CHANGED:{_C.RESET} {_C.BOLD}{sym_id}{_C.RESET}")
60
+
61
+ # Show symbol location info
62
+ if sym_id in symbols:
63
+ sym = symbols[sym_id]
64
+ lines.append(f" {_C.DIM} File: {sym.file}{_C.RESET}")
65
+ lines.append(f" {_C.DIM} Line: {sym.lineno}{_C.RESET}")
66
+
67
+ lines.append("")
68
+
69
+ # ---- CALLERS (who is affected by this change?) ----
70
+ direct_callers = sorted(reverse.get(sym_id, set()))
71
+ lines.append(f" {_C.BOLD}{_C.YELLOW}▲ WHO CALLS THIS? (directly affected){_C.RESET}")
72
+
73
+ if not direct_callers:
74
+ lines.append(f" {_C.DIM} (no direct callers found){_C.RESET}")
75
+ else:
76
+ for i, caller in enumerate(direct_callers):
77
+ is_last = i == len(direct_callers) - 1
78
+ prefix = "└──" if is_last else "├──"
79
+ lines.append(f" {_C.YELLOW} {prefix} {caller}{_C.RESET}")
80
+
81
+ if show_proof:
82
+ proof = _find_proof(caller, sym_id, symbols, graph)
83
+ if proof:
84
+ pad = " " if is_last else "│ "
85
+ lines.append(f" {_C.DIM} {pad} proof: {proof}{_C.RESET}")
86
+
87
+ # 2nd-level callers (transitive)
88
+ if max_depth >= 2:
89
+ indirect_callers = sorted(reverse.get(caller, set()))
90
+ for j, caller2 in enumerate(indirect_callers[:5]):
91
+ is_last2 = j == len(indirect_callers[:5]) - 1
92
+ indent = " " if is_last else "│ "
93
+ prefix2 = "└──" if is_last2 else "├──"
94
+ lines.append(
95
+ f" {_C.DIM} {indent}{prefix2} {caller2}{_C.RESET}"
96
+ )
97
+ if len(indirect_callers) > 5:
98
+ indent = " " if is_last else "│ "
99
+ lines.append(
100
+ f" {_C.DIM} {indent}... +{len(indirect_callers) - 5} more{_C.RESET}"
101
+ )
102
+
103
+ lines.append("")
104
+
105
+ # ---- CALLEES (what does this function depend on?) ----
106
+ direct_callees = sorted(graph.get(sym_id, []))
107
+ lines.append(f" {_C.BOLD}{_C.GREEN}▼ WHAT DOES THIS CALL? (dependencies){_C.RESET}")
108
+
109
+ if not direct_callees:
110
+ lines.append(f" {_C.DIM} (no outgoing calls resolved){_C.RESET}")
111
+ else:
112
+ for i, callee in enumerate(direct_callees):
113
+ is_last = i == len(direct_callees) - 1
114
+ prefix = "└──" if is_last else "├──"
115
+ lines.append(f" {_C.GREEN} {prefix} {callee}{_C.RESET}")
116
+
117
+ if show_proof:
118
+ proof = _find_proof(sym_id, callee, symbols, graph)
119
+ if proof:
120
+ pad = " " if is_last else "│ "
121
+ lines.append(f" {_C.DIM} {pad} proof: {proof}{_C.RESET}")
122
+
123
+ # 2nd-level callees
124
+ if max_depth >= 2:
125
+ indirect_callees = sorted(graph.get(callee, []))
126
+ for j, callee2 in enumerate(indirect_callees[:5]):
127
+ is_last2 = j == len(indirect_callees[:5]) - 1
128
+ indent = " " if is_last else "│ "
129
+ prefix2 = "└──" if is_last2 else "├──"
130
+ lines.append(
131
+ f" {_C.DIM} {indent}{prefix2} {callee2}{_C.RESET}"
132
+ )
133
+ if len(indirect_callees) > 5:
134
+ indent = " " if is_last else "│ "
135
+ lines.append(
136
+ f" {_C.DIM} {indent}... +{len(indirect_callees) - 5} more{_C.RESET}"
137
+ )
138
+
139
+ lines.append("")
140
+
141
+ # ---- FULL TRANSITIVE BLAST RADIUS ----
142
+ all_affected = _get_transitive_callers(reverse, sym_id, max_depth)
143
+ lines.append(f" {_C.BOLD}{_C.CYAN}◉ FULL BLAST RADIUS{_C.RESET}")
144
+ lines.append(
145
+ f" {_C.CYAN} {len(all_affected)} symbols transitively affected{_C.RESET}"
146
+ )
147
+
148
+ # Group by file
149
+ by_file: Dict[str, List[str]] = {}
150
+ for affected_sym in all_affected:
151
+ parts = affected_sym.split(":", 1)
152
+ filepath = parts[0] if len(parts) == 2 else "unknown"
153
+ by_file.setdefault(filepath, []).append(affected_sym)
154
+
155
+ for filepath, syms in sorted(by_file.items()):
156
+ lines.append(f" {_C.BLUE} 📄 {filepath} ({len(syms)} symbols){_C.RESET}")
157
+ for sym in sorted(syms)[:8]:
158
+ name = sym.split(":", 1)[1] if ":" in sym else sym
159
+ lines.append(f" {_C.DIM} · {name}{_C.RESET}")
160
+ if len(syms) > 8:
161
+ lines.append(f" {_C.DIM} ... +{len(syms) - 8} more{_C.RESET}")
162
+
163
+ # ---- SUMMARY ----
164
+ lines.append("")
165
+ lines.append(f"{_C.BOLD}{_C.WHITE}{'─' * 70}{_C.RESET}")
166
+
167
+ total_blast = set()
168
+ for sym_id in changed_symbols:
169
+ total_blast.update(_get_transitive_callers(reverse, sym_id, max_depth))
170
+
171
+ total_deps = set()
172
+ for sym_id in changed_symbols:
173
+ total_deps.update(_get_transitive_callees(graph, sym_id, max_depth))
174
+
175
+ blast_files = set()
176
+ for sym in total_blast:
177
+ parts = sym.split(":", 1)
178
+ if len(parts) == 2:
179
+ blast_files.add(parts[0])
180
+
181
+ lines.append(f" {_C.BOLD}Summary:{_C.RESET}")
182
+ lines.append(f" Changed symbols : {_C.RED}{len(changed_symbols)}{_C.RESET}")
183
+ lines.append(f" Direct callers : {_C.YELLOW}{sum(len(reverse.get(s, set())) for s in changed_symbols)}{_C.RESET}")
184
+ lines.append(f" Direct dependencies: {_C.GREEN}{sum(len(graph.get(s, [])) for s in changed_symbols)}{_C.RESET}")
185
+ lines.append(f" Total blast radius : {_C.CYAN}{len(total_blast)} symbols across {len(blast_files)} files{_C.RESET}")
186
+ lines.append(f" Total dependencies : {_C.GREEN}{len(total_deps)} symbols{_C.RESET}")
187
+ lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
188
+ lines.append("")
189
+
190
+ return "\n".join(lines)
191
+
192
+
193
+ def render_verification(
194
+ graph: Dict[str, List[str]],
195
+ changed_symbols: List[str],
196
+ symbols: dict,
197
+ ) -> str:
198
+ """
199
+ Render verification proof: for each edge in the blast radius,
200
+ show the actual code line that creates the dependency.
201
+ """
202
+ reverse: Dict[str, Set[str]] = {}
203
+ for caller, callees in graph.items():
204
+ for callee in callees:
205
+ reverse.setdefault(callee, set()).add(caller)
206
+
207
+ lines: List[str] = []
208
+ lines.append("")
209
+ lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
210
+ lines.append(f"{_C.BOLD}{_C.WHITE} VERIFICATION: Proof of each connection{_C.RESET}")
211
+ lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
212
+
213
+ for sym_id in changed_symbols:
214
+ lines.append("")
215
+ lines.append(f" {_C.BOLD}{_C.RED}⚡ {sym_id}{_C.RESET}")
216
+ lines.append("")
217
+
218
+ # Verify each caller
219
+ callers = sorted(reverse.get(sym_id, set()))
220
+ if callers:
221
+ lines.append(f" {_C.BOLD}{_C.YELLOW} Callers (these functions call the changed code):{_C.RESET}")
222
+ for caller in callers:
223
+ lines.append(f" {_C.YELLOW} → {caller}{_C.RESET}")
224
+ proof = _find_proof(caller, sym_id, symbols, graph)
225
+ if proof:
226
+ lines.append(f" {_C.DIM} evidence: {proof}{_C.RESET}")
227
+ else:
228
+ lines.append(f" {_C.DIM} evidence: (edge exists in call graph){_C.RESET}")
229
+ lines.append("")
230
+
231
+ # Verify each callee
232
+ callees = sorted(graph.get(sym_id, []))
233
+ if callees:
234
+ lines.append(f" {_C.BOLD}{_C.GREEN} Callees (the changed code calls these):{_C.RESET}")
235
+ for callee in callees:
236
+ lines.append(f" {_C.GREEN} → {callee}{_C.RESET}")
237
+ proof = _find_proof(sym_id, callee, symbols, graph)
238
+ if proof:
239
+ lines.append(f" {_C.DIM} evidence: {proof}{_C.RESET}")
240
+ else:
241
+ lines.append(f" {_C.DIM} evidence: (edge exists in call graph){_C.RESET}")
242
+
243
+ lines.append("")
244
+ lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
245
+ lines.append("")
246
+ return "\n".join(lines)
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Internal helpers
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def _get_transitive_callers(
255
+ reverse: Dict[str, Set[str]],
256
+ start: str,
257
+ max_depth: int,
258
+ ) -> List[str]:
259
+ """BFS up the reverse graph to find all transitively affected symbols."""
260
+ visited: Set[str] = {start}
261
+ result: List[str] = []
262
+ frontier = [start]
263
+ depth = 0
264
+
265
+ while frontier and depth < max_depth:
266
+ next_frontier = []
267
+ for node in frontier:
268
+ for caller in reverse.get(node, set()):
269
+ if caller not in visited:
270
+ visited.add(caller)
271
+ result.append(caller)
272
+ next_frontier.append(caller)
273
+ frontier = next_frontier
274
+ depth += 1
275
+
276
+ return result
277
+
278
+
279
+ def _get_transitive_callees(
280
+ graph: Dict[str, List[str]],
281
+ start: str,
282
+ max_depth: int,
283
+ ) -> List[str]:
284
+ """BFS down the forward graph to find all dependencies."""
285
+ visited: Set[str] = {start}
286
+ result: List[str] = []
287
+ frontier = [start]
288
+ depth = 0
289
+
290
+ while frontier and depth < max_depth:
291
+ next_frontier = []
292
+ for node in frontier:
293
+ for callee in graph.get(node, []):
294
+ if callee not in visited:
295
+ visited.add(callee)
296
+ result.append(callee)
297
+ next_frontier.append(callee)
298
+ frontier = next_frontier
299
+ depth += 1
300
+
301
+ return result
302
+
303
+
304
+ def _find_proof(
305
+ caller_id: str,
306
+ callee_id: str,
307
+ symbols: dict,
308
+ graph: Dict[str, List[str]],
309
+ ) -> Optional[str]:
310
+ """
311
+ Find the actual code line in `caller` that references `callee`.
312
+
313
+ This is the "proof" that the edge is real — a grep through the caller's
314
+ source code for the callee's function name.
315
+ """
316
+ if caller_id not in symbols:
317
+ return None
318
+
319
+ caller_sym = symbols[caller_id]
320
+ callee_name = callee_id.split(":")[-1] if ":" in callee_id else callee_id
321
+
322
+ # Strip class prefix for method calls: "ClassName.method" -> "method"
323
+ if "." in callee_name:
324
+ bare_name = callee_name.split(".")[-1]
325
+ else:
326
+ bare_name = callee_name
327
+
328
+ # Search caller's code for the call
329
+ for i, line in enumerate(caller_sym.code.splitlines(), start=1):
330
+ stripped = line.strip()
331
+ # Look for function/method call pattern
332
+ if bare_name + "(" in stripped or f".{bare_name}(" in stripped:
333
+ # Truncate long lines
334
+ if len(stripped) > 80:
335
+ stripped = stripped[:77] + "..."
336
+ return f"line {caller_sym.lineno + i - 1}: {stripped}"
337
+
338
+ return None
@@ -0,0 +1,80 @@
1
+ """
2
+ languages/ — Optional per-language adapters beyond the built-in Python
3
+ support.
4
+
5
+ The core pipeline (scoring, selection, compilation, caching, diff mapping)
6
+ is language-agnostic: it operates on `Symbol` records and a symbol-id
7
+ graph. What a language needs to supply is exactly what parser.py,
8
+ resolver.py, and graph_builder.py supply for Python:
9
+
10
+ 1. symbols per file (id "./rel/path.ext:Name", code, lineno)
11
+ 2. a dependency graph over those symbol ids
12
+
13
+ An adapter provides both. Adapters have runtime dependencies (tree-sitter
14
+ grammars) that the core deliberately does not: they are OPTIONAL extras,
15
+ probed at import time — without them installed, DiffContext behaves
16
+ exactly as the Python-only tool it was, no warnings, no degradation of
17
+ the Python path.
18
+
19
+ Honesty contract: adapter-produced graphs are shallower than the Python
20
+ graph (no attribute-type tracking, no cross-file MRO). Retrieval quality
21
+ for adapter languages is NOT covered by the benchmark numbers in the
22
+ README until measured separately — see the language support table there.
23
+ """
24
+
25
+ import logging
26
+ from typing import List, Optional, Tuple
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _adapters: "Optional[List]" = None
31
+
32
+
33
+ def available_adapters() -> "List":
34
+ """
35
+ Adapters whose runtime dependencies are importable, probed once per
36
+ process. An adapter that fails to import (missing extra, grammar ABI
37
+ mismatch) is skipped silently at INFO level — the Python path must
38
+ never degrade because an optional extra is absent or broken.
39
+ """
40
+ global _adapters
41
+ if _adapters is None:
42
+ _adapters = []
43
+ try:
44
+ from .typescript import TypeScriptAdapter
45
+ _adapters.append(TypeScriptAdapter())
46
+ except Exception as e: # ImportError, or grammar version mismatch
47
+ logger.info("TypeScript adapter unavailable: %s", e)
48
+ return _adapters
49
+
50
+
51
+ def discover_files(adapter, repo_path: str) -> "List[str]":
52
+ """All files this adapter should index in repo_path: extension match
53
+ (gitignore-aware) filtered through the adapter's own indexing policy
54
+ (vendored/minified/test-file exclusions)."""
55
+ from ..scanner import find_source_files
56
+ return [
57
+ f for f in find_source_files(repo_path, tuple(adapter.extensions))
58
+ if adapter.should_index(f)
59
+ ]
60
+
61
+
62
+ def adapter_for_path(path: str):
63
+ """The adapter that handles this file's extension, or None."""
64
+ for adapter in available_adapters():
65
+ if path.endswith(adapter.extensions):
66
+ return adapter
67
+ return None
68
+
69
+
70
+ def indexable_extensions() -> "Tuple[str, ...]":
71
+ """
72
+ Every file extension the current environment can index: Python always,
73
+ plus each available adapter's extensions. Used by file discovery and
74
+ git-diff filtering so a changed file is only ever reported when its
75
+ symbols can actually exist in the index.
76
+ """
77
+ exts: "Tuple[str, ...]" = (".py",)
78
+ for adapter in available_adapters():
79
+ exts = exts + tuple(adapter.extensions)
80
+ return exts