whycode-cli 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/PKG-INFO +1 -1
  2. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/pyproject.toml +1 -1
  3. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/__init__.py +1 -1
  4. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/cache.py +33 -7
  5. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/cli.py +120 -34
  6. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/git_facts.py +298 -13
  7. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/ignore.py +53 -1
  8. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/signals.py +18 -1
  9. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/PKG-INFO +1 -1
  10. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_cache.py +51 -0
  11. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_cli.py +177 -1
  12. whycode_cli-0.4.2/tests/test_git_facts.py +394 -0
  13. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_ignore.py +51 -1
  14. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_signals.py +72 -10
  15. whycode_cli-0.4.0/tests/test_git_facts.py +0 -188
  16. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/LICENSE +0 -0
  17. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/README.md +0 -0
  18. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/setup.cfg +0 -0
  19. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/__main__.py +0 -0
  20. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/decisions.py +0 -0
  21. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/llm.py +0 -0
  22. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/mcp_server.py +0 -0
  23. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/risk_card.py +0 -0
  24. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/scorer.py +0 -0
  25. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/suppressions.py +0 -0
  26. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/templates/__init__.py +0 -0
  27. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/templates/github-workflow.yml +0 -0
  28. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode/templates/pre-commit +0 -0
  29. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/SOURCES.txt +0 -0
  30. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/dependency_links.txt +0 -0
  31. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/entry_points.txt +0 -0
  32. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/requires.txt +0 -0
  33. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/src/whycode_cli.egg-info/top_level.txt +0 -0
  34. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_decisions.py +0 -0
  35. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_mcp_prompts.py +0 -0
  36. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_scorer.py +0 -0
  37. {whycode_cli-0.4.0 → whycode_cli-0.4.2}/tests/test_suppressions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whycode-cli
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Tells you what to be afraid of before you touch a file.
5
5
  Author: Kevin
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "whycode-cli"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Tells you what to be afraid of before you touch a file."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """WhyCode — tells you what to be afraid of before touching a file."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.2"
@@ -112,10 +112,21 @@ class CacheStore:
112
112
  cache misses; this class never invokes ``git`` itself.
113
113
  """
114
114
 
115
- def __init__(self, db_path: Path) -> None:
115
+ def __init__(self, db_path: Path, *, in_memory: bool = False) -> None:
116
+ """Open (creating if needed) the SQLite cache at ``db_path``.
117
+
118
+ ``in_memory=True`` opens a transient ``:memory:`` connection
119
+ instead — the disk file is never created and is never read.
120
+ Used by ``--no-cache`` to retain in-session amortisation
121
+ (matches the cold-fill code path) without persisting anything.
122
+ """
116
123
  self.db_path = db_path
117
- self.db_path.parent.mkdir(parents=True, exist_ok=True)
118
- self._conn = sqlite3.connect(self.db_path)
124
+ self._in_memory = in_memory
125
+ if in_memory:
126
+ self._conn = sqlite3.connect(":memory:")
127
+ else:
128
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
129
+ self._conn = sqlite3.connect(self.db_path)
119
130
  # row_factory makes column access readable in tests / debug.
120
131
  self._conn.row_factory = sqlite3.Row
121
132
  self._conn.execute("PRAGMA foreign_keys = ON")
@@ -402,13 +413,18 @@ class CacheStore:
402
413
  file_row_count = int(
403
414
  self._conn.execute("SELECT COUNT(*) FROM commit_files").fetchone()[0]
404
415
  )
405
- try:
406
- size_bytes = self.db_path.stat().st_size
407
- except OSError:
416
+ if self._in_memory:
408
417
  size_bytes = 0
418
+ exists = False
419
+ else:
420
+ try:
421
+ size_bytes = self.db_path.stat().st_size
422
+ except OSError:
423
+ size_bytes = 0
424
+ exists = self.db_path.exists()
409
425
  return CacheStats(
410
426
  path=self.db_path,
411
- exists=self.db_path.exists(),
427
+ exists=exists,
412
428
  schema_version=self.schema_version,
413
429
  head_sha=self.head_sha,
414
430
  commit_count=commit_count,
@@ -430,6 +446,16 @@ def open_for(repo_root: Path) -> CacheStore:
430
446
  return CacheStore(cache_path_for(repo_root))
431
447
 
432
448
 
449
+ def open_in_memory(repo_root: Path) -> CacheStore:
450
+ """Open a transient in-memory cache for ``repo_root``.
451
+
452
+ Used by ``--no-cache`` to keep within-session amortisation (the same
453
+ cold-fill code path everything else uses) while never touching disk.
454
+ The store is destroyed on ``close()`` and has no after-effects.
455
+ """
456
+ return CacheStore(cache_path_for(repo_root), in_memory=True)
457
+
458
+
433
459
  def parse_authored_at(value: str) -> datetime:
434
460
  """Parse the ``authored_at`` string we stored from git.
435
461
 
@@ -20,10 +20,12 @@ Commands
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ import functools
23
24
  import json
24
25
  import sys
26
+ from collections.abc import Callable
25
27
  from pathlib import Path
26
- from typing import Any
28
+ from typing import Any, TypeVar
27
29
 
28
30
  import typer
29
31
  from rich.console import Console
@@ -48,18 +50,27 @@ err = Console(stderr=True)
48
50
 
49
51
 
50
52
  def _open_cache(repo_root: Path, no_cache: bool) -> ch.CacheStore | None:
51
- """Open the on-disk cache for ``repo_root`` unless suppressed.
52
-
53
- A None return means "do not pass a cache through git_facts" — every
54
- git-side helper falls back to its original network-free, cache-free
55
- implementation. This is the escape hatch behind ``--no-cache`` and
56
- is also the default when the cache cannot be initialised at all
57
- (read-only filesystem, etc.); we never want a cache failure to
58
- block the main read path.
53
+ """Open the cache for ``repo_root`` according to the no-cache flag.
54
+
55
+ Modes:
56
+ * ``no_cache=False`` (the default): persistent on-disk SQLite at
57
+ ``.whycode/cache.db``.
58
+ * ``no_cache=True``: a transient ``:memory:`` SQLite store. The
59
+ same git-walk code path runs as for the cold-fill, but the
60
+ database is destroyed on ``close()`` — nothing lands on disk
61
+ and the next run starts cold. Keeping per-run amortisation
62
+ (one ``git log`` walk shared across files) is what makes
63
+ ``--no-cache`` at most as slow as a cold persistent fill;
64
+ the previous ``cache=None`` short-circuit lost that and so
65
+ ``--no-cache`` re-issued per-file walks every iteration.
66
+
67
+ A ``None`` return means "do not pass a cache through git_facts".
68
+ Happens only when even an in-memory open fails — very rare and
69
+ we never want a cache problem to block the main read path.
59
70
  """
60
- if no_cache:
61
- return None
62
71
  try:
72
+ if no_cache:
73
+ return ch.open_in_memory(repo_root)
63
74
  return ch.open_for(repo_root)
64
75
  except OSError:
65
76
  return None
@@ -115,6 +126,37 @@ def _require_tracked(path_arg: str) -> tuple[Path, str]:
115
126
  return repo_root, rel
116
127
 
117
128
 
129
+ _F = TypeVar("_F", bound=Callable[..., Any])
130
+
131
+
132
+ def _propagate_failures(func: _F) -> _F:
133
+ """Convert any uncaught exception into ``typer.Exit(2)``.
134
+
135
+ A read-only field test against psf/requests caught a bug where a single
136
+ bad-timezone commit raised ``ValueError`` deep inside ``_parse_log_records``;
137
+ Rich rendered the traceback to stderr, but the process exited with status
138
+ 0. CI integrations could not tell that the run had silently failed
139
+ (a ``whycode diff --fail-on history`` step was reported as green even
140
+ though it had crashed). We wrap each command body so any unhandled
141
+ exception leaves the existing rich traceback rendering in place but
142
+ forces a non-zero exit code (``2`` for general failure). ``typer.Exit``
143
+ and ``KeyboardInterrupt`` propagate untouched so explicit exit-code
144
+ paths and Ctrl-C still behave normally.
145
+ """
146
+
147
+ @functools.wraps(func)
148
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
149
+ try:
150
+ return func(*args, **kwargs)
151
+ except (typer.Exit, typer.Abort, KeyboardInterrupt):
152
+ raise
153
+ except Exception as exc:
154
+ err.print_exception(show_locals=False)
155
+ raise typer.Exit(2) from exc
156
+
157
+ return wrapper # type: ignore[return-value]
158
+
159
+
118
160
  # --- shared: band threshold parsing ----------------------------------------
119
161
 
120
162
  _BAND_THRESHOLDS_BY_KEY: dict[str, int] = {
@@ -148,6 +190,7 @@ def _print_brief(card: rc.RiskCard) -> None:
148
190
 
149
191
 
150
192
  @app.command()
193
+ @_propagate_failures
151
194
  def why(
152
195
  path: str = typer.Argument(..., help="File path to inspect."),
153
196
  json_out: bool = typer.Option(
@@ -317,6 +360,7 @@ def _resolve_base_ref(repo_root: Path, requested: str | None) -> str:
317
360
 
318
361
 
319
362
  @app.command()
363
+ @_propagate_failures
320
364
  def diff(
321
365
  base: str | None = typer.Option(
322
366
  None, "--base", help="Base ref (default: origin/main → main → HEAD~1)."
@@ -390,7 +434,9 @@ def diff(
390
434
  cards.append(rc.build(repo_root, f, cache=cache))
391
435
  except gf.GitError:
392
436
  continue
393
- cards.sort(key=lambda c: -c.score.value)
437
+ # Stable tie-break: lex smallest path on identical scores so cache
438
+ # and --no-cache truncate the same files at --top N.
439
+ cards.sort(key=lambda c: (-c.score.value, c.path))
394
440
  cards = cards[:top]
395
441
  finally:
396
442
  if cache is not None:
@@ -482,6 +528,7 @@ def diff(
482
528
 
483
529
 
484
530
  @app.command()
531
+ @_propagate_failures
485
532
  def highlights(
486
533
  invariants: int = typer.Option(
487
534
  5, "--invariants", help="How many invariant lines to surface."
@@ -529,16 +576,17 @@ def highlights(
529
576
 
530
577
  inv_pairs = gf.extract_invariant_quotes(commits)
531
578
  sha_to_commit = {c.sha: c for c in commits}
532
- seen_lines: dict[str, str] = {}
533
- for sha, line in inv_pairs:
534
- seen_lines.setdefault(line, sha)
579
+ deduped = gf.dedupe_invariant_lines(inv_pairs, sha_to_commit)
535
580
  inv_records: list[tuple[str, str, gf.Commit]] = []
536
- for line, sha in seen_lines.items():
581
+ for sha, line in deduped:
537
582
  commit = sha_to_commit.get(sha)
538
583
  if commit is None:
539
584
  continue
540
585
  inv_records.append((line, sha, commit))
541
- inv_records.sort(key=lambda t: t[2].authored_at, reverse=True)
586
+ # Sort newest first; on identical timestamps fall back to lexicographically
587
+ # smallest sha so cache and --no-cache emit byte-identical output.
588
+ inv_records.sort(key=lambda t: t[1]) # secondary: sha asc
589
+ inv_records.sort(key=lambda t: t[2].authored_at, reverse=True) # primary
542
590
  inv_records = inv_records[:invariants]
543
591
 
544
592
  incident_records = gf.find_incidents(commits)[:incidents]
@@ -636,6 +684,7 @@ def _sample_indices(total: int, max_samples: int) -> list[int]:
636
684
 
637
685
 
638
686
  @app.command()
687
+ @_propagate_failures
639
688
  def timeline(
640
689
  path: str = typer.Argument(..., help="File path to inspect."),
641
690
  samples: int = typer.Option(
@@ -677,6 +726,12 @@ def timeline(
677
726
  top,
678
727
  )
679
728
  )
729
+ # Field-test report F14: ``timeline`` used to render rows in whatever
730
+ # non-monotonic order ``_sample_indices`` produced (uniform-across-index
731
+ # selection on a list whose ordering is git's parent traversal). Sort
732
+ # by date ascending before rendering so a reader can scan left-to-right
733
+ # without misreading the trajectory.
734
+ rows.sort(key=lambda r: r[0])
680
735
 
681
736
  if json_out:
682
737
  console.print_json(
@@ -714,6 +769,7 @@ def timeline(
714
769
 
715
770
 
716
771
  @app.command()
772
+ @_propagate_failures
717
773
  def scan(
718
774
  top: int = typer.Option(10, "--top", help="How many files to list."),
719
775
  sample: int = typer.Option(
@@ -783,7 +839,10 @@ def scan(
783
839
  if cache is not None:
784
840
  cache.close()
785
841
 
786
- cards.sort(key=lambda c: -c.score.value)
842
+ # Stable tie-break on identical scores: lexicographically smallest path
843
+ # so cache and --no-cache produce byte-identical text output for the
844
+ # same HEAD. Without this, the truncation at --top N is non-deterministic.
845
+ cards.sort(key=lambda c: (-c.score.value, c.path))
787
846
  top_cards = cards[:top]
788
847
  if not top_cards:
789
848
  # Be honest about what "no flagged files" actually means. A user who
@@ -811,6 +870,7 @@ def scan(
811
870
 
812
871
 
813
872
  @app.command()
873
+ @_propagate_failures
814
874
  def honest(
815
875
  path: str = typer.Argument(..., help="File path to inspect."),
816
876
  json_out: bool = typer.Option(False, "--json", help="Emit JSON instead of prose."),
@@ -874,6 +934,7 @@ def honest(
874
934
 
875
935
 
876
936
  @app.command()
937
+ @_propagate_failures
877
938
  def show(
878
939
  sha: str = typer.Argument(..., help="Commit SHA (full or short) to inspect."),
879
940
  repo: Path = typer.Option(Path("."), "--repo", help="Path inside the repo."),
@@ -903,7 +964,8 @@ def show(
903
964
  cards.append(rc.build(repo_root, change.path))
904
965
  except gf.GitError:
905
966
  continue
906
- cards.sort(key=lambda c: -c.score.value)
967
+ # Stable tie-break on identical scores: lex smallest path.
968
+ cards.sort(key=lambda c: (-c.score.value, c.path))
907
969
 
908
970
  if json_out:
909
971
  console.print_json(
@@ -981,6 +1043,7 @@ _MCP_SNIPPET = ''' {
981
1043
 
982
1044
 
983
1045
  @app.command()
1046
+ @_propagate_failures
984
1047
  def tour(
985
1048
  repo: Path = typer.Option(Path("."), "--repo", help="Path inside the repo."),
986
1049
  no_cache: bool = typer.Option(
@@ -1018,29 +1081,50 @@ def tour(
1018
1081
 
1019
1082
  inv_pairs = gf.extract_invariant_quotes(commits)
1020
1083
  sha_to_commit = {c.sha: c for c in commits}
1021
- seen_lines: dict[str, str] = {}
1022
- for sha, line in inv_pairs:
1023
- seen_lines.setdefault(line, sha)
1084
+ deduped = gf.dedupe_invariant_lines(inv_pairs, sha_to_commit)
1085
+ # Sort newest first with sha-asc tie-break so cache and --no-cache
1086
+ # surface the same three lines in the same order.
1087
+ deduped_sorted = sorted(
1088
+ (p for p in deduped if p[0] in sha_to_commit),
1089
+ key=lambda p: p[0],
1090
+ )
1091
+ deduped_sorted.sort(
1092
+ key=lambda p: sha_to_commit[p[0]].authored_at, reverse=True
1093
+ )
1024
1094
  invariants_top = [
1025
- (line, sha_to_commit[sha])
1026
- for line, sha in seen_lines.items()
1027
- if sha in sha_to_commit
1095
+ (line, sha_to_commit[sha]) for sha, line in deduped_sorted
1028
1096
  ][:3]
1029
1097
  incidents_top = gf.find_incidents(commits)[:3]
1030
1098
 
1031
1099
  if invariants_top or incidents_top:
1032
- console.print("[bold yellow]Decisions and incidents[/bold yellow]")
1033
- for line, c in invariants_top:
1034
- console.print(f" [italic]{line}[/italic]")
1100
+ # Field-test report F16: the original tour rendered both classes
1101
+ # under one ``Decisions and incidents`` header, so a parenthetical
1102
+ # invariant prose line was visually indistinguishable from a real
1103
+ # incident commit. Render two subheads matching the layout
1104
+ # ``highlights`` already uses.
1105
+ if invariants_top:
1035
1106
  console.print(
1036
- f" [dim]{c.sha[:7]} {c.authored_at.date()} {c.author_name}[/dim]\n"
1107
+ f"[bold yellow]Stated invariants[/bold yellow] "
1108
+ f"[dim]({len(invariants_top)} most recent)[/dim]"
1037
1109
  )
1038
- for c in incidents_top:
1039
- subj = c.subject if len(c.subject) <= 70 else c.subject[:69] + "…"
1040
- console.print(f" [red]{subj}[/red]")
1110
+ for line, c in invariants_top:
1111
+ console.print(f" [italic]{line}[/italic]")
1112
+ console.print(
1113
+ f" [dim]{c.sha[:7]} {c.authored_at.date()} "
1114
+ f"{c.author_name}[/dim]\n"
1115
+ )
1116
+ if incidents_top:
1041
1117
  console.print(
1042
- f" [dim]{c.sha[:7]} {c.authored_at.date()} {c.author_name}[/dim]\n"
1118
+ f"[bold red]Recent incidents[/bold red] "
1119
+ f"[dim]({len(incidents_top)} most recent)[/dim]"
1043
1120
  )
1121
+ for c in incidents_top:
1122
+ subj = c.subject if len(c.subject) <= 70 else c.subject[:69] + "…"
1123
+ console.print(f" [red]{subj}[/red]")
1124
+ console.print(
1125
+ f" [dim]{c.sha[:7]} {c.authored_at.date()} "
1126
+ f"{c.author_name}[/dim]\n"
1127
+ )
1044
1128
  else:
1045
1129
  console.print(
1046
1130
  "[dim]No headline decisions or incidents in recent history.[/dim]"
@@ -1072,7 +1156,8 @@ def tour(
1072
1156
  ]
1073
1157
  if useful:
1074
1158
  cards.append(card)
1075
- cards.sort(key=lambda c: -c.score.value)
1159
+ # Stable tie-break: lex smallest path on identical scores.
1160
+ cards.sort(key=lambda c: (-c.score.value, c.path))
1076
1161
 
1077
1162
  if cards:
1078
1163
  console.print("[bold red]Top 3 risky files[/bold red]")
@@ -1113,6 +1198,7 @@ def tour(
1113
1198
 
1114
1199
 
1115
1200
  @app.command()
1201
+ @_propagate_failures
1116
1202
  def init(
1117
1203
  force: bool = typer.Option(
1118
1204
  False, "--force", "-f", help="Overwrite existing files instead of skipping."