mandrel 1.69.0 → 1.71.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/README.md +7 -7
- package/.agents/docs/SDLC.md +4 -5
- package/.agents/docs/configuration.md +9 -9
- package/.agents/docs/workflows.md +4 -6
- package/.agents/schemas/qa-finding.schema.json +1 -1
- package/.agents/scripts/apply-quality-bootstrap.js +79 -0
- package/.agents/scripts/audit-labels-bootstrap.js +52 -30
- package/.agents/scripts/audit-to-stories.js +54 -0
- package/.agents/scripts/bootstrap.js +13 -3
- package/.agents/scripts/generate-config-docs.js +189 -94
- package/.agents/scripts/lib/audit-suite/findings.js +0 -4
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
- package/.agents/scripts/lib/baseline-snapshot.js +163 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
- package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +1 -1
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +1 -1
- package/.agents/scripts/lib/config/baselines.js +0 -20
- package/.agents/scripts/lib/config/defaults.js +1 -1
- package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
- package/.agents/scripts/lib/config/temp-paths.js +0 -31
- package/.agents/scripts/lib/config-resolver.js +1 -1
- package/.agents/scripts/lib/crap-utils.js +281 -0
- package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
- package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
- package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
- package/.agents/scripts/lib/qa/qa-context-hydrator.js +1 -1
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +1 -1
- package/.agents/scripts/lib/story-body/story-body.js +110 -65
- package/.agents/scripts/lib/test-tiers.js +13 -7
- package/.agents/scripts/lib/wave-runner/tick.js +177 -53
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
- package/.agents/scripts/mandrel-update-preflight.js +235 -0
- package/.agents/scripts/providers/github/issues.js +48 -0
- package/.agents/scripts/providers/github.js +1 -0
- package/.agents/scripts/sync-agentrc.js +2 -2
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +3 -3
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +4 -4
- package/.agents/workflows/git-deliver.md +298 -0
- package/.agents/workflows/helpers/epic-testing.md +6 -6
- package/.agents/workflows/helpers/{agents-sync-config.md → mandrel-sync-config.md} +5 -4
- package/.agents/workflows/{agents-update.md → mandrel-update.md} +210 -33
- package/.agents/workflows/qa-explore.md +1 -1
- package/.agents/workflows/{qa-run-harness.md → qa-run.md} +5 -5
- package/README.md +40 -0
- package/docs/CHANGELOG.md +43 -0
- package/lib/cli/registry.js +49 -6
- package/lib/cli/update.js +335 -332
- package/package.json +16 -11
- package/.agents/workflows/git-commit-all.md +0 -15
- package/.agents/workflows/git-pr-all.md +0 -281
- package/.agents/workflows/git-push.md +0 -63
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* state) for every Story in scope,
|
|
13
13
|
* 3. classifies each by live label (`classifyStory`), re-derives
|
|
14
14
|
* adjacency from the live bodies (`buildStoryAdjacency`, inside
|
|
15
|
-
* `selectReadySet`)
|
|
15
|
+
* `selectReadySet`) and selects the ready set under a global
|
|
16
16
|
* in-flight cap with the file-overlap co-dispatch guard
|
|
17
17
|
* (`storiesOverlap`),
|
|
18
18
|
* 4. returns a `WaveTickResult` describing the next action.
|
|
@@ -29,6 +29,20 @@
|
|
|
29
29
|
* stories) flow back through result fields; unexpected failures (GH 5xx,
|
|
30
30
|
* malformed / old-shape checkpoint) throw `WaveRunnerError`.
|
|
31
31
|
*
|
|
32
|
+
* Story #4183 — the `tick(args)` orchestrator was a 252-line SRP /
|
|
33
|
+
* cognitive-load hotspot carrying six distinct responsibilities in one
|
|
34
|
+
* body. It is now a thin coordinator (Coordinator-plus-Phases pattern,
|
|
35
|
+
* `docs/patterns.md`) that wires four extracted stages:
|
|
36
|
+
* `resolveTickCollaborators` (collaborator/fallback resolution),
|
|
37
|
+
* `readAndValidateCheckpoint` (checkpoint read + shape validation, folding
|
|
38
|
+
* in `assertNotOldShape`), `refetchStoryRecords` (force-fresh re-fetch),
|
|
39
|
+
* and the **pure** `planTick` (classification → cycle detection → ready-set
|
|
40
|
+
* selection → dispatch decision, returning the signals to emit rather than
|
|
41
|
+
* emitting them, so it carries no I/O). The exported `tick(args)`
|
|
42
|
+
* signature, the `tickResult` / `withInFlight` envelope shapes, and every
|
|
43
|
+
* `WaveRunnerError` code are preserved verbatim — callers and tests are
|
|
44
|
+
* unchanged.
|
|
45
|
+
*
|
|
32
46
|
* @module lib/wave-runner/tick
|
|
33
47
|
*/
|
|
34
48
|
|
|
@@ -80,6 +94,13 @@ const OLD_SHAPE_FIELDS = Object.freeze(['plan', 'currentWave', 'totalWaves']);
|
|
|
80
94
|
* dependency cycle among the in-scope Stories is likewise surfaced as a
|
|
81
95
|
* `halt` (with the offending `cycle`), never collapsed to `epic-complete`.
|
|
82
96
|
*
|
|
97
|
+
* Coordinator (Story #4183): this function is a thin dispatcher. It resolves
|
|
98
|
+
* collaborators, reads + validates the checkpoint, re-fetches the live Story
|
|
99
|
+
* records, runs the best-effort recurring-failure scan, delegates the pure
|
|
100
|
+
* dispatch decision to `planTick`, then drains the `signals` `planTick`
|
|
101
|
+
* returned through the configured emitter. Each stage is an independently
|
|
102
|
+
* testable helper below.
|
|
103
|
+
*
|
|
83
104
|
* @typedef {object} WaveTickArgs
|
|
84
105
|
* @property {number | { id: number }} epic
|
|
85
106
|
* @property {{
|
|
@@ -94,13 +115,81 @@ const OLD_SHAPE_FIELDS = Object.freeze(['plan', 'currentWave', 'totalWaves']);
|
|
|
94
115
|
* @param {WaveTickArgs} args
|
|
95
116
|
*/
|
|
96
117
|
export async function tick(args = {}) {
|
|
118
|
+
const { epicId, provider, epicRunStateStore, emit, inFlightReader, ctx } =
|
|
119
|
+
resolveTickCollaborators(args);
|
|
120
|
+
|
|
121
|
+
const state = await readAndValidateCheckpoint(epicRunStateStore, epicId);
|
|
122
|
+
|
|
123
|
+
const storyIds = checkpointStoryIds(state);
|
|
124
|
+
|
|
125
|
+
if (storyIds.length === 0) {
|
|
126
|
+
// No Stories in scope — the Epic has nothing to dispatch.
|
|
127
|
+
return tickResult({
|
|
128
|
+
nextAction: withInFlight({ kind: 'epic-complete' }, []),
|
|
129
|
+
readyCount: 0,
|
|
130
|
+
inFlight: [],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Re-fetch the live Story records (body + labels + issue state) for every
|
|
135
|
+
// Story in scope. In-flight Stories are force-fresh-fetched so a label that
|
|
136
|
+
// flipped since the last tick is observed; every other Story serves from
|
|
137
|
+
// the provider's in-process cache.
|
|
138
|
+
const inFlight = await safeReadInFlight(inFlightReader);
|
|
139
|
+
const inFlightSet = new Set(inFlight);
|
|
140
|
+
const records = await refetchStoryRecords(provider, storyIds, inFlightSet);
|
|
141
|
+
|
|
142
|
+
// Best-effort recurring-failure scan (≥2 distinct Stories sharing the same
|
|
143
|
+
// `close-validate.end` failedGate). Idempotent across re-ticks; a reporter
|
|
144
|
+
// throw must not crash the planner.
|
|
145
|
+
const recurringFailureReporter =
|
|
146
|
+
args.collaborators?.recurringFailureReporter ??
|
|
147
|
+
defaultRecurringFailureReporter({ provider, epicId, config: ctx?.config });
|
|
148
|
+
await safeReportRecurringFailures(recurringFailureReporter);
|
|
149
|
+
|
|
150
|
+
// Decide the next action from the live records + ledger in-flight set. The
|
|
151
|
+
// decision is pure (no I/O); the signals it wants emitted come back in
|
|
152
|
+
// `plan.signals` and are drained by the coordinator below.
|
|
153
|
+
const plan = planTick(state, records, inFlight);
|
|
154
|
+
for (const signal of plan.signals) {
|
|
155
|
+
await emit(signal);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return tickResult({
|
|
159
|
+
nextAction: withInFlight(plan.nextAction, inFlight),
|
|
160
|
+
blockedStories: plan.blockedStories,
|
|
161
|
+
gateFailures: plan.gateFailures,
|
|
162
|
+
readyCount: plan.readyCount,
|
|
163
|
+
inFlight,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the Epic id and the five injectable collaborators (with their
|
|
169
|
+
* production-default fallbacks) from the `tick` args. The single home for the
|
|
170
|
+
* collaborator/fallback wiring so the coordinator stays declarative.
|
|
171
|
+
*
|
|
172
|
+
* Throws `WaveRunnerError('invalid-input')` when the epic id is not a
|
|
173
|
+
* positive integer (or `{ id: positiveInt }`) or when no provider is supplied
|
|
174
|
+
* via either `collaborators.provider` or `ctx.provider`.
|
|
175
|
+
*
|
|
176
|
+
* @param {WaveTickArgs} args
|
|
177
|
+
* @returns {{
|
|
178
|
+
* epicId: number,
|
|
179
|
+
* provider: object,
|
|
180
|
+
* epicRunStateStore: { read: () => Promise<object|null> },
|
|
181
|
+
* emit: (signal: object) => Promise<unknown>,
|
|
182
|
+
* inFlightReader: () => Promise<number[]>,
|
|
183
|
+
* ctx: object,
|
|
184
|
+
* }}
|
|
185
|
+
*/
|
|
186
|
+
function resolveTickCollaborators(args) {
|
|
97
187
|
const epicId = resolveEpicId(args.epic);
|
|
98
188
|
const {
|
|
99
189
|
provider: collabProvider,
|
|
100
190
|
epicRunStateStore: collabStore,
|
|
101
191
|
signalEmit,
|
|
102
192
|
inFlightReader: collabInFlightReader,
|
|
103
|
-
recurringFailureReporter: collabRecurringFailureReporter,
|
|
104
193
|
} = args.collaborators ?? {};
|
|
105
194
|
const ctx = args.ctx ?? {};
|
|
106
195
|
const provider = collabProvider ?? ctx.provider;
|
|
@@ -108,18 +197,41 @@ export async function tick(args = {}) {
|
|
|
108
197
|
throw new WaveRunnerError('invalid-input', 'provider is required');
|
|
109
198
|
}
|
|
110
199
|
// The ready-set tick is stateless. When the caller does not supply a
|
|
111
|
-
// collaborator shim, read the `epic-run-state` structured comment
|
|
112
|
-
//
|
|
200
|
+
// collaborator shim, read the `epic-run-state` structured comment directly
|
|
201
|
+
// via the function-based store.
|
|
113
202
|
const epicRunStateStore = collabStore ?? {
|
|
114
203
|
read: () => epicRunStateStoreModule.read({ provider, epicId }),
|
|
115
204
|
};
|
|
116
205
|
const emit = signalEmit ?? defaultSignalEmit(epicId, ctx);
|
|
117
206
|
const inFlightReader =
|
|
118
207
|
collabInFlightReader ?? (() => defaultInFlightReader(epicId, ctx?.config));
|
|
208
|
+
return { epicId, provider, epicRunStateStore, emit, inFlightReader, ctx };
|
|
209
|
+
}
|
|
119
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Read the `epic-run-state` checkpoint via the store, validate its shape, and
|
|
213
|
+
* fail closed on a pre-ready-set (wave-batch) checkpoint.
|
|
214
|
+
*
|
|
215
|
+
* Throws:
|
|
216
|
+
* - `WaveRunnerError('checkpoint-read')` when the store read rejects,
|
|
217
|
+
* - `WaveRunnerError('checkpoint-missing')` when the read resolves to a
|
|
218
|
+
* non-object (no comment),
|
|
219
|
+
* - `WaveRunnerError('old-shape-checkpoint')` when the checkpoint still
|
|
220
|
+
* carries a `plan` / `currentWave` / `totalWaves` field (via
|
|
221
|
+
* `assertNotOldShape`). A `plan` / `currentWave` / `totalWaves` comment
|
|
222
|
+
* predates the ready-set cutover (Story #4155); the ready-set runtime
|
|
223
|
+
* would otherwise ignore those fields and re-derive readiness from live
|
|
224
|
+
* labels — silently discarding an in-progress wave-batch run's resume
|
|
225
|
+
* pointer. Refuse with an explicit operator remediation instead.
|
|
226
|
+
*
|
|
227
|
+
* @param {{ read: () => Promise<object|null> }} store
|
|
228
|
+
* @param {number} epicId
|
|
229
|
+
* @returns {Promise<object>} the validated checkpoint state.
|
|
230
|
+
*/
|
|
231
|
+
async function readAndValidateCheckpoint(store, epicId) {
|
|
120
232
|
let state;
|
|
121
233
|
try {
|
|
122
|
-
state = await
|
|
234
|
+
state = await store.read();
|
|
123
235
|
} catch (err) {
|
|
124
236
|
throw new WaveRunnerError('checkpoint-read', err);
|
|
125
237
|
}
|
|
@@ -129,39 +241,30 @@ export async function tick(args = {}) {
|
|
|
129
241
|
`no epic-run-state comment on Epic #${epicId}`,
|
|
130
242
|
);
|
|
131
243
|
}
|
|
132
|
-
|
|
133
|
-
// Fail closed on an old-shape (wave-batch) checkpoint. A `plan` /
|
|
134
|
-
// `currentWave` / `totalWaves` comment predates the ready-set cutover
|
|
135
|
-
// (Story #4155); the ready-set runtime would otherwise ignore those
|
|
136
|
-
// fields and re-derive readiness from live labels — silently discarding
|
|
137
|
-
// an in-progress wave-batch run's resume pointer. Refuse with an explicit
|
|
138
|
-
// operator remediation instead.
|
|
139
244
|
assertNotOldShape(state, epicId);
|
|
245
|
+
return state;
|
|
246
|
+
}
|
|
140
247
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// in-process cache.
|
|
160
|
-
const inFlight = await safeReadInFlight(inFlightReader);
|
|
161
|
-
const inFlightSet = new Set(inFlight);
|
|
162
|
-
let records;
|
|
248
|
+
/**
|
|
249
|
+
* Re-fetch the live Story records (body + labels + issue state) for every
|
|
250
|
+
* Story in scope. The body feeds `buildStoryAdjacency` (inside
|
|
251
|
+
* `selectReadySet`) so the dependency edges are always read from the current
|
|
252
|
+
* ticket text, never a stale checkpoint snapshot. Stories in `inFlightSet`
|
|
253
|
+
* are force-fresh-fetched (`{ fresh: true }`) so a label that flipped since
|
|
254
|
+
* the last tick is observed; every other Story serves from the provider's
|
|
255
|
+
* in-process cache.
|
|
256
|
+
*
|
|
257
|
+
* Throws `WaveRunnerError('story-fetch')` when any `provider.getTicket`
|
|
258
|
+
* rejects.
|
|
259
|
+
*
|
|
260
|
+
* @param {{ getTicket: (id: number, opts?: object) => Promise<object> }} provider
|
|
261
|
+
* @param {number[]} storyIds Ascending, deduped in-scope Story ids.
|
|
262
|
+
* @param {Set<number>} inFlightSet Ledger-derived in-flight Story ids.
|
|
263
|
+
* @returns {Promise<Array<object>>} normalized Story records.
|
|
264
|
+
*/
|
|
265
|
+
async function refetchStoryRecords(provider, storyIds, inFlightSet) {
|
|
163
266
|
try {
|
|
164
|
-
|
|
267
|
+
return await Promise.all(
|
|
165
268
|
storyIds.map(async (id) => {
|
|
166
269
|
const opts = inFlightSet.has(id) ? { fresh: true } : {};
|
|
167
270
|
const ticket = await provider.getTicket(id, opts);
|
|
@@ -185,14 +288,42 @@ export async function tick(args = {}) {
|
|
|
185
288
|
} catch (err) {
|
|
186
289
|
throw new WaveRunnerError('story-fetch', err);
|
|
187
290
|
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Pure dispatch planner — the scheduler tick's decision core with **no I/O**.
|
|
295
|
+
* Given the parsed checkpoint, the live Story records, and the ledger-derived
|
|
296
|
+
* in-flight id list, it classifies every Story, detects a sibling dependency
|
|
297
|
+
* cycle, selects the ready set under the global in-flight cap, and decides the
|
|
298
|
+
* `nextAction`. It performs no fetching, no signal emission, and no ledger
|
|
299
|
+
* read: the two wave-window forensics signals are returned in the `signals`
|
|
300
|
+
* array for the coordinator to drain, so this function stays independently
|
|
301
|
+
* unit-testable against fixture records without a provider stub or an emitter.
|
|
302
|
+
*
|
|
303
|
+
* @param {object} state Parsed `epic-run-state` checkpoint (for the global
|
|
304
|
+
* cap and the per-Story `failed` rows surfaced as gate failures).
|
|
305
|
+
* @param {Array<object>} records Live Story records (id, title, body, labels,
|
|
306
|
+
* state, file-footprint shapes).
|
|
307
|
+
* @param {number[]} inFlight Ledger-derived dispatched-not-yet-ended ids.
|
|
308
|
+
* @returns {{
|
|
309
|
+
* nextAction: object,
|
|
310
|
+
* blockedStories: Array<{ storyId: number, reason: string, detail?: string }>,
|
|
311
|
+
* gateFailures: Array<{ storyId: number, gate: string, detail?: string }>,
|
|
312
|
+
* readyCount: number,
|
|
313
|
+
* signals: Array<object>,
|
|
314
|
+
* }}
|
|
315
|
+
*/
|
|
316
|
+
export function planTick(state, records, inFlight) {
|
|
317
|
+
const globalCap = positiveIntOrZero(state.concurrencyCap);
|
|
318
|
+
const inFlightSet = new Set(inFlight);
|
|
188
319
|
|
|
189
|
-
//
|
|
320
|
+
// 1. Classify by live label. `done` / `blocked` / `executing` / `ready`.
|
|
190
321
|
const byClass = { done: [], blocked: [], executing: [], ready: [] };
|
|
191
322
|
for (const rec of records) {
|
|
192
323
|
byClass[classifyStory(rec)].push(rec);
|
|
193
324
|
}
|
|
194
325
|
|
|
195
|
-
//
|
|
326
|
+
// 1a. Detect a dependency cycle among the in-scope Stories BEFORE selecting.
|
|
196
327
|
// A cycle makes every Story on it permanently un-eligible (no member's
|
|
197
328
|
// deps can all be done), so `selectReadySet` would return an empty set
|
|
198
329
|
// and the terminal decision could otherwise mistake the stall for
|
|
@@ -204,7 +335,7 @@ export async function tick(args = {}) {
|
|
|
204
335
|
const epicAdjacency = buildStoryAdjacency(records, { dropForeign: true });
|
|
205
336
|
const cycle = detectCycle(epicAdjacency);
|
|
206
337
|
|
|
207
|
-
//
|
|
338
|
+
// 2. Select the ready set under the GLOBAL in-flight cap. The selector
|
|
208
339
|
// re-derives adjacency from the live bodies (with `dropForeign: true` so
|
|
209
340
|
// a `blocked by #N` whose target is outside this Epic's Story set — a
|
|
210
341
|
// foreign id or a typo — is pruned rather than treated as a permanent
|
|
@@ -248,14 +379,6 @@ export async function tick(args = {}) {
|
|
|
248
379
|
dropForeign: true,
|
|
249
380
|
});
|
|
250
381
|
|
|
251
|
-
// 4. Best-effort recurring-failure scan (≥2 distinct Stories sharing the
|
|
252
|
-
// same `close-validate.end` failedGate). Idempotent across re-ticks; a
|
|
253
|
-
// reporter throw must not crash the planner.
|
|
254
|
-
const recurringFailureReporter =
|
|
255
|
-
collabRecurringFailureReporter ??
|
|
256
|
-
defaultRecurringFailureReporter({ provider, epicId, config: ctx?.config });
|
|
257
|
-
await safeReportRecurringFailures(recurringFailureReporter);
|
|
258
|
-
|
|
259
382
|
const blockedStories = byClass.blocked.map((s) => ({
|
|
260
383
|
storyId: s.id,
|
|
261
384
|
reason: 'agent::blocked',
|
|
@@ -263,7 +386,7 @@ export async function tick(args = {}) {
|
|
|
263
386
|
}));
|
|
264
387
|
const gateFailures = readGateFailures(state);
|
|
265
388
|
|
|
266
|
-
//
|
|
389
|
+
// 3. Decide nextAction.
|
|
267
390
|
// - A blocked Story halts the Epic → observe (the workflow flips the
|
|
268
391
|
// Epic to agent::blocked and parks).
|
|
269
392
|
// - A dependency cycle among the in-scope Stories halts the Epic → halt
|
|
@@ -278,6 +401,7 @@ export async function tick(args = {}) {
|
|
|
278
401
|
// unsatisfiable dependency that survived adjacency closure). Halt and
|
|
279
402
|
// name the stuck Story ids — never silently report the Epic complete.
|
|
280
403
|
const allDone = byClass.done.length === records.length;
|
|
404
|
+
const signals = [];
|
|
281
405
|
let nextAction;
|
|
282
406
|
if (blockedStories.length) {
|
|
283
407
|
nextAction = {
|
|
@@ -300,7 +424,7 @@ export async function tick(args = {}) {
|
|
|
300
424
|
byClass.done.length === 0 &&
|
|
301
425
|
inFlight.length === 0
|
|
302
426
|
) {
|
|
303
|
-
|
|
427
|
+
signals.push({
|
|
304
428
|
kind: 'wave-start',
|
|
305
429
|
stories: records.map((s) => ({ id: s.id, title: s.title })),
|
|
306
430
|
});
|
|
@@ -319,7 +443,7 @@ export async function tick(args = {}) {
|
|
|
319
443
|
nextAction = { kind: 'observe', waitingOn };
|
|
320
444
|
} else if (allDone) {
|
|
321
445
|
// Every Story is done and nothing is in flight: the run is complete.
|
|
322
|
-
|
|
446
|
+
signals.push({ kind: 'wave-complete' });
|
|
323
447
|
nextAction = { kind: 'epic-complete' };
|
|
324
448
|
} else {
|
|
325
449
|
// Ready set empty, nothing in flight, but not all Stories are done — a
|
|
@@ -338,13 +462,13 @@ export async function tick(args = {}) {
|
|
|
338
462
|
};
|
|
339
463
|
}
|
|
340
464
|
|
|
341
|
-
return
|
|
342
|
-
nextAction
|
|
465
|
+
return {
|
|
466
|
+
nextAction,
|
|
343
467
|
blockedStories,
|
|
344
468
|
gateFailures,
|
|
345
469
|
readyCount: readySet.length,
|
|
346
|
-
|
|
347
|
-
}
|
|
470
|
+
signals,
|
|
471
|
+
};
|
|
348
472
|
}
|
|
349
473
|
|
|
350
474
|
/**
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/workers/combined-mi-crap-worker.js — CPU-pool worker entry for the
|
|
3
|
+
* combined MI + CRAP single-pass scan (`scanAndScoreCombined`).
|
|
4
|
+
*
|
|
5
|
+
* One file in, BOTH the maintainability score and the per-method CRAP rows
|
|
6
|
+
* out — derived from a SINGLE `escomplex.analyzeModule` parse via
|
|
7
|
+
* `analyzeOnce`. This collapses the two independent worker-pool passes the
|
|
8
|
+
* full-tree baseline regenerator used to run (the MI worker parsed the AST
|
|
9
|
+
* once for the module score, the CRAP worker parsed the same file's AST
|
|
10
|
+
* again for the method rows) into one parse per file.
|
|
11
|
+
*
|
|
12
|
+
* The MI score and the CRAP rows have independent skip policies, mirroring
|
|
13
|
+
* the two separate passes this worker replaces:
|
|
14
|
+
* - **MI** never requires coverage. The module score is emitted for every
|
|
15
|
+
* file that reads + transpiles + parses. A read failure yields
|
|
16
|
+
* `miScore: null` (the host drops the file from the MI map, matching
|
|
17
|
+
* `calculateAll`'s `score === null` filter). A transpile failure or a
|
|
18
|
+
* parse error yields `miScore: 0` (matching `calculateForFile` /
|
|
19
|
+
* `calculateForSource`, which return 0 on transpile-null / parse-error).
|
|
20
|
+
* - **CRAP** honours `requireCoverage`. A file with no coverage entry is
|
|
21
|
+
* reported as `skippedFileNoCoverage: true` (the host increments its own
|
|
22
|
+
* counter and emits no CRAP rows for it) — but the MI score is STILL
|
|
23
|
+
* computed and returned, because the MI pass would have scored it.
|
|
24
|
+
*
|
|
25
|
+
* Message contract — see lib/cpu-pool.js:
|
|
26
|
+
* IN : { item: { abs: string, relPath: string, requireCoverage: boolean,
|
|
27
|
+
* coverageEntry: object | null } }
|
|
28
|
+
* { exit: true }
|
|
29
|
+
* OUT : { ok: true, result: {
|
|
30
|
+
* relPath,
|
|
31
|
+
* miScore: number | null,
|
|
32
|
+
* skippedFileNoCoverage: boolean,
|
|
33
|
+
* crapRows: Array<{ method, startLine, cyclomatic, coverage, crap }> | null,
|
|
34
|
+
* skippedMethodsNoCoverage: number,
|
|
35
|
+
* } }
|
|
36
|
+
*
|
|
37
|
+
* A read/transpile/parse failure surfaces as `crapRows: null` so the host
|
|
38
|
+
* loop drops the file's CRAP contribution (matching the crap-worker's
|
|
39
|
+
* `rows: null` contract) — never aborts the whole scan. On a read failure
|
|
40
|
+
* `miScore` is `null`; on a transpile/parse failure `miScore` is `0`.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import { parentPort } from 'node:worker_threads';
|
|
45
|
+
import { analyzeOnce } from '../crap-utils.js';
|
|
46
|
+
import { transpileIfNeeded } from '../transpile.js';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pure handler for a single inbound worker message. Exported so unit tests
|
|
50
|
+
* can exercise every branch (bad-shape rejection, coverage gate, read /
|
|
51
|
+
* transpile / parse failures, success rows, skipped methods, and the
|
|
52
|
+
* MI-computed-even-when-coverage-skipped invariant) without spawning a real
|
|
53
|
+
* `Worker` thread.
|
|
54
|
+
*
|
|
55
|
+
* Side effects (fs, transpile, analyzeOnce) are wired through `deps` so
|
|
56
|
+
* tests pass deterministic stubs.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} msg
|
|
59
|
+
* @param {{
|
|
60
|
+
* readFile?: (abs: string) => string,
|
|
61
|
+
* transpile?: (abs: string, source: string) => string | null,
|
|
62
|
+
* analyze?: (source: string, entry: object|null) => {
|
|
63
|
+
* miScore: number,
|
|
64
|
+
* crapRows: Array<object>,
|
|
65
|
+
* parseError: boolean,
|
|
66
|
+
* },
|
|
67
|
+
* }} [deps]
|
|
68
|
+
* @returns {{kind: 'exit'} | {kind: 'reply', message: object}}
|
|
69
|
+
*/
|
|
70
|
+
export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
|
|
71
|
+
if (msg && msg.exit === true) return { kind: 'exit' };
|
|
72
|
+
|
|
73
|
+
const item = msg?.item;
|
|
74
|
+
if (
|
|
75
|
+
!item ||
|
|
76
|
+
typeof item.abs !== 'string' ||
|
|
77
|
+
typeof item.relPath !== 'string'
|
|
78
|
+
) {
|
|
79
|
+
return {
|
|
80
|
+
kind: 'reply',
|
|
81
|
+
message: {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: `bad worker message: ${JSON.stringify(msg)}`,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const { abs, relPath, requireCoverage } = item;
|
|
88
|
+
const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
|
|
89
|
+
const transpile = deps.transpile ?? transpileIfNeeded;
|
|
90
|
+
const analyze = deps.analyze ?? analyzeOnce;
|
|
91
|
+
|
|
92
|
+
// Coverage entry is pre-resolved on the host and attached to the item.
|
|
93
|
+
// `item.coverageEntry` may be explicitly `null` when the file has no
|
|
94
|
+
// coverage, or `undefined` when the caller did not supply it (treat as null).
|
|
95
|
+
const entry = item.coverageEntry ?? null;
|
|
96
|
+
|
|
97
|
+
// Read the source once. A read failure means neither MI nor CRAP can be
|
|
98
|
+
// computed — MI drops (null), CRAP drops (rows null) — matching the two
|
|
99
|
+
// passes' read-failure contracts (calculateAll → score null; crap worker
|
|
100
|
+
// → rows null).
|
|
101
|
+
let source;
|
|
102
|
+
try {
|
|
103
|
+
source = readFile(abs);
|
|
104
|
+
} catch {
|
|
105
|
+
return {
|
|
106
|
+
kind: 'reply',
|
|
107
|
+
message: {
|
|
108
|
+
ok: true,
|
|
109
|
+
result: {
|
|
110
|
+
relPath,
|
|
111
|
+
miScore: null,
|
|
112
|
+
skippedFileNoCoverage: false,
|
|
113
|
+
crapRows: null,
|
|
114
|
+
skippedMethodsNoCoverage: 0,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// TS/TSX → strip-then-analyze. A transpile failure yields miScore 0
|
|
121
|
+
// (calculateForFile returns 0 when transpileIfNeeded returns null) and a
|
|
122
|
+
// null CRAP contribution (crap worker returns rows: null).
|
|
123
|
+
const prepared = transpile(abs, source);
|
|
124
|
+
if (prepared === null) {
|
|
125
|
+
return {
|
|
126
|
+
kind: 'reply',
|
|
127
|
+
message: {
|
|
128
|
+
ok: true,
|
|
129
|
+
result: {
|
|
130
|
+
relPath,
|
|
131
|
+
miScore: 0,
|
|
132
|
+
skippedFileNoCoverage: false,
|
|
133
|
+
crapRows: null,
|
|
134
|
+
skippedMethodsNoCoverage: 0,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ONE parse: analyzeOnce derives both the module MI score and the raw
|
|
141
|
+
// per-method CRAP rows from a single escomplex report. On a parse error it
|
|
142
|
+
// returns miScore 0 and an empty crapRows with parseError true.
|
|
143
|
+
const {
|
|
144
|
+
miScore,
|
|
145
|
+
crapRows: rawCrapRows,
|
|
146
|
+
parseError,
|
|
147
|
+
} = analyze(prepared, entry);
|
|
148
|
+
if (parseError) {
|
|
149
|
+
// Parse error: MI scores 0 (parity with calculateForSource's catch →
|
|
150
|
+
// returns 0), CRAP drops the file (rows null, parity with the crap
|
|
151
|
+
// worker's calculateCrap-throw branch).
|
|
152
|
+
return {
|
|
153
|
+
kind: 'reply',
|
|
154
|
+
message: {
|
|
155
|
+
ok: true,
|
|
156
|
+
result: {
|
|
157
|
+
relPath,
|
|
158
|
+
miScore: 0,
|
|
159
|
+
skippedFileNoCoverage: false,
|
|
160
|
+
crapRows: null,
|
|
161
|
+
skippedMethodsNoCoverage: 0,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// CRAP coverage gate runs AFTER the parse so the MI score is always
|
|
168
|
+
// available. When the file has no coverage under requireCoverage, the CRAP
|
|
169
|
+
// pass would have skipped it at the file level (no rows, counted) — but the
|
|
170
|
+
// MI pass would still have scored it, so miScore is returned regardless.
|
|
171
|
+
if (requireCoverage && entry === null) {
|
|
172
|
+
return {
|
|
173
|
+
kind: 'reply',
|
|
174
|
+
message: {
|
|
175
|
+
ok: true,
|
|
176
|
+
result: {
|
|
177
|
+
relPath,
|
|
178
|
+
miScore,
|
|
179
|
+
skippedFileNoCoverage: true,
|
|
180
|
+
crapRows: [],
|
|
181
|
+
skippedMethodsNoCoverage: 0,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const crapRows = [];
|
|
188
|
+
let skippedMethodsNoCoverage = 0;
|
|
189
|
+
for (const mr of rawCrapRows) {
|
|
190
|
+
if (mr.crap === null || mr.coverage === null) {
|
|
191
|
+
skippedMethodsNoCoverage += 1;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
crapRows.push({
|
|
195
|
+
method: mr.method,
|
|
196
|
+
startLine: mr.startLine,
|
|
197
|
+
cyclomatic: mr.cyclomatic,
|
|
198
|
+
coverage: mr.coverage,
|
|
199
|
+
crap: mr.crap,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
kind: 'reply',
|
|
204
|
+
message: {
|
|
205
|
+
ok: true,
|
|
206
|
+
result: {
|
|
207
|
+
relPath,
|
|
208
|
+
miScore,
|
|
209
|
+
skippedFileNoCoverage: false,
|
|
210
|
+
crapRows,
|
|
211
|
+
skippedMethodsNoCoverage,
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (parentPort) {
|
|
218
|
+
parentPort.on('message', (msg) => {
|
|
219
|
+
const out = handleCombinedMiCrapWorkerMessage(msg);
|
|
220
|
+
if (out.kind === 'exit') {
|
|
221
|
+
parentPort.close();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
parentPort.postMessage(out.message);
|
|
225
|
+
});
|
|
226
|
+
}
|