infra-kit 0.1.102 → 0.1.105

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 (127) 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-be37e426-6fc8-47f4-8178-221c8494551c.jsonl +3 -0
  8. package/.omc/state/agent-replay-c967c819-3d1c-447b-ab48-56a8448ef9f8.jsonl +2 -0
  9. package/.omc/state/idle-notif-cooldown.json +3 -0
  10. package/.omc/state/last-tool-error.json +4 -4
  11. package/.omc/state/mission-state.json +53 -0
  12. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  13. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/subagent-tracking-state.json +7 -0
  14. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/last-tool-error-state.json +7 -0
  15. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/mission-state.json +117 -0
  16. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/pre-tool-advisory-throttle.json +42 -0
  17. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/subagent-tracking-state.json +53 -0
  18. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/last-tool-error-state.json +7 -0
  19. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/pre-tool-advisory-throttle.json +18 -0
  20. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/subagent-tracking-state.json +7 -0
  21. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/mission-state.json +117 -0
  22. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/pre-tool-advisory-throttle.json +18 -0
  23. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/subagent-tracking-state.json +17 -0
  24. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +18 -0
  25. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/subagent-tracking-state.json +7 -0
  26. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +10 -0
  27. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/subagent-tracking-state.json +7 -0
  28. package/.omc/state/subagent-tracking.json +14 -4
  29. package/.turbo/turbo-build.log +7 -0
  30. package/.turbo/turbo-check.log +14 -0
  31. package/.turbo/turbo-prettier-fix.log +2 -1
  32. package/.turbo/turbo-test.log +28 -5
  33. package/.turbo/turbo-validate.log +14 -0
  34. package/dist/cli.js +81 -74
  35. package/dist/cli.js.map +4 -4
  36. package/dist/entry/index.d.ts +2 -0
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +7 -0
  39. package/dist/lib/package-config/package-config.d.ts +71 -0
  40. package/dist/mcp.js +43 -41
  41. package/dist/mcp.js.map +4 -4
  42. package/eslint.config.js +1 -1
  43. package/infra-kit.config.ts +5 -0
  44. package/package.json +20 -13
  45. package/scripts/build.js +32 -3
  46. package/src/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  47. package/src/commands/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +18 -0
  48. package/src/commands/audit/__tests__/audit.test.ts +59 -0
  49. package/src/commands/audit/audit.ts +177 -0
  50. package/src/commands/audit/index.ts +1 -0
  51. package/src/commands/config/config.ts +49 -7
  52. package/src/commands/doctor/doctor.ts +3 -3
  53. package/src/commands/env-clear/env-clear.ts +1 -1
  54. package/src/commands/env-list/env-list.ts +3 -3
  55. package/src/commands/env-load/env-load.ts +1 -1
  56. package/src/commands/env-status/env-status.ts +1 -1
  57. package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -8
  58. package/src/commands/gh-release-deliver/gh-release-deliver.ts +47 -21
  59. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +13 -7
  60. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +12 -6
  61. package/src/commands/gh-release-list/gh-release-list.ts +19 -8
  62. package/src/commands/init/__tests__/migrate-config.test.ts +160 -0
  63. package/src/commands/init/init.ts +48 -35
  64. package/src/commands/init/migrate-config.ts +146 -0
  65. package/src/commands/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  66. package/src/commands/release-create/__tests__/release-create.test.ts +55 -0
  67. package/src/commands/release-create/release-create.ts +142 -38
  68. package/src/commands/release-desc-edit/release-desc-edit.ts +28 -8
  69. package/src/commands/version/version.ts +1 -1
  70. package/src/commands/worktrees-add/worktrees-add.ts +7 -12
  71. package/src/commands/worktrees-list/worktrees-list.ts +13 -5
  72. package/src/commands/worktrees-open/worktrees-open.ts +1 -1
  73. package/src/commands/worktrees-remove/worktrees-remove.ts +6 -10
  74. package/src/commands/worktrees-sync/worktrees-sync.ts +3 -5
  75. package/src/entry/cli.ts +49 -6
  76. package/src/entry/index.ts +5 -0
  77. package/src/integrations/cmux/open-workspace-with-layout.ts +4 -4
  78. package/src/integrations/cmux/workspace-title.ts +10 -4
  79. package/src/integrations/doppler/doppler-project.ts +1 -1
  80. package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +115 -0
  81. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +49 -32
  82. package/src/lib/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +14 -0
  83. package/src/lib/constants/index.ts +15 -0
  84. package/src/lib/git-utils/__tests__/git-utils.test.ts +49 -0
  85. package/src/lib/git-utils/git-utils.ts +3 -1
  86. package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +270 -0
  87. package/src/lib/infra-kit-config/index.ts +7 -1
  88. package/src/lib/infra-kit-config/infra-kit-config.ts +46 -28
  89. package/src/lib/package-config/__tests__/package-config.test.ts +95 -0
  90. package/src/lib/package-config/index.ts +3 -0
  91. package/src/lib/package-config/package-config-schema.ts +19 -0
  92. package/src/lib/package-config/package-config.ts +99 -0
  93. package/src/lib/package-validator/__tests__/package-validator.test.ts +263 -0
  94. package/src/lib/package-validator/checks/__tests__/checks.test.ts +130 -0
  95. package/src/lib/package-validator/checks/config-check.ts +30 -0
  96. package/src/lib/package-validator/checks/files-check.ts +29 -0
  97. package/src/lib/package-validator/checks/index.ts +4 -0
  98. package/src/lib/package-validator/checks/scripts-check.ts +23 -0
  99. package/src/lib/package-validator/checks/turbo-check.ts +47 -0
  100. package/src/lib/package-validator/fs-utils.ts +18 -0
  101. package/src/lib/package-validator/index.ts +3 -0
  102. package/src/lib/package-validator/loader/config-loader.ts +77 -0
  103. package/src/lib/package-validator/loader/index.ts +2 -0
  104. package/src/lib/package-validator/loader/package-discovery.ts +98 -0
  105. package/src/lib/package-validator/package-validator.ts +48 -0
  106. package/src/lib/package-validator/types.ts +15 -0
  107. package/src/lib/release-id/__tests__/release-id.test.ts +351 -0
  108. package/src/lib/release-id/__tests__/versioned-regression.test.ts +69 -0
  109. package/src/lib/release-id/index.ts +15 -0
  110. package/src/lib/release-id/release-id.ts +257 -0
  111. package/src/lib/release-utils/__tests__/release-utils.test.ts +122 -0
  112. package/src/lib/release-utils/index.ts +4 -0
  113. package/src/lib/release-utils/release-utils.ts +85 -17
  114. package/src/lib/version-utils/__tests__/load-existing-versions.test.ts +37 -0
  115. package/src/lib/version-utils/__tests__/next-version.test.ts +119 -13
  116. package/src/lib/version-utils/index.ts +3 -0
  117. package/src/lib/version-utils/load-existing-versions.ts +29 -10
  118. package/src/lib/version-utils/next-version.ts +67 -12
  119. package/src/lib/version-utils/version-utils.ts +13 -4
  120. package/src/mcp/tools/index.ts +2 -0
  121. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  122. package/src/types.ts +1 -1
  123. package/tsconfig.tsbuildinfo +1 -1
  124. package/src/lib/__tests__/infra-kit-config.test.ts +0 -231
  125. /package/src/integrations/{clickup → linear}/.gitkeep +0 -0
  126. /package/src/lib/{__tests__ → constants/__tests__}/constants.test.ts +0 -0
  127. /package/src/lib/{constants.ts → constants/constants.ts} +0 -0
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { z } from 'zod'
5
+
6
+ import { DEFAULT_RULES, packageConfigSchema, resolvePackageConfig } from 'src/lib/package-config'
7
+ import type { ResolvedPackageRules } from 'src/lib/package-config'
8
+
9
+ import { pathExists } from '../fs-utils'
10
+
11
+ /** Per-package config filename every validated package must provide. */
12
+ export const PACKAGE_CONFIG_FILE = 'infra-kit.config.ts'
13
+
14
+ interface PackageJsonShape {
15
+ name?: string
16
+ scripts?: Record<string, string>
17
+ }
18
+
19
+ /**
20
+ * Read and JSON-parse a package.json, returning an empty object when it is
21
+ * missing or unreadable so callers can degrade into a clear "missing" check.
22
+ */
23
+ export const readPackageJson = async (packageDir: string): Promise<PackageJsonShape> => {
24
+ try {
25
+ const raw = await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8')
26
+
27
+ return JSON.parse(raw) as PackageJsonShape
28
+ } catch {
29
+ return {}
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Load, resolve, and validate a package's `infra-kit.config.ts`.
35
+ *
36
+ * Dynamic-imports the file (ESM), resolves the Vite-style factory or object
37
+ * default export, validates the result against {@link packageConfigSchema}, and
38
+ * merges it over the defaults. Throws a descriptive error when the file is
39
+ * absent or the resolved config violates the schema.
40
+ *
41
+ * @example
42
+ * await loadPackageConfig('/repo/packages/serverless-config')
43
+ * // => { requiredScripts: [], requiredFiles: ['serverless.common.yml'], turboTasks: [] }
44
+ */
45
+ export const loadPackageConfig = async (
46
+ packageDir: string,
47
+ baseline: Readonly<ResolvedPackageRules> = DEFAULT_RULES,
48
+ ): Promise<ResolvedPackageRules> => {
49
+ const configPath = path.join(packageDir, PACKAGE_CONFIG_FILE)
50
+
51
+ if (!(await pathExists(configPath))) {
52
+ throw new Error(`${PACKAGE_CONFIG_FILE} not found at ${configPath}`)
53
+ }
54
+
55
+ // Cache-bust with the file mtime so repeated loads (long-running MCP server)
56
+ // pick up edits without a process restart. `.ts` configs load via Node's
57
+ // native type stripping (the repo requires Node >= 24).
58
+ const stat = await fs.stat(configPath)
59
+ const moduleUrl = `${pathToFileURL(configPath).href}?mtime=${Number(stat.mtimeMs)}`
60
+
61
+ const imported = (await import(moduleUrl)) as { default?: unknown }
62
+ const rawExport = imported.default
63
+
64
+ if (rawExport === undefined) {
65
+ throw new Error(`${PACKAGE_CONFIG_FILE} at ${configPath} has no default export`)
66
+ }
67
+
68
+ const resolvedExport = typeof rawExport === 'function' ? await (rawExport as () => unknown)() : rawExport
69
+
70
+ const parsed = packageConfigSchema.safeParse(resolvedExport)
71
+
72
+ if (!parsed.success) {
73
+ throw new Error(`Invalid ${PACKAGE_CONFIG_FILE} at ${configPath}: ${z.prettifyError(parsed.error)}`)
74
+ }
75
+
76
+ return resolvePackageConfig(parsed.data, baseline)
77
+ }
@@ -0,0 +1,2 @@
1
+ export { loadPackageConfig, PACKAGE_CONFIG_FILE, readPackageJson } from './config-loader'
2
+ export { discoverPackages } from './package-discovery'
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import yaml from 'yaml'
4
+
5
+ import { pathExists } from '../fs-utils'
6
+
7
+ const WORKSPACE_FILE = 'pnpm-workspace.yaml'
8
+
9
+ /**
10
+ * List the immediate child directories of `dir`, returning `[]` when the path
11
+ * can't be read (e.g. the parent glob segment matched a non-existent dir).
12
+ */
13
+ const listChildDirs = async (dir: string): Promise<string[]> => {
14
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => {
15
+ return []
16
+ })
17
+
18
+ return entries
19
+ .filter((entry) => {
20
+ return entry.isDirectory()
21
+ })
22
+ .map((entry) => {
23
+ return path.join(dir, entry.name)
24
+ })
25
+ }
26
+
27
+ /**
28
+ * Apply one glob segment to a set of directories: `*` fans out to every child
29
+ * directory, a literal segment keeps the dirs where that child path exists.
30
+ */
31
+ const expandSegment = async (dirs: string[], segment: string): Promise<string[]> => {
32
+ const next: string[] = []
33
+
34
+ for (const dir of dirs) {
35
+ if (segment === '*') {
36
+ next.push(...(await listChildDirs(dir)))
37
+
38
+ continue
39
+ }
40
+
41
+ const candidate = path.join(dir, segment)
42
+
43
+ if (await pathExists(candidate)) {
44
+ next.push(candidate)
45
+ }
46
+ }
47
+
48
+ return next
49
+ }
50
+
51
+ /**
52
+ * Expand a single pnpm-workspace glob (only the `*` segment wildcard is
53
+ * supported, which covers every pattern this monorepo uses) into directories.
54
+ */
55
+ const expandGlob = async (projectRoot: string, pattern: string): Promise<string[]> => {
56
+ let dirs = [projectRoot]
57
+
58
+ for (const segment of pattern.split('/')) {
59
+ dirs = await expandSegment(dirs, segment)
60
+ }
61
+
62
+ return dirs
63
+ }
64
+
65
+ /**
66
+ * Discover validatable workspace packages from `pnpm-workspace.yaml`.
67
+ *
68
+ * Negation patterns (`!…`) and everything under `vendor/` are excluded —
69
+ * vendor is mirrored from `starter-workspace` and is checksum-enforced by
70
+ * `pnpm vendor:check`, so its configs are owned upstream, not here. Only
71
+ * directories that actually contain a package.json are returned.
72
+ *
73
+ * @example
74
+ * await discoverPackages('/repo')
75
+ * // => ['/repo/apps/infra-kit/cli', '/repo/packages/serverless-config']
76
+ */
77
+ export const discoverPackages = async (projectRoot: string): Promise<string[]> => {
78
+ const raw = await fs.readFile(path.join(projectRoot, WORKSPACE_FILE), 'utf-8')
79
+ const parsed = (yaml.parse(raw) ?? {}) as { packages?: string[] }
80
+
81
+ const patterns = (parsed.packages ?? []).filter((pattern) => {
82
+ return !pattern.startsWith('!') && !pattern.startsWith('vendor')
83
+ })
84
+
85
+ const found = new Set<string>()
86
+
87
+ for (const pattern of patterns) {
88
+ const dirs = await expandGlob(projectRoot, pattern)
89
+
90
+ for (const dir of dirs) {
91
+ if (await pathExists(path.join(dir, 'package.json'))) {
92
+ found.add(dir)
93
+ }
94
+ }
95
+ }
96
+
97
+ return [...found].sort()
98
+ }
@@ -0,0 +1,48 @@
1
+ import path from 'node:path'
2
+
3
+ import { DEFAULT_RULES } from 'src/lib/package-config'
4
+ import type { ResolvedPackageRules } from 'src/lib/package-config'
5
+
6
+ import { checkConfig, checkFiles, checkScripts, checkTurbo } from './checks'
7
+ import { readPackageJson } from './loader'
8
+ import type { PackageCheck, PackageValidationResult } from './types'
9
+
10
+ // Re-exported on the historical import path so consumers and tests that reach
11
+ // for the loader through `package-validator` keep resolving after the split.
12
+ export { discoverPackages, loadPackageConfig } from './loader'
13
+ export type { PackageCheck, PackageValidationResult } from './types'
14
+
15
+ /**
16
+ * Validate a single directory against its `infra-kit.config.ts` rules: the config
17
+ * must be present and valid, every required script must be declared, every
18
+ * required file must exist, and (root only) every required turbo task must be
19
+ * defined. When the config fails to load, only that check is reported (the rules
20
+ * are unknown, so the rule-based checks are skipped). `baseline` selects which
21
+ * under-the-hood defaults apply — package defaults or {@link ROOT_DEFAULT_RULES}.
22
+ *
23
+ * @example
24
+ * const result = await validatePackage('/repo/packages/serverless-config')
25
+ * // result.passed reflects the package's conformance; result.checks lists each check
26
+ */
27
+ export const validatePackage = async (
28
+ packageDir: string,
29
+ baseline: Readonly<ResolvedPackageRules> = DEFAULT_RULES,
30
+ ): Promise<PackageValidationResult> => {
31
+ const pkgJson = await readPackageJson(packageDir)
32
+ const packageName = pkgJson.name ?? path.basename(packageDir)
33
+
34
+ const { check: configCheck, rules } = await checkConfig(packageDir, baseline)
35
+ const checks: PackageCheck[] = [configCheck]
36
+
37
+ if (rules) {
38
+ checks.push(...checkScripts(pkgJson.scripts ?? {}, rules.requiredScripts))
39
+ checks.push(...(await checkFiles(packageDir, rules.requiredFiles)))
40
+ checks.push(...(await checkTurbo(packageDir, rules.turboTasks)))
41
+ }
42
+
43
+ const passed = checks.every((check) => {
44
+ return check.status === 'pass'
45
+ })
46
+
47
+ return { packageDir, packageName, checks, passed }
48
+ }
@@ -0,0 +1,15 @@
1
+ /** Result of a single validation check against one package. */
2
+ export interface PackageCheck {
3
+ /** Stable identifier for the check, e.g. `infra-kit.config.ts`, `script:build`. */
4
+ name: string
5
+ status: 'pass' | 'fail'
6
+ message: string
7
+ }
8
+
9
+ /** Aggregate validation outcome for one package directory. */
10
+ export interface PackageValidationResult {
11
+ packageDir: string
12
+ packageName: string
13
+ checks: PackageCheck[]
14
+ passed: boolean
15
+ }
@@ -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'