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.
- package/.agents/README.md +89 -87
- package/.agents/docs/SDLC.md +11 -7
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/audit-rules.json +20 -0
- package/.agents/scripts/acceptance-eval.js +20 -3
- package/.agents/scripts/assert-branch.js +1 -3
- package/.agents/scripts/bootstrap.js +1 -1
- package/.agents/scripts/check-arch-cycles.js +360 -0
- package/.agents/scripts/coverage-capture.js +24 -3
- package/.agents/scripts/epic-deliver-preflight.js +5 -3
- package/.agents/scripts/epic-deliver-prepare.js +12 -4
- package/.agents/scripts/epic-execute-record-wave.js +1 -1
- package/.agents/scripts/evidence-gate.js +1 -1
- package/.agents/scripts/git-rebase-and-resolve.js +1 -1
- package/.agents/scripts/hierarchy-gate.js +34 -14
- package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
- package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
- package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
- package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
- package/.agents/scripts/lib/baselines/writer.js +1 -1
- package/.agents/scripts/lib/close-validation/commands.js +188 -0
- package/.agents/scripts/lib/close-validation/gates.js +235 -0
- package/.agents/scripts/lib/close-validation/process.js +101 -0
- package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
- package/.agents/scripts/lib/close-validation/runner.js +325 -0
- package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
- package/.agents/scripts/lib/config/quality.js +6 -6
- package/.agents/scripts/lib/config-resolver.js +2 -5
- package/.agents/scripts/lib/coverage-capture.js +147 -4
- package/.agents/scripts/lib/cpu-pool.js +14 -0
- package/.agents/scripts/lib/crap-utils.js +6 -11
- package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
- package/.agents/scripts/lib/git-utils.js +24 -22
- package/.agents/scripts/lib/maintainability-engine.js +1 -1
- package/.agents/scripts/lib/maintainability-utils.js +4 -187
- package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
- package/.agents/scripts/lib/orchestration/code-review.js +90 -77
- package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
- package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
- package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
- package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
- package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
- package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
- package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
- package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
- package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
- package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
- package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
- package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
- package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
- package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
- package/.agents/scripts/lib/project-root.js +17 -0
- package/.agents/scripts/lib/story-adjacency.js +76 -0
- package/.agents/scripts/lib/story-lifecycle.js +1 -1
- package/.agents/scripts/lib/transpile.js +93 -0
- package/.agents/scripts/lib/wave-runner/tick.js +4 -153
- package/.agents/scripts/lib/workers/crap-worker.js +1 -1
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
- package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
- package/.agents/scripts/providers/github/tickets.js +110 -6
- package/.agents/scripts/run-lint.js +9 -0
- package/.agents/scripts/run-tests.js +24 -4
- package/.agents/scripts/stories-wave-tick.js +8 -5
- package/.agents/scripts/story-init.js +149 -10
- package/.agents/scripts/sync-branch-from-base.js +1 -1
- package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
- package/.agents/workflows/audit-documentation.md +226 -0
- package/.agents/workflows/epic-deliver.md +16 -23
- package/.agents/workflows/epic-plan.md +1 -1
- package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
- package/.agents/workflows/helpers/single-story-deliver.md +2 -1
- package/.agents/workflows/onboard.md +4 -3
- package/.agents/workflows/story-deliver.md +1 -1
- package/README.md +13 -8
- package/lib/cli/init.js +336 -0
- package/package.json +2 -1
- package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
- package/.agents/scripts/lib/close-validation.js +0 -897
- package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
- package/.agents/scripts/lib/task-utils.js +0 -26
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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:
|
|
56
|
-
accessibility:
|
|
57
|
-
bestPractices:
|
|
58
|
-
seo:
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 '
|
|
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 {
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 {
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
* `
|
|
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
|