mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -0,0 +1,177 @@
1
+ /**
2
+ * engine.js — assemble the `/audit-baselines` evidence envelope
3
+ * (Story #4902).
4
+ *
5
+ * Strictly read-only and strictly offline: it reads committed baselines, the
6
+ * resolved config, git history, the static import graph, and any friction
7
+ * ledger it finds. It never writes under `baselines/`, never refreshes a
8
+ * baseline, and never runs a test, coverage, or mutation suite — the whole
9
+ * point is that a baseline review costs a file read, not a CI run.
10
+ *
11
+ * Findings are evidence, not a verdict: assembling the envelope is success,
12
+ * however alarming its contents, so the CLI exits 0 whenever it got this far.
13
+ * Judgment belongs to the lens that reads the envelope.
14
+ *
15
+ * @module lib/audit-baselines/engine
16
+ */
17
+
18
+ import path from 'node:path';
19
+ import { mainCheckoutRoot, tempRootFrom } from '../config/temp-paths.js';
20
+ import { getQuality, resolveConfig } from '../config-resolver.js';
21
+ import { buildGateSurface } from './gate-surface.js';
22
+ import { buildHeadroom } from './headroom.js';
23
+ import { buildHotspots } from './hotspots.js';
24
+ import { ALL_KINDS, baselinePathFor, GATE_KINDS } from './kinds.js';
25
+ import { DEFAULT_TOP_N, extractOutliers } from './outliers.js';
26
+ import { buildTrend } from './trend.js';
27
+ import {
28
+ makeWeightResolver,
29
+ readCentrality,
30
+ readChurn,
31
+ readFriction,
32
+ } from './weights.js';
33
+
34
+ /** Envelope `kind` discriminator; matches the shipped schema's const. */
35
+ const ENVELOPE_KIND = 'audit-baselines-envelope';
36
+
37
+ /** Envelope schema version — bumped on any breaking shape change. */
38
+ const ENVELOPE_SCHEMA_VERSION = '1';
39
+
40
+ /** Cap on emitted hotspot clusters, independent of the per-gate `topN`. */
41
+ export const DEFAULT_HOTSPOT_LIMIT = 50;
42
+
43
+ /**
44
+ * Resolve the repository config without letting a broken `.agentrc.json`
45
+ * abort the run — an unreadable config still leaves the baseline files
46
+ * themselves readable at their default paths.
47
+ *
48
+ * @param {string} cwd
49
+ * @returns {{ quality: object, configError: string | null }}
50
+ */
51
+ function resolveQualityBlock(cwd) {
52
+ try {
53
+ return { quality: getQuality(resolveConfig({ cwd })), configError: null };
54
+ } catch (err) {
55
+ return { quality: { gates: {} }, configError: err?.message ?? String(err) };
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Absolute temp root the friction ledger is searched under, anchored to the
61
+ * **analysed** repository rather than the process cwd. `resolvedTempRoot()`
62
+ * anchors to whichever checkout the current process sits in, which is the
63
+ * right answer for a writer and the wrong one here: an engine pointed at
64
+ * another repo with `--cwd` must read that repo's ledger, not this one's.
65
+ *
66
+ * @param {string} cwd
67
+ * @returns {string}
68
+ */
69
+ function tempRootFor(cwd) {
70
+ let relative = 'temp';
71
+ try {
72
+ relative = tempRootFrom(resolveConfig({ cwd }));
73
+ } catch {
74
+ // Unreadable config — the framework default root is still worth probing.
75
+ }
76
+ if (path.isAbsolute(relative)) return relative;
77
+ return path.join(mainCheckoutRoot(cwd) ?? cwd, relative);
78
+ }
79
+
80
+ /**
81
+ * Run the engine and return the envelope object. Pure with respect to the
82
+ * filesystem apart from the reads named in the module docstring — writing
83
+ * the result is the caller's job.
84
+ *
85
+ * @param {{
86
+ * cwd: string,
87
+ * topN?: number,
88
+ * hotspotLimit?: number,
89
+ * trendDepth?: number,
90
+ * now?: Date,
91
+ * }} args
92
+ * @returns {object} the `audit-baselines-envelope`
93
+ */
94
+ export function runEngine({
95
+ cwd,
96
+ topN = DEFAULT_TOP_N,
97
+ hotspotLimit = DEFAULT_HOTSPOT_LIMIT,
98
+ trendDepth = 5,
99
+ now = new Date(),
100
+ }) {
101
+ const { quality, configError } = resolveQualityBlock(cwd);
102
+ const { entries, baselines } = buildGateSurface({ cwd, quality, now });
103
+
104
+ const outliers = ALL_KINDS.flatMap((kind) =>
105
+ extractOutliers({ kind, baseline: baselines.get(kind) ?? null, topN }),
106
+ );
107
+
108
+ const churn = readChurn({ cwd });
109
+ const centrality = readCentrality({ cwd });
110
+ const friction = readFriction({ tempRootAbs: tempRootFor(cwd) });
111
+
112
+ return {
113
+ kind: ENVELOPE_KIND,
114
+ schemaVersion: ENVELOPE_SCHEMA_VERSION,
115
+ generatedAt: now.toISOString(),
116
+ cwd,
117
+ topN,
118
+ configError,
119
+ degradations: {
120
+ gitHistory: churn.degraded,
121
+ importGraph: centrality.degraded,
122
+ frictionLedger: friction.degraded,
123
+ },
124
+ gateSurface: entries,
125
+ hotspots: buildHotspots({
126
+ outliers,
127
+ weightsFor: makeWeightResolver({ churn, centrality, friction }),
128
+ limit: hotspotLimit,
129
+ }),
130
+ trend: buildTrend({
131
+ cwd,
132
+ kinds: ALL_KINDS,
133
+ pathFor: (kind) => baselinePathFor(kind, quality),
134
+ depth: trendDepth,
135
+ }),
136
+ headroom: buildHeadroom({ kinds: GATE_KINDS, quality, baselines }),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Condense an envelope into the pure-JSON stdout summary. Small enough to
142
+ * read in a terminal, and never carrying a row set.
143
+ *
144
+ * @param {object} envelope
145
+ * @param {string} outPath absolute path the full envelope was written to
146
+ * @returns {object}
147
+ */
148
+ export function summarize(envelope, outPath) {
149
+ return {
150
+ kind: 'audit-baselines-summary',
151
+ schemaVersion: ENVELOPE_SCHEMA_VERSION,
152
+ out: outPath,
153
+ generatedAt: envelope.generatedAt,
154
+ gateSurface: {
155
+ total: envelope.gateSurface.length,
156
+ configured: envelope.gateSurface.filter((g) => g.configured).length,
157
+ missingBaseline: envelope.gateSurface
158
+ .filter((g) => !g.baselineExists)
159
+ .map((g) => g.kind),
160
+ stubs: envelope.gateSurface.filter((g) => g.stub).map((g) => g.kind),
161
+ deadIgnoreGlobs: envelope.gateSurface.reduce(
162
+ (n, g) => n + g.deadIgnoreGlobs.length,
163
+ 0,
164
+ ),
165
+ },
166
+ hotspots: {
167
+ total: envelope.hotspots.length,
168
+ multiGate: envelope.hotspots.filter((h) => h.gateCount > 1).length,
169
+ top: envelope.hotspots
170
+ .slice(0, 5)
171
+ .map((h) => ({ path: h.path, gates: h.gateKinds, rank: h.rank })),
172
+ },
173
+ trend: { kinds: envelope.trend.length },
174
+ headroom: { axes: envelope.headroom.length },
175
+ degradations: envelope.degradations,
176
+ };
177
+ }
@@ -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
+ }