diffguard 0.1.3__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.
- diffguard/__init__.py +3 -0
- diffguard/cli.py +676 -0
- diffguard/engine/__init__.py +1 -0
- diffguard/engine/_types.py +36 -0
- diffguard/engine/classifier.py +87 -0
- diffguard/engine/deps.py +204 -0
- diffguard/engine/matcher.py +128 -0
- diffguard/engine/parser.py +47 -0
- diffguard/engine/pipeline.py +205 -0
- diffguard/engine/signatures.py +248 -0
- diffguard/engine/summarizer.py +412 -0
- diffguard/git.py +323 -0
- diffguard/languages/__init__.py +52 -0
- diffguard/languages/_utils.py +13 -0
- diffguard/languages/go/__init__.py +101 -0
- diffguard/languages/go/queries.scm +1 -0
- diffguard/languages/python/__init__.py +204 -0
- diffguard/languages/python/queries.scm +1 -0
- diffguard/languages/typescript/__init__.py +184 -0
- diffguard/languages/typescript/queries.scm +1 -0
- diffguard/schema.py +97 -0
- diffguard-0.1.3.dist-info/METADATA +142 -0
- diffguard-0.1.3.dist-info/RECORD +26 -0
- diffguard-0.1.3.dist-info/WHEEL +4 -0
- diffguard-0.1.3.dist-info/entry_points.txt +2 -0
- diffguard-0.1.3.dist-info/licenses/LICENSE +53 -0
diffguard/__init__.py
ADDED
diffguard/cli.py
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""DiffGuard CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from diffguard import __version__
|
|
13
|
+
from diffguard.engine.deps import find_references
|
|
14
|
+
from diffguard.engine.pipeline import FileContentProvider, run_pipeline
|
|
15
|
+
from diffguard.git import get_diff, get_file_at_ref
|
|
16
|
+
from diffguard.schema import FileChange, DiffGuardOutput, SymbolChange
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Exit codes
|
|
21
|
+
EXIT_SUCCESS = 0 # No high-signal findings (silence)
|
|
22
|
+
EXIT_FINDINGS = 1 # Findings present — agent should read output
|
|
23
|
+
EXIT_ERROR = 2 # Something went wrong
|
|
24
|
+
EXIT_NO_CHANGES = 3 # No changes in diff (summarize command)
|
|
25
|
+
EXIT_PARTIAL = 4 # Parse errors in some files (summarize command)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_content_provider(repo_path: str) -> FileContentProvider:
|
|
29
|
+
"""Create a file content provider bound to a repo path."""
|
|
30
|
+
|
|
31
|
+
def _get(ref: str, file_path: str) -> str | None:
|
|
32
|
+
return get_file_at_ref(ref, file_path, repo_path=repo_path)
|
|
33
|
+
|
|
34
|
+
return _get
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _format_output(
|
|
38
|
+
output: DiffGuardOutput,
|
|
39
|
+
fmt: str,
|
|
40
|
+
tier: str,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Format pipeline output according to --format flag."""
|
|
43
|
+
if fmt == "json":
|
|
44
|
+
return output.model_dump_json(indent=2)
|
|
45
|
+
# Non-JSON: the format flag determines which tier to show
|
|
46
|
+
if fmt in ("oneliner", "short", "detailed"):
|
|
47
|
+
return str(getattr(output.tiered, fmt))
|
|
48
|
+
# Fallback to tier
|
|
49
|
+
return str(getattr(output.tiered, tier))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.group()
|
|
53
|
+
@click.version_option(__version__, "--version", "-v")
|
|
54
|
+
def main() -> None:
|
|
55
|
+
"""DiffGuard — Catches the structural breaks that pass code review. Analyzes git diffs to surface high-signal changes."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@main.command()
|
|
59
|
+
@click.argument("ref_range", required=False, default=None)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--diff",
|
|
62
|
+
"diff_source",
|
|
63
|
+
default=None,
|
|
64
|
+
help="Read unified diff from stdin. Use '--diff -' for pipe input.",
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--format",
|
|
68
|
+
"fmt",
|
|
69
|
+
type=click.Choice(["json", "oneliner", "short", "detailed"]),
|
|
70
|
+
default="json",
|
|
71
|
+
help="Output format (default: json).",
|
|
72
|
+
)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--tier",
|
|
75
|
+
type=click.Choice(["oneliner", "short", "detailed"]),
|
|
76
|
+
default="detailed",
|
|
77
|
+
help="Summary tier for JSON output (default: detailed).",
|
|
78
|
+
)
|
|
79
|
+
@click.option(
|
|
80
|
+
"--skip-generated",
|
|
81
|
+
"--no-generated",
|
|
82
|
+
is_flag=True,
|
|
83
|
+
default=False,
|
|
84
|
+
help="Skip generated file detection.",
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--include-tests",
|
|
88
|
+
is_flag=True,
|
|
89
|
+
default=False,
|
|
90
|
+
help="Include test file changes in summary text output.",
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"--show-skipped",
|
|
94
|
+
is_flag=True,
|
|
95
|
+
default=False,
|
|
96
|
+
help="Show skipped (unsupported/binary/generated) files in summary text.",
|
|
97
|
+
)
|
|
98
|
+
@click.option(
|
|
99
|
+
"--repo",
|
|
100
|
+
default=".",
|
|
101
|
+
help="Repository path (default: current directory).",
|
|
102
|
+
)
|
|
103
|
+
def summarize(
|
|
104
|
+
ref_range: str | None,
|
|
105
|
+
diff_source: str | None,
|
|
106
|
+
fmt: str,
|
|
107
|
+
tier: str,
|
|
108
|
+
skip_generated: bool,
|
|
109
|
+
include_tests: bool,
|
|
110
|
+
show_skipped: bool,
|
|
111
|
+
repo: str,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Summarize git changes.
|
|
114
|
+
|
|
115
|
+
REF_RANGE: Git ref range like HEAD~1..HEAD or main..feature.
|
|
116
|
+
Default: unstaged changes.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
diff_text: str
|
|
120
|
+
range_label: str
|
|
121
|
+
content_provider: FileContentProvider | None
|
|
122
|
+
|
|
123
|
+
if diff_source == "-":
|
|
124
|
+
diff_text = sys.stdin.read()
|
|
125
|
+
range_label = "stdin"
|
|
126
|
+
content_provider = None
|
|
127
|
+
elif ref_range is not None:
|
|
128
|
+
diff_text = get_diff(ref_range, repo_path=repo)
|
|
129
|
+
range_label = ref_range
|
|
130
|
+
content_provider = _make_content_provider(repo)
|
|
131
|
+
else:
|
|
132
|
+
diff_text = get_diff("HEAD", repo_path=repo)
|
|
133
|
+
range_label = "HEAD (unstaged)"
|
|
134
|
+
content_provider = _make_content_provider(repo)
|
|
135
|
+
|
|
136
|
+
if not diff_text.strip():
|
|
137
|
+
click.echo("No changes found.", err=True)
|
|
138
|
+
sys.exit(EXIT_NO_CHANGES)
|
|
139
|
+
|
|
140
|
+
output = run_pipeline(
|
|
141
|
+
diff_text,
|
|
142
|
+
range_label,
|
|
143
|
+
content_provider,
|
|
144
|
+
skip_generated=skip_generated,
|
|
145
|
+
include_tests=include_tests,
|
|
146
|
+
show_skipped=show_skipped,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
has_parse_errors = any(fc.parse_error for fc in output.files)
|
|
150
|
+
|
|
151
|
+
text = _format_output(output, fmt, tier)
|
|
152
|
+
click.echo(text)
|
|
153
|
+
|
|
154
|
+
if has_parse_errors:
|
|
155
|
+
sys.exit(EXIT_PARTIAL)
|
|
156
|
+
sys.exit(EXIT_SUCCESS)
|
|
157
|
+
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
logger.debug("CLI error", exc_info=True)
|
|
160
|
+
click.echo(f"Error: {exc}", err=True)
|
|
161
|
+
sys.exit(EXIT_ERROR)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# context command
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _has_high_signal_changes(
|
|
170
|
+
output: DiffGuardOutput,
|
|
171
|
+
dep_refs: list | None = None,
|
|
172
|
+
) -> bool:
|
|
173
|
+
"""Check if there are any high-signal changes worth reporting."""
|
|
174
|
+
for fc in output.files:
|
|
175
|
+
for sc in fc.changes:
|
|
176
|
+
# Signature changes (before != after)
|
|
177
|
+
if sc.before_signature and sc.after_signature:
|
|
178
|
+
return True
|
|
179
|
+
# Breaking changes
|
|
180
|
+
if sc.breaking:
|
|
181
|
+
return True
|
|
182
|
+
# Removed symbols
|
|
183
|
+
if sc.kind.endswith("_removed"):
|
|
184
|
+
return True
|
|
185
|
+
# Moved symbols
|
|
186
|
+
if sc.kind == "moved":
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
# External dependency references only matter if we already found
|
|
190
|
+
# high-signal changes for those symbols (checked above).
|
|
191
|
+
# Don't trigger on dep_refs alone — body-only changes with callers
|
|
192
|
+
# are not high-signal.
|
|
193
|
+
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _categorize_change(sc: SymbolChange) -> str:
|
|
198
|
+
"""Return a category label for a symbol change."""
|
|
199
|
+
from diffguard.engine.signatures import classify_signature_change
|
|
200
|
+
|
|
201
|
+
if sc.kind.endswith("_removed"):
|
|
202
|
+
return "SYMBOL REMOVED"
|
|
203
|
+
if sc.kind == "moved":
|
|
204
|
+
return "SYMBOL MOVED"
|
|
205
|
+
if sc.before_signature and sc.after_signature:
|
|
206
|
+
return classify_signature_change(sc.before_signature, sc.after_signature)
|
|
207
|
+
return "CHANGED"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _review_hint_for_category(category: str) -> str:
|
|
211
|
+
"""Return a review hint string for a given category."""
|
|
212
|
+
hints = {
|
|
213
|
+
"PARAMETER REMOVED": "These callers will break — removed parameter no longer accepted",
|
|
214
|
+
"PARAMETER ADDED (BREAKING)": "These callers will break — missing required argument",
|
|
215
|
+
"RETURN TYPE CHANGED": "Callers depending on the return type may break",
|
|
216
|
+
"DEFAULT VALUE CHANGED": "Verify callers expect the new default value",
|
|
217
|
+
"BREAKING SIGNATURE CHANGE": "Check all callers handle the new signature",
|
|
218
|
+
"SIGNATURE CHANGED": "Review the signature change for compatibility",
|
|
219
|
+
"SYMBOL REMOVED": "Ensure no remaining callers depend on this symbol",
|
|
220
|
+
"SYMBOL MOVED": "Update imports in dependent files",
|
|
221
|
+
}
|
|
222
|
+
return hints.get(category, "Review this change")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _shorten_paths(paths: list[str]) -> list[str]:
|
|
226
|
+
"""Strip common prefix from a list of paths."""
|
|
227
|
+
if not paths:
|
|
228
|
+
return paths
|
|
229
|
+
if len(paths) == 1:
|
|
230
|
+
# Just use filename
|
|
231
|
+
return [paths[0].rsplit("/", 1)[-1]]
|
|
232
|
+
# Find common prefix
|
|
233
|
+
parts = [p.split("/") for p in paths]
|
|
234
|
+
prefix_len = 0
|
|
235
|
+
for i in range(min(len(p) for p in parts)):
|
|
236
|
+
if len(set(p[i] for p in parts)) == 1:
|
|
237
|
+
prefix_len = i + 1
|
|
238
|
+
else:
|
|
239
|
+
break
|
|
240
|
+
if prefix_len > 0:
|
|
241
|
+
return ["/".join(p[prefix_len:]) for p in parts]
|
|
242
|
+
return paths
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _format_context_output(
|
|
246
|
+
output: DiffGuardOutput,
|
|
247
|
+
ref_range: str,
|
|
248
|
+
dep_refs: list | None = None,
|
|
249
|
+
) -> str:
|
|
250
|
+
"""Format pipeline output as actionable review instructions."""
|
|
251
|
+
from diffguard.engine.deps import Reference
|
|
252
|
+
from diffguard.engine.summarizer import is_test_file
|
|
253
|
+
|
|
254
|
+
# Collect high-signal changes
|
|
255
|
+
items: list[tuple[FileChange, SymbolChange]] = []
|
|
256
|
+
for fc in output.files:
|
|
257
|
+
for sc in fc.changes:
|
|
258
|
+
if (
|
|
259
|
+
(sc.before_signature and sc.after_signature)
|
|
260
|
+
or sc.breaking
|
|
261
|
+
or sc.kind.endswith("_removed")
|
|
262
|
+
or sc.kind == "moved"
|
|
263
|
+
):
|
|
264
|
+
items.append((fc, sc))
|
|
265
|
+
|
|
266
|
+
if not items:
|
|
267
|
+
return ""
|
|
268
|
+
|
|
269
|
+
# Build dep lookup: symbol_name -> list of references
|
|
270
|
+
dep_map: dict[str, list[Reference]] = {}
|
|
271
|
+
if dep_refs:
|
|
272
|
+
for ref in dep_refs:
|
|
273
|
+
dep_map.setdefault(ref.symbol_name, []).append(ref)
|
|
274
|
+
|
|
275
|
+
lines: list[str] = [f"⚠ DiffGuard: {len(items)} change{'s' if len(items) != 1 else ''} need{'s' if len(items) == 1 else ''} review"]
|
|
276
|
+
lines.append("")
|
|
277
|
+
|
|
278
|
+
for idx, (fc, sc) in enumerate(items, 1):
|
|
279
|
+
category = _categorize_change(sc)
|
|
280
|
+
sig_text = _sig_display(sc)
|
|
281
|
+
line_ref = f":{sc.line}" if sc.line else ""
|
|
282
|
+
|
|
283
|
+
lines.append(f"{idx}. {category}: {sig_text}")
|
|
284
|
+
lines.append(f" File: {fc.path}{line_ref}")
|
|
285
|
+
|
|
286
|
+
# Impact section
|
|
287
|
+
call_refs = dep_map.get(sc.name, [])
|
|
288
|
+
call_refs = [r for r in call_refs if r.context == "call"]
|
|
289
|
+
|
|
290
|
+
# Separate test vs prod callers
|
|
291
|
+
test_refs = [r for r in call_refs if is_test_file(r.file_path)]
|
|
292
|
+
prod_refs = [r for r in call_refs if not is_test_file(r.file_path)]
|
|
293
|
+
|
|
294
|
+
if sc.breaking:
|
|
295
|
+
if prod_refs:
|
|
296
|
+
lines.append(f" Impact: {len(prod_refs)} caller{'s' if len(prod_refs) != 1 else ''} rely on the default:")
|
|
297
|
+
for r in prod_refs[:5]:
|
|
298
|
+
short_path = r.file_path.rsplit("/", 1)[-1]
|
|
299
|
+
lines.append(f" {short_path}:{r.line} `{r.source_line}`")
|
|
300
|
+
else:
|
|
301
|
+
lines.append(" Impact: Breaking change")
|
|
302
|
+
elif sc.before_signature and sc.after_signature and not sc.breaking:
|
|
303
|
+
if prod_refs:
|
|
304
|
+
caller_parts = []
|
|
305
|
+
by_file: dict[str, int] = {}
|
|
306
|
+
for r in prod_refs:
|
|
307
|
+
fname = r.file_path.rsplit("/", 1)[-1]
|
|
308
|
+
by_file[fname] = by_file.get(fname, 0) + 1
|
|
309
|
+
caller_parts = [f"{f} ({n} call{'s' if n != 1 else ''})" for f, n in by_file.items()]
|
|
310
|
+
lines.append(" Impact: Backward-compatible (new kwarg has default)")
|
|
311
|
+
lines.append(f" Callers: {', '.join(caller_parts)}")
|
|
312
|
+
else:
|
|
313
|
+
lines.append(" Impact: Backward-compatible (new kwarg has default)")
|
|
314
|
+
elif sc.kind.endswith("_removed"):
|
|
315
|
+
if prod_refs:
|
|
316
|
+
lines.append(f" Impact: {len(prod_refs)} caller{'s' if len(prod_refs) != 1 else ''} will break:")
|
|
317
|
+
for r in prod_refs[:5]:
|
|
318
|
+
short_path = r.file_path.rsplit("/", 1)[-1]
|
|
319
|
+
lines.append(f" {short_path}:{r.line} `{r.source_line}`")
|
|
320
|
+
else:
|
|
321
|
+
lines.append(" Impact: Symbol removed")
|
|
322
|
+
|
|
323
|
+
# Show test callers compactly
|
|
324
|
+
if test_refs:
|
|
325
|
+
# Group by file
|
|
326
|
+
by_file: dict[str, int] = {}
|
|
327
|
+
for r in test_refs:
|
|
328
|
+
fname = r.file_path.rsplit("/", 1)[-1]
|
|
329
|
+
by_file[fname] = by_file.get(fname, 0) + 1
|
|
330
|
+
parts = [f"{f} ({n} call{'s' if n != 1 else ''})" for f, n in by_file.items()]
|
|
331
|
+
lines.append(f" Callers: {', '.join(parts)}")
|
|
332
|
+
|
|
333
|
+
# Review instruction
|
|
334
|
+
lines.append(f" Review: {_review_hint_for_category(category)}")
|
|
335
|
+
|
|
336
|
+
lines.append("")
|
|
337
|
+
|
|
338
|
+
return "\n".join(lines).rstrip()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _sig_display(sc: SymbolChange) -> str:
|
|
342
|
+
"""Format signature change display — compact, one-line."""
|
|
343
|
+
|
|
344
|
+
def _compact_sig(sig: str) -> str:
|
|
345
|
+
"""Extract just the def/class line and collapse to one line."""
|
|
346
|
+
# Strip decorators — find the first 'def ' or 'class ' line
|
|
347
|
+
for line in sig.split("\n"):
|
|
348
|
+
stripped = line.strip()
|
|
349
|
+
if stripped.startswith(("def ", "class ", "func ", "function ")):
|
|
350
|
+
# If multi-line params, collapse them
|
|
351
|
+
if "(" in stripped and ")" not in stripped:
|
|
352
|
+
# Grab remaining lines until closing paren
|
|
353
|
+
start = sig.index(stripped)
|
|
354
|
+
rest = sig[start:]
|
|
355
|
+
paren_depth = 0
|
|
356
|
+
result_chars = []
|
|
357
|
+
for ch in rest:
|
|
358
|
+
if ch == "(":
|
|
359
|
+
paren_depth += 1
|
|
360
|
+
elif ch == ")":
|
|
361
|
+
paren_depth -= 1
|
|
362
|
+
if ch == "\n":
|
|
363
|
+
ch = " "
|
|
364
|
+
result_chars.append(ch)
|
|
365
|
+
if paren_depth == 0 and ch == ")":
|
|
366
|
+
# Grab return type if present
|
|
367
|
+
remaining = rest[len("".join(result_chars)) :]
|
|
368
|
+
arrow = remaining.split("\n")[0].strip()
|
|
369
|
+
if arrow.startswith("->"):
|
|
370
|
+
result_chars.append(f" {arrow}")
|
|
371
|
+
break
|
|
372
|
+
return " ".join("".join(result_chars).split())
|
|
373
|
+
return stripped
|
|
374
|
+
# Fallback: collapse whole thing
|
|
375
|
+
return " ".join(sig.split())
|
|
376
|
+
|
|
377
|
+
def _strip_keyword(sig: str) -> str:
|
|
378
|
+
"""Strip leading def/class/func/function keyword for compact display."""
|
|
379
|
+
for kw in ("def ", "class ", "func ", "function "):
|
|
380
|
+
if sig.startswith(kw):
|
|
381
|
+
sig = sig[len(kw):]
|
|
382
|
+
break
|
|
383
|
+
# Strip return type annotation and trailing colon for compactness
|
|
384
|
+
sig = re.sub(r"\)\s*->.*$", ")", sig)
|
|
385
|
+
sig = sig.rstrip(":")
|
|
386
|
+
return sig
|
|
387
|
+
|
|
388
|
+
if sc.before_signature and sc.after_signature:
|
|
389
|
+
before = _strip_keyword(_compact_sig(sc.before_signature))
|
|
390
|
+
after = _strip_keyword(_compact_sig(sc.after_signature))
|
|
391
|
+
return f"{before} → {after}"
|
|
392
|
+
if sc.signature:
|
|
393
|
+
return _strip_keyword(_compact_sig(sc.signature))
|
|
394
|
+
return f"`{sc.name}`"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _build_json_output(
|
|
398
|
+
output: DiffGuardOutput,
|
|
399
|
+
ref_range: str,
|
|
400
|
+
dep_refs: list | None = None,
|
|
401
|
+
) -> str:
|
|
402
|
+
"""Build structured JSON output for the review command."""
|
|
403
|
+
from diffguard.engine.deps import Reference
|
|
404
|
+
from diffguard.engine.summarizer import is_test_file
|
|
405
|
+
|
|
406
|
+
dep_map: dict[str, list[Reference]] = {}
|
|
407
|
+
if dep_refs:
|
|
408
|
+
for ref in dep_refs:
|
|
409
|
+
dep_map.setdefault(ref.symbol_name, []).append(ref)
|
|
410
|
+
|
|
411
|
+
findings = []
|
|
412
|
+
for fc in output.files:
|
|
413
|
+
for sc in fc.changes:
|
|
414
|
+
if not (
|
|
415
|
+
(sc.before_signature and sc.after_signature)
|
|
416
|
+
or sc.breaking
|
|
417
|
+
or sc.kind.endswith("_removed")
|
|
418
|
+
or sc.kind == "moved"
|
|
419
|
+
):
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
category = _categorize_change(sc)
|
|
423
|
+
|
|
424
|
+
call_refs = dep_map.get(sc.name, [])
|
|
425
|
+
call_refs = [r for r in call_refs if r.context == "call"]
|
|
426
|
+
test_refs = [r for r in call_refs if is_test_file(r.file_path)]
|
|
427
|
+
prod_refs = [r for r in call_refs if not is_test_file(r.file_path)]
|
|
428
|
+
|
|
429
|
+
callers = []
|
|
430
|
+
for r in (prod_refs + test_refs)[:10]:
|
|
431
|
+
callers.append({
|
|
432
|
+
"file": r.file_path,
|
|
433
|
+
"line": r.line,
|
|
434
|
+
"source": r.source_line,
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
finding: dict = {
|
|
438
|
+
"category": category.replace(" ", "_"),
|
|
439
|
+
"symbol": sc.name,
|
|
440
|
+
"file": fc.path,
|
|
441
|
+
"line": sc.line,
|
|
442
|
+
}
|
|
443
|
+
if sc.before_signature:
|
|
444
|
+
finding["before_signature"] = sc.before_signature.strip()
|
|
445
|
+
if sc.after_signature:
|
|
446
|
+
finding["after_signature"] = sc.after_signature.strip()
|
|
447
|
+
|
|
448
|
+
finding["impact"] = {
|
|
449
|
+
"production_callers": len(prod_refs),
|
|
450
|
+
"test_callers": len(test_refs),
|
|
451
|
+
"callers": callers,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
finding["review_hint"] = _review_hint_for_category(category)
|
|
455
|
+
|
|
456
|
+
findings.append(finding)
|
|
457
|
+
|
|
458
|
+
symbols_changed = sum(len(fc.changes) for fc in output.files)
|
|
459
|
+
result = {
|
|
460
|
+
"version": "0.1.0",
|
|
461
|
+
"ref_range": ref_range,
|
|
462
|
+
"findings": findings,
|
|
463
|
+
"stats": {
|
|
464
|
+
"files_analyzed": len(output.files),
|
|
465
|
+
"symbols_changed": symbols_changed,
|
|
466
|
+
"silence_reason": None if findings else "no high-signal changes",
|
|
467
|
+
},
|
|
468
|
+
}
|
|
469
|
+
return json.dumps(result, indent=2)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _run_review(ref_range: str, repo: str, deps: bool, verbose: bool, fmt: str) -> None:
|
|
473
|
+
"""Shared implementation for review/context commands."""
|
|
474
|
+
try:
|
|
475
|
+
diff_text = get_diff(ref_range, repo_path=repo)
|
|
476
|
+
|
|
477
|
+
if not diff_text.strip():
|
|
478
|
+
if fmt == "json":
|
|
479
|
+
click.echo(json.dumps({
|
|
480
|
+
"version": "0.1.0",
|
|
481
|
+
"ref_range": ref_range,
|
|
482
|
+
"findings": [],
|
|
483
|
+
"stats": {"files_analyzed": 0, "symbols_changed": 0, "silence_reason": "no changes in diff"},
|
|
484
|
+
}, indent=2))
|
|
485
|
+
else:
|
|
486
|
+
click.echo("No changes found.", err=True)
|
|
487
|
+
sys.exit(EXIT_SUCCESS)
|
|
488
|
+
|
|
489
|
+
content_provider = _make_content_provider(repo)
|
|
490
|
+
output = run_pipeline(diff_text, ref_range, content_provider)
|
|
491
|
+
|
|
492
|
+
dep_refs = None
|
|
493
|
+
if deps:
|
|
494
|
+
changed_symbols = []
|
|
495
|
+
changed_files = set()
|
|
496
|
+
for fc in output.files:
|
|
497
|
+
changed_files.add(fc.path)
|
|
498
|
+
for sc in fc.changes:
|
|
499
|
+
changed_symbols.append(sc.name)
|
|
500
|
+
|
|
501
|
+
if changed_symbols:
|
|
502
|
+
parts = ref_range.split("..")
|
|
503
|
+
after_ref = parts[1] if len(parts) == 2 else ref_range # noqa: PLR2004
|
|
504
|
+
dep_refs = find_references(
|
|
505
|
+
repo_path=repo,
|
|
506
|
+
changed_symbols=changed_symbols,
|
|
507
|
+
ref=after_ref,
|
|
508
|
+
changed_files=changed_files,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
has_findings = _has_high_signal_changes(output, dep_refs)
|
|
512
|
+
|
|
513
|
+
if fmt == "json":
|
|
514
|
+
click.echo(_build_json_output(output, ref_range, dep_refs))
|
|
515
|
+
sys.exit(EXIT_FINDINGS if has_findings else EXIT_SUCCESS)
|
|
516
|
+
|
|
517
|
+
# Text format
|
|
518
|
+
if not verbose and not has_findings:
|
|
519
|
+
sys.exit(EXIT_SUCCESS)
|
|
520
|
+
|
|
521
|
+
text = _format_context_output(output, ref_range, dep_refs)
|
|
522
|
+
if text:
|
|
523
|
+
click.echo(text)
|
|
524
|
+
sys.exit(EXIT_FINDINGS)
|
|
525
|
+
sys.exit(EXIT_SUCCESS)
|
|
526
|
+
|
|
527
|
+
except SystemExit:
|
|
528
|
+
raise
|
|
529
|
+
except Exception as exc:
|
|
530
|
+
logger.debug("CLI error", exc_info=True)
|
|
531
|
+
click.echo(f"Error: {exc}", err=True)
|
|
532
|
+
sys.exit(EXIT_ERROR)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@main.command()
|
|
536
|
+
@click.argument("ref_range", required=False, default=None)
|
|
537
|
+
@click.option("--repo", default=".", help="Repository path (default: current directory).")
|
|
538
|
+
@click.option("--deps/--no-deps", default=True, help="Enable dependency scanning (default: enabled).")
|
|
539
|
+
@click.option("--verbose", is_flag=True, default=False, help="Show full output even when no high-signal changes.")
|
|
540
|
+
@click.option(
|
|
541
|
+
"--format",
|
|
542
|
+
"fmt",
|
|
543
|
+
type=click.Choice(["text", "json"]),
|
|
544
|
+
default="text",
|
|
545
|
+
help="Output format: 'text' for human-readable review, 'json' for structured output.",
|
|
546
|
+
)
|
|
547
|
+
def review(ref_range: str | None, repo: str, deps: bool, verbose: bool, fmt: str) -> None:
|
|
548
|
+
"""Analyze git changes and surface high-signal findings for code review.
|
|
549
|
+
|
|
550
|
+
REF_RANGE: Git ref range like HEAD~3..HEAD or main..feature.
|
|
551
|
+
Default: HEAD~1..HEAD (last commit).
|
|
552
|
+
|
|
553
|
+
Detects signature changes, breaking changes, removed/moved symbols,
|
|
554
|
+
and finds callers that may be affected.
|
|
555
|
+
|
|
556
|
+
\b
|
|
557
|
+
Exit codes:
|
|
558
|
+
0 — No high-signal findings (silence)
|
|
559
|
+
1 — Findings present (read the output)
|
|
560
|
+
2 — Error
|
|
561
|
+
"""
|
|
562
|
+
if ref_range is None:
|
|
563
|
+
ref_range = "HEAD~1..HEAD"
|
|
564
|
+
_run_review(ref_range, repo, deps, verbose, fmt)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@main.command(hidden=True)
|
|
568
|
+
@click.argument("ref_range", required=False, default=None)
|
|
569
|
+
@click.option("--repo", default=".", help="Repository path (default: current directory).")
|
|
570
|
+
@click.option("--deps/--no-deps", default=True, help="Enable dependency scanning (default: enabled).")
|
|
571
|
+
@click.option("--verbose", is_flag=True, default=False, help="Show full output even when no high-signal changes.")
|
|
572
|
+
@click.option(
|
|
573
|
+
"--format",
|
|
574
|
+
"fmt",
|
|
575
|
+
type=click.Choice(["text", "json"]),
|
|
576
|
+
default="text",
|
|
577
|
+
help="Output format: 'text' for human-readable review, 'json' for structured output.",
|
|
578
|
+
)
|
|
579
|
+
def context(ref_range: str | None, repo: str, deps: bool, verbose: bool, fmt: str) -> None:
|
|
580
|
+
"""Alias for 'review' (deprecated)."""
|
|
581
|
+
if ref_range is None:
|
|
582
|
+
ref_range = "HEAD~1..HEAD"
|
|
583
|
+
_run_review(ref_range, repo, deps, verbose, fmt)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
_PRE_PUSH_HOOK = """\
|
|
587
|
+
#!/bin/sh
|
|
588
|
+
# DiffGuard pre-push hook — runs diffguard review on pushed changes
|
|
589
|
+
# Installed by: diffguard install-hook
|
|
590
|
+
|
|
591
|
+
remote="$1"
|
|
592
|
+
z40=0000000000000000000000000000000000000000
|
|
593
|
+
|
|
594
|
+
while read local_ref local_sha remote_ref remote_sha; do
|
|
595
|
+
if [ "$remote_sha" = "$z40" ]; then
|
|
596
|
+
# New branch — compare against main/master
|
|
597
|
+
base=$(git rev-parse --verify refs/heads/main 2>/dev/null || git rev-parse --verify refs/heads/master 2>/dev/null || echo "")
|
|
598
|
+
if [ -z "$base" ]; then
|
|
599
|
+
continue
|
|
600
|
+
fi
|
|
601
|
+
range="$base..$local_sha"
|
|
602
|
+
else
|
|
603
|
+
range="$remote_sha..$local_sha"
|
|
604
|
+
fi
|
|
605
|
+
|
|
606
|
+
echo "Running diffguard review $range ..."
|
|
607
|
+
diffguard review "$range"
|
|
608
|
+
status=$?
|
|
609
|
+
if [ $status -eq 1 ]; then
|
|
610
|
+
echo ""
|
|
611
|
+
echo "DiffGuard found changes that need review (see above)."
|
|
612
|
+
echo "Push anyway with: git push --no-verify"
|
|
613
|
+
exit 1
|
|
614
|
+
fi
|
|
615
|
+
done
|
|
616
|
+
|
|
617
|
+
exit 0
|
|
618
|
+
"""
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
@main.command("install-hook")
|
|
622
|
+
@click.option("--repo", default=".", help="Repository path (default: current directory).")
|
|
623
|
+
@click.option(
|
|
624
|
+
"--hook-type",
|
|
625
|
+
type=click.Choice(["pre-push", "pre-commit"]),
|
|
626
|
+
default="pre-push",
|
|
627
|
+
help="Git hook type to install (default: pre-push).",
|
|
628
|
+
)
|
|
629
|
+
@click.option("--force", is_flag=True, default=False, help="Overwrite existing hook.")
|
|
630
|
+
def install_hook(repo: str, hook_type: str, force: bool) -> None:
|
|
631
|
+
"""Install a git hook that runs diffguard review before push/commit."""
|
|
632
|
+
import os
|
|
633
|
+
import stat
|
|
634
|
+
|
|
635
|
+
git_dir = os.path.join(repo, ".git")
|
|
636
|
+
if not os.path.isdir(git_dir):
|
|
637
|
+
click.echo(f"Error: {repo} is not a git repository", err=True)
|
|
638
|
+
sys.exit(EXIT_ERROR)
|
|
639
|
+
|
|
640
|
+
hooks_dir = os.path.join(git_dir, "hooks")
|
|
641
|
+
os.makedirs(hooks_dir, exist_ok=True)
|
|
642
|
+
|
|
643
|
+
hook_path = os.path.join(hooks_dir, hook_type)
|
|
644
|
+
if os.path.exists(hook_path) and not force:
|
|
645
|
+
click.echo(f"Hook already exists: {hook_path}", err=True)
|
|
646
|
+
click.echo("Use --force to overwrite.", err=True)
|
|
647
|
+
sys.exit(EXIT_ERROR)
|
|
648
|
+
|
|
649
|
+
hook_content = _PRE_PUSH_HOOK
|
|
650
|
+
if hook_type == "pre-commit":
|
|
651
|
+
hook_content = """\
|
|
652
|
+
#!/bin/sh
|
|
653
|
+
# DiffGuard pre-commit hook — runs diffguard review on staged changes
|
|
654
|
+
# Installed by: diffguard install-hook
|
|
655
|
+
|
|
656
|
+
echo "Running diffguard review HEAD ..."
|
|
657
|
+
diffguard review HEAD
|
|
658
|
+
status=$?
|
|
659
|
+
if [ $status -eq 1 ]; then
|
|
660
|
+
echo ""
|
|
661
|
+
echo "DiffGuard found changes that need review (see above)."
|
|
662
|
+
echo "Commit anyway with: git commit --no-verify"
|
|
663
|
+
exit 1
|
|
664
|
+
fi
|
|
665
|
+
|
|
666
|
+
exit 0
|
|
667
|
+
"""
|
|
668
|
+
|
|
669
|
+
with open(hook_path, "w") as f:
|
|
670
|
+
f.write(hook_content)
|
|
671
|
+
|
|
672
|
+
# Make executable
|
|
673
|
+
st = os.stat(hook_path)
|
|
674
|
+
os.chmod(hook_path, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
675
|
+
|
|
676
|
+
click.echo(f"Installed {hook_type} hook: {hook_path}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DiffGuard analysis engine."""
|