yadflow 4.0.0-next.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +699 -0
  2. package/README.md +85 -22
  3. package/bin/commands.mjs +3 -1
  4. package/bin/yad.mjs +183 -29
  5. package/cli/capture.mjs +4 -2
  6. package/cli/checkpoint.mjs +10 -10
  7. package/cli/claims.mjs +4 -4
  8. package/cli/commit.mjs +48 -12
  9. package/cli/docs.mjs +15 -13
  10. package/cli/doctor.mjs +557 -141
  11. package/cli/epic-state.mjs +41 -18
  12. package/cli/errors.mjs +3 -2
  13. package/cli/find-product.mjs +197 -0
  14. package/cli/fold.mjs +4 -4
  15. package/cli/gate-local.mjs +13 -13
  16. package/cli/gate.mjs +111 -103
  17. package/cli/history.mjs +13 -13
  18. package/cli/hook.mjs +201 -30
  19. package/cli/index-command.mjs +6 -6
  20. package/cli/lib.mjs +91 -29
  21. package/cli/manifest.mjs +171 -104
  22. package/cli/migrate.mjs +208 -17
  23. package/cli/mode.mjs +22 -22
  24. package/cli/next.mjs +6 -6
  25. package/cli/openpr.mjs +32 -24
  26. package/cli/people.mjs +22 -10
  27. package/cli/plan.mjs +718 -45
  28. package/cli/platform.mjs +13 -7
  29. package/cli/product-index.mjs +4 -4
  30. package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
  31. package/cli/protection.mjs +19 -9
  32. package/cli/reconcile.mjs +98 -23
  33. package/cli/repo-publish.mjs +22 -16
  34. package/cli/repo.mjs +128 -33
  35. package/cli/report.mjs +4 -2
  36. package/cli/review.mjs +26 -19
  37. package/cli/setup.mjs +105 -36
  38. package/cli/ship.mjs +1 -1
  39. package/cli/skip.mjs +4 -4
  40. package/cli/tidy.mjs +7 -7
  41. package/cli/update-commit.mjs +22 -19
  42. package/cli/workspace.mjs +495 -0
  43. package/package.json +7 -4
  44. package/skills/sdlc/config.yaml +14 -13
  45. package/skills/sdlc/module-help.csv +8 -8
  46. package/skills/yad-analysis/SKILL.md +6 -4
  47. package/skills/yad-architecture/SKILL.md +4 -3
  48. package/skills/yad-backfill/SKILL.md +11 -5
  49. package/skills/yad-backfill/references/backfill.md +33 -3
  50. package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
  51. package/skills/yad-change/SKILL.md +1 -1
  52. package/skills/yad-checks/SKILL.md +48 -27
  53. package/skills/yad-checks/references/check-gates.md +213 -53
  54. package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
  55. package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
  56. package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
  57. package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
  58. package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
  59. package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
  60. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
  61. package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
  62. package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
  63. package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
  64. package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
  65. package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
  66. package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
  67. package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
  68. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
  69. package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
  70. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
  71. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
  72. package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
  73. package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
  74. package/skills/yad-commit/SKILL.md +18 -7
  75. package/skills/yad-connect-design/SKILL.md +1 -1
  76. package/skills/yad-connect-design/references/design-registry.md +1 -1
  77. package/skills/yad-connect-docs/SKILL.md +16 -11
  78. package/skills/yad-connect-docs/references/docs-registry.md +8 -8
  79. package/skills/yad-connect-learning/SKILL.md +1 -1
  80. package/skills/yad-connect-learning/references/learning-registry.md +1 -1
  81. package/skills/yad-connect-repos/SKILL.md +31 -8
  82. package/skills/yad-connect-repos/references/code-context.md +1 -1
  83. package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
  84. package/skills/yad-connect-repos/references/repos-registry.md +1 -1
  85. package/skills/yad-connect-testing/SKILL.md +1 -1
  86. package/skills/yad-connect-testing/references/testing-registry.md +1 -1
  87. package/skills/yad-discovery/SKILL.md +2 -2
  88. package/skills/yad-discovery/references/discovery-schema.md +1 -1
  89. package/skills/yad-docs/SKILL.md +1 -1
  90. package/skills/yad-docs/references/data-mapping.md +1 -1
  91. package/skills/yad-docs/templates/app/package-lock.json +8 -8
  92. package/skills/yad-docs/templates/app/package.json +1 -1
  93. package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
  94. package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
  95. package/skills/yad-engineer-review/SKILL.md +2 -2
  96. package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
  97. package/skills/yad-epic/SKILL.md +6 -4
  98. package/skills/yad-epic/references/state-schema.md +13 -12
  99. package/skills/yad-open-pr/SKILL.md +1 -1
  100. package/skills/yad-pair-review/SKILL.md +5 -5
  101. package/skills/yad-pair-review/references/session-state.md +2 -2
  102. package/skills/yad-pr-template/SKILL.md +14 -12
  103. package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
  104. package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
  105. package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
  106. package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
  107. package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
  108. package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
  109. package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
  110. package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
  111. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
  112. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
  113. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
  114. package/skills/yad-report/SKILL.md +1 -1
  115. package/skills/yad-review-companion/SKILL.md +3 -3
  116. package/skills/yad-review-gate/SKILL.md +22 -23
  117. package/skills/yad-review-gate/references/gating.md +13 -13
  118. package/skills/yad-run/SKILL.md +2 -2
  119. package/skills/yad-run/references/run-loop.md +2 -2
  120. package/skills/yad-spec/SKILL.md +2 -1
  121. package/skills/yad-spec/references/spec-handoff.md +19 -10
  122. package/skills/yad-stories/SKILL.md +2 -2
  123. package/skills/yad-sync-repos/SKILL.md +19 -3
  124. package/skills/yad-test-cases/SKILL.md +2 -2
  125. package/skills/yad-ui/SKILL.md +2 -2
  126. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
  127. package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
  128. package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
@@ -7,6 +7,30 @@
7
7
  # (link.md's pinned hash must match the product lock). Otherwise FAIL and route back to the
8
8
  # architecture gate. Normal implementation that only CONSUMES the contract passes untouched.
9
9
  set -euo pipefail
10
+
11
+ # --- shared Product settings file (byte-identical across the gates; they are standalone by design, so
12
+ # --- it is duplicated, not sourced) ---
13
+ # The Product's settings live under two names until v5: `.sdlc/product.json`, read first from 4.0, and
14
+ # `.sdlc/hub.json`, which an older yadflow wrote and its gates read. The environment may name the file
15
+ # instead: SDLC_PRODUCT_CONFIG, or the older SDLC_HUB_CONFIG. Two names that say different things are
16
+ # never settled by picking one — the CLI refuses the same way (YAD-STATE-008) — so the gate FAILS and
17
+ # names both. Said on stderr and returned as a failure: this runs inside `$(...)`, where an `exit` would
18
+ # only leave the subshell. `cmp -s` is byte for byte, like the CLI's own comparison.
19
+ product_config() {
20
+ if [ -n "${SDLC_PRODUCT_CONFIG:-}" ] && [ -n "${SDLC_HUB_CONFIG:-}" ] \
21
+ && [ "$SDLC_PRODUCT_CONFIG" != "$SDLC_HUB_CONFIG" ] && ! cmp -s "$SDLC_PRODUCT_CONFIG" "$SDLC_HUB_CONFIG"; then
22
+ echo "FAIL [product-settings]: SDLC_PRODUCT_CONFIG (${SDLC_PRODUCT_CONFIG}) and SDLC_HUB_CONFIG (${SDLC_HUB_CONFIG}) name files that say different things. Set only SDLC_PRODUCT_CONFIG." >&2
23
+ return 1
24
+ fi
25
+ if [ -n "${SDLC_PRODUCT_CONFIG:-}" ]; then printf '%s' "$SDLC_PRODUCT_CONFIG"; return 0; fi
26
+ if [ -n "${SDLC_HUB_CONFIG:-}" ]; then printf '%s' "$SDLC_HUB_CONFIG"; return 0; fi
27
+ if [ -f .sdlc/product.json ] && [ -f .sdlc/hub.json ] && ! cmp -s .sdlc/product.json .sdlc/hub.json; then
28
+ echo "FAIL [product-settings]: .sdlc/product.json and .sdlc/hub.json say different things — they are one file under two names until v5. Run \`yad migrate\` in the Product to choose the copy to keep, and commit both." >&2
29
+ return 1
30
+ fi
31
+ if [ -f .sdlc/product.json ]; then printf '%s' .sdlc/product.json; else printf '%s' .sdlc/hub.json; fi
32
+ }
33
+ PRODUCT_CONFIG="$(product_config)" || exit 1
10
34
  # Bytes, not characters (E114). The changed list below carries raw path bytes, and a path that is not
11
35
  # valid UTF-8 read under a UTF-8 locale is one GNU grep and sed may skip or leave unmatched — one such
12
36
  # file in a diff would hide every other path from the patterns (Linux CI). On macOS, `tr` and `sed` stop
@@ -17,7 +41,7 @@ export LC_ALL=C
17
41
  # --- is duplicated, not sourced) ---
18
42
  # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
19
43
  # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
20
- # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
44
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/productcommit.mjs,
21
45
  # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
22
46
  # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
23
47
  # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
@@ -26,7 +50,7 @@ export LC_ALL=C
26
50
  # command substitution in an assignment aborts the script.
27
51
  resolve_base() {
28
52
  # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
29
- _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
53
+ _cfg="$({ tr -d '\n' < "$PRODUCT_CONFIG"; } 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
30
54
  _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
31
55
  for _c in "origin/${_cfg}" "${_head}" origin/main; do
32
56
  case "$_c" in ''|origin/) continue ;; esac
@@ -62,19 +86,29 @@ RANGE="${BASE}..HEAD"
62
86
  # link under `Specs/` got past a `-- specs` read, and a plain file at `Specs/<story>/contracts/…` was
63
87
  # never on the surface below, which is spelled in lowercase. A top folder spelled any other way than
64
88
  # `specs` is refused too, once, by its own name. The name printed is everything after the first tab.
65
- # `tolower` under LC_ALL=C folds ASCII only, so the one other letter the file systems fold into "specs"
66
- # is mapped by hand: `ſ` (long s, U+017F, bytes 305 277) — APFS reads `ſpecs/` as `specs/`. That is
67
- # `fold` below.
89
+ # `tolower` under LC_ALL=C folds ASCII only, so every character APFS folds (full case folding, then
90
+ # NFD) into ASCII alone is mapped by hand, as bytes — all 13, from Unicode's own tables, the same list
91
+ # as backfill-check (E121): ß and ẞ into `ss`, ſ into `s` (APFS reads `ſpecs/` as `specs/`), the Kelvin
92
+ # sign into `k`, the ligatures ff fi fl ffi ffl ſt st into their letters, and the Greek question mark and varia
93
+ # into `;` and `` ` ``. That is `fold` below.
68
94
  #
69
95
  # The same holds one and two folders down (review 3): `specs/<story>/Contracts/` IS `contracts/` on a
70
96
  # Mac, and `specs/EP-x-S01/` and `specs/ep-x-s01/` are one folder there, while every rule below reads
71
97
  # exact bytes. So a `contracts` spelled any other way is refused, and so is a second spelling of a
72
98
  # story folder — each once, by name. So is a FILE named `specs`, where the folder has to go. `fold`
73
- # knows ASCII case and the long s only: `EP-démo` beside `EP-DÉMO` (or an NFC/NFD twin) is not caught.
74
- # That costs a skipped lock check at most — the surface grep takes any story spelling — and refusing
75
- # every non-ASCII story name would refuse real repos.
76
- links="$(git ls-tree -r -z --full-tree HEAD | tr '\0' '\n' | awk '
77
- function fold(x) { x = tolower(x); gsub(/\305\277/, "s", x); return x }
99
+ # does not know other non-ASCII case or NFC/NFD: `EP-démo` beside `EP-DÉMO` (or an NFC/NFD twin) is not
100
+ # caught here. A twin that holds a slice fails anyway — its folder is not a story ID (E117) — and
101
+ # refusing every non-ASCII story name would refuse real repos.
102
+ #
103
+ # `tr '\n\0' '?\n'`: one record per line, and a newline inside a path becomes `?` (E121, as E116
104
+ # review 2). Split on newlines alone, a file named `x<newline>specs` read as a file named `specs`, and
105
+ # one named like a whole record added a link that was not there — every PR refused, naming the wrong file.
106
+ links="$(git ls-tree -r -z --full-tree HEAD | tr '\n\0' '?\n' | awk '
107
+ function fold(x) { x = tolower(x)
108
+ gsub(/\303\237|\341\272\236/, "ss", x); gsub(/\305\277/, "s", x); gsub(/\342\204\252/, "k", x)
109
+ gsub(/\357\254\200/, "ff", x); gsub(/\357\254\201/, "fi", x); gsub(/\357\254\202/, "fl", x)
110
+ gsub(/\357\254\203/, "ffi", x); gsub(/\357\254\204/, "ffl", x); gsub(/\357\254\205|\357\254\206/, "st", x)
111
+ gsub(/\315\276/, ";", x); gsub(/\341\277\257/, "`", x); return x }
78
112
  function once(k, msg) { if (!(k in said)) { said[k] = 1; print " " msg } }
79
113
  { t = index($0, "\t"); p = (t ? substr($0, t + 1) : $0); m = (t ? substr($0, 1, t - 1) : ""); lp = fold(p) }
80
114
  lp !~ /^specs(\/|$)/ { next }
@@ -104,10 +138,13 @@ fi
104
138
  # -z | tr NUL-separated, so git never quotes a path. By default git wraps a path holding a
105
139
  # non-ASCII byte in quotes and octal-escapes it, and it still quotes one holding a `"`
106
140
  # or a tab with core.quotePath off — either way a slice like
107
- # specs/EP-démo-S01/contracts/api.md never matched the pattern below.
141
+ # specs/EP-démo-S01/contracts/api.md never matched the pattern below. `tr '\n\0' '?\n'`
142
+ # (E121): one path per line, and a newline inside a path becomes `?` — split on
143
+ # newlines alone, a file named `notes<newline>specs/S1/contracts/api.md` read as a
144
+ # slice change and failed the PR over a file that does not exist.
108
145
  # Both lists (this one and `deleted` below) are built the SAME way: the no-lock escape hatch compares
109
146
  # them line for line.
110
- changed="$(git diff --no-renames --name-only -z "$RANGE" | tr '\0' '\n')"
147
+ changed="$(git diff --no-renames --name-only -z "$RANGE" | tr '\n\0' '?\n')"
111
148
  surface="$(printf '%s\n' "$changed" | grep -E '^specs/[^/]+/contracts(/|$)' || true)"
112
149
 
113
150
  # Slice paths this diff DELETES. `--name-only` above lists a deleted file exactly like a changed one,
@@ -125,7 +162,7 @@ surface="$(printf '%s\n' "$changed" | grep -E '^specs/[^/]+/contracts(/|$)' || t
125
162
  # change and every rule below applies to it unchanged).
126
163
  # --no-renames matters here too: a slice MOVED out of contracts/ is a delete of the old path, and
127
164
  # without it git reports an `R`, which --diff-filter=D never matches — the hatch would refuse the move.
128
- deleted="$(git diff --no-renames --diff-filter=D --name-only -z "$RANGE" | tr '\0' '\n' | grep -E '^specs/[^/]+/contracts(/|$)' || true)"
165
+ deleted="$(git diff --no-renames --diff-filter=D --name-only -z "$RANGE" | tr '\n\0' '?\n' | grep -E '^specs/[^/]+/contracts(/|$)' || true)"
129
166
 
130
167
  if [ -z "$surface" ]; then
131
168
  echo "PASS [contract-check]: diff does not touch the contract surface (specs/*/contracts and everything under it)."
@@ -150,8 +187,9 @@ fi
150
187
  # --- reconcile-debt-check; the gates are deliberately standalone, so it is duplicated, not sourced) ---
151
188
  # Read one frontmatter value from the FIRST --- … --- block only. awk bounds to the first block (stops
152
189
  # at the first closing fence), so a body `---` or an absent key can never leak a body line. Plain
153
- # scalars only; trailing spaces/CR are stripped so they never become part of a path.
154
- 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:]]+$//'; }
190
+ # scalars only; trailing spaces/CR are stripped so they never become part of a path. The CR goes BEFORE
191
+ # the fences are matched: a CRLF file's `---\r` is no fence, and every key in it read as empty (E117 r2).
192
+ fm_val() { awk -v k="$1" '{ sub(/\r$/, "") } 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:]]+$//'; }
155
193
 
156
194
  # Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
157
195
  # pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
@@ -163,6 +201,349 @@ link_val() {
163
201
  printf '%s' "$_v"
164
202
  }
165
203
 
204
+ # E117. WHERE the Product is read from must not be something the PR under check chose. link.md is a file
205
+ # the PR's own author writes, so two things are read around it:
206
+ # - `product-repo` comes from the BASE. Where the Product lives is a fact about the whole repo, not
207
+ # about one story, so: link.md as it stands on the base when that has a value; else a sibling
208
+ # `specs/<story>/link.md` on the base — one of the same epic first, and the first whose value reaches
209
+ # a folder here, so one stale link.md cannot hand every new story a deferral; else — the repo's
210
+ # first spec — this PR's value. Otherwise a PR could point it at a path that does not exist —
211
+ # "not reachable", a deferral, a PASS — or at a folder of its own. A value that differs is printed
212
+ # and counts once it merges. Only `product-repo` comes from the base: `contract-lock` is read as the
213
+ # PR leaves it — a re-spec PR updates the pin, and must.
214
+ # - a Product this repo KEEPS (a monorepo: the Product and the code in one git repo) is read as it
215
+ # stands on the base, never as the PR leaves it. In order: (1) the path's TEXT names a folder whose
216
+ # `.sdlc/product.json` or `hub.json` the base tracks (every Product commits one) — asked of the base before the disk,
217
+ # which is the PR's: a PR that deleted or moved that folder read as untracked, and deferred; (2) the
218
+ # path is walked on disk: a tracked symlink, submodule or file on the way is refused (what is behind
219
+ # it is the PR's to choose), and so is a tracked folder that is not a kept Product, so one force-added
220
+ # file cannot switch the gates to a partial Product; the walk is repeated from where the path really
221
+ # lands (an alias like /proc/self/cwd means another folder to each process); an untracked folder
222
+ # that is there is CI's checkout, and is read — unless it is inside the repo's own git folder,
223
+ # which branch names shape (review 7); (3) only when the path reaches NOTHING, a Product the base keeps that holds
224
+ # this story's epic is the one meant. "Inside the code repo" is NOT the test: CI can only check a
225
+ # second repo out INSIDE the workspace, and an untracked checkout there is not the PR's.
226
+ #
227
+ # E120. Before any of that, the repo's own record: `.sdlc/product-link.json`, written by `yad check --fix`
228
+ # / `yad update` and merged on its own. Its `path` (from the repo root; where CI checks the Product out)
229
+ # is read from the BASE and wins over every link.md — so no gate takes where the Product lives from the
230
+ # PR. A record the PR adds or changes counts once it merges (a note says so). A link.md whose own
231
+ # product-repo reaches a DIFFERENT folder here gets a note; one that reaches nothing (a path on its
232
+ # author's machine) is passed over in silence. When the record's path reaches NOTHING — a developer's
233
+ # own run, where nobody cloned into it — the base's link.md order below is used if IT reaches a folder
234
+ # (still the base's, never the PR's); otherwise the record stands, and the gate defers (review 1).
235
+ # With no record on the base, the order above is kept.
236
+ # Sets product_rel, prod (the folder to read), prod_note and prod_fail (text; the caller prints them).
237
+ _yad_tmp=""
238
+ _arch_key=""
239
+ _arch_dir=""
240
+ yad_tmp() { [ -n "$_yad_tmp" ] || { _yad_tmp="$(mktemp -d)"; trap 'rm -rf "$_yad_tmp"' EXIT; }; }
241
+ # `product-repo` as link.md $1 has it on the base (empty when it is not there or names none).
242
+ base_product_rel() {
243
+ git cat-file -e "${BASE}:$1" 2>/dev/null || return 0
244
+ yad_tmp
245
+ git show "${BASE}:$1" > "$_yad_tmp/base-link.md" 2>/dev/null || : > "$_yad_tmp/base-link.md"
246
+ link_val product-repo "$_yad_tmp/base-link.md"
247
+ }
248
+ # The `path` of `.sdlc/product-link.json` at $1 (a commit), or empty when there is none or it names none.
249
+ record_path() {
250
+ git cat-file -e "$1:.sdlc/product-link.json" 2>/dev/null || return 0
251
+ git show "$1:.sdlc/product-link.json" 2>/dev/null | tr -d '\n\r' | sed -nE 's/.*"path"[[:space:]]*:[[:space:]]*"([^"\\]*)".*/\1/p' || true
252
+ }
253
+ product_for() {
254
+ _link="specs/$1/link.md"
255
+ prod=""; prod_note=""; prod_fail=""
256
+ yad_tmp # here, in this shell: a $(…) below would set it, and its EXIT trap, in a subshell only
257
+ product_rel="$(link_val product-repo "$_link")"
258
+ _from="$_link"
259
+ _rec="$(record_path "$BASE")"
260
+ _rec_head="$(record_path HEAD)"
261
+ if [ "$_rec_head" != "$_rec" ]; then
262
+ if [ -n "$_rec" ]; then _w="changes in this PR"; else _w="is new in this PR"; fi
263
+ prod_note=".sdlc/product-link.json ${_w} — where the Product lives is read from ${BASE}, not from this PR; the new one counts once it merges."
264
+ fi
265
+ _base_rel="$(base_product_rel "$_link")"
266
+ if [ -z "$_base_rel" ]; then
267
+ # A sibling's value. Every link.md sits at specs/<story>/, so a relative value means the same there.
268
+ _sibs="$(git ls-tree -r -z --name-only "$BASE" -- specs 2>/dev/null | tr '\n\0' '?\n' | grep -E '^specs/[^/]+/link\.md$' || true)"
269
+ _first=""; _first_from=""
270
+ for _pass in same other; do
271
+ while IFS= read -r _sib; do
272
+ [ -n "$_sib" ] && [ "$_sib" != "$_link" ] || continue
273
+ _s="${_sib#specs/}"; _s="${_s%/link.md}"
274
+ story_ok "$_s" || continue
275
+ if [ "$(story_epic "$_s")" = "$(story_epic "$1")" ]; then [ "$_pass" = same ] || continue; else [ "$_pass" = other ] || continue; fi
276
+ _v="$(base_product_rel "$_sib")"
277
+ [ -n "$_v" ] || continue
278
+ if [ -z "$_first" ]; then _first="$_v"; _first_from="$_sib"; fi
279
+ if [ -d "$(resolve_product "$_v" "$1")" ]; then _base_rel="$_v"; _from="$_sib"; break 2; fi
280
+ done <<SIBLINGS
281
+ $_sibs
282
+ SIBLINGS
283
+ done
284
+ if [ -z "$_base_rel" ]; then _base_rel="$_first"; _from="$_first_from"; fi
285
+ fi
286
+ _use_rec=""
287
+ if [ -n "$_rec" ]; then
288
+ # Relative to the repo root; link.md values are relative to specs/<story>/, so join it that way.
289
+ case "$_rec" in /*) _rv="$_rec" ;; *) _rv="../../$_rec" ;; esac
290
+ if [ -d "$(resolve_product "$_rv" "$1")" ] || [ -z "$_base_rel" ] || [ ! -d "$(resolve_product "$_base_rel" "$1")" ]; then
291
+ _use_rec=1; _base_rel="$_rv"; _from=".sdlc/product-link.json"
292
+ fi
293
+ fi
294
+ if [ -n "$_use_rec" ]; then
295
+ # The record wins. Say so only when this link.md's own value reaches a different folder here.
296
+ _mine="$(resolve_product "$product_rel" "$1")"
297
+ _theirs="$(resolve_product "$_base_rel" "$1")"
298
+ if [ -n "$product_rel" ] && [ -d "$_mine" ] && ! [ "$_mine" -ef "$_theirs" ]; then
299
+ prod_note="${prod_note:+$prod_note
300
+ }${_link} names product-repo '${product_rel}', but .sdlc/product-link.json on ${BASE} says '${_rec}' — the record is read."
301
+ fi
302
+ product_rel="$_base_rel"
303
+ elif [ -n "$_base_rel" ] && [ "$_base_rel" != "$product_rel" ]; then
304
+ prod_note="${prod_note:+$prod_note
305
+ }${_link} names product-repo '${product_rel}', but ${_from} on ${BASE} says '${_base_rel}' — the Product is read from the base value; a new one counts once it merges."
306
+ product_rel="$_base_rel"
307
+ fi
308
+ # A Product this repo keeps, asked of the base first (see above). Both joins resolve_product may pick.
309
+ case "$product_rel" in
310
+ '') _cands="" ;;
311
+ /*|'~'*|'$'*) _cands="$product_rel" ;;
312
+ *) _cands="specs/$1/$product_rel
313
+ $product_rel" ;;
314
+ esac
315
+ while IFS= read -r _cand; do
316
+ [ -n "$_cand" ] || continue
317
+ _lr="$(lex_rel "$_cand")"
318
+ [ -n "$_lr" ] || continue
319
+ [ "$_lr" != . ] || _lr=""
320
+ if git cat-file -e "${BASE}:${_lr:+$_lr/}.sdlc/product.json" 2>/dev/null || git cat-file -e "${BASE}:${_lr:+$_lr/}.sdlc/hub.json" 2>/dev/null; then base_product "$_lr"; return 0; fi
321
+ done <<CANDS
322
+ $_cands
323
+ CANDS
324
+ if [ -n "$product_rel" ]; then
325
+ prod="$(resolve_product "$product_rel" "$1")"
326
+ ! tracked_verdict "$(product_tracked "$prod")" || return 0
327
+ if [ -d "$prod" ]; then
328
+ # Walked once more, from where it really lands, resolved in ONE step from this shell (review 5).
329
+ # The walk above goes one part at a time in a subshell, and a Linux magic link such as
330
+ # /proc/self/cwd means a different folder to each process: there it pointed at the walk's own
331
+ # subshell and found nothing, while this shell read the PR's own copy of a kept Product through it.
332
+ # `./` before a relative path, so a value starting with `-` is not read by `cd` as an option
333
+ # (review 6); and a folder that is there but cannot be entered is refused, not read unchecked.
334
+ case "$prod" in /*) _pc="$prod" ;; *) _pc="./$prod" ;; esac
335
+ _phys="$(cd -P "$_pc" 2>/dev/null && pwd -P)" || _phys=""
336
+ if [ -z "$_phys" ]; then
337
+ prod_fail="product-repo resolves to '${prod}', a folder the gate cannot enter to check where it really is — so it is not read."
338
+ return 0
339
+ fi
340
+ # Not inside this repo's own git folder (review 7). git never lists `.git/` as tracked, yet a PR
341
+ # shapes it: a branch named `epics/EP-x/y` makes the folders `.git/refs/…/epics/EP-x/` when CI
342
+ # fetches, and that read as an untracked checkout holding an open epic with no lock.
343
+ # Compared as FOLDERS (`-ef`: the same one on disk), not as spellings: on a disk that ignores case
344
+ # (macOS, Windows) `.GIT/refs` is `.git/refs`, and `pwd -P` keeps the case it was typed in (review 8).
345
+ for _gd in "$(git rev-parse --absolute-git-dir 2>/dev/null)" "$(git rev-parse --git-common-dir 2>/dev/null)"; do
346
+ [ -n "$_gd" ] && [ -d "$_gd" ] || continue
347
+ _p="$_phys"
348
+ while :; do
349
+ if [ "$_p" -ef "$_gd" ]; then
350
+ prod_fail="product-repo resolves to '${prod}', inside this repo's own git folder — what is there is git's, shaped by branch names, not a Product."
351
+ return 0
352
+ fi
353
+ case "$_p" in /|'') break ;; esac
354
+ _p="${_p%/*}"; [ -n "$_p" ] || _p=/
355
+ done
356
+ done
357
+ ! tracked_verdict "$(product_tracked "$_phys")" || return 0
358
+ # An untracked folder that is there is the Product CI checked out: a PR cannot make one. Read it.
359
+ return 0
360
+ fi
361
+ fi
362
+ # product-repo reaches NOTHING. Then a Product the base keeps that holds this story's epic is the one
363
+ # meant (E117 review 3): a monorepo value spelled past the repo's own folder name, another machine's
364
+ # absolute path, or a first spec pointing nowhere cannot be folded to it by text, and a PR that deleted
365
+ # or moved it leaves no disk to walk. Only when nothing is reached (review 4): a kept Product used to
366
+ # win over the real checkout, so a merged test fixture shaped like a Product was read instead of it.
367
+ # And only one holding the epic, so such a fixture is not read for stories it knows nothing about.
368
+ # (In a two-repo setup whose CI checks nothing out, a fixture that DOES hold the epic is read where
369
+ # the gate used to defer — no weaker than the deferral it replaces.)
370
+ kept_products
371
+ _epic_of="$(story_epic "$1")"
372
+ _kept_here=""
373
+ while IFS= read -r _k; do
374
+ [ -n "$_k" ] || continue
375
+ _kd="$_k"; [ "$_kd" != . ] || _kd=""
376
+ if [ "$(git cat-file -t "${BASE}:${_kd:+$_kd/}epics/${_epic_of}" 2>/dev/null)" = tree ]; then _kept_here="${_kept_here}${_k}
377
+ "; fi
378
+ done <<KEPT
379
+ $_kept
380
+ KEPT
381
+ [ -n "$_kept_here" ] || return 0
382
+ if [ -n "$product_rel" ]; then _what="product-repo '${product_rel}' reaches nothing"; else _what="link.md names no product-repo"; fi
383
+ if [ "$(printf '%s' "$_kept_here" | grep -c .)" -gt 1 ]; then
384
+ prod_fail="${_what}, and ${BASE} keeps more than one Product holding ${_epic_of} ($(printf '%s' "$_kept_here" | tr '\n' ' ' | sed -E 's/ +$//')) — the gate cannot tell which is meant."
385
+ return 0
386
+ fi
387
+ _k="$(printf '%s' "$_kept_here" | head -1)"
388
+ prod_note="${prod_note:+$prod_note
389
+ }${_what} here; the Product this repo keeps at '${_k}' on ${BASE} holds ${_epic_of}, so that one is read."
390
+ product_rel="$_k"
391
+ [ "$_k" != . ] || _k=""
392
+ base_product "$_k"
393
+ return 0
394
+ }
395
+
396
+ # Act on product_tracked's answer $1: a tracked entry on the way is refused; a tracked folder is read from
397
+ # the base when the base keeps a Product there, and refused otherwise. Returns 1 when there was nothing to
398
+ # act on (the path is not the repo's), so the caller goes on.
399
+ tracked_verdict() {
400
+ case "$1" in
401
+ entry:*)
402
+ prod_fail="product-repo reaches the Product through '${1#*:}', a symlink, submodule or file this repo tracks — what is behind it is this PR's to choose. Point product-repo at the Product itself."
403
+ return 0 ;;
404
+ tree:*)
405
+ _r="${1#*:}"
406
+ if git cat-file -e "${BASE}:${_r:+$_r/}.sdlc/product.json" 2>/dev/null || git cat-file -e "${BASE}:${_r:+$_r/}.sdlc/hub.json" 2>/dev/null; then base_product "$_r"; return 0; fi
407
+ prod_fail="product-repo resolves to '${_r:-.}', which holds files this repo tracks, but ${BASE} does not track '${_r:+$_r/}.sdlc/product.json' (or the older hub.json) there, so it is not a Product kept in this repo. Untrack those files (git rm -r --cached '${_r:-.}') or point product-repo at the Product."
408
+ return 0 ;;
409
+ esac
410
+ return 1
411
+ }
412
+
413
+ # Every Product the base keeps: a tracked `.sdlc/product.json` or `.sdlc/hub.json` (both, until v5) with
414
+ # an `epics/` folder beside it (a code repo may track one of its own; only a Product has epics). One line
415
+ # each, `.` for the root, the two names counted once; read once.
416
+ _kept=""
417
+ _kept_read=""
418
+ kept_products() {
419
+ [ -z "$_kept_read" ] || return 0
420
+ _kept_read=1
421
+ while IFS= read -r _h; do
422
+ [ -n "$_h" ] || continue
423
+ _d="${_h%.sdlc/*.json}"; _d="${_d%/}"
424
+ case "
425
+ ${_kept}" in *"
426
+ ${_d:-.}
427
+ "*) continue ;; esac
428
+ if [ "$(git cat-file -t "${BASE}:${_d:+$_d/}epics" 2>/dev/null)" = tree ]; then _kept="${_kept}${_d:-.}
429
+ "; fi
430
+ done <<KEPT
431
+ $(git ls-tree -r -z --name-only "$BASE" 2>/dev/null | tr '\n\0' '?\n' | grep -E '(^|/)\.sdlc/(product|hub)\.json$' || true)
432
+ KEPT
433
+ }
434
+
435
+ # Read the Product this repo keeps at $1 (from the repo root; "" is the root) as it stands on the base:
436
+ # its `epics/` written out under the temp folder. Sets prod, or prod_fail.
437
+ base_product() {
438
+ _r="$1"; _e="${_r:+$_r/}epics"
439
+ if [ "$(git cat-file -t "${BASE}:${_e}" 2>/dev/null)" != tree ]; then
440
+ prod_fail="product-repo resolves to '${_r:-.}', a Product this repo keeps, and ${BASE} has no '${_e}' folder — there is nothing on the base to read."
441
+ return 0
442
+ fi
443
+ # (awk reads to the end rather than stopping at the first hit: stopping left `tr` writing into a
444
+ # closed pipe on a large epics/, and under pipefail the gate died with no message — review 6.)
445
+ # A symlink or submodule inside it is refused by name, as under specs/ (E115): a link written out
446
+ # still points where it pointed — an absolute one into this PR's working tree — and a submodule comes
447
+ # out as an empty folder, which reads as an epic with no stories.
448
+ _bad="$(git ls-tree -r -z "${BASE}:${_e}" 2>/dev/null | tr '\n\0' '?\n' | awk '!f && ($1 == "120000" || $1 == "160000") { sub(/^[^\t]*\t/, ""); print; f = 1 }')"
449
+ if [ -n "$_bad" ]; then
450
+ prod_fail="'${_e}/${_bad}' on ${BASE} is a symlink or a submodule, so the Product this repo keeps cannot be read from the base. Replace it with the files themselves."
451
+ return 0
452
+ fi
453
+ # Keyed as "x<path>": the repo root is the path "", which an empty key would take as done.
454
+ if [ "$_arch_key" != "x$_r" ]; then
455
+ _arch_dir="$(mktemp -d "$_yad_tmp/product.XXXXXX")"
456
+ # A throwaway index holding the base's epics/, written out with the WORK TREE pointed at the empty
457
+ # temp folder. Not `git archive`: it drops whatever the base's .gitattributes marks `export-ignore`.
458
+ # And not with the real work tree: `git checkout-index` takes attributes from the work tree, which
459
+ # is the PR's — a `.gitattributes` of `*.md text eol=crlf` or `working-tree-encoding=UTF-16` in the
460
+ # PR wrote the base's files unreadable, and every gate passed. Pointed here, it finds none but the
461
+ # base's own.
462
+ # The index's own `.gitattributes` entries are dropped before it is written out, for the same reason
463
+ # (review 3): one merged into the base's epics/ applied `working-tree-encoding=UTF-16` to every file.
464
+ if ! { mkdir -p "${_arch_dir}/${_e}" &&
465
+ GIT_INDEX_FILE="${_arch_dir}.idx" git read-tree "${BASE}:${_e}" &&
466
+ GIT_INDEX_FILE="${_arch_dir}.idx" git ls-files -z -- ':(glob)**/.gitattributes' |
467
+ GIT_INDEX_FILE="${_arch_dir}.idx" xargs -0 git update-index --force-remove -- &&
468
+ GIT_WORK_TREE="${_arch_dir}/${_e}" GIT_INDEX_FILE="${_arch_dir}.idx" git checkout-index -a -f; } >/dev/null 2>&1; then
469
+ prod_fail="product-repo resolves to '${_r:-.}', a Product this repo keeps, and '${_e}' could not be read from ${BASE}."
470
+ return 0
471
+ fi
472
+ _arch_key="x$_r"
473
+ fi
474
+ prod="${_arch_dir}${_r:+/$_r}"
475
+ prod_note="${prod_note:+$prod_note
476
+ }the Product at '${_r:-.}' is kept in this repo, so it is read as it stands on ${BASE}, not as this PR leaves it."
477
+ return 0
478
+ }
479
+
480
+ # The path $1 from the repo root, `.` and `..` folded by its text alone — no disk. Prints `.` for the
481
+ # root and nothing when it leaves the repo. An absolute path counts when it starts with the repo's path
482
+ # as the shell spells it or as the disk does (`pwd`, `pwd -P`; the gates run from the root).
483
+ lex_rel() (
484
+ _p="$1"
485
+ case "$_p" in
486
+ /*)
487
+ _in=""
488
+ for _top in "$(pwd)" "$(pwd -P)"; do
489
+ case "$_p/" in "$_top"/*) _p="${_p#"$_top"}"; _in=1; break ;; esac
490
+ done
491
+ [ -n "$_in" ] || exit 0 ;;
492
+ esac
493
+ _out=""
494
+ set -f; IFS=/
495
+ for _c in $_p; do
496
+ case "$_c" in
497
+ ''|.) ;;
498
+ ..) [ -n "$_out" ] || exit 0
499
+ case "$_out" in */*) _out="${_out%/*}" ;; *) _out="" ;; esac ;;
500
+ *) _out="${_out:+$_out/}$_c" ;;
501
+ esac
502
+ done
503
+ printf '%s' "${_out:-.}"
504
+ )
505
+
506
+ # Is the path $1 — as the shell would walk it, `..` and links included — something this repo tracks?
507
+ # Prints `entry:<path>` when a part of the way is a tracked entry itself (a symlink, a submodule, a
508
+ # file), `tree:<path>` when it ends in a folder with tracked files at or under it, and nothing when
509
+ # it is the repo's to neither. <path> is from the repo root, as the disk spells it (`pwd -P`). The case
510
+ # is folded where git folds it (core.ignorecase — a Mac checkout), and in ASCII only.
511
+ product_tracked() (
512
+ _top="$(git rev-parse --show-toplevel 2>/dev/null)" && _top="$(cd -P "$_top" 2>/dev/null && pwd -P)" || exit 0
513
+ _ps='literal'; [ "$(git config --bool core.ignorecase 2>/dev/null)" = true ] && _ps='literal,icase'
514
+ _in() { case "$1/" in "$_top"/*) _r="${1#"$_top"}"; _r="${_r#/}"; return 0 ;; esac; return 1; }
515
+ _hit() { git -C "$_top" ls-files -z -- ":(${_ps})$1" 2>/dev/null | tr '\n\0' '?\n'; }
516
+ case "$1" in /*) cd / ;; esac
517
+ _rest="$1"
518
+ while [ -n "$_rest" ]; do
519
+ _c="${_rest%%/*}"
520
+ case "$_rest" in */*) _rest="${_rest#*/}" ;; *) _rest="" ;; esac
521
+ case "$_c" in ''|.) continue ;; esac
522
+ if [ "$_c" != .. ] && _in "$(pwd -P)/$_c" && [ -n "$_r" ] &&
523
+ _hit "$_r" | awk -v p="$_r" -v i="$_ps" '{ a = $0; b = p; if (i ~ /icase/) { a = tolower(a); b = tolower(b) } } a == b { f = 1 } END { exit !f }'; then
524
+ printf 'entry:%s' "$_r"; exit 0
525
+ fi
526
+ cd -P "./$_c" 2>/dev/null || exit 0 # ./ — a part named `-p` is not an option
527
+ done
528
+ _in "$(pwd -P)" || exit 0
529
+ if [ -n "$(_hit "${_r:-.}" | head -1)" ]; then printf 'tree:%s' "$_r"; fi
530
+ exit 0
531
+ )
532
+
533
+ # E118. A story's epic is its ID's prefix (EP-<slug>-S0N -> EP-<slug>): yad-spec always writes link.md's
534
+ # `epic:` so, and contract-check never read the field. The field is the PR's to write, so a gate that took
535
+ # it as it stands gated whichever epic the PR named — a story of a SEALED epic could name an open one and
536
+ # pass epic-open, or step off a thread frozen for hotfix debt. The gates that read `epic:` require it to
537
+ # agree with the story ID.
538
+ story_epic() { printf '%s' "$1" | sed -E 's/-S[0-9]+$//'; }
539
+
540
+ # One story ID shape for every gate (E117 review 1): EP-<slug>-S<n>, what yad-stories writes. Anything
541
+ # else is refused by name — a Task trailer of `EP-x-S01/.-T1` read `specs/EP-x-S01/./link.md`, a path
542
+ # the base read never found, and its `epic:` of `EP-x-S01/.` agreed with story_epic. The whole string
543
+ # must match (bash `=~`, not a line-wise grep), and the letters are spelled out so no locale widens them.
544
+ _story_re='^EP-[abcdefghijklmnopqrstuvwxyz0123456789-]+-S[0123456789]+$'
545
+ story_ok() { [[ $1 =~ $_story_re ]]; }
546
+
166
547
  # Resolve link.md's `product-repo` to a path in THIS checkout. An ABSOLUTE value is used as-is. A
167
548
  # RELATIVE value is written relative to the link.md's own directory (specs/<story>/) — the canonical
168
549
  # form — but contract-check historically read it from the repo root, so a link.md authored against that
@@ -191,15 +572,52 @@ stories="$(printf '%s\n' "$surface" | sed -E 's#^specs/([^/]+)/contracts(/.*)?$#
191
572
  rc=0
192
573
  while IFS= read -r story; do
193
574
  [ -z "$story" ] && continue
575
+ # This story's own changed and deleted slice paths, matched as text (a folder that is not a story ID
576
+ # may hold characters a pattern would read; the prefix goes in through the environment, because
577
+ # `awk -v` turns a `\b` in a folder name into a backspace). A story whose every changed slice file is
578
+ # DELETED only removes (only_removes); one whose slice is also GONE at HEAD (slice_gone) is a whole
579
+ # clean-up, the only kind that needs no link.md — deleting link.md with PART of a slice used to drop
580
+ # a stale pin while the rest of the slice stayed (E117 review 3).
581
+ story_surface="$(printf '%s\n' "$surface" | p="specs/${story}/contracts" awk 'BEGIN { p = ENVIRON["p"] } index($0, p) == 1 && (length($0) == length(p) || substr($0, length(p) + 1, 1) == "/")')"
582
+ story_deleted="$(printf '%s\n' "$deleted" | p="specs/${story}/contracts" awk 'BEGIN { p = ENVIRON["p"] } index($0, p) == 1 && (length($0) == length(p) || substr($0, length(p) + 1, 1) == "/")')"
583
+ only_removes=""
584
+ [ -n "$story_surface" ] && [ "$story_surface" = "$story_deleted" ] && only_removes=1
585
+ slice_gone=""
586
+ if [ -n "$only_removes" ] && ! git cat-file -e "HEAD:specs/${story}/contracts" 2>/dev/null; then slice_gone=1; fi
194
587
  link="specs/${story}/link.md"
588
+ if ! story_ok "$story"; then
589
+ if [ -n "$only_removes" ]; then
590
+ echo "note [contract-check]: specs/${story}/contracts is not under a story ID, and this only removes it — allowed."
591
+ continue
592
+ fi
593
+ echo "FAIL [contract-check]: specs/${story}/contracts is not under a story ID (expected EP-<slug>-S<n>, the slug in lowercase letters, digits and dashes) — its lock cannot be found."
594
+ echo " Move the slice under its story's folder, or delete it. (A Spec Kit folder such as specs/001-name/ is not one.)"
595
+ rc=1
596
+ continue
597
+ fi
195
598
  if [ ! -f "$link" ]; then
196
- echo "note [contract-check]: no ${link} — fidelity check deferred (spec-link gates the link itself)."
599
+ # Removing a story's whole slice with its link.md is a clean-up, as before. Adding or changing a
600
+ # slice without its link.md is not (E117 review 2): yad-spec writes the two together, and a PR could
601
+ # delete link.md to drop a stale pin, or add a slice for an epic that exists nowhere. Nor is removing
602
+ # PART of one (review 3): what is left is still a slice, with nothing pinning it.
603
+ if [ -n "$slice_gone" ]; then
604
+ echo "note [contract-check]: no ${link}, and this only removes ${story}'s slice — fidelity check not needed."
605
+ continue
606
+ fi
607
+ echo "FAIL [contract-check]: ${story}'s contract slice changes, but ${link} is not there — the slice cannot be tied to a contract lock."
608
+ echo " Re-run yad-spec for ${story} (it writes the slice and link.md together), or remove the slice."
609
+ rc=1
197
610
  continue
198
611
  fi
199
- product_rel="$(link_val product-repo "$link")"
200
612
  pinned="$(printf '%s' "$(link_val contract-lock "$link")" | sed -E 's/^sha256:([0-9a-f]+).*$/\1/')"
201
- epic="$(printf '%s' "$story" | sed -E 's/-S[0-9]+$//')" # story EP-<slug>-S0N -> epic EP-<slug>
202
- prod="$(resolve_product "$product_rel" "$story")"
613
+ epic="$(story_epic "$story")" # story EP-<slug>-S0N -> epic EP-<slug>
614
+ product_for "$story"
615
+ if [ -n "$prod_note" ]; then printf '%s\n' "$prod_note" | sed 's/^/note [contract-check]: /'; fi
616
+ if [ -n "$prod_fail" ]; then
617
+ echo "FAIL [contract-check]: ${link} — ${prod_fail}"
618
+ rc=1
619
+ continue
620
+ fi
203
621
  # Only build the lock path when the product repo actually resolved. With an empty `prod` the
204
622
  # interpolation yields "/epics/<epic>/…" — a path rooted at the filesystem root, which is both a
205
623
  # misleading thing to print and, on a machine that happened to have /epics, a foreign file to read.
@@ -248,9 +666,7 @@ while IFS= read -r story; do
248
666
  # The escape hatch described at the top: with no lock upstream there is no agreed shape for a
249
667
  # deletion to contradict, so REMOVING a slice is always allowed here. This is what keeps a
250
668
  # short-lane story that should never have had a `contracts/` folder from being unfixable.
251
- story_surface="$(printf '%s\n' "$surface" | grep -E "^specs/${story}/contracts(/|$)" || true)"
252
- story_deleted="$(printf '%s\n' "$deleted" | grep -E "^specs/${story}/contracts(/|$)" || true)"
253
- if [ -n "$story_surface" ] && [ "$story_surface" = "$story_deleted" ]; then
669
+ if [ -n "$only_removes" ]; then
254
670
  echo "note [contract-check]: ${story} only REMOVES contract slice files and ${epic} has no lock —"
255
671
  echo " nothing upstream is being contradicted, so the removal is allowed."
256
672
  continue
@@ -267,6 +683,39 @@ while IFS= read -r story; do
267
683
  echo " first (yad-architecture Step 5)."
268
684
  rc=1
269
685
  continue
686
+ elif [ -n "$prod" ] && [ -d "$prod" ] && [ ! -d "${prod}/epics/${epic}" ]; then
687
+ # The Product WAS reached, and has no such epic: a slice for an epic that exists nowhere (E117
688
+ # review 2). It used to say "not reachable" and defer. "Reached" is the folder itself, as in the three
689
+ # twin gates — not `epics/` inside it (review 3). Removing such a slice is always allowed: there is
690
+ # no lock anywhere for it to contradict.
691
+ if [ -n "$only_removes" ]; then
692
+ echo "note [contract-check]: ${story}'s slice belongs to ${epic}, which the Product does not have, and this only removes it — allowed."
693
+ continue
694
+ fi
695
+ echo "FAIL [contract-check]: ${story}'s slice belongs to ${epic}, which does not exist in the Product (${prod}/epics/) — an orphan slice."
696
+ rc=1
697
+ continue
698
+ elif [ -z "$_base_rel" ] && [ -z "$only_removes" ] && { [ -z "$prod" ] || [ ! -d "$prod" ]; }; then
699
+ # The repo's FIRST spec (E119): no record (.sdlc/product-link.json, E120) and no link.md on the base
700
+ # names a Product, so product-repo is this PR's own value — and it reaches nothing. Deferring here let a PR pick a path that does not exist and
701
+ # pass a slice change with no lock check at all. This case is known from paths alone: the slice is
702
+ # on the surface, and the base holds no link.md with a value. A value merged on the base that reaches
703
+ # nothing still defers below (a CI job that does not check the Product out); a PR that only removes
704
+ # a slice passes as before. The other Product-reading gates judge the epic, not the surface, and
705
+ # still defer here — E120. Only when the Product is NOT reached (review 1): one that is reached, with
706
+ # the epic's folder but no ledger in it, is not "nowhere", and defers below as it always has. That
707
+ # includes a Product this repo keeps, which product_for falls back to when product-repo reaches
708
+ # nothing: it is read as it stands on the base, so the PR cannot shape it.
709
+ echo "FAIL [contract-check]: ${link} is this repo's first spec — no link.md on ${BASE} names a Product, and no .sdlc/product-link.json does —"
710
+ if [ -n "$product_rel" ]; then
711
+ echo " and its product-repo '${product_rel}' reaches nothing, so the contract lock cannot be checked."
712
+ else
713
+ echo " and it names no product-repo, so the contract lock cannot be checked."
714
+ fi
715
+ echo " Run \`yad check --fix --push\` from the Product: it merges .sdlc/product-link.json, which says where CI"
716
+ echo " checks the Product out (with the YAD_PRODUCT_TOKEN secret set). Or fix product-repo."
717
+ rc=1
718
+ continue
270
719
  else
271
720
  # Say so. A skipped fidelity check used to be indistinguishable from a passed one, which is how a
272
721
  # mis-resolved product-repo could turn a stale-pin FAIL into a silent PASS (issue #149).
@@ -277,7 +726,7 @@ $stories
277
726
  EOF
278
727
 
279
728
  if [ "$rc" != 0 ]; then
280
- echo "FAIL [contract-check]: a changed slice pins a stale or absent contract lock (see above) — the surface was not re-locked upstream for every story in this diff."
729
+ echo "FAIL [contract-check]: a changed slice pins a stale or absent contract lock, or reads its Product from a place this PR chose (see above) — the surface was not proven re-locked upstream for every story in this diff."
281
730
  exit 1
282
731
  fi
283
732