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.
- package/CHANGELOG.md +30 -0
- package/bin/yad.mjs +26 -6
- package/cli/checkpoint.mjs +59 -1
- package/cli/doctor.mjs +113 -1
- package/cli/epic-state.mjs +14 -6
- package/cli/gate.mjs +191 -25
- package/cli/ledger.mjs +26 -0
- package/cli/platform.mjs +80 -0
- package/package.json +3 -3
- package/skills/yad-architecture/SKILL.md +15 -3
- package/skills/yad-architecture/references/contract-format.md +4 -1
- package/skills/yad-checks/references/check-gates.md +18 -1
- package/skills/yad-checks/templates/checks/contract-check.sh +46 -3
- package/skills/yad-checks/templates/checks/epic-open.sh +41 -9
- package/skills/yad-checks/templates/checks/lineage-check.sh +38 -9
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +39 -7
- package/skills/yad-checks/templates/checks/spec-link.sh +17 -5
- package/skills/yad-docs/templates/app/package-lock.json +7 -519
- package/skills/yad-engineer-review/references/ship-and-record.md +20 -0
- package/skills/yad-epic/references/state-schema.md +13 -0
- package/skills/yad-hub-bridge/references/bridge.md +50 -5
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +6 -3
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +13 -2
- package/skills/yad-review-gate/SKILL.md +13 -3
- package/skills/yad-review-gate/references/gating.md +7 -3
- package/skills/yad-spec/references/spec-handoff.md +12 -2
|
@@ -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="$(
|
|
47
|
-
pinned="$(sed -
|
|
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
|
-
|
|
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
|
-
#
|
|
24
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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="$(
|
|
66
|
-
epic="$(
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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="$(
|
|
55
|
-
epic="$(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="$(
|
|
77
|
-
epic="$(
|
|
78
|
-
|
|
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.
|
|
7
|
-
#
|
|
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
|
-
|
|
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
|