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.
- scar_cli-0.2.0/.scars/candidates/fp-log.txt +1 -0
- scar_cli-0.2.0/CHANGELOG.md +20 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/PKG-INFO +2 -2
- {scar_cli-0.1.1 → scar_cli-0.2.0}/README.md +1 -1
- {scar_cli-0.1.1 → scar_cli-0.2.0}/pyproject.toml +1 -1
- {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/cli.py +41 -2
- {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/hooks.py +2 -1
- {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/lint.py +10 -1
- {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/match.py +2 -2
- {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/store.py +26 -4
- scar_cli-0.2.0/tests/test_lifecycle.py +137 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/uv.lock +1 -1
- scar_cli-0.1.1/CHANGELOG.md +0 -8
- {scar_cli-0.1.1 → scar_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/.github/workflows/pr-validation.yml +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/.github/workflows/release.yml +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/.gitignore +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/README.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/candidates/history-rewrite-orphans-commit-evidence.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/template.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/CONTRIBUTING.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/IDEA.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/LICENSE +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/ROADMAP.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/SCAR-FORMAT.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/SPEC.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/STRESS-TEST.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/anchor-survival/RESULTS.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/anchor-survival/long_replay.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/anchor-survival/replay.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/auto-authorship/FINDINGS.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/.gitignore +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/PROTOCOL.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/RESULTS.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/README.md +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/fence-honor/grade.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/experiments/harvest/harvest.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/hook/scar-hooks.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/__init__.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/harvest.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/src/scar/model.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_cli.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_harvest.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_hooks.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_installer.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_lint.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_match.py +0 -0
- {scar_cli-0.1.1 → scar_cli-0.2.0}/tests/test_model.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
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"[{
|
|
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"[{
|
|
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
|
|
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.
|
|
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
|
|
104
|
+
def parsed(self) -> list[tuple[Path, Scar]]:
|
|
105
105
|
out = []
|
|
106
106
|
for f in self._scar_files():
|
|
107
107
|
try:
|
|
108
|
-
|
|
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
|
scar_cli-0.1.1/CHANGELOG.md
DELETED
|
@@ -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
|
{scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md
RENAMED
|
File without changes
|
{scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md
RENAMED
|
File without changes
|
|
File without changes
|
{scar_cli-0.1.1 → scar_cli-0.2.0}/.scars/candidates/history-rewrite-orphans-commit-evidence.md
RENAMED
|
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
|
|
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
|