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.
Files changed (57) hide show
  1. package/.agents/README.md +7 -7
  2. package/.agents/docs/SDLC.md +4 -5
  3. package/.agents/docs/configuration.md +9 -9
  4. package/.agents/docs/workflows.md +4 -6
  5. package/.agents/schemas/qa-finding.schema.json +1 -1
  6. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  7. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  8. package/.agents/scripts/audit-to-stories.js +54 -0
  9. package/.agents/scripts/bootstrap.js +13 -3
  10. package/.agents/scripts/generate-config-docs.js +189 -94
  11. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  12. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  13. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  14. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  15. package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
  16. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +1 -1
  17. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +1 -1
  18. package/.agents/scripts/lib/config/baselines.js +0 -20
  19. package/.agents/scripts/lib/config/defaults.js +1 -1
  20. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  21. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  22. package/.agents/scripts/lib/config-resolver.js +1 -1
  23. package/.agents/scripts/lib/crap-utils.js +281 -0
  24. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  25. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
  26. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  27. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  28. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  29. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  30. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  31. package/.agents/scripts/lib/qa/qa-context-hydrator.js +1 -1
  32. package/.agents/scripts/lib/qa/resolve-qa-contract.js +1 -1
  33. package/.agents/scripts/lib/story-body/story-body.js +110 -65
  34. package/.agents/scripts/lib/test-tiers.js +13 -7
  35. package/.agents/scripts/lib/wave-runner/tick.js +177 -53
  36. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  37. package/.agents/scripts/mandrel-update-preflight.js +235 -0
  38. package/.agents/scripts/providers/github/issues.js +48 -0
  39. package/.agents/scripts/providers/github.js +1 -0
  40. package/.agents/scripts/sync-agentrc.js +2 -2
  41. package/.agents/skills/skills.index.json +2 -2
  42. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +3 -3
  43. package/.agents/skills/stack/qa/qa-harness/SKILL.md +4 -4
  44. package/.agents/workflows/git-deliver.md +298 -0
  45. package/.agents/workflows/helpers/epic-testing.md +6 -6
  46. package/.agents/workflows/helpers/{agents-sync-config.md → mandrel-sync-config.md} +5 -4
  47. package/.agents/workflows/{agents-update.md → mandrel-update.md} +210 -33
  48. package/.agents/workflows/qa-explore.md +1 -1
  49. package/.agents/workflows/{qa-run-harness.md → qa-run.md} +5 -5
  50. package/README.md +40 -0
  51. package/docs/CHANGELOG.md +43 -0
  52. package/lib/cli/registry.js +49 -6
  53. package/lib/cli/update.js +335 -332
  54. package/package.json +16 -11
  55. package/.agents/workflows/git-commit-all.md +0 -15
  56. package/.agents/workflows/git-pr-all.md +0 -281
  57. 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`), and selects the ready set under a global
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
- // directly via the function-based store.
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 epicRunStateStore.read();
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
- const globalCap = positiveIntOrZero(state.concurrencyCap);
142
- const storyIds = checkpointStoryIds(state);
143
-
144
- if (storyIds.length === 0) {
145
- // No Stories in scope the Epic has nothing to dispatch.
146
- return tickResult({
147
- nextAction: withInFlight({ kind: 'epic-complete' }, []),
148
- readyCount: 0,
149
- inFlight: [],
150
- });
151
- }
152
-
153
- // 1. Re-fetch the live Story records (body + labels + issue state) for
154
- // every Story in scope. The body feeds `buildStoryAdjacency` (inside
155
- // `selectReadySet`) so the dependency edges are always read from the
156
- // current ticket text, never a stale checkpoint snapshot. In-flight
157
- // Stories are force-fresh-fetched so a label that flipped since the
158
- // last tick is observed; every other Story serves from the provider's
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
- records = await Promise.all(
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
- // 2. Classify by live label. `done` / `blocked` / `executing` / `ready`.
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
- // 2a. Detect a dependency cycle among the in-scope Stories BEFORE selecting.
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
- // 3. Select the ready set under the GLOBAL in-flight cap. The selector
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
- // 5. Decide nextAction.
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
- await emit({
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
- await emit({ kind: 'wave-complete' });
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 tickResult({
342
- nextAction: withInFlight(nextAction, inFlight),
465
+ return {
466
+ nextAction,
343
467
  blockedStories,
344
468
  gateFailures,
345
469
  readyCount: readySet.length,
346
- inFlight,
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
+ }