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.
- scar_cli-0.26.0/.release-please-manifest.json +3 -0
- scar_cli-0.26.0/.scars/0012-firing-log-readers-need-blanket-guards.landmine.md +42 -0
- scar_cli-0.26.0/.scars/candidates/violation-evidence-must-not-persist-raw-edits.md +28 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/CHANGELOG.md +13 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/PKG-INFO +2 -1
- {scar_cli-0.25.1 → scar_cli-0.26.0}/README.md +1 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/SPEC.md +58 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/action.yml +7 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/plugin/plugin.json +1 -1
- {scar_cli-0.25.1 → scar_cli-0.26.0}/pyproject.toml +1 -1
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/cli.py +28 -22
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/hooks.py +2 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/match.py +14 -16
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/mcp.py +2 -4
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/store.py +45 -2
- scar_cli-0.26.0/src/scar/violation_evidence.py +55 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_action_yml.py +7 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_cli.py +39 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_json_contract.py +1 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_match.py +12 -3
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_mcp.py +14 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_store.py +69 -0
- scar_cli-0.26.0/tests/test_violation_evidence.py +288 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/uv.lock +1 -1
- scar_cli-0.25.1/.release-please-manifest.json +0 -3
- scar_cli-0.25.1/.scars/0012-firing-log-readers-need-blanket-guards.landmine.md +0 -37
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.claude-plugin/marketplace.json +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/workflows/ci.yml +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/workflows/docs.yml +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/workflows/pr-validation.yml +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.github/workflows/release.yml +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.gitignore +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.pre-commit-config.yaml +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.pre-commit-hooks.yaml +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0006-yaml-pattern-anchor-over-escaping.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0007-release-please-config-change-skips-open-pr.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0008-rich-output-nontty-must-bypass-rich.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0009-anchor-survival-harness-measures-symbol-not-path-anchors.deadend.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0010-git-returncode-128-vs-empty-tracked-set.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0011-lint-compile-check-lets-redos-anchors-hang-hotpath.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0013-docusaurus-future-v4-requires-faster-dep.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0014-inject-flag-dest-collides-with-subcommand-dispatch.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0015-zero-checkruns-means-approval-gate-not-workflow-bug.deadend.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0016-hook-ownership-must-be-kind-scoped.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0017-codex-pretool-context-can-replay-apply-patch.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0018-cli-subparsers-register-through-_add-wrapper.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0019-token-in-scar-body-cannot-prove-hook-delivery.deadend.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0020-one-directional-health-check-leaves-flattering-direction-unwatched.landmine.md +0 -0
- {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
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0022-codex-hook-delivery-needs-user-file-and-trust.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0023-cascade-block-exit-collides-with-argparse.fence.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0024-host-detection-must-not-read-the-process-home.deadend.md +0 -0
- {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
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/0026-path-anchor-breadth-drives-the-review-due-threshold.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/README.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/.scars/template.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/AGENTS.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/CONTRIBUTING.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/IDEA.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/LICENSE +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/ROADMAP.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/SCAR-FORMAT.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/STRESS-TEST.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/codecov.yml +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/RESULTS.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/long_replay.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/replay.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/replay_shipped.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/anchor-survival/test_replay_shipped.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/auto-authorship/FINDINGS.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/.gitignore +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/PROTOCOL.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/RESULTS.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/README.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/fence-honor/grade.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/harvest/PROTOCOL.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/experiments/harvest/harvest.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/hook/scar-hooks.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/plugin/hooks/run.sh +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/plugin/skills/scar-authoring/SKILL.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/plugin/skills/scar-authoring/assets/template.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/release-please-config.json +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/__init__.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/agent.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/cascade.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/codex.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/draftcheck.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/evidence.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/firing_log.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/gc.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/harvest.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/hosts.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/installer.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/lint.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/model.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/orphan.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/output.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/reanchor.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/renames.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/render.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/review.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/skills/scar-authoring/SKILL.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/skills/scar-authoring/assets/template.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/src/scar/symbols.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/conftest.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_anchor_kind.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_block_capability.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_cascade.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_codex.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_context_size.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_docs.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_draftcheck.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_evidence.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_firing_log.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_gc.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_git_hook.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_harvest.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_hooks.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_hosts.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_installer.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_lifecycle.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_lint.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_matched_by.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_model.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_orphan.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_output.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_plugin.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_precommit_hooks.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_reanchor.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_renames.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_render.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_review.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_skill.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_sweep.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_symbols.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/tests/test_verdict_observability.py +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/.gitignore +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docs/agents.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docs/concepts.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docs/index.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docs/methodology.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docs/quickstart.md +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/docusaurus.config.ts +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/package-lock.json +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/package.json +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/scripts/sync-changelog.mjs +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/sidebars.ts +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/src/css/custom.css +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/static/img/favicon.svg +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/static/img/social-card.png +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/static/llms.txt +0 -0
- {scar_cli-0.25.1 → scar_cli-0.26.0}/website/tsconfig.json +0 -0
|
@@ -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.
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
415
|
-
"""
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
|
435
|
+
[] (lint's job; mirrors _violation_details).
|
|
439
436
|
|
|
440
|
-
Scans the WHOLE text, unlike
|
|
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
|
-
|
|
499
|
-
if
|
|
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
|
-
|
|
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
|