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.
Files changed (88) hide show
  1. scar_cli-0.8.0/.release-please-manifest.json +3 -0
  2. scar_cli-0.8.0/.scars/candidates/rich-output-nontty-must-bypass-rich.md +42 -0
  3. {scar_cli-0.7.0 → scar_cli-0.8.0}/CHANGELOG.md +8 -0
  4. {scar_cli-0.7.0 → scar_cli-0.8.0}/PKG-INFO +2 -1
  5. {scar_cli-0.7.0 → scar_cli-0.8.0}/plugin/plugin.json +1 -1
  6. {scar_cli-0.7.0 → scar_cli-0.8.0}/pyproject.toml +4 -2
  7. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/cli.py +299 -75
  8. scar_cli-0.8.0/src/scar/output.py +52 -0
  9. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_cli.py +124 -0
  10. scar_cli-0.8.0/tests/test_output.py +48 -0
  11. {scar_cli-0.7.0 → scar_cli-0.8.0}/uv.lock +39 -1
  12. scar_cli-0.7.0/.release-please-manifest.json +0 -3
  13. {scar_cli-0.7.0 → scar_cli-0.8.0}/.claude-plugin/marketplace.json +0 -0
  14. {scar_cli-0.7.0 → scar_cli-0.8.0}/.github/workflows/ci.yml +0 -0
  15. {scar_cli-0.7.0 → scar_cli-0.8.0}/.github/workflows/pr-validation.yml +0 -0
  16. {scar_cli-0.7.0 → scar_cli-0.8.0}/.github/workflows/release.yml +0 -0
  17. {scar_cli-0.7.0 → scar_cli-0.8.0}/.gitignore +0 -0
  18. {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
  19. {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
  20. {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
  21. {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
  22. {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md +0 -0
  23. {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0006-yaml-pattern-anchor-over-escaping.landmine.md +0 -0
  24. {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/0007-release-please-config-change-skips-open-pr.landmine.md +0 -0
  25. {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/README.md +0 -0
  26. {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/candidates/fp-log.txt +0 -0
  27. {scar_cli-0.7.0 → scar_cli-0.8.0}/.scars/template.md +0 -0
  28. {scar_cli-0.7.0 → scar_cli-0.8.0}/AGENTS.md +0 -0
  29. {scar_cli-0.7.0 → scar_cli-0.8.0}/CONTRIBUTING.md +0 -0
  30. {scar_cli-0.7.0 → scar_cli-0.8.0}/IDEA.md +0 -0
  31. {scar_cli-0.7.0 → scar_cli-0.8.0}/LICENSE +0 -0
  32. {scar_cli-0.7.0 → scar_cli-0.8.0}/README.md +0 -0
  33. {scar_cli-0.7.0 → scar_cli-0.8.0}/ROADMAP.md +0 -0
  34. {scar_cli-0.7.0 → scar_cli-0.8.0}/SCAR-FORMAT.md +0 -0
  35. {scar_cli-0.7.0 → scar_cli-0.8.0}/SPEC.md +0 -0
  36. {scar_cli-0.7.0 → scar_cli-0.8.0}/STRESS-TEST.md +0 -0
  37. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
  38. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/anchor-survival/RESULTS.md +0 -0
  39. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/anchor-survival/long_replay.py +0 -0
  40. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/anchor-survival/replay.py +0 -0
  41. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/auto-authorship/FINDINGS.md +0 -0
  42. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
  43. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/.gitignore +0 -0
  44. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/PROTOCOL.md +0 -0
  45. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/RESULTS.md +0 -0
  46. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
  47. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
  48. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
  49. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/README.md +0 -0
  50. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
  51. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
  52. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
  53. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/fence-honor/grade.py +0 -0
  54. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/harvest/PROTOCOL.md +0 -0
  55. {scar_cli-0.7.0 → scar_cli-0.8.0}/experiments/harvest/harvest.py +0 -0
  56. {scar_cli-0.7.0 → scar_cli-0.8.0}/hook/scar-hooks.py +0 -0
  57. {scar_cli-0.7.0 → scar_cli-0.8.0}/plugin/skills/scar-authoring/SKILL.md +0 -0
  58. {scar_cli-0.7.0 → scar_cli-0.8.0}/plugin/skills/scar-authoring/assets/template.md +0 -0
  59. {scar_cli-0.7.0 → scar_cli-0.8.0}/release-please-config.json +0 -0
  60. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/__init__.py +0 -0
  61. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/agent.py +0 -0
  62. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/evidence.py +0 -0
  63. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/harvest.py +0 -0
  64. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/hooks.py +0 -0
  65. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/installer.py +0 -0
  66. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/lint.py +0 -0
  67. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/match.py +0 -0
  68. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/mcp.py +0 -0
  69. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/model.py +0 -0
  70. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/orphan.py +0 -0
  71. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/render.py +0 -0
  72. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/skills/scar-authoring/SKILL.md +0 -0
  73. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/skills/scar-authoring/assets/template.md +0 -0
  74. {scar_cli-0.7.0 → scar_cli-0.8.0}/src/scar/store.py +0 -0
  75. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_docs.py +0 -0
  76. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_evidence.py +0 -0
  77. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_harvest.py +0 -0
  78. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_hooks.py +0 -0
  79. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_installer.py +0 -0
  80. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_lifecycle.py +0 -0
  81. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_lint.py +0 -0
  82. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_match.py +0 -0
  83. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_mcp.py +0 -0
  84. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_model.py +0 -0
  85. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_orphan.py +0 -0
  86. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_plugin.py +0 -0
  87. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_skill.py +0 -0
  88. {scar_cli-0.7.0 → scar_cli-0.8.0}/tests/test_store.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.8.0"
3
+ }
@@ -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.7.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,6 +1,6 @@
1
1
  {
2
2
  "name": "scar",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "SCAR — version control for negative knowledge (deadends, fences, landmines)",
5
5
  "hooks": {
6
6
  "PreToolUse": [
@@ -1,11 +1,13 @@
1
1
  [project]
2
2
  name = "scar-cli"
3
- version = "0.7.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
- for finding in findings:
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 pr in partial:
91
- print(f"HINT partial-rot: scar #{pr.scar_id} {_partial_rot_reason(pr)} "
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
- if unreachable is None:
104
- print("note: shallow clone — evidence-reachability check skipped "
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
- print(f"lint: {len(files)} file(s), {failed} with errors, "
112
- f"{len(orphans)} orphan(s), {len(partial)} partial-rot, "
113
- f"{len(unreachable)} unreachable-evidence")
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(_args) -> int:
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
- print(f"{store.scars_dir}: {len(active)} active, {len(cands)} candidate(s) pending review")
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
- if broken:
157
- print(f" WARNING: {len(broken)} unparseable (can NEVER fire): "
158
- + ", ".join(b.name for b in broken))
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
- if not hits:
195
- print(f"no scars anchored to {args.path}")
196
- return 0
197
- for s in hits:
198
- print(label_line(s))
199
- print(" " + s.body[:200].replace("\n", "\n "))
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
- if not findings:
232
- print("no orphan-detected scars")
233
- else:
234
- for of in findings:
235
- print(f"orphan-detected [#{of.scar_id}] {_orphan_reason(of)}")
236
- print(f"{len(findings)} orphan(s) detected — review, then "
237
- "`scar orphan --apply --id N --reason ...` to persist")
238
- # Partial rot is advisory and surfaced separately — never persisted as
239
- # orphaned (the fix is re-anchoring, not a status transition). #35.
240
- for pr in partial:
241
- print(f"partial-rot [#{pr.scar_id}] {_partial_rot_reason(pr)}")
242
- if partial:
243
- print(f"{len(partial)} partial-rot — advisory; re-anchor the dead "
244
- "anchor(s). Not an orphan (still firing on survivors).")
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
- for f, s in records:
277
- print(f"[{s.status} {s.type} #{s.id}] {s.title} ({f.name})")
278
- print(" " + s.body[:300].replace("\n", "\n ") + "\n")
279
- if not records:
280
- print(f"no recorded pain for {rel}")
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
- sub.add_parser("status", help="counts, titles, broken-file warnings")
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.6.1"
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" }]
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.7.0"
3
- }
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