yadflow 3.12.2 → 3.13.1

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.
@@ -38,15 +38,52 @@ if ! printf '%s\n' "$cc" | grep -qx 'yes'; then
38
38
  exit 1
39
39
  fi
40
40
 
41
+ # --- shared link.md resolution (byte-identical in contract-check / lineage-check / epic-open /
42
+ # --- reconcile-debt-check; the gates are deliberately standalone, so it is duplicated, not sourced) ---
43
+ # Read one frontmatter value from the FIRST --- … --- block only. awk bounds to the first block (stops
44
+ # at the first closing fence), so a body `---` or an absent key can never leak a body line. Plain
45
+ # scalars only; trailing spaces/CR are stripped so they never become part of a path.
46
+ fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f && index($0, k":")==1 {sub("^" k ":[ \t]*", ""); print; exit}' "$2" 2>/dev/null | tr -d '\r' | sed -E 's/[[:space:]]+$//'; }
47
+
48
+ # Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
49
+ # pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
50
+ # rather than silently reading an empty value and skipping the check it guards. Deliberately separate
51
+ # from fm_val: hub artifacts (epic.md, stories/*.md) stay bounded to their first block.
52
+ link_val() {
53
+ _v="$(fm_val "$1" "$2")"
54
+ [ -n "$_v" ] || _v="$(sed -nE "s/^$1:[[:space:]]*(.*)\$/\1/p" "$2" 2>/dev/null | head -1 | tr -d '\r' | sed -E 's/[[:space:]]+$//')"
55
+ printf '%s' "$_v"
56
+ }
57
+
58
+ # Resolve link.md's `product-repo` to a path in THIS checkout. An ABSOLUTE value is used as-is. A
59
+ # RELATIVE value is written relative to the link.md's own directory (specs/<story>/) — the canonical
60
+ # form — but contract-check historically read it from the repo root, so a link.md authored against that
61
+ # reading still resolves: prefer the canonical join, fall back to the root-relative one when only it
62
+ # exists. All four gates share this verbatim, so a value one gate can reach is reachable from every
63
+ # gate (issue #149). An unexpanded ~ or $VAR is returned untouched, so it fails the reachability test
64
+ # loudly instead of being joined into a nonsense path.
65
+ resolve_product() {
66
+ case "$1" in
67
+ '') return ;;
68
+ /*|'~'*|'$'*) printf '%s' "$1" ;;
69
+ *) if [ -d "specs/$2/$1" ] || [ ! -d "$1" ]; then printf 'specs/%s/%s' "$2" "$1"; else printf '%s' "$1"; fi ;;
70
+ esac
71
+ }
72
+
41
73
  # Fidelity check (best-effort): when the product repo is reachable, the story's link.md must pin the
42
74
  # CURRENT product lock — proof the contract was actually updated/re-locked upstream, not just flagged.
43
75
  story="$(printf '%s\n' "$surface" | head -1 | sed -E 's#^specs/([^/]+)/contracts/.*#\1#')"
44
76
  link="specs/${story}/link.md"
45
77
  if [ -f "$link" ]; then
46
- product_rel="$(sed -nE 's/^product-repo:[[:space:]]*(.*)$/\1/p' "$link" | head -1)"
47
- pinned="$(sed -nE 's/^contract-lock:[[:space:]]*sha256:([0-9a-f]+).*$/\1/p' "$link" | head -1)"
78
+ product_rel="$(link_val product-repo "$link")"
79
+ pinned="$(printf '%s' "$(link_val contract-lock "$link")" | sed -E 's/^sha256:([0-9a-f]+).*$/\1/')"
48
80
  epic="$(printf '%s' "$story" | sed -E 's/-S[0-9]+$//')" # story EP-<slug>-S0N -> epic EP-<slug>
49
- lock="${product_rel}/epics/${epic}/.sdlc/contract-lock.json"
81
+ prod="$(resolve_product "$product_rel" "$story")"
82
+ # Only build the lock path when the product repo actually resolved. With an empty `prod` the
83
+ # interpolation yields "/epics/<epic>/…" — a path rooted at the filesystem root, which is both a
84
+ # misleading thing to print and, on a machine that happened to have /epics, a foreign file to read.
85
+ lock=""
86
+ [ -n "$prod" ] && lock="${prod}/epics/${epic}/.sdlc/contract-lock.json"
50
87
  if [ -n "$product_rel" ] && [ -f "$lock" ]; then
51
88
  current="$(sed -nE 's/.*"hash":[[:space:]]*"sha256:([0-9a-f]+)".*/\1/p' "$lock" | head -1)"
52
89
  if [ -n "$current" ] && [ "$current" != "$pinned" ]; then
@@ -55,7 +92,13 @@ if [ -f "$link" ]; then
55
92
  exit 1
56
93
  fi
57
94
  echo "note [contract-check]: link.md hash matches the product lock (${current:0:12}…)."
95
+ else
96
+ # Say so. A skipped fidelity check used to be indistinguishable from a passed one, which is how a
97
+ # mis-resolved product-repo could turn a stale-pin FAIL into a silent PASS (issue #149).
98
+ echo "note [contract-check]: product lock not reachable at ${lock:-<no product-repo in link.md>} — fidelity check deferred."
58
99
  fi
100
+ else
101
+ echo "note [contract-check]: no ${link} — fidelity check deferred (spec-link gates the link itself)."
59
102
  fi
60
103
 
61
104
  echo "PASS [contract-check]: surface change accompanied by Contract-Change: yes (and an updated contract)."
@@ -20,8 +20,37 @@ fi
20
20
  RANGE="${BASE}..HEAD"
21
21
  EXEMPT='ci|chore|build|test'
22
22
 
23
- # Read one frontmatter value from the FIRST --- --- block only (awk stops at the first closing fence).
24
- fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f && index($0, k":")==1 {sub("^" k ":[ \t]*", ""); print; exit}' "$2" 2>/dev/null | tr -d '\r'; }
23
+ # --- shared link.md resolution (byte-identical in contract-check / lineage-check / epic-open /
24
+ # --- reconcile-debt-check; the gates are deliberately standalone, so it is duplicated, not sourced) ---
25
+ # Read one frontmatter value from the FIRST --- … --- block only. awk bounds to the first block (stops
26
+ # at the first closing fence), so a body `---` or an absent key can never leak a body line. Plain
27
+ # scalars only; trailing spaces/CR are stripped so they never become part of a path.
28
+ fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f && index($0, k":")==1 {sub("^" k ":[ \t]*", ""); print; exit}' "$2" 2>/dev/null | tr -d '\r' | sed -E 's/[[:space:]]+$//'; }
29
+
30
+ # Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
31
+ # pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
32
+ # rather than silently reading an empty value and skipping the check it guards. Deliberately separate
33
+ # from fm_val: hub artifacts (epic.md, stories/*.md) stay bounded to their first block.
34
+ link_val() {
35
+ _v="$(fm_val "$1" "$2")"
36
+ [ -n "$_v" ] || _v="$(sed -nE "s/^$1:[[:space:]]*(.*)\$/\1/p" "$2" 2>/dev/null | head -1 | tr -d '\r' | sed -E 's/[[:space:]]+$//')"
37
+ printf '%s' "$_v"
38
+ }
39
+
40
+ # Resolve link.md's `product-repo` to a path in THIS checkout. An ABSOLUTE value is used as-is. A
41
+ # RELATIVE value is written relative to the link.md's own directory (specs/<story>/) — the canonical
42
+ # form — but contract-check historically read it from the repo root, so a link.md authored against that
43
+ # reading still resolves: prefer the canonical join, fall back to the root-relative one when only it
44
+ # exists. All four gates share this verbatim, so a value one gate can reach is reachable from every
45
+ # gate (issue #149). An unexpanded ~ or $VAR is returned untouched, so it fails the reachability test
46
+ # loudly instead of being joined into a nonsense path.
47
+ resolve_product() {
48
+ case "$1" in
49
+ '') return ;;
50
+ /*|'~'*|'$'*) printf '%s' "$1" ;;
51
+ *) if [ -d "specs/$2/$1" ] || [ ! -d "$1" ]; then printf 'specs/%s/%s' "$2" "$1"; else printf '%s' "$1"; fi ;;
52
+ esac
53
+ }
25
54
 
26
55
  # Is the epic SEALED? true iff it has >=1 story and EVERY stories/*.md frontmatter status is `shipped`.
27
56
  epic_sealed() {
@@ -50,11 +79,15 @@ while IFS= read -r sha; do
50
79
  [ -z "$sha" ] && continue
51
80
  short="$(git log -1 --format=%h "$sha")"
52
81
  subject="$(git log -1 --format=%s "$sha")"
53
- if printf '%s' "$subject" | grep -qE "^(${EXEMPT})(\([a-z0-9._-]+\))?!?: "; then
54
- echo "PASS [epic-open]: ${short} '${subject}' maintenance commit (exempt)"
82
+ task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
83
+ # The type exemption waives the REQUIREMENT for an owning epic, not the VALIDITY of one that is
84
+ # claimed — same rule spec-link applies. Exempting on the subject alone would let `chore(x): …` plus
85
+ # a Task trailer pointing at a SEALED epic add behaviour to it, which is exactly what this gate exists
86
+ # to refuse.
87
+ if printf '%s' "$subject" | grep -qE "^(${EXEMPT})(\([a-z0-9._-]+\))?!?: " && [ -z "$task" ]; then
88
+ echo "PASS [epic-open]: ${short} '${subject}' — maintenance commit, no Task trailer (exempt)"
55
89
  continue
56
90
  fi
57
- task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
58
91
  if ! printf '%s' "$task" | grep -qE '.+-T[0-9]+$'; then
59
92
  echo "note [epic-open]: ${short} has no resolvable Task trailer — deferring to spec-link."
60
93
  continue
@@ -62,8 +95,8 @@ while IFS= read -r sha; do
62
95
  story="$(printf '%s' "$task" | sed -E 's/-T[0-9]+$//')"
63
96
  link="specs/${story}/link.md"
64
97
  [ -f "$link" ] || { echo "note [epic-open]: ${short} ${task} — link.md missing (spec-link will FAIL)."; continue; }
65
- product_rel="$(fm_val product-repo "$link")"
66
- epic="$(fm_val epic "$link")"
98
+ product_rel="$(link_val product-repo "$link")"
99
+ epic="$(link_val epic "$link")"
67
100
  # A malformed link.md (empty product-repo, or an epic that is not a real EP-<slug>) must FAIL, not
68
101
  # slip through as "not reachable" — an empty epic would collapse ep_dir to <product>/epics/ (a real
69
102
  # dir) and pass the seal check as if the epic were open.
@@ -72,8 +105,7 @@ while IFS= read -r sha; do
72
105
  rc=1
73
106
  continue
74
107
  fi
75
- # product-repo is relative to the link.md's directory (specs/<story>/), so join it there.
76
- prod="specs/${story}/${product_rel}"
108
+ prod="$(resolve_product "$product_rel" "$story")"
77
109
  ep_dir="${prod}/epics/${epic}"
78
110
  if [ ! -d "$prod" ]; then
79
111
  echo "PASS [epic-open]: ${short} ${task} -> ${epic} (product repo not reachable — seal check deferred)."
@@ -19,10 +19,37 @@ fi
19
19
  RANGE="${BASE}..HEAD"
20
20
  EXEMPT='ci|chore|build|test'
21
21
 
22
+ # --- shared link.md resolution (byte-identical in contract-check / lineage-check / epic-open /
23
+ # --- reconcile-debt-check; the gates are deliberately standalone, so it is duplicated, not sourced) ---
22
24
  # Read one frontmatter value from the FIRST --- … --- block only. awk bounds to the first block (stops
23
25
  # at the first closing fence), so a body `---` or an absent key can never leak a body line. Plain
24
- # scalars only.
25
- fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f && index($0, k":")==1 {sub("^" k ":[ \t]*", ""); print; exit}' "$2" 2>/dev/null | tr -d '\r'; }
26
+ # scalars only; trailing spaces/CR are stripped so they never become part of a path.
27
+ fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f && index($0, k":")==1 {sub("^" k ":[ \t]*", ""); print; exit}' "$2" 2>/dev/null | tr -d '\r' | sed -E 's/[[:space:]]+$//'; }
28
+
29
+ # Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
30
+ # pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
31
+ # rather than silently reading an empty value and skipping the check it guards. Deliberately separate
32
+ # from fm_val: hub artifacts (epic.md, stories/*.md) stay bounded to their first block.
33
+ link_val() {
34
+ _v="$(fm_val "$1" "$2")"
35
+ [ -n "$_v" ] || _v="$(sed -nE "s/^$1:[[:space:]]*(.*)\$/\1/p" "$2" 2>/dev/null | head -1 | tr -d '\r' | sed -E 's/[[:space:]]+$//')"
36
+ printf '%s' "$_v"
37
+ }
38
+
39
+ # Resolve link.md's `product-repo` to a path in THIS checkout. An ABSOLUTE value is used as-is. A
40
+ # RELATIVE value is written relative to the link.md's own directory (specs/<story>/) — the canonical
41
+ # form — but contract-check historically read it from the repo root, so a link.md authored against that
42
+ # reading still resolves: prefer the canonical join, fall back to the root-relative one when only it
43
+ # exists. All four gates share this verbatim, so a value one gate can reach is reachable from every
44
+ # gate (issue #149). An unexpanded ~ or $VAR is returned untouched, so it fails the reachability test
45
+ # loudly instead of being joined into a nonsense path.
46
+ resolve_product() {
47
+ case "$1" in
48
+ '') return ;;
49
+ /*|'~'*|'$'*) printf '%s' "$1" ;;
50
+ *) if [ -d "specs/$2/$1" ] || [ ! -d "$1" ]; then printf 'specs/%s/%s' "$2" "$1"; else printf '%s' "$1"; fi ;;
51
+ esac
52
+ }
26
53
 
27
54
  commits="$(git rev-list --no-merges "$RANGE")"
28
55
  if [ -z "$commits" ]; then
@@ -35,11 +62,14 @@ while IFS= read -r sha; do
35
62
  [ -z "$sha" ] && continue
36
63
  short="$(git log -1 --format=%h "$sha")"
37
64
  subject="$(git log -1 --format=%s "$sha")"
38
- if printf '%s' "$subject" | grep -qE "^(${EXEMPT})(\([a-z0-9._-]+\))?!?: "; then
39
- echo "PASS [lineage-check]: ${short} '${subject}' maintenance commit (exempt)"
65
+ task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
66
+ # The type exemption waives the REQUIREMENT for an owning epic, not the VALIDITY of one that is
67
+ # claimed — same rule spec-link applies. Exempting on the subject alone would let `chore(x): …` plus
68
+ # a Task trailer pointing at an orphaned/sealed epic bypass this gate entirely.
69
+ if printf '%s' "$subject" | grep -qE "^(${EXEMPT})(\([a-z0-9._-]+\))?!?: " && [ -z "$task" ]; then
70
+ echo "PASS [lineage-check]: ${short} '${subject}' — maintenance commit, no Task trailer (exempt)"
40
71
  continue
41
72
  fi
42
- task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
43
73
  # No / malformed Task trailer is spec-link's job to FAIL; here we only skip what we can't resolve.
44
74
  if ! printf '%s' "$task" | grep -qE '.+-T[0-9]+$'; then
45
75
  echo "note [lineage-check]: ${short} has no resolvable Task trailer — deferring to spec-link."
@@ -51,15 +81,14 @@ while IFS= read -r sha; do
51
81
  echo "note [lineage-check]: ${short} ${task} — specs/${story}/link.md missing (spec-link will FAIL)."
52
82
  continue
53
83
  fi
54
- product_rel="$(fm_val product-repo "$link")"
55
- epic="$(fm_val epic "$link")"
84
+ product_rel="$(link_val product-repo "$link")"
85
+ epic="$(link_val epic "$link")"
56
86
  if [ -z "$epic" ]; then
57
87
  echo "FAIL [lineage-check]: ${short} ${task} — link.md has no 'epic:' (cannot place it in a thread)."
58
88
  rc=1
59
89
  continue
60
90
  fi
61
- # product-repo is relative to the link.md's directory (specs/<story>/), so join it there.
62
- prod="specs/${story}/${product_rel}"
91
+ prod="$(resolve_product "$product_rel" "$story")"
63
92
  epicmd="${prod}/epics/${epic}/epic.md"
64
93
  # Defer ONLY when the product checkout itself is unreachable. A reachable hub whose epic is missing is
65
94
  # an orphaned story link — FAIL, do not pass it off as "not reachable".
@@ -19,7 +19,37 @@ fi
19
19
  RANGE="${BASE}..HEAD"
20
20
  EXEMPT='ci|chore|build|test'
21
21
 
22
- fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f && index($0, k":")==1 {sub("^" k ":[ \t]*", ""); print; exit}' "$2" 2>/dev/null | tr -d '\r'; }
22
+ # --- shared link.md resolution (byte-identical in contract-check / lineage-check / epic-open /
23
+ # --- reconcile-debt-check; the gates are deliberately standalone, so it is duplicated, not sourced) ---
24
+ # Read one frontmatter value from the FIRST --- … --- block only. awk bounds to the first block (stops
25
+ # at the first closing fence), so a body `---` or an absent key can never leak a body line. Plain
26
+ # scalars only; trailing spaces/CR are stripped so they never become part of a path.
27
+ fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f && index($0, k":")==1 {sub("^" k ":[ \t]*", ""); print; exit}' "$2" 2>/dev/null | tr -d '\r' | sed -E 's/[[:space:]]+$//'; }
28
+
29
+ # Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
30
+ # pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
31
+ # rather than silently reading an empty value and skipping the check it guards. Deliberately separate
32
+ # from fm_val: hub artifacts (epic.md, stories/*.md) stay bounded to their first block.
33
+ link_val() {
34
+ _v="$(fm_val "$1" "$2")"
35
+ [ -n "$_v" ] || _v="$(sed -nE "s/^$1:[[:space:]]*(.*)\$/\1/p" "$2" 2>/dev/null | head -1 | tr -d '\r' | sed -E 's/[[:space:]]+$//')"
36
+ printf '%s' "$_v"
37
+ }
38
+
39
+ # Resolve link.md's `product-repo` to a path in THIS checkout. An ABSOLUTE value is used as-is. A
40
+ # RELATIVE value is written relative to the link.md's own directory (specs/<story>/) — the canonical
41
+ # form — but contract-check historically read it from the repo root, so a link.md authored against that
42
+ # reading still resolves: prefer the canonical join, fall back to the root-relative one when only it
43
+ # exists. All four gates share this verbatim, so a value one gate can reach is reachable from every
44
+ # gate (issue #149). An unexpanded ~ or $VAR is returned untouched, so it fails the reachability test
45
+ # loudly instead of being joined into a nonsense path.
46
+ resolve_product() {
47
+ case "$1" in
48
+ '') return ;;
49
+ /*|'~'*|'$'*) printf '%s' "$1" ;;
50
+ *) if [ -d "specs/$2/$1" ] || [ ! -d "$1" ]; then printf 'specs/%s/%s' "$2" "$1"; else printf '%s' "$1"; fi ;;
51
+ esac
52
+ }
23
53
 
24
54
  # Thread ROOT of an epic: walk `parent:` to the genesis (no parent). COMPUTED — never trusts the
25
55
  # denormalized `thread:` cache, so a missing/wrong cache cannot bypass the freeze. Cycle-safe.
@@ -65,18 +95,20 @@ while IFS= read -r sha; do
65
95
  [ -z "$sha" ] && continue
66
96
  short="$(git log -1 --format=%h "$sha")"
67
97
  subject="$(git log -1 --format=%s "$sha")"
68
- if printf '%s' "$subject" | grep -qE "^(${EXEMPT})(\([a-z0-9._-]+\))?!?: "; then
98
+ task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
99
+ # The type exemption waives the REQUIREMENT for an owning epic, not the VALIDITY of one that is
100
+ # claimed — same rule spec-link applies. Exempting on the subject alone would let `chore(x): …` plus
101
+ # a Task trailer ship a change onto a thread that is frozen for open hotfix debt.
102
+ if printf '%s' "$subject" | grep -qE "^(${EXEMPT})(\([a-z0-9._-]+\))?!?: " && [ -z "$task" ]; then
69
103
  continue
70
104
  fi
71
- task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
72
105
  printf '%s' "$task" | grep -qE '.+-T[0-9]+$' || continue
73
106
  story="$(printf '%s' "$task" | sed -E 's/-T[0-9]+$//')"
74
107
  link="specs/${story}/link.md"
75
108
  [ -f "$link" ] || continue
76
- product_rel="$(fm_val product-repo "$link")"
77
- epic="$(fm_val epic "$link")"
78
- # product-repo is relative to the link.md's directory (specs/<story>/), so join it there.
79
- prod="specs/${story}/${product_rel}"
109
+ product_rel="$(link_val product-repo "$link")"
110
+ epic="$(link_val epic "$link")"
111
+ prod="$(resolve_product "$product_rel" "$story")"
80
112
  ep_dir="${prod}/epics/${epic}"
81
113
  if [ -z "$product_rel" ] || [ ! -d "$ep_dir" ]; then
82
114
  echo "PASS [reconcile-debt]: ${short} ${task} -> ${epic} (product repo not reachable — debt check deferred)."
@@ -3,8 +3,11 @@
3
3
  # Every NON-MAINTENANCE commit must link a real story/spec: it must carry a
4
4
  # `Task: <story>-<task>` trailer whose <story> resolves to a specs/<story>/link.md.
5
5
  # Maintenance commits (ci/chore/build/test) are EXEMPT — CI wiring, dependency bumps,
6
- # and test-infra changes legitimately link no story. Checked per commit (not aggregated
7
- # across the range), so the report names every offending commit.
6
+ # and test-infra changes legitimately link no story. The exemption covers the ABSENCE of
7
+ # a link, never a BROKEN one: a maintenance commit that carries a Task trailer is still
8
+ # resolved, so `chore: x` + `Task: EP-ghost-S01-T01` fails exactly like any other commit
9
+ # claiming a story that does not exist. Checked per commit (not aggregated across the
10
+ # range), so the report names every offending commit.
8
11
  set -euo pipefail
9
12
 
10
13
  BASE="${1:-${SDLC_BASE:-origin/main}}"
@@ -32,11 +35,20 @@ while IFS= read -r sha; do
32
35
  [ -z "$sha" ] && continue
33
36
  short="$(git log -1 --format=%h "$sha")"
34
37
  subject="$(git log -1 --format=%s "$sha")"
38
+ task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
39
+ exempt=0
40
+ note=''
35
41
  if printf '%s' "$subject" | grep -qE "^(${EXEMPT})(\([a-z0-9._-]+\))?!?: "; then
36
- echo "PASS [spec-link]: ${short} '${subject}' — maintenance commit (exempt)"
42
+ exempt=1
43
+ note=' (maintenance commit, trailer resolved anyway)'
44
+ fi
45
+ # The type exemption waives the REQUIREMENT for a link, not the VALIDITY of one that is claimed.
46
+ # Exempting on the subject alone left an unlinked `chore:` and a `chore:` naming a story that does
47
+ # not exist indistinguishable — both PASSed, so the trailer was decorative on every exempt commit.
48
+ if [ "$exempt" = 1 ] && [ -z "$task" ]; then
49
+ echo "PASS [spec-link]: ${short} '${subject}' — maintenance commit, no Task trailer (exempt)"
37
50
  continue
38
51
  fi
39
- task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
40
52
  if [ -z "$task" ]; then
41
53
  echo "FAIL [spec-link]: ${short} '${subject}' has no 'Task:' trailer"
42
54
  rc=1
@@ -52,7 +64,7 @@ while IFS= read -r sha; do
52
64
  fi
53
65
  story="$(printf '%s' "$task" | sed -E 's/-T[0-9]+$//')"
54
66
  if [ -f "specs/${story}/link.md" ]; then
55
- echo "PASS [spec-link]: ${short} ${task} -> specs/${story}/link.md"
67
+ echo "PASS [spec-link]: ${short} ${task} -> specs/${story}/link.md${note}"
56
68
  else
57
69
  echo "FAIL [spec-link]: ${short} ${task} references specs/${story}/ but link.md is missing."
58
70
  rc=1