mandrel 2.15.0 → 2.17.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 (69) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/docs/quality-gates.md +137 -0
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/agentrc.schema.json +6 -0
  5. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  6. package/.agents/schemas/baselines/crap.schema.json +4 -0
  7. package/.agents/scripts/acceptance-eval.js +52 -12
  8. package/.agents/scripts/audit-to-stories.js +92 -25
  9. package/.agents/scripts/boot-sweep.js +28 -6
  10. package/.agents/scripts/check-baseline-drift.js +138 -0
  11. package/.agents/scripts/coverage-capture.js +74 -25
  12. package/.agents/scripts/deliver-light.js +31 -3
  13. package/.agents/scripts/deliver-recover.js +45 -18
  14. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  15. package/.agents/scripts/generate-lens-checklists.js +81 -30
  16. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  17. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  18. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  19. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  20. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  21. package/.agents/scripts/lib/baselines/reader.js +12 -1
  22. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  23. package/.agents/scripts/lib/baselines/writer.js +10 -0
  24. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  25. package/.agents/scripts/lib/cli-utils.js +48 -13
  26. package/.agents/scripts/lib/close-validation/process.js +61 -15
  27. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  28. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  29. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  30. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  31. package/.agents/scripts/lib/config/quality.js +40 -0
  32. package/.agents/scripts/lib/coverage-utils.js +92 -9
  33. package/.agents/scripts/lib/crap-engine.js +113 -23
  34. package/.agents/scripts/lib/crap-utils.js +159 -93
  35. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  36. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  37. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  38. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  39. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  40. package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
  41. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  42. package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
  43. package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +38 -15
  47. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  48. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  49. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  50. package/.agents/scripts/lib/stdio-flush.js +71 -0
  51. package/.agents/scripts/lib/transpile.js +133 -6
  52. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  53. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  54. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  55. package/.agents/scripts/nav-registry-diff.js +30 -8
  56. package/.agents/scripts/plan-context.js +4 -1
  57. package/.agents/scripts/plan-run-epilogue.js +27 -11
  58. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  59. package/.agents/scripts/single-story-close.js +9 -92
  60. package/.agents/scripts/update-crap-baseline.js +13 -0
  61. package/.agents/workflows/helpers/deliver-light.md +34 -8
  62. package/.agents/workflows/helpers/plan-reference.md +27 -6
  63. package/.agents/workflows/plan.md +4 -2
  64. package/.agents/workflows/prototype.md +104 -0
  65. package/README.md +14 -6
  66. package/docs/CHANGELOG.md +41 -0
  67. package/lib/cli/version-helpers.js +7 -0
  68. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  69. package/package.json +5 -1
@@ -0,0 +1,184 @@
1
+ // .agents/scripts/lib/close-validation/projections/advisories.js
2
+ /**
3
+ * advisories.js — the projection layer's single call site (Story #4776).
4
+ *
5
+ * `projections/maintainability.js` shipped fully written, fully unit-tested
6
+ * and imported by nothing: the v2 Epic-tier collapse removed its caller and
7
+ * left the module behind. The practical consequence was that the advisory
8
+ * telling an operator to run `npm run maintainability:update` and commit a
9
+ * `baseline-refresh:` subject had never fired in v2 — consumers refreshed
10
+ * their baselines by hand or not at all.
11
+ *
12
+ * This module is that caller, for both projections. It is deliberately the
13
+ * only door: `close-validation/runner.js` invokes `runProjectionAdvisories`
14
+ * once, after the gate chain has passed, and every per-kind concern (gate
15
+ * enablement, baseline path resolution, scorer construction, formatting)
16
+ * lives here rather than being re-derived at the runner boundary.
17
+ *
18
+ * **Advisory, always.** Nothing in here can fail a close. `check-baselines`
19
+ * already fails closed on a real regression; the projections add the refresh
20
+ * half of the loop, not a second gate. Every projection is wrapped so a
21
+ * throw becomes a logged skip.
22
+ */
23
+
24
+ import path from 'node:path';
25
+ import { getQuality } from '../../config/quality.js';
26
+ import {
27
+ createCrapScorer,
28
+ formatCrapProjection,
29
+ projectCrapBreaches,
30
+ } from './crap.js';
31
+ import {
32
+ formatMaintainabilityProjection,
33
+ projectMaintainabilityRegressions,
34
+ } from './maintainability.js';
35
+
36
+ /** Default on-disk baseline locations, mirroring the per-gate defaults. */
37
+ const DEFAULT_BASELINE_PATHS = Object.freeze({
38
+ maintainability: 'baselines/maintainability.json',
39
+ crap: 'baselines/crap.json',
40
+ });
41
+
42
+ /**
43
+ * Resolve a gate's baseline file to an absolute path.
44
+ *
45
+ * @param {string} kind
46
+ * @param {object} gate resolved `delivery.quality.gates.<kind>` block
47
+ * @param {string} cwd
48
+ * @returns {string}
49
+ */
50
+ function resolveBaselinePath(kind, gate, cwd) {
51
+ const rel =
52
+ typeof gate?.baselinePath === 'string' && gate.baselinePath.length > 0
53
+ ? gate.baselinePath
54
+ : DEFAULT_BASELINE_PATHS[kind];
55
+ return path.isAbsolute(rel) ? rel : path.resolve(cwd, rel);
56
+ }
57
+
58
+ /**
59
+ * A gate is projected unless it is explicitly disabled. An absent gate
60
+ * block means "framework defaults", which enable it — the same reading
61
+ * `buildDefaultGates` applies.
62
+ *
63
+ * @param {object|undefined} gate
64
+ * @returns {boolean}
65
+ */
66
+ function isEnabled(gate) {
67
+ return gate?.enabled !== false;
68
+ }
69
+
70
+ /**
71
+ * Run one projection with its formatter, swallowing every failure into a
72
+ * logged skip. Returns the projection result (or `null` when it threw) so
73
+ * callers and tests can inspect what happened without parsing log lines.
74
+ *
75
+ * @param {{ kind: string, log: (m: string) => void, run: () => Promise<object>|object, format: (r: object) => string|null }} opts
76
+ * @returns {Promise<object|null>}
77
+ */
78
+ async function runOne({ kind, log, run, format }) {
79
+ let result;
80
+ try {
81
+ result = await run();
82
+ } catch (err) {
83
+ log(
84
+ `[close-validation] ⚠ ${kind} projection skipped (errored): ${err?.message ?? err}`,
85
+ );
86
+ return null;
87
+ }
88
+ if (result?.skipped) {
89
+ log(
90
+ `[close-validation] ⏭ ${kind} projection skipped (${result.skipped}${
91
+ result.detail ? `: ${result.detail}` : ''
92
+ })`,
93
+ );
94
+ return result;
95
+ }
96
+ const advisory = format(result);
97
+ if (advisory) log(advisory);
98
+ return result;
99
+ }
100
+
101
+ /**
102
+ * Run the maintainability and CRAP pre-merge projections and log their
103
+ * advisories. Never throws; never affects the close verdict.
104
+ *
105
+ * @param {{
106
+ * cwd: string,
107
+ * baseBranch: string,
108
+ * storyBranch: string,
109
+ * config?: object,
110
+ * quality?: object,
111
+ * log?: (m: string) => void,
112
+ * projectMaintainability?: typeof projectMaintainabilityRegressions,
113
+ * formatMaintainability?: typeof formatMaintainabilityProjection,
114
+ * projectCrap?: typeof projectCrapBreaches,
115
+ * formatCrap?: typeof formatCrapProjection,
116
+ * }} opts
117
+ * @returns {Promise<{ maintainability: object|null, crap: object|null }>}
118
+ */
119
+ export async function runProjectionAdvisories({
120
+ cwd,
121
+ baseBranch,
122
+ storyBranch,
123
+ config,
124
+ quality,
125
+ log = () => {},
126
+ projectMaintainability = projectMaintainabilityRegressions,
127
+ formatMaintainability = formatMaintainabilityProjection,
128
+ projectCrap = projectCrapBreaches,
129
+ formatCrap = formatCrapProjection,
130
+ } = {}) {
131
+ const out = { maintainability: null, crap: null };
132
+ let gates;
133
+ try {
134
+ gates = quality ?? getQuality(config) ?? {};
135
+ } catch {
136
+ gates = {};
137
+ }
138
+
139
+ const miGate = gates.maintainability;
140
+ if (isEnabled(miGate)) {
141
+ out.maintainability = await runOne({
142
+ kind: 'maintainability',
143
+ log,
144
+ format: formatMaintainability,
145
+ run: () =>
146
+ projectMaintainability({
147
+ cwd,
148
+ baseBranch,
149
+ storyBranch,
150
+ baselinePath: resolveBaselinePath('maintainability', miGate, cwd),
151
+ }),
152
+ });
153
+ } else {
154
+ log('[close-validation] ⏭ maintainability projection skipped (disabled)');
155
+ }
156
+
157
+ const crapGate = gates.crap;
158
+ if (isEnabled(crapGate)) {
159
+ out.crap = await runOne({
160
+ kind: 'crap',
161
+ log,
162
+ format: formatCrap,
163
+ run: () =>
164
+ projectCrap({
165
+ cwd,
166
+ baseBranch,
167
+ storyBranch,
168
+ baselinePath: resolveBaselinePath('crap', crapGate, cwd),
169
+ newMethodCeiling: crapGate?.newMethodCeiling,
170
+ scoreFiles: createCrapScorer({
171
+ cwd,
172
+ targetDirs: crapGate?.targetDirs,
173
+ ignoreGlobs: crapGate?.ignoreGlobs,
174
+ requireCoverage: crapGate?.requireCoverage,
175
+ coveragePath: crapGate?.coveragePath,
176
+ }),
177
+ }),
178
+ });
179
+ } else {
180
+ log('[close-validation] ⏭ crap projection skipped (disabled)');
181
+ }
182
+
183
+ return out;
184
+ }
@@ -0,0 +1,303 @@
1
+ // .agents/scripts/lib/close-validation/projections/crap.js
2
+ /**
3
+ * crap.js — pre-merge CRAP ceiling projection helper (Story #4776).
4
+ *
5
+ * The CRAP analogue of `projections/maintainability.js`. Both answer the
6
+ * same operator question before the merge runs: *given what this branch
7
+ * changed, which committed baseline rows would the post-merge tree breach,
8
+ * and what is the exact remedy?*
9
+ *
10
+ * Advisory only. `check-baselines` already fails close-validation closed on
11
+ * a real regression; duplicating that here would double-gate the same
12
+ * defect. What this projection adds is the **refresh** half of the loop —
13
+ * naming the breaching methods and the `npm run crap:update` +
14
+ * `baseline-refresh:` remedy while the operator still has the branch in
15
+ * hand, so a corrected baseline does not rot back into staleness.
16
+ *
17
+ * The helper never throws and never mutates anything. Every failure path
18
+ * resolves to `{ ok: true, breaches: [], skipped: '<reason>' }` so the
19
+ * caller can treat the advisory as best-effort.
20
+ *
21
+ * Post-merge approximation: CRAP needs a coverage join, so — unlike the MI
22
+ * projection, which scores a `git show` blob — the scorer reads the working
23
+ * tree. Close-validation runs inside the Story worktree at the branch tip,
24
+ * which is exactly the content a squash-merge lands, so the approximation is
25
+ * exact whenever the merge applies cleanly.
26
+ */
27
+
28
+ import path from 'node:path';
29
+ import {
30
+ compareCrap,
31
+ filterRowsByFileScope,
32
+ } from '../../baselines/kinds/crap.js';
33
+ import { loadFile as loadBaselineFile } from '../../baselines/reader.js';
34
+ import { diffNameOnly } from '../../changed-files.js';
35
+ import { loadCoverage } from '../../coverage-utils.js';
36
+ import { scanAndScore } from '../../crap-utils.js';
37
+ import { cachedGitFetchSync } from '../../git/cached-fetch.js';
38
+ import { gitSpawn as defaultGitSpawn } from '../../git-utils.js';
39
+ import { MISSING_ARG_REASONS, validateProjectionInputs } from './inputs.js';
40
+
41
+ /**
42
+ * Default absolute tolerance on a projected CRAP score, shared with
43
+ * `check-crap`'s regression arm: floating-point noise must not register as
44
+ * a breach.
45
+ */
46
+ export const DEFAULT_CRAP_TOLERANCE = 0.001;
47
+
48
+ /** Framework default for the new-method ceiling (`gates.crap.newMethodCeiling`). */
49
+ export const DEFAULT_NEW_METHOD_CEILING = 30;
50
+
51
+ /** Extensions the CRAP scanner can score. */
52
+ const SCORABLE = /\.(?:js|mjs|cjs|ts|tsx)$/;
53
+
54
+ /**
55
+ * Map the shared predicate's fine-grained `missing-*` reason onto the
56
+ * `missing-args` skipped-reason the sibling MI projection reports, so both
57
+ * projections speak one vocabulary at the advisory boundary.
58
+ *
59
+ * @param {string} reason
60
+ * @returns {string}
61
+ */
62
+ function normaliseSkipReason(reason) {
63
+ return MISSING_ARG_REASONS.has(reason) ? 'missing-args' : reason;
64
+ }
65
+
66
+ /**
67
+ * Read the committed CRAP baseline and re-key its rows onto the `file`
68
+ * field `compareCrap` matches on (the on-disk v2 envelope keys on `path`).
69
+ * Returns `[]` when the baseline is absent or unreadable — the caller maps
70
+ * that to the `no-baseline` skip.
71
+ *
72
+ * Deliberately module-local: `projectCrapBreaches`'s `loadBaseline` default
73
+ * is the single production door to it, and tests inject their own loader.
74
+ * Exporting it would add a second entry point nothing in production reaches
75
+ * — which is precisely the orphaning this Story exists to stop.
76
+ *
77
+ * @param {string} baselinePath absolute path to `baselines/crap.json`
78
+ * @returns {Array<{file: string, method: string, startLine: number, crap: number}>}
79
+ */
80
+ function loadCrapBaselineRows(baselinePath) {
81
+ let envelope;
82
+ try {
83
+ envelope = loadBaselineFile(baselinePath, { kind: 'crap' });
84
+ } catch {
85
+ return [];
86
+ }
87
+ const rows = Array.isArray(envelope?.rows) ? envelope.rows : [];
88
+ return rows.map((row) => ({
89
+ file: row.path,
90
+ method: row.method,
91
+ startLine: row.startLine,
92
+ crap: row.crap,
93
+ }));
94
+ }
95
+
96
+ /**
97
+ * Build the default working-tree CRAP scorer. Returns an async callable
98
+ * `(files) => rows | null`; `null` signals "coverage artifact missing under
99
+ * `requireCoverage`", which the projection reports as the `no-coverage`
100
+ * skip rather than as a clean run.
101
+ *
102
+ * @param {{
103
+ * cwd: string,
104
+ * targetDirs?: string[],
105
+ * ignoreGlobs?: string[],
106
+ * requireCoverage?: boolean,
107
+ * coveragePath?: string,
108
+ * }} opts
109
+ * @returns {(files: string[]) => Promise<Array<object>|null>}
110
+ */
111
+ export function createCrapScorer({
112
+ cwd,
113
+ targetDirs = [],
114
+ ignoreGlobs = [],
115
+ requireCoverage = true,
116
+ coveragePath = 'coverage/coverage-final.json',
117
+ } = {}) {
118
+ return async (files) => {
119
+ const abs = path.isAbsolute(coveragePath)
120
+ ? coveragePath
121
+ : path.resolve(cwd, coveragePath);
122
+ const coverage = loadCoverage(abs);
123
+ if (!coverage && requireCoverage) return null;
124
+ const { rows } = await scanAndScore({
125
+ targetDirs,
126
+ coverage,
127
+ requireCoverage,
128
+ cwd,
129
+ ignoreGlobs,
130
+ scopeFiles: files,
131
+ });
132
+ return rows ?? [];
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Refresh `origin/<baseBranch>` so the diff range resolves even when close
138
+ * has not reached its own base-sync step. Routed through the shared fetch
139
+ * cache so a story-init fetch in the same run satisfies it for free.
140
+ *
141
+ * @param {string} cwd
142
+ * @param {string} baseBranch
143
+ * @param {{ gitSpawn: typeof defaultGitSpawn }} git
144
+ * @returns {{ ok: true } | { ok: false, detail: string }}
145
+ */
146
+ function refreshBaseRef(cwd, baseBranch, git) {
147
+ const res = cachedGitFetchSync(cwd, baseBranch, { gitSpawn: git.gitSpawn });
148
+ if (res.status !== 0) {
149
+ return {
150
+ ok: false,
151
+ detail: res.stderr || res.stdout || `exit ${res.status}`,
152
+ };
153
+ }
154
+ return { ok: true };
155
+ }
156
+
157
+ /**
158
+ * Enumerate the Story branch's changed files, narrowed to the extensions
159
+ * the CRAP scanner can score.
160
+ *
161
+ * @param {{ cwd: string, baseBranch: string, storyBranch: string, git: { gitSpawn: typeof defaultGitSpawn } }} opts
162
+ * @returns {{ ok: true, files: string[] } | { ok: false, detail: string }}
163
+ */
164
+ function diffScorableFiles({ cwd, baseBranch, storyBranch, git }) {
165
+ try {
166
+ const files = diffNameOnly({
167
+ range: `origin/${baseBranch}...${storyBranch}`,
168
+ cwd,
169
+ gitSpawn: git.gitSpawn,
170
+ });
171
+ return { ok: true, files: files.filter((f) => SCORABLE.test(f)) };
172
+ } catch (err) {
173
+ return { ok: false, detail: err.message };
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Project the post-merge CRAP scores for every file changed on the Story
179
+ * branch and return the subset of methods that would breach either their
180
+ * committed baseline row or, for methods with no baseline row, the
181
+ * configured `newMethodCeiling`.
182
+ *
183
+ * Baseline rows are narrowed to the changed-file set before the compare so
184
+ * every untouched file's rows are not spuriously reported as removed.
185
+ *
186
+ * @param {{
187
+ * cwd: string,
188
+ * baseBranch: string,
189
+ * storyBranch: string,
190
+ * baselinePath: string,
191
+ * newMethodCeiling?: number,
192
+ * tolerance?: number,
193
+ * git?: { gitSpawn: typeof defaultGitSpawn },
194
+ * loadBaseline?: (path: string) => Array<object>,
195
+ * scoreFiles?: (files: string[]) => Promise<Array<object>|null>|Array<object>|null,
196
+ * }} opts
197
+ * @returns {Promise<{
198
+ * ok: boolean,
199
+ * breaches: Array<object>,
200
+ * skipped?: string,
201
+ * detail?: string,
202
+ * }>}
203
+ */
204
+ export async function projectCrapBreaches({
205
+ cwd,
206
+ baseBranch,
207
+ storyBranch,
208
+ baselinePath,
209
+ newMethodCeiling = DEFAULT_NEW_METHOD_CEILING,
210
+ tolerance = DEFAULT_CRAP_TOLERANCE,
211
+ git = { gitSpawn: defaultGitSpawn },
212
+ loadBaseline = loadCrapBaselineRows,
213
+ scoreFiles,
214
+ } = {}) {
215
+ const skip = (reason, detail) => ({
216
+ ok: true,
217
+ breaches: [],
218
+ skipped: reason,
219
+ ...(detail === undefined ? {} : { detail }),
220
+ });
221
+
222
+ const validation = validateProjectionInputs({
223
+ cwd,
224
+ baseBranch,
225
+ storyBranch,
226
+ baselinePath,
227
+ });
228
+ if (!validation.ok) return skip(normaliseSkipReason(validation.reason));
229
+
230
+ const baselineRows = loadBaseline(baselinePath);
231
+ if (!Array.isArray(baselineRows) || baselineRows.length === 0) {
232
+ return skip('no-baseline');
233
+ }
234
+
235
+ const fetched = refreshBaseRef(cwd, baseBranch, git);
236
+ if (!fetched.ok) return skip('fetch-failed', fetched.detail);
237
+
238
+ const diffed = diffScorableFiles({ cwd, baseBranch, storyBranch, git });
239
+ if (!diffed.ok) return skip('diff-failed', diffed.detail);
240
+ if (diffed.files.length === 0) return skip('no-scorable-files');
241
+
242
+ const scorer =
243
+ typeof scoreFiles === 'function' ? scoreFiles : createCrapScorer({ cwd });
244
+ let currentRows;
245
+ try {
246
+ currentRows = await scorer(diffed.files);
247
+ } catch (err) {
248
+ return skip('score-failed', err?.message ?? String(err));
249
+ }
250
+ if (currentRows === null || currentRows === undefined) {
251
+ return skip('no-coverage');
252
+ }
253
+ if (currentRows.length === 0) return skip('no-scored-methods');
254
+
255
+ const scopeSet = new Set(diffed.files);
256
+ const result = compareCrap({
257
+ currentRows,
258
+ baselineRows: filterRowsByFileScope(baselineRows, scopeSet),
259
+ newMethodCeiling,
260
+ tolerance,
261
+ });
262
+ const breaches = result.violations ?? [];
263
+ return { ok: breaches.length === 0, breaches };
264
+ }
265
+
266
+ /**
267
+ * Render one breach as an advisory bullet. New-method breaches name the
268
+ * ceiling they cleared; regressions name the baseline row they exceeded.
269
+ *
270
+ * @param {object} b
271
+ * @returns {string}
272
+ */
273
+ function formatBreach(b) {
274
+ const where = `${b.file}::${b.method} (line ${b.startLine})`;
275
+ const projected = Number(b.crap ?? 0).toFixed(2);
276
+ if (b.kind === 'new') {
277
+ return ` • ${where} projected=${projected} ceiling=${b.ceiling} [new method]`;
278
+ }
279
+ const baseline = Number(b.baseline ?? 0).toFixed(2);
280
+ return ` • ${where} projected=${projected} baseline=${baseline} [${b.kind}]`;
281
+ }
282
+
283
+ /**
284
+ * Render the pre-merge CRAP advisory as a human-readable multi-line log
285
+ * block, naming every breaching method and the exact refresh remedy.
286
+ * Returns `null` when there is nothing to surface so callers can `if` past
287
+ * the log call without a string-empty check.
288
+ *
289
+ * @param {Awaited<ReturnType<typeof projectCrapBreaches>>} result
290
+ * @returns {string | null}
291
+ */
292
+ export function formatCrapProjection(result) {
293
+ if (!result || !Array.isArray(result.breaches)) return null;
294
+ if (result.breaches.length === 0) return null;
295
+ const lines = [
296
+ `[close-validation] ⚠ Pre-merge CRAP projection: ${result.breaches.length} method(s) would breach post-merge:`,
297
+ ];
298
+ for (const b of result.breaches) lines.push(formatBreach(b));
299
+ lines.push(
300
+ '[close-validation] To land cleanly, run `npm run crap:update` and commit the refreshed baseline with a `baseline-refresh:` tagged subject (non-empty body) on the story branch before re-running close.',
301
+ );
302
+ return lines.join('\n');
303
+ }
@@ -19,6 +19,7 @@ import {
19
19
  } from './commands.js';
20
20
  import { DEFAULT_GATES, partitionGates } from './gates.js';
21
21
  import { defaultGateRunner } from './process.js';
22
+ import { runProjectionAdvisories as defaultRunProjections } from './projections/advisories.js';
22
23
  import { defaultGetHeadSha } from './projections/head-sha.js';
23
24
 
24
25
  /** @typedef {import('./gates.js').Gate} Gate */
@@ -90,10 +91,23 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
90
91
  * story-close uses it to drive `phaseTimer.mark(...)` for per-gate
91
92
  * wall-clock telemetry. Errors thrown from the hook propagate.
92
93
  *
94
+ * Projection advisories (Story #4776): when `baseBranch` and `storyBranch`
95
+ * are both supplied and every gate passed, the maintainability and CRAP
96
+ * pre-merge projections run through `projections/advisories.js` and log
97
+ * their advisories to the same `log` sink the gates use. They are advisory
98
+ * by construction — the returned `ok` is decided entirely by the gates, so
99
+ * a projected breach never fails a close. They are skipped after a gate
100
+ * failure, where the operator needs the failing gate's evidence, not a
101
+ * baseline-refresh nudge.
102
+ *
93
103
  * @param {{
94
104
  * cwd: string,
95
105
  * worktreePath?: string,
96
106
  * gates?: Gate[],
107
+ * baseBranch?: string|null,
108
+ * storyBranch?: string|null,
109
+ * config?: object|null,
110
+ * runProjections?: typeof defaultRunProjections,
97
111
  * runner?: (cmd: string, args: string[], opts: { cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void }) => Promise<{ status: number }> | { status: number },
98
112
  * log?: (m: string) => void,
99
113
  * onGateStart?: (gate: Gate) => void,
@@ -114,6 +128,10 @@ export async function runCloseValidation({
114
128
  runner = defaultGateRunner,
115
129
  log = () => {},
116
130
  onGateStart,
131
+ baseBranch = null,
132
+ storyBranch = null,
133
+ config = null,
134
+ runProjections = defaultRunProjections,
117
135
  storyId = null,
118
136
  standalone = false,
119
137
  useEvidence = true,
@@ -346,5 +364,55 @@ export async function runCloseValidation({
346
364
  );
347
365
  }
348
366
 
367
+ // ── Phase 3: advisory projections ───────────────────────────────────
368
+ // Story #4776 — the projection layer's live call site. Deliberately
369
+ // outside the `ok` computation: a projected breach informs, it never
370
+ // fails a close.
371
+ if (failed.length === 0) {
372
+ await runAdvisoryProjections({
373
+ runProjections,
374
+ cwd: spawnCwd,
375
+ baseBranch,
376
+ storyBranch,
377
+ config,
378
+ log,
379
+ });
380
+ }
381
+
349
382
  return { ok: failed.length === 0, failed, skipped };
350
383
  }
384
+
385
+ /**
386
+ * Phase 3 helper — run the advisory projections, absorbing every failure.
387
+ *
388
+ * No-ops without a branch pair to diff (resume / legacy callers), and can
389
+ * never influence the close verdict: the caller has already decided `ok`
390
+ * before this runs, and a throw here is logged, not propagated.
391
+ *
392
+ * @param {{
393
+ * runProjections: typeof defaultRunProjections,
394
+ * cwd: string,
395
+ * baseBranch: string|null,
396
+ * storyBranch: string|null,
397
+ * config: object|null,
398
+ * log: (m: string) => void,
399
+ * }} opts
400
+ * @returns {Promise<void>}
401
+ */
402
+ async function runAdvisoryProjections({
403
+ runProjections,
404
+ cwd,
405
+ baseBranch,
406
+ storyBranch,
407
+ config,
408
+ log,
409
+ }) {
410
+ if (!(baseBranch && storyBranch)) return;
411
+ try {
412
+ await runProjections({ cwd, baseBranch, storyBranch, config, log });
413
+ } catch (err) {
414
+ log(
415
+ `[close-validation] ⚠ projection advisories skipped: ${err?.message ?? err}`,
416
+ );
417
+ }
418
+ }
@@ -13,6 +13,13 @@ export const CRAP_GATE = {
13
13
  targetDirs: LIST_OR_EXTENDER_OF_STRINGS,
14
14
  newMethodCeiling: { type: 'integer', minimum: 1 },
15
15
  requireCoverage: { type: 'boolean' },
16
+ // Story #4775 — fail-closed floor on the per-method coverage JOIN: the
17
+ // fraction of methods that must resolve a coverage entry, counted only
18
+ // over files that HAVE one, before `update-crap-baseline.js` will
19
+ // persist. A broken join is silent by construction (unresolved methods
20
+ // are simply absent), so the updater refuses rather than writing a thin
21
+ // baseline and logging it as success. Default 0.75.
22
+ minMethodResolutionRate: { type: 'number', minimum: 0, maximum: 1 },
16
23
  friction: {
17
24
  type: 'object',
18
25
  properties: { markerKey: { type: 'string', minLength: 1 } },
@@ -88,6 +88,15 @@ export const CRAP_GATE_DEFAULTS = Object.freeze({
88
88
  // semantics.
89
89
  refreshTimeoutMs: 60_000,
90
90
  ignoreGlobs: Object.freeze([]),
91
+ // Story #4775 — fail-closed floor on the per-method coverage JOIN. The
92
+ // fraction of methods that must resolve a coverage entry, counted only over
93
+ // files that HAVE one, before `update-crap-baseline.js` will persist. A
94
+ // broken join is silent by construction (unresolved methods are simply
95
+ // absent from the baseline), so the updater refuses rather than writing a
96
+ // thin baseline and logging it as success. 0.75 sits far above a healthy
97
+ // run (a repo with fresh coverage resolves ~98%) and far below the 4–6%
98
+ // signature of a coordinate-system mismatch.
99
+ minMethodResolutionRate: 0.75,
91
100
  });
92
101
 
93
102
  /** Framework defaults for the coverage gate. */
@@ -121,6 +130,15 @@ export const MAINTAINABILITY_GATE_DEFAULTS = Object.freeze({
121
130
  // spawned by the baseline-attribution refresh path. Defaults to 60 s.
122
131
  refreshTimeoutMs: 60_000,
123
132
  ignoreGlobs: Object.freeze([]),
133
+ // Story #4775 — fail-closed floor on the per-method coverage JOIN. The
134
+ // fraction of methods that must resolve a coverage entry, counted only over
135
+ // files that HAVE one, before `update-crap-baseline.js` will persist. A
136
+ // broken join is silent by construction (unresolved methods are simply
137
+ // absent from the baseline), so the updater refuses rather than writing a
138
+ // thin baseline and logging it as success. 0.75 sits far above a healthy
139
+ // run (a repo with fresh coverage resolves ~98%) and far below the 4–6%
140
+ // signature of a coordinate-system mismatch.
141
+ minMethodResolutionRate: 0.75,
124
142
  });
125
143
 
126
144
  /**
@@ -144,6 +162,7 @@ const CRAP_GATE_KEYS = new Set([
144
162
  'refreshTag',
145
163
  'refreshTimeoutMs',
146
164
  'ignoreGlobs',
165
+ 'minMethodResolutionRate',
147
166
  ]);
148
167
 
149
168
  const COVERAGE_GATE_KEYS = new Set([
@@ -205,6 +224,22 @@ function warnUnknownKeys(userBlock, knownKeys, blockLabel) {
205
224
  * @param {{ coveragePath: string }} coverageGate resolved coverage gate
206
225
  * @returns {object} flattened legacy-bag view that existing callers read
207
226
  */
227
+ /**
228
+ * Clamp a user-supplied method-resolution floor into `[0, 1]`. A
229
+ * non-numeric, non-finite, or out-of-range value falls back to the framework
230
+ * default rather than silently disabling the guard (a floor of `NaN` would
231
+ * compare false against every rate and never fire).
232
+ *
233
+ * @param {unknown} value
234
+ * @param {number} fallback
235
+ * @returns {number}
236
+ */
237
+ function resolveResolutionRate(value, fallback) {
238
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
239
+ if (value < 0 || value > 1) return fallback;
240
+ return value;
241
+ }
242
+
208
243
  export function resolveMaintainabilityCrap(
209
244
  userCrap,
210
245
  gateScoping,
@@ -227,6 +262,7 @@ export function resolveMaintainabilityCrap(
227
262
  DEFAULT_CRAP_TOLERANCE.value,
228
263
  ),
229
264
  requireCoverage: defaults.requireCoverage,
265
+ minMethodResolutionRate: defaults.minMethodResolutionRate,
230
266
  friction: { ...defaults.friction },
231
267
  refreshTag: defaults.refreshTag,
232
268
  refreshTimeoutMs: defaults.refreshTimeoutMs,
@@ -248,6 +284,10 @@ export function resolveMaintainabilityCrap(
248
284
  toleranceScalar(defaults.tolerance, DEFAULT_CRAP_TOLERANCE.value),
249
285
  ),
250
286
  requireCoverage: userCrap.requireCoverage ?? defaults.requireCoverage,
287
+ minMethodResolutionRate: resolveResolutionRate(
288
+ userCrap.minMethodResolutionRate,
289
+ defaults.minMethodResolutionRate,
290
+ ),
251
291
  friction: { ...defaults.friction, ...(userCrap.friction ?? {}) },
252
292
  refreshTag: userCrap.refreshTag ?? defaults.refreshTag,
253
293
  refreshTimeoutMs: resolvePositiveIntegerMs(