forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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/AGENTS.md +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/pr-monitor/watch.js
CHANGED
|
@@ -15,29 +15,23 @@
|
|
|
15
15
|
* and only pushed if the next tick did not recover/green it — the JOURNAL still
|
|
16
16
|
* records everything (authority), so `events --since` replay stays complete.
|
|
17
17
|
*
|
|
18
|
-
* Every external effect is injectable (emit/sleep/rng/now/gather/
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* Every external effect is injectable (emit/sleep/rng/now/gather/owner authority,
|
|
19
|
+
* plus `maxPasses`/`signal`) so tests exercise the loop with a fake clock and fake
|
|
20
|
+
* provider, never touching live GitHub or waiting 60s.
|
|
21
21
|
*
|
|
22
22
|
* @module pr-monitor/watch
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
const { runMonitorPass } = require('./monitor');
|
|
26
26
|
const journal = require('./journal');
|
|
27
|
+
const watchOwner = require('./watch-owner');
|
|
27
28
|
const { EVENT_TYPES: T } = require('./events');
|
|
28
29
|
|
|
29
30
|
const DEFAULT_INTERVAL_MS = 60000;
|
|
31
|
+
const DEFAULT_OWNER_HEARTBEAT_MS = 60000;
|
|
30
32
|
const JITTER_RATIO = 0.2;
|
|
31
33
|
const TERMINAL_TYPES = new Set([T.PR_MERGED, T.PR_CLOSED]);
|
|
32
34
|
|
|
33
|
-
// In-process ownership of the watcher slot, keyed by journal dir. The journal
|
|
34
|
-
// PID file serializes claims ACROSS processes, but it cannot make claims within
|
|
35
|
-
// ONE process idempotent: after writePid, `watcherRunning(dir)` returns false
|
|
36
|
-
// because the PID equals process.pid, so a second same-process watchLoop() would
|
|
37
|
-
// also claim, run in parallel, and (on either exit) remove the shared PID while
|
|
38
|
-
// the other is still active. This Set closes that gap for the loop lifetime.
|
|
39
|
-
const ACTIVE_DIRS = new Set();
|
|
40
|
-
|
|
41
35
|
/** Default push sink: one NDJSON line per event to stdout. */
|
|
42
36
|
function defaultEmit(event) {
|
|
43
37
|
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
@@ -78,30 +72,6 @@ function isAborted(signal) {
|
|
|
78
72
|
return Boolean(signal && signal.aborted);
|
|
79
73
|
}
|
|
80
74
|
|
|
81
|
-
/**
|
|
82
|
-
* Atomically claim the watcher slot for this PR. The `watcherRunning` check and
|
|
83
|
-
* the `writePid` write run TOGETHER inside the cross-process journal lock (so two
|
|
84
|
-
* starts in DIFFERENT processes can never both pass), AND the in-process
|
|
85
|
-
* `ACTIVE_DIRS` guard rejects a SECOND concurrent `watchLoop()` in the SAME
|
|
86
|
-
* process (which the PID file alone misses, since our own PID reads as "not
|
|
87
|
-
* running"). Returns true only when the slot was newly claimed; the caller must
|
|
88
|
-
* release with `releaseClaim(dir)` on loop exit. The (possibly injected)
|
|
89
|
-
* `watcherRunning`/`writePid` primitives are honored, so test injection works.
|
|
90
|
-
*/
|
|
91
|
-
function defaultClaim(dir, { watcherRunning, writePid, lockOpts }) {
|
|
92
|
-
return journal.withJournalLock(dir, () => {
|
|
93
|
-
if (ACTIVE_DIRS.has(dir) || watcherRunning(dir)) return false;
|
|
94
|
-
writePid(dir);
|
|
95
|
-
ACTIVE_DIRS.add(dir);
|
|
96
|
-
return true;
|
|
97
|
-
}, lockOpts);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Release the in-process watcher claim for `dir` (idempotent). */
|
|
101
|
-
function releaseClaim(dir) {
|
|
102
|
-
ACTIVE_DIRS.delete(dir);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
75
|
/**
|
|
106
76
|
* Split a pass's events into: held-candidate failures (by check name), terminal
|
|
107
77
|
* events (pr.merged/closed), and everything else (emitted immediately).
|
|
@@ -144,6 +114,20 @@ function confirmHeld(pending, contra, emit) {
|
|
|
144
114
|
}
|
|
145
115
|
}
|
|
146
116
|
|
|
117
|
+
function emitTerminalPass(state, events, emit) {
|
|
118
|
+
const { failures, terminal, others } = partition(events);
|
|
119
|
+
const contra = contradictions(events);
|
|
120
|
+
const heldNames = new Set(state.pending.keys());
|
|
121
|
+
confirmHeld(state.pending, contra, emit);
|
|
122
|
+
state.pending = new Map();
|
|
123
|
+
for (const event of others) {
|
|
124
|
+
if (event.type === T.CHECK_RECOVERED && heldNames.has(checkName(event))) continue;
|
|
125
|
+
emit(event);
|
|
126
|
+
}
|
|
127
|
+
for (const failure of failures.values()) emit(failure);
|
|
128
|
+
for (const event of terminal) emit(event);
|
|
129
|
+
}
|
|
130
|
+
|
|
147
131
|
/**
|
|
148
132
|
* Run ONE watch tick: a bounded monitor pass, then stream its events with the
|
|
149
133
|
* 2-pass flap debounce applied. Mutates `state.pending` (the held failures).
|
|
@@ -151,10 +135,18 @@ function confirmHeld(pending, contra, emit) {
|
|
|
151
135
|
* @returns {Promise<{ terminal: boolean }>} terminal=true → stop the loop.
|
|
152
136
|
*/
|
|
153
137
|
async function runWatchPass(state, ctx, emit) {
|
|
154
|
-
const
|
|
155
|
-
|
|
138
|
+
const monitorPass = ctx.runMonitorPass || runMonitorPass;
|
|
139
|
+
const { events: rawEvents, terminalReceiptId, journalCursor } = await monitorPass({
|
|
140
|
+
...ctx, dir: ctx.dir, gather: ctx.gather, now: ctx.now, enrich: ctx.enrich, lockOpts: ctx.lockOpts,
|
|
156
141
|
});
|
|
142
|
+
const events = ctx.dir && Number.isSafeInteger(journalCursor)
|
|
143
|
+
? journal.readEventsSince(ctx.dir, journalCursor)
|
|
144
|
+
: rawEvents;
|
|
157
145
|
const { failures, terminal, others } = partition(events);
|
|
146
|
+
if (terminalReceiptId) {
|
|
147
|
+
emitTerminalPass(state, events, emit);
|
|
148
|
+
return { terminal: true, terminalReceiptId };
|
|
149
|
+
}
|
|
158
150
|
const contra = contradictions(events);
|
|
159
151
|
const heldNames = new Set(state.pending.keys());
|
|
160
152
|
|
|
@@ -168,7 +160,7 @@ async function runWatchPass(state, ctx, emit) {
|
|
|
168
160
|
if (terminal.length) {
|
|
169
161
|
for (const failure of failures.values()) emit(failure);
|
|
170
162
|
for (const t of terminal) emit(t);
|
|
171
|
-
return { terminal: true };
|
|
163
|
+
return { terminal: false, receiptUnavailable: true };
|
|
172
164
|
}
|
|
173
165
|
state.pending = failures;
|
|
174
166
|
return { terminal: false };
|
|
@@ -192,51 +184,130 @@ async function runWatchPass(state, ctx, emit) {
|
|
|
192
184
|
* @returns {Promise<{ started: boolean, passes: number, stopped: boolean, reason?: string }>}
|
|
193
185
|
*/
|
|
194
186
|
async function watchLoop(ctx) {
|
|
195
|
-
const { dir } = ctx;
|
|
196
187
|
const emit = ctx.emit || defaultEmit;
|
|
197
188
|
const sleep = ctx.sleep || defaultSleep;
|
|
198
189
|
const rng = ctx.rng || Math.random;
|
|
199
190
|
const intervalMs = ctx.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
191
|
+
const ownerHeartbeatMs = ctx.ownerHeartbeatMs ?? DEFAULT_OWNER_HEARTBEAT_MS;
|
|
200
192
|
const maxPasses = ctx.maxPasses ?? Infinity;
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
193
|
+
const owner = ctx.owner || watchOwner;
|
|
194
|
+
const ownerOptions = ctx.ownerOptions || {};
|
|
195
|
+
const identity = { repo: String(ctx.repo || '').toLowerCase(), pr: Number(ctx.pr) };
|
|
196
|
+
const generation = ctx.generation;
|
|
197
|
+
const controllerPid = Number(ctx.controllerPid);
|
|
198
|
+
const pid = Number(ctx.pid ?? process.pid);
|
|
199
|
+
|
|
200
|
+
// Exact generation binding makes a duplicate or stale child a clean no-op.
|
|
201
|
+
if (!Number.isSafeInteger(identity.pr) || identity.pr <= 0 || !identity.repo
|
|
202
|
+
|| !generation || !Number.isSafeInteger(controllerPid) || controllerPid <= 0
|
|
203
|
+
|| !Number.isSafeInteger(pid) || pid <= 0 || typeof owner.bindRunning !== 'function') {
|
|
204
|
+
return { started: false, passes: 0, stopped: false, reason: 'authority-unavailable' };
|
|
205
|
+
}
|
|
206
|
+
const bound = await owner.bindRunning(identity, { generation, controllerPid, pid }, ownerOptions);
|
|
207
|
+
if (!bound?.ok) {
|
|
208
|
+
return { started: false, passes: 0, stopped: false, reason: bound?.reason || 'bind-failed' };
|
|
212
209
|
}
|
|
213
210
|
|
|
214
211
|
const state = { pending: new Map() };
|
|
215
212
|
let passes = 0;
|
|
216
213
|
let stopped = false;
|
|
214
|
+
let terminalPending = false;
|
|
215
|
+
let reason;
|
|
216
|
+
let heartbeatFailure;
|
|
217
|
+
let heartbeatInFlight = null;
|
|
218
|
+
const checkpointOwner = () => {
|
|
219
|
+
if (heartbeatInFlight) return heartbeatInFlight;
|
|
220
|
+
heartbeatInFlight = (async () => {
|
|
221
|
+
try {
|
|
222
|
+
const checkpoint = await owner.heartbeat(identity, { generation, pid }, ownerOptions);
|
|
223
|
+
if (!checkpoint?.ok) heartbeatFailure = checkpoint?.reason || 'owner-lost';
|
|
224
|
+
} catch {
|
|
225
|
+
heartbeatFailure = 'owner-lost';
|
|
226
|
+
} finally {
|
|
227
|
+
heartbeatInFlight = null;
|
|
228
|
+
}
|
|
229
|
+
})();
|
|
230
|
+
return heartbeatInFlight;
|
|
231
|
+
};
|
|
232
|
+
const scheduleHeartbeat = ctx.setInterval || setInterval;
|
|
233
|
+
const cancelHeartbeat = ctx.clearInterval || clearInterval;
|
|
234
|
+
const heartbeatTimer = scheduleHeartbeat(() => { void checkpointOwner(); }, ownerHeartbeatMs);
|
|
235
|
+
heartbeatTimer?.unref?.();
|
|
217
236
|
try {
|
|
218
237
|
while (passes < maxPasses && !isAborted(ctx.signal)) {
|
|
238
|
+
if (heartbeatFailure) {
|
|
239
|
+
reason = heartbeatFailure;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
const current = await owner.readOwner(identity, ownerOptions);
|
|
243
|
+
if (!current?.ok || current.record?.generation !== generation || current.record?.watcherPid !== pid) {
|
|
244
|
+
reason = current?.reason || 'owner-lost';
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
if (current.record.phase === 'stop_requested') {
|
|
248
|
+
stopped = true;
|
|
249
|
+
reason = 'stop-requested';
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
if (current.record.phase === 'terminal_pending') {
|
|
253
|
+
stopped = true;
|
|
254
|
+
terminalPending = true;
|
|
255
|
+
reason = 'terminal-pending';
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
if (current.record.phase !== 'running') {
|
|
259
|
+
reason = 'owner-not-running';
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
219
262
|
const result = await runWatchPass(state, ctx, emit);
|
|
220
263
|
passes += 1;
|
|
221
|
-
if (result.terminal) {
|
|
264
|
+
if (result.terminal) {
|
|
265
|
+
// Keep the exact running row for dead-owner receipt recovery if this write fails.
|
|
266
|
+
terminalPending = true;
|
|
267
|
+
const recorded = await owner.recordTerminal(identity, {
|
|
268
|
+
generation, pid, terminalReceiptId: result.terminalReceiptId,
|
|
269
|
+
}, ownerOptions);
|
|
270
|
+
if (recorded?.ok) {
|
|
271
|
+
stopped = true;
|
|
272
|
+
terminalPending = true;
|
|
273
|
+
reason = 'terminal-pending';
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
reason = recorded?.reason || 'receipt-unavailable';
|
|
277
|
+
}
|
|
278
|
+
await checkpointOwner();
|
|
279
|
+
if (heartbeatFailure) {
|
|
280
|
+
reason = heartbeatFailure;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
222
283
|
if (passes >= maxPasses || isAborted(ctx.signal)) break;
|
|
223
284
|
await sleep(jitter(intervalMs, rng), ctx.signal);
|
|
224
285
|
}
|
|
225
286
|
} finally {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
287
|
+
cancelHeartbeat(heartbeatTimer);
|
|
288
|
+
await heartbeatInFlight;
|
|
289
|
+
// Terminal ownership remains until a later dead-PID completion transaction;
|
|
290
|
+
// every other process exit releases its exact generation cooperatively.
|
|
291
|
+
if (!terminalPending) {
|
|
292
|
+
try {
|
|
293
|
+
const stopRequested = typeof owner.requestStop === 'function'
|
|
294
|
+
? await owner.requestStop(identity, { generation, pid }, ownerOptions)
|
|
295
|
+
: { ok: true };
|
|
296
|
+
if (stopRequested?.ok) {
|
|
297
|
+
await owner.releaseNonterminal(identity, { generation, pid }, ownerOptions);
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
// Preserve the owner row for a later dead-watcher recovery transaction.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
230
303
|
}
|
|
231
|
-
return { started: true, passes, stopped };
|
|
304
|
+
return { started: true, passes, stopped, ...(reason ? { reason } : {}) };
|
|
232
305
|
}
|
|
233
306
|
|
|
234
307
|
module.exports = {
|
|
235
308
|
watchLoop,
|
|
236
309
|
runWatchPass,
|
|
237
310
|
defaultSleep,
|
|
238
|
-
defaultClaim,
|
|
239
|
-
releaseClaim,
|
|
240
311
|
jitter,
|
|
241
312
|
partition,
|
|
242
313
|
contradictions,
|
package/lib/pr-pull.js
CHANGED
|
@@ -43,6 +43,11 @@ const LOG_FETCH_MULTIPLIER = 3;
|
|
|
43
43
|
// re-review or a late reviewer comment inside the window means the PR is still
|
|
44
44
|
// in flight. Mirrors the merge-rules `settle_min` idea (default 10 minutes).
|
|
45
45
|
const DEFAULT_SETTLE_WINDOW_MS = 600000;
|
|
46
|
+
const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
|
|
47
|
+
|
|
48
|
+
function normalizeHeadSha(value) {
|
|
49
|
+
return typeof value === 'string' && FULL_HEAD_SHA.test(value) ? value.toLowerCase() : null;
|
|
50
|
+
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
48
53
|
* Bot logins whose review threads ARE actionable fixes (their comments tell you
|
|
@@ -320,7 +325,7 @@ function isSkipped(check) {
|
|
|
320
325
|
/** A check still in flight (not green, not failed) — e.g. IN_PROGRESS/QUEUED or
|
|
321
326
|
* a status context with no conclusion yet. */
|
|
322
327
|
function isPending(check) {
|
|
323
|
-
return !isGreen(check) && !isFailed(check);
|
|
328
|
+
return !isSkipped(check) && !isGreen(check) && !isFailed(check);
|
|
324
329
|
}
|
|
325
330
|
|
|
326
331
|
/**
|
|
@@ -865,12 +870,14 @@ function buildVerdictContext(input) {
|
|
|
865
870
|
/** Rank 1: UNKNOWN (fail-closed) — unreadable input/required set, missing head
|
|
866
871
|
* oid, or a torn read (head moved during the gather). */
|
|
867
872
|
function rankUnknown(v) {
|
|
868
|
-
const
|
|
873
|
+
const startValid = normalizeHeadSha(v.headOidStart) !== null;
|
|
874
|
+
const endValid = normalizeHeadSha(v.headOidEnd) !== null;
|
|
875
|
+
const torn = startValid && endValid && v.headOidStart !== v.headOidEnd;
|
|
869
876
|
v.evidence.tornRead = torn;
|
|
870
877
|
if (v.requiredClass.unreadable && !v.evidence.unreadable.includes('requiredChecks')) {
|
|
871
878
|
v.evidence.unreadable.push('requiredChecks');
|
|
872
879
|
}
|
|
873
|
-
if (!
|
|
880
|
+
if ((!startValid || !endValid) && !v.evidence.unreadable.includes('headOid')) {
|
|
874
881
|
v.evidence.unreadable.push('headOid');
|
|
875
882
|
}
|
|
876
883
|
return (v.evidence.unreadable.length > 0 || torn) ? 'UNKNOWN' : null;
|
|
@@ -1139,10 +1146,13 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1139
1146
|
|
|
1140
1147
|
// readState is the one REQUIRED read — if it throws, the whole gather fails.
|
|
1141
1148
|
const state = await adapter.readState(pr);
|
|
1149
|
+
const headOidStart = normalizeHeadSha(state.headSha);
|
|
1150
|
+
if (!headOidStart) unreadable.push('headOid');
|
|
1151
|
+
if (state.providerEvidenceReadable === false) unreadable.push('providerEvidence');
|
|
1152
|
+
|
|
1142
1153
|
const requiredSet = await safeRead(
|
|
1143
1154
|
'requiredChecks',
|
|
1144
|
-
// `pr`
|
|
1145
|
-
// protection is unreadable (the Actions token can't read protection).
|
|
1155
|
+
// `pr` is included for diagnostic rollup evidence when protection is unreadable.
|
|
1146
1156
|
() => adapter.readRequiredChecks({ owner, repo, base, pr }),
|
|
1147
1157
|
{ degraded, fallback: null },
|
|
1148
1158
|
);
|
|
@@ -1169,16 +1179,26 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1169
1179
|
// Branch divergence (behind base → needs an update/rebase).
|
|
1170
1180
|
const div = await safeRead(
|
|
1171
1181
|
'divergence',
|
|
1172
|
-
() => callIfPresent(
|
|
1173
|
-
|
|
1182
|
+
() => callIfPresent(
|
|
1183
|
+
adapter,
|
|
1184
|
+
'readDivergence',
|
|
1185
|
+
{ baseRef: ctx.baseRef, cwd, headRef: state.headSha },
|
|
1186
|
+
{ behind: 0 },
|
|
1187
|
+
),
|
|
1188
|
+
{ degraded, unreadable, fallback: { behind: 0 } },
|
|
1174
1189
|
);
|
|
1175
1190
|
const behind = div.behind || 0;
|
|
1176
1191
|
|
|
1177
1192
|
// Predicted merge conflicts (which files) — optional adapter capability.
|
|
1178
1193
|
const conflicts = await safeRead(
|
|
1179
1194
|
'conflicts',
|
|
1180
|
-
() => callIfPresent(
|
|
1181
|
-
|
|
1195
|
+
() => callIfPresent(
|
|
1196
|
+
adapter,
|
|
1197
|
+
'detectConflicts',
|
|
1198
|
+
{ baseRef: ctx.baseRef, cwd, headRef: state.headSha },
|
|
1199
|
+
null,
|
|
1200
|
+
),
|
|
1201
|
+
{ degraded, unreadable, fallback: null },
|
|
1182
1202
|
);
|
|
1183
1203
|
|
|
1184
1204
|
// Bot STATUS COMMENTS (Sonar/Vercel/Netlify/Codecov quality-gate + deployment
|
|
@@ -1214,7 +1234,8 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1214
1234
|
const headPushKnown = headPushTimeMs != null;
|
|
1215
1235
|
// Torn-read guard: re-read the head oid at the END of the gather.
|
|
1216
1236
|
const endState = await safeRead('headEnd', () => adapter.readState(pr), { degraded, unreadable });
|
|
1217
|
-
const headOidEnd = endState
|
|
1237
|
+
const headOidEnd = normalizeHeadSha(endState && endState.headSha);
|
|
1238
|
+
if (!headOidEnd && !unreadable.includes('headOid')) unreadable.push('headOid');
|
|
1218
1239
|
|
|
1219
1240
|
// Actionable NON-HUMAN direct comments (mechanism-detected, suppression-list
|
|
1220
1241
|
// filtered) and an AUTHOR-AGNOSTIC unresolved-thread count — both independent
|
|
@@ -1224,7 +1245,7 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1224
1245
|
const unresolvedThreadCount = countUnresolvedThreads(threads);
|
|
1225
1246
|
|
|
1226
1247
|
const { verdict, evidence } = computeVerdict({
|
|
1227
|
-
headOidStart
|
|
1248
|
+
headOidStart,
|
|
1228
1249
|
headOidEnd,
|
|
1229
1250
|
mergeStateStatus: state.mergeStateStatus,
|
|
1230
1251
|
mergeable: state.mergeable,
|
|
@@ -1245,9 +1266,7 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1245
1266
|
unreadable,
|
|
1246
1267
|
});
|
|
1247
1268
|
|
|
1248
|
-
// Record
|
|
1249
|
-
// `rollup` | null) so the source is visible in the verdict evidence — the
|
|
1250
|
-
// rollup source is the CI path where branch protection is unreadable.
|
|
1269
|
+
// Record the authoritative required-check source (`protection` or null).
|
|
1251
1270
|
evidence.requiredSource = adapter.lastRequiredSource || null;
|
|
1252
1271
|
|
|
1253
1272
|
return {
|
package/lib/pr-shepherd.js
CHANGED
|
@@ -32,6 +32,7 @@ const { fenceUntrusted } = require('./untrusted-content');
|
|
|
32
32
|
|
|
33
33
|
/** Non-erroring terminal states a pass can settle into. */
|
|
34
34
|
const TERMINAL_STATES = ['MERGE_READY', 'ESCALATE', 'PENDING', 'MERGED', 'CLOSED', 'NEEDS_REVIEW'];
|
|
35
|
+
const MERGE_READY_PROVIDER_STATES = new Set(['CLEAN', 'HAS_HOOKS', 'UNSTABLE']);
|
|
35
36
|
|
|
36
37
|
// Review threads are classified BY MECHANISM, not by a bot-name list: a GitHub
|
|
37
38
|
// review THREAD is opened by a reviewer (a human OR any bot) and stays open until
|
|
@@ -72,9 +73,11 @@ function actionableComments(threads, _self) {
|
|
|
72
73
|
);
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
const SUCCESS_CONCLUSIONS = new Set(['SUCCESS'
|
|
76
|
+
const SUCCESS_CONCLUSIONS = new Set(['SUCCESS']);
|
|
76
77
|
|
|
77
78
|
function isGreen(check) {
|
|
79
|
+
if (!Object.prototype.hasOwnProperty.call(check || {}, 'status')
|
|
80
|
+
|| String(check.status || '').toUpperCase() !== 'COMPLETED') return false;
|
|
78
81
|
const c = String(check.conclusion || '').toUpperCase();
|
|
79
82
|
return SUCCESS_CONCLUSIONS.has(c);
|
|
80
83
|
}
|
|
@@ -208,7 +211,7 @@ async function handleFailedRequired({
|
|
|
208
211
|
* @returns {Promise<object>} decision envelope.
|
|
209
212
|
*/
|
|
210
213
|
async function handleBehindBase({
|
|
211
|
-
behind, autoRebase, cleanTree, adapter, baseRef, headUnchanged, actions,
|
|
214
|
+
behind, autoRebase, cleanTree, adapter, baseRef, expectedHead, headUnchanged, actions,
|
|
212
215
|
}) {
|
|
213
216
|
if (!autoRebase) {
|
|
214
217
|
return result('ESCALATE', {
|
|
@@ -237,10 +240,22 @@ async function handleBehindBase({
|
|
|
237
240
|
});
|
|
238
241
|
}
|
|
239
242
|
try {
|
|
240
|
-
await adapter.rebaseOntoBase({ baseRef });
|
|
243
|
+
const rebaseResult = await adapter.rebaseOntoBase({ baseRef, expectedHead });
|
|
244
|
+
const verifiedHead = rebaseResult?.previousHead === expectedHead
|
|
245
|
+
&& typeof rebaseResult?.headSha === 'string'
|
|
246
|
+
&& /^[0-9a-f]{40}$/i.test(rebaseResult.headSha)
|
|
247
|
+
? rebaseResult.headSha.toLowerCase()
|
|
248
|
+
: null;
|
|
249
|
+
if (!verifiedHead) {
|
|
250
|
+
return result('ESCALATE', {
|
|
251
|
+
actions,
|
|
252
|
+
reason: 'Rebase completed without a lease-bound post-action head; refusing convergence handoff.',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
241
255
|
actions.push({ type: 'rebase', baseRef });
|
|
242
256
|
return result('PENDING', {
|
|
243
257
|
actions,
|
|
258
|
+
expectedHead: verifiedHead,
|
|
244
259
|
reason: 'Rebased onto base and force-pushed with lease. Awaiting CI on the next scheduled pass.',
|
|
245
260
|
});
|
|
246
261
|
} catch (error) {
|
|
@@ -388,13 +403,18 @@ async function runShepherdPass(ctx) {
|
|
|
388
403
|
|
|
389
404
|
// Every read goes through the auth guard so 401/403-scope/rate-limit map to
|
|
390
405
|
// the documented PENDING/HARD_STOP states instead of escaping as a generic
|
|
391
|
-
// failure.
|
|
406
|
+
// failure. Other read errors are surfaced below as ESCALATE results.
|
|
392
407
|
|
|
393
408
|
// --- Read PR/CI state FIRST so a merged/closed PR is detected as terminal
|
|
394
409
|
// even when the branch-protection (required-checks) read would fail with an
|
|
395
410
|
// auth/scope error — the scheduler must always get the terminal signal for a
|
|
396
411
|
// landed/closed PR. ---
|
|
397
|
-
|
|
412
|
+
let stateRead;
|
|
413
|
+
try {
|
|
414
|
+
stateRead = await guardAuth(() => adapter.readState(pr), actions);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
return result('ESCALATE', { actions, reason: `PR provider state is unreadable: ${err.message}` });
|
|
417
|
+
}
|
|
398
418
|
if (stateRead.outcome) return stateRead.outcome;
|
|
399
419
|
const startState = stateRead.value;
|
|
400
420
|
const startSha = startState.headSha;
|
|
@@ -402,13 +422,24 @@ async function runShepherdPass(ctx) {
|
|
|
402
422
|
// --- Lifecycle: a merged/closed PR is terminal. ---
|
|
403
423
|
const lifecycle = lifecycleOutcome(startState.state, actions);
|
|
404
424
|
if (lifecycle) return lifecycle;
|
|
425
|
+
if (startState.providerEvidenceReadable === false) {
|
|
426
|
+
return result('ESCALATE', {
|
|
427
|
+
actions,
|
|
428
|
+
reason: 'PR lifecycle, draft, head, or check-rollup evidence is malformed or incomplete.',
|
|
429
|
+
});
|
|
430
|
+
}
|
|
405
431
|
|
|
406
432
|
// --- Required-checks set (only matters for non-terminal PRs); this is where
|
|
407
433
|
// auth/scope fails fast. ---
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
434
|
+
let requiredRead;
|
|
435
|
+
try {
|
|
436
|
+
requiredRead = await guardAuth(
|
|
437
|
+
() => adapter.readRequiredChecks({ owner, repo, base }),
|
|
438
|
+
actions,
|
|
439
|
+
);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
return result('ESCALATE', { actions, reason: `Required-check policy is unreadable: ${err.message}` });
|
|
442
|
+
}
|
|
412
443
|
if (requiredRead.outcome) return requiredRead.outcome;
|
|
413
444
|
const required = requiredRead.value;
|
|
414
445
|
|
|
@@ -422,7 +453,7 @@ async function runShepherdPass(ctx) {
|
|
|
422
453
|
}
|
|
423
454
|
|
|
424
455
|
const divergenceRead = await guardAuth(
|
|
425
|
-
() => adapter.readDivergence({ baseRef, cwd }),
|
|
456
|
+
() => adapter.readDivergence({ baseRef, cwd, headRef: startSha }),
|
|
426
457
|
actions,
|
|
427
458
|
);
|
|
428
459
|
if (divergenceRead.outcome) return divergenceRead.outcome;
|
|
@@ -459,7 +490,8 @@ async function runShepherdPass(ctx) {
|
|
|
459
490
|
// never rebase — force autoRebase off so the branch-behind path only escalates. ---
|
|
460
491
|
if (behind > 0) {
|
|
461
492
|
return handleBehindBase({
|
|
462
|
-
behind, autoRebase: dryRun ? false : autoRebase, cleanTree, adapter, baseRef,
|
|
493
|
+
behind, autoRebase: dryRun ? false : autoRebase, cleanTree, adapter, baseRef,
|
|
494
|
+
expectedHead: startSha, headUnchanged, actions,
|
|
463
495
|
});
|
|
464
496
|
}
|
|
465
497
|
|
|
@@ -472,8 +504,16 @@ async function runShepherdPass(ctx) {
|
|
|
472
504
|
|
|
473
505
|
// --- Terminal: all required green + not behind → merge-ready handoff. ---
|
|
474
506
|
if (allRequiredGreen) {
|
|
507
|
+
const providerMergeState = String(startState.mergeStateStatus || '').toUpperCase();
|
|
508
|
+
if (!MERGE_READY_PROVIDER_STATES.has(providerMergeState)) {
|
|
509
|
+
return result('ESCALATE', {
|
|
510
|
+
actions,
|
|
511
|
+
reason: `Provider merge state ${providerMergeState || 'UNKNOWN'} does not authorize merge readiness.`,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
475
514
|
return result('MERGE_READY', {
|
|
476
515
|
actions,
|
|
516
|
+
expectedHead: startSha,
|
|
477
517
|
reason: 'All required checks are green and the branch is up to date. Handing off to the human to merge in the GitHub UI — the shepherd never merges.',
|
|
478
518
|
});
|
|
479
519
|
}
|
package/lib/preflight/gates.js
CHANGED
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const path = require('node:path');
|
|
20
|
+
const crypto = require('node:crypto');
|
|
20
21
|
const { spawnSync } = require('node:child_process');
|
|
21
|
-
const { execFileSync } = require('node:child_process');
|
|
22
22
|
const nodeFs = require('node:fs');
|
|
23
|
-
const {
|
|
23
|
+
const { getTestCandidatesForChangedFile } = require('../commands/test');
|
|
24
|
+
const { selectDocAssertingTests } = require('../doc-assertions');
|
|
24
25
|
|
|
25
26
|
const IS_WINDOWS = process.platform === 'win32';
|
|
26
27
|
|
|
@@ -37,6 +38,36 @@ function statusSummary(result, okMsg, failMsg) {
|
|
|
37
38
|
: { ok: false, summary: failMsg };
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
function compareStringsByCodePoint(left, right) {
|
|
42
|
+
if (left < right) return -1;
|
|
43
|
+
if (left > right) return 1;
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeChangedFiles(files) {
|
|
48
|
+
if (!Array.isArray(files)) return Object.freeze([]);
|
|
49
|
+
return Object.freeze([...new Set(
|
|
50
|
+
files.map((file) => String(file).replaceAll('\\', '/')).filter((file) => file !== ''),
|
|
51
|
+
)].sort(compareStringsByCodePoint));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fingerprintChangedFiles(files) {
|
|
55
|
+
return crypto.createHash('sha256').update(JSON.stringify(files)).digest('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function selectAffectedTests(projectRoot, changedFiles, fs = nodeFs) {
|
|
59
|
+
const tests = new Set();
|
|
60
|
+
for (const file of changedFiles) {
|
|
61
|
+
for (const candidate of getTestCandidatesForChangedFile(file)) {
|
|
62
|
+
if (fs.existsSync(path.join(projectRoot, candidate))) tests.add(candidate);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const candidate of selectDocAssertingTests(changedFiles, projectRoot, fs)) {
|
|
66
|
+
tests.add(candidate);
|
|
67
|
+
}
|
|
68
|
+
return [...tests].sort(compareStringsByCodePoint);
|
|
69
|
+
}
|
|
70
|
+
|
|
40
71
|
/**
|
|
41
72
|
* Gate 1 — ESLint on the change blast radius (or the whole tree with --all).
|
|
42
73
|
* `files === null` means "lint everything" (`eslint .`).
|
|
@@ -152,38 +183,50 @@ function runSonar(files, { projectRoot, spawn = spawnSync } = {}) {
|
|
|
152
183
|
*/
|
|
153
184
|
function runAffectedTests({
|
|
154
185
|
projectRoot,
|
|
155
|
-
changedFiles
|
|
186
|
+
changedFiles,
|
|
156
187
|
spawn = spawnSync,
|
|
157
188
|
resolveTests,
|
|
158
189
|
} = {}) {
|
|
190
|
+
const inputs = Array.isArray(changedFiles) && Object.isFrozen(changedFiles)
|
|
191
|
+
? changedFiles
|
|
192
|
+
: normalizeChangedFiles(changedFiles);
|
|
193
|
+
const inputFingerprint = fingerprintChangedFiles(inputs);
|
|
159
194
|
const resolver = typeof resolveTests === 'function'
|
|
160
195
|
? resolveTests
|
|
161
|
-
|
|
162
|
-
// otherwise a git error would masquerade as "no affected tests" (fast-lane
|
|
163
|
-
// green), a fail-OPEN. The catch below turns that throw into a closed gate.
|
|
164
|
-
: () => getAffectedTestFiles(projectRoot, execFileSync, nodeFs, { strict: true });
|
|
196
|
+
: (files) => selectAffectedTests(projectRoot, files);
|
|
165
197
|
let targets;
|
|
166
198
|
try {
|
|
167
|
-
targets = resolver() || [];
|
|
199
|
+
targets = resolver(inputs) || [];
|
|
168
200
|
} catch (err) {
|
|
169
201
|
// A resolver ERROR must NOT masquerade as "no affected tests" (green).
|
|
170
202
|
// We could not determine what to run, so fail closed — never a vacuous pass.
|
|
171
203
|
const reason = err && err.message ? err.message : String(err);
|
|
172
|
-
return {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
summary: `affected-test resolution failed — fail-closed (${reason})`,
|
|
207
|
+
inputFingerprint,
|
|
208
|
+
};
|
|
173
209
|
}
|
|
174
210
|
if (targets.length === 0) {
|
|
175
|
-
return {
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
summary: 'no affected tests resolved (fast lane)',
|
|
214
|
+
inputFingerprint,
|
|
215
|
+
};
|
|
176
216
|
}
|
|
177
217
|
const result = spawn(
|
|
178
218
|
'bun',
|
|
179
219
|
['test', '--timeout', '15000', ...targets],
|
|
180
220
|
{ cwd: projectRoot, stdio: 'inherit', shell: IS_WINDOWS },
|
|
181
221
|
);
|
|
182
|
-
return
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
222
|
+
return {
|
|
223
|
+
...statusSummary(
|
|
224
|
+
result,
|
|
225
|
+
`${targets.length} affected test file(s) passed`,
|
|
226
|
+
'affected tests failed',
|
|
227
|
+
),
|
|
228
|
+
inputFingerprint,
|
|
229
|
+
};
|
|
187
230
|
}
|
|
188
231
|
|
|
189
232
|
/**
|
|
@@ -198,15 +241,18 @@ function runAffectedTests({
|
|
|
198
241
|
* @returns {{ name: string, run: () => Promise<{ok:boolean, summary?:string}> }[]}
|
|
199
242
|
*/
|
|
200
243
|
function buildGates({ projectRoot, changedFiles = [], runAll = false, deps = {} }) {
|
|
244
|
+
const normalizedChangedFiles = normalizeChangedFiles(changedFiles);
|
|
245
|
+
const inputFingerprint = fingerprintChangedFiles(normalizedChangedFiles);
|
|
201
246
|
const eslint = deps.eslint || ((files) => runEslint(files, { projectRoot }));
|
|
202
247
|
const structural = deps.structural || (() => runStructural({ projectRoot }));
|
|
203
248
|
const sonar = deps.sonar || ((files) => runSonar(files, { projectRoot }));
|
|
204
|
-
const affected = deps.affected
|
|
249
|
+
const affected = deps.affected
|
|
250
|
+
|| ((files) => runAffectedTests({ projectRoot, changedFiles: files }));
|
|
205
251
|
|
|
206
252
|
// Under --all, scope BOTH lint and sonar to the whole tree (null). Otherwise
|
|
207
253
|
// sonar would receive changedFiles=[] and report a vacuous "no changed files"
|
|
208
254
|
// pass while lint scanned everything — a fail-open hole on the remedy path.
|
|
209
|
-
const scanTargets = runAll ? null :
|
|
255
|
+
const scanTargets = runAll ? null : normalizedChangedFiles;
|
|
210
256
|
|
|
211
257
|
// Affected-tests maps from the change set, which is empty under --all. Running
|
|
212
258
|
// the WHOLE suite here defeats preflight's fast purpose (and hangs on Windows),
|
|
@@ -216,8 +262,9 @@ function buildGates({ projectRoot, changedFiles = [], runAll = false, deps = {}
|
|
|
216
262
|
ok: true,
|
|
217
263
|
skipped: true,
|
|
218
264
|
summary: 'whole-tree mode (--all): affected-test mapping N/A — run the full suite (CI does)',
|
|
265
|
+
inputFingerprint,
|
|
219
266
|
})
|
|
220
|
-
: async () => affected();
|
|
267
|
+
: async () => affected(normalizedChangedFiles);
|
|
221
268
|
|
|
222
269
|
return [
|
|
223
270
|
{ name: 'lint', run: async () => eslint(scanTargets) },
|