scar-cli 0.6.1__tar.gz → 0.7.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 (85) hide show
  1. scar_cli-0.7.0/.release-please-manifest.json +3 -0
  2. scar_cli-0.7.0/.scars/0007-release-please-config-change-skips-open-pr.landmine.md +38 -0
  3. {scar_cli-0.6.1 → scar_cli-0.7.0}/.scars/candidates/fp-log.txt +1 -0
  4. {scar_cli-0.6.1/plugin/skills/scar-authoring/assets → scar_cli-0.7.0/.scars}/template.md +4 -1
  5. {scar_cli-0.6.1 → scar_cli-0.7.0}/CHANGELOG.md +13 -0
  6. {scar_cli-0.6.1 → scar_cli-0.7.0}/PKG-INFO +1 -1
  7. {scar_cli-0.6.1 → scar_cli-0.7.0}/plugin/plugin.json +1 -1
  8. {scar_cli-0.6.1/src/scar → scar_cli-0.7.0/plugin}/skills/scar-authoring/assets/template.md +4 -1
  9. {scar_cli-0.6.1 → scar_cli-0.7.0}/pyproject.toml +1 -1
  10. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/harvest.py +21 -5
  11. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/lint.py +6 -1
  12. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/model.py +19 -2
  13. {scar_cli-0.6.1/.scars → scar_cli-0.7.0/src/scar/skills/scar-authoring/assets}/template.md +4 -1
  14. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/store.py +7 -3
  15. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_harvest.py +43 -0
  16. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_lint.py +29 -0
  17. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_model.py +79 -0
  18. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_store.py +15 -0
  19. {scar_cli-0.6.1 → scar_cli-0.7.0}/uv.lock +1 -1
  20. scar_cli-0.6.1/.release-please-manifest.json +0 -3
  21. {scar_cli-0.6.1 → scar_cli-0.7.0}/.claude-plugin/marketplace.json +0 -0
  22. {scar_cli-0.6.1 → scar_cli-0.7.0}/.github/workflows/ci.yml +0 -0
  23. {scar_cli-0.6.1 → scar_cli-0.7.0}/.github/workflows/pr-validation.yml +0 -0
  24. {scar_cli-0.6.1 → scar_cli-0.7.0}/.github/workflows/release.yml +0 -0
  25. {scar_cli-0.6.1 → scar_cli-0.7.0}/.gitignore +0 -0
  26. {scar_cli-0.6.1 → scar_cli-0.7.0}/.scars/0001-git-grep-ere-pitfalls.landmine.md +0 -0
  27. {scar_cli-0.6.1 → scar_cli-0.7.0}/.scars/0002-agent-direct-hook-install.deadend.md +0 -0
  28. {scar_cli-0.6.1 → scar_cli-0.7.0}/.scars/0003-installer-binds-to-active-venv-scar.landmine.md +0 -0
  29. {scar_cli-0.6.1 → scar_cli-0.7.0}/.scars/0004-promote-roundtrip-drops-expires-evidence.landmine.md +0 -0
  30. {scar_cli-0.6.1 → scar_cli-0.7.0}/.scars/0005-history-rewrite-orphans-commit-evidence.landmine.md +0 -0
  31. {scar_cli-0.6.1 → scar_cli-0.7.0}/.scars/0006-yaml-pattern-anchor-over-escaping.landmine.md +0 -0
  32. {scar_cli-0.6.1 → scar_cli-0.7.0}/.scars/README.md +0 -0
  33. {scar_cli-0.6.1 → scar_cli-0.7.0}/AGENTS.md +0 -0
  34. {scar_cli-0.6.1 → scar_cli-0.7.0}/CONTRIBUTING.md +0 -0
  35. {scar_cli-0.6.1 → scar_cli-0.7.0}/IDEA.md +0 -0
  36. {scar_cli-0.6.1 → scar_cli-0.7.0}/LICENSE +0 -0
  37. {scar_cli-0.6.1 → scar_cli-0.7.0}/README.md +0 -0
  38. {scar_cli-0.6.1 → scar_cli-0.7.0}/ROADMAP.md +0 -0
  39. {scar_cli-0.6.1 → scar_cli-0.7.0}/SCAR-FORMAT.md +0 -0
  40. {scar_cli-0.6.1 → scar_cli-0.7.0}/SPEC.md +0 -0
  41. {scar_cli-0.6.1 → scar_cli-0.7.0}/STRESS-TEST.md +0 -0
  42. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/anchor-survival/PROTOCOL.md +0 -0
  43. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/anchor-survival/RESULTS.md +0 -0
  44. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/anchor-survival/long_replay.py +0 -0
  45. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/anchor-survival/replay.py +0 -0
  46. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/auto-authorship/FINDINGS.md +0 -0
  47. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/auto-authorship/PROTOCOL.md +0 -0
  48. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/fence-honor/.gitignore +0 -0
  49. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/fence-honor/PROTOCOL.md +0 -0
  50. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/fence-honor/RESULTS.md +0 -0
  51. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/fence-honor/fixture/.scars/0001-vendor-retry-window.fence.md +0 -0
  52. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/fence-honor/fixture/.scars/0002-evicting-session-store.deadend.md +0 -0
  53. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/fence-honor/fixture/.scars/0003-export-column-order.landmine.md +0 -0
  54. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/fence-honor/fixture/README.md +0 -0
  55. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/fence-honor/fixture/payments/retry.py +0 -0
  56. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/fence-honor/fixture/reports/export.py +0 -0
  57. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/fence-honor/fixture/services/sessions.py +0 -0
  58. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/fence-honor/grade.py +0 -0
  59. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/harvest/PROTOCOL.md +0 -0
  60. {scar_cli-0.6.1 → scar_cli-0.7.0}/experiments/harvest/harvest.py +0 -0
  61. {scar_cli-0.6.1 → scar_cli-0.7.0}/hook/scar-hooks.py +0 -0
  62. {scar_cli-0.6.1 → scar_cli-0.7.0}/plugin/skills/scar-authoring/SKILL.md +0 -0
  63. {scar_cli-0.6.1 → scar_cli-0.7.0}/release-please-config.json +0 -0
  64. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/__init__.py +0 -0
  65. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/agent.py +0 -0
  66. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/cli.py +0 -0
  67. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/evidence.py +0 -0
  68. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/hooks.py +0 -0
  69. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/installer.py +0 -0
  70. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/match.py +0 -0
  71. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/mcp.py +0 -0
  72. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/orphan.py +0 -0
  73. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/render.py +0 -0
  74. {scar_cli-0.6.1 → scar_cli-0.7.0}/src/scar/skills/scar-authoring/SKILL.md +0 -0
  75. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_cli.py +0 -0
  76. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_docs.py +0 -0
  77. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_evidence.py +0 -0
  78. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_hooks.py +0 -0
  79. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_installer.py +0 -0
  80. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_lifecycle.py +0 -0
  81. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_match.py +0 -0
  82. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_mcp.py +0 -0
  83. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_orphan.py +0 -0
  84. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_plugin.py +0 -0
  85. {scar_cli-0.6.1 → scar_cli-0.7.0}/tests/test_skill.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.7.0"
3
+ }
@@ -0,0 +1,38 @@
1
+ ---
2
+ id: 7
3
+ type: landmine
4
+ title: Changing release-please extra-files config does NOT retro-apply to an already-open release PR
5
+ severity: medium
6
+ confidence: 0.85
7
+ created: 2026-06-26
8
+ authors: ["claude-code", "Kibukx"]
9
+ anchors:
10
+ - path: release-please-config.json
11
+ - path: .github/workflows/release.yml
12
+ evidence:
13
+ - pr: 64
14
+ - pr: 66
15
+ - note: v0.6.1 release cycle, 2026-06-26
16
+ expires:
17
+ condition: "release-please gains retro-application of config changes to open release PRs, or the repo stops carrying version in extra files (plugin.json)"
18
+ review_after: 2027-06-26
19
+ status: active
20
+ ---
21
+
22
+ If a release PR is already open and you then merge a change to `release-please-config.json`
23
+ (e.g. adding/altering `extra-files`), release-please will NOT retro-apply the new config to
24
+ that open PR — especially when the commit that lands the config is a non-releasable type
25
+ (`ci:`, `chore:`), because release-please sees no new releasable commit and leaves the
26
+ existing PR untouched.
27
+
28
+ Observed live: PR #64 (release 0.6.1) was computed from the #63 `fix:` commit BEFORE #65 added
29
+ the `extra-files` updater for `plugin/plugin.json`. When #65 (a `ci:` commit) merged,
30
+ release-please re-ran but did not re-bump the open PR, so #64 bumped `pyproject.toml` only.
31
+ `plugin/plugin.json` stayed behind and the `test_plugin_version_matches_pyproject` drift guard
32
+ failed CI — correctly blocking a broken release.
33
+
34
+ Fix when this happens: close the stale release PR, delete its `release-please--branches--main`
35
+ branch, and re-dispatch the Release workflow (`gh workflow run release.yml`). The fresh PR is
36
+ built from scratch and applies the current `extra-files` config. Do NOT hand-patch the version
37
+ into the open release branch — it leaves the manifest/state half-migrated. The drift guard is
38
+ the safety net: it makes this failure loud instead of shipping a mismatched manifest.
@@ -4,3 +4,4 @@
4
4
  2026-06-13 — false trigger: signals from iterative re-anchoring + a caught staging bug (fixed #51) + gh CLI flakiness; all genuine lessons already captured as scars #5/#6 and issues #50
5
5
  2026-06-13 false trigger: harvest-labeling session — 'revert/reverted/abandoned' prose describes ANOTHER repo's history (homelab-apps reverts judged as scar candidates), not an approach abandoned here; tool_errors were a wc-on-wrong-path + benign exit-1 during label recording. Curation work trips the drafter like meta-sessions do (FP #1).
6
6
  2026-06-24 false trigger: #54 scorer investigation — 'revert' is feature-domain (the revert heuristic) + 'user_corrections'/'wrong' is me correcting the ISSUE BODY's mechanism claim (flapping base vs uncapped osc bonus), not abandoning a tried code approach. Read-only mapping + scope question; no code written, nothing deadended. Same self-ref class as FP #1/#5: sessions reasoning about the scorer trip the scorer.
7
+ 2026-06-29 — FP: 'revert' was #54 harvest revert-signal discussion; corrections were process steering (wait for investigation, repeat question); no approach tried-and-abandoned.
@@ -13,7 +13,10 @@ anchors:
13
13
  - path: src/module/ # file or directory this protects
14
14
  - pattern: "regex" # optional: fires when matching code appears in ANY new/edited file
15
15
  evidence:
16
- - commit: abc1234 # at least one receipt: commit, pr, incident, or note
16
+ - pr: 123 # at least one receipt: pr, issue, url, commit, incident, or note
17
+ # Prefer pr/issue/url over a bare commit: SHA — feature-branch SHAs orphan on squash-merge.
18
+ - issue: 50
19
+ - url: https://github.com/org/repo/commit/abc1234
17
20
  expires:
18
21
  condition: "what change would make this scar obsolete"
19
22
  review_after: 1971-01-01 # force a freshness look even if condition never triggers
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://github.com/Daily-Nerd/Scar/compare/v0.6.1...v0.7.0) (2026-06-30)
4
+
5
+
6
+ ### Features
7
+
8
+ * durable evidence forms (issue:/url:) to survive squash-merge ([#50](https://github.com/Daily-Nerd/Scar/issues/50)) ([#74](https://github.com/Daily-Nerd/Scar/issues/74)) ([d558849](https://github.com/Daily-Nerd/Scar/commit/d558849652c068a9679617c5ed522b3eefffcc78))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **harvest:** exclude vendored/scaffold paths from comment-archaeology ([#72](https://github.com/Daily-Nerd/Scar/issues/72)) ([#73](https://github.com/Daily-Nerd/Scar/issues/73)) ([919add4](https://github.com/Daily-Nerd/Scar/commit/919add4b3503b65f866274d97995088a0aa8612a))
14
+ * **parser:** strip unquoted inline comments in _field ([#70](https://github.com/Daily-Nerd/Scar/issues/70)) ([03ec578](https://github.com/Daily-Nerd/Scar/commit/03ec578290f8dd8ce4ed436367a12eabbdb5c120))
15
+
3
16
  ## [0.6.1](https://github.com/Daily-Nerd/Scar/compare/v0.6.0...v0.6.1) (2026-06-26)
4
17
 
5
18
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scar-cli
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: SCAR — version control for negative knowledge (deadends, fences, landmines)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scar",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "SCAR — version control for negative knowledge (deadends, fences, landmines)",
5
5
  "hooks": {
6
6
  "PreToolUse": [
@@ -13,7 +13,10 @@ anchors:
13
13
  - path: src/module/ # file or directory this protects
14
14
  - pattern: "regex" # optional: fires when matching code appears in ANY new/edited file
15
15
  evidence:
16
- - commit: abc1234 # at least one receipt: commit, pr, incident, or note
16
+ - pr: 123 # at least one receipt: pr, issue, url, commit, incident, or note
17
+ # Prefer pr/issue/url over a bare commit: SHA — feature-branch SHAs orphan on squash-merge.
18
+ - issue: 50
19
+ - url: https://github.com/org/repo/commit/abc1234
17
20
  expires:
18
21
  condition: "what change would make this scar obsolete"
19
22
  review_after: 1971-01-01 # force a freshness look even if condition never triggers
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scar-cli"
3
- version = "0.6.1"
3
+ version = "0.7.0"
4
4
  description = "SCAR — version control for negative knowledge (deadends, fences, landmines)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -351,6 +351,18 @@ def _in_scars(path: str) -> bool:
351
351
  return ".scars" in path.split("/")
352
352
 
353
353
 
354
+ # Vendored dependency trees + tool-scaffold dirs. Their files carry DO-NOT /
355
+ # load-bearing prose the project never wrote (third-party docs, generated
356
+ # config), so comment-archaeology self-matches the same way #55's .scars did.
357
+ # Matched as any path segment — a nested node_modules/ is the same noise (#72).
358
+ _VENDORED_SEGMENTS = frozenset({"node_modules", "vendor", ".tessl", ".kiro"})
359
+
360
+
361
+ def _is_vendored(path: str) -> bool:
362
+ """True if a candidate path lives under a vendored/scaffold tree (#72)."""
363
+ return bool(_VENDORED_SEGMENTS & set(path.split("/")))
364
+
365
+
354
366
  def _annotate_and_sort(candidates: list[dict], signal_type: str,
355
367
  now_months: int | None = None) -> list[dict]:
356
368
  """Add 'score' and 'id' fields to each candidate, then sort by score desc."""
@@ -370,12 +382,16 @@ def harvest(repo: Path) -> dict[str, list[dict]]:
370
382
  "flapping": _flapping(repo),
371
383
  "comments": _comment_archaeology(repo),
372
384
  }
373
- # Drop a repo's own .scars/ tree self-ref noise, never a fresh candidate
374
- # (#55). Filtered in code, not via a git grep pathspec (landmine #1: a
375
- # zero-match pathspec silently empties the whole result set).
385
+ # Drop self-ref noise (#55: a repo's own .scars/ tree) and vendored/scaffold
386
+ # noise (#72: node_modules, vendor, tool-scaffold docs). Filtered in code,
387
+ # not via a git grep pathspec (landmine #1: a zero-match pathspec silently
388
+ # empties the whole result set).
389
+ def _excluded(section: str, c: dict) -> bool:
390
+ path = _candidate_path(_SECTION_TYPES[section], c)
391
+ return _in_scars(path) or _is_vendored(path)
392
+
376
393
  raw = {
377
- section: [c for c in candidates
378
- if not _in_scars(_candidate_path(_SECTION_TYPES[section], c))]
394
+ section: [c for c in candidates if not _excluded(section, c)]
379
395
  for section, candidates in raw.items()
380
396
  }
381
397
  return {
@@ -9,7 +9,7 @@ import re
9
9
  import time
10
10
  from dataclasses import dataclass
11
11
 
12
- from .model import SEVERITIES, STATUSES, TYPES, ParseError, parse_scar_text
12
+ from .model import SEVERITIES, STATUSES, TYPES, ParseError, is_valid_url, parse_scar_text
13
13
 
14
14
 
15
15
  @dataclass
@@ -46,6 +46,11 @@ def lint_text(text: str, today: str | None = None) -> list[Finding]:
46
46
  findings.append(Finding("error", f"invalid pattern anchor /{pat}/: {exc}"))
47
47
  if not scar.evidence:
48
48
  findings.append(Finding("warning", "no evidence links — challengeable on sight"))
49
+ for e in scar.evidence:
50
+ if e.startswith("url:") and not is_valid_url(e[len("url:"):]):
51
+ value = e[len("url:"):].strip()
52
+ findings.append(Finding(
53
+ "warning", f"url evidence not a valid http(s) link: '{value}'"))
49
54
  # ISO dates compare correctly as strings; never an error — a human
50
55
  # decides whether to archive (ADR-4), lint only surfaces the due date
51
56
  if (scar.status in ("active", "challenged") and scar.review_after
@@ -16,6 +16,13 @@ SEVERITIES = ("low", "medium", "high", "critical")
16
16
  STATUSES = ("candidate", "active", "challenged", "archived", "orphaned", "template")
17
17
 
18
18
  _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)
19
+ _URL_RE = re.compile(r"^https?://")
20
+
21
+
22
+ def is_valid_url(value: str) -> bool:
23
+ """True iff value is an http(s) URL. Used by lint to flag durable-link
24
+ evidence (url:) that isn't actually a link (#50)."""
25
+ return bool(_URL_RE.match(value.strip()))
19
26
 
20
27
 
21
28
  class ParseError(ValueError):
@@ -67,7 +74,17 @@ class Scar:
67
74
 
68
75
  def _field(front: str, name: str, default: str = "") -> str:
69
76
  m = re.search(rf"^\s*{name}:\s*(.+?)\s*$", front, re.MULTILINE)
70
- return m.group(1) if m else default
77
+ if not m:
78
+ return default
79
+ value = m.group(1)
80
+ # Strip an unquoted inline comment (YAML: a '#' after whitespace starts a
81
+ # comment). The template ships '# ...' on every field; a copied-but-uncleaned
82
+ # comment otherwise lands in the value — silently for confidence/severity,
83
+ # loudly for type. Quoted values are left intact so a '#' inside a quoted
84
+ # scalar (title, condition) stays data. See issue #69.
85
+ if '"' not in value and "'" not in value:
86
+ value = re.sub(r"\s+#.*$", "", value).rstrip()
87
+ return value
71
88
 
72
89
 
73
90
  def parse_scar_text(text: str) -> Scar:
@@ -91,7 +108,7 @@ def parse_scar_text(text: str) -> Scar:
91
108
  for a in authors_raw.strip("[]").split(",") if a.strip()] if authors_raw else []
92
109
 
93
110
  evidence = [f"{m1.group(1)}: {m1.group(2).strip().strip('\"')}" for m1 in re.finditer(
94
- r"^\s*-\s*(commit|pr|incident|note):\s*(.+)\s*$", front, re.MULTILINE)]
111
+ r"^\s*-\s*(commit|pr|issue|incident|note|url):\s*(.+)\s*$", front, re.MULTILINE)]
95
112
 
96
113
  return Scar(
97
114
  type=_field(front, "type", "deadend"),
@@ -13,7 +13,10 @@ anchors:
13
13
  - path: src/module/ # file or directory this protects
14
14
  - pattern: "regex" # optional: fires when matching code appears in ANY new/edited file
15
15
  evidence:
16
- - commit: abc1234 # at least one receipt: commit, pr, incident, or note
16
+ - pr: 123 # at least one receipt: pr, issue, url, commit, incident, or note
17
+ # Prefer pr/issue/url over a bare commit: SHA — feature-branch SHAs orphan on squash-merge.
18
+ - issue: 50
19
+ - url: https://github.com/org/repo/commit/abc1234
17
20
  expires:
18
21
  condition: "what change would make this scar obsolete"
19
22
  review_after: 1971-01-01 # force a freshness look even if condition never triggers
@@ -34,8 +34,9 @@ challenge it: update or archive it with a note, don't ignore it.
34
34
  2. **YAML frontmatter is mandatory.** A scar without it is unparseable and
35
35
  will NEVER fire in any tool. `scar lint` checks; the hooks warn loudly.
36
36
  3. **Promotion** = human review: `scar promote candidates/<slug>.md`.
37
- 4. **Evidence required.** A scar without a commit/PR/incident reference is
38
- an opinion and can be challenged on sight.
37
+ 4. **Evidence required.** A scar without a pr/issue/url/commit/incident
38
+ reference is an opinion and can be challenged on sight. Prefer durable
39
+ pr/issue/url refs — feature-branch commit SHAs orphan on squash-merge.
39
40
 
40
41
  Format details: `template.md`. Project: SCAR.
41
42
  """
@@ -56,7 +57,10 @@ anchors:
56
57
  - path: src/module/ # file or directory this protects
57
58
  - pattern: "regex" # optional: fires when matching code appears in ANY new/edited file
58
59
  evidence:
59
- - commit: abc1234 # at least one receipt: commit, pr, incident, or note
60
+ - pr: 123 # at least one receipt: pr, issue, url, commit, incident, or note
61
+ # Prefer pr/issue/url over a bare commit: SHA — feature-branch SHAs orphan on squash-merge.
62
+ - issue: 50
63
+ - url: https://github.com/org/repo/commit/abc1234
60
64
  expires:
61
65
  condition: "what change would make this scar obsolete"
62
66
  review_after: 1971-01-01
@@ -105,6 +105,49 @@ def test_excludes_scars_dir_from_candidates(tmp_path):
105
105
  "no candidate may point into any .scars/ tree (self-ref noise, #55)"
106
106
 
107
107
 
108
+ def test_excludes_vendored_and_scaffold_dirs_from_candidates(tmp_path):
109
+ """Scar #72: harvest must not surface candidates from vendored/scaffold trees.
110
+
111
+ Comment-archaeology greps DO-NOT/load-bearing prose across the whole repo,
112
+ so third-party doc tiles and dependency trees (node_modules/, vendor/, and
113
+ tool-scaffold dirs like .tessl/.kiro) match the fence regex with prose the
114
+ project never wrote. Same path-based pollution class as #55's .scars filter.
115
+ """
116
+ git(tmp_path.parent, "init", "-q", "-b", "main", str(tmp_path))
117
+ git(tmp_path, "config", "user.email", "t@t")
118
+ git(tmp_path, "config", "user.name", "t")
119
+ # real code comment — SHOULD still be harvested
120
+ (tmp_path / "app.py").write_text(
121
+ "# DO NOT remove this init — load-bearing\nx = 1\n")
122
+ # vendored / scaffold trees full of trigger prose — must NOT be harvested
123
+ vendored = {
124
+ "node_modules/pkg/readme.md": "DO NOT edit; load-bearing vendored code.\n",
125
+ "vendor/lib/notes.md": "This must remain unchanged — load-bearing.\n",
126
+ ".tessl/tiles/sqlalchemy/docs/dialects.md":
127
+ "Add ON CONFLICT DO NOTHING clause. DO NOT change.\n",
128
+ ".kiro/steering/rules.md": "// DO NOT: forget the route guard\n",
129
+ }
130
+ for rel, content in vendored.items():
131
+ p = tmp_path / rel
132
+ p.parent.mkdir(parents=True, exist_ok=True)
133
+ p.write_text(content)
134
+ git(tmp_path, "add", "-A")
135
+ git(tmp_path, "commit", "-qm", "feat: code + vendored trees")
136
+
137
+ result = harvest(tmp_path)
138
+ locations = [c["location"] for c in result["comments"]]
139
+ assert any(loc.startswith("app.py") for loc in locations), \
140
+ "real code comment should still be harvested"
141
+ all_paths = (
142
+ locations
143
+ + [c["component"] for c in result["deleted_components"]]
144
+ + [c["file"] for c in result["flapping"]]
145
+ )
146
+ vendored_segs = {"node_modules", "vendor", ".tessl", ".kiro"}
147
+ assert not any(vendored_segs & set(p.split("/")) for p in all_paths), \
148
+ "no candidate may point into a vendored/scaffold tree (#72)"
149
+
150
+
108
151
  # ---------------------------------------------------------------------------
109
152
  # Ranking / scoring tests
110
153
  # ---------------------------------------------------------------------------
@@ -68,3 +68,32 @@ def test_bad_pattern_regex_is_error():
68
68
  ' - pattern: "([unclosed"')
69
69
  findings = lint_text(bad)
70
70
  assert any(f.level == "error" and "pattern" in f.message for f in findings)
71
+
72
+
73
+ def test_malformed_url_evidence_is_warning():
74
+ bad = GOOD.replace(
75
+ "evidence:\n - commit: abc1234\n",
76
+ "evidence:\n - url: not-a-link\n",
77
+ )
78
+ findings = lint_text(bad)
79
+ assert any(f.level == "warning" and "url" in f.message and "not-a-link" in f.message
80
+ for f in findings)
81
+ assert not any(f.level == "error" for f in findings)
82
+
83
+
84
+ def test_valid_url_evidence_no_warning():
85
+ ok = GOOD.replace(
86
+ "evidence:\n - commit: abc1234\n",
87
+ "evidence:\n - url: https://github.com/org/repo/commit/abc1234\n",
88
+ )
89
+ findings = lint_text(ok)
90
+ assert not any("url" in f.message for f in findings)
91
+
92
+
93
+ def test_issue_evidence_skips_url_check():
94
+ ok = GOOD.replace(
95
+ "evidence:\n - commit: abc1234\n",
96
+ "evidence:\n - issue: 50\n",
97
+ )
98
+ findings = lint_text(ok)
99
+ assert findings == []
@@ -56,6 +56,31 @@ def test_confidence_defaults_when_malformed():
56
56
  assert parse_scar_text(text).confidence == 0.5
57
57
 
58
58
 
59
+ def test_inline_comment_stripped_from_type():
60
+ # The template ships `type: deadend # ...`; a copied-but-uncleaned comment
61
+ # must not become part of the value (else the type fails its enum check).
62
+ text = VALID.replace("type: deadend", "type: deadend # tried+failed")
63
+ assert parse_scar_text(text).type == "deadend"
64
+
65
+
66
+ def test_inline_comment_stripped_from_confidence_keeps_value():
67
+ # Silent-corruption case: a comment used to make float() fail -> reset to 0.5.
68
+ text = VALID.replace("confidence: 0.9", "confidence: 0.9 # 0..1 how sure")
69
+ assert parse_scar_text(text).confidence == 0.9
70
+
71
+
72
+ def test_inline_comment_stripped_from_severity():
73
+ text = VALID.replace("severity: high", "severity: high # low | medium | high | critical")
74
+ assert parse_scar_text(text).severity == "high"
75
+
76
+
77
+ def test_quoted_hash_is_data_not_comment():
78
+ # A '#' inside a quoted scalar is part of the value, not a comment.
79
+ text = VALID.replace('condition: "sessions become re-derivable"',
80
+ 'condition: "drop #legacy sessions"')
81
+ assert parse_scar_text(text).expires_condition == "drop #legacy sessions"
82
+
83
+
59
84
  def test_quoted_pattern_anchor_unwrapped():
60
85
  s = parse_scar_text(VALID)
61
86
  assert '"' not in s.pattern_anchors[0]
@@ -98,3 +123,57 @@ Body.
98
123
  assert 'inner' in s.evidence[0]
99
124
  s2 = parse_scar_text(s.to_text())
100
125
  assert s2.evidence == s.evidence
126
+
127
+
128
+ def test_issue_and_url_evidence_parse_and_roundtrip():
129
+ text = """\
130
+ ---
131
+ type: landmine
132
+ title: Durable evidence forms
133
+ severity: medium
134
+ confidence: 0.7
135
+ anchors:
136
+ - path: src/scar/
137
+ evidence:
138
+ - pr: 123
139
+ - issue: 50
140
+ - url: https://github.com/org/repo/commit/abc1234
141
+ status: active
142
+ ---
143
+
144
+ Body.
145
+ """
146
+ s = parse_scar_text(text)
147
+ assert s.evidence == [
148
+ "pr: 123",
149
+ "issue: 50",
150
+ "url: https://github.com/org/repo/commit/abc1234",
151
+ ]
152
+ s2 = parse_scar_text(s.to_text())
153
+ assert s2.evidence == s.evidence
154
+
155
+
156
+ def test_existing_evidence_prefixes_still_parse():
157
+ text = """\
158
+ ---
159
+ type: deadend
160
+ title: keeps old forms
161
+ severity: low
162
+ confidence: 0.5
163
+ anchors:
164
+ - path: a/
165
+ evidence:
166
+ - commit: a3f9c21
167
+ - incident: 2024-prod-outage
168
+ - note: archived 2025-01-01: superseded
169
+ status: active
170
+ ---
171
+
172
+ Body.
173
+ """
174
+ s = parse_scar_text(text)
175
+ assert s.evidence == [
176
+ "commit: a3f9c21",
177
+ "incident: 2024-prod-outage",
178
+ "note: archived 2025-01-01: superseded",
179
+ ]
@@ -107,3 +107,18 @@ def test_scars_for_path_is_bidirectional(repo):
107
107
  assert store.scars_for_path("src/deep/file.py")
108
108
  assert store.scars_for_path("") # repo root: every anchor is under it
109
109
  assert not store.scars_for_path("docs/readme.md")
110
+
111
+
112
+ def test_template_documents_durable_evidence_forms():
113
+ from scar.store import TEMPLATE
114
+ assert "issue:" in TEMPLATE
115
+ assert "url:" in TEMPLATE
116
+ assert "squash" in TEMPLATE.lower()
117
+
118
+
119
+ def test_template_with_durable_forms_parses_clean():
120
+ from scar.store import TEMPLATE
121
+ from scar.model import parse_scar_text
122
+ text = TEMPLATE.replace("status: template", "status: active")
123
+ s = parse_scar_text(text)
124
+ assert any(e.startswith("issue:") or e.startswith("url:") for e in s.evidence)
@@ -79,7 +79,7 @@ wheels = [
79
79
 
80
80
  [[package]]
81
81
  name = "scar-cli"
82
- version = "0.6.0"
82
+ version = "0.6.1"
83
83
  source = { editable = "." }
84
84
 
85
85
  [package.dev-dependencies]
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.6.1"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes