mandrel 2.24.0 → 2.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -30,6 +30,8 @@
30
30
 
31
31
  import crypto from 'node:crypto';
32
32
 
33
+ import { fingerprintSeverity } from './severity.js';
34
+
33
35
  const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
34
36
  const MARKER = 'audit-fingerprints:';
35
37
  const SEMANTIC_MARKER = 'audit-semantic-keys:';
@@ -65,6 +67,20 @@ function normaliseLabels(labels) {
65
67
 
66
68
  /**
67
69
  * Compute the stable identity payload for a finding.
70
+ *
71
+ * **Severity is projected, not raw (Story #4877).** The severity vocabulary was
72
+ * normalised onto the canonical five-level scale in the same change that wrote
73
+ * this comment, and severity is an identity field — so a naive
74
+ * `normaliseField(finding.severity)` here would have re-minted the fingerprint
75
+ * of every finding whose spelling the normalisation touched, silently breaking
76
+ * dedup against every Issue already filed. {@link fingerprintSeverity} is the
77
+ * projection that makes the hash **invariant** under that normalisation: it
78
+ * resolves aliases onto their canonical level, keeps an absent severity as the
79
+ * empty string (what the raw call produced), and passes an unrecognised value
80
+ * through verbatim. Labels are deliberately left on the raw
81
+ * lower-case/trim/sort path for the same reason — order- and case-insensitive
82
+ * already, and any further folding would move existing shas.
83
+ *
68
84
  * @param {object} finding
69
85
  * @returns {{ title: string, area: string, primaryFile: string, severity: string, labels: string }}
70
86
  */
@@ -73,7 +89,7 @@ function fingerprintComponents(finding) {
73
89
  title: normaliseField(finding?.title),
74
90
  area: normaliseField(finding?.area),
75
91
  primaryFile: normaliseField(finding?.primaryFile),
76
- severity: normaliseField(finding?.severity),
92
+ severity: fingerprintSeverity(finding?.severity),
77
93
  labels: normaliseLabels(finding?.labels),
78
94
  };
79
95
  }
@@ -142,20 +158,18 @@ export function semanticKeyFooter(keys) {
142
158
 
143
159
  /**
144
160
  * Extract semantic keys from an Issue body carrying the semantic-key footer.
145
- * Internal — the audit filers stamp the footer via {@link semanticKeyFooter};
146
- * only the confirmation path here reads it back.
161
+ * The audit filers stamp the footer via {@link semanticKeyFooter}; the
162
+ * confirmation path here and {@link carryProvenanceFooters} read it back.
147
163
  *
148
164
  * @param {string} body
149
165
  * @returns {string[]}
150
166
  */
151
167
  function parseSemanticKeyFooter(body) {
152
- if (typeof body !== 'string') return [];
153
- const match = body.match(/<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/);
154
- if (!match) return [];
155
- return match[1]
156
- .split(',')
157
- .map((s) => s.trim())
158
- .filter((s) => s.length > 0);
168
+ return parseAllFooterValues(
169
+ body,
170
+ /<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/g,
171
+ (s) => s.length > 0,
172
+ );
159
173
  }
160
174
 
161
175
  /**
@@ -190,13 +204,104 @@ export function fingerprintFooter(shas) {
190
204
  * @returns {string[]}
191
205
  */
192
206
  export function parseFingerprintFooter(body) {
193
- if (typeof body !== 'string') return [];
194
- const match = body.match(/<!--\s*audit-fingerprints:\s*([^>]+?)\s*-->/);
195
- if (!match) return [];
196
- return match[1]
197
- .split(',')
198
- .map((s) => s.trim())
199
- .filter((s) => SHA1_RE.test(s));
207
+ return parseAllFooterValues(
208
+ body,
209
+ /<!--\s*audit-fingerprints:\s*([^>]+?)\s*-->/g,
210
+ (s) => SHA1_RE.test(s),
211
+ );
212
+ }
213
+
214
+ /**
215
+ * Collect the comma-separated values out of **every** occurrence of a footer
216
+ * marker in `text`, de-duplicated, in first-seen order.
217
+ *
218
+ * Scanning every occurrence rather than only the first matters for the
219
+ * provenance carry (Story #4877): the audit Single-plan seed stamps one footer
220
+ * pair per MVP Scope bullet, so a multi-group seed carries several. A
221
+ * first-match-only parse silently dropped every group but the first, which
222
+ * would have made the carry look wired while leaking most of the provenance.
223
+ * Reading all footers is also strictly more correct for issue-body confirmation
224
+ * — a body that accumulated two footer lines confirms against either.
225
+ *
226
+ * @param {unknown} text
227
+ * @param {RegExp} pattern — a global regex whose first capture group is the
228
+ * comma-separated value list.
229
+ * @param {(value: string) => boolean} isValid
230
+ * @returns {string[]}
231
+ */
232
+ function parseAllFooterValues(text, pattern, isValid) {
233
+ if (typeof text !== 'string') return [];
234
+ const out = [];
235
+ const seen = new Set();
236
+ for (const match of text.matchAll(pattern)) {
237
+ for (const raw of match[1].split(',')) {
238
+ const value = raw.trim();
239
+ if (!isValid(value) || seen.has(value)) continue;
240
+ seen.add(value);
241
+ out.push(value);
242
+ }
243
+ }
244
+ return out;
245
+ }
246
+
247
+ /**
248
+ * Carry audit dedup provenance from a source document into a target body
249
+ * (Story #4877).
250
+ *
251
+ * The audit sweep's Single-plan path emits a `/plan` seed whose MVP Scope
252
+ * bullets already carry the `audit-fingerprints` / `audit-semantic-keys`
253
+ * footers (Story #4626). Nothing then copied them into the Story `/plan`
254
+ * actually persisted, so the recommended path filed Stories that the next
255
+ * sweep could not recognise and re-filed as new. It was left to the authoring
256
+ * agent to notice HTML comments in a one-pager and hand-carry them — a
257
+ * remembered step, which is to say no step at all.
258
+ *
259
+ * This is that carry, as a function: harvest both footers out of `from`, and
260
+ * append whichever provenance `into` is missing. It is deliberately:
261
+ *
262
+ * - **Additive.** Shas and keys already present in `into` are never duplicated,
263
+ * and a footer `into` already carries is left exactly as authored.
264
+ * - **Union-preserving.** When both sides carry footers the result carries the
265
+ * union, so a hand-authored fingerprint is not dropped in favour of the seed's.
266
+ * - **Idempotent.** Re-running over its own output is a no-op, so a resumed
267
+ * persist cannot stack footers.
268
+ * - **Silent on nothing-to-do.** No provenance in `from` returns `into`
269
+ * unchanged with `carried: false`, so a non-audit plan run is untouched.
270
+ *
271
+ * @param {{ from?: string, into?: string }} args — `from` is the provenance
272
+ * source (the seed markdown); `into` is the body being persisted.
273
+ * @returns {{ body: string, carried: boolean, fingerprints: string[], semanticKeys: string[] }}
274
+ * `body` is the augmented text; `fingerprints` / `semanticKeys` are the values
275
+ * newly carried (empty when there was nothing to carry).
276
+ */
277
+ export function carryProvenanceFooters({ from = '', into = '' } = {}) {
278
+ const body = typeof into === 'string' ? into : '';
279
+ const source = typeof from === 'string' ? from : '';
280
+
281
+ const have = new Set(parseFingerprintFooter(body));
282
+ const haveKeys = new Set(parseSemanticKeyFooter(body));
283
+ const fingerprints = parseFingerprintFooter(source).filter(
284
+ (sha) => !have.has(sha),
285
+ );
286
+ const semanticKeys = parseSemanticKeyFooter(source).filter(
287
+ (key) => !haveKeys.has(key),
288
+ );
289
+
290
+ if (fingerprints.length === 0 && semanticKeys.length === 0) {
291
+ return { body, carried: false, fingerprints: [], semanticKeys: [] };
292
+ }
293
+
294
+ const appended = [];
295
+ if (fingerprints.length > 0) appended.push(fingerprintFooter(fingerprints));
296
+ if (semanticKeys.length > 0) appended.push(semanticKeyFooter(semanticKeys));
297
+
298
+ const separator = body.length === 0 || body.endsWith('\n') ? '' : '\n';
299
+ return {
300
+ body: `${body}${separator}\n${appended.join('\n')}\n`,
301
+ carried: true,
302
+ fingerprints,
303
+ semanticKeys,
304
+ };
200
305
  }
201
306
 
202
307
  /**
@@ -378,4 +483,5 @@ export const __testing = {
378
483
  confirmCandidates,
379
484
  decideFromConfirmed,
380
485
  issueCarriesSemanticKey,
486
+ parseSemanticKeyFooter,
381
487
  };
@@ -40,10 +40,38 @@ export const SEVERITIES = Object.freeze([
40
40
  * malformed input — and because both the classify and promote paths share it,
41
41
  * malformed input still fingerprints identically across the two paths.
42
42
  */
43
- export const DEFAULT_SEVERITY = 'info';
43
+ const DEFAULT_SEVERITY = 'info';
44
44
 
45
45
  const SEVERITY_SET = new Set(SEVERITIES);
46
46
 
47
+ /**
48
+ * Non-canonical spellings that resolve onto a canonical level (Story #4877).
49
+ *
50
+ * The vocabulary drifted because four modules each carried their own partial
51
+ * copy of it: `audit-to-stories/parse-audit-md.js` recognised
52
+ * `critical|high|medium|mod|moderate|low` and nothing else, `audit-to-stories.js`
53
+ * ranked `critical|high|medium|low`, and `audit-to-stories/seed-from-findings.js`
54
+ * ordered the same four. None of them knew `info` — the canonical floor — so an
55
+ * `Info` / `Informational` finding parsed to `null`, tallied as `unknown`, and
56
+ * was dropped by EVERY severity-filtered run (including `--severity low`).
57
+ * Folding the alias table in here makes this module the only place the
58
+ * vocabulary is written down.
59
+ *
60
+ * Keys are already lower-cased and trimmed by {@link normalizeSeverity}.
61
+ *
62
+ * @type {Readonly<Record<string, string>>}
63
+ */
64
+ const SEVERITY_ALIASES = Object.freeze({
65
+ blocker: 'critical',
66
+ major: 'high',
67
+ mod: 'medium',
68
+ moderate: 'medium',
69
+ minor: 'low',
70
+ informational: 'info',
71
+ nit: 'info',
72
+ trivial: 'info',
73
+ });
74
+
47
75
  /**
48
76
  * Numeric rank for "highest severity wins" comparisons, derived from the
49
77
  * canonical order so the ranking has exactly one source. `critical` is the
@@ -74,7 +102,44 @@ export const SEVERITY_RANK = Object.freeze(
74
102
  export function normalizeSeverity(value, fallback = DEFAULT_SEVERITY) {
75
103
  if (typeof value !== 'string') return fallback;
76
104
  const normalized = value.trim().toLowerCase();
77
- return SEVERITY_SET.has(normalized) ? normalized : fallback;
105
+ if (SEVERITY_SET.has(normalized)) return normalized;
106
+ return SEVERITY_ALIASES[normalized] ?? fallback;
107
+ }
108
+
109
+ /**
110
+ * The **identity projection** of a severity, for use inside a finding
111
+ * fingerprint — and nowhere else (Story #4877).
112
+ *
113
+ * `severity` is a `fingerprintFinding` identity field
114
+ * (`route-finding.js`), so whatever this returns is folded into the sha that
115
+ * deduplicates findings against already-filed Issues. That makes the severity
116
+ * pipeline and the severity *identity* two different jobs with two different
117
+ * failure modes:
118
+ *
119
+ * - {@link normalizeSeverity} resolves an absent or unrecognised severity to
120
+ * `info` so downstream filtering and tallies have a level to work with.
121
+ * - This function must NOT. Folding `info` in where the previous
122
+ * implementation folded the empty string would re-mint the fingerprint of
123
+ * every finding that carries no severity, silently breaking dedup for all of
124
+ * them. An absent severity therefore stays the empty string, exactly as the
125
+ * raw `String(value).toLowerCase().trim()` it replaces produced.
126
+ *
127
+ * What it *does* change is alias resolution: `Informational` and `info` project
128
+ * onto the same `info`, so the fingerprint is **invariant** under the
129
+ * normalization this Story introduces — a finding hashes the same whether it is
130
+ * fingerprinted before or after {@link normalizeSeverity} has run over it. An
131
+ * unrecognised non-empty value is passed through verbatim rather than collapsed,
132
+ * again so no already-filed fingerprint moves.
133
+ *
134
+ * @param {unknown} value — the raw severity field off a finding.
135
+ * @returns {string} a canonical level, the empty string when absent, or the
136
+ * lower-cased raw value when it is neither canonical nor a known alias.
137
+ */
138
+ export function fingerprintSeverity(value) {
139
+ if (typeof value !== 'string') return '';
140
+ const raw = value.trim().toLowerCase();
141
+ if (raw.length === 0) return '';
142
+ return normalizeSeverity(raw, raw);
78
143
  }
79
144
 
80
145
  /**
@@ -99,3 +164,16 @@ export function highestSeverity(values) {
99
164
  }
100
165
  return best;
101
166
  }
167
+
168
+ /**
169
+ * Internals reached by the unit tests only. The floor and the alias table are
170
+ * consumed exclusively by this module's own exported functions, so they are
171
+ * not public API — but the alias table carries a structural invariant (no
172
+ * alias shadows a canonical level, every target IS one) that cannot be proven
173
+ * through `normalizeSeverity` alone, since that function returns a canonical
174
+ * value by construction.
175
+ */
176
+ export const __testing = {
177
+ DEFAULT_SEVERITY,
178
+ SEVERITY_ALIASES,
179
+ };
@@ -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
  /**