@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,47 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { colors } from 'consola/utils'
5
+ import fs from 'node:fs/promises'
6
+
7
+ /**
8
+ * Read and parse a JSON file.
9
+ *
10
+ * Throws a descriptive error when the file cannot be read or parsed, or when
11
+ * the parsed value is not an object.
12
+ *
13
+ * @param filePath Path to the JSON file
14
+ * @returns Parsed JSON object
15
+ * @throws If the file cannot be read, parsed, or is not an object
16
+ */
17
+ export async function readJson<T>(filePath: string): Promise<T> {
18
+ let content: string
19
+
20
+ try {
21
+ content = await fs.readFile(filePath, 'utf-8')
22
+ } catch (err) {
23
+ throw new Error(
24
+ `JSON file at "${colors.bold(filePath)}" could not be read.`,
25
+ { cause: err }
26
+ )
27
+ }
28
+
29
+ let json: T
30
+
31
+ try {
32
+ json = JSON.parse(content) as T
33
+ } catch (err) {
34
+ throw new Error(
35
+ `JSON file at "${colors.bold(filePath)}" could not be parsed.`,
36
+ { cause: err }
37
+ )
38
+ }
39
+
40
+ if (typeof json !== 'object' || json === null) {
41
+ throw new Error(
42
+ `JSON file at "${colors.bold(filePath)}" does not contain a valid object.`
43
+ )
44
+ }
45
+
46
+ return json
47
+ }
@@ -0,0 +1,26 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ /**
5
+ * Return a new array containing only the first-seen occurrence of each item.
6
+ *
7
+ * This preserves the original iteration order and performs equality checks
8
+ * using JavaScript's `Set` semantics (i.e., primitives by value, objects by
9
+ * reference). The input array is not mutated.
10
+ *
11
+ * @param array The array of items to deduplicate
12
+ * @returns A new array with duplicates removed, preserving first-seen order
13
+ */
14
+ export function unique<T>(array: readonly T[]): T[] {
15
+ const seen = new Set<T>()
16
+ const result: T[] = []
17
+
18
+ for (const item of array) {
19
+ if (!seen.has(item)) {
20
+ seen.add(item)
21
+ result.push(item)
22
+ }
23
+ }
24
+
25
+ return result
26
+ }
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import fs from 'node:fs/promises'
5
+ import path from 'node:path'
6
+ import { formatFile } from './formatFile'
7
+
8
+ /**
9
+ * Write a JSON `payload` to `filePath`, formatting the content first.
10
+ *
11
+ * Ensures the directory exists and writes the formatted JSON.
12
+ *
13
+ * @param filePath Path to write the JSON file
14
+ * @param payload JSON-serializable object to write
15
+ * @returns Promise that resolves when the file is written
16
+ */
17
+ export async function writeJson(
18
+ filePath: string,
19
+ payload: object
20
+ ): Promise<void> {
21
+ let content = JSON.stringify(payload, null, 2)
22
+
23
+ content = await formatFile(filePath, content)
24
+
25
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
26
+
27
+ await fs.writeFile(filePath, content)
28
+ }
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import type { Commit, CommitClassifier, ReleasePlan } from '#types'
5
+ import { deriveReleaseType, pickPriorityReleaseType } from '../versioning'
6
+ import { isCommitForPackage } from './isCommitForPackage'
7
+
8
+ /**
9
+ * Analyze commit history and compute release types for each package plan.
10
+ *
11
+ * Iterates through commits, associates them with affected packages, and
12
+ * calculates each package's release type using the provided classifier.
13
+ *
14
+ * @param classifier Function mapping commits to release types
15
+ * @param plans Array of release plans to update
16
+ * @param history Commit history as iterable or async iterable
17
+ * @returns Updated array of plans with computed `releaseType`
18
+ */
19
+ export async function analyzePackageChanges(
20
+ classifier: CommitClassifier,
21
+ plans: readonly ReleasePlan[],
22
+ history: Iterable<Commit> | AsyncIterable<Commit>
23
+ ): Promise<ReleasePlan[]> {
24
+ for await (const commit of history) {
25
+ plans = plans.map((plan): ReleasePlan => {
26
+ if (!isCommitForPackage(plan.package, commit)) {
27
+ return plan
28
+ }
29
+
30
+ return {
31
+ ...plan,
32
+ history: plan.history.concat(commit),
33
+ }
34
+ })
35
+ }
36
+
37
+ return plans.map((plan): ReleasePlan => {
38
+ let { release } = plan
39
+
40
+ const releaseType = pickPriorityReleaseType(
41
+ release?.type,
42
+ deriveReleaseType(classifier, plan.history)
43
+ )
44
+
45
+ if (releaseType) {
46
+ release = { ...release, type: releaseType }
47
+ }
48
+
49
+ return {
50
+ ...plan,
51
+ release,
52
+ }
53
+ })
54
+ }
@@ -0,0 +1,9 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ export * from './analyzePackageChanges'
5
+ export * from './isCommitForPackage'
6
+ export * from './promotePrereleaseVersions'
7
+ export * from './propagateDependencyUpdates'
8
+ export * from './sliceUnreleased'
9
+ export * from './streamPackageCommits'
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { ALL_FILES } from '#config'
5
+ import type { Commit, Package } from '#types'
6
+ import { isMatch } from '#util'
7
+ import path from 'node:path'
8
+
9
+ /**
10
+ * Test whether a commit modifies files in a package.
11
+ *
12
+ * @param pkg Package with an explicit file pattern or base directory
13
+ * @param commit Parsed commit containing changed file paths
14
+ * @returns `true` if any changed file matches the package pattern
15
+ */
16
+ export function isCommitForPackage(pkg: Package, commit: Commit): boolean {
17
+ return commit.files.some((file) => {
18
+ const { dir } = pkg
19
+ const files = pkg.files || path.join(dir, ALL_FILES)
20
+
21
+ return isMatch(file, files)
22
+ })
23
+ }
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import type { ReleasePlan, ReleaseType } from '#types'
5
+ import semver from 'semver'
6
+
7
+ /**
8
+ * Promote prerelease versions when they have no explicit release type.
9
+ *
10
+ * Analyzes prerelease and zero-major versions to determine an appropriate
11
+ * release type for graduation:
12
+ * - Prerelease versions are promoted based on their highest incremented component
13
+ * (patch, minor, or major)
14
+ * - Zero-major versions (0.y.z) are promoted to major
15
+ * - Stable versions (1.0.0+) remain unchanged
16
+ * - Plans with explicit releases are preserved as-is
17
+ *
18
+ * @param plans Release plans to analyze
19
+ * @returns Plans with inferred release types for prerelease versions
20
+ */
21
+ export async function promotePrereleaseVersions(
22
+ plans: readonly ReleasePlan[]
23
+ ): Promise<ReleasePlan[]> {
24
+ return plans.map((plan): ReleasePlan => {
25
+ if (plan.release) return plan
26
+
27
+ if (!plan.package.tag) return plan
28
+
29
+ const parsed = semver.parse(plan.package.version)
30
+
31
+ if (!parsed) return plan
32
+
33
+ let type: ReleaseType | undefined
34
+
35
+ if (parsed.prerelease.length) {
36
+ if (parsed.patch !== 0) type = 'patch'
37
+ else if (parsed.minor !== 0) type = 'minor'
38
+ else if (parsed.major !== 0) type = 'major'
39
+ else type = 'major'
40
+ } else if (parsed.major === 0) {
41
+ type = 'major'
42
+ }
43
+
44
+ if (type) {
45
+ return { ...plan, release: { type } }
46
+ }
47
+
48
+ return plan
49
+ })
50
+ }
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { mergeGitHistory } from '#git'
5
+ import type { Commit, ReleasePlan } from '#types'
6
+ import { buildDependencyGraph } from '../discovery'
7
+
8
+ /**
9
+ * Propagate release updates through package dependencies.
10
+ *
11
+ * Marks packages that depend on a releasing package for a `patch` release
12
+ * when they do not already have a release type.
13
+ *
14
+ * @param plans Array of release plans to update
15
+ * @returns Updated array of release plans
16
+ */
17
+ export async function propagateDependencyUpdates(
18
+ cwd: string,
19
+ plans: readonly ReleasePlan[]
20
+ ): Promise<ReleasePlan[]> {
21
+ const graph = buildDependencyGraph(plans)
22
+
23
+ const scheduled = new Map<ReleasePlan, Commit[]>()
24
+
25
+ const stack: ReleasePlan[] = []
26
+
27
+ for (const plan of plans) {
28
+ if (plan.release?.type) {
29
+ const visited = new Set([plan])
30
+
31
+ stack.push(plan)
32
+
33
+ let curr: ReleasePlan | undefined
34
+
35
+ while ((curr = stack.pop())) {
36
+ if (curr.package.name) {
37
+ for (const dependent of graph.get(curr.package.name) ?? []) {
38
+ if (!visited.has(dependent)) {
39
+ visited.add(dependent)
40
+
41
+ stack.push(dependent)
42
+
43
+ scheduled.set(
44
+ dependent,
45
+ await mergeGitHistory(
46
+ cwd,
47
+ scheduled.get(dependent) ?? dependent.history,
48
+ plan.history
49
+ )
50
+ )
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ return plans.map((plan) => {
59
+ const history = scheduled.get(plan)
60
+
61
+ if (history) {
62
+ plan = { ...plan, history }
63
+
64
+ if (!plan.release?.type) {
65
+ plan.release = { type: 'patch' }
66
+ }
67
+ }
68
+
69
+ return plan
70
+ })
71
+ }
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { showParsedGitHistory, type ShowGitHistoryOptions } from '#git'
5
+ import type { Commit } from '#types'
6
+ import {
7
+ isReleaseCommit,
8
+ type ReleaseMarker,
9
+ } from '../committing/isReleaseCommit'
10
+
11
+ export interface SliceUnreleasedOptions extends ShowGitHistoryOptions {
12
+ marker?: ReleaseMarker | undefined
13
+ }
14
+
15
+ /**
16
+ * Yield parsed commits from the workspace until a release commit is found.
17
+ *
18
+ * Iterates through parsed commit history and yields unreleased commits in
19
+ * order. Stops processing upon reaching the first release commit.
20
+ *
21
+ * @param cwd Path to the working directory
22
+ * @param options Options for configuring Git history retrieval
23
+ * @yields Parsed `Commit` objects up to the most recent release commit
24
+ */
25
+ export async function* sliceUnreleased(
26
+ cwd: string,
27
+ options?: SliceUnreleasedOptions
28
+ ): AsyncGenerator<Commit> {
29
+ for await (const commit of showParsedGitHistory(cwd, options)) {
30
+ if (await isReleaseCommit(cwd, commit, options?.marker)) {
31
+ break
32
+ }
33
+
34
+ yield commit
35
+ }
36
+ }
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { showParsedGitHistory, type ShowGitHistoryOptions } from '#git'
5
+ import type { Commit, Package } from '#types'
6
+ import { isCommitForPackage } from './isCommitForPackage'
7
+
8
+ /**
9
+ * Yield commits from git history associated with a package.
10
+ *
11
+ * Filters git commit history to stream only entries relevant to the target
12
+ * `pkg` directory.
13
+ *
14
+ * @param cwd Path to the working directory for git commands
15
+ * @param pkg Package configuration containing the target directory
16
+ * @param options Options for parsing and filtering git history
17
+ * @yields Commits belonging to the specified package
18
+ */
19
+ export async function* streamPackageCommits(
20
+ cwd: string,
21
+ pkg: Package,
22
+ options: ShowGitHistoryOptions = {}
23
+ ): AsyncGenerator<Commit> {
24
+ options = {
25
+ ...options,
26
+ filePath: options.filePath || pkg.dir,
27
+ }
28
+
29
+ for await (const commit of showParsedGitHistory(cwd, options)) {
30
+ if (isCommitForPackage(pkg, commit)) {
31
+ yield commit
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { DEFAULT_CHANGELOG_FILE } from '#config'
5
+ import type { Commit, ReleaseContext } from '#types'
6
+ import { formatFile } from '#util'
7
+ import createPreset from 'conventional-changelog-conventionalcommits'
8
+ import {
9
+ writeChangelogString,
10
+ type CommitKnownProps,
11
+ type FinalTemplateContext,
12
+ type TemplateContext,
13
+ } from 'conventional-changelog-writer'
14
+ import path from 'node:path'
15
+
16
+ export interface BuildChangelogOptions {
17
+ packageDir?: string | undefined
18
+ changelogFile?: string | undefined
19
+ version?: string | null | undefined
20
+ previousTag?: string | null | undefined
21
+ currentTag?: string | null | undefined
22
+ }
23
+
24
+ const preset = await createPreset()
25
+
26
+ const headerPartial = (context: FinalTemplateContext) => {
27
+ return context.version ? preset.writer.headerPartial?.(context) : undefined
28
+ }
29
+
30
+ /**
31
+ * Build the changelog string for a release.
32
+ *
33
+ * Compiles commit history and release context into a formatted changelog using
34
+ * conventional commits and writer configurations. Resolves file paths and handles
35
+ * tag comparisons when repository URLs are available.
36
+ *
37
+ * @param context The release context containing repository and working directory details
38
+ * @param history The iterable or async iterable collection of commits to process
39
+ * @param options The build configuration options for the changelog
40
+ * @returns The formatted changelog content string
41
+ */
42
+ export async function buildChangelog(
43
+ context: ReleaseContext,
44
+ history: Iterable<Commit> | AsyncIterable<Commit>,
45
+ options: BuildChangelogOptions = {}
46
+ ): Promise<string> {
47
+ const repository = context.repository?.url || undefined
48
+
49
+ const version = options.version || undefined
50
+
51
+ const previousTag = options.previousTag || undefined
52
+
53
+ const currentTag = options.currentTag || undefined
54
+
55
+ const filePath = path.join(
56
+ context.cwd,
57
+ options.packageDir || '.',
58
+ options.changelogFile || DEFAULT_CHANGELOG_FILE
59
+ )
60
+
61
+ const content = await writeChangelogString<CommitKnownProps>(
62
+ history,
63
+ {
64
+ date: context.today,
65
+ version,
66
+ previousTag,
67
+ currentTag,
68
+ linkCompare: !!repository && !!previousTag && !!currentTag,
69
+ linkReferences: !!repository,
70
+ repoUrl: repository,
71
+ commit: 'commit',
72
+ issue: 'issues',
73
+ headerPartial,
74
+ } as TemplateContext,
75
+ preset.writer
76
+ )
77
+
78
+ return formatFile(filePath, content)
79
+ }
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { DEFAULT_CHANGELOG_FILE } from '#config'
5
+ import type { Commit, ReleaseContext } from '#types'
6
+ import { formatFile } from '#util'
7
+ import type { Heading, RootContent } from 'mdast'
8
+ import { fromMarkdown } from 'mdast-util-from-markdown'
9
+ import { toMarkdown } from 'mdast-util-to-markdown'
10
+ import path from 'node:path'
11
+ import { buildChangelog } from './buildChangelog'
12
+
13
+ /**
14
+ * Build release notes from a release context and commit history.
15
+ *
16
+ * Generates changelog content from commits, parses it into an abstract syntax
17
+ * tree, shifts heading depths, and formats the resulting markdown file.
18
+ *
19
+ * @param context The release context containing repository and configuration details
20
+ * @param history The iterable or async iterable collection of commits to process
21
+ * @returns The formatted release notes as a markdown string
22
+ */
23
+ export async function buildReleaseNotes(
24
+ context: ReleaseContext,
25
+ history: Iterable<Commit> | AsyncIterable<Commit>
26
+ ): Promise<string> {
27
+ const changelogContent = await buildChangelog(context, history)
28
+
29
+ const root = fromMarkdown(changelogContent)
30
+
31
+ root.children = root.children.flatMap<RootContent>((node) => {
32
+ if (node.type === 'heading') {
33
+ /* istanbul ignore next */
34
+ if (node.depth <= 2) {
35
+ return []
36
+ }
37
+
38
+ node = {
39
+ ...node,
40
+ depth: (node.depth - 1) as Heading['depth'],
41
+ }
42
+ }
43
+
44
+ return node
45
+ })
46
+
47
+ const filePath = path.join(context.cwd, DEFAULT_CHANGELOG_FILE)
48
+
49
+ return formatFile(filePath, toMarkdown(root))
50
+ }
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { iterateChangelogVersions } from './iterateChangelogVersions'
5
+
6
+ /**
7
+ * Find the highest semantic version from the first version-bearing element.
8
+ *
9
+ * Pulls the first available version string from the parsed markdown iterator.
10
+ * Short-circuits execution immediately upon locating a match to avoid passing
11
+ * through the remaining document structure.
12
+ *
13
+ * @param changelogContent The raw markdown string representing the changelog
14
+ * @returns The highest valid semantic version string found or null
15
+ */
16
+ export function extractLatestVersion(changelogContent: string): string | null {
17
+ for (const version of iterateChangelogVersions(changelogContent)) {
18
+ return version
19
+ }
20
+
21
+ return null
22
+ }
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import type { Heading, Paragraph, PhrasingContent } from 'mdast'
5
+
6
+ /**
7
+ * Extract trimmed plain text from a Heading, Paragraph, or PhrasingContent node.
8
+ *
9
+ * Performs a depth-first traversal, concatenating string `value` tokens in
10
+ * document order. Non-string values are ignored. Returns `null` for empty or
11
+ * missing input.
12
+ *
13
+ * @param node Node to extract text from
14
+ * @returns The trimmed plain-text string, or null if empty
15
+ */
16
+ export function extractPlainText(
17
+ node: Heading | Paragraph | PhrasingContent | null | undefined
18
+ ): string | null {
19
+ if (!node) {
20
+ return null
21
+ }
22
+
23
+ const queue = [node]
24
+ let result = ''
25
+
26
+ while ((node = queue.shift())) {
27
+ if ('value' in node && typeof node.value === 'string') {
28
+ result += node.value
29
+ }
30
+
31
+ /* istanbul ignore else */
32
+ if ('children' in node && Array.isArray(node.children)) {
33
+ queue.unshift(...node.children)
34
+ }
35
+ }
36
+
37
+ return result.trim() || null
38
+ }
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { colors } from 'consola/utils'
5
+ import semver from 'semver'
6
+ import { iterateChangelogVersions } from './iterateChangelogVersions'
7
+
8
+ // TODO: remove unused
9
+ /**
10
+ * Find the latest stable version that precedes the current version.
11
+ *
12
+ * Parses markdown from top to bottom to extract semantic versions from headings
13
+ * and paragraphs. Enforces that discovered versions appear in strict
14
+ * descending order. Returns the first version that falls outside the current
15
+ * version's major, minor, and patch boundaries if the current version is
16
+ * `v1.0.0` or higher, or the first non-matching version for `v0.x.x` releases.
17
+ *
18
+ * @param currentVersion The version to evaluate against
19
+ * @param changelogContent The raw markdown content to scan for versions
20
+ * @returns The resolved stable version string or null when none is found
21
+ * @throws If `currentVersion` is invalid or already stable, or if versions in the changelog are not listed in strict descending order
22
+ */
23
+ export async function extractPreviousVersion(
24
+ currentVersion: string | null | undefined,
25
+ changelogContent: string
26
+ ): Promise<string | null> {
27
+ const parsedCurrent = semver.parse(currentVersion)
28
+
29
+ if (!currentVersion || !parsedCurrent) {
30
+ throw new Error(
31
+ `The current version "${colors.bold(String(currentVersion))}" is not a valid semantic version.`
32
+ )
33
+ }
34
+
35
+ const currentMajor = parsedCurrent.major
36
+
37
+ if (currentMajor >= 1 && parsedCurrent.prerelease.length === 0) {
38
+ throw new Error(
39
+ `The current version "${colors.bold(currentVersion)}" is already a stable release.`
40
+ )
41
+ }
42
+
43
+ const baseVersion = `${parsedCurrent.major}.${parsedCurrent.minor}.${parsedCurrent.patch}`
44
+
45
+ let priorVersion: semver.SemVer | null = null
46
+
47
+ for (const version of iterateChangelogVersions(changelogContent)) {
48
+ const parsedVersion = semver.parse(version)
49
+
50
+ /* istanbul ignore else */
51
+ if (parsedVersion) {
52
+ if (!priorVersion) {
53
+ if (version !== currentVersion) {
54
+ return version
55
+ }
56
+
57
+ priorVersion = parsedVersion
58
+ } else {
59
+ if (semver.gte(parsedVersion, priorVersion)) {
60
+ throw new Error(
61
+ `Each newer release must have a higher version, but "${colors.bold(priorVersion.version)}" appears before "${colors.bold(version)}".`
62
+ )
63
+ }
64
+
65
+ priorVersion = parsedVersion
66
+
67
+ const { major, minor, patch } = parsedVersion
68
+
69
+ if (currentMajor >= 1 && `${major}.${minor}.${patch}` !== baseVersion) {
70
+ return version
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ return null
77
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Fai
3
+
4
+ import { SEMVER_REGEX } from '#config'
5
+ import { compact } from '#util'
6
+
7
+ const ANCHORED_SEMVER_REGEX = new RegExp(`^${SEMVER_REGEX.source}$`)
8
+
9
+ const BRACKETED_SEMVER_REGEX = new RegExp(`\\[${SEMVER_REGEX.source}\\]`)
10
+
11
+ /**
12
+ * Extract SemVer core strings from a line.
13
+ *
14
+ * Splits the line on runs of spaces or tabs and on `..`/`...`. For each token,
15
+ * first attempts an anchored SemVer match (must match the whole token), then
16
+ * a bracketed match that finds a SemVer inside `[...]`. Returns the captured
17
+ * SemVer core (capture group 2) for each successful match; falsy results are
18
+ * filtered out.
19
+ *
20
+ * @param line Line to scan for SemVer cores
21
+ * @returns Array of extracted SemVer core strings; empty if none
22
+ */
23
+ export function extractSemVer(line: string): string[] {
24
+ const tokens = line.split(/[ \t]+|\.{2,3}/)
25
+
26
+ return compact(
27
+ tokens.map((token) => {
28
+ return (
29
+ ANCHORED_SEMVER_REGEX.exec(token) ?? BRACKETED_SEMVER_REGEX.exec(token)
30
+ )?.at(2)
31
+ })
32
+ )
33
+ }