mandrel 2.25.0 → 2.27.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 (132) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/reader.js +52 -38
  41. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  42. package/.agents/scripts/lib/baselines/scope.js +39 -90
  43. package/.agents/scripts/lib/baselines/writer.js +16 -11
  44. package/.agents/scripts/lib/changed-files.js +8 -1
  45. package/.agents/scripts/lib/cli-args.js +115 -1
  46. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  47. package/.agents/scripts/lib/crap-engine.js +32 -13
  48. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  49. package/.agents/scripts/lib/crap-utils.js +13 -0
  50. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  51. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  53. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  54. package/.agents/scripts/lib/git-utils.js +136 -80
  55. package/.agents/scripts/lib/import-graph.js +156 -0
  56. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  57. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  58. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  62. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  64. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  65. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  66. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  67. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  68. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  69. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  70. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  71. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  72. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  73. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  74. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  78. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  79. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  80. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  81. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  82. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  83. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  84. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  85. package/.agents/scripts/lib/story-adjacency.js +3 -3
  86. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  87. package/.agents/scripts/lib/test-tiers.js +11 -2
  88. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  89. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  90. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  91. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  92. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  93. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  94. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  96. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  97. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  98. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  99. package/.agents/scripts/lib/worktree-manager.js +14 -0
  100. package/.agents/scripts/plan-run-epilogue.js +17 -5
  101. package/.agents/scripts/providers/github/tickets.js +33 -10
  102. package/.agents/scripts/provision-git-hooks.js +85 -0
  103. package/.agents/scripts/quality-preview.js +112 -28
  104. package/.agents/scripts/resolve-stories.js +4 -1
  105. package/.agents/scripts/run-coverage.js +86 -35
  106. package/.agents/scripts/run-lint.js +20 -0
  107. package/.agents/scripts/run-tests.js +26 -36
  108. package/.agents/scripts/single-story-close.js +28 -2
  109. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  110. package/.agents/scripts/stories-wave-tick.js +214 -38
  111. package/.agents/scripts/update-coverage-baseline.js +34 -4
  112. package/.agents/scripts/update-duplication-baseline.js +209 -83
  113. package/.agents/scripts/validate-docs-freshness.js +1 -0
  114. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  115. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  116. package/.agents/skills/skills.index.json +2 -2
  117. package/.agents/workflows/audit-baselines.md +289 -0
  118. package/.agents/workflows/audit-navigability.md +5 -4
  119. package/.agents/workflows/deliver.md +13 -4
  120. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  121. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  122. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  123. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  124. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  125. package/.agents/workflows/helpers/plan-reference.md +15 -5
  126. package/.agents/workflows/memory-consolidate.md +116 -0
  127. package/.agents/workflows/plan.md +3 -0
  128. package/README.md +13 -6
  129. package/docs/CHANGELOG.md +71 -0
  130. package/package.json +9 -4
  131. package/.agents/schemas/friction-event.schema.json +0 -56
  132. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -0,0 +1,63 @@
1
+ /**
2
+ * gate-surface.js — walk both halves of the baseline surface (Story #4902).
3
+ *
4
+ * The closed `delivery.quality.gates` kinds and the out-of-band ratchet
5
+ * baselines are one surface, and an engine that walks only the first silently
6
+ * drops the second. This module owns that walk and the file universe an
7
+ * `ignoreGlobs` entry is checked against; [`surface-entry.js`](surface-entry.js)
8
+ * turns each kind into its health report.
9
+ *
10
+ * @module lib/audit-baselines/gate-surface
11
+ */
12
+
13
+ import path from 'node:path';
14
+ import { ALL_KINDS, baselinePathFor, GATE_KINDS } from './kinds.js';
15
+ import { listFilesUnder, readJsonFile } from './read.js';
16
+ import { surfaceEntryFor } from './surface-entry.js';
17
+
18
+ /**
19
+ * Every `targetDirs` entry declared by any gate, deduplicated. This is the
20
+ * file universe an `ignoreGlobs` entry is checked against — a glob that
21
+ * matches nothing inside the dirs its own gate scans is dead weight.
22
+ *
23
+ * @param {object | null | undefined} quality
24
+ * @returns {string[]}
25
+ */
26
+ function declaredTargetDirs(quality) {
27
+ const dirs = new Set();
28
+ for (const kind of GATE_KINDS) {
29
+ for (const dir of quality?.gates?.[kind]?.targetDirs ?? []) {
30
+ if (typeof dir === 'string' && dir.length > 0) dirs.add(dir);
31
+ }
32
+ }
33
+ return [...dirs].sort();
34
+ }
35
+
36
+ /**
37
+ * Walk both halves of the gate surface — the closed `delivery.quality.gates`
38
+ * kinds and the out-of-band ratchet baselines — and report each instrument's
39
+ * health.
40
+ *
41
+ * @param {{ cwd: string, quality: object, now?: Date, run?: Function }} args
42
+ * `run` overrides the git spawn; `staleness.js` owns the real default.
43
+ * @returns {{ entries: object[], baselines: Map<string, object|null> }}
44
+ * `baselines` carries each parsed envelope forward so the hotspot, trend,
45
+ * and headroom sections never re-read a 650KB file off disk.
46
+ */
47
+ export function buildGateSurface({ cwd, quality, now = new Date(), run }) {
48
+ const files = declaredTargetDirs(quality).flatMap((dir) =>
49
+ listFilesUnder(cwd, dir),
50
+ );
51
+ const io = { cwd, run };
52
+ const entries = [];
53
+ const baselines = new Map();
54
+ for (const kind of ALL_KINDS) {
55
+ const relPath = baselinePathFor(kind, quality);
56
+ const read = readJsonFile(path.resolve(cwd, relPath));
57
+ entries.push(
58
+ surfaceEntryFor({ kind, quality, read, relPath, files, now, io }),
59
+ );
60
+ baselines.set(kind, read.parsed);
61
+ }
62
+ return { entries, baselines };
63
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * headroom.js — how much slack sits between each configured floor and the
3
+ * number actually measured (Story #4902).
4
+ *
5
+ * A floor set far from reality is a gate that cannot fail. Headroom is the
6
+ * distance the measurement could still drift before the gate notices, and it
7
+ * is the direct input to the only remediation this lens ever recommends:
8
+ * tighten the floor to what the repo already achieves.
9
+ *
10
+ * Both sides come from the repository, never from this module: the floor
11
+ * from `resolveQuality()` over `.agentrc.json` (so a consumer's own floors
12
+ * are the ones reported), and the measurement from the committed baseline's
13
+ * whole-repo rollup. Polarity comes from `axisDirection` in the gate's own
14
+ * floors phase, so "better" always means the same thing here as it does when
15
+ * `check-baselines.js` decides pass or fail.
16
+ *
17
+ * @module lib/audit-baselines/headroom
18
+ */
19
+
20
+ import { axisDirection } from '../orchestration/check-baselines/phases/floors.js';
21
+ import { rollupOf } from './kinds.js';
22
+
23
+ /**
24
+ * Signed distance from the floor, positive when the measurement sits on the
25
+ * good side of it. Null when the baseline carries no value for the axis.
26
+ *
27
+ * @param {number | undefined} measured
28
+ * @param {number} floor
29
+ * @param {'gte' | 'lte'} direction
30
+ * @returns {number | null}
31
+ */
32
+ function headroomFor(measured, floor, direction) {
33
+ if (typeof measured !== 'number') return null;
34
+ return direction === 'gte' ? measured - floor : floor - measured;
35
+ }
36
+
37
+ /**
38
+ * Build the `headroom[]` section across every configured gate kind.
39
+ *
40
+ * @param {{
41
+ * kinds: string[],
42
+ * quality: object,
43
+ * baselines: Map<string, object | null>,
44
+ * }} args
45
+ * @returns {Array<object>}
46
+ */
47
+ export function buildHeadroom({ kinds, quality, baselines }) {
48
+ const out = [];
49
+ for (const kind of kinds) {
50
+ const floors = quality?.gates?.[kind]?.floors?.['*'];
51
+ if (!floors || typeof floors !== 'object') continue;
52
+ const rollup = rollupOf(baselines.get(kind) ?? null);
53
+ for (const [axis, floor] of Object.entries(floors)) {
54
+ if (typeof floor !== 'number' || !Number.isFinite(floor)) continue;
55
+ const measured = rollup?.[axis];
56
+ const direction = axisDirection(kind, axis);
57
+ out.push({
58
+ kind,
59
+ axis,
60
+ floor,
61
+ measured: typeof measured === 'number' ? measured : null,
62
+ direction,
63
+ // Positive headroom = the measurement is on the good side of the
64
+ // floor by this much; negative = the floor is already breached.
65
+ headroom: headroomFor(measured, floor, direction),
66
+ });
67
+ }
68
+ }
69
+ return out.sort(
70
+ (a, b) => a.kind.localeCompare(b.kind) || a.axis.localeCompare(b.axis),
71
+ );
72
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * hotspots.js — join per-gate outliers into ranked, per-file clusters
3
+ * (Story #4902).
4
+ *
5
+ * The signal a baseline review is looking for is *convergence*: the file that
6
+ * is simultaneously a CRAP outlier, a maintainability outlier, and a
7
+ * duplication outlier is a different kind of problem from three unrelated
8
+ * files each bad at one thing. Reading the baselines one at a time cannot see
9
+ * it, because each gate's own top-20 is a different list.
10
+ *
11
+ * So severity **adds across gate memberships** and the three cost
12
+ * multipliers apply to the sum. Two gate memberships of severity s therefore
13
+ * outrank one membership of severity s at equal churn, centrality, and
14
+ * friction — which is the ranking property this section exists to provide.
15
+ *
16
+ * @module lib/audit-baselines/hotspots
17
+ */
18
+
19
+ /**
20
+ * Group per-gate outlier rows by cluster id and rank them.
21
+ *
22
+ * @param {{
23
+ * outliers: Array<object>,
24
+ * weightsFor: (id: string) => {
25
+ * churnWeight: number, centralityWeight: number, frictionWeight: number,
26
+ * },
27
+ * limit?: number,
28
+ * }} args
29
+ * @returns {Array<object>} highest rank first
30
+ */
31
+ export function buildHotspots({ outliers, weightsFor, limit = 50 }) {
32
+ const clusters = new Map();
33
+ for (const row of outliers) {
34
+ let cluster = clusters.get(row.id);
35
+ if (!cluster) {
36
+ cluster = { path: row.id, gates: [], severityWeight: 0 };
37
+ clusters.set(row.id, cluster);
38
+ }
39
+ cluster.gates.push({
40
+ kind: row.kind,
41
+ metric: row.metric,
42
+ value: row.value,
43
+ rowCount: row.rowCount,
44
+ severityWeight: row.severityWeight,
45
+ });
46
+ cluster.severityWeight += row.severityWeight;
47
+ }
48
+
49
+ const ranked = [];
50
+ for (const cluster of clusters.values()) {
51
+ const weights = weightsFor(cluster.path);
52
+ cluster.gates.sort((a, b) => a.kind.localeCompare(b.kind));
53
+ ranked.push({
54
+ path: cluster.path,
55
+ gates: cluster.gates,
56
+ gateKinds: cluster.gates.map((g) => g.kind),
57
+ gateCount: cluster.gates.length,
58
+ severityWeight: cluster.severityWeight,
59
+ ...weights,
60
+ rank:
61
+ cluster.severityWeight *
62
+ weights.churnWeight *
63
+ weights.centralityWeight *
64
+ weights.frictionWeight,
65
+ });
66
+ }
67
+ ranked.sort((a, b) => b.rank - a.rank || a.path.localeCompare(b.path));
68
+ return ranked.slice(0, Math.max(0, limit));
69
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * kinds.js — the two halves of the gate surface, and how to read a row out
3
+ * of each (Story #4902).
4
+ *
5
+ * Mandrel's baseline surface is not one list. `delivery.quality.gates` is a
6
+ * **closed** AJV schema of eight kinds, enforced by `check-baselines.js`.
7
+ * Alongside it sit out-of-band **ratchet** baselines — dead exports (two
8
+ * passes), import cycles, context budget — which no gate block declares and
9
+ * only the CI baselines job runs. An engine that walks one half and calls it
10
+ * "the baselines" silently drops the other; both halves are enumerated here.
11
+ *
12
+ * `GATE_KINDS` is derived from `GATES_SCHEMA` rather than re-typed, so a
13
+ * ninth gate kind landing in the schema reaches this engine automatically.
14
+ *
15
+ * @module lib/audit-baselines/kinds
16
+ */
17
+
18
+ import { GATES_SCHEMA } from '../config/gates/index.js';
19
+
20
+ /** The closed `delivery.quality.gates` kind set, in stable order. */
21
+ export const GATE_KINDS = Object.freeze(
22
+ Object.keys(GATES_SCHEMA.properties).sort(),
23
+ );
24
+
25
+ /**
26
+ * Out-of-band ratchet baselines: committed under `baselines/` and enforced
27
+ * only by the CI baselines job, never by `check-baselines.js`.
28
+ */
29
+ const RATCHET_KINDS = Object.freeze([
30
+ 'arch-cycles',
31
+ 'context-budget',
32
+ 'cyclomatic',
33
+ 'dead-exports',
34
+ 'dead-exports-production',
35
+ ]);
36
+
37
+ /** Every kind the engine walks, gates first then ratchets. */
38
+ export const ALL_KINDS = Object.freeze([...GATE_KINDS, ...RATCHET_KINDS]);
39
+
40
+ /**
41
+ * Resolve a kind's baseline path from config, falling back to the framework
42
+ * default layout. Never hardcodes a consumer's location: a repo that moved
43
+ * `baselines/crap.json` via `gates.crap.baselinePath` is followed.
44
+ *
45
+ * @param {string} kind
46
+ * @param {object | null | undefined} quality resolved `delivery.quality`
47
+ * @returns {string} repo-relative path
48
+ */
49
+ export function baselinePathFor(kind, quality) {
50
+ const configured = quality?.gates?.[kind]?.baselinePath;
51
+ if (typeof configured === 'string' && configured.length > 0) {
52
+ return configured;
53
+ }
54
+ return `baselines/${kind}.json`;
55
+ }
56
+
57
+ /**
58
+ * Flatten `context-budget.json` into `{ id, value }` rows. Its three
59
+ * sections all measure the same axis (bytes of context a file costs) under
60
+ * different keys, so they fold into one row set rather than three.
61
+ *
62
+ * @param {object} baseline
63
+ * @returns {Array<{ id: string, value: number }>}
64
+ */
65
+ function contextBudgetRows(baseline) {
66
+ const out = [];
67
+ for (const tier of Object.values(baseline?.tiers ?? {})) {
68
+ for (const f of tier?.files ?? []) out.push({ id: f.path, value: f.bytes });
69
+ }
70
+ for (const f of baseline?.agentBoot?.files ?? []) {
71
+ out.push({ id: f.path, value: f.bytes });
72
+ }
73
+ for (const e of baseline?.workflowClosure?.entryPoints ?? []) {
74
+ out.push({ id: e.path, value: e.reachableBytes });
75
+ }
76
+ return out.filter(
77
+ (r) => typeof r.id === 'string' && Number.isFinite(r.value),
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Count how many allowlisted cycles each module participates in. A module in
83
+ * three cycles is a worse architectural hotspot than one in a single cycle.
84
+ *
85
+ * @param {object} baseline
86
+ * @returns {Array<{ id: string, value: number }>}
87
+ */
88
+ function archCycleRows(baseline) {
89
+ const counts = new Map();
90
+ for (const cycle of baseline?.cycles ?? []) {
91
+ for (const member of new Set(cycle ?? [])) {
92
+ counts.set(member, (counts.get(member) ?? 0) + 1);
93
+ }
94
+ }
95
+ return [...counts].map(([id, value]) => ({ id, value }));
96
+ }
97
+
98
+ /**
99
+ * Count dead exports per file. The row grain is `{ file, symbol }`; the
100
+ * hotspot grain is the file.
101
+ *
102
+ * @param {object} baseline
103
+ * @returns {Array<{ id: string, value: number }>}
104
+ */
105
+ function deadExportRows(baseline) {
106
+ const counts = new Map();
107
+ for (const row of baseline?.rows ?? []) {
108
+ if (typeof row?.file !== 'string') continue;
109
+ counts.set(row.file, (counts.get(row.file) ?? 0) + 1);
110
+ }
111
+ return [...counts].map(([id, value]) => ({ id, value }));
112
+ }
113
+
114
+ /**
115
+ * Build the `{ id, value }` extractor for an envelope-shaped gate kind.
116
+ *
117
+ * @param {string} idKey row property carrying the cluster identity
118
+ * @param {string} metric row property carrying the measured value
119
+ * @returns {(baseline: object) => Array<{ id: string, value: number }>}
120
+ */
121
+ function envelopeRows(idKey, metric) {
122
+ return (baseline) =>
123
+ (baseline?.rows ?? [])
124
+ .map((row) => ({ id: row?.[idKey], value: row?.[metric] }))
125
+ .filter((r) => typeof r.id === 'string' && Number.isFinite(r.value));
126
+ }
127
+
128
+ /**
129
+ * Fold `{ id, value }` rows into one whole-repo number.
130
+ *
131
+ * `TOTAL` is for **additive** metrics — dead-export symbols, context bytes,
132
+ * lint errors — where the sum is the quantity the instrument measures.
133
+ * `TALLY` is for **non-additive** ones — percentages, indices, scores — where
134
+ * summing per-file values would fabricate a statistic; the honest whole-repo
135
+ * number is how many rows are tracked, and the unit name says so.
136
+ */
137
+ const TOTAL = (rows) => rows.reduce((sum, row) => sum + row.value, 0);
138
+ const TALLY = (rows) => rows.length;
139
+
140
+ /**
141
+ * `[unit, fold]` per kind: the unit each kind's whole-repo total is
142
+ * denominated in, and how its rows fold into it.
143
+ *
144
+ * This is the fix for the roll-up that counted **files** for every kind
145
+ * (Story #4962). `dead-exports-production` moving 590 → 589 *symbols* across
146
+ * 187 files on both sides read as a delta of 0, and a 421-byte context-budget
147
+ * growth read as 0 too, because the fallback counted rows — a grain finer than
148
+ * the file for dead exports and coarser than the byte for context budget.
149
+ * Naming the unit is half the fix: an axis called `symbols` or `bytes` cannot
150
+ * be re-read as a file count the way a bare `rowCount` was.
151
+ */
152
+ const TREND_UNITS = Object.freeze({
153
+ 'bundle-size': ['rawKb', TOTAL],
154
+ coverage: ['filesTracked', TALLY],
155
+ crap: ['filesTracked', TALLY],
156
+ duplication: ['filesTracked', TALLY],
157
+ lighthouse: ['routesTracked', TALLY],
158
+ lint: ['errorCount', TOTAL],
159
+ maintainability: ['filesTracked', TALLY],
160
+ mutation: ['filesTracked', TALLY],
161
+ 'arch-cycles': ['cycleMemberships', TOTAL],
162
+ 'context-budget': ['bytes', TOTAL],
163
+ cyclomatic: ['filesTracked', TALLY],
164
+ 'dead-exports': ['symbols', TOTAL],
165
+ 'dead-exports-production': ['symbols', TOTAL],
166
+ });
167
+
168
+ /**
169
+ * Per-kind row spec.
170
+ *
171
+ * - `metric` — the axis a hotspot is measured on.
172
+ * - `worse` — which end of that axis is the bad end.
173
+ * - `rows` — baseline → `{ id, value }` pairs, already aggregated where
174
+ * the on-disk grain is finer than the file (dead exports,
175
+ * cycles).
176
+ * - `idKind` — what the cluster key names. Every kind but `lighthouse`
177
+ * (routes) and `bundle-size` (bundle names) keys on a
178
+ * repository file path.
179
+ */
180
+ export const KIND_SPECS = Object.freeze({
181
+ 'bundle-size': {
182
+ metric: 'rawKb',
183
+ worse: 'higher',
184
+ idKind: 'bundle',
185
+ rows: envelopeRows('bundle', 'rawKb'),
186
+ },
187
+ coverage: {
188
+ metric: 'lines',
189
+ worse: 'lower',
190
+ idKind: 'path',
191
+ rows: envelopeRows('path', 'lines'),
192
+ },
193
+ crap: {
194
+ metric: 'crap',
195
+ worse: 'higher',
196
+ idKind: 'path',
197
+ rows: envelopeRows('path', 'crap'),
198
+ },
199
+ duplication: {
200
+ metric: 'percentage',
201
+ worse: 'higher',
202
+ idKind: 'path',
203
+ rows: envelopeRows('path', 'percentage'),
204
+ },
205
+ lighthouse: {
206
+ metric: 'performance',
207
+ worse: 'lower',
208
+ idKind: 'route',
209
+ rows: envelopeRows('route', 'performance'),
210
+ },
211
+ lint: {
212
+ metric: 'errorCount',
213
+ worse: 'higher',
214
+ idKind: 'path',
215
+ rows: envelopeRows('path', 'errorCount'),
216
+ },
217
+ maintainability: {
218
+ metric: 'mi',
219
+ worse: 'lower',
220
+ idKind: 'path',
221
+ rows: envelopeRows('path', 'mi'),
222
+ },
223
+ mutation: {
224
+ metric: 'score',
225
+ worse: 'lower',
226
+ idKind: 'path',
227
+ rows: envelopeRows('path', 'score'),
228
+ },
229
+ 'arch-cycles': {
230
+ metric: 'cycleMemberships',
231
+ worse: 'higher',
232
+ idKind: 'path',
233
+ rows: archCycleRows,
234
+ },
235
+ 'context-budget': {
236
+ metric: 'bytes',
237
+ worse: 'higher',
238
+ idKind: 'path',
239
+ rows: contextBudgetRows,
240
+ },
241
+ cyclomatic: {
242
+ metric: 'maxCyclomatic',
243
+ worse: 'higher',
244
+ idKind: 'path',
245
+ rows: envelopeRows('file', 'maxCyclomatic'),
246
+ },
247
+ 'dead-exports': {
248
+ metric: 'deadExports',
249
+ worse: 'higher',
250
+ idKind: 'path',
251
+ rows: deadExportRows,
252
+ },
253
+ 'dead-exports-production': {
254
+ metric: 'deadExports',
255
+ worse: 'higher',
256
+ idKind: 'path',
257
+ rows: deadExportRows,
258
+ },
259
+ });
260
+
261
+ /**
262
+ * The whole-repo rollup for a kind, or `null` when the kind does not carry
263
+ * one. Ratchet baselines have no rollup — which is why stub detection asks
264
+ * for an all-zero rollup rather than merely empty rows: an `arch-cycles`
265
+ * baseline with zero cycles is a passing gate, not a dead instrument.
266
+ *
267
+ * @param {object | null} baseline
268
+ * @returns {object | null}
269
+ */
270
+ export function rollupOf(baseline) {
271
+ const rollup = baseline?.rollup?.['*'];
272
+ return rollup && typeof rollup === 'object' ? rollup : null;
273
+ }
274
+
275
+ /**
276
+ * The whole-repo quantity a kind measures, in the unit it measures it in —
277
+ * `{ unit, value }`, or `null` when the kind or the baseline is unreadable.
278
+ *
279
+ * This sits beside `rowCount` in `gateSurface[]` precisely because the two
280
+ * disagree: `dead-exports-production` carries 589 rows across 187 files, so a
281
+ * lone `rowCount: 187` reads as a symbol count and is not one.
282
+ *
283
+ * @param {string} kind
284
+ * @param {object | null} baseline
285
+ * @returns {{ unit: string, value: number } | null}
286
+ */
287
+ export function measuredTotalOf(kind, baseline) {
288
+ const spec = KIND_SPECS[kind];
289
+ const denomination = TREND_UNITS[kind];
290
+ if (!baseline || !spec || !denomination) return null;
291
+ const [unit, fold] = denomination;
292
+ return { unit, value: fold(spec.rows(baseline)) };
293
+ }
294
+
295
+ /**
296
+ * The rollup a trend sample compares on. Gate kinds carry their own, already
297
+ * axis-named; ratchet baselines carry none, so the whole-repo total stands in
298
+ * under its own unit — "dead exports went 590 → 589 **symbols**" is exactly
299
+ * the direction-of-travel question trend answers, and skipping the ratchet
300
+ * half here would leave it visible in `gateSurface[]` but invisible in
301
+ * `trend[]`.
302
+ *
303
+ * @param {string} kind
304
+ * @param {object | null} baseline
305
+ * @returns {object | null}
306
+ */
307
+ export function trendRollupOf(kind, baseline) {
308
+ if (!baseline) return null;
309
+ const declared = rollupOf(baseline);
310
+ if (declared) return declared;
311
+ const measured = measuredTotalOf(kind, baseline);
312
+ return measured ? { [measured.unit]: measured.value } : null;
313
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * outliers.js — bounded top-N outlier extraction per gate (Story #4902).
3
+ *
4
+ * `baselines/crap.json` alone is ~650KB of per-method rows. The engine must
5
+ * never embed a whole baseline in its envelope, so every kind is narrowed to
6
+ * at most `topN` rows here, before anything downstream sees them.
7
+ *
8
+ * Narrowing happens in two steps:
9
+ *
10
+ * 1. **Aggregate to the cluster grain.** Baseline rows are per-method
11
+ * (crap) or per-symbol (dead exports); hotspots are per-file. Each id
12
+ * keeps its single worst value, plus how many rows it contributed.
13
+ * 2. **Score, then cut.** `severityWeight` is the row's position in its
14
+ * own kind's distribution, from 0 (the best value present) to 1 (the
15
+ * worst). Scoring within the kind is what makes CRAP 29 and MI 74
16
+ * comparable at all — the two axes share no unit, and the whole point
17
+ * of a cluster is to add them up.
18
+ *
19
+ * @module lib/audit-baselines/outliers
20
+ */
21
+
22
+ import { KIND_SPECS } from './kinds.js';
23
+
24
+ /** Default bound on rows extracted per gate. */
25
+ export const DEFAULT_TOP_N = 20;
26
+
27
+ /**
28
+ * Fold `{ id, value }` rows to one entry per id, keeping the worst value.
29
+ *
30
+ * @param {Array<{ id: string, value: number }>} rows
31
+ * @param {'higher' | 'lower'} worse
32
+ * @returns {Array<{ id: string, value: number, rowCount: number }>}
33
+ */
34
+ function aggregateById(rows, worse) {
35
+ const byId = new Map();
36
+ for (const { id, value } of rows) {
37
+ const prev = byId.get(id);
38
+ if (prev === undefined) {
39
+ byId.set(id, { id, value, rowCount: 1 });
40
+ continue;
41
+ }
42
+ prev.rowCount += 1;
43
+ const isWorse =
44
+ worse === 'higher' ? value > prev.value : value < prev.value;
45
+ if (isWorse) prev.value = value;
46
+ }
47
+ return [...byId.values()];
48
+ }
49
+
50
+ /**
51
+ * Position of `value` in `[min, max]` normalized so 1 is always the worst
52
+ * end. A degenerate distribution (every value identical) scores 1 for every
53
+ * row: they are all equally the worst, and equally the best.
54
+ *
55
+ * @param {number} value
56
+ * @param {number} min
57
+ * @param {number} max
58
+ * @param {'higher' | 'lower'} worse
59
+ * @returns {number} 0..1
60
+ */
61
+ function normalizeSeverity(value, min, max, worse) {
62
+ if (!(max > min)) return 1;
63
+ const ratio = (value - min) / (max - min);
64
+ return worse === 'higher' ? ratio : 1 - ratio;
65
+ }
66
+
67
+ /**
68
+ * Extract the bounded worst-N rows for one kind.
69
+ *
70
+ * @param {{ kind: string, baseline: object | null, topN?: number }} args
71
+ * @returns {Array<{
72
+ * kind: string, id: string, metric: string, value: number,
73
+ * rowCount: number, severityWeight: number,
74
+ * }>} worst first
75
+ */
76
+ export function extractOutliers({ kind, baseline, topN = DEFAULT_TOP_N }) {
77
+ const spec = KIND_SPECS[kind];
78
+ if (!spec || !baseline) return [];
79
+ const aggregated = aggregateById(spec.rows(baseline), spec.worse);
80
+ if (aggregated.length === 0) return [];
81
+ let min = Number.POSITIVE_INFINITY;
82
+ let max = Number.NEGATIVE_INFINITY;
83
+ for (const row of aggregated) {
84
+ if (row.value < min) min = row.value;
85
+ if (row.value > max) max = row.value;
86
+ }
87
+ return aggregated
88
+ .map((row) => ({
89
+ kind,
90
+ id: row.id,
91
+ metric: spec.metric,
92
+ value: row.value,
93
+ rowCount: row.rowCount,
94
+ severityWeight: normalizeSeverity(row.value, min, max, spec.worse),
95
+ }))
96
+ .sort(
97
+ (a, b) => b.severityWeight - a.severityWeight || a.id.localeCompare(b.id),
98
+ )
99
+ .slice(0, Math.max(0, topN));
100
+ }