whycode-cli 0.2.1__py3-none-any.whl → 0.2.3__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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """WhyCode — tells you what to be afraid of before touching a file."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.3"
whycode/cli.py CHANGED
@@ -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
- card = rc.build(repo_root, rel, max_commits=max_commits, ref=resolved_ref)
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
- paths = [line for line in raw.splitlines() if line.strip()][:sample]
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
whycode/git_facts.py CHANGED
@@ -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 given commits, how often other files changed alongside ``target_path``.
274
+ """Count, across the file's history, how often other files changed alongside ``target_path``.
273
275
 
274
- The target file is excluded from the result.
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 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
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),
whycode/ignore.py ADDED
@@ -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
+ ]
whycode/risk_card.py CHANGED
@@ -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.1
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> # what did this file's risk look like as of <sha>?
94
+ whycode why src/some/file.py --at <sha> # risk as of a past commit
95
+ whycode why src/some/file.py --mute <kind> # local "this signal is wrong, hide it" feedback
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
@@ -0,0 +1,19 @@
1
+ whycode/__init__.py,sha256=AfhFZOERWAaRQmKxdYLrQXOh-AyNEVx9tf_458Ge7AE,96
2
+ whycode/__main__.py,sha256=dqAk6746YpuM-FTIH4TBOULegGc5WweojiZjce0VYgQ,105
3
+ whycode/cli.py,sha256=yXnwr-xwnS58erdV-Thh5lPXXU8oXANN0rlpvrfY4NQ,31266
4
+ whycode/git_facts.py,sha256=VIft8vPjiPfAX6g0Vp7OevwHB96X4DOZdsDN0xJ4eQw,16177
5
+ whycode/ignore.py,sha256=sdRO_0HSedm8aO69CSGl-zQrUVX5MEg9QGcAJWwAvP4,3021
6
+ whycode/mcp_server.py,sha256=56csOHSP90Zk59-_Puvk4WTSlCJ6xQAm-K10b_qmyAQ,7105
7
+ whycode/risk_card.py,sha256=iIk4MkQQrlnj782dxdfoogUcByunI5j6y8vUnuhByAA,6996
8
+ whycode/scorer.py,sha256=4pBejunfxzYhGUzMeL8uGEMQzC6DWiqwcTeMdo3eras,1444
9
+ whycode/signals.py,sha256=14KziRolXvhmOnMnluXpPPInoBRO5uDu0tm024EYik0,13066
10
+ whycode/suppressions.py,sha256=1lKSs-kCgpnJbcxozcgiSP8ZAfjEDMHXuM3sw4FaY78,3836
11
+ whycode/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ whycode/templates/github-workflow.yml,sha256=yy87tbYKCexNYFso4e4OxGAdIIYOLn2cVxEt-FzP2oo,1095
13
+ whycode/templates/pre-commit,sha256=IhU11CvoDwqRAAsvHwUo-BwaNbdgy1cpXc54Z_phrmQ,316
14
+ whycode_cli-0.2.3.dist-info/licenses/LICENSE,sha256=U6LN5qg5kJXSJf7KFPm9KJhmiGn3qK_GsTVWXdt1DFA,1062
15
+ whycode_cli-0.2.3.dist-info/METADATA,sha256=bdWWe3CfKf7hWiaZMjN-wWStnqvG0NKWaiixFHyGKC0,9260
16
+ whycode_cli-0.2.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
17
+ whycode_cli-0.2.3.dist-info/entry_points.txt,sha256=xrNWc4CQn3ZhQFJxsGIPiTqpN19K4pRpgaj6qGaEzSQ,44
18
+ whycode_cli-0.2.3.dist-info/top_level.txt,sha256=6yIL5rxW-4DbARHQYrPlGQVqKddZ88sjvmNosDh1w3A,8
19
+ whycode_cli-0.2.3.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- whycode/__init__.py,sha256=jJnNmctGBHLWNIpQ6nSfkbHd9x-7trhSglEmlsrULME,96
2
- whycode/__main__.py,sha256=dqAk6746YpuM-FTIH4TBOULegGc5WweojiZjce0VYgQ,105
3
- whycode/cli.py,sha256=9e2lUpAFamzmwS1ixtk8TxW49gDpXlBq0oQ5r9I5mR4,24391
4
- whycode/git_facts.py,sha256=qn4ctE6RkkGa5sFq9ZYjT7UlKIcZnmjSQkOE8NkgvcY,15177
5
- whycode/mcp_server.py,sha256=56csOHSP90Zk59-_Puvk4WTSlCJ6xQAm-K10b_qmyAQ,7105
6
- whycode/risk_card.py,sha256=IuQTfSeKwjC6RCmbXXT012xEODESonRBmZKcfvDwAOY,6479
7
- whycode/scorer.py,sha256=4pBejunfxzYhGUzMeL8uGEMQzC6DWiqwcTeMdo3eras,1444
8
- whycode/signals.py,sha256=14KziRolXvhmOnMnluXpPPInoBRO5uDu0tm024EYik0,13066
9
- whycode/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- whycode/templates/github-workflow.yml,sha256=yy87tbYKCexNYFso4e4OxGAdIIYOLn2cVxEt-FzP2oo,1095
11
- whycode/templates/pre-commit,sha256=IhU11CvoDwqRAAsvHwUo-BwaNbdgy1cpXc54Z_phrmQ,316
12
- whycode_cli-0.2.1.dist-info/licenses/LICENSE,sha256=U6LN5qg5kJXSJf7KFPm9KJhmiGn3qK_GsTVWXdt1DFA,1062
13
- whycode_cli-0.2.1.dist-info/METADATA,sha256=W9iwebvjq96Ry9P-6I3NDwA1TPOcu403hDVxwVT1-aM,9100
14
- whycode_cli-0.2.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
- whycode_cli-0.2.1.dist-info/entry_points.txt,sha256=xrNWc4CQn3ZhQFJxsGIPiTqpN19K4pRpgaj6qGaEzSQ,44
16
- whycode_cli-0.2.1.dist-info/top_level.txt,sha256=6yIL5rxW-4DbARHQYrPlGQVqKddZ88sjvmNosDh1w3A,8
17
- whycode_cli-0.2.1.dist-info/RECORD,,