mandrel 2.25.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 (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -41,10 +41,20 @@
41
41
  * tier along with the per-AC-cluster `--epic <id> --cluster <id>` mode that
42
42
  * scored an Epic `## Acceptance Table` against a `main..epic/<id>` diff.)
43
43
  *
44
+ * One gate call per round (Story #4951). A round may fan out into N parallel
45
+ * maker-blind cluster critics, but their per-cluster verdicts are merged by
46
+ * the caller into ONE verdict — `criteria[]` in acceptance-array order — and
47
+ * scored here exactly once. Invoking the gate per cluster instead would burn
48
+ * one Story-level round per cluster (distinct fingerprints defeat the replay
49
+ * guard) and race the `signals.ndjson` round ledger. `--expected-criteria`
50
+ * makes that contract enforceable: a partial (single-cluster) verdict is
51
+ * rejected before scoring, so the mistake costs no round.
52
+ *
44
53
  * CLI:
45
- * --story <id> Story ID (required).
46
- * --verdict <path> Path to the round's verdict JSON (required).
47
- * --no-signal Suppress the signal emit (tests).
54
+ * --story <id> Story ID (required).
55
+ * --verdict <path> Path to the round's verdict JSON (required).
56
+ * --expected-criteria <n> Reject a verdict not covering exactly n criteria.
57
+ * --no-signal Suppress the signal emit (tests).
48
58
  *
49
59
  * Stdout: a single JSON envelope
50
60
  * { storyId, epicId, decision, round, cap, capReached, totalCriteria,
@@ -143,6 +153,7 @@ function parseCliArgs(argv) {
143
153
  options: {
144
154
  story: { type: 'string' },
145
155
  verdict: { type: 'string' },
156
+ 'expected-criteria': { type: 'string' },
146
157
  'no-signal': { type: 'boolean', default: false },
147
158
  },
148
159
  strict: false,
@@ -151,10 +162,69 @@ function parseCliArgs(argv) {
151
162
  return {
152
163
  storyId: Number.isInteger(storyId) && storyId > 0 ? storyId : null,
153
164
  verdictPath: values.verdict ?? null,
165
+ expectedCriteria: values['expected-criteria'] ?? null,
154
166
  emitSignal: values['no-signal'] !== true,
155
167
  };
156
168
  }
157
169
 
170
+ /**
171
+ * The merge contract, stated once so both the flag error and the coverage
172
+ * error name the same shape the caller has to produce.
173
+ */
174
+ const MERGE_CONTRACT =
175
+ 'One round = N parallel cluster critics -> ONE merged verdict -> ONE gate call: ' +
176
+ "merge every cluster's records into a single criteria[] in acceptance[] order, " +
177
+ 'one per acceptance item, before scoring.';
178
+
179
+ /**
180
+ * Resolve the optional `--expected-criteria` flag to a positive integer, or
181
+ * `null` when the flag is absent (which preserves the pre-#4951 behaviour
182
+ * exactly — no coverage assertion is made).
183
+ *
184
+ * Exported for tests.
185
+ *
186
+ * @param {string|null|undefined} raw
187
+ * @returns {number|null}
188
+ */
189
+ export function resolveExpectedCriteria(raw) {
190
+ if (raw === null || raw === undefined) return null;
191
+ // Digits only. `Number.parseInt` stops at the first non-digit, so `4abc`
192
+ // resolved to 4 — a guard whose entire job is to reject a wrong-sized
193
+ // verdict was itself accepting a malformed count, and a typo'd `--expected-
194
+ // criteria` would then wave through a verdict of the wrong length.
195
+ const text = String(raw).trim();
196
+ const expected = /^\d+$/.test(text) ? Number(text) : Number.NaN;
197
+ if (!Number.isInteger(expected) || expected < 1) {
198
+ throw new Error(
199
+ `acceptance-eval: --expected-criteria must be a positive integer (the Story's acceptance[] count). ${MERGE_CONTRACT}`,
200
+ );
201
+ }
202
+ return expected;
203
+ }
204
+
205
+ /**
206
+ * Reject a verdict that does not cover exactly `expectedCriteria` criteria.
207
+ *
208
+ * Called **before** `runAcceptanceEval`, which is where the round ledger is
209
+ * read and appended — so a partial cluster verdict handed to the gate by
210
+ * mistake costs no round and can never escalate a `redraft` into a `block`.
211
+ *
212
+ * Exported for tests.
213
+ *
214
+ * @param {object} verdict — schema-validated verdict.
215
+ * @param {number|null} expectedCriteria — `null` disables the assertion.
216
+ * @returns {void}
217
+ */
218
+ export function assertCriteriaCoverage(verdict, expectedCriteria) {
219
+ if (expectedCriteria === null) return;
220
+ const actual = Array.isArray(verdict?.criteria) ? verdict.criteria.length : 0;
221
+ if (actual === expectedCriteria) return;
222
+ throw new Error(
223
+ `acceptance-eval: verdict covers ${actual} criteria but --expected-criteria is ${expectedCriteria}. ` +
224
+ `${MERGE_CONTRACT} No round was consumed.`,
225
+ );
226
+ }
227
+
158
228
  /**
159
229
  * Compose the operator-facing envelope and emit the per-criterion signal.
160
230
  *
@@ -294,11 +364,13 @@ export async function runAcceptanceEvalCli(
294
364
  runAcceptanceEvalImpl = runAcceptanceEval,
295
365
  logger = Logger,
296
366
  } = deps;
297
- const { storyId, verdictPath, emitSignal } = parseCliArgs(argv);
367
+ const { storyId, verdictPath, expectedCriteria, emitSignal } =
368
+ parseCliArgs(argv);
369
+ const expected = resolveExpectedCriteria(expectedCriteria);
298
370
 
299
371
  if (!storyId) {
300
372
  throw new Error(
301
- 'Usage: node acceptance-eval.js --story <id> --verdict <path> [--no-signal]',
373
+ 'Usage: node acceptance-eval.js --story <id> --verdict <path> [--expected-criteria <n>] [--no-signal]',
302
374
  );
303
375
  }
304
376
  if (!verdictPath) {
@@ -329,6 +401,11 @@ export async function runAcceptanceEvalCli(
329
401
 
330
402
  const verdict = validateVerdictImpl(parsed);
331
403
 
404
+ // Story #4951: a merged verdict must cover every acceptance[] item. This
405
+ // runs before the round ledger is touched, so a partial cluster verdict is
406
+ // a free mistake.
407
+ assertCriteriaCoverage(verdict, expected);
408
+
332
409
  // A verdict whose embedded storyId disagrees with the CLI flag is a
333
410
  // wiring error worth failing on, not a silent mismatch.
334
411
  if (Number.isInteger(verdict.storyId) && verdict.storyId !== storyId) {
@@ -372,12 +449,18 @@ runAsCli(import.meta.url, main, {
372
449
  source: 'acceptance-eval',
373
450
  usage: {
374
451
  invocation:
375
- 'node .agents/scripts/acceptance-eval.js --story <id> --verdict <path> [--no-signal]',
452
+ 'node .agents/scripts/acceptance-eval.js --story <id> --verdict <path> [--expected-criteria <n>] [--no-signal]',
376
453
  summary:
377
454
  "Score an authored acceptance verdict against the Story's acceptance[] criteria and emit the bounded loop's proceed / redraft / block decision.",
378
455
  flags: [
379
456
  ['--story <id>', 'GitHub issue number of the Story (required).'],
380
457
  ['--verdict <path>', 'Path to the authored verdict JSON (required).'],
458
+ [
459
+ '--expected-criteria <n>',
460
+ 'Reject — before scoring, consuming no round — a verdict whose criteria[] ' +
461
+ "length is not n. Pass the Story's acceptance[] count so a partial " +
462
+ 'cluster verdict cannot be scored as the round.',
463
+ ],
381
464
  [
382
465
  '--no-signal',
383
466
  "Skip appending the per-criterion signal to the Story's signals ledger.",
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI: baseline hotspot engine for the `/audit-baselines` lens (Story #4902).
5
+ *
6
+ * Turns the committed `baselines/` folder into ranked hotspot clusters,
7
+ * gate-surface health signals, trend deltas, and floor-tightening headroom —
8
+ * the deterministic evidence half of a baseline review, so the lens spends
9
+ * its judgment on findings instead of re-deriving the numbers by hand.
10
+ *
11
+ * Read-only by contract: nothing under `baselines/` is written, and no test,
12
+ * coverage, or mutation suite is run. The only file this process creates is
13
+ * the envelope at `--out`.
14
+ *
15
+ * Exit 0 whenever evidence was assembled — findings are evidence, not a gate.
16
+ * A missing git history, absent friction ledger, or unresolvable import graph
17
+ * are reported as degradations and still exit 0. Only an unwritable `--out`
18
+ * (or a missing one) is a failure.
19
+ *
20
+ * Usage:
21
+ * node .agents/scripts/audit-baselines.js --out temp/audit-baselines/envelope.json
22
+ */
23
+
24
+ // Fail-fast if the framework's runtime deps are not installed — must be the
25
+ // first import so the check runs before any third-party-importing sibling
26
+ // module is evaluated (Story #3432).
27
+ import './lib/runtime-deps/ensure-installed.js';
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import process from 'node:process';
31
+ import {
32
+ DEFAULT_HOTSPOT_LIMIT,
33
+ runEngine,
34
+ summarize,
35
+ } from './lib/audit-baselines/engine.js';
36
+ import { DEFAULT_TOP_N } from './lib/audit-baselines/outliers.js';
37
+ import { buildBaselineSchemaAjv } from './lib/baseline-schema-registry.js';
38
+ import { defineFlags } from './lib/cli-args.js';
39
+ import { runAsCli } from './lib/cli-utils.js';
40
+
41
+ const ENVELOPE_SCHEMA_FILE = 'audit-baselines-envelope.schema.json';
42
+
43
+ const FLAG_SPEC = {
44
+ out: { type: 'string' },
45
+ cwd: { type: 'string' },
46
+ 'top-n': { type: 'integer' },
47
+ 'hotspot-limit': { type: 'integer' },
48
+ 'trend-depth': { type: 'integer' },
49
+ };
50
+
51
+ /**
52
+ * Validate the envelope against its shipped schema through the shared
53
+ * baseline schema registry. Throws with the AJV error list on mismatch —
54
+ * a malformed envelope is an engine bug, not evidence.
55
+ *
56
+ * @param {object} envelope
57
+ * @returns {void}
58
+ */
59
+ export function assertEnvelope(envelope) {
60
+ const validate = buildBaselineSchemaAjv().getSchema(ENVELOPE_SCHEMA_FILE);
61
+ if (!validate) {
62
+ throw new Error(
63
+ `[audit-baselines] ${ENVELOPE_SCHEMA_FILE} is not registered in the baseline schema registry`,
64
+ );
65
+ }
66
+ if (validate(envelope)) return;
67
+ const detail = (validate.errors ?? [])
68
+ .map((e) => `${e.instancePath || '/'} ${e.message}`)
69
+ .join('; ');
70
+ throw new Error(
71
+ `[audit-baselines] envelope failed schema validation: ${detail}`,
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Assemble the envelope, validate it, write it, and return the stdout
77
+ * summary. Exported so tests drive the whole pipeline without spawning.
78
+ *
79
+ * @param {{ argv?: string[], cwd?: string, stdout?: { write: (s: string) => void } }} [opts]
80
+ * @returns {Promise<number>} exit code
81
+ */
82
+ export async function runCli({
83
+ argv = process.argv.slice(2),
84
+ cwd = process.cwd(),
85
+ stdout = process.stdout,
86
+ } = {}) {
87
+ const { values } = defineFlags(FLAG_SPEC, argv);
88
+ if (!values.out) {
89
+ throw new Error('[audit-baselines] --out <path> is required');
90
+ }
91
+ const repoRoot = path.resolve(values.cwd ?? cwd);
92
+ const outPath = path.resolve(repoRoot, values.out);
93
+
94
+ const envelope = runEngine({
95
+ cwd: repoRoot,
96
+ topN: values.topN ?? DEFAULT_TOP_N,
97
+ hotspotLimit: values.hotspotLimit ?? DEFAULT_HOTSPOT_LIMIT,
98
+ trendDepth: values.trendDepth ?? 5,
99
+ });
100
+ assertEnvelope(envelope);
101
+
102
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
103
+ fs.writeFileSync(outPath, `${JSON.stringify(envelope, null, 2)}\n`, 'utf8');
104
+
105
+ stdout.write(`${JSON.stringify(summarize(envelope, outPath), null, 2)}\n`);
106
+ return 0;
107
+ }
108
+
109
+ runAsCli(import.meta.url, async () => runCli(), {
110
+ source: 'audit-baselines',
111
+ propagateExitCode: true,
112
+ errorPrefix: '[audit-baselines] ❌ Fatal error',
113
+ usage: {
114
+ invocation:
115
+ 'node .agents/scripts/audit-baselines.js --out <path> [--cwd <dir>] [--top-n <n>] [--hotspot-limit <n>] [--trend-depth <n>]',
116
+ summary:
117
+ 'Read-only baseline hotspot engine: extract bounded per-gate outliers, cluster them per file, rank by severity x churn x import in-degree x friction, and report gate-surface health, trend deltas, and floor headroom.',
118
+ flags: [
119
+ ['--out <path>', 'Write the JSON envelope here (required).'],
120
+ ['--cwd <dir>', 'Repository root to analyse (default: cwd).'],
121
+ [
122
+ '--top-n <n>',
123
+ `Outlier rows extracted per gate (default: ${DEFAULT_TOP_N}).`,
124
+ ],
125
+ [
126
+ '--hotspot-limit <n>',
127
+ `Hotspot clusters emitted (default: ${DEFAULT_HOTSPOT_LIMIT}).`,
128
+ ],
129
+ ['--trend-depth <n>', 'Baseline commits sampled per kind (default: 5).'],
130
+ ],
131
+ notes: [
132
+ 'Never writes under baselines/ and never runs a test, coverage, or mutation suite.',
133
+ 'Exit codes:\n 0 evidence assembled (including every degraded input)\n 1 the envelope could not be built or written',
134
+ ],
135
+ },
136
+ });
@@ -37,16 +37,19 @@ import fs from 'node:fs';
37
37
  import path from 'node:path';
38
38
  import process from 'node:process';
39
39
  import { runAsCli } from './lib/cli-utils.js';
40
+ import {
41
+ buildGraph,
42
+ collectJsFiles,
43
+ DEFAULT_ROOTS,
44
+ parseRelativeImports,
45
+ } from './lib/import-graph.js';
40
46
 
41
- /**
42
- * Default scan roots making up the project's distributed surface — the
43
- * directories published to npm via `package.json` `files[]`. Resolving
44
- * them into one graph (relativized against the repo root) means a cycle
45
- * crossing two roots is visible to `findCycles`.
46
- *
47
- * @type {string[]}
48
- */
49
- export const DEFAULT_ROOTS = [path.join('.agents', 'scripts'), 'bin', 'lib'];
47
+ // The import-graph builder itself lives in `lib/import-graph.js` (Story
48
+ // #4902) so `audit-baselines.js` can rank hotspots by import in-degree
49
+ // against the same graph this ratchet detects cycles in. Re-exported here
50
+ // because this module's named exports are its unit-test surface and its
51
+ // documented contract; the behaviour is unchanged by the move.
52
+ export { buildGraph, collectJsFiles, DEFAULT_ROOTS, parseRelativeImports };
50
53
 
51
54
  /**
52
55
  * Parse argv for `--baseline <path>`, `--root <path>`, and `--json`.
@@ -80,90 +83,6 @@ export function parseArgv(argv = []) {
80
83
  return { baselinePath, rootPath, json };
81
84
  }
82
85
 
83
- /**
84
- * Recursively collect `.js` files under `rootDir`, skipping
85
- * `node_modules`. Returns absolute paths, sorted for determinism.
86
- *
87
- * @param {string} rootDir
88
- * @returns {string[]}
89
- */
90
- export function collectJsFiles(rootDir) {
91
- const out = [];
92
- const walk = (dir) => {
93
- let entries;
94
- try {
95
- entries = fs.readdirSync(dir, { withFileTypes: true });
96
- } catch {
97
- return;
98
- }
99
- for (const entry of entries) {
100
- if (entry.name === 'node_modules') continue;
101
- const full = path.join(dir, entry.name);
102
- if (entry.isDirectory()) {
103
- walk(full);
104
- } else if (entry.isFile() && entry.name.endsWith('.js')) {
105
- out.push(full);
106
- }
107
- }
108
- };
109
- walk(rootDir);
110
- return out.sort();
111
- }
112
-
113
- const IMPORT_RE = /from\s+['"](\.\.?\/[^'"]+\.js)['"]/g;
114
-
115
- /**
116
- * Pure helper: extract relative static-import specifiers from source text.
117
- *
118
- * @param {string} source
119
- * @returns {string[]}
120
- */
121
- export function parseRelativeImports(source) {
122
- const specs = [];
123
- for (const m of source.matchAll(IMPORT_RE)) {
124
- specs.push(m[1]);
125
- }
126
- return specs;
127
- }
128
-
129
- /**
130
- * Build a directed import graph over the given files. Node identity is the
131
- * file path relative to `rootDir`, posix-separated, so the graph (and any
132
- * cycles found in it) serializes identically across platforms. Edges that
133
- * resolve outside the scanned file set are dropped.
134
- *
135
- * @param {string[]} files absolute paths
136
- * @param {string} rootDir
137
- * @param {{ readFile?: (p: string) => string }} [opts]
138
- * @returns {Map<string, string[]>}
139
- */
140
- export function buildGraph(files, rootDir, { readFile } = {}) {
141
- const read = readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
142
- const toId = (abs) => path.relative(rootDir, abs).split(path.sep).join('/');
143
- const idSet = new Set(files.map(toId));
144
- const graph = new Map();
145
- for (const file of files) {
146
- const id = toId(file);
147
- let source;
148
- try {
149
- source = read(file);
150
- } catch {
151
- graph.set(id, []);
152
- continue;
153
- }
154
- const edges = [];
155
- for (const spec of parseRelativeImports(source)) {
156
- const target = path
157
- .relative(rootDir, path.resolve(path.dirname(file), spec))
158
- .split(path.sep)
159
- .join('/');
160
- if (idSet.has(target) && target !== id) edges.push(target);
161
- }
162
- graph.set(id, [...new Set(edges)].sort());
163
- }
164
- return graph;
165
- }
166
-
167
86
  /**
168
87
  * Pure helper: rotate a cycle (array of module ids, no repeated terminal
169
88
  * element) so it starts at its lexicographically-smallest member. The same
@@ -118,21 +118,34 @@ export async function runCheckBaselineDrift({
118
118
  return { exitCode: run.ok ? 0 : 1, output };
119
119
  }
120
120
 
121
+ /**
122
+ * Run the drift check and *return* its exit code rather than calling
123
+ * `process.exit()` — this CLI prints one row per drifted baseline entry
124
+ * full-scope, so its report is exactly the kind of payload that outgrows the
125
+ * 64 KiB pipe buffer under a `| tee`. `process.exit()` terminates before a
126
+ * queued async pipe write drains, silently truncating it (Story #4783, the
127
+ * same defect `check-baselines.js` carried). Handing the code back lets
128
+ * `runAsCli`'s `propagateExitCode` path settle it through
129
+ * `settleCli`/`flushStdio` instead. The 0/1/2 contract documented at the top
130
+ * of this file is unchanged — only *when* the process leaves is.
131
+ *
132
+ * @returns {Promise<number>} 0 no drift, 1 drift detected, 2 could not run.
133
+ */
121
134
  async function main() {
122
135
  let result;
123
136
  try {
124
137
  result = await runCheckBaselineDrift({ argv: process.argv.slice(2) });
125
138
  } catch (err) {
126
139
  process.stdout.write(`${err?.message ?? String(err)}\n`);
127
- process.exit(2);
128
- return;
140
+ return 2;
129
141
  }
130
142
  process.stdout.write(`${result.output}\n`);
131
- process.exit(result.exitCode);
143
+ return result.exitCode;
132
144
  }
133
145
 
134
146
  runAsCli(import.meta.url, main, {
135
147
  source: 'check-baseline-drift',
136
148
  usage: HELP_TEXT,
137
149
  exitCode: 2,
150
+ propagateExitCode: true,
138
151
  });
@@ -65,6 +65,22 @@ export {
65
65
  selectEnabledGates,
66
66
  };
67
67
 
68
+ /**
69
+ * Run the dispatcher and *return* its exit code rather than calling
70
+ * `process.exit()`.
71
+ *
72
+ * The return is load-bearing (Story #4783's defect, reintroduced here):
73
+ * `process.exit()` terminates before a queued async pipe write drains, so a
74
+ * full `--gate coverage` report — a quarter of a megabyte of JSON — arrived
75
+ * truncated at the 64 KiB pipe boundary under the `| tee` in CI's coverage
76
+ * step, while still exiting 0. Handing the code back to `runAsCli`'s
77
+ * `propagateExitCode` path settles it through `settleCli`/`flushStdio`
78
+ * instead, which assigns `process.exitCode` and lets Node terminate once
79
+ * stdout has drained. The 0/1/2/3/4 contract in `lib/baselines/exit-codes.js`
80
+ * is unchanged — only *when* the process leaves is.
81
+ *
82
+ * @returns {Promise<number>} An exit code from the `EXIT_*` contract.
83
+ */
68
84
  async function main() {
69
85
  let result;
70
86
  try {
@@ -74,14 +90,14 @@ async function main() {
74
90
  process.stdout.write(
75
91
  `${JSON.stringify({ schemaVersion: '1', error: message }, null, 2)}\n`,
76
92
  );
77
- process.exit(EXIT_CONFIG);
78
- return;
93
+ return EXIT_CONFIG;
79
94
  }
80
95
  process.stdout.write(`${result.output}\n`);
81
- process.exit(result.exitCode);
96
+ return result.exitCode;
82
97
  }
83
98
 
84
99
  runAsCli(import.meta.url, main, {
85
100
  source: 'check-baselines',
86
101
  usage: HELP_TEXT,
102
+ propagateExitCode: true,
87
103
  });