yadflow 3.12.2 → 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 +30 -0
- package/bin/yad.mjs +26 -6
- package/cli/checkpoint.mjs +59 -1
- package/cli/doctor.mjs +113 -1
- package/cli/epic-state.mjs +14 -6
- package/cli/gate.mjs +191 -25
- package/cli/ledger.mjs +26 -0
- 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-engineer-review/references/ship-and-record.md +20 -0
- 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/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) {
|
package/cli/ledger.mjs
CHANGED
|
@@ -79,6 +79,32 @@ export function readShips(epicDir) {
|
|
|
79
79
|
return [...byKey.values()];
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// Record a RETROACTIVE ship for a pre-tracking story — one merged & shipped before the back-half
|
|
83
|
+
// ledger existed, so it has no build-log ship and `yad checkpoint` can't carry its `status:` flip
|
|
84
|
+
// (issue #142). Writes ONE minimal ship shard, marked `retroactive: true`, so `readShips` now proves
|
|
85
|
+
// the story shipped and checkpoint carries the human's already-made flip. It is NOT a fabricated real
|
|
86
|
+
// ship: `task` defaults to the sentinel `retro`, `mergeCommit` is written only when the caller supplies
|
|
87
|
+
// it (never invented), and `shippedAt` is the backfill date (the `retroactive` flag marks it as such).
|
|
88
|
+
//
|
|
89
|
+
// Guard: refuse when the story ALREADY has ANY build-log ship — then it isn't pre-tracking and the
|
|
90
|
+
// normal ship/checkpoint flow applies; a retro record would only muddy the ledger. Returns
|
|
91
|
+
// { written: false, reason } in that case, else { written: true, file, ship }.
|
|
92
|
+
export function writeRetroShip(epicDir, { story, repo, task = 'retro', mergeCommit, shippedAt }) {
|
|
93
|
+
if (!story) throw new Error('writeRetroShip: story is required');
|
|
94
|
+
if (!repo) throw new Error('writeRetroShip: repo is required');
|
|
95
|
+
if (readShips(epicDir).some((s) => s.story === story)) {
|
|
96
|
+
return { written: false, reason: 'exists' };
|
|
97
|
+
}
|
|
98
|
+
const t = task || 'retro';
|
|
99
|
+
const ship = { story, task: t, repo, retroactive: true, note: 'pre-tracking backfill' };
|
|
100
|
+
if (mergeCommit) ship.mergeCommit = mergeCommit;
|
|
101
|
+
if (shippedAt) ship.shippedAt = shippedAt;
|
|
102
|
+
const f = epicFiles(epicDir);
|
|
103
|
+
const file = path.join(f.buildLogDir, buildShardName({ story, task: t, repo }));
|
|
104
|
+
writeJSON(file, ship);
|
|
105
|
+
return { written: true, file, ship };
|
|
106
|
+
}
|
|
107
|
+
|
|
82
108
|
// Find the ship matching `match(ship)` across loose shards (authoritative until folded) then the
|
|
83
109
|
// folded file, apply `update(ship)`, and write back ONLY the file that holds it. Returns
|
|
84
110
|
// { found, where, file, ship }; found:false writes nothing (the caller warns).
|
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.
|
|
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
|