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,655 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli/main.py — DiffContext command-line interface.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
diffcontext index .
|
|
6
|
+
diffcontext impact auth.py:validate_jwt
|
|
7
|
+
diffcontext diff HEAD~1
|
|
8
|
+
diffcontext compile --changed ./api.py:get_user
|
|
9
|
+
diffcontext blast --ref HEAD~1 # visual blast radius
|
|
10
|
+
diffcontext blast --ref HEAD~1 --verify # with proof chains
|
|
11
|
+
diffcontext verify --ref HEAD~1 # sufficiency report
|
|
12
|
+
diffcontext verify --cases cases.json # user test cases
|
|
13
|
+
diffcontext verify --from-history 30 --calibrate # score vs measured recall
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from ..pipeline import index_repository, analyze_impact, compile, warn_unknown_symbols
|
|
24
|
+
from ..diff.git_diff import find_changed_symbols
|
|
25
|
+
from ..impact.visualizer import render_blast_radius, render_verification
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
# Make sure warnings from anywhere in the pipeline (broken files,
|
|
30
|
+
# invalid encoding, unknown --changed symbols) are actually visible.
|
|
31
|
+
# Without this, they depend on logging's lastResort fallback, which is
|
|
32
|
+
# unreliable -- some warnings showed up by accident, others silently
|
|
33
|
+
# didn't, depending on subtle propagation/level details.
|
|
34
|
+
logging.basicConfig(level=logging.WARNING, format="%(message)s", stream=sys.stderr)
|
|
35
|
+
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="diffcontext",
|
|
38
|
+
description="Static-analysis-powered repository context compiler for LLMs",
|
|
39
|
+
)
|
|
40
|
+
sub = parser.add_subparsers(dest="command", help="Available commands")
|
|
41
|
+
|
|
42
|
+
# --- index ---
|
|
43
|
+
p_index = sub.add_parser("index", help="Index a repository")
|
|
44
|
+
p_index.add_argument("repo", default=".", nargs="?", help="Path to repository")
|
|
45
|
+
p_index.add_argument(
|
|
46
|
+
"--include", nargs="*", default=[], metavar="DIR",
|
|
47
|
+
help="Directory names to index despite the default exclusions "
|
|
48
|
+
"(tests/, benchmarks/, docs/, ...). Useful when a change "
|
|
49
|
+
"spans an excluded dir; .gitignore still applies.",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# --- impact ---
|
|
53
|
+
p_impact = sub.add_parser("impact", help="Analyze impact of a symbol change")
|
|
54
|
+
p_impact.add_argument("symbols", nargs="+", help="Changed symbol IDs (e.g. ./auth.py:validate_jwt)")
|
|
55
|
+
p_impact.add_argument("--repo", default=".", help="Repository path")
|
|
56
|
+
p_impact.add_argument(
|
|
57
|
+
"--include", nargs="*", default=[], metavar="DIR",
|
|
58
|
+
help="Directory names to index despite default exclusions (tests/, "
|
|
59
|
+
"benchmarks/, docs/, ...). .gitignore still applies.",
|
|
60
|
+
)
|
|
61
|
+
p_impact.add_argument("--depth", type=int, default=2, help="Max dependency depth")
|
|
62
|
+
p_impact.add_argument("--tree", action="store_true", help="Show visual blast radius tree")
|
|
63
|
+
p_impact.add_argument("--verify", action="store_true", help="Show proof chains for each edge")
|
|
64
|
+
|
|
65
|
+
# --- diff ---
|
|
66
|
+
p_diff = sub.add_parser("diff", help="Find changed symbols from git diff")
|
|
67
|
+
p_diff.add_argument("ref", default="HEAD~1", nargs="?", help="Git ref to compare against")
|
|
68
|
+
p_diff.add_argument("--repo", default=".", help="Repository path")
|
|
69
|
+
p_diff.add_argument(
|
|
70
|
+
"--include", nargs="*", default=[], metavar="DIR",
|
|
71
|
+
help="Directory names to index despite default exclusions (tests/, "
|
|
72
|
+
"benchmarks/, docs/, ...). .gitignore still applies.",
|
|
73
|
+
)
|
|
74
|
+
p_diff.add_argument(
|
|
75
|
+
"--committed-only", action="store_true",
|
|
76
|
+
help="Compare two commits only (ref vs HEAD); ignores uncommitted working-tree changes",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# --- compile ---
|
|
80
|
+
p_compile = sub.add_parser("compile", help="Build LLM context for changes")
|
|
81
|
+
p_compile.add_argument("--changed", nargs="+", help="Changed symbol IDs")
|
|
82
|
+
p_compile.add_argument("--ref", default=None, help="Git ref (auto-detect changes)")
|
|
83
|
+
p_compile.add_argument("--repo", default=".", help="Repository path")
|
|
84
|
+
p_compile.add_argument(
|
|
85
|
+
"--include", nargs="*", default=[], metavar="DIR",
|
|
86
|
+
help="Directory names to index despite default exclusions (tests/, "
|
|
87
|
+
"benchmarks/, docs/, ...). When a --ref change spans an excluded "
|
|
88
|
+
"dir, re-run with --include <dir> so its symbols are resolved. "
|
|
89
|
+
".gitignore still applies.",
|
|
90
|
+
)
|
|
91
|
+
p_compile.add_argument("--depth", type=int, default=2, help="Max dependency depth")
|
|
92
|
+
p_compile.add_argument("--max-tokens", type=int, default=10000, help="Token budget")
|
|
93
|
+
p_compile.add_argument(
|
|
94
|
+
"--top-k", type=int, default=20,
|
|
95
|
+
help="Max context symbols per changed symbol (benchmarked sweet spot: 20; 0 = unlimited)",
|
|
96
|
+
)
|
|
97
|
+
p_compile.add_argument(
|
|
98
|
+
"--graph-only", action="store_true",
|
|
99
|
+
help="Disable the hybrid (graph+BM25+same-file) blend and rank by call graph alone",
|
|
100
|
+
)
|
|
101
|
+
p_compile.add_argument(
|
|
102
|
+
"--cutoff", choices=["topk", "gap"], default="topk",
|
|
103
|
+
help=(
|
|
104
|
+
"Selection policy. 'gap' cuts at the largest relative score drop "
|
|
105
|
+
"— measured ~4x the precision of top-20 at 6-9 symbols, costing "
|
|
106
|
+
"~30%% relative recall (benchmarks/RIGOR_REPORT_2026-07.md #7). "
|
|
107
|
+
"Use for token-priced callers; default 'topk' stays recall-first."
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
p_compile.add_argument(
|
|
111
|
+
"--with-history", action="store_true",
|
|
112
|
+
help="Blend git co-change history as a fourth signal (mines git log once; "
|
|
113
|
+
"reaches related files with no call or lexical connection)",
|
|
114
|
+
)
|
|
115
|
+
p_compile.add_argument("--notes", type=str, default=None, help="Developer notes to prepend to the context output")
|
|
116
|
+
p_compile.add_argument("--json", action="store_true", help="Output as JSON")
|
|
117
|
+
|
|
118
|
+
# --- blast (NEW: visual blast radius) ---
|
|
119
|
+
p_blast = sub.add_parser("blast", help="Visual blast radius analysis")
|
|
120
|
+
p_blast.add_argument("--changed", nargs="+", help="Changed symbol IDs (manual)")
|
|
121
|
+
p_blast.add_argument("--ref", default=None, help="Git ref (auto-detect changes)")
|
|
122
|
+
p_blast.add_argument("--repo", default=".", help="Repository path")
|
|
123
|
+
p_blast.add_argument(
|
|
124
|
+
"--include", nargs="*", default=[], metavar="DIR",
|
|
125
|
+
help="Directory names to index despite default exclusions (tests/, "
|
|
126
|
+
"benchmarks/, docs/, ...). .gitignore still applies.",
|
|
127
|
+
)
|
|
128
|
+
p_blast.add_argument("--depth", type=int, default=3, help="Max traversal depth for tree")
|
|
129
|
+
p_blast.add_argument("--verify", action="store_true", help="Show proof chains for each edge")
|
|
130
|
+
p_blast.add_argument("--no-color", action="store_true", help="Disable ANSI colors")
|
|
131
|
+
p_blast.add_argument(
|
|
132
|
+
"--committed-only", action="store_true",
|
|
133
|
+
help="Compare two commits only (ref vs HEAD); ignores uncommitted working-tree changes",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# --- verify (sufficiency + test cases + calibration) ---
|
|
137
|
+
p_verify = sub.add_parser(
|
|
138
|
+
"verify",
|
|
139
|
+
help="Score context sufficiency; run user test cases; calibrate the score",
|
|
140
|
+
)
|
|
141
|
+
p_verify.add_argument("--changed", nargs="+", help="Changed symbol IDs")
|
|
142
|
+
p_verify.add_argument("--ref", default=None, help="Git ref (auto-detect changes)")
|
|
143
|
+
p_verify.add_argument("--repo", default=".", help="Repository path")
|
|
144
|
+
p_verify.add_argument(
|
|
145
|
+
"--include", nargs="*", default=[], metavar="DIR",
|
|
146
|
+
help="Directory names to index despite default exclusions (tests/, "
|
|
147
|
+
"benchmarks/, docs/, ...). .gitignore still applies.",
|
|
148
|
+
)
|
|
149
|
+
p_verify.add_argument("--depth", type=int, default=2, help="Max dependency depth")
|
|
150
|
+
p_verify.add_argument("--max-tokens", type=int, default=10000, help="Token budget (0 = unlimited)")
|
|
151
|
+
p_verify.add_argument(
|
|
152
|
+
"--top-k", type=int, default=20,
|
|
153
|
+
help="Max context symbols per changed symbol (0 = unlimited)",
|
|
154
|
+
)
|
|
155
|
+
p_verify.add_argument(
|
|
156
|
+
"--cutoff", choices=["topk", "gap"], default="topk",
|
|
157
|
+
help=(
|
|
158
|
+
"Selection policy to verify under (see `compile --cutoff`). Run "
|
|
159
|
+
"--from-history once with each to measure the recall/precision "
|
|
160
|
+
"tradeoff on YOUR repo before adopting 'gap'."
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
p_verify.add_argument(
|
|
164
|
+
"--cases", default=None, metavar="FILE",
|
|
165
|
+
help="Run test cases from a JSON/YAML file (see docs/VERIFY.md for format)",
|
|
166
|
+
)
|
|
167
|
+
p_verify.add_argument(
|
|
168
|
+
"--from-history", type=int, default=None, metavar="N",
|
|
169
|
+
help="Auto-generate up to N test cases from git co-change history",
|
|
170
|
+
)
|
|
171
|
+
p_verify.add_argument(
|
|
172
|
+
"--out", default=None, metavar="FILE",
|
|
173
|
+
help="With --from-history: write generated cases to FILE instead of running them",
|
|
174
|
+
)
|
|
175
|
+
p_verify.add_argument(
|
|
176
|
+
"--save-calibration", action="store_true",
|
|
177
|
+
help=(
|
|
178
|
+
"With --calibrate: persist the score buckets and fitted recall "
|
|
179
|
+
"predictor to .diffcontext-calibration.json so later `verify` "
|
|
180
|
+
"runs report calibrated confidence instead of a bare score"
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
p_verify.add_argument(
|
|
184
|
+
"--calibrate", action="store_true",
|
|
185
|
+
help="With --cases/--from-history: report how the structural score tracks measured recall",
|
|
186
|
+
)
|
|
187
|
+
p_verify.add_argument("--json", action="store_true", help="Output as JSON")
|
|
188
|
+
|
|
189
|
+
args = parser.parse_args()
|
|
190
|
+
|
|
191
|
+
if args.command is None:
|
|
192
|
+
parser.print_help()
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
if args.command == "index":
|
|
196
|
+
_cmd_index(args)
|
|
197
|
+
elif args.command == "impact":
|
|
198
|
+
_cmd_impact(args)
|
|
199
|
+
elif args.command == "diff":
|
|
200
|
+
_cmd_diff(args)
|
|
201
|
+
elif args.command == "compile":
|
|
202
|
+
_cmd_compile(args)
|
|
203
|
+
elif args.command == "blast":
|
|
204
|
+
_cmd_blast(args)
|
|
205
|
+
elif args.command == "verify":
|
|
206
|
+
_cmd_verify(args)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _print_index_scope(repo, indexed_files, include):
|
|
210
|
+
"""One-line index-scope summary: which top-level dirs were indexed and
|
|
211
|
+
which were skipped by default (EXCLUDED_DIRS, minus any --include).
|
|
212
|
+
|
|
213
|
+
Printed after `diffcontext index` so the scoping that drives later
|
|
214
|
+
"was not found in the index" warnings is visible up front, not buried
|
|
215
|
+
in 14 warnings on the next command. Only source-like exclusions are
|
|
216
|
+
reported — tests/, benchmarks/, docs/ are where a real commit spans an
|
|
217
|
+
excluded dir and produces the warning; .git/, venv/, .pytest_cache/
|
|
218
|
+
never contain changed source and would be noise here. Stays quiet when
|
|
219
|
+
nothing source-like was skipped (a flat repo, or all --include'd)."""
|
|
220
|
+
# The subset of EXCLUDED_DIRS that commonly hold the user's own source
|
|
221
|
+
# and are the ones a commit can span — the rest are caches/venvs where
|
|
222
|
+
# "not found in the index" never fires for a real change.
|
|
223
|
+
SOURCE_LIKE_EXCLUDED = {
|
|
224
|
+
"tests", "test", "benchmarks", "docs", "examples",
|
|
225
|
+
"experimental", "datasets",
|
|
226
|
+
}
|
|
227
|
+
repo_abs = os.path.abspath(repo)
|
|
228
|
+
if not os.path.isdir(repo_abs):
|
|
229
|
+
return
|
|
230
|
+
indexed_dirs = set()
|
|
231
|
+
for f in indexed_files:
|
|
232
|
+
try:
|
|
233
|
+
rel = os.path.relpath(f, repo_abs)
|
|
234
|
+
except ValueError:
|
|
235
|
+
continue
|
|
236
|
+
parts = rel.replace(os.sep, "/").split("/")
|
|
237
|
+
if len(parts) > 1 and parts[0] not in (".", ".."):
|
|
238
|
+
indexed_dirs.add(parts[0])
|
|
239
|
+
skipped = sorted(
|
|
240
|
+
name for name in os.listdir(repo_abs)
|
|
241
|
+
if os.path.isdir(os.path.join(repo_abs, name))
|
|
242
|
+
and name in SOURCE_LIKE_EXCLUDED and name not in include
|
|
243
|
+
)
|
|
244
|
+
if skipped:
|
|
245
|
+
print(f"Scope : {len(indexed_files)} files in "
|
|
246
|
+
f"{', '.join(sorted(indexed_dirs)) or '(root)'}; "
|
|
247
|
+
f"skipped by default: {', '.join(skipped)} "
|
|
248
|
+
f"(re-run with --include <dir> to index them)")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _cmd_index(args):
|
|
252
|
+
"""Index repository: show stats."""
|
|
253
|
+
t0 = time.perf_counter()
|
|
254
|
+
include = set(args.include or [])
|
|
255
|
+
idx = index_repository(args.repo, include=include)
|
|
256
|
+
elapsed = (time.perf_counter() - t0) * 1000
|
|
257
|
+
|
|
258
|
+
print(f"Symbols : {len(idx.symbols)}")
|
|
259
|
+
print(f"Edges : {idx.total_edges}")
|
|
260
|
+
print(f"Time : {elapsed:.0f}ms")
|
|
261
|
+
|
|
262
|
+
# Show top-level breakdown
|
|
263
|
+
files = set()
|
|
264
|
+
for sym in idx.symbols.values():
|
|
265
|
+
files.add(sym.file)
|
|
266
|
+
print(f"Files : {len(files)}")
|
|
267
|
+
|
|
268
|
+
if idx.broken_files:
|
|
269
|
+
print(f"Broken : {len(idx.broken_files)} file(s) failed to parse (see warnings above)")
|
|
270
|
+
|
|
271
|
+
# Index-scope summary: which top-level dirs were indexed and which were
|
|
272
|
+
# skipped by default. The scoping is the single biggest practical gotcha
|
|
273
|
+
# (a commit spanning benchmarks/ produces 14 "not found in the index"
|
|
274
|
+
# warnings and omits the changed file) — surfacing it here, right after
|
|
275
|
+
# indexing, tells the user *why* a later --ref compile may miss symbols
|
|
276
|
+
# before they see the wall of warnings.
|
|
277
|
+
_print_index_scope(args.repo, files, include)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _cmd_impact(args):
|
|
281
|
+
"""Analyze impact of specific symbol changes."""
|
|
282
|
+
idx = index_repository(args.repo, include=set(args.include or []))
|
|
283
|
+
impact = analyze_impact(idx, args.symbols, max_depth=args.depth)
|
|
284
|
+
|
|
285
|
+
if getattr(args, 'tree', False) or getattr(args, 'verify', False):
|
|
286
|
+
# Visual tree mode
|
|
287
|
+
output = render_blast_radius(
|
|
288
|
+
idx.graph, args.symbols, idx.symbols,
|
|
289
|
+
max_depth=args.depth,
|
|
290
|
+
show_proof=getattr(args, 'verify', False),
|
|
291
|
+
repo_path=os.path.abspath(args.repo),
|
|
292
|
+
)
|
|
293
|
+
print(output)
|
|
294
|
+
|
|
295
|
+
if getattr(args, 'verify', False):
|
|
296
|
+
verification = render_verification(
|
|
297
|
+
idx.graph, args.symbols, idx.symbols,
|
|
298
|
+
)
|
|
299
|
+
print(verification)
|
|
300
|
+
else:
|
|
301
|
+
# Original text mode
|
|
302
|
+
print(f"\nChanged: {impact.changed}")
|
|
303
|
+
print(f"\nBlast radius ({len(impact.blast_radius)}):")
|
|
304
|
+
for sym in impact.blast_radius[:20]:
|
|
305
|
+
score = impact.scores.get(sym, 0)
|
|
306
|
+
print(f" {sym} (score: {score:.0f})")
|
|
307
|
+
|
|
308
|
+
print(f"\nTotal impacted: {len(impact.all_relevant)}")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _print_broken_files(idx, broken_patches):
|
|
312
|
+
"""Shared helper: print patch text for any files that failed to parse."""
|
|
313
|
+
if not idx.broken_files:
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
print(f"\n⚠ {len(idx.broken_files)} file(s) failed to parse and could not be fully analyzed:")
|
|
317
|
+
for f in idx.broken_files:
|
|
318
|
+
print(f"\n--- {f} ---")
|
|
319
|
+
patch = broken_patches.get(f)
|
|
320
|
+
if patch:
|
|
321
|
+
print(patch.rstrip("\n"))
|
|
322
|
+
else:
|
|
323
|
+
print(" (no patch text available -- file may be new/untracked)")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _cmd_diff(args):
|
|
327
|
+
"""Find changed symbols from git diff."""
|
|
328
|
+
idx = index_repository(args.repo, include=set(args.include or []))
|
|
329
|
+
|
|
330
|
+
against = "HEAD" if args.committed_only else None
|
|
331
|
+
broken_patches = {}
|
|
332
|
+
changed = find_changed_symbols(
|
|
333
|
+
args.repo, idx.symbols, ref=args.ref, against=against,
|
|
334
|
+
broken_files=idx.broken_files,
|
|
335
|
+
broken_file_patches=broken_patches,
|
|
336
|
+
known_broken_files=idx.broken_files,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if not changed:
|
|
340
|
+
print("No changed symbols found.")
|
|
341
|
+
_print_broken_files(idx, broken_patches)
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
print(f"Changed symbols ({len(changed)}):")
|
|
345
|
+
for sym_id in changed:
|
|
346
|
+
print(f" {sym_id}")
|
|
347
|
+
|
|
348
|
+
_print_broken_files(idx, broken_patches)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _cmd_compile(args):
|
|
352
|
+
"""Build full context package."""
|
|
353
|
+
idx = index_repository(args.repo, include=set(args.include or []))
|
|
354
|
+
|
|
355
|
+
# Determine changed symbols
|
|
356
|
+
if args.changed:
|
|
357
|
+
changed = args.changed
|
|
358
|
+
elif args.ref:
|
|
359
|
+
changed = find_changed_symbols(
|
|
360
|
+
args.repo, idx.symbols, ref=args.ref,
|
|
361
|
+
broken_files=idx.broken_files,
|
|
362
|
+
known_broken_files=idx.broken_files,
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
print("Error: provide --changed or --ref", file=sys.stderr)
|
|
366
|
+
sys.exit(1)
|
|
367
|
+
|
|
368
|
+
if not changed:
|
|
369
|
+
print("No changes detected.")
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
history = None
|
|
373
|
+
if getattr(args, "with_history", False):
|
|
374
|
+
from ..history import CoChangeIndex
|
|
375
|
+
history = CoChangeIndex(args.repo)
|
|
376
|
+
|
|
377
|
+
impact = analyze_impact(
|
|
378
|
+
idx, changed, max_depth=args.depth, hybrid=not args.graph_only,
|
|
379
|
+
history=history,
|
|
380
|
+
)
|
|
381
|
+
max_tokens = args.max_tokens if args.max_tokens > 0 else None
|
|
382
|
+
top_k = args.top_k * len(changed) if args.top_k > 0 else None
|
|
383
|
+
cutoff = args.cutoff if args.cutoff != "topk" else None
|
|
384
|
+
ctx = compile(idx, impact, max_tokens=max_tokens, notes=args.notes,
|
|
385
|
+
top_k=top_k, cutoff=cutoff)
|
|
386
|
+
|
|
387
|
+
if args.json:
|
|
388
|
+
# Existing keys are kept for backwards compatibility. Added for
|
|
389
|
+
# agent integration (USAGE.md advertises --json as "machine-readable,
|
|
390
|
+
# for scripts and agents"): `included_symbols` and `dropped_symbols`
|
|
391
|
+
# expose WHICH symbols were selected / cut, with scores and tokens,
|
|
392
|
+
# so a calling agent can inspect or filter the selection instead of
|
|
393
|
+
# having to parse the rendered `context` text.
|
|
394
|
+
included_symbols = [
|
|
395
|
+
{
|
|
396
|
+
"id": item.symbol_id,
|
|
397
|
+
"role": item.role,
|
|
398
|
+
"score": round(item.score, 2),
|
|
399
|
+
"tokens": item.token_estimate,
|
|
400
|
+
}
|
|
401
|
+
for item in ctx.items
|
|
402
|
+
]
|
|
403
|
+
dropped_symbols = [
|
|
404
|
+
{
|
|
405
|
+
"id": sid,
|
|
406
|
+
"score": round(impact.scores.get(sid, 0.0), 2),
|
|
407
|
+
}
|
|
408
|
+
for sid in ctx.dropped_symbols
|
|
409
|
+
]
|
|
410
|
+
result = {
|
|
411
|
+
"symbol_count": ctx.symbol_count,
|
|
412
|
+
"token_estimate": ctx.token_estimate,
|
|
413
|
+
"total_repo_tokens": ctx.total_repo_tokens,
|
|
414
|
+
"reduction_pct": round(ctx.reduction_pct, 2),
|
|
415
|
+
"context": ctx.text,
|
|
416
|
+
"included_symbols": included_symbols,
|
|
417
|
+
"dropped_symbols": dropped_symbols,
|
|
418
|
+
}
|
|
419
|
+
print(json.dumps(result, indent=2))
|
|
420
|
+
else:
|
|
421
|
+
print(ctx.text)
|
|
422
|
+
print("\n--- Stats ---")
|
|
423
|
+
print(f"Symbols : {ctx.symbol_count}")
|
|
424
|
+
print(f"Tokens : {ctx.token_estimate:,} / {ctx.total_repo_tokens:,}")
|
|
425
|
+
print(f"Reduction: {ctx.reduction_pct:.1f}%")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _cmd_blast(args):
|
|
429
|
+
"""Visual blast radius analysis."""
|
|
430
|
+
t0 = time.perf_counter()
|
|
431
|
+
idx = index_repository(args.repo, include=set(args.include or []))
|
|
432
|
+
index_ms = (time.perf_counter() - t0) * 1000
|
|
433
|
+
|
|
434
|
+
against = "HEAD" if getattr(args, "committed_only", False) else None
|
|
435
|
+
broken_patches = {}
|
|
436
|
+
|
|
437
|
+
# Determine changed symbols
|
|
438
|
+
if args.changed:
|
|
439
|
+
changed = args.changed
|
|
440
|
+
elif args.ref:
|
|
441
|
+
changed = find_changed_symbols(
|
|
442
|
+
args.repo, idx.symbols, ref=args.ref, against=against,
|
|
443
|
+
broken_files=idx.broken_files, broken_file_patches=broken_patches,
|
|
444
|
+
known_broken_files=idx.broken_files,
|
|
445
|
+
)
|
|
446
|
+
else:
|
|
447
|
+
# Default: compare against HEAD~1
|
|
448
|
+
changed = find_changed_symbols(
|
|
449
|
+
args.repo, idx.symbols, ref="HEAD~1", against=against,
|
|
450
|
+
broken_files=idx.broken_files, broken_file_patches=broken_patches,
|
|
451
|
+
known_broken_files=idx.broken_files,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
if not changed:
|
|
455
|
+
print("No changed symbols detected.")
|
|
456
|
+
print(" Tip: make a Python change and commit it, or use --changed <symbol_id>")
|
|
457
|
+
print(f" Available symbols: {len(idx.symbols)} (use 'diffcontext index' to see stats)")
|
|
458
|
+
_print_broken_files(idx, broken_patches)
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
# blast renders directly from idx.graph and never calls analyze_impact,
|
|
462
|
+
# so it needs its own unknown-symbol check (typo'd --changed, renamed/
|
|
463
|
+
# deleted symbol) -- otherwise a typo silently renders as "0 impact"
|
|
464
|
+
# indistinguishable from a real, genuinely-isolated symbol.
|
|
465
|
+
warn_unknown_symbols(idx, changed)
|
|
466
|
+
|
|
467
|
+
# Strip ANSI if --no-color
|
|
468
|
+
if args.no_color:
|
|
469
|
+
from ..impact import visualizer
|
|
470
|
+
visualizer._C.RED = ""
|
|
471
|
+
visualizer._C.YELLOW = ""
|
|
472
|
+
visualizer._C.GREEN = ""
|
|
473
|
+
visualizer._C.CYAN = ""
|
|
474
|
+
visualizer._C.MAGENTA = ""
|
|
475
|
+
visualizer._C.BLUE = ""
|
|
476
|
+
visualizer._C.DIM = ""
|
|
477
|
+
visualizer._C.BOLD = ""
|
|
478
|
+
visualizer._C.RESET = ""
|
|
479
|
+
visualizer._C.WHITE = ""
|
|
480
|
+
|
|
481
|
+
# Render visual blast radius
|
|
482
|
+
output = render_blast_radius(
|
|
483
|
+
idx.graph, changed, idx.symbols,
|
|
484
|
+
max_depth=args.depth,
|
|
485
|
+
show_proof=args.verify,
|
|
486
|
+
repo_path=os.path.abspath(args.repo),
|
|
487
|
+
)
|
|
488
|
+
print(output)
|
|
489
|
+
|
|
490
|
+
# If --verify, also show detailed proof chains
|
|
491
|
+
if args.verify:
|
|
492
|
+
verification = render_verification(
|
|
493
|
+
idx.graph, changed, idx.symbols,
|
|
494
|
+
)
|
|
495
|
+
print(verification)
|
|
496
|
+
|
|
497
|
+
_print_broken_files(idx, broken_patches)
|
|
498
|
+
|
|
499
|
+
# Timing footer
|
|
500
|
+
total_ms = (time.perf_counter() - t0) * 1000
|
|
501
|
+
print(f" Indexed {len(idx.symbols)} symbols in {index_ms:.0f}ms")
|
|
502
|
+
print(f" Total analysis time: {total_ms:.0f}ms")
|
|
503
|
+
print()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _cmd_verify(args):
|
|
507
|
+
"""Sufficiency report, user test cases, and calibration."""
|
|
508
|
+
from ..verify import (
|
|
509
|
+
analyze_sufficiency, load_cases, save_cases, run_cases,
|
|
510
|
+
cases_from_history, calibrate, render_results, render_calibration,
|
|
511
|
+
CaseFormatError, CALIBRATION_FILENAME,
|
|
512
|
+
predict_recall, save_calibration, load_calibration,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# ── Mode 1/2: test cases (from file or from git history) ─────────────
|
|
516
|
+
if args.cases or args.from_history is not None:
|
|
517
|
+
if args.cases:
|
|
518
|
+
try:
|
|
519
|
+
cases = load_cases(args.cases)
|
|
520
|
+
except (CaseFormatError, OSError) as e:
|
|
521
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
522
|
+
sys.exit(1)
|
|
523
|
+
else:
|
|
524
|
+
skipped = []
|
|
525
|
+
cases = cases_from_history(
|
|
526
|
+
args.repo, max_cases=args.from_history, skipped_out=skipped,
|
|
527
|
+
)
|
|
528
|
+
if skipped:
|
|
529
|
+
print(
|
|
530
|
+
f"Skipped {len(skipped)} mechanical-refactor commit(s) "
|
|
531
|
+
f"(e.g. {skipped[0].commit_hash}: {skipped[0].reason}). "
|
|
532
|
+
"The published benchmark excludes these too — see "
|
|
533
|
+
"docs/BENCHMARKS.md."
|
|
534
|
+
)
|
|
535
|
+
if not cases:
|
|
536
|
+
print(
|
|
537
|
+
"No co-change cases found in git history. Need commits that "
|
|
538
|
+
"modify 2+ functions (non-test .py files).",
|
|
539
|
+
file=sys.stderr,
|
|
540
|
+
)
|
|
541
|
+
sys.exit(1)
|
|
542
|
+
if args.out:
|
|
543
|
+
save_cases(cases, args.out)
|
|
544
|
+
print(f"Wrote {len(cases)} case(s) to {args.out}")
|
|
545
|
+
print("Edit them (they're noisy — commits touch unrelated code too),")
|
|
546
|
+
print(f"then run: diffcontext verify --cases {args.out} --calibrate")
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
cutoff = args.cutoff if args.cutoff != "topk" else None
|
|
550
|
+
results = run_cases(args.repo, cases, cutoff=cutoff)
|
|
551
|
+
|
|
552
|
+
cal = calibrate(results) if args.calibrate else None
|
|
553
|
+
if cal is not None and args.save_calibration:
|
|
554
|
+
cal_path = os.path.join(os.path.abspath(args.repo), CALIBRATION_FILENAME)
|
|
555
|
+
save_calibration(cal, cal_path)
|
|
556
|
+
|
|
557
|
+
if args.json:
|
|
558
|
+
payload = {"results": [r.to_dict() for r in results]}
|
|
559
|
+
if cal is not None:
|
|
560
|
+
payload["calibration"] = cal.to_dict()
|
|
561
|
+
print(json.dumps(payload, indent=2))
|
|
562
|
+
else:
|
|
563
|
+
print(render_results(results))
|
|
564
|
+
if cal is not None:
|
|
565
|
+
print()
|
|
566
|
+
print(render_calibration(cal))
|
|
567
|
+
if args.save_calibration and cal.model is not None:
|
|
568
|
+
print(f"\nSaved calibration to {CALIBRATION_FILENAME} — "
|
|
569
|
+
f"`diffcontext verify` now reports calibrated confidence.")
|
|
570
|
+
elif args.save_calibration:
|
|
571
|
+
print(f"\nSaved score buckets to {CALIBRATION_FILENAME} "
|
|
572
|
+
f"(no recall predictor: not enough cases or a "
|
|
573
|
+
f"degenerate fit).")
|
|
574
|
+
|
|
575
|
+
sys.exit(0 if all(r.passed for r in results) else 1)
|
|
576
|
+
|
|
577
|
+
# ── Mode 3: single sufficiency report for a change ────────────────────
|
|
578
|
+
idx = index_repository(args.repo, include=set(args.include or []))
|
|
579
|
+
|
|
580
|
+
if args.changed:
|
|
581
|
+
changed = args.changed
|
|
582
|
+
elif args.ref:
|
|
583
|
+
changed = find_changed_symbols(
|
|
584
|
+
args.repo, idx.symbols, ref=args.ref,
|
|
585
|
+
broken_files=idx.broken_files,
|
|
586
|
+
known_broken_files=idx.broken_files,
|
|
587
|
+
)
|
|
588
|
+
else:
|
|
589
|
+
print("Error: provide --changed, --ref, --cases, or --from-history", file=sys.stderr)
|
|
590
|
+
sys.exit(1)
|
|
591
|
+
|
|
592
|
+
if not changed:
|
|
593
|
+
print("No changes detected.")
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
warn_unknown_symbols(idx, changed)
|
|
597
|
+
impact = analyze_impact(idx, changed, max_depth=args.depth)
|
|
598
|
+
max_tokens = args.max_tokens if args.max_tokens > 0 else None
|
|
599
|
+
top_k = args.top_k * len(changed) if args.top_k > 0 else None
|
|
600
|
+
cutoff = args.cutoff if args.cutoff != "topk" else None
|
|
601
|
+
ctx = compile(idx, impact, max_tokens=max_tokens, top_k=top_k, cutoff=cutoff)
|
|
602
|
+
|
|
603
|
+
report = analyze_sufficiency(idx, impact, ctx)
|
|
604
|
+
|
|
605
|
+
# Apply a saved per-repo calibration, if one exists: the structural
|
|
606
|
+
# score alone is a ranking signal, not a probability (measured in
|
|
607
|
+
# benchmarks/calibration_at_scale.py); the fitted mapping is what turns
|
|
608
|
+
# it into disclosed confidence.
|
|
609
|
+
cal_data = load_calibration(
|
|
610
|
+
os.path.join(os.path.abspath(args.repo), CALIBRATION_FILENAME))
|
|
611
|
+
predicted = None
|
|
612
|
+
if cal_data and cal_data.get("model"):
|
|
613
|
+
predicted = predict_recall(cal_data["model"], report,
|
|
614
|
+
len(ctx.items), ctx.token_estimate)
|
|
615
|
+
report.calibrated = True
|
|
616
|
+
|
|
617
|
+
if args.json:
|
|
618
|
+
payload = report.to_dict()
|
|
619
|
+
if predicted is not None:
|
|
620
|
+
payload["calibrated_recall_estimate"] = round(predicted, 3)
|
|
621
|
+
payload["calibration_n_cases"] = cal_data["model"]["n_cases"]
|
|
622
|
+
print(json.dumps(payload, indent=2))
|
|
623
|
+
else:
|
|
624
|
+
print(report.render())
|
|
625
|
+
if predicted is not None:
|
|
626
|
+
print(f"\nCalibrated recall estimate: {predicted * 100:.0f}% "
|
|
627
|
+
f"(fit on {cal_data['model']['n_cases']} of this repo's own "
|
|
628
|
+
f"history cases — see {CALIBRATION_FILENAME})")
|
|
629
|
+
|
|
630
|
+
# Exit code mirrors the verdict so CI can gate on it.
|
|
631
|
+
sys.exit(0 if report.verdict == "SUFFICIENT" else 1)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
if __name__ == "__main__":
|
|
635
|
+
main()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def cli_main():
|
|
639
|
+
"""
|
|
640
|
+
Entry point for the `diffcontext` console script.
|
|
641
|
+
|
|
642
|
+
Wraps main() to handle BrokenPipeError gracefully -- this happens
|
|
643
|
+
whenever stdout is piped into something that closes early (a missing
|
|
644
|
+
command, `head`, a reader that exits before reading everything). Without
|
|
645
|
+
this, piping `diffcontext compile | some-missing-tool` prints a full
|
|
646
|
+
Python traceback even though nothing is actually wrong.
|
|
647
|
+
"""
|
|
648
|
+
try:
|
|
649
|
+
sys.exit(main())
|
|
650
|
+
except BrokenPipeError:
|
|
651
|
+
# Redirect remaining stdout to devnull so the interpreter's own
|
|
652
|
+
# shutdown-time flush doesn't also raise BrokenPipeError.
|
|
653
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
654
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
655
|
+
sys.exit(1)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""context subpackage — selection, expansion, compilation."""
|