mandrel 1.58.0 → 1.59.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 (129) hide show
  1. package/.agents/README.md +89 -87
  2. package/.agents/docs/SDLC.md +11 -7
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/audit-rules.json +20 -0
  5. package/.agents/scripts/acceptance-eval.js +20 -3
  6. package/.agents/scripts/assert-branch.js +1 -3
  7. package/.agents/scripts/bootstrap.js +1 -1
  8. package/.agents/scripts/check-arch-cycles.js +360 -0
  9. package/.agents/scripts/coverage-capture.js +24 -3
  10. package/.agents/scripts/epic-deliver-preflight.js +5 -3
  11. package/.agents/scripts/epic-deliver-prepare.js +12 -4
  12. package/.agents/scripts/epic-execute-record-wave.js +1 -1
  13. package/.agents/scripts/evidence-gate.js +1 -1
  14. package/.agents/scripts/git-rebase-and-resolve.js +1 -1
  15. package/.agents/scripts/hierarchy-gate.js +34 -14
  16. package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
  17. package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
  18. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
  19. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
  20. package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
  21. package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
  22. package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
  23. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
  24. package/.agents/scripts/lib/baselines/writer.js +1 -1
  25. package/.agents/scripts/lib/close-validation/commands.js +188 -0
  26. package/.agents/scripts/lib/close-validation/gates.js +235 -0
  27. package/.agents/scripts/lib/close-validation/process.js +101 -0
  28. package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
  29. package/.agents/scripts/lib/close-validation/runner.js +325 -0
  30. package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
  31. package/.agents/scripts/lib/config/quality.js +6 -6
  32. package/.agents/scripts/lib/config-resolver.js +2 -5
  33. package/.agents/scripts/lib/coverage-capture.js +147 -4
  34. package/.agents/scripts/lib/cpu-pool.js +14 -0
  35. package/.agents/scripts/lib/crap-utils.js +6 -11
  36. package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
  37. package/.agents/scripts/lib/git-utils.js +24 -22
  38. package/.agents/scripts/lib/maintainability-engine.js +1 -1
  39. package/.agents/scripts/lib/maintainability-utils.js +4 -187
  40. package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
  42. package/.agents/scripts/lib/orchestration/code-review.js +90 -77
  43. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
  44. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
  46. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
  50. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
  51. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
  52. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
  53. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
  54. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
  55. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
  56. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
  57. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
  58. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
  59. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
  60. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
  61. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
  62. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
  63. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
  64. package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
  65. package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
  67. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
  68. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
  69. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
  70. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  71. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  72. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  73. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  74. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  75. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  78. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  79. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  80. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  81. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  82. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  83. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  84. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  85. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  86. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  87. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  88. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  89. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  90. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  91. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  92. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  93. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  94. package/.agents/scripts/lib/project-root.js +17 -0
  95. package/.agents/scripts/lib/story-adjacency.js +76 -0
  96. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  97. package/.agents/scripts/lib/transpile.js +93 -0
  98. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  99. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  100. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  101. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  102. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  103. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  104. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  105. package/.agents/scripts/providers/github/tickets.js +110 -6
  106. package/.agents/scripts/run-lint.js +9 -0
  107. package/.agents/scripts/run-tests.js +24 -4
  108. package/.agents/scripts/stories-wave-tick.js +8 -5
  109. package/.agents/scripts/story-init.js +149 -10
  110. package/.agents/scripts/sync-branch-from-base.js +1 -1
  111. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  112. package/.agents/workflows/audit-documentation.md +226 -0
  113. package/.agents/workflows/epic-deliver.md +16 -23
  114. package/.agents/workflows/epic-plan.md +1 -1
  115. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  116. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  117. package/.agents/workflows/onboard.md +4 -3
  118. package/.agents/workflows/story-deliver.md +1 -1
  119. package/README.md +13 -8
  120. package/lib/cli/init.js +336 -0
  121. package/package.json +2 -1
  122. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  123. package/.agents/scripts/lib/close-validation.js +0 -897
  124. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  125. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  126. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  127. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  128. package/.agents/scripts/lib/task-utils.js +0 -26
  129. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -3,19 +3,22 @@
3
3
  * (Story #1891). Row shape: `{ route, performance, accessibility,
4
4
  * bestPractices, seo }`. The key field is `route` (a path-like string that
5
5
  * still passes the path-canon checks for absolute / `..` rejection).
6
+ *
7
+ * Higher score = better. New routes inherit a base of 100 for each axis
8
+ * (so lower scores register as regressions — a new route must meet the
9
+ * bar); dropped routes inherit a head of 100 (so a higher base registers
10
+ * as an improvement). Unlike the file-derived kinds, compare emits no
11
+ * `additions` bucket. Scaffold is generated by `makeBaselineKind`
12
+ * (Story #3983).
6
13
  */
7
14
 
8
- import { componentMatches } from '../component-matcher.js';
9
15
  import { canonicalise } from '../path-canon.js';
10
- import { mergeRowsByScope } from '../scope.js';
16
+ import { makeBaselineKind } from './kind-factory.js';
11
17
 
12
18
  export const name = 'lighthouse';
13
19
  export const keyField = 'route';
14
- const KERNEL_VERSION = '1.0.0';
15
20
 
16
- export function kernelVersion() {
17
- return KERNEL_VERSION;
18
- }
21
+ const LH_AXES = ['performance', 'accessibility', 'bestPractices', 'seo'];
19
22
 
20
23
  function canonRoute(route) {
21
24
  // Routes look like `/`, `/dashboard`, or `pricing` — strip the leading
@@ -36,75 +39,24 @@ export function projectRow(row) {
36
39
  };
37
40
  }
38
41
 
39
- export function sortRows(rows) {
40
- return [...rows].sort((a, b) => a.route.localeCompare(b.route));
42
+ function meanOf(rows, axis) {
43
+ let sum = 0;
44
+ for (const r of rows) sum += r[axis] ?? 0;
45
+ return Number((sum / rows.length).toFixed(2));
41
46
  }
42
47
 
43
48
  function aggregate(rows) {
44
49
  if (!rows || rows.length === 0) {
45
50
  return { performance: 0, accessibility: 0, bestPractices: 0, seo: 0 };
46
51
  }
47
- const sum = { performance: 0, accessibility: 0, bestPractices: 0, seo: 0 };
48
- for (const r of rows) {
49
- sum.performance += r.performance ?? 0;
50
- sum.accessibility += r.accessibility ?? 0;
51
- sum.bestPractices += r.bestPractices ?? 0;
52
- sum.seo += r.seo ?? 0;
53
- }
54
52
  return {
55
- performance: Number((sum.performance / rows.length).toFixed(2)),
56
- accessibility: Number((sum.accessibility / rows.length).toFixed(2)),
57
- bestPractices: Number((sum.bestPractices / rows.length).toFixed(2)),
58
- seo: Number((sum.seo / rows.length).toFixed(2)),
53
+ performance: meanOf(rows, 'performance'),
54
+ accessibility: meanOf(rows, 'accessibility'),
55
+ bestPractices: meanOf(rows, 'bestPractices'),
56
+ seo: meanOf(rows, 'seo'),
59
57
  };
60
58
  }
61
59
 
62
- export function rollup(rows, components = []) {
63
- const out = { '*': aggregate(rows) };
64
- for (const c of components ?? []) {
65
- const matched = (rows ?? []).filter((r) => componentMatches(c, r.route));
66
- out[c.name] = aggregate(matched);
67
- }
68
- return out;
69
- }
70
-
71
- /**
72
- * Pure compare(head, base) for the lighthouse kind. Diffs rows by `route`.
73
- *
74
- * Higher score = better. A row regresses when any of performance,
75
- * accessibility, bestPractices, or seo decreases vs the base. An
76
- * improvement requires at least one score to increase and none to
77
- * decrease. Otherwise the row is unchanged. New routes inherit a base of
78
- * 100 for each axis (so lower scores register as regressions); dropped
79
- * routes inherit a head of 100 (so a higher base registers as an
80
- * improvement).
81
- *
82
- * No I/O. No process exit. No friction emission.
83
- */
84
- const LH_AXES = ['performance', 'accessibility', 'bestPractices', 'seo'];
85
-
86
- export function compare(head, base) {
87
- const headRows = Array.isArray(head?.rows) ? head.rows : [];
88
- const baseRows = Array.isArray(base?.rows) ? base.rows : [];
89
- const baseByKey = new Map();
90
- for (const r of baseRows) baseByKey.set(r.route, r);
91
- const seen = new Set();
92
- const regressions = [];
93
- const improvements = [];
94
- const unchanged = [];
95
- for (const h of headRows) {
96
- seen.add(h.route);
97
- const b = baseByKey.get(h.route) ?? perfectLighthouseRow(h.route);
98
- classify(regressions, improvements, unchanged, h.route, h, b);
99
- }
100
- for (const b of baseRows) {
101
- if (seen.has(b.route)) continue;
102
- const h = perfectLighthouseRow(b.route);
103
- classify(regressions, improvements, unchanged, b.route, h, b);
104
- }
105
- return { regressions, improvements, unchanged };
106
- }
107
-
108
60
  function perfectLighthouseRow(route) {
109
61
  return {
110
62
  route,
@@ -115,71 +67,20 @@ function perfectLighthouseRow(route) {
115
67
  };
116
68
  }
117
69
 
118
- function classify(regressions, improvements, unchanged, key, head, base) {
119
- let down = false;
120
- let up = false;
121
- for (const axis of LH_AXES) {
122
- const delta = (head[axis] ?? 0) - (base[axis] ?? 0);
123
- if (delta < 0) down = true;
124
- else if (delta > 0) up = true;
125
- }
126
- if (down) regressions.push({ key, head, base });
127
- else if (up) improvements.push({ key, head, base });
128
- else unchanged.push({ key, head, base });
129
- }
130
-
131
- /**
132
- * Pure stabilizer for s-stability-epsilon (Story #1964). Lighthouse rows
133
- * match by `route`. The metric is the maximum absolute delta across the
134
- * four scoring axes. Sub-epsilon deltas resolve to the prior bytes;
135
- * missing-prior rows fall through.
136
- *
137
- * @param {Array<{route: string, performance: number, accessibility: number, bestPractices: number, seo: number}>} prior
138
- * @param {Array<{route: string, performance: number, accessibility: number, bestPractices: number, seo: number}>} regenerated
139
- * @param {number} epsilon non-negative absolute tolerance per axis
140
- * @returns {Array<object>}
141
- */
142
- export function applyEpsilon(prior, regenerated, epsilon) {
143
- const priorRows = Array.isArray(prior) ? prior : [];
144
- const regenRows = Array.isArray(regenerated) ? regenerated : [];
145
- const eps = Number.isFinite(epsilon) && epsilon >= 0 ? epsilon : 0;
146
- const priorByKey = new Map();
147
- for (const r of priorRows) priorByKey.set(r.route, r);
148
- return regenRows.map((row) => {
149
- const p = priorByKey.get(row.route);
150
- if (!p) return row;
151
- let maxAxisDelta = 0;
152
- for (const axis of LH_AXES) {
153
- const d = Math.abs((row[axis] ?? 0) - (p[axis] ?? 0));
154
- if (d > maxAxisDelta) maxAxisDelta = d;
155
- }
156
- return maxAxisDelta <= eps ? p : row;
157
- });
158
- }
159
-
160
- /**
161
- * Pure scope-aware merge for s-diff-scoped-writes (Story #1974). Lighthouse
162
- * rows match by `route`. In diff mode, rows whose `route` is OUTSIDE
163
- * `scope.files` are preserved from `prior` verbatim; in-scope rows come
164
- * from `regenerated`. In full mode (or no scope), regenerated wins
165
- * everywhere.
166
- *
167
- * Note: lighthouse routes are not file paths, so the scope filter only
168
- * narrows naturally when callers seed `scope.files` with route strings.
169
- * Auto-refresh callers using a Story file diff will see no in-scope rows
170
- * and therefore preserve every prior row — which is the safe default for
171
- * a baseline that is not file-derived.
172
- *
173
- * @param {Array<{route: string, performance: number, accessibility: number, bestPractices: number, seo: number}>} prior
174
- * @param {Array<{route: string, performance: number, accessibility: number, bestPractices: number, seo: number}>} regenerated
175
- * @param {{mode: 'full'|'diff', files: Set<string>}|null|undefined} scope
176
- * @returns {Array<object>}
177
- */
178
- export function mergeRows(prior, regenerated, scope) {
179
- return mergeRowsByScope({
180
- prior,
181
- regenerated,
182
- scope,
183
- scopeKey: (row) => row.route,
184
- });
185
- }
70
+ export const {
71
+ kernelVersion,
72
+ sortRows,
73
+ rollup,
74
+ compare,
75
+ applyEpsilon,
76
+ mergeRows,
77
+ } = makeBaselineKind({
78
+ keyField,
79
+ kernelVersion: '1.0.0',
80
+ axes: LH_AXES,
81
+ betterWhen: 'higher',
82
+ aggregate,
83
+ missingBasePolicy: 'perfect',
84
+ removedRowPolicy: { kind: 'perfect-head' },
85
+ perfectRow: perfectLighthouseRow,
86
+ });
@@ -9,15 +9,22 @@
9
9
  * MI is computed by the in-repo `escomplex-engine` kernel — same upstream
10
10
  * dependency family as CRAP — so the kernel version tracks
11
11
  * `typhonjs-escomplex` too.
12
+ *
13
+ * Higher MI = better. New paths land in the `additions` bucket
14
+ * (Story #2012 — new files MUST NOT register as regressions); removed
15
+ * paths count as improvements when their MI was non-perfect — the file is
16
+ * gone, so its prior debt is gone too. Scaffold (sortRows / rollup /
17
+ * compare / applyEpsilon / mergeRows) is generated by `makeBaselineKind`
18
+ * (Story #3983).
12
19
  */
13
20
 
14
21
  import { readBaselineAtRef } from '../../baseline-loader.js';
15
22
  import { loadBaseline } from '../../gates/baseline-store.js';
16
- import { getBaseline } from '../../maintainability-utils.js';
17
- import { componentMatches } from '../component-matcher.js';
23
+ import { getBaseline } from '../maintainability-baseline-io.js';
18
24
  import { canonicalise } from '../path-canon.js';
19
- import { mergeRowsByScope } from '../scope.js';
25
+ import { percentile } from './_shared-metric.js';
20
26
  import { kernelVersion as crapKernelVersion } from './crap.js';
27
+ import { makeBaselineKind } from './kind-factory.js';
21
28
 
22
29
  export const name = 'maintainability';
23
30
  export const keyField = 'path';
@@ -111,12 +118,6 @@ export function filterExcludedRows(rows) {
111
118
  );
112
119
  }
113
120
 
114
- export function kernelVersion() {
115
- // MI and CRAP share the escomplex kernel — pin them together so a drift
116
- // in either always invalidates both baselines.
117
- return crapKernelVersion();
118
- }
119
-
120
121
  export function projectRow(row) {
121
122
  return {
122
123
  path: canonicalise(row.path),
@@ -124,10 +125,6 @@ export function projectRow(row) {
124
125
  };
125
126
  }
126
127
 
127
- export function sortRows(rows) {
128
- return [...rows].sort((a, b) => a.path.localeCompare(b.path));
129
- }
130
-
131
128
  function aggregate(rows) {
132
129
  if (!rows || rows.length === 0) return { min: 0, p50: 0, p95: 0 };
133
130
  const sorted = [...rows].map((r) => r.mi).sort((a, b) => a - b);
@@ -138,117 +135,27 @@ function aggregate(rows) {
138
135
  };
139
136
  }
140
137
 
141
- function percentile(sortedValues, p) {
142
- if (sortedValues.length === 0) return 0;
143
- const idx = Math.min(
144
- sortedValues.length - 1,
145
- Math.max(0, Math.ceil((p / 100) * sortedValues.length) - 1),
146
- );
147
- return sortedValues[idx];
148
- }
149
-
150
- export function rollup(rows, components = []) {
151
- const out = { '*': aggregate(rows) };
152
- for (const c of components ?? []) {
153
- const matched = (rows ?? []).filter((r) => componentMatches(c, r.path));
154
- out[c.name] = aggregate(matched);
155
- }
156
- return out;
157
- }
158
-
159
- /**
160
- * Pure compare(head, base) for the maintainability kind. Diffs rows by
161
- * `path`. Higher MI = better — a row regresses when its mi drops vs
162
- * base, improves when it rises, unchanged when equal. New paths (head
163
- * has a row that base lacks) land in the `additions` bucket; absolute-
164
- * floor enforcement is the unified `check-baselines` gate's job and runs
165
- * independently. Removed paths (base has a row that head dropped) count
166
- * as improvements when their MI was non-perfect; the file is gone, so
167
- * its prior debt is gone too.
168
- *
169
- * Story #2012 — new files MUST NOT register as regressions. The prior
170
- * behaviour treated missing-in-base as base.mi = 100 and any real-world
171
- * MI under 100 (i.e., almost every file) flipped to a regression.
172
- *
173
- * No I/O. No process exit. No friction emission.
174
- */
175
- export function compare(head, base) {
176
- const headRows = Array.isArray(head?.rows) ? head.rows : [];
177
- const baseRows = Array.isArray(base?.rows) ? base.rows : [];
178
- const baseByKey = new Map();
179
- for (const r of baseRows) baseByKey.set(r.path, r);
180
- const seen = new Set();
181
- const regressions = [];
182
- const improvements = [];
183
- const unchanged = [];
184
- const additions = [];
185
- for (const h of headRows) {
186
- seen.add(h.path);
187
- const b = baseByKey.get(h.path);
188
- if (!b) {
189
- additions.push({ key: h.path, head: h, base: null });
190
- continue;
191
- }
192
- const delta = (h.mi ?? 0) - (b.mi ?? 0);
193
- if (delta < 0) regressions.push({ key: h.path, head: h, base: b });
194
- else if (delta > 0) improvements.push({ key: h.path, head: h, base: b });
195
- else unchanged.push({ key: h.path, head: h, base: b });
196
- }
197
- for (const b of baseRows) {
198
- if (seen.has(b.path)) continue;
199
- if ((b.mi ?? 0) < 100) {
200
- improvements.push({ key: b.path, head: null, base: b });
201
- } else {
202
- unchanged.push({ key: b.path, head: null, base: b });
203
- }
204
- }
205
- return { regressions, improvements, unchanged, additions };
206
- }
207
-
208
- /**
209
- * Pure stabilizer for s-stability-epsilon (Story #1964). Folds sub-epsilon
210
- * MI deltas back to the prior bytes so env variance does not rewrite the
211
- * on-disk baseline. Missing-prior rows fall through to the regenerated
212
- * row.
213
- *
214
- * @param {Array<{path: string, mi: number}>} prior
215
- * @param {Array<{path: string, mi: number}>} regenerated
216
- * @param {number} epsilon non-negative absolute tolerance on MI
217
- * @returns {Array<object>}
218
- */
219
- export function applyEpsilon(prior, regenerated, epsilon) {
220
- const priorRows = Array.isArray(prior) ? prior : [];
221
- const regenRows = Array.isArray(regenerated) ? regenerated : [];
222
- const eps = Number.isFinite(epsilon) && epsilon >= 0 ? epsilon : 0;
223
- const priorByKey = new Map();
224
- for (const r of priorRows) priorByKey.set(r.path, r);
225
- return regenRows.map((row) => {
226
- const p = priorByKey.get(row.path);
227
- if (!p) return row;
228
- return Math.abs((row.mi ?? 0) - (p.mi ?? 0)) <= eps ? p : row;
229
- });
230
- }
231
-
232
- /**
233
- * Pure scope-aware merge for s-diff-scoped-writes (Story #1974). MI rows
234
- * match by `path`. In diff mode, rows whose `path` is OUTSIDE
235
- * `scope.files` are preserved from `prior` verbatim; in-scope rows come
236
- * from `regenerated`. In full mode (or no scope), regenerated wins
237
- * everywhere.
238
- *
239
- * @param {Array<{path: string, mi: number}>} prior
240
- * @param {Array<{path: string, mi: number}>} regenerated
241
- * @param {{mode: 'full'|'diff', files: Set<string>}|null|undefined} scope
242
- * @returns {Array<object>}
243
- */
244
- export function mergeRows(prior, regenerated, scope) {
245
- return mergeRowsByScope({
246
- prior,
247
- regenerated,
248
- scope,
249
- scopeKey: (row) => row.path,
250
- });
251
- }
138
+ export const {
139
+ kernelVersion,
140
+ sortRows,
141
+ rollup,
142
+ compare,
143
+ applyEpsilon,
144
+ mergeRows,
145
+ } = makeBaselineKind({
146
+ keyField,
147
+ // MI and CRAP share the escomplex kernel — pin them together so a drift
148
+ // in either always invalidates both baselines.
149
+ kernelVersion: crapKernelVersion,
150
+ axes: ['mi'],
151
+ betterWhen: 'higher',
152
+ aggregate,
153
+ missingBasePolicy: 'addition',
154
+ removedRowPolicy: {
155
+ kind: 'improvement-when',
156
+ when: (b) => (b.mi ?? 0) < 100,
157
+ },
158
+ });
252
159
 
253
160
  // ---------------------------------------------------------------------------
254
161
  // CLI-facing pure helpers (Story #1981, Task #1989).
@@ -4,19 +4,18 @@
4
4
  * carries score/killed/survived/noCoverage. Stryker is the upstream
5
5
  * kernel; we pin a static `1.0.0` until a Mandrel-side retrofit story
6
6
  * wires the running Stryker version through (#1908).
7
+ *
8
+ * Higher score = better. New paths land in the `additions` bucket
9
+ * (Story #2012 — any real-world score under 100 must never flip to a
10
+ * regression); removed paths count as improvements when their score was
11
+ * non-perfect. Scaffold is generated by `makeBaselineKind` (Story #3983).
7
12
  */
8
13
 
9
- import { componentMatches } from '../component-matcher.js';
10
14
  import { canonicalise } from '../path-canon.js';
11
- import { mergeRowsByScope } from '../scope.js';
15
+ import { makeBaselineKind } from './kind-factory.js';
12
16
 
13
17
  export const name = 'mutation';
14
18
  export const keyField = 'path';
15
- const KERNEL_VERSION = '1.0.0';
16
-
17
- export function kernelVersion() {
18
- return KERNEL_VERSION;
19
- }
20
19
 
21
20
  export function projectRow(row) {
22
21
  return {
@@ -27,10 +26,6 @@ export function projectRow(row) {
27
26
  };
28
27
  }
29
28
 
30
- export function sortRows(rows) {
31
- return [...rows].sort((a, b) => a.path.localeCompare(b.path));
32
- }
33
-
34
29
  function aggregate(rows) {
35
30
  if (!rows || rows.length === 0) {
36
31
  return { score: 0, killed: 0, survived: 0, noCoverage: 0 };
@@ -51,103 +46,22 @@ function aggregate(rows) {
51
46
  };
52
47
  }
53
48
 
54
- export function rollup(rows, components = []) {
55
- const out = { '*': aggregate(rows) };
56
- for (const c of components ?? []) {
57
- const matched = (rows ?? []).filter((r) => componentMatches(c, r.path));
58
- out[c.name] = aggregate(matched);
59
- }
60
- return out;
61
- }
62
-
63
- /**
64
- * Pure compare(head, base) for the mutation kind. Diffs rows by `path`.
65
- * Higher score = better — a row regresses when its score drops vs base,
66
- * improves when it rises, unchanged when equal. New paths (head has a
67
- * row that base lacks) land in the `additions` bucket; absolute-floor
68
- * enforcement is the unified `check-baselines` gate's job and runs
69
- * independently. Removed paths (base has a row that head dropped) count
70
- * as improvements when their score was non-perfect.
71
- *
72
- * Story #2012 — sibling fix to maintainability.compare. The prior
73
- * behaviour treated missing-in-base as base.score = 100 and any real-
74
- * world mutation score under 100 flipped to a regression.
75
- *
76
- * No I/O. No process exit. No friction emission.
77
- */
78
- export function compare(head, base) {
79
- const headRows = Array.isArray(head?.rows) ? head.rows : [];
80
- const baseRows = Array.isArray(base?.rows) ? base.rows : [];
81
- const baseByKey = new Map();
82
- for (const r of baseRows) baseByKey.set(r.path, r);
83
- const seen = new Set();
84
- const regressions = [];
85
- const improvements = [];
86
- const unchanged = [];
87
- const additions = [];
88
- for (const h of headRows) {
89
- seen.add(h.path);
90
- const b = baseByKey.get(h.path);
91
- if (!b) {
92
- additions.push({ key: h.path, head: h, base: null });
93
- continue;
94
- }
95
- const delta = (h.score ?? 0) - (b.score ?? 0);
96
- if (delta < 0) regressions.push({ key: h.path, head: h, base: b });
97
- else if (delta > 0) improvements.push({ key: h.path, head: h, base: b });
98
- else unchanged.push({ key: h.path, head: h, base: b });
99
- }
100
- for (const b of baseRows) {
101
- if (seen.has(b.path)) continue;
102
- if ((b.score ?? 0) < 100) {
103
- improvements.push({ key: b.path, head: null, base: b });
104
- } else {
105
- unchanged.push({ key: b.path, head: null, base: b });
106
- }
107
- }
108
- return { regressions, improvements, unchanged, additions };
109
- }
110
-
111
- /**
112
- * Pure stabilizer for s-stability-epsilon (Story #1964). Folds sub-epsilon
113
- * mutation-score deltas back to the prior bytes. Missing-prior rows fall
114
- * through to the regenerated row.
115
- *
116
- * @param {Array<{path: string, score: number, killed: number, survived: number}>} prior
117
- * @param {Array<{path: string, score: number, killed: number, survived: number}>} regenerated
118
- * @param {number} epsilon non-negative absolute tolerance on mutation score
119
- * @returns {Array<object>}
120
- */
121
- export function applyEpsilon(prior, regenerated, epsilon) {
122
- const priorRows = Array.isArray(prior) ? prior : [];
123
- const regenRows = Array.isArray(regenerated) ? regenerated : [];
124
- const eps = Number.isFinite(epsilon) && epsilon >= 0 ? epsilon : 0;
125
- const priorByKey = new Map();
126
- for (const r of priorRows) priorByKey.set(r.path, r);
127
- return regenRows.map((row) => {
128
- const p = priorByKey.get(row.path);
129
- if (!p) return row;
130
- return Math.abs((row.score ?? 0) - (p.score ?? 0)) <= eps ? p : row;
131
- });
132
- }
133
-
134
- /**
135
- * Pure scope-aware merge for s-diff-scoped-writes (Story #1974). Mutation
136
- * rows match by `path`. In diff mode, rows whose `path` is OUTSIDE
137
- * `scope.files` are preserved from `prior` verbatim; in-scope rows come
138
- * from `regenerated`. In full mode (or no scope), regenerated wins
139
- * everywhere.
140
- *
141
- * @param {Array<{path: string, score: number, killed: number, survived: number}>} prior
142
- * @param {Array<{path: string, score: number, killed: number, survived: number}>} regenerated
143
- * @param {{mode: 'full'|'diff', files: Set<string>}|null|undefined} scope
144
- * @returns {Array<object>}
145
- */
146
- export function mergeRows(prior, regenerated, scope) {
147
- return mergeRowsByScope({
148
- prior,
149
- regenerated,
150
- scope,
151
- scopeKey: (row) => row.path,
152
- });
153
- }
49
+ export const {
50
+ kernelVersion,
51
+ sortRows,
52
+ rollup,
53
+ compare,
54
+ applyEpsilon,
55
+ mergeRows,
56
+ } = makeBaselineKind({
57
+ keyField,
58
+ kernelVersion: '1.0.0',
59
+ axes: ['score'],
60
+ betterWhen: 'higher',
61
+ aggregate,
62
+ missingBasePolicy: 'addition',
63
+ removedRowPolicy: {
64
+ kind: 'improvement-when',
65
+ when: (b) => (b.score ?? 0) < 100,
66
+ },
67
+ });
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Logger } from '../Logger.js';
4
+
5
+ /**
6
+ * Story #1895: project the canonical maintainability envelope back to the
7
+ * legacy flat `{ path: mi }` map so existing gate consumers keep working
8
+ * without churn — Story #1912 will replace this shim with the shared
9
+ * reader. Returns the parsed input unchanged when it doesn't look like an
10
+ * envelope (legacy flat shape stays flat).
11
+ */
12
+ function projectMaintainabilityEnvelopeToFlat(parsed) {
13
+ if (
14
+ !parsed ||
15
+ typeof parsed !== 'object' ||
16
+ Array.isArray(parsed) ||
17
+ !Array.isArray(parsed.rows) ||
18
+ typeof parsed.$schema !== 'string'
19
+ ) {
20
+ return parsed;
21
+ }
22
+ const flat = {};
23
+ for (const row of parsed.rows) {
24
+ if (row && typeof row.path === 'string' && typeof row.mi === 'number') {
25
+ flat[row.path] = row.mi;
26
+ }
27
+ }
28
+ return flat;
29
+ }
30
+
31
+ /**
32
+ * Loads the current maintainability baseline from disk. The on-disk path is
33
+ * resolved by the caller via {@link getBaselines}; passing it explicitly
34
+ * removes the silent-default behaviour the framework dropped in Epic #730
35
+ * Story 5.5.
36
+ *
37
+ * @param {string} baselinePath Repo-relative or absolute path to the baseline
38
+ * JSON. Required.
39
+ * @returns {Record<string, number>}
40
+ */
41
+ export function getBaseline(baselinePath) {
42
+ if (typeof baselinePath !== 'string' || baselinePath.length === 0) {
43
+ throw new TypeError(
44
+ 'maintainability-utils.getBaseline: baselinePath is required (Epic #730 ' +
45
+ 'Story 5.5 — callers resolve the path via getBaselines(config).maintainability.path).',
46
+ );
47
+ }
48
+ const abs = path.isAbsolute(baselinePath)
49
+ ? baselinePath
50
+ : path.resolve(process.cwd(), baselinePath);
51
+ if (!fs.existsSync(abs)) return {};
52
+ try {
53
+ const parsed = JSON.parse(fs.readFileSync(abs, 'utf-8'));
54
+ return projectMaintainabilityEnvelopeToFlat(parsed);
55
+ } catch (err) {
56
+ Logger.warn(`[Maintainability] Failed to parse baseline: ${err.message}`);
57
+ return {};
58
+ }
59
+ }
@@ -0,0 +1,37 @@
1
+ import path from 'node:path';
2
+ import {
3
+ write as writeBaselineEnvelope,
4
+ writeFile as writeBaselineFile,
5
+ } from './writer.js';
6
+
7
+ /**
8
+ * Saves a new maintainability baseline to disk at `baselinePath`.
9
+ *
10
+ * Accepts the legacy flat `{ path: mi }` shape for backwards compatibility
11
+ * with existing callers (`regenerateMainFromTree`, refresh helpers). The
12
+ * map is transformed into the canonical envelope shape (`$schema`,
13
+ * `kernelVersion`, `generatedAt`, `rollup`, `rows`) via the shared
14
+ * `lib/baselines/writer.js` pipeline before being persisted, so every
15
+ * write produces a file that round-trips through `lib/baselines/reader.js`
16
+ * without schema errors.
17
+ *
18
+ * @param {Record<string, number>} baseline path→MI flat map.
19
+ * @param {string} baselinePath Required — caller supplies via getBaselines().
20
+ */
21
+ export function saveBaseline(baseline, baselinePath) {
22
+ if (typeof baselinePath !== 'string' || baselinePath.length === 0) {
23
+ throw new TypeError(
24
+ 'maintainability-utils.saveBaseline: baselinePath is required.',
25
+ );
26
+ }
27
+ const abs = path.isAbsolute(baselinePath)
28
+ ? baselinePath
29
+ : path.resolve(process.cwd(), baselinePath);
30
+
31
+ const rows = Object.entries(baseline ?? {}).map(([p, mi]) => ({
32
+ path: p,
33
+ mi,
34
+ }));
35
+ const envelope = writeBaselineEnvelope({ kind: 'maintainability', rows });
36
+ writeBaselineFile(abs, envelope);
37
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Every legacy baseline-refresh script (`update-crap-baseline.js`,
5
5
  * `update-maintainability-baseline.js`, `lib/coverage-baseline.js`,
6
- * `lib/auto-refresh-baselines.js`) used to assemble its own envelope and
6
+ * the auto-refresh evaluator, now inside `auto-refresh-runner.js`) used to assemble its own envelope and
7
7
  * call `fs.writeFileSync`. That meant the path canonicalisation, rollup
8
8
  * math, kernel-version stamping, and JSON-serialisation conventions were
9
9
  * duplicated five-ish times, each with subtly different rules. The