yadflow 3.13.0 → 3.13.2

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/cli/platform.mjs CHANGED
@@ -298,6 +298,86 @@ export function readPr(platform, n, opts = {}) {
298
298
  return platform === 'gitlab' ? readPrGitLab(n, opts) : readPrGitHub(n, opts);
299
299
  }
300
300
 
301
+ // ---- find the PR/MR for a branch ----------------------------------------------------------------
302
+ // The review PR/MR opened for `review/EP-<slug>/<artifact>`, by HEAD/source branch. Under the bridge
303
+ // the ledger records that pointer only at merge (CI is the sole writer), so without this a human has
304
+ // no way to name the review a merged PR belongs to — `gate sync` would just report "no open review PR
305
+ // recorded" for a PR that is sitting merged on the platform (issue #158).
306
+ //
307
+ // State is deliberately UNfiltered: the interesting case is a MERGED PR that never advanced. Newest
308
+ // first, so a re-opened review resolves to its current PR and not a superseded one. Returns
309
+ // { ok, number, url } — never throws; `ok:false` carries the reason.
310
+ export function findPrForBranch(platform, branch, { cwd } = {}) {
311
+ if (!branch) return { ok: false, reason: 'no branch given' };
312
+ if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
313
+ if (platform === 'gitlab') {
314
+ const r = run('glab', ['api', `projects/:id/merge_requests?source_branch=${encodeURIComponent(branch)}&order_by=updated_at&sort=desc&per_page=1`], { cwd });
315
+ if (!r.ok) return { ok: false, reason: r.stderr || 'glab api merge_requests failed' };
316
+ let rows;
317
+ try { rows = JSON.parse(r.stdout); } catch { return { ok: false, reason: 'unreadable glab api response' }; }
318
+ const mr = Array.isArray(rows) ? rows[0] : null;
319
+ if (!mr?.iid) return { ok: false, reason: `no merge request found for source branch ${branch}` };
320
+ return { ok: true, number: Number(mr.iid), url: mr.web_url || null };
321
+ }
322
+ const r = run('gh', ['pr', 'list', '--head', branch, '--state', 'all', '--limit', '1', '--json', 'number,url'], { cwd });
323
+ if (!r.ok) return { ok: false, reason: r.stderr || 'gh pr list failed' };
324
+ let rows;
325
+ try { rows = JSON.parse(r.stdout); } catch { return { ok: false, reason: 'unreadable gh pr list response' }; }
326
+ const pr = Array.isArray(rows) ? rows[0] : null;
327
+ if (!pr?.number) return { ok: false, reason: `no pull request found for head branch ${branch}` };
328
+ return { ok: true, number: Number(pr.number), url: pr.url || null };
329
+ }
330
+
331
+ // The head/source branch of a PR/MR, so a caller can confirm a number a human typed actually belongs
332
+ // to the review it is about to bind approvals to. Returns { ok, branch }; never throws.
333
+ export function prBranch(platform, n, { cwd } = {}) {
334
+ if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
335
+ if (platform === 'gitlab') {
336
+ const r = run('glab', ['api', `projects/:id/merge_requests/${Number(n)}`], { cwd });
337
+ if (!r.ok) return { ok: false, reason: r.stderr || 'glab api merge_request failed' };
338
+ try {
339
+ const mr = JSON.parse(r.stdout);
340
+ return mr?.source_branch ? { ok: true, branch: mr.source_branch } : { ok: false, reason: `MR !${n} has no source_branch` };
341
+ } catch { return { ok: false, reason: 'unreadable glab api response' }; }
342
+ }
343
+ const r = run('gh', ['pr', 'view', String(n), '--json', 'headRefName'], { cwd });
344
+ if (!r.ok) return { ok: false, reason: r.stderr || 'gh pr view failed' };
345
+ try {
346
+ const pr = JSON.parse(r.stdout);
347
+ return pr?.headRefName ? { ok: true, branch: pr.headRefName } : { ok: false, reason: `PR #${n} has no headRefName` };
348
+ } catch { return { ok: false, reason: 'unreadable gh pr view response' }; }
349
+ }
350
+
351
+ // Is `branch` on ORIGIN? `gate open` opens a PR against the review branch but never creates or pushes
352
+ // it — and neither does the platform CLI, since `gh pr create --head <b>` explicitly disables its
353
+ // automatic push. So a branch that exists only locally is just as unusable as one that does not exist
354
+ // at all, and checking locally would wave it through into the opaque platform error this replaces.
355
+ // Returns null when git cannot answer (not a checkout, no origin, network/auth failure) — "unknown"
356
+ // must never read as "missing" and block.
357
+ export function branchExists(cwd, branch) {
358
+ if (!run('git', ['rev-parse', '--git-dir'], { cwd }).ok) return null;
359
+ // This runs synchronously on the `gate open` path, so "cannot ask" has to be FAST — a blocked probe
360
+ // is a hung command, not the intended null. Three separate ways it could block:
361
+ // GIT_TERMINAL_PROMPT=0 — git's own credential prompt (https origins)
362
+ // GIT_SSH_COMMAND — ssh's passphrase / host-key prompts, which git's flag does NOT cover
363
+ // (an unset host key otherwise waits on "Are you sure…?" forever)
364
+ // timeout — anything else that stalls: a black-holed host, a wedged helper
365
+ // A caller's own GIT_SSH_COMMAND wins; we only supply the default.
366
+ const remote = run('git', ['ls-remote', '--exit-code', '--heads', 'origin', branch], {
367
+ cwd,
368
+ timeout: 10_000,
369
+ env: {
370
+ ...process.env,
371
+ GIT_TERMINAL_PROMPT: '0',
372
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new',
373
+ },
374
+ });
375
+ if (remote.ok) return true;
376
+ // exit 2 is ls-remote's own "no matching ref" — the only definite negative. Anything else (no
377
+ // remote configured, auth, offline) is a question we could not ask.
378
+ return remote.code === 2 ? false : null;
379
+ }
380
+
301
381
  // ---- create a PR/MR -----------------------------------------------------------------------------
302
382
  // `assignees` = the committer/PR-opener (always set, so the PR is owned by whoever pushed it);
303
383
  // `reviewers` = the scope's reviewers + domain-owners (computed by reviewersForScopes). On GitHub an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.13.0",
3
+ "version": "3.13.2",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -62,8 +62,8 @@
62
62
  ],
63
63
  "devDependencies": {
64
64
  "@eslint/js": "^10.0.1",
65
- "@semantic-release/changelog": "^6.0.3",
66
- "@semantic-release/git": "^10.0.1",
65
+ "@semantic-release/changelog": "^7.0.0",
66
+ "@semantic-release/git": "^11.0.1",
67
67
  "eslint": "^10.5.0",
68
68
  "semantic-release": "^25.0.3"
69
69
  }
@@ -155,16 +155,28 @@ themselves) and write `{project-root}/epics/EP-<slug>/.sdlc/contract-lock.json`:
155
155
  ```
156
156
 
157
157
  Canonicalization (so the hash round-trips): hash the surface region as written between the markers,
158
- LF line endings, no leading/trailing blank-line normalization beyond what is in the file. Recompute
159
- the same way later; if it differs, the contract surface changed. The reference command:
158
+ LF line endings, including the newline that terminates the last surface line, and no leading/trailing
159
+ blank-line normalization beyond what is in the file. Recompute the same way later; if it differs, the
160
+ contract surface changed. The command below **is** the definition — `yad` computes the identical
161
+ digest, so `yad doctor` can verify `contract-lock.json` against the live `contract.md` and FAIL when
162
+ the surface drifted from its lock:
160
163
 
161
164
  ```bash
162
165
  awk '/CONTRACT-SURFACE:BEGIN/{f=1;next} /CONTRACT-SURFACE:END/{f=0} f' \
163
- epics/EP-<slug>/contract.md | shasum -a 256
166
+ epics/EP-<slug>/contract.md | tr -d '\r' | shasum -a 256
164
167
  ```
165
168
 
166
169
  (See `references/contract-format.md` for the altitude rule and the exact hashing recipe.)
167
170
 
171
+ > **Upgrading from a yadflow before this recipe and the CLI agreed.** The CLI used to omit the final
172
+ > newline, so the digest it bound approvals to differed from the one the recipe above wrote into
173
+ > `contract-lock.json` — always, on every surface. Lock files are unaffected (they were written by the
174
+ > recipe and are now verifiable), but **architecture approvals recorded under the old CLI are bound to
175
+ > the old digest and go stale once**. An in-flight `architecture-review` therefore needs re-approval
176
+ > after the upgrade; a step already `done` stays done and is reported by `yad doctor` (see the
177
+ > `…:stale` check) rather than silently re-opened. Nothing else re-binds on its own — that is the point
178
+ > of hash-binding.
179
+
168
180
  ### Step 6 — Advance the authoring step (NOT the gate)
169
181
  In `state.json`: set `architecture.status: "done"`, set `architecture-review.status: "in_review"`, and
170
182
  set `currentStep: "architecture-review"`. Write `state.json`. Do **not** touch `approvals.json` — only
@@ -49,7 +49,10 @@ awk '/CONTRACT-SURFACE:BEGIN/{f=1;next} /CONTRACT-SURFACE:END/{f=0} f' \
49
49
  ```
50
50
 
51
51
  - `awk` emits every line strictly between the two markers (the `next` after BEGIN skips the BEGIN
52
- line; setting `f=0` on END stops before printing END).
52
+ line; setting `f=0` on END stops before printing END), each **terminated by a newline** — so the
53
+ hashed bytes are the surface lines joined by LF **plus a trailing LF**. That trailing byte is part
54
+ of the digest; `yad` computes the identical value (`contractSurfaceHash`, `cli/epic-state.mjs`), so
55
+ the lock file and what the gate binds approvals to are the same number.
53
56
  - `tr -d '\r'` normalizes CRLF line endings to LF before hashing — the same surface must hash
54
57
  identically no matter which platform last saved the file (the CLI normalizes the same way).
55
58
  - `shasum -a 256` (BSD/macOS) or `sha256sum` (GNU/Linux) produce the same hex digest for identical
@@ -26,6 +26,10 @@ repo uses. Each reads conventions established by earlier steps — it invents no
26
26
  - Maintenance commits are **exempt**: a Conventional-Commits subject of type `ci`, `chore`, `build`,
27
27
  or `test` (optional `(scope)` / breaking `!`) **PASSes** without a link — CI wiring, dependency
28
28
  bumps, and test-infra changes legitimately link no story.
29
+ - The exemption waives the **requirement** for a link, never the **validity** of one that is claimed.
30
+ A maintenance commit that *does* carry a `Task:` trailer is resolved like any other: a malformed id
31
+ or a missing `specs/<story>/link.md` **FAILS**. Otherwise the trailer is decorative on exempt
32
+ commits and an unlinked `chore:` is indistinguishable from one naming a story that never existed.
29
33
  - For every other commit, requires a `Task: <story>-<task>` trailer. **FAIL** if absent.
30
34
  - The trailer must be a well-formed `<story>-T<NN>` id. **FAIL** on a malformed trailer (e.g.
31
35
  `EP-demo-S01` with no `-T<NN>`) rather than letting it slip through the suffix-strip.
@@ -168,10 +172,23 @@ After the contract locks and code ships, a change must not mutate a locked artif
168
172
  epic threaded to its parent (`config.yaml` `change:`). These three gates keep that discipline. All three
169
173
  resolve the owning epic the same way: `Task:` trailer → `specs/<story>/link.md` (`epic` + `product-repo`)
170
174
  → the hub epic. All **fail closed** on an unresolvable base; all are **per commit**; `ci|chore|build|test`
171
- commits are exempt. When the **product hub is not reachable** from CI (the usual case for a code-repo
175
+ commits **with no `Task:` trailer** are exempt like spec-link, the exemption waives the requirement
176
+ for an owning epic, never the validity of one that is claimed, so a maintenance subject cannot buy a
177
+ pass past the sealed-epic / orphan-thread / frozen-thread checks. When the **product hub is not reachable** from CI (the usual case for a code-repo
172
178
  PR), each degrades to a **PASS-with-note** — the hub-side check (`yad doctor` / `yad reconcile`) covers
173
179
  that path, and spec-link still proves the story link.
174
180
 
181
+ **Resolving `product-repo` (shared by all four hub-reading gates, contract-check included).** An
182
+ **absolute** value is used as-is; a **relative** value is joined to the `link.md`'s own directory,
183
+ `specs/<story>/`, falling back to a repo-root reading when only that resolves (what contract-check
184
+ historically did, so `link.md` files written for it keep working). The `link.md` itself is read from
185
+ its frontmatter block, falling back to a whole-file scan for a pre-frontmatter one. Every gate applies
186
+ the identical rule — when they disagree, a value one gate can resolve becomes an unreachable path for
187
+ another, and "unreachable" is a PASS-with-note, so the gate silently stops gating (issue #149). Each
188
+ gate now **prints that note**, so a deferred check is never mistaken for a passed one. The block is
189
+ duplicated verbatim across the four scripts (they are standalone by design) and a test asserts the
190
+ four copies stay byte-identical.
191
+
175
192
  - **lineage-check** — reads the hub epic's `kind`/`parent` frontmatter. A `feature` (genesis) epic
176
193
  passes. A `change`/`defect`/`hotfix` epic **FAILS** unless it declares a `parent:` that resolves to a
177
194
  real `epics/<parent>/` in the hub (no orphan threads). This is the "every code change has an owning
@@ -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