scar-cli 0.7.0__tar.gz → 0.8.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.8.0/.release-please-manifest.json +3 -0
- scar_cli-0.8.0/.scars/candidates/rich-output-nontty-must-bypass-rich.md +42 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/CHANGELOG.md +8 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/PKG-INFO +2 -1
- {scar_cli-0.7.0 → scar_cli-0.8.0}/plugin/plugin.json +1 -1
- {scar_cli-0.7.0 → scar_cli-0.8.0}/pyproject.toml +4 -2
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/cli.py +299 -75
- scar_cli-0.8.0/src/scar/output.py +52 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_cli.py +124 -0
- scar_cli-0.8.0/tests/test_output.py +48 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/uv.lock +39 -1
- scar_cli-0.7.0/.release-please-manifest.json +0 -3
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.claude-plugin/marketplace.json +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.github/workflows/ci.yml +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.github/workflows/pr-validation.yml +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.github/workflows/release.yml +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.gitignore +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0006-yaml-pattern-anchor-over-escaping.landmine.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0007-release-please-config-change-skips-open-pr.landmine.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/README.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/candidates/fp-log.txt +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/template.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/AGENTS.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/CONTRIBUTING.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/IDEA.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/LICENSE +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/README.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/ROADMAP.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/SCAR-FORMAT.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/SPEC.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/STRESS-TEST.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/anchor-survival/RESULTS.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/anchor-survival/long_replay.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/anchor-survival/replay.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/auto-authorship/FINDINGS.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/.gitignore +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/PROTOCOL.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/RESULTS.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/README.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/grade.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/harvest/PROTOCOL.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/harvest/harvest.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/hook/scar-hooks.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/plugin/skills/scar-authoring/SKILL.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/plugin/skills/scar-authoring/assets/template.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/release-please-config.json +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/__init__.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/agent.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/evidence.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/harvest.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/hooks.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/installer.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/lint.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/match.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/mcp.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/model.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/orphan.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/render.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/skills/scar-authoring/SKILL.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/skills/scar-authoring/assets/template.md +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/store.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_docs.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_evidence.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_harvest.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_hooks.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_installer.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_lifecycle.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_lint.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_match.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_mcp.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_model.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_orphan.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_plugin.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_skill.py +0 -0
- {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_store.py +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
# COPY THIS FILE — do not edit the template itself.
|
|
3
|
+
# New scars: write to .scars/candidates/<slug>.md with status: candidate.
|
|
4
|
+
# A human reviewer promotes to .scars/NNNN-<slug>.<type>.md with status: active.
|
|
5
|
+
id: 0 # assigned at promotion (next free NNNN)
|
|
6
|
+
type: landmine # deadend = tried+failed | fence = looks wrong, intentional | landmine = touching A breaks B
|
|
7
|
+
title: Read commands' non-tty branch must bypass Rich — Rich wraps to 80 cols and breaks path-substring tests
|
|
8
|
+
severity: high
|
|
9
|
+
confidence: 0.9
|
|
10
|
+
created: 2026-06-30
|
|
11
|
+
authors: ["claude-code"]
|
|
12
|
+
anchors:
|
|
13
|
+
- path: src/scar/cli.py
|
|
14
|
+
- path: src/scar/output.py
|
|
15
|
+
- pattern: "output\.render\("
|
|
16
|
+
evidence:
|
|
17
|
+
- issue: 78
|
|
18
|
+
- note: "187-test suite asserts plain substrings like '0001-bad.deadend.md' and long anchor paths on main([...]) under capsys"
|
|
19
|
+
expires:
|
|
20
|
+
condition: "the test suite stops asserting on plain stdout substrings (e.g. moves to asserting structured --json only)"
|
|
21
|
+
review_after: 2027-06-30
|
|
22
|
+
status: candidate
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The five read commands (status, lint, check, why, orphan) route output 3 ways:
|
|
26
|
+
--json, Rich (tty), and plain print() (non-tty). The non-tty branch MUST keep
|
|
27
|
+
emitting the legacy plain lines byte-for-byte. It looks redundant — "why not
|
|
28
|
+
just always render with Rich?" — but it is load-bearing.
|
|
29
|
+
|
|
30
|
+
Rich's Console wraps and truncates to ~80 columns when it does not detect a wide
|
|
31
|
+
terminal. Many existing tests call main([...]) under pytest's capsys (stdout is
|
|
32
|
+
NOT a tty) and assert on plain SUBSTRINGS, including long anchor paths like
|
|
33
|
+
`src/long_gone/` and filenames like `0001-bad.deadend.md`. If you route the
|
|
34
|
+
non-tty branch through Rich, those strings get wrapped/elided and the assertions
|
|
35
|
+
fail in subtle, hard-to-trace ways (the value is "there" visually but split
|
|
36
|
+
across a wrapped line).
|
|
37
|
+
|
|
38
|
+
What a future editor must do: keep `output.is_tty()` gating Rich. Only branch 2
|
|
39
|
+
(real tty) may call a `_*_rich` renderer. The `plain()` closure in each handler
|
|
40
|
+
is the contract for non-tty + CI consumers — change its text only if you also
|
|
41
|
+
update the corresponding test substrings. Do NOT collapse the plain branch into
|
|
42
|
+
the Rich branch. See issue #78 and src/scar/output.py's module docstring.
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0](https://github.com/Daily-Nerd/Scar/compare/v0.7.0...v0.8.0) (2026-06-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **cli:** add --version flag to report installed version ([#76](https://github.com/Daily-Nerd/Scar/issues/76)) ([e45805d](https://github.com/Daily-Nerd/Scar/commit/e45805d01d171e2264a37a102f73a5254d5cedeb)), closes [#75](https://github.com/Daily-Nerd/Scar/issues/75)
|
|
9
|
+
* **cli:** Rich output for read commands (TTY-detect + --json) ([#79](https://github.com/Daily-Nerd/Scar/issues/79)) ([3ff9d40](https://github.com/Daily-Nerd/Scar/commit/3ff9d4036138e7c898cae8dc18c9affc26553525))
|
|
10
|
+
|
|
3
11
|
## [0.7.0](https://github.com/Daily-Nerd/Scar/compare/v0.6.1...v0.7.0) (2026-06-30)
|
|
4
12
|
|
|
5
13
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scar-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: SCAR — version control for negative knowledge (deadends, fences, landmines)
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: rich>=15.0.0
|
|
8
9
|
Description-Content-Type: text/markdown
|
|
9
10
|
|
|
10
11
|
# SCAR — Version Control for Negative Knowledge
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "scar-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "SCAR — version control for negative knowledge (deadends, fences, landmines)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
license = { text = "MIT" }
|
|
8
|
-
dependencies = [
|
|
8
|
+
dependencies = [
|
|
9
|
+
"rich>=15.0.0",
|
|
10
|
+
]
|
|
9
11
|
|
|
10
12
|
[project.scripts]
|
|
11
13
|
scar = "scar.cli:main"
|
|
@@ -11,6 +11,7 @@ import argparse
|
|
|
11
11
|
import json
|
|
12
12
|
import sys
|
|
13
13
|
import time
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version as _dist_version
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
16
17
|
from .lint import lint_text
|
|
@@ -25,6 +26,7 @@ from .orphan import (
|
|
|
25
26
|
)
|
|
26
27
|
from .render import injection_context, label_line
|
|
27
28
|
from .store import ScarStore, init_scars
|
|
29
|
+
from . import output
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
def _require_store(start: Path | None = None) -> ScarStore | None:
|
|
@@ -66,51 +68,198 @@ def _partial_rot_reason(finding) -> str:
|
|
|
66
68
|
return "partial rot — dead anchor(s) (" + _dead_anchor_summary(finding) + ")"
|
|
67
69
|
|
|
68
70
|
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Rich renderers (Issue #78). These run ONLY on a real tty — never under capsys
|
|
73
|
+
# or when piped, where the legacy plain output is the byte-preserved contract.
|
|
74
|
+
# They consume the same structured data the --json branch emits (or, for check/
|
|
75
|
+
# why, the parsed Scar objects) so the three surfaces never drift in substance.
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
_TYPE_STYLE = {"deadend": "red", "fence": "yellow", "landmine": "magenta"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _type_label(t: str) -> str:
|
|
81
|
+
return f"[{_TYPE_STYLE.get(t, 'white')}]{t}[/]"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _status_rich(data: dict) -> None:
|
|
85
|
+
from rich.panel import Panel
|
|
86
|
+
from rich.table import Table
|
|
87
|
+
|
|
88
|
+
console = output.console
|
|
89
|
+
c = data["counts"]
|
|
90
|
+
console.print(Panel.fit(
|
|
91
|
+
f"[bold]{data['scars_dir']}[/]\n"
|
|
92
|
+
f"{c['active']} active · {c['candidates']} candidate(s) pending review",
|
|
93
|
+
title="scar status"))
|
|
94
|
+
|
|
95
|
+
if data["active"]:
|
|
96
|
+
t = Table(title="Active scars", show_edge=False, expand=False)
|
|
97
|
+
t.add_column("type"); t.add_column("id", justify="right")
|
|
98
|
+
t.add_column("severity"); t.add_column("title")
|
|
99
|
+
for s in data["active"]:
|
|
100
|
+
t.add_row(_type_label(s["type"]), f"#{s['id']}", s["severity"], s["title"])
|
|
101
|
+
console.print(t)
|
|
102
|
+
for s in data["challenged"]:
|
|
103
|
+
console.print(f" [dim]challenged[/] {_type_label(s['type'])} #{s['id']} {s['title']}")
|
|
104
|
+
for name in data["candidates"]:
|
|
105
|
+
console.print(f" [cyan]candidate:[/] {name}")
|
|
106
|
+
for s in data["review_due"]:
|
|
107
|
+
console.print(f" [yellow]REVIEW DUE[/] {_type_label(s['type'])} #{s['id']} "
|
|
108
|
+
f"review_after {s['review_after']}")
|
|
109
|
+
|
|
110
|
+
console.print(f" [bold]{c['orphan_detected']}[/] orphan-detected · "
|
|
111
|
+
f"[bold]{c['orphaned']}[/] orphaned (persisted) · "
|
|
112
|
+
f"[bold]{c['partial_rot']}[/] partial-rot")
|
|
113
|
+
for o in data["orphan_detected"]:
|
|
114
|
+
console.print(f" [red]orphan-detected[/] [#{o['scar_id']}] {o['reason']}")
|
|
115
|
+
for s in data["orphaned"]:
|
|
116
|
+
console.print(f" [dim]orphaned[/] {_type_label(s['type'])} #{s['id']} {s['title']}")
|
|
117
|
+
for pr in data["partial_rot"]:
|
|
118
|
+
console.print(f" [yellow]partial-rot[/] [#{pr['scar_id']}] {pr['reason']}")
|
|
119
|
+
if data["broken"]:
|
|
120
|
+
console.print(f" [bold red]WARNING:[/] {len(data['broken'])} unparseable "
|
|
121
|
+
f"(can NEVER fire): " + ", ".join(data["broken"]))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _lint_rich(data: dict) -> None:
|
|
125
|
+
from rich.table import Table
|
|
126
|
+
|
|
127
|
+
console = output.console
|
|
128
|
+
if data["findings"]:
|
|
129
|
+
t = Table(title="Lint findings", show_edge=False)
|
|
130
|
+
t.add_column("file"); t.add_column("level"); t.add_column("message")
|
|
131
|
+
for f in data["findings"]:
|
|
132
|
+
style = "red" if f["level"] == "error" else "yellow"
|
|
133
|
+
t.add_row(f["file"], f"[{style}]{f['level']}[/]", f["message"])
|
|
134
|
+
console.print(t)
|
|
135
|
+
for o in data["orphans"]:
|
|
136
|
+
console.print(f"[yellow]WARNING orphan-detected:[/] scar #{o['scar_id']} — {o['reason']}")
|
|
137
|
+
for pr in data["partial_rot"]:
|
|
138
|
+
console.print(f"[cyan]HINT partial-rot:[/] scar #{pr['scar_id']} — {pr['reason']}")
|
|
139
|
+
for h in data["reverse_hints"]:
|
|
140
|
+
console.print(f"[cyan]HINT:[/] scar #{h['id']} marked orphaned but anchors live again")
|
|
141
|
+
if data["shallow_clone"]:
|
|
142
|
+
console.print("[dim]note: shallow clone — evidence-reachability check skipped[/]")
|
|
143
|
+
for ue in data["unreachable_evidence"]:
|
|
144
|
+
console.print(f"[yellow]WARNING evidence-unreachable:[/] scar #{ue['scar_id']} — "
|
|
145
|
+
f"commit {ue['sha']} {ue['reason']}")
|
|
146
|
+
style = "red" if data["failed"] else "green"
|
|
147
|
+
console.print(f"[{style}]lint:[/] {data['files']} file(s), {data['failed']} with errors, "
|
|
148
|
+
f"{len(data['orphans'])} orphan(s), {len(data['partial_rot'])} partial-rot, "
|
|
149
|
+
f"{len(data['unreachable_evidence'])} unreachable-evidence")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _check_rich(path: str, hits) -> None:
|
|
153
|
+
from rich.panel import Panel
|
|
154
|
+
|
|
155
|
+
console = output.console
|
|
156
|
+
if not hits:
|
|
157
|
+
console.print(f"[green]no scars anchored to[/] {path}")
|
|
158
|
+
return
|
|
159
|
+
for s in hits:
|
|
160
|
+
title = (f"{_type_label(s.type)} #{s.id} · severity: {s.severity} · "
|
|
161
|
+
f"confidence: {s.confidence}")
|
|
162
|
+
console.print(Panel(s.body[:200].strip(), title=title,
|
|
163
|
+
subtitle=f"[bold]{s.title}[/]", title_align="left"))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _why_rich(rel: str, records) -> None:
|
|
167
|
+
from rich.panel import Panel
|
|
168
|
+
|
|
169
|
+
console = output.console
|
|
170
|
+
if not records:
|
|
171
|
+
console.print(f"[green]no recorded pain for[/] {rel}")
|
|
172
|
+
return
|
|
173
|
+
console.print(f"[bold]History of pain for[/] {rel}")
|
|
174
|
+
for f, s in records:
|
|
175
|
+
title = f"[{s.status}] {_type_label(s.type)} #{s.id} — {s.title}"
|
|
176
|
+
console.print(Panel(s.body[:300].strip(), title=title, subtitle=f"[dim]{f.name}[/]",
|
|
177
|
+
title_align="left"))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _orphan_rich(findings, partial) -> None:
|
|
181
|
+
console = output.console
|
|
182
|
+
if not findings:
|
|
183
|
+
console.print("[green]no orphan-detected scars[/]")
|
|
184
|
+
else:
|
|
185
|
+
for of in findings:
|
|
186
|
+
console.print(f"[red]orphan-detected[/] [#{of.scar_id}] {_orphan_reason(of)}")
|
|
187
|
+
console.print(f"[bold]{len(findings)}[/] orphan(s) detected — review, then "
|
|
188
|
+
"`scar orphan --apply --id N --reason ...` to persist")
|
|
189
|
+
for pr in partial:
|
|
190
|
+
console.print(f"[yellow]partial-rot[/] [#{pr.scar_id}] {_partial_rot_reason(pr)}")
|
|
191
|
+
if partial:
|
|
192
|
+
console.print(f"[bold]{len(partial)}[/] partial-rot — advisory; re-anchor the dead "
|
|
193
|
+
"anchor(s). Not an orphan (still firing on survivors).")
|
|
194
|
+
|
|
195
|
+
|
|
69
196
|
def _cmd_lint(args) -> int:
|
|
70
197
|
store = _require_store()
|
|
71
198
|
if store is None:
|
|
72
199
|
return 1
|
|
73
200
|
failed = 0
|
|
74
201
|
files = store._scar_files() + store.candidates()
|
|
202
|
+
findings_by_file: list[tuple[str, list]] = []
|
|
75
203
|
for f in files:
|
|
76
204
|
findings = lint_text(f.read_text(encoding="utf-8"))
|
|
77
|
-
|
|
78
|
-
print(f"{f.relative_to(store.root)}: {finding}")
|
|
205
|
+
findings_by_file.append((str(f.relative_to(store.root)), findings))
|
|
79
206
|
if any(fi.level == "error" for fi in findings):
|
|
80
207
|
failed += 1
|
|
81
208
|
|
|
82
209
|
ctx = build_repo_context(store.root)
|
|
83
210
|
orphans = detect_orphans(store, ctx)
|
|
84
|
-
for of in orphans:
|
|
85
|
-
print(f"WARNING orphan-detected: scar #{of.scar_id} — {_orphan_reason(of)}")
|
|
86
|
-
|
|
87
|
-
# partial rot (#35): firing scars with a dead anchor among live ones.
|
|
88
|
-
# Advisory only — never a blocking gate, even under --fail-orphans.
|
|
89
211
|
partial = detect_partial_rot(store, ctx)
|
|
90
|
-
for
|
|
91
|
-
|
|
92
|
-
"— re-anchor to restore full coverage")
|
|
93
|
-
|
|
94
|
-
# reverse hint: persisted-orphaned scars whose anchors resolve again
|
|
95
|
-
for _f, s in store.parsed():
|
|
96
|
-
if s.status == "orphaned" and not anchors_all_dead(s, ctx):
|
|
97
|
-
print(f"HINT: scar #{s.id} is marked orphaned but its anchors live "
|
|
98
|
-
"again — consider re-activating (scar challenge/archive note)")
|
|
212
|
+
reverse_hints = [s for _f, s in store.parsed()
|
|
213
|
+
if s.status == "orphaned" and not anchors_all_dead(s, ctx)]
|
|
99
214
|
|
|
100
215
|
# evidence reachability (#43, scar #5): commit-SHA receipts that no longer
|
|
101
216
|
# resolve from HEAD. None = shallow clone, reachability indeterminate → skip.
|
|
102
217
|
unreachable = unreachable_evidence(store, store.root)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"(actions/checkout defaults to depth 1; use fetch-depth: 0)")
|
|
218
|
+
shallow = unreachable is None
|
|
219
|
+
if shallow:
|
|
106
220
|
unreachable = []
|
|
107
|
-
for ue in unreachable:
|
|
108
|
-
print(f"WARNING evidence-unreachable: scar #{ue.scar_id} — commit "
|
|
109
|
-
f"{ue.sha} {ue.reason}, not reachable from HEAD")
|
|
110
221
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
222
|
+
data = {
|
|
223
|
+
"files": len(files),
|
|
224
|
+
"findings": [{"file": rel, "level": fi.level, "message": fi.message}
|
|
225
|
+
for rel, fs in findings_by_file for fi in fs],
|
|
226
|
+
"orphans": [{"scar_id": of.scar_id, "reason": _orphan_reason(of)} for of in orphans],
|
|
227
|
+
"partial_rot": [{"scar_id": pr.scar_id, "reason": _partial_rot_reason(pr)} for pr in partial],
|
|
228
|
+
"reverse_hints": [{"id": s.id} for s in reverse_hints],
|
|
229
|
+
"shallow_clone": shallow,
|
|
230
|
+
"unreachable_evidence": [{"scar_id": ue.scar_id, "sha": ue.sha, "reason": ue.reason}
|
|
231
|
+
for ue in unreachable],
|
|
232
|
+
"failed": failed,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def plain():
|
|
236
|
+
for rel, findings in findings_by_file:
|
|
237
|
+
for finding in findings:
|
|
238
|
+
print(f"{rel}: {finding}")
|
|
239
|
+
for of in orphans:
|
|
240
|
+
print(f"WARNING orphan-detected: scar #{of.scar_id} — {_orphan_reason(of)}")
|
|
241
|
+
# partial rot (#35): firing scars with a dead anchor among live ones.
|
|
242
|
+
# Advisory only — never a blocking gate, even under --fail-orphans.
|
|
243
|
+
for pr in partial:
|
|
244
|
+
print(f"HINT partial-rot: scar #{pr.scar_id} — {_partial_rot_reason(pr)} "
|
|
245
|
+
"— re-anchor to restore full coverage")
|
|
246
|
+
# reverse hint: persisted-orphaned scars whose anchors resolve again
|
|
247
|
+
for s in reverse_hints:
|
|
248
|
+
print(f"HINT: scar #{s.id} is marked orphaned but its anchors live "
|
|
249
|
+
"again — consider re-activating (scar challenge/archive note)")
|
|
250
|
+
if shallow:
|
|
251
|
+
print("note: shallow clone — evidence-reachability check skipped "
|
|
252
|
+
"(actions/checkout defaults to depth 1; use fetch-depth: 0)")
|
|
253
|
+
for ue in unreachable:
|
|
254
|
+
print(f"WARNING evidence-unreachable: scar #{ue.scar_id} — commit "
|
|
255
|
+
f"{ue.sha} {ue.reason}, not reachable from HEAD")
|
|
256
|
+
print(f"lint: {len(files)} file(s), {failed} with errors, "
|
|
257
|
+
f"{len(orphans)} orphan(s), {len(partial)} partial-rot, "
|
|
258
|
+
f"{len(unreachable)} unreachable-evidence")
|
|
259
|
+
|
|
260
|
+
output.render(data=data, json_flag=getattr(args, "json", False),
|
|
261
|
+
tty=lambda: _lint_rich(data), plain=plain)
|
|
262
|
+
|
|
114
263
|
if failed:
|
|
115
264
|
return 1
|
|
116
265
|
if orphans and getattr(args, "fail_orphans", False):
|
|
@@ -118,24 +267,14 @@ def _cmd_lint(args) -> int:
|
|
|
118
267
|
return 0
|
|
119
268
|
|
|
120
269
|
|
|
121
|
-
def _cmd_status(
|
|
270
|
+
def _cmd_status(args) -> int:
|
|
122
271
|
store = _require_store()
|
|
123
272
|
if store is None:
|
|
124
273
|
return 1
|
|
125
274
|
active, broken, cands = store.active(), store.broken(), store.candidates()
|
|
126
|
-
|
|
127
|
-
for f, s in active:
|
|
128
|
-
print(f" [{s.type} #{s.id} | {s.severity}] {s.title}")
|
|
129
|
-
for f, s in store.parsed():
|
|
130
|
-
if s.status == "challenged":
|
|
131
|
-
print(f" [challenged {s.type} #{s.id}] {s.title}")
|
|
132
|
-
for c in cands:
|
|
133
|
-
print(f" candidate: {c.name}")
|
|
275
|
+
challenged = [(f, s) for f, s in store.parsed() if s.status == "challenged"]
|
|
134
276
|
today = time.strftime("%Y-%m-%d")
|
|
135
277
|
due = [s for _, s in store.firing() if s.review_after and s.review_after < today]
|
|
136
|
-
for s in due:
|
|
137
|
-
print(f" REVIEW DUE [{s.type} #{s.id}] review_after {s.review_after} — "
|
|
138
|
-
"re-verify, then update the date or archive")
|
|
139
278
|
|
|
140
279
|
# Orphans: detected (firing scars whose anchors all died — not yet persisted)
|
|
141
280
|
# and persisted (already flipped to status: orphaned, invisible until now).
|
|
@@ -143,19 +282,54 @@ def _cmd_status(_args) -> int:
|
|
|
143
282
|
detected = detect_orphans(store, ctx)
|
|
144
283
|
persisted = [s for _, s in store.parsed() if s.status == "orphaned"]
|
|
145
284
|
partial = detect_partial_rot(store, ctx)
|
|
146
|
-
print(f" {len(detected)} orphan-detected (firing, anchors gone), "
|
|
147
|
-
f"{len(persisted)} orphaned (persisted), "
|
|
148
|
-
f"{len(partial)} partial-rot (firing, ≥1 anchor dead)")
|
|
149
|
-
for of in detected:
|
|
150
|
-
print(f" orphan-detected [#{of.scar_id}] {_orphan_reason(of)}")
|
|
151
|
-
for s in persisted:
|
|
152
|
-
print(f" orphaned [{s.type} #{s.id}] {s.title}")
|
|
153
|
-
for pr in partial:
|
|
154
|
-
print(f" partial-rot [#{pr.scar_id}] {_partial_rot_reason(pr)}")
|
|
155
285
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
286
|
+
data = {
|
|
287
|
+
"scars_dir": str(store.scars_dir),
|
|
288
|
+
"active": [{"type": s.type, "id": s.id, "severity": s.severity, "title": s.title}
|
|
289
|
+
for _f, s in active],
|
|
290
|
+
"challenged": [{"type": s.type, "id": s.id, "title": s.title} for _f, s in challenged],
|
|
291
|
+
"candidates": [c.name for c in cands],
|
|
292
|
+
"review_due": [{"type": s.type, "id": s.id, "review_after": s.review_after} for s in due],
|
|
293
|
+
"orphan_detected": [{"scar_id": of.scar_id, "reason": _orphan_reason(of)} for of in detected],
|
|
294
|
+
"orphaned": [{"type": s.type, "id": s.id, "title": s.title} for s in persisted],
|
|
295
|
+
"partial_rot": [{"scar_id": pr.scar_id, "reason": _partial_rot_reason(pr)} for pr in partial],
|
|
296
|
+
"broken": [b.name for b in broken],
|
|
297
|
+
"counts": {
|
|
298
|
+
"active": len(active),
|
|
299
|
+
"candidates": len(cands),
|
|
300
|
+
"orphan_detected": len(detected),
|
|
301
|
+
"orphaned": len(persisted),
|
|
302
|
+
"partial_rot": len(partial),
|
|
303
|
+
"broken": len(broken),
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def plain():
|
|
308
|
+
print(f"{store.scars_dir}: {len(active)} active, {len(cands)} candidate(s) pending review")
|
|
309
|
+
for f, s in active:
|
|
310
|
+
print(f" [{s.type} #{s.id} | {s.severity}] {s.title}")
|
|
311
|
+
for f, s in challenged:
|
|
312
|
+
print(f" [challenged {s.type} #{s.id}] {s.title}")
|
|
313
|
+
for c in cands:
|
|
314
|
+
print(f" candidate: {c.name}")
|
|
315
|
+
for s in due:
|
|
316
|
+
print(f" REVIEW DUE [{s.type} #{s.id}] review_after {s.review_after} — "
|
|
317
|
+
"re-verify, then update the date or archive")
|
|
318
|
+
print(f" {len(detected)} orphan-detected (firing, anchors gone), "
|
|
319
|
+
f"{len(persisted)} orphaned (persisted), "
|
|
320
|
+
f"{len(partial)} partial-rot (firing, ≥1 anchor dead)")
|
|
321
|
+
for of in detected:
|
|
322
|
+
print(f" orphan-detected [#{of.scar_id}] {_orphan_reason(of)}")
|
|
323
|
+
for s in persisted:
|
|
324
|
+
print(f" orphaned [{s.type} #{s.id}] {s.title}")
|
|
325
|
+
for pr in partial:
|
|
326
|
+
print(f" partial-rot [#{pr.scar_id}] {_partial_rot_reason(pr)}")
|
|
327
|
+
if broken:
|
|
328
|
+
print(f" WARNING: {len(broken)} unparseable (can NEVER fire): "
|
|
329
|
+
+ ", ".join(b.name for b in broken))
|
|
330
|
+
|
|
331
|
+
output.render(data=data, json_flag=getattr(args, "json", False),
|
|
332
|
+
tty=lambda: _status_rich(data), plain=plain)
|
|
159
333
|
return 0
|
|
160
334
|
|
|
161
335
|
|
|
@@ -191,12 +365,23 @@ def _cmd_check(args) -> int:
|
|
|
191
365
|
return 1
|
|
192
366
|
hits = rank_for_edit(store, Path(args.path).resolve(), args.content or "",
|
|
193
367
|
top_k=args.top_k)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
368
|
+
data = {
|
|
369
|
+
"path": args.path,
|
|
370
|
+
"scars": [{"type": s.type, "id": s.id, "severity": s.severity,
|
|
371
|
+
"confidence": s.confidence, "status": s.status, "title": s.title,
|
|
372
|
+
"body": s.body[:200]} for s in hits],
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
def plain():
|
|
376
|
+
if not hits:
|
|
377
|
+
print(f"no scars anchored to {args.path}")
|
|
378
|
+
return
|
|
379
|
+
for s in hits:
|
|
380
|
+
print(label_line(s))
|
|
381
|
+
print(" " + s.body[:200].replace("\n", "\n "))
|
|
382
|
+
|
|
383
|
+
output.render(data=data, json_flag=getattr(args, "json", False),
|
|
384
|
+
tty=lambda: _check_rich(args.path, hits), plain=plain)
|
|
200
385
|
return 0
|
|
201
386
|
|
|
202
387
|
|
|
@@ -228,20 +413,31 @@ def _cmd_orphan(args) -> int:
|
|
|
228
413
|
|
|
229
414
|
if not args.apply:
|
|
230
415
|
partial = detect_partial_rot(store, ctx)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
416
|
+
data = {
|
|
417
|
+
"orphan_detected": [{"scar_id": of.scar_id, "reason": _orphan_reason(of)}
|
|
418
|
+
for of in findings],
|
|
419
|
+
"partial_rot": [{"scar_id": pr.scar_id, "reason": _partial_rot_reason(pr)}
|
|
420
|
+
for pr in partial],
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
def plain():
|
|
424
|
+
if not findings:
|
|
425
|
+
print("no orphan-detected scars")
|
|
426
|
+
else:
|
|
427
|
+
for of in findings:
|
|
428
|
+
print(f"orphan-detected [#{of.scar_id}] {_orphan_reason(of)}")
|
|
429
|
+
print(f"{len(findings)} orphan(s) detected — review, then "
|
|
430
|
+
"`scar orphan --apply --id N --reason ...` to persist")
|
|
431
|
+
# Partial rot is advisory and surfaced separately — never persisted as
|
|
432
|
+
# orphaned (the fix is re-anchoring, not a status transition). #35.
|
|
433
|
+
for pr in partial:
|
|
434
|
+
print(f"partial-rot [#{pr.scar_id}] {_partial_rot_reason(pr)}")
|
|
435
|
+
if partial:
|
|
436
|
+
print(f"{len(partial)} partial-rot — advisory; re-anchor the dead "
|
|
437
|
+
"anchor(s). Not an orphan (still firing on survivors).")
|
|
438
|
+
|
|
439
|
+
output.render(data=data, json_flag=getattr(args, "json", False),
|
|
440
|
+
tty=lambda: _orphan_rich(findings, partial), plain=plain)
|
|
245
441
|
return 0
|
|
246
442
|
|
|
247
443
|
# --apply: persist. Human-only (never wire into CI/lint).
|
|
@@ -273,11 +469,21 @@ def _cmd_why(args) -> int:
|
|
|
273
469
|
return 1
|
|
274
470
|
rel = str(Path(args.path).resolve().relative_to(store.root))
|
|
275
471
|
records = store.scars_for_path(rel)
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
472
|
+
data = {
|
|
473
|
+
"path": rel,
|
|
474
|
+
"records": [{"status": s.status, "type": s.type, "id": s.id, "title": s.title,
|
|
475
|
+
"file": f.name, "body": s.body[:300]} for f, s in records],
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
def plain():
|
|
479
|
+
for f, s in records:
|
|
480
|
+
print(f"[{s.status} {s.type} #{s.id}] {s.title} ({f.name})")
|
|
481
|
+
print(" " + s.body[:300].replace("\n", "\n ") + "\n")
|
|
482
|
+
if not records:
|
|
483
|
+
print(f"no recorded pain for {rel}")
|
|
484
|
+
|
|
485
|
+
output.render(data=data, json_flag=getattr(args, "json", False),
|
|
486
|
+
tty=lambda: _why_rich(rel, records), plain=plain)
|
|
281
487
|
return 0
|
|
282
488
|
|
|
283
489
|
|
|
@@ -514,16 +720,30 @@ def _cmd_skill_lifecycle(args) -> int:
|
|
|
514
720
|
return skill_status()
|
|
515
721
|
|
|
516
722
|
|
|
723
|
+
def _scar_version() -> str:
|
|
724
|
+
"""Installed package version (pyproject is the source of truth via
|
|
725
|
+
release-please). 'unknown' when run from a tree that was never installed."""
|
|
726
|
+
try:
|
|
727
|
+
return _dist_version("scar-cli")
|
|
728
|
+
except PackageNotFoundError:
|
|
729
|
+
return "unknown"
|
|
730
|
+
|
|
731
|
+
|
|
517
732
|
def main(argv: list[str] | None = None) -> int:
|
|
518
733
|
parser = argparse.ArgumentParser(prog="scar",
|
|
519
734
|
description="version control for negative knowledge")
|
|
735
|
+
# argparse's version action prints and exits during optional parsing, before
|
|
736
|
+
# the required-subcommand check below — so `scar --version` needs no command.
|
|
737
|
+
parser.add_argument("--version", action="version", version=f"scar {_scar_version()}")
|
|
520
738
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
521
739
|
|
|
522
740
|
sub.add_parser("init", help="create .scars/ layout in the current repo")
|
|
523
741
|
p = sub.add_parser("lint", help="validate every scar and candidate")
|
|
524
742
|
p.add_argument("--fail-orphans", action="store_true",
|
|
525
743
|
help="exit non-zero when any scar is orphan-detected")
|
|
526
|
-
|
|
744
|
+
p.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
745
|
+
p = sub.add_parser("status", help="counts, titles, broken-file warnings")
|
|
746
|
+
p.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
527
747
|
|
|
528
748
|
p = sub.add_parser("promote", help="review a candidate into an active scar")
|
|
529
749
|
p.add_argument("candidate", help="candidate filename (or unique substring)")
|
|
@@ -533,9 +753,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
533
753
|
p.add_argument("path")
|
|
534
754
|
p.add_argument("--content", default="", help="new code to test pattern anchors against")
|
|
535
755
|
p.add_argument("--top-k", type=int, default=10)
|
|
756
|
+
p.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
536
757
|
|
|
537
758
|
p = sub.add_parser("why", help="history of pain for a path (any status)")
|
|
538
759
|
p.add_argument("path")
|
|
760
|
+
p.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
539
761
|
|
|
540
762
|
p = sub.add_parser("challenge", help="dispute a scar (still fires, marked challenged)")
|
|
541
763
|
p.add_argument("id", type=int)
|
|
@@ -552,6 +774,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
552
774
|
help="with --apply: the detected scar id to persist")
|
|
553
775
|
p.add_argument("--reason", default="anchors no longer resolve",
|
|
554
776
|
help="with --apply: why it is being orphaned (recorded in the note)")
|
|
777
|
+
p.add_argument("--json", action="store_true",
|
|
778
|
+
help="emit machine-readable JSON (read mode only)")
|
|
555
779
|
|
|
556
780
|
p = sub.add_parser("harvest", help="mine git history for candidate scars")
|
|
557
781
|
p.add_argument("repo", nargs="?", default=".")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""3-way output dispatch for the human-facing read commands (Issue #78).
|
|
2
|
+
|
|
3
|
+
Each read command builds a structured data object, then routes it here:
|
|
4
|
+
|
|
5
|
+
1. ``--json`` → ``json.dumps(data, indent=2)`` — the stable machine contract.
|
|
6
|
+
2. ``sys.stdout.isatty()`` → the Rich renderer (Table/Panel/colour). Pretty path.
|
|
7
|
+
3. otherwise (piped / captured / non-tty) → the legacy plain ``print()`` output,
|
|
8
|
+
byte-for-byte unchanged.
|
|
9
|
+
|
|
10
|
+
Critical invariant: Rich must NEVER run in the non-tty branch. Rich wraps and
|
|
11
|
+
truncates to ~80 cols, which would break the long-path substring assertions the
|
|
12
|
+
test suite (and CI consumers) rely on. The plain renderer is the source of truth
|
|
13
|
+
for non-tty; Rich is strictly additive on top of a real terminal.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
|
|
24
|
+
# One shared console for every Rich-rendered surface — keeps width/colour policy
|
|
25
|
+
# in a single place. ``file`` is resolved lazily by Rich at print time, so tests
|
|
26
|
+
# that swap sys.stdout (capsys) still capture output.
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_tty() -> bool:
|
|
31
|
+
"""True when stdout is an interactive terminal. Pulled out as a function so
|
|
32
|
+
tests can monkeypatch the branch without faking a real tty."""
|
|
33
|
+
return sys.stdout.isatty()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render(
|
|
37
|
+
*,
|
|
38
|
+
data: Any,
|
|
39
|
+
json_flag: bool,
|
|
40
|
+
tty: Callable[[], None],
|
|
41
|
+
plain: Callable[[], None],
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Dispatch one command's output the 3 ways. ``data`` is the structured
|
|
44
|
+
object (only consumed by the JSON branch); ``tty`` and ``plain`` are
|
|
45
|
+
zero-arg renderers that close over the shared console / legacy print lines."""
|
|
46
|
+
if json_flag:
|
|
47
|
+
print(json.dumps(data, indent=2))
|
|
48
|
+
return
|
|
49
|
+
if is_tty():
|
|
50
|
+
tty()
|
|
51
|
+
return
|
|
52
|
+
plain()
|
|
@@ -52,6 +52,17 @@ def test_lint_broken_scar_exits_nonzero_names_file(repo, capsys):
|
|
|
52
52
|
assert "0001-bad.deadend.md" in capsys.readouterr().out
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def test_version_flag_prints_version_and_exits_zero(capsys):
|
|
56
|
+
import re
|
|
57
|
+
|
|
58
|
+
with pytest.raises(SystemExit) as exc:
|
|
59
|
+
main(["--version"])
|
|
60
|
+
assert exc.value.code == 0
|
|
61
|
+
out = capsys.readouterr().out
|
|
62
|
+
assert out.startswith("scar ")
|
|
63
|
+
assert re.search(r"\d+\.\d+", out)
|
|
64
|
+
|
|
65
|
+
|
|
55
66
|
def test_status_counts(repo, capsys):
|
|
56
67
|
init_scars(repo)
|
|
57
68
|
(repo / ".scars" / "candidates" / "x.md").write_text(CANDIDATE)
|
|
@@ -657,3 +668,116 @@ def test_agent_skill_prints_body_with_three_types(repo, capsys):
|
|
|
657
668
|
assert "name: scar-authoring" in out
|
|
658
669
|
for kind in ("deadend", "fence", "landmine"):
|
|
659
670
|
assert kind in out
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# ---------------------------------------------------------------------------
|
|
674
|
+
# Rich output (Issue #78): the five human-facing read commands gain a 3-way
|
|
675
|
+
# surface — --json (machine), Rich (tty), plain (non-tty, byte-preserved). The
|
|
676
|
+
# plain non-tty assertions live in the tests above; here we cover --json keys
|
|
677
|
+
# and that the Rich tty path renders without crashing.
|
|
678
|
+
# ---------------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _force_tty(monkeypatch):
|
|
682
|
+
"""Force the tty branch so the Rich renderer runs (under capsys stdout is
|
|
683
|
+
never a real tty). We assert no crash + exit 0, never exact ANSI."""
|
|
684
|
+
import scar.output as out
|
|
685
|
+
monkeypatch.setattr(out, "is_tty", lambda: True)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def test_status_json_emits_structured_counts(repo, capsys):
|
|
689
|
+
init_scars(repo)
|
|
690
|
+
(repo / ".scars" / "candidates" / "x.md").write_text(CANDIDATE)
|
|
691
|
+
assert main(["status", "--json"]) == 0
|
|
692
|
+
data = json.loads(capsys.readouterr().out)
|
|
693
|
+
assert data["counts"]["candidates"] == 1
|
|
694
|
+
assert data["counts"]["active"] == 0
|
|
695
|
+
assert isinstance(data["active"], list)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def test_status_tty_renders_without_crashing(repo, capsys, monkeypatch):
|
|
699
|
+
init_scars(repo)
|
|
700
|
+
(repo / ".scars" / "candidates" / "x.md").write_text(CANDIDATE)
|
|
701
|
+
_force_tty(monkeypatch)
|
|
702
|
+
assert main(["status"]) == 0
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def test_lint_json_emits_findings_and_summary(repo, capsys):
|
|
706
|
+
init_scars(repo)
|
|
707
|
+
(repo / ".scars" / "0001-gone.deadend.md").write_text(ORPHAN_SCAR)
|
|
708
|
+
assert main(["lint", "--json"]) == 0
|
|
709
|
+
data = json.loads(capsys.readouterr().out)
|
|
710
|
+
assert data["files"] >= 1
|
|
711
|
+
assert any(o["scar_id"] == 1 for o in data["orphans"])
|
|
712
|
+
assert "failed" in data
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def test_lint_json_broken_scar_exit_one_and_lists_file(repo, capsys):
|
|
716
|
+
init_scars(repo)
|
|
717
|
+
(repo / ".scars" / "0001-bad.deadend.md").write_text("# nope\n")
|
|
718
|
+
assert main(["lint", "--json"]) == 1
|
|
719
|
+
data = json.loads(capsys.readouterr().out)
|
|
720
|
+
assert any("0001-bad.deadend.md" in f["file"] for f in data["findings"])
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def test_lint_tty_renders_without_crashing(repo, capsys, monkeypatch):
|
|
724
|
+
init_scars(repo)
|
|
725
|
+
(repo / ".scars" / "0001-gone.deadend.md").write_text(ORPHAN_SCAR)
|
|
726
|
+
_force_tty(monkeypatch)
|
|
727
|
+
assert main(["lint"]) == 0
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def test_check_json_lists_anchored_scars(repo, capsys):
|
|
731
|
+
init_scars(repo)
|
|
732
|
+
(repo / ".scars" / "candidates" / "tried-x.md").write_text(CANDIDATE)
|
|
733
|
+
main(["promote", "tried-x", "--reviewer", "k"])
|
|
734
|
+
(repo / "src").mkdir()
|
|
735
|
+
capsys.readouterr()
|
|
736
|
+
assert main(["check", "src/thing.py", "--json"]) == 0
|
|
737
|
+
data = json.loads(capsys.readouterr().out)
|
|
738
|
+
assert data["path"] == "src/thing.py"
|
|
739
|
+
assert any(s["title"] == "Tried X, failed" for s in data["scars"])
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def test_check_tty_renders_without_crashing(repo, capsys, monkeypatch):
|
|
743
|
+
init_scars(repo)
|
|
744
|
+
(repo / ".scars" / "candidates" / "tried-x.md").write_text(CANDIDATE)
|
|
745
|
+
main(["promote", "tried-x", "--reviewer", "k"])
|
|
746
|
+
(repo / "src").mkdir()
|
|
747
|
+
_force_tty(monkeypatch)
|
|
748
|
+
assert main(["check", "src/thing.py"]) == 0
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def test_why_json_lists_records(repo, capsys):
|
|
752
|
+
init_scars(repo)
|
|
753
|
+
(repo / ".scars" / "candidates" / "tried-x.md").write_text(CANDIDATE)
|
|
754
|
+
main(["promote", "tried-x", "--reviewer", "k"])
|
|
755
|
+
(repo / "src").mkdir()
|
|
756
|
+
capsys.readouterr()
|
|
757
|
+
assert main(["why", "src", "--json"]) == 0
|
|
758
|
+
data = json.loads(capsys.readouterr().out)
|
|
759
|
+
assert any(r["title"] == "Tried X, failed" for r in data["records"])
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def test_why_tty_renders_without_crashing(repo, capsys, monkeypatch):
|
|
763
|
+
init_scars(repo)
|
|
764
|
+
(repo / ".scars" / "candidates" / "tried-x.md").write_text(CANDIDATE)
|
|
765
|
+
main(["promote", "tried-x", "--reviewer", "k"])
|
|
766
|
+
(repo / "src").mkdir()
|
|
767
|
+
_force_tty(monkeypatch)
|
|
768
|
+
assert main(["why", "src"]) == 0
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def test_orphan_json_lists_detected(repo, capsys):
|
|
772
|
+
init_scars(repo)
|
|
773
|
+
(repo / ".scars" / "0005-both.deadend.md").write_text(MULTI_ANCHOR_ORPHAN)
|
|
774
|
+
assert main(["orphan", "--json"]) == 0
|
|
775
|
+
data = json.loads(capsys.readouterr().out)
|
|
776
|
+
assert any(o["scar_id"] == 5 for o in data["orphan_detected"])
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def test_orphan_tty_renders_without_crashing(repo, capsys, monkeypatch):
|
|
780
|
+
init_scars(repo)
|
|
781
|
+
(repo / ".scars" / "0005-both.deadend.md").write_text(MULTI_ANCHOR_ORPHAN)
|
|
782
|
+
_force_tty(monkeypatch)
|
|
783
|
+
assert main(["orphan"]) == 0
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The 3-way output dispatch: JSON, Rich-tty, plain non-tty.
|
|
2
|
+
|
|
3
|
+
The non-tty branch must call the plain renderer verbatim (byte-preserving for
|
|
4
|
+
CI/substring consumers); Rich must NEVER render there. Only the tty branch uses
|
|
5
|
+
Rich. JSON short-circuits both.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from scar import output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_json_flag_emits_indented_json_and_skips_renderers(capsys):
|
|
14
|
+
calls = []
|
|
15
|
+
output.render(
|
|
16
|
+
data={"a": 1, "b": ["x"]},
|
|
17
|
+
json_flag=True,
|
|
18
|
+
tty=lambda: calls.append("tty"),
|
|
19
|
+
plain=lambda: calls.append("plain"),
|
|
20
|
+
)
|
|
21
|
+
out = capsys.readouterr().out
|
|
22
|
+
assert json.loads(out) == {"a": 1, "b": ["x"]}
|
|
23
|
+
assert "\n " in out # indent=2 pretty print
|
|
24
|
+
assert calls == [] # neither renderer ran
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_non_tty_calls_plain_renderer_only(monkeypatch, capsys):
|
|
28
|
+
monkeypatch.setattr(output, "is_tty", lambda: False)
|
|
29
|
+
calls = []
|
|
30
|
+
output.render(
|
|
31
|
+
data={"a": 1},
|
|
32
|
+
json_flag=False,
|
|
33
|
+
tty=lambda: calls.append("tty"),
|
|
34
|
+
plain=lambda: calls.append("plain"),
|
|
35
|
+
)
|
|
36
|
+
assert calls == ["plain"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_tty_calls_tty_renderer_only(monkeypatch):
|
|
40
|
+
monkeypatch.setattr(output, "is_tty", lambda: True)
|
|
41
|
+
calls = []
|
|
42
|
+
output.render(
|
|
43
|
+
data={"a": 1},
|
|
44
|
+
json_flag=False,
|
|
45
|
+
tty=lambda: calls.append("tty"),
|
|
46
|
+
plain=lambda: calls.append("plain"),
|
|
47
|
+
)
|
|
48
|
+
assert calls == ["tty"]
|
|
@@ -32,6 +32,27 @@ wheels = [
|
|
|
32
32
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
33
33
|
]
|
|
34
34
|
|
|
35
|
+
[[package]]
|
|
36
|
+
name = "markdown-it-py"
|
|
37
|
+
version = "4.2.0"
|
|
38
|
+
source = { registry = "https://pypi.org/simple" }
|
|
39
|
+
dependencies = [
|
|
40
|
+
{ name = "mdurl" },
|
|
41
|
+
]
|
|
42
|
+
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
|
43
|
+
wheels = [
|
|
44
|
+
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[[package]]
|
|
48
|
+
name = "mdurl"
|
|
49
|
+
version = "0.1.2"
|
|
50
|
+
source = { registry = "https://pypi.org/simple" }
|
|
51
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
|
52
|
+
wheels = [
|
|
53
|
+
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
|
54
|
+
]
|
|
55
|
+
|
|
35
56
|
[[package]]
|
|
36
57
|
name = "packaging"
|
|
37
58
|
version = "26.2"
|
|
@@ -77,10 +98,26 @@ wheels = [
|
|
|
77
98
|
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
|
78
99
|
]
|
|
79
100
|
|
|
101
|
+
[[package]]
|
|
102
|
+
name = "rich"
|
|
103
|
+
version = "15.0.0"
|
|
104
|
+
source = { registry = "https://pypi.org/simple" }
|
|
105
|
+
dependencies = [
|
|
106
|
+
{ name = "markdown-it-py" },
|
|
107
|
+
{ name = "pygments" },
|
|
108
|
+
]
|
|
109
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
|
110
|
+
wheels = [
|
|
111
|
+
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
|
112
|
+
]
|
|
113
|
+
|
|
80
114
|
[[package]]
|
|
81
115
|
name = "scar-cli"
|
|
82
|
-
version = "0.
|
|
116
|
+
version = "0.7.0"
|
|
83
117
|
source = { editable = "." }
|
|
118
|
+
dependencies = [
|
|
119
|
+
{ name = "rich" },
|
|
120
|
+
]
|
|
84
121
|
|
|
85
122
|
[package.dev-dependencies]
|
|
86
123
|
dev = [
|
|
@@ -88,6 +125,7 @@ dev = [
|
|
|
88
125
|
]
|
|
89
126
|
|
|
90
127
|
[package.metadata]
|
|
128
|
+
requires-dist = [{ name = "rich", specifier = ">=15.0.0" }]
|
|
91
129
|
|
|
92
130
|
[package.metadata.requires-dev]
|
|
93
131
|
dev = [{ name = "pytest", specifier = ">=8.0" }]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md
RENAMED
|
File without changes
|
{scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md
RENAMED
|
File without changes
|
{scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md
RENAMED
|
File without changes
|
|
File without changes
|
{scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0007-release-please-config-change-skips-open-pr.landmine.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
|
|
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
|