whycode-cli 0.2.5__tar.gz → 0.3.0__tar.gz
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_cli-0.2.5/src/whycode_cli.egg-info → whycode_cli-0.3.0}/PKG-INFO +30 -6
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/README.md +27 -5
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/pyproject.toml +2 -1
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/__init__.py +1 -1
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/cli.py +186 -0
- whycode_cli-0.3.0/src/whycode/decisions.py +219 -0
- whycode_cli-0.3.0/src/whycode/llm.py +112 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/risk_card.py +41 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0/src/whycode_cli.egg-info}/PKG-INFO +30 -6
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode_cli.egg-info/SOURCES.txt +3 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode_cli.egg-info/requires.txt +3 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/tests/test_cli.py +48 -0
- whycode_cli-0.3.0/tests/test_decisions.py +214 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/LICENSE +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/setup.cfg +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/__main__.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/git_facts.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/ignore.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/mcp_server.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/scorer.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/signals.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/suppressions.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/templates/__init__.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/templates/github-workflow.yml +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode/templates/pre-commit +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode_cli.egg-info/dependency_links.txt +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode_cli.egg-info/entry_points.txt +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/src/whycode_cli.egg-info/top_level.txt +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/tests/test_git_facts.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/tests/test_ignore.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/tests/test_scorer.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/tests/test_signals.py +0 -0
- {whycode_cli-0.2.5 → whycode_cli-0.3.0}/tests/test_suppressions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: whycode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Tells you what to be afraid of before you touch a file.
|
|
5
5
|
Author: Kevin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,6 +19,8 @@ Requires-Dist: typer>=0.12
|
|
|
19
19
|
Requires-Dist: rich>=13.7
|
|
20
20
|
Provides-Extra: mcp
|
|
21
21
|
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
22
|
+
Provides-Extra: llm
|
|
23
|
+
Requires-Dist: anthropic>=0.40; extra == "llm"
|
|
22
24
|
Provides-Extra: dev
|
|
23
25
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
26
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
@@ -87,8 +89,9 @@ Requires Python 3.11+.
|
|
|
87
89
|
```bash
|
|
88
90
|
cd /path/to/your/repo
|
|
89
91
|
|
|
92
|
+
whycode tour # the one command to run first
|
|
90
93
|
whycode init # one-command setup: CI workflow + pre-commit gate
|
|
91
|
-
whycode highlights #
|
|
94
|
+
whycode highlights # repo-wide treasure map: top decisions + incidents
|
|
92
95
|
whycode why src/some/file.py # the Risk Card for one file
|
|
93
96
|
whycode why src/some/file.py -b # one-line summary (for triage / scripts)
|
|
94
97
|
whycode why src/some/file.py --at <sha> # risk as of a past commit
|
|
@@ -196,11 +199,32 @@ Tune the thresholds inside those two files for your repo. Re-run with
|
|
|
196
199
|
| ----- | ------------------------------------------------------------------------ | -------- | -------- |
|
|
197
200
|
| 1 | Deterministic git facts (log, diffstat, revert pairs, author activity) | no | no |
|
|
198
201
|
| 2 | Heuristic signals (reverts, incidents, silence, ghost keeper, coupling, invariants, churn, newborn) | no | no |
|
|
199
|
-
| 3 | LLM
|
|
202
|
+
| 3 | LLM-extracted structured decisions (optional, opt-in, never on by default) | yes | yes |
|
|
200
203
|
|
|
201
|
-
**Layer 1 + Layer 2 produce the Risk Card
|
|
202
|
-
data leaving your machine.** Layer 3
|
|
203
|
-
|
|
204
|
+
**Layer 1 + Layer 2 produce the Risk Card by default. No model calls, no
|
|
205
|
+
data leaving your machine.** Layer 3 lifts the keyword fragments L1 + L2
|
|
206
|
+
extract ("do not switch to async") into structured decisions with the
|
|
207
|
+
*why* drawn from the surrounding commit body — but only when you ask for
|
|
208
|
+
it with `--llm`.
|
|
209
|
+
|
|
210
|
+
### Optional L3 — LLM-enriched decisions
|
|
211
|
+
|
|
212
|
+
Install the optional extras and configure the env vars:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
pip install 'whycode-cli[llm]'
|
|
216
|
+
export WHYCODE_LLM_API_KEY="…"
|
|
217
|
+
export WHYCODE_LLM_MODEL="<your-provider's-model-identifier>"
|
|
218
|
+
|
|
219
|
+
whycode why src/some/file.py --llm # full card + structured decisions
|
|
220
|
+
whycode why src/some/file.py --llm-dry-run # see exactly what would be sent
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Privacy contract: configuration is entirely environment-driven (no
|
|
224
|
+
hardcoded provider in the source tree); the SDK is lazy-imported (no
|
|
225
|
+
import cost unless you opt in); only L2-filtered high-signal commits
|
|
226
|
+
are sent (capped at 10 per call); a malformed model response degrades
|
|
227
|
+
to "no decisions" rather than crashing.
|
|
204
228
|
|
|
205
229
|
## What this is NOT
|
|
206
230
|
|
|
@@ -59,8 +59,9 @@ Requires Python 3.11+.
|
|
|
59
59
|
```bash
|
|
60
60
|
cd /path/to/your/repo
|
|
61
61
|
|
|
62
|
+
whycode tour # the one command to run first
|
|
62
63
|
whycode init # one-command setup: CI workflow + pre-commit gate
|
|
63
|
-
whycode highlights #
|
|
64
|
+
whycode highlights # repo-wide treasure map: top decisions + incidents
|
|
64
65
|
whycode why src/some/file.py # the Risk Card for one file
|
|
65
66
|
whycode why src/some/file.py -b # one-line summary (for triage / scripts)
|
|
66
67
|
whycode why src/some/file.py --at <sha> # risk as of a past commit
|
|
@@ -168,11 +169,32 @@ Tune the thresholds inside those two files for your repo. Re-run with
|
|
|
168
169
|
| ----- | ------------------------------------------------------------------------ | -------- | -------- |
|
|
169
170
|
| 1 | Deterministic git facts (log, diffstat, revert pairs, author activity) | no | no |
|
|
170
171
|
| 2 | Heuristic signals (reverts, incidents, silence, ghost keeper, coupling, invariants, churn, newborn) | no | no |
|
|
171
|
-
| 3 | LLM
|
|
172
|
+
| 3 | LLM-extracted structured decisions (optional, opt-in, never on by default) | yes | yes |
|
|
172
173
|
|
|
173
|
-
**Layer 1 + Layer 2 produce the Risk Card
|
|
174
|
-
data leaving your machine.** Layer 3
|
|
175
|
-
|
|
174
|
+
**Layer 1 + Layer 2 produce the Risk Card by default. No model calls, no
|
|
175
|
+
data leaving your machine.** Layer 3 lifts the keyword fragments L1 + L2
|
|
176
|
+
extract ("do not switch to async") into structured decisions with the
|
|
177
|
+
*why* drawn from the surrounding commit body — but only when you ask for
|
|
178
|
+
it with `--llm`.
|
|
179
|
+
|
|
180
|
+
### Optional L3 — LLM-enriched decisions
|
|
181
|
+
|
|
182
|
+
Install the optional extras and configure the env vars:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
pip install 'whycode-cli[llm]'
|
|
186
|
+
export WHYCODE_LLM_API_KEY="…"
|
|
187
|
+
export WHYCODE_LLM_MODEL="<your-provider's-model-identifier>"
|
|
188
|
+
|
|
189
|
+
whycode why src/some/file.py --llm # full card + structured decisions
|
|
190
|
+
whycode why src/some/file.py --llm-dry-run # see exactly what would be sent
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Privacy contract: configuration is entirely environment-driven (no
|
|
194
|
+
hardcoded provider in the source tree); the SDK is lazy-imported (no
|
|
195
|
+
import cost unless you opt in); only L2-filtered high-signal commits
|
|
196
|
+
are sent (capped at 10 per call); a malformed model response degrades
|
|
197
|
+
to "no decisions" rather than crashing.
|
|
176
198
|
|
|
177
199
|
## What this is NOT
|
|
178
200
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "whycode-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Tells you what to be afraid of before you touch a file."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -26,6 +26,7 @@ dependencies = [
|
|
|
26
26
|
|
|
27
27
|
[project.optional-dependencies]
|
|
28
28
|
mcp = ["mcp>=1.0"]
|
|
29
|
+
llm = ["anthropic>=0.40"]
|
|
29
30
|
dev = [
|
|
30
31
|
"pytest>=8",
|
|
31
32
|
"pytest-cov>=5",
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Commands
|
|
4
4
|
--------
|
|
5
|
+
- ``whycode tour`` — first-run walkthrough: highlights + top risk + MCP setup.
|
|
5
6
|
- ``whycode why <path>`` — print the Risk Card for a single file.
|
|
6
7
|
- ``whycode why <path> --at SHA`` — risk card as of a past commit.
|
|
7
8
|
- ``whycode why <path> --mute KIND`` — locally suppress a noisy signal kind.
|
|
9
|
+
- ``whycode why <path> --llm`` — opt-in L3: LLM-extracted structured decisions.
|
|
8
10
|
- ``whycode highlights`` — repo-wide treasure map of decisions and incidents.
|
|
9
11
|
- ``whycode diff [--base REF]`` — risk-rank files changed against a base ref.
|
|
10
12
|
- ``whycode show <sha>`` — risk-flavored summary for one commit.
|
|
@@ -154,6 +156,20 @@ def why(
|
|
|
154
156
|
"--no-mutes",
|
|
155
157
|
help="Bypass the local suppression list — show all signals.",
|
|
156
158
|
),
|
|
159
|
+
llm: bool = typer.Option(
|
|
160
|
+
False,
|
|
161
|
+
"--llm",
|
|
162
|
+
help=(
|
|
163
|
+
"Enrich the card with LLM-extracted structured decisions "
|
|
164
|
+
"(L3, opt-in, requires WHYCODE_LLM_API_KEY + WHYCODE_LLM_MODEL). "
|
|
165
|
+
"Sends only commits already filtered by L2 — see --llm-dry-run."
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
llm_dry_run: bool = typer.Option(
|
|
169
|
+
False,
|
|
170
|
+
"--llm-dry-run",
|
|
171
|
+
help="Show exactly what would be sent to the LLM without making the call.",
|
|
172
|
+
),
|
|
157
173
|
max_commits: int | None = typer.Option(
|
|
158
174
|
None, "--max-commits", help="Cap the number of commits scanned (debug)."
|
|
159
175
|
),
|
|
@@ -194,6 +210,51 @@ def why(
|
|
|
194
210
|
ref=resolved_ref,
|
|
195
211
|
apply_suppressions=not no_mutes,
|
|
196
212
|
)
|
|
213
|
+
|
|
214
|
+
if llm or llm_dry_run:
|
|
215
|
+
from whycode import decisions as dec
|
|
216
|
+
|
|
217
|
+
# Pick high-signal commits for L3: incidents take priority, plus
|
|
218
|
+
# any commit with a substantial body. Cap to keep the prompt small.
|
|
219
|
+
facts = gf.gather(repo_root, rel, max_commits=max_commits, ref=resolved_ref)
|
|
220
|
+
candidates = list(facts.incident_commits)
|
|
221
|
+
for c in facts.commits:
|
|
222
|
+
if c not in candidates and len(c.body) >= 100:
|
|
223
|
+
candidates.append(c)
|
|
224
|
+
if len(candidates) >= dec.DEFAULT_MAX_COMMITS:
|
|
225
|
+
break
|
|
226
|
+
candidates = candidates[: dec.DEFAULT_MAX_COMMITS]
|
|
227
|
+
n_commits, prompt_chars = dec.estimate_payload(candidates)
|
|
228
|
+
|
|
229
|
+
if llm_dry_run:
|
|
230
|
+
err.print(
|
|
231
|
+
f"[bold]LLM dry-run:[/bold] would send "
|
|
232
|
+
f"[bold]{n_commits}[/bold] commit(s), "
|
|
233
|
+
f"[bold]~{prompt_chars}[/bold] chars to the configured LLM provider.\n"
|
|
234
|
+
f" [dim]Provider, model, and key all read from "
|
|
235
|
+
f"WHYCODE_LLM_* environment variables.[/dim]"
|
|
236
|
+
)
|
|
237
|
+
if not json_out:
|
|
238
|
+
console.print(rc.render_text(card))
|
|
239
|
+
else:
|
|
240
|
+
console.print_json(json.dumps(card.to_dict()))
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
if n_commits == 0:
|
|
244
|
+
err.print(
|
|
245
|
+
"[yellow]--llm:[/yellow] no high-signal commits to enrich on this file."
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
try:
|
|
249
|
+
decisions = dec.extract_decisions(candidates)
|
|
250
|
+
except dec.LLMConfigError as exc:
|
|
251
|
+
err.print(f"[red]--llm config error:[/red] {exc}")
|
|
252
|
+
raise typer.Exit(2) from exc
|
|
253
|
+
except dec.LLMCallError as exc:
|
|
254
|
+
err.print(f"[red]--llm call failed:[/red] {exc}")
|
|
255
|
+
raise typer.Exit(2) from exc
|
|
256
|
+
card = card.with_decisions(tuple(decisions))
|
|
257
|
+
|
|
197
258
|
if json_out:
|
|
198
259
|
console.print_json(json.dumps(card.to_dict()))
|
|
199
260
|
return
|
|
@@ -845,6 +906,131 @@ def _install_template(
|
|
|
845
906
|
return f"[green]wrote:[/green] {rel_label}"
|
|
846
907
|
|
|
847
908
|
|
|
909
|
+
_MCP_SNIPPET = ''' {
|
|
910
|
+
"mcpServers": {
|
|
911
|
+
"whycode": {"command": "whycode", "args": ["mcp"]}
|
|
912
|
+
}
|
|
913
|
+
}'''
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@app.command()
|
|
917
|
+
def tour(
|
|
918
|
+
repo: Path = typer.Option(Path("."), "--repo", help="Path inside the repo."),
|
|
919
|
+
) -> None:
|
|
920
|
+
"""First-run walkthrough: highlights + top risky files + MCP setup snippet.
|
|
921
|
+
|
|
922
|
+
The single command to run after installing WhyCode. Skips straight to
|
|
923
|
+
the most concrete things in the repo (verbatim invariants and
|
|
924
|
+
incident-flagged commits) and ends with the one snippet you'll need to
|
|
925
|
+
wire WhyCode into an MCP-aware editor.
|
|
926
|
+
"""
|
|
927
|
+
try:
|
|
928
|
+
repo_root = gf.discover_repo_root(repo.resolve())
|
|
929
|
+
except gf.GitError as exc:
|
|
930
|
+
err.print(f"[red]error:[/red] {exc}")
|
|
931
|
+
raise typer.Exit(2) from exc
|
|
932
|
+
|
|
933
|
+
console.print("[bold]Welcome to WhyCode.[/bold]")
|
|
934
|
+
console.print(f"[dim]Reading the history of {repo_root.name}…[/dim]\n")
|
|
935
|
+
|
|
936
|
+
# Section 1 — invariants and incidents (cheap; one git log call).
|
|
937
|
+
with console.status("Looking for stated decisions…", spinner="dots"):
|
|
938
|
+
commits = gf.all_commits(repo_root, max_count=2000)
|
|
939
|
+
if not commits:
|
|
940
|
+
console.print("[yellow]This repo has no commits yet — nothing to learn from.[/yellow]")
|
|
941
|
+
return
|
|
942
|
+
|
|
943
|
+
inv_pairs = gf.extract_invariant_quotes(commits)
|
|
944
|
+
sha_to_commit = {c.sha: c for c in commits}
|
|
945
|
+
seen_lines: dict[str, str] = {}
|
|
946
|
+
for sha, line in inv_pairs:
|
|
947
|
+
seen_lines.setdefault(line, sha)
|
|
948
|
+
invariants_top = [
|
|
949
|
+
(line, sha_to_commit[sha])
|
|
950
|
+
for line, sha in seen_lines.items()
|
|
951
|
+
if sha in sha_to_commit
|
|
952
|
+
][:3]
|
|
953
|
+
incidents_top = gf.find_incidents(commits)[:3]
|
|
954
|
+
|
|
955
|
+
if invariants_top or incidents_top:
|
|
956
|
+
console.print("[bold yellow]Decisions and incidents[/bold yellow]")
|
|
957
|
+
for line, c in invariants_top:
|
|
958
|
+
console.print(f" [italic]{line}[/italic]")
|
|
959
|
+
console.print(
|
|
960
|
+
f" [dim]{c.sha[:7]} {c.authored_at.date()} {c.author_name}[/dim]\n"
|
|
961
|
+
)
|
|
962
|
+
for c in incidents_top:
|
|
963
|
+
subj = c.subject if len(c.subject) <= 70 else c.subject[:69] + "…"
|
|
964
|
+
console.print(f" [red]{subj}[/red]")
|
|
965
|
+
console.print(
|
|
966
|
+
f" [dim]{c.sha[:7]} {c.authored_at.date()} {c.author_name}[/dim]\n"
|
|
967
|
+
)
|
|
968
|
+
else:
|
|
969
|
+
console.print(
|
|
970
|
+
"[dim]No headline decisions or incidents in recent history.[/dim]"
|
|
971
|
+
)
|
|
972
|
+
console.print(
|
|
973
|
+
"[dim]Commit messages may be too terse — describing 'why' in commit "
|
|
974
|
+
"bodies (or using `hotfix:` / `BREAKING CHANGE:` prefixes) makes WhyCode "
|
|
975
|
+
"much more useful.[/dim]\n"
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
# Section 2 — top risky files. Slimmer scan: 100 files, depth 50 commits.
|
|
979
|
+
raw = gf.run_git(repo_root, "ls-files")
|
|
980
|
+
patterns = ign.effective_patterns(repo_root)
|
|
981
|
+
paths = [p for p in raw.splitlines() if p.strip() and not ign.is_ignored(p, patterns)][
|
|
982
|
+
:100
|
|
983
|
+
]
|
|
984
|
+
cards: list[rc.RiskCard] = []
|
|
985
|
+
if paths:
|
|
986
|
+
with console.status(
|
|
987
|
+
f"Risk-ranking {len(paths)} files (slim scan)…", spinner="dots"
|
|
988
|
+
):
|
|
989
|
+
for p in paths:
|
|
990
|
+
try:
|
|
991
|
+
card = rc.build(repo_root, p, max_commits=50)
|
|
992
|
+
except gf.GitError:
|
|
993
|
+
continue
|
|
994
|
+
useful = [s for s in card.signals if s.kind is not sig.SignalKind.NEWBORN]
|
|
995
|
+
if useful:
|
|
996
|
+
cards.append(card)
|
|
997
|
+
cards.sort(key=lambda c: -c.score.value)
|
|
998
|
+
|
|
999
|
+
if cards:
|
|
1000
|
+
console.print("[bold red]Top 3 risky files[/bold red]")
|
|
1001
|
+
for top in cards[:3]:
|
|
1002
|
+
console.print(
|
|
1003
|
+
f" [bold]{top.score.value:>3}[/bold] "
|
|
1004
|
+
f"{top.score.band.value:<20} [cyan]{top.path}[/cyan]"
|
|
1005
|
+
)
|
|
1006
|
+
console.print(f" [dim]{top.signals[0].headline}[/dim]")
|
|
1007
|
+
console.print()
|
|
1008
|
+
|
|
1009
|
+
# Section 3 — MCP setup snippet (vendor-neutral phrasing).
|
|
1010
|
+
console.print("[bold magenta]Wire WhyCode into your AI editor[/bold magenta]")
|
|
1011
|
+
console.print(
|
|
1012
|
+
" WhyCode ships an MCP server. Any MCP-aware editor or assistant\n"
|
|
1013
|
+
" can call it — just add this snippet to your editor's MCP config:\n"
|
|
1014
|
+
)
|
|
1015
|
+
console.print(_MCP_SNIPPET)
|
|
1016
|
+
console.print(
|
|
1017
|
+
"\n [dim](See your editor's docs for the exact config-file location.)[/dim]\n"
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
# Section 4 — what to do next.
|
|
1021
|
+
console.print("[bold]Next:[/bold]")
|
|
1022
|
+
if cards:
|
|
1023
|
+
console.print(
|
|
1024
|
+
f" [dim]·[/dim] [bold]whycode why {cards[0].path}[/bold] the full Risk Card"
|
|
1025
|
+
)
|
|
1026
|
+
console.print(
|
|
1027
|
+
" [dim]·[/dim] [bold]whycode init[/bold] install CI + pre-commit"
|
|
1028
|
+
)
|
|
1029
|
+
console.print(
|
|
1030
|
+
" [dim]·[/dim] [bold]whycode highlights[/bold] more invariants and incidents"
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
|
|
848
1034
|
@app.command()
|
|
849
1035
|
def init(
|
|
850
1036
|
force: bool = typer.Option(
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""L3 — LLM-enriched decision extraction.
|
|
2
|
+
|
|
3
|
+
What L1+L2 give: a regex-level harvest of single lines like
|
|
4
|
+
``"Do not switch to async"``. What L3 adds: structured decisions with
|
|
5
|
+
the full *why* drawn from the surrounding commit body.
|
|
6
|
+
|
|
7
|
+
Structured decision schema (one ``Decision`` per finding):
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"decision_type": "incident_fix" | "compat_workaround" | "perf_rewrite"
|
|
11
|
+
| "rollback" | "constraint" | "other",
|
|
12
|
+
"what_changed": "one sentence summary",
|
|
13
|
+
"why": "one paragraph; quotes from the body where possible",
|
|
14
|
+
"do_not": "actionable constraint, or null",
|
|
15
|
+
"evidence": ["<sha1>", "<sha2>", …],
|
|
16
|
+
"confidence": 0.0 - 1.0
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Confidence < ``min_confidence`` is filtered out before return — better to
|
|
20
|
+
emit nothing than emit a dressed-up guess. Privacy: this module makes a
|
|
21
|
+
network call only if ``call_llm`` is invoked, which only happens when the
|
|
22
|
+
caller passed commits in. Layer 1 and Layer 2 never reach this module.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
from whycode.git_facts import Commit
|
|
33
|
+
from whycode.llm import LLMCallError, LLMConfigError, call_llm
|
|
34
|
+
|
|
35
|
+
DEFAULT_MIN_CONFIDENCE = 0.5
|
|
36
|
+
DEFAULT_MAX_COMMITS = 10
|
|
37
|
+
|
|
38
|
+
_SYSTEM = (
|
|
39
|
+
"You are a careful code-history archaeologist. You read commit messages "
|
|
40
|
+
"and surface the engineering decisions that future readers will need to "
|
|
41
|
+
"respect. You never invent facts; if a commit body does not state a "
|
|
42
|
+
"decision worth carrying forward, you emit nothing for that commit. "
|
|
43
|
+
"All quotes you produce must be drawn from the commit body itself; "
|
|
44
|
+
"summarise rather than paraphrase when you cannot quote."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_PROMPT_TEMPLATE = """Below are commits from a Git repository. For each commit, extract a structured Decision **only when the commit body genuinely states one**. Otherwise emit nothing for that commit.
|
|
48
|
+
|
|
49
|
+
A Decision has this shape:
|
|
50
|
+
|
|
51
|
+
{{
|
|
52
|
+
"decision_type": one of
|
|
53
|
+
"incident_fix" | "compat_workaround" | "perf_rewrite" |
|
|
54
|
+
"rollback" | "constraint" | "other",
|
|
55
|
+
"what_changed": one-sentence summary of the change itself,
|
|
56
|
+
"why": one paragraph drawn from the body (quote where possible),
|
|
57
|
+
"do_not": the actionable constraint a future editor must respect,
|
|
58
|
+
or null if none stated,
|
|
59
|
+
"evidence": array of commit SHAs supporting this decision,
|
|
60
|
+
"confidence": a float in [0, 1] reflecting how clearly the body
|
|
61
|
+
states this decision (use < 0.5 if you are unsure)
|
|
62
|
+
}}
|
|
63
|
+
|
|
64
|
+
Rules:
|
|
65
|
+
- Reply with a JSON array of Decision objects, no prose, no code fences.
|
|
66
|
+
- Empty array if nothing qualifies.
|
|
67
|
+
- Quote rather than rephrase when stating "why".
|
|
68
|
+
- Do not infer constraints that are not in the body.
|
|
69
|
+
- Skip commits whose body is just a release note, dependency bump, or
|
|
70
|
+
one-line fix without explanation.
|
|
71
|
+
|
|
72
|
+
COMMITS:
|
|
73
|
+
|
|
74
|
+
{commits}
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Decision:
|
|
80
|
+
decision_type: str
|
|
81
|
+
what_changed: str
|
|
82
|
+
why: str
|
|
83
|
+
do_not: str | None
|
|
84
|
+
evidence: tuple[str, ...]
|
|
85
|
+
confidence: float
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, object]:
|
|
88
|
+
return {
|
|
89
|
+
"decision_type": self.decision_type,
|
|
90
|
+
"what_changed": self.what_changed,
|
|
91
|
+
"why": self.why,
|
|
92
|
+
"do_not": self.do_not,
|
|
93
|
+
"evidence": list(self.evidence),
|
|
94
|
+
"confidence": round(self.confidence, 2),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _format_commits_for_prompt(commits: Sequence[Commit]) -> str:
|
|
99
|
+
parts: list[str] = []
|
|
100
|
+
for c in commits:
|
|
101
|
+
parts.append(f"COMMIT {c.sha[:12]} ({c.author_name}, {c.authored_at.date()})")
|
|
102
|
+
parts.append(f"Subject: {c.subject}")
|
|
103
|
+
if c.body:
|
|
104
|
+
parts.append(f"Body:\n{c.body}")
|
|
105
|
+
parts.append("---")
|
|
106
|
+
return "\n".join(parts)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_VALID_TYPES = frozenset(
|
|
110
|
+
{
|
|
111
|
+
"incident_fix",
|
|
112
|
+
"compat_workaround",
|
|
113
|
+
"perf_rewrite",
|
|
114
|
+
"rollback",
|
|
115
|
+
"constraint",
|
|
116
|
+
"other",
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _strip_code_fence(raw: str) -> str:
|
|
122
|
+
raw = raw.strip()
|
|
123
|
+
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
|
124
|
+
raw = re.sub(r"\s*```\s*$", "", raw)
|
|
125
|
+
return raw.strip()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_decisions(raw: str, valid_shas: Sequence[str]) -> list[Decision]:
|
|
129
|
+
"""Lenient parser. Bad JSON → empty list (we do not crash on a bad model
|
|
130
|
+
response). Missing fields default to empty/zero. Invalid evidence SHAs
|
|
131
|
+
are dropped silently."""
|
|
132
|
+
text = _strip_code_fence(raw)
|
|
133
|
+
try:
|
|
134
|
+
data = json.loads(text)
|
|
135
|
+
except json.JSONDecodeError:
|
|
136
|
+
return []
|
|
137
|
+
if not isinstance(data, list):
|
|
138
|
+
return []
|
|
139
|
+
short_lookup = {s[:12]: s for s in valid_shas}
|
|
140
|
+
out: list[Decision] = []
|
|
141
|
+
for item in data:
|
|
142
|
+
if not isinstance(item, dict):
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
decision_type = str(item.get("decision_type", "other"))
|
|
146
|
+
if decision_type not in _VALID_TYPES:
|
|
147
|
+
decision_type = "other"
|
|
148
|
+
what_changed = str(item.get("what_changed", "")).strip()
|
|
149
|
+
why = str(item.get("why", "")).strip()
|
|
150
|
+
do_not_raw = item.get("do_not")
|
|
151
|
+
do_not = str(do_not_raw).strip() if do_not_raw else None
|
|
152
|
+
raw_evidence = item.get("evidence", []) or []
|
|
153
|
+
evidence: list[str] = []
|
|
154
|
+
for token in raw_evidence:
|
|
155
|
+
t = str(token).strip()
|
|
156
|
+
# Accept full or 12-char prefix SHAs that match what we sent.
|
|
157
|
+
if t in short_lookup:
|
|
158
|
+
evidence.append(short_lookup[t])
|
|
159
|
+
elif len(t) >= 12 and t[:12] in short_lookup:
|
|
160
|
+
evidence.append(short_lookup[t[:12]])
|
|
161
|
+
if not evidence and valid_shas:
|
|
162
|
+
evidence = [valid_shas[0]]
|
|
163
|
+
confidence = float(item.get("confidence", 0.0))
|
|
164
|
+
confidence = max(0.0, min(1.0, confidence))
|
|
165
|
+
except (TypeError, ValueError):
|
|
166
|
+
continue
|
|
167
|
+
if not what_changed or not why:
|
|
168
|
+
continue
|
|
169
|
+
out.append(
|
|
170
|
+
Decision(
|
|
171
|
+
decision_type=decision_type,
|
|
172
|
+
what_changed=what_changed,
|
|
173
|
+
why=why,
|
|
174
|
+
do_not=do_not,
|
|
175
|
+
evidence=tuple(evidence),
|
|
176
|
+
confidence=confidence,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def estimate_payload(commits: Sequence[Commit]) -> tuple[int, int]:
|
|
183
|
+
"""Return ``(commit_count, prompt_char_count)`` so callers can show the
|
|
184
|
+
user the exact size of what would be sent before invoking the network.
|
|
185
|
+
"""
|
|
186
|
+
if not commits:
|
|
187
|
+
return 0, 0
|
|
188
|
+
prompt = _PROMPT_TEMPLATE.format(commits=_format_commits_for_prompt(commits))
|
|
189
|
+
return len(commits), len(prompt) + len(_SYSTEM)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def extract_decisions(
|
|
193
|
+
commits: Sequence[Commit],
|
|
194
|
+
*,
|
|
195
|
+
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
|
|
196
|
+
) -> list[Decision]:
|
|
197
|
+
"""Send ``commits`` to the configured LLM and parse structured decisions.
|
|
198
|
+
|
|
199
|
+
Raises ``LLMConfigError`` when the environment is not set up; raises
|
|
200
|
+
``LLMCallError`` on transport / API failure. Returns ``[]`` on empty
|
|
201
|
+
input or a malformed model response.
|
|
202
|
+
"""
|
|
203
|
+
if not commits:
|
|
204
|
+
return []
|
|
205
|
+
prompt = _PROMPT_TEMPLATE.format(commits=_format_commits_for_prompt(commits))
|
|
206
|
+
raw = call_llm(prompt, _SYSTEM)
|
|
207
|
+
decisions = _parse_decisions(raw, [c.sha for c in commits])
|
|
208
|
+
return [d for d in decisions if d.confidence >= min_confidence]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
__all__ = [
|
|
212
|
+
"DEFAULT_MAX_COMMITS",
|
|
213
|
+
"DEFAULT_MIN_CONFIDENCE",
|
|
214
|
+
"Decision",
|
|
215
|
+
"LLMCallError",
|
|
216
|
+
"LLMConfigError",
|
|
217
|
+
"estimate_payload",
|
|
218
|
+
"extract_decisions",
|
|
219
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Provider-neutral LLM client wrapper for the optional L3 layer.
|
|
2
|
+
|
|
3
|
+
L3 is opt-in. Off by default. The CLI must require an explicit ``--llm``
|
|
4
|
+
flag and the user must set their own API key. This module never embeds
|
|
5
|
+
provider names, model identifiers, or default keys in source code —
|
|
6
|
+
configuration lives entirely in environment variables, so the source tree
|
|
7
|
+
itself does not advertise any specific vendor.
|
|
8
|
+
|
|
9
|
+
Required:
|
|
10
|
+
``WHYCODE_LLM_API_KEY`` Your provider's API key.
|
|
11
|
+
``WHYCODE_LLM_MODEL`` Your provider's model identifier (string).
|
|
12
|
+
|
|
13
|
+
Optional:
|
|
14
|
+
``WHYCODE_LLM_MAX_TOKENS`` Output cap (default 2000).
|
|
15
|
+
|
|
16
|
+
The actual provider SDK is loaded lazily (``pip install 'whycode-cli[llm]'``)
|
|
17
|
+
so users who never invoke L3 do not pay the import cost or force a
|
|
18
|
+
dependency on any AI SDK.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LLMConfigError(RuntimeError):
|
|
28
|
+
"""Raised when L3 is invoked without sufficient configuration."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LLMCallError(RuntimeError):
|
|
32
|
+
"""Raised when the underlying provider call fails."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class LLMConfig:
|
|
37
|
+
api_key: str
|
|
38
|
+
model: str
|
|
39
|
+
max_tokens: int = 2000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_config() -> LLMConfig:
|
|
43
|
+
"""Read configuration from environment variables.
|
|
44
|
+
|
|
45
|
+
No defaults for ``api_key`` or ``model`` — both must be set explicitly.
|
|
46
|
+
The error message points the user at the ``--llm-dry-run`` flag for
|
|
47
|
+
self-service auditing.
|
|
48
|
+
"""
|
|
49
|
+
api_key = os.environ.get("WHYCODE_LLM_API_KEY", "").strip()
|
|
50
|
+
model = os.environ.get("WHYCODE_LLM_MODEL", "").strip()
|
|
51
|
+
if not api_key:
|
|
52
|
+
raise LLMConfigError(
|
|
53
|
+
"WHYCODE_LLM_API_KEY is not set. To use --llm:\n"
|
|
54
|
+
" 1. Get an API key from your LLM provider.\n"
|
|
55
|
+
" 2. export WHYCODE_LLM_API_KEY=…\n"
|
|
56
|
+
" 3. export WHYCODE_LLM_MODEL=<your-provider's-model-identifier>\n"
|
|
57
|
+
" Use --llm-dry-run first to see exactly what would be sent."
|
|
58
|
+
)
|
|
59
|
+
if not model:
|
|
60
|
+
raise LLMConfigError(
|
|
61
|
+
"WHYCODE_LLM_MODEL is not set. Set it to your provider's model "
|
|
62
|
+
"identifier (consult your provider's docs for available models)."
|
|
63
|
+
)
|
|
64
|
+
raw_max = os.environ.get("WHYCODE_LLM_MAX_TOKENS", "2000").strip()
|
|
65
|
+
try:
|
|
66
|
+
max_tokens = int(raw_max)
|
|
67
|
+
except ValueError:
|
|
68
|
+
max_tokens = 2000
|
|
69
|
+
return LLMConfig(api_key=api_key, model=model, max_tokens=max_tokens)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def call_llm(prompt: str, system: str) -> str:
|
|
73
|
+
"""Send ``prompt`` (with ``system`` instruction) to the configured LLM.
|
|
74
|
+
|
|
75
|
+
Returns the assistant's text response. Raises ``LLMConfigError`` if the
|
|
76
|
+
environment is not set up or the provider SDK is missing; raises
|
|
77
|
+
``LLMCallError`` on transport / API failure.
|
|
78
|
+
|
|
79
|
+
The provider SDK is loaded lazily inside this call to keep the import
|
|
80
|
+
out of the cold path. This matches the architectural rule that L1+L2
|
|
81
|
+
must run with zero network and zero LLM dependencies.
|
|
82
|
+
"""
|
|
83
|
+
cfg = _read_config()
|
|
84
|
+
try:
|
|
85
|
+
# Lazy import — the SDK is in the optional ``[llm]`` extras and is
|
|
86
|
+
# not required for the rest of WhyCode. Keep the package name out
|
|
87
|
+
# of any user-facing strings.
|
|
88
|
+
client_module = __import__("anthropic")
|
|
89
|
+
except ImportError as exc:
|
|
90
|
+
raise LLMConfigError(
|
|
91
|
+
"LLM support not installed. Run: pip install 'whycode-cli[llm]'"
|
|
92
|
+
) from exc
|
|
93
|
+
try:
|
|
94
|
+
client = client_module.Anthropic(api_key=cfg.api_key)
|
|
95
|
+
msg = client.messages.create(
|
|
96
|
+
model=cfg.model,
|
|
97
|
+
max_tokens=cfg.max_tokens,
|
|
98
|
+
system=system,
|
|
99
|
+
messages=[{"role": "user", "content": prompt}],
|
|
100
|
+
)
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
raise LLMCallError(f"LLM call failed: {exc}") from exc
|
|
103
|
+
# Anthropic returns a list of content blocks; concatenate text-typed ones.
|
|
104
|
+
parts: list[str] = []
|
|
105
|
+
for block in getattr(msg, "content", []):
|
|
106
|
+
text = getattr(block, "text", None)
|
|
107
|
+
if isinstance(text, str):
|
|
108
|
+
parts.append(text)
|
|
109
|
+
return "".join(parts)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = ["LLMCallError", "LLMConfig", "LLMConfigError", "call_llm"]
|
|
@@ -24,6 +24,8 @@ from whycode.scorer import Band, Score, score
|
|
|
24
24
|
if TYPE_CHECKING:
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
|
+
from whycode.decisions import Decision
|
|
28
|
+
|
|
27
29
|
|
|
28
30
|
@dataclass(frozen=True)
|
|
29
31
|
class RiskCard:
|
|
@@ -38,6 +40,15 @@ class RiskCard:
|
|
|
38
40
|
as_of_sha: str | None = None
|
|
39
41
|
"""When set, the card was computed *as of* this commit (historical view)."""
|
|
40
42
|
|
|
43
|
+
decisions: tuple[Decision, ...] = ()
|
|
44
|
+
"""L3 — LLM-extracted structured decisions. Empty unless ``--llm`` was on."""
|
|
45
|
+
|
|
46
|
+
def with_decisions(self, decisions: tuple[Decision, ...]) -> RiskCard:
|
|
47
|
+
"""Return a copy with the L3 ``decisions`` field populated."""
|
|
48
|
+
from dataclasses import replace
|
|
49
|
+
|
|
50
|
+
return replace(self, decisions=decisions)
|
|
51
|
+
|
|
41
52
|
def to_dict(self) -> dict[str, Any]:
|
|
42
53
|
return {
|
|
43
54
|
"path": self.path,
|
|
@@ -65,6 +76,7 @@ class RiskCard:
|
|
|
65
76
|
}
|
|
66
77
|
for s in self.signals
|
|
67
78
|
],
|
|
79
|
+
"decisions": [d.to_dict() for d in self.decisions],
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
|
|
@@ -190,11 +202,40 @@ def _next_step_hint(signals: tuple[sig.Signal, ...]) -> Text | None:
|
|
|
190
202
|
return None
|
|
191
203
|
|
|
192
204
|
|
|
205
|
+
def _decisions_block(decisions: tuple[Decision, ...]) -> Padding:
|
|
206
|
+
"""Render the L3 decisions section inside a labelled panel."""
|
|
207
|
+
body = Text()
|
|
208
|
+
for i, d in enumerate(decisions):
|
|
209
|
+
if i:
|
|
210
|
+
body.append("\n\n")
|
|
211
|
+
# Header: type + confidence badge.
|
|
212
|
+
body.append(f"{d.decision_type.replace('_', ' ').upper()}", style="bold cyan")
|
|
213
|
+
body.append(f" confidence {int(d.confidence * 100)}%\n", style="dim")
|
|
214
|
+
body.append(d.what_changed + "\n", style="bold")
|
|
215
|
+
body.append("Why: ", style="dim")
|
|
216
|
+
body.append(d.why + "\n", style="italic")
|
|
217
|
+
if d.do_not:
|
|
218
|
+
body.append("Don't: ", style="bold red")
|
|
219
|
+
body.append(d.do_not + "\n", style="")
|
|
220
|
+
if d.evidence:
|
|
221
|
+
short = ", ".join(s[:7] for s in d.evidence)
|
|
222
|
+
body.append(f"evidence: {short}", style="dim")
|
|
223
|
+
panel = Panel(
|
|
224
|
+
body,
|
|
225
|
+
title=Text(" DECISIONS (L3) ", style="bold white on magenta"),
|
|
226
|
+
title_align="left",
|
|
227
|
+
border_style="grey50",
|
|
228
|
+
)
|
|
229
|
+
return Padding(panel, (1, 1, 0, 1))
|
|
230
|
+
|
|
231
|
+
|
|
193
232
|
def render_text(card: RiskCard) -> Group:
|
|
194
233
|
pieces: list[Any] = [
|
|
195
234
|
_header(card),
|
|
196
235
|
Padding(_signals_table(card.signals), (0, 1, 0, 1)),
|
|
197
236
|
]
|
|
237
|
+
if card.decisions:
|
|
238
|
+
pieces.append(_decisions_block(card.decisions))
|
|
198
239
|
hint = _next_step_hint(card.signals)
|
|
199
240
|
if hint is not None:
|
|
200
241
|
pieces.append(Padding(hint, (0, 1, 1, 2)))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: whycode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Tells you what to be afraid of before you touch a file.
|
|
5
5
|
Author: Kevin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,6 +19,8 @@ Requires-Dist: typer>=0.12
|
|
|
19
19
|
Requires-Dist: rich>=13.7
|
|
20
20
|
Provides-Extra: mcp
|
|
21
21
|
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
22
|
+
Provides-Extra: llm
|
|
23
|
+
Requires-Dist: anthropic>=0.40; extra == "llm"
|
|
22
24
|
Provides-Extra: dev
|
|
23
25
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
26
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
@@ -87,8 +89,9 @@ Requires Python 3.11+.
|
|
|
87
89
|
```bash
|
|
88
90
|
cd /path/to/your/repo
|
|
89
91
|
|
|
92
|
+
whycode tour # the one command to run first
|
|
90
93
|
whycode init # one-command setup: CI workflow + pre-commit gate
|
|
91
|
-
whycode highlights #
|
|
94
|
+
whycode highlights # repo-wide treasure map: top decisions + incidents
|
|
92
95
|
whycode why src/some/file.py # the Risk Card for one file
|
|
93
96
|
whycode why src/some/file.py -b # one-line summary (for triage / scripts)
|
|
94
97
|
whycode why src/some/file.py --at <sha> # risk as of a past commit
|
|
@@ -196,11 +199,32 @@ Tune the thresholds inside those two files for your repo. Re-run with
|
|
|
196
199
|
| ----- | ------------------------------------------------------------------------ | -------- | -------- |
|
|
197
200
|
| 1 | Deterministic git facts (log, diffstat, revert pairs, author activity) | no | no |
|
|
198
201
|
| 2 | Heuristic signals (reverts, incidents, silence, ghost keeper, coupling, invariants, churn, newborn) | no | no |
|
|
199
|
-
| 3 | LLM
|
|
202
|
+
| 3 | LLM-extracted structured decisions (optional, opt-in, never on by default) | yes | yes |
|
|
200
203
|
|
|
201
|
-
**Layer 1 + Layer 2 produce the Risk Card
|
|
202
|
-
data leaving your machine.** Layer 3
|
|
203
|
-
|
|
204
|
+
**Layer 1 + Layer 2 produce the Risk Card by default. No model calls, no
|
|
205
|
+
data leaving your machine.** Layer 3 lifts the keyword fragments L1 + L2
|
|
206
|
+
extract ("do not switch to async") into structured decisions with the
|
|
207
|
+
*why* drawn from the surrounding commit body — but only when you ask for
|
|
208
|
+
it with `--llm`.
|
|
209
|
+
|
|
210
|
+
### Optional L3 — LLM-enriched decisions
|
|
211
|
+
|
|
212
|
+
Install the optional extras and configure the env vars:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
pip install 'whycode-cli[llm]'
|
|
216
|
+
export WHYCODE_LLM_API_KEY="…"
|
|
217
|
+
export WHYCODE_LLM_MODEL="<your-provider's-model-identifier>"
|
|
218
|
+
|
|
219
|
+
whycode why src/some/file.py --llm # full card + structured decisions
|
|
220
|
+
whycode why src/some/file.py --llm-dry-run # see exactly what would be sent
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Privacy contract: configuration is entirely environment-driven (no
|
|
224
|
+
hardcoded provider in the source tree); the SDK is lazy-imported (no
|
|
225
|
+
import cost unless you opt in); only L2-filtered high-signal commits
|
|
226
|
+
are sent (capped at 10 per call); a malformed model response degrades
|
|
227
|
+
to "no decisions" rather than crashing.
|
|
204
228
|
|
|
205
229
|
## What this is NOT
|
|
206
230
|
|
|
@@ -4,8 +4,10 @@ pyproject.toml
|
|
|
4
4
|
src/whycode/__init__.py
|
|
5
5
|
src/whycode/__main__.py
|
|
6
6
|
src/whycode/cli.py
|
|
7
|
+
src/whycode/decisions.py
|
|
7
8
|
src/whycode/git_facts.py
|
|
8
9
|
src/whycode/ignore.py
|
|
10
|
+
src/whycode/llm.py
|
|
9
11
|
src/whycode/mcp_server.py
|
|
10
12
|
src/whycode/risk_card.py
|
|
11
13
|
src/whycode/scorer.py
|
|
@@ -21,6 +23,7 @@ src/whycode_cli.egg-info/entry_points.txt
|
|
|
21
23
|
src/whycode_cli.egg-info/requires.txt
|
|
22
24
|
src/whycode_cli.egg-info/top_level.txt
|
|
23
25
|
tests/test_cli.py
|
|
26
|
+
tests/test_decisions.py
|
|
24
27
|
tests/test_git_facts.py
|
|
25
28
|
tests/test_ignore.py
|
|
26
29
|
tests/test_scorer.py
|
|
@@ -589,3 +589,51 @@ def test_scan_lists_top_files(repo, days_ago) -> None: # type: ignore[no-untype
|
|
|
589
589
|
result = _invoke(repo.root, "scan", "--top", "3")
|
|
590
590
|
assert result.exit_code == 0
|
|
591
591
|
assert "a.py" in result.output
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def test_tour_runs_and_emits_all_sections(repo, days_ago) -> None: # type: ignore[no-untyped-def]
|
|
595
|
+
repo.commit(
|
|
596
|
+
"compat: keep sync path",
|
|
597
|
+
{"a.py": "1"},
|
|
598
|
+
body="Do not switch to async — v1 clients break.",
|
|
599
|
+
when=days_ago(60),
|
|
600
|
+
)
|
|
601
|
+
repo.commit(
|
|
602
|
+
"hotfix: refund regression",
|
|
603
|
+
{"b.py": "1"},
|
|
604
|
+
body="See INC-447.",
|
|
605
|
+
when=days_ago(20),
|
|
606
|
+
)
|
|
607
|
+
sha = repo.commit("feat: A", {"a.py": "2"}, when=days_ago(40))
|
|
608
|
+
repo.revert(sha, when=days_ago(15))
|
|
609
|
+
result = _invoke(repo.root, "tour")
|
|
610
|
+
assert result.exit_code == 0
|
|
611
|
+
out = result.output
|
|
612
|
+
assert "Welcome to WhyCode" in out
|
|
613
|
+
assert "Decisions and incidents" in out
|
|
614
|
+
assert "Do not switch to async" in out
|
|
615
|
+
assert "hotfix: refund regression" in out
|
|
616
|
+
assert "Wire WhyCode into your AI editor" in out
|
|
617
|
+
# MCP snippet appears verbatim so users can copy-paste.
|
|
618
|
+
assert '"command": "whycode"' in out
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def test_tour_quiet_repo_explains_why(repo) -> None: # type: ignore[no-untyped-def]
|
|
622
|
+
repo.commit("init", {"a.py": "1"})
|
|
623
|
+
result = _invoke(repo.root, "tour")
|
|
624
|
+
assert result.exit_code == 0
|
|
625
|
+
out = result.output
|
|
626
|
+
# MCP section appears regardless — most useful next step.
|
|
627
|
+
assert "Wire WhyCode into your AI editor" in out
|
|
628
|
+
# And the empty-state explanation should mention why nothing fires.
|
|
629
|
+
assert "terse" in out.lower() or "no headline" in out.lower()
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def test_tour_outside_repo_errors(tmp_path) -> None: # type: ignore[no-untyped-def]
|
|
633
|
+
cwd = os.getcwd()
|
|
634
|
+
os.chdir(tmp_path)
|
|
635
|
+
try:
|
|
636
|
+
result = runner.invoke(app, ["tour"], catch_exceptions=False)
|
|
637
|
+
finally:
|
|
638
|
+
os.chdir(cwd)
|
|
639
|
+
assert result.exit_code != 0
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Tests for the L3 decision-extraction layer.
|
|
2
|
+
|
|
3
|
+
LLM calls are mocked; no real network is touched.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from whycode.decisions import (
|
|
15
|
+
Decision,
|
|
16
|
+
_parse_decisions,
|
|
17
|
+
estimate_payload,
|
|
18
|
+
extract_decisions,
|
|
19
|
+
)
|
|
20
|
+
from whycode.git_facts import Commit
|
|
21
|
+
from whycode.llm import LLMCallError, LLMConfigError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _commit(
|
|
25
|
+
sha: str = "a" * 40,
|
|
26
|
+
subject: str = "compat: keep sync path",
|
|
27
|
+
body: str = "Do not switch to async — v1 clients break.",
|
|
28
|
+
author: str = "Mei",
|
|
29
|
+
) -> Commit:
|
|
30
|
+
return Commit(
|
|
31
|
+
sha=sha,
|
|
32
|
+
author_name=author,
|
|
33
|
+
author_email=f"{author.lower()}@example.com",
|
|
34
|
+
authored_at=datetime(2025, 9, 14, tzinfo=UTC),
|
|
35
|
+
subject=subject,
|
|
36
|
+
body=body,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_estimate_payload_zero_for_empty() -> None:
|
|
41
|
+
assert estimate_payload([]) == (0, 0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_estimate_payload_grows_with_commits() -> None:
|
|
45
|
+
one = estimate_payload([_commit()])
|
|
46
|
+
two = estimate_payload([_commit(), _commit(sha="b" * 40)])
|
|
47
|
+
assert one[0] == 1
|
|
48
|
+
assert two[0] == 2
|
|
49
|
+
assert two[1] > one[1]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_parse_decisions_well_formed() -> None:
|
|
53
|
+
raw = json.dumps(
|
|
54
|
+
[
|
|
55
|
+
{
|
|
56
|
+
"decision_type": "compat_workaround",
|
|
57
|
+
"what_changed": "Kept synchronous HTTP for refund flow.",
|
|
58
|
+
"why": "v1 clients break under async.",
|
|
59
|
+
"do_not": "Don't switch this to async.",
|
|
60
|
+
"evidence": ["a" * 12],
|
|
61
|
+
"confidence": 0.9,
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
out = _parse_decisions(raw, ["a" * 40])
|
|
66
|
+
assert len(out) == 1
|
|
67
|
+
d = out[0]
|
|
68
|
+
assert d.decision_type == "compat_workaround"
|
|
69
|
+
assert d.confidence == 0.9
|
|
70
|
+
assert d.evidence == ("a" * 40,)
|
|
71
|
+
assert d.do_not is not None and "async" in d.do_not
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_parse_decisions_unknown_type_normalised_to_other() -> None:
|
|
75
|
+
raw = json.dumps(
|
|
76
|
+
[
|
|
77
|
+
{
|
|
78
|
+
"decision_type": "made-up-category",
|
|
79
|
+
"what_changed": "x",
|
|
80
|
+
"why": "y",
|
|
81
|
+
"do_not": None,
|
|
82
|
+
"evidence": ["a" * 12],
|
|
83
|
+
"confidence": 0.7,
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
out = _parse_decisions(raw, ["a" * 40])
|
|
88
|
+
assert len(out) == 1
|
|
89
|
+
assert out[0].decision_type == "other"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_parse_decisions_strips_code_fence() -> None:
|
|
93
|
+
raw = "```json\n[]\n```"
|
|
94
|
+
assert _parse_decisions(raw, ["a" * 40]) == []
|
|
95
|
+
raw_filled = '```json\n[{"decision_type":"other","what_changed":"x","why":"y","confidence":0.6,"evidence":[]}]\n```'
|
|
96
|
+
out = _parse_decisions(raw_filled, ["a" * 40])
|
|
97
|
+
assert len(out) == 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_parse_decisions_garbage_returns_empty() -> None:
|
|
101
|
+
assert _parse_decisions("not json", ["a" * 40]) == []
|
|
102
|
+
assert _parse_decisions("", ["a" * 40]) == []
|
|
103
|
+
assert _parse_decisions("{}", ["a" * 40]) == [] # not a list
|
|
104
|
+
assert _parse_decisions("null", ["a" * 40]) == []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_parse_decisions_drops_invalid_evidence() -> None:
|
|
108
|
+
raw = json.dumps(
|
|
109
|
+
[
|
|
110
|
+
{
|
|
111
|
+
"decision_type": "constraint",
|
|
112
|
+
"what_changed": "x",
|
|
113
|
+
"why": "y",
|
|
114
|
+
"evidence": ["zz" * 6, "a" * 12], # first invalid, second valid
|
|
115
|
+
"confidence": 0.8,
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
)
|
|
119
|
+
out = _parse_decisions(raw, ["a" * 40])
|
|
120
|
+
assert len(out) == 1
|
|
121
|
+
assert out[0].evidence == ("a" * 40,)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_parse_decisions_drops_when_required_fields_empty() -> None:
|
|
125
|
+
raw = json.dumps(
|
|
126
|
+
[
|
|
127
|
+
{
|
|
128
|
+
"decision_type": "constraint",
|
|
129
|
+
"what_changed": "", # empty
|
|
130
|
+
"why": "y",
|
|
131
|
+
"confidence": 0.9,
|
|
132
|
+
"evidence": ["a" * 12],
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
assert _parse_decisions(raw, ["a" * 40]) == []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_extract_filters_below_min_confidence() -> None:
|
|
140
|
+
raw = json.dumps(
|
|
141
|
+
[
|
|
142
|
+
{
|
|
143
|
+
"decision_type": "constraint",
|
|
144
|
+
"what_changed": "x",
|
|
145
|
+
"why": "y",
|
|
146
|
+
"confidence": 0.3, # below default 0.5
|
|
147
|
+
"evidence": ["a" * 12],
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
)
|
|
151
|
+
with patch("whycode.decisions.call_llm", return_value=raw):
|
|
152
|
+
out = extract_decisions([_commit()])
|
|
153
|
+
assert out == []
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_extract_keeps_above_min_confidence() -> None:
|
|
157
|
+
raw = json.dumps(
|
|
158
|
+
[
|
|
159
|
+
{
|
|
160
|
+
"decision_type": "compat_workaround",
|
|
161
|
+
"what_changed": "Kept sync HTTP",
|
|
162
|
+
"why": "v1 clients break",
|
|
163
|
+
"confidence": 0.85,
|
|
164
|
+
"evidence": ["a" * 12],
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
)
|
|
168
|
+
with patch("whycode.decisions.call_llm", return_value=raw):
|
|
169
|
+
out = extract_decisions([_commit()])
|
|
170
|
+
assert len(out) == 1
|
|
171
|
+
assert out[0].confidence == 0.85
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_extract_returns_empty_on_no_commits() -> None:
|
|
175
|
+
out = extract_decisions([])
|
|
176
|
+
assert out == []
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_extract_propagates_llm_config_error() -> None:
|
|
180
|
+
def boom(*_a, **_kw) -> str:
|
|
181
|
+
raise LLMConfigError("not configured")
|
|
182
|
+
|
|
183
|
+
with (
|
|
184
|
+
patch("whycode.decisions.call_llm", side_effect=boom),
|
|
185
|
+
pytest.raises(LLMConfigError),
|
|
186
|
+
):
|
|
187
|
+
extract_decisions([_commit()])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_extract_propagates_llm_call_error() -> None:
|
|
191
|
+
def boom(*_a, **_kw) -> str:
|
|
192
|
+
raise LLMCallError("network down")
|
|
193
|
+
|
|
194
|
+
with (
|
|
195
|
+
patch("whycode.decisions.call_llm", side_effect=boom),
|
|
196
|
+
pytest.raises(LLMCallError),
|
|
197
|
+
):
|
|
198
|
+
extract_decisions([_commit()])
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_decision_to_dict_round_trips() -> None:
|
|
202
|
+
d = Decision(
|
|
203
|
+
decision_type="rollback",
|
|
204
|
+
what_changed="Reverted async refactor",
|
|
205
|
+
why="broke v1",
|
|
206
|
+
do_not="don't try async again",
|
|
207
|
+
evidence=("a" * 40,),
|
|
208
|
+
confidence=0.92,
|
|
209
|
+
)
|
|
210
|
+
payload = d.to_dict()
|
|
211
|
+
assert payload["decision_type"] == "rollback"
|
|
212
|
+
assert payload["confidence"] == 0.92
|
|
213
|
+
assert payload["do_not"] == "don't try async again"
|
|
214
|
+
assert payload["evidence"] == ["a" * 40]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|