mandrel 2.31.0 → 2.32.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 (250) hide show
  1. package/.agents/README.md +13 -17
  2. package/.agents/agents/acceptance-critic.md +1 -2
  3. package/.agents/docs/SDLC.md +4 -4
  4. package/.agents/docs/agentrc-reference.json +61 -57
  5. package/.agents/docs/configuration.md +274 -227
  6. package/.agents/docs/execution-reference.md +13 -14
  7. package/.agents/docs/quality-gates.md +195 -23
  8. package/.agents/instructions.md +2 -5
  9. package/.agents/rules/git-conventions-reference.md +27 -27
  10. package/.agents/rules/git-conventions.md +4 -2
  11. package/.agents/rules/known-tooling-behavior.md +66 -30
  12. package/.agents/rules/testing-standards.md +35 -71
  13. package/.agents/runtime-deps.json +0 -1
  14. package/.agents/schemas/agentrc.schema.json +1939 -1400
  15. package/.agents/schemas/lifecycle/README.md +21 -14
  16. package/.agents/schemas/lifecycle/ledger-record.schema.json +76 -22
  17. package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
  18. package/.agents/scripts/README.md +7 -29
  19. package/.agents/scripts/apply-quality-bootstrap.js +27 -34
  20. package/.agents/scripts/bootstrap.js +28 -26
  21. package/.agents/scripts/check-baseline-drift.js +73 -13
  22. package/.agents/scripts/check-baseline-scope.js +362 -0
  23. package/.agents/scripts/check-dead-exports.js +9 -1
  24. package/.agents/scripts/check-gherkin-corpus.js +508 -0
  25. package/.agents/scripts/check-knip-entries.js +136 -0
  26. package/.agents/scripts/check-lifecycle-lint.js +36 -112
  27. package/.agents/scripts/check-schema-references.js +1 -1
  28. package/.agents/scripts/diagnose-friction.js +7 -4
  29. package/.agents/scripts/generate-config-docs.js +263 -171
  30. package/.agents/scripts/install-matrix-assert.js +0 -1
  31. package/.agents/scripts/lib/ITicketingProvider.js +0 -58
  32. package/.agents/scripts/lib/audit-baselines/staleness.js +6 -6
  33. package/.agents/scripts/lib/audit-baselines/trend.js +7 -8
  34. package/.agents/scripts/lib/audit-baselines/weights.js +4 -5
  35. package/.agents/scripts/lib/audit-suite/checklist-threading.js +1 -1
  36. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +0 -1
  37. package/.agents/scripts/lib/baselines/envelope.js +41 -60
  38. package/.agents/scripts/lib/baselines/git-base.js +30 -37
  39. package/.agents/scripts/lib/baselines/kinds/_crap-new-method-gate.js +103 -0
  40. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +150 -0
  41. package/.agents/scripts/lib/baselines/kinds/crap.js +25 -65
  42. package/.agents/scripts/lib/baselines/orphan-pruner.js +233 -0
  43. package/.agents/scripts/lib/baselines/refresh-service.js +6 -8
  44. package/.agents/scripts/lib/baselines/scope-assert.js +223 -0
  45. package/.agents/scripts/lib/baselines/scope-inventory.js +314 -0
  46. package/.agents/scripts/lib/bdd-step-index.js +326 -0
  47. package/.agents/scripts/lib/bootstrap/install-ledger.js +5 -3
  48. package/.agents/scripts/lib/bootstrap/issue-forms-template.js +4 -6
  49. package/.agents/scripts/lib/bootstrap/manifest.js +17 -40
  50. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +12 -59
  51. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +62 -2
  52. package/.agents/scripts/lib/checks/loop-health.js +9 -37
  53. package/.agents/scripts/lib/child-exec.js +193 -0
  54. package/.agents/scripts/lib/cli/standard-args.js +1 -1
  55. package/.agents/scripts/lib/cli-args.js +64 -0
  56. package/.agents/scripts/lib/close-validation/gates.js +2 -2
  57. package/.agents/scripts/lib/close-validation/runner.js +3 -3
  58. package/.agents/scripts/lib/config/acceptance-eval.js +5 -52
  59. package/.agents/scripts/lib/config/commands.js +3 -5
  60. package/.agents/scripts/lib/config/explain.js +5 -7
  61. package/.agents/scripts/lib/config/gates/bundle-size.schema.js +32 -6
  62. package/.agents/scripts/lib/config/gates/coverage.schema.js +25 -5
  63. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +12 -2
  64. package/.agents/scripts/lib/config/gates/crap.schema.js +68 -23
  65. package/.agents/scripts/lib/config/gates/duplication.schema.js +29 -17
  66. package/.agents/scripts/lib/config/gates/index.js +5 -2
  67. package/.agents/scripts/lib/config/gates/lighthouse.schema.js +34 -6
  68. package/.agents/scripts/lib/config/gates/lint.schema.js +11 -2
  69. package/.agents/scripts/lib/config/gates/maintainability.schema.js +37 -15
  70. package/.agents/scripts/lib/config/gates/mutation.schema.js +15 -3
  71. package/.agents/scripts/lib/config/gates/shared.js +58 -9
  72. package/.agents/scripts/lib/config/github.js +0 -1
  73. package/.agents/scripts/lib/config/limits.js +3 -48
  74. package/.agents/scripts/lib/config/qa.js +105 -0
  75. package/.agents/scripts/lib/config/temp-paths.js +6 -5
  76. package/.agents/scripts/lib/config-settings-schema-delivery.js +237 -56
  77. package/.agents/scripts/lib/config-settings-schema-quality.js +209 -29
  78. package/.agents/scripts/lib/config-settings-schema.js +386 -39
  79. package/.agents/scripts/lib/crap-baseline-join.js +126 -9
  80. package/.agents/scripts/lib/crap-utils.js +84 -520
  81. package/.agents/scripts/lib/dead-exports-knip.js +79 -10
  82. package/.agents/scripts/lib/degraded-mode.js +2 -2
  83. package/.agents/scripts/lib/doc-tiers.js +3 -3
  84. package/.agents/scripts/lib/feedback-loop/graduator-core.js +46 -104
  85. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +10 -8
  86. package/.agents/scripts/lib/fs-walk.js +52 -0
  87. package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
  88. package/.agents/scripts/lib/git-utils.js +16 -36
  89. package/.agents/scripts/lib/knip-entry-sync.js +469 -0
  90. package/.agents/scripts/lib/observability/metrics-ledger.js +1 -1
  91. package/.agents/scripts/lib/observability/runtime-friction.js +10 -0
  92. package/.agents/scripts/lib/observability/signal-validator.js +5 -85
  93. package/.agents/scripts/lib/observability/signals-writer.js +19 -62
  94. package/.agents/scripts/lib/observability/source-classifier.js +5 -7
  95. package/.agents/scripts/lib/observability/terse-result.js +3 -3
  96. package/.agents/scripts/lib/orchestration/behind-recovery.js +114 -0
  97. package/.agents/scripts/lib/orchestration/ceremony-routing.js +7 -8
  98. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +34 -33
  99. package/.agents/scripts/lib/orchestration/code-review.js +2 -2
  100. package/.agents/scripts/lib/orchestration/complexity-gate.js +43 -161
  101. package/.agents/scripts/lib/orchestration/diff-magnitude.js +4 -4
  102. package/.agents/scripts/lib/orchestration/label-transitions.js +3 -2
  103. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +12 -38
  104. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +5 -6
  105. package/.agents/scripts/lib/orchestration/plan-metrics.js +2 -3
  106. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +6 -0
  107. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +0 -1
  108. package/.agents/scripts/lib/orchestration/{lifecycle/listeners/watcher.js → pr-watch.js} +58 -208
  109. package/.agents/scripts/lib/orchestration/resolve-stories.js +5 -15
  110. package/.agents/scripts/lib/orchestration/review-providers/codex.js +1 -1
  111. package/.agents/scripts/lib/orchestration/review-providers/mi-exemptions.js +130 -0
  112. package/.agents/scripts/lib/orchestration/review-providers/native.js +30 -16
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +37 -26
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +376 -0
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +161 -151
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +15 -3
  118. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +10 -15
  119. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +5 -0
  120. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +157 -0
  121. package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +0 -14
  122. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +59 -25
  123. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +20 -31
  124. package/.agents/scripts/lib/orchestration/spec-spill.js +17 -3
  125. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +7 -6
  126. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +2 -1
  127. package/.agents/scripts/lib/orchestration/task-body-validator.js +4 -1
  128. package/.agents/scripts/lib/orchestration/ticket-lease.js +28 -127
  129. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -1
  130. package/.agents/scripts/lib/orchestration/ticketing/reads.js +5 -5
  131. package/.agents/scripts/lib/orchestration/ticketing/transition.js +5 -4
  132. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +107 -0
  133. package/.agents/scripts/lib/qa/coverage-verdict.js +5 -87
  134. package/.agents/scripts/lib/signals/detectors/common.js +1 -1
  135. package/.agents/scripts/lib/signals/index.js +8 -6
  136. package/.agents/scripts/lib/signals/schema.js +20 -25
  137. package/.agents/scripts/lib/signals/write.js +8 -8
  138. package/.agents/scripts/lib/story-body/story-body.js +12 -59
  139. package/.agents/scripts/lib/temp-retention.js +1 -1
  140. package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -14
  141. package/.agents/scripts/lib/ticket-body-sections.js +4 -5
  142. package/.agents/scripts/lib/worktree/lifecycle/merge-reachability.js +13 -45
  143. package/.agents/scripts/lib/worktree/lifecycle/reap.js +4 -5
  144. package/.agents/scripts/lib/worktree-manager.js +2 -3
  145. package/.agents/scripts/lint-label-vocabulary.js +2 -24
  146. package/.agents/scripts/pr-watch-with-update.js +7 -5
  147. package/.agents/scripts/providers/github/cache.js +2 -2
  148. package/.agents/scripts/providers/github/comments.js +6 -28
  149. package/.agents/scripts/providers/github/compose.js +0 -15
  150. package/.agents/scripts/providers/github/errors.js +10 -27
  151. package/.agents/scripts/providers/github/request-helpers.js +1 -2
  152. package/.agents/scripts/providers/github/sub-issues.js +10 -218
  153. package/.agents/scripts/providers/github.js +4 -7
  154. package/.agents/scripts/prune-baseline-orphans.js +181 -0
  155. package/.agents/scripts/resolve-stories.js +0 -2
  156. package/.agents/scripts/run-lint.js +61 -61
  157. package/.agents/scripts/run-test-profile.js +6 -6
  158. package/.agents/scripts/run-verify.js +48 -30
  159. package/.agents/scripts/single-story-close.js +20 -0
  160. package/.agents/scripts/single-story-init.js +12 -35
  161. package/.agents/scripts/update-dead-exports-baseline.js +321 -0
  162. package/.agents/skills/core/gates-and-baselines/SKILL.md +2 -2
  163. package/.agents/skills/skills.index.json +1 -11
  164. package/.agents/workflows/audit-documentation.md +5 -6
  165. package/.agents/workflows/audit-to-stories.md +2 -2
  166. package/.agents/workflows/helpers/audit-lens-core.md +11 -12
  167. package/.agents/workflows/helpers/code-quality-guardrails.md +15 -14
  168. package/.agents/workflows/helpers/code-review.md +3 -8
  169. package/.agents/workflows/helpers/deliver-reference.md +2 -1
  170. package/.agents/workflows/helpers/deliver-story-reference.md +27 -16
  171. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -2
  172. package/.agents/workflows/mandrel-update.md +10 -10
  173. package/.agents/workflows/qa-assist.md +15 -20
  174. package/.agents/workflows/qa-explore.md +9 -8
  175. package/README.md +1 -1
  176. package/docs/CHANGELOG.md +42 -0
  177. package/lib/migrations/index.js +2 -0
  178. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +127 -0
  179. package/package.json +12 -3
  180. package/.agents/schemas/lifecycle/checkpoint.written.schema.json +0 -13
  181. package/.agents/schemas/lifecycle/close-validate.end.schema.json +0 -18
  182. package/.agents/schemas/lifecycle/close-validate.start.schema.json +0 -13
  183. package/.agents/schemas/lifecycle/code-review.end.schema.json +0 -30
  184. package/.agents/schemas/lifecycle/code-review.start.schema.json +0 -12
  185. package/.agents/schemas/lifecycle/intervention.recorded.schema.json +0 -15
  186. package/.agents/schemas/lifecycle/loop.tick.schema.json +0 -20
  187. package/.agents/schemas/lifecycle/notification.emitted.schema.json +0 -18
  188. package/.agents/schemas/lifecycle/pr.created.schema.json +0 -14
  189. package/.agents/schemas/lifecycle/retro.end.schema.json +0 -16
  190. package/.agents/schemas/lifecycle/retro.start.schema.json +0 -12
  191. package/.agents/schemas/lifecycle/story.blocked.schema.json +0 -13
  192. package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +0 -17
  193. package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +0 -15
  194. package/.agents/schemas/lifecycle/story.merged.schema.json +0 -13
  195. package/.agents/scripts/check-gherkin-placeholders.js +0 -663
  196. package/.agents/scripts/check-lifecycle-doc-drift.js +0 -411
  197. package/.agents/scripts/lib/audit-suite/cli.js +0 -64
  198. package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +0 -202
  199. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +0 -212
  200. package/.agents/scripts/lib/checks/baseline-drift-main-checkout.js +0 -104
  201. package/.agents/scripts/lib/checks/push-hook-parity.js +0 -106
  202. package/.agents/scripts/lib/checks/windows-coverage-noise-floor.js +0 -92
  203. package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +0 -81
  204. package/.agents/scripts/lib/checks/worktree-residue-biome.js +0 -55
  205. package/.agents/scripts/lib/crap-baseline-index.js +0 -46
  206. package/.agents/scripts/lib/crap-utils-incremental.js +0 -113
  207. package/.agents/scripts/lib/dynamic-workflow/capability.js +0 -396
  208. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -335
  209. package/.agents/scripts/lib/mutation/baseline-snapshot.js +0 -239
  210. package/.agents/scripts/lib/mutation/config-detector.js +0 -119
  211. package/.agents/scripts/lib/mutation/stryker-runner.js +0 -306
  212. package/.agents/scripts/lib/mutation/survivor-report.js +0 -160
  213. package/.agents/scripts/lib/observability/active-story-env.js +0 -170
  214. package/.agents/scripts/lib/observability/tool-trace-hook.js +0 -456
  215. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +0 -111
  216. package/.agents/scripts/lib/orchestration/context-envelope.js +0 -277
  217. package/.agents/scripts/lib/orchestration/detectors-phase.js +0 -194
  218. package/.agents/scripts/lib/orchestration/lifecycle/bus.js +0 -309
  219. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +0 -181
  220. package/.agents/scripts/lib/orchestration/lifecycle/ledger-writer.js +0 -229
  221. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +0 -54
  222. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -344
  223. package/.agents/scripts/lib/orchestration/lint-baseline-service.js +0 -114
  224. package/.agents/scripts/lib/orchestration/pr-base-guard.js +0 -37
  225. package/.agents/scripts/lib/orchestration/resolves-token.js +0 -127
  226. package/.agents/scripts/lib/orchestration/spec-section-validator.js +0 -130
  227. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +0 -55
  228. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +0 -211
  229. package/.agents/scripts/lib/planning-corpus.js +0 -37
  230. package/.agents/scripts/lib/qa/coverage-report.js +0 -181
  231. package/.agents/scripts/lib/qa/propose-missing-test.js +0 -95
  232. package/.agents/scripts/lib/qa/qa-context-hydrator.js +0 -217
  233. package/.agents/scripts/lib/signals/detectors/index.js +0 -14
  234. package/.agents/scripts/lib/signals/detectors/retry.js +0 -253
  235. package/.agents/scripts/lib/signals/detectors/rework.js +0 -167
  236. package/.agents/scripts/lib/signals/read.js +0 -268
  237. package/.agents/scripts/lib/signals/span-tree.js +0 -291
  238. package/.agents/scripts/lib/story-lifecycle.js +0 -194
  239. package/.agents/scripts/lib/story-plan.js +0 -379
  240. package/.agents/scripts/lib/util/phase-timer-state.js +0 -72
  241. package/.agents/scripts/lib/util/phase-timer.js +0 -163
  242. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +0 -169
  243. package/.agents/scripts/lint-baseline.js +0 -507
  244. package/.agents/scripts/providers/github/prs.js +0 -103
  245. package/.agents/scripts/signals-view.js +0 -309
  246. package/.agents/scripts/story-plan.js +0 -370
  247. package/.agents/scripts/sync-branch-from-base.js +0 -149
  248. package/.agents/scripts/validate-docs-freshness.js +0 -314
  249. package/.agents/skills/core/diagnose-friction/SKILL.md +0 -78
  250. package/.agents/workflows/helpers/signals.md +0 -112
@@ -96,6 +96,7 @@ import {
96
96
  readPrMergeState as defaultReadPrMergeState,
97
97
  } from '../../../single-story/confirm-merge.js';
98
98
  import { pollUntil } from '../../../util/poll-loop.js';
99
+ import { applyBehindUpdate } from '../../behind-recovery.js';
99
100
  import {
100
101
  emitMergeFlipFailed as defaultEmitMergeFlipFailed,
101
102
  MERGED_FLIP_FAILED_BLOCK_CLASS,
@@ -635,7 +636,15 @@ async function blockOnUnlanded({
635
636
  /**
636
637
  * Bring a BEHIND PR up to date, bounded by `updateAttempts`. Best-effort:
637
638
  * a failed update is not itself a terminal — the next poll re-reads the
638
- * real state and lets the normal classification decide.
639
+ * real state and lets the normal classification decide, which is why a
640
+ * failed attempt still counts against the wait's tick.
641
+ *
642
+ * The BEHIND / budget / did-it-land decision itself lives in the shared
643
+ * {@link applyBehindUpdate} (Story #5006) — the CI-watch loop in
644
+ * `lib/orchestration/pr-watch.js` runs the same one. This wrapper supplies
645
+ * the merge wait's probe source, its `gh` facade (bounded by
646
+ * {@link withGhTimeout}, so a wedged child cannot strand an unattended
647
+ * async-mode wait), and its operator wording.
639
648
  *
640
649
  * @returns {Promise<boolean>} whether an update was actually attempted.
641
650
  */
@@ -648,31 +657,33 @@ async function maybeUpdateBehindPr({
648
657
  ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
649
658
  progress,
650
659
  }) {
651
- if (probe.mergeStateStatus !== 'BEHIND') return false;
652
- if (updatesUsed >= updateAttempts) {
653
- progress?.(
654
- 'CONFIRM',
655
- `⚠️ PR #${prNumber} is BEHIND but the update budget (${updateAttempts}) is spent — not updating again.`,
656
- );
657
- return false;
658
- }
659
- try {
660
- await withGhTimeout(
661
- (gh ?? defaultGh).pr.updateBranch(prNumber),
662
- ghTimeoutMs,
663
- `gh pr update-branch ${prNumber}`,
664
- );
665
- progress?.(
666
- 'CONFIRM',
667
- `⏫ PR #${prNumber} was BEHIND its base — updated (attempt ${updatesUsed + 1}/${updateAttempts}).`,
668
- );
669
- } catch (err) {
670
- progress?.(
671
- 'CONFIRM',
672
- `⚠️ gh pr update-branch failed (continuing): ${err?.message ?? err}`,
673
- );
674
- }
675
- return true;
660
+ const recovery = await applyBehindUpdate({
661
+ mergeStateStatus: probe.mergeStateStatus,
662
+ updatesUsed,
663
+ maxUpdates: updateAttempts,
664
+ updateBranch: () =>
665
+ withGhTimeout(
666
+ (gh ?? defaultGh).pr.updateBranch(prNumber),
667
+ ghTimeoutMs,
668
+ `gh pr update-branch ${prNumber}`,
669
+ ),
670
+ onBudgetSpent: () =>
671
+ progress?.(
672
+ 'CONFIRM',
673
+ `⚠️ PR #${prNumber} is BEHIND but the update budget (${updateAttempts}) is spent — not updating again.`,
674
+ ),
675
+ onUpdated: () =>
676
+ progress?.(
677
+ 'CONFIRM',
678
+ `⏫ PR #${prNumber} was BEHIND its base — updated (attempt ${updatesUsed + 1}/${updateAttempts}).`,
679
+ ),
680
+ onUpdateFailed: (detail) =>
681
+ progress?.(
682
+ 'CONFIRM',
683
+ `⚠️ gh pr update-branch failed (continuing): ${detail}`,
684
+ ),
685
+ });
686
+ return recovery.attempted;
676
687
  }
677
688
 
678
689
  /**
@@ -0,0 +1,376 @@
1
+ /**
2
+ * conventional-subject.js — the pure Conventional-Commit rules behind the
3
+ * squash-merge subject a standalone Story lands on `main`.
4
+ *
5
+ * Split out of `normalize-pr-title.js` so the rules that decide
6
+ * what the release notes say are testable without a git read. The three rules
7
+ * here each closed a defect observed live on `main`:
8
+ *
9
+ * 1. **Type precedence follows release impact.** A multi-commit Story used
10
+ * to resolve its type off a hand-ordered list in which `docs` outranked
11
+ * `chore` for no stated reason. Story #5004 (`chore(gates)`, `docs(ci)`,
12
+ * `chore(baselines)`) therefore landed as
13
+ * `docs: check/CI gate sweep …` — a change that deleted five
14
+ * `lib/checks` modules, two CLIs and a dependency, filed under
15
+ * documentation. `TYPE_RANK` now mirrors `changelog-sections` in
16
+ * `release-please-config.json`: rank IS how visible the type is in the
17
+ * release notes. Types that render identically (the six `hidden: true`
18
+ * ones) share a rank, because there is no honest ordering between them —
19
+ * those ties break on how much of the branch carries the type, then on
20
+ * the Story's primary (oldest) commit.
21
+ *
22
+ * 2. **Casing leaves acronyms alone.** The old synthesizer lowercased the
23
+ * first character unconditionally to satisfy commitlint's `subject-case`
24
+ * rule, which turned Story #5002's "CRAP surface diet" into
25
+ * `refactor: cRAP surface diet`. `shapeDescription` lowercases only when
26
+ * the leading word is not an all-caps token, so CRAP / CI / QA / API
27
+ * survive. That is a deliberate, narrow trade: commitlint's
28
+ * `subject-case` treats ANY leading capital as sentence-case and would
29
+ * flag `CRAP surface diet` — but commitlint never runs on a PR title or
30
+ * a GitHub squash subject (see `rules/git-conventions.md` §
31
+ * Conventional Commits), so the rule it would fail is not a gate this
32
+ * subject passes through, while a mangled acronym is permanent in the
33
+ * changelog.
34
+ *
35
+ * 3. **A breaking change survives the squash.** release-please only sees a
36
+ * breaking change through a `!` in the subject or a `BREAKING CHANGE:`
37
+ * footer. Story #5004 removed `project.commands.lintBaseline` from a
38
+ * schema block that is `additionalProperties: false` — a hard consumer
39
+ * break — described in prose in the commit body and therefore invisible
40
+ * to the parser. `collectBreakingNotes` reads the footer out of any
41
+ * constituent commit (or a Story-body declaration) and `markBreaking`
42
+ * puts the `!` where the parser looks.
43
+ */
44
+
45
+ /**
46
+ * The Conventional-Commit types Mandrel accepts. Mirrors
47
+ * `commitlint.config.js` → `type-enum` and `release-please-config.json` →
48
+ * `changelog-sections`. Kept in sync by hand (single hard-cutover, no
49
+ * shim) — adding a type means touching all three.
50
+ */
51
+ const CONVENTIONAL_TYPES = Object.freeze([
52
+ 'feat',
53
+ 'fix',
54
+ 'perf',
55
+ 'refactor',
56
+ 'revert',
57
+ 'docs',
58
+ 'style',
59
+ 'chore',
60
+ 'test',
61
+ 'build',
62
+ 'ci',
63
+ ]);
64
+
65
+ /**
66
+ * Release-impact rank per type — LOWER wins. Derived from
67
+ * `changelog-sections` in `release-please-config.json`: the five types that
68
+ * render a visible section are ordered by how much a reader needs to see
69
+ * them, and the six `hidden: true` types share the bottom rank because the
70
+ * release notes draw no distinction between them.
71
+ *
72
+ * @type {Readonly<Record<string, number>>}
73
+ */
74
+ const TYPE_RANK = Object.freeze({
75
+ feat: 0, // "Added"
76
+ fix: 1, // "Fixed"
77
+ perf: 2, // "Performance"
78
+ revert: 3, // "Reverted"
79
+ refactor: 4, // "Changed"
80
+ docs: 5, // hidden
81
+ style: 5, // hidden
82
+ chore: 5, // hidden
83
+ test: 5, // hidden
84
+ build: 5, // hidden
85
+ ci: 5, // hidden
86
+ });
87
+
88
+ /** Rank for a type absent from `TYPE_RANK` — always loses. */
89
+ const UNRANKED = Number.MAX_SAFE_INTEGER;
90
+
91
+ const TYPE_GROUP = CONVENTIONAL_TYPES.join('|');
92
+
93
+ // Anchored Conventional-Commit header matcher:
94
+ // <type>(<optional scope>)<optional !>: <non-empty description>
95
+ // Mirrors the shape `@commitlint/config-conventional` enforces (a known
96
+ // type, an optional parenthesised scope, an optional breaking `!`, a
97
+ // colon-space separator, and a non-empty subject). Used for the pure
98
+ // "is this already conventional?" check and to pull the type off a branch
99
+ // commit subject without spawning commitlint per call.
100
+ const CONVENTIONAL_HEADER_RE = new RegExp(
101
+ `^(?:${TYPE_GROUP})(?:\\([^()\\r\\n]+\\))?!?: \\S.*$`,
102
+ );
103
+ const LEADING_TYPE_RE = new RegExp(
104
+ `^(${TYPE_GROUP})(?:\\([^()\\r\\n]+\\))?(!?):`,
105
+ );
106
+ // Splits a conventional header into `<type><scope?>`, `<!?>`, `<description>`
107
+ // so the breaking marker can be inserted at the one position the parser reads.
108
+ const HEADER_PARTS_RE = new RegExp(
109
+ `^((?:${TYPE_GROUP})(?:\\([^()\\r\\n]+\\))?)(!?): (.*)$`,
110
+ );
111
+
112
+ /**
113
+ * The Conventional-Commits breaking footer, in both spellings the spec
114
+ * defines (`BREAKING CHANGE:` and the hyphenated `BREAKING-CHANGE:`). Case is
115
+ * significant — the spec requires uppercase, and so does
116
+ * `conventional-commits-parser`'s default `noteKeywords`, so matching
117
+ * case-insensitively here would announce breaks release-please will not.
118
+ */
119
+ const BREAKING_FOOTER_RE = /^BREAKING[ -]CHANGE:[ \t]*(.*)$/;
120
+
121
+ /** A git-trailer-shaped line (`Some-Token: value`) — ends a footer's text. */
122
+ const TRAILER_RE = /^[A-Za-z][A-Za-z-]*:[ \t]/;
123
+ /** An opening or closing markdown code fence: ``` or ~~~, optionally indented. */
124
+ const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
125
+
126
+ /**
127
+ * True iff `subject` is a parseable Conventional Commit subject under the
128
+ * repo's type vocabulary. Pure.
129
+ *
130
+ * @param {string} subject
131
+ * @returns {boolean}
132
+ */
133
+ export function isConventionalSubject(subject) {
134
+ if (typeof subject !== 'string') return false;
135
+ return CONVENTIONAL_HEADER_RE.test(subject.trim());
136
+ }
137
+
138
+ /**
139
+ * Extract the Conventional-Commit `type` from a single commit subject, or
140
+ * `null` when the subject is not conventional. Pure.
141
+ *
142
+ * @param {string} subject
143
+ * @returns {string|null}
144
+ */
145
+ function parseConventionalType(subject) {
146
+ if (typeof subject !== 'string') return null;
147
+ const match = subject.trim().match(LEADING_TYPE_RE);
148
+ return match ? match[1] : null;
149
+ }
150
+
151
+ /**
152
+ * Order two type candidates: release impact first, then how much of the
153
+ * branch carries the type, then the earliest commit that used it. The second
154
+ * and third keys only ever decide a tie inside the hidden tier — every
155
+ * visible type holds its rank alone.
156
+ *
157
+ * @param {{rank: number, count: number, firstIndex: number}} a
158
+ * @param {{rank: number, count: number, firstIndex: number}} b
159
+ * @returns {number}
160
+ */
161
+ function compareTypeCandidates(a, b) {
162
+ if (a.rank !== b.rank) return a.rank - b.rank;
163
+ if (a.count !== b.count) return b.count - a.count;
164
+ return a.firstIndex - b.firstIndex;
165
+ }
166
+
167
+ /**
168
+ * Pick the type the squash subject should carry from the branch's own commit
169
+ * subjects. Returns `null` when no subject is conventional.
170
+ *
171
+ * `subjects` MUST be oldest-first: the final tie-break reads index 0 as the
172
+ * Story's primary commit, the one whose type the operator chose before any
173
+ * fixup or baseline-refresh commit piled on.
174
+ *
175
+ * @param {string[]} subjects Commit subjects, oldest first.
176
+ * @returns {string|null}
177
+ */
178
+ export function pickDominantType(subjects) {
179
+ /** @type {Map<string, {type: string, rank: number, count: number, firstIndex: number}>} */
180
+ const candidates = new Map();
181
+ const list = Array.isArray(subjects) ? subjects : [];
182
+ list.forEach((subject, index) => {
183
+ const type = parseConventionalType(subject);
184
+ if (!type) return;
185
+ const seen = candidates.get(type);
186
+ if (seen) {
187
+ seen.count += 1;
188
+ return;
189
+ }
190
+ candidates.set(type, {
191
+ type,
192
+ rank: TYPE_RANK[type] ?? UNRANKED,
193
+ count: 1,
194
+ firstIndex: index,
195
+ });
196
+ });
197
+ if (candidates.size === 0) return null;
198
+ return [...candidates.values()].sort(compareTypeCandidates)[0].type;
199
+ }
200
+
201
+ /**
202
+ * True when the leading word of `text` is an all-caps token — an acronym the
203
+ * synthesizer must not touch. Requires two or more letters so a stray leading
204
+ * "A" or "I" still lowercases; punctuation and digits are ignored so
205
+ * `CRAP:`, `CI/CD` and `API-surface` all read as acronyms while `A11y` does
206
+ * not.
207
+ *
208
+ * @param {string} text
209
+ * @returns {boolean}
210
+ */
211
+ function leadsWithAcronym(text) {
212
+ const [word = ''] = text.split(/\s+/, 1);
213
+ const letters = word.replace(/[^A-Za-z]/g, '');
214
+ return letters.length >= 2 && letters === letters.toUpperCase();
215
+ }
216
+
217
+ /**
218
+ * Shape a human Story title into the description half of a synthesized
219
+ * Conventional-Commit subject: lowercase the first character so the subject
220
+ * reads as a sentence fragment, EXCEPT when the leading word is an acronym.
221
+ * Pure.
222
+ *
223
+ * @param {string} text
224
+ * @returns {string}
225
+ */
226
+ export function shapeDescription(text) {
227
+ const trimmed = typeof text === 'string' ? text.trim() : '';
228
+ if (trimmed.length === 0) return trimmed;
229
+ if (leadsWithAcronym(trimmed)) return trimmed;
230
+ return trimmed.charAt(0).toLowerCase() + trimmed.slice(1);
231
+ }
232
+
233
+ /**
234
+ * Insert the breaking-change `!` into a conventional subject, at the one
235
+ * position `conventional-commits-parser` reads it: immediately before the
236
+ * colon, after any scope. A subject that already carries `!`, or that is not
237
+ * conventional, is returned unchanged. Pure.
238
+ *
239
+ * @param {string} subject
240
+ * @returns {string}
241
+ */
242
+ export function markBreaking(subject) {
243
+ const parts = String(subject ?? '').match(HEADER_PARTS_RE);
244
+ if (!parts) return subject;
245
+ const [, prefix, bang, description] = parts;
246
+ if (bang === '!') return subject;
247
+ return `${prefix}!: ${description}`;
248
+ }
249
+
250
+ /**
251
+ * Pull the text of one `BREAKING CHANGE:` footer out of `lines`, starting at
252
+ * the footer line itself. The note runs to the end of its paragraph: a blank
253
+ * line, another trailer, or the end of the message closes it.
254
+ *
255
+ * @param {string[]} lines
256
+ * @param {number} start Index of the matched footer line.
257
+ * @param {string} head The footer line's own text (may be empty).
258
+ * @returns {{ note: string, next: number }}
259
+ */
260
+ function readFooterNote(lines, start, head) {
261
+ const collected = head.trim().length > 0 ? [head.trim()] : [];
262
+ let cursor = start + 1;
263
+ while (cursor < lines.length) {
264
+ const line = lines[cursor];
265
+ if (line.trim().length === 0) break;
266
+ if (BREAKING_FOOTER_RE.test(line) || TRAILER_RE.test(line)) break;
267
+ collected.push(line.trim());
268
+ cursor += 1;
269
+ }
270
+ return { note: collected.join(' ').trim(), next: cursor };
271
+ }
272
+
273
+ /**
274
+ * Scan one commit message (or the Story body) for breaking-change evidence.
275
+ *
276
+ * @param {string} text
277
+ * @param {boolean} readHeaderBang Whether line 0 is a commit header whose `!`
278
+ * counts. False for the Story body, which has no header.
279
+ * @returns {{ breaking: boolean, notes: string[], subject: string|null }}
280
+ */
281
+ /**
282
+ * Blank every line inside a markdown code fence, keeping line indices intact.
283
+ *
284
+ * A Story `## Spec` that documents this very contract quotes the footer in a
285
+ * fence; reading that as a declaration ships a `<type>!:` subject and a release
286
+ * note for a break nobody made. Fenced spans are the only place the anchored
287
+ * footer regex can fire on non-footer text — an indented block or a blockquote
288
+ * already fails the `^` anchor. Blanking rather than dropping means a fence
289
+ * also closes an open footer note, which is what a paragraph break would do.
290
+ *
291
+ * @param {string[]} lines
292
+ * @returns {string[]}
293
+ */
294
+ function blankFencedLines(lines) {
295
+ let fence = null;
296
+ return lines.map((line) => {
297
+ const marker = line.match(FENCE_RE)?.[1][0];
298
+ if (!marker) return fence === null ? line : '';
299
+ if (fence === null) fence = marker;
300
+ else if (fence === marker) fence = null;
301
+ return '';
302
+ });
303
+ }
304
+
305
+ function scanForBreaking(text, readHeaderBang) {
306
+ const lines = blankFencedLines(String(text ?? '').split('\n'));
307
+ const notes = [];
308
+ let breaking = false;
309
+ let subject = null;
310
+
311
+ if (readHeaderBang) {
312
+ const header = lines[0]?.trim() ?? '';
313
+ const parts = header.match(HEADER_PARTS_RE);
314
+ if (parts?.[2] === '!') {
315
+ breaking = true;
316
+ subject = parts[3].trim();
317
+ }
318
+ }
319
+
320
+ let cursor = 0;
321
+ while (cursor < lines.length) {
322
+ const match = lines[cursor].match(BREAKING_FOOTER_RE);
323
+ if (!match) {
324
+ cursor += 1;
325
+ continue;
326
+ }
327
+ breaking = true;
328
+ const { note, next } = readFooterNote(lines, cursor, match[1]);
329
+ if (note.length > 0) notes.push(note);
330
+ cursor = Math.max(next, cursor + 1);
331
+ }
332
+
333
+ return { breaking, notes, subject };
334
+ }
335
+
336
+ /**
337
+ * Collect the branch's breaking-change declarations.
338
+ *
339
+ * Two sources, both honoured:
340
+ *
341
+ * - **Any constituent commit** — a `BREAKING CHANGE:` / `BREAKING-CHANGE:`
342
+ * footer, or a `!` in the header. This is the path a maker takes while
343
+ * writing the commit that does the breaking.
344
+ * - **The Story body** — the same footer, written as its own line anywhere
345
+ * in the Story issue (the `## Spec` block is the natural home). This is
346
+ * the declarative path: `/plan` can state the break up front and close
347
+ * propagates it even when no individual commit remembered the footer.
348
+ * Only the footer form counts; prose describing a break does not, because
349
+ * a keyword-free sentence is exactly what release-please cannot parse.
350
+ *
351
+ * When something is breaking but no footer supplied text, the `!` commit's
352
+ * own description becomes the note — the fallback Conventional Commits
353
+ * itself prescribes for a `!` header with no footer.
354
+ *
355
+ * @param {{ commitMessages?: string[], storyBody?: string }} args
356
+ * @returns {{ breaking: boolean, notes: string[] }}
357
+ */
358
+ export function collectBreakingNotes({ commitMessages = [], storyBody = '' }) {
359
+ const notes = [];
360
+ let breaking = false;
361
+ let bangSubject = null;
362
+
363
+ for (const message of Array.isArray(commitMessages) ? commitMessages : []) {
364
+ const scan = scanForBreaking(message, true);
365
+ breaking = breaking || scan.breaking;
366
+ notes.push(...scan.notes);
367
+ bangSubject ??= scan.subject;
368
+ }
369
+
370
+ const bodyScan = scanForBreaking(storyBody, false);
371
+ breaking = breaking || bodyScan.breaking;
372
+ notes.push(...bodyScan.notes);
373
+
374
+ if (breaking && notes.length === 0 && bangSubject) notes.push(bangSubject);
375
+ return { breaking, notes: [...new Set(notes)] };
376
+ }