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,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."""