yadflow 3.13.0 → 3.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/bin/yad.mjs +8 -3
- package/cli/doctor.mjs +135 -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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
## [3.13.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.13.1...v3.13.2) (2026-08-10)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **doctor:** fail a done review gate that holds no approval ([b1b23df](https://github.com/abdelrahmannasr/yadflow/commit/b1b23df5426e8523cdb1d95221cb2291200c6249))
|
|
7
|
+
|
|
8
|
+
## [3.13.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.13.0...v3.13.1) (2026-07-29)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **checks:** apply the Task-trailer rule to the thread gates too ([18aed8d](https://github.com/abdelrahmannasr/yadflow/commit/18aed8df958718483fe929cea84275ca86ae67be)), closes [#157](https://github.com/abdelrahmannasr/yadflow/issues/157)
|
|
14
|
+
* **checks:** do not build a /-rooted lock path when product-repo is absent ([0d2a214](https://github.com/abdelrahmannasr/yadflow/commit/0d2a214a30c7fc31ad087fa127277b50637a00de))
|
|
15
|
+
* **checks:** keep product-repo resolution backward-compatible and loud ([4c00c92](https://github.com/abdelrahmannasr/yadflow/commit/4c00c928642fbab4517805be14a1c6926f41b84c)), closes [#149](https://github.com/abdelrahmannasr/yadflow/issues/149) [#149](https://github.com/abdelrahmannasr/yadflow/issues/149)
|
|
16
|
+
* **checks:** resolve link.md product-repo the same way in every gate ([47c9b30](https://github.com/abdelrahmannasr/yadflow/commit/47c9b30548153ef5be607cf760c3e9ec9e73699f)), closes [#149](https://github.com/abdelrahmannasr/yadflow/issues/149)
|
|
17
|
+
* **checks:** spec-link resolves a Task trailer even on a maintenance commit ([d24dd83](https://github.com/abdelrahmannasr/yadflow/commit/d24dd83abadc63bb00e8c8d6d7c6008e32344db8)), closes [#157](https://github.com/abdelrahmannasr/yadflow/issues/157)
|
|
18
|
+
* **doctor:** refuse a decorative contract lock, and report a gate that went stale ([1ee3296](https://github.com/abdelrahmannasr/yadflow/commit/1ee3296dbddfdd308949b8013b3bf678f638fc80)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
19
|
+
* **gate:** bound the review-branch probe so it can never hang gate open ([afa5416](https://github.com/abdelrahmannasr/yadflow/commit/afa54167efd5879537c4bc19ab29531fe08ed31b))
|
|
20
|
+
* **gate:** hash the contract surface exactly as the documented recipe does ([f29e781](https://github.com/abdelrahmannasr/yadflow/commit/f29e78183b92c7baf33b46dbdfa7a4776aa06e4e)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
21
|
+
* **gate:** keep the recorded PR entry when --pr names that same PR ([b1febe1](https://github.com/abdelrahmannasr/yadflow/commit/b1febe184278109f0bafc2968648da362d68483a))
|
|
22
|
+
* **gate:** make a merged review PR reachable by hand ([4ccbd17](https://github.com/abdelrahmannasr/yadflow/commit/4ccbd1736640935175d63bac15a01a94e381b44e)), closes [#158](https://github.com/abdelrahmannasr/yadflow/issues/158)
|
|
23
|
+
* **gate:** never drop a done step's approval record on re-sync ([1a5e434](https://github.com/abdelrahmannasr/yadflow/commit/1a5e4348d00edd4e775a735a49fc0a86a3f0e391)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
24
|
+
* **gate:** re-bind approvals recorded before PR provenance existed ([6b7e816](https://github.com/abdelrahmannasr/yadflow/commit/6b7e816638a329d9365c6e727b2c12debdbd24aa)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156) [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
25
|
+
* **gate:** re-sync a re-opened review so its approvals re-bind ([28eabd2](https://github.com/abdelrahmannasr/yadflow/commit/28eabd222214ad2af7d18ef20c56795c9eb171d1)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
26
|
+
* **gate:** require the review branch on origin, and check before writing state ([27f0d92](https://github.com/abdelrahmannasr/yadflow/commit/27f0d92ce680c26df6ea7ff6510879a11f05d00f)), closes [#158](https://github.com/abdelrahmannasr/yadflow/issues/158)
|
|
27
|
+
* **gate:** stop a done-step re-sync from churning the ledger ([68462a6](https://github.com/abdelrahmannasr/yadflow/commit/68462a622753c0d30b458d22d9e1ad890ad8e61f)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
28
|
+
* **gate:** validate --pr and confirm it names this artifact's review ([f5d7773](https://github.com/abdelrahmannasr/yadflow/commit/f5d7773da474784a43cc3e24298da94c8f38bbdb)), closes [#7](https://github.com/abdelrahmannasr/yadflow/issues/7) [#158](https://github.com/abdelrahmannasr/yadflow/issues/158)
|
|
29
|
+
* **hub-bridge:** serialize the GitLab gate-sync job ([4294979](https://github.com/abdelrahmannasr/yadflow/commit/4294979f80cb4491d074fe27e74f9af454a2f62a)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
30
|
+
|
|
1
31
|
# [3.13.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.2...v3.13.0) (2026-07-14)
|
|
2
32
|
|
|
3
33
|
|
package/bin/yad.mjs
CHANGED
|
@@ -74,8 +74,13 @@ ${c.bold('Where am I / what next')}
|
|
|
74
74
|
--undo reverses it until the stories review opens
|
|
75
75
|
|
|
76
76
|
${c.bold('Review gate (front half)')}
|
|
77
|
-
yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review
|
|
78
|
-
|
|
77
|
+
yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review. The review
|
|
78
|
+
branch must already be on origin (it is never created here)
|
|
79
|
+
yad gate sync <epic> [artifact] [--pr <n>]
|
|
80
|
+
Pull PR state -> ledger; advance on approved+resolved+merged.
|
|
81
|
+
With no recorded PR, resolves it from the review branch; --pr
|
|
82
|
+
names one (and overrides a stale recorded pointer). Advisory
|
|
83
|
+
in bridge mode — there, recover with 'yad gate ci' below
|
|
79
84
|
yad gate comments <epic> [artifact] Fetch unresolved review comments to address
|
|
80
85
|
yad gate status <epic> Show each review step + approvals
|
|
81
86
|
yad gate repair <epic> [--push] Close an author step stranded behind a passed review gate
|
|
@@ -272,7 +277,7 @@ async function main() {
|
|
|
272
277
|
// advisory (reads the platform, prints status, writes nothing). The artifact status flip is
|
|
273
278
|
// CI's job at merge — never wired into the local gate. File-only mode keeps local writes.
|
|
274
279
|
if (action === 'open') await gateOpen(o.dir, { epic, artifact });
|
|
275
|
-
else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
|
|
280
|
+
else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, number: o.pr, local: true });
|
|
276
281
|
else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
|
|
277
282
|
else if (action === 'status') await gateStatus(o.dir, { epic });
|
|
278
283
|
else if (action === 'repair') await gateRepair(o.dir, { epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
|
package/cli/doctor.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSONStrict } from './lib.mjs';
|
|
8
8
|
import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
|
|
9
|
-
import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants } from './epic-state.mjs';
|
|
9
|
+
import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants, contractSurfaceHash, artifactHash } from './epic-state.mjs';
|
|
10
10
|
import { loadDebt } from './thread.mjs';
|
|
11
11
|
import { gitHead, insideWorkspace } from './setup.mjs';
|
|
12
12
|
import { cliFor, validateLogin, hostFromGitUrl } from './platform.mjs';
|
|
@@ -280,9 +280,141 @@ export function ciTagsChecks(checks, root, hub, registry) {
|
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// Is `.sdlc/contract-lock.json` still the hash of the surface it claims to lock? The lock is what the
|
|
284
|
+
// spec pins and what contract-check compares a code repo's slice against, but nothing ever verified it
|
|
285
|
+
// against the live contract.md — so a surface edited without a re-lock (or locked with a different
|
|
286
|
+
// recipe) read as "locked" while binding nothing. FAIL on a mismatch: a decorative lock is worse than
|
|
287
|
+
// none, because everyone downstream treats it as proof.
|
|
288
|
+
//
|
|
289
|
+
// Two shapes (yad-change references/triage.md): a SURFACE lock, verified against this epic's own
|
|
290
|
+
// contract.md, and a POINTER lock — a change-epic that inherited architecture, which carries no
|
|
291
|
+
// contract.md at all and instead copies the parent's hash verbatim. Its integrity property is that the
|
|
292
|
+
// copy still equals what the referenced lock holds, so verify it there.
|
|
293
|
+
function contractLockCheck(checks, root, epic, ledger) {
|
|
294
|
+
const id = `epic:${epic}:contract-lock`;
|
|
295
|
+
const lock = ledger.contractLock;
|
|
296
|
+
const epicDir = epicRoot(root, epic);
|
|
297
|
+
// An epic that has not reached the lock yet simply has no lock file — that is the normal pre-lock
|
|
298
|
+
// state and stays silent. A lock file that EXISTS but carries no usable hash is the opposite: it is
|
|
299
|
+
// the decorative lock this check was added to catch, so it must never read as "not locked yet".
|
|
300
|
+
// `readJSONStrict` yields null both for an absent file and for one holding literal `null`, so ask the
|
|
301
|
+
// filesystem — the second is a malformed lock, not a missing one.
|
|
302
|
+
if (lock === null && !exists(ledger.files.contractLock)) return;
|
|
303
|
+
const stored = typeof lock?.hash === 'string' && /^sha256:[0-9a-f]{64}$/.test(lock.hash) ? lock.hash : null;
|
|
304
|
+
if (!stored) {
|
|
305
|
+
check(checks, id, 'epics', 'fail',
|
|
306
|
+
`${epic}: contract-lock.json exists but carries no usable sha256 hash`,
|
|
307
|
+
're-lock the surface (yad-architecture Step 5) or delete the file — a lock nobody can verify is worse than none');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const short = (h) => `${h.slice(0, 19)}…`;
|
|
311
|
+
|
|
312
|
+
if (lock.inheritedFrom || lock.ref) {
|
|
313
|
+
// The ref is repo-controlled text, so keep it inside this hub's epics/ — a lock file must not be
|
|
314
|
+
// able to point the check at arbitrary JSON elsewhere on disk.
|
|
315
|
+
const epicsDir = path.join(root, 'epics');
|
|
316
|
+
const refPath = path.resolve(path.join(epicDir, '.sdlc'), lock.ref || `../../${lock.inheritedFrom}/.sdlc/contract-lock.json`);
|
|
317
|
+
if (refPath !== epicsDir && !refPath.startsWith(epicsDir + path.sep)) {
|
|
318
|
+
check(checks, id, 'epics', 'fail',
|
|
319
|
+
`${epic}: pointer-lock ref '${lock.ref}' resolves outside epics/`,
|
|
320
|
+
'a pointer-lock must reference another epic in this hub — fix `ref` (yad-change writes ../../EP-<parent>/.sdlc/contract-lock.json)');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const parent = readJSON(refPath, null);
|
|
324
|
+
if (!parent || typeof parent.hash !== 'string') {
|
|
325
|
+
check(checks, id, 'epics', 'fail',
|
|
326
|
+
`${epic}: pointer-lock references ${lock.inheritedFrom || lock.ref}, whose contract-lock.json is missing or has no hash`,
|
|
327
|
+
're-thread the change-epic (yad-change) so it points at a real parent lock');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (parent.hash !== stored) {
|
|
331
|
+
check(checks, id, 'epics', 'fail',
|
|
332
|
+
`${epic}: pointer-lock pins ${short(stored)} but ${lock.inheritedFrom || 'its parent'} now locks ${short(parent.hash)}`,
|
|
333
|
+
'the inherited surface was re-locked upstream — re-copy the parent hash, or re-author architecture in this epic');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// A pointer-lock epic has no contract.md by construction (the surface physically cannot drift).
|
|
337
|
+
// One that DOES have a contract.md is a change-epic that re-authored architecture but left the
|
|
338
|
+
// inherited fields behind, so verify the live surface as well rather than trusting the pointer.
|
|
339
|
+
if (!exists(path.join(epicDir, 'contract.md'))) {
|
|
340
|
+
check(checks, id, 'epics', 'ok', `${epic}: pointer-lock matches ${lock.inheritedFrom || 'its parent'} (${short(stored)})`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
check(checks, `${id}:inherited`, 'epics', 'warn',
|
|
344
|
+
`${epic}: lock is marked inherited from ${lock.inheritedFrom || lock.ref} but this epic has its own contract.md`,
|
|
345
|
+
're-authored architecture? drop `inheritedFrom`/`ref` and re-lock against this epic\'s surface');
|
|
346
|
+
// and fall through to verify the live surface too
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!exists(path.join(epicDir, 'contract.md'))) {
|
|
350
|
+
check(checks, id, 'epics', 'fail',
|
|
351
|
+
`${epic}: contract-lock.json pins ${short(stored)} but there is no contract.md to lock`,
|
|
352
|
+
'restore contract.md, or record the lock as inherited (`inheritedFrom` + `ref`) if this epic threads off a parent');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const current = contractSurfaceHash(epicDir);
|
|
356
|
+
if (current === null) {
|
|
357
|
+
check(checks, id, 'epics', 'fail',
|
|
358
|
+
`${epic}: contract-lock.json pins ${short(stored)} but contract.md has no readable CONTRACT-SURFACE block`,
|
|
359
|
+
'restore the BEGIN/END markers around the surface, then re-lock (see yad-architecture Step 5)');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (current !== stored) {
|
|
363
|
+
check(checks, id, 'epics', 'fail',
|
|
364
|
+
`${epic}: contract surface drifted from its lock — contract.md hashes ${short(current)}, contract-lock.json pins ${short(stored)}`,
|
|
365
|
+
'the surface changed without a re-lock: re-run the yad-architecture Step 5 recipe and re-open the architecture gate');
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
check(checks, id, 'epics', 'ok', `${epic}: contract surface matches its lock (${short(stored)})`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Two findings on a review step that is already `done`, both about the approval record behind it.
|
|
372
|
+
//
|
|
373
|
+
// FAIL — it holds NO qualifying approval at all (outside solo mode). This is the state the gate
|
|
374
|
+
// exists to prevent: the step advanced without the record that justifies it. `gatePredicate` counts
|
|
375
|
+
// exactly the same thing (`status === 'approved'`, with `inherited`/`skipped` steps short-circuited
|
|
376
|
+
// before it), so a step doctor reports here is one the gate itself would refuse today.
|
|
377
|
+
//
|
|
378
|
+
// WARN — it holds approvals, but none still bind to the artifact as it stands. The gate is
|
|
379
|
+
// deliberately one-way — nothing pulls a chain backward once work is built on it — so the only way
|
|
380
|
+
// this surfaces is if something reports it. `gate sync` records the gap on the step it is syncing;
|
|
381
|
+
// this reports it for the whole epic, so a re-locked surface that was never re-approved is visible
|
|
382
|
+
// in the one command people run when something looks wrong. A warning, because the state is a fact
|
|
383
|
+
// about history and the fix (re-open the review) is a human decision.
|
|
384
|
+
function staleGateCheck(checks, root, epic, ledger, { solo = false } = {}) {
|
|
385
|
+
const epicDir = epicRoot(root, epic);
|
|
386
|
+
for (const s of ledger.state.steps) {
|
|
387
|
+
if (s.type !== 'review+approve' || s.status !== 'done' || s.inherited || s.skipped) continue;
|
|
388
|
+
const forStep = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved');
|
|
389
|
+
// Checked BEFORE the artifact hash below: "done holding no approval" is a claim about the ledger,
|
|
390
|
+
// not about content, so it must not depend on there being something to hash. Gating it behind the
|
|
391
|
+
// hash would keep hiding it on every epic with no locked surface.
|
|
392
|
+
if (!forStep.length) {
|
|
393
|
+
// Solo mode waives the approval requirement outright (you cannot approve your own PR) — the
|
|
394
|
+
// merge + resolved threads are what advance the step, so an empty record is the documented
|
|
395
|
+
// shape there, not a finding. Everywhere else it is the gate being silently defeated.
|
|
396
|
+
if (solo) continue;
|
|
397
|
+
const records = ledger.approvals.filter((a) => a.step === s.id).length;
|
|
398
|
+
check(checks, `epic:${epic}:${s.id}:unapproved`, 'epics', 'fail',
|
|
399
|
+
`${epic}: ${s.id} is done but holds no approval${records ? ` (${records} record(s), none of them live)` : ''}`,
|
|
400
|
+
'the step advanced without the record the gate exists to keep — re-open the review (a fresh PR/MR) and re-approve, or run `yad gate sync` if the approvals are on the PR but never reached the ledger');
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const cur = artifactHash(epicDir, s.artifact);
|
|
404
|
+
if (!cur) continue; // nothing to bind to (no locked surface / incomplete set) — not a staleness claim
|
|
405
|
+
const live = forStep.filter((a) => !a.artifactHash || a.artifactHash === cur);
|
|
406
|
+
if (live.length) continue;
|
|
407
|
+
check(checks, `epic:${epic}:${s.id}:stale`, 'epics', 'warn',
|
|
408
|
+
`${epic}: ${s.id} is done, but all ${forStep.length} approval(s) are bound to an older ${s.artifact}`,
|
|
409
|
+
'the artifact changed after it was approved — re-open the review (a fresh PR/MR) so the record matches what shipped');
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
283
413
|
export function epicChecks(checks, root) {
|
|
284
414
|
const epicsDir = path.join(root, 'epics');
|
|
285
415
|
if (!exists(epicsDir)) return;
|
|
416
|
+
// Read once for the whole sweep: whether approval is waived is a project fact, not a per-epic one.
|
|
417
|
+
const solo = isSolo(readJSON(path.join(root, PROJECT_FILES.hubConfig), null));
|
|
286
418
|
for (const e of fs.readdirSync(epicsDir).sort()) {
|
|
287
419
|
if (!fs.statSync(path.join(epicsDir, e)).isDirectory()) continue;
|
|
288
420
|
try {
|
|
@@ -308,6 +440,8 @@ export function epicChecks(checks, root) {
|
|
|
308
440
|
if (openPr) check(checks, `epic:${e}:migration`, 'epics', 'warn',
|
|
309
441
|
`${e}: an open review PR (${openPr.artifact}${openPr.number ? ` #${openPr.number}` : ''}) is recorded on the default branch`,
|
|
310
442
|
'opened under a pre-3.0 yadflow? merge/close it before continuing — CI now records the gate ledger on the default branch only at merge');
|
|
443
|
+
contractLockCheck(checks, root, e, ledger);
|
|
444
|
+
staleGateCheck(checks, root, e, ledger, { solo });
|
|
311
445
|
}
|
|
312
446
|
} catch (err) {
|
|
313
447
|
check(checks, `epic:${e}`, 'epics', 'fail', `${e}: ${err.message} [${err.code || 'YAD-STATE-001'}]`, err.hint || 'fix the file or restore it from git');
|
package/cli/epic-state.mjs
CHANGED
|
@@ -57,11 +57,19 @@ export function upsertHubPr(hubPrs = [], rec) {
|
|
|
57
57
|
return [...hubPrs.filter((p) => p.artifact !== rec.artifact), rec];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// SHA-256 of the contract surface block (architecture only).
|
|
61
|
-
// yad-architecture/references/contract-format.md
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
60
|
+
// SHA-256 of the contract surface block (architecture only). Byte-for-byte identical to the recipe
|
|
61
|
+
// yad-architecture/references/contract-format.md publishes — the one the architect runs to write
|
|
62
|
+
// contract-lock.json:
|
|
63
|
+
//
|
|
64
|
+
// awk '/CONTRACT-SURFACE:BEGIN/{f=1;next} /CONTRACT-SURFACE:END/{f=0} f' contract.md \
|
|
65
|
+
// | tr -d '\r' | shasum -a 256
|
|
66
|
+
//
|
|
67
|
+
// Canonicalization, in the order awk applies it: every line strictly between the markers, CRLF
|
|
68
|
+
// normalized to LF (so a CRLF re-save never revokes approvals), joined by LF, and TERMINATED by a
|
|
69
|
+
// trailing LF — awk emits a newline after every line it prints, so that last newline is part of the
|
|
70
|
+
// hashed bytes. Omitting it made the CLI digest and the documented recipe disagree by exactly one
|
|
71
|
+
// byte on every surface, so contract-lock.json could never equal what approvals bind to (issue #156).
|
|
72
|
+
// A BEGIN without an END is malformed and yields null — never a silent hash of everything to EOF.
|
|
65
73
|
export function contractSurfaceHash(epicDir) {
|
|
66
74
|
const file = path.join(epicDir, 'contract.md');
|
|
67
75
|
if (!fs.existsSync(file)) return null;
|
|
@@ -75,7 +83,7 @@ export function contractSurfaceHash(epicDir) {
|
|
|
75
83
|
if (inside) body.push(ln);
|
|
76
84
|
}
|
|
77
85
|
if (!terminated || !body.length) return null;
|
|
78
|
-
return 'sha256:' + createHash('sha256').update(body.join('\n')).digest('hex');
|
|
86
|
+
return 'sha256:' + createHash('sha256').update(body.join('\n') + '\n').digest('hex');
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
// Deterministic fingerprint of the whole stories/ set: hash each story file, sort, combine. Lets an
|
package/cli/gate.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { hubGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
|
|
17
17
|
import {
|
|
18
18
|
readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin,
|
|
19
|
-
getPrBody, editPrBody, postComment,
|
|
19
|
+
getPrBody, editPrBody, postComment, findPrForBranch, prBranch, branchExists,
|
|
20
20
|
} from './platform.mjs';
|
|
21
21
|
import { isNoBlock, upsertTrailerBlock, nudgeMessage, parseEngagement } from './companion.mjs';
|
|
22
22
|
import { sequenceDiff } from './walkthrough.mjs';
|
|
@@ -127,32 +127,54 @@ const requireEngagement = (hub) => !!(hub && (hub.review?.requireEngagement ===
|
|
|
127
127
|
// revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
|
|
128
128
|
// reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
|
|
129
129
|
// makes "revoke only when the artifact changed" work.
|
|
130
|
-
|
|
130
|
+
// `closed`: the step already advanced. Drop-and-re-add is what makes a dismissal or revocation vanish
|
|
131
|
+
// idempotently on an OPEN step — the platform is the live source of truth there. On a CLOSED step it
|
|
132
|
+
// is destructive instead: the gate passed, and the approvals that passed it are the audit record of
|
|
133
|
+
// why. A roster edit, a GitLab approval reset, or any degraded-but-`ok` read yields an empty `recs`
|
|
134
|
+
// and would erase them, leaving `done` with zero approvals — the very state issue #156 is about,
|
|
135
|
+
// reached from the other side. So a closed step's record is only ever added to or refreshed in place.
|
|
136
|
+
function upsertBridge(approvals, recs, { stepId, artifact, curHash, today, prNumber = null, closed = false }) {
|
|
131
137
|
const keyOf = (name, role, domain) => `${stepId}|${name}|${role}|${domain || ''}`;
|
|
132
138
|
const prior = new Map(
|
|
133
139
|
approvals.filter((a) => a.step === stepId && a.source === 'bridge')
|
|
134
140
|
.map((a) => [keyOf(a.approver, a.role, a.domain), a]),
|
|
135
141
|
);
|
|
136
|
-
const
|
|
142
|
+
const seen = new Set(recs.map((r) => keyOf(r.name, r.role, r.domain)));
|
|
143
|
+
const kept = approvals.filter((a) => {
|
|
144
|
+
if (!(a.step === stepId && a.source === 'bridge')) return true;
|
|
145
|
+
// Closed step: keep a prior approval the platform no longer reports. It is history, not state.
|
|
146
|
+
return closed && !seen.has(keyOf(a.approver, a.role, a.domain));
|
|
147
|
+
});
|
|
137
148
|
for (const r of recs) {
|
|
138
149
|
const was = prior.get(keyOf(r.name, r.role, r.domain));
|
|
139
150
|
let artHash = curHash; // first time we see this approval => bind to current content
|
|
140
151
|
let approvedAt = r.submittedAt || today;
|
|
152
|
+
let recordedOn = today;
|
|
141
153
|
if (was) {
|
|
142
|
-
// We only adopt the new hash when the platform PROVES a genuinely newer review
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
154
|
+
// We only adopt the new hash when the platform PROVES a genuinely newer review. Otherwise —
|
|
155
|
+
// the same review read again — we KEEP the hash they originally approved, so a later artifact
|
|
156
|
+
// change still revokes the approval. Two independent proofs, because one platform lacks each:
|
|
157
|
+
// - a later submittedAt (GitHub; GitLab approvals carry no timestamp at all), or
|
|
158
|
+
// - a DIFFERENT PR/MR than the one this approval was recorded against. A re-opened review is
|
|
159
|
+
// always a new PR, so an approval arriving on it cannot be the old one read again. Without
|
|
160
|
+
// this, a GitLab re-review after a re-lock re-recorded the pre-edit hash and stayed
|
|
161
|
+
// permanently stale — the step read `done` with zero live approvals (issue #156).
|
|
162
|
+
const newerReview = r.submittedAt && was.approvedAt && r.submittedAt > was.approvedAt;
|
|
163
|
+
const newerPr = prNumber != null && was.pr != null && was.pr !== prNumber;
|
|
164
|
+
if (!newerReview && !newerPr) {
|
|
147
165
|
artHash = was.artifactHash ?? curHash;
|
|
148
166
|
approvedAt = was.approvedAt ?? approvedAt;
|
|
167
|
+
// Same review, re-read: keep the date it was RECORDED too, so re-syncing an unchanged
|
|
168
|
+
// approval is a byte-identical no-op instead of a daily one-line ledger commit.
|
|
169
|
+
recordedOn = was.date ?? recordedOn;
|
|
149
170
|
}
|
|
150
171
|
}
|
|
151
172
|
kept.push({
|
|
152
173
|
artifact, step: stepId, approver: r.name, role: r.role,
|
|
153
174
|
...(r.domain ? { domain: r.domain } : {}),
|
|
154
|
-
status: 'approved', date:
|
|
175
|
+
status: 'approved', date: recordedOn, source: 'bridge',
|
|
155
176
|
artifactHash: artHash, approvedAt,
|
|
177
|
+
...(prNumber != null ? { pr: prNumber } : {}),
|
|
156
178
|
engagement: r.engagement === 'verified' ? 'verified' : 'none',
|
|
157
179
|
...(r.unverified ? { unverified: true } : {}),
|
|
158
180
|
});
|
|
@@ -160,6 +182,20 @@ function upsertBridge(approvals, recs, { stepId, artifact, curHash, today }) {
|
|
|
160
182
|
return kept;
|
|
161
183
|
}
|
|
162
184
|
|
|
185
|
+
// Mutates in place, returns how many it stamped. Backfill `pr` on this step's bridge approvals that
|
|
186
|
+
// predate approvals recording which PR they arrived on. `prNumber` must be the pointer they were
|
|
187
|
+
// recorded against — callers stamp only at the moment that pointer is about to be replaced, so nothing
|
|
188
|
+
// is invented: it is exactly the PR those approvals came from.
|
|
189
|
+
export function stampLegacyPr(approvals, stepId, prNumber) {
|
|
190
|
+
let n = 0;
|
|
191
|
+
for (const a of approvals) {
|
|
192
|
+
if (a.step !== stepId || a.source !== 'bridge' || a.pr != null) continue;
|
|
193
|
+
a.pr = prNumber;
|
|
194
|
+
n++;
|
|
195
|
+
}
|
|
196
|
+
return n;
|
|
197
|
+
}
|
|
198
|
+
|
|
163
199
|
function writeComments(epicDir, base, today, blocking) {
|
|
164
200
|
if (!blocking.length) return;
|
|
165
201
|
const file = path.join(epicDir, 'reviews', `${base}--${today}--comments.md`);
|
|
@@ -192,7 +228,57 @@ function recordComments(comments, { artifact, stepId, today, roster, blocking })
|
|
|
192
228
|
|
|
193
229
|
// ---- actions ------------------------------------------------------------------------------------
|
|
194
230
|
|
|
195
|
-
|
|
231
|
+
// The review PR/MR(s) to sync. Normally the ledger's own pointer — but under the bridge the ledger
|
|
232
|
+
// records that pointer only at merge (CI is the sole writer), so a review a human needs to push
|
|
233
|
+
// through by hand has NO recorded pointer at all. Fall back to the PR number the caller named
|
|
234
|
+
// (`--pr`), else resolve it from the review branch on the platform. Without this, `gate sync` reported
|
|
235
|
+
// "no open review PR recorded" for a PR sitting merged on the platform and the advance was
|
|
236
|
+
// unreachable by hand (issue #158).
|
|
237
|
+
// `--pr` is a recovery flag, so an explicit one WINS over the recorded pointer (a re-opened review is a
|
|
238
|
+
// new PR the ledger has not seen). It is also the one number a human types, so it is checked before it
|
|
239
|
+
// can bind approvals: it must be a positive integer, and — when the platform can be asked — it must be
|
|
240
|
+
// the PR for this artifact's review branch. Without that confirmation a typo'd number naming some
|
|
241
|
+
// unrelated merged-and-approved PR would have its reviewers bound to this artifact's hash and satisfy
|
|
242
|
+
// the gate.
|
|
243
|
+
function resolveTargets(hubPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd }) {
|
|
244
|
+
const recorded = hubPrs.filter((p) => !artifact || p.artifact === artifact);
|
|
245
|
+
const named = number == null || number === '' ? null : Number(number);
|
|
246
|
+
if (named !== null && (!Number.isInteger(named) || named <= 0)) {
|
|
247
|
+
return { targets: [], discovered: false, reason: `--pr must be a positive integer, got '${number}'` };
|
|
248
|
+
}
|
|
249
|
+
if (named === null && recorded.length) return { targets: recorded, discovered: false };
|
|
250
|
+
if (!artifact) return { targets: [], discovered: false, reason: 'name the artifact to resolve its review PR' };
|
|
251
|
+
const step = findReviewStep(state, artifact);
|
|
252
|
+
if (!step) return { targets: [], discovered: false, reason: `no review step for ${artifact}` };
|
|
253
|
+
const branch = `review/${epic}/${base(artifact)}`;
|
|
254
|
+
// `upsertHubPr` replaces the whole entry for an artifact, so a record built from scratch DROPS
|
|
255
|
+
// whatever the recorded one carried. That matters when `--pr` names the PR already on file: `nudged`
|
|
256
|
+
// is the idempotency set for the engagement nudge, so losing it makes the next writer run
|
|
257
|
+
// re-@-mention every bare approver on the PR — a platform write, not just a ledger one — and `url`
|
|
258
|
+
// would churn to null. Carry the recorded entry forward whenever the number is the same one.
|
|
259
|
+
const entry = (n, url) => {
|
|
260
|
+
const prev = recorded.find((p) => p.number === n) || {};
|
|
261
|
+
return [{ ...prev, step: step.id, artifact, platform, number: n, url: url ?? prev.url ?? null, branch, lastSyncedAt: prev.lastSyncedAt ?? null }];
|
|
262
|
+
};
|
|
263
|
+
if (named !== null) {
|
|
264
|
+
// Confirm the number names THIS artifact's review before its reviewers are bound to this
|
|
265
|
+
// artifact's hash. A platform that cannot answer (no CLI, no auth, offline) is not evidence
|
|
266
|
+
// against it — warn and take the human at their word — but a definite mismatch is refused.
|
|
267
|
+
const head = branchOf(platform, named, { cwd });
|
|
268
|
+
if (head.ok && head.branch !== branch) {
|
|
269
|
+
return { targets: [], discovered: false, reason: `#${named} is on '${head.branch}', not this artifact's review branch '${branch}'` };
|
|
270
|
+
}
|
|
271
|
+
if (!head.ok) warn(`could not confirm #${named} belongs to ${branch} (${head.reason}) — using it as given`);
|
|
272
|
+
if (recorded.length && recorded[0].number !== named) info(`--pr #${named} overrides the recorded review PR #${recorded[0].number}`);
|
|
273
|
+
return { targets: entry(named, null), discovered: true };
|
|
274
|
+
}
|
|
275
|
+
const found = finder(platform, branch, { cwd });
|
|
276
|
+
if (!found.ok) return { targets: [], discovered: false, reason: found.reason };
|
|
277
|
+
info(`no recorded review PR — resolved #${found.number}${found.state ? ` (${found.state})` : ''} from ${branch}`);
|
|
278
|
+
return { targets: entry(found.number, found.url), discovered: true };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function gateSync(root, { epic, artifact, today, reader = readPr, finder = findPrForBranch, branchOf = prBranch, poster = postComment, number = null, local = false, dryRun = false } = {}) {
|
|
196
282
|
const { hub, repos } = loadHub(root);
|
|
197
283
|
if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — file-only gate, nothing to sync'); return { synced: 0 }; }
|
|
198
284
|
const platform = hub.platform;
|
|
@@ -211,17 +297,42 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
|
|
|
211
297
|
if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { synced: 0 }; }
|
|
212
298
|
|
|
213
299
|
let { approvals, comments, hubPrs, state } = ledger;
|
|
214
|
-
|
|
215
|
-
|
|
300
|
+
// Migration (see stampLegacyPr): an approval written before PR provenance existed carries no `pr`,
|
|
301
|
+
// so it can never be told apart from one arriving on a replacement PR — and on GitLab, with no
|
|
302
|
+
// submittedAt either, the other proof is unavailable too. The pointer recorded here IS the PR those
|
|
303
|
+
// approvals came from, so stamp them before anything replaces it.
|
|
304
|
+
for (const p of hubPrs) {
|
|
305
|
+
const s = p.number != null ? findReviewStep(state, p.artifact) : null;
|
|
306
|
+
if (s) stampLegacyPr(approvals, s.id, p.number);
|
|
307
|
+
}
|
|
308
|
+
const resolved = resolveTargets(hubPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd: root });
|
|
309
|
+
const targets = resolved.targets;
|
|
310
|
+
if (!targets.length) {
|
|
311
|
+
warn(`no review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''}${resolved.reason ? ` — ${resolved.reason}` : ''}`);
|
|
312
|
+
hand(`run \`yad gate open ${epic} ${artifact || '<artifact>'}\`, or name the PR: \`yad gate sync ${epic} ${artifact || '<artifact>'} --pr <n>\``);
|
|
313
|
+
return { synced: 0 };
|
|
314
|
+
}
|
|
315
|
+
// A pointer resolved from the platform is adopted into the ledger on the WRITER path only. In
|
|
316
|
+
// bridge mode this run is advisory and writes nothing, so the human never ends up with a gate-state
|
|
317
|
+
// file in their working tree for the ledger-guard check to reject.
|
|
318
|
+
if (resolved.discovered && !readOnly) hubPrs = upsertHubPr(hubPrs, targets[0]);
|
|
216
319
|
|
|
217
320
|
let synced = 0;
|
|
218
321
|
let advanced = 0;
|
|
322
|
+
// Targets whose step is still open. The dated approval-roster file is regenerated only for these —
|
|
323
|
+
// an already-done step is re-synced for its approvals alone, and would otherwise drop a new
|
|
324
|
+
// reviews/<artifact>--<today>--approved.md every time the scheduled sweep re-visits it.
|
|
325
|
+
const open = [];
|
|
219
326
|
for (const pr of targets) {
|
|
220
327
|
const step = findReviewStep(state, pr.artifact);
|
|
221
328
|
if (!step) { warn(`no review step for ${pr.artifact}`); continue; }
|
|
222
|
-
//
|
|
223
|
-
// currentStep backward)
|
|
224
|
-
|
|
329
|
+
// A step that already advanced is never advanced AGAIN (that would reset the next step's status /
|
|
330
|
+
// currentStep backward) — the gate is one-way per step. But it is still SYNCED: in bridge mode
|
|
331
|
+
// nothing ever moves a step back to in_review (CI is the sole ledger writer), so a re-opened
|
|
332
|
+
// review — surface re-locked, fresh PR, fresh approvals, merged — used to hit a blanket skip here
|
|
333
|
+
// and write nothing but the PR pointer. The step then read `done` while its approvals were all
|
|
334
|
+
// stale: work proceeded on an audit trail saying the re-review never happened (issue #156).
|
|
335
|
+
const alreadyDone = step.status === 'done';
|
|
225
336
|
const domains = touchedDomains(epicDir, step);
|
|
226
337
|
const pull = reader(platform, pr.number, { cwd: root });
|
|
227
338
|
// A failed platform read must not pass as a green no-op: flag the run non-zero so CI surfaces it
|
|
@@ -231,8 +342,9 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
|
|
|
231
342
|
const curHash = artifactHash(epicDir, pr.artifact);
|
|
232
343
|
warnUnlockedContract(epicDir, pr.artifact);
|
|
233
344
|
warnIncompleteDiscovery(epicDir, pr.artifact);
|
|
345
|
+
const approvalsBefore = JSON.stringify(approvals);
|
|
234
346
|
const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains, headOid: pull.headOid });
|
|
235
|
-
approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today });
|
|
347
|
+
approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today, prNumber: pr.number ?? null, closed: alreadyDone });
|
|
236
348
|
|
|
237
349
|
const changeRequested = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
|
|
238
350
|
// 2f: companion scaffolding + nudge threads carry the noblock marker and are EXCLUDED from the
|
|
@@ -245,17 +357,27 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
|
|
|
245
357
|
...unresolved,
|
|
246
358
|
];
|
|
247
359
|
// Advisory (read-only) sync must not touch the working tree — defer the reviews/*.md write.
|
|
248
|
-
|
|
249
|
-
|
|
360
|
+
//
|
|
361
|
+
// An already-done step is re-synced for its APPROVALS ONLY. Everything else here is per-round
|
|
362
|
+
// bookkeeping for a review still in flight, and re-running it on a closed one is pure churn: both
|
|
363
|
+
// wired sweeps drive `gate ci --branch … --merged` (event mode) over a 7-day window, so a merged
|
|
364
|
+
// review that still carries one unresolved thread would append a fresh comment round — and a fresh
|
|
365
|
+
// `chore(gate): advance … [skip ci]` commit on the default branch — every 15 minutes for a week.
|
|
366
|
+
// That is the same churn the resource_group fix exists to stop, so it must not be reintroduced here.
|
|
367
|
+
if (!alreadyDone) {
|
|
368
|
+
if (!readOnly) writeComments(epicDir, base(pr.artifact), today, blocking);
|
|
369
|
+
comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, roster, repos, blocking });
|
|
370
|
+
}
|
|
250
371
|
|
|
251
372
|
// Social nudge: a bare APPROVE (no verified engagement) still counts (soft default), but the bot
|
|
252
373
|
// posts a friendly public @-mention inviting the reviewer to run the companion. Idempotent via
|
|
253
|
-
// pr.nudged; only on the writer path (a platform comment, not a ledger write)
|
|
254
|
-
|
|
374
|
+
// pr.nudged; only on the writer path (a platform comment, not a ledger write) — and never on a
|
|
375
|
+
// closed step, where it would @-mention reviewers on an already-merged PR.
|
|
376
|
+
if (!readOnly && !alreadyDone) {
|
|
255
377
|
const nudged = new Set(pr.nudged || []);
|
|
256
378
|
for (const rv of pull.reviews) {
|
|
257
379
|
if (rv.state !== 'APPROVED' || parseEngagement(rv.body) === 'verified' || !rv.login || nudged.has(rv.login)) continue;
|
|
258
|
-
if (
|
|
380
|
+
if (poster(platform, pr.number, nudgeMessage(rv.login), { cwd: root }).ok) nudged.add(rv.login);
|
|
259
381
|
}
|
|
260
382
|
pr.nudged = [...nudged];
|
|
261
383
|
}
|
|
@@ -266,7 +388,15 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
|
|
|
266
388
|
});
|
|
267
389
|
|
|
268
390
|
log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule})`)}`);
|
|
269
|
-
if (
|
|
391
|
+
if (alreadyDone) {
|
|
392
|
+
// The step keeps its `done` status and the chain is untouched — re-advancing would reset the
|
|
393
|
+
// next step, and moving it back to in_review would un-ship work already built on it. What this
|
|
394
|
+
// pass DOES do is record the approvals that arrived, so `gate status` tells the truth about how
|
|
395
|
+
// many of them are live against the current artifact.
|
|
396
|
+
const verdict = pred.passed ? 'the rule still holds' : `the rule no longer holds${pred.staleDropped ? ` (${pred.staleDropped} stale)` : ''}`;
|
|
397
|
+
info(`${step.id} already done — approvals re-synced, chain not re-advanced; ${verdict}`);
|
|
398
|
+
for (const m of pred.missing) hand(`recorded gap: ${m}`);
|
|
399
|
+
} else if (pred.passed) {
|
|
270
400
|
state = advanceState(state, step);
|
|
271
401
|
advanced++;
|
|
272
402
|
ok(`gate PASSED — ${step.id} → done; next: ${state.currentStep}`);
|
|
@@ -274,7 +404,12 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
|
|
|
274
404
|
state = markInReview(state, step);
|
|
275
405
|
for (const m of pred.missing) hand(`still needed: ${m}`);
|
|
276
406
|
}
|
|
277
|
-
|
|
407
|
+
// Stamp when this run actually learned something: an open step every time, and a closed one only
|
|
408
|
+
// when the approval record genuinely changed (a re-opened review that was re-approved). Otherwise
|
|
409
|
+
// an identical re-sync would rewrite the date daily and churn the ledger, while a real re-review
|
|
410
|
+
// would leave no trace of when it was reconciled.
|
|
411
|
+
if (!alreadyDone) { pr.lastSyncedAt = today; open.push(pr); }
|
|
412
|
+
else if (JSON.stringify(approvals) !== approvalsBefore) pr.lastSyncedAt = today;
|
|
278
413
|
synced++;
|
|
279
414
|
}
|
|
280
415
|
|
|
@@ -286,7 +421,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
|
|
|
286
421
|
writeJSON(ledger.files.comments, comments);
|
|
287
422
|
writeJSON(ledger.files.hubPrs, hubPrs);
|
|
288
423
|
writeJSON(ledger.files.state, state);
|
|
289
|
-
refreshRoster(epicDir,
|
|
424
|
+
refreshRoster(epicDir, open, approvals, today);
|
|
290
425
|
return { synced, advanced };
|
|
291
426
|
}
|
|
292
427
|
|
|
@@ -370,6 +505,16 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
370
505
|
// build the entry from the event itself so the advance commit carries it onto the default branch.
|
|
371
506
|
const existing = (ledger.hubPrs || []).find((x) => x.artifact === job.artifact);
|
|
372
507
|
const number = Number(job.pr) || existing?.number || null;
|
|
508
|
+
// Same migration as gateSync, at the one point CI knows the OLD pointer: stamp the approvals it
|
|
509
|
+
// recorded before replacing it, or a re-review on the replacement PR can never be told from a
|
|
510
|
+
// re-read of the old one and stays permanently stale.
|
|
511
|
+
if (existing?.number != null && number !== existing.number) {
|
|
512
|
+
const stamped = stampLegacyPr(ledger.approvals, step.id, existing.number);
|
|
513
|
+
if (stamped) {
|
|
514
|
+
writeJSON(ledger.files.approvals, ledger.approvals);
|
|
515
|
+
info(`${job.epic}: recorded PR #${existing.number} on ${stamped} approval(s) that predate PR provenance`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
373
518
|
if (!existing || existing.number !== number || existing.branch !== job.branch) {
|
|
374
519
|
ledger.hubPrs = upsertHubPr(ledger.hubPrs, {
|
|
375
520
|
step: step.id, artifact: job.artifact, platform: hub.platform, number,
|
|
@@ -555,7 +700,7 @@ export async function gateRepair(root, { epic, push = false, allowBranch = false
|
|
|
555
700
|
// the user's checked-out branch, which for a per-story review (review/EP-*/stories-S01) does NOT equal
|
|
556
701
|
// the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
|
|
557
702
|
// the real pushed head so the PR targets a branch that exists. `creator` is injected in tests.
|
|
558
|
-
export async function gateOpen(root, { epic, artifact, head, creator = createPr } = {}) {
|
|
703
|
+
export async function gateOpen(root, { epic, artifact, head, creator = createPr, hasBranch = branchExists } = {}) {
|
|
559
704
|
const { hub, repos } = loadHub(root);
|
|
560
705
|
const epicDir = epicRoot(root, epic);
|
|
561
706
|
const ledger = loadLedger(epicDir);
|
|
@@ -570,6 +715,27 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr
|
|
|
570
715
|
warnIncompleteDiscovery(epicDir, artifact);
|
|
571
716
|
|
|
572
717
|
const bridge = isBridge(hub);
|
|
718
|
+
// The review branch must exist ON ORIGIN: this command opens a PR against it, it never creates or
|
|
719
|
+
// pushes it, and `gh pr create --head` explicitly does NOT push either — so a branch that is only
|
|
720
|
+
// local still fails inside the platform CLI, which is the opaque error this guard exists to replace.
|
|
721
|
+
// `open-pr` pushes the checked-out branch first and passes it as `head`, so that path is unaffected;
|
|
722
|
+
// only the branch this command COMPUTED is checked. A null answer means git could not be asked (no
|
|
723
|
+
// checkout, unreachable origin) — not evidence of absence, so it warns rather than blocks.
|
|
724
|
+
//
|
|
725
|
+
// Checked BEFORE any state is written: marking the step in_review and then refusing would leave the
|
|
726
|
+
// ledger claiming a review is open that was never opened.
|
|
727
|
+
if (!head && hub?.platform) {
|
|
728
|
+
const present = hasBranch(root, branch);
|
|
729
|
+
if (present === false) {
|
|
730
|
+
fail(`review branch '${branch}' is not on origin`);
|
|
731
|
+
hand(`git push -u origin ${branch}`);
|
|
732
|
+
hand('or run `yad open-pr` from the branch — it pushes, then opens the review PR');
|
|
733
|
+
process.exitCode = 1;
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (present === null) warn(`could not verify that '${branch}' is on origin — opening the PR against it anyway`);
|
|
737
|
+
}
|
|
738
|
+
|
|
573
739
|
// Outside bridge mode (file-only, OR a platform with no gate-sync CI) there is no CI to write the
|
|
574
740
|
// ledger, so the local command marks the step in_review. In bridge mode CI is the sole writer.
|
|
575
741
|
if (!bridge) {
|