whycode-cli 0.2.0__tar.gz → 0.2.2__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.
Files changed (29) hide show
  1. {whycode_cli-0.2.0/src/whycode_cli.egg-info → whycode_cli-0.2.2}/PKG-INFO +15 -10
  2. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/README.md +14 -9
  3. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/pyproject.toml +1 -1
  4. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode/__init__.py +1 -1
  5. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode/cli.py +172 -1
  6. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode/risk_card.py +12 -0
  7. whycode_cli-0.2.2/src/whycode/suppressions.py +123 -0
  8. whycode_cli-0.2.2/src/whycode/templates/github-workflow.yml +40 -0
  9. {whycode_cli-0.2.0 → whycode_cli-0.2.2/src/whycode_cli.egg-info}/PKG-INFO +15 -10
  10. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode_cli.egg-info/SOURCES.txt +3 -1
  11. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/tests/test_cli.py +88 -0
  12. whycode_cli-0.2.2/tests/test_suppressions.py +96 -0
  13. whycode_cli-0.2.0/src/whycode/templates/github-workflow.yml +0 -42
  14. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/LICENSE +0 -0
  15. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/setup.cfg +0 -0
  16. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode/__main__.py +0 -0
  17. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode/git_facts.py +0 -0
  18. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode/mcp_server.py +0 -0
  19. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode/scorer.py +0 -0
  20. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode/signals.py +0 -0
  21. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode/templates/__init__.py +0 -0
  22. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode/templates/pre-commit +0 -0
  23. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode_cli.egg-info/dependency_links.txt +0 -0
  24. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode_cli.egg-info/entry_points.txt +0 -0
  25. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode_cli.egg-info/requires.txt +0 -0
  26. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/src/whycode_cli.egg-info/top_level.txt +0 -0
  27. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/tests/test_git_facts.py +0 -0
  28. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/tests/test_scorer.py +0 -0
  29. {whycode_cli-0.2.0 → whycode_cli-0.2.2}/tests/test_signals.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whycode-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Tells you what to be afraid of before you touch a file.
5
5
  Author: Kevin
6
6
  License-Expression: MIT
@@ -67,13 +67,14 @@ decide what to do.
67
67
 
68
68
  ## Install
69
69
 
70
- Once published to PyPI:
71
-
72
70
  ```bash
73
- pip install whycode
71
+ pip install whycode-cli
74
72
  ```
75
73
 
76
- From source (current canonical path):
74
+ The PyPI distribution is `whycode-cli` (the bare name `whycode` was already
75
+ taken by an unrelated project); the installed command is still `whycode`.
76
+
77
+ From source, if you want to track `main`:
77
78
 
78
79
  ```bash
79
80
  pip install git+https://github.com/fangshuor/WhyCode.git
@@ -87,9 +88,11 @@ Requires Python 3.11+.
87
88
  cd /path/to/your/repo
88
89
 
89
90
  whycode init # one-command setup: CI workflow + pre-commit gate
91
+ whycode highlights # first-run treasure map: top decisions + incidents
90
92
  whycode why src/some/file.py # the Risk Card for one file
91
93
  whycode why src/some/file.py -b # one-line summary (for triage / scripts)
92
- whycode why src/some/file.py --at <sha> # what did this file's risk look like as of <sha>?
94
+ whycode why src/some/file.py --at <sha> # risk as of a past commit
95
+ whycode why src/some/file.py --mute <kind> # local "this signal is wrong, hide it" feedback
93
96
  whycode diff # rank everything you changed vs origin/main
94
97
  whycode diff --staged # ditto, for files staged for commit
95
98
  whycode diff --fail-on history # CI gate: exit 1 if any file is ≥ READ HISTORY FIRST
@@ -177,11 +180,13 @@ That installs two things:
177
180
  - **`.git/hooks/pre-commit`** — runs `whycode diff --staged --fail-on handle`
178
181
  before every commit. HANDLE WITH CARE files can't be touched without an
179
182
  explicit `git commit --no-verify`.
180
- - **`.github/workflows/whycode.yml`** — a GitHub Action that risk-ranks every
181
- PR's files and fails the build at `--fail-on history` ( READ HISTORY FIRST).
183
+ - **`.github/workflows/whycode.yml`** — a GitHub Action that prints a
184
+ risk-ranked table for every PR. Advisory by default (never blocks merging);
185
+ append `--fail-on history` (or `handle` / `look`) to the diff line to turn
186
+ it into a hard gate.
182
187
 
183
- Tune the `--fail-on` thresholds inside those two files for your repo. Re-run
184
- with `whycode init --force` to overwrite.
188
+ Tune the thresholds inside those two files for your repo. Re-run with
189
+ `whycode init --force` to overwrite.
185
190
 
186
191
  **MCP server** — see the next section.
187
192
 
@@ -39,13 +39,14 @@ decide what to do.
39
39
 
40
40
  ## Install
41
41
 
42
- Once published to PyPI:
43
-
44
42
  ```bash
45
- pip install whycode
43
+ pip install whycode-cli
46
44
  ```
47
45
 
48
- From source (current canonical path):
46
+ The PyPI distribution is `whycode-cli` (the bare name `whycode` was already
47
+ taken by an unrelated project); the installed command is still `whycode`.
48
+
49
+ From source, if you want to track `main`:
49
50
 
50
51
  ```bash
51
52
  pip install git+https://github.com/fangshuor/WhyCode.git
@@ -59,9 +60,11 @@ Requires Python 3.11+.
59
60
  cd /path/to/your/repo
60
61
 
61
62
  whycode init # one-command setup: CI workflow + pre-commit gate
63
+ whycode highlights # first-run treasure map: top decisions + incidents
62
64
  whycode why src/some/file.py # the Risk Card for one file
63
65
  whycode why src/some/file.py -b # one-line summary (for triage / scripts)
64
- whycode why src/some/file.py --at <sha> # what did this file's risk look like as of <sha>?
66
+ whycode why src/some/file.py --at <sha> # risk as of a past commit
67
+ whycode why src/some/file.py --mute <kind> # local "this signal is wrong, hide it" feedback
65
68
  whycode diff # rank everything you changed vs origin/main
66
69
  whycode diff --staged # ditto, for files staged for commit
67
70
  whycode diff --fail-on history # CI gate: exit 1 if any file is ≥ READ HISTORY FIRST
@@ -149,11 +152,13 @@ That installs two things:
149
152
  - **`.git/hooks/pre-commit`** — runs `whycode diff --staged --fail-on handle`
150
153
  before every commit. HANDLE WITH CARE files can't be touched without an
151
154
  explicit `git commit --no-verify`.
152
- - **`.github/workflows/whycode.yml`** — a GitHub Action that risk-ranks every
153
- PR's files and fails the build at `--fail-on history` ( READ HISTORY FIRST).
155
+ - **`.github/workflows/whycode.yml`** — a GitHub Action that prints a
156
+ risk-ranked table for every PR. Advisory by default (never blocks merging);
157
+ append `--fail-on history` (or `handle` / `look`) to the diff line to turn
158
+ it into a hard gate.
154
159
 
155
- Tune the `--fail-on` thresholds inside those two files for your repo. Re-run
156
- with `whycode init --force` to overwrite.
160
+ Tune the thresholds inside those two files for your repo. Re-run with
161
+ `whycode init --force` to overwrite.
157
162
 
158
163
  **MCP server** — see the next section.
159
164
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "whycode-cli"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Tells you what to be afraid of before you touch a file."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """WhyCode — tells you what to be afraid of before touching a file."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
@@ -4,6 +4,8 @@ Commands
4
4
  --------
5
5
  - ``whycode why <path>`` — print the Risk Card for a single file.
6
6
  - ``whycode why <path> --at SHA`` — risk card as of a past commit.
7
+ - ``whycode why <path> --mute KIND`` — locally suppress a noisy signal kind.
8
+ - ``whycode highlights`` — repo-wide treasure map of decisions and incidents.
7
9
  - ``whycode diff [--base REF]`` — risk-rank files changed against a base ref.
8
10
  - ``whycode show <sha>`` — risk-flavored summary for one commit.
9
11
  - ``whycode timeline <path>`` — risk score evolution across the file's history.
@@ -29,6 +31,7 @@ from whycode import __version__
29
31
  from whycode import git_facts as gf
30
32
  from whycode import risk_card as rc
31
33
  from whycode import signals as sig
34
+ from whycode import suppressions as supp
32
35
 
33
36
  app = typer.Typer(
34
37
  add_completion=False,
@@ -119,6 +122,20 @@ def why(
119
122
  "--at",
120
123
  help="Show the Risk Card as of this commit / ref (postmortem queries).",
121
124
  ),
125
+ mute: list[str] = typer.Option(
126
+ [],
127
+ "--mute",
128
+ help=(
129
+ "Suppress a signal kind for this path going forward "
130
+ "(stored in .whycode/suppressed.json). Accepts kind name or "
131
+ "unique prefix: incident, revert, ghost, coupling, silence, …"
132
+ ),
133
+ ),
134
+ no_mutes: bool = typer.Option(
135
+ False,
136
+ "--no-mutes",
137
+ help="Bypass the local suppression list — show all signals.",
138
+ ),
122
139
  max_commits: int | None = typer.Option(
123
140
  None, "--max-commits", help="Cap the number of commits scanned (debug)."
124
141
  ),
@@ -140,7 +157,31 @@ def why(
140
157
  except gf.GitError:
141
158
  err.print(f"[red]error:[/red] unknown commit / ref: {at!r}")
142
159
  raise typer.Exit(2) from None
143
- card = rc.build(repo_root, rel, max_commits=max_commits, ref=resolved_ref)
160
+ if mute:
161
+ sl = supp.load(repo_root)
162
+ added: list[str] = []
163
+ for token in mute:
164
+ try:
165
+ kind = supp.resolve_kind(token)
166
+ except ValueError as exc:
167
+ err.print(f"[red]error:[/red] {exc}")
168
+ raise typer.Exit(2) from None
169
+ if sl.add(rel, kind):
170
+ added.append(kind.value)
171
+ if added:
172
+ supp.save(repo_root, sl)
173
+ if not json_out:
174
+ err.print(
175
+ f"[dim]muted on {rel}: {', '.join(added)} "
176
+ f"(stored in .whycode/suppressed.json — edit to undo)[/dim]"
177
+ )
178
+ card = rc.build(
179
+ repo_root,
180
+ rel,
181
+ max_commits=max_commits,
182
+ ref=resolved_ref,
183
+ apply_suppressions=not no_mutes,
184
+ )
144
185
  if json_out:
145
186
  console.print_json(json.dumps(card.to_dict()))
146
187
  return
@@ -286,6 +327,136 @@ def diff(
286
327
  raise typer.Exit(1)
287
328
 
288
329
 
330
+ @app.command()
331
+ def highlights(
332
+ invariants: int = typer.Option(
333
+ 5, "--invariants", help="How many invariant lines to surface."
334
+ ),
335
+ incidents: int = typer.Option(
336
+ 5, "--incidents", help="How many incident commits to surface."
337
+ ),
338
+ max_commits: int | None = typer.Option(
339
+ None,
340
+ "--max-commits",
341
+ help="Cap on commits scanned (defaults to no cap; tune for very large repos).",
342
+ ),
343
+ repo: Path = typer.Option(Path("."), "--repo", help="Path inside the repo."),
344
+ json_out: bool = typer.Option(
345
+ False, "--json", help="Emit machine-readable JSON instead of a card."
346
+ ),
347
+ ) -> None:
348
+ """The first-run treasure map: top decisions and incidents across the repo.
349
+
350
+ Surfaces the highest-value commit-message lines (invariants stated by past
351
+ authors) and the most recent incident-flavoured commits — the things a
352
+ reader most wants to know about the codebase before touching anything.
353
+ """
354
+ try:
355
+ repo_root = gf.discover_repo_root(repo.resolve())
356
+ except gf.GitError as exc:
357
+ err.print(f"[red]error:[/red] {exc}")
358
+ raise typer.Exit(2) from exc
359
+
360
+ with console.status("Reading repo history…", spinner="dots"):
361
+ commits = gf.all_commits(repo_root, max_count=max_commits)
362
+ if not commits:
363
+ console.print("[yellow]no commits in this repo[/yellow]")
364
+ return
365
+
366
+ inv_pairs = gf.extract_invariant_quotes(commits)
367
+ sha_to_commit = {c.sha: c for c in commits}
368
+ seen_lines: dict[str, str] = {}
369
+ for sha, line in inv_pairs:
370
+ seen_lines.setdefault(line, sha)
371
+ inv_records: list[tuple[str, str, gf.Commit]] = []
372
+ for line, sha in seen_lines.items():
373
+ commit = sha_to_commit.get(sha)
374
+ if commit is None:
375
+ continue
376
+ inv_records.append((line, sha, commit))
377
+ inv_records.sort(key=lambda t: t[2].authored_at, reverse=True)
378
+ inv_records = inv_records[:invariants]
379
+
380
+ incident_records = gf.find_incidents(commits)[:incidents]
381
+
382
+ if json_out:
383
+ console.print_json(
384
+ json.dumps(
385
+ {
386
+ "repo": str(repo_root),
387
+ "scanned_commits": len(commits),
388
+ "invariants": [
389
+ {
390
+ "sha": c.sha[:12],
391
+ "subject": c.subject,
392
+ "author": c.author_name,
393
+ "authored_at": c.authored_at.isoformat(),
394
+ "line": line,
395
+ }
396
+ for line, _, c in inv_records
397
+ ],
398
+ "incidents": [
399
+ {
400
+ "sha": c.sha[:12],
401
+ "subject": c.subject,
402
+ "author": c.author_name,
403
+ "authored_at": c.authored_at.isoformat(),
404
+ }
405
+ for c in incident_records
406
+ ],
407
+ }
408
+ )
409
+ )
410
+ return
411
+
412
+ console.print(
413
+ f"[bold]WhyCode highlights[/bold] "
414
+ f"[dim]{len(commits)} commits scanned in this repo[/dim]\n"
415
+ )
416
+ if inv_records:
417
+ console.print(
418
+ f"[bold yellow]INVARIANTS[/bold yellow] "
419
+ f"[dim]({len(inv_records)} most recent stated by past authors)[/dim]"
420
+ )
421
+ for i, (line, sha, commit) in enumerate(inv_records, 1):
422
+ short = sha[:7]
423
+ date = str(commit.authored_at.date())
424
+ console.print(
425
+ f" {i}. [dim]{short} {date} {commit.author_name}[/dim]"
426
+ )
427
+ console.print(f" [italic]{line}[/italic]")
428
+ console.print()
429
+ else:
430
+ console.print(
431
+ "[dim]INVARIANTS: none found. The repo's commit bodies don't use "
432
+ "cautionary language like 'do not', 'must not', 'warning:', etc.[/dim]\n"
433
+ )
434
+
435
+ if incident_records:
436
+ console.print(
437
+ f"[bold red]INCIDENTS[/bold red] "
438
+ f"[dim]({len(incident_records)} most recent incident-flavoured commits)[/dim]"
439
+ )
440
+ for i, c in enumerate(incident_records, 1):
441
+ short = c.sha[:7]
442
+ date = str(c.authored_at.date())
443
+ subj = c.subject if len(c.subject) <= 70 else c.subject[:69] + "…"
444
+ console.print(
445
+ f" {i}. [dim]{short} {date} {c.author_name}[/dim]\n"
446
+ f" {subj}"
447
+ )
448
+ console.print()
449
+ else:
450
+ console.print(
451
+ "[dim]INCIDENTS: none found. No commit subject contains 'hotfix', "
452
+ "'incident', 'regression', etc.[/dim]\n"
453
+ )
454
+
455
+ console.print(
456
+ "[dim]→ whycode why <path> to dig into the file behind any of these.[/dim]"
457
+ )
458
+
459
+
289
460
  def _sample_indices(total: int, max_samples: int) -> list[int]:
290
461
  """Pick at most ``max_samples`` indices spread across [0, total).
291
462
 
@@ -18,6 +18,7 @@ from rich.text import Text
18
18
 
19
19
  from whycode import git_facts as gf
20
20
  from whycode import signals as sig
21
+ from whycode import suppressions as supp
21
22
  from whycode.scorer import Band, Score, score
22
23
 
23
24
  if TYPE_CHECKING:
@@ -73,9 +74,20 @@ def build(
73
74
  *,
74
75
  max_commits: int | None = None,
75
76
  ref: str | None = None,
77
+ apply_suppressions: bool = True,
76
78
  ) -> RiskCard:
79
+ """Build a Risk Card.
80
+
81
+ By default, signals matching the local ``.whycode/suppressed.json`` list
82
+ are dropped — that file is the user's "this signal is wrong, hide it"
83
+ feedback. Pass ``apply_suppressions=False`` to bypass it (useful for
84
+ debug or auditing what was hidden).
85
+ """
77
86
  facts = gf.gather(repo_root, path, max_commits=max_commits, ref=ref)
78
87
  signals = sig.all_signals(facts)
88
+ if apply_suppressions:
89
+ suppressions = supp.load(repo_root)
90
+ signals = supp.filter_signals(signals, suppressions, path)
79
91
  s = score(signals)
80
92
  head = facts.commits[0] if facts.commits else None
81
93
  return RiskCard(
@@ -0,0 +1,123 @@
1
+ """Local suppression list — the "this signal is wrong, hide it" feedback loop.
2
+
3
+ Stored at ``<repo>/.whycode/suppressed.json``. ``.whycode/`` is gitignored,
4
+ so the list is per-developer and never travels with the repo. This honours
5
+ the privacy contract: no central DB, no telemetry, no cross-team sharing of
6
+ "these signals are bogus" data.
7
+
8
+ Schema (intentionally tiny — easy to hand-edit):
9
+
10
+ {
11
+ "suppressed": [
12
+ ["src/foo.py", "incident_history"],
13
+ ["src/bar.py", "ghost_keeper"]
14
+ ]
15
+ }
16
+
17
+ Match is exact ``(path, kind)`` — no globs. We can add patterns if real
18
+ users ask for them; for now precision beats convenience.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from collections.abc import Iterable, Iterator
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING
28
+
29
+ from whycode.signals import SignalKind
30
+
31
+ if TYPE_CHECKING:
32
+ from whycode.signals import Signal
33
+
34
+ _FILENAME = "suppressed.json"
35
+ _DIRNAME = ".whycode"
36
+
37
+
38
+ @dataclass
39
+ class SuppressionList:
40
+ entries: list[tuple[str, str]] = field(default_factory=list)
41
+
42
+ def is_suppressed(self, path: str, kind: SignalKind) -> bool:
43
+ return (path, kind.value) in self.entries
44
+
45
+ def add(self, path: str, kind: SignalKind) -> bool:
46
+ """Idempotent. Returns True if a new entry was added."""
47
+ pair = (path, kind.value)
48
+ if pair in self.entries:
49
+ return False
50
+ self.entries.append(pair)
51
+ return True
52
+
53
+ def __iter__(self) -> Iterator[tuple[str, str]]:
54
+ return iter(self.entries)
55
+
56
+ def __len__(self) -> int:
57
+ return len(self.entries)
58
+
59
+
60
+ def _path(repo_root: Path) -> Path:
61
+ return repo_root / _DIRNAME / _FILENAME
62
+
63
+
64
+ def load(repo_root: Path) -> SuppressionList:
65
+ """Return the suppression list for ``repo_root``. Empty if no file exists."""
66
+ target = _path(repo_root)
67
+ if not target.exists():
68
+ return SuppressionList()
69
+ try:
70
+ raw = json.loads(target.read_text())
71
+ except (OSError, json.JSONDecodeError):
72
+ # A corrupt file should not stop ``whycode why`` from running.
73
+ # Treat as empty and let the user see what happened on next save.
74
+ return SuppressionList()
75
+ items = raw.get("suppressed", [])
76
+ out: list[tuple[str, str]] = []
77
+ for item in items:
78
+ if isinstance(item, list) and len(item) == 2:
79
+ out.append((str(item[0]), str(item[1])))
80
+ return SuppressionList(entries=out)
81
+
82
+
83
+ def save(repo_root: Path, sl: SuppressionList) -> None:
84
+ target = _path(repo_root)
85
+ target.parent.mkdir(parents=True, exist_ok=True)
86
+ payload = {"suppressed": [list(pair) for pair in sl.entries]}
87
+ target.write_text(json.dumps(payload, indent=2) + "\n")
88
+
89
+
90
+ def resolve_kind(token: str) -> SignalKind:
91
+ """Resolve a user-supplied kind name (or unique prefix) to a SignalKind.
92
+
93
+ Raises ValueError when the token is unknown or matches multiple kinds.
94
+ """
95
+ needle = token.lower().replace("-", "_").strip()
96
+ if not needle:
97
+ raise ValueError("empty signal kind")
98
+ matches = [k for k in SignalKind if needle in k.value]
99
+ if not matches:
100
+ raise ValueError(
101
+ f"unknown signal kind: {token!r}. "
102
+ f"Known: {', '.join(k.value for k in SignalKind)}"
103
+ )
104
+ if len(matches) > 1:
105
+ names = ", ".join(k.value for k in matches)
106
+ raise ValueError(f"ambiguous: {token!r} matches multiple kinds: {names}")
107
+ return matches[0]
108
+
109
+
110
+ def filter_signals(
111
+ signals: Iterable[Signal], suppressions: SuppressionList, path: str
112
+ ) -> list[Signal]:
113
+ """Drop signals whose ``(path, kind)`` is in the suppression list."""
114
+ return [s for s in signals if not suppressions.is_suppressed(path, s.kind)]
115
+
116
+
117
+ __all__ = [
118
+ "SuppressionList",
119
+ "filter_signals",
120
+ "load",
121
+ "resolve_kind",
122
+ "save",
123
+ ]
@@ -0,0 +1,40 @@
1
+ # Risk-rank pull requests with WhyCode.
2
+ #
3
+ # On every PR this workflow computes the Risk Card for each changed file
4
+ # (vs the PR base) and prints a risk-ranked table to the job log.
5
+ # Advisory by default — humans decide.
6
+ #
7
+ # To turn it into a hard gate that blocks merging, append `--fail-on <band>`
8
+ # to the `whycode diff` line below:
9
+ # handle block at HANDLE WITH CARE (score >= 75)
10
+ # history block at READ HISTORY FIRST (score >= 50)
11
+ # look stricter — block at >= 25
12
+ name: WhyCode
13
+
14
+ on:
15
+ pull_request:
16
+ types: [opened, synchronize, reopened]
17
+
18
+ permissions:
19
+ contents: read
20
+ pull-requests: read
21
+
22
+ jobs:
23
+ whycode:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - name: Check out PR
27
+ uses: actions/checkout@v4
28
+ with:
29
+ fetch-depth: 0 # WhyCode needs full history
30
+
31
+ - name: Set up Python
32
+ uses: actions/setup-python@v5
33
+ with:
34
+ python-version: "3.11"
35
+
36
+ - name: Install WhyCode
37
+ run: pip install whycode-cli
38
+
39
+ - name: Risk-rank files in this PR
40
+ run: whycode diff --base origin/${{ github.base_ref }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whycode-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Tells you what to be afraid of before you touch a file.
5
5
  Author: Kevin
6
6
  License-Expression: MIT
@@ -67,13 +67,14 @@ decide what to do.
67
67
 
68
68
  ## Install
69
69
 
70
- Once published to PyPI:
71
-
72
70
  ```bash
73
- pip install whycode
71
+ pip install whycode-cli
74
72
  ```
75
73
 
76
- From source (current canonical path):
74
+ The PyPI distribution is `whycode-cli` (the bare name `whycode` was already
75
+ taken by an unrelated project); the installed command is still `whycode`.
76
+
77
+ From source, if you want to track `main`:
77
78
 
78
79
  ```bash
79
80
  pip install git+https://github.com/fangshuor/WhyCode.git
@@ -87,9 +88,11 @@ Requires Python 3.11+.
87
88
  cd /path/to/your/repo
88
89
 
89
90
  whycode init # one-command setup: CI workflow + pre-commit gate
91
+ whycode highlights # first-run treasure map: top decisions + incidents
90
92
  whycode why src/some/file.py # the Risk Card for one file
91
93
  whycode why src/some/file.py -b # one-line summary (for triage / scripts)
92
- whycode why src/some/file.py --at <sha> # what did this file's risk look like as of <sha>?
94
+ whycode why src/some/file.py --at <sha> # risk as of a past commit
95
+ whycode why src/some/file.py --mute <kind> # local "this signal is wrong, hide it" feedback
93
96
  whycode diff # rank everything you changed vs origin/main
94
97
  whycode diff --staged # ditto, for files staged for commit
95
98
  whycode diff --fail-on history # CI gate: exit 1 if any file is ≥ READ HISTORY FIRST
@@ -177,11 +180,13 @@ That installs two things:
177
180
  - **`.git/hooks/pre-commit`** — runs `whycode diff --staged --fail-on handle`
178
181
  before every commit. HANDLE WITH CARE files can't be touched without an
179
182
  explicit `git commit --no-verify`.
180
- - **`.github/workflows/whycode.yml`** — a GitHub Action that risk-ranks every
181
- PR's files and fails the build at `--fail-on history` ( READ HISTORY FIRST).
183
+ - **`.github/workflows/whycode.yml`** — a GitHub Action that prints a
184
+ risk-ranked table for every PR. Advisory by default (never blocks merging);
185
+ append `--fail-on history` (or `handle` / `look`) to the diff line to turn
186
+ it into a hard gate.
182
187
 
183
- Tune the `--fail-on` thresholds inside those two files for your repo. Re-run
184
- with `whycode init --force` to overwrite.
188
+ Tune the thresholds inside those two files for your repo. Re-run with
189
+ `whycode init --force` to overwrite.
185
190
 
186
191
  **MCP server** — see the next section.
187
192
 
@@ -9,6 +9,7 @@ src/whycode/mcp_server.py
9
9
  src/whycode/risk_card.py
10
10
  src/whycode/scorer.py
11
11
  src/whycode/signals.py
12
+ src/whycode/suppressions.py
12
13
  src/whycode/templates/__init__.py
13
14
  src/whycode/templates/github-workflow.yml
14
15
  src/whycode/templates/pre-commit
@@ -21,4 +22,5 @@ src/whycode_cli.egg-info/top_level.txt
21
22
  tests/test_cli.py
22
23
  tests/test_git_facts.py
23
24
  tests/test_scorer.py
24
- tests/test_signals.py
25
+ tests/test_signals.py
26
+ tests/test_suppressions.py
@@ -377,6 +377,94 @@ def test_honest_silent_when_no_invariants(repo, days_ago) -> None: # type: igno
377
377
  assert "no invariants" in result.output.lower()
378
378
 
379
379
 
380
+ def test_highlights_surfaces_invariants_and_incidents(repo, days_ago) -> None: # type: ignore[no-untyped-def]
381
+ repo.commit(
382
+ "compat: keep sync path",
383
+ {"a.py": "1"},
384
+ body="Do not switch to async — v1 clients break.",
385
+ when=days_ago(60),
386
+ )
387
+ repo.commit(
388
+ "hotfix: refund regression",
389
+ {"b.py": "1"},
390
+ body="See INC-447 for context.",
391
+ when=days_ago(20),
392
+ )
393
+ result = _invoke(repo.root, "highlights")
394
+ assert result.exit_code == 0
395
+ out = result.output
396
+ assert "INVARIANTS" in out
397
+ assert "Do not switch to async" in out
398
+ assert "INCIDENTS" in out
399
+ assert "hotfix: refund regression" in out
400
+
401
+
402
+ def test_highlights_json_output(repo, days_ago) -> None: # type: ignore[no-untyped-def]
403
+ repo.commit(
404
+ "compat: keep sync",
405
+ {"a.py": "1"},
406
+ body="Important: legacy header must stay.",
407
+ when=days_ago(30),
408
+ )
409
+ result = _invoke(repo.root, "highlights", "--json")
410
+ assert result.exit_code == 0
411
+ data = json.loads(result.output)
412
+ assert "invariants" in data
413
+ assert "incidents" in data
414
+ assert any("legacy header" in inv["line"] for inv in data["invariants"])
415
+
416
+
417
+ def test_highlights_quiet_repo_does_not_error(repo) -> None: # type: ignore[no-untyped-def]
418
+ repo.commit("init: nothing here", {"a.py": "1"})
419
+ result = _invoke(repo.root, "highlights")
420
+ assert result.exit_code == 0
421
+ out = result.output.lower()
422
+ assert "none found" in out
423
+
424
+
425
+ def test_why_mute_writes_suppression_and_hides_signal(repo, days_ago) -> None: # type: ignore[no-untyped-def]
426
+ repo.commit(
427
+ "init",
428
+ {"refund.py": "1"},
429
+ when=days_ago(60),
430
+ )
431
+ repo.commit(
432
+ "hotfix: regression",
433
+ {"refund.py": "2"},
434
+ body="See #INC-42",
435
+ when=days_ago(10),
436
+ )
437
+ # First call: incident_history fires.
438
+ before = _invoke(repo.root, "why", "refund.py", "--json")
439
+ data_before = json.loads(before.output)
440
+ assert any(s["kind"] == "incident_history" for s in data_before["signals"])
441
+
442
+ # Mute it.
443
+ muted = _invoke(repo.root, "why", "refund.py", "--mute", "incident", "--json")
444
+ assert muted.exit_code == 0
445
+ data_muted = json.loads(muted.output)
446
+ assert all(s["kind"] != "incident_history" for s in data_muted["signals"])
447
+ # File now exists on disk.
448
+ assert (repo.root / ".whycode" / "suppressed.json").exists()
449
+
450
+ # Subsequent run: still hidden (persistence).
451
+ again = _invoke(repo.root, "why", "refund.py", "--json")
452
+ data_again = json.loads(again.output)
453
+ assert all(s["kind"] != "incident_history" for s in data_again["signals"])
454
+
455
+ # --no-mutes brings it back without removing from the file.
456
+ bypass = _invoke(repo.root, "why", "refund.py", "--no-mutes", "--json")
457
+ data_bypass = json.loads(bypass.output)
458
+ assert any(s["kind"] == "incident_history" for s in data_bypass["signals"])
459
+
460
+
461
+ def test_why_mute_unknown_kind_errors(repo) -> None: # type: ignore[no-untyped-def]
462
+ repo.commit("init", {"a.py": "1"})
463
+ result = _invoke(repo.root, "why", "a.py", "--mute", "nonsense")
464
+ assert result.exit_code != 0
465
+ assert "unknown signal kind" in result.output.lower()
466
+
467
+
380
468
  def test_mcp_summary_field_present_in_json(repo, days_ago) -> None: # type: ignore[no-untyped-def]
381
469
  """Verify the MCP server includes a quotable summary string in get_risk_profile."""
382
470
  sha = repo.commit("feat: A", {"a.py": "1"}, when=days_ago(40))
@@ -0,0 +1,96 @@
1
+ """Tests for the local suppression list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from whycode import suppressions as sup
10
+ from whycode.signals import SignalKind
11
+
12
+
13
+ def test_empty_when_no_file(tmp_path) -> None: # type: ignore[no-untyped-def]
14
+ sl = sup.load(tmp_path)
15
+ assert len(sl) == 0
16
+ assert not sl.is_suppressed("foo.py", SignalKind.NEWBORN)
17
+
18
+
19
+ def test_add_save_load_roundtrip(tmp_path) -> None: # type: ignore[no-untyped-def]
20
+ sl = sup.load(tmp_path)
21
+ assert sl.add("src/a.py", SignalKind.INCIDENT_HISTORY) is True
22
+ assert sl.add("src/a.py", SignalKind.INCIDENT_HISTORY) is False # idempotent
23
+ sl.add("src/b.py", SignalKind.GHOST_KEEPER)
24
+ sup.save(tmp_path, sl)
25
+
26
+ reloaded = sup.load(tmp_path)
27
+ assert reloaded.is_suppressed("src/a.py", SignalKind.INCIDENT_HISTORY)
28
+ assert reloaded.is_suppressed("src/b.py", SignalKind.GHOST_KEEPER)
29
+ assert not reloaded.is_suppressed("src/a.py", SignalKind.GHOST_KEEPER)
30
+ assert not reloaded.is_suppressed("src/c.py", SignalKind.INCIDENT_HISTORY)
31
+
32
+
33
+ def test_corrupt_file_treated_as_empty(tmp_path) -> None: # type: ignore[no-untyped-def]
34
+ target = tmp_path / ".whycode" / "suppressed.json"
35
+ target.parent.mkdir()
36
+ target.write_text("not json {")
37
+ sl = sup.load(tmp_path)
38
+ assert len(sl) == 0 # graceful degradation
39
+
40
+
41
+ def test_save_creates_directory(tmp_path) -> None: # type: ignore[no-untyped-def]
42
+ sl = sup.SuppressionList()
43
+ sl.add("a.py", SignalKind.SILENCE)
44
+ sup.save(tmp_path, sl)
45
+ assert (tmp_path / ".whycode" / "suppressed.json").exists()
46
+ payload = json.loads((tmp_path / ".whycode" / "suppressed.json").read_text())
47
+ assert payload == {"suppressed": [["a.py", "silence"]]}
48
+
49
+
50
+ def test_resolve_kind_exact_match() -> None:
51
+ assert sup.resolve_kind("incident_history") is SignalKind.INCIDENT_HISTORY
52
+
53
+
54
+ def test_resolve_kind_prefix_match() -> None:
55
+ assert sup.resolve_kind("revert") is SignalKind.REVERT_CHAIN
56
+ assert sup.resolve_kind("ghost") is SignalKind.GHOST_KEEPER
57
+ assert sup.resolve_kind("invariant") is SignalKind.INVARIANT_QUOTE
58
+
59
+
60
+ def test_resolve_kind_dash_normalised() -> None:
61
+ assert sup.resolve_kind("incident-history") is SignalKind.INCIDENT_HISTORY
62
+
63
+
64
+ def test_resolve_kind_unknown_raises() -> None:
65
+ with pytest.raises(ValueError, match="unknown signal kind"):
66
+ sup.resolve_kind("nonsense")
67
+
68
+
69
+ def test_resolve_kind_empty_raises() -> None:
70
+ with pytest.raises(ValueError, match="empty"):
71
+ sup.resolve_kind("")
72
+
73
+
74
+ def test_filter_signals_drops_suppressed() -> None:
75
+ from whycode.signals import Signal
76
+
77
+ sigs = [
78
+ Signal(kind=SignalKind.INCIDENT_HISTORY, severity=3, headline="x", detail="x"),
79
+ Signal(kind=SignalKind.SILENCE, severity=2, headline="y", detail="y"),
80
+ ]
81
+ sl = sup.SuppressionList()
82
+ sl.add("foo.py", SignalKind.INCIDENT_HISTORY)
83
+ out = sup.filter_signals(sigs, sl, "foo.py")
84
+ assert len(out) == 1
85
+ assert out[0].kind is SignalKind.SILENCE
86
+
87
+
88
+ def test_filter_signals_path_specific() -> None:
89
+ from whycode.signals import Signal
90
+
91
+ sigs = [Signal(kind=SignalKind.INCIDENT_HISTORY, severity=3, headline="x", detail="x")]
92
+ sl = sup.SuppressionList()
93
+ sl.add("other.py", SignalKind.INCIDENT_HISTORY)
94
+ # Suppression is for other.py — must not affect foo.py.
95
+ out = sup.filter_signals(sigs, sl, "foo.py")
96
+ assert len(out) == 1
@@ -1,42 +0,0 @@
1
- # Risk-gates pull requests with WhyCode.
2
- #
3
- # On every PR this workflow computes the Risk Card for each changed file
4
- # (vs the PR base) and:
5
- # - prints a risk-ranked table to the job log
6
- # - exits non-zero if any file reaches the band you choose, blocking merge
7
- #
8
- # Tune `--fail-on` to taste:
9
- # handle block PRs touching files >= HANDLE WITH CARE (>= 75)
10
- # history block PRs touching files >= READ HISTORY FIRST (>= 50)
11
- # look stricter; block at >= 25 (probably too noisy)
12
- #
13
- # Remove `--fail-on` entirely to make WhyCode advisory only.
14
- name: WhyCode
15
-
16
- on:
17
- pull_request:
18
- types: [opened, synchronize, reopened]
19
-
20
- permissions:
21
- contents: read
22
- pull-requests: read
23
-
24
- jobs:
25
- whycode:
26
- runs-on: ubuntu-latest
27
- steps:
28
- - name: Check out PR
29
- uses: actions/checkout@v4
30
- with:
31
- fetch-depth: 0 # WhyCode needs full history
32
-
33
- - name: Set up Python
34
- uses: actions/setup-python@v5
35
- with:
36
- python-version: "3.11"
37
-
38
- - name: Install WhyCode
39
- run: pip install git+https://github.com/fangshuor/WhyCode.git
40
-
41
- - name: Risk-rank files in this PR
42
- run: whycode diff --base origin/${{ github.base_ref }} --fail-on history
File without changes
File without changes