yadflow 3.18.1 → 4.0.0-next.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 +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +12 -12
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
package/cli/gate.mjs
CHANGED
|
@@ -1,29 +1,67 @@
|
|
|
1
|
-
// `yad gate open|sync|comments|status` — the PR/MR-driven
|
|
1
|
+
// `yad gate open|sync|comments|status` — the PR/MR-driven Shape review gate.
|
|
2
2
|
// The platform PR/MR is the review UI; this command syncs its state into the file ledger and, when
|
|
3
3
|
// the gate passes (approvals satisfied + all comment threads resolved + PR merged), auto-advances the
|
|
4
|
-
// step. The merge click is the human approval act, so
|
|
4
|
+
// step. The merge click is the human approval act, so Shape steps still never machine_advance.
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import {
|
|
8
|
-
c, log, ok, info, warn, hand, fail, note, readJSONStrict, writeJSON, run, pushWithRebase,
|
|
8
|
+
c, log, ok, info, warn, hand, fail, note, readJSON, readJSONStrict, writeJSON, run, pushWithRebase, isPlainObject, writeMirrored, emitJSON, refuse, collectWarning,
|
|
9
9
|
} from './lib.mjs';
|
|
10
|
-
import { PROJECT_FILES,
|
|
10
|
+
import { PROJECT_FILES, isVerifiedLedger , productConfigPath } from './manifest.mjs';
|
|
11
11
|
import {
|
|
12
|
-
epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
|
|
13
|
-
advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
|
|
14
|
-
upsertHubPr, stateInvariants, repairState, DISCOVERY_FILES,
|
|
15
|
-
canonicalApprovals, canonicalComments, canonicalHubPrs,
|
|
12
|
+
epicIds, epicRel, epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, acceptedHashes, isStaleHash, gatePredicate, printable,
|
|
13
|
+
advanceState, closingRecord, markInReview, isEscalated, gateRuleFor, gateCapFor, gateReach, uniqueReach, peopleWord, capSeat, gateRuleSum, gateRuleEnforced, parseReviewBranch, artifactFromBase, legacyLogins,
|
|
14
|
+
upsertHubPr, stateInvariants, repairState, DISCOVERY_FILES, FOUNDATION_REQUIRED, unwrittenSections,
|
|
15
|
+
canonicalApprovals, canonicalComments, canonicalHubPrs, optionalStepsFor, isSkippableStep, writeState, routeLacksStep,
|
|
16
|
+
isPassed, stepStatus, claimsSkipped, claimsInherited, DISCOVERY_EPIC, FOUNDATION_DIR, FOUNDATION_EPIC, staleFoundationGuards,
|
|
16
17
|
} from './epic-state.mjs';
|
|
17
|
-
import {
|
|
18
|
+
import { activePeople, activeSum, activeBasis, approverCount, printTeamHint, soloTeamHint } from './people.mjs';
|
|
19
|
+
import { applyProductMove, planProductMove } from './migrate.mjs';
|
|
20
|
+
import { productGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
|
|
18
21
|
import {
|
|
19
|
-
readPr, mapApprovers, createPr,
|
|
22
|
+
readPr, mapApprovers, createPr, platformLogin, actorName, ambiguousLegacyNames, prNumberFromUrl,
|
|
20
23
|
getPrBody, editPrBody, postComment, findPrForBranch, prBranch, branchExists,
|
|
21
24
|
} from './platform.mjs';
|
|
22
25
|
import { isNoBlock, upsertTrailerBlock, nudgeMessage, parseEngagement } from './companion.mjs';
|
|
23
26
|
import { sequenceDiff } from './walkthrough.mjs';
|
|
24
27
|
import { syncStatuses } from './artifact-status.mjs';
|
|
28
|
+
import { writeIndex, refreshIndexAfterWrite, uncommittedIndexInputs, INDEX_FILE } from './product-index.mjs';
|
|
25
29
|
import { err } from './errors.mjs';
|
|
26
30
|
|
|
31
|
+
// Who WRITES a closing record (E18): the platform login, else the raw git user.name, else null
|
|
32
|
+
// (`actorName`). On CI that is usually the bot's git name — a job token cannot read `/user`. Best-effort,
|
|
33
|
+
// like every record's `by`: attribution never blocks a gate. Kept here rather than imported from
|
|
34
|
+
// skip.mjs, which imports this file.
|
|
35
|
+
function closingActor(root, hub) {
|
|
36
|
+
return actorName(root, hub?.platform);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// One line for a step's closing record — `yad gate status` prints it under a review step (E18), and
|
|
40
|
+
// `yad history show` under every step (E20), so the two views say it in the same words.
|
|
41
|
+
//
|
|
42
|
+
// The record is a file a person can edit, and it is printed on other people's terminals: a value is used
|
|
43
|
+
// only when it is text (made safe by `printable`), and a PR only when it is a whole number (E20 review —
|
|
44
|
+
// an object printed as `[object Object]`, and `pr: "9; rm"` as `PR #9; rm`).
|
|
45
|
+
// `prText`: a PR number as the text to print, or null. Text, not a number — a number written `"007"`
|
|
46
|
+
// prints as written. (Not `prNumber`: two functions below take a parameter of that name.)
|
|
47
|
+
export const prText = (v) => (Number.isInteger(v) && v >= 0 ? String(v) : typeof v === 'string' && /^\d+$/.test(v) ? v : null);
|
|
48
|
+
export function closedLine(closed) {
|
|
49
|
+
const [via, mergedBy, commit, date, by, pr] = [closed.via, closed.mergedBy, closed.commit, closed.date, closed.by].map(printable).concat(prText(closed.pr));
|
|
50
|
+
const how = via === 'merge'
|
|
51
|
+
? `merged${mergedBy ? ` by ${mergedBy}` : ''}${pr !== null ? ` (PR #${pr})` : ''}${commit ? ` at ${commit.slice(0, 7)}` : ''}`
|
|
52
|
+
: `via ${via || 'an unknown path'}${pr !== null ? ` (PR #${pr})` : ''}`;
|
|
53
|
+
const waivedText = printable(closed.waived);
|
|
54
|
+
const waived = waivedText === 'solo' ? '; approvals waived (solo mode)' : waivedText ? `; approvals waived (${waivedText})` : '';
|
|
55
|
+
// E72 — a count the capacity cap lowered. Read strictly: the record is a file a person can edit, and
|
|
56
|
+
// a line built from half a record would state a cap nobody applied. All three numbers, and a `to`
|
|
57
|
+
// of at least 1 (the base) and below `needed` (a cap only ever lowers), or nothing.
|
|
58
|
+
const k = closed.capped;
|
|
59
|
+
const whole = (n) => Number.isInteger(n) && n >= 0;
|
|
60
|
+
const capped = k && typeof k === 'object' && !Array.isArray(k) && whole(k.needed) && whole(k.to) && whole(k.active) && k.to >= 1 && k.to < k.needed
|
|
61
|
+
? `; count capped from ${k.needed} to ${k.to} (${k.active} active ${peopleWord(k.active)})` : '';
|
|
62
|
+
return `closed${date ? ` on ${date}` : ''} — ${how}${waived}${capped}${by ? `; recorded by ${by}` : ''}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
27
65
|
// ---- tiny frontmatter reader (key: value, and `repos: [a, b]`) ----------------------------------
|
|
28
66
|
function frontmatter(file) {
|
|
29
67
|
if (!fs.existsSync(file)) return {};
|
|
@@ -39,11 +77,11 @@ function frontmatter(file) {
|
|
|
39
77
|
return out;
|
|
40
78
|
}
|
|
41
79
|
|
|
42
|
-
// Touched domains, resolved from files (gating.md):
|
|
43
|
-
//
|
|
80
|
+
// Touched domains, resolved from files (gating.md): stories => union of every story's repos; a step
|
|
81
|
+
// carrying a risk tag (architecture's `contract`) => epic.repos; otherwise none. The stories clause is
|
|
82
|
+
// checked FIRST and on its own: it used to live inside `isEscalated`, which E62 narrowed to risk tags.
|
|
44
83
|
export function touchedDomains(epicDir, step) {
|
|
45
|
-
if (
|
|
46
|
-
if (step.id === 'stories-review') {
|
|
84
|
+
if (step?.id === 'stories-review') {
|
|
47
85
|
const dir = path.join(epicDir, 'stories');
|
|
48
86
|
if (!fs.existsSync(dir)) return [];
|
|
49
87
|
const set = new Set();
|
|
@@ -52,11 +90,13 @@ export function touchedDomains(epicDir, step) {
|
|
|
52
90
|
}
|
|
53
91
|
return [...set];
|
|
54
92
|
}
|
|
93
|
+
if (!isEscalated(step)) return [];
|
|
55
94
|
return frontmatter(path.join(epicDir, 'epic.md')).repos || [];
|
|
56
95
|
}
|
|
57
96
|
|
|
58
|
-
// The artifact owner shown in the review PR/MR body. Feature epics carry it in epic.md; the
|
|
59
|
-
//
|
|
97
|
+
// The artifact owner shown in the review PR/MR body. Feature epics carry it in epic.md; the Product
|
|
98
|
+
// level has no epic.md, so fall back to roadmap.md's frontmatter owner — a section the Foundation and
|
|
99
|
+
// its old `EP-discovery` spelling both have, under that same name, in their own folder.
|
|
60
100
|
const ownerOf = (epicDir) =>
|
|
61
101
|
frontmatter(path.join(epicDir, 'epic.md')).owner
|
|
62
102
|
|| frontmatter(path.join(epicDir, 'roadmap.md')).owner
|
|
@@ -73,23 +113,42 @@ function warnUnlockedContract(epicDir, artifact) {
|
|
|
73
113
|
}
|
|
74
114
|
}
|
|
75
115
|
|
|
76
|
-
// A null
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
116
|
+
// A null hash on a product-level set means it is incomplete (a required file is missing), so the review
|
|
117
|
+
// is not yet reviewable and an approval would not be hash-bound. Name the missing files so the owner can
|
|
118
|
+
// complete the set before the gate is opened/advanced (mirrors warnUnlockedContract). Two sets: the
|
|
119
|
+
// Foundation's required sections (E75 — its optional ones never make it incomplete) and the six files
|
|
120
|
+
// of the old `discovery` spelling.
|
|
121
|
+
//
|
|
122
|
+
// A complete Foundation can still be an EMPTY one (E76): every section present and holding only its
|
|
123
|
+
// template headings has a fingerprint, so reviewers could approve it. That is named too — a warning,
|
|
124
|
+
// never a refusal, the stance E75 took on the Foundation's gate itself. The old `discovery` spelling
|
|
125
|
+
// is not asked: its templates were never read this way, and it is only ever converted, not authored.
|
|
126
|
+
export function warnIncompleteDiscovery(epicDir, artifact) {
|
|
127
|
+
const b = artifactBase(artifact);
|
|
128
|
+
const required = b === 'foundation' ? FOUNDATION_REQUIRED : b === 'discovery' ? DISCOVERY_FILES : null;
|
|
129
|
+
if (!required) return;
|
|
130
|
+
if (artifactHash(epicDir, artifact) === null) {
|
|
131
|
+
const missing = required.filter((f) => !fs.existsSync(path.join(epicDir, f)));
|
|
132
|
+
const label = b === 'foundation' ? 'Foundation incomplete' : 'discovery set incomplete';
|
|
133
|
+
warn(`${label} — missing ${missing.join(', ')}; review is not yet reviewable (approvals will not be hash-bound until the full set exists)`);
|
|
134
|
+
}
|
|
135
|
+
if (b !== 'foundation') return;
|
|
136
|
+
const empty = unwrittenSections(epicDir);
|
|
137
|
+
if (empty.length) {
|
|
138
|
+
// Worded for both callers: `gate open` before any approval, and `gate sync` on a review that may
|
|
139
|
+
// already have passed — so it says what an approval of this content means, not that one is coming.
|
|
140
|
+
const one = empty.length === 1;
|
|
141
|
+
warn(`Foundation not written yet — ${empty.join(', ')} ${one ? 'holds nothing but its' : 'hold nothing but their'} template, so an approval of ${one ? 'it approves an empty section' : 'them approves empty sections'}`);
|
|
142
|
+
}
|
|
84
143
|
}
|
|
85
144
|
|
|
86
|
-
// Fail fast on a corrupt or wrong-shape
|
|
87
|
-
// every gate to
|
|
88
|
-
export function
|
|
89
|
-
const hubFile =
|
|
145
|
+
// Fail fast on a corrupt or wrong-shape Product config: a silently-defaulted hub.json would degrade
|
|
146
|
+
// every gate to local without anyone noticing, and a typo'd platform would read as a local ledger.
|
|
147
|
+
export function loadProduct(root) {
|
|
148
|
+
const hubFile = productConfigPath(root);
|
|
90
149
|
const regFile = path.join(root, PROJECT_FILES.reposRegistry);
|
|
91
|
-
// Distinguish an ABSENT hub.json (null default → fine,
|
|
92
|
-
// holds literal `null` (malformed — must not silently downgrade to
|
|
150
|
+
// Distinguish an ABSENT hub.json (null default → fine, local gate) from one that exists but
|
|
151
|
+
// holds literal `null` (malformed — must not silently downgrade to local).
|
|
93
152
|
const hub = readJSONStrict(hubFile, null);
|
|
94
153
|
if (hub === null && fs.existsSync(hubFile)) {
|
|
95
154
|
throw err('YAD-STATE-002', `${hubFile}: contains \`null\` — expected a config object`, 'fix the file or re-run `yad setup`');
|
|
@@ -99,9 +158,6 @@ export function loadHub(root) {
|
|
|
99
158
|
if (![null, undefined, 'github', 'gitlab'].includes(hub.platform)) {
|
|
100
159
|
throw err('YAD-CFG-001', `${hubFile}: unknown platform '${hub.platform}'`, 'expected github, gitlab, or null — fix the file or re-run `yad setup`');
|
|
101
160
|
}
|
|
102
|
-
if (hub.roster !== undefined && !Array.isArray(hub.roster)) {
|
|
103
|
-
throw err('YAD-STATE-002', `${hubFile}: expected \`roster\` to be an array`, 'fix the file or re-run `yad setup`');
|
|
104
|
-
}
|
|
105
161
|
}
|
|
106
162
|
const registry = readJSONStrict(regFile, { repos: [] });
|
|
107
163
|
if (!Array.isArray(registry?.repos)) throw err('YAD-STATE-002', `${regFile}: expected a \`repos\` array`, 'fix the file or re-run `yad setup`');
|
|
@@ -113,16 +169,21 @@ export function loadHub(root) {
|
|
|
113
169
|
// merge advances the step). Recorded per-project in hub.json by `yad setup`.
|
|
114
170
|
export const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
|
|
115
171
|
|
|
116
|
-
//
|
|
117
|
-
// defined once in manifest.mjs (`
|
|
172
|
+
// Verified mode: CI is the sole ledger writer, so `gate open`/`sync` stay hands-off. The predicate is
|
|
173
|
+
// defined once in manifest.mjs (`isVerifiedLedger`) and shared with plan.mjs's wiring and the ledger
|
|
118
174
|
// hook, so no two readers can disagree about who owns the ledger (#186).
|
|
119
|
-
const isBridge = isBridgeHub;
|
|
120
175
|
|
|
121
176
|
// requireEngagement (config `hub.review.requireEngagement`): when on, the predicate counts only
|
|
122
177
|
// approvals carrying a verified engagement signal. Soft-off by default — a bare approve still counts
|
|
123
178
|
// but is recorded `engagement: none` and draws the friendly nudge.
|
|
124
179
|
export const requireEngagement = (hub) => !!(hub && (hub.review?.requireEngagement === true));
|
|
125
180
|
|
|
181
|
+
// `legacyLogins` LIVES in cli/epic-state.mjs now (E71): the active-people reader needs the same table to
|
|
182
|
+
// recognise an older record, and importing it from here would have made a cycle (gate.mjs prints the
|
|
183
|
+
// count that reader returns). This re-export stays — rule 3, the new name beside the old — and is the
|
|
184
|
+
// one every older reader here and in cli/usage.mjs still calls.
|
|
185
|
+
export { legacyLogins };
|
|
186
|
+
|
|
126
187
|
// Re-add this step's bridge approvals from the current platform state (drop+re-add => dismissals and
|
|
127
188
|
// revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
|
|
128
189
|
// reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
|
|
@@ -130,53 +191,194 @@ export const requireEngagement = (hub) => !!(hub && (hub.review?.requireEngageme
|
|
|
130
191
|
// `closed`: the step already advanced. Drop-and-re-add is what makes a dismissal or revocation vanish
|
|
131
192
|
// idempotently on an OPEN step — the platform is the live source of truth there. On a CLOSED step it
|
|
132
193
|
// is destructive instead: the gate passed, and the approvals that passed it are the audit record of
|
|
133
|
-
// why. A
|
|
194
|
+
// why. A GitLab approval reset, or any degraded-but-`ok` read yields an empty `recs`
|
|
134
195
|
// and would erase them, leaving `done` with zero approvals — the very state issue #156 is about,
|
|
135
196
|
// reached from the other side. So a closed step's record is only ever added to or refreshed in place.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
197
|
+
//
|
|
198
|
+
// ONE RECORD PER PERSON (E62). An approval is keyed by who gave it and nothing else. Before E62 the key
|
|
199
|
+
// also held the role and domain the roster gave that person, so one approval could be several records;
|
|
200
|
+
// such a person's older records are replaced by one, which keeps the fingerprint and dates they carried
|
|
201
|
+
// — a STALE one when their records disagree, so the merge leans stale. Stale means not among
|
|
202
|
+
// `accepted` (`acceptedHashes`), never merely "not today's exact hash": every approval a released
|
|
203
|
+
// yadflow wrote carries an older fingerprint form the gate still reads as live, and treating those as
|
|
204
|
+
// stale picked the wrong record and passed a gate on a stale approval (E62 review). The
|
|
205
|
+
// `role`/`domain` fields are not carried forward — nothing reads them any more.
|
|
206
|
+
//
|
|
207
|
+
// AN OLDER RECORD NAMES THE PERSON AS THE ROSTER DID (`alice`), and the platform now reports their LOGIN
|
|
208
|
+
// (`al`). Unmatched, the first sync after the upgrade treated the approval as new and bound it to
|
|
209
|
+
// TODAY's content — an approval of the old text read as approval of the edited one, the hole #156 is
|
|
210
|
+
// about — and on a closed step it listed one person twice. `aliases` is the roster's own name → login
|
|
211
|
+
// table (`legacyLogins`), read for this recognition ONLY; it decides nothing about the gate. With it the
|
|
212
|
+
// match is exact — unless the roster itself is ambiguous (a name held twice is dropped by `legacyLogins`;
|
|
213
|
+
// a name equal to another entry's login `yad doctor` warns about). Without it (the roster was deleted) an
|
|
214
|
+
// older record is matched only when nothing else could be it: the same submission time on GitHub, or, on
|
|
215
|
+
// an OPEN step, the single unmatched approval against the single unmatched older approver of this PR on
|
|
216
|
+
// GitLab. A record an older release marked `unverified` names a login already and is never translated. Guessing by order is how two people's fingerprints got
|
|
217
|
+
// swapped. Where it stays ambiguous, a closed step adds nothing (its record is history, and a second
|
|
218
|
+
// entry for the same review would count one person twice), and an open step records the approval
|
|
219
|
+
// against a STALE fingerprint from those older records when one exists — the approval must be given
|
|
220
|
+
// again, on a new review, rather than pass on content one of those people may never have seen.
|
|
221
|
+
function upsertBridge(approvals, recs, { stepId, artifact, curHash, today, prNumber = null, closed = false, aliases = new Map(), clashed = new Map(), accepted = null }) {
|
|
222
|
+
const live = accepted ?? (curHash ? [curHash] : []);
|
|
223
|
+
const stale = (a) => !!a.artifactHash && isStaleHash(a.artifactHash, live);
|
|
224
|
+
// A submission TIME, not a date. Every GitLab record written before E64 read `approved_at` holds the
|
|
225
|
+
// day it was synced (`today`), and as text `2026-09-15T10:00:00Z` sorts after `2026-09-15` — so a
|
|
226
|
+
// date-only value compared as a time made the first E64 sync read every same-day GitLab approval as a
|
|
227
|
+
// newer review and bind it to today's content (#156). A date is "time unknown".
|
|
228
|
+
const hasTime = (t) => typeof t === 'string' && t.includes('T') && !Number.isNaN(Date.parse(t));
|
|
229
|
+
const isLegacy = (a) => a.role !== undefined || a.domain !== undefined;
|
|
230
|
+
// An `unverified` record already names a LOGIN — an older release wrote one for a reviewer the roster did
|
|
231
|
+
// not list — so it is never translated through the roster's names, where it could collide with a name.
|
|
232
|
+
// A record under a roster name two logins share is keyed apart from every login (a NUL-prefixed key no
|
|
233
|
+
// platform login can equal), so it is never name-matched and, on a closed step, stays as history. The
|
|
234
|
+
// key also carries the submission time: an older release wrote one record per login under that one
|
|
235
|
+
// name, so a single key would lump two people together, and continuing one would delete the other.
|
|
236
|
+
const personOf = (a) => {
|
|
237
|
+
if (!isLegacy(a) || a.unverified) return a.approver;
|
|
238
|
+
if (aliases.has(a.approver)) return aliases.get(a.approver);
|
|
239
|
+
return clashed.has(a.approver) ? `\u0000${a.approver}\u0000${a.approvedAt ?? ''}` : a.approver;
|
|
240
|
+
};
|
|
241
|
+
// Who could own a group: anyone for a record no name places; only the logins the roster gives that
|
|
242
|
+
// name for a shared-name group — a login that merely EQUALS the shared name is someone else.
|
|
243
|
+
const nameOfKey = (k) => (k.startsWith('\u0000') ? k.split('\u0000')[1] : null);
|
|
244
|
+
const eligible = (k, r) => { const n = nameOfKey(k); return n === null || !!clashed.get(n)?.has(r.name); };
|
|
245
|
+
const bridge = approvals.filter((a) => a.step === stepId && a.source === 'bridge');
|
|
246
|
+
const groups = new Map(); // person -> their records
|
|
247
|
+
for (const a of bridge) {
|
|
248
|
+
const k = personOf(a);
|
|
249
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
250
|
+
groups.get(k).push(a);
|
|
251
|
+
}
|
|
252
|
+
const repOf = (list) => list.find(stale) || list[0];
|
|
253
|
+
const seen = new Set(recs.map((r) => r.name));
|
|
254
|
+
const matchOf = new Map(); // rec -> the record it continues
|
|
255
|
+
const replaced = new Set(); // people some of whose records a rec continues
|
|
256
|
+
const claimed = new Set(); // the older records a rec continues — per REVIEW, not per person (see below)
|
|
257
|
+
const claim = (r, k, list) => { matchOf.set(r, repOf(list)); for (const a of list) claimed.add(a); replaced.add(k); };
|
|
258
|
+
// An older release's record, or one `stampLegacyLogins` moved onto a login through the name table.
|
|
259
|
+
const olderRec = (a) => (isLegacy(a) && !a.unverified) || a.rosterName !== undefined;
|
|
260
|
+
// AN EXACT SUBMISSION TIME BEATS THE NAME TABLE. On GitHub every review carries the second it was
|
|
261
|
+
// submitted, and an older record kept it as `approvedAt`; when exactly one older group holds that
|
|
262
|
+
// second, it is the same review whatever name the roster now gives it. The name table can be wrong —
|
|
263
|
+
// a team that renamed one of two people sharing a name hands the older records to whoever kept it,
|
|
264
|
+
// and trusting the name then dropped the right record and passed the gate on an approval of old content
|
|
265
|
+
// (E62 upgrade simulation). Only older groups are matched this way: role-bearing records, and records
|
|
266
|
+
// `stampLegacyLogins` moved onto a login through that same name table, which keep the name in
|
|
267
|
+
// `rosterName` (E64) so that stamping them does not take this correction away.
|
|
268
|
+
//
|
|
269
|
+
// The time claims THE RECORDS OF THAT REVIEW, not the whole name group. Two people an older release wrote
|
|
270
|
+
// under one name sit in one group, each with their own submission time; claiming the group for whichever
|
|
271
|
+
// approval matched first, and continuing from the group's first stale record, handed one person the
|
|
272
|
+
// other's record — the later time then read as a newer review and both approvals were bound to today's
|
|
273
|
+
// content, passing a gate on approvals of content that had changed (E64 upgrade simulation). So each
|
|
274
|
+
// approval continues from the records at its own time, and it does so even when the name matches too, or
|
|
275
|
+
// the name match would pick from both people's records. A submission time is unique among these approvals
|
|
276
|
+
// (a shared one is skipped above), so no two claim the same records and the platform's order cannot matter.
|
|
277
|
+
for (const r of recs) {
|
|
278
|
+
if (!r.submittedAt || recs.some((x) => x !== r && x.submittedAt === r.submittedAt)) continue;
|
|
279
|
+
// A group matches on an OLDER record holding that second; the review is then every record of the group
|
|
280
|
+
// holding it — an older release could write one review twice (under the roster name, and `unverified`
|
|
281
|
+
// under the login), and leaving one behind kept it as a second person on a closed step.
|
|
282
|
+
const atTime = (k) => groups.get(k).filter((a) => a.approvedAt === r.submittedAt);
|
|
283
|
+
const byTime = [...groups.keys()].filter((k) => atTime(k).some(olderRec));
|
|
284
|
+
if (byTime.length === 1) claim(r, byTime[0], atTime(byTime[0]));
|
|
285
|
+
}
|
|
286
|
+
// By name, from what no time claimed — and still per review: when some of those records hold this
|
|
287
|
+
// approval's own time, those are its review; the rest may be another person's under the same name.
|
|
288
|
+
for (const r of recs) {
|
|
289
|
+
if (matchOf.has(r) || !groups.has(r.name)) continue;
|
|
290
|
+
const rest = groups.get(r.name).filter((a) => !claimed.has(a));
|
|
291
|
+
const own = r.submittedAt ? rest.filter((a) => a.approvedAt === r.submittedAt) : [];
|
|
292
|
+
if (rest.length) claim(r, r.name, own.length ? own : rest);
|
|
293
|
+
}
|
|
294
|
+
// Older approvers nobody could name: legacy records with no alias, recorded against THIS review (a
|
|
295
|
+
// record with no `pr` predates PR provenance and is taken as the pointer's, as `stampLegacyPr` does).
|
|
296
|
+
// A group an exact time already continued is not an orphan: claiming it again through the one-to-one or
|
|
297
|
+
// time rule below handed the same records to a second approval (E64 upgrade simulation).
|
|
298
|
+
const orphans = prNumber == null ? [] : [...groups.keys()].filter((k) => !seen.has(k) && !replaced.has(k)
|
|
299
|
+
&& groups.get(k).every((a) => isLegacy(a) && !a.unverified && !aliases.has(a.approver) && (a.pr == null || a.pr === prNumber)));
|
|
300
|
+
// (a clashed-name group's key starts with NUL, so `seen` never holds it and it is always eligible here)
|
|
301
|
+
const unmatched = recs.filter((r) => !matchOf.has(r));
|
|
302
|
+
// Every approval is judged against the SAME set of older approvers, and only then are the matches taken:
|
|
303
|
+
// deciding them one at a time made the result depend on the order the platform lists reviews, so an
|
|
304
|
+
// unchanged re-sync could add a record the first sync had dropped. A CLOSED step continues an older
|
|
305
|
+
// record only on an exact submission time — the GitLab one-to-one guess there could hand one person's
|
|
306
|
+
// history to another. Two approvals that would continue the same older record continue neither.
|
|
307
|
+
const picks = new Map();
|
|
308
|
+
for (const r of unmatched) {
|
|
309
|
+
const mine = orphans.filter((k) => eligible(k, r));
|
|
310
|
+
// Match by time only when the older records hold one to match. A GitLab record from before E64 holds a
|
|
311
|
+
// date, so an approval that now carries `approved_at` still takes the one-to-one rule against it.
|
|
312
|
+
const byTimeOnly = !!r.submittedAt && mine.some((k) => groups.get(k).some((a) => hasTime(a.approvedAt)));
|
|
313
|
+
if (closed && !byTimeOnly) continue;
|
|
314
|
+
const candidates = byTimeOnly
|
|
315
|
+
? mine.filter((k) => groups.get(k).some((a) => a.approvedAt === r.submittedAt))
|
|
316
|
+
: (mine.length === 1 ? mine : []);
|
|
317
|
+
const rivals = byTimeOnly
|
|
318
|
+
? unmatched.filter((x) => x.submittedAt === r.submittedAt && candidates.some((k) => eligible(k, x))).length
|
|
319
|
+
: unmatched.filter((x) => candidates.some((k) => eligible(k, x))).length;
|
|
320
|
+
if (candidates.length === 1 && rivals === 1) picks.set(r, candidates[0]);
|
|
321
|
+
}
|
|
322
|
+
const claims = new Map();
|
|
323
|
+
for (const k of picks.values()) claims.set(k, (claims.get(k) || 0) + 1);
|
|
324
|
+
for (const [r, k] of picks) {
|
|
325
|
+
if (claims.get(k) > 1) { picks.delete(r); continue; }
|
|
326
|
+
claim(r, k, groups.get(k));
|
|
327
|
+
}
|
|
328
|
+
const unclaimed = orphans.filter((k) => !replaced.has(k));
|
|
329
|
+
// Ambiguous: an unmatched approval that one of the older approvers still left could be. Each is bound to
|
|
330
|
+
// a stale fingerprint from the records IT could be, never from someone it cannot be.
|
|
331
|
+
const couldBe = (r) => unclaimed.filter((k) => eligible(k, r));
|
|
332
|
+
const ambiguous = new Set(unmatched.filter((r) => !picks.has(r) && couldBe(r).length));
|
|
333
|
+
const staleFor = (r) => couldBe(r).flatMap((k) => groups.get(k)).find(stale) || null;
|
|
143
334
|
const kept = approvals.filter((a) => {
|
|
144
335
|
if (!(a.step === stepId && a.source === 'bridge')) return true;
|
|
145
|
-
|
|
146
|
-
|
|
336
|
+
if (claimed.has(a)) return false;
|
|
337
|
+
// Closed step: keep a prior approval the platform no longer reports. It is history, not state. That
|
|
338
|
+
// includes a record left in a name group some of whose records a review continued: it is another
|
|
339
|
+
// review — under a shared name, another person's — and dropping the whole group deleted their history.
|
|
340
|
+
return closed && (!seen.has(personOf(a)) || replaced.has(personOf(a)));
|
|
147
341
|
});
|
|
148
342
|
for (const r of recs) {
|
|
149
|
-
|
|
150
|
-
|
|
343
|
+
if (closed && ambiguous.has(r)) continue;
|
|
344
|
+
const was = matchOf.get(r);
|
|
345
|
+
// first time we see this approval => bind to current content; an ambiguous one => to the stale print
|
|
346
|
+
const staleOrphan = ambiguous.has(r) ? staleFor(r) : null;
|
|
347
|
+
let artHash = staleOrphan ? staleOrphan.artifactHash : curHash;
|
|
151
348
|
let approvedAt = r.submittedAt || today;
|
|
152
349
|
let recordedOn = today;
|
|
153
350
|
if (was) {
|
|
154
351
|
// We only adopt the new hash when the platform PROVES a genuinely newer review. Otherwise —
|
|
155
352
|
// the same review read again — we KEEP the hash they originally approved, so a later artifact
|
|
156
353
|
// change still revokes the approval. Two independent proofs, because one platform lacks each:
|
|
157
|
-
// - a later
|
|
354
|
+
// - a later submission TIME on both sides (GitHub always; GitLab when it sends `approved_at` and the
|
|
355
|
+
// record already holds a time — a date-only record is "time unknown", see `hasTime`), or
|
|
158
356
|
// - a DIFFERENT PR/MR than the one this approval was recorded against. A re-opened review is
|
|
159
357
|
// always a new PR, so an approval arriving on it cannot be the old one read again. Without
|
|
160
358
|
// this, a GitLab re-review after a re-lock re-recorded the pre-edit hash and stayed
|
|
161
359
|
// permanently stale — the step read `done` with zero live approvals (issue #156).
|
|
162
|
-
const newerReview = r.submittedAt && was.approvedAt && r.submittedAt > was.approvedAt;
|
|
360
|
+
const newerReview = hasTime(r.submittedAt) && hasTime(was.approvedAt) && Date.parse(r.submittedAt) > Date.parse(was.approvedAt);
|
|
163
361
|
const newerPr = prNumber != null && was.pr != null && was.pr !== prNumber;
|
|
164
362
|
if (!newerReview && !newerPr) {
|
|
165
363
|
artHash = was.artifactHash ?? curHash;
|
|
166
|
-
|
|
364
|
+
// A record holding only a date takes the platform's time once (E64) and keeps its fingerprint: the
|
|
365
|
+
// time is evidence of when, the fingerprint is what the gate decides on. Then it is byte-stable.
|
|
366
|
+
approvedAt = !hasTime(was.approvedAt) && hasTime(r.submittedAt) ? r.submittedAt : (was.approvedAt ?? approvedAt);
|
|
167
367
|
// Same review, re-read: keep the date it was RECORDED too, so re-syncing an unchanged
|
|
168
368
|
// approval is a byte-identical no-op instead of a daily one-line ledger commit.
|
|
169
369
|
recordedOn = was.date ?? recordedOn;
|
|
170
370
|
}
|
|
171
371
|
}
|
|
172
372
|
kept.push({
|
|
173
|
-
artifact, step: stepId, approver: r.name,
|
|
174
|
-
...(r.domain ? { domain: r.domain } : {}),
|
|
373
|
+
artifact, step: stepId, approver: r.name,
|
|
175
374
|
status: 'approved', date: recordedOn, source: 'bridge',
|
|
176
375
|
artifactHash: artHash, approvedAt,
|
|
177
376
|
...(prNumber != null ? { pr: prNumber } : {}),
|
|
377
|
+
// The platform's evidence for this review, when it gave any (E64, see mapApprovers). Audit only.
|
|
378
|
+
...(r.commit ? { commit: r.commit } : {}),
|
|
379
|
+
...(r.url ? { url: r.url } : {}),
|
|
380
|
+
...(r.reviewId ? { reviewId: r.reviewId } : {}),
|
|
178
381
|
engagement: r.engagement === 'verified' ? 'verified' : 'none',
|
|
179
|
-
...(r.unverified ? { unverified: true } : {}),
|
|
180
382
|
});
|
|
181
383
|
}
|
|
182
384
|
// Canonical order, not insertion order: this function re-appends at the tail, so without it the
|
|
@@ -200,6 +402,101 @@ export function stampLegacyPr(approvals, stepId, prNumber) {
|
|
|
200
402
|
return n;
|
|
201
403
|
}
|
|
202
404
|
|
|
405
|
+
// Record the platform login on every older approval and comment record the roster can place (E64), so no
|
|
406
|
+
// later sync needs the roster at all. E62's handoff: without this, a roster deleted before the first sync
|
|
407
|
+
// after the upgrade left an open review's older approvals matchable only when nothing else could be them.
|
|
408
|
+
//
|
|
409
|
+
// An older record is one an older release wrote with a `role` or `domain`, naming the person by the
|
|
410
|
+
// roster's `name`. It is stamped exactly as `upsertBridge` rewrites a record it continues: `approver` /
|
|
411
|
+
// `commenter` becomes the login, and `role`, `domain` and `unverified` are removed — a role left on a
|
|
412
|
+
// login-named record would make the next sync look its login up as a roster name, which can be another
|
|
413
|
+
// person's. The dated `reviews/*--approved.md` keeps the roles.
|
|
414
|
+
//
|
|
415
|
+
// It stamps only what the name table places for certain, and leaves the rest for `upsertBridge` to judge
|
|
416
|
+
// with the platform's evidence in hand:
|
|
417
|
+
// - an `unverified` record already names a login, and a name two logins share (`clashed`) or a name the
|
|
418
|
+
// roster does not hold is not in `aliases` — none is touched;
|
|
419
|
+
// - ONE RECORD PER PERSON, BUT ONLY FOR ONE REVIEW. An older release wrote one record per role, so one
|
|
420
|
+
// approval can be several records. Several records for one login on one step are merged only when they
|
|
421
|
+
// provably are one review: every one holds the same submission TIME and the same `pr`. Anything less is
|
|
422
|
+
// not proof — a GitLab record from before E64 holds the day it was synced, so two people a renamed
|
|
423
|
+
// roster now gives one name, synced the same day, look identical; a hand-written record holds no time at
|
|
424
|
+
// all. Merging those deleted one person's approval and passed a gate on the other's approval of old
|
|
425
|
+
// content (E64 upgrade simulation). Such a group is left as it is (`unplaced`). When records for one
|
|
426
|
+
// review disagree on the fingerprint, the stale one (outside `acceptedHashes`) is kept, as E62 does;
|
|
427
|
+
// - a stamped approval keeps the roster name it had in `rosterName`. `upsertBridge` still lets an exact
|
|
428
|
+
// submission time beat that name, as it did while the record carried a role — so a renamed shared name
|
|
429
|
+
// stamped onto the wrong login is corrected by the platform's time, not locked in;
|
|
430
|
+
// - a comment round where two records would name one login is left as it is: which count is right is
|
|
431
|
+
// not knowable, and the cost of leaving it is one extra round.
|
|
432
|
+
// Pure: returns new arrays and the counts; `acceptedFor(artifact)` gives that artifact's live fingerprints.
|
|
433
|
+
// `unplaced` counts older records the roster was still needed for and the stamp could not place.
|
|
434
|
+
export function stampLegacyLogins({ approvals = [], comments = [] } = {}, { aliases = new Map(), clashed = new Map(), acceptedFor = () => [] } = {}) {
|
|
435
|
+
const isOld = (x) => x.role !== undefined || x.domain !== undefined;
|
|
436
|
+
// A ledger is a list a person can edit, so an entry may be anything. One this cannot read is left exactly
|
|
437
|
+
// as it is: the stamp runs over every epic on a merge run, and a bad record in an epic nobody is syncing
|
|
438
|
+
// must not stop the review that merged (E64 review).
|
|
439
|
+
const readable = (x, who) => isPlainObject(x) && typeof x[who] === 'string' && x[who] !== '';
|
|
440
|
+
const hasTime = (t) => typeof t === 'string' && t.includes('T') && !Number.isNaN(Date.parse(t));
|
|
441
|
+
const accepted = new Map();
|
|
442
|
+
const live = (artifact) => { if (!accepted.has(artifact)) accepted.set(artifact, acceptedFor(artifact) || []); return accepted.get(artifact); };
|
|
443
|
+
let stamped = 0;
|
|
444
|
+
let unplaced = 0;
|
|
445
|
+
|
|
446
|
+
const groups = new Map();
|
|
447
|
+
approvals.forEach((a, i) => {
|
|
448
|
+
if (!readable(a, 'approver') || typeof a.step !== 'string' || !readable(a, 'artifact')) return;
|
|
449
|
+
const old = isOld(a) && !a.unverified;
|
|
450
|
+
if (old && !aliases.has(a.approver)) { if (clashed.has(a.approver)) unplaced++; return; }
|
|
451
|
+
const login = old ? aliases.get(a.approver) : (isOld(a) ? null : a.approver);
|
|
452
|
+
if (login == null) return;
|
|
453
|
+
const k = JSON.stringify([a.step, a.artifact, a.source ?? null, login]);
|
|
454
|
+
if (!groups.has(k)) groups.set(k, { login, list: [] });
|
|
455
|
+
groups.get(k).list.push({ a, i, old });
|
|
456
|
+
});
|
|
457
|
+
const out = approvals.slice();
|
|
458
|
+
const drop = new Set();
|
|
459
|
+
for (const { login, list } of groups.values()) {
|
|
460
|
+
const olds = list.filter((x) => x.old);
|
|
461
|
+
if (!olds.length) continue;
|
|
462
|
+
const oneReview = list.length === 1
|
|
463
|
+
|| (list.every((x) => hasTime(x.a.approvedAt)) && new Set(list.map((x) => `${x.a.approvedAt}|${x.a.pr ?? ''}`)).size === 1);
|
|
464
|
+
if (!oneReview) { unplaced += olds.length; continue; }
|
|
465
|
+
// Which fingerprints are live cannot always be computed from a hand-edited record (an `artifact` naming a
|
|
466
|
+
// folder throws). Then which record is stale is unknown, and choosing one could keep the live print, so
|
|
467
|
+
// the group is left as it is (E64 review).
|
|
468
|
+
let staleRec;
|
|
469
|
+
try { staleRec = list.find((x) => !!x.a.artifactHash && isStaleHash(x.a.artifactHash, live(x.a.artifact))); } catch { unplaced += olds.length; continue; }
|
|
470
|
+
const rep = staleRec || list.find((x) => !x.old) || olds[0];
|
|
471
|
+
const { role, domain, unverified, ...rest } = rep.a; // eslint-disable-line no-unused-vars
|
|
472
|
+
out[rep.i] = { ...rest, approver: login, rosterName: rep.old ? rep.a.approver : (rep.a.rosterName ?? olds[0].a.approver) };
|
|
473
|
+
for (const x of list) if (x !== rep) drop.add(x.i);
|
|
474
|
+
stamped += olds.length;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const rounds = new Map();
|
|
478
|
+
comments.forEach((cm, i) => {
|
|
479
|
+
if (!readable(cm, 'commenter')) return;
|
|
480
|
+
const old = isOld(cm);
|
|
481
|
+
if (old && !aliases.has(cm.commenter)) { if (clashed.has(cm.commenter)) unplaced++; return; }
|
|
482
|
+
const login = old ? aliases.get(cm.commenter) : cm.commenter;
|
|
483
|
+
const k = JSON.stringify([cm.step, cm.round ?? null, login]);
|
|
484
|
+
if (!rounds.has(k)) rounds.set(k, { login, list: [] });
|
|
485
|
+
rounds.get(k).list.push({ cm, i, old });
|
|
486
|
+
});
|
|
487
|
+
const cOut = comments.slice();
|
|
488
|
+
for (const { login, list } of rounds.values()) {
|
|
489
|
+
const olds = list.filter((x) => x.old);
|
|
490
|
+
if (!olds.length) continue;
|
|
491
|
+
if (list.length > 1) { unplaced += olds.length; continue; }
|
|
492
|
+
const { role, domain, ...rest } = olds[0].cm; // eslint-disable-line no-unused-vars
|
|
493
|
+
cOut[olds[0].i] = { ...rest, commenter: login, rosterName: olds[0].cm.commenter };
|
|
494
|
+
stamped++;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return { approvals: out.filter((_, i) => !drop.has(i)), comments: cOut, stamped, unplaced };
|
|
498
|
+
}
|
|
499
|
+
|
|
203
500
|
function writeComments(epicDir, base, today, blocking) {
|
|
204
501
|
if (!blocking.length) return;
|
|
205
502
|
const file = path.join(epicDir, 'reviews', `${base}--${today}--comments.md`);
|
|
@@ -216,10 +513,19 @@ function writeComments(epicDir, base, today, blocking) {
|
|
|
216
513
|
// Upsert machine-readable participation records into the comments ledger (the counterpart to the
|
|
217
514
|
// markdown side file) so the ledger — not just reviews/*.md — reflects platform thread state. One
|
|
218
515
|
// record per (step, commenter, round); `round` is the count of prior synced rounds for the step.
|
|
219
|
-
|
|
516
|
+
// The commenter is the platform login (E62) — there is no stored list to turn it into another name.
|
|
517
|
+
// A round an older release wrote names the roster's name for a person; `aliases` (see `legacyLogins`)
|
|
518
|
+
// reads it as their login, so the first sync after the upgrade does not open a new round for the same
|
|
519
|
+
// threads.
|
|
520
|
+
function recordComments(comments, { artifact, stepId, today, blocking, aliases = new Map(), clashed = new Map() }) {
|
|
220
521
|
if (!blocking.length) return comments;
|
|
221
|
-
const byName = (login) =>
|
|
222
|
-
|
|
522
|
+
const byName = (login) => login || 'reviewer';
|
|
523
|
+
// As `upsertBridge` reads approvals: a shared roster name is nobody's login, so its round is not "the same".
|
|
524
|
+
const personOf = (cm) => {
|
|
525
|
+
if (cm.role === undefined) return cm.commenter;
|
|
526
|
+
if (aliases.has(cm.commenter)) return aliases.get(cm.commenter);
|
|
527
|
+
return clashed.has(cm.commenter) ? `\u0000${cm.commenter}` : cm.commenter;
|
|
528
|
+
};
|
|
223
529
|
const counts = new Map();
|
|
224
530
|
for (const t of blocking) counts.set(t.login, (counts.get(t.login) || 0) + 1);
|
|
225
531
|
// A round is a CHANGE in the thread state, not a sync. Allocating max+1 on every call made an
|
|
@@ -232,21 +538,23 @@ function recordComments(comments, { artifact, stepId, today, roster, blocking })
|
|
|
232
538
|
const latest = rounds.reduce((m, cm) => Math.max(m, cm.round || 0), 0);
|
|
233
539
|
const prior = rounds.filter((cm) => cm.round === latest);
|
|
234
540
|
const now = new Map([...counts].map(([login, count]) => [byName(login), count]));
|
|
235
|
-
|
|
541
|
+
// The same people, each once, with the same counts — two older records that map to one person are not.
|
|
542
|
+
const same = latest > 0 && prior.length === now.size && new Set(prior.map(personOf)).size === prior.length
|
|
543
|
+
&& prior.every((cm) => now.get(personOf(cm)) === cm.count);
|
|
236
544
|
const round = same ? latest : latest + 1;
|
|
237
545
|
const kept = comments.filter((cm) => !(cm.step === stepId && cm.round === round));
|
|
238
546
|
for (const [login, count] of counts) {
|
|
239
547
|
// An unchanged round keeps its original date, so re-syncing it is byte-identical rather than a
|
|
240
548
|
// daily one-line churn (the same rule upsertBridge applies to an unchanged approval).
|
|
241
|
-
const was = prior.find((cm) => cm
|
|
242
|
-
kept.push({ artifact, step: stepId, commenter: byName(login),
|
|
549
|
+
const was = prior.find((cm) => personOf(cm) === byName(login));
|
|
550
|
+
kept.push({ artifact, step: stepId, commenter: byName(login), round, count, date: (same && was?.date) || today });
|
|
243
551
|
}
|
|
244
552
|
return canonicalComments(kept); // same drop-and-re-append churn as approvals — see canonicalApprovals
|
|
245
553
|
}
|
|
246
554
|
|
|
247
555
|
// ---- actions ------------------------------------------------------------------------------------
|
|
248
556
|
|
|
249
|
-
// The review PR/MR(s) to sync. Normally the ledger's own pointer — but
|
|
557
|
+
// The review PR/MR(s) to sync. Normally the ledger's own pointer — but with a verified ledger the record
|
|
250
558
|
// records that pointer only at merge (CI is the sole writer), so a review a human needs to push
|
|
251
559
|
// through by hand has NO recorded pointer at all. Fall back to the PR number the caller named
|
|
252
560
|
// (`--pr`), else resolve it from the review branch on the platform. Without this, `gate sync` reported
|
|
@@ -296,23 +604,53 @@ function resolveTargets(hubPrs, { epic, artifact, state, platform, number, finde
|
|
|
296
604
|
return { targets: entry(found.number, found.url), discovered: true };
|
|
297
605
|
}
|
|
298
606
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
607
|
+
// Rebuild the index and stage it — but ONLY when every file it reads is exactly what the commit will
|
|
608
|
+
// hold. The index hashes the files ON DISK, so in a checkout carrying someone's half-written epic.md (the
|
|
609
|
+
// documented manual recovery, `yad gate ci … --merged` run by a person) it would describe work that is
|
|
610
|
+
// not in the commit, and read as behind on every clean checkout after it. Then it stays out and says so.
|
|
611
|
+
// Derived, so a failure is said and never stops the commit it would have ridden.
|
|
612
|
+
// `commits`: the paths a pathspec-limited commit will hold (`gate repair`), or null for a commit of
|
|
613
|
+
// everything staged (`gate ci`). The check itself is `uncommittedIndexInputs` (cli/product-index.mjs).
|
|
614
|
+
function stageIndexIfClean(root, git, commits = null) {
|
|
615
|
+
const dirty = uncommittedIndexInputs(root, commits);
|
|
616
|
+
if (dirty === null || dirty.length) {
|
|
617
|
+
warn(`${INDEX_FILE} left out of this commit: ${dirty === null ? 'git could not list the working tree' : `${dirty.join(', ')} ${dirty.length === 1 ? 'is' : 'are'} not in it as on disk`} — run \`yad index\` on a clean checkout`);
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
writeIndex(root);
|
|
622
|
+
} catch (e) {
|
|
623
|
+
warn(`${INDEX_FILE} was not rebuilt (${e.code || e.message}) — the ledger is committed without it`);
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
// Staged when it differs from what is committed — not "when this write changed the bytes": a caller
|
|
627
|
+
// may have rebuilt it a moment earlier (`gate repair`), and that rebuild still belongs in the commit.
|
|
628
|
+
if (!git('add', '--', INDEX_FILE).ok) {
|
|
629
|
+
warn(`${INDEX_FILE} could not be staged — the ledger is committed without it`);
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
return !git('diff', '--cached', '--quiet', '--', INDEX_FILE).ok;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export async function gateSync(root, { epic, artifact, today, reader = readPr, finder = findPrForBranch, branchOf = prBranch, poster = postComment, number = null, local = false, dryRun = false, headCount = null } = {}) {
|
|
636
|
+
const { hub } = loadProduct(root);
|
|
637
|
+
if (!hub?.platform) { warn('no Product platform configured (.sdlc/hub.json) — local gate, nothing to sync'); return { epic, synced: 0, advanced: 0, written: false, gates: [] }; }
|
|
302
638
|
const platform = hub.platform;
|
|
303
|
-
const
|
|
304
|
-
const
|
|
639
|
+
const aliases = legacyLogins(hub);
|
|
640
|
+
const clashed = ambiguousLegacyNames(hub);
|
|
305
641
|
const solo = isSolo(hub);
|
|
306
642
|
const reqEng = requireEngagement(hub);
|
|
307
|
-
// Local invocation in
|
|
643
|
+
// Local invocation in verified mode is ADVISORY: CI is the sole ledger writer, so a human run reads
|
|
308
644
|
// the platform and prints the predicate but writes nothing. CI calls gateSync with local=false.
|
|
309
|
-
//
|
|
310
|
-
// dryRun forces the same read-only behavior regardless of
|
|
645
|
+
// With a local ledger (platform but no gate-sync CI) the local command stays the writer.
|
|
646
|
+
// dryRun forces the same read-only behavior regardless of the ledger — used for the Path B pre-merge
|
|
311
647
|
// evaluation, which must persist nothing (gateCi passes dryRun for a held branch event).
|
|
312
|
-
const readOnly = (local &&
|
|
648
|
+
const readOnly = (local && isVerifiedLedger(hub)) || dryRun;
|
|
649
|
+
// Who writes this run's closing records (E18). Not asked on a read-only run, which writes nothing.
|
|
650
|
+
const by = readOnly ? null : closingActor(root, hub);
|
|
313
651
|
const epicDir = epicRoot(root, epic);
|
|
314
652
|
const ledger = loadLedger(epicDir);
|
|
315
|
-
if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { synced: 0 }; }
|
|
653
|
+
if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { epic, synced: 0, advanced: 0, written: false, gates: [] }; }
|
|
316
654
|
|
|
317
655
|
let { approvals, comments, hubPrs, state } = ledger;
|
|
318
656
|
// Migration (see stampLegacyPr): an approval written before PR provenance existed carries no `pr`,
|
|
@@ -323,6 +661,19 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
323
661
|
const s = p.number != null ? findReviewStep(state, p.artifact) : null;
|
|
324
662
|
if (s) stampLegacyPr(approvals, s.id, p.number);
|
|
325
663
|
}
|
|
664
|
+
// E64: record the login on every older record the roster can place, every step of this epic, before
|
|
665
|
+
// anything else reads them — after the `pr` backfill above, so one review's role records agree on `pr` and
|
|
666
|
+
// merge on this write rather than the next. In memory like everything here: a read-only run writes
|
|
667
|
+
// nothing, and the writer path below persists it with the rest. A failure here is named and the sync goes
|
|
668
|
+
// on with the records as they were: the stamp must never stop a merge.
|
|
669
|
+
let stampedOld = { approvals, comments, stamped: 0 };
|
|
670
|
+
try {
|
|
671
|
+
stampedOld = stampLegacyLogins({ approvals, comments }, { aliases, clashed, acceptedFor: (a) => acceptedHashes(epicDir, a) });
|
|
672
|
+
} catch (e) {
|
|
673
|
+
warn(`${epic}: older records not stamped — ${e.message}`);
|
|
674
|
+
}
|
|
675
|
+
approvals = stampedOld.approvals;
|
|
676
|
+
comments = stampedOld.comments;
|
|
326
677
|
const resolved = resolveTargets(hubPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd: root });
|
|
327
678
|
// Advance in CHAIN order, never in ledger order. `advanceState` opens the step that FOLLOWS the one
|
|
328
679
|
// it closes, so syncing two passing gates out of chain order rewinds the epic: closing
|
|
@@ -338,18 +689,41 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
338
689
|
};
|
|
339
690
|
const targets = [...resolved.targets].sort((a, b) => stepIndex(a) - stepIndex(b));
|
|
340
691
|
if (!targets.length) {
|
|
692
|
+
// The stamp still lands (E64): an epic with no review PR on file is exactly one no later sync would
|
|
693
|
+
// write, so `yad doctor`'s "run `yad gate sync <epic>`" would otherwise repeat for ever.
|
|
694
|
+
if (stampedOld.stamped && !readOnly) {
|
|
695
|
+
writeJSON(ledger.files.approvals, canonicalApprovals(approvals));
|
|
696
|
+
writeJSON(ledger.files.comments, canonicalComments(comments));
|
|
697
|
+
info(`${epic}: recorded the platform login on ${stampedOld.stamped} older approval/comment record(s) that named a roster name`);
|
|
698
|
+
}
|
|
341
699
|
warn(`no review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''}${resolved.reason ? ` — ${resolved.reason}` : ''}`);
|
|
342
700
|
hand(`run \`yad gate open ${epic} ${artifact || '<artifact>'}\`, or name the PR: \`yad gate sync ${epic} ${artifact || '<artifact>'} --pr <n>\``);
|
|
343
|
-
return { synced: 0 };
|
|
701
|
+
return { epic, synced: 0, advanced: 0, written: false, gates: [] };
|
|
344
702
|
}
|
|
345
703
|
// A pointer resolved from the platform is adopted into the ledger on the WRITER path only. In
|
|
346
|
-
//
|
|
704
|
+
// verified mode this run is advisory and writes nothing, so the human never ends up with a gate-state
|
|
347
705
|
// file in their working tree for the ledger-guard check to reject.
|
|
348
706
|
if (resolved.discovered && !readOnly) hubPrs = upsertHubPr(hubPrs, targets[0]);
|
|
349
707
|
|
|
350
708
|
let synced = 0;
|
|
351
709
|
let advanced = 0;
|
|
352
|
-
|
|
710
|
+
const gates = [];
|
|
711
|
+
// E71 — said ONCE, before the per-artifact lines, because it is a fact about the PRODUCT and not
|
|
712
|
+
// about any one gate (rule 6: say the arithmetic, not just the verdict). Since E72 it caps the count
|
|
713
|
+
// each gate below ASKS for — reported, and not enforced until E108.
|
|
714
|
+
// E71 — ONE Product-wide count per command, read here: before the per-step loop, so N steps cannot
|
|
715
|
+
// mean N walks of every repo's history, and AFTER the early exits above, so a run that bails out for
|
|
716
|
+
// a missing ledger or no targets never pays for a git walk at all. `today` is the one this command
|
|
717
|
+
// was given, so the count and the records it reports on are measured from the same day.
|
|
718
|
+
//
|
|
719
|
+
// `yad gate ci` is why it can be HANDED IN. Its sweep builds one job per (epic, open review PR) and
|
|
720
|
+
// calls this function once per job, so reading it here would walk every repo once PER PR — the very
|
|
721
|
+
// thing the sentence above promises it does not do. The sweep reads it once and passes it down.
|
|
722
|
+
const people = headCount || activePeople(root, { today: today || undefined, aliases });
|
|
723
|
+
log(` ${c.dim(activeSum(people))}`);
|
|
724
|
+
note(c.dim(activeBasis(people)));
|
|
725
|
+
const reachSeen = new Set(); // E73: each warning line once per command, not once per PR
|
|
726
|
+
// Targets whose step is still open. The dated approval-record file is regenerated only for these —
|
|
353
727
|
// an already-done step is re-synced for its approvals alone, and would otherwise drop a new
|
|
354
728
|
// reviews/<artifact>--<today>--approved.md every time the scheduled sweep re-visits it.
|
|
355
729
|
const open = [];
|
|
@@ -357,24 +731,23 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
357
731
|
const step = findReviewStep(state, pr.artifact);
|
|
358
732
|
if (!step) { warn(`no review step for ${pr.artifact}`); continue; }
|
|
359
733
|
// A step that already advanced is never advanced AGAIN (that would reset the next step's status /
|
|
360
|
-
// currentStep backward) — the gate is one-way per step. But it is still SYNCED: in
|
|
734
|
+
// currentStep backward) — the gate is one-way per step. But it is still SYNCED: in verified mode
|
|
361
735
|
// nothing ever moves a step back to in_review (CI is the sole ledger writer), so a re-opened
|
|
362
736
|
// review — surface re-locked, fresh PR, fresh approvals, merged — used to hit a blanket skip here
|
|
363
737
|
// and write nothing but the PR pointer. The step then read `done` while its approvals were all
|
|
364
738
|
// stale: work proceeded on an audit trail saying the re-review never happened (issue #156).
|
|
365
|
-
const alreadyDone = step
|
|
366
|
-
const domains = touchedDomains(epicDir, step);
|
|
739
|
+
const alreadyDone = isPassed(step);
|
|
367
740
|
const pull = reader(platform, pr.number, { cwd: root });
|
|
368
741
|
// A failed platform read must not pass as a green no-op: flag the run non-zero so CI surfaces it
|
|
369
742
|
// (the wired workflow's reconcile/sweep aggregates this exit) instead of silently not advancing.
|
|
370
|
-
if (!pull.ok) {
|
|
743
|
+
if (!pull.ok) { fail(`${pr.artifact}: ${pull.reason} — skipping (local)`); process.exitCode = 1; continue; }
|
|
371
744
|
|
|
372
745
|
const curHash = artifactHash(epicDir, pr.artifact);
|
|
373
746
|
warnUnlockedContract(epicDir, pr.artifact);
|
|
374
747
|
warnIncompleteDiscovery(epicDir, pr.artifact);
|
|
375
748
|
const approvalsBefore = JSON.stringify(approvals);
|
|
376
|
-
const recs = mapApprovers(pull.reviews, {
|
|
377
|
-
approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today, prNumber: pr.number ?? null, closed: alreadyDone });
|
|
749
|
+
const recs = mapApprovers(pull.reviews, { headOid: pull.headOid });
|
|
750
|
+
approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today, prNumber: pr.number ?? null, closed: alreadyDone, aliases, clashed, accepted: acceptedHashes(epicDir, pr.artifact) });
|
|
378
751
|
|
|
379
752
|
const changeRequested = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
|
|
380
753
|
// 2f: companion scaffolding + nudge threads carry the noblock marker and are EXCLUDED from the
|
|
@@ -396,7 +769,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
396
769
|
// That is the same churn the resource_group fix exists to stop, so it must not be reintroduced here.
|
|
397
770
|
if (!alreadyDone) {
|
|
398
771
|
if (!readOnly) writeComments(epicDir, base(pr.artifact), today, blocking);
|
|
399
|
-
comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today,
|
|
772
|
+
comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, blocking, aliases, clashed });
|
|
400
773
|
}
|
|
401
774
|
|
|
402
775
|
// Social nudge: a bare APPROVE (no verified engagement) still counts (soft default), but the bot
|
|
@@ -413,25 +786,74 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
413
786
|
}
|
|
414
787
|
|
|
415
788
|
const pred = gatePredicate({
|
|
416
|
-
|
|
417
|
-
|
|
789
|
+
// E72: caps the reported ask at `active − 1`; `null` (not counted) gives no cap. Only the base holds.
|
|
790
|
+
active: people.capacity.active,
|
|
791
|
+
step, approvals, currentHash: curHash, acceptedHashes: acceptedHashes(epicDir, pr.artifact),
|
|
792
|
+
threadsResolved, merged: pull.merged, solo, requireEngagement: reqEng,
|
|
793
|
+
// Which steps may be skipped is a fact about THIS epic's route (E35), so it is resolved from the
|
|
794
|
+
// ledger here rather than from a module-level set that answered the same for every epic.
|
|
795
|
+
optional: optionalStepsFor(state),
|
|
418
796
|
});
|
|
419
797
|
|
|
420
|
-
|
|
798
|
+
// Say the arithmetic, not just the verdict (rule 6): the count, the cap when the people were counted,
|
|
799
|
+
// and which half of it holds the gate — only the base until E108 — labelled so nobody reads a number
|
|
800
|
+
// the gate is not enforcing as the reason it did or did not pass.
|
|
801
|
+
// `have: null` is a step whose approvals were never counted (inherited from a parent epic, or
|
|
802
|
+
// skipped): there is no head count to report and no requirement to report either.
|
|
803
|
+
const count = pred.have === null
|
|
804
|
+
? 'approvals not counted here'
|
|
805
|
+
: `${pred.have} approved; count: ${gateRuleSum(pred.gateRule)}${gateRuleEnforced(pred.gateRule, pred.cap)}${pred.short ? ` — ${pred.short} short` : ''}`;
|
|
806
|
+
log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule}, ${count})`)}`);
|
|
807
|
+
// The --json answer (E1): each gate this run read, as the predicate judged it.
|
|
808
|
+
gates.push({
|
|
809
|
+
step: step.id, artifact: pr.artifact, pr: pr.number ?? null, merged: !!pull.merged, rule: pred.rule,
|
|
810
|
+
passed: !!pred.passed, alreadyDone: !!alreadyDone, have: pred.have, gateRule: pred.gateRule ?? null,
|
|
811
|
+
cap: pred.cap ?? null, short: pred.short ?? null, missing: pred.missing, staleDropped: pred.staleDropped ?? 0,
|
|
812
|
+
});
|
|
813
|
+
// E73: why this gate may not pass — reported only, never enforced. Only on a gate that counted
|
|
814
|
+
// (`rule: 'count'`, so never in solo mode) and is still open: not one already done (`alreadyDone` —
|
|
815
|
+
// a deferred step, or a done step whose approvals went stale, is not reopened by a sync) and not one
|
|
816
|
+
// that passed on this run. A LOCAL sync while the review is open shows it before the merge, when
|
|
817
|
+
// approvals can still come; wired CI does not — it runs only on merged PRs, and usually cannot count
|
|
818
|
+
// people. Each distinct line once per command (`uniqueReach`).
|
|
819
|
+
if (!alreadyDone && !pred.passed && pred.rule === 'count' && pred.have !== null) {
|
|
820
|
+
for (const why of uniqueReach(gateReach(pred.gateRule, pred.cap, { have: pred.have, nameOnly: people.capacity.nameOnly, approvers: approverCount(people), days: people.capacity.days }), reachSeen)) warn(why);
|
|
821
|
+
}
|
|
421
822
|
if (alreadyDone) {
|
|
422
823
|
// The step keeps its `done` status and the chain is untouched — re-advancing would reset the
|
|
423
824
|
// next step, and moving it back to in_review would un-ship work already built on it. What this
|
|
424
825
|
// pass DOES do is record the approvals that arrived, so `gate status` tells the truth about how
|
|
425
826
|
// many of them are live against the current artifact.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
827
|
+
// A DEFERRED step reads as passed, so it lands here too — but nothing was approved, and "already
|
|
828
|
+
// done … the rule no longer holds" would describe a pass that never happened. Say what is true: the
|
|
829
|
+
// review is still owed, and what it still needs (E37). The approvals themselves are kept either way:
|
|
830
|
+
// a sync of a step that reads as passed only ever adds to its record (`upsertBridge`'s `closed`).
|
|
831
|
+
if (stepStatus(step) === 'deferred') {
|
|
832
|
+
info(`${step.id} is deferred — approvals re-synced, chain not re-advanced; its review is still owed`);
|
|
833
|
+
for (const m of pred.missing) hand(`still owed: ${m}`);
|
|
834
|
+
} else {
|
|
835
|
+
const verdict = pred.passed ? 'the rule still holds' : `the rule no longer holds${pred.staleDropped ? ` (${pred.staleDropped} stale)` : ''}`;
|
|
836
|
+
info(`${step.id} already done — approvals re-synced, chain not re-advanced; ${verdict}`);
|
|
837
|
+
for (const m of pred.missing) hand(`recorded gap: ${m}`);
|
|
838
|
+
}
|
|
429
839
|
} else if (pred.passed) {
|
|
430
|
-
|
|
840
|
+
// The merge is the close (E18): its date, not the day this sweep happened to run, when the platform
|
|
841
|
+
// says so.
|
|
842
|
+
state = advanceState(state, step, {
|
|
843
|
+
by, date: (typeof pull.mergedAt === 'string' && pull.mergedAt.slice(0, 10)) || today,
|
|
844
|
+
pr: pr.number ?? null, commit: pull.mergeCommit || null, hash: curHash, mergedBy: pull.mergedBy || null,
|
|
845
|
+
// Solo mode passed this gate without counting approvals, and the record says so (E10).
|
|
846
|
+
waived: solo ? 'solo' : null,
|
|
847
|
+
// Every cap is recorded (E72): a team gate that passed while the cap LOWERED its ask says so, with
|
|
848
|
+
// the full count, the capped ask and the count of people it read. Only on a gate that COUNTED
|
|
849
|
+
// (`rule: 'count'`): not in solo mode, and not on a skipped or inherited step that passed by its
|
|
850
|
+
// shortcut — nothing was asked there, so nothing was lowered.
|
|
851
|
+
capped: pred.rule === 'count' && pred.cap?.capped ? { needed: pred.gateRule.needed, to: pred.cap.to, active: pred.cap.active } : null,
|
|
852
|
+
});
|
|
431
853
|
advanced++;
|
|
432
854
|
ok(`gate PASSED — ${step.id} → done; next: ${state.currentStep}`);
|
|
433
855
|
} else {
|
|
434
|
-
state = markInReview(state, step);
|
|
856
|
+
state = markInReview(state, step, { by, date: today, pr: pr.number ?? null, hash: curHash });
|
|
435
857
|
for (const m of pred.missing) hand(`still needed: ${m}`);
|
|
436
858
|
}
|
|
437
859
|
// Stamp when this run actually learned something: an open step every time, and a closed one only
|
|
@@ -444,8 +866,8 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
444
866
|
}
|
|
445
867
|
|
|
446
868
|
if (readOnly) {
|
|
447
|
-
info('
|
|
448
|
-
return { synced, advanced };
|
|
869
|
+
info('verified mode: advisory view — CI owns the ledger, nothing written locally');
|
|
870
|
+
return { epic, synced, advanced, written: false, gates };
|
|
449
871
|
}
|
|
450
872
|
// Belt-and-braces: the upserts above already return canonical order, but a ledger this run only
|
|
451
873
|
// READ (no matching target, or a pre-canonical file written by an older release) still gets sorted
|
|
@@ -453,15 +875,19 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
453
875
|
approvals = canonicalApprovals(approvals);
|
|
454
876
|
comments = canonicalComments(comments);
|
|
455
877
|
hubPrs = canonicalHubPrs(hubPrs);
|
|
878
|
+
if (stampedOld.stamped) info(`${epic}: recorded the platform login on ${stampedOld.stamped} older approval/comment record(s) that named a roster name`);
|
|
456
879
|
writeJSON(ledger.files.approvals, approvals);
|
|
457
880
|
writeJSON(ledger.files.comments, comments);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
881
|
+
writeMirrored(ledger.files.productPrs, ledger.files.hubPrs, hubPrs);
|
|
882
|
+
writeState(ledger.files.state, state);
|
|
883
|
+
refreshApprovalRecord(epicDir, open, approvals, today); // the dated side file lists them in the same order
|
|
884
|
+
// Only a PERSON's sync (`local`): `gateCi` calls this too, and rebuilds the index itself, inside the
|
|
885
|
+
// one allowlisted commit it makes on a merge — and never in its read-only pre-merge run.
|
|
886
|
+
if (local) refreshIndexAfterWrite(root, hub);
|
|
887
|
+
return { epic, synced, advanced, written: true, currentStep: state.currentStep ?? null, gates };
|
|
462
888
|
}
|
|
463
889
|
|
|
464
|
-
// `yad gate ci` — the self-sufficient entry point
|
|
890
|
+
// `yad gate ci` — the self-sufficient entry point Product CI calls on platform events. Path B: CI
|
|
465
891
|
// never writes the ledger to the review branch — during review the platform PR/MR is the source of
|
|
466
892
|
// truth, and the ledger is reconciled onto the default branch at merge.
|
|
467
893
|
//
|
|
@@ -478,47 +904,125 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
478
904
|
// CI is the SOLE writer of the ledger and only ever commits to the default branch; humans never
|
|
479
905
|
// commit gate-state files (enforced by the ledger-guard check). Sweep mode (no --branch) advances
|
|
480
906
|
// merged-but-stuck reviews found in the locally checked-out default-branch ledgers.
|
|
907
|
+
// Shape 8 on a VERIFIED Product. `yad migrate` refuses to move `epics/EP-discovery/` there, because CI
|
|
908
|
+
// owns the ledger and the ledger guard rejects a person's commit that moves it. The gate bot is the one
|
|
909
|
+
// writer allowed to, so the move happens here — the same rule as every other verified-mode shape change:
|
|
910
|
+
// the gate write IS the migration. Returns the paths it wrote, or null when nothing moved.
|
|
911
|
+
//
|
|
912
|
+
// It refuses, and moves nothing, on everything `yad migrate` refuses (an open review, two product levels,
|
|
913
|
+
// a collision, an unreadable ledger) and on one thing more: checks committed in the repo that predate the
|
|
914
|
+
// Foundation. Moving the ledger under those would put it where CI stops nobody from hand-editing it, so
|
|
915
|
+
// the project keeps the old spelling — which every command still reads — until `yad update` lands.
|
|
916
|
+
//
|
|
917
|
+
// Two refusals are about the CHECKOUT, not the project, and exist for the documented manual recovery
|
|
918
|
+
// (`yad gate ci … --merged` run by a person), since CI's own checkout is always fresh and on the default
|
|
919
|
+
// branch: `dirty` — uncommitted or untracked files under either folder BEFORE this run began (the move
|
|
920
|
+
// copies what is on disk, so they would be committed and pushed to the default branch), and a HEAD that
|
|
921
|
+
// is not the default branch (the push goes to the default branch whatever is checked out).
|
|
922
|
+
export function convertProductLevel(root, hub, { git = (...a) => run('git', a, { cwd: root }), defaultBranch = 'main', dirty = false } = {}) {
|
|
923
|
+
if (!isVerifiedLedger(hub)) return null;
|
|
924
|
+
const move = planProductMove(root, { verified: true, ci: true });
|
|
925
|
+
if (!move) return null;
|
|
926
|
+
const out = (r) => (r?.ok ? String(r.stdout || '').trim() : '');
|
|
927
|
+
const head = out(git('rev-parse', 'HEAD'));
|
|
928
|
+
const onDefault = out(git('rev-parse', '--abbrev-ref', 'HEAD')) === defaultBranch
|
|
929
|
+
|| (!!head && head === out(git('rev-parse', '--verify', '-q', `origin/${defaultBranch}`)));
|
|
930
|
+
if (!onDefault) {
|
|
931
|
+
info(`the product level stays in ${move.from}/ — this checkout is not on ${defaultBranch}, and the move is only ever made there`);
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
if (dirty) {
|
|
935
|
+
warn(`the product level stays in ${move.from}/ — this checkout has uncommitted changes under ${move.from}/ or ${move.to}/, and the move would commit them; commit or discard them first`);
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
if (move.action !== 'move') {
|
|
939
|
+
info(`the product level stays in ${move.from}/ for now — ${move.detail}`);
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
const stale = staleFoundationGuards(root);
|
|
943
|
+
if (stale.length) {
|
|
944
|
+
warn(`the product level stays in ${move.from}/ — the wired checks predate the Foundation (${stale.join(', ')}); run \`yad update\` and commit the refreshed checks, and the next gate run moves it`);
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
const written = applyProductMove(root, move, { backup: false });
|
|
949
|
+
ok(`the product level moved: ${move.from}/ → ${move.to}/ as ${FOUNDATION_EPIC} (shape 8)`);
|
|
950
|
+
return written;
|
|
951
|
+
} catch (e) {
|
|
952
|
+
// `applyProductMove` has already put everything back. Red, not quiet: nothing here should fail.
|
|
953
|
+
fail(`the product level was not moved — ${e.message}`);
|
|
954
|
+
process.exitCode = 1;
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
481
959
|
export async function gateCi(root, { branch, pr, merged = false, today, push = true, reader = readPr } = {}) {
|
|
482
|
-
const { hub } =
|
|
483
|
-
if (!hub?.platform) { warn('no
|
|
960
|
+
const { hub } = loadProduct(root);
|
|
961
|
+
if (!hub?.platform) { warn('no Product platform configured (.sdlc/hub.json) — nothing to sync'); return { synced: 0, committed: false, pushed: false }; }
|
|
484
962
|
const git = (...args) => run('git', args, { cwd: root });
|
|
485
963
|
const defaultBranch = hub.default_branch || (() => { const h = git('rev-parse', '--abbrev-ref', 'HEAD').stdout; return h && h !== 'HEAD' ? h : 'main'; })();
|
|
486
964
|
// Push is decided AFTER the sync, once we know whether any step advanced: a held step (no advance,
|
|
487
965
|
// not merged) is read-only and pushes nothing; an advance lands on the default branch (see below).
|
|
488
966
|
|
|
967
|
+
// Whether the old product-level folder (or foundation/) held a person's uncommitted work BEFORE this
|
|
968
|
+
// run writes anything — read now, because the sync below legitimately modifies that ledger. Only asked
|
|
969
|
+
// when a move could happen at all; an unreadable status counts as dirty (refuse rather than guess).
|
|
970
|
+
const legacyDirtyBefore = (merged || !branch) && isVerifiedLedger(hub)
|
|
971
|
+
&& fs.existsSync(path.join(epicRoot(root, DISCOVERY_EPIC), '.sdlc', 'state.json'))
|
|
972
|
+
? (() => {
|
|
973
|
+
const st = git('status', '--porcelain', '--untracked-files=all', '--', epicRel(DISCOVERY_EPIC), FOUNDATION_DIR);
|
|
974
|
+
return !st.ok || String(st.stdout || '').trim() !== '';
|
|
975
|
+
})()
|
|
976
|
+
: false;
|
|
977
|
+
|
|
489
978
|
// Build the work list: one job per (epic, artifact) — from the event branch, or a full sweep.
|
|
490
979
|
const jobs = [];
|
|
491
980
|
if (branch) {
|
|
492
981
|
const parsed = parseReviewBranch(branch);
|
|
493
|
-
if (!parsed) { warn(`${branch} is not a review/EP-*/<artifact> branch — nothing to sync`); return { synced: 0 }; }
|
|
494
|
-
|
|
982
|
+
if (!parsed) { warn(`${branch} is not a review/EP-*/<artifact> branch — nothing to sync`); return { synced: 0, committed: false, pushed: false }; }
|
|
983
|
+
// A review branch named for the OLD spelling whose ledger has already moved (shape 8) is the
|
|
984
|
+
// Foundation's review: its steps keep `artifact: "discovery/"`, so the job resolves to them there.
|
|
985
|
+
// Without this, that merge finds no ledger at `epics/EP-discovery/` and is dropped.
|
|
986
|
+
const moved8 = parsed.epic === DISCOVERY_EPIC
|
|
987
|
+
&& !fs.existsSync(path.join(epicRoot(root, DISCOVERY_EPIC), '.sdlc', 'state.json'))
|
|
988
|
+
&& fs.existsSync(path.join(epicRoot(root, FOUNDATION_EPIC), '.sdlc', 'state.json'));
|
|
989
|
+
jobs.push({ epic: moved8 ? FOUNDATION_EPIC : parsed.epic, base: parsed.base, artifact: artifactFromBase(parsed.base), branch, pr });
|
|
495
990
|
} else {
|
|
496
|
-
|
|
497
|
-
|
|
991
|
+
// `epicIds`, not a listing of `epics/`: the Foundation's ledger lives in `foundation/` (E75), and a
|
|
992
|
+
// sweep that missed it would leave a merged Foundation review stranded, un-advanced, for ever.
|
|
993
|
+
for (const e of epicIds(root)) {
|
|
498
994
|
// Sweep mode isolates per-epic failures: one corrupt ledger must not block the other epics'
|
|
499
995
|
// syncs in an unattended CI run. The run still exits non-zero so the bad file gets fixed.
|
|
500
996
|
let ledger;
|
|
501
997
|
try {
|
|
502
998
|
ledger = loadLedger(epicRoot(root, e));
|
|
503
999
|
} catch (err) {
|
|
504
|
-
|
|
1000
|
+
fail(`${e}: ${err.message} — skipping this epic`);
|
|
505
1001
|
process.exitCode = 1;
|
|
506
1002
|
continue;
|
|
507
1003
|
}
|
|
508
1004
|
if (!ledger.state) continue;
|
|
509
1005
|
for (const p of ledger.hubPrs || []) {
|
|
510
1006
|
const step = findReviewStep(ledger.state, p.artifact);
|
|
511
|
-
if (!step || step
|
|
1007
|
+
if (!step || isPassed(step)) continue;
|
|
512
1008
|
jobs.push({ epic: e, base: base(p.artifact), artifact: p.artifact, branch: p.branch, pr: p.number });
|
|
513
1009
|
}
|
|
514
1010
|
}
|
|
515
|
-
|
|
1011
|
+
// No early return: a sweep with nothing to sync may still have the product level to move (below).
|
|
1012
|
+
if (!jobs.length) info('no open review PRs to sync');
|
|
516
1013
|
}
|
|
517
1014
|
|
|
518
1015
|
let synced = 0;
|
|
519
1016
|
const touched = new Set();
|
|
1017
|
+
const failedEpics = new Set();
|
|
520
1018
|
const advancedEpics = new Set(); // epics whose step actually passed this run (merge OR a swept merge)
|
|
521
1019
|
const statusFiles = new Map(); // epic -> the artifact files syncStatuses rewrote (staging allowlist)
|
|
1020
|
+
// E71 — read ONCE for the whole sweep, and only once there is something to sweep. `gateSync` is
|
|
1021
|
+
// called per job, so letting each call read it would walk the Product and every connected repo's
|
|
1022
|
+
// history once PER open review PR; reading it above the early exits (no platform, an unparseable
|
|
1023
|
+
// branch, no jobs) would pay for a 360-day `git log --all` on every push and throw it away. It is
|
|
1024
|
+
// one fact about the Product, so every job also prints the same number.
|
|
1025
|
+
const sweepCount = jobs.length ? activePeople(root, { today: today || undefined, aliases: legacyLogins(hub) }) : null;
|
|
522
1026
|
for (const job of jobs) {
|
|
523
1027
|
const epicDir = epicRoot(root, job.epic);
|
|
524
1028
|
// Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
|
|
@@ -527,12 +1031,15 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
527
1031
|
ledger = loadLedger(epicDir);
|
|
528
1032
|
} catch (err) {
|
|
529
1033
|
if (branch) throw err;
|
|
530
|
-
|
|
1034
|
+
fail(`${job.epic}: ${err.message} — skipping this epic`);
|
|
531
1035
|
process.exitCode = 1;
|
|
532
1036
|
continue;
|
|
533
1037
|
}
|
|
534
1038
|
if (!ledger.state) {
|
|
535
|
-
|
|
1039
|
+
// Red on a named merge: that review's approval would otherwise be dropped by a run that ends green,
|
|
1040
|
+
// and the scheduled reconcile would repeat the same silent no-op for as long as it looks back.
|
|
1041
|
+
(branch && merged ? fail : warn)(`${job.epic}: no epic state on the checked-out branch — the review branch is cut from the default branch, so it should carry it`);
|
|
1042
|
+
if (branch && merged) process.exitCode = 1;
|
|
536
1043
|
continue;
|
|
537
1044
|
}
|
|
538
1045
|
const step = findReviewStep(ledger.state, job.artifact);
|
|
@@ -565,7 +1072,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
565
1072
|
step: step.id, artifact: job.artifact, platform: hub.platform, number,
|
|
566
1073
|
url: existing?.url ?? null, branch: job.branch, lastSyncedAt: existing?.lastSyncedAt ?? null,
|
|
567
1074
|
});
|
|
568
|
-
|
|
1075
|
+
writeMirrored(ledger.files.productPrs, ledger.files.hubPrs, ledger.hubPrs);
|
|
569
1076
|
}
|
|
570
1077
|
|
|
571
1078
|
// No overlay: at merge the artifact is on the default branch CI checked out, so artifactHash
|
|
@@ -574,7 +1081,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
574
1081
|
try {
|
|
575
1082
|
// A branch event that is not a merge can never advance (the predicate requires merged), so it
|
|
576
1083
|
// is read-only under Path B — run it as a dry sync that persists nothing to the working tree.
|
|
577
|
-
const r = await gateSync(root, { epic: job.epic, artifact: job.artifact, today, reader, dryRun: !!branch && !merged });
|
|
1084
|
+
const r = await gateSync(root, { epic: job.epic, artifact: job.artifact, today, reader, dryRun: !!branch && !merged, headCount: sweepCount });
|
|
578
1085
|
synced += r.synced;
|
|
579
1086
|
// When the step actually ADVANCED (the merge phase, or a swept merge the schedule observed),
|
|
580
1087
|
// reflect it in the artifact frontmatter (draft → approved). Keyed off the advance, not the
|
|
@@ -589,14 +1096,54 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
589
1096
|
}
|
|
590
1097
|
} catch (err) {
|
|
591
1098
|
if (branch) throw err; // event mode: one epic — surface the failure
|
|
592
|
-
|
|
1099
|
+
fail(`${job.epic}: sync failed — ${err.message} — skipping this epic`);
|
|
593
1100
|
process.exitCode = 1;
|
|
594
1101
|
failed = true;
|
|
595
1102
|
}
|
|
596
|
-
if (failed) continue; // a failed epic's partial state must not be committed by this run
|
|
1103
|
+
if (failed) { failedEpics.add(job.epic); continue; } // a failed epic's partial state must not be committed by this run
|
|
597
1104
|
touched.add(job.epic);
|
|
598
1105
|
}
|
|
599
|
-
|
|
1106
|
+
// The product-level move runs AFTER the jobs, so an event for `review/EP-discovery/…` resolved and
|
|
1107
|
+
// advanced its ledger in the old folder first, and only on the DEFAULT branch — a merge run or a sweep,
|
|
1108
|
+
// never a review head, where CI writes nothing (Path B).
|
|
1109
|
+
// E64: the login stamp for every OTHER epic — one with no open review, which the job list never reaches.
|
|
1110
|
+
// An epic this run synced was already stamped by `gateSync`, and a failed one is left alone. It is a
|
|
1111
|
+
// write in its own right, so it can be the reason this run commits, exactly as the product-level move is.
|
|
1112
|
+
// It runs after the jobs, so a job that throws leaves no stamp behind uncommitted. Only on a `--merged`
|
|
1113
|
+
// run — the merge event, and the wired reconcile that re-runs it — never pre-merge (Path B). An epic with
|
|
1114
|
+
// uncommitted changes under `.sdlc` or `reviews` is skipped: the commit below stages both, and must not
|
|
1115
|
+
// carry a person's edit along with the stamp. One epic's unreadable ledger is named and skipped; it does
|
|
1116
|
+
// not stop the review that merged.
|
|
1117
|
+
let stampedCount = 0;
|
|
1118
|
+
if (merged) {
|
|
1119
|
+
const aliases = legacyLogins(hub);
|
|
1120
|
+
const clashed = ambiguousLegacyNames(hub);
|
|
1121
|
+
for (const e of aliases.size ? epicIds(root) : []) {
|
|
1122
|
+
if (touched.has(e) || failedEpics.has(e)) continue;
|
|
1123
|
+
const dirty = git('status', '--porcelain', '--untracked-files=all', '--', path.join(epicRel(e), '.sdlc'), path.join(epicRel(e), 'reviews'));
|
|
1124
|
+
if (!dirty.ok || String(dirty.stdout || '').trim() !== '') continue;
|
|
1125
|
+
const dir = epicRoot(root, e);
|
|
1126
|
+
try {
|
|
1127
|
+
const led = loadLedger(dir);
|
|
1128
|
+
if (!led.state) continue;
|
|
1129
|
+
const st = stampLegacyLogins({ approvals: led.approvals, comments: led.comments }, { aliases, clashed, acceptedFor: (a) => acceptedHashes(dir, a) });
|
|
1130
|
+
if (!st.stamped) continue;
|
|
1131
|
+
const approvalsNow = canonicalApprovals(st.approvals);
|
|
1132
|
+
const commentsNow = canonicalComments(st.comments);
|
|
1133
|
+
if (JSON.stringify(approvalsNow) !== JSON.stringify(canonicalApprovals(led.approvals))) writeJSON(led.files.approvals, approvalsNow);
|
|
1134
|
+
if (JSON.stringify(commentsNow) !== JSON.stringify(canonicalComments(led.comments))) writeJSON(led.files.comments, commentsNow);
|
|
1135
|
+
touched.add(e);
|
|
1136
|
+
stampedCount += st.stamped;
|
|
1137
|
+
info(`${e}: recorded the platform login on ${st.stamped} older approval/comment record(s) that named a roster name`);
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
warn(`${e}: older records not stamped — ${err.message}`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
const moved = (merged || !branch)
|
|
1144
|
+
? convertProductLevel(root, hub, { git, defaultBranch, dirty: legacyDirtyBefore })
|
|
1145
|
+
: null;
|
|
1146
|
+
if (!touched.size && !moved) return { synced, committed: false, pushed: false };
|
|
600
1147
|
|
|
601
1148
|
// Path B: CI never writes the ledger to the review branch. A held step that did not advance is
|
|
602
1149
|
// read-only here — during review the platform PR/MR is the source of truth (native approvals/
|
|
@@ -604,7 +1151,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
604
1151
|
// is what stops an approval from being dismissed and required checks from stranding. Correctness is
|
|
605
1152
|
// unaffected: the merge phase re-reads approvals fresh from the platform (readPr).
|
|
606
1153
|
const advancedAny = advancedEpics.size > 0;
|
|
607
|
-
if (!merged && !advancedAny) {
|
|
1154
|
+
if (!merged && !advancedAny && !moved) {
|
|
608
1155
|
// EVENT mode (--branch) pre-merge is read-only (Path B): the gate was evaluated with a dry sync
|
|
609
1156
|
// that persists nothing, so the one working-tree write is the hub-prs.json seed above (which let
|
|
610
1157
|
// the dry sync find the PR). Restore exactly that file per epic so the checkout stays clean —
|
|
@@ -617,13 +1164,21 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
617
1164
|
// and reviews/*.md may be modified and are deliberately left alone: reverting a sync that genuinely
|
|
618
1165
|
// ran would discard platform state the run just recorded. A bare `yad gate ci` that advances
|
|
619
1166
|
// nothing therefore leaves those files dirty for the operator to inspect and commit (or discard).
|
|
1167
|
+
// (A sweep that MOVES the product level does not come here: it commits, and those sync writes are
|
|
1168
|
+
// recorded with the move rather than left behind.)
|
|
1169
|
+
// BOTH names of the PR ledger. It is written under its new name and its old one (see
|
|
1170
|
+
// MIRRORED_FILES, cli/manifest.mjs), so restoring only one leaves the other behind as an
|
|
1171
|
+
// untracked file — a read-only run that dirties the checkout, which is exactly what this block
|
|
1172
|
+
// exists to prevent.
|
|
620
1173
|
for (const e of touched) {
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
1174
|
+
for (const name of ['product-prs.json', 'hub-prs.json']) {
|
|
1175
|
+
const hp = path.join(epicRel(e), '.sdlc', name);
|
|
1176
|
+
git('checkout', '-q', '--', hp); // restore it if it was tracked
|
|
1177
|
+
git('clean', '-fq', '--', hp); // remove it if the event first-seeded it (untracked)
|
|
1178
|
+
}
|
|
624
1179
|
}
|
|
625
1180
|
info('pre-merge: gate evaluated; the ledger reconciles on the default branch at merge — nothing pushed');
|
|
626
|
-
return { synced };
|
|
1181
|
+
return { synced, committed: false, pushed: false };
|
|
627
1182
|
}
|
|
628
1183
|
const target = defaultBranch; // CI only ever commits the ledger to the default branch
|
|
629
1184
|
|
|
@@ -640,39 +1195,72 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
640
1195
|
// straight to the default branch under a `chore(gate)` subject with [skip ci] — unreviewed, and
|
|
641
1196
|
// contradicting the "CI commits only the ledger" contract every doc in this repo states.
|
|
642
1197
|
for (const e of touched) {
|
|
643
|
-
git('add', '-A', '--', path.join(
|
|
644
|
-
git('add', '-A', '--', path.join(
|
|
1198
|
+
git('add', '-A', '--', path.join(epicRel(e), '.sdlc'));
|
|
1199
|
+
git('add', '-A', '--', path.join(epicRel(e), 'reviews'));
|
|
645
1200
|
for (const f of statusFiles.get(e) || []) git('add', '--', f);
|
|
646
1201
|
}
|
|
647
|
-
|
|
1202
|
+
// The move: exactly the files it wrote, by name, and the old folder's removal — the same allowlist rule.
|
|
1203
|
+
if (moved) {
|
|
1204
|
+
for (const f of moved) git('add', '--', f);
|
|
1205
|
+
git('add', '-A', '--', epicRel(DISCOVERY_EPIC));
|
|
1206
|
+
}
|
|
1207
|
+
// E19: the Product index, rebuilt from the ledger this run just staged and carried in the same commit —
|
|
1208
|
+
// the one writer, on the default branch, as the user's decision asks. Only here, past the read-only
|
|
1209
|
+
// pre-merge return above, and only when HEAD really IS the default branch: `defaultBranch` above falls
|
|
1210
|
+
// back to the current branch when hub.json names none, and a person may run this from any checkout
|
|
1211
|
+
// (E19 review). `stageIndexIfClean` keeps it out of a commit that does not hold what it read.
|
|
1212
|
+
const onDefault = (() => {
|
|
1213
|
+
const head = git('rev-parse', '--abbrev-ref', 'HEAD');
|
|
1214
|
+
return head.ok && head.stdout === resolveDefaultBranch(git, hub);
|
|
1215
|
+
})();
|
|
1216
|
+
const indexStaged = onDefault ? stageIndexIfClean(root, git) : false;
|
|
1217
|
+
if (!onDefault) info(`${INDEX_FILE} not rebuilt — this checkout is not on the default branch, the only place it is written`);
|
|
1218
|
+
if (git('diff', '--cached', '--quiet').ok) { info('ledger unchanged — nothing to commit'); return { synced, committed: false, pushed: false }; }
|
|
648
1219
|
// [skip ci]: the advance lands on the default branch (no PR trigger) but keeps the marker to guard
|
|
649
1220
|
// sibling workflows. CI never pushes the review branch (Path B), so there is no synchronize loop.
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
1221
|
+
// Only the index staged (the ledger this run wrote was already committed): say that, not an advance.
|
|
1222
|
+
const indexOnly = indexStaged && git('diff', '--cached', '--name-only').stdout.split('\n').filter(Boolean).every((f) => f === INDEX_FILE);
|
|
1223
|
+
const sync = indexOnly
|
|
1224
|
+
? 'rebuild the Product index'
|
|
1225
|
+
: !branch
|
|
1226
|
+
? 'scheduled gate sync' // sweep is a batch; one subject for the run
|
|
1227
|
+
: `advance ${jobs[0].epic}/${jobs[0].base} on merge`;
|
|
1228
|
+
// The move, when there is one, is the subject: it is the change a reader of the default branch most
|
|
1229
|
+
// needs to find, and a sync subject would name a folder the same commit deletes. The sync goes in the body.
|
|
1230
|
+
const subject = moved
|
|
1231
|
+
? `chore(gate): move the product level to ${FOUNDATION_DIR}/ (shape 8) [skip ci]`
|
|
1232
|
+
: `chore(gate): ${sync} [skip ci]`;
|
|
1233
|
+
const cm = git('commit', '-m', subject, ...(moved && touched.size ? ['-m', `Also: ${sync}.`] : []),
|
|
1234
|
+
...(stampedCount ? ['-m', `Also: recorded the platform login on ${stampedCount} older approval/comment record(s) (E64).`] : []));
|
|
1235
|
+
if (!cm.ok) { fail(`commit failed: ${cm.stderr || cm.stdout}`); process.exitCode = 1; return { synced, committed: false, pushed: false }; }
|
|
655
1236
|
ok(`committed gate update: ${c.dim(subject)}`);
|
|
656
|
-
if (!push) return { synced };
|
|
1237
|
+
if (!push) return { synced, committed: true, pushed: false };
|
|
657
1238
|
|
|
658
|
-
if (pushWithRebase(root, target).ok) { ok(`pushed to origin/${target}`); return { synced }; }
|
|
1239
|
+
if (pushWithRebase(root, target).ok) { ok(`pushed to origin/${target}`); return { synced, committed: true, pushed: true }; }
|
|
659
1240
|
fail(`could not push to origin/${target}${merged ? ' — protected default branch? allow the gate bot to push the merge advance (see yad-hub-bridge references/bridge.md)' : ''} — or run \`yad gate sync\` locally`);
|
|
660
1241
|
process.exitCode = 1;
|
|
661
|
-
return { synced };
|
|
1242
|
+
return { synced, committed: true, pushed: false };
|
|
662
1243
|
}
|
|
663
1244
|
|
|
664
1245
|
export async function gateComments(root, { epic, artifact, today, reader = readPr } = {}) {
|
|
665
|
-
const { hub } =
|
|
666
|
-
|
|
1246
|
+
const { hub } = loadProduct(root);
|
|
1247
|
+
// The --json answer (E1): each review PR, and what still blocks it.
|
|
1248
|
+
const prs = [];
|
|
1249
|
+
if (!hub?.platform) { warn('no Product platform configured — nothing to fetch'); return { epic, prs }; }
|
|
667
1250
|
const epicDir = epicRoot(root, epic);
|
|
668
1251
|
const ledger = loadLedger(epicDir);
|
|
669
1252
|
const targets = (ledger.hubPrs || []).filter((p) => !artifact || p.artifact === artifact);
|
|
670
|
-
if (!targets.length) { warn('no review PR recorded — run `yad gate open` first'); return; }
|
|
1253
|
+
if (!targets.length) { warn('no review PR recorded — run `yad gate open` first'); return { epic, prs }; }
|
|
671
1254
|
for (const pr of targets) {
|
|
672
1255
|
const pull = reader(hub.platform, pr.number, { cwd: root });
|
|
673
|
-
if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason}`); continue; }
|
|
1256
|
+
if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason}`); prs.push({ artifact: pr.artifact, pr: pr.number, read: false, reason: pull.reason ?? null }); continue; }
|
|
674
1257
|
const cr = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
|
|
675
1258
|
const unresolved = (pull.threads || []).filter((t) => !t.resolved);
|
|
1259
|
+
prs.push({
|
|
1260
|
+
artifact: pr.artifact, pr: pr.number, read: true,
|
|
1261
|
+
changesRequested: cr.map((r) => r.login ?? null),
|
|
1262
|
+
unresolved: unresolved.map((t) => ({ login: t.login ?? null, body: (t.body || '').split('\n')[0] })),
|
|
1263
|
+
});
|
|
676
1264
|
log(`\n ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number})`)}`);
|
|
677
1265
|
if (!cr.length && !unresolved.length) { ok('no unresolved comments — clear to approve/merge'); continue; }
|
|
678
1266
|
for (const r of cr) hand(`${r.login}: changes requested ${c.red('(blocking)')}`);
|
|
@@ -683,25 +1271,119 @@ export async function gateComments(root, { epic, artifact, today, reader = readP
|
|
|
683
1271
|
]);
|
|
684
1272
|
hand('address them in the artifact, reply on the PR, then ask reviewers to resolve their threads');
|
|
685
1273
|
}
|
|
1274
|
+
return { epic, prs };
|
|
686
1275
|
}
|
|
687
1276
|
|
|
688
|
-
export async function gateStatus(root, { epic } = {}) {
|
|
1277
|
+
export async function gateStatus(root, { epic, headCount: given = null } = {}) {
|
|
689
1278
|
const epicDir = epicRoot(root, epic);
|
|
690
1279
|
const ledger = loadLedger(epicDir);
|
|
691
1280
|
if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
|
|
692
|
-
const
|
|
693
|
-
|
|
1281
|
+
const { hub } = loadProduct(root);
|
|
1282
|
+
const solo = isSolo(hub);
|
|
1283
|
+
const reqEng = requireEngagement(hub);
|
|
1284
|
+
const optional = optionalStepsFor(ledger.state); // which steps THIS epic's route allows to be skipped
|
|
1285
|
+
// E71 — read ONCE for the whole view, not once per step: it is a Product-wide fact, and a per-step
|
|
1286
|
+
// read would walk every connected repo's history once for every gate on the screen.
|
|
1287
|
+
// NOT `counted`: the per-step loop below already binds that name to this step's engagement-filtered
|
|
1288
|
+
// approvals, and two different meanings of one word in one function is how a later edit reads the
|
|
1289
|
+
// wrong one.
|
|
1290
|
+
// `given` is a count the caller already read, as `gateSync` takes one — the CLI passes none; a test
|
|
1291
|
+
// passes one so the cap (E72) can be seen on a fixture that has no git history of its own.
|
|
1292
|
+
const headCount = given || activePeople(root, { aliases: legacyLogins(hub) });
|
|
1293
|
+
log(`\n ${c.bold(epic)} ${c.dim(`currentStep: ${printable(ledger.state.currentStep) ?? '(none)'}${solo ? ' — solo mode (approval waived; merge still required)' : ''}`)}`);
|
|
1294
|
+
// Printed in solo mode too, exactly as the per-step count is: someone who later switches to team mode
|
|
1295
|
+
// can see the number their gates will be capped against (E72), before it holds anything for them.
|
|
1296
|
+
log(` ${c.dim(activeSum(headCount))}`);
|
|
1297
|
+
note(c.dim(activeBasis(headCount)));
|
|
1298
|
+
// E74: in solo mode, suggest `yad mode team` when the count shows more than one person may work here.
|
|
1299
|
+
// An unknown count is already said by `activeSum` just above, so only a known suggestion prints.
|
|
1300
|
+
printTeamHint(soloTeamHint(root, hub, { solo, headCount }));
|
|
1301
|
+
const reachSeen = new Set(); // E73: each warning line once per view, not once per step
|
|
1302
|
+
const gates = []; // the --json answer (E1): each review step as this view reads it
|
|
694
1303
|
for (const s of ledger.state.steps.filter((x) => x.type === 'review+approve')) {
|
|
695
|
-
const
|
|
696
|
-
const live = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && !(a.artifactHash
|
|
697
|
-
const stale = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && a.artifactHash
|
|
1304
|
+
const accepted = acceptedHashes(epicDir, s.artifact);
|
|
1305
|
+
const live = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && !isStaleHash(a.artifactHash, accepted));
|
|
1306
|
+
const stale = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && isStaleHash(a.artifactHash, accepted)).length;
|
|
698
1307
|
const tags = `${isEscalated(s) ? ', escalated' : ''}${stale ? `, ${stale} stale (revoked)` : ''}`;
|
|
699
|
-
|
|
1308
|
+
// E7's count, per step, from the step's own risk tags, capped by E72 when the people were counted —
|
|
1309
|
+
// only its base holds the gate until E108, and it is labelled so.
|
|
1310
|
+
// Distinct PEOPLE, which is why it can differ from the approval count beside it: two approvals from
|
|
1311
|
+
// one person are one approver. Printed in solo mode too, where approvals are waived, so a reader who
|
|
1312
|
+
// later switches to team mode can see what each gate will then ask for.
|
|
1313
|
+
//
|
|
1314
|
+
// COUNTED THE WAY THE PREDICATE COUNTS. With `requireEngagement` on, an approval carrying no
|
|
1315
|
+
// verified engagement signal is dropped BEFORE people are counted, so a line built from every live
|
|
1316
|
+
// approval would report a head count the engine does not recognise. The dropped ones are named
|
|
1317
|
+
// rather than hidden, because "two people approved and neither counts" is the fact a reader needs.
|
|
1318
|
+
//
|
|
1319
|
+
// NO COUNT ON A WAIVED STEP. An inherited step's approvals live under the parent epic and a skipped
|
|
1320
|
+
// step was never reviewed, so printing what the count asks for would read as an audit failure on a
|
|
1321
|
+
// step the engine deliberately waives.
|
|
1322
|
+
const counted = reqEng ? live.filter((a) => a.engagement === 'verified') : live;
|
|
1323
|
+
const unengaged = live.length - counted.length;
|
|
1324
|
+
const people = new Set(counted.filter((a) => typeof a.approver === 'string' && a.approver.trim()).map((a) => a.approver)).size; // as gatePredicate counts
|
|
1325
|
+
const from = `from ${people} ${peopleWord(people)}${unengaged ? `, ${unengaged} not engagement-verified (not counted)` : ''}`;
|
|
1326
|
+
// A `skipped` flag is honoured here on exactly the terms `gatePredicate` honours it: only on a step
|
|
1327
|
+
// THIS epic's route marks optional (`isSkippableStep`). Without that guard a hand-edited
|
|
1328
|
+
// `skipped: true` on a required step would read as waived in `gate status` while `gate sync` fell
|
|
1329
|
+
// through to the real rule — two read-only views of one ledger disagreeing, and the misleading one
|
|
1330
|
+
// is the view a human checks first.
|
|
1331
|
+
// `claimsInherited` / `claimsSkipped`, not the canonical state: this note mirrors what
|
|
1332
|
+
// `gatePredicate` honours, and the predicate reads the claim. Reading the canonical state here
|
|
1333
|
+
// instead would make `gate status` and `gate sync` disagree about a half-stamped step — two
|
|
1334
|
+
// read-only views of one ledger, and a human checks this one first.
|
|
1335
|
+
const state = stepStatus(s);
|
|
1336
|
+
const waived = claimsInherited(s)
|
|
1337
|
+
? `; inherited from ${printable(s.inheritedFrom) ?? 'the parent epic'}`
|
|
1338
|
+
: (claimsSkipped(s) && isSkippableStep(s.id, optional)) ? '; skipped (N/A)'
|
|
1339
|
+
: (state === 'deferred' && isSkippableStep(s.id, optional)) ? `; deferred (still owed${s.debt === true ? ', as debt' : ''})` : '';
|
|
1340
|
+
// The shortfall, the same number `gatePredicate` returns as `short`: against the capped count when
|
|
1341
|
+
// the people were counted, the full count when they were not. Printed here because this is the
|
|
1342
|
+
// surface people read when they want to know where a gate stands, and a count with no distance to it
|
|
1343
|
+
// is half the fact.
|
|
1344
|
+
const rule = gateRuleFor(s);
|
|
1345
|
+
const cap = gateCapFor(rule, headCount.capacity.active);
|
|
1346
|
+
const short = Math.max(0, (cap ? cap.to : rule.needed) - people);
|
|
1347
|
+
const count = waived || `; count: ${gateRuleSum(rule)}${gateRuleEnforced(rule, cap)}${short && !solo ? ` — ${short} short` : ''}`;
|
|
1348
|
+
// The CANONICAL state, not the raw field: a pre-shape-7 chain says `blocked` where it means
|
|
1349
|
+
// `todo`, and printing the file's word in the one view people read to see where a gate stands
|
|
1350
|
+
// would make the old vocabulary outlive the model. A status this release cannot name falls back
|
|
1351
|
+
// to the raw string with a mark, because inventing a name for it would hide the finding
|
|
1352
|
+
// `yad doctor` reports as `step:unknown-status`.
|
|
1353
|
+
// A deferred step lets the chain continue, but nobody has reviewed it: a green tick beside "still
|
|
1354
|
+
// owed" would read as reviewed, so it keeps the open-step mark (E37).
|
|
1355
|
+
// A debt being paid back (E41) is no longer `deferred`, so the tag above does not show; say it here,
|
|
1356
|
+
// until the review passes and clears the flag.
|
|
1357
|
+
const paying = s.debt === true && state !== 'deferred' ? '; owed as debt — being paid back' : '';
|
|
1358
|
+
log(` ${isPassed(s) && state !== 'deferred' ? c.green('✓') : c.yellow('•')} ${printable(s.id) ?? '(no id)'} ${c.dim(`— ${state || `${printable(s.status) ?? 'no state'} (unknown)`}, ${live.length} approval(s) ${from}${tags}${count}${paying}`)}`);
|
|
1359
|
+
if (s.closed && typeof s.closed === 'object' && !Array.isArray(s.closed)) log(` ${c.dim(closedLine(s.closed))}`);
|
|
1360
|
+
// E73: why this gate may not pass — reported only, never enforced. Not in solo mode, not on a waived
|
|
1361
|
+
// (inherited, skipped, deferred) step, and not on one that passed. Every other review step counts,
|
|
1362
|
+
// including one not reached yet — so a line about the whole Product (the base, one person) prints
|
|
1363
|
+
// once, under the first step it applies to (`uniqueReach`), not under every step after it.
|
|
1364
|
+
const reach = [];
|
|
1365
|
+
if (!solo && !waived && !isPassed(s)) {
|
|
1366
|
+
for (const why of uniqueReach(gateReach(rule, cap, { have: people, nameOnly: headCount.capacity.nameOnly, approvers: approverCount(headCount), days: headCount.capacity.days }), reachSeen)) {
|
|
1367
|
+
reach.push(why);
|
|
1368
|
+
log(` ${c.yellow('!')} ${c.dim(why)}`);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
gates.push({
|
|
1372
|
+
step: s.id ?? null, artifact: s.artifact ?? null, state: state || null, passed: isPassed(s),
|
|
1373
|
+
approvals: live.length, people, stale, notEngaged: unengaged, escalated: isEscalated(s),
|
|
1374
|
+
// No count on a waived step, as the prose prints none (`have: null` is E7's "nothing counted here").
|
|
1375
|
+
waived: waived ? (claimsInherited(s) ? 'inherited' : claimsSkipped(s) ? 'skipped' : 'deferred') : null,
|
|
1376
|
+
gateRule: waived ? null : rule, cap: waived ? null : (cap ?? null), short: waived ? null : short,
|
|
1377
|
+
debt: s.debt === true, closed: s.closed && typeof s.closed === 'object' && !Array.isArray(s.closed) ? s.closed : null, reach,
|
|
1378
|
+
});
|
|
700
1379
|
}
|
|
1380
|
+
// The count, not the people: the names behind it are `yad history`'s and the platform's to show.
|
|
1381
|
+
const { days, from, active, basis } = headCount.capacity;
|
|
1382
|
+
return { epic, currentStep: ledger.state.currentStep ?? null, solo, active: { active, days, from, basis, unknown: headCount.unknown }, gates };
|
|
701
1383
|
}
|
|
702
1384
|
|
|
703
1385
|
// PURE — the audit-trail commit message for a state repair (mirrors buildCheckpointMessage). The
|
|
704
|
-
// subject passes the
|
|
1386
|
+
// subject passes the Product commit-message gate (valid type `chore`, scope `gate`, no trailing period)
|
|
705
1387
|
// and carries [skip ci]: the repair lands on the default branch, where a re-triggered gate workflow
|
|
706
1388
|
// would have nothing to do. No Task trailer and no Co-Authored-By footer — this is machine state a
|
|
707
1389
|
// human corrected, not an authored code change.
|
|
@@ -719,70 +1401,98 @@ export function buildRepairMessage({ epic, steps }) {
|
|
|
719
1401
|
// sweep up an unrelated edit. It lands on the DEFAULT branch, where `ledger-guard` (which polices the
|
|
720
1402
|
// machine-written ledger on review PRs) does not apply — so this stays compatible with "CI is the sole
|
|
721
1403
|
// writer of the ledger" during review.
|
|
722
|
-
export async function gateRepair(root, { epic, push = false, allowBranch = false, dryRun = false } = {}) {
|
|
1404
|
+
export async function gateRepair(root, { epic, push = false, allowBranch = false, dryRun = false, today = new Date().toISOString().slice(0, 10) } = {}) {
|
|
723
1405
|
const epicDir = epicRoot(root, epic);
|
|
724
1406
|
const ledger = loadLedger(epicDir);
|
|
725
|
-
if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { closed: [] }; }
|
|
1407
|
+
if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { epic, closed: [], dryRun, written: false, committed: false, pushed: false }; }
|
|
726
1408
|
|
|
727
1409
|
log(c.bold(`\nyad gate repair ${c.dim(epic)}`));
|
|
728
1410
|
const violations = stateInvariants(ledger.state);
|
|
729
|
-
if (!violations.length) { ok('epic state is consistent — nothing to repair'); return { closed: [] }; }
|
|
1411
|
+
if (!violations.length) { ok('epic state is consistent — nothing to repair'); return { epic, closed: [], dryRun, written: false, committed: false, pushed: false }; }
|
|
730
1412
|
for (const v of violations) warn(`${v.message} [${v.code}]`);
|
|
731
1413
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1414
|
+
// Read leniently: a repair heals a broken ledger, and a broken hub.json must not stop it (E18).
|
|
1415
|
+
const closed = repairState(ledger.state, { by: closingActor(root, readJSON(productConfigPath(root), null)), date: today });
|
|
1416
|
+
// The --json answer (E1): what this run did, so a dry run and a real one never read the same.
|
|
1417
|
+
const did = (written, committed = false, pushed = false) => ({ epic, closed, dryRun, written, committed, pushed });
|
|
1418
|
+
if (dryRun) { info('dry run — nothing written'); return did(false); }
|
|
1419
|
+
writeState(ledger.files.state, ledger.state);
|
|
1420
|
+
// Lenient, as above: a broken hub.json reads as a local Product, and must not stop the repair. With
|
|
1421
|
+
// --push the rebuild happens in `stageIndexIfClean` below, which checks the commit will hold what it
|
|
1422
|
+
// reads — rebuilding here too would leave a half-true index on disk when it cannot (E19 review).
|
|
1423
|
+
const indexRebuilt = push ? false : refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null));
|
|
735
1424
|
ok(`closed ${closed.length} stranded author step(s): ${c.dim(closed.join(', '))}`);
|
|
736
|
-
if (!push) {
|
|
1425
|
+
if (!push) {
|
|
1426
|
+
hand(`re-run \`yad doctor\` to confirm, then commit epics/*/.sdlc/state.json${indexRebuilt ? ` and ${INDEX_FILE}` : ''} (or re-run with --push)`);
|
|
1427
|
+
return did(true);
|
|
1428
|
+
}
|
|
737
1429
|
|
|
738
1430
|
// --- publish: narrow, default-branch-only commit of the one repaired file ---
|
|
739
1431
|
preflightGuardReadiness(root);
|
|
740
|
-
const git =
|
|
1432
|
+
const git = productGit(root);
|
|
741
1433
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
742
|
-
const defaultBranch = resolveDefaultBranch(git,
|
|
743
|
-
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad gate repair' })) return
|
|
1434
|
+
const defaultBranch = resolveDefaultBranch(git, loadProduct(root).hub);
|
|
1435
|
+
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad gate repair' })) return did(true);
|
|
744
1436
|
|
|
745
1437
|
const spec = path.relative(root, ledger.files.state);
|
|
746
|
-
if (!git('add', '--', spec).ok) { fail(`git add failed for ${spec}`); process.exitCode = 1; return
|
|
747
|
-
if (git('diff', '--cached', '--quiet', '--', spec).ok) { info('state.json unchanged on disk — nothing to commit'); return
|
|
1438
|
+
if (!git('add', '--', spec).ok) { fail(`git add failed for ${spec}`); process.exitCode = 1; return did(true); }
|
|
1439
|
+
if (git('diff', '--cached', '--quiet', '--', spec).ok) { info('state.json unchanged on disk — nothing to commit'); return did(true); }
|
|
1440
|
+
// The index rides the same narrow commit (E19): it summarizes this very file — when the working tree
|
|
1441
|
+
// holds nothing else it reads (`stageIndexIfClean`), on the default branch only (`--allow-branch`
|
|
1442
|
+
// commits the repair elsewhere, and the index is never written on a branch), and never on a verified
|
|
1443
|
+
// Product, where CI is the index's one writer.
|
|
1444
|
+
const specs = [spec, ...(branch === defaultBranch && !isVerifiedLedger(readJSON(productConfigPath(root), null)) && stageIndexIfClean(root, git, [spec]) ? [INDEX_FILE] : [])];
|
|
748
1445
|
|
|
749
1446
|
const message = buildRepairMessage({ epic, steps: closed });
|
|
750
|
-
const cm = git('commit', '-m', message, '--',
|
|
1447
|
+
const cm = git('commit', '-m', message, '--', ...specs);
|
|
751
1448
|
if (!cm.ok) {
|
|
752
|
-
git('reset', '-q', '--',
|
|
1449
|
+
git('reset', '-q', '--', ...specs); // never leave them staged for an unrelated commit to sweep up
|
|
753
1450
|
fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
|
|
754
1451
|
process.exitCode = 1;
|
|
755
|
-
return
|
|
1452
|
+
return did(true);
|
|
756
1453
|
}
|
|
757
1454
|
ok(`committed the repair: ${c.dim(message.split('\n')[0])}`);
|
|
758
1455
|
// Push HEAD to its OWN branch — with --allow-branch we are not on the default branch, and pushing
|
|
759
1456
|
// HEAD:defaultBranch would publish a WIP branch straight to it.
|
|
760
|
-
if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return
|
|
1457
|
+
if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return did(true, true, true); }
|
|
761
1458
|
fail(`could not push to origin/${branch} — a protected branch, or an unresolvable rebase conflict`);
|
|
762
1459
|
hand('run `git pull --rebase` and re-run `yad gate repair <epic> --push`');
|
|
763
1460
|
process.exitCode = 1;
|
|
764
|
-
return
|
|
1461
|
+
return did(true, true, false);
|
|
765
1462
|
}
|
|
766
1463
|
|
|
767
1464
|
// `head` overrides the review branch the PR is opened against — `open-pr` delegates here after pushing
|
|
768
1465
|
// the user's checked-out branch, which for a per-story review (review/EP-*/stories-S01) does NOT equal
|
|
769
1466
|
// the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
|
|
770
1467
|
// the real pushed head so the PR targets a branch that exists. `creator` is injected in tests.
|
|
771
|
-
export async function gateOpen(root, { epic, artifact, head, creator = createPr, hasBranch = branchExists } = {}) {
|
|
772
|
-
const { hub
|
|
1468
|
+
export async function gateOpen(root, { epic, artifact, head, creator = createPr, hasBranch = branchExists, today = new Date().toISOString().slice(0, 10) } = {}) {
|
|
1469
|
+
const { hub } = loadProduct(root);
|
|
773
1470
|
const epicDir = epicRoot(root, epic);
|
|
774
1471
|
const ledger = loadLedger(epicDir);
|
|
775
1472
|
if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
|
|
776
1473
|
if (!artifact) { fail('artifact is required: `yad gate open <epic> <artifact>`'); process.exitCode = 1; return; }
|
|
777
1474
|
const step = findReviewStep(ledger.state, artifact);
|
|
778
1475
|
if (!step) { fail(`no review step for ${artifact}`); process.exitCode = 1; return; }
|
|
1476
|
+
// The step's own spelling from here on: `stories` and `stories/` name one review, and a pointer
|
|
1477
|
+
// recorded under the other spelling would be a second pointer that nothing stamps or replaces.
|
|
1478
|
+
artifact = step.artifact || artifact;
|
|
1479
|
+
// A step SET ASIDE — skipped or deferred — has no review to open. The chain already walks past it, so
|
|
1480
|
+
// `markInReview` would leave the ledger alone while the PR opened anyway: a live review of a step the
|
|
1481
|
+
// chain has walked past, which no merge can advance. Put the step back first (E37).
|
|
1482
|
+
const aside = stepStatus(step);
|
|
1483
|
+
if (aside === 'skipped' || aside === 'deferred') {
|
|
1484
|
+
fail(`${step.id} is ${aside} — there is no review to open`);
|
|
1485
|
+
hand(`put it back in the chain first: yad ${aside === 'skipped' ? 'unskip' : 'undefer'} ${epic} ${step.id.replace(/-review$/, '')}`);
|
|
1486
|
+
process.exitCode = 1;
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
779
1489
|
const b = base(artifact);
|
|
780
1490
|
const branch = head || `review/${epic}/${b}`;
|
|
781
1491
|
const domains = touchedDomains(epicDir, step);
|
|
782
1492
|
warnUnlockedContract(epicDir, artifact);
|
|
783
1493
|
warnIncompleteDiscovery(epicDir, artifact);
|
|
784
1494
|
|
|
785
|
-
const
|
|
1495
|
+
const verified = isVerifiedLedger(hub);
|
|
786
1496
|
// The review branch must exist ON ORIGIN: this command opens a PR against it, it never creates or
|
|
787
1497
|
// pushes it, and `gh pr create --head` explicitly does NOT push either — so a branch that is only
|
|
788
1498
|
// local still fails inside the platform CLI, which is the opaque error this guard exists to replace.
|
|
@@ -804,69 +1514,119 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
|
|
|
804
1514
|
if (present === null) warn(`could not verify that '${branch}' is on origin — opening the PR against it anyway`);
|
|
805
1515
|
}
|
|
806
1516
|
|
|
807
|
-
// Outside
|
|
808
|
-
// ledger, so the local command marks the step in_review. In
|
|
809
|
-
if (
|
|
810
|
-
|
|
811
|
-
|
|
1517
|
+
// Outside verified mode (local, OR a platform with no gate-sync CI) there is no CI to write the
|
|
1518
|
+
// ledger, so the local command marks the step in_review. In verified mode CI is the sole writer.
|
|
1519
|
+
// The author step this run closes, if any: its record gets the PR number once the PR exists (below).
|
|
1520
|
+
const author = ledger.state.steps.find((s) => s?.type === 'author' && s.artifact === step.artifact && s.id !== step.id);
|
|
1521
|
+
const closesAuthor = !verified && !!author && !isPassed(author) && !author.closed;
|
|
1522
|
+
if (!verified) {
|
|
1523
|
+
ledger.state = markInReview(ledger.state, step, { by: closingActor(root, hub), date: today, hash: artifactHash(epicDir, step.artifact) });
|
|
1524
|
+
writeState(ledger.files.state, ledger.state);
|
|
1525
|
+
refreshIndexAfterWrite(root, hub);
|
|
812
1526
|
}
|
|
813
1527
|
if (!hub?.platform) {
|
|
814
|
-
warn('no
|
|
1528
|
+
warn('no Product platform — marked in_review locally (no PR opened)');
|
|
815
1529
|
ok(`${step.id} → in_review`);
|
|
816
|
-
return;
|
|
1530
|
+
return { epic, step: step.id, artifact, branch, opened: false, url: null, markedInReview: true };
|
|
817
1531
|
}
|
|
818
1532
|
|
|
819
|
-
// Open the PR. In
|
|
1533
|
+
// Open the PR. In verified mode CI records the hub-prs entry (and advances) on the default branch at
|
|
820
1534
|
// merge — `yad gate open` never commits gate-state files (the ledger-guard check enforces that), and
|
|
821
|
-
// CI writes nothing pre-merge.
|
|
822
|
-
const body = fillHubTemplate({
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1535
|
+
// CI writes nothing pre-merge. With a local ledger the local command records the PR itself (no CI will).
|
|
1536
|
+
const body = fillHubTemplate({
|
|
1537
|
+
epic, artifact, step, owner: ownerOf(epicDir), domains,
|
|
1538
|
+
active: activePeople(root, { aliases: legacyLogins(hub) }).capacity.active,
|
|
1539
|
+
// Does this epic's ROUTE have an architecture step? Asked of the recorded route and never of the
|
|
1540
|
+
// chain (`routeLacksStep`): a truncated legacy chain has no `architecture` row and is not on a
|
|
1541
|
+
// short lane, and telling its reviewer in writing that it is would be a false claim in a record
|
|
1542
|
+
// people act on. This repo's own e2e fixture is exactly that shape.
|
|
1543
|
+
hasArchitecture: !routeLacksStep(ledger.state, 'architecture'),
|
|
1544
|
+
});
|
|
1545
|
+
// Assignee = whoever opens the review PR: `@me` on GitHub, which `gh` resolves on the right host
|
|
1546
|
+
// (buildPrArgs sends it when no assignee is named), and the login `glab` reports on GitLab.
|
|
1547
|
+
// NO REVIEWERS ARE REQUESTED (E62): they used to come from the roster's reviewer and domain-owner
|
|
1548
|
+
// roles, and there is no stored list to pick them from any more. The team requests them on the PR,
|
|
1549
|
+
// and E68 will suggest them from history. Said on the way out, so nobody waits for a request that
|
|
1550
|
+
// was never sent.
|
|
1551
|
+
const committer = hub.platform === 'gitlab' ? platformLogin(root, hub.platform) : null;
|
|
828
1552
|
const assignees = committer ? [committer] : [];
|
|
829
|
-
const labels =
|
|
1553
|
+
const labels = domains.map((d) => `domain:${d}`); // empty unless the step names its repos (touchedDomains)
|
|
830
1554
|
info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
|
|
831
|
-
const r = creator(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch,
|
|
832
|
-
if (!r.ok) {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
if (r.dropped?.length) warn(`could not request as reviewer (unknown/non-collaborator login): ${r.dropped.join(', ')}`);
|
|
1555
|
+
const r = creator(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, assignees, labels, cwd: root });
|
|
1556
|
+
if (!r.ok) {
|
|
1557
|
+
warn(`could not open PR (${r.reason || 'unknown'})${verified ? ' — open it manually; CI records the gate on merge' : '; step is in_review locally'}`);
|
|
1558
|
+
return { epic, step: step.id, artifact, branch, opened: false, url: null, markedInReview: !verified, reason: r.reason || null };
|
|
1559
|
+
}
|
|
837
1560
|
|
|
838
|
-
if (!
|
|
839
|
-
|
|
840
|
-
|
|
1561
|
+
if (!verified) {
|
|
1562
|
+
// The PR number from its path segment — the first number in the URL can be the repo (`acme/2048/pull/9`).
|
|
1563
|
+
const opened = Number(prNumberFromUrl(r.url)) || null;
|
|
1564
|
+
// A re-opened review replaces the pointer. Stamp the OLD number on this step's approvals that predate
|
|
1565
|
+
// PR provenance first, exactly as `gateCi` does at merge: once the pointer names the new PR, the next
|
|
1566
|
+
// sync would stamp THAT number on them, so an approval given again on the new PR could never be told
|
|
1567
|
+
// from a re-read of the old one and, on GitLab (whose older records hold no submission time), would stay
|
|
1568
|
+
// stale for good.
|
|
1569
|
+
const previous = (ledger.hubPrs || []).find((x) => x.artifact === artifact)?.number ?? null;
|
|
1570
|
+
// Also when the new URL carries no number: the old pointer is about to be overwritten either way.
|
|
1571
|
+
if (previous != null && previous !== opened && stampLegacyPr(ledger.approvals, step.id, previous)) {
|
|
1572
|
+
writeJSON(ledger.files.approvals, canonicalApprovals(ledger.approvals));
|
|
1573
|
+
}
|
|
1574
|
+
ledger.hubPrs = upsertHubPr(ledger.hubPrs, { step: step.id, artifact, platform: hub.platform, number: opened, url: r.url, branch, lastSyncedAt: null });
|
|
1575
|
+
writeMirrored(ledger.files.productPrs, ledger.files.hubPrs, ledger.hubPrs);
|
|
1576
|
+
// The record was written before the PR existed, and the first close wins, so no later sync can add
|
|
1577
|
+
// the number. Add it here, to the record this run wrote and to nothing older (E18).
|
|
1578
|
+
const number = ledger.hubPrs.find((p) => p.artifact === artifact)?.number ?? null;
|
|
1579
|
+
const closedHere = closesAuthor ? ledger.state.steps.find((s) => s.id === author.id) : null;
|
|
1580
|
+
if (number != null && closedHere?.closed?.via === 'review-opened' && closedHere.closed.pr == null) {
|
|
1581
|
+
closedHere.closed = closingRecord({ ...closedHere.closed, pr: number });
|
|
1582
|
+
writeState(ledger.files.state, ledger.state);
|
|
1583
|
+
refreshIndexAfterWrite(root, hub);
|
|
1584
|
+
}
|
|
841
1585
|
}
|
|
842
1586
|
ok(`opened ${r.url}`);
|
|
843
|
-
hand(
|
|
1587
|
+
hand('no reviewers were requested — ask them on the PR itself');
|
|
1588
|
+
hand(verified
|
|
844
1589
|
? 'reviewers approve/comment there; CI advances the gate on the default branch when it is merged'
|
|
845
1590
|
: `reviewers approve/comment there; then run \`yad gate sync ${epic} ${artifact}\``);
|
|
846
|
-
return { url: r.url };
|
|
1591
|
+
return { epic, step: step.id, artifact, branch, opened: true, url: r.url, markedInReview: !verified };
|
|
847
1592
|
}
|
|
848
1593
|
|
|
849
1594
|
// `yad gate review <epic> [artifact]` — assemble + print the grounding bundle the companion skill uses
|
|
850
1595
|
// to generate the 60-sec trailer / swipe cards and to run the grounded chat (artifact + risk tags +
|
|
851
1596
|
// contract + PR + repo code-maps). The CLI never calls an LLM; the skill (yad-review-companion)
|
|
852
1597
|
// consumes this JSON, generates, and posts back via the platform (trailer/comments/approval).
|
|
853
|
-
//
|
|
1598
|
+
// Assemble (but don't print) the Shape grounding bundle. Shared by `review` and `walkthrough` so the
|
|
854
1599
|
// pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
|
|
855
1600
|
// Returns { error } when there is no epic state, else { bundle, epicDir, hub }.
|
|
856
|
-
function reviewBundle(root, { epic, artifact } = {}) {
|
|
857
|
-
const { hub, repos } =
|
|
1601
|
+
function reviewBundle(root, { epic, artifact, headCount = null } = {}) {
|
|
1602
|
+
const { hub, repos } = loadProduct(root);
|
|
858
1603
|
const epicDir = epicRoot(root, epic);
|
|
859
1604
|
const ledger = loadLedger(epicDir);
|
|
860
1605
|
if (!ledger.state) return { error: `no epic state at ${epicDir}` };
|
|
861
1606
|
const pr = (ledger.hubPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
|
|
862
1607
|
const art = artifact || pr?.artifact || null;
|
|
863
1608
|
const step = art ? findReviewStep(ledger.state, art) : null;
|
|
1609
|
+
// E71 — the live capacity count. Its own read, and still once per command: `reviewBundle` has exactly
|
|
1610
|
+
// two callers, `gate review` and `gate walkthrough`, and neither runs alongside `gate status` or
|
|
1611
|
+
// `gate sync`. Read before the bundle because the step's cap (E72) is computed from it. `headCount`
|
|
1612
|
+
// is a count the caller already read, as `gateSync` and `gateStatus` take one — the CLI passes none.
|
|
1613
|
+
const counted = headCount || activePeople(root, { aliases: legacyLogins(hub) });
|
|
864
1614
|
const bundle = {
|
|
865
1615
|
epic,
|
|
866
1616
|
artifact: art,
|
|
867
1617
|
platform: hub?.platform || null,
|
|
868
1618
|
pr: pr ? { number: pr.number, url: pr.url } : null,
|
|
869
|
-
|
|
1619
|
+
// `gateRule` is E7's per-step rule — the number of distinct approvers the count asks for and the
|
|
1620
|
+
// arithmetic behind it. `escalated` says the step carries a risk tag that raises the count. `cap`
|
|
1621
|
+
// is E72's `gateCapFor` object for the count read above, or null when the people could not be
|
|
1622
|
+
// counted — then no cap applies and only the base holds.
|
|
1623
|
+
step: step
|
|
1624
|
+
? { id: step.id, riskTags: step.risk_tags || [], escalated: isEscalated(step), gateRule: gateRuleFor(step), cap: gateCapFor(gateRuleFor(step), counted.capacity.active) }
|
|
1625
|
+
: null,
|
|
1626
|
+
// E71 — the live capacity count, as an OBJECT and never as the sentence, so a consumer reads the
|
|
1627
|
+
// number rather than parsing prose (the same discipline `gateRule` follows). `active: null` means a
|
|
1628
|
+
// source could not be read, which is NOT the same fact as "few people": `unknown` says which.
|
|
1629
|
+
activePeople: { active: counted.capacity.active, windowDays: counted.capacity.days, basis: counted.capacity.basis, unknown: counted.unknown },
|
|
870
1630
|
artifactPath: art ? path.join(epicDir, art) : null,
|
|
871
1631
|
contractPath: art && base(art) === 'architecture' ? path.join(epicDir, 'contract.md') : null,
|
|
872
1632
|
touchedDomains: step ? touchedDomains(epicDir, step) : [],
|
|
@@ -883,19 +1643,19 @@ function reviewBundle(root, { epic, artifact } = {}) {
|
|
|
883
1643
|
return { bundle, epicDir, hub };
|
|
884
1644
|
}
|
|
885
1645
|
|
|
886
|
-
export async function gateReview(root, { epic, artifact } = {}) {
|
|
887
|
-
const r = reviewBundle(root, { epic, artifact });
|
|
888
|
-
if (r.error) {
|
|
889
|
-
|
|
1646
|
+
export async function gateReview(root, { epic, artifact, headCount = null } = {}) {
|
|
1647
|
+
const r = reviewBundle(root, { epic, artifact, headCount });
|
|
1648
|
+
if (r.error) { refuse(r.error, null, { json: true }); return; }
|
|
1649
|
+
emitJSON(r.bundle);
|
|
890
1650
|
return r.bundle;
|
|
891
1651
|
}
|
|
892
1652
|
|
|
893
|
-
// `yad gate walkthrough <epic> [artifact]` — the
|
|
1653
|
+
// `yad gate walkthrough <epic> [artifact]` — the Shape pair-review grounding: the same bundle PLUS
|
|
894
1654
|
// an ordered `stops[]` from the artifact's review diff (highest-risk first). The skill (yad-pair-review)
|
|
895
1655
|
// walks the stops and runs the two-way teaching session. Deterministic sequencing only — no LLM here.
|
|
896
1656
|
export async function gateWalkthrough(root, { epic, artifact, runner = run } = {}) {
|
|
897
1657
|
const r = reviewBundle(root, { epic, artifact });
|
|
898
|
-
if (r.error) {
|
|
1658
|
+
if (r.error) { refuse(r.error, null, { json: true }); return; }
|
|
899
1659
|
const { bundle, hub } = r;
|
|
900
1660
|
const defaultBranch = hub?.default_branch || 'main';
|
|
901
1661
|
let stops = [];
|
|
@@ -905,11 +1665,11 @@ export async function gateWalkthrough(root, { epic, artifact, runner = run } = {
|
|
|
905
1665
|
if (diff.ok && diff.stdout.trim()) {
|
|
906
1666
|
stops = sequenceDiff(diff.stdout, { contractPath: bundle.contractPath });
|
|
907
1667
|
} else if (!diff.ok) {
|
|
908
|
-
|
|
1668
|
+
{ const w = `could not read the artifact diff (${defaultBranch}...HEAD) in ${root} — is the review branch checked out and the base correct?`; collectWarning(w); note(w); }
|
|
909
1669
|
}
|
|
910
1670
|
}
|
|
911
1671
|
const out = { ...bundle, stops };
|
|
912
|
-
|
|
1672
|
+
emitJSON(out);
|
|
913
1673
|
// Diagnostics to STDERR so STDOUT stays pure JSON (the skill / e2e parse it).
|
|
914
1674
|
if (!stops.length) note('no stops from the artifact diff — walk the artifact by section (see yad-pair-review)');
|
|
915
1675
|
return out;
|
|
@@ -919,26 +1679,35 @@ export async function gateWalkthrough(root, { epic, artifact, runner = run } = {
|
|
|
919
1679
|
// briefing text and passes it here; this upserts it idempotently into the review PR/MR description as a
|
|
920
1680
|
// delimited block, so regenerating on every artifact change never duplicates it. A platform write only.
|
|
921
1681
|
export async function gateTrailer(root, { epic, artifact, body, number, getBody = getPrBody, editBody = editPrBody } = {}) {
|
|
922
|
-
const { hub } =
|
|
923
|
-
if (!hub?.platform) { warn('no
|
|
1682
|
+
const { hub } = loadProduct(root);
|
|
1683
|
+
if (!hub?.platform) { warn('no Product platform configured — the trailer posts to the PR/MR (local has none)'); return { number: null, posted: false }; }
|
|
924
1684
|
if (!body || !String(body).trim()) { fail('trailer body is required: `yad gate trailer <epic> <artifact> --body <text>` (the companion generates it)'); process.exitCode = 1; return; }
|
|
925
1685
|
const epicDir = epicRoot(root, epic);
|
|
926
1686
|
const ledger = loadLedger(epicDir);
|
|
927
1687
|
const pr = (ledger.hubPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
|
|
928
1688
|
const n = number || pr?.number;
|
|
929
|
-
if (!n) { warn('no PR number — pass `--pr <n>` (in
|
|
1689
|
+
if (!n) { warn('no PR number — pass `--pr <n>` (in verified mode the PR is recorded in the ledger only at merge)'); return { number: null, posted: false }; }
|
|
930
1690
|
const cur = getBody(hub.platform, n, { cwd: root });
|
|
931
1691
|
if (!cur.ok) { fail(`could not read PR #${n} description: ${cur.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
932
1692
|
const r = editBody(hub.platform, n, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: root });
|
|
933
1693
|
if (!r.ok) { fail(`could not update PR #${n}: ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
934
1694
|
ok(`trailer posted to PR #${n}`);
|
|
935
|
-
return { number: n };
|
|
1695
|
+
return { number: n, posted: true };
|
|
936
1696
|
}
|
|
937
1697
|
|
|
938
1698
|
// ---- helpers ------------------------------------------------------------------------------------
|
|
939
1699
|
const base = (artifact) => artifactBase(artifact);
|
|
940
1700
|
|
|
941
|
-
|
|
1701
|
+
// `active` is the caller's already-read count (E71, `activePeople`), or null when it did not read one.
|
|
1702
|
+
// Passed in rather than read here for the same reason the predicate takes it: this builds a string and
|
|
1703
|
+
// must stay callable from a test without a Product on disk.
|
|
1704
|
+
export function fillHubTemplate({ epic, artifact, step, owner, domains, hasArchitecture = true, active = null }) {
|
|
1705
|
+
const rule = gateRuleFor(step);
|
|
1706
|
+
const cap = gateCapFor(rule, active);
|
|
1707
|
+
// What the count asks for. Only the base holds (until E108); the cap (E72) is shown when it lowered the
|
|
1708
|
+
// ask, dated, because this body is written once and the count can differ when the gate decides.
|
|
1709
|
+
const capped = cap?.capped ? `, capped to ${cap.to} for ${cap.active} active ${peopleWord(cap.active)} when this PR was opened` : '';
|
|
1710
|
+
const needed = `${rule.base} (enforced) · full count ${gateRuleSum(rule)}${capped}${rule.riskStep ? ' (the risk step is advisory)' : ''}`;
|
|
942
1711
|
return [
|
|
943
1712
|
'## Artifact under review',
|
|
944
1713
|
`- Epic: \`${epic}\``,
|
|
@@ -949,23 +1718,47 @@ export function fillHubTemplate({ epic, artifact, step, owner, domains }) {
|
|
|
949
1718
|
'## Impact & Risk (front-half)',
|
|
950
1719
|
`- **Domains / repos touched:** ${domains.join(', ') || 'n/a'}`,
|
|
951
1720
|
`- **Risk tags:** ${(step.risk_tags || []).join(', ') || 'none'}`,
|
|
1721
|
+
// What the count asks for, stated on the artifact people are about to review rather than left for
|
|
1722
|
+
// them to discover later (rule 6). See `needed` above.
|
|
1723
|
+
`- **Approvals needed:** ${needed}`,
|
|
1724
|
+
// E71 — how many people could give those approvals, stated beside the ask so a reviewer can see a
|
|
1725
|
+
// gate that asks for more people than the team has BEFORE they start. E72 caps the reported ask with it.
|
|
1726
|
+
//
|
|
1727
|
+
// THIS IS THE ONE SURFACE WHERE THE COUNT BECOMES A LASTING RECORD. Everywhere else it is printed
|
|
1728
|
+
// live and gone; a PR description is written once, at `gate open`, and read for as long as the PR
|
|
1729
|
+
// exists. The number can be different an hour later, and on a different machine: `gate open` run
|
|
1730
|
+
// from a laptop that has not cloned the connected repos reads "not counted" and would leave that
|
|
1731
|
+
// word in the body for good. So the line dates itself. It does NOT go quiet on an unknown (Part 3),
|
|
1732
|
+
// it just says WHEN it could not count, which is the only honest thing a frozen line can say.
|
|
1733
|
+
`- **Active people:** ${active === null ? 'not counted when this PR was opened (an unreadable source is never read as few people — `yad gate status` counts it live)' : `${active} when this PR was opened (${rule.riskStep && cap ? `the cap is ${cap.limit} (${capSeat(active)}), so this gate's count is ${cap.to}; ` : ''}\`yad gate status\` counts it live)`}`,
|
|
952
1734
|
'',
|
|
953
1735
|
'## How to review (this drives the gate)',
|
|
954
1736
|
'- **Approve** to record your approval; **comment / request changes** to hold the gate.',
|
|
955
1737
|
'- This step advances when approvals are satisfied, all threads are resolved, and this PR is merged.',
|
|
956
1738
|
'',
|
|
957
|
-
// Required by the
|
|
1739
|
+
// Required by the Product `pr-template` gate (check_hub_body). Mirrors the Checklist block of the
|
|
958
1740
|
// committed static template (yad-pr-template/templates/hub/<platform>/) so the generated body
|
|
959
1741
|
// passes on the first CI run.
|
|
960
1742
|
'## Checklist',
|
|
961
1743
|
'- [ ] `owner` set in the artifact frontmatter (inherited from `epic.md`)',
|
|
962
|
-
|
|
963
|
-
|
|
1744
|
+
// The contract item asks about re-locking a surface. An epic on a short lane (E40) has no
|
|
1745
|
+
// architecture step and therefore no `contract.md` and no lock, ever — so the box can never be
|
|
1746
|
+
// ticked and never needs to be. A checklist carrying an item nobody on this route can act on
|
|
1747
|
+
// teaches people to tick without reading, which costs more than the line is worth. Replaced
|
|
1748
|
+
// rather than dropped, so the reviewer is told the surface is out of scope instead of finding a
|
|
1749
|
+
// gap where a contract line used to be. `hasArchitecture` defaults true: every caller outside
|
|
1750
|
+
// this file predates the flag, and the classic wording is the safe answer for an unknown chain.
|
|
1751
|
+
hasArchitecture
|
|
1752
|
+
? '- [ ] Contract re-locked (`.sdlc/contract-lock.json`) if the surface changed (architecture only)'
|
|
1753
|
+
: '- [ ] Contract surface unchanged — this epic is on a short lane with no architecture gate, so it may consume the shared surface but never change it',
|
|
1754
|
+
'- [ ] Risk tags reflect the real surface touched (contract/auth/payments raise the approval count)',
|
|
964
1755
|
'- [ ] No secrets or tokens in the artifact or this description',
|
|
965
1756
|
].join('\n');
|
|
966
1757
|
}
|
|
967
1758
|
|
|
968
|
-
|
|
1759
|
+
// The dated, human-readable approval record beside the ledger. An older approval still carries the
|
|
1760
|
+
// role the roster gave it (E62 left those fields on disk), and it is printed as recorded.
|
|
1761
|
+
function refreshApprovalRecord(epicDir, targets, approvals, today) {
|
|
969
1762
|
for (const pr of targets) {
|
|
970
1763
|
const stepApprovals = approvals.filter((a) => a.step === pr.step && a.status === 'approved');
|
|
971
1764
|
const file = path.join(epicDir, 'reviews', `${base(pr.artifact)}--${today}--approved.md`);
|
|
@@ -973,7 +1766,7 @@ function refreshRoster(epicDir, targets, approvals, today) {
|
|
|
973
1766
|
const lines = [
|
|
974
1767
|
`# Approval record — ${pr.artifact} — ${today}`, '',
|
|
975
1768
|
'## Approved by',
|
|
976
|
-
...stepApprovals.map((a) => `- ${a.approver} — ${a.role}${a.domain ? ` (${a.domain})` : ''} — approved ${a.date}${a.source ? ` (${a.source})` : ''}`),
|
|
1769
|
+
...stepApprovals.map((a) => `- ${a.approver}${a.role ? ` — ${a.role}` : ''}${a.domain ? ` (${a.domain})` : ''} — approved ${a.date}${a.source ? ` (${a.source})` : ''}`),
|
|
977
1770
|
'',
|
|
978
1771
|
];
|
|
979
1772
|
fs.writeFileSync(file, lines.join('\n') + '\n');
|