whycode-cli 0.2.1__tar.gz → 0.2.3__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.1/src/whycode_cli.egg-info → whycode_cli-0.2.3}/PKG-INFO +4 -2
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/README.md +3 -1
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/pyproject.toml +1 -1
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode/__init__.py +1 -1
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode/cli.py +191 -3
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode/git_facts.py +31 -8
- whycode_cli-0.2.3/src/whycode/ignore.py +114 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode/risk_card.py +12 -0
- whycode_cli-0.2.3/src/whycode/suppressions.py +123 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3/src/whycode_cli.egg-info}/PKG-INFO +4 -2
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode_cli.egg-info/SOURCES.txt +5 -1
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/tests/test_cli.py +144 -0
- whycode_cli-0.2.3/tests/test_ignore.py +73 -0
- whycode_cli-0.2.3/tests/test_suppressions.py +96 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/LICENSE +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/setup.cfg +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode/__main__.py +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode/mcp_server.py +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode/scorer.py +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode/signals.py +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode/templates/__init__.py +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode/templates/github-workflow.yml +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode/templates/pre-commit +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode_cli.egg-info/dependency_links.txt +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode_cli.egg-info/entry_points.txt +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode_cli.egg-info/requires.txt +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/src/whycode_cli.egg-info/top_level.txt +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/tests/test_git_facts.py +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/tests/test_scorer.py +0 -0
- {whycode_cli-0.2.1 → whycode_cli-0.2.3}/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.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Tells you what to be afraid of before you touch a file.
|
|
5
5
|
Author: Kevin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -88,9 +88,11 @@ Requires Python 3.11+.
|
|
|
88
88
|
cd /path/to/your/repo
|
|
89
89
|
|
|
90
90
|
whycode init # one-command setup: CI workflow + pre-commit gate
|
|
91
|
+
whycode highlights # first-run treasure map: top decisions + incidents
|
|
91
92
|
whycode why src/some/file.py # the Risk Card for one file
|
|
92
93
|
whycode why src/some/file.py -b # one-line summary (for triage / scripts)
|
|
93
|
-
whycode why src/some/file.py --at <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
|
|
94
96
|
whycode diff # rank everything you changed vs origin/main
|
|
95
97
|
whycode diff --staged # ditto, for files staged for commit
|
|
96
98
|
whycode diff --fail-on history # CI gate: exit 1 if any file is ≥ READ HISTORY FIRST
|
|
@@ -60,9 +60,11 @@ Requires Python 3.11+.
|
|
|
60
60
|
cd /path/to/your/repo
|
|
61
61
|
|
|
62
62
|
whycode init # one-command setup: CI workflow + pre-commit gate
|
|
63
|
+
whycode highlights # first-run treasure map: top decisions + incidents
|
|
63
64
|
whycode why src/some/file.py # the Risk Card for one file
|
|
64
65
|
whycode why src/some/file.py -b # one-line summary (for triage / scripts)
|
|
65
|
-
whycode why src/some/file.py --at <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
|
|
66
68
|
whycode diff # rank everything you changed vs origin/main
|
|
67
69
|
whycode diff --staged # ditto, for files staged for commit
|
|
68
70
|
whycode diff --fail-on history # CI gate: exit 1 if any file is ≥ READ HISTORY FIRST
|
|
@@ -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.
|
|
@@ -27,8 +29,10 @@ from rich.table import Table
|
|
|
27
29
|
|
|
28
30
|
from whycode import __version__
|
|
29
31
|
from whycode import git_facts as gf
|
|
32
|
+
from whycode import ignore as ign
|
|
30
33
|
from whycode import risk_card as rc
|
|
31
34
|
from whycode import signals as sig
|
|
35
|
+
from whycode import suppressions as supp
|
|
32
36
|
|
|
33
37
|
app = typer.Typer(
|
|
34
38
|
add_completion=False,
|
|
@@ -119,6 +123,20 @@ def why(
|
|
|
119
123
|
"--at",
|
|
120
124
|
help="Show the Risk Card as of this commit / ref (postmortem queries).",
|
|
121
125
|
),
|
|
126
|
+
mute: list[str] = typer.Option(
|
|
127
|
+
[],
|
|
128
|
+
"--mute",
|
|
129
|
+
help=(
|
|
130
|
+
"Suppress a signal kind for this path going forward "
|
|
131
|
+
"(stored in .whycode/suppressed.json). Accepts kind name or "
|
|
132
|
+
"unique prefix: incident, revert, ghost, coupling, silence, …"
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
no_mutes: bool = typer.Option(
|
|
136
|
+
False,
|
|
137
|
+
"--no-mutes",
|
|
138
|
+
help="Bypass the local suppression list — show all signals.",
|
|
139
|
+
),
|
|
122
140
|
max_commits: int | None = typer.Option(
|
|
123
141
|
None, "--max-commits", help="Cap the number of commits scanned (debug)."
|
|
124
142
|
),
|
|
@@ -140,7 +158,31 @@ def why(
|
|
|
140
158
|
except gf.GitError:
|
|
141
159
|
err.print(f"[red]error:[/red] unknown commit / ref: {at!r}")
|
|
142
160
|
raise typer.Exit(2) from None
|
|
143
|
-
|
|
161
|
+
if mute:
|
|
162
|
+
sl = supp.load(repo_root)
|
|
163
|
+
added: list[str] = []
|
|
164
|
+
for token in mute:
|
|
165
|
+
try:
|
|
166
|
+
kind = supp.resolve_kind(token)
|
|
167
|
+
except ValueError as exc:
|
|
168
|
+
err.print(f"[red]error:[/red] {exc}")
|
|
169
|
+
raise typer.Exit(2) from None
|
|
170
|
+
if sl.add(rel, kind):
|
|
171
|
+
added.append(kind.value)
|
|
172
|
+
if added:
|
|
173
|
+
supp.save(repo_root, sl)
|
|
174
|
+
if not json_out:
|
|
175
|
+
err.print(
|
|
176
|
+
f"[dim]muted on {rel}: {', '.join(added)} "
|
|
177
|
+
f"(stored in .whycode/suppressed.json — edit to undo)[/dim]"
|
|
178
|
+
)
|
|
179
|
+
card = rc.build(
|
|
180
|
+
repo_root,
|
|
181
|
+
rel,
|
|
182
|
+
max_commits=max_commits,
|
|
183
|
+
ref=resolved_ref,
|
|
184
|
+
apply_suppressions=not no_mutes,
|
|
185
|
+
)
|
|
144
186
|
if json_out:
|
|
145
187
|
console.print_json(json.dumps(card.to_dict()))
|
|
146
188
|
return
|
|
@@ -286,6 +328,136 @@ def diff(
|
|
|
286
328
|
raise typer.Exit(1)
|
|
287
329
|
|
|
288
330
|
|
|
331
|
+
@app.command()
|
|
332
|
+
def highlights(
|
|
333
|
+
invariants: int = typer.Option(
|
|
334
|
+
5, "--invariants", help="How many invariant lines to surface."
|
|
335
|
+
),
|
|
336
|
+
incidents: int = typer.Option(
|
|
337
|
+
5, "--incidents", help="How many incident commits to surface."
|
|
338
|
+
),
|
|
339
|
+
max_commits: int | None = typer.Option(
|
|
340
|
+
None,
|
|
341
|
+
"--max-commits",
|
|
342
|
+
help="Cap on commits scanned (defaults to no cap; tune for very large repos).",
|
|
343
|
+
),
|
|
344
|
+
repo: Path = typer.Option(Path("."), "--repo", help="Path inside the repo."),
|
|
345
|
+
json_out: bool = typer.Option(
|
|
346
|
+
False, "--json", help="Emit machine-readable JSON instead of a card."
|
|
347
|
+
),
|
|
348
|
+
) -> None:
|
|
349
|
+
"""The first-run treasure map: top decisions and incidents across the repo.
|
|
350
|
+
|
|
351
|
+
Surfaces the highest-value commit-message lines (invariants stated by past
|
|
352
|
+
authors) and the most recent incident-flavoured commits — the things a
|
|
353
|
+
reader most wants to know about the codebase before touching anything.
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
repo_root = gf.discover_repo_root(repo.resolve())
|
|
357
|
+
except gf.GitError as exc:
|
|
358
|
+
err.print(f"[red]error:[/red] {exc}")
|
|
359
|
+
raise typer.Exit(2) from exc
|
|
360
|
+
|
|
361
|
+
with console.status("Reading repo history…", spinner="dots"):
|
|
362
|
+
commits = gf.all_commits(repo_root, max_count=max_commits)
|
|
363
|
+
if not commits:
|
|
364
|
+
console.print("[yellow]no commits in this repo[/yellow]")
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
inv_pairs = gf.extract_invariant_quotes(commits)
|
|
368
|
+
sha_to_commit = {c.sha: c for c in commits}
|
|
369
|
+
seen_lines: dict[str, str] = {}
|
|
370
|
+
for sha, line in inv_pairs:
|
|
371
|
+
seen_lines.setdefault(line, sha)
|
|
372
|
+
inv_records: list[tuple[str, str, gf.Commit]] = []
|
|
373
|
+
for line, sha in seen_lines.items():
|
|
374
|
+
commit = sha_to_commit.get(sha)
|
|
375
|
+
if commit is None:
|
|
376
|
+
continue
|
|
377
|
+
inv_records.append((line, sha, commit))
|
|
378
|
+
inv_records.sort(key=lambda t: t[2].authored_at, reverse=True)
|
|
379
|
+
inv_records = inv_records[:invariants]
|
|
380
|
+
|
|
381
|
+
incident_records = gf.find_incidents(commits)[:incidents]
|
|
382
|
+
|
|
383
|
+
if json_out:
|
|
384
|
+
console.print_json(
|
|
385
|
+
json.dumps(
|
|
386
|
+
{
|
|
387
|
+
"repo": str(repo_root),
|
|
388
|
+
"scanned_commits": len(commits),
|
|
389
|
+
"invariants": [
|
|
390
|
+
{
|
|
391
|
+
"sha": c.sha[:12],
|
|
392
|
+
"subject": c.subject,
|
|
393
|
+
"author": c.author_name,
|
|
394
|
+
"authored_at": c.authored_at.isoformat(),
|
|
395
|
+
"line": line,
|
|
396
|
+
}
|
|
397
|
+
for line, _, c in inv_records
|
|
398
|
+
],
|
|
399
|
+
"incidents": [
|
|
400
|
+
{
|
|
401
|
+
"sha": c.sha[:12],
|
|
402
|
+
"subject": c.subject,
|
|
403
|
+
"author": c.author_name,
|
|
404
|
+
"authored_at": c.authored_at.isoformat(),
|
|
405
|
+
}
|
|
406
|
+
for c in incident_records
|
|
407
|
+
],
|
|
408
|
+
}
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
console.print(
|
|
414
|
+
f"[bold]WhyCode highlights[/bold] "
|
|
415
|
+
f"[dim]{len(commits)} commits scanned in this repo[/dim]\n"
|
|
416
|
+
)
|
|
417
|
+
if inv_records:
|
|
418
|
+
console.print(
|
|
419
|
+
f"[bold yellow]INVARIANTS[/bold yellow] "
|
|
420
|
+
f"[dim]({len(inv_records)} most recent stated by past authors)[/dim]"
|
|
421
|
+
)
|
|
422
|
+
for i, (line, sha, commit) in enumerate(inv_records, 1):
|
|
423
|
+
short = sha[:7]
|
|
424
|
+
date = str(commit.authored_at.date())
|
|
425
|
+
console.print(
|
|
426
|
+
f" {i}. [dim]{short} {date} {commit.author_name}[/dim]"
|
|
427
|
+
)
|
|
428
|
+
console.print(f" [italic]{line}[/italic]")
|
|
429
|
+
console.print()
|
|
430
|
+
else:
|
|
431
|
+
console.print(
|
|
432
|
+
"[dim]INVARIANTS: none found. The repo's commit bodies don't use "
|
|
433
|
+
"cautionary language like 'do not', 'must not', 'warning:', etc.[/dim]\n"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if incident_records:
|
|
437
|
+
console.print(
|
|
438
|
+
f"[bold red]INCIDENTS[/bold red] "
|
|
439
|
+
f"[dim]({len(incident_records)} most recent incident-flavoured commits)[/dim]"
|
|
440
|
+
)
|
|
441
|
+
for i, c in enumerate(incident_records, 1):
|
|
442
|
+
short = c.sha[:7]
|
|
443
|
+
date = str(c.authored_at.date())
|
|
444
|
+
subj = c.subject if len(c.subject) <= 70 else c.subject[:69] + "…"
|
|
445
|
+
console.print(
|
|
446
|
+
f" {i}. [dim]{short} {date} {c.author_name}[/dim]\n"
|
|
447
|
+
f" {subj}"
|
|
448
|
+
)
|
|
449
|
+
console.print()
|
|
450
|
+
else:
|
|
451
|
+
console.print(
|
|
452
|
+
"[dim]INCIDENTS: none found. No commit subject contains 'hotfix', "
|
|
453
|
+
"'incident', 'regression', etc.[/dim]\n"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
console.print(
|
|
457
|
+
"[dim]→ whycode why <path> to dig into the file behind any of these.[/dim]"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
289
461
|
def _sample_indices(total: int, max_samples: int) -> list[int]:
|
|
290
462
|
"""Pick at most ``max_samples`` indices spread across [0, total).
|
|
291
463
|
|
|
@@ -392,6 +564,19 @@ def scan(
|
|
|
392
564
|
"--sample",
|
|
393
565
|
help="Cap on tracked files to evaluate (for very large repos).",
|
|
394
566
|
),
|
|
567
|
+
scan_depth: int = typer.Option(
|
|
568
|
+
200,
|
|
569
|
+
"--scan-depth",
|
|
570
|
+
help=(
|
|
571
|
+
"Cap commits-per-file scanned (controls scan speed). "
|
|
572
|
+
"Use 0 for no cap (slow on large repos)."
|
|
573
|
+
),
|
|
574
|
+
),
|
|
575
|
+
no_ignore: bool = typer.Option(
|
|
576
|
+
False,
|
|
577
|
+
"--no-ignore",
|
|
578
|
+
help="Bypass the default-ignore list and scan everything (CHANGELOGs, lockfiles, vendored).",
|
|
579
|
+
),
|
|
395
580
|
repo: Path = typer.Option(
|
|
396
581
|
Path("."), "--repo", help="Path inside the repo (defaults to cwd)."
|
|
397
582
|
),
|
|
@@ -404,16 +589,19 @@ def scan(
|
|
|
404
589
|
raise typer.Exit(2) from exc
|
|
405
590
|
|
|
406
591
|
raw = gf._run_git(repo_root, "ls-files")
|
|
407
|
-
|
|
592
|
+
all_paths = [line for line in raw.splitlines() if line.strip()]
|
|
593
|
+
patterns = () if no_ignore else ign.effective_patterns(repo_root)
|
|
594
|
+
paths = [p for p in all_paths if not ign.is_ignored(p, patterns)][:sample]
|
|
408
595
|
if not paths:
|
|
409
596
|
console.print("[yellow]no tracked files found[/yellow]")
|
|
410
597
|
raise typer.Exit(0)
|
|
411
598
|
|
|
599
|
+
depth_cap = scan_depth if scan_depth > 0 else None
|
|
412
600
|
cards: list[rc.RiskCard] = []
|
|
413
601
|
with console.status(f"Scanning {len(paths)} files…", spinner="dots"):
|
|
414
602
|
for p in paths:
|
|
415
603
|
try:
|
|
416
|
-
card = rc.build(repo_root, p)
|
|
604
|
+
card = rc.build(repo_root, p, max_commits=depth_cap)
|
|
417
605
|
except gf.GitError:
|
|
418
606
|
continue
|
|
419
607
|
# Skip files whose only signal is NEWBORN — that's "not enough
|
|
@@ -268,17 +268,40 @@ def co_changes(
|
|
|
268
268
|
repo_root: Path,
|
|
269
269
|
commits: Sequence[Commit],
|
|
270
270
|
target_path: str,
|
|
271
|
+
*,
|
|
272
|
+
max_count: int | None = None,
|
|
271
273
|
) -> Counter[str]:
|
|
272
|
-
"""Count, across the
|
|
274
|
+
"""Count, across the file's history, how often other files changed alongside ``target_path``.
|
|
273
275
|
|
|
274
|
-
|
|
276
|
+
Implemented as a single ``git log --no-walk --numstat`` call over the
|
|
277
|
+
pre-fetched SHA list, rather than one ``git show`` per commit. On a
|
|
278
|
+
200-commit file this drops the cost from 200 git invocations to 1 —
|
|
279
|
+
typically a 30-50x speedup for the coupling signal in ``scan``.
|
|
280
|
+
|
|
281
|
+
Note: we cannot just pass ``--follow -- <path>`` to a single log call,
|
|
282
|
+
because git limits the numstat output to the followed path itself in
|
|
283
|
+
that mode. So we depend on the caller having already resolved the
|
|
284
|
+
relevant SHAs (in ``commits``), then pass them via ``--no-walk``.
|
|
275
285
|
"""
|
|
286
|
+
del max_count # depth was already applied when ``commits`` was built
|
|
287
|
+
if not commits:
|
|
288
|
+
return Counter()
|
|
289
|
+
shas = [c.sha for c in commits]
|
|
290
|
+
args = ["log", "--no-walk", "--numstat", "--format=%x1eCOMMIT"]
|
|
291
|
+
args.extend(shas)
|
|
292
|
+
raw = _run_git(repo_root, *args)
|
|
276
293
|
counter: Counter[str] = Counter()
|
|
277
|
-
for
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
294
|
+
for line in raw.splitlines():
|
|
295
|
+
line = line.strip()
|
|
296
|
+
if not line or line.startswith(RECORD_SEP):
|
|
297
|
+
continue
|
|
298
|
+
parts = line.split("\t")
|
|
299
|
+
if len(parts) != 3:
|
|
300
|
+
continue
|
|
301
|
+
path = parts[2]
|
|
302
|
+
if path == target_path:
|
|
303
|
+
continue
|
|
304
|
+
counter[path] += 1
|
|
282
305
|
return counter
|
|
283
306
|
|
|
284
307
|
|
|
@@ -443,7 +466,7 @@ def gather(
|
|
|
443
466
|
repo_root=repo_root,
|
|
444
467
|
path=path,
|
|
445
468
|
commits=commits,
|
|
446
|
-
co_changed_files=co_changes(repo_root, commits, path),
|
|
469
|
+
co_changed_files=co_changes(repo_root, commits, path, max_count=max_commits),
|
|
447
470
|
revert_pairs=find_revert_pairs(commits),
|
|
448
471
|
incident_commits=find_incidents(commits),
|
|
449
472
|
invariant_quotes=extract_invariant_quotes(commits),
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Default ignore patterns for repo-wide scans.
|
|
2
|
+
|
|
3
|
+
These are paths/files that almost always pollute risk analysis without
|
|
4
|
+
adding signal: changelogs (touched on every release, so they look "tightly
|
|
5
|
+
coupled to everything"), lockfiles (regenerated on every dependency bump),
|
|
6
|
+
vendored third-party code, and machine-generated stubs.
|
|
7
|
+
|
|
8
|
+
Users can extend this list with a ``.whycodeignore`` file at repo root,
|
|
9
|
+
one ``fnmatch``-style pattern per line. Comments start with ``#``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fnmatch
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
DEFAULT_IGNORE_PATTERNS: tuple[str, ...] = (
|
|
19
|
+
# Changelogs / release-notes — touched every release, never the source of risk.
|
|
20
|
+
"CHANGELOG*",
|
|
21
|
+
"CHANGES*",
|
|
22
|
+
"HISTORY*",
|
|
23
|
+
"NEWS*",
|
|
24
|
+
"RELEASE_NOTES*",
|
|
25
|
+
# Lockfiles — regenerated on every dependency bump.
|
|
26
|
+
"*.lock",
|
|
27
|
+
"package-lock.json",
|
|
28
|
+
"yarn.lock",
|
|
29
|
+
"pnpm-lock.yaml",
|
|
30
|
+
"Cargo.lock",
|
|
31
|
+
"poetry.lock",
|
|
32
|
+
"uv.lock",
|
|
33
|
+
"Pipfile.lock",
|
|
34
|
+
"Gemfile.lock",
|
|
35
|
+
"composer.lock",
|
|
36
|
+
"go.sum",
|
|
37
|
+
# Generated stubs.
|
|
38
|
+
"*.pb.go",
|
|
39
|
+
"*.pb.py",
|
|
40
|
+
"*_pb2.py",
|
|
41
|
+
"*_pb2_grpc.py",
|
|
42
|
+
"*.generated.go",
|
|
43
|
+
"*.generated.ts",
|
|
44
|
+
"*.generated.js",
|
|
45
|
+
# Minified / bundled web assets.
|
|
46
|
+
"*.min.js",
|
|
47
|
+
"*.min.css",
|
|
48
|
+
"*.bundle.js",
|
|
49
|
+
# Vendored third-party trees.
|
|
50
|
+
"vendor/**",
|
|
51
|
+
"_vendor/**",
|
|
52
|
+
"third_party/**",
|
|
53
|
+
"third-party/**",
|
|
54
|
+
"node_modules/**",
|
|
55
|
+
"bower_components/**",
|
|
56
|
+
# Built docs.
|
|
57
|
+
"_build/**",
|
|
58
|
+
"site/**",
|
|
59
|
+
"docs/_build/**",
|
|
60
|
+
"docs/build/**",
|
|
61
|
+
# Common binary / data formats that aren't code.
|
|
62
|
+
"*.png",
|
|
63
|
+
"*.jpg",
|
|
64
|
+
"*.jpeg",
|
|
65
|
+
"*.gif",
|
|
66
|
+
"*.ico",
|
|
67
|
+
"*.svg",
|
|
68
|
+
"*.pdf",
|
|
69
|
+
"*.woff",
|
|
70
|
+
"*.woff2",
|
|
71
|
+
"*.ttf",
|
|
72
|
+
"*.otf",
|
|
73
|
+
"*.eot",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
_USER_IGNORE_FILE = ".whycodeignore"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_user_patterns(repo_root: Path) -> tuple[str, ...]:
|
|
80
|
+
"""Read ``.whycodeignore`` if present. One pattern per line; ``#`` comments."""
|
|
81
|
+
target = repo_root / _USER_IGNORE_FILE
|
|
82
|
+
if not target.exists():
|
|
83
|
+
return ()
|
|
84
|
+
out: list[str] = []
|
|
85
|
+
for raw in target.read_text().splitlines():
|
|
86
|
+
line = raw.strip()
|
|
87
|
+
if not line or line.startswith("#"):
|
|
88
|
+
continue
|
|
89
|
+
out.append(line)
|
|
90
|
+
return tuple(out)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_ignored(path: str, patterns: Iterable[str]) -> bool:
|
|
94
|
+
"""True if ``path`` matches any pattern (``fnmatch`` semantics)."""
|
|
95
|
+
for pat in patterns:
|
|
96
|
+
if fnmatch.fnmatch(path, pat):
|
|
97
|
+
return True
|
|
98
|
+
# Also match basename for non-recursive patterns like ``CHANGELOG*``.
|
|
99
|
+
if "/" not in pat and "/" in path and fnmatch.fnmatch(path.rsplit("/", 1)[-1], pat):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def effective_patterns(repo_root: Path) -> tuple[str, ...]:
|
|
105
|
+
"""Combine the built-in defaults with the user's ``.whycodeignore``."""
|
|
106
|
+
return DEFAULT_IGNORE_PATTERNS + load_user_patterns(repo_root)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
"DEFAULT_IGNORE_PATTERNS",
|
|
111
|
+
"effective_patterns",
|
|
112
|
+
"is_ignored",
|
|
113
|
+
"load_user_patterns",
|
|
114
|
+
]
|
|
@@ -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
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: whycode-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Tells you what to be afraid of before you touch a file.
|
|
5
5
|
Author: Kevin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -88,9 +88,11 @@ Requires Python 3.11+.
|
|
|
88
88
|
cd /path/to/your/repo
|
|
89
89
|
|
|
90
90
|
whycode init # one-command setup: CI workflow + pre-commit gate
|
|
91
|
+
whycode highlights # first-run treasure map: top decisions + incidents
|
|
91
92
|
whycode why src/some/file.py # the Risk Card for one file
|
|
92
93
|
whycode why src/some/file.py -b # one-line summary (for triage / scripts)
|
|
93
|
-
whycode why src/some/file.py --at <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
|
|
94
96
|
whycode diff # rank everything you changed vs origin/main
|
|
95
97
|
whycode diff --staged # ditto, for files staged for commit
|
|
96
98
|
whycode diff --fail-on history # CI gate: exit 1 if any file is ≥ READ HISTORY FIRST
|
|
@@ -5,10 +5,12 @@ src/whycode/__init__.py
|
|
|
5
5
|
src/whycode/__main__.py
|
|
6
6
|
src/whycode/cli.py
|
|
7
7
|
src/whycode/git_facts.py
|
|
8
|
+
src/whycode/ignore.py
|
|
8
9
|
src/whycode/mcp_server.py
|
|
9
10
|
src/whycode/risk_card.py
|
|
10
11
|
src/whycode/scorer.py
|
|
11
12
|
src/whycode/signals.py
|
|
13
|
+
src/whycode/suppressions.py
|
|
12
14
|
src/whycode/templates/__init__.py
|
|
13
15
|
src/whycode/templates/github-workflow.yml
|
|
14
16
|
src/whycode/templates/pre-commit
|
|
@@ -20,5 +22,7 @@ src/whycode_cli.egg-info/requires.txt
|
|
|
20
22
|
src/whycode_cli.egg-info/top_level.txt
|
|
21
23
|
tests/test_cli.py
|
|
22
24
|
tests/test_git_facts.py
|
|
25
|
+
tests/test_ignore.py
|
|
23
26
|
tests/test_scorer.py
|
|
24
|
-
tests/test_signals.py
|
|
27
|
+
tests/test_signals.py
|
|
28
|
+
tests/test_suppressions.py
|
|
@@ -377,6 +377,150 @@ 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
|
+
|
|
468
|
+
def test_scan_skips_default_ignored_paths_by_default(repo, days_ago) -> None: # type: ignore[no-untyped-def]
|
|
469
|
+
"""CHANGELOG and lockfiles must not appear in scan output by default."""
|
|
470
|
+
sha = repo.commit(
|
|
471
|
+
"init",
|
|
472
|
+
{"CHANGELOG.md": "v1", "package-lock.json": "{}", "src/app.py": "x"},
|
|
473
|
+
when=days_ago(60),
|
|
474
|
+
)
|
|
475
|
+
repo.revert(sha, when=days_ago(50))
|
|
476
|
+
repo.commit(
|
|
477
|
+
"release: 1.1",
|
|
478
|
+
{"CHANGELOG.md": "v2", "src/app.py": "y"},
|
|
479
|
+
when=days_ago(20),
|
|
480
|
+
)
|
|
481
|
+
result = _invoke(repo.root, "scan", "--top", "10")
|
|
482
|
+
assert result.exit_code == 0
|
|
483
|
+
out = result.output
|
|
484
|
+
# CHANGELOG and lockfile must not appear in the table.
|
|
485
|
+
assert "CHANGELOG" not in out
|
|
486
|
+
assert "package-lock.json" not in out
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def test_scan_no_ignore_brings_them_back(repo, days_ago) -> None: # type: ignore[no-untyped-def]
|
|
490
|
+
repo.commit(
|
|
491
|
+
"init",
|
|
492
|
+
{"CHANGELOG.md": "v1", "src/app.py": "x"},
|
|
493
|
+
when=days_ago(60),
|
|
494
|
+
)
|
|
495
|
+
sha = repo.commit(
|
|
496
|
+
"feat: A",
|
|
497
|
+
{"CHANGELOG.md": "v2", "src/app.py": "y"},
|
|
498
|
+
when=days_ago(40),
|
|
499
|
+
)
|
|
500
|
+
repo.revert(sha, when=days_ago(20)) # safe to revert: files still exist after
|
|
501
|
+
default_run = _invoke(repo.root, "scan", "--top", "10")
|
|
502
|
+
permissive_run = _invoke(repo.root, "scan", "--top", "10", "--no-ignore")
|
|
503
|
+
assert default_run.exit_code == 0
|
|
504
|
+
assert permissive_run.exit_code == 0
|
|
505
|
+
# CHANGELOG was hidden from the default run by the ignore list…
|
|
506
|
+
assert "CHANGELOG" not in default_run.output
|
|
507
|
+
# …but is at least reachable when --no-ignore is on.
|
|
508
|
+
assert "CHANGELOG" in permissive_run.output or "src/app.py" in permissive_run.output
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_scan_respects_user_whycodeignore(repo, days_ago) -> None: # type: ignore[no-untyped-def]
|
|
512
|
+
(repo.root / ".whycodeignore").write_text("internal/legacy.py\n")
|
|
513
|
+
sha = repo.commit(
|
|
514
|
+
"init",
|
|
515
|
+
{"internal/legacy.py": "1", "src/app.py": "x"},
|
|
516
|
+
when=days_ago(60),
|
|
517
|
+
)
|
|
518
|
+
repo.revert(sha, when=days_ago(50))
|
|
519
|
+
result = _invoke(repo.root, "scan", "--top", "10")
|
|
520
|
+
assert result.exit_code == 0
|
|
521
|
+
assert "internal/legacy.py" not in result.output
|
|
522
|
+
|
|
523
|
+
|
|
380
524
|
def test_mcp_summary_field_present_in_json(repo, days_ago) -> None: # type: ignore[no-untyped-def]
|
|
381
525
|
"""Verify the MCP server includes a quotable summary string in get_risk_profile."""
|
|
382
526
|
sha = repo.commit("feat: A", {"a.py": "1"}, when=days_ago(40))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Tests for the ignore-pattern matcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from whycode.ignore import (
|
|
6
|
+
DEFAULT_IGNORE_PATTERNS,
|
|
7
|
+
effective_patterns,
|
|
8
|
+
is_ignored,
|
|
9
|
+
load_user_patterns,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_default_patterns_match_changelog() -> None:
|
|
14
|
+
assert is_ignored("CHANGELOG.md", DEFAULT_IGNORE_PATTERNS)
|
|
15
|
+
assert is_ignored("CHANGES.rst", DEFAULT_IGNORE_PATTERNS)
|
|
16
|
+
assert is_ignored("HISTORY.txt", DEFAULT_IGNORE_PATTERNS)
|
|
17
|
+
assert is_ignored("RELEASE_NOTES.md", DEFAULT_IGNORE_PATTERNS)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_default_patterns_match_lockfiles() -> None:
|
|
21
|
+
assert is_ignored("package-lock.json", DEFAULT_IGNORE_PATTERNS)
|
|
22
|
+
assert is_ignored("yarn.lock", DEFAULT_IGNORE_PATTERNS)
|
|
23
|
+
assert is_ignored("Cargo.lock", DEFAULT_IGNORE_PATTERNS)
|
|
24
|
+
assert is_ignored("uv.lock", DEFAULT_IGNORE_PATTERNS)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_default_patterns_match_vendored_dirs() -> None:
|
|
28
|
+
assert is_ignored("node_modules/foo/index.js", DEFAULT_IGNORE_PATTERNS)
|
|
29
|
+
assert is_ignored("vendor/github.com/foo/bar.go", DEFAULT_IGNORE_PATTERNS)
|
|
30
|
+
assert is_ignored("third_party/x/y.cc", DEFAULT_IGNORE_PATTERNS)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_default_patterns_match_generated_stubs() -> None:
|
|
34
|
+
assert is_ignored("api_pb2.py", DEFAULT_IGNORE_PATTERNS)
|
|
35
|
+
assert is_ignored("foo.pb.go", DEFAULT_IGNORE_PATTERNS)
|
|
36
|
+
assert is_ignored("schema.generated.ts", DEFAULT_IGNORE_PATTERNS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_default_patterns_do_not_match_normal_code() -> None:
|
|
40
|
+
assert not is_ignored("src/whycode/cli.py", DEFAULT_IGNORE_PATTERNS)
|
|
41
|
+
assert not is_ignored("README.md", DEFAULT_IGNORE_PATTERNS)
|
|
42
|
+
assert not is_ignored("tests/test_cli.py", DEFAULT_IGNORE_PATTERNS)
|
|
43
|
+
assert not is_ignored("Makefile", DEFAULT_IGNORE_PATTERNS)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_basename_match_for_root_pattern_in_subdir() -> None:
|
|
47
|
+
# `CHANGELOG*` should match `docs/CHANGELOG.md` even though the pattern has no slash.
|
|
48
|
+
assert is_ignored("docs/CHANGELOG.md", DEFAULT_IGNORE_PATTERNS)
|
|
49
|
+
assert is_ignored("packages/foo/CHANGES.rst", DEFAULT_IGNORE_PATTERNS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_user_patterns_loaded(tmp_path) -> None: # type: ignore[no-untyped-def]
|
|
53
|
+
(tmp_path / ".whycodeignore").write_text(
|
|
54
|
+
"# this is a comment\n"
|
|
55
|
+
"*.proto\n"
|
|
56
|
+
"scripts/\n"
|
|
57
|
+
"\n" # blank line
|
|
58
|
+
"internal/legacy.py\n"
|
|
59
|
+
)
|
|
60
|
+
patterns = load_user_patterns(tmp_path)
|
|
61
|
+
assert patterns == ("*.proto", "scripts/", "internal/legacy.py")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_user_patterns_empty_when_no_file(tmp_path) -> None: # type: ignore[no-untyped-def]
|
|
65
|
+
assert load_user_patterns(tmp_path) == ()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_effective_patterns_combines_defaults_and_user(tmp_path) -> None: # type: ignore[no-untyped-def]
|
|
69
|
+
(tmp_path / ".whycodeignore").write_text("internal/legacy.py\n")
|
|
70
|
+
eff = effective_patterns(tmp_path)
|
|
71
|
+
assert "internal/legacy.py" in eff
|
|
72
|
+
assert "*.lock" in eff # default still present
|
|
73
|
+
assert is_ignored("internal/legacy.py", eff)
|
|
@@ -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
|
|
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
|