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
@@ -141,6 +141,92 @@ function resolveNode(schema, node) {
141
141
  return { node, refName: null };
142
142
  }
143
143
 
144
+ /**
145
+ * Render the "Type" cell for an `array`-typed node by inspecting its `items`
146
+ * schema. Mirrors the original inline ladder exactly: a `$ref` item renders
147
+ * `array<RefName>`, an enum item renders `array<enum>`, a typed item renders
148
+ * `array<type>`, and anything else collapses to a bare `array`.
149
+ *
150
+ * @param {object} flat Flattened array node.
151
+ * @returns {string}
152
+ */
153
+ function renderArrayType(flat) {
154
+ const items = flat.items;
155
+ if (items && typeof items === 'object') {
156
+ if (items.$ref) {
157
+ const refName = items.$ref.startsWith('#/$defs/')
158
+ ? items.$ref.slice('#/$defs/'.length)
159
+ : items.$ref;
160
+ return `\`array<${refName}>\``;
161
+ }
162
+ if (Array.isArray(items.enum)) {
163
+ return `\`array<enum>\``;
164
+ }
165
+ if (typeof items.type === 'string') {
166
+ return `\`array<${items.type}>\``;
167
+ }
168
+ }
169
+ return '`array`';
170
+ }
171
+
172
+ /**
173
+ * Render the "Type" cell for an `object`-typed node — `object<map>` when it
174
+ * carries an `additionalProperties` schema (the map form), `object`
175
+ * otherwise.
176
+ *
177
+ * @param {object} flat Flattened object node.
178
+ * @returns {string}
179
+ */
180
+ function renderObjectType(flat) {
181
+ if (
182
+ flat.additionalProperties &&
183
+ typeof flat.additionalProperties === 'object'
184
+ ) {
185
+ return '`object<map>`';
186
+ }
187
+ return '`object`';
188
+ }
189
+
190
+ /**
191
+ * Ordered dispatch table for the "Type" cell. Each rule pairs a `when(flat)`
192
+ * predicate with a `render(flat)` producer; {@link renderType} walks the
193
+ * table once and returns the first match, so a new schema shape becomes a new
194
+ * row here rather than another nested branch.
195
+ *
196
+ * Order is load-bearing — `oneOf` and `enum` are matched before the plain
197
+ * `type` rules, exactly as the original ladder short-circuited.
198
+ *
199
+ * @type {Array<{ when: (flat: object) => boolean, render: (flat: object) => string }>}
200
+ */
201
+ const TYPE_RULES = [
202
+ // The only oneOf in the schema is `listOrExtenderOfStrings`.
203
+ {
204
+ when: (flat) => Array.isArray(flat.oneOf),
205
+ render: () => '`string[]` or `{ append?, prepend? }`',
206
+ },
207
+ {
208
+ when: (flat) => Array.isArray(flat.enum),
209
+ render: (flat) =>
210
+ flat.enum.map((v) => `\`${JSON.stringify(v)}\``).join(' \\| '),
211
+ },
212
+ {
213
+ when: (flat) => Array.isArray(flat.type),
214
+ render: (flat) => flat.type.map((t) => `\`${t}\``).join(' \\| '),
215
+ },
216
+ {
217
+ when: (flat) => flat.type === 'array',
218
+ render: renderArrayType,
219
+ },
220
+ {
221
+ when: (flat) => flat.type === 'object',
222
+ render: renderObjectType,
223
+ },
224
+ {
225
+ when: (flat) => typeof flat.type === 'string',
226
+ render: (flat) => `\`${flat.type}\``,
227
+ },
228
+ ];
229
+
144
230
  /**
145
231
  * Render the "Type" cell for a schema node. The agentrc schema uses a few
146
232
  * recurring shapes — string, integer, number, boolean, array, object,
@@ -155,51 +241,8 @@ function resolveNode(schema, node) {
155
241
  function renderType(schema, node) {
156
242
  if (!node || typeof node !== 'object') return '?';
157
243
  const flat = flattenAllOf(schema, node);
158
-
159
- if (Array.isArray(flat.oneOf)) {
160
- // The only oneOf in the schema is `listOrExtenderOfStrings`.
161
- return '`string[]` or `{ append?, prepend? }`';
162
- }
163
-
164
- if (Array.isArray(flat.enum)) {
165
- return flat.enum.map((v) => `\`${JSON.stringify(v)}\``).join(' \\| ');
166
- }
167
-
168
- const type = flat.type;
169
- if (Array.isArray(type)) {
170
- return type.map((t) => `\`${t}\``).join(' \\| ');
171
- }
172
- if (typeof type === 'string') {
173
- if (type === 'array') {
174
- const items = flat.items;
175
- if (items && typeof items === 'object') {
176
- if (items.$ref) {
177
- const refName = items.$ref.startsWith('#/$defs/')
178
- ? items.$ref.slice('#/$defs/'.length)
179
- : items.$ref;
180
- return `\`array<${refName}>\``;
181
- }
182
- if (Array.isArray(items.enum)) {
183
- return `\`array<enum>\``;
184
- }
185
- if (typeof items.type === 'string') {
186
- return `\`array<${items.type}>\``;
187
- }
188
- }
189
- return '`array`';
190
- }
191
- if (type === 'object') {
192
- if (
193
- flat.additionalProperties &&
194
- typeof flat.additionalProperties === 'object'
195
- ) {
196
- return '`object<map>`';
197
- }
198
- return '`object`';
199
- }
200
- return `\`${type}\``;
201
- }
202
- return '?';
244
+ const rule = TYPE_RULES.find((r) => r.when(flat));
245
+ return rule ? rule.render(flat) : '?';
203
246
  }
204
247
 
205
248
  /**
@@ -233,6 +276,94 @@ function escapeCell(text) {
233
276
  return String(text).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
234
277
  }
235
278
 
279
+ /**
280
+ * Emit the rows for a nested-object property: a header row carrying the
281
+ * parent's description followed by the recursively-flattened child rows.
282
+ * Returns `null` when `flat` is not a properties-bearing object, so the
283
+ * caller can fall through to the next row shape.
284
+ *
285
+ * @param {object} schema
286
+ * @param {{flat: object, keyPath: string, pathParts: string[], propName: string, isRequired: boolean, description: string}} ctx
287
+ * @returns {Array<object> | null}
288
+ */
289
+ function nestedObjectRows(schema, ctx) {
290
+ const { flat, keyPath, pathParts, propName, isRequired, description } = ctx;
291
+ if (flat.type !== 'object' || !flat.properties) return null;
292
+ const childRequired = new Set(
293
+ Array.isArray(flat.required) ? flat.required : [],
294
+ );
295
+ return [
296
+ {
297
+ key: keyPath,
298
+ required: isRequired ? 'Yes' : 'No',
299
+ type: '`object`',
300
+ def: renderDefault(flat.default),
301
+ description: description || 'Nested configuration block.',
302
+ },
303
+ ...flattenObject(schema, flat, [...pathParts, propName], childRequired),
304
+ ];
305
+ }
306
+
307
+ /**
308
+ * Emit the single `[]`-suffixed row for an array-of-objects property,
309
+ * describing the item shape in the Description cell. Returns `null` when the
310
+ * property is not an array whose items are a properties-bearing object.
311
+ *
312
+ * @param {object} schema
313
+ * @param {{flat: object, keyPath: string, isRequired: boolean, description: string}} ctx
314
+ * @returns {Array<object> | null}
315
+ */
316
+ function arrayOfObjectsRows(schema, ctx) {
317
+ const { flat, keyPath, isRequired, description } = ctx;
318
+ if (flat.type !== 'array' || !flat.items) return null;
319
+ const { node: itemNode, refName } = resolveNode(schema, flat.items);
320
+ if (!itemNode || itemNode.type !== 'object' || !itemNode.properties) {
321
+ return null;
322
+ }
323
+ const itemKeys = Object.keys(itemNode.properties).join(', ');
324
+ const suffix = refName ? ` (\`${refName}\`)` : '';
325
+ const desc =
326
+ (description ? `${description} ` : '') +
327
+ `Each item${suffix} has: ${itemKeys}.`;
328
+ return [
329
+ {
330
+ key: `${keyPath}[]`,
331
+ required: isRequired ? 'Yes' : 'No',
332
+ type: renderType(schema, flat),
333
+ def: renderDefault(flat.default),
334
+ description: desc,
335
+ },
336
+ ];
337
+ }
338
+
339
+ /**
340
+ * Emit the leaf (scalar / non-recursed) row for a property. Always matches —
341
+ * it is the fallthrough shape when neither the nested-object nor the
342
+ * array-of-objects builder applied.
343
+ *
344
+ * @param {object} schema
345
+ * @param {{flat: object, keyPath: string, isRequired: boolean, description: string}} ctx
346
+ * @returns {Array<object>}
347
+ */
348
+ function leafRow(schema, ctx) {
349
+ const { flat, keyPath, isRequired, description } = ctx;
350
+ return [
351
+ {
352
+ key: keyPath,
353
+ required: isRequired ? 'Yes' : 'No',
354
+ type: renderType(schema, flat),
355
+ def: renderDefault(flat.default),
356
+ description: description || '—',
357
+ },
358
+ ];
359
+ }
360
+
361
+ // Ordered row-shape builders for one property. The loop in flattenObject
362
+ // returns the first builder that yields rows (non-null), matching the
363
+ // original if/continue ladder: nested-object first, array-of-objects next,
364
+ // scalar leaf as the always-matching fallthrough.
365
+ const ROW_BUILDERS = [nestedObjectRows, arrayOfObjectsRows, leafRow];
366
+
236
367
  /**
237
368
  * Flatten one object-typed schema node into table rows. Recurses into
238
369
  * nested `object` properties (resolving `$ref`s along the way) so dot-paths
@@ -260,57 +391,21 @@ function flattenObject(schema, node, pathParts, required) {
260
391
  for (const [propName, rawChild] of Object.entries(properties)) {
261
392
  const { node: child } = resolveNode(schema, rawChild);
262
393
  const flat = flattenAllOf(schema, child);
263
- const keyPath = [...pathParts, propName].join('.');
264
- const isRequired = required.has(propName) || localRequired.has(propName);
265
- const description = flat.description || rawChild.description || '';
266
-
267
- // Nested object: recurse to surface dot-pathed children.
268
- if (flat.type === 'object' && flat.properties) {
269
- // Emit a header row for the parent object so its description is
270
- // visible, then recurse.
271
- rows.push({
272
- key: keyPath,
273
- required: isRequired ? 'Yes' : 'No',
274
- type: '`object`',
275
- def: renderDefault(flat.default),
276
- description: description || 'Nested configuration block.',
277
- });
278
- const childRequired = new Set(
279
- Array.isArray(flat.required) ? flat.required : [],
280
- );
281
- rows.push(
282
- ...flattenObject(schema, flat, [...pathParts, propName], childRequired),
283
- );
284
- continue;
285
- }
286
-
287
- // Array-of-objects: emit one row and describe the item shape.
288
- if (flat.type === 'array' && flat.items) {
289
- const { node: itemNode, refName } = resolveNode(schema, flat.items);
290
- if (itemNode && itemNode.type === 'object' && itemNode.properties) {
291
- const itemKeys = Object.keys(itemNode.properties).join(', ');
292
- const suffix = refName ? ` (\`${refName}\`)` : '';
293
- const desc =
294
- (description ? `${description} ` : '') +
295
- `Each item${suffix} has: ${itemKeys}.`;
296
- rows.push({
297
- key: `${keyPath}[]`,
298
- required: isRequired ? 'Yes' : 'No',
299
- type: renderType(schema, flat),
300
- def: renderDefault(flat.default),
301
- description: desc,
302
- });
303
- continue;
394
+ const ctx = {
395
+ flat,
396
+ keyPath: [...pathParts, propName].join('.'),
397
+ pathParts,
398
+ propName,
399
+ isRequired: required.has(propName) || localRequired.has(propName),
400
+ description: flat.description || rawChild.description || '',
401
+ };
402
+ for (const build of ROW_BUILDERS) {
403
+ const built = build(schema, ctx);
404
+ if (built !== null) {
405
+ rows.push(...built);
406
+ break;
304
407
  }
305
408
  }
306
-
307
- rows.push({
308
- key: keyPath,
309
- required: isRequired ? 'Yes' : 'No',
310
- type: renderType(schema, flat),
311
- def: renderDefault(flat.default),
312
- description: description || '—',
313
- });
314
409
  }
315
410
 
316
411
  return rows;
@@ -17,8 +17,6 @@
17
17
 
18
18
  import { groupRows, resolveComponents } from '../baselines/components.js';
19
19
 
20
- const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']);
21
-
22
20
  /**
23
21
  * Pure: count findings into a {critical,high,medium,low} histogram. Findings
24
22
  * with severities outside that set are ignored, keeping the rendered summary
@@ -37,8 +35,6 @@ export function aggregateSummary(findings) {
37
35
  return summary;
38
36
  }
39
37
 
40
- export const KNOWN_SEVERITIES = SEVERITIES;
41
-
42
38
  /**
43
39
  * Resolve the per-component rollup map for an envelope. When the envelope
44
40
  * already carries a `rollup` block (every writer-produced baseline does), we
@@ -0,0 +1,99 @@
1
+ /**
2
+ * lib/audit-to-stories/audit-lenses.js — SSOT for the canonical audit lens
3
+ * taxonomy.
4
+ *
5
+ * The `/audit-<lens>` workflows each emit a report named
6
+ * `audit-<lens>-results.md`. The `audit::<lens>` GitHub label set is keyed off
7
+ * exactly these lens names — never off a finding's fine-grained `dimension`
8
+ * text. `build-story-body.js` derives a group's `audit::<lens>` labels from
9
+ * each finding's `sourceReport` basename via {@link lensFromSourceReport}, and
10
+ * `audit-labels-bootstrap.js` creates one `audit::<lens>` label per entry in
11
+ * {@link AUDIT_LENSES}. Centralising the list here keeps the label producer
12
+ * (bootstrap) and the label deriver (story-body) from drifting apart.
13
+ *
14
+ * Pure: no I/O.
15
+ *
16
+ * @see Story #4195 — junk `audit::<dimension>` label derivation.
17
+ */
18
+
19
+ /**
20
+ * The canonical lens names, one per `/audit-<lens>` workflow under
21
+ * `.agents/workflows/` (excluding the `audit-to-stories` and `audit-fan-out`
22
+ * meta-workflows, which produce no `audit-<lens>-results.md` of their own).
23
+ * Adding a new `audit-*` workflow MUST add its lens name here.
24
+ *
25
+ * @type {ReadonlyArray<string>}
26
+ */
27
+ export const AUDIT_LENSES = Object.freeze([
28
+ 'architecture',
29
+ 'clean-code',
30
+ 'dependencies',
31
+ 'devops',
32
+ 'documentation',
33
+ 'lighthouse',
34
+ 'navigability',
35
+ 'performance',
36
+ 'privacy',
37
+ 'quality',
38
+ 'security',
39
+ 'seo',
40
+ 'sre',
41
+ 'ux-ui',
42
+ ]);
43
+
44
+ /** O(1) membership set for {@link isCanonicalLens}. */
45
+ const LENS_SET = new Set(AUDIT_LENSES);
46
+
47
+ /**
48
+ * @param {string} lens
49
+ * @returns {boolean} true when `lens` is one of the canonical {@link AUDIT_LENSES}.
50
+ */
51
+ export function isCanonicalLens(lens) {
52
+ return LENS_SET.has(lens);
53
+ }
54
+
55
+ /**
56
+ * Derive the canonical lens name from an `audit-<lens>-results.md` source
57
+ * report path. The derivation is basename-only (the directory — typically
58
+ * `temp/audits/` — is irrelevant) and matches the exact
59
+ * `audit-<lens>-results.md` shape the audit workflows emit. Backslash and
60
+ * forward-slash separators are both handled so a Windows-authored path
61
+ * resolves identically.
62
+ *
63
+ * Returns `null` when the path does not match the expected shape or the
64
+ * extracted lens is not one of the canonical {@link AUDIT_LENSES} — callers
65
+ * drop a `null` so a stray report name can never mint a junk `audit::*` label.
66
+ *
67
+ * @param {unknown} sourceReport — e.g. `temp/audits/audit-clean-code-results.md`.
68
+ * @returns {string|null} the canonical lens (`clean-code`) or `null`.
69
+ */
70
+ export function lensFromSourceReport(sourceReport) {
71
+ if (typeof sourceReport !== 'string' || sourceReport.length === 0) {
72
+ return null;
73
+ }
74
+ const normalised = sourceReport.replace(/\\/g, '/');
75
+ const base = normalised.slice(normalised.lastIndexOf('/') + 1);
76
+ const match = base.match(/^audit-(.+)-results\.md$/);
77
+ if (!match) return null;
78
+ const lens = match[1];
79
+ return isCanonicalLens(lens) ? lens : null;
80
+ }
81
+
82
+ /**
83
+ * Derive the deduped, sorted set of canonical `audit::<lens>` labels for a
84
+ * collection of findings, keyed off each finding's `sourceReport` basename.
85
+ * Findings whose `sourceReport` does not resolve to a canonical lens
86
+ * contribute no label (never a junk one). Multi-lens groups (findings from
87
+ * more than one report) yield one label per distinct lens.
88
+ *
89
+ * @param {Array<{ sourceReport?: unknown }>} findings
90
+ * @returns {string[]} sorted `audit::<lens>` labels.
91
+ */
92
+ export function auditLabelsForFindings(findings) {
93
+ const lenses = new Set();
94
+ for (const finding of findings ?? []) {
95
+ const lens = lensFromSourceReport(finding?.sourceReport);
96
+ if (lens) lenses.add(lens);
97
+ }
98
+ return [...lenses].sort().map((lens) => `audit::${lens}`);
99
+ }
@@ -6,8 +6,10 @@
6
6
  * criteria #8: Title (caller), Summary, Acceptance Criteria, Agent
7
7
  * Prompts, Context block, fingerprint footer.
8
8
  *
9
- * Pure: returns { title, body, labels }. Labels include every
10
- * `audit::<dimension>` represented in the merge plus the standard
9
+ * Pure: returns { title, body, labels }. Labels carry one canonical
10
+ * `audit::<lens>` per distinct source report represented in the merge
11
+ * (derived from each finding's `sourceReport` basename, NEVER from the
12
+ * fine-grained `dimension` text — see Story #4195), plus the standard
11
13
  * `type::story`, `agent::ready`, and (when any finding is Critical)
12
14
  * `risk::high`.
13
15
  *
@@ -19,6 +21,7 @@
19
21
  */
20
22
 
21
23
  import { serialize } from '../story-body/story-body.js';
24
+ import { auditLabelsForFindings } from './audit-lenses.js';
22
25
  import { renderFingerprintFooter } from './finding-adapter.js';
23
26
 
24
27
  const STATIC_LABELS = Object.freeze(['type::story', 'agent::ready']);
@@ -71,9 +74,14 @@ function contextLinksFromGroup(group) {
71
74
  }
72
75
 
73
76
  function labelsForGroup(group) {
74
- const auditLabels = uniq(
75
- (group.dimensions ?? []).map((d) => `audit::${d.replace(/\s+/g, '-')}`),
76
- );
77
+ // Derive `audit::<lens>` from each finding's `sourceReport` basename
78
+ // (`audit-<lens>-results.md` `audit::<lens>`), NOT from the finding's
79
+ // fine-grained `dimension` text. The dimension is free-form prose
80
+ // ("stale-description", "dry", "efficiency (cpu)") and minting
81
+ // `audit::<dimension>` from it produced non-existent labels; only the 14
82
+ // canonical lens labels are valid. Multi-lens groups carry one label per
83
+ // distinct source report. See Story #4195.
84
+ const auditLabels = auditLabelsForFindings(group.findings ?? []);
77
85
  const labels = [...STATIC_LABELS, ...auditLabels];
78
86
  const hasCritical = (group.findings ?? []).some(
79
87
  (f) => f.severity === 'critical',
@@ -20,6 +20,7 @@ import {
20
20
  resolveEscomplexVersion,
21
21
  resolveTsTranspilerVersion,
22
22
  scanAndScore,
23
+ scanAndScoreCombined,
23
24
  } from './crap-utils.js';
24
25
  import { ensureEpicBranchRef as defaultEnsureEpicBranchRef } from './git-branch-lifecycle.js';
25
26
  import { calculateAll, scanDirectory } from './maintainability-utils.js';
@@ -562,6 +563,126 @@ function crapDirsMatchMi({
562
563
  );
563
564
  }
564
565
 
566
+ /**
567
+ * Config-only sibling of `crapDirsMatchMi` (Story #4192): decide — before any
568
+ * tree scan — whether the MI and CRAP passes target the same dirs with the
569
+ * same ignore globs. When true, `regenerateMainFromTree` collapses the two
570
+ * escomplex passes into a single combined `analyzeOnce` scan; when false it
571
+ * falls back to the independent two-pass path. Pure array compare, no scan
572
+ * list required.
573
+ */
574
+ function crapConfigMatchesMi({
575
+ crapTargetDirs,
576
+ crapIgnoreGlobs,
577
+ miTargetDirs,
578
+ miIgnoreGlobs,
579
+ }) {
580
+ return (
581
+ crapTargetDirs.length === miTargetDirs.length &&
582
+ crapTargetDirs.every((d, i) => d === miTargetDirs[i]) &&
583
+ crapIgnoreGlobs.length === miIgnoreGlobs.length &&
584
+ crapIgnoreGlobs.every((g, i) => g === miIgnoreGlobs[i])
585
+ );
586
+ }
587
+
588
+ /**
589
+ * Run the combined MI + CRAP single-pass scan once and project it into the
590
+ * `{ calculateAllFn, scanDirectoryFn, scanAndScoreFn, loadCoverageFn }`
591
+ * injection seams that `regenerateMaintainability` / `regenerateCrap` already
592
+ * consume. Returns `null` when the combined path is NOT eligible (either
593
+ * baseline unconfigured, dirs/globs differ, or coverage is required but
594
+ * missing) — the caller then falls back to the two independent passes.
595
+ *
596
+ * Eligibility deliberately requires coverage to be present under
597
+ * `requireCoverage`: when coverage is missing, the two-pass path takes a
598
+ * dedicated `no-coverage` short-circuit for CRAP (no scan at all), and
599
+ * reproducing that precise envelope is cleaner by deferring to the existing
600
+ * `regenerateCrap` branch than by routing through the combined scanner.
601
+ *
602
+ * Story #4192 — collapses the duplicate escomplex AST parse on the full-tree
603
+ * baseline path (2 parses/file → 1 parse/file). Byte-for-byte equivalent to
604
+ * the two-pass path: the combined scan returns the same MI score map and the
605
+ * same CRAP rows the separate passes would, and the downstream projection +
606
+ * writer logic is shared verbatim.
607
+ *
608
+ * @returns {Promise<null | {
609
+ * calculateAllFn: () => Promise<Record<string, number>>,
610
+ * scanDirectoryFn: (dir: string, list: string[]) => string[],
611
+ * scanAndScoreFn: () => Promise<{ rows: Array<object> }>,
612
+ * loadCoverageFn: () => object,
613
+ * }>}
614
+ */
615
+ async function buildCombinedScanSeams({
616
+ cwd,
617
+ baselines,
618
+ quality,
619
+ loadCoverageFn,
620
+ scanAndScoreCombinedFn,
621
+ }) {
622
+ const miPath = baselines?.maintainability?.path;
623
+ const crapPath = baselines?.crap?.path;
624
+ if (
625
+ typeof miPath !== 'string' ||
626
+ miPath.length === 0 ||
627
+ typeof crapPath !== 'string' ||
628
+ crapPath.length === 0
629
+ ) {
630
+ return null; // both baselines must be configured to combine
631
+ }
632
+
633
+ const miTargetDirs = quality?.maintainability?.targetDirs ?? [];
634
+ const miIgnoreGlobs = quality?.maintainability?.ignoreGlobs ?? [];
635
+ const crapCfg = quality?.crap ?? {};
636
+ const crapTargetDirs = Array.isArray(crapCfg.targetDirs)
637
+ ? crapCfg.targetDirs
638
+ : [];
639
+ const crapIgnoreGlobs = Array.isArray(crapCfg.ignoreGlobs)
640
+ ? crapCfg.ignoreGlobs
641
+ : [];
642
+
643
+ if (
644
+ !crapConfigMatchesMi({
645
+ crapTargetDirs,
646
+ crapIgnoreGlobs,
647
+ miTargetDirs,
648
+ miIgnoreGlobs,
649
+ })
650
+ ) {
651
+ return null; // dirs/globs differ → two-pass fallback
652
+ }
653
+
654
+ const requireCoverage = crapCfg.requireCoverage !== false;
655
+ const coveragePath = crapCfg.coveragePath ?? 'coverage/coverage-final.json';
656
+ const coverageAbs = path.isAbsolute(coveragePath)
657
+ ? coveragePath
658
+ : path.resolve(cwd, coveragePath);
659
+ const coverage = loadCoverageFn(coverageAbs);
660
+
661
+ if (!coverage && requireCoverage) {
662
+ return null; // no coverage → let the two-pass CRAP short-circuit run
663
+ }
664
+
665
+ const { miScores, crap } = await scanAndScoreCombinedFn({
666
+ targetDirs: miTargetDirs,
667
+ coverage,
668
+ requireCoverage,
669
+ cwd,
670
+ ignoreGlobs: miIgnoreGlobs,
671
+ });
672
+
673
+ return {
674
+ // MI consumes the precomputed score map directly; the scanDirectory seam
675
+ // is short-circuited to a no-op list (the combined scan already walked
676
+ // the tree), so regenerateMaintainability's projection runs unchanged.
677
+ calculateAllFn: async () => miScores,
678
+ scanDirectoryFn: (_dir, list = []) => list,
679
+ // CRAP consumes the precomputed scan result; coverage is already loaded
680
+ // so its loadCoverage seam returns the same object without re-reading.
681
+ scanAndScoreFn: async () => crap,
682
+ loadCoverageFn: () => coverage,
683
+ };
684
+ }
685
+
565
686
  /**
566
687
  * Regenerate the CRAP baseline from a fresh tree scan + coverage map.
567
688
  * Returns `null` when no CRAP baseline path is configured. Story #4075 —
@@ -675,6 +796,7 @@ export async function regenerateMainFromTree({
675
796
  scanDirectoryFn = scanDirectory,
676
797
  calculateAllFn = calculateAll,
677
798
  scanAndScoreFn = scanAndScore,
799
+ scanAndScoreCombinedFn = scanAndScoreCombined,
678
800
  loadCoverageFn = loadCoverage,
679
801
  resolveEscomplexVersionFn = resolveEscomplexVersion,
680
802
  resolveTsTranspilerVersionFn = resolveTsTranspilerVersion,
@@ -689,12 +811,49 @@ export async function regenerateMainFromTree({
689
811
  const files = [];
690
812
  let didChange = false;
691
813
 
814
+ // Story #4192 — when MI and CRAP target the same dirs/globs (and coverage
815
+ // is present), collapse the two escomplex passes into a single combined
816
+ // `analyzeOnce` scan. The combined scan is projected into the same scan
817
+ // seams the two passes consume, so the MI/CRAP envelope projection + writer
818
+ // logic below runs byte-for-byte identically either way. When the combined
819
+ // path is not eligible, `combined` is `null` and the original two
820
+ // independent passes run.
821
+ //
822
+ // The combined path is an internal optimization of the *production-default*
823
+ // scan seams. A caller that injects its own `calculateAllFn` or
824
+ // `scanAndScoreFn` is explicitly opting into the two independent passes
825
+ // (the DI contract: "use my seam"), so the combined path defers to those
826
+ // injected seams rather than silently bypassing them. In production neither
827
+ // seam is overridden, so the optimization is always taken.
828
+ const usingDefaultScanSeams =
829
+ calculateAllFn === calculateAll && scanAndScoreFn === scanAndScore;
830
+ const combined = usingDefaultScanSeams
831
+ ? await buildCombinedScanSeams({
832
+ cwd,
833
+ baselines,
834
+ quality,
835
+ loadCoverageFn,
836
+ scanAndScoreCombinedFn,
837
+ })
838
+ : null;
839
+
840
+ const miCalculateAllFn = combined ? combined.calculateAllFn : calculateAllFn;
841
+ const miScanDirectoryFn = combined
842
+ ? combined.scanDirectoryFn
843
+ : scanDirectoryFn;
844
+ const crapScanAndScoreFn = combined
845
+ ? combined.scanAndScoreFn
846
+ : scanAndScoreFn;
847
+ const crapLoadCoverageFn = combined
848
+ ? combined.loadCoverageFn
849
+ : loadCoverageFn;
850
+
692
851
  const miScan = await regenerateMaintainability({
693
852
  cwd,
694
853
  baselines,
695
854
  quality,
696
- scanDirectoryFn,
697
- calculateAllFn,
855
+ scanDirectoryFn: miScanDirectoryFn,
856
+ calculateAllFn: miCalculateAllFn,
698
857
  writeFn,
699
858
  writeFileFn,
700
859
  loadPriorFn,
@@ -711,8 +870,8 @@ export async function regenerateMainFromTree({
711
870
  quality,
712
871
  logger,
713
872
  miScan,
714
- scanAndScoreFn,
715
- loadCoverageFn,
873
+ scanAndScoreFn: crapScanAndScoreFn,
874
+ loadCoverageFn: crapLoadCoverageFn,
716
875
  resolveEscomplexVersionFn,
717
876
  resolveTsTranspilerVersionFn,
718
877
  writeFn,
@@ -739,7 +739,3 @@ function validateOptions({ kind, scopeFiles, fullScope, writePath }) {
739
739
  );
740
740
  }
741
741
  }
742
-
743
- // Exposed for the lint/test invariant (Task #2208) so the guard can list
744
- // every kind the service is contracted to dispatch without re-deriving it.
745
- export const REFRESH_SERVICE_SUPPORTED_KINDS = SUPPORTED_KINDS;
@@ -180,7 +180,7 @@ export const CI_WORKFLOW_RELATIVE_PATH = '.github/workflows/ci.yml';
180
180
  * `unchanged`. When the file is absent the action is `created`. When the
181
181
  * file exists with operator-authored differences the helper preserves it
182
182
  * and returns `custom-workflow-skip` along with the rendered body so the
183
- * bootstrap caller (or `/agents-update`) can offer a side-by-side diff.
183
+ * bootstrap caller (or `/mandrel-update`) can offer a side-by-side diff.
184
184
  *
185
185
  * Network-free; safe to invoke under tests with a tmp `projectRoot`.
186
186
  *