scar-cli 0.25.1__tar.gz → 0.26.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 (169) hide show
  1. scar_cli-0.26.0/.release-please-manifest.json +3 -0
  2. scar_cli-0.26.0/.scars/0012-firing-log-readers-need-blanket-guards.landmine.md +42 -0
  3. scar_cli-0.26.0/.scars/candidates/violation-evidence-must-not-persist-raw-edits.md +28 -0
  4. {scar_cli-0.25.1 → scar_cli-0.26.0}/CHANGELOG.md +13 -0
  5. {scar_cli-0.25.1 → scar_cli-0.26.0}/PKG-INFO +2 -1
  6. {scar_cli-0.25.1 → scar_cli-0.26.0}/README.md +1 -0
  7. {scar_cli-0.25.1 → scar_cli-0.26.0}/SPEC.md +58 -0
  8. {scar_cli-0.25.1 → scar_cli-0.26.0}/action.yml +7 -0
  9. {scar_cli-0.25.1 → scar_cli-0.26.0}/plugin/plugin.json +1 -1
  10. {scar_cli-0.25.1 → scar_cli-0.26.0}/pyproject.toml +1 -1
  11. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/cli.py +28 -22
  12. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/hooks.py +2 -0
  13. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/match.py +14 -16
  14. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/mcp.py +2 -4
  15. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/store.py +45 -2
  16. scar_cli-0.26.0/src/scar/violation_evidence.py +55 -0
  17. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_action_yml.py +7 -0
  18. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_cli.py +39 -0
  19. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_json_contract.py +1 -0
  20. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_match.py +12 -3
  21. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_mcp.py +14 -0
  22. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_store.py +69 -0
  23. scar_cli-0.26.0/tests/test_violation_evidence.py +288 -0
  24. {scar_cli-0.25.1 → scar_cli-0.26.0}/uv.lock +1 -1
  25. scar_cli-0.25.1/.release-please-manifest.json +0 -3
  26. scar_cli-0.25.1/.scars/0012-firing-log-readers-need-blanket-guards.landmine.md +0 -37
  27. {scar_cli-0.25.1 → scar_cli-0.26.0}/.claude-plugin/marketplace.json +0 -0
  28. {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  29. {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  30. {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  31. {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/workflows/ci.yml +0 -0
  33. {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/workflows/docs.yml +0 -0
  34. {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/workflows/pr-validation.yml +0 -0
  35. {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/workflows/release.yml +0 -0
  36. {scar_cli-0.25.1 → scar_cli-0.26.0}/.gitignore +0 -0
  37. {scar_cli-0.25.1 → scar_cli-0.26.0}/.pre-commit-config.yaml +0 -0
  38. {scar_cli-0.25.1 → scar_cli-0.26.0}/.pre-commit-hooks.yaml +0 -0
  39. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
  40. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
  41. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
  42. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
  43. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md +0 -0
  44. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0006-yaml-pattern-anchor-over-escaping.landmine.md +0 -0
  45. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0007-release-please-config-change-skips-open-pr.landmine.md +0 -0
  46. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0008-rich-output-nontty-must-bypass-rich.landmine.md +0 -0
  47. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0009-anchor-survival-harness-measures-symbol-not-path-anchors.deadend.md +0 -0
  48. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0010-git-returncode-128-vs-empty-tracked-set.landmine.md +0 -0
  49. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0011-lint-compile-check-lets-redos-anchors-hang-hotpath.landmine.md +0 -0
  50. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0013-docusaurus-future-v4-requires-faster-dep.landmine.md +0 -0
  51. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0014-inject-flag-dest-collides-with-subcommand-dispatch.landmine.md +0 -0
  52. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0015-zero-checkruns-means-approval-gate-not-workflow-bug.deadend.md +0 -0
  53. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0016-hook-ownership-must-be-kind-scoped.landmine.md +0 -0
  54. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0017-codex-pretool-context-can-replay-apply-patch.landmine.md +0 -0
  55. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0018-cli-subparsers-register-through-_add-wrapper.landmine.md +0 -0
  56. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0019-token-in-scar-body-cannot-prove-hook-delivery.deadend.md +0 -0
  57. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0020-one-directional-health-check-leaves-flattering-direction-unwatched.landmine.md +0 -0
  58. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0021-posttool-fix-in-hooks-does-not-reach-codex-or-cascade.landmine.md +0 -0
  59. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0022-codex-hook-delivery-needs-user-file-and-trust.landmine.md +0 -0
  60. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0023-cascade-block-exit-collides-with-argparse.fence.md +0 -0
  61. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0024-host-detection-must-not-read-the-process-home.deadend.md +0 -0
  62. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0025-one-plugin-hook-manifest-two-hosts-two-root-variables.landmine.md +0 -0
  63. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0026-path-anchor-breadth-drives-the-review-due-threshold.landmine.md +0 -0
  64. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/README.md +0 -0
  65. {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/template.md +0 -0
  66. {scar_cli-0.25.1 → scar_cli-0.26.0}/AGENTS.md +0 -0
  67. {scar_cli-0.25.1 → scar_cli-0.26.0}/CONTRIBUTING.md +0 -0
  68. {scar_cli-0.25.1 → scar_cli-0.26.0}/IDEA.md +0 -0
  69. {scar_cli-0.25.1 → scar_cli-0.26.0}/LICENSE +0 -0
  70. {scar_cli-0.25.1 → scar_cli-0.26.0}/ROADMAP.md +0 -0
  71. {scar_cli-0.25.1 → scar_cli-0.26.0}/SCAR-FORMAT.md +0 -0
  72. {scar_cli-0.25.1 → scar_cli-0.26.0}/STRESS-TEST.md +0 -0
  73. {scar_cli-0.25.1 → scar_cli-0.26.0}/codecov.yml +0 -0
  74. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
  75. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/RESULTS.md +0 -0
  76. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/long_replay.py +0 -0
  77. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/replay.py +0 -0
  78. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/replay_shipped.py +0 -0
  79. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/test_replay_shipped.py +0 -0
  80. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/auto-authorship/FINDINGS.md +0 -0
  81. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
  82. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/.gitignore +0 -0
  83. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/PROTOCOL.md +0 -0
  84. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/RESULTS.md +0 -0
  85. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
  86. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
  87. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
  88. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/README.md +0 -0
  89. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
  90. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
  91. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
  92. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/grade.py +0 -0
  93. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/harvest/PROTOCOL.md +0 -0
  94. {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/harvest/harvest.py +0 -0
  95. {scar_cli-0.25.1 → scar_cli-0.26.0}/hook/scar-hooks.py +0 -0
  96. {scar_cli-0.25.1 → scar_cli-0.26.0}/plugin/hooks/run.sh +0 -0
  97. {scar_cli-0.25.1 → scar_cli-0.26.0}/plugin/skills/scar-authoring/SKILL.md +0 -0
  98. {scar_cli-0.25.1 → scar_cli-0.26.0}/plugin/skills/scar-authoring/assets/template.md +0 -0
  99. {scar_cli-0.25.1 → scar_cli-0.26.0}/release-please-config.json +0 -0
  100. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/__init__.py +0 -0
  101. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/agent.py +0 -0
  102. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/cascade.py +0 -0
  103. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/codex.py +0 -0
  104. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/draftcheck.py +0 -0
  105. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/evidence.py +0 -0
  106. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/firing_log.py +0 -0
  107. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/gc.py +0 -0
  108. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/harvest.py +0 -0
  109. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/hosts.py +0 -0
  110. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/installer.py +0 -0
  111. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/lint.py +0 -0
  112. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/model.py +0 -0
  113. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/orphan.py +0 -0
  114. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/output.py +0 -0
  115. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/reanchor.py +0 -0
  116. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/renames.py +0 -0
  117. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/render.py +0 -0
  118. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/review.py +0 -0
  119. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/skills/scar-authoring/SKILL.md +0 -0
  120. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/skills/scar-authoring/assets/template.md +0 -0
  121. {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/symbols.py +0 -0
  122. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/conftest.py +0 -0
  123. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_anchor_kind.py +0 -0
  124. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_block_capability.py +0 -0
  125. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_cascade.py +0 -0
  126. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_codex.py +0 -0
  127. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_context_size.py +0 -0
  128. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_docs.py +0 -0
  129. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_draftcheck.py +0 -0
  130. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_evidence.py +0 -0
  131. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_firing_log.py +0 -0
  132. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_gc.py +0 -0
  133. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_git_hook.py +0 -0
  134. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_harvest.py +0 -0
  135. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_hooks.py +0 -0
  136. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_hosts.py +0 -0
  137. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_installer.py +0 -0
  138. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_lifecycle.py +0 -0
  139. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_lint.py +0 -0
  140. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_matched_by.py +0 -0
  141. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_model.py +0 -0
  142. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_orphan.py +0 -0
  143. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_output.py +0 -0
  144. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_plugin.py +0 -0
  145. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_precommit_hooks.py +0 -0
  146. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_reanchor.py +0 -0
  147. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_renames.py +0 -0
  148. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_render.py +0 -0
  149. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_review.py +0 -0
  150. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_skill.py +0 -0
  151. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_sweep.py +0 -0
  152. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_symbols.py +0 -0
  153. {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_verdict_observability.py +0 -0
  154. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/.gitignore +0 -0
  155. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docs/agents.md +0 -0
  156. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docs/concepts.md +0 -0
  157. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docs/index.md +0 -0
  158. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docs/methodology.md +0 -0
  159. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docs/quickstart.md +0 -0
  160. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docusaurus.config.ts +0 -0
  161. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/package-lock.json +0 -0
  162. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/package.json +0 -0
  163. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/scripts/sync-changelog.mjs +0 -0
  164. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/sidebars.ts +0 -0
  165. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/src/css/custom.css +0 -0
  166. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/static/img/favicon.svg +0 -0
  167. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/static/img/social-card.png +0 -0
  168. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/static/llms.txt +0 -0
  169. {scar_cli-0.25.1 → scar_cli-0.26.0}/website/tsconfig.json +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.26.0"
3
+ }
@@ -0,0 +1,42 @@
1
+ ---
2
+ id: 12
3
+ type: landmine
4
+ title: firing-log.jsonl has one reader: read through read_firing_log_records, never hand-roll, distrust slice math
5
+ severity: high
6
+ confidence: 0.85
7
+ created: 2026-07-02
8
+ authors: ["claude-code", "kibukx"]
9
+ anchors:
10
+ - path: src/scar/firing_log.py
11
+ - path: src/scar/gc.py
12
+ - pattern: "firing_log_path\("
13
+ violation: "except \((KeyError|TypeError|ValueError|AttributeError|IndexError)[^)]*\)"
14
+ evidence:
15
+ - pr: 122
16
+ - issue: 124
17
+ - issue: 312
18
+ expires:
19
+ condition: "the shared reader validates field types (ts is a string, scar_ids is a list), not only row shape, so callers stop re-guarding fields"
20
+ review_after: 2027-03-01
21
+ status: active
22
+ ---
23
+
24
+ The firing log is append-only JSONL written best-effort from a hook that must
25
+ never fail, so ANY line shape can and will appear: `null`, `[]`, numbers,
26
+ truncated JSON, non-string `ts`, non-list `scar_ids`. Three bugs shipped from
27
+ that fact in two days (PR #122, issue #124):
28
+
29
+ 1. Narrow per-line except tuples let `ts: null` (TypeError) and bare `null`
30
+ lines (AttributeError at `rec.get`) escape a reader into precheck's outer
31
+ fail-open, killing injection silently and PERMANENTLY.
32
+ 2. `lines[-max_firings:]` with 0 is `[-0:]`, the whole list, while the report
33
+ arithmetic claimed everything was dropped (issue #124).
34
+
35
+ Since #312 both rules live in ONE place: `read_firing_log_records` in
36
+ `src/scar/firing_log.py` (blanket `except Exception` per line, `tail == 0`
37
+ special-cased, negative tail refused). Rule now: every new reader calls it.
38
+ Do not parse the log by hand anywhere else. When editing the reader itself,
39
+ keep the blanket guard. It checks row SHAPE only (dicts kept), so callers
40
+ must still treat every field as untrusted. `gc.py` counts and truncates raw
41
+ lines on purpose (a malformed line still counts); keep its slice math
42
+ special-casing 0, and verify report numbers against the file, not arithmetic.
@@ -0,0 +1,28 @@
1
+ ---
2
+ id: 0
3
+ type: fence
4
+ title: Violation evidence omits raw edits deliberately because excerpts can contain secrets
5
+ severity: high
6
+ confidence: 0.9
7
+ created: 2026-11-05
8
+ authors: ["codex"]
9
+ anchors:
10
+ - path: src/scar/violation_evidence.py
11
+ - path: src/scar/hooks.py
12
+ evidence:
13
+ - issue: 317
14
+ expires:
15
+ condition: "a reviewed capture design can retain original edits without exposing arbitrary secrets"
16
+ status: candidate
17
+ ---
18
+
19
+ The display excerpt is arbitrary edited content, not safe log text (#317).
20
+ Truncating to 120 characters can retain an entire credential. The shared
21
+ verdict writer therefore persists a bounded summary, never the raw excerpt
22
+ or violation regex. Do not add either string to make the log more readable.
23
+ Capture positions and fingerprints from the original regex match; searching
24
+ the truncated excerpt again can miss the actual match on a long line.
25
+ Hashes still permit guessing low-entropy content, so the log remains sensitive.
26
+ A summary needs an independently retained edit for review; it is not a
27
+ confirmed true positive. Old rows without evidence must stay unknown, never
28
+ be backfilled from current files or current scar definitions.
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.26.0](https://github.com/Daily-Nerd/Scar/compare/v0.25.1...v0.26.0) (2026-11-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * **firing-log:** capture bounded violation evidence at match time ([#349](https://github.com/Daily-Nerd/Scar/issues/349)) ([9789b37](https://github.com/Daily-Nerd/Scar/commit/9789b370d78e3590abed69bee249db42e0baa2b0))
9
+ * **lint:** opt-in --fail-partial-rot gate ([#355](https://github.com/Daily-Nerd/Scar/issues/355)) ([aa0346b](https://github.com/Daily-Nerd/Scar/commit/aa0346b3ed18f9968df27619d53b9bf766f5d344)), closes [#353](https://github.com/Daily-Nerd/Scar/issues/353)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **promote:** derive the active filename from one shared slug normaliser ([#356](https://github.com/Daily-Nerd/Scar/issues/356)) ([04e8def](https://github.com/Daily-Nerd/Scar/commit/04e8defd70b59f135e6cf0a0d3c90ba99efa9805)), closes [#352](https://github.com/Daily-Nerd/Scar/issues/352)
15
+
3
16
  ## [0.25.1](https://github.com/Daily-Nerd/Scar/compare/v0.25.0...v0.25.1) (2026-10-07)
4
17
 
5
18
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scar-cli
3
- Version: 0.25.1
3
+ Version: 0.26.0
4
4
  Summary: SCAR — version control for negative knowledge (deadends, fences, landmines)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -118,6 +118,7 @@ CI without pre-commit:
118
118
  - uses: Daily-Nerd/Scar@main # or pin a tag, e.g. @v0.11.0 (release-please)
119
119
  with:
120
120
  fail-orphans: 'true' # default; set 'false' to only fail on parse errors
121
+ fail-partial-rot: 'false' # opt in; also fail when a scar has a dead anchor among live ones
121
122
  version: '' # empty = latest scar-cli from PyPI; or pin e.g. '0.11.0'
122
123
  args: '' # extra args appended to `scar lint`
123
124
  ```
@@ -103,6 +103,7 @@ CI without pre-commit:
103
103
  - uses: Daily-Nerd/Scar@main # or pin a tag, e.g. @v0.11.0 (release-please)
104
104
  with:
105
105
  fail-orphans: 'true' # default; set 'false' to only fail on parse errors
106
+ fail-partial-rot: 'false' # opt in; also fail when a scar has a dead anchor among live ones
106
107
  version: '' # empty = latest scar-cli from PyPI; or pin e.g. '0.11.0'
107
108
  args: '' # extra args appended to `scar lint`
108
109
  ```
@@ -137,6 +137,7 @@ candidate ──promote──▶ active ──challenge──▶ challenged
137
137
  - Confidence is a static, human-authored ranking weight (0..1); the tool does not currently mutate it. Dynamic confidence — decay toward a floor without confirmations, an uphold bump on a survived challenge, and an explicit `scar confirm <id>` — is deferred until scar volume and confirmation data can calibrate a decay policy without unvalidated constants (issue #95).
138
138
  - `review_after` forces periodic human contact with old scars; CI can warn on overdue reviews.
139
139
  - `review_after_firings` is the count-based sibling of `review_after`: once a scar has fired N times *since the last commit that touched its file*, `scar status` and `scar lint` escalate it from an ambient warning to an explicit review flag. Revising the scar re-affirms it and restarts the count; archiving it silences the flag because archived scars do not fire. Absent ⇒ per-type default (`landmine` 10, `fence` 15, `deadend` disabled); `0` ⇒ never escalate. Advisory by default — `scar lint --fail-firing-review` opts into a non-zero exit. Escalation reports only; it never archives or challenges a scar (ADR-4 keeps lifecycle transitions human).
140
+ - Partial rot (a dead anchor on a scar whose other anchors still live) is advisory by default. `scar lint --fail-partial-rot` opts into a non-zero exit when any is found, and the GitHub Action exposes it as the `fail-partial-rot` input (default `false`). It is independent of `--fail-orphans`: neither implies the other. Partial rot is computed from git, so when git is unavailable the list is empty and the gate passes, matching `--fail-orphans` (#91).
140
141
  - When the reset point cannot be determined (untracked scar file, or no git history), the count reported is a **lifetime** count and says so. A since-revision count and a lifetime count are different quantities and must never share one label.
141
142
  - Archive keeps everything: SCAR's own history is negative knowledge.
142
143
 
@@ -239,6 +240,13 @@ Top-level keys, and the keys of each object inside the listed arrays.
239
240
  `gc` mutates state. A consumer that only wants to observe MUST pass
240
241
  `--dry-run`; `dry_run` echoes back which mode ran.
241
242
 
243
+ `stats` also guarantees integer `violation_evidence_summarized` and
244
+ `violation_evidence_unknown` keys, including in each `--all-repos` group.
245
+ They count individual violations, not rows: a row with two violated scars
246
+ contributes two. Summarized means a valid write-time summary exists; unknown
247
+ means missing, malformed, or an unsupported summary format. Neither means
248
+ adjudicated. See §9.11 for the capture policy and its limits.
249
+
242
250
  `stats.armed_firings` counts firings on scars that carried a `violation:`
243
251
  tripwire at the moment they fired; `armed_unknown` counts firings on log rows
244
252
  written before that was recorded. A scar with no tripwire can never be recorded
@@ -496,3 +504,53 @@ ones that reach `main` wrong.
496
504
  `scar hook install` and `scar skill install` without `--runtime` detect hosts (claude, codex, windsurf, cursor, opencode) by config directory or binary on PATH, and resolve for each the channel that already serves it: `settings` (Scar's own entries in the host's hook file), `plugin` (the scar marketplace plugin installed and not disabled), or `none`. windsurf is repo-scoped: its hooks file is written into the working repo, so the binary on PATH counts only when that repo is inside a git repository. On a terminal, one question per present, wirable, unserved host, default no. Without a terminal, exactly one such host is installed; zero or several means the commands are printed and nothing is written. Zero detected hosts says so, rather than naming the plugin. `--all` wires every such host. `--runtime claude` on a Claude Code already served by the plugin refuses unless `--force`. `--all` and `--force` apply to `install` only, and `--force` applies to `--runtime claude` only. A missing or unreadable plugin registry never counts as `plugin`.
497
505
 
498
506
  Two rules differ for the skill kind. windsurf is not repo-scoped there: its global skills live under home (`~/.codeium/windsurf/skills`), so the binary on PATH counts on its own, with no git-repo guard. And `scar skill install --runtime <host>` refuses, exit 1, when detection does not find that host on the machine, rather than creating a skill directory nothing reads. Every destination resolves through the same home the detection pass used, and the Codex destination honours `$CODEX_HOME`, so a reported success always names a directory the detected host actually reads.
507
+
508
+ ### 9.11 Violation evidence is captured at match time
509
+
510
+ Claude Code, Codex, and Windsurf use one verdict writer. New verdict rows
511
+ carry `violation_evidence`, keyed by the string scar id, alongside the existing
512
+ `violation_ids`. Each violated scar contributes one bounded `summary-v1`
513
+ object describing its first regex match on that edited target:
514
+
515
+ | Field | Meaning |
516
+ |---|---|
517
+ | `format` | `summary-v1`; consumers must not interpret an unknown format as this one |
518
+ | `pattern_sha256` | SHA-256 of the exact violation regex used at capture time |
519
+ | `match_sha256` | SHA-256 of the full matched substring, even when it falls outside the display excerpt |
520
+ | `excerpt_sha256` | SHA-256 of the same at-most-120-character line prefix used for display |
521
+ | `line`, `column` | 1-based character position of the match start in the adapter's scanned edit buffer |
522
+ | `match_chars`, `excerpt_chars` | Character lengths, not byte lengths; zero-length regex matches are valid |
523
+ | `excerpt_truncated` | Whether that line in the scanned buffer exceeded 120 characters |
524
+
525
+ The evidence is derived from the same regex match as the verdict, not from a
526
+ second search on a truncated excerpt or a later read of the target. The matcher
527
+ scans at most the first 65,536 characters, as before. Positions describe that
528
+ buffer, **not file line numbers**: adapters combine edits or patch additions,
529
+ and may insert joining whitespace. Truncation describes only the captured
530
+ buffer, not text beyond the scan cap. Digests use UTF-8 with `surrogatepass`
531
+ for lone surrogate characters accepted from JSON payloads.
532
+
533
+ **Privacy tradeoff:** no edited text or regex text is persisted in the summary.
534
+ The display excerpt can contain credentials, and limiting it to 120 characters
535
+ is not redaction. This implementation deliberately chooses structured summaries
536
+ instead of claiming an incomplete secret filter makes arbitrary source safe.
537
+ Hashes are not encryption or anonymization: low-entropy values can be guessed
538
+ and compared. The machine-global log still contains repository and target paths;
539
+ it remains sensitive local state and must not be published unreviewed.
540
+
541
+ **Audit limit:** a reviewer with an independently retained edit and regex can
542
+ compare their hashes and positions with the captured summary. A summary alone
543
+ cannot recover the source or decide whether a match was a real violation, a
544
+ quoted example, or another false positive. `violation_evidence_summarized`
545
+ therefore never means "adjudicated" or "confirmed violation". Stats report the
546
+ summarized and unknown counts separately, with this limitation in human output.
547
+
548
+ Clean observed verdicts carry an empty evidence object. Legacy rows without
549
+ evidence remain unknown for every recorded violation; missing or malformed
550
+ per-scar entries are also unknown. Reads never backfill from present-day code,
551
+ Git history, or current scar definitions. Old rows and existing violation counts
552
+ remain unchanged. Codex multi-file edits retain a separate verdict and evidence
553
+ map for each target. Files under `.scars/` remain eligible for matching other
554
+ scars; there is no blanket directory exclusion. Logging remains best-effort:
555
+ failure to append cannot block an edit, and a failed write does not claim that
556
+ evidence was recorded.
@@ -7,6 +7,10 @@ inputs:
7
7
  description: "Exit non-zero when any scar is orphan-detected (passes --fail-orphans)."
8
8
  required: false
9
9
  default: 'true'
10
+ fail-partial-rot:
11
+ description: "Exit non-zero when any scar has a dead anchor among live ones (passes --fail-partial-rot). Passes when git is unavailable."
12
+ required: false
13
+ default: 'false'
10
14
  version:
11
15
  description: "scar-cli version to install (PyPI version specifier, e.g. '0.11.0'). Empty = latest."
12
16
  required: false
@@ -40,6 +44,9 @@ runs:
40
44
  if [ "${{ inputs.fail-orphans }}" = "true" ]; then
41
45
  set -- "$@" --fail-orphans
42
46
  fi
47
+ if [ "${{ inputs.fail-partial-rot }}" = "true" ]; then
48
+ set -- "$@" --fail-partial-rot
49
+ fi
43
50
  if [ -n "${{ inputs.args }}" ]; then
44
51
  set -- "$@" ${{ inputs.args }}
45
52
  fi
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scar",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "SCAR — version control for negative knowledge (deadends, fences, landmines)",
5
5
  "hooks": {
6
6
  "PreToolUse": [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scar-cli"
3
- version = "0.25.1"
3
+ version = "0.26.0"
4
4
  description = "SCAR — version control for negative knowledge (deadends, fences, landmines)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -51,7 +51,8 @@ from .reanchor import (
51
51
  )
52
52
  from .render import injection_context, label_line, rule_line
53
53
  from .renames import apply_anchor_rewrite, apply_rename_fix
54
- from .store import ScarStore, init_scars
54
+ from .store import ScarStore, init_scars, sanitize_signal_text, slugify
55
+ from .violation_evidence import is_violation_summary
55
56
  from . import output
56
57
 
57
58
 
@@ -770,7 +771,8 @@ def _cmd_lint(args) -> int:
770
771
  for of in orphans:
771
772
  print(f"WARNING orphan-detected: scar #{of.scar_id} — {_orphan_reason(of)}")
772
773
  # partial rot (#35): firing scars with a dead anchor among live ones.
773
- # Advisory only — never a blocking gate, even under --fail-orphans.
774
+ # Advisory by default; --fail-partial-rot opts into a blocking gate.
775
+ # --fail-orphans does not imply it (the two gates are independent).
774
776
  for pr in partial:
775
777
  print(f"HINT partial-rot: scar #{pr.scar_id} — {_partial_rot_reason(pr)} "
776
778
  "— re-anchor to restore full coverage")
@@ -814,6 +816,8 @@ def _cmd_lint(args) -> int:
814
816
  return 1
815
817
  if orphans and getattr(args, "fail_orphans", False):
816
818
  return 1
819
+ if partial and getattr(args, "fail_partial_rot", False):
820
+ return 1
817
821
  if reviews and getattr(args, "fail_firing_review", False):
818
822
  return 1
819
823
  return 0
@@ -1678,6 +1682,7 @@ STAGE_KEYS = frozenset({"retrieval_misses", "demotions",
1678
1682
  "edits_observed", "injection_rate",
1679
1683
  "command_firings", "firings_kind_unknown",
1680
1684
  "armed_firings", "armed_unknown",
1685
+ "violation_evidence_summarized", "violation_evidence_unknown",
1681
1686
  "firings_block_capable", "firings_advisory",
1682
1687
  "firings_block_unknown", "all_firings_advisory",
1683
1688
  "firings_context_known", "firings_context_unknown"})
@@ -1799,6 +1804,12 @@ def _stage_lines(block: dict, per_scar: list[dict], total_firings: int) -> list[
1799
1804
  if cap_unknown:
1800
1805
  enforcement += (f", {cap_unknown} of unknown capability (logged "
1801
1806
  "before it was recorded)")
1807
+ evidence_summarized = block.get("violation_evidence_summarized", 0)
1808
+ evidence_unknown = block.get("violation_evidence_unknown", 0)
1809
+ if evidence_summarized or evidence_unknown:
1810
+ enforcement += (f"; evidence: {evidence_summarized} summarized (no raw text), "
1811
+ f"{evidence_unknown} unknown (missing or invalid evidence); "
1812
+ "summaries alone cannot adjudicate violations")
1802
1813
  # #284: what the demotions are EVIDENCE of. A path-only demotion is a
1803
1814
  # near-miss on the file, a cooldown demotion is a content hit we chose not
1804
1815
  # to repeat; one blended number hides which precision story this repo
@@ -1862,6 +1873,8 @@ def _aggregate_firings(records: list[dict]) -> dict:
1862
1873
  from .hooks import DEMOTED_COOLDOWN, DEMOTED_PATH_ONLY # one spelling, both sides
1863
1874
  counts: dict[int, int] = {}
1864
1875
  violations: dict[int, int] = {}
1876
+ violation_evidence_summarized = 0
1877
+ violation_evidence_unknown = 0
1865
1878
  # (scar_id, target) -> earliest firing ts, for the retrieval-miss check.
1866
1879
  fired_on: dict[tuple[int, str], str] = {}
1867
1880
  pending_violations: list[tuple[int, str, str]] = []
@@ -2043,12 +2056,18 @@ def _aggregate_firings(records: list[dict]) -> dict:
2043
2056
  if isinstance(vid, int):
2044
2057
  pending_violations.append((vid, target, ts_rec))
2045
2058
  if isinstance(vids, list):
2059
+ evidence = rec.get("violation_evidence")
2046
2060
  # Guard: violation_ids might be garbage in corrupted records —
2047
2061
  # skip the whole record unless it's a list, and skip any
2048
2062
  # non-int element within it, rather than coercing or crashing.
2049
2063
  for vid in vids:
2050
2064
  if isinstance(vid, int):
2051
2065
  violations[vid] = violations.get(vid, 0) + 1
2066
+ summary = evidence.get(str(vid)) if isinstance(evidence, dict) else None
2067
+ if is_violation_summary(summary):
2068
+ violation_evidence_summarized += 1
2069
+ else:
2070
+ violation_evidence_unknown += 1
2052
2071
  # `last_fired` means LAST FIRED. Only records where a scar actually
2053
2072
  # fired may move it — not posttool violation records, not zero-hit
2054
2073
  # precheck passes. Otherwise a repo with zero firings still reports a
@@ -2129,6 +2148,8 @@ def _aggregate_firings(records: list[dict]) -> dict:
2129
2148
  "firings_kind_unknown": firings_kind_unknown,
2130
2149
  "armed_firings": armed_firings,
2131
2150
  "armed_unknown": armed_unknown,
2151
+ "violation_evidence_summarized": violation_evidence_summarized,
2152
+ "violation_evidence_unknown": violation_evidence_unknown,
2132
2153
  # #277: the posttool half's own liveness. `posttool_silent` is
2133
2154
  # ONE-DIRECTIONAL like every other health field here — it can show
2134
2155
  # the half is dead, and observing a verdict never certifies it
@@ -2653,26 +2674,8 @@ _WRITE_SCAR_TYPES = {"reverts": "deadend", "deleted_components": "deadend",
2653
2674
  "flapping": "fence", "comments": "fence"}
2654
2675
 
2655
2676
 
2656
- def _sanitize_signal_text(s: str) -> str:
2657
- """Mined git text (revert subjects, squash-merge subjects) can carry
2658
- markdown links, emphasis markers, and bare URLs; leaked into a candidate
2659
- they make the title unreadable and a URL eats the whole slug budget (#159)."""
2660
- import re as _re
2661
- s = _re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s) # [text](url) -> text
2662
- s = _re.sub(r"https?://\S+", "", s)
2663
- s = _re.sub(r"[*_`]+", "", s)
2664
- return " ".join(s.split())
2665
-
2666
-
2667
2677
  def _write_slug(title: str) -> str:
2668
- import re as _re
2669
- s = _re.sub(r"[^a-z0-9]+", "-", _sanitize_signal_text(title).lower()).strip("-")
2670
- if len(s) > 60:
2671
- cut = s[:60]
2672
- if s[60] != "-" and "-" in cut: # sliced mid-token: drop the fragment
2673
- cut = cut.rsplit("-", 1)[0]
2674
- s = cut.rstrip("-")
2675
- return s or "signal"
2678
+ return slugify(title, fallback="signal")
2676
2679
 
2677
2680
 
2678
2681
  def _write_anchors(repo: Path, section: str, c: dict, tracked: set[str]) -> list[str]:
@@ -2699,7 +2702,7 @@ def _write_title(section: str, c: dict) -> str:
2699
2702
  raw = f"Flapping value {c['key']} in {c['file']}"
2700
2703
  else:
2701
2704
  raw = "Keep-out comment in " + c["location"].split(":", 1)[0]
2702
- return _sanitize_signal_text(raw)
2705
+ return sanitize_signal_text(raw)
2703
2706
 
2704
2707
 
2705
2708
  def _write_evidence(section: str, c: dict) -> str:
@@ -3107,6 +3110,9 @@ def build_parser() -> argparse.ArgumentParser:
3107
3110
  p = _add(sub, "lint", _cmd_lint, help="validate every scar and candidate")
3108
3111
  p.add_argument("--fail-orphans", action="store_true",
3109
3112
  help="exit non-zero when any scar is orphan-detected")
3113
+ p.add_argument("--fail-partial-rot", action="store_true",
3114
+ help="exit non-zero when any scar has a dead anchor among live "
3115
+ "ones (passes when git is unavailable, like --fail-orphans)")
3110
3116
  p.add_argument("--fail-firing-review", action="store_true",
3111
3117
  help="exit non-zero when any scar has crossed its "
3112
3118
  "review_after_firings threshold")
@@ -374,6 +374,8 @@ def _log_violation_firing(store: ScarStore, target: str, violations: list,
374
374
  "repo": str(store.root),
375
375
  "target": target,
376
376
  "violation_ids": [v.scar.id for v in violations],
377
+ "violation_evidence": {str(violation.scar.id): violation.evidence
378
+ for violation in violations},
377
379
  "count": len(violations),
378
380
  "runtime": runtime,
379
381
  # The posttool half ran and reached a verdict on this edit.
@@ -15,6 +15,7 @@ from pathlib import Path
15
15
 
16
16
  from .model import Scar
17
17
  from .store import ScarStore
18
+ from .violation_evidence import summarize_match
18
19
 
19
20
  SEVERITY_WEIGHT = {"low": 1, "medium": 2, "high": 3, "critical": 4}
20
21
  DEFAULT_TOP_K = 3
@@ -409,13 +410,14 @@ class Violation:
409
410
  source: Path # scar file relative to root
410
411
  path: str # edited file that tripped it
411
412
  excerpt: str # line containing the first match (trimmed ~120 chars)
413
+ evidence: dict
412
414
 
413
415
 
414
- def _violation_excerpt(pattern: str, text: str) -> str | None:
415
- """First line of `text` matching `pattern`, trimmed to ~120 chars.
416
- Searches the whole capped text for the pattern, then extracts the line
417
- containing the match. Invalid regex -> None (lint's job; never crash the
418
- read path — mirrors _pattern_anchor_matches)."""
416
+ def _violation_details(pattern: str, text: str) -> tuple[str, dict] | None:
417
+ """Display excerpt and evidence from the same first match in capped text.
418
+
419
+ Invalid regex returns None, matching _pattern_anchor_matches.
420
+ """
419
421
  try:
420
422
  rx = re.compile(pattern, re.IGNORECASE)
421
423
  except re.error:
@@ -424,20 +426,15 @@ def _violation_excerpt(pattern: str, text: str) -> str | None:
424
426
  m = rx.search(capped)
425
427
  if m is None:
426
428
  return None
427
- # Extract the line containing the match
428
- line_start = capped.rfind("\n", 0, m.start()) + 1
429
- line_end = capped.find("\n", m.start())
430
- if line_end == -1:
431
- line_end = len(capped)
432
- return capped[line_start:line_end][:120]
429
+ return summarize_match(m)
433
430
 
434
431
 
435
432
  def _violation_lines(pattern: str, text: str) -> list[tuple[int, str]]:
436
433
  """Every (1-based line number, trimmed line) where `pattern` matches.
437
434
  Each line is reported once however many times it matches. Invalid regex ->
438
- [] (lint's job; mirrors _violation_excerpt).
435
+ [] (lint's job; mirrors _violation_details).
439
436
 
440
- Scans the WHOLE text, unlike _violation_excerpt: the 64 KiB bound belongs to
437
+ Scans the WHOLE text, unlike _violation_details: the 64 KiB bound belongs to
441
438
  the edit hot path, and the sweep reads files offline that are already capped
442
439
  at MAX_CONTENT_BYTES (same reasoning as _pattern_anchor_live). The ReDoS
443
440
  defense stays lint._is_redos_prone() at the gate (landmine #11)."""
@@ -495,11 +492,12 @@ def _violations_for_target(firing: list, root: Path, rel_path: str,
495
492
  new_content: str) -> list["Violation"]:
496
493
  out: list[Violation] = []
497
494
  for scar, rel_source in _armed_candidates(firing, root, rel_path):
498
- excerpt = _violation_excerpt(scar.violation, new_content)
499
- if excerpt is None:
495
+ details = _violation_details(scar.violation, new_content)
496
+ if details is None:
500
497
  continue
498
+ excerpt, evidence = details
501
499
  out.append(Violation(scar=scar, source=rel_source,
502
- path=rel_path, excerpt=excerpt))
500
+ path=rel_path, excerpt=excerpt, evidence=evidence))
503
501
  return out
504
502
 
505
503
 
@@ -7,7 +7,6 @@ MCP hosts can launch it with `scar mcp`.
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
- import re
11
10
  import sys
12
11
  import time
13
12
  from pathlib import Path
@@ -16,7 +15,7 @@ from typing import Any
16
15
  from .lint import lint_text
17
16
  from .match import rank_matches_for_diff, rank_matches_for_paths
18
17
  from .model import Scar
19
- from .store import ScarStore
18
+ from .store import ScarStore, slugify
20
19
 
21
20
 
22
21
  def _version() -> str:
@@ -95,8 +94,7 @@ def _scar_from_args(args: dict[str, Any]) -> Scar:
95
94
 
96
95
 
97
96
  def _slug(title: str) -> str:
98
- slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
99
- return slug[:60] or "scar-candidate"
97
+ return slugify(title, fallback="scar-candidate")
100
98
 
101
99
 
102
100
  def _draft(args: dict[str, Any]) -> dict[str, Any]:
@@ -6,11 +6,51 @@ embedded so `scar init` works from a bare install with no data files.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import re
9
10
  from dataclasses import dataclass
10
11
  from pathlib import Path
11
12
 
12
13
  from .lint import lint_text
13
- from .model import ParseError, Scar, parse_scar_text
14
+ from .model import TYPES, ParseError, Scar, parse_scar_text
15
+
16
+ SLUG_MAX = 60
17
+ _TYPE_EXT_CHAIN = re.compile(r"(?:\.(?:%s))+$" % "|".join(TYPES))
18
+
19
+
20
+ def sanitize_signal_text(s: str) -> str:
21
+ """Mined git text (revert subjects, squash-merge subjects) can carry
22
+ markdown links, emphasis markers, and bare URLs; leaked into a candidate
23
+ they make the title unreadable and a URL eats the whole slug budget (#159)."""
24
+ s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s) # [text](url) -> text
25
+ s = re.sub(r"https?://\S+", "", s)
26
+ s = re.sub(r"[*_`]+", "", s)
27
+ return " ".join(s.split())
28
+
29
+
30
+ def slugify(text: str, fallback: str = "scar") -> str:
31
+ """The one slug normaliser (#352): every writer of a scar filename (promote,
32
+ the MCP draft tool, harvest --write) goes through here so the rules cannot
33
+ drift apart. Sanitise (#159), lowercase, collapse non-alnum to '-', cap at
34
+ SLUG_MAX without leaving a sliced-mid-token fragment."""
35
+ s = re.sub(r"[^a-z0-9]+", "-", sanitize_signal_text(text).lower()).strip("-")
36
+ if len(s) > SLUG_MAX:
37
+ cut = s[:SLUG_MAX]
38
+ if s[SLUG_MAX] != "-" and "-" in cut:
39
+ cut = cut.rsplit("-", 1)[0]
40
+ s = cut.rstrip("-")
41
+ return s or fallback
42
+
43
+
44
+ def active_slug(stem: str, scar_type: str) -> str:
45
+ """Candidate stem -> slug for the active `NNNN-<slug>.<type>.md` name.
46
+ A trailing `.{type}` chain (any type) is the extension leaking into the
47
+ stem; a trailing `-{type}` is redundant only for the scar's own type."""
48
+ stem = _TYPE_EXT_CHAIN.sub("", stem)
49
+ slug = slugify(stem, fallback="scar")
50
+ suffix = "-" + scar_type
51
+ if slug.endswith(suffix):
52
+ slug = slug[: -len(suffix)] or slug
53
+ return slug
14
54
 
15
55
  SKIP_NAMES = {"readme.md", "template.md"}
16
56
 
@@ -268,8 +308,11 @@ class ScarStore:
268
308
  # like promoted_by itself: whatever the candidate carried here was
269
309
  # never a real vouch either.
270
310
  scar.promoted_by_source = reviewer_source if reviewer else None
271
- slug = candidate.stem
311
+ slug = active_slug(candidate.stem, scar.type)
272
312
  new_path = self.scars_dir / f"{scar.id:04d}-{slug}.{scar.type}.md"
313
+ if new_path.exists(): # #352: never clobber an existing active scar
314
+ raise ValueError(
315
+ f"refusing to promote, active file already exists: {new_path.name}")
273
316
  new_path.write_text(scar.to_text(), encoding="utf-8")
274
317
  candidate.unlink()
275
318
  return new_path
@@ -0,0 +1,55 @@
1
+ """Bounded, text-free evidence captured from the original regex match."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+
8
+
9
+ SUMMARY_FORMAT = "summary-v1"
10
+ EXCERPT_CHARS = 120
11
+
12
+
13
+ def summarize_match(match: re.Match[str]) -> tuple[str, dict]:
14
+ """Return the display excerpt and its persistable summary, not raw text."""
15
+ text = match.string
16
+ start = match.start()
17
+ line_start = text.rfind("\n", 0, start) + 1
18
+ line_end = text.find("\n", start)
19
+ if line_end == -1:
20
+ line_end = len(text)
21
+ excerpt = text[line_start:line_end][:EXCERPT_CHARS]
22
+
23
+ def digest(value: str) -> str:
24
+ return hashlib.sha256(value.encode("utf-8", errors="surrogatepass")).hexdigest()
25
+
26
+ return excerpt, {
27
+ "format": SUMMARY_FORMAT,
28
+ "pattern_sha256": digest(match.re.pattern),
29
+ "match_sha256": digest(match.group()),
30
+ "excerpt_sha256": digest(excerpt),
31
+ "line": text.count("\n", 0, start) + 1,
32
+ "column": start - line_start + 1,
33
+ "match_chars": match.end() - start,
34
+ "excerpt_chars": len(excerpt),
35
+ "excerpt_truncated": line_end - line_start > EXCERPT_CHARS,
36
+ }
37
+
38
+
39
+ def is_violation_summary(value: object) -> bool:
40
+ """Validate persisted evidence without consulting present-day source files."""
41
+ if not isinstance(value, dict) or value.get("format") != SUMMARY_FORMAT:
42
+ return False
43
+ for key in ("pattern_sha256", "match_sha256", "excerpt_sha256"):
44
+ digest = value.get(key)
45
+ if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
46
+ return False
47
+ for key, minimum in (("line", 1), ("column", 1), ("match_chars", 0),
48
+ ("excerpt_chars", 0)):
49
+ count = value.get(key)
50
+ if type(count) is not int or count < minimum:
51
+ return False
52
+ return (value["excerpt_chars"] <= EXCERPT_CHARS
53
+ and type(value.get("excerpt_truncated")) is bool
54
+ and (not value["excerpt_truncated"]
55
+ or value["excerpt_chars"] == EXCERPT_CHARS))
@@ -34,6 +34,7 @@ def test_action_declares_expected_inputs_with_defaults():
34
34
 
35
35
  expected_defaults = {
36
36
  "fail-orphans": "'true'",
37
+ "fail-partial-rot": "'false'",
37
38
  "version": "''",
38
39
  "args": "''",
39
40
  }
@@ -64,6 +65,12 @@ def test_action_uses_fail_orphans_input_conditionally():
64
65
  assert "--fail-orphans" in text
65
66
 
66
67
 
68
+ def test_action_uses_fail_partial_rot_input_conditionally():
69
+ text = _text()
70
+ assert "inputs.fail-partial-rot" in text
71
+ assert "--fail-partial-rot" in text
72
+
73
+
67
74
  def test_action_forwards_extra_args_input():
68
75
  text = _text()
69
76
  assert "inputs.args" in text