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/risk_card.py ADDED
@@ -0,0 +1,192 @@
1
+ """Render a Risk Card for a single file.
2
+
3
+ Two output modes:
4
+ - ``render_text`` returns a rich-renderable Group suitable for terminals.
5
+ - ``to_dict`` returns a JSON-friendly dict (used by --json and the MCP server).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from rich.console import Group
14
+ from rich.padding import Padding
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+
19
+ from whycode import git_facts as gf
20
+ from whycode import signals as sig
21
+ from whycode.scorer import Band, Score, score
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RiskCard:
29
+ path: str
30
+ score: Score
31
+ signals: tuple[sig.Signal, ...]
32
+ commit_count: int
33
+ most_recent_sha: str | None
34
+ most_recent_subject: str | None
35
+ most_recent_author: str | None
36
+ most_recent_at: str | None
37
+ as_of_sha: str | None = None
38
+ """When set, the card was computed *as of* this commit (historical view)."""
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "path": self.path,
43
+ "score": self.score.value,
44
+ "band": self.score.band.value,
45
+ "commit_count": self.commit_count,
46
+ "as_of": self.as_of_sha,
47
+ "most_recent": (
48
+ {
49
+ "sha": self.most_recent_sha,
50
+ "subject": self.most_recent_subject,
51
+ "author": self.most_recent_author,
52
+ "authored_at": self.most_recent_at,
53
+ }
54
+ if self.most_recent_sha
55
+ else None
56
+ ),
57
+ "signals": [
58
+ {
59
+ "kind": s.kind.value,
60
+ "severity": s.severity,
61
+ "headline": s.headline,
62
+ "detail": s.detail,
63
+ "evidence": list(s.evidence),
64
+ }
65
+ for s in self.signals
66
+ ],
67
+ }
68
+
69
+
70
+ def build(
71
+ repo_root: Path,
72
+ path: str,
73
+ *,
74
+ max_commits: int | None = None,
75
+ ref: str | None = None,
76
+ ) -> RiskCard:
77
+ facts = gf.gather(repo_root, path, max_commits=max_commits, ref=ref)
78
+ signals = sig.all_signals(facts)
79
+ s = score(signals)
80
+ head = facts.commits[0] if facts.commits else None
81
+ return RiskCard(
82
+ path=path,
83
+ score=s,
84
+ signals=tuple(signals),
85
+ commit_count=len(facts.commits),
86
+ most_recent_sha=head.sha[:12] if head else None,
87
+ most_recent_subject=head.subject if head else None,
88
+ most_recent_author=head.author_name if head else None,
89
+ most_recent_at=head.authored_at.isoformat() if head else None,
90
+ as_of_sha=ref[:12] if ref else None,
91
+ )
92
+
93
+
94
+ # ----- rendering ------------------------------------------------------------
95
+
96
+ _BAND_STYLE: dict[Band, str] = {
97
+ Band.HANDLE_WITH_CARE: "bold white on red",
98
+ Band.READ_HISTORY_FIRST: "bold black on yellow",
99
+ Band.WORTH_A_LOOK: "bold black on cyan",
100
+ Band.NO_FLAGS: "bold black on green",
101
+ }
102
+
103
+
104
+ def _severity_badge(severity: int) -> Text:
105
+ """Replace cryptic glyphs with a labelled, colour-coded severity tag."""
106
+ if severity >= 4:
107
+ return Text(" HIGH ", style="bold white on red")
108
+ if severity == 3:
109
+ return Text(" MED ", style="bold black on yellow")
110
+ return Text(" LOW ", style="bold black on cyan")
111
+
112
+
113
+ def _header(card: RiskCard) -> Panel:
114
+ style = _BAND_STYLE[card.score.band]
115
+ title = Text()
116
+ title.append(" ")
117
+ title.append(card.score.band.value, style=style)
118
+ title.append(" ")
119
+ title.append(f"score {card.score.value}/100", style="bold")
120
+ if card.as_of_sha:
121
+ title.append(f" as of {card.as_of_sha}", style="dim")
122
+ body = Text()
123
+ body.append(card.path, style="bold")
124
+ body.append(f" ({card.commit_count} commits)\n", style="dim")
125
+ if card.most_recent_subject:
126
+ # Subject must fit on one line inside an 80-col Panel: 80 minus borders,
127
+ # padding, and the 8-char "Latest: " prefix leaves ~64 chars usable.
128
+ subj = card.most_recent_subject
129
+ if len(subj) > 64:
130
+ subj = subj[:63] + "…"
131
+ body.append("Latest: ", style="dim")
132
+ body.append(subj + "\n", style="")
133
+ body.append(
134
+ f" {card.most_recent_sha} {card.most_recent_author} "
135
+ f"{(card.most_recent_at or '')[:10]}",
136
+ style="dim",
137
+ )
138
+ return Panel(body, title=title, title_align="left", border_style="grey50")
139
+
140
+
141
+ def _evidence_redundant(evidence: tuple[str, ...], detail: str) -> bool:
142
+ """True if every evidence token already appears verbatim in the detail."""
143
+ if not evidence:
144
+ return True
145
+ return all(token in detail for token in evidence)
146
+
147
+
148
+ def _signals_table(signals: tuple[sig.Signal, ...]) -> Table | Text:
149
+ if not signals:
150
+ return Text(
151
+ "No flags fired. The history is quiet — this is information, "
152
+ "not safety. Read the diff anyway.",
153
+ style="italic dim",
154
+ )
155
+ table = Table(show_header=False, box=None, padding=(0, 1, 1, 1), expand=True)
156
+ table.add_column(width=7, no_wrap=True)
157
+ table.add_column(ratio=1)
158
+ for s in signals:
159
+ block = Text()
160
+ block.append(s.headline + "\n", style="bold")
161
+ block.append(s.detail, style="")
162
+ if s.evidence and not _evidence_redundant(s.evidence, s.detail):
163
+ block.append("\nevidence: " + ", ".join(s.evidence), style="dim")
164
+ table.add_row(_severity_badge(s.severity), block)
165
+ return table
166
+
167
+
168
+ def _next_step_hint(signals: tuple[sig.Signal, ...]) -> Text | None:
169
+ """Suggest a single concrete next action if a SHA-shaped evidence exists."""
170
+ for s in signals:
171
+ for token in s.evidence:
172
+ if 7 <= len(token) <= 12 and all(c in "0123456789abcdef" for c in token):
173
+ hint = Text()
174
+ hint.append("→ ", style="bold")
175
+ hint.append(f"git show {token}", style="bold cyan")
176
+ hint.append(" to read the most relevant commit in full", style="dim")
177
+ return hint
178
+ return None
179
+
180
+
181
+ def render_text(card: RiskCard) -> Group:
182
+ pieces: list[Any] = [
183
+ _header(card),
184
+ Padding(_signals_table(card.signals), (0, 1, 0, 1)),
185
+ ]
186
+ hint = _next_step_hint(card.signals)
187
+ if hint is not None:
188
+ pieces.append(Padding(hint, (0, 1, 1, 2)))
189
+ return Group(*pieces)
190
+
191
+
192
+ __all__ = ["RiskCard", "build", "render_text"]
whycode/scorer.py ADDED
@@ -0,0 +1,55 @@
1
+ """Risk scoring: signals -> a single 0..100 score and a human-readable band."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+
9
+ from whycode.signals import Signal, SignalKind
10
+
11
+ # Per-signal contribution at severity 1. Severity multiplies linearly up to 5.
12
+ _BASE_WEIGHT: dict[SignalKind, int] = {
13
+ SignalKind.REVERT_CHAIN: 9,
14
+ SignalKind.INCIDENT_HISTORY: 8,
15
+ SignalKind.GHOST_KEEPER: 6,
16
+ SignalKind.INVARIANT_QUOTE: 6,
17
+ SignalKind.COUPLING: 5,
18
+ SignalKind.HIGH_CHURN: 5,
19
+ SignalKind.SILENCE: 3,
20
+ SignalKind.NEWBORN: 1,
21
+ }
22
+
23
+ _BAND_THRESHOLDS = (
24
+ (75, "HANDLE WITH CARE"),
25
+ (50, "READ HISTORY FIRST"),
26
+ (25, "WORTH A LOOK"),
27
+ (0, "NO FLAGS"),
28
+ )
29
+
30
+
31
+ class Band(StrEnum):
32
+ HANDLE_WITH_CARE = "HANDLE WITH CARE"
33
+ READ_HISTORY_FIRST = "READ HISTORY FIRST"
34
+ WORTH_A_LOOK = "WORTH A LOOK"
35
+ NO_FLAGS = "NO FLAGS"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Score:
40
+ value: int
41
+ band: Band
42
+
43
+
44
+ def score(signals: Sequence[Signal]) -> Score:
45
+ """Combine signals into a 0..100 risk score with a band label."""
46
+ raw = 0
47
+ for s in signals:
48
+ weight = _BASE_WEIGHT.get(s.kind, 1)
49
+ raw += weight * s.severity
50
+ bounded = max(0, min(100, raw))
51
+ label = next(label for thresh, label in _BAND_THRESHOLDS if bounded >= thresh)
52
+ return Score(value=bounded, band=Band(label))
53
+
54
+
55
+ __all__ = ["Band", "Score", "score"]
whycode/signals.py ADDED
@@ -0,0 +1,389 @@
1
+ """Layer 2: heuristic signal extraction.
2
+
3
+ Each signal answers one specific question. Signals never invent evidence —
4
+ every one carries the SHAs that produced it, so a careful reader can verify.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime
12
+ from enum import StrEnum
13
+ from typing import TYPE_CHECKING
14
+
15
+ from whycode import git_facts as gf
16
+
17
+ if TYPE_CHECKING:
18
+ from whycode.git_facts import RepoFacts
19
+
20
+
21
+ class SignalKind(StrEnum):
22
+ REVERT_CHAIN = "revert_chain"
23
+ INCIDENT_HISTORY = "incident_history"
24
+ HIGH_CHURN = "high_churn"
25
+ COUPLING = "coupling"
26
+ SILENCE = "silence"
27
+ GHOST_KEEPER = "ghost_keeper"
28
+ INVARIANT_QUOTE = "invariant_quote"
29
+ NEWBORN = "newborn"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Signal:
34
+ kind: SignalKind
35
+ severity: int # 1..5; 5 is loudest.
36
+ headline: str
37
+ detail: str
38
+ evidence: tuple[str, ...] = field(default_factory=tuple)
39
+ """Commit SHAs (or other identifiers) backing this signal."""
40
+
41
+
42
+ # ----- thresholds -----------------------------------------------------------
43
+ COUPLING_MIN_COCHANGES = 3
44
+ SILENCE_DAYS = 180
45
+ GHOST_KEEPER_DAYS = 365
46
+ HIGH_CHURN_WINDOW_DAYS = 90
47
+ HIGH_CHURN_MIN_COMMITS = 6
48
+ NEWBORN_DAYS = 14
49
+
50
+
51
+ def _now() -> datetime:
52
+ return datetime.now(UTC)
53
+
54
+
55
+ def _days_since(when: datetime) -> int:
56
+ if when.tzinfo is None:
57
+ when = when.replace(tzinfo=UTC)
58
+ return max(0, (_now() - when).days)
59
+
60
+
61
+ def _short(sha: str) -> str:
62
+ return sha[:7]
63
+
64
+
65
+ def _age_phrase(days: int) -> str:
66
+ """Render a days count as a human phrase used in headlines."""
67
+ if days < 14:
68
+ return f"{days} day{'s' if days != 1 else ''} ago"
69
+ if days < 90:
70
+ return f"{days // 7} weeks ago"
71
+ if days < 730:
72
+ return f"{days // 30} months ago"
73
+ years = days // 365
74
+ return f"{years} year{'s' if years != 1 else ''} ago"
75
+
76
+
77
+ def _decay_severity(severity: int, days_since_most_recent: int) -> int:
78
+ """Reduce severity for older signals; never below 1.
79
+
80
+ Buckets chosen for legibility:
81
+ - < 2 years: full weight (the team likely still remembers this)
82
+ - 2-5 years: drop one severity step (memory is fading)
83
+ - > 5 years: drop two steps, but keep at least 1 (still real evidence)
84
+ """
85
+ if days_since_most_recent > 1825:
86
+ return max(1, severity - 2)
87
+ if days_since_most_recent > 730:
88
+ return max(1, severity - 1)
89
+ return severity
90
+
91
+
92
+ def detect_revert_chain(facts: RepoFacts) -> Signal | None:
93
+ if not facts.revert_pairs:
94
+ return None
95
+ n = len(facts.revert_pairs)
96
+ severity = min(5, 2 + n)
97
+ revert_shas = {sha for sha, _ in facts.revert_pairs}
98
+ revert_commits = [c for c in facts.commits if c.sha in revert_shas]
99
+ age_phrase = ""
100
+ if revert_commits:
101
+ days = _days_since(max(c.authored_at for c in revert_commits))
102
+ severity = _decay_severity(severity, days)
103
+ age_phrase = f" (most recent: {_age_phrase(days)})"
104
+ evidence = tuple(_short(rev) for rev, _ in facts.revert_pairs)
105
+ pairs_text = ", ".join(f"{_short(rev)} reverts {_short(orig)}" for rev, orig in facts.revert_pairs)
106
+ return Signal(
107
+ kind=SignalKind.REVERT_CHAIN,
108
+ severity=severity,
109
+ headline=f"{n} revert{'s' if n != 1 else ''} touched this file{age_phrase}",
110
+ detail=f"Reverts in this file's history: {pairs_text}.",
111
+ evidence=evidence,
112
+ )
113
+
114
+
115
+ def detect_incident_history(facts: RepoFacts) -> Signal | None:
116
+ if not facts.incident_commits:
117
+ return None
118
+ most_recent = max(facts.incident_commits, key=lambda c: c.authored_at)
119
+ days = _days_since(most_recent.authored_at)
120
+ n = len(facts.incident_commits)
121
+ severity = 4 if (days < 90 and n >= 2) else 3 if days < 365 else 2
122
+ detail = (
123
+ f"{n} commit{'s' if n != 1 else ''} matched incident keywords "
124
+ f"(latest {days} day{'s' if days != 1 else ''} ago: '{most_recent.subject[:80]}')."
125
+ )
126
+ return Signal(
127
+ kind=SignalKind.INCIDENT_HISTORY,
128
+ severity=severity,
129
+ headline=f"{n} incident-flagged change{'s' if n != 1 else ''} in history",
130
+ detail=detail,
131
+ evidence=tuple(_short(c.sha) for c in facts.incident_commits[:5]),
132
+ )
133
+
134
+
135
+ def detect_high_churn(facts: RepoFacts) -> Signal | None:
136
+ cutoff_days = HIGH_CHURN_WINDOW_DAYS
137
+ recent = [c for c in facts.commits if _days_since(c.authored_at) <= cutoff_days]
138
+ if len(recent) < HIGH_CHURN_MIN_COMMITS:
139
+ return None
140
+ severity = 3 if len(recent) < 12 else 4
141
+ return Signal(
142
+ kind=SignalKind.HIGH_CHURN,
143
+ severity=severity,
144
+ headline=f"High churn: {len(recent)} commits in last {cutoff_days} days",
145
+ detail="Code that changes this often is rarely settled — read recent diffs first.",
146
+ evidence=tuple(_short(c.sha) for c in recent[:5]),
147
+ )
148
+
149
+
150
+ def detect_coupling(facts: RepoFacts) -> Signal | None:
151
+ paired = [(p, n) for p, n in facts.co_changed_files.items() if n >= COUPLING_MIN_COCHANGES]
152
+ if not paired:
153
+ return None
154
+ paired.sort(key=lambda x: (-x[1], x[0]))
155
+ top = paired[:5]
156
+ severity = 3 if top[0][1] < 6 else 4
157
+ listed = "; ".join(f"{p} (x{n})" for p, n in top)
158
+ return Signal(
159
+ kind=SignalKind.COUPLING,
160
+ severity=severity,
161
+ headline=f"Tightly coupled to {len(top)} other file{'s' if len(top) != 1 else ''}",
162
+ detail=f"Tends to change together with: {listed}.",
163
+ evidence=tuple(p for p, _ in top),
164
+ )
165
+
166
+
167
+ def detect_silence(facts: RepoFacts) -> Signal | None:
168
+ if not facts.commits:
169
+ return None
170
+ most_recent = facts.commits[0]
171
+ days = _days_since(most_recent.authored_at)
172
+ if days < SILENCE_DAYS:
173
+ return None
174
+ severity = 2 if days < 365 else 3
175
+ return Signal(
176
+ kind=SignalKind.SILENCE,
177
+ severity=severity,
178
+ headline=f"Untouched for {days} days",
179
+ detail=(
180
+ "Long-quiet code is often load-bearing. Verify it is still exercised "
181
+ "before assuming the silence means stability."
182
+ ),
183
+ evidence=(_short(most_recent.sha),),
184
+ )
185
+
186
+
187
+ def detect_newborn(facts: RepoFacts) -> Signal | None:
188
+ if not facts.commits:
189
+ return None
190
+ oldest = facts.commits[-1]
191
+ days = _days_since(oldest.authored_at)
192
+ if days > NEWBORN_DAYS:
193
+ return None
194
+ return Signal(
195
+ kind=SignalKind.NEWBORN,
196
+ severity=1,
197
+ headline=f"New file (first commit {days} day{'s' if days != 1 else ''} ago)",
198
+ detail="Limited history — the usual signals are not yet trustworthy.",
199
+ evidence=(_short(oldest.sha),),
200
+ )
201
+
202
+
203
+ def detect_ghost_keeper(facts: RepoFacts) -> Signal | None:
204
+ """Has the file's primary author left the project?
205
+
206
+ Ownership is measured by ``git blame`` lines on HEAD (so a single big
207
+ initial commit by Alice still names Alice as primary even if Bob has 20
208
+ small fixes after). When blame is unavailable, fall back to commit count.
209
+
210
+ The signal fires only if the primary owner's last commit anywhere in the
211
+ repo is older than ``GHOST_KEEPER_DAYS``. Severity scales with both how
212
+ long they have been gone and how much of the current file they own.
213
+ """
214
+ if not facts.commits:
215
+ return None
216
+
217
+ blame = gf.line_ownership(facts.repo_root, facts.path)
218
+ ownership_share: float | None = None
219
+ if blame:
220
+ total_lines = sum(blame.values())
221
+ if total_lines == 0:
222
+ return None
223
+ primary_email = max(blame, key=lambda e: blame[e])
224
+ ownership_share = blame[primary_email] / total_lines
225
+ else:
226
+ counts: dict[str, int] = {}
227
+ for commit in facts.commits:
228
+ counts[commit.author_email] = counts.get(commit.author_email, 0) + 1
229
+ primary_email = max(counts, key=lambda e: counts[e])
230
+
231
+ primary_last_seen = gf.author_last_activity(facts.repo_root, primary_email)
232
+ if primary_last_seen is None or _days_since(primary_last_seen) < GHOST_KEEPER_DAYS:
233
+ return None
234
+ days_since_seen = _days_since(primary_last_seen)
235
+
236
+ primary_commit: gf.Commit | None = None
237
+ for commit in facts.commits:
238
+ if commit.author_email == primary_email:
239
+ primary_commit = commit
240
+ break
241
+ if primary_commit is None:
242
+ primary_commit = facts.commits[0]
243
+
244
+ severity = 3
245
+ if days_since_seen > 730 or (ownership_share is not None and ownership_share >= 0.7):
246
+ severity = 4
247
+ if days_since_seen > 1825 and (ownership_share is None or ownership_share >= 0.5):
248
+ severity = 5
249
+
250
+ if ownership_share is not None:
251
+ ownership_phrase = f"wrote {ownership_share:.0%} of current lines"
252
+ else:
253
+ share = sum(1 for c in facts.commits if c.author_email == primary_email)
254
+ ownership_phrase = f"wrote {share} of {len(facts.commits)} commits here"
255
+
256
+ return Signal(
257
+ kind=SignalKind.GHOST_KEEPER,
258
+ severity=severity,
259
+ headline=f"Primary author last active {days_since_seen} days ago",
260
+ detail=(
261
+ f"{primary_commit.author_name} {ownership_phrase}, but has not "
262
+ f"committed anywhere in this repo for {days_since_seen} days. "
263
+ f"Knowledge may have left the team."
264
+ ),
265
+ evidence=(_short(primary_commit.sha),),
266
+ )
267
+
268
+
269
+ _INVARIANT_BULLETS = 3
270
+ _INVARIANT_BULLET_LEN = 110
271
+
272
+
273
+ _SENTENCE_MIN = 25
274
+
275
+
276
+ def _first_sentence(line: str, limit: int) -> str:
277
+ """Return the first complete clause from ``line``, capped at ``limit``.
278
+
279
+ A separator only counts as a clause-break once the prefix is at least
280
+ ``_SENTENCE_MIN`` characters long — otherwise short tokens like "1." or
281
+ "e.g." would split off into useless one-token bullets.
282
+ """
283
+ candidates = []
284
+ for sep in (". ", "; ", " — ", " - "):
285
+ idx = line.find(sep, _SENTENCE_MIN)
286
+ if idx > 0:
287
+ candidates.append(idx + (1 if sep.startswith(".") else 0))
288
+ if candidates:
289
+ cut = min(candidates)
290
+ sliced = line[:cut].rstrip()
291
+ if len(sliced) <= limit:
292
+ return sliced
293
+ if len(line) <= limit:
294
+ return line
295
+ return line[: limit - 1].rstrip() + "…"
296
+
297
+
298
+ def detect_invariant_quotes(facts: RepoFacts) -> Signal | None:
299
+ if not facts.invariant_quotes:
300
+ return None
301
+ # Dedupe by line, preserving the first SHA.
302
+ seen: dict[str, str] = {}
303
+ for sha, line in facts.invariant_quotes:
304
+ if line not in seen:
305
+ seen[line] = sha
306
+ total = len(seen)
307
+ quotes = list(seen.items())[:_INVARIANT_BULLETS]
308
+ bullets = [
309
+ f" > {_first_sentence(line, _INVARIANT_BULLET_LEN)} ({_short(sha)})"
310
+ for line, sha in quotes
311
+ ]
312
+ if total > _INVARIANT_BULLETS:
313
+ bullets.append(f" > …and {total - _INVARIANT_BULLETS} more in this file's history.")
314
+ rendered = "\n".join(bullets)
315
+ severity = 3 if total >= 2 else 2
316
+ # Look up the most recent invariant-bearing commit and decay severity by age.
317
+ quote_shas = {sha for _, sha in seen.items()}
318
+ quote_commits = [c for c in facts.commits if c.sha in quote_shas]
319
+ age_phrase = ""
320
+ if quote_commits:
321
+ days = _days_since(max(c.authored_at for c in quote_commits))
322
+ severity = _decay_severity(severity, days)
323
+ age_phrase = f" (most recent: {_age_phrase(days)})"
324
+ evidence_shas: list[str] = []
325
+ seen_shas: set[str] = set()
326
+ for _, sha in quotes:
327
+ short = _short(sha)
328
+ if short not in seen_shas:
329
+ seen_shas.add(short)
330
+ evidence_shas.append(short)
331
+ return Signal(
332
+ kind=SignalKind.INVARIANT_QUOTE,
333
+ severity=severity,
334
+ headline=(
335
+ f"{total} invariant{'s' if total != 1 else ''} stated by past authors"
336
+ + age_phrase
337
+ ),
338
+ detail="Past authors used cautionary language in commit messages:\n" + rendered,
339
+ evidence=tuple(evidence_shas),
340
+ )
341
+
342
+
343
+ _DETECTORS = (
344
+ detect_revert_chain,
345
+ detect_incident_history,
346
+ detect_invariant_quotes,
347
+ detect_ghost_keeper,
348
+ detect_coupling,
349
+ detect_high_churn,
350
+ detect_silence,
351
+ detect_newborn,
352
+ )
353
+
354
+
355
+ def all_signals(facts: RepoFacts) -> list[Signal]:
356
+ """Run every detector and return signals sorted by severity (loudest first).
357
+
358
+ NEWBORN is suppressed when any other signal fires: it's a "we don't have
359
+ enough history" hedge that becomes contradictory when the file has already
360
+ surfaced real flags.
361
+ """
362
+ out: list[Signal] = []
363
+ for detector in _DETECTORS:
364
+ signal = detector(facts)
365
+ if signal is not None:
366
+ out.append(signal)
367
+ if any(s.kind is not SignalKind.NEWBORN for s in out):
368
+ out = [s for s in out if s.kind is not SignalKind.NEWBORN]
369
+ out.sort(key=lambda s: (-s.severity, s.kind.value))
370
+ return out
371
+
372
+
373
+ __all__ = [
374
+ "Signal",
375
+ "SignalKind",
376
+ "all_signals",
377
+ "detect_coupling",
378
+ "detect_ghost_keeper",
379
+ "detect_high_churn",
380
+ "detect_incident_history",
381
+ "detect_invariant_quotes",
382
+ "detect_newborn",
383
+ "detect_revert_chain",
384
+ "detect_silence",
385
+ ]
386
+
387
+
388
+ def __dir__() -> Sequence[str]:
389
+ return __all__
File without changes
@@ -0,0 +1,42 @@
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
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # WhyCode pre-commit risk gate.
3
+ # Blocks commits that touch HANDLE WITH CARE files (score >= 75) without
4
+ # acknowledging them. Tune `--fail-on` if this is too strict for you.
5
+ #
6
+ # Bypass (rare): commit with `git commit --no-verify` — but think twice.
7
+ exec whycode diff --staged --fail-on handle