mandrel 2.25.0 → 2.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/reader.js +52 -38
  41. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  42. package/.agents/scripts/lib/baselines/scope.js +39 -90
  43. package/.agents/scripts/lib/baselines/writer.js +16 -11
  44. package/.agents/scripts/lib/changed-files.js +8 -1
  45. package/.agents/scripts/lib/cli-args.js +115 -1
  46. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  47. package/.agents/scripts/lib/crap-engine.js +32 -13
  48. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  49. package/.agents/scripts/lib/crap-utils.js +13 -0
  50. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  51. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  53. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  54. package/.agents/scripts/lib/git-utils.js +136 -80
  55. package/.agents/scripts/lib/import-graph.js +156 -0
  56. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  57. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  58. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  62. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  64. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  65. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  66. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  67. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  68. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  69. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  70. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  71. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  72. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  73. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  74. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  78. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  79. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  80. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  81. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  82. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  83. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  84. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  85. package/.agents/scripts/lib/story-adjacency.js +3 -3
  86. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  87. package/.agents/scripts/lib/test-tiers.js +11 -2
  88. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  89. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  90. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  91. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  92. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  93. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  94. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  96. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  97. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  98. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  99. package/.agents/scripts/lib/worktree-manager.js +14 -0
  100. package/.agents/scripts/plan-run-epilogue.js +17 -5
  101. package/.agents/scripts/providers/github/tickets.js +33 -10
  102. package/.agents/scripts/provision-git-hooks.js +85 -0
  103. package/.agents/scripts/quality-preview.js +112 -28
  104. package/.agents/scripts/resolve-stories.js +4 -1
  105. package/.agents/scripts/run-coverage.js +86 -35
  106. package/.agents/scripts/run-lint.js +20 -0
  107. package/.agents/scripts/run-tests.js +26 -36
  108. package/.agents/scripts/single-story-close.js +28 -2
  109. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  110. package/.agents/scripts/stories-wave-tick.js +214 -38
  111. package/.agents/scripts/update-coverage-baseline.js +34 -4
  112. package/.agents/scripts/update-duplication-baseline.js +209 -83
  113. package/.agents/scripts/validate-docs-freshness.js +1 -0
  114. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  115. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  116. package/.agents/skills/skills.index.json +2 -2
  117. package/.agents/workflows/audit-baselines.md +289 -0
  118. package/.agents/workflows/audit-navigability.md +5 -4
  119. package/.agents/workflows/deliver.md +13 -4
  120. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  121. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  122. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  123. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  124. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  125. package/.agents/workflows/helpers/plan-reference.md +15 -5
  126. package/.agents/workflows/memory-consolidate.md +116 -0
  127. package/.agents/workflows/plan.md +3 -0
  128. package/README.md +13 -6
  129. package/docs/CHANGELOG.md +71 -0
  130. package/package.json +9 -4
  131. package/.agents/schemas/friction-event.schema.json +0 -56
  132. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -0,0 +1,265 @@
1
+ /**
2
+ * cyclomatic-ceiling.js — the enforcing core behind
3
+ * `delivery.quality.codingGuardrails.cyclomaticMustFix` (Story #4923).
4
+ *
5
+ * The two `codingGuardrails` cyclomatic knobs shipped schema-validated,
6
+ * bootstrap-defaulted and resolver-resolved, and were then read by nothing:
7
+ * `cyclomaticMustFix` had no consumer at all, and `cyclomaticFlag` was
8
+ * shadowed by a hardcoded `8` in a `quality-preview` display column. A ceiling
9
+ * nothing enforces is worse than no ceiling, because the workflow docs promise
10
+ * the merge will be refused.
11
+ *
12
+ * This module is that enforcement, shaped as a **ratchet** rather than a
13
+ * cliff. The repository already carries dozens of functions above the
14
+ * must-fix ceiling; failing every one of them at once would have made the
15
+ * gate un-landable and it would have been disabled the same day. So the
16
+ * committed `baselines/cyclomatic.json` records the existing breaches per
17
+ * file, and the gate fails only when a change **adds** an over-ceiling
18
+ * function to a file that had none, adds one **beyond** that file's recorded
19
+ * count, or pushes a file's worst function **higher** than recorded. Burning
20
+ * the recorded breaches down is a separate, always-permitted motion — a
21
+ * shrinking baseline is the success signal.
22
+ *
23
+ * Scope: the module walks the `maintainability` gate's `targetDirs` /
24
+ * `ignoreGlobs`. Both instruments read the same coverage-free escomplex
25
+ * surface, so re-declaring the scope under `codingGuardrails` would have
26
+ * added two config keys whose only correct value is "whatever maintainability
27
+ * says".
28
+ *
29
+ * @module lib/cyclomatic-ceiling
30
+ */
31
+
32
+ import path from 'node:path';
33
+ import { calculateReportForFile } from './maintainability-engine.js';
34
+ import { isIgnoredByGlobs, scanDirectory } from './maintainability-utils.js';
35
+
36
+ /** Default location of the committed breach baseline. */
37
+ export const DEFAULT_CYCLOMATIC_BASELINE = 'baselines/cyclomatic.json';
38
+
39
+ /** Baseline `$schema` marker, matching the sibling ratchet baselines. */
40
+ const CYCLOMATIC_BASELINE_SCHEMA =
41
+ 'https://mandrel.dev/baselines/cyclomatic.schema.json';
42
+
43
+ /**
44
+ * Resolve the enforcement policy from a resolved `delivery.quality` block.
45
+ *
46
+ * `mustFix` and `flag` come straight from `resolveCodingGuardrails`, so a
47
+ * consumer that tunes either knob tunes this gate — which is the whole point
48
+ * of the Story. `targetDirs` / `ignoreGlobs` are borrowed from the
49
+ * maintainability gate (see the module note).
50
+ *
51
+ * @param {object | null | undefined} quality resolved `delivery.quality`
52
+ * @returns {{ mustFix: number, flag: number, targetDirs: string[], ignoreGlobs: string[] }}
53
+ */
54
+ export function resolveCyclomaticPolicy(quality) {
55
+ const guardrails = quality?.codingGuardrails ?? {};
56
+ const mi = quality?.maintainability ?? {};
57
+ return {
58
+ mustFix: Number(guardrails.cyclomaticMustFix ?? 12),
59
+ flag: Number(guardrails.cyclomaticFlag ?? 8),
60
+ targetDirs: Array.isArray(mi.targetDirs) ? mi.targetDirs : [],
61
+ ignoreGlobs: Array.isArray(mi.ignoreGlobs) ? mi.ignoreGlobs : [],
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Reduce one file's escomplex method list to a breach row, or `null` when the
67
+ * file carries no function above `ceiling`.
68
+ *
69
+ * Pure. Module-private: tests reach it through `scanCyclomatic`'s `scoreFile`
70
+ * seam, which keeps the row math exercised without adding an export whose only
71
+ * importer is a test (the `--production` dead-export pass discounts those).
72
+ *
73
+ * @param {string} file repo-relative POSIX path
74
+ * @param {Array<{ cyclomatic?: number }>} methods
75
+ * @param {number} ceiling
76
+ * @returns {{ file: string, methodsAboveCeiling: number, maxCyclomatic: number } | null}
77
+ */
78
+ function breachRowFor(file, methods, ceiling) {
79
+ let count = 0;
80
+ let max = 0;
81
+ for (const method of methods ?? []) {
82
+ const c = Number(method?.cyclomatic ?? 0);
83
+ if (!Number.isFinite(c)) continue;
84
+ if (c > ceiling) count += 1;
85
+ if (c > max) max = c;
86
+ }
87
+ return count === 0
88
+ ? null
89
+ : { file, methodsAboveCeiling: count, maxCyclomatic: max };
90
+ }
91
+
92
+ /**
93
+ * Walk the configured scope and score every file, returning the breach rows
94
+ * sorted by path.
95
+ *
96
+ * `scoreFile` is a seam so tests can drive the reduction without the kernel;
97
+ * production callers omit it.
98
+ *
99
+ * @param {{
100
+ * targetDirs: string[],
101
+ * ignoreGlobs?: string[],
102
+ * ceiling: number,
103
+ * cwd?: string,
104
+ * scoreFile?: (absPath: string) => { methods?: Array<{ cyclomatic?: number }>, parseError?: boolean },
105
+ * }} args
106
+ * @returns {{ rows: Array<object>, scannedFiles: number, parseErrors: number }}
107
+ */
108
+ export function scanCyclomatic({
109
+ targetDirs,
110
+ ignoreGlobs = [],
111
+ ceiling,
112
+ cwd = process.cwd(),
113
+ scoreFile = calculateReportForFile,
114
+ }) {
115
+ const files = [];
116
+ for (const dir of targetDirs ?? []) {
117
+ const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
118
+ scanDirectory(abs, files, { cwd, ignoreGlobs });
119
+ }
120
+ files.sort();
121
+ const rows = [];
122
+ let parseErrors = 0;
123
+ for (const abs of files) {
124
+ if (isIgnoredByGlobs(abs, ignoreGlobs, cwd)) continue;
125
+ const report = scoreFile(abs);
126
+ if (!report || report.parseError) {
127
+ parseErrors += 1;
128
+ continue;
129
+ }
130
+ const rel = path.relative(cwd, abs).split(path.sep).join('/');
131
+ const row = breachRowFor(rel, report.methods, ceiling);
132
+ if (row) rows.push(row);
133
+ }
134
+ rows.sort((a, b) => a.file.localeCompare(b.file));
135
+ return { rows, scannedFiles: files.length, parseErrors };
136
+ }
137
+
138
+ /**
139
+ * Diff current breach rows against the committed baseline.
140
+ *
141
+ * Four buckets, only the first two of which fail the gate:
142
+ *
143
+ * - `added` — a file whose over-ceiling function count rose (including
144
+ * 0 → 1, i.e. a brand-new breach in new or changed code).
145
+ * - `worsened` — a file whose worst function got worse than recorded.
146
+ * - `removed` — a file that no longer breaches at all.
147
+ * - `improved` — a file that breaches less than recorded.
148
+ *
149
+ * Pure; identity is the repo-relative file path.
150
+ *
151
+ * @param {Array<{file: string, methodsAboveCeiling: number, maxCyclomatic: number}>} baselineRows
152
+ * @param {Array<{file: string, methodsAboveCeiling: number, maxCyclomatic: number}>} currentRows
153
+ * @returns {{ added: Array<object>, worsened: Array<object>, removed: Array<object>, improved: Array<object> }}
154
+ */
155
+ export function diffCyclomaticRows(baselineRows, currentRows) {
156
+ const base = new Map(
157
+ (baselineRows ?? [])
158
+ .filter((r) => typeof r?.file === 'string')
159
+ .map((r) => [r.file, r]),
160
+ );
161
+ const added = [];
162
+ const worsened = [];
163
+ const improved = [];
164
+ const seen = new Set();
165
+ for (const row of currentRows ?? []) {
166
+ if (typeof row?.file !== 'string') continue;
167
+ seen.add(row.file);
168
+ const prior = base.get(row.file);
169
+ const priorCount = Number(prior?.methodsAboveCeiling ?? 0);
170
+ const priorMax = Number(prior?.maxCyclomatic ?? 0);
171
+ if (row.methodsAboveCeiling > priorCount) {
172
+ added.push({ ...row, baselineCount: priorCount });
173
+ continue;
174
+ }
175
+ if (row.maxCyclomatic > priorMax) {
176
+ worsened.push({ ...row, baselineMax: priorMax });
177
+ continue;
178
+ }
179
+ if (row.methodsAboveCeiling < priorCount || row.maxCyclomatic < priorMax) {
180
+ improved.push({
181
+ ...row,
182
+ baselineCount: priorCount,
183
+ baselineMax: priorMax,
184
+ });
185
+ }
186
+ }
187
+ const removed = (baselineRows ?? []).filter(
188
+ (r) => typeof r?.file === 'string' && !seen.has(r.file),
189
+ );
190
+ const byFile = (a, b) => a.file.localeCompare(b.file);
191
+ return {
192
+ added: added.sort(byFile),
193
+ worsened: worsened.sort(byFile),
194
+ removed: removed.sort(byFile),
195
+ improved: improved.sort(byFile),
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Assemble the committed baseline envelope. The rollup is deliberately
201
+ * derived, never hand-written: a zero-row baseline here reports zero breaches
202
+ * because the repository has none, not because nobody produced it.
203
+ *
204
+ * @param {{ rows: Array<object>, ceiling: number, generatedAt?: string }} args
205
+ * @returns {object}
206
+ */
207
+ export function buildCyclomaticEnvelope({ rows, ceiling, generatedAt }) {
208
+ const safeRows = rows ?? [];
209
+ let methods = 0;
210
+ let max = 0;
211
+ for (const row of safeRows) {
212
+ methods += Number(row.methodsAboveCeiling ?? 0);
213
+ if (Number(row.maxCyclomatic ?? 0) > max) max = Number(row.maxCyclomatic);
214
+ }
215
+ return {
216
+ $schema: CYCLOMATIC_BASELINE_SCHEMA,
217
+ generatedAt: generatedAt ?? new Date().toISOString(),
218
+ ceiling,
219
+ rollup: {
220
+ '*': {
221
+ filesAboveCeiling: safeRows.length,
222
+ methodsAboveCeiling: methods,
223
+ maxCyclomatic: max,
224
+ },
225
+ },
226
+ rows: safeRows,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Render the human-readable diff. Emits a summary line even on a clean run so
232
+ * operators see the "no drift" signal rather than silence.
233
+ *
234
+ * @param {{ added: Array, worsened: Array, removed: Array, improved: Array }} diff
235
+ * @param {number} ceiling
236
+ * @returns {string}
237
+ */
238
+ export function renderCyclomaticDiff(diff, ceiling) {
239
+ const lines = [];
240
+ for (const r of diff.added) {
241
+ lines.push(
242
+ `+ ${r.file}: ${r.methodsAboveCeiling} function(s) over c=${ceiling} (recorded ${r.baselineCount}), worst c=${r.maxCyclomatic}`,
243
+ );
244
+ }
245
+ for (const r of diff.worsened) {
246
+ lines.push(
247
+ `! ${r.file}: worst function c=${r.maxCyclomatic} (recorded ${r.baselineMax})`,
248
+ );
249
+ }
250
+ for (const r of diff.improved) {
251
+ lines.push(
252
+ `~ ${r.file}: ${r.methodsAboveCeiling} over c=${ceiling} (recorded ${r.baselineCount}), worst c=${r.maxCyclomatic} (recorded ${r.baselineMax})`,
253
+ );
254
+ }
255
+ for (const r of diff.removed) {
256
+ lines.push(`- ${r.file}: no longer over c=${ceiling}`);
257
+ }
258
+ const failing = diff.added.length + diff.worsened.length;
259
+ lines.push(
260
+ `[cyclomatic] ceiling=${ceiling} added=${diff.added.length} worsened=${diff.worsened.length} improved=${diff.improved.length} removed=${diff.removed.length} ${
261
+ failing > 0 ? '(gate fail)' : '(ok)'
262
+ }`,
263
+ );
264
+ return lines.join('\n');
265
+ }
@@ -333,5 +333,3 @@ export async function graduateAuditResults(opts = {}) {
333
333
  },
334
334
  });
335
335
  }
336
-
337
- export default graduateAuditResults;
@@ -307,5 +307,3 @@ export async function fetchPriorFeedback({
307
307
 
308
308
  return envelope;
309
309
  }
310
-
311
- export default fetchPriorFeedback;
@@ -495,5 +495,3 @@ export async function fileRetroProposals({
495
495
  );
496
496
  return { routedProposals: enriched, summary };
497
497
  }
498
-
499
- export default graduateRetroProposals;
@@ -40,6 +40,23 @@ import { execFileSync, spawnSync } from 'node:child_process';
40
40
  * @property {string} stderr - Trimmed stderr.
41
41
  */
42
42
 
43
+ /**
44
+ * Explicit stdout ceiling for every git invocation in this module.
45
+ *
46
+ * Both child-process runners default `maxBuffer` to 1 MB, at which point the
47
+ * child is killed and the call fails with `ENOBUFS` for a reason unrelated to
48
+ * git. The push path is the sharp edge: `git push` relays the whole `pre-push`
49
+ * hook output, which is unbounded by design — this repo's hook emits a full
50
+ * `check-baselines` envelope, measured at 2,166,643 bytes, so every Story
51
+ * close failed at `phase: push` once hooks became reachable inside worktrees.
52
+ *
53
+ * 64 MB is the bound Story #4914 already set on `baselines/git-base.js` for
54
+ * the identical failure, matching `run-test-profile.js`,
55
+ * `audit-baselines/trend.js` and `audit-baselines/weights.js`. This module was
56
+ * missed by that sweep.
57
+ */
58
+ const MAX_BUFFER_BYTES = 64 * 1024 * 1024;
59
+
43
60
  let _execFileSync = execFileSync;
44
61
  let _spawnSync = spawnSync;
45
62
 
@@ -108,38 +125,44 @@ export function __setGitRunners(exec, spawn) {
108
125
  }
109
126
 
110
127
  /**
111
- * Run a git command synchronously, returning trimmed stdout.
112
- * Throws an Error if the command exits with a non-zero code.
128
+ * The **single** throwing git runner. Both the module-level {@link gitSync} and
129
+ * the interface returned by {@link createGitInterface} route through this
130
+ * they differ only in which `execFileSync` they hand it.
113
131
  *
114
- * @param {string} cwd - Working directory for the git process.
115
- * @param {...string} args - Git sub-command and arguments.
132
+ * @param {typeof execFileSync} exec
133
+ * @param {string} cwd
134
+ * @param {string[]} args
116
135
  * @returns {string} Trimmed stdout text.
117
136
  */
118
- export function gitSync(cwd, ...args) {
119
- return _execFileSync('git', args, {
137
+ function runGitSync(exec, cwd, args) {
138
+ return exec('git', args, {
120
139
  cwd,
121
140
  encoding: 'utf8',
122
141
  stdio: ['pipe', 'pipe', 'pipe'],
123
142
  shell: false,
124
143
  env: cleanGitEnv(),
144
+ maxBuffer: MAX_BUFFER_BYTES,
125
145
  }).trim();
126
146
  }
127
147
 
128
148
  /**
129
- * Run a git command synchronously, returning a result object.
130
- * Never throws callers must inspect `status` to detect failure.
149
+ * The **single** non-throwing git runner the `spawnSync` counterpart of
150
+ * {@link runGitSync}, normalising `status`/stdout/stderr into a
151
+ * {@link GitResult}.
131
152
  *
132
- * @param {string} cwd - Working directory for the git process.
133
- * @param {...string} args - Git sub-command and arguments.
153
+ * @param {typeof spawnSync} spawn
154
+ * @param {string} cwd
155
+ * @param {string[]} args
134
156
  * @returns {GitResult}
135
157
  */
136
- export function gitSpawn(cwd, ...args) {
137
- const result = _spawnSync('git', args, {
158
+ function runGitSpawn(spawn, cwd, args) {
159
+ const result = spawn('git', args, {
138
160
  cwd,
139
161
  stdio: 'pipe',
140
162
  encoding: 'utf-8',
141
163
  shell: false,
142
164
  env: cleanGitEnv(),
165
+ maxBuffer: MAX_BUFFER_BYTES,
143
166
  });
144
167
  return {
145
168
  status: result.status ?? 1,
@@ -148,6 +171,30 @@ export function gitSpawn(cwd, ...args) {
148
171
  };
149
172
  }
150
173
 
174
+ /**
175
+ * Run a git command synchronously, returning trimmed stdout.
176
+ * Throws an Error if the command exits with a non-zero code.
177
+ *
178
+ * @param {string} cwd - Working directory for the git process.
179
+ * @param {...string} args - Git sub-command and arguments.
180
+ * @returns {string} Trimmed stdout text.
181
+ */
182
+ export function gitSync(cwd, ...args) {
183
+ return runGitSync(_execFileSync, cwd, args);
184
+ }
185
+
186
+ /**
187
+ * Run a git command synchronously, returning a result object.
188
+ * Never throws — callers must inspect `status` to detect failure.
189
+ *
190
+ * @param {string} cwd - Working directory for the git process.
191
+ * @param {...string} args - Git sub-command and arguments.
192
+ * @returns {GitResult}
193
+ */
194
+ export function gitSpawn(cwd, ...args) {
195
+ return runGitSpawn(_spawnSync, cwd, args);
196
+ }
197
+
151
198
  /**
152
199
  * Build a git interface closed over injected child-process runners. Preferred
153
200
  * seam for callers that want explicit injection without touching the
@@ -165,57 +212,25 @@ export function gitSpawn(cwd, ...args) {
165
212
  export function createGitInterface(deps = {}) {
166
213
  const exec = deps.exec ?? execFileSync;
167
214
  const spawn = deps.spawn ?? spawnSync;
168
- const sleep =
169
- deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
215
+ const sleep = deps.sleep ?? defaultSleep;
170
216
  const jitterFactor = deps.jitter ?? 0.5;
171
217
 
172
- const gitSync = (cwd, ...args) =>
173
- exec('git', args, {
174
- cwd,
175
- encoding: 'utf8',
176
- stdio: ['pipe', 'pipe', 'pipe'],
177
- shell: false,
178
- env: cleanGitEnv(),
179
- }).trim();
180
-
181
- const gitSpawn = (cwd, ...args) => {
182
- const result = spawn('git', args, {
183
- cwd,
184
- stdio: 'pipe',
185
- encoding: 'utf-8',
186
- shell: false,
187
- env: cleanGitEnv(),
188
- });
189
- return {
190
- status: result.status ?? 1,
191
- stdout: (result.stdout ?? '').trim(),
192
- stderr: (result.stderr ?? '').trim(),
193
- };
194
- };
195
-
196
- async function runWithRetry(leadingArgs, cwd, args) {
197
- const backoff = [250, 500, 1000];
198
- let attempt = 0;
199
- let last;
200
- for (;;) {
201
- attempt++;
202
- last = gitSpawn(cwd, ...leadingArgs, ...args);
203
- if (last.status === 0) return { ...last, attempts: attempt };
204
- if (!isPackedRefsContention(last.stderr))
205
- return { ...last, attempts: attempt };
206
- if (attempt > backoff.length) return { ...last, attempts: attempt };
207
- const base = backoff[attempt - 1];
208
- const jitter = Math.floor(Math.random() * base * jitterFactor);
209
- await sleep(base + jitter);
210
- }
211
- }
218
+ const boundGitSpawn = (cwd, ...args) => runGitSpawn(spawn, cwd, args);
219
+ const withRetry =
220
+ (argvPrefix) =>
221
+ (cwd, ...args) =>
222
+ gitWithContentionRetry(
223
+ { spawnGit: boundGitSpawn, sleep, jitterFactor },
224
+ cwd,
225
+ argvPrefix,
226
+ args,
227
+ );
212
228
 
213
229
  return {
214
- gitSync,
215
- gitSpawn,
216
- gitFetchWithRetry: (cwd, ...args) => runWithRetry(['fetch'], cwd, args),
217
- gitPullWithRetry: (cwd, ...args) =>
218
- runWithRetry(['pull', '--rebase'], cwd, args),
230
+ gitSync: (cwd, ...args) => runGitSync(exec, cwd, args),
231
+ gitSpawn: boundGitSpawn,
232
+ gitFetchWithRetry: withRetry(['fetch']),
233
+ gitPullWithRetry: withRetry(['pull', '--rebase']),
219
234
  };
220
235
  }
221
236
 
@@ -238,12 +253,21 @@ function isPackedRefsContention(stderr) {
238
253
  }
239
254
 
240
255
  /**
241
- * Sleep helper for retry backoff. Overridable via `__setSleep` so tests
242
- * can skip real wall-clock delays without relying on node:test timer mocks.
256
+ * Real wall-clock sleep the default backoff delay for both the module-level
257
+ * retry helpers and {@link createGitInterface}.
243
258
  * @param {number} ms
244
259
  * @returns {Promise<void>}
245
260
  */
246
- let _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
261
+ function defaultSleep(ms) {
262
+ return new Promise((resolve) => setTimeout(resolve, ms));
263
+ }
264
+
265
+ /**
266
+ * Sleep helper for retry backoff. Overridable via `__setSleep` so tests
267
+ * can skip real wall-clock delays without relying on node:test timer mocks.
268
+ * @type {(ms: number) => Promise<void>}
269
+ */
270
+ let _sleep = defaultSleep;
247
271
  let _jitterFactor = 0.5;
248
272
 
249
273
  /**
@@ -258,38 +282,65 @@ export function __setSleep(fn, opts = {}) {
258
282
  }
259
283
 
260
284
  /**
261
- * Shared bounded retry loop for git commands that can hit packed-refs lock
262
- * contention. Only contention signatures trigger a retry — non-contention
285
+ * Backoff schedule for {@link gitWithContentionRetry}: 250ms, 500ms, 1000ms
286
+ * (3 retries 4 attempts total).
287
+ */
288
+ const CONTENTION_BACKOFF_MS = Object.freeze([250, 500, 1000]);
289
+
290
+ /**
291
+ * The **single** bounded retry loop for git commands that can hit packed-refs
292
+ * lock contention. Only contention signatures trigger a retry — non-contention
263
293
  * failures surface immediately, and success short-circuits the loop.
264
294
  *
265
- * Backoff schedule: 250ms, 500ms, 1000ms (3 retries → 4 attempts total).
266
295
  * Deliberately no global lock — a mutex would erase the parallelism the
267
- * worktree-isolation model is designed to enable. The schedule and the
268
- * jitter policy (`_sleep` / `_jitterFactor` seams) live only here so a
269
- * backoff tuning change has a single point of application.
296
+ * worktree-isolation model is designed to enable. The schedule and the jitter
297
+ * policy live only here, so a backoff tuning change has a single point of
298
+ * application: the module-level `gitFetchWithRetry` / `gitPullWithRetry` pass
299
+ * the `_sleep` / `_jitterFactor` seams, and {@link createGitInterface} passes
300
+ * its injected equivalents.
270
301
  *
302
+ * @param {{ spawnGit: (cwd: string, ...args: string[]) => GitResult,
303
+ * sleep: (ms: number) => Promise<void>, jitterFactor: number }} runners
271
304
  * @param {string} cwd
272
305
  * @param {string[]} argvPrefix - Leading git argv (e.g. `['fetch']`).
273
306
  * @param {string[]} args - Trailing arguments (e.g. `['origin']`).
274
307
  * @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
275
308
  */
276
- async function gitWithContentionRetry(cwd, argvPrefix, args) {
277
- const backoff = [250, 500, 1000];
309
+ async function gitWithContentionRetry(
310
+ { spawnGit, sleep, jitterFactor },
311
+ cwd,
312
+ argvPrefix,
313
+ args,
314
+ ) {
278
315
  let attempt = 0;
279
- let last;
280
316
  for (;;) {
281
317
  attempt++;
282
- last = gitSpawn(cwd, ...argvPrefix, ...args);
283
- if (last.status === 0) return { ...last, attempts: attempt };
284
- if (!isPackedRefsContention(last.stderr))
318
+ const last = spawnGit(cwd, ...argvPrefix, ...args);
319
+ const exhausted = attempt > CONTENTION_BACKOFF_MS.length;
320
+ if (
321
+ last.status === 0 ||
322
+ exhausted ||
323
+ !isPackedRefsContention(last.stderr)
324
+ ) {
285
325
  return { ...last, attempts: attempt };
286
- if (attempt > backoff.length) return { ...last, attempts: attempt };
287
- const base = backoff[attempt - 1];
288
- const jitter = Math.floor(Math.random() * base * _jitterFactor);
289
- await _sleep(base + jitter);
326
+ }
327
+ const base = CONTENTION_BACKOFF_MS[attempt - 1];
328
+ await sleep(base + Math.floor(Math.random() * base * jitterFactor));
290
329
  }
291
330
  }
292
331
 
332
+ /**
333
+ * The module-level retry runners — the `__setSleep`-overridable seams bound to
334
+ * the module-global {@link gitSpawn}. Read lazily so `__setSleep` and
335
+ * `__setGitRunners` still take effect after import.
336
+ *
337
+ * @returns {{ spawnGit: typeof gitSpawn, sleep: (ms: number) => Promise<void>,
338
+ * jitterFactor: number }}
339
+ */
340
+ function moduleRetryRunners() {
341
+ return { spawnGit: gitSpawn, sleep: _sleep, jitterFactor: _jitterFactor };
342
+ }
343
+
293
344
  /**
294
345
  * Run `git fetch …` with the bounded packed-refs-contention retry loop
295
346
  * (see `gitWithContentionRetry`).
@@ -299,7 +350,7 @@ async function gitWithContentionRetry(cwd, argvPrefix, args) {
299
350
  * @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
300
351
  */
301
352
  export function gitFetchWithRetry(cwd, ...args) {
302
- return gitWithContentionRetry(cwd, ['fetch'], args);
353
+ return gitWithContentionRetry(moduleRetryRunners(), cwd, ['fetch'], args);
303
354
  }
304
355
 
305
356
  /**
@@ -312,7 +363,12 @@ export function gitFetchWithRetry(cwd, ...args) {
312
363
  * @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
313
364
  */
314
365
  export function gitPullWithRetry(cwd, ...args) {
315
- return gitWithContentionRetry(cwd, ['pull', '--rebase'], args);
366
+ return gitWithContentionRetry(
367
+ moduleRetryRunners(),
368
+ cwd,
369
+ ['pull', '--rebase'],
370
+ args,
371
+ );
316
372
  }
317
373
 
318
374
  /**