@reelout/core 0.1.1 → 0.2.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 (125) hide show
  1. package/dist/analyzer.js +38 -0
  2. package/dist/analyzer.js.map +1 -0
  3. package/dist/builder.js +52 -0
  4. package/dist/builder.js.map +1 -0
  5. package/dist/changelog.js +58 -0
  6. package/dist/changelog.js.map +1 -0
  7. package/dist/formatError-BXNdyRfu.js +23 -0
  8. package/dist/formatError-BXNdyRfu.js.map +1 -0
  9. package/dist/index.js +175 -1
  10. package/dist/index.js.map +1 -0
  11. package/dist/types-la_KkjCS.js +1 -0
  12. package/dist/types.js +2 -0
  13. package/dist/workflow-BoVmbvH6.js +2342 -0
  14. package/dist/workflow-BoVmbvH6.js.map +1 -0
  15. package/package.json +27 -3
  16. package/src/analyzer.ts +63 -0
  17. package/src/builder.ts +104 -0
  18. package/src/cache/TTLCache.ts +100 -0
  19. package/src/cache/index.ts +4 -0
  20. package/src/changelog.ts +97 -0
  21. package/src/config/const.ts +97 -0
  22. package/src/config/index.ts +11 -0
  23. package/src/config/isSemVerGitTag.ts +25 -0
  24. package/src/config/normalizeCommitFilter.ts +37 -0
  25. package/src/config/normalizeReleaseRule.ts +42 -0
  26. package/src/config/normalizeWorkspace.ts +34 -0
  27. package/src/config/resolvePlugin.ts +56 -0
  28. package/src/config/resolveReleaseTarget.ts +71 -0
  29. package/src/config/sanitizePrereleaseLabel.ts +18 -0
  30. package/src/git/branch/getCurrentGitBranch.ts +18 -0
  31. package/src/git/branch/index.ts +4 -0
  32. package/src/git/commit/createGitCommit.ts +29 -0
  33. package/src/git/commit/extractMentions.ts +34 -0
  34. package/src/git/commit/getCurrentGitCommit.ts +14 -0
  35. package/src/git/commit/getParsedCommit.ts +53 -0
  36. package/src/git/commit/index.ts +9 -0
  37. package/src/git/commit/parseCommit.ts +99 -0
  38. package/src/git/commit/parseGitPrettyFormat.ts +76 -0
  39. package/src/git/history/countGitHistory.ts +28 -0
  40. package/src/git/history/expandGitHistory.ts +108 -0
  41. package/src/git/history/index.ts +9 -0
  42. package/src/git/history/mergeGitHistory.ts +76 -0
  43. package/src/git/history/restoreFullGitHistory.ts +19 -0
  44. package/src/git/history/showGitHistory.ts +80 -0
  45. package/src/git/history/showParsedGitHistory.ts +30 -0
  46. package/src/git/index.ts +9 -0
  47. package/src/git/repository/ensureGitUserConfig.ts +31 -0
  48. package/src/git/repository/getGitConfigValue.ts +27 -0
  49. package/src/git/repository/getGitRemote.ts +20 -0
  50. package/src/git/repository/index.ts +10 -0
  51. package/src/git/repository/isGitShallowRepository.ts +21 -0
  52. package/src/git/repository/pushGitChanges.ts +25 -0
  53. package/src/git/repository/resolveGitRepository.ts +40 -0
  54. package/src/git/repository/withShallowLock.ts +62 -0
  55. package/src/git/revision/detectGitRelationship.ts +43 -0
  56. package/src/git/revision/index.ts +7 -0
  57. package/src/git/revision/isGitAncestor.ts +33 -0
  58. package/src/git/revision/readGitFile.ts +29 -0
  59. package/src/git/revision/resolveGitRevision.ts +23 -0
  60. package/src/git/tag/createGitTag.ts +25 -0
  61. package/src/git/tag/deleteGitTag.ts +17 -0
  62. package/src/git/tag/ensureGitTag.ts +63 -0
  63. package/src/git/tag/generateGitTagName.ts +43 -0
  64. package/src/git/tag/getCurrentGitTag.ts +23 -0
  65. package/src/git/tag/getGitTagMessage.ts +35 -0
  66. package/src/git/tag/getLatestGitTag.ts +22 -0
  67. package/src/git/tag/index.ts +13 -0
  68. package/src/git/tag/isGitTagConnected.ts +33 -0
  69. package/src/git/tag/isGitTagPointed.ts +38 -0
  70. package/src/git/tag/isGitTagPresent.ts +17 -0
  71. package/src/index.ts +8 -2
  72. package/src/types.ts +217 -0
  73. package/src/util/buildTrailerPattern.ts +20 -0
  74. package/src/util/compact.ts +14 -0
  75. package/src/util/execCommand.ts +130 -0
  76. package/src/util/execGit.ts +18 -0
  77. package/src/util/formatError.ts +21 -0
  78. package/src/util/formatFile.ts +41 -0
  79. package/src/util/index.ts +16 -0
  80. package/src/util/interpolate.ts +141 -0
  81. package/src/util/isCI.ts +23 -0
  82. package/src/util/isDefined.ts +12 -0
  83. package/src/util/isMatch.ts +43 -0
  84. package/src/util/readJson.ts +47 -0
  85. package/src/util/unique.ts +26 -0
  86. package/src/util/writeJson.ts +28 -0
  87. package/src/workflow/analysis/analyzePackageChanges.ts +54 -0
  88. package/src/workflow/analysis/index.ts +9 -0
  89. package/src/workflow/analysis/isCommitForPackage.ts +23 -0
  90. package/src/workflow/analysis/promotePrereleaseVersions.ts +50 -0
  91. package/src/workflow/analysis/propagateDependencyUpdates.ts +71 -0
  92. package/src/workflow/analysis/sliceUnreleased.ts +36 -0
  93. package/src/workflow/analysis/streamPackageCommits.ts +34 -0
  94. package/src/workflow/changelog/buildChangelog.ts +79 -0
  95. package/src/workflow/changelog/buildReleaseNotes.ts +50 -0
  96. package/src/workflow/changelog/extractLatestVersion.ts +22 -0
  97. package/src/workflow/changelog/extractPlainText.ts +38 -0
  98. package/src/workflow/changelog/extractPreviousVersion.ts +77 -0
  99. package/src/workflow/changelog/extractSemVer.ts +33 -0
  100. package/src/workflow/changelog/index.ts +12 -0
  101. package/src/workflow/changelog/iterateChangelogVersions.ts +70 -0
  102. package/src/workflow/changelog/prependChangelog.ts +59 -0
  103. package/src/workflow/changelog/writeChangelog.ts +27 -0
  104. package/src/workflow/committing/generateReleaseCommitMessage.ts +96 -0
  105. package/src/workflow/committing/index.ts +5 -0
  106. package/src/workflow/committing/isReleaseCommit.ts +52 -0
  107. package/src/workflow/discovery/buildDependencyGraph.ts +43 -0
  108. package/src/workflow/discovery/discoverPackageTag.ts +98 -0
  109. package/src/workflow/discovery/index.ts +9 -0
  110. package/src/workflow/discovery/resolvePackageStableTag.ts +88 -0
  111. package/src/workflow/discovery/resolvePackageTag.ts +45 -0
  112. package/src/workflow/discovery/sortPackages.ts +73 -0
  113. package/src/workflow/discovery/verifyPackageTag.ts +43 -0
  114. package/src/workflow/index.ts +10 -0
  115. package/src/workflow/report/generateReleaseReport.ts +76 -0
  116. package/src/workflow/report/index.ts +5 -0
  117. package/src/workflow/report/printReleaseReport.ts +97 -0
  118. package/src/workflow/runner/index.ts +4 -0
  119. package/src/workflow/runner/runWorkflow.ts +120 -0
  120. package/src/workflow/versioning/bumpVersion.ts +67 -0
  121. package/src/workflow/versioning/deriveReleaseType.ts +33 -0
  122. package/src/workflow/versioning/determineReleaseType.ts +35 -0
  123. package/src/workflow/versioning/index.ts +8 -0
  124. package/src/workflow/versioning/isStableVersion.ts +31 -0
  125. package/src/workflow/versioning/pickPriorityReleaseType.ts +27 -0
@@ -0,0 +1,100 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ interface Entry<V> {
5
+ value: V
6
+ timer: NodeJS.Timeout
7
+ ttl: number
8
+ }
9
+
10
+ /**
11
+ * A simple time-to-live cache that automatically evicts entries after a TTL.
12
+ *
13
+ * - Entries are removed automatically when their TTL expires.
14
+ * - Reading an entry resets its TTL to the original value.
15
+ * - Setting an existing key clears its previous timer and resets the TTL.
16
+ *
17
+ * @template K Type of cache keys
18
+ * @template V Type of cache values
19
+ */
20
+ export class TTLCache<K, V> {
21
+ private cache = new Map<K, Entry<V>>()
22
+
23
+ /**
24
+ * Store a value under `key` for `ttl` milliseconds.
25
+ *
26
+ * If the key already exists its previous timer is cleared and the TTL is reset.
27
+ *
28
+ * @param key Cache key
29
+ * @param value Value to store
30
+ * @param ttl Time to live in milliseconds (default: 60_000)
31
+ */
32
+ set(key: K, value: V, ttl: number = 60_000): void {
33
+ const ref = this.cache.get(key)
34
+
35
+ if (ref) {
36
+ clearTimeout(ref.timer)
37
+ }
38
+
39
+ const timer = setTimeout(() => {
40
+ this.cache.delete(key)
41
+ }, ttl).unref()
42
+
43
+ this.cache.set(key, { value, timer, ttl })
44
+ }
45
+
46
+ /**
47
+ * Retrieve the value for `key`, or `undefined` if not present.
48
+ *
49
+ * Reading an entry resets its TTL to the original `ttl` provided when it was set.
50
+ *
51
+ * @param key Cache key
52
+ * @returns The stored value or `undefined` if not present
53
+ */
54
+ get(key: K): V | undefined {
55
+ const ref = this.cache.get(key)
56
+
57
+ if (!ref) return undefined
58
+
59
+ this.set(key, ref.value, ref.ttl)
60
+
61
+ return ref.value
62
+ }
63
+
64
+ /**
65
+ * Check whether `key` exists in the cache.
66
+ *
67
+ * @param key Cache key
68
+ * @returns `true` if present; otherwise `false`
69
+ */
70
+ has(key: K): boolean {
71
+ return this.cache.has(key)
72
+ }
73
+
74
+ /**
75
+ * Remove `key` from the cache and clear its eviction timer.
76
+ *
77
+ * @param key Cache key
78
+ * @returns `true` if the key was present and removed; otherwise `false`
79
+ */
80
+ delete(key: K): boolean {
81
+ const ref = this.cache.get(key)
82
+
83
+ if (!ref) return false
84
+
85
+ clearTimeout(ref.timer)
86
+
87
+ return this.cache.delete(key)
88
+ }
89
+
90
+ /**
91
+ * Clear all entries and their timers.
92
+ */
93
+ clear(): void {
94
+ for (const { timer } of this.cache.values()) {
95
+ clearTimeout(timer)
96
+ }
97
+
98
+ this.cache.clear()
99
+ }
100
+ }
@@ -0,0 +1,4 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ export * from './TTLCache'
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import type { Package, ReleasePlugin } from '#types'
5
+ import { formatError } from '#util'
6
+ import {
7
+ buildChangelog,
8
+ discoverPackageTag,
9
+ resolvePackageTag,
10
+ writeChangelog,
11
+ } from '#workflow'
12
+ import { colors } from 'consola/utils'
13
+ import { version } from '../package.json'
14
+
15
+ /**
16
+ * Built-in plugin that resolves package tags, generates changelogs, and writes changelog files.
17
+ *
18
+ * Stages:
19
+ * - discover (enforce: post): resolve missing package tags from the repository.
20
+ * - build: generate changelogs for planned releases (optionally using the previous tag)
21
+ * and attach tag messages derived from changelogs.
22
+ * - write: persist changelog files for each plan (skipped in dry-run).
23
+ */
24
+ const changelog = (): ReleasePlugin => ({
25
+ name: '@reelout/core/changelog',
26
+ version,
27
+ discover: {
28
+ enforce: 'post',
29
+ handler: async (context) => {
30
+ context.packages = await Promise.all(
31
+ context.packages.map(async (pkg): Promise<Package> => ({
32
+ ...pkg,
33
+ tag: await resolvePackageTag(context.cwd, pkg),
34
+ }))
35
+ )
36
+ },
37
+ },
38
+ build: async (context) => {
39
+ context.plans = await Promise.all(
40
+ context.plans.map(async (plan) => {
41
+ plan = { ...plan }
42
+
43
+ if (!plan.release) {
44
+ return plan
45
+ }
46
+
47
+ if (!plan.release.version) {
48
+ return plan
49
+ }
50
+
51
+ plan.release = { ...plan.release }
52
+
53
+ let changelog: string | null | undefined
54
+
55
+ let previousTag = plan.package.tag?.name
56
+
57
+ if (!previousTag) {
58
+ previousTag = (await discoverPackageTag(context.cwd, plan.package))
59
+ ?.name
60
+ }
61
+
62
+ try {
63
+ changelog = await buildChangelog(context, plan.history, {
64
+ packageDir: plan.package.dir,
65
+ changelogFile: plan.package.changelogFile,
66
+ version: plan.release.version,
67
+ previousTag,
68
+ currentTag: plan.release.tag?.name,
69
+ })
70
+ } catch (err) {
71
+ context.logger.warn(
72
+ colors.yellow(
73
+ `Changelog could not be generated for package at "${colors.bold(plan.package.dir)}": ${formatError(err)}`
74
+ )
75
+ )
76
+ }
77
+
78
+ if (changelog) {
79
+ plan.release.changelog = changelog
80
+ }
81
+
82
+ return plan
83
+ })
84
+ )
85
+ },
86
+ write: async (context) => {
87
+ if (context.dryRun) {
88
+ return
89
+ }
90
+
91
+ for (const plan of context.plans) {
92
+ await writeChangelog(context.cwd, plan)
93
+ }
94
+ },
95
+ })
96
+
97
+ export default changelog
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import type { ReleaseLifecycle } from '#types'
5
+
6
+ export const ALL_FILES = '**/*'
7
+
8
+ export const DEFAULT_CHANGELOG_FILE = 'CHANGELOG.md'
9
+
10
+ export const GIT_TRAILER_RELEASE_AGENT = 'Release-Agent'
11
+
12
+ /**
13
+ * 1. Primitive Building Blocks
14
+ *
15
+ * Base regular expression patterns for wildcards, alphanumeric strings with hyphens,
16
+ * and standard non-negative integers.
17
+ */
18
+ const WILDCARD = '\\*'
19
+ const ALPHANUM_DASH = '[0-9a-zA-Z-]+'
20
+ const NUMERIC_IDENTIFIER = '0|[1-9]\\d*'
21
+
22
+ /**
23
+ * 2. Package Name & Prefix Constructs
24
+ *
25
+ * Matches package name prefixes (unscoped or scoped) or a simple `v` prefix.
26
+ *
27
+ * Capture Groups:
28
+ * - Group 1: Package name (e.g., `my-package` or `@scope/my-package`)
29
+ */
30
+ const PKG_NAME_PART = `(?:${WILDCARD}|${ALPHANUM_DASH})`
31
+ const CAPTURED_PACKAGE_NAME = `(${PKG_NAME_PART}|@${PKG_NAME_PART}\\/${PKG_NAME_PART})`
32
+ const OPTIONAL_VERSION_PREFIX = `(?:v|${CAPTURED_PACKAGE_NAME}@)`
33
+
34
+ /**
35
+ * 3. Core SemVer Components
36
+ *
37
+ * Matches standard `MAJOR.MINOR.PATCH` versions or wildcard variants (e.g., `1.0.0`, `*.*.*`).
38
+ *
39
+ * Capture Groups:
40
+ * - Group 3: Major version
41
+ * - Group 4: Minor version
42
+ * - Group 5: Patch version
43
+ */
44
+ const CAPTURED_VERSION_DIGIT = `(${WILDCARD}|${NUMERIC_IDENTIFIER})`
45
+ const SEMVER_CORE = `${CAPTURED_VERSION_DIGIT}\\.${CAPTURED_VERSION_DIGIT}\\.${CAPTURED_VERSION_DIGIT}`
46
+
47
+ /**
48
+ * 4. Prerelease & Build Metadata
49
+ *
50
+ * Matches optional prerelease identifiers (e.g., `-alpha.1`) and build metadata (e.g., `+build123`).
51
+ *
52
+ * Capture Groups:
53
+ * - Group 6: Complete prerelease string (without `-`)
54
+ * - Group 7: Non-numeric prerelease token
55
+ * - Group 8: Complete build metadata string (without `+`)
56
+ */
57
+ const CAPTURED_NON_NUMERIC_PRERELEASE_ID = `(\\d*[a-zA-Z-][0-9a-zA-Z-]*)`
58
+ const PRERELEASE_IDENTIFIER = `(?:${WILDCARD}|${NUMERIC_IDENTIFIER}|${CAPTURED_NON_NUMERIC_PRERELEASE_ID})`
59
+ const OPTIONAL_PRERELEASE_SECTION = `(?:-(${PRERELEASE_IDENTIFIER}(?:\\.${PRERELEASE_IDENTIFIER})*))`
60
+ const OPTIONAL_BUILD_SECTION = `(?:\\+(${ALPHANUM_DASH}(?:\\.${ALPHANUM_DASH})*))`
61
+
62
+ /**
63
+ * 5. Assembled SemVer Pattern
64
+ *
65
+ * Combines core SemVer components with optional prerelease identifiers and build metadata.
66
+ *
67
+ * Capture Groups:
68
+ * - Group 2: Full SemVer version string (excluding package/prefix)
69
+ */
70
+ const CAPTURED_SEMVER_VERSION = `(${SEMVER_CORE}${OPTIONAL_PRERELEASE_SECTION}?${OPTIONAL_BUILD_SECTION}?)`
71
+ const FULL_SEMVER_PATTERN = `${OPTIONAL_VERSION_PREFIX}?${CAPTURED_SEMVER_VERSION}`
72
+
73
+ /**
74
+ * Regular expression to match and capture Semantic Versioning (SemVer) strings,
75
+ * including optional package prefixes (e.g., `@scope/pkg@`) and `v` prefixes.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * SEMVER_REGEX.test('1.0.0') // true
80
+ * SEMVER_REGEX.test('v1.2.3-beta.1+sha.1234') // true
81
+ * SEMVER_REGEX.test('@scope/package@1.0.0') // true
82
+ * ```
83
+ *
84
+ * @see {@link https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string SemVer Specification}
85
+ */
86
+ export const SEMVER_REGEX = new RegExp(FULL_SEMVER_PATTERN)
87
+
88
+ export const RELEASE_LIFECYCLE_HOOK_LIST = [
89
+ 'configure',
90
+ 'discover',
91
+ 'analyze',
92
+ 'build',
93
+ 'write',
94
+ 'commit',
95
+ 'publish',
96
+ 'finalize',
97
+ ] as const satisfies ReleaseLifecycle[]
@@ -0,0 +1,11 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ export * from './const'
5
+ export * from './isSemVerGitTag'
6
+ export * from './normalizeCommitFilter'
7
+ export * from './normalizeReleaseRule'
8
+ export * from './normalizeWorkspace'
9
+ export * from './resolvePlugin'
10
+ export * from './resolveReleaseTarget'
11
+ export * from './sanitizePrereleaseLabel'
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { SEMVER_REGEX } from './const'
5
+
6
+ /**
7
+ * Determine whether a tag string is a valid SemVer-style Git tag.
8
+ *
9
+ * Anchors `SEMVER_REGEX.source` for full-string matching and applies the
10
+ * repository's Git-tag acceptance rules.
11
+ *
12
+ * @param tag Tag string to validate
13
+ * @returns `true` if `tag` is accepted as a SemVer-style Git tag; otherwise `false`
14
+ */
15
+ export function isSemVerGitTag(tag: string): boolean {
16
+ const match = new RegExp(`^${SEMVER_REGEX.source}$`).exec(tag)
17
+
18
+ if (!match) return false
19
+
20
+ const isScopedPackage = Boolean(match[1])
21
+
22
+ const hasVersionPrefix = tag.startsWith('v')
23
+
24
+ return isScopedPackage || hasVersionPrefix
25
+ }
@@ -0,0 +1,37 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import type { CommitClassifier, ReleaseConfig, ReleaseType } from '#types'
5
+
6
+ /**
7
+ * Normalize a commit classifier into a consistent function.
8
+ *
9
+ * Accepts `null`/`undefined` (returns no-op classifier), a function classifier,
10
+ * or a config object mapping commit types to release types. Returns a classifier
11
+ * function that maps commits to their corresponding release types.
12
+ *
13
+ * @param classifier Commit classifier config, function, or falsy value
14
+ * @returns Normalized classifier function
15
+ */
16
+ export function normalizeCommitFilter(
17
+ classifier: ReleaseConfig['classifier'] | null | undefined
18
+ ): CommitClassifier {
19
+ if (classifier == null) {
20
+ return () => null
21
+ }
22
+
23
+ if (typeof classifier === 'function') {
24
+ return classifier
25
+ }
26
+
27
+ const map = new Map(
28
+ Object.entries(classifier).flatMap(([releaseType, commitTypes]) => {
29
+ return commitTypes.map((commitType) => [
30
+ commitType,
31
+ releaseType as ReleaseType,
32
+ ])
33
+ })
34
+ )
35
+
36
+ return (commit) => (commit.type ? map.get(commit.type) : null)
37
+ }
@@ -0,0 +1,42 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import type { ReleaseContext, ReleaseRule } from '#types'
5
+
6
+ /**
7
+ * Normalize a release rule for the given ref.
8
+ *
9
+ * If `rule` is `true`, derive the rule from the ref (branch or tag).
10
+ * Falsy inputs return an empty rule.
11
+ *
12
+ * @param ref Current repository ref (branch or tag)
13
+ * @param rule Rule input to normalize
14
+ * @returns Normalized `ReleaseRule` object
15
+ */
16
+ export function normalizeReleaseRule(
17
+ ref: ReleaseContext['ref'],
18
+ rule: boolean | string | Partial<ReleaseRule> | null | undefined
19
+ ): ReleaseRule {
20
+ if (rule === true) {
21
+ switch (ref.type) {
22
+ case 'branch':
23
+ rule = ref.name.split('/').at(0)
24
+ break
25
+ }
26
+ }
27
+
28
+ if (!rule) {
29
+ rule = {}
30
+ }
31
+
32
+ if (typeof rule === 'string') {
33
+ rule = { prereleaseLabel: rule, releaseChannel: rule }
34
+ }
35
+
36
+ return {
37
+ type: rule.type || ref.type,
38
+ pattern: rule.pattern || ref.name,
39
+ prereleaseLabel: rule.prereleaseLabel,
40
+ releaseChannel: rule.releaseChannel,
41
+ }
42
+ }
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import type { Workspace } from '#types'
5
+ import path from 'node:path'
6
+
7
+ /**
8
+ * Normalize and validate workspace configuration for a repository.
9
+ *
10
+ * Resolves the workspace entry directory relative to `cwd` and normalizes
11
+ * the path representation. Returns a workspace object with normalized `entry`
12
+ * and optional workspace members.
13
+ *
14
+ * @param cwd Repository working directory
15
+ * @param workspace Partial workspace configuration (optional)
16
+ * @returns Normalized workspace object
17
+ */
18
+ export function normalizeWorkspace(
19
+ cwd: string,
20
+ workspace?: {
21
+ entry?: string | null | undefined
22
+ members?: readonly string[] | null | undefined
23
+ }
24
+ ): Workspace {
25
+ let entry = workspace?.entry || '.'
26
+ const members = workspace?.members ?? null
27
+
28
+ entry = path.normalize(path.relative(cwd, path.resolve(cwd, entry)))
29
+
30
+ return {
31
+ entry,
32
+ members,
33
+ }
34
+ }
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import type { ReleasePlugin } from '#types'
5
+ import { createRequire } from 'node:module'
6
+ import { pathToFileURL } from 'node:url'
7
+
8
+ /**
9
+ * Resolve a plugin reference to a `ReleasePlugin` instance.
10
+ *
11
+ * Accepts a module specifier, a loaded plugin object, or an initializer
12
+ * function (optionally with options) and normalizes it to a `ReleasePlugin`,
13
+ * or `null` when resolution fails.
14
+ *
15
+ * @param cwd Base directory used to resolve module specifiers
16
+ * @param plugin Plugin reference (module path, function, object, or [path, opts])
17
+ * @returns Resolved `ReleasePlugin` or `null` when not resolvable
18
+ */
19
+ export async function resolvePlugin(
20
+ cwd: string,
21
+ plugin: unknown
22
+ ): Promise<ReleasePlugin | null> {
23
+ let options: unknown
24
+
25
+ if (Array.isArray(plugin)) {
26
+ ;[plugin, options] = plugin
27
+ }
28
+
29
+ if (typeof plugin === 'string') {
30
+ const require = createRequire(pathToFileURL(cwd))
31
+ plugin = await import(require.resolve(plugin))
32
+ }
33
+
34
+ if (
35
+ plugin != null &&
36
+ typeof plugin === 'object' &&
37
+ Object.hasOwn(plugin, 'default')
38
+ ) {
39
+ plugin = (plugin as { default: unknown }).default
40
+ }
41
+
42
+ if (typeof plugin === 'function') {
43
+ plugin = plugin(options)
44
+ }
45
+
46
+ if (
47
+ plugin != null &&
48
+ typeof plugin === 'object' &&
49
+ Object.hasOwn(plugin, 'name') &&
50
+ typeof (plugin as ReleasePlugin).name === 'string'
51
+ ) {
52
+ return plugin as ReleasePlugin
53
+ }
54
+
55
+ return null
56
+ }
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import type { ReleaseContext, ReleaseRule } from '#types'
5
+ import { interpolate, isMatch } from '#util'
6
+ import { sanitizePrereleaseLabel } from './sanitizePrereleaseLabel'
7
+
8
+ export type ResolveReleaseTargetOptions = Record<
9
+ Extract<keyof ReleaseRule, 'prereleaseLabel' | 'releaseChannel'>,
10
+ string | boolean | undefined
11
+ >
12
+
13
+ /**
14
+ * Resolve prerelease label and release channel for a ref.
15
+ *
16
+ * Resolution priority for each key:
17
+ * 1. Explicit `options` (string values are used verbatim; `false` disables the key).
18
+ * 2. First matching `rule` (rules are checked in order).
19
+ * 3. Fallback: derive and sanitize a label from `ref.name`.
20
+ *
21
+ * Resolved string values are interpolated with `data` before being returned.
22
+ *
23
+ * @param ref Reference context containing `type` and `name`
24
+ * @param rules Ordered list of release rules to match against
25
+ * @param options User overrides for `prereleaseLabel` and `releaseChannel`
26
+ * @param data Values used to interpolate resolved template strings
27
+ * @returns Partial release rule with resolved `prereleaseLabel` and/or `releaseChannel`
28
+ */
29
+ export function resolveReleaseTarget(
30
+ ref: ReleaseContext['ref'],
31
+ rules: readonly ReleaseRule[],
32
+ options: ResolveReleaseTargetOptions,
33
+ data: Record<string, unknown>
34
+ ): Partial<ReleaseRule> {
35
+ const matchedRule = rules.find(
36
+ (rule) => rule.type === ref.type && isMatch(ref.name, rule.pattern)
37
+ )
38
+
39
+ const resolve = (key: keyof ResolveReleaseTargetOptions) => {
40
+ const value1 = options[key]
41
+
42
+ if (value1 === false) {
43
+ return undefined
44
+ }
45
+
46
+ if (value1 && typeof value1 === 'string') {
47
+ return value1
48
+ }
49
+
50
+ const value2 = matchedRule?.[key] || value1
51
+
52
+ if (!value2) {
53
+ return undefined
54
+ }
55
+
56
+ if (typeof value2 === 'string') {
57
+ return value2
58
+ }
59
+
60
+ return sanitizePrereleaseLabel(ref.name)
61
+ }
62
+
63
+ const prereleaseLabel = resolve('prereleaseLabel')
64
+
65
+ const releaseChannel = resolve('releaseChannel')
66
+
67
+ return {
68
+ prereleaseLabel: prereleaseLabel && interpolate(prereleaseLabel, data),
69
+ releaseChannel: releaseChannel && interpolate(releaseChannel, data),
70
+ }
71
+ }
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ /**
5
+ * Sanitize a string into a safe, semver-compliant prerelease label.
6
+ *
7
+ * Replaces sequential non-alphanumeric characters or consecutive hyphens
8
+ * with a single hyphen, and strips leading or trailing hyphens.
9
+ *
10
+ * @param label Raw string to sanitize
11
+ * @returns Safe string for a prerelease label
12
+ */
13
+ export function sanitizePrereleaseLabel(label: string): string {
14
+ return label
15
+ .replace(/[^a-zA-Z0-9]+/g, '-')
16
+ .replace(/-+/g, '-')
17
+ .replace(/^-+|-+$/g, '')
18
+ }
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { execGit } from '#util'
5
+
6
+ /**
7
+ * Get the current Git branch name.
8
+ *
9
+ * Uses `execGit` to run `git symbolic-ref -q --short HEAD` in the provided
10
+ * working directory and trims the result. Returns an empty string when the
11
+ * repository is in a detached HEAD state.
12
+ *
13
+ * @param cwd The working directory to run the Git command in
14
+ * @returns The current branch name as a string
15
+ */
16
+ export async function getCurrentGitBranch(cwd: string): Promise<string> {
17
+ return (await execGit(cwd, ['symbolic-ref', '-q', '--short', 'HEAD'])).trim()
18
+ }
@@ -0,0 +1,4 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ export * from './getCurrentGitBranch'
@@ -0,0 +1,29 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { execGit } from '#util'
5
+
6
+ /**
7
+ * Stage all changes and create a git commit with the specified message.
8
+ *
9
+ * Validates the commit message and working directory status before staging
10
+ * all changes and executing the commit command.
11
+ *
12
+ * @param cwd The target directory path for executing git commands
13
+ * @param message The message to associate with the commit
14
+ * @returns A promise that resolves when the operation completes
15
+ */
16
+ export async function createGitCommit(
17
+ cwd: string,
18
+ message: string | null | undefined
19
+ ): Promise<void> {
20
+ if (!message) return
21
+
22
+ await execGit(cwd, ['add', '.'])
23
+
24
+ const stdout = (await execGit(cwd, ['status', '--porcelain'])).trim()
25
+
26
+ if (!stdout) return
27
+
28
+ await execGit(cwd, ['commit', '--message', message])
29
+ }
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ const ALPHANUMERIC = '[a-zA-Z0-9]'
5
+ const SEPARATOR = '[-_]'
6
+ const USERNAME = `${ALPHANUMERIC}+(?:${SEPARATOR}+${ALPHANUMERIC}+)*`
7
+ const EMAIL_LOCAL = '[a-zA-Z0-9._%+-]'
8
+ const DOMAIN_SUFFIX = '[a-zA-Z0-9.-]*\\.[a-zA-Z]{2,}'
9
+ const SCOPED_PACKAGE_SPEC_SUFFIX = `/${USERNAME}@[0-9]+\\.[0-9]+\\.[0-9]+`
10
+
11
+ /**
12
+ * Extract unique mention handles from the input text string.
13
+ *
14
+ * Evaluates `text` against boundary rules to isolate valid handles while
15
+ * excluding email addresses, domain names, and scoped package specs, then
16
+ * de-duplicates the resulting handles.
17
+ *
18
+ * @param text The input string to parse for mentions
19
+ * @returns An array of unique mention handles
20
+ */
21
+ export function extractMentions(text: string): string[] {
22
+ if (!text) return []
23
+
24
+ const regex = new RegExp(
25
+ `(?<!${EMAIL_LOCAL})@(${USERNAME})\\b(?!${SEPARATOR}|${DOMAIN_SUFFIX}|${SCOPED_PACKAGE_SPEC_SUFFIX})`,
26
+ 'g'
27
+ )
28
+
29
+ const matches = text.matchAll(regex)
30
+
31
+ const mentions = new Set(matches.map((match) => match[1] as string))
32
+
33
+ return Array.from(mentions)
34
+ }