yadflow 3.15.0 → 3.15.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 CHANGED
@@ -1,3 +1,12 @@
1
+ ## [3.15.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.0...v3.15.1) (2026-08-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **checks:** close the silent-PASS holes the [#161](https://github.com/abdelrahmannasr/yadflow/issues/161) fix left open ([0f180ab](https://github.com/abdelrahmannasr/yadflow/commit/0f180abc72618fcd793ef6717fe1e128aa5ccfa5))
7
+ * **checks:** pin every changed contract slice, not just the first ([a79a946](https://github.com/abdelrahmannasr/yadflow/commit/a79a946aa37c08615741082bc638d006f7e80b76)), closes [#161](https://github.com/abdelrahmannasr/yadflow/issues/161)
8
+ * **checks:** read hub.json and the contract lock across line breaks ([43a618d](https://github.com/abdelrahmannasr/yadflow/commit/43a618d0822ed8576b9a164889bc53a52bbdf713)), closes [#161](https://github.com/abdelrahmannasr/yadflow/issues/161)
9
+
1
10
  # [3.15.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.14.0...v3.15.0) (2026-08-10)
2
11
 
3
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.15.0",
3
+ "version": "3.15.1",
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",
@@ -72,8 +72,9 @@ the approver(s) + date. Only a `verified: true` backfill spec counts as real.
72
72
 
73
73
  ### Step 5 — `gate` (block changes per touched feature)
74
74
  `bash checks/backfill-check.sh <base>` blocks a change that touches a feature being backfilled until
75
- that feature's spec is `verified: true`. It is **per touched feature** a change touching feature A is
76
- not blocked by an unverified feature B. Forward-spec'd features (those with their own `specs/<story>/`)
75
+ that feature's spec is `verified: true`. `<base>` is optional and resolves like every other gate's
76
+ see "Resolving `<base>`" in `../yad-checks/references/check-gates.md`. It is **per touched feature**
77
+ a change touching feature A is not blocked by an unverified feature B. Forward-spec'd features (those with their own `specs/<story>/`)
77
78
  are not this gate's concern.
78
79
 
79
80
  ### Step 6 — `promote` (flip a stub epic → real, once its spec is approved)
@@ -5,13 +5,37 @@
5
5
  # forward-spec'd (their own specs/<story>/) or not yet being backfilled are not this gate's concern.
6
6
  set -euo pipefail
7
7
 
8
- BASE="${1:-${SDLC_BASE:-origin/main}}"
8
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
9
+ # --- is duplicated, not sourced) ---
10
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
11
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
12
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
13
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
14
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
15
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
16
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
17
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
18
+ # command substitution in an assignment aborts the script.
19
+ resolve_base() {
20
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
21
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
22
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
23
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
24
+ case "$_c" in ''|origin/) continue ;; esac
25
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
26
+ done
27
+ printf '%s' origin/main
28
+ }
29
+
30
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
31
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [backfill]: no base given — diffing against '${BASE}'."
9
32
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
10
33
  echo "FAIL [backfill]: base ref '${BASE}' not found — fetch full history / check the base branch."
11
34
  exit 1
12
35
  fi
13
36
 
14
- changed="$(git diff --name-only "${BASE}..HEAD")"
37
+ # quotePath off so a non-ASCII feature directory still matches src/<feature>/ below.
38
+ changed="$(git -c core.quotePath=false diff --name-only "${BASE}..HEAD")"
15
39
  # Feature = a directory under src/ (src/<feature>/...). Top-level src/*.js files are deliberately NOT
16
40
  # gated here (they belong to no single feature); only src/<feature>/ changes are checked.
17
41
  feats="$(printf '%s\n' "$changed" | sed -nE 's#^src/([^/]+)/.*#\1#p' | sort -u)"
@@ -15,6 +15,7 @@ in CI on every PR/MR and must pass before merge (build plan §C). Each is a smal
15
15
  `specs/<story>/contracts/`) without a `Contract-Change: yes` trailer **and** an updated, re-locked
16
16
  contract upstream, it **FAILS and routes back to the architecture gate**. The shared surface is
17
17
  never widened from inside a code repo (Phase 2 contract representation: delimited block + SHA-256 lock).
18
+ Every story whose slice the diff touches is checked, not just the first — see `references/check-gates.md`.
18
19
  3. **build/test/lint** — standard quality stage; tests must actually exercise new behavior, not just pass.
19
20
  The CI job sets `YAD_TEST_MAX_WORKERS` (default `2`); the gate caps jest/vitest test concurrency at
20
21
  that and is a no-op for other runners (see `references/check-gates.md`).
@@ -141,6 +142,10 @@ bash checks/spec-link.sh "<base>"
141
142
  bash checks/contract-check.sh "<base>"
142
143
  bash checks/build-test-lint.sh
143
144
  ```
145
+ `<base>` is optional for the gates that take one (`build-test-lint` takes none) — omitted, the gate
146
+ resolves the trunk (configured `default_branch`, else `origin/HEAD`, else `origin/main`) and prints
147
+ the base it chose; pass it (or `SDLC_BASE`) when the PR/MR targets another branch.
148
+
144
149
  A non-zero exit is a FAIL. Summarize which gates passed and, for any failure, the exact remediation
145
150
  (spec-link: add the `Task:` trailer / spec; contract-check: route back to the architecture gate and
146
151
  re-lock the contract; build/test/lint: fix the failing lint/test).
@@ -19,6 +19,27 @@ repo uses. Each reads conventions established by earlier steps — it invents no
19
19
  | pr-title | the PR/MR title (from the CI event payload) | `yad-pr-template` (`config.yaml build.pr_title_style`) |
20
20
  | pr-template | the PR/MR body (from the CI event payload) | `yad-pr-template` (the committed PR/MR template) |
21
21
 
22
+ ## Resolving `<base>` (every gate that takes one)
23
+
24
+ The `<base>` argument is **optional**. The order is: the **argument**, else `SDLC_BASE`, else the
25
+ **configured** `default_branch` (`.sdlc/hub.json`, or `SDLC_HUB_CONFIG`), else the remote's
26
+ **published default branch** (`git symbolic-ref refs/remotes/origin/HEAD`), else `origin/main` —
27
+ the same order the CLI resolves (`cli/hubcommit.mjs`, `cli/repo.mjs`), so a gate never diffs a
28
+ different range than the `yad` commands run beside it. Each candidate must actually **resolve**
29
+ before it is used, so a *dangling* `origin/HEAD` (trunk renamed, the old remote-tracking ref pruned)
30
+ falls through instead of failing the gate on a fully-fetched repo. CI always passes the base
31
+ explicitly (`origin/<PR base>` / `origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME`), so this governs
32
+ local runs.
33
+
34
+ Hardcoding `origin/main` diffed the wrong range on a repo whose trunk is `develop`/`master` — either
35
+ failing closed for the wrong reason, or, where a stale `main` still existed, silently diffing a range
36
+ that both mis-reports the surface and drags unrelated stories into contract-check's per-story fidelity
37
+ pass (issue #161). An auto-resolved base is **printed as a note**, so the range a local run gated is
38
+ never implicit. Like the `product-repo` block below, this one is duplicated verbatim across the
39
+ scripts (they are standalone by design) and pinned byte-identical by a test — which covers every
40
+ base-taking gate, including `yad-backfill`'s `backfill-check.sh` and the installed copies this repo's
41
+ own CI runs, plus an assertion that each one actually *assigns* `BASE` from it.
42
+
22
43
  ## 1. spec-link (`templates/checks/spec-link.sh`)
23
44
 
24
45
  - Checks every non-merge commit in `<base>..HEAD` **per commit** (not aggregated across the range),
@@ -38,11 +59,13 @@ repo uses. Each reads conventions established by earlier steps — it invents no
38
59
  - An empty range (no non-merge commits) **PASSes**.
39
60
  - Portable across bash 3.2 (macOS) and 4+ (no `mapfile`).
40
61
  - **Fails closed** when `<base>` can't be resolved (so a shallow clone / wrong base never PASSes blind).
62
+ `<base>` is optional — see [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
41
63
 
42
64
  ## 2. contract-check (`templates/checks/contract-check.sh`)
43
65
 
44
66
  - **Fails closed** if `<base>` can't be resolved — an undiffable range must never report "no surface
45
- change" and silently green-light a bypass.
67
+ change" and silently green-light a bypass. `<base>` is optional — see
68
+ [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
46
69
  - Computes the changed files in `<base>..HEAD`.
47
70
  - If **nothing** under `specs/*/contracts/**` changed → **PASS** (normal implementation only *consumes*
48
71
  the contract).
@@ -52,6 +75,19 @@ repo uses. Each reads conventions established by earlier steps — it invents no
52
75
  require `link.md`'s pinned `contract-lock` hash to match the product repo's current
53
76
  `contract-lock.json`. A claimed change that still pins the **old** lock **FAILS** — re-run
54
77
  `yad-spec` so the slice matches the re-locked contract.
78
+ - The fidelity check runs for **every story whose slice the diff touches**, and **aggregates**: each
79
+ story reports (matched / stale / deferred), and any stale pin fails the gate. `git diff
80
+ --name-only` is path-sorted, so reading one story off the first changed path validated whichever
81
+ story sorted first and left the rest unpinned — a later story pinning a stale hash passed, and a
82
+ first story with no `link.md` deferred the whole check before the stale one was ever read
83
+ (issue #161). One clean-or-deferred story never masks another's stale pin, the same rule spec-link
84
+ applies per commit.
85
+ - A lock the gate can **read but not parse** (truncated, half-written, or a changed schema — no
86
+ `"hash": "sha256:…"`) **FAILS**. It used to short-circuit the comparison into the "hash matches"
87
+ note, i.e. the gate affirmatively reported a match it never made.
88
+ - The changed-file list is read with `core.quotePath=false`. With git's default, a path holding a
89
+ non-ASCII byte comes back quoted and octal-escaped, so a slice like `specs/EP-démo-S01/contracts/…`
90
+ never matched the surface pattern and an undeclared widening passed untouched.
55
91
  - This enforces the Phase 2 rule: the shared surface is owned upstream and is never widened from inside
56
92
  a code repo. The hash recipe is in `../yad-architecture/references/contract-format.md`.
57
93
 
@@ -129,6 +165,7 @@ non-merge commit in `<base>..HEAD`:
129
165
  - **Profiles** (`--profile code|hub`): the subject rule is identical on both; the gate never requires
130
166
  the `Task:` trailer (spec-link owns that on code repos; hub commits are not task-scoped).
131
167
  - **Fails closed** when `<base>` can't be resolved.
168
+ `<base>` is optional — see [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
132
169
 
133
170
  ## 6. pr-title (`templates/checks/pr-title.sh`)
134
171
 
@@ -319,9 +356,13 @@ line). Code repos run the same three with `--profile code` inside the main `yad-
319
356
 
320
357
  ## Running by hand (Phase 3 is manual)
321
358
 
322
- From inside the code repo, against the PR/MR base (e.g. `master`):
359
+ From inside the code repo, against the PR/MR base (e.g. `master`). For the gates that take one, the
360
+ base argument is optional — omit it and the gate resolves the trunk in the order above (configured
361
+ `default_branch`, else `origin/HEAD`, else `origin/main`), printing the base it chose; pass it (or
362
+ `export SDLC_BASE=…`) whenever the PR targets something else. `build-test-lint` takes no base at all.
323
363
 
324
364
  ```bash
365
+ bash checks/spec-link.sh # -> diffs the resolved trunk, and says which one
325
366
  bash checks/spec-link.sh master
326
367
  bash checks/contract-check.sh master
327
368
  bash checks/build-test-lint.sh
@@ -25,7 +25,30 @@ while [ $# -gt 0 ]; do
25
25
  done
26
26
  case "$PROFILE" in code|hub) ;; *) echo "FAIL [commit-message]: unknown --profile '$PROFILE' (code|hub)."; exit 1 ;; esac
27
27
 
28
- BASE="${ARGS[0]:-${SDLC_BASE:-origin/main}}"
28
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
29
+ # --- is duplicated, not sourced) ---
30
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
31
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
32
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
33
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
34
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
35
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
36
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
37
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
38
+ # command substitution in an assignment aborts the script.
39
+ resolve_base() {
40
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
41
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
42
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
43
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
44
+ case "$_c" in ''|origin/) continue ;; esac
45
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
46
+ done
47
+ printf '%s' origin/main
48
+ }
49
+
50
+ BASE="${ARGS[0]:-${SDLC_BASE:-$(resolve_base)}}"
51
+ [ -n "${ARGS[0]:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [commit-message]: no base given — diffing against '${BASE}'."
29
52
 
30
53
  # Fail closed if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
31
54
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
@@ -8,7 +8,30 @@
8
8
  # architecture gate. Normal implementation that only CONSUMES the contract passes untouched.
9
9
  set -euo pipefail
10
10
 
11
- BASE="${1:-${SDLC_BASE:-origin/main}}"
11
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
12
+ # --- is duplicated, not sourced) ---
13
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
14
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
15
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
16
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
17
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
18
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
19
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
20
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
21
+ # command substitution in an assignment aborts the script.
22
+ resolve_base() {
23
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
24
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
25
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
26
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
27
+ case "$_c" in ''|origin/) continue ;; esac
28
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
29
+ done
30
+ printf '%s' origin/main
31
+ }
32
+
33
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
34
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [contract-check]: no base given — diffing against '${BASE}'."
12
35
 
13
36
  # Fail CLOSED if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
14
37
  # Never let an undiffable range silently report "no surface change" — that would green-light a bypass.
@@ -18,7 +41,10 @@ if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
18
41
  fi
19
42
  RANGE="${BASE}..HEAD"
20
43
 
21
- changed="$(git diff --name-only "$RANGE")"
44
+ # core.quotePath=false: with the default ON, git wraps any path holding a non-ASCII byte in quotes
45
+ # and octal-escapes it, so a slice like specs/EP-démo-S01/contracts/api.md never matches the pattern
46
+ # below — the surface change would be invisible to the gate it exists to stop.
47
+ changed="$(git -c core.quotePath=false diff --name-only "$RANGE")"
22
48
  surface="$(printf '%s\n' "$changed" | grep -E '^specs/[^/]+/contracts/' || true)"
23
49
 
24
50
  if [ -z "$surface" ]; then
@@ -72,9 +98,22 @@ resolve_product() {
72
98
 
73
99
  # Fidelity check (best-effort): when the product repo is reachable, the story's link.md must pin the
74
100
  # CURRENT product lock — proof the contract was actually updated/re-locked upstream, not just flagged.
75
- story="$(printf '%s\n' "$surface" | head -1 | sed -E 's#^specs/([^/]+)/contracts/.*#\1#')"
76
- link="specs/${story}/link.md"
77
- if [ -f "$link" ]; then
101
+ #
102
+ # Checked for EVERY story whose slice the diff touches, not just the first one. `git diff --name-only`
103
+ # is path-sorted, so reading a single story off `head -1` validated whichever story sorted first and
104
+ # left the rest unpinned: a second story pinning a STALE hash passed, and a first story with no
105
+ # link.md deferred the whole check before the stale one was ever read (issue #161). Failures are
106
+ # AGGREGATED — every story reports, so one clean-or-deferred story never masks another's stale pin
107
+ # (the same rule spec-link applies per commit).
108
+ stories="$(printf '%s\n' "$surface" | sed -E 's#^specs/([^/]+)/contracts/.*#\1#' | sort -u)"
109
+ rc=0
110
+ while IFS= read -r story; do
111
+ [ -z "$story" ] && continue
112
+ link="specs/${story}/link.md"
113
+ if [ ! -f "$link" ]; then
114
+ echo "note [contract-check]: no ${link} — fidelity check deferred (spec-link gates the link itself)."
115
+ continue
116
+ fi
78
117
  product_rel="$(link_val product-repo "$link")"
79
118
  pinned="$(printf '%s' "$(link_val contract-lock "$link")" | sed -E 's/^sha256:([0-9a-f]+).*$/\1/')"
80
119
  epic="$(printf '%s' "$story" | sed -E 's/-S[0-9]+$//')" # story EP-<slug>-S0N -> epic EP-<slug>
@@ -85,20 +124,40 @@ if [ -f "$link" ]; then
85
124
  lock=""
86
125
  [ -n "$prod" ] && lock="${prod}/epics/${epic}/.sdlc/contract-lock.json"
87
126
  if [ -n "$product_rel" ] && [ -f "$lock" ]; then
88
- current="$(sed -nE 's/.*"hash":[[:space:]]*"sha256:([0-9a-f]+)".*/\1/p' "$lock" | head -1)"
89
- if [ -n "$current" ] && [ "$current" != "$pinned" ]; then
127
+ # Newline-tolerant, first-match: `"hash":` and its value may legally sit on separate lines, and an
128
+ # unparseable lock is a FAIL below so a formatting choice must not become a gate failure.
129
+ # `|| current=""` is load-bearing: under `pipefail` a no-match grep fails the whole pipeline, which
130
+ # under `set -e` would abort the gate instead of reaching the unparseable-lock FAIL below.
131
+ current="$(tr '\n' ' ' < "$lock" | grep -oE '"hash"[[:space:]]*:[[:space:]]*"sha256:[0-9a-f]+"' | head -1 | sed -E 's/.*sha256:([0-9a-f]+)"$/\1/')" || current=""
132
+ # A lock we can READ but cannot PARSE proves nothing, and an empty `current` used to short-circuit
133
+ # the comparison below straight into the "hash matches" note — the gate affirmatively reporting a
134
+ # match it never made. Fail closed instead: a truncated, half-written or schema-changed lock is a
135
+ # broken lock, and a Contract-Change is being claimed against it.
136
+ if [ -z "$current" ]; then
137
+ echo "FAIL [contract-check]: ${lock} has no readable \"hash\": \"sha256:…\" value —"
138
+ echo " the lock cannot prove ${link}'s pin. Re-lock the contract upstream (yad-architecture Step 5)."
139
+ rc=1
140
+ continue
141
+ fi
142
+ if [ "$current" != "$pinned" ]; then
90
143
  echo "FAIL [contract-check]: Contract-Change claimed, but ${link} still pins ${pinned:0:12}…"
91
144
  echo " while the product lock is ${current:0:12}… — re-run yad-spec so the slice matches the re-locked contract."
92
- exit 1
145
+ rc=1
146
+ continue
93
147
  fi
94
- echo "note [contract-check]: link.md hash matches the product lock (${current:0:12}…)."
148
+ echo "note [contract-check]: ${link} hash matches the product lock (${current:0:12}…)."
95
149
  else
96
150
  # Say so. A skipped fidelity check used to be indistinguishable from a passed one, which is how a
97
151
  # mis-resolved product-repo could turn a stale-pin FAIL into a silent PASS (issue #149).
98
152
  echo "note [contract-check]: product lock not reachable at ${lock:-<no product-repo in link.md>} — fidelity check deferred."
99
153
  fi
100
- else
101
- echo "note [contract-check]: no ${link} — fidelity check deferred (spec-link gates the link itself)."
154
+ done <<EOF
155
+ $stories
156
+ EOF
157
+
158
+ if [ "$rc" != 0 ]; then
159
+ echo "FAIL [contract-check]: a changed slice pins a stale contract lock (see above) — the surface was not re-locked upstream for every story in this diff."
160
+ exit 1
102
161
  fi
103
162
 
104
163
  echo "PASS [contract-check]: surface change accompanied by Contract-Change: yes (and an updated contract)."
@@ -11,7 +11,30 @@
11
11
  # Fails CLOSED on an unresolvable base.
12
12
  set -euo pipefail
13
13
 
14
- BASE="${1:-${SDLC_BASE:-origin/main}}"
14
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
15
+ # --- is duplicated, not sourced) ---
16
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
17
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
18
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
19
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
20
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
21
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
22
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
23
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
24
+ # command substitution in an assignment aborts the script.
25
+ resolve_base() {
26
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
27
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
28
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
29
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
30
+ case "$_c" in ''|origin/) continue ;; esac
31
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
32
+ done
33
+ printf '%s' origin/main
34
+ }
35
+
36
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
37
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [epic-open]: no base given — diffing against '${BASE}'."
15
38
 
16
39
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
17
40
  echo "FAIL [epic-open]: base ref '${BASE}' not found — fetch full history / check the base branch."
@@ -32,7 +32,30 @@ if [ ! -f "$HUB" ] || ! grep -Eq '"(bridge_enabled|bridge)"[[:space:]]*:[[:space
32
32
  exit 0
33
33
  fi
34
34
 
35
- BASE="${1:-${SDLC_BASE:-origin/main}}"
35
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
36
+ # --- is duplicated, not sourced) ---
37
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
38
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
39
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
40
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
41
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
42
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
43
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
44
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
45
+ # command substitution in an assignment aborts the script.
46
+ resolve_base() {
47
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
48
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
49
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
50
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
51
+ case "$_c" in ''|origin/) continue ;; esac
52
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
53
+ done
54
+ printf '%s' origin/main
55
+ }
56
+
57
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
58
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [ledger-guard]: no base given — diffing against '${BASE}'."
36
59
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
37
60
  echo "FAIL [ledger-guard]: base ref '${BASE}' not found — fetch full history / check the base branch."
38
61
  exit 1
@@ -10,7 +10,30 @@
10
10
  # best-effort: the commit PASSes with a note (spec-link already proved the story link).
11
11
  set -euo pipefail
12
12
 
13
- BASE="${1:-${SDLC_BASE:-origin/main}}"
13
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
14
+ # --- is duplicated, not sourced) ---
15
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
16
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
17
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
18
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
19
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
20
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
21
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
22
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
23
+ # command substitution in an assignment aborts the script.
24
+ resolve_base() {
25
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
26
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
27
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
28
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
29
+ case "$_c" in ''|origin/) continue ;; esac
30
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
31
+ done
32
+ printf '%s' origin/main
33
+ }
34
+
35
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
36
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [lineage-check]: no base given — diffing against '${BASE}'."
14
37
 
15
38
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
16
39
  echo "FAIL [lineage-check]: base ref '${BASE}' not found — fetch full history / check the base branch."
@@ -10,7 +10,30 @@
10
10
  # ci/chore/build/test exempt. Fails CLOSED on an unresolvable base.
11
11
  set -euo pipefail
12
12
 
13
- BASE="${1:-${SDLC_BASE:-origin/main}}"
13
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
14
+ # --- is duplicated, not sourced) ---
15
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
16
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
17
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
18
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
19
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
20
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
21
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
22
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
23
+ # command substitution in an assignment aborts the script.
24
+ resolve_base() {
25
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
26
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
27
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
28
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
29
+ case "$_c" in ''|origin/) continue ;; esac
30
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
31
+ done
32
+ printf '%s' origin/main
33
+ }
34
+
35
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
36
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [reconcile-debt]: no base given — diffing against '${BASE}'."
14
37
 
15
38
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
16
39
  echo "FAIL [reconcile-debt]: base ref '${BASE}' not found — fetch full history / check the base branch."
@@ -10,7 +10,30 @@
10
10
  # range), so the report names every offending commit.
11
11
  set -euo pipefail
12
12
 
13
- BASE="${1:-${SDLC_BASE:-origin/main}}"
13
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
14
+ # --- is duplicated, not sourced) ---
15
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
16
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
17
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
18
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
19
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
20
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
21
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
22
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
23
+ # command substitution in an assignment aborts the script.
24
+ resolve_base() {
25
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
26
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
27
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
28
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
29
+ case "$_c" in ''|origin/) continue ;; esac
30
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
31
+ done
32
+ printf '%s' origin/main
33
+ }
34
+
35
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
36
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [spec-link]: no base given — diffing against '${BASE}'."
14
37
 
15
38
  # Fail closed if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
16
39
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
@@ -26,7 +26,30 @@
26
26
  # GITLAB_TOKEN (or SDLC_API_TOKEN) with read_api scope; see the pipeline fragment header.
27
27
  set -euo pipefail
28
28
 
29
- BASE="${1:-${SDLC_BASE:-origin/main}}"
29
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
30
+ # --- is duplicated, not sourced) ---
31
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
32
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
33
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
34
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
35
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
36
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
37
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
38
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
39
+ # command substitution in an assignment aborts the script.
40
+ resolve_base() {
41
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
42
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
43
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
44
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
45
+ case "$_c" in ''|origin/) continue ;; esac
46
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
47
+ done
48
+ printf '%s' origin/main
49
+ }
50
+
51
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
52
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [verified-commits]: no base given — diffing against '${BASE}'."
30
53
 
31
54
  # Fail closed if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
32
55
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
@@ -2382,9 +2382,9 @@
2382
2382
  "license": "MIT"
2383
2383
  },
2384
2384
  "node_modules/js-yaml": {
2385
- "version": "4.3.0",
2386
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
2387
- "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
2385
+ "version": "4.3.1",
2386
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
2387
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
2388
2388
  "dev": true,
2389
2389
  "funding": [
2390
2390
  {