whycode-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- whycode/__init__.py +3 -0
- whycode/__main__.py +6 -0
- whycode/cli.py +709 -0
- whycode/git_facts.py +450 -0
- whycode/mcp_server.py +204 -0
- whycode/risk_card.py +192 -0
- whycode/scorer.py +55 -0
- whycode/signals.py +389 -0
- whycode/templates/__init__.py +0 -0
- whycode/templates/github-workflow.yml +42 -0
- whycode/templates/pre-commit +7 -0
- whycode_cli-0.2.0.dist-info/METADATA +223 -0
- whycode_cli-0.2.0.dist-info/RECORD +17 -0
- whycode_cli-0.2.0.dist-info/WHEEL +5 -0
- whycode_cli-0.2.0.dist-info/entry_points.txt +2 -0
- whycode_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- whycode_cli-0.2.0.dist-info/top_level.txt +1 -0
whycode/git_facts.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Layer 1: deterministic git facts.
|
|
2
|
+
|
|
3
|
+
Pure git plumbing wrapped in safe Python. Never interprets, never guesses.
|
|
4
|
+
The output is the bedrock that Layer 2 builds on.
|
|
5
|
+
|
|
6
|
+
Design notes
|
|
7
|
+
------------
|
|
8
|
+
- We delimit log output with ASCII unit (0x1f) and record (0x1e) separators
|
|
9
|
+
because they essentially never appear in commit messages or paths.
|
|
10
|
+
- We use ``--follow`` so file rename history is traced through.
|
|
11
|
+
- We never invoke a subcommand that mutates the repo.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
UNIT_SEP = "\x1f"
|
|
25
|
+
RECORD_SEP = "\x1e"
|
|
26
|
+
|
|
27
|
+
# A commit subject/body containing one of these markers is treated as evidence
|
|
28
|
+
# that the original author flagged something worth carrying forward.
|
|
29
|
+
INCIDENT_TOKENS: tuple[str, ...] = (
|
|
30
|
+
"hotfix",
|
|
31
|
+
"incident",
|
|
32
|
+
"outage",
|
|
33
|
+
"p0",
|
|
34
|
+
"p1",
|
|
35
|
+
"sev1",
|
|
36
|
+
"sev2",
|
|
37
|
+
"production down",
|
|
38
|
+
"rollback",
|
|
39
|
+
"regression",
|
|
40
|
+
)
|
|
41
|
+
_INCIDENT_RE = re.compile(
|
|
42
|
+
r"|".join(rf"\b{re.escape(tok)}\b" for tok in INCIDENT_TOKENS),
|
|
43
|
+
re.IGNORECASE,
|
|
44
|
+
)
|
|
45
|
+
# A Conventional Commits structured marker. Unlike free-form keywords above,
|
|
46
|
+
# this is a deliberate, anchored footer — high enough confidence to fire on
|
|
47
|
+
# body alone with no need for a corroborating issue ID.
|
|
48
|
+
_BREAKING_FOOTER_RE = re.compile(r"\bBREAKING[- ]CHANGE:", re.IGNORECASE)
|
|
49
|
+
# Conventional Commits "breaking" indicator: ``feat!:``, ``fix!:``, ``refactor!:``…
|
|
50
|
+
# Anchored to the start of the subject line (or after whitespace) and limited
|
|
51
|
+
# to known type tokens so we don't match URL fragments like ``foo!:bar``.
|
|
52
|
+
_BREAKING_CC_RE = re.compile(
|
|
53
|
+
r"(?:^|\s)(?:feat|fix|chore|refactor|perf|build|ci|docs|test|style|revert)!:",
|
|
54
|
+
re.IGNORECASE,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Issue / incident identifiers that corroborate a body-only incident keyword:
|
|
58
|
+
# - GitHub-style: #1234
|
|
59
|
+
# - Jira-style: ABC-123
|
|
60
|
+
# - Severity: SEV-1, sev1, P0, P1
|
|
61
|
+
# Used to raise body matches above the "passing mention in prose" floor.
|
|
62
|
+
_ISSUE_ID_RE = re.compile(
|
|
63
|
+
r"(?:#\d+|\b[A-Z][A-Z0-9_]+-\d+|\bSEV[- ]?\d\b|\bP[01]\b)",
|
|
64
|
+
)
|
|
65
|
+
INVARIANT_TOKENS: tuple[str, ...] = (
|
|
66
|
+
"do not",
|
|
67
|
+
"don't",
|
|
68
|
+
"must not",
|
|
69
|
+
"warning:",
|
|
70
|
+
"important:",
|
|
71
|
+
"danger:",
|
|
72
|
+
"note:",
|
|
73
|
+
"invariant",
|
|
74
|
+
"workaround",
|
|
75
|
+
"tradeoff",
|
|
76
|
+
)
|
|
77
|
+
# Compiled once: each token must appear as a whole phrase. Tokens that already
|
|
78
|
+
# end in a colon or apostrophe are treated literally; otherwise we require word
|
|
79
|
+
# boundaries so e.g. "guard" does not match "scope guard" of "guard rail".
|
|
80
|
+
_INVARIANT_RE = re.compile(
|
|
81
|
+
r"|".join(
|
|
82
|
+
rf"\b{re.escape(tok)}\b" if re.match(r"^[a-z][a-z ]*$", tok) else re.escape(tok)
|
|
83
|
+
for tok in INVARIANT_TOKENS
|
|
84
|
+
),
|
|
85
|
+
re.IGNORECASE,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Commit:
|
|
91
|
+
sha: str
|
|
92
|
+
author_name: str
|
|
93
|
+
author_email: str
|
|
94
|
+
authored_at: datetime
|
|
95
|
+
subject: str
|
|
96
|
+
body: str
|
|
97
|
+
files: tuple[str, ...] = ()
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def message(self) -> str:
|
|
101
|
+
return f"{self.subject}\n\n{self.body}".strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class FileChange:
|
|
106
|
+
sha: str
|
|
107
|
+
path: str
|
|
108
|
+
insertions: int
|
|
109
|
+
deletions: int
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class RepoFacts:
|
|
114
|
+
"""Snapshot of facts relevant to a single file."""
|
|
115
|
+
|
|
116
|
+
repo_root: Path
|
|
117
|
+
path: str
|
|
118
|
+
commits: list[Commit] = field(default_factory=list)
|
|
119
|
+
co_changed_files: Counter[str] = field(default_factory=Counter)
|
|
120
|
+
revert_pairs: list[tuple[str, str]] = field(default_factory=list)
|
|
121
|
+
"""Pairs of (revert_commit_sha, reverted_commit_sha)."""
|
|
122
|
+
|
|
123
|
+
incident_commits: list[Commit] = field(default_factory=list)
|
|
124
|
+
invariant_quotes: list[tuple[str, str]] = field(default_factory=list)
|
|
125
|
+
"""Pairs of (commit_sha, line containing an invariant token)."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class GitError(RuntimeError):
|
|
129
|
+
"""Raised when a git invocation fails or produces unexpected output."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _run_git(repo_root: Path, *args: str) -> str:
|
|
133
|
+
"""Invoke git, return stdout. Raises GitError on non-zero exit."""
|
|
134
|
+
cmd = ["git", "-C", str(repo_root), *args]
|
|
135
|
+
try:
|
|
136
|
+
proc = subprocess.run(
|
|
137
|
+
cmd,
|
|
138
|
+
check=False,
|
|
139
|
+
capture_output=True,
|
|
140
|
+
text=True,
|
|
141
|
+
encoding="utf-8",
|
|
142
|
+
errors="replace",
|
|
143
|
+
)
|
|
144
|
+
except FileNotFoundError as exc:
|
|
145
|
+
raise GitError("git executable not found on PATH") from exc
|
|
146
|
+
if proc.returncode != 0:
|
|
147
|
+
raise GitError(
|
|
148
|
+
f"git {' '.join(args)} failed (exit {proc.returncode}): {proc.stderr.strip()}"
|
|
149
|
+
)
|
|
150
|
+
return proc.stdout
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def discover_repo_root(start: Path) -> Path:
|
|
154
|
+
"""Find the enclosing git repo root for ``start``."""
|
|
155
|
+
out = _run_git(start, "rev-parse", "--show-toplevel").strip()
|
|
156
|
+
if not out:
|
|
157
|
+
raise GitError(f"{start} is not inside a git repository")
|
|
158
|
+
return Path(out)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_tracked(repo_root: Path, path: str) -> bool:
|
|
162
|
+
"""Return True if ``path`` is tracked by git in ``repo_root``."""
|
|
163
|
+
try:
|
|
164
|
+
out = _run_git(repo_root, "ls-files", "--error-unmatch", "--", path)
|
|
165
|
+
except GitError:
|
|
166
|
+
return False
|
|
167
|
+
return bool(out.strip())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_iso(timestamp: str) -> datetime:
|
|
171
|
+
return datetime.fromisoformat(timestamp.strip())
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _log_format() -> str:
|
|
175
|
+
"""The format used to serialise a commit on a single record."""
|
|
176
|
+
fields = ["%H", "%an", "%ae", "%aI", "%s", "%b"]
|
|
177
|
+
return UNIT_SEP.join(fields) + RECORD_SEP
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_log_records(raw: str) -> list[Commit]:
|
|
181
|
+
commits: list[Commit] = []
|
|
182
|
+
for record in raw.split(RECORD_SEP):
|
|
183
|
+
record = record.strip("\n")
|
|
184
|
+
if not record:
|
|
185
|
+
continue
|
|
186
|
+
parts = record.split(UNIT_SEP)
|
|
187
|
+
if len(parts) < 6:
|
|
188
|
+
# Body may contain a UNIT_SEP only if a contributor pasted one in
|
|
189
|
+
# — vanishingly rare, but be defensive: re-stitch the trailing fields.
|
|
190
|
+
head = parts[:5]
|
|
191
|
+
body = UNIT_SEP.join(parts[5:])
|
|
192
|
+
parts = [*head, body]
|
|
193
|
+
sha, author_name, author_email, authored_at, subject, body = parts
|
|
194
|
+
commits.append(
|
|
195
|
+
Commit(
|
|
196
|
+
sha=sha.strip(),
|
|
197
|
+
author_name=author_name,
|
|
198
|
+
author_email=author_email,
|
|
199
|
+
authored_at=_parse_iso(authored_at),
|
|
200
|
+
subject=subject,
|
|
201
|
+
body=body.strip("\n"),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
return commits
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def commits_for_path(
|
|
208
|
+
repo_root: Path,
|
|
209
|
+
path: str,
|
|
210
|
+
*,
|
|
211
|
+
max_count: int | None = None,
|
|
212
|
+
ref: str | None = None,
|
|
213
|
+
) -> list[Commit]:
|
|
214
|
+
"""Return commits that touched ``path`` (rename-aware), newest first.
|
|
215
|
+
|
|
216
|
+
When ``ref`` is given, only commits reachable from that revision are
|
|
217
|
+
returned — i.e., the file's history *as of* that point in time.
|
|
218
|
+
"""
|
|
219
|
+
args = [
|
|
220
|
+
"log",
|
|
221
|
+
"--follow",
|
|
222
|
+
"--no-merges",
|
|
223
|
+
f"--pretty=format:{_log_format()}",
|
|
224
|
+
]
|
|
225
|
+
if max_count is not None:
|
|
226
|
+
args.append(f"--max-count={max_count}")
|
|
227
|
+
if ref is not None:
|
|
228
|
+
args.append(ref)
|
|
229
|
+
args.extend(["--", path])
|
|
230
|
+
raw = _run_git(repo_root, *args)
|
|
231
|
+
return _parse_log_records(raw)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def all_commits(repo_root: Path, *, max_count: int | None = None) -> list[Commit]:
|
|
235
|
+
"""Return all commits in repo, newest first. Used for revert / ghost-author scans."""
|
|
236
|
+
args = ["log", "--no-merges", f"--pretty=format:{_log_format()}"]
|
|
237
|
+
if max_count is not None:
|
|
238
|
+
args.append(f"--max-count={max_count}")
|
|
239
|
+
raw = _run_git(repo_root, *args)
|
|
240
|
+
return _parse_log_records(raw)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def files_changed_in(repo_root: Path, sha: str) -> list[FileChange]:
|
|
244
|
+
"""Return the list of files (with diffstat) changed in ``sha``."""
|
|
245
|
+
raw = _run_git(
|
|
246
|
+
repo_root, "show", "--no-renames", "--numstat", "--format=", sha
|
|
247
|
+
)
|
|
248
|
+
out: list[FileChange] = []
|
|
249
|
+
for line in raw.splitlines():
|
|
250
|
+
line = line.strip()
|
|
251
|
+
if not line:
|
|
252
|
+
continue
|
|
253
|
+
parts = line.split("\t")
|
|
254
|
+
if len(parts) != 3:
|
|
255
|
+
continue
|
|
256
|
+
ins_s, del_s, path = parts
|
|
257
|
+
# Binary files appear as "-" "-".
|
|
258
|
+
try:
|
|
259
|
+
insertions = int(ins_s) if ins_s != "-" else 0
|
|
260
|
+
deletions = int(del_s) if del_s != "-" else 0
|
|
261
|
+
except ValueError:
|
|
262
|
+
continue
|
|
263
|
+
out.append(FileChange(sha=sha, path=path, insertions=insertions, deletions=deletions))
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def co_changes(
|
|
268
|
+
repo_root: Path,
|
|
269
|
+
commits: Sequence[Commit],
|
|
270
|
+
target_path: str,
|
|
271
|
+
) -> Counter[str]:
|
|
272
|
+
"""Count, across the given commits, how often other files changed alongside ``target_path``.
|
|
273
|
+
|
|
274
|
+
The target file is excluded from the result.
|
|
275
|
+
"""
|
|
276
|
+
counter: Counter[str] = Counter()
|
|
277
|
+
for commit in commits:
|
|
278
|
+
for change in files_changed_in(repo_root, commit.sha):
|
|
279
|
+
if change.path == target_path:
|
|
280
|
+
continue
|
|
281
|
+
counter[change.path] += 1
|
|
282
|
+
return counter
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
_REVERT_PREFIX = 'this reverts commit '
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def find_revert_pairs(commits: Sequence[Commit]) -> list[tuple[str, str]]:
|
|
289
|
+
"""Detect (revert_sha, reverted_sha) pairs from commit messages.
|
|
290
|
+
|
|
291
|
+
Git's default revert message body contains ``This reverts commit <sha>.``.
|
|
292
|
+
We are tolerant of leading whitespace and trailing punctuation.
|
|
293
|
+
"""
|
|
294
|
+
pairs: list[tuple[str, str]] = []
|
|
295
|
+
for commit in commits:
|
|
296
|
+
for line in commit.message.splitlines():
|
|
297
|
+
stripped = line.strip().lower()
|
|
298
|
+
if not stripped.startswith(_REVERT_PREFIX):
|
|
299
|
+
continue
|
|
300
|
+
after = stripped[len(_REVERT_PREFIX) :].strip().rstrip(".")
|
|
301
|
+
# The first whitespace-separated token is the SHA.
|
|
302
|
+
token = after.split()[0] if after else ""
|
|
303
|
+
if len(token) >= 7 and all(c in "0123456789abcdef" for c in token):
|
|
304
|
+
pairs.append((commit.sha, token))
|
|
305
|
+
break
|
|
306
|
+
return pairs
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def find_incidents(commits: Sequence[Commit]) -> list[Commit]:
|
|
310
|
+
"""Return commits whose evidence-level signals incident-flavored intent.
|
|
311
|
+
|
|
312
|
+
Acceptance ladder (highest to lowest confidence):
|
|
313
|
+
1. Subject contains an incident keyword. A commit's subject is its
|
|
314
|
+
declared purpose, so a subject hit is treated as ground truth.
|
|
315
|
+
2. Subject carries the Conventional Commits breaking marker
|
|
316
|
+
(``feat!:`` / ``fix!:`` / …).
|
|
317
|
+
3. Body carries the structured ``BREAKING CHANGE:`` footer. This is a
|
|
318
|
+
deliberate, anchored marker, not free-form prose.
|
|
319
|
+
4. Body contains an incident keyword AND an issue / incident
|
|
320
|
+
identifier nearby (``#1234``, ``INC-447``, ``SEV-1``, ``P0``).
|
|
321
|
+
This filters out passing mentions in prose like "feat: add
|
|
322
|
+
incident-aware logging" where the keyword describes a *feature*.
|
|
323
|
+
|
|
324
|
+
A bare body keyword with no corroborating ID does NOT fire.
|
|
325
|
+
"""
|
|
326
|
+
out: list[Commit] = []
|
|
327
|
+
for c in commits:
|
|
328
|
+
if _INCIDENT_RE.search(c.subject) or _BREAKING_CC_RE.search(c.subject):
|
|
329
|
+
out.append(c)
|
|
330
|
+
continue
|
|
331
|
+
if _BREAKING_FOOTER_RE.search(c.body):
|
|
332
|
+
out.append(c)
|
|
333
|
+
continue
|
|
334
|
+
if _INCIDENT_RE.search(c.body) and _ISSUE_ID_RE.search(c.body):
|
|
335
|
+
out.append(c)
|
|
336
|
+
return out
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# Straight, backtick, and the four common Unicode "smart" quote code points.
|
|
340
|
+
# We build the string from chr() calls because ruff's RUF001 ambiguous-char
|
|
341
|
+
# check rejects the literal Unicode quotes inline.
|
|
342
|
+
_QUOTE_CHARS = "\"'`" + "".join(chr(c) for c in (0x2018, 0x2019, 0x201C, 0x201D))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _all_matches_are_quoted(line: str, regex: re.Pattern[str]) -> bool:
|
|
346
|
+
"""True iff every match of ``regex`` in ``line`` is immediately bracketed
|
|
347
|
+
by quote characters — i.e. the tokens are being *named* rather than used.
|
|
348
|
+
"""
|
|
349
|
+
matches = list(regex.finditer(line))
|
|
350
|
+
if not matches:
|
|
351
|
+
return False
|
|
352
|
+
for m in matches:
|
|
353
|
+
before = line[m.start() - 1] if m.start() > 0 else ""
|
|
354
|
+
after = line[m.end()] if m.end() < len(line) else ""
|
|
355
|
+
if before in _QUOTE_CHARS and after in _QUOTE_CHARS:
|
|
356
|
+
continue
|
|
357
|
+
return False
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def extract_invariant_quotes(commits: Sequence[Commit]) -> list[tuple[str, str]]:
|
|
362
|
+
"""Pull lines from commit *bodies* that match invariant tokens.
|
|
363
|
+
|
|
364
|
+
Returns pairs of (sha, the matching line) — verbatim, capped at 200 chars.
|
|
365
|
+
|
|
366
|
+
Body-only because the subject describes what the commit did; an actual
|
|
367
|
+
constraint is almost always stated in the body. Skipping the subject also
|
|
368
|
+
eliminates the meta-mention failure mode where a commit *about* an
|
|
369
|
+
invariant token (e.g. "fix invariant matcher") would self-flag.
|
|
370
|
+
|
|
371
|
+
Lines where every matching token is wrapped in quotes (``"do not"``) are
|
|
372
|
+
treated as references rather than statements and are skipped.
|
|
373
|
+
"""
|
|
374
|
+
out: list[tuple[str, str]] = []
|
|
375
|
+
for commit in commits:
|
|
376
|
+
for raw_line in commit.body.splitlines():
|
|
377
|
+
line = raw_line.strip()
|
|
378
|
+
if not line:
|
|
379
|
+
continue
|
|
380
|
+
if not _INVARIANT_RE.search(line):
|
|
381
|
+
continue
|
|
382
|
+
if _all_matches_are_quoted(line, _INVARIANT_RE):
|
|
383
|
+
continue
|
|
384
|
+
out.append((commit.sha, line[:200]))
|
|
385
|
+
return out
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def author_last_activity(repo_root: Path, email: str) -> datetime | None:
|
|
389
|
+
"""Most recent commit timestamp by ``email`` anywhere in the repo, or None."""
|
|
390
|
+
raw = _run_git(
|
|
391
|
+
repo_root,
|
|
392
|
+
"log",
|
|
393
|
+
"-1",
|
|
394
|
+
"--all",
|
|
395
|
+
f"--author={email}",
|
|
396
|
+
"--pretty=format:%aI",
|
|
397
|
+
)
|
|
398
|
+
raw = raw.strip()
|
|
399
|
+
if not raw:
|
|
400
|
+
return None
|
|
401
|
+
try:
|
|
402
|
+
return _parse_iso(raw)
|
|
403
|
+
except ValueError:
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def line_ownership(repo_root: Path, path: str) -> dict[str, int]:
|
|
408
|
+
"""Return ``{author_email: line_count}`` from ``git blame`` of HEAD's ``path``.
|
|
409
|
+
|
|
410
|
+
Empty dict if blame is unavailable (file deleted, binary, etc.). Used by
|
|
411
|
+
Layer 2 to refine ghost-keeper detection: line ownership is a stronger
|
|
412
|
+
signal than commit count, which can be skewed by a single big initial
|
|
413
|
+
commit followed by many tiny fixes.
|
|
414
|
+
"""
|
|
415
|
+
try:
|
|
416
|
+
raw = _run_git(repo_root, "blame", "--line-porcelain", "HEAD", "--", path)
|
|
417
|
+
except GitError:
|
|
418
|
+
return {}
|
|
419
|
+
counts: dict[str, int] = {}
|
|
420
|
+
current_email: str | None = None
|
|
421
|
+
for line in raw.splitlines():
|
|
422
|
+
if line.startswith("author-mail "):
|
|
423
|
+
current_email = line[len("author-mail "):].strip().strip("<>")
|
|
424
|
+
elif line.startswith("\t") and current_email:
|
|
425
|
+
counts[current_email] = counts.get(current_email, 0) + 1
|
|
426
|
+
return counts
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def gather(
|
|
430
|
+
repo_root: Path,
|
|
431
|
+
path: str,
|
|
432
|
+
*,
|
|
433
|
+
max_commits: int | None = None,
|
|
434
|
+
ref: str | None = None,
|
|
435
|
+
) -> RepoFacts:
|
|
436
|
+
"""Top-level convenience: build a RepoFacts snapshot for ``path``.
|
|
437
|
+
|
|
438
|
+
Pass ``ref`` to compute facts as of a past commit (e.g., for postmortem
|
|
439
|
+
"what did this file's risk look like at the time of the outage" queries).
|
|
440
|
+
"""
|
|
441
|
+
commits = commits_for_path(repo_root, path, max_count=max_commits, ref=ref)
|
|
442
|
+
return RepoFacts(
|
|
443
|
+
repo_root=repo_root,
|
|
444
|
+
path=path,
|
|
445
|
+
commits=commits,
|
|
446
|
+
co_changed_files=co_changes(repo_root, commits, path),
|
|
447
|
+
revert_pairs=find_revert_pairs(commits),
|
|
448
|
+
incident_commits=find_incidents(commits),
|
|
449
|
+
invariant_quotes=extract_invariant_quotes(commits),
|
|
450
|
+
)
|
whycode/mcp_server.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""MCP server for WhyCode.
|
|
2
|
+
|
|
3
|
+
Exposes WhyCode's Risk Card to MCP-aware editors and assistants so the host
|
|
4
|
+
LLM can pull a file's risk profile *before* it edits the code.
|
|
5
|
+
|
|
6
|
+
Tools
|
|
7
|
+
-----
|
|
8
|
+
- ``get_risk_profile(path)`` — full Risk Card.
|
|
9
|
+
- ``get_file_decisions(path, limit=5)`` — decision-flavoured signals only
|
|
10
|
+
(incidents, reverts, invariants), highest severity first.
|
|
11
|
+
|
|
12
|
+
The server speaks stdio. Configure your client with:
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"whycode": {"command": "whycode", "args": ["mcp"]}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from mcp.server import Server
|
|
31
|
+
from mcp.server.stdio import stdio_server
|
|
32
|
+
from mcp.types import TextContent, Tool
|
|
33
|
+
|
|
34
|
+
from whycode import git_facts as gf
|
|
35
|
+
from whycode import risk_card as rc
|
|
36
|
+
from whycode.signals import SignalKind
|
|
37
|
+
|
|
38
|
+
DECISION_KINDS = {
|
|
39
|
+
SignalKind.REVERT_CHAIN,
|
|
40
|
+
SignalKind.INCIDENT_HISTORY,
|
|
41
|
+
SignalKind.INVARIANT_QUOTE,
|
|
42
|
+
SignalKind.GHOST_KEEPER,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve(path: str) -> tuple[Path, str]:
|
|
47
|
+
p = Path(path).resolve()
|
|
48
|
+
start = p if p.is_dir() else p.parent if p.exists() else Path.cwd()
|
|
49
|
+
repo_root = gf.discover_repo_root(start)
|
|
50
|
+
if p.exists():
|
|
51
|
+
try:
|
|
52
|
+
return repo_root, str(p.relative_to(repo_root))
|
|
53
|
+
except ValueError as exc:
|
|
54
|
+
raise gf.GitError(f"{p} is not inside {repo_root}") from exc
|
|
55
|
+
return repo_root, path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _log_call(name: str, arguments: dict[str, Any]) -> None:
|
|
59
|
+
"""Print a one-line audit record to stderr (for `whycode mcp --verbose`)."""
|
|
60
|
+
stamp = time.strftime("%H:%M:%S")
|
|
61
|
+
path = arguments.get("path", "?")
|
|
62
|
+
print(f"[whycode {stamp}] {name}(path={path!r})", file=sys.stderr, flush=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _build_server(verbose: bool = False) -> Server:
|
|
66
|
+
server: Server = Server("whycode")
|
|
67
|
+
|
|
68
|
+
@server.list_tools() # type: ignore[no-untyped-call,untyped-decorator]
|
|
69
|
+
async def _list_tools() -> list[Tool]:
|
|
70
|
+
return [
|
|
71
|
+
Tool(
|
|
72
|
+
name="get_risk_profile",
|
|
73
|
+
description=(
|
|
74
|
+
"Return the WhyCode Risk Card for the given file path: a 0..100 "
|
|
75
|
+
"score, a band label, and the list of fired signals (revert "
|
|
76
|
+
"chains, incidents, coupling, silence, ghost keeper, invariant "
|
|
77
|
+
"quotes). Call this BEFORE editing any file you are unfamiliar "
|
|
78
|
+
"with — the response includes the SHAs that justify each flag."
|
|
79
|
+
),
|
|
80
|
+
inputSchema={
|
|
81
|
+
"type": "object",
|
|
82
|
+
"properties": {
|
|
83
|
+
"path": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"description": "Path to the file (absolute or repo-relative).",
|
|
86
|
+
},
|
|
87
|
+
"max_commits": {
|
|
88
|
+
"type": "integer",
|
|
89
|
+
"description": "Optional cap on commits scanned.",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
"required": ["path"],
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
Tool(
|
|
96
|
+
name="get_file_decisions",
|
|
97
|
+
description=(
|
|
98
|
+
"Return decision-flavoured signals only — past reverts, "
|
|
99
|
+
"incident-tagged changes, ghost keepers, and invariants stated "
|
|
100
|
+
"verbatim by past authors. Use when you specifically want the "
|
|
101
|
+
"'why' of past changes, not the broader risk picture."
|
|
102
|
+
),
|
|
103
|
+
inputSchema={
|
|
104
|
+
"type": "object",
|
|
105
|
+
"properties": {
|
|
106
|
+
"path": {"type": "string"},
|
|
107
|
+
"limit": {"type": "integer", "default": 5},
|
|
108
|
+
},
|
|
109
|
+
"required": ["path"],
|
|
110
|
+
},
|
|
111
|
+
),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
@server.call_tool() # type: ignore[untyped-decorator]
|
|
115
|
+
async def _call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
116
|
+
if verbose:
|
|
117
|
+
_log_call(name, arguments)
|
|
118
|
+
if name == "get_risk_profile":
|
|
119
|
+
return _handle_risk_profile(arguments)
|
|
120
|
+
if name == "get_file_decisions":
|
|
121
|
+
return _handle_file_decisions(arguments)
|
|
122
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
123
|
+
|
|
124
|
+
return server
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _summary_text(card: rc.RiskCard) -> str:
|
|
128
|
+
"""One-paragraph prose summary of the card. Designed to be quotable verbatim
|
|
129
|
+
by an LLM consumer without further processing."""
|
|
130
|
+
if not card.signals:
|
|
131
|
+
return (
|
|
132
|
+
f"{card.path}: {card.score.band.value} ({card.score.value}/100). "
|
|
133
|
+
f"No flagged signals across {card.commit_count} commits — but read "
|
|
134
|
+
f"the diff anyway."
|
|
135
|
+
)
|
|
136
|
+
top = card.signals[0]
|
|
137
|
+
extras = ""
|
|
138
|
+
if len(card.signals) > 1:
|
|
139
|
+
extras = f" Plus {len(card.signals) - 1} more signal(s) in the full card."
|
|
140
|
+
return (
|
|
141
|
+
f"{card.path}: {card.score.band.value} ({card.score.value}/100). "
|
|
142
|
+
f"Top concern: {top.headline}.{extras}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _handle_risk_profile(arguments: dict[str, Any]) -> list[TextContent]:
|
|
147
|
+
path = str(arguments["path"])
|
|
148
|
+
max_commits = arguments.get("max_commits")
|
|
149
|
+
try:
|
|
150
|
+
repo_root, rel = _resolve(path)
|
|
151
|
+
card = rc.build(repo_root, rel, max_commits=max_commits)
|
|
152
|
+
except gf.GitError as exc:
|
|
153
|
+
return [TextContent(type="text", text=json.dumps({"error": str(exc)}))]
|
|
154
|
+
payload = card.to_dict()
|
|
155
|
+
payload["summary"] = _summary_text(card)
|
|
156
|
+
return [TextContent(type="text", text=json.dumps(payload, indent=2))]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _handle_file_decisions(arguments: dict[str, Any]) -> list[TextContent]:
|
|
160
|
+
path = str(arguments["path"])
|
|
161
|
+
limit = int(arguments.get("limit", 5))
|
|
162
|
+
try:
|
|
163
|
+
repo_root, rel = _resolve(path)
|
|
164
|
+
card = rc.build(repo_root, rel)
|
|
165
|
+
except gf.GitError as exc:
|
|
166
|
+
return [TextContent(type="text", text=json.dumps({"error": str(exc)}))]
|
|
167
|
+
decisions = [s for s in card.signals if s.kind in DECISION_KINDS][:limit]
|
|
168
|
+
payload = {
|
|
169
|
+
"path": card.path,
|
|
170
|
+
"score": card.score.value,
|
|
171
|
+
"band": card.score.band.value,
|
|
172
|
+
"summary": _summary_text(card),
|
|
173
|
+
"decisions": [
|
|
174
|
+
{
|
|
175
|
+
"kind": s.kind.value,
|
|
176
|
+
"severity": s.severity,
|
|
177
|
+
"headline": s.headline,
|
|
178
|
+
"detail": s.detail,
|
|
179
|
+
"evidence": list(s.evidence),
|
|
180
|
+
}
|
|
181
|
+
for s in decisions
|
|
182
|
+
],
|
|
183
|
+
}
|
|
184
|
+
return [TextContent(type="text", text=json.dumps(payload, indent=2))]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _run(verbose: bool) -> None:
|
|
188
|
+
server = _build_server(verbose=verbose)
|
|
189
|
+
if verbose:
|
|
190
|
+
print(
|
|
191
|
+
"[whycode] MCP server up. Tool calls from the AI will be logged below.",
|
|
192
|
+
file=sys.stderr,
|
|
193
|
+
flush=True,
|
|
194
|
+
)
|
|
195
|
+
async with stdio_server() as (reader, writer):
|
|
196
|
+
await server.run(reader, writer, server.create_initialization_options())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def serve(verbose: bool = False) -> None:
|
|
200
|
+
"""Block on the MCP server. Used by ``whycode mcp``."""
|
|
201
|
+
asyncio.run(_run(verbose))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
__all__ = ["serve"]
|