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
@@ -7,19 +7,22 @@
7
7
  * `*` key and each component. Per-row denominators land later when the
8
8
  * components resolver and per-component weighting (Story #1902, #1919)
9
9
  * arrive; the rollup signature is stable so callers don't churn.
10
+ *
11
+ * Higher percentages are better. New paths land in the `additions` bucket
12
+ * (Story #2012 — partial coverage on a new file must never flip to a
13
+ * regression); removed paths inherit a perfect head row so any lower base
14
+ * registers as an improvement. Scaffold (sortRows / rollup / compare /
15
+ * applyEpsilon / mergeRows) is generated by `makeBaselineKind`
16
+ * (Story #3983).
10
17
  */
11
18
 
12
- import { componentMatches } from '../component-matcher.js';
13
19
  import { canonicalise } from '../path-canon.js';
14
- import { mergeRowsByScope } from '../scope.js';
20
+ import { makeBaselineKind } from './kind-factory.js';
15
21
 
16
22
  export const name = 'coverage';
17
23
  export const keyField = 'path';
18
- const KERNEL_VERSION = '1.0.0';
19
24
 
20
- export function kernelVersion() {
21
- return KERNEL_VERSION;
22
- }
25
+ const COV_AXES = ['lines', 'branches', 'functions'];
23
26
 
24
27
  export function projectRow(row) {
25
28
  return {
@@ -35,160 +38,41 @@ function roundPct(v) {
35
38
  return Number(v.toFixed(2));
36
39
  }
37
40
 
38
- export function sortRows(rows) {
39
- return [...rows].sort((a, b) => a.path.localeCompare(b.path));
41
+ function meanOf(rows, axis) {
42
+ let sum = 0;
43
+ for (const r of rows) sum += r[axis] ?? 0;
44
+ return Number((sum / rows.length).toFixed(2));
40
45
  }
41
46
 
42
47
  function aggregate(rows) {
43
48
  if (!rows || rows.length === 0) {
44
49
  return { lines: 0, branches: 0, functions: 0 };
45
50
  }
46
- let l = 0;
47
- let b = 0;
48
- let f = 0;
49
- for (const row of rows) {
50
- l += row.lines ?? 0;
51
- b += row.branches ?? 0;
52
- f += row.functions ?? 0;
53
- }
54
51
  return {
55
- lines: Number((l / rows.length).toFixed(2)),
56
- branches: Number((b / rows.length).toFixed(2)),
57
- functions: Number((f / rows.length).toFixed(2)),
52
+ lines: meanOf(rows, 'lines'),
53
+ branches: meanOf(rows, 'branches'),
54
+ functions: meanOf(rows, 'functions'),
58
55
  };
59
56
  }
60
57
 
61
- export function rollup(rows, components = []) {
62
- const out = { '*': aggregate(rows) };
63
- for (const c of components ?? []) {
64
- const matched = (rows ?? []).filter((r) => componentMatches(c, r.path));
65
- out[c.name] = aggregate(matched);
66
- }
67
- return out;
68
- }
69
-
70
- /**
71
- * Pure compare(head, base) for the coverage kind. Diffs rows by `path`.
72
- * Higher percentages are better — a row regresses if any axis (lines,
73
- * branches, functions) drops vs base; improves if any axis rises with no
74
- * axis dropping; unchanged otherwise. New paths (head has a row that
75
- * base lacks) land in the `additions` bucket; absolute-floor enforcement
76
- * is the unified `check-baselines` gate's job and runs independently.
77
- * Removed paths inherit a head of 100% so any lower base registers as
78
- * an improvement.
79
- *
80
- * Story #2012 — sibling fix to maintainability.compare. The prior
81
- * behaviour treated new paths as base=100% on every axis, so any partial
82
- * coverage on a new file flipped to a regression.
83
- *
84
- * No I/O. No process exit. No friction emission.
85
- */
86
- const COV_AXES = ['lines', 'branches', 'functions'];
87
-
88
- export function compare(head, base) {
89
- const headRows = Array.isArray(head?.rows) ? head.rows : [];
90
- const baseRows = Array.isArray(base?.rows) ? base.rows : [];
91
- const baseByKey = new Map();
92
- for (const r of baseRows) baseByKey.set(r.path, r);
93
- const seen = new Set();
94
- const regressions = [];
95
- const improvements = [];
96
- const unchanged = [];
97
- const additions = [];
98
- for (const h of headRows) {
99
- seen.add(h.path);
100
- const b = baseByKey.get(h.path);
101
- if (!b) {
102
- additions.push({ key: h.path, head: h, base: null });
103
- continue;
104
- }
105
- classifyCoverage(regressions, improvements, unchanged, h.path, h, b);
106
- }
107
- for (const b of baseRows) {
108
- if (seen.has(b.path)) continue;
109
- const h = perfectCoverageRow(b.path);
110
- classifyCoverage(regressions, improvements, unchanged, b.path, h, b);
111
- }
112
- return { regressions, improvements, unchanged, additions };
113
- }
114
-
115
58
  function perfectCoverageRow(path) {
116
59
  return { path, lines: 100, branches: 100, functions: 100 };
117
60
  }
118
61
 
119
- function classifyCoverage(
120
- regressions,
121
- improvements,
122
- unchanged,
123
- key,
124
- head,
125
- base,
126
- ) {
127
- let down = false;
128
- let up = false;
129
- for (const axis of COV_AXES) {
130
- const delta = (head[axis] ?? 0) - (base[axis] ?? 0);
131
- if (delta < 0) down = true;
132
- else if (delta > 0) up = true;
133
- }
134
- if (down) regressions.push({ key, head, base });
135
- else if (up) improvements.push({ key, head, base });
136
- else unchanged.push({ key, head, base });
137
- }
138
-
139
- /**
140
- * Pure stabilizer for s-stability-epsilon (Story #1964). Folds sub-epsilon
141
- * row deltas back to the prior bytes so env variance (e.g. ±0.05% jitter
142
- * on a coverage axis) does not rewrite the on-disk baseline.
143
- *
144
- * For coverage, the comparison metric is the maximum |delta| across the
145
- * three axes (lines, branches, functions). When the prior row exists and
146
- * every axis delta is within `epsilon`, the prior row is returned
147
- * verbatim; otherwise the regenerated row wins. Missing-prior rows always
148
- * fall through to the regenerated row.
149
- *
150
- * No I/O. No mutation of inputs.
151
- *
152
- * @param {Array<{path: string, lines: number, branches: number, functions: number}>} prior
153
- * @param {Array<{path: string, lines: number, branches: number, functions: number}>} regenerated
154
- * @param {number} epsilon non-negative absolute tolerance (percentage points)
155
- * @returns {Array<object>}
156
- */
157
- export function applyEpsilon(prior, regenerated, epsilon) {
158
- const priorRows = Array.isArray(prior) ? prior : [];
159
- const regenRows = Array.isArray(regenerated) ? regenerated : [];
160
- const eps = Number.isFinite(epsilon) && epsilon >= 0 ? epsilon : 0;
161
- const priorByKey = new Map();
162
- for (const r of priorRows) priorByKey.set(r.path, r);
163
- return regenRows.map((row) => {
164
- const p = priorByKey.get(row.path);
165
- if (!p) return row;
166
- let maxAxisDelta = 0;
167
- for (const axis of COV_AXES) {
168
- const d = Math.abs((row[axis] ?? 0) - (p[axis] ?? 0));
169
- if (d > maxAxisDelta) maxAxisDelta = d;
170
- }
171
- return maxAxisDelta <= eps ? p : row;
172
- });
173
- }
174
-
175
- /**
176
- * Pure scope-aware merge for s-diff-scoped-writes (Story #1974). Coverage
177
- * rows match by `path`. In diff mode, rows whose `path` is OUTSIDE
178
- * `scope.files` are preserved from `prior` verbatim; in-scope rows come
179
- * from `regenerated`. In full mode (or no scope), regenerated wins
180
- * everywhere. Pure; downstream `sortRows` re-sorts before write.
181
- *
182
- * @param {Array<{path: string, lines: number, branches: number, functions: number}>} prior
183
- * @param {Array<{path: string, lines: number, branches: number, functions: number}>} regenerated
184
- * @param {{mode: 'full'|'diff', files: Set<string>}|null|undefined} scope
185
- * @returns {Array<object>}
186
- */
187
- export function mergeRows(prior, regenerated, scope) {
188
- return mergeRowsByScope({
189
- prior,
190
- regenerated,
191
- scope,
192
- scopeKey: (row) => row.path,
193
- });
194
- }
62
+ export const {
63
+ kernelVersion,
64
+ sortRows,
65
+ rollup,
66
+ compare,
67
+ applyEpsilon,
68
+ mergeRows,
69
+ } = makeBaselineKind({
70
+ keyField,
71
+ kernelVersion: '1.0.0',
72
+ axes: COV_AXES,
73
+ betterWhen: 'higher',
74
+ aggregate,
75
+ missingBasePolicy: 'addition',
76
+ removedRowPolicy: { kind: 'perfect-head' },
77
+ perfectRow: perfectCoverageRow,
78
+ });
@@ -11,26 +11,25 @@
11
11
  * per-file percentages (which would over-weight small files).
12
12
  *
13
13
  * Lower duplication is better, so the gate's floor direction is `lte`
14
- * (see `check-baselines/phases/floors.js#axisDirection`).
14
+ * (see `check-baselines/phases/floors.js#axisDirection`). New paths land
15
+ * in the `additions` bucket (Story #2012); removed paths count as
16
+ * improvements when they carried any duplication — the file is gone, so
17
+ * its prior debt is gone too.
15
18
  *
16
19
  * `kernelVersion()` returns a static in-repo semver — the duplication
17
20
  * scorer is the in-repo scan + rollup contract (the jscpd output is
18
21
  * normalised before it reaches this module), so there is no upstream
19
22
  * library version to track the way CRAP/MI track `typhonjs-escomplex`.
20
- * Bump `KERNEL_VERSION` whenever the row shape or rollup math changes.
23
+ * Bump the version passed to `makeBaselineKind` whenever the row shape or
24
+ * rollup math changes. Scaffold is generated by `makeBaselineKind`
25
+ * (Story #3983).
21
26
  */
22
27
 
23
- import { componentMatches } from '../component-matcher.js';
24
28
  import { canonicalise } from '../path-canon.js';
25
- import { mergeRowsByScope } from '../scope.js';
29
+ import { makeBaselineKind } from './kind-factory.js';
26
30
 
27
31
  export const name = 'duplication';
28
32
  export const keyField = 'path';
29
- const KERNEL_VERSION = '1.0.0';
30
-
31
- export function kernelVersion() {
32
- return KERNEL_VERSION;
33
- }
34
33
 
35
34
  export function projectRow(row) {
36
35
  const duplicatedLines = Number(row.duplicatedLines ?? 0);
@@ -47,10 +46,6 @@ export function projectRow(row) {
47
46
  };
48
47
  }
49
48
 
50
- export function sortRows(rows) {
51
- return [...rows].sort((a, b) => a.path.localeCompare(b.path));
52
- }
53
-
54
49
  /**
55
50
  * Exact aggregate duplication ratio: total duplicated lines / total lines
56
51
  * across the row set, expressed as a percentage. Averaging per-file
@@ -92,106 +87,22 @@ function roundTo2(value) {
92
87
  return Number(value.toFixed(2));
93
88
  }
94
89
 
95
- export function rollup(rows, components = []) {
96
- const out = { '*': aggregate(rows) };
97
- for (const c of components ?? []) {
98
- const matched = (rows ?? []).filter((r) => componentMatches(c, r.path));
99
- out[c.name] = aggregate(matched);
100
- }
101
- return out;
102
- }
103
-
104
- /**
105
- * Pure compare(head, base) for the duplication kind. Diffs rows by `path`.
106
- *
107
- * Higher duplication = worse. A row regresses when its `percentage`
108
- * increases vs base; improves when it decreases; unchanged when equal.
109
- * New paths (head has a row that base lacks) land in the `additions`
110
- * bucket — absolute-floor enforcement is the unified `check-baselines`
111
- * gate's job and runs independently, so a Story that lands a new file
112
- * never fails through the regression arm (mirrors crap/maintainability,
113
- * Story #2012). Removed paths (base has a row that head dropped) count as
114
- * improvements when they carried any duplication; the file is gone, so its
115
- * prior debt is gone too.
116
- *
117
- * No I/O. No process exit. No friction emission.
118
- */
119
- export function compare(head, base) {
120
- const headRows = Array.isArray(head?.rows) ? head.rows : [];
121
- const baseRows = Array.isArray(base?.rows) ? base.rows : [];
122
- const baseByKey = new Map();
123
- for (const r of baseRows) baseByKey.set(r.path, r);
124
- const seen = new Set();
125
- const regressions = [];
126
- const improvements = [];
127
- const unchanged = [];
128
- const additions = [];
129
- for (const h of headRows) {
130
- seen.add(h.path);
131
- const b = baseByKey.get(h.path);
132
- if (!b) {
133
- additions.push({ key: h.path, head: h, base: null });
134
- continue;
135
- }
136
- const delta = (h.percentage ?? 0) - (b.percentage ?? 0);
137
- if (delta > 0) regressions.push({ key: h.path, head: h, base: b });
138
- else if (delta < 0) improvements.push({ key: h.path, head: h, base: b });
139
- else unchanged.push({ key: h.path, head: h, base: b });
140
- }
141
- for (const b of baseRows) {
142
- if (seen.has(b.path)) continue;
143
- if ((b.percentage ?? 0) > 0) {
144
- improvements.push({ key: b.path, head: null, base: b });
145
- } else {
146
- unchanged.push({ key: b.path, head: null, base: b });
147
- }
148
- }
149
- return { regressions, improvements, unchanged, additions };
150
- }
151
-
152
- /**
153
- * Pure stabilizer for s-stability-epsilon (Story #1964). Duplication rows
154
- * match by `path`. The metric is the absolute `percentage` delta. Sub-
155
- * epsilon deltas resolve to the prior bytes so env variance never rewrites
156
- * the on-disk baseline; missing-prior rows fall through.
157
- *
158
- * @param {Array<{path: string, percentage: number}>} prior
159
- * @param {Array<{path: string, percentage: number}>} regenerated
160
- * @param {number} epsilon non-negative absolute tolerance on percentage
161
- * @returns {Array<object>}
162
- */
163
- export function applyEpsilon(prior, regenerated, epsilon) {
164
- const priorRows = Array.isArray(prior) ? prior : [];
165
- const regenRows = Array.isArray(regenerated) ? regenerated : [];
166
- const eps = Number.isFinite(epsilon) && epsilon >= 0 ? epsilon : 0;
167
- const priorByKey = new Map();
168
- for (const r of priorRows) priorByKey.set(r.path, r);
169
- return regenRows.map((row) => {
170
- const p = priorByKey.get(row.path);
171
- if (!p) return row;
172
- return Math.abs((row.percentage ?? 0) - (p.percentage ?? 0)) <= eps
173
- ? p
174
- : row;
175
- });
176
- }
177
-
178
- /**
179
- * Pure scope-aware merge for s-diff-scoped-writes (Story #1974).
180
- * Duplication rows match by `path`. In diff mode, rows whose `path` is
181
- * OUTSIDE `scope.files` are preserved from `prior` verbatim; in-scope rows
182
- * come from `regenerated`. In full mode (or no scope), regenerated wins
183
- * everywhere.
184
- *
185
- * @param {Array<{path: string, percentage: number}>} prior
186
- * @param {Array<{path: string, percentage: number}>} regenerated
187
- * @param {{mode: 'full'|'diff', files: Set<string>}|null|undefined} scope
188
- * @returns {Array<object>}
189
- */
190
- export function mergeRows(prior, regenerated, scope) {
191
- return mergeRowsByScope({
192
- prior,
193
- regenerated,
194
- scope,
195
- scopeKey: (row) => row.path,
196
- });
197
- }
90
+ export const {
91
+ kernelVersion,
92
+ sortRows,
93
+ rollup,
94
+ compare,
95
+ applyEpsilon,
96
+ mergeRows,
97
+ } = makeBaselineKind({
98
+ keyField,
99
+ kernelVersion: '1.0.0',
100
+ axes: ['percentage'],
101
+ betterWhen: 'lower',
102
+ aggregate,
103
+ missingBasePolicy: 'addition',
104
+ removedRowPolicy: {
105
+ kind: 'improvement-when',
106
+ when: (b) => (b.percentage ?? 0) > 0,
107
+ },
108
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * kinds/kind-factory.js — shared scaffold factory for per-kind baseline
3
+ * modules (Story #3983).
4
+ *
5
+ * The five row-metric kinds (coverage, mutation, maintainability,
6
+ * lighthouse, duplication) used to hand-roll the same scaffold each:
7
+ * `kernelVersion`, `sortRows`, a component-aware `rollup`, a
8
+ * `compare(head, base)` that diffs rows by key into
9
+ * `{regressions, improvements, unchanged, additions}`, an
10
+ * `applyEpsilon` stabilizer (Story #1964), and a scope-aware
11
+ * `mergeRows` (Story #1974). Only the axis list, direction-of-better,
12
+ * aggregate math, and the missing/removed-row policies differ per kind.
13
+ *
14
+ * `makeBaselineKind` generates the scaffold once; each kind module stays
15
+ * a thin parameterization with a byte-identical exported surface. The
16
+ * Story #2012 class of fix ("new paths must land in `additions`, never
17
+ * the regression arm") lives here exactly once.
18
+ *
19
+ * All generated functions are pure: no I/O, no process exit, no
20
+ * friction emission.
21
+ */
22
+
23
+ import { componentMatches } from '../component-matcher.js';
24
+ import { mergeRowsByScope } from '../scope.js';
25
+
26
+ /**
27
+ * Build the shared scaffold for a per-kind baseline module.
28
+ *
29
+ * @param {{
30
+ * keyField: string,
31
+ * kernelVersion: string | (() => string),
32
+ * axes: string[],
33
+ * betterWhen: 'higher' | 'lower',
34
+ * aggregate: (rows: object[]) => Record<string, number>,
35
+ * missingBasePolicy?: 'addition' | 'perfect',
36
+ * removedRowPolicy?:
37
+ * | { kind: 'perfect-head' }
38
+ * | { kind: 'improvement-when', when: (row: object) => boolean },
39
+ * perfectRow?: (key: string) => object,
40
+ * }} opts
41
+ * - `keyField` — row identity property (`'path'` or `'route'`)
42
+ * - `kernelVersion` — static semver, or a thunk for kinds that pin
43
+ * to another kind's kernel (MI → CRAP)
44
+ * - `axes` — metric property names compared per row
45
+ * - `betterWhen` — `'higher'` (coverage, MI, …) or `'lower'`
46
+ * (duplication): decides which delta sign is a
47
+ * regression
48
+ * - `aggregate` — per-kind rollup math over a row set
49
+ * - `missingBasePolicy` — head row with no base row: `'addition'`
50
+ * (default; Story #2012 bucket) or `'perfect'`
51
+ * (classify against a perfect base — lighthouse,
52
+ * where a new route must meet the bar)
53
+ * - `removedRowPolicy` — base row with no head row: `'perfect-head'`
54
+ * classifies against a perfect head row;
55
+ * `'improvement-when'` pushes an improvement
56
+ * when `when(baseRow)` holds, else unchanged
57
+ * - `perfectRow` — builds the perfect row for the policies above
58
+ * @returns {{
59
+ * kernelVersion: () => string,
60
+ * sortRows: (rows: object[]) => object[],
61
+ * rollup: (rows: object[], components?: object[]) => Record<string, object>,
62
+ * compare: (head: object, base: object) => object,
63
+ * applyEpsilon: (prior: object[], regenerated: object[], epsilon: number) => object[],
64
+ * mergeRows: (prior: object[], regenerated: object[], scope: object) => object[],
65
+ * }}
66
+ */
67
+ export function makeBaselineKind({
68
+ keyField,
69
+ kernelVersion,
70
+ axes,
71
+ betterWhen,
72
+ aggregate,
73
+ missingBasePolicy = 'addition',
74
+ removedRowPolicy = { kind: 'improvement-when', when: () => false },
75
+ perfectRow = null,
76
+ }) {
77
+ const keyOf = (row) => row[keyField];
78
+ const kernelVersionFn =
79
+ typeof kernelVersion === 'function' ? kernelVersion : () => kernelVersion;
80
+
81
+ function sortRows(rows) {
82
+ return [...rows].sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
83
+ }
84
+
85
+ function rollup(rows, components = []) {
86
+ const out = { '*': aggregate(rows) };
87
+ for (const c of components ?? []) {
88
+ const matched = (rows ?? []).filter((r) => componentMatches(c, keyOf(r)));
89
+ out[c.name] = aggregate(matched);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function classify(regressions, improvements, unchanged, key, head, base) {
95
+ let down = false;
96
+ let up = false;
97
+ for (const axis of axes) {
98
+ const delta = (head[axis] ?? 0) - (base[axis] ?? 0);
99
+ const worse = betterWhen === 'higher' ? delta < 0 : delta > 0;
100
+ const better = betterWhen === 'higher' ? delta > 0 : delta < 0;
101
+ if (worse) down = true;
102
+ else if (better) up = true;
103
+ }
104
+ if (down) regressions.push({ key, head, base });
105
+ else if (up) improvements.push({ key, head, base });
106
+ else unchanged.push({ key, head, base });
107
+ }
108
+
109
+ function compare(head, base) {
110
+ const headRows = Array.isArray(head?.rows) ? head.rows : [];
111
+ const baseRows = Array.isArray(base?.rows) ? base.rows : [];
112
+ const baseByKey = new Map();
113
+ for (const r of baseRows) baseByKey.set(keyOf(r), r);
114
+ const seen = new Set();
115
+ const regressions = [];
116
+ const improvements = [];
117
+ const unchanged = [];
118
+ const additions = [];
119
+ for (const h of headRows) {
120
+ const key = keyOf(h);
121
+ seen.add(key);
122
+ const b = baseByKey.get(key);
123
+ if (!b) {
124
+ if (missingBasePolicy === 'addition') {
125
+ additions.push({ key, head: h, base: null });
126
+ } else {
127
+ classify(
128
+ regressions,
129
+ improvements,
130
+ unchanged,
131
+ key,
132
+ h,
133
+ perfectRow(key),
134
+ );
135
+ }
136
+ continue;
137
+ }
138
+ classify(regressions, improvements, unchanged, key, h, b);
139
+ }
140
+ for (const b of baseRows) {
141
+ const key = keyOf(b);
142
+ if (seen.has(key)) continue;
143
+ if (removedRowPolicy.kind === 'perfect-head') {
144
+ classify(regressions, improvements, unchanged, key, perfectRow(key), b);
145
+ } else if (removedRowPolicy.when(b)) {
146
+ improvements.push({ key, head: null, base: b });
147
+ } else {
148
+ unchanged.push({ key, head: null, base: b });
149
+ }
150
+ }
151
+ if (missingBasePolicy === 'addition') {
152
+ return { regressions, improvements, unchanged, additions };
153
+ }
154
+ return { regressions, improvements, unchanged };
155
+ }
156
+
157
+ function applyEpsilon(prior, regenerated, epsilon) {
158
+ const priorRows = Array.isArray(prior) ? prior : [];
159
+ const regenRows = Array.isArray(regenerated) ? regenerated : [];
160
+ const eps = Number.isFinite(epsilon) && epsilon >= 0 ? epsilon : 0;
161
+ const priorByKey = new Map();
162
+ for (const r of priorRows) priorByKey.set(keyOf(r), r);
163
+ return regenRows.map((row) => {
164
+ const p = priorByKey.get(keyOf(row));
165
+ if (!p) return row;
166
+ let maxAxisDelta = 0;
167
+ for (const axis of axes) {
168
+ const d = Math.abs((row[axis] ?? 0) - (p[axis] ?? 0));
169
+ if (d > maxAxisDelta) maxAxisDelta = d;
170
+ }
171
+ return maxAxisDelta <= eps ? p : row;
172
+ });
173
+ }
174
+
175
+ function mergeRows(prior, regenerated, scope) {
176
+ return mergeRowsByScope({
177
+ prior,
178
+ regenerated,
179
+ scope,
180
+ scopeKey: keyOf,
181
+ });
182
+ }
183
+
184
+ return {
185
+ kernelVersion: kernelVersionFn,
186
+ sortRows,
187
+ rollup,
188
+ compare,
189
+ applyEpsilon,
190
+ mergeRows,
191
+ };
192
+ }