whycode-cli 0.2.0__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.
- whycode/__init__.py +3 -0
- whycode/__main__.py +6 -0
- whycode/cli.py +709 -0
- whycode/git_facts.py +450 -0
- whycode/mcp_server.py +204 -0
- whycode/risk_card.py +192 -0
- whycode/scorer.py +55 -0
- whycode/signals.py +389 -0
- whycode/templates/__init__.py +0 -0
- whycode/templates/github-workflow.yml +42 -0
- whycode/templates/pre-commit +7 -0
- whycode_cli-0.2.0.dist-info/METADATA +223 -0
- whycode_cli-0.2.0.dist-info/RECORD +17 -0
- whycode_cli-0.2.0.dist-info/WHEEL +5 -0
- whycode_cli-0.2.0.dist-info/entry_points.txt +2 -0
- whycode_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- whycode_cli-0.2.0.dist-info/top_level.txt +1 -0
whycode/cli.py
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"""The ``whycode`` CLI.
|
|
2
|
+
|
|
3
|
+
Commands
|
|
4
|
+
--------
|
|
5
|
+
- ``whycode why <path>`` — print the Risk Card for a single file.
|
|
6
|
+
- ``whycode why <path> --at SHA`` — risk card as of a past commit.
|
|
7
|
+
- ``whycode diff [--base REF]`` — risk-rank files changed against a base ref.
|
|
8
|
+
- ``whycode show <sha>`` — risk-flavored summary for one commit.
|
|
9
|
+
- ``whycode timeline <path>`` — risk score evolution across the file's history.
|
|
10
|
+
- ``whycode honest <path>`` — every invariant line, verbatim, untruncated.
|
|
11
|
+
- ``whycode scan [--top N]`` — print the top-N riskiest files in the repo.
|
|
12
|
+
- ``whycode init`` — install CI workflow + pre-commit risk gate.
|
|
13
|
+
- ``whycode mcp`` — start the MCP stdio server.
|
|
14
|
+
- ``whycode version`` — print version.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
from rich.table import Table
|
|
27
|
+
|
|
28
|
+
from whycode import __version__
|
|
29
|
+
from whycode import git_facts as gf
|
|
30
|
+
from whycode import risk_card as rc
|
|
31
|
+
from whycode import signals as sig
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
add_completion=False,
|
|
35
|
+
help="WhyCode — tells you what to be afraid of before touching a file.",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
console = Console()
|
|
40
|
+
err = Console(stderr=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_repo_and_path(path_arg: str) -> tuple[Path, str]:
|
|
44
|
+
"""Translate a user-provided path into (repo_root, repo-relative path)."""
|
|
45
|
+
p = Path(path_arg).resolve()
|
|
46
|
+
start = p if p.is_dir() else p.parent if p.exists() else Path.cwd()
|
|
47
|
+
try:
|
|
48
|
+
repo_root = gf.discover_repo_root(start)
|
|
49
|
+
except gf.GitError as exc:
|
|
50
|
+
err.print(f"[red]error:[/red] {exc}")
|
|
51
|
+
raise typer.Exit(2) from exc
|
|
52
|
+
if not p.exists():
|
|
53
|
+
# Allow the user to pass a path that was deleted in HEAD but lived in
|
|
54
|
+
# history — we still want to report on it.
|
|
55
|
+
rel = path_arg
|
|
56
|
+
else:
|
|
57
|
+
try:
|
|
58
|
+
rel = str(p.relative_to(repo_root))
|
|
59
|
+
except ValueError:
|
|
60
|
+
err.print(f"[red]error:[/red] {p} is not inside {repo_root}")
|
|
61
|
+
raise typer.Exit(2) from None
|
|
62
|
+
return repo_root, rel
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _path_is_known_to_git(repo_root: Path, rel: str) -> bool:
|
|
66
|
+
"""Has git ever seen this path? (tracked OR appears in history)"""
|
|
67
|
+
if gf.is_tracked(repo_root, rel):
|
|
68
|
+
return True
|
|
69
|
+
try:
|
|
70
|
+
out = gf._run_git(repo_root, "log", "--oneline", "-1", "--all", "--", rel)
|
|
71
|
+
except gf.GitError:
|
|
72
|
+
return False
|
|
73
|
+
return bool(out.strip())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- shared: band threshold parsing ----------------------------------------
|
|
77
|
+
|
|
78
|
+
_BAND_THRESHOLDS_BY_KEY: dict[str, int] = {
|
|
79
|
+
"handle": 75,
|
|
80
|
+
"handle-with-care": 75,
|
|
81
|
+
"history": 50,
|
|
82
|
+
"read": 50,
|
|
83
|
+
"read-history-first": 50,
|
|
84
|
+
"look": 25,
|
|
85
|
+
"worth-a-look": 25,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_fail_on(value: str) -> int:
|
|
90
|
+
threshold = _BAND_THRESHOLDS_BY_KEY.get(value.lower().strip())
|
|
91
|
+
if threshold is None:
|
|
92
|
+
raise typer.BadParameter(
|
|
93
|
+
f"unknown band: {value!r}. "
|
|
94
|
+
f"Use one of: handle | history | look (or full names with hyphens)."
|
|
95
|
+
)
|
|
96
|
+
return threshold
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _print_brief(card: rc.RiskCard) -> None:
|
|
100
|
+
"""Print a one-line summary suitable for grep/awk and 3am triage."""
|
|
101
|
+
top = card.signals[0].headline if card.signals else "no flags"
|
|
102
|
+
console.print(
|
|
103
|
+
f"{card.path}: [bold]{card.score.band.value}[/bold] "
|
|
104
|
+
f"({card.score.value}/100) — {top}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def why(
|
|
110
|
+
path: str = typer.Argument(..., help="File path to inspect."),
|
|
111
|
+
json_out: bool = typer.Option(
|
|
112
|
+
False, "--json", help="Emit machine-readable JSON instead of a card."
|
|
113
|
+
),
|
|
114
|
+
brief: bool = typer.Option(
|
|
115
|
+
False, "--brief", "-b", help="One-line summary (for triage and scripts)."
|
|
116
|
+
),
|
|
117
|
+
at: str | None = typer.Option(
|
|
118
|
+
None,
|
|
119
|
+
"--at",
|
|
120
|
+
help="Show the Risk Card as of this commit / ref (postmortem queries).",
|
|
121
|
+
),
|
|
122
|
+
max_commits: int | None = typer.Option(
|
|
123
|
+
None, "--max-commits", help="Cap the number of commits scanned (debug)."
|
|
124
|
+
),
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Print the Risk Card for ``path``."""
|
|
127
|
+
repo_root, rel = _resolve_repo_and_path(path)
|
|
128
|
+
if not _path_is_known_to_git(repo_root, rel):
|
|
129
|
+
err.print(
|
|
130
|
+
f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
|
|
131
|
+
f"and has no history in this repo. Nothing to learn from."
|
|
132
|
+
)
|
|
133
|
+
raise typer.Exit(1)
|
|
134
|
+
resolved_ref: str | None = None
|
|
135
|
+
if at is not None:
|
|
136
|
+
try:
|
|
137
|
+
resolved_ref = gf._run_git(
|
|
138
|
+
repo_root, "rev-parse", "--verify", f"{at}^{{commit}}"
|
|
139
|
+
).strip()
|
|
140
|
+
except gf.GitError:
|
|
141
|
+
err.print(f"[red]error:[/red] unknown commit / ref: {at!r}")
|
|
142
|
+
raise typer.Exit(2) from None
|
|
143
|
+
card = rc.build(repo_root, rel, max_commits=max_commits, ref=resolved_ref)
|
|
144
|
+
if json_out:
|
|
145
|
+
console.print_json(json.dumps(card.to_dict()))
|
|
146
|
+
return
|
|
147
|
+
if brief:
|
|
148
|
+
_print_brief(card)
|
|
149
|
+
return
|
|
150
|
+
console.print(rc.render_text(card))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_base_ref(repo_root: Path, requested: str | None) -> str:
|
|
154
|
+
"""Pick a base ref for ``whycode diff``.
|
|
155
|
+
|
|
156
|
+
Order: explicit --base, origin/main, origin/master, main, master, HEAD~1.
|
|
157
|
+
"""
|
|
158
|
+
if requested:
|
|
159
|
+
return requested
|
|
160
|
+
candidates = ("origin/main", "origin/master", "main", "master", "HEAD~1")
|
|
161
|
+
for ref in candidates:
|
|
162
|
+
try:
|
|
163
|
+
gf._run_git(repo_root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
|
|
164
|
+
return ref
|
|
165
|
+
except gf.GitError:
|
|
166
|
+
continue
|
|
167
|
+
raise gf.GitError(
|
|
168
|
+
"could not pick a base ref (tried origin/main, main, HEAD~1). "
|
|
169
|
+
"Use --base <ref> to specify one."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command()
|
|
174
|
+
def diff(
|
|
175
|
+
base: str | None = typer.Option(
|
|
176
|
+
None, "--base", help="Base ref (default: origin/main → main → HEAD~1)."
|
|
177
|
+
),
|
|
178
|
+
staged: bool = typer.Option(
|
|
179
|
+
False,
|
|
180
|
+
"--staged",
|
|
181
|
+
help="Score files staged for commit instead (for pre-commit hooks).",
|
|
182
|
+
),
|
|
183
|
+
repo: Path = typer.Option(Path("."), "--repo", help="Path inside the repo."),
|
|
184
|
+
top: int = typer.Option(20, "--top", help="Show at most this many files."),
|
|
185
|
+
json_out: bool = typer.Option(
|
|
186
|
+
False, "--json", help="Emit machine-readable JSON instead of a table."
|
|
187
|
+
),
|
|
188
|
+
fail_on: str | None = typer.Option(
|
|
189
|
+
None,
|
|
190
|
+
"--fail-on",
|
|
191
|
+
help=(
|
|
192
|
+
"Exit non-zero if any file reaches this band: "
|
|
193
|
+
"handle (≥75) / history (≥50) / look (≥25). "
|
|
194
|
+
"Use in CI: `whycode diff --fail-on history`."
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Risk-rank files that changed against a base ref. The 'pre-PR' command."""
|
|
199
|
+
try:
|
|
200
|
+
repo_root = gf.discover_repo_root(repo.resolve())
|
|
201
|
+
if staged:
|
|
202
|
+
raw = gf._run_git(
|
|
203
|
+
repo_root, "diff", "--cached", "--name-only", "--diff-filter=ACMR"
|
|
204
|
+
)
|
|
205
|
+
actual_base = "(staged changes)"
|
|
206
|
+
else:
|
|
207
|
+
actual_base = _resolve_base_ref(repo_root, base)
|
|
208
|
+
raw = gf._run_git(repo_root, "diff", "--name-only", f"{actual_base}...HEAD")
|
|
209
|
+
except gf.GitError as exc:
|
|
210
|
+
err.print(f"[red]error:[/red] {exc}")
|
|
211
|
+
raise typer.Exit(2) from exc
|
|
212
|
+
|
|
213
|
+
threshold: int | None = None
|
|
214
|
+
if fail_on is not None:
|
|
215
|
+
threshold = _parse_fail_on(fail_on)
|
|
216
|
+
|
|
217
|
+
files = [line for line in raw.splitlines() if line.strip()]
|
|
218
|
+
if not files:
|
|
219
|
+
if json_out:
|
|
220
|
+
console.print_json(json.dumps({"base": actual_base, "files": []}))
|
|
221
|
+
else:
|
|
222
|
+
scope = "staged" if staged else f"vs {actual_base}"
|
|
223
|
+
console.print(f"[green]no changes {scope}[/green]")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
cards: list[rc.RiskCard] = []
|
|
227
|
+
for f in files:
|
|
228
|
+
try:
|
|
229
|
+
cards.append(rc.build(repo_root, f))
|
|
230
|
+
except gf.GitError:
|
|
231
|
+
continue
|
|
232
|
+
cards.sort(key=lambda c: -c.score.value)
|
|
233
|
+
cards = cards[:top]
|
|
234
|
+
|
|
235
|
+
if json_out:
|
|
236
|
+
console.print_json(
|
|
237
|
+
json.dumps(
|
|
238
|
+
{
|
|
239
|
+
"base": actual_base,
|
|
240
|
+
"files": [c.to_dict() for c in cards],
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
if threshold is not None and any(c.score.value >= threshold for c in cards):
|
|
245
|
+
raise typer.Exit(1)
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
# NEWBORN-only files have no real risk signal — they're "we don't know yet".
|
|
249
|
+
# Push them into the quiet bucket so the table only shows actionable risk.
|
|
250
|
+
def _is_actionable(c: rc.RiskCard) -> bool:
|
|
251
|
+
return any(s.kind is not sig.SignalKind.NEWBORN for s in c.signals)
|
|
252
|
+
|
|
253
|
+
flagged = [c for c in cards if _is_actionable(c)]
|
|
254
|
+
quiet_n = len(cards) - len(flagged)
|
|
255
|
+
scope = "staged for commit" if staged else f"changed vs {actual_base}"
|
|
256
|
+
console.print(f"[bold]{len(files)} file(s) {scope}[/bold]")
|
|
257
|
+
if not flagged:
|
|
258
|
+
console.print("[green]nothing flagged[/green] — but read the diff anyway.")
|
|
259
|
+
return
|
|
260
|
+
table = Table(title="Risk-ranked changes")
|
|
261
|
+
table.add_column("score", justify="right", style="bold")
|
|
262
|
+
table.add_column("band")
|
|
263
|
+
table.add_column("path")
|
|
264
|
+
table.add_column("top signal")
|
|
265
|
+
for c in flagged:
|
|
266
|
+
table.add_row(
|
|
267
|
+
str(c.score.value),
|
|
268
|
+
c.score.band.value,
|
|
269
|
+
c.path,
|
|
270
|
+
c.signals[0].headline,
|
|
271
|
+
)
|
|
272
|
+
console.print(table)
|
|
273
|
+
if quiet_n:
|
|
274
|
+
console.print(f"[dim]+ {quiet_n} file(s) changed with no flags[/dim]")
|
|
275
|
+
console.print(
|
|
276
|
+
"[dim]→ whycode why <path> for the full Risk Card on any of the above[/dim]"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if threshold is not None:
|
|
280
|
+
breaches = [c for c in cards if c.score.value >= threshold]
|
|
281
|
+
if breaches:
|
|
282
|
+
err.print(
|
|
283
|
+
f"[red]fail-on:[/red] {len(breaches)} file(s) at or above "
|
|
284
|
+
f"[bold]{fail_on}[/bold] (≥{threshold})."
|
|
285
|
+
)
|
|
286
|
+
raise typer.Exit(1)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _sample_indices(total: int, max_samples: int) -> list[int]:
|
|
290
|
+
"""Pick at most ``max_samples`` indices spread across [0, total).
|
|
291
|
+
|
|
292
|
+
Always includes both endpoints when there are at least two items, so the
|
|
293
|
+
timeline shows the file's first and most recent state.
|
|
294
|
+
"""
|
|
295
|
+
if total <= max_samples:
|
|
296
|
+
return list(range(total))
|
|
297
|
+
if max_samples < 2:
|
|
298
|
+
return [total - 1]
|
|
299
|
+
step = (total - 1) / (max_samples - 1)
|
|
300
|
+
return sorted({round(i * step) for i in range(max_samples)})
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.command()
|
|
304
|
+
def timeline(
|
|
305
|
+
path: str = typer.Argument(..., help="File path to inspect."),
|
|
306
|
+
samples: int = typer.Option(
|
|
307
|
+
15, "--samples", help="Maximum number of points sampled across history."
|
|
308
|
+
),
|
|
309
|
+
json_out: bool = typer.Option(
|
|
310
|
+
False, "--json", help="Emit machine-readable JSON instead of a table."
|
|
311
|
+
),
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Show how this file's risk score evolved over its history."""
|
|
314
|
+
repo_root, rel = _resolve_repo_and_path(path)
|
|
315
|
+
if not _path_is_known_to_git(repo_root, rel):
|
|
316
|
+
err.print(
|
|
317
|
+
f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
|
|
318
|
+
f"and has no history in this repo."
|
|
319
|
+
)
|
|
320
|
+
raise typer.Exit(1)
|
|
321
|
+
|
|
322
|
+
commits = gf.commits_for_path(repo_root, rel)
|
|
323
|
+
if not commits:
|
|
324
|
+
console.print(f"[yellow]no history for {rel}[/yellow]")
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
# Order chronologically (oldest first) so the table reads left-to-right.
|
|
328
|
+
chronological = list(reversed(commits))
|
|
329
|
+
indices = _sample_indices(len(chronological), samples)
|
|
330
|
+
sampled = [chronological[i] for i in indices]
|
|
331
|
+
|
|
332
|
+
rows: list[tuple[str, str, int, str, str]] = []
|
|
333
|
+
with console.status(
|
|
334
|
+
f"Computing risk at {len(sampled)} points…", spinner="dots"
|
|
335
|
+
):
|
|
336
|
+
for c in sampled:
|
|
337
|
+
try:
|
|
338
|
+
card = rc.build(repo_root, rel, ref=c.sha)
|
|
339
|
+
except gf.GitError:
|
|
340
|
+
continue
|
|
341
|
+
top = card.signals[0].headline if card.signals else "—"
|
|
342
|
+
rows.append(
|
|
343
|
+
(
|
|
344
|
+
str(c.authored_at.date()),
|
|
345
|
+
c.sha[:7],
|
|
346
|
+
card.score.value,
|
|
347
|
+
card.score.band.value,
|
|
348
|
+
top,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if json_out:
|
|
353
|
+
console.print_json(
|
|
354
|
+
json.dumps(
|
|
355
|
+
{
|
|
356
|
+
"path": rel,
|
|
357
|
+
"samples": [
|
|
358
|
+
{
|
|
359
|
+
"date": r[0],
|
|
360
|
+
"sha": r[1],
|
|
361
|
+
"score": r[2],
|
|
362
|
+
"band": r[3],
|
|
363
|
+
"top_signal": r[4],
|
|
364
|
+
}
|
|
365
|
+
for r in rows
|
|
366
|
+
],
|
|
367
|
+
}
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
table = Table(title=f"Risk timeline for {rel}")
|
|
373
|
+
table.add_column("date")
|
|
374
|
+
table.add_column("sha")
|
|
375
|
+
table.add_column("score", justify="right", style="bold")
|
|
376
|
+
table.add_column("band")
|
|
377
|
+
table.add_column("top signal")
|
|
378
|
+
for date_s, sha_s, score_v, band_s, top_s in rows:
|
|
379
|
+
table.add_row(date_s, sha_s, str(score_v), band_s, top_s)
|
|
380
|
+
console.print(table)
|
|
381
|
+
console.print(
|
|
382
|
+
f"[dim]{len(commits)} commit(s) total; sampled {len(rows)}. "
|
|
383
|
+
f"Use --samples N to change.[/dim]"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@app.command()
|
|
388
|
+
def scan(
|
|
389
|
+
top: int = typer.Option(10, "--top", help="How many files to list."),
|
|
390
|
+
sample: int = typer.Option(
|
|
391
|
+
500,
|
|
392
|
+
"--sample",
|
|
393
|
+
help="Cap on tracked files to evaluate (for very large repos).",
|
|
394
|
+
),
|
|
395
|
+
repo: Path = typer.Option(
|
|
396
|
+
Path("."), "--repo", help="Path inside the repo (defaults to cwd)."
|
|
397
|
+
),
|
|
398
|
+
) -> None:
|
|
399
|
+
"""List the top-N files with the highest risk scores in the repo."""
|
|
400
|
+
try:
|
|
401
|
+
repo_root = gf.discover_repo_root(repo.resolve())
|
|
402
|
+
except gf.GitError as exc:
|
|
403
|
+
err.print(f"[red]error:[/red] {exc}")
|
|
404
|
+
raise typer.Exit(2) from exc
|
|
405
|
+
|
|
406
|
+
raw = gf._run_git(repo_root, "ls-files")
|
|
407
|
+
paths = [line for line in raw.splitlines() if line.strip()][:sample]
|
|
408
|
+
if not paths:
|
|
409
|
+
console.print("[yellow]no tracked files found[/yellow]")
|
|
410
|
+
raise typer.Exit(0)
|
|
411
|
+
|
|
412
|
+
cards: list[rc.RiskCard] = []
|
|
413
|
+
with console.status(f"Scanning {len(paths)} files…", spinner="dots"):
|
|
414
|
+
for p in paths:
|
|
415
|
+
try:
|
|
416
|
+
card = rc.build(repo_root, p)
|
|
417
|
+
except gf.GitError:
|
|
418
|
+
continue
|
|
419
|
+
# Skip files whose only signal is NEWBORN — that's "not enough
|
|
420
|
+
# history yet", not real risk. `scan` is for surfacing risk;
|
|
421
|
+
# informational signals don't belong here.
|
|
422
|
+
useful = [s for s in card.signals if s.kind is not sig.SignalKind.NEWBORN]
|
|
423
|
+
if useful:
|
|
424
|
+
cards.append(card)
|
|
425
|
+
|
|
426
|
+
cards.sort(key=lambda c: -c.score.value)
|
|
427
|
+
top_cards = cards[:top]
|
|
428
|
+
if not top_cards:
|
|
429
|
+
# Be honest about what "no flagged files" actually means. A user who
|
|
430
|
+
# just installed WhyCode and sees a one-line "nothing fired" walks away
|
|
431
|
+
# thinking the tool is broken. Spell out the two real possibilities.
|
|
432
|
+
console.print(
|
|
433
|
+
f"[green]No flagged files among {len(paths)} scanned.[/green]\n\n"
|
|
434
|
+
"WhyCode reads commit messages, reverts and authorship to find risk.\n"
|
|
435
|
+
"A clean output means [bold]one of:[/bold]\n"
|
|
436
|
+
" • Your repo's history is genuinely quiet, or\n"
|
|
437
|
+
" • Commits are too terse for WhyCode to learn from "
|
|
438
|
+
"(e.g. only \"fix\", \"update\", \"wip\")."
|
|
439
|
+
)
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
table = Table(title=f"Top {len(top_cards)} flagged files")
|
|
443
|
+
table.add_column("score", justify="right", style="bold")
|
|
444
|
+
table.add_column("band")
|
|
445
|
+
table.add_column("path")
|
|
446
|
+
table.add_column("top signal")
|
|
447
|
+
for c in top_cards:
|
|
448
|
+
top_signal = c.signals[0].headline if c.signals else "—"
|
|
449
|
+
table.add_row(str(c.score.value), c.score.band.value, c.path, top_signal)
|
|
450
|
+
console.print(table)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@app.command()
|
|
454
|
+
def honest(
|
|
455
|
+
path: str = typer.Argument(..., help="File path to inspect."),
|
|
456
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON instead of prose."),
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Print every invariant line in this file's history, verbatim and untruncated.
|
|
459
|
+
|
|
460
|
+
Use when the Risk Card's first-sentence truncation is hiding important
|
|
461
|
+
context — e.g., a commit whose constraint is stated across two lines.
|
|
462
|
+
"""
|
|
463
|
+
repo_root, rel = _resolve_repo_and_path(path)
|
|
464
|
+
if not _path_is_known_to_git(repo_root, rel):
|
|
465
|
+
err.print(
|
|
466
|
+
f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
|
|
467
|
+
f"and has no history in this repo."
|
|
468
|
+
)
|
|
469
|
+
raise typer.Exit(1)
|
|
470
|
+
facts = gf.gather(repo_root, rel)
|
|
471
|
+
if not facts.invariant_quotes:
|
|
472
|
+
if json_out:
|
|
473
|
+
console.print_json(json.dumps({"path": rel, "invariants": []}))
|
|
474
|
+
else:
|
|
475
|
+
console.print(
|
|
476
|
+
f"[dim]No invariants found in the history of {rel}.[/dim]"
|
|
477
|
+
)
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
sha_to_commit: dict[str, gf.Commit] = {c.sha: c for c in facts.commits}
|
|
481
|
+
grouped: dict[str, list[str]] = {}
|
|
482
|
+
sha_order: list[str] = []
|
|
483
|
+
for sha, line in facts.invariant_quotes:
|
|
484
|
+
if sha not in grouped:
|
|
485
|
+
grouped[sha] = []
|
|
486
|
+
sha_order.append(sha)
|
|
487
|
+
grouped[sha].append(line)
|
|
488
|
+
|
|
489
|
+
if json_out:
|
|
490
|
+
invariants = []
|
|
491
|
+
for sha in sha_order:
|
|
492
|
+
entry: dict[str, Any] = {"sha": sha[:12], "lines": grouped[sha]}
|
|
493
|
+
commit = sha_to_commit.get(sha)
|
|
494
|
+
if commit is not None:
|
|
495
|
+
entry["subject"] = commit.subject
|
|
496
|
+
entry["author"] = commit.author_name
|
|
497
|
+
entry["authored_at"] = commit.authored_at.isoformat()
|
|
498
|
+
invariants.append(entry)
|
|
499
|
+
console.print_json(json.dumps({"path": rel, "invariants": invariants}))
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
total = sum(len(v) for v in grouped.values())
|
|
503
|
+
console.print(
|
|
504
|
+
f"[bold]{total} invariant line(s) across {len(sha_order)} commit(s) "
|
|
505
|
+
f"in {rel}:[/bold]\n"
|
|
506
|
+
)
|
|
507
|
+
for sha in sha_order:
|
|
508
|
+
commit = sha_to_commit.get(sha)
|
|
509
|
+
if commit is not None:
|
|
510
|
+
header = (
|
|
511
|
+
f"[bold]{sha[:7]}[/bold] {commit.authored_at.date()} "
|
|
512
|
+
f"{commit.author_name} · {commit.subject}"
|
|
513
|
+
)
|
|
514
|
+
else:
|
|
515
|
+
header = f"[bold]{sha[:7]}[/bold]"
|
|
516
|
+
console.print(header)
|
|
517
|
+
for line in grouped[sha]:
|
|
518
|
+
console.print(f" > {line}")
|
|
519
|
+
console.print()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@app.command()
|
|
523
|
+
def show(
|
|
524
|
+
sha: str = typer.Argument(..., help="Commit SHA (full or short) to inspect."),
|
|
525
|
+
repo: Path = typer.Option(Path("."), "--repo", help="Path inside the repo."),
|
|
526
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON instead of a card."),
|
|
527
|
+
) -> None:
|
|
528
|
+
"""Risk-flavored summary for a single commit: classification + per-file risk."""
|
|
529
|
+
try:
|
|
530
|
+
repo_root = gf.discover_repo_root(repo.resolve())
|
|
531
|
+
full_sha = gf._run_git(repo_root, "rev-parse", "--verify", f"{sha}^{{commit}}").strip()
|
|
532
|
+
except gf.GitError as exc:
|
|
533
|
+
err.print(f"[red]error:[/red] {exc}")
|
|
534
|
+
raise typer.Exit(2) from exc
|
|
535
|
+
|
|
536
|
+
raw = gf._run_git(
|
|
537
|
+
repo_root, "log", "-1", "--no-merges", f"--pretty=format:{gf._log_format()}", full_sha
|
|
538
|
+
)
|
|
539
|
+
commits = gf._parse_log_records(raw)
|
|
540
|
+
if not commits:
|
|
541
|
+
err.print(f"[red]error:[/red] could not read commit {full_sha}")
|
|
542
|
+
raise typer.Exit(2)
|
|
543
|
+
commit = commits[0]
|
|
544
|
+
|
|
545
|
+
is_incident = bool(
|
|
546
|
+
gf._INCIDENT_RE.search(commit.subject + "\n" + commit.body)
|
|
547
|
+
or gf._BREAKING_CC_RE.search(commit.subject)
|
|
548
|
+
)
|
|
549
|
+
invariants = gf.extract_invariant_quotes([commit])
|
|
550
|
+
file_changes = gf.files_changed_in(repo_root, full_sha)
|
|
551
|
+
|
|
552
|
+
cards: list[rc.RiskCard] = []
|
|
553
|
+
for change in file_changes:
|
|
554
|
+
try:
|
|
555
|
+
cards.append(rc.build(repo_root, change.path))
|
|
556
|
+
except gf.GitError:
|
|
557
|
+
continue
|
|
558
|
+
cards.sort(key=lambda c: -c.score.value)
|
|
559
|
+
|
|
560
|
+
if json_out:
|
|
561
|
+
console.print_json(
|
|
562
|
+
json.dumps(
|
|
563
|
+
{
|
|
564
|
+
"sha": full_sha[:12],
|
|
565
|
+
"subject": commit.subject,
|
|
566
|
+
"author": commit.author_name,
|
|
567
|
+
"authored_at": commit.authored_at.isoformat(),
|
|
568
|
+
"incident_flavored": is_incident,
|
|
569
|
+
"invariants_stated": len(invariants),
|
|
570
|
+
"files_changed": len(file_changes),
|
|
571
|
+
"files": [c.to_dict() for c in cards],
|
|
572
|
+
}
|
|
573
|
+
)
|
|
574
|
+
)
|
|
575
|
+
return
|
|
576
|
+
|
|
577
|
+
console.print(
|
|
578
|
+
f"[bold]{full_sha[:12]}[/bold] {commit.author_name} "
|
|
579
|
+
f"{commit.authored_at.date()}"
|
|
580
|
+
)
|
|
581
|
+
console.print(f" {commit.subject}")
|
|
582
|
+
console.print()
|
|
583
|
+
classification = []
|
|
584
|
+
if is_incident:
|
|
585
|
+
classification.append("[bold red]incident-flavored[/bold red]")
|
|
586
|
+
if invariants:
|
|
587
|
+
classification.append(f"[yellow]states {len(invariants)} invariant(s)[/yellow]")
|
|
588
|
+
if not classification:
|
|
589
|
+
classification.append("[dim]no special classification[/dim]")
|
|
590
|
+
console.print(" " + " ".join(classification))
|
|
591
|
+
console.print(f" [dim]{len(file_changes)} files changed[/dim]")
|
|
592
|
+
|
|
593
|
+
if not cards:
|
|
594
|
+
return
|
|
595
|
+
table = Table(title="Files in this commit, by current risk")
|
|
596
|
+
table.add_column("score", justify="right", style="bold")
|
|
597
|
+
table.add_column("band")
|
|
598
|
+
table.add_column("path")
|
|
599
|
+
table.add_column("top signal")
|
|
600
|
+
for c in cards[:20]:
|
|
601
|
+
top = c.signals[0].headline if c.signals else "—"
|
|
602
|
+
table.add_row(str(c.score.value), c.score.band.value, c.path, top)
|
|
603
|
+
console.print(table)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _install_template(
|
|
607
|
+
template_name: str,
|
|
608
|
+
dst: Path,
|
|
609
|
+
repo_root: Path,
|
|
610
|
+
*,
|
|
611
|
+
force: bool,
|
|
612
|
+
executable: bool,
|
|
613
|
+
) -> str:
|
|
614
|
+
"""Copy a packaged template to ``dst``. Returns a one-line status."""
|
|
615
|
+
from importlib.resources import files
|
|
616
|
+
|
|
617
|
+
rel_label = str(dst.relative_to(repo_root)) if dst.is_relative_to(repo_root) else str(dst)
|
|
618
|
+
if dst.exists() and not force:
|
|
619
|
+
return f"[dim]skipped:[/dim] {rel_label} (exists; use --force to overwrite)"
|
|
620
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
621
|
+
payload = (files("whycode") / "templates" / template_name).read_text()
|
|
622
|
+
dst.write_text(payload)
|
|
623
|
+
if executable:
|
|
624
|
+
dst.chmod(0o755)
|
|
625
|
+
return f"[green]wrote:[/green] {rel_label}"
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
@app.command()
|
|
629
|
+
def init(
|
|
630
|
+
force: bool = typer.Option(
|
|
631
|
+
False, "--force", "-f", help="Overwrite existing files instead of skipping."
|
|
632
|
+
),
|
|
633
|
+
repo: Path = typer.Option(
|
|
634
|
+
Path("."), "--repo", help="Path inside the repo (defaults to cwd)."
|
|
635
|
+
),
|
|
636
|
+
) -> None:
|
|
637
|
+
"""One-command setup: install CI risk gate + local pre-commit hook."""
|
|
638
|
+
try:
|
|
639
|
+
repo_root = gf.discover_repo_root(repo.resolve())
|
|
640
|
+
except gf.GitError as exc:
|
|
641
|
+
err.print(f"[red]error:[/red] {exc}")
|
|
642
|
+
raise typer.Exit(2) from exc
|
|
643
|
+
|
|
644
|
+
workflow_status = _install_template(
|
|
645
|
+
"github-workflow.yml",
|
|
646
|
+
repo_root / ".github" / "workflows" / "whycode.yml",
|
|
647
|
+
repo_root,
|
|
648
|
+
force=force,
|
|
649
|
+
executable=False,
|
|
650
|
+
)
|
|
651
|
+
hook_status = _install_template(
|
|
652
|
+
"pre-commit",
|
|
653
|
+
repo_root / ".git" / "hooks" / "pre-commit",
|
|
654
|
+
repo_root,
|
|
655
|
+
force=force,
|
|
656
|
+
executable=True,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
console.print(workflow_status)
|
|
660
|
+
console.print(hook_status)
|
|
661
|
+
console.print()
|
|
662
|
+
console.print("[bold]WhyCode is wired into this repo.[/bold]")
|
|
663
|
+
console.print(
|
|
664
|
+
" [dim]local[/dim] pre-commit blocks HANDLE WITH CARE commits "
|
|
665
|
+
"(`git commit --no-verify` to bypass)"
|
|
666
|
+
)
|
|
667
|
+
console.print(
|
|
668
|
+
" [dim]ci[/dim] .github/workflows/whycode.yml gates PRs "
|
|
669
|
+
"(commit + push the workflow file to enable)"
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
@app.command()
|
|
674
|
+
def mcp(
|
|
675
|
+
verbose: bool = typer.Option(
|
|
676
|
+
False,
|
|
677
|
+
"--verbose",
|
|
678
|
+
"-v",
|
|
679
|
+
help="Log every tool call to stderr so you can verify the AI uses it.",
|
|
680
|
+
),
|
|
681
|
+
) -> None:
|
|
682
|
+
"""Start the MCP stdio server."""
|
|
683
|
+
try:
|
|
684
|
+
from whycode.mcp_server import serve
|
|
685
|
+
except ImportError as exc:
|
|
686
|
+
err.print(
|
|
687
|
+
"[red]error:[/red] MCP support is not installed. "
|
|
688
|
+
"Run [bold]pip install 'whycode[mcp]'[/bold]."
|
|
689
|
+
)
|
|
690
|
+
raise typer.Exit(2) from exc
|
|
691
|
+
serve(verbose=verbose)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@app.command()
|
|
695
|
+
def version() -> None:
|
|
696
|
+
"""Print the installed WhyCode version."""
|
|
697
|
+
console.print(__version__)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def main() -> None:
|
|
701
|
+
"""Entry-point used by ``python -m whycode`` and tests."""
|
|
702
|
+
try:
|
|
703
|
+
app()
|
|
704
|
+
except KeyboardInterrupt:
|
|
705
|
+
sys.exit(130)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
if __name__ == "__main__":
|
|
709
|
+
main()
|