scar-cli 0.1.1__tar.gz → 0.2.0__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 (61) hide show
  1. scar_cli-0.2.0/.scars/candidates/fp-log.txt +1 -0
  2. scar_cli-0.2.0/CHANGELOG.md +20 -0
  3. {scar_cli-0.1.1 → scar_cli-0.2.0}/PKG-INFO +2 -2
  4. {scar_cli-0.1.1 → scar_cli-0.2.0}/README.md +1 -1
  5. {scar_cli-0.1.1 → scar_cli-0.2.0}/pyproject.toml +1 -1
  6. {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/cli.py +41 -2
  7. {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/hooks.py +2 -1
  8. {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/lint.py +10 -1
  9. {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/match.py +2 -2
  10. {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/store.py +26 -4
  11. scar_cli-0.2.0/tests/test_lifecycle.py +137 -0
  12. {scar_cli-0.1.1 → scar_cli-0.2.0}/uv.lock +1 -1
  13. scar_cli-0.1.1/CHANGELOG.md +0 -8
  14. {scar_cli-0.1.1 → scar_cli-0.2.0}/.github/workflows/ci.yml +0 -0
  15. {scar_cli-0.1.1 → scar_cli-0.2.0}/.github/workflows/pr-validation.yml +0 -0
  16. {scar_cli-0.1.1 → scar_cli-0.2.0}/.github/workflows/release.yml +0 -0
  17. {scar_cli-0.1.1 → scar_cli-0.2.0}/.gitignore +0 -0
  18. {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
  19. {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
  20. {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
  21. {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
  22. {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/README.md +0 -0
  23. {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/candidates/history-rewrite-orphans-commit-evidence.md +0 -0
  24. {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/template.md +0 -0
  25. {scar_cli-0.1.1 → scar_cli-0.2.0}/CONTRIBUTING.md +0 -0
  26. {scar_cli-0.1.1 → scar_cli-0.2.0}/IDEA.md +0 -0
  27. {scar_cli-0.1.1 → scar_cli-0.2.0}/LICENSE +0 -0
  28. {scar_cli-0.1.1 → scar_cli-0.2.0}/ROADMAP.md +0 -0
  29. {scar_cli-0.1.1 → scar_cli-0.2.0}/SCAR-FORMAT.md +0 -0
  30. {scar_cli-0.1.1 → scar_cli-0.2.0}/SPEC.md +0 -0
  31. {scar_cli-0.1.1 → scar_cli-0.2.0}/STRESS-TEST.md +0 -0
  32. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
  33. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/anchor-survival/RESULTS.md +0 -0
  34. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/anchor-survival/long_replay.py +0 -0
  35. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/anchor-survival/replay.py +0 -0
  36. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/auto-authorship/FINDINGS.md +0 -0
  37. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
  38. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/.gitignore +0 -0
  39. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/PROTOCOL.md +0 -0
  40. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/RESULTS.md +0 -0
  41. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
  42. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
  43. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
  44. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/README.md +0 -0
  45. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
  46. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
  47. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
  48. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/grade.py +0 -0
  49. {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/harvest/harvest.py +0 -0
  50. {scar_cli-0.1.1 → scar_cli-0.2.0}/hook/scar-hooks.py +0 -0
  51. {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/__init__.py +0 -0
  52. {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/harvest.py +0 -0
  53. {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/model.py +0 -0
  54. {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_cli.py +0 -0
  55. {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_harvest.py +0 -0
  56. {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_hooks.py +0 -0
  57. {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_installer.py +0 -0
  58. {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_lint.py +0 -0
  59. {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_match.py +0 -0
  60. {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_model.py +0 -0
  61. {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_store.py +0 -0
@@ -0,0 +1 @@
1
+ 2026-06-12 false trigger: meta-session — we tuned the revert-language detector itself, so assistant prose ('revert language', 'reverting' in test fixtures/PR text) matched REVERT_RE; nothing abandoned (tool_errors were expected CLI probes/rejections). First post-tune FP pattern: self-referential sessions about the drafter trip the drafter.
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/Daily-Nerd/Scar/compare/v0.1.1...v0.2.0) (2026-06-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * **cli:** lifecycle v0 — challenge, archive, review_after surfacing ([#16](https://github.com/Daily-Nerd/Scar/issues/16)) ([0c6fb05](https://github.com/Daily-Nerd/Scar/commit/0c6fb05fbdbb57f8ac9b2a5b558e4cf121c3d5c0)), closes [#14](https://github.com/Daily-Nerd/Scar/issues/14)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * **readme:** scar challenge is planned, not shipped — point to lifecycle issue ([8c6b021](https://github.com/Daily-Nerd/Scar/commit/8c6b021c95299cf40bf6c2d978a0421bb9705cb6))
14
+
15
+ ## [0.1.1](https://github.com/Daily-Nerd/Scar/compare/v0.1.0...v0.1.1) (2026-06-12)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **hooks:** drafter triggers on revert language only ([#12](https://github.com/Daily-Nerd/Scar/issues/12)) ([547c4bb](https://github.com/Daily-Nerd/Scar/commit/547c4bb21e3521682b6a4046602d6703d88c2cf1)), closes [#11](https://github.com/Daily-Nerd/Scar/issues/11)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scar-cli
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: SCAR — version control for negative knowledge (deadends, fences, landmines)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -51,7 +51,7 @@ The flip side: agents also solve the historically fatal flaw of every knowledge-
51
51
  - Agent hook (Claude Code `PreToolUse`, etc.) — injects relevant scars into the agent's context *before* it edits the file
52
52
  - MCP server — planned, so any agent can query the scar graph
53
53
  - `scar harvest` — mines git history (reverts, add-then-remove dependencies, reopened issues) to propose candidate scars for codebases starting from zero.
54
- - Scars are **advisory, never blocking, by default**. Stale knowledge is challenged via `scar challenge`, and every scar can carry expiry conditions ("valid until we drop Postgres 12").
54
+ - Scars are **advisory, never blocking, by default** and stale knowledge has a lifecycle: `scar challenge <id> --reason` disputes a scar (it still fires, marked as disputed), `scar archive <id> --reason` retires it (never fires again; `scar why` keeps the history), and `scar lint`/`scar status` surface any scar whose `review_after` date has passed. Nothing expires automatically — archiving is a human decision, same governance as promotion.
55
55
 
56
56
  ## Install
57
57
 
@@ -42,7 +42,7 @@ The flip side: agents also solve the historically fatal flaw of every knowledge-
42
42
  - Agent hook (Claude Code `PreToolUse`, etc.) — injects relevant scars into the agent's context *before* it edits the file
43
43
  - MCP server — planned, so any agent can query the scar graph
44
44
  - `scar harvest` — mines git history (reverts, add-then-remove dependencies, reopened issues) to propose candidate scars for codebases starting from zero.
45
- - Scars are **advisory, never blocking, by default**. Stale knowledge is challenged via `scar challenge`, and every scar can carry expiry conditions ("valid until we drop Postgres 12").
45
+ - Scars are **advisory, never blocking, by default** and stale knowledge has a lifecycle: `scar challenge <id> --reason` disputes a scar (it still fires, marked as disputed), `scar archive <id> --reason` retires it (never fires again; `scar why` keeps the history), and `scar lint`/`scar status` surface any scar whose `review_after` date has passed. Nothing expires automatically — archiving is a human decision, same governance as promotion.
46
46
 
47
47
  ## Install
48
48
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scar-cli"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "SCAR — version control for negative knowledge (deadends, fences, landmines)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import argparse
11
11
  import json
12
12
  import sys
13
+ import time
13
14
  from pathlib import Path
14
15
 
15
16
  from .lint import lint_text
@@ -58,8 +59,16 @@ def _cmd_status(_args) -> int:
58
59
  print(f"{store.scars_dir}: {len(active)} active, {len(cands)} candidate(s) pending review")
59
60
  for f, s in active:
60
61
  print(f" [{s.type} #{s.id} | {s.severity}] {s.title}")
62
+ for f, s in store.parsed():
63
+ if s.status == "challenged":
64
+ print(f" [challenged {s.type} #{s.id}] {s.title}")
61
65
  for c in cands:
62
66
  print(f" candidate: {c.name}")
67
+ today = time.strftime("%Y-%m-%d")
68
+ due = [s for _, s in store.firing() if s.review_after and s.review_after < today]
69
+ for s in due:
70
+ print(f" REVIEW DUE [{s.type} #{s.id}] review_after {s.review_after} — "
71
+ "re-verify, then update the date or archive")
63
72
  if broken:
64
73
  print(f" WARNING: {len(broken)} unparseable (can NEVER fire): "
65
74
  + ", ".join(b.name for b in broken))
@@ -94,11 +103,29 @@ def _cmd_check(args) -> int:
94
103
  print(f"no scars anchored to {args.path}")
95
104
  return 0
96
105
  for s in hits:
97
- print(f"[{s.type} #{s.id} | severity: {s.severity} | confidence: {s.confidence}] {s.title}")
106
+ label = f"challenged {s.type}" if s.status == "challenged" else s.type
107
+ print(f"[{label} #{s.id} | severity: {s.severity} | confidence: {s.confidence}] {s.title}")
98
108
  print(" " + s.body[:200].replace("\n", "\n "))
99
109
  return 0
100
110
 
101
111
 
112
+ def _cmd_transition(args, new_status: str) -> int:
113
+ store = _require_store()
114
+ if store is None:
115
+ return 1
116
+ try:
117
+ path = store.transition(args.id, new_status, reason=args.reason,
118
+ date=time.strftime("%Y-%m-%d"))
119
+ except ValueError as exc:
120
+ print(str(exc))
121
+ return 1
122
+ verb = ("still fires, marked as disputed — resolve by archiving or "
123
+ "re-validating" if new_status == "challenged"
124
+ else "never fires again; history kept (scar why still shows it)")
125
+ print(f"{new_status} -> {path.relative_to(store.root)} ({verb})")
126
+ return 0
127
+
128
+
102
129
  def _cmd_why(args) -> int:
103
130
  """History of pain for a path: every scar that anchors it, any status."""
104
131
  store = _require_store(Path(args.path).resolve())
@@ -134,7 +161,8 @@ def _cmd_inject(args) -> int:
134
161
  parts = []
135
162
  if hits:
136
163
  blocks = [
137
- f"[{s.type} #{s.id} | severity: {s.severity} | confidence: {s.confidence}] "
164
+ f"[{'challenged ' if s.status == 'challenged' else ''}{s.type} #{s.id} "
165
+ f"| severity: {s.severity} | confidence: {s.confidence}] "
138
166
  f"{s.title}\n{s.body[:MAX_BODY_CHARS]}" for s in hits
139
167
  ]
140
168
  parts.append(
@@ -196,6 +224,14 @@ def main(argv: list[str] | None = None) -> int:
196
224
  p = sub.add_parser("why", help="history of pain for a path (any status)")
197
225
  p.add_argument("path")
198
226
 
227
+ p = sub.add_parser("challenge", help="dispute a scar (still fires, marked challenged)")
228
+ p.add_argument("id", type=int)
229
+ p.add_argument("--reason", required=True, help="why the scar may no longer hold")
230
+
231
+ p = sub.add_parser("archive", help="retire a scar (never fires; history kept)")
232
+ p.add_argument("id", type=int)
233
+ p.add_argument("--reason", required=True, help="why it is retired (e.g. expiry condition met)")
234
+
199
235
  p = sub.add_parser("harvest", help="mine git history for candidate scars")
200
236
  p.add_argument("repo", nargs="?", default=".")
201
237
 
@@ -212,6 +248,9 @@ def main(argv: list[str] | None = None) -> int:
212
248
  if args.command == "hook":
213
249
  from .hooks import HANDLERS # hot path: imports nothing beyond library
214
250
  return HANDLERS[args.kind]()
251
+ if args.command in ("challenge", "archive"):
252
+ status = {"challenge": "challenged", "archive": "archived"}[args.command]
253
+ return _cmd_transition(args, status)
215
254
  handler = {
216
255
  "init": _cmd_init, "lint": _cmd_lint, "status": _cmd_status,
217
256
  "promote": _cmd_promote, "check": _cmd_check, "why": _cmd_why,
@@ -74,7 +74,8 @@ def precheck() -> int:
74
74
  broken = store.broken()
75
75
  parts = []
76
76
  if hits:
77
- blocks = [f"[{s.type} #{s.id} | severity: {s.severity} | confidence: "
77
+ blocks = [f"[{'challenged ' if s.status == 'challenged' else ''}{s.type} "
78
+ f"#{s.id} | severity: {s.severity} | confidence: "
78
79
  f"{s.confidence}] {s.title}\n{s.body[:MAX_BODY_CHARS]}" for s in hits]
79
80
  parts.append(
80
81
  "SCAR pre-edit check — negative knowledge anchored to code you are "
@@ -6,6 +6,7 @@ contract in .scars/README.md.
6
6
  from __future__ import annotations
7
7
 
8
8
  import re
9
+ import time
9
10
  from dataclasses import dataclass
10
11
 
11
12
  from .model import SEVERITIES, STATUSES, TYPES, ParseError, parse_scar_text
@@ -20,11 +21,12 @@ class Finding:
20
21
  return f"{self.level}: {self.message}"
21
22
 
22
23
 
23
- def lint_text(text: str) -> list[Finding]:
24
+ def lint_text(text: str, today: str | None = None) -> list[Finding]:
24
25
  try:
25
26
  scar = parse_scar_text(text)
26
27
  except ParseError:
27
28
  return [Finding("error", "missing YAML frontmatter — this scar can NEVER fire")]
29
+ today = today or time.strftime("%Y-%m-%d")
28
30
 
29
31
  findings = []
30
32
  if scar.type not in TYPES:
@@ -44,6 +46,13 @@ def lint_text(text: str) -> list[Finding]:
44
46
  findings.append(Finding("error", f"invalid pattern anchor /{pat}/: {exc}"))
45
47
  if not scar.evidence:
46
48
  findings.append(Finding("warning", "no evidence links — challengeable on sight"))
49
+ # ISO dates compare correctly as strings; never an error — a human
50
+ # decides whether to archive (ADR-4), lint only surfaces the due date
51
+ if (scar.status in ("active", "challenged") and scar.review_after
52
+ and scar.review_after < today):
53
+ findings.append(Finding(
54
+ "warning", f"review_after {scar.review_after} is past — re-verify "
55
+ "the scar still holds, then update the date or archive it"))
47
56
  if not scar.body:
48
57
  findings.append(Finding("warning", "empty body — future readers get no why"))
49
58
  return findings
@@ -36,13 +36,13 @@ def _anchor_strength(scar: Scar, rel_path: str, new_content: str) -> float:
36
36
 
37
37
  def rank_for_edit(store: ScarStore, target: Path, new_content: str,
38
38
  top_k: int = DEFAULT_TOP_K) -> list[Scar]:
39
- """Top-k active scars relevant to editing `target` with `new_content`."""
39
+ """Top-k firing scars (active + challenged) relevant to editing `target`."""
40
40
  try:
41
41
  rel_path = str(Path(target).resolve().relative_to(store.root))
42
42
  except ValueError:
43
43
  return []
44
44
  ranked = []
45
- for _, scar in store.active():
45
+ for _, scar in store.firing():
46
46
  strength = _anchor_strength(scar, rel_path, new_content)
47
47
  if strength > 0:
48
48
  rank = strength * SEVERITY_WEIGHT.get(scar.severity, 2) * scar.confidence
@@ -101,17 +101,25 @@ class ScarStore:
101
101
  return [f for f in sorted(self.scars_dir.glob("*.md"))
102
102
  if f.name.lower() not in SKIP_NAMES and not f.name.startswith("_")]
103
103
 
104
- def active(self) -> list[tuple[Path, Scar]]:
104
+ def parsed(self) -> list[tuple[Path, Scar]]:
105
105
  out = []
106
106
  for f in self._scar_files():
107
107
  try:
108
- scar = parse_scar_text(f.read_text(encoding="utf-8"))
108
+ out.append((f, parse_scar_text(f.read_text(encoding="utf-8"))))
109
109
  except (ParseError, OSError):
110
110
  continue
111
- if scar.status == "active":
112
- out.append((f, scar))
113
111
  return out
114
112
 
113
+ def active(self) -> list[tuple[Path, Scar]]:
114
+ return [(f, s) for f, s in self.parsed() if s.status == "active"]
115
+
116
+ def firing(self) -> list[tuple[Path, Scar]]:
117
+ """Scars that still inject: active, plus challenged (disputed but
118
+ not yet resolved by a human — suppressing them would let a mere
119
+ objection silently delete knowledge)."""
120
+ return [(f, s) for f, s in self.parsed()
121
+ if s.status in ("active", "challenged")]
122
+
115
123
  def broken(self) -> list[Path]:
116
124
  out = []
117
125
  for f in self._scar_files():
@@ -138,6 +146,20 @@ class ScarStore:
138
146
  continue
139
147
  return max(ids, default=0) + 1
140
148
 
149
+ def transition(self, scar_id: int, new_status: str, reason: str, date: str) -> Path:
150
+ """Flip a scar's status in place, appending the reason as an evidence
151
+ note. The file keeps its name and id — archived/challenged scars stay
152
+ findable (`scar why`); orphaned != deleted, ever."""
153
+ for f, s in self.parsed():
154
+ if s.id == scar_id:
155
+ if s.status == new_status:
156
+ raise ValueError(f"scar #{scar_id} is already {new_status}")
157
+ s.status = new_status
158
+ s.evidence.append(f"note: {new_status} {date}: {reason}")
159
+ f.write_text(s.to_text(), encoding="utf-8")
160
+ return f
161
+ raise ValueError(f"no scar with id {scar_id}")
162
+
141
163
  def promote(self, candidate: Path, reviewer: str) -> Path:
142
164
  text = candidate.read_text(encoding="utf-8")
143
165
  findings = lint_text(text)
@@ -0,0 +1,137 @@
1
+ """Lifecycle v0 (#14): challenge, archive, review_after surfacing.
2
+
3
+ Contract: challenged scars still fire (marked), archived never fire,
4
+ expiry dates surface as warnings — never auto-archive (ADR-4 governance).
5
+ """
6
+
7
+ import pytest
8
+
9
+ from scar.cli import main
10
+ from scar.lint import lint_text
11
+ from scar.match import rank_for_edit
12
+ from scar.store import ScarStore, init_scars
13
+
14
+ ACTIVE = """\
15
+ ---
16
+ id: 1
17
+ type: deadend
18
+ title: Tried X, failed
19
+ severity: high
20
+ confidence: 0.9
21
+ created: 2026-06-10
22
+ authors: ["claude-code"]
23
+ anchors:
24
+ - path: src/
25
+ evidence:
26
+ - commit: abc1234
27
+ expires:
28
+ condition: "X becomes viable"
29
+ review_after: 2026-01-01
30
+ status: active
31
+ ---
32
+
33
+ Why X failed.
34
+ """
35
+
36
+
37
+ @pytest.fixture
38
+ def repo(tmp_path, monkeypatch):
39
+ (tmp_path / ".git").mkdir()
40
+ monkeypatch.chdir(tmp_path)
41
+ init_scars(tmp_path)
42
+ (tmp_path / ".scars" / "0001-tried-x.deadend.md").write_text(ACTIVE)
43
+ return tmp_path
44
+
45
+
46
+ def store_of(repo):
47
+ return ScarStore.discover(repo)
48
+
49
+
50
+ # --- store.transition ---
51
+
52
+ def test_challenge_flips_status_and_records_reason(repo):
53
+ store = store_of(repo)
54
+ path = store.transition(1, "challenged", reason="X works since v9", date="2026-06-12")
55
+ text = path.read_text()
56
+ assert "status: challenged" in text
57
+ assert "challenged 2026-06-12: X works since v9" in text
58
+ # same file, in place — archived/challenged scars keep their identity
59
+ assert path.name == "0001-tried-x.deadend.md"
60
+
61
+
62
+ def test_archive_flips_status_and_records_reason(repo):
63
+ store = store_of(repo)
64
+ path = store.transition(1, "archived", reason="condition met", date="2026-06-12")
65
+ assert "status: archived" in path.read_text()
66
+
67
+
68
+ def test_transition_unknown_id_raises(repo):
69
+ with pytest.raises(ValueError):
70
+ store_of(repo).transition(99, "archived", reason="r", date="2026-06-12")
71
+
72
+
73
+ def test_transition_to_same_status_raises(repo):
74
+ store = store_of(repo)
75
+ store.transition(1, "challenged", reason="r", date="2026-06-12")
76
+ with pytest.raises(ValueError):
77
+ store.transition(1, "challenged", reason="again", date="2026-06-12")
78
+
79
+
80
+ # --- firing semantics ---
81
+
82
+ def test_challenged_scar_still_fires(repo):
83
+ store = store_of(repo)
84
+ store.transition(1, "challenged", reason="r", date="2026-06-12")
85
+ hits = rank_for_edit(store, repo / "src" / "x.py", "")
86
+ assert [s.id for s in hits] == [1]
87
+
88
+
89
+ def test_archived_scar_never_fires(repo):
90
+ store = store_of(repo)
91
+ store.transition(1, "archived", reason="r", date="2026-06-12")
92
+ assert rank_for_edit(store, repo / "src" / "x.py", "") == []
93
+
94
+
95
+ # --- CLI ---
96
+
97
+ def test_cli_challenge_and_marker_in_check(repo, capsys):
98
+ assert main(["challenge", "1", "--reason", "X works since v9"]) == 0
99
+ capsys.readouterr()
100
+ assert main(["check", str(repo / "src")]) == 0
101
+ out = capsys.readouterr().out
102
+ assert "challenged" in out and "#1" in out
103
+
104
+
105
+ def test_cli_archive_silences_check(repo, capsys):
106
+ assert main(["archive", "1", "--reason", "condition met"]) == 0
107
+ capsys.readouterr()
108
+ assert main(["check", str(repo / "src")]) == 0
109
+ assert "no scars anchored" in capsys.readouterr().out
110
+
111
+
112
+ def test_cli_challenge_unknown_id_fails(repo, capsys):
113
+ assert main(["challenge", "7", "--reason", "r"]) == 1
114
+
115
+
116
+ # --- review_after surfacing ---
117
+
118
+ def test_lint_warns_on_past_review_after():
119
+ findings = lint_text(ACTIVE, today="2026-06-12")
120
+ assert any("review_after" in f.message and f.level == "warning" for f in findings)
121
+
122
+
123
+ def test_lint_silent_on_future_review_after():
124
+ findings = lint_text(ACTIVE, today="2025-06-12")
125
+ assert not any("review_after" in f.message for f in findings)
126
+
127
+
128
+ def test_lint_silent_on_archived_past_review_after():
129
+ text = ACTIVE.replace("status: active", "status: archived")
130
+ findings = lint_text(text, today="2026-06-12")
131
+ assert not any("review_after" in f.message for f in findings)
132
+
133
+
134
+ def test_status_lists_review_due(repo, capsys):
135
+ assert main(["status"]) == 0
136
+ out = capsys.readouterr().out
137
+ assert "review due" in out.lower() and "#1" in out
@@ -79,7 +79,7 @@ wheels = [
79
79
 
80
80
  [[package]]
81
81
  name = "scar-cli"
82
- version = "0.1.0"
82
+ version = "0.1.1"
83
83
  source = { editable = "." }
84
84
 
85
85
  [package.dev-dependencies]
@@ -1,8 +0,0 @@
1
- # Changelog
2
-
3
- ## [0.1.1](https://github.com/Daily-Nerd/Scar/compare/v0.1.0...v0.1.1) (2026-06-12)
4
-
5
-
6
- ### Bug Fixes
7
-
8
- * **hooks:** drafter triggers on revert language only ([#12](https://github.com/Daily-Nerd/Scar/issues/12)) ([547c4bb](https://github.com/Daily-Nerd/Scar/commit/547c4bb21e3521682b6a4046602d6703d88c2cf1)), closes [#11](https://github.com/Daily-Nerd/Scar/issues/11)
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
File without changes
File without changes
File without changes
File without changes
File without changes