whycode-cli 0.2.3__tar.gz → 0.2.4__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 (30) hide show
  1. {whycode_cli-0.2.3/src/whycode_cli.egg-info → whycode_cli-0.2.4}/PKG-INFO +1 -1
  2. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/pyproject.toml +1 -1
  3. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/__init__.py +1 -1
  4. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/cli.py +38 -45
  5. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/git_facts.py +50 -2
  6. {whycode_cli-0.2.3 → whycode_cli-0.2.4/src/whycode_cli.egg-info}/PKG-INFO +1 -1
  7. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/LICENSE +0 -0
  8. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/README.md +0 -0
  9. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/setup.cfg +0 -0
  10. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/__main__.py +0 -0
  11. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/ignore.py +0 -0
  12. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/mcp_server.py +0 -0
  13. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/risk_card.py +0 -0
  14. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/scorer.py +0 -0
  15. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/signals.py +0 -0
  16. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/suppressions.py +0 -0
  17. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/templates/__init__.py +0 -0
  18. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/templates/github-workflow.yml +0 -0
  19. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode/templates/pre-commit +0 -0
  20. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode_cli.egg-info/SOURCES.txt +0 -0
  21. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode_cli.egg-info/dependency_links.txt +0 -0
  22. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode_cli.egg-info/entry_points.txt +0 -0
  23. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode_cli.egg-info/requires.txt +0 -0
  24. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/src/whycode_cli.egg-info/top_level.txt +0 -0
  25. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_cli.py +0 -0
  26. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_git_facts.py +0 -0
  27. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_ignore.py +0 -0
  28. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_scorer.py +0 -0
  29. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_signals.py +0 -0
  30. {whycode_cli-0.2.3 → whycode_cli-0.2.4}/tests/test_suppressions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whycode-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
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.2.3"
7
+ version = "0.2.4"
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.2.3"
3
+ __version__ = "0.2.4"
@@ -71,12 +71,29 @@ def _path_is_known_to_git(repo_root: Path, rel: str) -> bool:
71
71
  if gf.is_tracked(repo_root, rel):
72
72
  return True
73
73
  try:
74
- out = gf._run_git(repo_root, "log", "--oneline", "-1", "--all", "--", rel)
74
+ out = gf.run_git(repo_root, "log", "--oneline", "-1", "--all", "--", rel)
75
75
  except gf.GitError:
76
76
  return False
77
77
  return bool(out.strip())
78
78
 
79
79
 
80
+ def _require_tracked(path_arg: str) -> tuple[Path, str]:
81
+ """Resolve ``path_arg`` to ``(repo_root, rel)`` or exit with a friendly warning.
82
+
83
+ Used by every command that takes a path argument and needs git history
84
+ to be useful (``why``, ``timeline``, ``honest``). Combines the two earlier
85
+ helpers so callers don't repeat the warn-and-exit boilerplate.
86
+ """
87
+ repo_root, rel = _resolve_repo_and_path(path_arg)
88
+ if not _path_is_known_to_git(repo_root, rel):
89
+ err.print(
90
+ f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
91
+ f"and has no history in this repo. Nothing to learn from."
92
+ )
93
+ raise typer.Exit(1)
94
+ return repo_root, rel
95
+
96
+
80
97
  # --- shared: band threshold parsing ----------------------------------------
81
98
 
82
99
  _BAND_THRESHOLDS_BY_KEY: dict[str, int] = {
@@ -142,17 +159,11 @@ def why(
142
159
  ),
143
160
  ) -> None:
144
161
  """Print the Risk Card for ``path``."""
145
- repo_root, rel = _resolve_repo_and_path(path)
146
- if not _path_is_known_to_git(repo_root, rel):
147
- err.print(
148
- f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
149
- f"and has no history in this repo. Nothing to learn from."
150
- )
151
- raise typer.Exit(1)
162
+ repo_root, rel = _require_tracked(path)
152
163
  resolved_ref: str | None = None
153
164
  if at is not None:
154
165
  try:
155
- resolved_ref = gf._run_git(
166
+ resolved_ref = gf.run_git(
156
167
  repo_root, "rev-parse", "--verify", f"{at}^{{commit}}"
157
168
  ).strip()
158
169
  except gf.GitError:
@@ -202,7 +213,7 @@ def _resolve_base_ref(repo_root: Path, requested: str | None) -> str:
202
213
  candidates = ("origin/main", "origin/master", "main", "master", "HEAD~1")
203
214
  for ref in candidates:
204
215
  try:
205
- gf._run_git(repo_root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
216
+ gf.run_git(repo_root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
206
217
  return ref
207
218
  except gf.GitError:
208
219
  continue
@@ -241,13 +252,13 @@ def diff(
241
252
  try:
242
253
  repo_root = gf.discover_repo_root(repo.resolve())
243
254
  if staged:
244
- raw = gf._run_git(
255
+ raw = gf.run_git(
245
256
  repo_root, "diff", "--cached", "--name-only", "--diff-filter=ACMR"
246
257
  )
247
258
  actual_base = "(staged changes)"
248
259
  else:
249
260
  actual_base = _resolve_base_ref(repo_root, base)
250
- raw = gf._run_git(repo_root, "diff", "--name-only", f"{actual_base}...HEAD")
261
+ raw = gf.run_git(repo_root, "diff", "--name-only", f"{actual_base}...HEAD")
251
262
  except gf.GitError as exc:
252
263
  err.print(f"[red]error:[/red] {exc}")
253
264
  raise typer.Exit(2) from exc
@@ -483,13 +494,7 @@ def timeline(
483
494
  ),
484
495
  ) -> None:
485
496
  """Show how this file's risk score evolved over its history."""
486
- repo_root, rel = _resolve_repo_and_path(path)
487
- if not _path_is_known_to_git(repo_root, rel):
488
- err.print(
489
- f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
490
- f"and has no history in this repo."
491
- )
492
- raise typer.Exit(1)
497
+ repo_root, rel = _require_tracked(path)
493
498
 
494
499
  commits = gf.commits_for_path(repo_root, rel)
495
500
  if not commits:
@@ -588,7 +593,7 @@ def scan(
588
593
  err.print(f"[red]error:[/red] {exc}")
589
594
  raise typer.Exit(2) from exc
590
595
 
591
- raw = gf._run_git(repo_root, "ls-files")
596
+ raw = gf.run_git(repo_root, "ls-files")
592
597
  all_paths = [line for line in raw.splitlines() if line.strip()]
593
598
  patterns = () if no_ignore else ign.effective_patterns(repo_root)
594
599
  paths = [p for p in all_paths if not ign.is_ignored(p, patterns)][:sample]
@@ -648,13 +653,7 @@ def honest(
648
653
  Use when the Risk Card's first-sentence truncation is hiding important
649
654
  context — e.g., a commit whose constraint is stated across two lines.
650
655
  """
651
- repo_root, rel = _resolve_repo_and_path(path)
652
- if not _path_is_known_to_git(repo_root, rel):
653
- err.print(
654
- f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
655
- f"and has no history in this repo."
656
- )
657
- raise typer.Exit(1)
656
+ repo_root, rel = _require_tracked(path)
658
657
  facts = gf.gather(repo_root, rel)
659
658
  if not facts.invariant_quotes:
660
659
  if json_out:
@@ -716,24 +715,18 @@ def show(
716
715
  """Risk-flavored summary for a single commit: classification + per-file risk."""
717
716
  try:
718
717
  repo_root = gf.discover_repo_root(repo.resolve())
719
- full_sha = gf._run_git(repo_root, "rev-parse", "--verify", f"{sha}^{{commit}}").strip()
720
718
  except gf.GitError as exc:
721
719
  err.print(f"[red]error:[/red] {exc}")
722
720
  raise typer.Exit(2) from exc
723
721
 
724
- raw = gf._run_git(
725
- repo_root, "log", "-1", "--no-merges", f"--pretty=format:{gf._log_format()}", full_sha
726
- )
727
- commits = gf._parse_log_records(raw)
728
- if not commits:
729
- err.print(f"[red]error:[/red] could not read commit {full_sha}")
722
+ commit = gf.read_commit(repo_root, sha)
723
+ if commit is None:
724
+ err.print(f"[red]error:[/red] could not read commit {sha!r}")
730
725
  raise typer.Exit(2)
731
- commit = commits[0]
726
+ full_sha = commit.sha
732
727
 
733
- is_incident = bool(
734
- gf._INCIDENT_RE.search(commit.subject + "\n" + commit.body)
735
- or gf._BREAKING_CC_RE.search(commit.subject)
736
- )
728
+ classification = gf.classify_commit(commit)
729
+ is_incident = classification.incident_flavoured
737
730
  invariants = gf.extract_invariant_quotes([commit])
738
731
  file_changes = gf.files_changed_in(repo_root, full_sha)
739
732
 
@@ -768,14 +761,14 @@ def show(
768
761
  )
769
762
  console.print(f" {commit.subject}")
770
763
  console.print()
771
- classification = []
764
+ badges: list[str] = []
772
765
  if is_incident:
773
- classification.append("[bold red]incident-flavored[/bold red]")
766
+ badges.append("[bold red]incident-flavored[/bold red]")
774
767
  if invariants:
775
- classification.append(f"[yellow]states {len(invariants)} invariant(s)[/yellow]")
776
- if not classification:
777
- classification.append("[dim]no special classification[/dim]")
778
- console.print(" " + " ".join(classification))
768
+ badges.append(f"[yellow]states {len(invariants)} invariant(s)[/yellow]")
769
+ if not badges:
770
+ badges.append("[dim]no special classification[/dim]")
771
+ console.print(" " + " ".join(badges))
779
772
  console.print(f" [dim]{len(file_changes)} files changed[/dim]")
780
773
 
781
774
  if not cards:
@@ -129,8 +129,13 @@ class GitError(RuntimeError):
129
129
  """Raised when a git invocation fails or produces unexpected output."""
130
130
 
131
131
 
132
- def _run_git(repo_root: Path, *args: str) -> str:
133
- """Invoke git, return stdout. Raises GitError on non-zero exit."""
132
+ def run_git(repo_root: Path, *args: str) -> str:
133
+ """Invoke ``git -C <repo_root> <args>`` and return stdout.
134
+
135
+ Public API: callers (CLI, MCP server) use this to run git commands
136
+ that aren't already wrapped in a higher-level helper here. Raises
137
+ :class:`GitError` on non-zero exit or when ``git`` itself is missing.
138
+ """
134
139
  cmd = ["git", "-C", str(repo_root), *args]
135
140
  try:
136
141
  proc = subprocess.run(
@@ -150,6 +155,10 @@ def _run_git(repo_root: Path, *args: str) -> str:
150
155
  return proc.stdout
151
156
 
152
157
 
158
+ # Back-compat alias. Prefer ``run_git`` in new code.
159
+ _run_git = run_git
160
+
161
+
153
162
  def discover_repo_root(start: Path) -> Path:
154
163
  """Find the enclosing git repo root for ``start``."""
155
164
  out = _run_git(start, "rev-parse", "--show-toplevel").strip()
@@ -240,6 +249,25 @@ def all_commits(repo_root: Path, *, max_count: int | None = None) -> list[Commit
240
249
  return _parse_log_records(raw)
241
250
 
242
251
 
252
+ def read_commit(repo_root: Path, ref: str) -> Commit | None:
253
+ """Resolve ``ref`` (SHA, tag, branch, ``HEAD~3`` …) to a single ``Commit``.
254
+
255
+ Returns ``None`` when the ref doesn't exist or doesn't resolve to a
256
+ commit. Used by ``whycode show <sha>`` and similar single-commit views.
257
+ """
258
+ try:
259
+ full_sha = run_git(
260
+ repo_root, "rev-parse", "--verify", f"{ref}^{{commit}}"
261
+ ).strip()
262
+ except GitError:
263
+ return None
264
+ raw = run_git(
265
+ repo_root, "log", "-1", "--no-merges", f"--pretty=format:{_log_format()}", full_sha
266
+ )
267
+ parsed = _parse_log_records(raw)
268
+ return parsed[0] if parsed else None
269
+
270
+
243
271
  def files_changed_in(repo_root: Path, sha: str) -> list[FileChange]:
244
272
  """Return the list of files (with diffstat) changed in ``sha``."""
245
273
  raw = _run_git(
@@ -359,6 +387,26 @@ def find_incidents(commits: Sequence[Commit]) -> list[Commit]:
359
387
  return out
360
388
 
361
389
 
390
+ @dataclass(frozen=True)
391
+ class CommitClassification:
392
+ """Light-weight summary of what kind of work a single commit represents."""
393
+
394
+ incident_flavoured: bool
395
+ invariant_count: int
396
+
397
+
398
+ def classify_commit(commit: Commit) -> CommitClassification:
399
+ """Classify a single commit by reusing the same rules ``find_incidents`` and
400
+ ``extract_invariant_quotes`` apply to a list. Public API for ``whycode show``
401
+ and any other surface that wants a single-commit verdict without
402
+ re-implementing the regex ladder.
403
+ """
404
+ return CommitClassification(
405
+ incident_flavoured=bool(find_incidents([commit])),
406
+ invariant_count=len(extract_invariant_quotes([commit])),
407
+ )
408
+
409
+
362
410
  # Straight, backtick, and the four common Unicode "smart" quote code points.
363
411
  # We build the string from chr() calls because ruff's RUF001 ambiguous-char
364
412
  # check rejects the literal Unicode quotes inline.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whycode-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Tells you what to be afraid of before you touch a file.
5
5
  Author: Kevin
6
6
  License-Expression: MIT
File without changes
File without changes
File without changes