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