infra-kit 0.1.102 → 0.1.107

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 (147) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-0a58307d-2a37-4c69-851c-83a646502d62.jsonl +1 -0
  3. package/.omc/state/agent-replay-11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc.jsonl +16 -0
  4. package/.omc/state/agent-replay-4cf1c186-81b2-497c-b002-d7f84e7839f3.jsonl +9 -0
  5. package/.omc/state/agent-replay-5c4ab554-64f1-42ae-83e3-21e0237e955c.jsonl +11 -0
  6. package/.omc/state/agent-replay-a60ac2ec-afbd-449f-a540-6df287392fc2.jsonl +1 -0
  7. package/.omc/state/agent-replay-afc6290b-40d3-4bef-b3b6-14484c034ab9.jsonl +14 -0
  8. package/.omc/state/agent-replay-be37e426-6fc8-47f4-8178-221c8494551c.jsonl +3 -0
  9. package/.omc/state/agent-replay-c967c819-3d1c-447b-ab48-56a8448ef9f8.jsonl +2 -0
  10. package/.omc/state/agent-replay-e947a3c6-989d-4a60-91dd-6b0ddd827b2d.jsonl +3 -0
  11. package/.omc/state/idle-notif-cooldown.json +3 -0
  12. package/.omc/state/last-tool-error.json +4 -4
  13. package/.omc/state/mission-state.json +53 -0
  14. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  15. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/subagent-tracking-state.json +7 -0
  16. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/last-tool-error-state.json +7 -0
  17. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/mission-state.json +117 -0
  18. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/pre-tool-advisory-throttle.json +42 -0
  19. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/subagent-tracking-state.json +53 -0
  20. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/last-tool-error-state.json +7 -0
  21. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/pre-tool-advisory-throttle.json +18 -0
  22. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/subagent-tracking-state.json +7 -0
  23. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/mission-state.json +117 -0
  24. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/pre-tool-advisory-throttle.json +18 -0
  25. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/subagent-tracking-state.json +17 -0
  26. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +18 -0
  27. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/subagent-tracking-state.json +7 -0
  28. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/last-tool-error-state.json +7 -0
  29. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/mission-state.json +89 -0
  30. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/pre-tool-advisory-throttle.json +34 -0
  31. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/ralph-state.json +13 -0
  32. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/skill-active-state.json +15 -0
  33. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/subagent-tracking-state.json +35 -0
  34. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/ultrawork-state.json +11 -0
  35. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +10 -0
  36. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/subagent-tracking-state.json +7 -0
  37. package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/last-tool-error-state.json +7 -0
  38. package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/pre-tool-advisory-throttle.json +10 -0
  39. package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/subagent-tracking-state.json +26 -0
  40. package/.omc/state/subagent-tracking.json +14 -4
  41. package/.turbo/turbo-build.log +7 -0
  42. package/.turbo/turbo-check.log +14 -0
  43. package/.turbo/turbo-prettier-fix.log +2 -1
  44. package/.turbo/turbo-test.log +28 -5
  45. package/.turbo/turbo-validate.log +14 -0
  46. package/dist/cli.js +88 -74
  47. package/dist/cli.js.map +4 -4
  48. package/dist/entry/index.d.ts +2 -0
  49. package/dist/index.js +2 -0
  50. package/dist/index.js.map +7 -0
  51. package/dist/lib/package-config/package-config.d.ts +71 -0
  52. package/dist/mcp.js +43 -41
  53. package/dist/mcp.js.map +4 -4
  54. package/eslint.config.js +1 -1
  55. package/infra-kit.config.ts +5 -0
  56. package/package.json +20 -13
  57. package/scripts/build.js +32 -3
  58. package/src/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  59. package/src/commands/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/pre-tool-advisory-throttle.json +14 -0
  60. package/src/commands/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +18 -0
  61. package/src/commands/audit/__tests__/audit.test.ts +59 -0
  62. package/src/commands/audit/audit.ts +177 -0
  63. package/src/commands/audit/index.ts +1 -0
  64. package/src/commands/config/config.ts +49 -7
  65. package/src/commands/doctor/__tests__/agent-files.test.ts +110 -0
  66. package/src/commands/doctor/doctor.ts +69 -4
  67. package/src/commands/env-clear/env-clear.ts +1 -1
  68. package/src/commands/env-list/env-list.ts +3 -3
  69. package/src/commands/env-load/env-load.ts +1 -1
  70. package/src/commands/env-status/env-status.ts +1 -1
  71. package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -8
  72. package/src/commands/gh-release-deliver/gh-release-deliver.ts +47 -21
  73. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +13 -7
  74. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +12 -6
  75. package/src/commands/gh-release-list/gh-release-list.ts +19 -8
  76. package/src/commands/init/__tests__/agent-files.test.ts +147 -0
  77. package/src/commands/init/__tests__/migrate-config.test.ts +160 -0
  78. package/src/commands/init/agent-files.ts +199 -0
  79. package/src/commands/init/index.ts +7 -0
  80. package/src/commands/init/init.ts +82 -60
  81. package/src/commands/init/migrate-config.ts +146 -0
  82. package/src/commands/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  83. package/src/commands/release-create/__tests__/release-create.test.ts +55 -0
  84. package/src/commands/release-create/release-create.ts +142 -38
  85. package/src/commands/release-desc-edit/release-desc-edit.ts +28 -8
  86. package/src/commands/version/version.ts +1 -1
  87. package/src/commands/worktrees-add/worktrees-add.ts +7 -12
  88. package/src/commands/worktrees-list/worktrees-list.ts +13 -5
  89. package/src/commands/worktrees-open/worktrees-open.ts +1 -1
  90. package/src/commands/worktrees-remove/worktrees-remove.ts +6 -10
  91. package/src/commands/worktrees-sync/worktrees-sync.ts +3 -5
  92. package/src/entry/cli.ts +50 -7
  93. package/src/entry/index.ts +5 -0
  94. package/src/integrations/cmux/open-workspace-with-layout.ts +4 -4
  95. package/src/integrations/cmux/workspace-title.ts +10 -4
  96. package/src/integrations/doppler/doppler-project.ts +1 -1
  97. package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +115 -0
  98. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +49 -32
  99. package/src/lib/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +14 -0
  100. package/src/lib/constants/index.ts +15 -0
  101. package/src/lib/git-utils/__tests__/git-utils.test.ts +49 -0
  102. package/src/lib/git-utils/git-utils.ts +3 -1
  103. package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +270 -0
  104. package/src/lib/infra-kit-config/index.ts +7 -1
  105. package/src/lib/infra-kit-config/infra-kit-config.ts +46 -28
  106. package/src/lib/managed-block/__tests__/managed-block.test.ts +121 -0
  107. package/src/lib/managed-block/index.ts +8 -0
  108. package/src/lib/managed-block/managed-block.ts +145 -0
  109. package/src/lib/package-config/__tests__/package-config.test.ts +95 -0
  110. package/src/lib/package-config/index.ts +3 -0
  111. package/src/lib/package-config/package-config-schema.ts +19 -0
  112. package/src/lib/package-config/package-config.ts +99 -0
  113. package/src/lib/package-validator/__tests__/package-validator.test.ts +263 -0
  114. package/src/lib/package-validator/checks/__tests__/checks.test.ts +130 -0
  115. package/src/lib/package-validator/checks/config-check.ts +30 -0
  116. package/src/lib/package-validator/checks/files-check.ts +29 -0
  117. package/src/lib/package-validator/checks/index.ts +4 -0
  118. package/src/lib/package-validator/checks/scripts-check.ts +23 -0
  119. package/src/lib/package-validator/checks/turbo-check.ts +47 -0
  120. package/src/lib/package-validator/fs-utils.ts +18 -0
  121. package/src/lib/package-validator/index.ts +3 -0
  122. package/src/lib/package-validator/loader/config-loader.ts +77 -0
  123. package/src/lib/package-validator/loader/index.ts +2 -0
  124. package/src/lib/package-validator/loader/package-discovery.ts +98 -0
  125. package/src/lib/package-validator/package-validator.ts +48 -0
  126. package/src/lib/package-validator/types.ts +15 -0
  127. package/src/lib/release-id/__tests__/release-id.test.ts +351 -0
  128. package/src/lib/release-id/__tests__/versioned-regression.test.ts +69 -0
  129. package/src/lib/release-id/index.ts +15 -0
  130. package/src/lib/release-id/release-id.ts +257 -0
  131. package/src/lib/release-utils/__tests__/release-utils.test.ts +122 -0
  132. package/src/lib/release-utils/index.ts +4 -0
  133. package/src/lib/release-utils/release-utils.ts +85 -17
  134. package/src/lib/version-utils/__tests__/load-existing-versions.test.ts +37 -0
  135. package/src/lib/version-utils/__tests__/next-version.test.ts +119 -13
  136. package/src/lib/version-utils/index.ts +3 -0
  137. package/src/lib/version-utils/load-existing-versions.ts +29 -10
  138. package/src/lib/version-utils/next-version.ts +67 -12
  139. package/src/lib/version-utils/version-utils.ts +13 -4
  140. package/src/mcp/tools/index.ts +2 -0
  141. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  142. package/src/types.ts +1 -1
  143. package/tsconfig.tsbuildinfo +1 -1
  144. package/src/lib/__tests__/infra-kit-config.test.ts +0 -231
  145. /package/src/integrations/{clickup → linear}/.gitkeep +0 -0
  146. /package/src/lib/{__tests__ → constants/__tests__}/constants.test.ts +0 -0
  147. /package/src/lib/{constants.ts → constants/constants.ts} +0 -0
@@ -0,0 +1,351 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ InvalidReleaseNameError,
5
+ InvalidReleaseRefError,
6
+ compareReleaseIds,
7
+ displayLabel,
8
+ formatBranchName,
9
+ formatJiraName,
10
+ formatPrTitle,
11
+ formatRcTitle,
12
+ isReleaseBranch,
13
+ parseBranchName,
14
+ parseReleaseRef,
15
+ validateName,
16
+ } from '../release-id'
17
+ import type { ReleaseId } from '../release-id'
18
+
19
+ const version = (major: number, minor: number, patch: number): ReleaseId => {
20
+ return { kind: 'version', semver: { major, minor, patch }, raw: `${major}.${minor}.${patch}` }
21
+ }
22
+
23
+ const name = (n: string): ReleaseId => {
24
+ return { kind: 'name', name: n, raw: n }
25
+ }
26
+
27
+ describe('parseBranchName', () => {
28
+ it('parses release/v<semver> as a version', () => {
29
+ expect(parseBranchName('release/v1.2.3')).toEqual(version(1, 2, 3))
30
+ })
31
+
32
+ it('parses release/n/<name> as a name', () => {
33
+ expect(parseBranchName('release/n/checkout-redesign')).toEqual(name('checkout-redesign'))
34
+ })
35
+
36
+ it('strips a leading refs/heads/ prefix for versions and names', () => {
37
+ expect(parseBranchName('refs/heads/release/v2.0.1')).toEqual(version(2, 0, 1))
38
+ expect(parseBranchName('refs/heads/release/n/my-feature')).toEqual(name('my-feature'))
39
+ })
40
+
41
+ it('trims surrounding whitespace', () => {
42
+ expect(parseBranchName(' release/v1.0.0 ')).toEqual(version(1, 0, 0))
43
+ })
44
+
45
+ it('returns null for non-release branches', () => {
46
+ expect(parseBranchName('feature/x')).toBeNull()
47
+ expect(parseBranchName('main')).toBeNull()
48
+ expect(parseBranchName('dev')).toBeNull()
49
+ })
50
+
51
+ it('returns null for junk under the release/ prefix', () => {
52
+ expect(parseBranchName('release/foo')).toBeNull()
53
+ expect(parseBranchName('release/garbage')).toBeNull()
54
+ })
55
+
56
+ it('returns null for incomplete version branches', () => {
57
+ expect(parseBranchName('release/v1.2')).toBeNull()
58
+ expect(parseBranchName('release/v1')).toBeNull()
59
+ expect(parseBranchName('release/v1.2.3.4')).toBeNull()
60
+ expect(parseBranchName('release/vfoo')).toBeNull()
61
+ })
62
+
63
+ it('returns null for invalid names', () => {
64
+ expect(parseBranchName('release/n/Bad_Name')).toBeNull()
65
+ expect(parseBranchName('release/n/UPPER')).toBeNull()
66
+ expect(parseBranchName('release/n/main')).toBeNull()
67
+ expect(parseBranchName('release/n/')).toBeNull()
68
+ })
69
+
70
+ it('never throws on arbitrary input', () => {
71
+ expect(() => {
72
+ return parseBranchName('')
73
+ }).not.toThrow()
74
+ expect(() => {
75
+ return parseBranchName('!!!')
76
+ }).not.toThrow()
77
+ expect(parseBranchName('')).toBeNull()
78
+ })
79
+ })
80
+
81
+ describe('parseReleaseRef', () => {
82
+ it('delegates a release branch ref to parseBranchName', () => {
83
+ expect(parseReleaseRef('release/v1.2.3')).toEqual(version(1, 2, 3))
84
+ expect(parseReleaseRef('release/n/checkout-redesign')).toEqual(name('checkout-redesign'))
85
+ expect(parseReleaseRef('refs/heads/release/v3.4.5')).toEqual(version(3, 4, 5))
86
+ })
87
+
88
+ it('throws when a release branch ref is invalid', () => {
89
+ expect(() => {
90
+ return parseReleaseRef('release/garbage')
91
+ }).toThrow(InvalidReleaseRefError)
92
+ expect(() => {
93
+ return parseReleaseRef('release/v1.2')
94
+ }).toThrow(InvalidReleaseRefError)
95
+ expect(() => {
96
+ return parseReleaseRef('release/n/Bad_Name')
97
+ }).toThrow(InvalidReleaseRefError)
98
+ })
99
+
100
+ it('parses a bare semver token as a version', () => {
101
+ expect(parseReleaseRef('1.2.3')).toEqual(version(1, 2, 3))
102
+ })
103
+
104
+ it('parses a v-prefixed semver token as a version (normalized raw)', () => {
105
+ expect(parseReleaseRef('v1.2.3')).toEqual(version(1, 2, 3))
106
+ expect(parseReleaseRef('v1.2.3').raw).toBe('1.2.3')
107
+ })
108
+
109
+ it('parses a kebab name as a name', () => {
110
+ expect(parseReleaseRef('checkout-redesign')).toEqual(name('checkout-redesign'))
111
+ })
112
+
113
+ it('throws on the next token (must be resolved before ref parsing)', () => {
114
+ expect(() => {
115
+ return parseReleaseRef('next')
116
+ }).toThrow(InvalidReleaseRefError)
117
+ expect(() => {
118
+ return parseReleaseRef('next')
119
+ }).toThrow(/computeNextVersion/)
120
+ })
121
+
122
+ it('throws on an invalid token with the validation reason', () => {
123
+ expect(() => {
124
+ return parseReleaseRef('Bad_Name')
125
+ }).toThrow(InvalidReleaseRefError)
126
+ expect(() => {
127
+ return parseReleaseRef('main')
128
+ }).toThrow(InvalidReleaseRefError)
129
+ })
130
+
131
+ it('trims input before classifying', () => {
132
+ expect(parseReleaseRef(' 1.2.3 ')).toEqual(version(1, 2, 3))
133
+ expect(parseReleaseRef(' checkout-redesign ')).toEqual(name('checkout-redesign'))
134
+ })
135
+ })
136
+
137
+ describe('validateName', () => {
138
+ it('accepts simple kebab-case names', () => {
139
+ expect(() => {
140
+ return validateName('a')
141
+ }).not.toThrow()
142
+ expect(() => {
143
+ return validateName('a-b-c')
144
+ }).not.toThrow()
145
+ expect(() => {
146
+ return validateName('1-2-3')
147
+ }).not.toThrow()
148
+ expect(() => {
149
+ return validateName('checkout-redesign')
150
+ }).not.toThrow()
151
+ })
152
+
153
+ it('rejects non-kebab tokens', () => {
154
+ expect(() => {
155
+ return validateName('-a')
156
+ }).toThrow(InvalidReleaseNameError)
157
+ expect(() => {
158
+ return validateName('a-')
159
+ }).toThrow(InvalidReleaseNameError)
160
+ expect(() => {
161
+ return validateName('a--b')
162
+ }).toThrow(InvalidReleaseNameError)
163
+ expect(() => {
164
+ return validateName('A-b')
165
+ }).toThrow(InvalidReleaseNameError)
166
+ expect(() => {
167
+ return validateName('a_b')
168
+ }).toThrow(InvalidReleaseNameError)
169
+ })
170
+
171
+ it('rejects an empty name', () => {
172
+ expect(() => {
173
+ return validateName('')
174
+ }).toThrow(InvalidReleaseNameError)
175
+ })
176
+
177
+ it('rejects each reserved word', () => {
178
+ for (const reserved of ['dev', 'main', 'next', 'hotfix', 'regular', 'release']) {
179
+ expect(() => {
180
+ return validateName(reserved)
181
+ }).toThrow(InvalidReleaseNameError)
182
+ }
183
+ })
184
+
185
+ it('rejects a name of length 51 but accepts length 50', () => {
186
+ expect(() => {
187
+ return validateName('a'.repeat(50))
188
+ }).not.toThrow()
189
+ expect(() => {
190
+ return validateName('a'.repeat(51))
191
+ }).toThrow(InvalidReleaseNameError)
192
+ })
193
+
194
+ it('rejects semver-looking tokens via the kebab rule', () => {
195
+ expect(() => {
196
+ return validateName('1.2.3')
197
+ }).toThrow(InvalidReleaseNameError)
198
+ expect(() => {
199
+ return validateName('v1.2.3')
200
+ }).toThrow(InvalidReleaseNameError)
201
+ })
202
+ })
203
+
204
+ describe('formatBranchName', () => {
205
+ it('formats versions and names', () => {
206
+ expect(formatBranchName(version(1, 2, 3))).toBe('release/v1.2.3')
207
+ expect(formatBranchName(name('checkout-redesign'))).toBe('release/n/checkout-redesign')
208
+ })
209
+ })
210
+
211
+ describe('formatPrTitle', () => {
212
+ it('formats versioned regular and hotfix titles', () => {
213
+ expect(formatPrTitle(version(1, 2, 3), 'regular')).toBe('Release v1.2.3')
214
+ expect(formatPrTitle(version(1, 2, 3), 'hotfix')).toBe('Hotfix v1.2.3')
215
+ })
216
+
217
+ it('formats named regular and hotfix titles', () => {
218
+ expect(formatPrTitle(name('checkout-redesign'), 'regular')).toBe('Release checkout-redesign')
219
+ expect(formatPrTitle(name('checkout-redesign'), 'hotfix')).toBe('Hotfix checkout-redesign')
220
+ })
221
+ })
222
+
223
+ describe('formatRcTitle', () => {
224
+ it('formats versioned and named RC titles', () => {
225
+ expect(formatRcTitle(version(1, 2, 3))).toBe('Release v1.2.3 (RC)')
226
+ expect(formatRcTitle(name('checkout-redesign'))).toBe('Release checkout-redesign (RC)')
227
+ })
228
+ })
229
+
230
+ describe('formatJiraName', () => {
231
+ it('formats versioned and named Jira names', () => {
232
+ expect(formatJiraName(version(1, 2, 3))).toBe('v1.2.3')
233
+ expect(formatJiraName(name('checkout-redesign'))).toBe('checkout-redesign')
234
+ })
235
+ })
236
+
237
+ describe('displayLabel', () => {
238
+ it('formats versioned and named labels', () => {
239
+ expect(displayLabel(version(1, 2, 3))).toBe('1.2.3')
240
+ expect(displayLabel(name('checkout-redesign'))).toBe('checkout-redesign')
241
+ })
242
+ })
243
+
244
+ describe('isReleaseBranch', () => {
245
+ it('returns true for both branch schemes', () => {
246
+ expect(isReleaseBranch('release/v1.2.3')).toBe(true)
247
+ expect(isReleaseBranch('release/n/checkout-redesign')).toBe(true)
248
+ expect(isReleaseBranch('refs/heads/release/v1.2.3')).toBe(true)
249
+ })
250
+
251
+ it('returns false for junk', () => {
252
+ expect(isReleaseBranch('feature/x')).toBe(false)
253
+ expect(isReleaseBranch('release/foo')).toBe(false)
254
+ expect(isReleaseBranch('main')).toBe(false)
255
+ })
256
+
257
+ it('returns false for null and undefined', () => {
258
+ expect(isReleaseBranch(null)).toBe(false)
259
+ expect(isReleaseBranch(undefined)).toBe(false)
260
+ })
261
+ })
262
+
263
+ describe('compareReleaseIds', () => {
264
+ it('sorts pure-version arrays in numeric semver order (not lexicographic)', () => {
265
+ const ids = [version(1, 10, 0), version(1, 9, 0), version(2, 0, 0), version(1, 9, 5)]
266
+ const sorted = [...ids].sort((a, b) => {
267
+ return compareReleaseIds(a, b)
268
+ })
269
+
270
+ expect(sorted).toEqual([version(1, 9, 0), version(1, 9, 5), version(1, 10, 0), version(2, 0, 0)])
271
+ })
272
+
273
+ it('matches existing semver order byte-for-byte for the all-versioned case', () => {
274
+ const raws = [version(1, 62, 0), version(1, 64, 5), version(1, 63, 0)]
275
+ .sort((a, b) => {
276
+ return compareReleaseIds(a, b)
277
+ })
278
+ .map((id) => {
279
+ return displayLabel(id)
280
+ })
281
+
282
+ expect(raws).toEqual(['1.62.0', '1.63.0', '1.64.5'])
283
+ })
284
+
285
+ it('places all names after all versions regardless of comparison order', () => {
286
+ expect(compareReleaseIds(version(9, 9, 9), name('aaa'))).toBeLessThan(0)
287
+ expect(compareReleaseIds(name('aaa'), version(0, 0, 0))).toBeGreaterThan(0)
288
+ })
289
+
290
+ it('produces a versions-block-then-names-block when sorted', () => {
291
+ const ids = [name('zeta'), version(2, 0, 0), name('alpha'), version(1, 0, 0)]
292
+ const sorted = [...ids].sort((a, b) => {
293
+ return compareReleaseIds(a, b)
294
+ })
295
+
296
+ expect(sorted).toEqual([version(1, 0, 0), version(2, 0, 0), name('alpha'), name('zeta')])
297
+ })
298
+
299
+ it('orders names by date ascending when both dates are provided', () => {
300
+ const result = compareReleaseIds(name('zzz'), name('aaa'), {
301
+ a: '2026-01-01T00:00:00Z',
302
+ b: '2026-02-01T00:00:00Z',
303
+ })
304
+
305
+ // zzz is earlier by date, so it sorts first despite later lexicographically.
306
+ expect(result).toBeLessThan(0)
307
+ })
308
+
309
+ it('accepts Date objects for name dates', () => {
310
+ const result = compareReleaseIds(name('aaa'), name('zzz'), {
311
+ a: new Date('2026-03-01T00:00:00Z'),
312
+ b: new Date('2026-01-01T00:00:00Z'),
313
+ })
314
+
315
+ // aaa is later by date, so it sorts after zzz.
316
+ expect(result).toBeGreaterThan(0)
317
+ })
318
+
319
+ it('falls back to lexicographic name order when dates are absent', () => {
320
+ expect(compareReleaseIds(name('alpha'), name('beta'))).toBeLessThan(0)
321
+ expect(compareReleaseIds(name('beta'), name('alpha'))).toBeGreaterThan(0)
322
+ expect(compareReleaseIds(name('same'), name('same'))).toBe(0)
323
+ })
324
+
325
+ it('falls back to lexicographic order when only one date is present', () => {
326
+ expect(compareReleaseIds(name('alpha'), name('beta'), { a: '2026-05-01T00:00:00Z' })).toBeLessThan(0)
327
+ })
328
+
329
+ it('falls back to lexicographic order when dates are equal', () => {
330
+ const date = '2026-01-01T00:00:00Z'
331
+
332
+ expect(compareReleaseIds(name('alpha'), name('beta'), { a: date, b: date })).toBeLessThan(0)
333
+ })
334
+
335
+ it('ignores invalid date strings and falls back to lexicographic order', () => {
336
+ expect(compareReleaseIds(name('alpha'), name('beta'), { a: 'not-a-date', b: 'also-bad' })).toBeLessThan(0)
337
+ })
338
+
339
+ it('is stable and deterministic across repeated sorts', () => {
340
+ const ids = [name('b'), version(1, 0, 0), name('a'), version(0, 5, 0), name('c')]
341
+ const first = [...ids].sort((a, b) => {
342
+ return compareReleaseIds(a, b)
343
+ })
344
+ const second = [...ids].sort((a, b) => {
345
+ return compareReleaseIds(a, b)
346
+ })
347
+
348
+ expect(first).toEqual(second)
349
+ expect(first).toEqual([version(0, 5, 0), version(1, 0, 0), name('a'), name('b'), name('c')])
350
+ })
351
+ })
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ compareReleaseIds,
5
+ displayLabel,
6
+ formatBranchName,
7
+ formatJiraName,
8
+ formatPrTitle,
9
+ formatRcTitle,
10
+ parseBranchName,
11
+ parseReleaseRef,
12
+ } from 'src/lib/release-id'
13
+ import { sortVersions } from 'src/lib/version-utils'
14
+
15
+ /**
16
+ * Regression lock for the named-releases feature (plan Principle 2):
17
+ * a VERSIONED release must produce byte-identical branch names, PR titles,
18
+ * RC titles, Jira version names, display labels, and sort order to the
19
+ * pre-named-releases behavior. If any assertion here fails, versioned
20
+ * releases have regressed.
21
+ */
22
+ describe('versioned-release output regression', () => {
23
+ const id = parseReleaseRef('1.62.0')
24
+
25
+ it('derives byte-identical strings for a versioned release', () => {
26
+ expect(formatBranchName(id)).toBe('release/v1.62.0')
27
+ expect(formatPrTitle(id, 'regular')).toBe('Release v1.62.0')
28
+ expect(formatPrTitle(id, 'hotfix')).toBe('Hotfix v1.62.0')
29
+ expect(formatRcTitle(id)).toBe('Release v1.62.0 (RC)')
30
+ expect(formatJiraName(id)).toBe('v1.62.0')
31
+ expect(displayLabel(id)).toBe('1.62.0')
32
+ })
33
+
34
+ it('accepts the historical input forms for the same version', () => {
35
+ for (const input of ['1.62.0', 'v1.62.0', 'release/v1.62.0']) {
36
+ expect(formatBranchName(parseReleaseRef(input))).toBe('release/v1.62.0')
37
+ }
38
+
39
+ expect(parseBranchName('release/v1.62.0')).toEqual(id)
40
+ })
41
+
42
+ it('sorts pure-version branch lists in the same order as legacy sortVersions', () => {
43
+ const branches = ['release/v1.10.0', 'release/v1.9.3', 'release/v2.0.0', 'release/v1.9.10', 'release/v1.62.0']
44
+
45
+ const expectedOrder = ['release/v1.9.3', 'release/v1.9.10', 'release/v1.10.0', 'release/v1.62.0', 'release/v2.0.0']
46
+
47
+ const newOrder = [...branches].sort((a, b) => {
48
+ const idA = parseBranchName(a)
49
+ const idB = parseBranchName(b)
50
+
51
+ if (idA === null || idB === null) throw new Error('unexpected unparseable version branch')
52
+
53
+ return compareReleaseIds(idA, idB)
54
+ })
55
+
56
+ expect(newOrder).toEqual(expectedOrder)
57
+
58
+ // Cross-check against legacy sortVersions on its v-token contract.
59
+ const asToken = (branch: string): string => {
60
+ const id = parseBranchName(branch)
61
+
62
+ if (id === null) throw new Error('unexpected unparseable version branch')
63
+
64
+ return `v${displayLabel(id)}`
65
+ }
66
+
67
+ expect(newOrder.map(asToken)).toEqual(sortVersions(branches.map(asToken)))
68
+ })
69
+ })
@@ -0,0 +1,15 @@
1
+ export {
2
+ compareReleaseIds,
3
+ displayLabel,
4
+ formatBranchName,
5
+ formatJiraName,
6
+ formatPrTitle,
7
+ formatRcTitle,
8
+ InvalidReleaseNameError,
9
+ InvalidReleaseRefError,
10
+ isReleaseBranch,
11
+ parseBranchName,
12
+ parseReleaseRef,
13
+ type ReleaseId,
14
+ validateName,
15
+ } from './release-id'
@@ -0,0 +1,257 @@
1
+ /**
2
+ * A release identity is either a semantic version or a free-form kebab-case
3
+ * name. `raw` is the canonical token for the id: the no-`v` semver string for
4
+ * versions (e.g. `1.2.3`) and the name itself for named releases.
5
+ */
6
+ export type ReleaseId =
7
+ | { kind: 'version'; semver: { major: number; minor: number; patch: number }; raw: string }
8
+ | { kind: 'name'; name: string; raw: string }
9
+
10
+ /** Matches a bare or `v`-prefixed semver token, e.g. `1.2.3` or `v1.2.3`. */
11
+ const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)$/
12
+
13
+ /** Matches the semver core after the `v` in a `release/v…` branch. */
14
+ const BRANCH_SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/
15
+
16
+ /** Kebab-case: lowercase alphanumeric segments joined by single hyphens. */
17
+ const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
18
+
19
+ const RELEASE_BRANCH_PREFIX = 'release/'
20
+ const VERSION_BRANCH_PREFIX = 'release/v'
21
+ const NAME_BRANCH_PREFIX = 'release/n/'
22
+ const REFS_HEADS_PREFIX = 'refs/heads/'
23
+
24
+ const NEXT_TOKEN = 'next'
25
+ const MAX_NAME_LENGTH = 50
26
+
27
+ /**
28
+ * Names that would collide with branch/release semantics or read as a special
29
+ * token. Banned regardless of kebab-case validity.
30
+ */
31
+ const RESERVED_NAMES: ReadonlySet<string> = new Set(['dev', 'main', 'next', 'hotfix', 'regular', 'release'])
32
+
33
+ /** Thrown by {@link validateName} when a release name is not acceptable. */
34
+ export class InvalidReleaseNameError extends Error {
35
+ constructor(message: string) {
36
+ super(message)
37
+ this.name = 'InvalidReleaseNameError'
38
+ }
39
+ }
40
+
41
+ /** Thrown by {@link parseReleaseRef} when a release ref cannot be parsed. */
42
+ export class InvalidReleaseRefError extends Error {
43
+ constructor(message: string) {
44
+ super(message)
45
+ this.name = 'InvalidReleaseRefError'
46
+ }
47
+ }
48
+
49
+ const stripRefsHeads = (input: string): string => {
50
+ return input.startsWith(REFS_HEADS_PREFIX) ? input.slice(REFS_HEADS_PREFIX.length) : input
51
+ }
52
+
53
+ const makeVersion = (major: number, minor: number, patch: number): ReleaseId => {
54
+ return {
55
+ kind: 'version',
56
+ semver: { major, minor, patch },
57
+ raw: `${major}.${minor}.${patch}`,
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Validate a release name. Throws {@link InvalidReleaseNameError} with a
63
+ * specific message unless the name is kebab-case, at most 50 characters, and
64
+ * not a reserved word. Semver-looking tokens (e.g. `1.2.3`) are already
65
+ * excluded by the kebab-case rule since they contain dots.
66
+ */
67
+ export const validateName = (name: string): void => {
68
+ if (name.length === 0) {
69
+ throw new InvalidReleaseNameError('Release name is empty. Provide a kebab-case name like "checkout-redesign".')
70
+ }
71
+
72
+ if (name.length > MAX_NAME_LENGTH) {
73
+ throw new InvalidReleaseNameError(
74
+ `Release name "${name}" is ${name.length} characters; the maximum is ${MAX_NAME_LENGTH}.`,
75
+ )
76
+ }
77
+
78
+ if (!KEBAB_RE.test(name)) {
79
+ throw new InvalidReleaseNameError(
80
+ `Release name "${name}" is not kebab-case. Use lowercase letters, digits, and single hyphens, e.g. "checkout-redesign".`,
81
+ )
82
+ }
83
+
84
+ if (RESERVED_NAMES.has(name)) {
85
+ throw new InvalidReleaseNameError(
86
+ `Release name "${name}" is reserved. Reserved names: ${[...RESERVED_NAMES].join(', ')}.`,
87
+ )
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Lenient parse of a git branch name into a {@link ReleaseId}. Tolerates a
93
+ * leading `refs/heads/`. Returns `null` for anything that is not a valid
94
+ * `release/v<semver>` or `release/n/<name>` branch. Never throws.
95
+ */
96
+ export const parseBranchName = (branch: string): ReleaseId | null => {
97
+ const stripped = stripRefsHeads(branch.trim())
98
+
99
+ if (stripped.startsWith(VERSION_BRANCH_PREFIX)) {
100
+ const semverPart = stripped.slice(VERSION_BRANCH_PREFIX.length)
101
+ const match = BRANCH_SEMVER_RE.exec(semverPart)
102
+
103
+ if (!match) return null
104
+
105
+ return makeVersion(Number(match[1]), Number(match[2]), Number(match[3]))
106
+ }
107
+
108
+ if (stripped.startsWith(NAME_BRANCH_PREFIX)) {
109
+ const namePart = stripped.slice(NAME_BRANCH_PREFIX.length)
110
+
111
+ try {
112
+ validateName(namePart)
113
+ } catch {
114
+ return null
115
+ }
116
+
117
+ return { kind: 'name', name: namePart, raw: namePart }
118
+ }
119
+
120
+ return null
121
+ }
122
+
123
+ /**
124
+ * Strict parse of a release ref into a {@link ReleaseId}. Throws
125
+ * {@link InvalidReleaseRefError} on invalid input. Precedence (order matters):
126
+ * 1. `release/…` (or `refs/heads/release/…`) → delegate to parseBranchName.
127
+ * 2. semver token (`1.2.3` / `v1.2.3`) → version.
128
+ * 3. `next` → throws; callers must resolve `next` to a concrete version
129
+ * via computeNextVersion before calling this.
130
+ * 4. otherwise → validateName, returning a named release.
131
+ */
132
+ export const parseReleaseRef = (input: string): ReleaseId => {
133
+ const trimmed = input.trim()
134
+ const branchCandidate = stripRefsHeads(trimmed)
135
+
136
+ if (branchCandidate.startsWith(RELEASE_BRANCH_PREFIX)) {
137
+ const parsed = parseBranchName(trimmed)
138
+
139
+ if (!parsed) {
140
+ throw new InvalidReleaseRefError(
141
+ `"${input}" looks like a release branch but is not a valid release/v<semver> or release/n/<name> ref.`,
142
+ )
143
+ }
144
+
145
+ return parsed
146
+ }
147
+
148
+ const versionMatch = VERSION_RE.exec(trimmed)
149
+
150
+ if (versionMatch) {
151
+ return makeVersion(Number(versionMatch[1]), Number(versionMatch[2]), Number(versionMatch[3]))
152
+ }
153
+
154
+ if (trimmed.toLowerCase() === NEXT_TOKEN) {
155
+ throw new InvalidReleaseRefError(
156
+ 'The "next" token must be resolved to a concrete version (via computeNextVersion) before parsing a release ref.',
157
+ )
158
+ }
159
+
160
+ try {
161
+ validateName(trimmed)
162
+ } catch (err) {
163
+ const reason = err instanceof Error ? err.message : String(err)
164
+
165
+ throw new InvalidReleaseRefError(`Cannot parse "${input}" as a release ref: ${reason}`)
166
+ }
167
+
168
+ return { kind: 'name', name: trimmed, raw: trimmed }
169
+ }
170
+
171
+ /** Render the branch name for a release id: `release/v1.2.3` | `release/n/<name>`. */
172
+ export const formatBranchName = (id: ReleaseId): string => {
173
+ if (id.kind === 'version') return `${VERSION_BRANCH_PREFIX}${id.raw}`
174
+
175
+ return `${NAME_BRANCH_PREFIX}${id.name}`
176
+ }
177
+
178
+ /**
179
+ * Render a PR title: `Release v1.2.3` / `Hotfix v1.2.3` for versions,
180
+ * `Release <name>` / `Hotfix <name>` for names.
181
+ */
182
+ export const formatPrTitle = (id: ReleaseId, type: 'regular' | 'hotfix'): string => {
183
+ const prefix = type === 'hotfix' ? 'Hotfix' : 'Release'
184
+
185
+ if (id.kind === 'version') return `${prefix} v${id.raw}`
186
+
187
+ return `${prefix} ${id.name}`
188
+ }
189
+
190
+ /** Render a release-candidate PR title: `Release v1.2.3 (RC)` | `Release <name> (RC)`. */
191
+ export const formatRcTitle = (id: ReleaseId): string => {
192
+ if (id.kind === 'version') return `Release v${id.raw} (RC)`
193
+
194
+ return `Release ${id.name} (RC)`
195
+ }
196
+
197
+ /** Render the Jira fix-version name: `v1.2.3` | `<name>`. */
198
+ export const formatJiraName = (id: ReleaseId): string => {
199
+ if (id.kind === 'version') return `v${id.raw}`
200
+
201
+ return id.name
202
+ }
203
+
204
+ /** Render a short human display label: `1.2.3` | `<name>`. */
205
+ export const displayLabel = (id: ReleaseId): string => {
206
+ return id.raw
207
+ }
208
+
209
+ /** True iff `branch` is a valid release branch under either scheme. */
210
+ export const isReleaseBranch = (branch: string | null | undefined): boolean => {
211
+ if (branch === null || branch === undefined) return false
212
+
213
+ return parseBranchName(branch) !== null
214
+ }
215
+
216
+ const toTime = (value: string | Date | undefined): number | null => {
217
+ if (value === undefined) return null
218
+
219
+ const time = value instanceof Date ? value.getTime() : new Date(value).getTime()
220
+
221
+ return Number.isNaN(time) ? null : time
222
+ }
223
+
224
+ /**
225
+ * Comparator for {@link ReleaseId} values (locked ordering):
226
+ * - All versions sort before all names.
227
+ * - Versions: semver ascending (major, then minor, then patch; numeric).
228
+ * - Names: by date ascending when both dates are provided, otherwise
229
+ * lexicographic by name. The result is stable and deterministic.
230
+ */
231
+ export const compareReleaseIds = (
232
+ a: ReleaseId,
233
+ b: ReleaseId,
234
+ dates?: { a?: string | Date; b?: string | Date },
235
+ ): number => {
236
+ if (a.kind === 'version' && b.kind === 'version') {
237
+ if (a.semver.major !== b.semver.major) return a.semver.major - b.semver.major
238
+ if (a.semver.minor !== b.semver.minor) return a.semver.minor - b.semver.minor
239
+
240
+ return a.semver.patch - b.semver.patch
241
+ }
242
+
243
+ if (a.kind === 'version') return -1
244
+ if (b.kind === 'version') return 1
245
+
246
+ const timeA = toTime(dates?.a)
247
+ const timeB = toTime(dates?.b)
248
+
249
+ if (timeA !== null && timeB !== null && timeA !== timeB) {
250
+ return timeA - timeB
251
+ }
252
+
253
+ if (a.name < b.name) return -1
254
+ if (a.name > b.name) return 1
255
+
256
+ return 0
257
+ }