mandrel 2.67.0 → 2.69.0
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/agents/story-worker.md +15 -11
- package/.agents/docs/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +36 -1
- package/.agents/schemas/agentrc.schema.json +14 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/coverage-capture.js +65 -9
- package/.agents/scripts/evidence-gate.js +106 -8
- package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
- package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
- package/.agents/scripts/lib/child-exec.js +39 -1
- package/.agents/scripts/lib/close-validation/gates.js +59 -19
- package/.agents/scripts/lib/close-validation/process.js +23 -24
- package/.agents/scripts/lib/close-validation/runner.js +71 -40
- package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
- package/.agents/scripts/lib/config/quality.js +7 -1
- package/.agents/scripts/lib/config/temp-paths.js +15 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
- package/.agents/scripts/lib/coverage-baseline.js +78 -5
- package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
- package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
- package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
- package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
- package/.agents/scripts/lib/coverage-capture.js +121 -81
- package/.agents/scripts/lib/full-suite-lock.js +49 -46
- package/.agents/scripts/lib/full-suite-queue.js +83 -8
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/code-review.js +15 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
- package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
- package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
- package/.agents/scripts/lib/process-group.js +1 -1
- package/.agents/scripts/lib/supervised-suite.js +247 -0
- package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
- package/.agents/scripts/quality-preview.js +112 -14
- package/.agents/scripts/stories-wave-tick.js +47 -0
- package/.agents/scripts/story-review-compute.js +207 -0
- package/.agents/scripts/update-coverage-baseline.js +15 -10
- package/.agents/scripts/update-crap-baseline.js +12 -2
- package/.agents/scripts/update-maintainability-baseline.js +12 -2
- package/.agents/workflows/audit-security.md +0 -1
- package/.agents/workflows/helpers/code-review.md +7 -5
- package/.agents/workflows/helpers/deliver-digest.md +39 -36
- package/.agents/workflows/helpers/deliver-reference.md +115 -5
- package/.agents/workflows/helpers/deliver-story.md +2 -1
- package/docs/CHANGELOG.md +33 -0
- package/lib/cli/registry.js +125 -18
- package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
- package/package.json +1 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A full suite supervised as a process group. The suite may write
|
|
3
|
+
* `$MANDREL_SUITE_READY_FILE` when its tests start; the kill timer then
|
|
4
|
+
* re-arms a fresh `timeoutMs`, so a pre-test wait (bounded by `timeoutMs`
|
|
5
|
+
* too) never spends the test budget. Close parses the timing line back.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
groupSpawnOptions,
|
|
15
|
+
killProcessGroup,
|
|
16
|
+
superviseGroup,
|
|
17
|
+
TIMEOUT_EXIT_CODE,
|
|
18
|
+
} from './process-group.js';
|
|
19
|
+
|
|
20
|
+
/** @typedef {{ lockWaitMs: number, hostWaitMs: number|null, testRunMs: number }} SuiteTimings */
|
|
21
|
+
|
|
22
|
+
export const SUITE_READY_FILE_ENV = 'MANDREL_SUITE_READY_FILE';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_READY_POLL_MS = 250;
|
|
25
|
+
|
|
26
|
+
const NO_HOST_WAIT = 'n/a';
|
|
27
|
+
|
|
28
|
+
/** @param {{ dir?: string }} [opts] */
|
|
29
|
+
export function suiteReadyHandshake({ dir = os.tmpdir() } = {}) {
|
|
30
|
+
const name = `mandrel-suite-ready-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
31
|
+
const file = path.join(dir, name);
|
|
32
|
+
return { file, env: { [SUITE_READY_FILE_ENV]: file } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {SuiteTimings} timings */
|
|
36
|
+
export function formatSuiteTimings({ lockWaitMs, hostWaitMs, testRunMs }) {
|
|
37
|
+
const host = hostWaitMs === null ? NO_HOST_WAIT : Math.round(hostWaitMs);
|
|
38
|
+
return `⏲ suite timings: lockWaitMs=${Math.round(lockWaitMs)} hostWaitMs=${host} testRunMs=${Math.round(testRunMs)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} line
|
|
43
|
+
* @returns {SuiteTimings|null}
|
|
44
|
+
*/
|
|
45
|
+
export function parseSuiteTimings(line) {
|
|
46
|
+
const match =
|
|
47
|
+
/suite timings: lockWaitMs=(\d+) hostWaitMs=(\d+|n\/a) testRunMs=(\d+)/u.exec(
|
|
48
|
+
String(line ?? ''),
|
|
49
|
+
);
|
|
50
|
+
if (!match) return null;
|
|
51
|
+
return {
|
|
52
|
+
lockWaitMs: Number(match[1]),
|
|
53
|
+
hostWaitMs: match[2] === NO_HOST_WAIT ? null : Number(match[2]),
|
|
54
|
+
testRunMs: Number(match[3]),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fileExists(fsImpl, file) {
|
|
59
|
+
try {
|
|
60
|
+
return fsImpl.existsSync(file);
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function removeQuietly(fsImpl, file) {
|
|
67
|
+
try {
|
|
68
|
+
fsImpl.rmSync(file, { force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
// A leftover marker is harmless.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class SuiteClock {
|
|
75
|
+
constructor({ kill, timeoutMs, readyTimeoutMs, readyFile, fsImpl, nowFn }) {
|
|
76
|
+
Object.assign(this, { kill, timeoutMs, readyFile, fsImpl, nowFn });
|
|
77
|
+
this.timedOut = false;
|
|
78
|
+
this.startedAt = nowFn();
|
|
79
|
+
this.readyAt = null;
|
|
80
|
+
this.endedAt = null;
|
|
81
|
+
this.timer = null;
|
|
82
|
+
this.poller = null;
|
|
83
|
+
this.arm(readyTimeoutMs ?? timeoutMs);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
arm(boundMs) {
|
|
87
|
+
clearTimeout(this.timer);
|
|
88
|
+
if (!(Number.isFinite(boundMs) && boundMs > 0)) return;
|
|
89
|
+
this.timer = setTimeout(() => {
|
|
90
|
+
this.timedOut = true;
|
|
91
|
+
this.kill();
|
|
92
|
+
}, boundMs);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
watch(pollMs) {
|
|
96
|
+
this.poller = setInterval(() => this.checkReady(), pollMs);
|
|
97
|
+
this.poller.unref?.();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
checkReady() {
|
|
101
|
+
if (this.readyAt !== null || this.timedOut) return;
|
|
102
|
+
if (!fileExists(this.fsImpl, this.readyFile)) return;
|
|
103
|
+
this.readyAt = this.nowFn();
|
|
104
|
+
clearInterval(this.poller);
|
|
105
|
+
this.arm(this.timeoutMs);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
stop() {
|
|
109
|
+
this.endedAt ??= this.nowFn();
|
|
110
|
+
this.checkReady();
|
|
111
|
+
clearInterval(this.poller);
|
|
112
|
+
clearTimeout(this.timer);
|
|
113
|
+
removeQuietly(this.fsImpl, this.readyFile);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
timings() {
|
|
117
|
+
const end = this.endedAt ?? this.nowFn();
|
|
118
|
+
const testStart = this.readyAt ?? this.startedAt;
|
|
119
|
+
return {
|
|
120
|
+
hostWaitMs: this.readyAt === null ? null : this.readyAt - this.startedAt,
|
|
121
|
+
testRunMs: Math.max(0, end - testStart),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `hostWaitMs` is spawn → ready (null without a signal); `testRunMs` ends at
|
|
128
|
+
* exit. `readyTimeoutMs` (pre-ready bound) is a test seam over `timeoutMs`.
|
|
129
|
+
*
|
|
130
|
+
* @param {{ pid?: number, kill?: Function }} child
|
|
131
|
+
* @param {{ readyFile: string, timeoutMs?: number, readyTimeoutMs?: number, readyPollMs?: number, abortSignal?: AbortSignal, signalOnParentSignal?: string, fsImpl?: object, nowFn?: () => number }} opts
|
|
132
|
+
*/
|
|
133
|
+
export function superviseSuite(child, opts) {
|
|
134
|
+
const { readyPollMs = DEFAULT_READY_POLL_MS, fsImpl = fs } = opts;
|
|
135
|
+
const group = superviseGroup(child, {
|
|
136
|
+
abortSignal: opts.abortSignal,
|
|
137
|
+
signalOnParentSignal: opts.signalOnParentSignal,
|
|
138
|
+
});
|
|
139
|
+
const clock = new SuiteClock({
|
|
140
|
+
...opts,
|
|
141
|
+
fsImpl,
|
|
142
|
+
nowFn: opts.nowFn ?? Date.now,
|
|
143
|
+
kill: () => killProcessGroup(child, 'SIGKILL'),
|
|
144
|
+
});
|
|
145
|
+
clock.watch(readyPollMs);
|
|
146
|
+
return {
|
|
147
|
+
get timedOut() {
|
|
148
|
+
return clock.timedOut;
|
|
149
|
+
},
|
|
150
|
+
get timings() {
|
|
151
|
+
return clock.timings();
|
|
152
|
+
},
|
|
153
|
+
release() {
|
|
154
|
+
clock.stop();
|
|
155
|
+
group.release();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A bare suite gets SIGKILL on a parent signal; a gate with its own cleanup
|
|
162
|
+
* (a capture holding the lock) SIGTERM. A full-suite gate also gets the
|
|
163
|
+
* handshake and emits its timing line on release.
|
|
164
|
+
*
|
|
165
|
+
* @param {{ fullSuiteLock?: boolean, timeoutMs?: number, signal?: AbortSignal, env?: Record<string, string> }} opts
|
|
166
|
+
* @param {{ lockWaitMs?: number }} [lock]
|
|
167
|
+
*/
|
|
168
|
+
export function gateSupervision(opts, lock = {}) {
|
|
169
|
+
const base = {
|
|
170
|
+
timeoutMs: opts.timeoutMs,
|
|
171
|
+
abortSignal: opts.signal,
|
|
172
|
+
signalOnParentSignal: opts.fullSuiteLock ? 'SIGKILL' : 'SIGTERM',
|
|
173
|
+
};
|
|
174
|
+
if (!opts.fullSuiteLock) {
|
|
175
|
+
return { env: opts.env, supervise: (child) => superviseGroup(child, base) };
|
|
176
|
+
}
|
|
177
|
+
const handshake = suiteReadyHandshake();
|
|
178
|
+
return {
|
|
179
|
+
env: { ...opts.env, ...handshake.env },
|
|
180
|
+
supervise: (child, output) =>
|
|
181
|
+
reportOnRelease(
|
|
182
|
+
superviseSuite(child, { ...base, readyFile: handshake.file }),
|
|
183
|
+
(timings) =>
|
|
184
|
+
output.emit(
|
|
185
|
+
output.prefix +
|
|
186
|
+
formatSuiteTimings({
|
|
187
|
+
lockWaitMs: lock.lockWaitMs ?? 0,
|
|
188
|
+
...timings,
|
|
189
|
+
}),
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {ReturnType<typeof superviseSuite>} supervisor
|
|
197
|
+
* @param {(timings: object) => void} report
|
|
198
|
+
*/
|
|
199
|
+
function reportOnRelease(supervisor, report) {
|
|
200
|
+
return {
|
|
201
|
+
get timedOut() {
|
|
202
|
+
return supervisor.timedOut;
|
|
203
|
+
},
|
|
204
|
+
release() {
|
|
205
|
+
supervisor.release();
|
|
206
|
+
report(supervisor.timings);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolves the exit code, or `124` when the supervisor killed the suite.
|
|
213
|
+
*
|
|
214
|
+
* @param {{ cmd: string, args: string[], cwd: string, env?: Record<string, string>, timeoutMs?: number, readyTimeoutMs?: number, readyPollMs?: number, lockWaitMs?: number, spawnImpl?: typeof spawn, onTimings?: (timings: SuiteTimings) => void, onTimeout?: () => void }} opts
|
|
215
|
+
* @returns {Promise<number>}
|
|
216
|
+
*/
|
|
217
|
+
export function runSupervisedSuite(opts) {
|
|
218
|
+
const { cmd, args, cwd, env = {}, spawnImpl = spawn } = opts;
|
|
219
|
+
const handshake = suiteReadyHandshake();
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
const child = spawnImpl(cmd, args, {
|
|
222
|
+
cwd,
|
|
223
|
+
env: { ...process.env, ...env, ...handshake.env },
|
|
224
|
+
stdio: 'inherit',
|
|
225
|
+
shell: process.platform === 'win32',
|
|
226
|
+
...groupSpawnOptions(),
|
|
227
|
+
});
|
|
228
|
+
const supervisor = superviseSuite(child, {
|
|
229
|
+
...opts,
|
|
230
|
+
readyFile: handshake.file,
|
|
231
|
+
});
|
|
232
|
+
let settled = false;
|
|
233
|
+
const settle = (code) => {
|
|
234
|
+
if (settled) return;
|
|
235
|
+
settled = true;
|
|
236
|
+
supervisor.release();
|
|
237
|
+
opts.onTimings?.({
|
|
238
|
+
lockWaitMs: opts.lockWaitMs ?? 0,
|
|
239
|
+
...supervisor.timings,
|
|
240
|
+
});
|
|
241
|
+
if (supervisor.timedOut) opts.onTimeout?.();
|
|
242
|
+
resolve(supervisor.timedOut ? TIMEOUT_EXIT_CODE : code);
|
|
243
|
+
};
|
|
244
|
+
child.on('error', () => settle(1));
|
|
245
|
+
child.on('exit', (code) => settle(code ?? 1));
|
|
246
|
+
});
|
|
247
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Advisory report of probed Stories whose declared footprint shares a
|
|
3
|
+
* concrete path with a Story another session has in flight. Never feeds
|
|
4
|
+
* selection, ordering or the exit code.
|
|
5
|
+
*
|
|
6
|
+
* @module lib/wave-runner/cross-run-overlap
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { TYPE_LABELS } from '../label-constants.js';
|
|
10
|
+
import { storyFootprintPaths } from '../orchestration/resolve-stories.js';
|
|
11
|
+
import { currentOwner } from '../orchestration/ticket-lease.js';
|
|
12
|
+
import { detectCollision } from './footprint.js';
|
|
13
|
+
import { classifyStory, storyIdOf } from './ready-set.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One labelled list query. Never rejects, so a failed read reports
|
|
17
|
+
* "unavailable" instead of an empty list.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} provider
|
|
20
|
+
* @returns {Promise<{ tickets?: object[], error?: string }>}
|
|
21
|
+
*/
|
|
22
|
+
async function listOpenStories(provider) {
|
|
23
|
+
if (typeof provider?.listTicketsByLabel !== 'function') {
|
|
24
|
+
return { error: 'the provider cannot list issues by label' };
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const tickets = await provider.listTicketsByLabel({
|
|
28
|
+
state: 'open',
|
|
29
|
+
labels: TYPE_LABELS.STORY,
|
|
30
|
+
});
|
|
31
|
+
return { tickets: Array.isArray(tickets) ? tickets : [] };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return { error: String(err?.message ?? err) };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object[]} tickets
|
|
39
|
+
* @param {Set<number>} inSetIds
|
|
40
|
+
* @returns {Array<{id: number, files: string[], holder: string|null}>}
|
|
41
|
+
*/
|
|
42
|
+
function outsideInFlight(tickets, inSetIds) {
|
|
43
|
+
const outside = [];
|
|
44
|
+
for (const ticket of tickets) {
|
|
45
|
+
const id = storyIdOf(ticket);
|
|
46
|
+
if (id === null || inSetIds.has(id)) continue;
|
|
47
|
+
if (classifyStory(ticket) !== 'executing') continue;
|
|
48
|
+
outside.push({
|
|
49
|
+
id,
|
|
50
|
+
files: storyFootprintPaths(ticket.body, id),
|
|
51
|
+
holder: currentOwner(ticket.assignees),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return outside;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The reservation guard's `concreteOnly` rule, so advisory and guard never
|
|
59
|
+
* disagree: a glob or the UNKNOWN sentinel reports nothing.
|
|
60
|
+
*
|
|
61
|
+
* @param {Array<{id: number}>} probed
|
|
62
|
+
* @param {Array<{id: number, holder: string|null}>} outside
|
|
63
|
+
* @returns {object[]}
|
|
64
|
+
*/
|
|
65
|
+
function findCrossRunOverlaps(probed, outside) {
|
|
66
|
+
const overlaps = [];
|
|
67
|
+
for (const rec of probed) {
|
|
68
|
+
for (const other of outside) {
|
|
69
|
+
const hit = detectCollision(rec, other, { concreteOnly: true });
|
|
70
|
+
if (!hit) continue;
|
|
71
|
+
overlaps.push({
|
|
72
|
+
id: rec.id,
|
|
73
|
+
otherId: other.id,
|
|
74
|
+
holder: other.holder,
|
|
75
|
+
paths: hit.paths,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return overlaps;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {{ tickets?: object[], error?: string }} listing
|
|
84
|
+
* @param {object[]} nodes
|
|
85
|
+
* @param {Set<number>} inSetIds
|
|
86
|
+
* @returns {object}
|
|
87
|
+
*/
|
|
88
|
+
function buildReport(listing, nodes, inSetIds) {
|
|
89
|
+
if (listing.error) {
|
|
90
|
+
return {
|
|
91
|
+
crossRunOverlapProbe: 'unavailable',
|
|
92
|
+
crossRunOverlapProbeReason: `Could not list in-flight Stories outside this run: ${listing.error}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const candidates = nodes.filter((node) => {
|
|
96
|
+
const cls = classifyStory(node);
|
|
97
|
+
return cls === 'ready' || cls === 'executing';
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
crossRunOverlaps: findCrossRunOverlaps(
|
|
101
|
+
candidates,
|
|
102
|
+
outsideInFlight(listing.tickets, inSetIds),
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Starts the read now so it overlaps the per-Story reads; probed-set Stories
|
|
109
|
+
* are excluded (their overlap is the ready-set guard's job).
|
|
110
|
+
*
|
|
111
|
+
* @param {object} provider
|
|
112
|
+
* @returns {{ report: (nodes: object[]) => Promise<object> }}
|
|
113
|
+
*/
|
|
114
|
+
export function startCrossRunProbe(provider) {
|
|
115
|
+
const listing = listOpenStories(provider);
|
|
116
|
+
return {
|
|
117
|
+
report: async (nodes) =>
|
|
118
|
+
buildReport(await listing, nodes, new Set(nodes.map((n) => n.id))),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
currentOwner,
|
|
20
20
|
normalizeOperatorHandle,
|
|
21
21
|
} from '../orchestration/ticket-lease.js';
|
|
22
|
+
import { startCrossRunProbe } from './cross-run-overlap.js';
|
|
22
23
|
import { classifyStory, storyIdOf } from './ready-set.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -143,7 +144,8 @@ export function createProbeContext({
|
|
|
143
144
|
* inFlight: number,
|
|
144
145
|
* blockedIds: number[],
|
|
145
146
|
* stalledDispatch: number[],
|
|
146
|
-
* foreignHeld: Array<{id: number, holder: string}
|
|
147
|
+
* foreignHeld: Array<{id: number, holder: string}>,
|
|
148
|
+
* crossRunOverlaps?: object[]
|
|
147
149
|
* }>}
|
|
148
150
|
*/
|
|
149
151
|
export async function probeLiveState({
|
|
@@ -156,6 +158,7 @@ export async function probeLiveState({
|
|
|
156
158
|
self,
|
|
157
159
|
warn,
|
|
158
160
|
}) {
|
|
161
|
+
const crossRun = startCrossRunProbe(provider);
|
|
159
162
|
// The `agent::*` guard is an admission check; a just-dispatched Story is
|
|
160
163
|
// legitimately unlabelled until init flips it, so a per-beat probe allows it.
|
|
161
164
|
const stories = await fetchStories(provider, ids, { allowUnlabelled: true });
|
|
@@ -207,6 +210,7 @@ export async function probeLiveState({
|
|
|
207
210
|
.filter((id) => !foreignHeld.has(id))
|
|
208
211
|
.sort((a, b) => a - b),
|
|
209
212
|
foreignHeld: [...foreignHeld].map(([id, holder]) => ({ id, holder })),
|
|
213
|
+
...(await crossRun.report(nodes)),
|
|
210
214
|
};
|
|
211
215
|
}
|
|
212
216
|
|
|
@@ -18,7 +18,7 @@ import { CODING_GUARDRAILS } from './lib/config/quality.js';
|
|
|
18
18
|
|
|
19
19
|
const USAGE = {
|
|
20
20
|
invocation:
|
|
21
|
-
'node .agents/scripts/quality-preview.js [--staged | --changed-since <ref>] [--json]',
|
|
21
|
+
'node .agents/scripts/quality-preview.js [--staged | --changed-since <ref>] [--only mi|crap] [--json]',
|
|
22
22
|
summary:
|
|
23
23
|
'Preview the per-file maintainability and CRAP deltas for the change set, and exit non-zero on any threshold violation.',
|
|
24
24
|
flags: [
|
|
@@ -30,6 +30,10 @@ const USAGE = {
|
|
|
30
30
|
'--changed-since <ref>',
|
|
31
31
|
'Score the diff against <ref> (default: HEAD). Last occurrence wins.',
|
|
32
32
|
],
|
|
33
|
+
[
|
|
34
|
+
'--only mi|crap',
|
|
35
|
+
'Run one half only — maintainability (`mi`) or CRAP (`crap`); the other half is reported as not run. Default: both, serially.',
|
|
36
|
+
],
|
|
33
37
|
['--json', 'Emit both gate envelopes plus the merged table as JSON.'],
|
|
34
38
|
],
|
|
35
39
|
};
|
|
@@ -73,6 +77,96 @@ export function parseStagedFlag(argv) {
|
|
|
73
77
|
return argv.includes('--staged');
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
/** The halves `--only` can select. */
|
|
81
|
+
const PREVIEW_HALVES = new Set(['mi', 'crap']);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `--only <half>` (last occurrence wins); `null` runs both halves. A value
|
|
85
|
+
* outside {@link PREVIEW_HALVES} is returned as-is for the caller to refuse.
|
|
86
|
+
*
|
|
87
|
+
* @param {string[]} argv
|
|
88
|
+
* @returns {string | null}
|
|
89
|
+
*/
|
|
90
|
+
function parseOnlyArg(argv) {
|
|
91
|
+
const at = argv.lastIndexOf('--only');
|
|
92
|
+
return at === -1 ? null : (argv[at + 1] ?? '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string|null} only
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
function isUnknownHalf(only) {
|
|
100
|
+
return only !== null && !PREVIEW_HALVES.has(only);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Run `half` unless `--only` selected the other one.
|
|
105
|
+
*
|
|
106
|
+
* @param {{ only: string|null, half: 'mi'|'crap', run: () => Promise<{exitCode: number, envelope: object|null}> }} opts
|
|
107
|
+
* @returns {Promise<{exitCode: number, envelope: object|null}>}
|
|
108
|
+
*/
|
|
109
|
+
function runHalf({ only, half, run }) {
|
|
110
|
+
return only === null || only === half ? run() : Promise.resolve(NOT_RUN);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The `--json` field naming the selected half; empty for the default run so
|
|
115
|
+
* its envelope stays byte-identical.
|
|
116
|
+
*
|
|
117
|
+
* @param {string|null} only
|
|
118
|
+
* @returns {{ only?: string }}
|
|
119
|
+
*/
|
|
120
|
+
function onlyField(only) {
|
|
121
|
+
return only ? { only } : {};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The report line naming the selected half; empty for the default run.
|
|
126
|
+
*
|
|
127
|
+
* @param {string|null} only
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function halfLine(only) {
|
|
131
|
+
return only ? `half=${only} only — the other half was not run\n` : '';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `--staged` wins; otherwise `--changed-since` (absent → `HEAD`).
|
|
136
|
+
*
|
|
137
|
+
* @param {string[]} argv
|
|
138
|
+
* @param {boolean} staged
|
|
139
|
+
* @returns {string|null}
|
|
140
|
+
*/
|
|
141
|
+
function resolveRef(argv, staged) {
|
|
142
|
+
return staged ? null : (parseChangedSinceArg(argv) ?? 'HEAD');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A half `--only` left out: clean, with no envelope to merge. */
|
|
146
|
+
const NOT_RUN = Object.freeze({ exitCode: 0, envelope: null });
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run the selected halves serially, not via Promise.all: each runner sizes its
|
|
150
|
+
* own pool to availableParallelism, so overlapping them oversubscribes 2x and
|
|
151
|
+
* stacks two escomplex heaps (>1 GB RSS).
|
|
152
|
+
*
|
|
153
|
+
* @param {{ only: string|null, args: object, runMi: Function, runCrap: Function, stderr: { write: (s: string) => void } }} opts
|
|
154
|
+
* @returns {Promise<{ miResult: {exitCode: number, envelope: object|null}, crapResult: {exitCode: number, envelope: object|null} }>}
|
|
155
|
+
*/
|
|
156
|
+
async function runHalves({ only, args, runMi, runCrap, stderr }) {
|
|
157
|
+
const miResult = await runHalf({
|
|
158
|
+
only,
|
|
159
|
+
half: 'mi',
|
|
160
|
+
run: () => runGateSafely(runMi, args, 'MI', stderr),
|
|
161
|
+
});
|
|
162
|
+
const crapResult = await runHalf({
|
|
163
|
+
only,
|
|
164
|
+
half: 'crap',
|
|
165
|
+
run: () => runGateSafely(runCrap, args, 'CRAP', stderr),
|
|
166
|
+
});
|
|
167
|
+
return { miResult, crapResult };
|
|
168
|
+
}
|
|
169
|
+
|
|
76
170
|
/**
|
|
77
171
|
* @param {unknown} value
|
|
78
172
|
* @returns {number}
|
|
@@ -325,6 +419,7 @@ function stagedScopeLine({ staged, ref, cwd }) {
|
|
|
325
419
|
*
|
|
326
420
|
* @param {{
|
|
327
421
|
* json: boolean,
|
|
422
|
+
* only: string|null,
|
|
328
423
|
* staged: boolean,
|
|
329
424
|
* ref: string|null,
|
|
330
425
|
* cwd: string,
|
|
@@ -338,6 +433,7 @@ function stagedScopeLine({ staged, ref, cwd }) {
|
|
|
338
433
|
*/
|
|
339
434
|
function emitReport({
|
|
340
435
|
json,
|
|
436
|
+
only,
|
|
341
437
|
staged,
|
|
342
438
|
ref,
|
|
343
439
|
cwd,
|
|
@@ -355,6 +451,7 @@ function emitReport({
|
|
|
355
451
|
{
|
|
356
452
|
ref: staged ? null : ref,
|
|
357
453
|
staged,
|
|
454
|
+
...onlyField(only),
|
|
358
455
|
mi: { exit: miExit, envelope: miResult.envelope },
|
|
359
456
|
crap: { exit: crapExit, envelope: crapResult.envelope },
|
|
360
457
|
merged,
|
|
@@ -366,6 +463,7 @@ function emitReport({
|
|
|
366
463
|
return;
|
|
367
464
|
}
|
|
368
465
|
stdout.write('\n--- quality:preview ---\n');
|
|
466
|
+
stdout.write(halfLine(only));
|
|
369
467
|
stdout.write(stagedScopeLine({ staged, ref, cwd }));
|
|
370
468
|
stdout.write(`${renderTable(merged)}\n`);
|
|
371
469
|
writeAdvisories(merged.advisories, stdout);
|
|
@@ -399,23 +497,22 @@ export async function runCli({
|
|
|
399
497
|
} = {}) {
|
|
400
498
|
const json = parseJsonFlag(argv);
|
|
401
499
|
const staged = parseStagedFlag(argv);
|
|
402
|
-
const ref =
|
|
500
|
+
const ref = resolveRef(argv, staged);
|
|
501
|
+
const only = parseOnlyArg(argv);
|
|
502
|
+
if (isUnknownHalf(only)) {
|
|
503
|
+
stderr.write(
|
|
504
|
+
`[quality:preview] --only takes mi or crap (got "${only}").\n`,
|
|
505
|
+
);
|
|
506
|
+
return { exitCode: 2, merged: mergeEnvelopes(null, null) };
|
|
507
|
+
}
|
|
403
508
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const miResult = await runGateSafely(
|
|
509
|
+
const { miResult, crapResult } = await runHalves({
|
|
510
|
+
only,
|
|
511
|
+
args: { cwd, staged, changedSinceRef: ref },
|
|
408
512
|
runMi,
|
|
409
|
-
{ cwd, staged, changedSinceRef: ref },
|
|
410
|
-
'MI',
|
|
411
|
-
stderr,
|
|
412
|
-
);
|
|
413
|
-
const crapResult = await runGateSafely(
|
|
414
513
|
runCrap,
|
|
415
|
-
{ cwd, staged, changedSinceRef: ref },
|
|
416
|
-
'CRAP',
|
|
417
514
|
stderr,
|
|
418
|
-
);
|
|
515
|
+
});
|
|
419
516
|
|
|
420
517
|
const merged = mergeEnvelopes(miResult.envelope, crapResult.envelope, {
|
|
421
518
|
cyclomaticFlag: DEFAULT_CYCLOMATIC_FLAG,
|
|
@@ -423,6 +520,7 @@ export async function runCli({
|
|
|
423
520
|
|
|
424
521
|
emitReport({
|
|
425
522
|
json,
|
|
523
|
+
only,
|
|
426
524
|
staged,
|
|
427
525
|
ref,
|
|
428
526
|
cwd,
|
|
@@ -183,6 +183,15 @@ delivery.deliverRunner.footprintGuard: under "advisory" the collisions are
|
|
|
183
183
|
detected and listed in "advisory" but never withhold, and dispatch follows the
|
|
184
184
|
declared depends_on edges alone.
|
|
185
185
|
|
|
186
|
+
crossRunOverlaps (probe mode only) is ADVISORY: each probed Story that is
|
|
187
|
+
ready or in flight and shares a concrete path with an open Story in flight in
|
|
188
|
+
ANOTHER session (outside --stories) — { id, otherId, holder, paths } — plus one
|
|
189
|
+
stderr warning line per pair. It never withholds, reorders or delays dispatch
|
|
190
|
+
and never changes the exit code; it uses the same concrete-path rule as
|
|
191
|
+
inFlightReservation. When the outside query fails the envelope carries
|
|
192
|
+
crossRunOverlapProbe: "unavailable" and crossRunOverlapProbeReason instead of
|
|
193
|
+
the list, so "no overlap" is never inferred from a failed read.
|
|
194
|
+
|
|
186
195
|
Exit codes:
|
|
187
196
|
0 - Success, ready set emitted
|
|
188
197
|
1 - Invalid input (missing/malformed DAG, invalid --concurrency/--in-flight/--done)
|
|
@@ -890,6 +899,7 @@ export function runStoriesWaveTick({
|
|
|
890
899
|
* @param {NodeJS.ProcessEnv} [args.env]
|
|
891
900
|
* @param {Function} [args.probe] Test seam.
|
|
892
901
|
* @param {Function} [args.context] Test seam.
|
|
902
|
+
* @param {Function} [args.warn] Test seam.
|
|
893
903
|
* @returns {Promise<{ envelope: object, exitCode: number, records: object[] }>}
|
|
894
904
|
* `records` are the probed nodes, kept off stdout.
|
|
895
905
|
*/
|
|
@@ -902,6 +912,7 @@ export async function runProbedStoriesWaveTick({
|
|
|
902
912
|
env,
|
|
903
913
|
probe = probeLiveState,
|
|
904
914
|
context = createProbeContext,
|
|
915
|
+
warn = Logger.warn,
|
|
905
916
|
} = {}) {
|
|
906
917
|
const { value: override, error: concurrencyError } =
|
|
907
918
|
parseConcurrencyOverride(concurrency);
|
|
@@ -956,6 +967,8 @@ export async function runProbedStoriesWaveTick({
|
|
|
956
967
|
foreignHeld = [],
|
|
957
968
|
inFlightRecords = [],
|
|
958
969
|
} = probed;
|
|
970
|
+
const crossRun = crossRunFields(probed);
|
|
971
|
+
for (const line of crossRunWarnings(crossRun.crossRunOverlaps)) warn(line);
|
|
959
972
|
const { envelope, exitCode } = buildReadySetEnvelope(nodes, {
|
|
960
973
|
concurrencyCap,
|
|
961
974
|
capPrecedence,
|
|
@@ -979,6 +992,7 @@ export async function runProbedStoriesWaveTick({
|
|
|
979
992
|
stalledDispatch,
|
|
980
993
|
foreignHeld,
|
|
981
994
|
foreignHeldReason: foreignHeldReasonFor(foreignHeld),
|
|
995
|
+
...crossRun,
|
|
982
996
|
},
|
|
983
997
|
records: nodes,
|
|
984
998
|
// Blocked outranks a wedge (its blockers are moot while a human owes a
|
|
@@ -990,6 +1004,39 @@ export async function runProbedStoriesWaveTick({
|
|
|
990
1004
|
};
|
|
991
1005
|
}
|
|
992
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* Exactly one advisory shape passes through; a failed read never becomes [].
|
|
1009
|
+
*
|
|
1010
|
+
* @param {object} probed
|
|
1011
|
+
* @returns {object}
|
|
1012
|
+
*/
|
|
1013
|
+
function crossRunFields(probed) {
|
|
1014
|
+
if (Array.isArray(probed.crossRunOverlaps)) {
|
|
1015
|
+
return { crossRunOverlaps: probed.crossRunOverlaps };
|
|
1016
|
+
}
|
|
1017
|
+
if (probed.crossRunOverlapProbe === 'unavailable') {
|
|
1018
|
+
return {
|
|
1019
|
+
crossRunOverlapProbe: 'unavailable',
|
|
1020
|
+
crossRunOverlapProbeReason: probed.crossRunOverlapProbeReason ?? null,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
return {};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* @param {object[]} [overlaps]
|
|
1028
|
+
* @returns {string[]}
|
|
1029
|
+
*/
|
|
1030
|
+
function crossRunWarnings(overlaps = []) {
|
|
1031
|
+
return overlaps.map(({ id, otherId, holder, paths }) => {
|
|
1032
|
+
const who = holder ? `held by @${holder}` : 'holder unknown';
|
|
1033
|
+
return (
|
|
1034
|
+
`stories-wave-tick: #${id} overlaps #${otherId} (in flight in another session, ${who}) ` +
|
|
1035
|
+
`on ${paths.join(', ')} — expect a rebase conflict at close. Advisory only; dispatch is unchanged.`
|
|
1036
|
+
);
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
993
1040
|
/**
|
|
994
1041
|
* @param {number[]} blockedIds
|
|
995
1042
|
* @returns {string|null}
|