mandrel 2.16.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 (57) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/docs/quality-gates.md +137 -0
  3. package/.agents/schemas/agentrc.schema.json +6 -0
  4. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  5. package/.agents/schemas/baselines/crap.schema.json +4 -0
  6. package/.agents/scripts/acceptance-eval.js +52 -12
  7. package/.agents/scripts/audit-to-stories.js +92 -25
  8. package/.agents/scripts/boot-sweep.js +28 -6
  9. package/.agents/scripts/check-baseline-drift.js +138 -0
  10. package/.agents/scripts/coverage-capture.js +74 -25
  11. package/.agents/scripts/deliver-recover.js +45 -18
  12. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  13. package/.agents/scripts/generate-lens-checklists.js +81 -30
  14. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  15. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  16. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  17. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  18. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  19. package/.agents/scripts/lib/baselines/reader.js +12 -1
  20. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  21. package/.agents/scripts/lib/baselines/writer.js +10 -0
  22. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  23. package/.agents/scripts/lib/cli-utils.js +48 -13
  24. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  25. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  26. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  27. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  28. package/.agents/scripts/lib/config/quality.js +40 -0
  29. package/.agents/scripts/lib/coverage-utils.js +92 -9
  30. package/.agents/scripts/lib/crap-engine.js +113 -23
  31. package/.agents/scripts/lib/crap-utils.js +159 -93
  32. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  33. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  34. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  35. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  36. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  37. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  38. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +14 -0
  40. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  41. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  42. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  43. package/.agents/scripts/lib/stdio-flush.js +71 -0
  44. package/.agents/scripts/lib/transpile.js +133 -6
  45. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  46. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  47. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  48. package/.agents/scripts/nav-registry-diff.js +30 -8
  49. package/.agents/scripts/plan-run-epilogue.js +27 -11
  50. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  51. package/.agents/scripts/single-story-close.js +9 -92
  52. package/.agents/scripts/update-crap-baseline.js +13 -0
  53. package/README.md +14 -6
  54. package/docs/CHANGELOG.md +24 -0
  55. package/lib/cli/version-helpers.js +7 -0
  56. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  57. package/package.json +5 -1
@@ -133,6 +133,7 @@ function resolveGeneratedAt(explicit) {
133
133
  * rows: Array<object>,
134
134
  * kernelVersion: string,
135
135
  * generatedAt?: string,
136
+ * extras?: Record<string, unknown>,
136
137
  * }} params
137
138
  * @returns {{
138
139
  * $schema: string,
@@ -148,6 +149,7 @@ export function buildEnvelope({
148
149
  rows,
149
150
  kernelVersion,
150
151
  generatedAt,
152
+ extras,
151
153
  } = {}) {
152
154
  if (typeof kind !== 'string' || !KNOWN_KINDS.includes(kind)) {
153
155
  throw new TypeError(
@@ -176,10 +178,15 @@ export function buildEnvelope({
176
178
  throw new TypeError('envelope.buildEnvelope: rows must be an array');
177
179
  }
178
180
 
181
+ // Per-kind envelope-level stamps (Story #4775). A kind whose SCORING
182
+ // SEMANTICS can change independently of its kernel version contributes them
183
+ // here; `assertEnvelope` still validates the result against the kind's
184
+ // schema, so an unrecognised extra fails closed rather than being persisted.
179
185
  return {
180
186
  $schema: schemaRefFor(kind),
181
187
  kernelVersion,
182
188
  generatedAt: resolveGeneratedAt(generatedAt),
189
+ ...(extras && typeof extras === 'object' ? extras : {}),
183
190
  rollup,
184
191
  rows,
185
192
  };
@@ -50,7 +50,9 @@ import {
50
50
  } from './kinds/coverage.js';
51
51
  import {
52
52
  applyEpsilon as crapApplyEpsilon,
53
+ assertBaselineCompatible as crapAssertBaselineCompatible,
53
54
  compare as crapCompare,
55
+ envelopeExtras as crapEnvelopeExtras,
54
56
  kernelVersion as crapKernelVersion,
55
57
  keyField as crapKeyField,
56
58
  mergeRows as crapMergeRows,
@@ -139,6 +141,12 @@ function bindKindModule(members) {
139
141
  compare: members.compare,
140
142
  applyEpsilon: members.applyEpsilon,
141
143
  mergeRows: members.mergeRows,
144
+ // Optional per-kind hooks (Story #4775). `envelopeExtras` contributes
145
+ // envelope-level stamps the shared writer would not otherwise know about;
146
+ // `assertBaselineCompatible` lets a kind refuse a loaded baseline whose
147
+ // scoring semantics predate the running scorer.
148
+ envelopeExtras: members.envelopeExtras,
149
+ assertBaselineCompatible: members.assertBaselineCompatible,
142
150
  });
143
151
  }
144
152
 
@@ -179,6 +187,8 @@ const KIND_MODULES = Object.freeze({
179
187
  compare: crapCompare,
180
188
  applyEpsilon: crapApplyEpsilon,
181
189
  mergeRows: crapMergeRows,
190
+ envelopeExtras: crapEnvelopeExtras,
191
+ assertBaselineCompatible: crapAssertBaselineCompatible,
182
192
  }),
183
193
  maintainability: bindKindModule({
184
194
  name: maintainabilityName,
@@ -270,6 +280,27 @@ export function currentKernelVersion(kind) {
270
280
  return getKindModule(kind).kernelVersion();
271
281
  }
272
282
 
283
+ /**
284
+ * Ask a kind whether a loaded baseline is compatible with the running
285
+ * scorer's SEMANTICS — a dimension `kernelVersion` cannot express, because a
286
+ * kind's scoring can change while the upstream package it stamps does not
287
+ * (Story #4775). Kinds without the hook always answer "compatible".
288
+ *
289
+ * @param {string} kind
290
+ * @param {object|null} baseline
291
+ * @returns {string|null} Operator-facing message, or null when compatible.
292
+ */
293
+ export function checkBaselineSemantics(kind, baseline) {
294
+ let mod;
295
+ try {
296
+ mod = getKindModule(kind);
297
+ } catch {
298
+ return null;
299
+ }
300
+ if (typeof mod.assertBaselineCompatible !== 'function') return null;
301
+ return mod.assertBaselineCompatible(baseline);
302
+ }
303
+
273
304
  /**
274
305
  * Compare a baseline's stamped version against the currently running
275
306
  * kernel for the same kind. Returns `{ match, current }` so callers can
@@ -75,6 +75,41 @@ export function kernelVersion() {
75
75
  return '0.0.0';
76
76
  }
77
77
 
78
+ /**
79
+ * Scoring-semantics stamp (Story #4775, fix part 5).
80
+ *
81
+ * `kernelVersion()` above tracks the `typhonjs-escomplex` package and
82
+ * `escomplexVersion` tracks the same dependency — so a change in how THIS
83
+ * repo joins escomplex methods to istanbul coverage moves neither. Rows
84
+ * scored by the pre-#4775 join (exact transpiled-line equality, methods
85
+ * dropped when unresolved) are not comparable to rows scored by the join
86
+ * that replaced it (original-source coordinates, containment matching,
87
+ * honest `requireCoverage: false`): the same method can carry a different
88
+ * `crap`, a different `startLine`, or exist in one baseline and not the
89
+ * other. Comparing across that boundary produces phantom regressions and,
90
+ * worse, phantom passes.
91
+ *
92
+ * The stamp makes the boundary explicit and fails closed. Bump it whenever
93
+ * the coverage join, the line coordinate system, or the unresolved-method
94
+ * policy changes.
95
+ *
96
+ * Deliberately module-local: `envelopeExtras()` is the single production door
97
+ * to this value, so exporting the bare constant would add a second entry
98
+ * point that nothing in production reaches. Callers and tests that need the
99
+ * string read it off `envelopeExtras().scoringSemantics`.
100
+ */
101
+ const SCORING_SEMANTICS = 'coverage-join-v2';
102
+
103
+ /**
104
+ * Envelope-level stamps this kind contributes beyond the shared envelope
105
+ * keys. Consumed by `writer.write` via the kind-module protocol.
106
+ *
107
+ * @returns {{scoringSemantics: string}}
108
+ */
109
+ export function envelopeExtras() {
110
+ return { scoringSemantics: SCORING_SEMANTICS };
111
+ }
112
+
78
113
  export function projectRow(row) {
79
114
  return {
80
115
  path: canonicalise(row.path ?? row.file),
@@ -372,6 +407,23 @@ export const CRAP_COMPAT_AXES = [
372
407
  : null,
373
408
  },
374
409
  kernelDriftAxis('CRAP'),
410
+ {
411
+ name: 'scoring-semantics-drift',
412
+ severity: 'fatal',
413
+ check: ({ baseline }) => {
414
+ if (!baseline) return null;
415
+ const stamped = baseline.scoringSemantics ?? null;
416
+ if (stamped === SCORING_SEMANTICS) return null;
417
+ return (
418
+ `[CRAP] scoring semantics changed: baseline=${stamped ?? '<unstamped>'} ` +
419
+ `running=${SCORING_SEMANTICS}. Rows scored by the previous per-method ` +
420
+ 'coverage join are not comparable to rows scored by the current one, ' +
421
+ 'so this baseline cannot be compared — it must be re-derived. Run ' +
422
+ "'npm run test:coverage' then 'npm run crap:update -- --full-scope' " +
423
+ "and commit the result with a 'baseline-refresh:' subject."
424
+ );
425
+ },
426
+ },
375
427
  {
376
428
  name: 'ts-transpiler-drift',
377
429
  severity: 'warn',
@@ -401,6 +453,30 @@ export function evaluateBaselineCompatibility(ctx) {
401
453
  return reduceCompatAxes(CRAP_COMPAT_AXES, ctx);
402
454
  }
403
455
 
456
+ /**
457
+ * Kind-module hook (Story #4775): the subset of the compat table that a
458
+ * *loaded* v2 envelope can be judged against on its own, with no running
459
+ * dependency versions to compare. The unified `check-baselines` gate calls it
460
+ * straight after `reader.load` and turns a message into a fail-closed
461
+ * schema-class error, so a baseline written by the previous scoring semantics
462
+ * can never be silently compared against new-semantics scores.
463
+ *
464
+ * The version-drift axes stay out: the v2 envelope does not carry
465
+ * `escomplexVersion` / `tsTranspilerVersion`, and running those checks against
466
+ * an absent field would compare `undefined` to `undefined` and pass
467
+ * vacuously — worse than not running them.
468
+ *
469
+ * @param {object|null} baseline A loaded v2 baseline envelope.
470
+ * @returns {string|null} Operator-facing message, or null when compatible.
471
+ */
472
+ export function assertBaselineCompatible(baseline) {
473
+ if (!baseline) return null;
474
+ const axis = CRAP_COMPAT_AXES.find(
475
+ (a) => a.name === 'scoring-semantics-drift',
476
+ );
477
+ return axis ? axis.check({ baseline }) : null;
478
+ }
479
+
404
480
  /**
405
481
  * Pure helper: resolve the CRAP baseline either from the working tree
406
482
  * (via `getCrapBaseline`) or, when `epicRef` is supplied, from
@@ -18,7 +18,8 @@
18
18
  // hand-edited while inside a story worktree — so downstream
19
19
  // consumers see canonical repo-relative paths.
20
20
  // 5. Returns the envelope's headline fields plus rows/rollup as a
21
- // narrow contract: `{ rollup, rows, kernelVersion, generatedAt }`.
21
+ // narrow contract: `{ rollup, rows, kernelVersion, generatedAt,
22
+ // scoringSemantics }`.
22
23
  //
23
24
  // Reader-only: the writer side lives in a sibling module (Story #1891).
24
25
  // No I/O happens here beyond reading the JSON file itself.
@@ -215,6 +216,11 @@ function readAndShape(kind, absolutePath) {
215
216
  rows,
216
217
  kernelVersion: parsed.kernelVersion,
217
218
  generatedAt: parsed.generatedAt,
219
+ // Story #4775 — carry the per-kind scoring-semantics stamp through the
220
+ // narrowing. The gate's compat check reads it off the LOADED envelope, so
221
+ // dropping it here would make every baseline look unstamped and fail the
222
+ // whole repo closed on a stamp that is actually present on disk.
223
+ scoringSemantics: parsed.scoringSemantics,
218
224
  };
219
225
  }
220
226
 
@@ -303,6 +309,11 @@ export function loadFile(absolutePath, opts = {}) {
303
309
  rows,
304
310
  kernelVersion: parsed.kernelVersion,
305
311
  generatedAt: parsed.generatedAt,
312
+ // Story #4775 — carry the per-kind scoring-semantics stamp through the
313
+ // narrowing. The gate's compat check reads it off the LOADED envelope, so
314
+ // dropping it here would make every baseline look unstamped and fail the
315
+ // whole repo closed on a stamp that is actually present on disk.
316
+ scoringSemantics: parsed.scoringSemantics,
306
317
  };
307
318
  }
308
319
 
@@ -355,11 +355,17 @@ const KIND_SCORER_BUILDERS = Object.freeze({
355
355
  * rather than crashing the refresh. The production crap/maintainability paths
356
356
  * never rely on this fallback — they inject an explicit, configured scorer.
357
357
  *
358
+ * Exported since Story #4776 so the full-scope drift detector
359
+ * (`check-baseline-drift.js`) re-scores through the *same* scorer that
360
+ * writes the baseline. A drift check scoring by a second, parallel
361
+ * implementation would report the two implementations' disagreement as
362
+ * drift, which is exactly the false signal it exists to rule out.
363
+ *
358
364
  * @param {string} kind
359
365
  * @param {{ cwd: string }} opts
360
366
  * @returns {((files: string[], opts: object) => Promise<object[]> | object[]) | undefined}
361
367
  */
362
- function resolveDefaultScorer(kind, { cwd } = {}) {
368
+ export function resolveDefaultScorer(kind, { cwd } = {}) {
363
369
  const builder = KIND_SCORER_BUILDERS[kind];
364
370
  if (typeof builder !== 'function') return undefined;
365
371
  const effectiveCwd = cwd ?? process.cwd();
@@ -184,6 +184,8 @@ export function write({
184
184
  rollup,
185
185
  kernelVersion: kernelVersion ?? currentKernelVersion(kind),
186
186
  generatedAt,
187
+ extras:
188
+ typeof mod.envelopeExtras === 'function' ? mod.envelopeExtras() : null,
187
189
  });
188
190
  assertEnvelope(envelope);
189
191
  return envelope;
@@ -258,10 +260,18 @@ export function writeFile(absPath, envelope, opts = {}) {
258
260
  // Canonical key order on the top-level envelope keeps diffs stable
259
261
  // across runs and platforms. Per-kind row keys retain their natural
260
262
  // declaration order; the row sort is done by `sortRows()`.
263
+ //
264
+ // Story #4775: the projection is deliberately explicit, so any per-kind
265
+ // envelope stamp (`scoringSemantics`) must be carried through by name or it
266
+ // is silently dropped on the way to disk — the stamp would then be present
267
+ // in memory, validated, and absent from the file it exists to protect.
261
268
  const canonical = {
262
269
  $schema: envelope.$schema,
263
270
  kernelVersion: envelope.kernelVersion,
264
271
  generatedAt: envelope.generatedAt,
272
+ ...(envelope.scoringSemantics === undefined
273
+ ? {}
274
+ : { scoringSemantics: envelope.scoringSemantics }),
265
275
  rollup: envelope.rollup,
266
276
  rows: envelope.rows,
267
277
  };
@@ -26,7 +26,7 @@
26
26
  * fan-out target, or `&` shell backgrounding) within a small line window.
27
27
  */
28
28
 
29
- import { readdirSync, readFileSync } from 'node:fs';
29
+ import nodeFs from 'node:fs';
30
30
  import path from 'node:path';
31
31
 
32
32
  const SCAN_ROOT_DEFAULT = '.agents';
@@ -54,14 +54,20 @@ const BACKGROUND_TOKENS = [
54
54
  * and `.md` sources. Skips `node_modules`, `.worktrees`, and directories
55
55
  * starting with `.git`.
56
56
  *
57
+ * The optional final `fsImpl` parameter defaults to the real `node:fs`
58
+ * (`.agents/rules/test-seams.md` rule 1) and is forwarded to the recursive
59
+ * call rather than re-acquired there (rule 4), so a test drives the whole walk
60
+ * through a plain stub object instead of module mocking (rule 5).
61
+ *
57
62
  * @param {string} dir
63
+ * @param {typeof nodeFs} [fsImpl]
58
64
  * @returns {string[]}
59
65
  */
60
- function walkSources(dir) {
66
+ export function walkSources(dir, fsImpl = nodeFs) {
61
67
  const out = [];
62
68
  let entries;
63
69
  try {
64
- entries = readdirSync(dir, { withFileTypes: true });
70
+ entries = fsImpl.readdirSync(dir, { withFileTypes: true });
65
71
  } catch {
66
72
  return out;
67
73
  }
@@ -75,7 +81,7 @@ function walkSources(dir) {
75
81
  ) {
76
82
  continue;
77
83
  }
78
- out.push(...walkSources(full));
84
+ out.push(...walkSources(full, fsImpl));
79
85
  continue;
80
86
  }
81
87
  if (!entry.isFile()) continue;
@@ -94,11 +100,14 @@ function walkSources(dir) {
94
100
  * any dedicated check module (this file): the source-of-truth
95
101
  * implementation legitimately mentions itself.
96
102
  *
103
+ * Pure: it is handed the already-read source, so it needs no filesystem seam
104
+ * of its own.
105
+ *
97
106
  * @param {string} file
98
107
  * @param {string} src
99
108
  * @returns {Array<{ line: number, kind: string }>}
100
109
  */
101
- function scanFile(file, src) {
110
+ export function scanFile(file, src) {
102
111
  const offences = [];
103
112
  // Don't flag the actual story-init script, self-references, or the
104
113
  // parallel-tooling helper — the helper documents both Rule 2
@@ -149,15 +158,21 @@ export default {
149
158
  scope: ['story-close', 'retro'],
150
159
  autoCorrect: 'refuse-and-print',
151
160
 
152
- detect(state) {
161
+ /**
162
+ * @param {{ cwd?: string, scanRoot?: string, scope?: string }} [state]
163
+ * @param {typeof nodeFs} [fsImpl] Optional final filesystem seam; defaults
164
+ * to the real `node:fs` (`.agents/rules/test-seams.md` rule 1) and is
165
+ * forwarded to {@link walkSources} rather than re-acquired (rule 4).
166
+ */
167
+ detect(state, fsImpl = nodeFs) {
153
168
  const cwd = state?.cwd ?? process.cwd();
154
169
  const root = state?.scanRoot ?? path.join(cwd, SCAN_ROOT_DEFAULT);
155
- const files = walkSources(root);
170
+ const files = walkSources(root, fsImpl);
156
171
  const offences = [];
157
172
  for (const file of files) {
158
173
  let src;
159
174
  try {
160
- src = readFileSync(file, 'utf8');
175
+ src = fsImpl.readFileSync(file, 'utf8');
161
176
  } catch {
162
177
  continue;
163
178
  }
@@ -18,6 +18,7 @@ import path from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { respondToHelp } from './cli-usage.js';
20
20
  import { formatCliError } from './error-redactor.js';
21
+ import { flushStdio } from './stdio-flush.js';
21
22
 
22
23
  /**
23
24
  * Is the current module being executed directly as a CLI (as opposed to
@@ -32,11 +33,48 @@ export function isDirectInvocation(importMetaUrl) {
32
33
  return fileURLToPath(importMetaUrl) === path.resolve(entry);
33
34
  }
34
35
 
36
+ /**
37
+ * Settle one CLI run: await `main`, translate its outcome into an exit code,
38
+ * then flush stdio.
39
+ *
40
+ * **The exit is never eager** (Story #4783). This helper assigns
41
+ * `process.exitCode` and returns, letting Node terminate once the event loop
42
+ * is empty — which is *after* the pending stdout writes drain. The previous
43
+ * `process.exit()` call terminated first, discarding anything still queued
44
+ * behind a full pipe buffer, so any CLI emitting more than 64 KiB into a pipe
45
+ * truncated silently while still reporting success. The resulting exit code is
46
+ * identical for every caller: `code ?? 0` on the `propagateExitCode` path,
47
+ * `options.exitCode` (default 1) on the fatal-error path, and an untouched 0
48
+ * everywhere else.
49
+ *
50
+ * @param {() => Promise<unknown>} main
51
+ * @param {{ source: string, exitCode: number, onError?: (err: Error) => void,
52
+ * propagateExitCode: boolean, errorPrefix?: string }} settings
53
+ * @returns {Promise<void>}
54
+ */
55
+ async function settleCli(main, settings) {
56
+ const { source, exitCode, onError, propagateExitCode, errorPrefix } =
57
+ settings;
58
+ try {
59
+ const code = await main();
60
+ if (propagateExitCode) process.exitCode = code ?? 0;
61
+ } catch (err) {
62
+ if (typeof onError === 'function') {
63
+ onError(err);
64
+ } else {
65
+ const prefix = errorPrefix ?? `[${source}] Fatal error`;
66
+ console.error(`${prefix}: ${formatCliError(err)}`);
67
+ process.exitCode = exitCode;
68
+ }
69
+ }
70
+ await flushStdio();
71
+ }
72
+
35
73
  /**
36
74
  * Run `main` as the CLI entry point for the caller's module. No-op when the
37
75
  * module is imported rather than invoked directly. Promise rejection from
38
76
  * `main` is funnelled through either the caller-supplied `onError` callback
39
- * or the default handler (prefixed stderr line + `process.exit(exitCode)`).
77
+ * or the default handler (prefixed stderr line + `process.exitCode`).
40
78
  *
41
79
  * A `usage` option makes the script self-describing: when the argv carries
42
80
  * `--help` / `-h`, the rendered usage block goes to stdout and `main` is
@@ -51,6 +89,9 @@ export function isDirectInvocation(importMetaUrl) {
51
89
  * @param {string} [options.source='CLI'] Prefix used in the default error message.
52
90
  * @param {number} [options.exitCode=1] Exit code used by the default error handler.
53
91
  * @param {(err: Error) => void} [options.onError] Full override of the error handler.
92
+ * @param {boolean} [options.propagateExitCode=false] Adopt `main`'s resolved
93
+ * value as the process exit code.
94
+ * @param {string} [options.errorPrefix] Overrides the `[source] Fatal error` prefix.
54
95
  * @param {object|string} [options.usage] Usage spec (or pre-rendered
55
96
  * text) printed for `--help`; see `lib/cli-usage.js`.
56
97
  */
@@ -65,17 +106,11 @@ export function runAsCli(importMetaUrl, main, options = {}) {
65
106
  usage,
66
107
  } = options;
67
108
  if (usage && respondToHelp(process.argv.slice(2), usage)) return;
68
- const promise = main();
69
- if (propagateExitCode) {
70
- promise.then((code) => process.exit(code ?? 0));
71
- }
72
- promise.catch((err) => {
73
- if (typeof onError === 'function') {
74
- onError(err);
75
- return;
76
- }
77
- const prefix = errorPrefix ?? `[${source}] Fatal error`;
78
- console.error(`${prefix}: ${formatCliError(err)}`);
79
- process.exit(exitCode);
109
+ void settleCli(main, {
110
+ source,
111
+ exitCode,
112
+ onError,
113
+ propagateExitCode,
114
+ errorPrefix,
80
115
  });
81
116
  }
@@ -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
+ }