From 48fc64afb8f55b955ab9d04c317ea92e9917f4e1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 8 Sep 2026 08:51:25 +0100 Subject: [PATCH 1/4] refactor: share package.json version reading between resolveBuildIdentity and predictNextVersion Both need the same "read the semantic-release-managed version from package.json" logic. Extract it into its own module, alongside the default v tag-name convention, so the invariant lives in one place rather than being copied. --- src/package-version.ts | 28 ++++++++++++++++++++++++++++ src/resolve-build-identity.ts | 27 +-------------------------- 2 files changed, 29 insertions(+), 26 deletions(-) create mode 100644 src/package-version.ts diff --git a/src/package-version.ts b/src/package-version.ts new file mode 100644 index 0000000..fbf9463 --- /dev/null +++ b/src/package-version.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Turns a version into the tag name expected to mark its release -- the shared default (and shared option shape) both `resolveBuildIdentity` and `predictNextVersion` accept, so a repo that tags differently only has to say so once per call site, not maintain two independent conventions. */ +export function defaultTagName(version: string): string { + return `v${version}`; +} + +function isPackageJsonWithVersion(value: unknown): value is { version: string } { + if (typeof value !== 'object' || value === null) { + return false; + } + if (!('version' in value)) { + return false; + } + return typeof value.version === 'string' && value.version.length > 0; +} + +/** The single semantic-release-managed version, read from `package.json` at `repoRoot`'s root -- shared by `resolveBuildIdentity` (what this build's version is, if released) and `predictNextVersion` (the version to diff commits since). Throws rather than defaulting, matching this package's own "no sensible placeholder identity" stance. */ +export function readPackageVersion(repoRoot: string): string { + const packageJsonPath = join(repoRoot, 'package.json'); + const raw = readFileSync(packageJsonPath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (!isPackageJsonWithVersion(parsed)) { + throw new Error(`${packageJsonPath} must contain a non-empty string "version" field`); + } + return parsed.version; +} diff --git a/src/resolve-build-identity.ts b/src/resolve-build-identity.ts index b9042e3..ec66d37 100644 --- a/src/resolve-build-identity.ts +++ b/src/resolve-build-identity.ts @@ -1,34 +1,9 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { getHeadCommit, tagPointsAtHead } from './git'; +import { defaultTagName, readPackageVersion } from './package-version'; import type { BuildIdentity, ResolveBuildIdentityOptions } from './types'; const REPO_SLUG_PATTERN = /^[^/\s]+\/[^/\s]+$/; -function defaultTagName(version: string): string { - return `v${version}`; -} - -function isPackageJsonWithVersion(value: unknown): value is { version: string } { - if (typeof value !== 'object' || value === null) { - return false; - } - if (!('version' in value)) { - return false; - } - return typeof value.version === 'string' && value.version.length > 0; -} - -function readPackageVersion(repoRoot: string): string { - const packageJsonPath = join(repoRoot, 'package.json'); - const raw = readFileSync(packageJsonPath, 'utf8'); - const parsed: unknown = JSON.parse(raw); - if (!isPackageJsonWithVersion(parsed)) { - throw new Error(`${packageJsonPath} must contain a non-empty string "version" field`); - } - return parsed.version; -} - function assertRepoSlug(repoSlug: string): void { if (!REPO_SLUG_PATTERN.test(repoSlug)) { throw new Error(`repoSlug must be a GitHub "owner/repo" slug, got: ${JSON.stringify(repoSlug)}`); From 73cd18353025683cec105212041711411b624c1c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 8 Sep 2026 08:51:41 +0100 Subject: [PATCH 2/4] feat!: model a predicted-but-unreleased build as its own DisplayIdentity kind resolvePredictedIdentity used to return BuildIdentity & { predicted?: boolean }, bolting an optional flag onto a two-value kind rather than naming the third real state directly. A caller had to check kind === 'commit' and then separately check predicted, when there are really three states to switch on: a confirmed release, a predicted-but-not- yet-tagged version, and a plain unreleased commit with no prediction available. DisplayIdentity's kind now has all three as its own literal values, and predicted is gone. BREAKING CHANGE: resolvePredictedIdentity's return type is now DisplayIdentity (kind: 'release' | 'predicted' | 'commit'), not BuildIdentity & { predicted?: boolean }. An unreleased build with a prediction now reports kind: 'predicted' instead of kind: 'commit' with a separate predicted: true field. --- README.md | 59 +++++++++++++++++++++++--- src/resolve-predicted-identity.test.ts | 3 +- src/resolve-predicted-identity.ts | 13 +++--- src/types.ts | 11 +++++ 4 files changed, 71 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 37df49d..27652cd 100644 --- a/README.md +++ b/README.md @@ -50,22 +50,69 @@ interface ResolveBuildIdentityOptions { ## `resolvePredictedIdentity(build, predictedVersion)` ```ts -function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): BuildIdentity & { predicted?: boolean }; +type DisplayIdentity = { kind: 'release' | 'predicted' | 'commit'; version: string; url: string; date: string; commit: string }; + +function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): DisplayIdentity; ``` -An unreleased build's `version` is a short commit hash, which isn't always what you want to show a user -- often what's actually useful is *the version this commit will become once it releases*. This function lets a caller upgrade the displayed label to a predicted version (e.g. computed by running a commit-analyzer-style tool such as `semantic-release`'s own dry-run mode) without ever upgrading the URL to a release page that doesn't exist yet: +An unreleased build's `version` is a short commit hash, which isn't always what you want to show a user -- often what's actually useful is *the version this commit will become once it releases*. This function lets a caller upgrade the displayed label to a predicted version (e.g. computed by `predictNextVersion`, below, or `semantic-release`'s own dry-run mode) without ever upgrading the URL to a release page that doesn't exist yet: - If `build.kind === 'release'`, it is returned **completely unchanged** -- a confirmed release always wins outright, prediction or not. -- Otherwise, when `predictedVersion` is a real, non-empty string, the result's `version` becomes that (trimmed) prediction, `predicted` becomes `true`, and `url`/`date` are copied verbatim from `build` -- the link still points at the real commit. +- Otherwise, when `predictedVersion` is a real, non-empty string, the result is `kind: 'predicted'` with `version` set to that (trimmed) prediction -- `url`/`date`/`commit` are copied verbatim from `build`, still describing the real commit. - With no usable prediction (`undefined`, empty, or whitespace-only), `build` is returned unchanged. This function does no git or filesystem access of its own -- computing the predicted version is entirely the caller's job, kept deliberately out of this package's core so the one property `resolveBuildIdentity` guarantees stays easy to audit on its own. ```ts -import { resolveBuildIdentity, resolvePredictedIdentity } from '@exadev/build-identity'; +import { resolveBuildIdentity, resolvePredictedIdentity, predictNextVersion, loadCommitAnalyzer } from '@exadev/build-identity'; + +const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity'); +const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, await loadCommitAnalyzer()); +const identity = resolvePredictedIdentity(build, predictedVersion); +``` + +## `predictNextVersion(repoRoot, releaseRules, analyzeCommits, options?)` + +```ts +type ReleaseLevel = 'major' | 'minor' | 'patch'; +type ReleaseRule = { type: string; release: ReleaseLevel | false } | { breaking: true; release: ReleaseLevel | false }; +type AnalyzeCommits = (pluginConfig: unknown, context: unknown) => Promise; + +function predictNextVersion(repoRoot: string, releaseRules: readonly ReleaseRule[], analyzeCommits: AnalyzeCommits, options?: PredictNextVersionOptions): Promise; + +interface PredictNextVersionOptions { + tagName?: (version: string) => string; // matches resolveBuildIdentity's own option of the same name + logger?: { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; // defaults to discarding +} +``` + +Predicts the version a repo's next release would be, from the commits since its last tagged release -- **without** running `semantic-release`'s own top-level orchestrator. That orchestrator verifies push access to the remote as part of resolving branches before analysis ever runs, on every call, dry-run or not -- slow, and needing credentials a prediction has no real reason to hold. `predictNextVersion` instead calls `@semantic-release/commit-analyzer`'s own `analyzeCommits` hook directly: no network, no registry lookups, no push check. + +`releaseRules` is your own repo's commit-type-to-release-level convention (the same shape `@semantic-release/commit-analyzer`'s own `releaseRules` option takes) -- this package hardcodes none of its own. + +Returns `undefined` when there is genuinely no predicted release -- no commits since the last tag, or none of them are release-worthy -- the same "nothing to report" case `resolvePredictedIdentity` already treats as valid, not an error. Throws for a genuine setup problem instead of defaulting: `repoRoot` isn't a git repository, or `package.json` has no usable version. + +**`analyzeCommits`** is supplied by the caller rather than imported by this package: `@semantic-release/commit-analyzer` ships no type declarations and `analyzeCommits` isn't part of its documented public API, so loading it safely is a concern specific to your own toolchain. `predictNextVersion` itself stays a pure function of its arguments -- easy to test with a fake analyzer. + +## `loadCommitAnalyzer()` + +```ts +function loadCommitAnalyzer(): Promise; +``` + +The tested, correct way to obtain a real `analyzeCommits` for `predictNextVersion` above -- the one place in this package that actually loads `@semantic-release/commit-analyzer`. `@semantic-release/commit-analyzer` is an **optional peer dependency**: install it yourself if you use this function (or `predictNextVersion`); every other export in this package works without it. Throws a clear error, rather than a bare "Cannot find module", when it isn't installed or doesn't export `analyzeCommits`. + +```ts +import { resolveBuildIdentity, resolvePredictedIdentity, predictNextVersion, loadCommitAnalyzer } from '@exadev/build-identity'; + +const releaseRules = [ + { breaking: true, release: 'major' }, + { type: 'feat', release: 'minor' }, + { type: 'fix', release: 'patch' }, +]; const build = resolveBuildIdentity(process.cwd(), 'exadev/build-identity'); -const predictedVersion = await computeNextVersionSomehow(); // out of scope for this package +const predictedVersion = await predictNextVersion(process.cwd(), releaseRules, await loadCommitAnalyzer()); const identity = resolvePredictedIdentity(build, predictedVersion); ``` @@ -111,7 +158,7 @@ export default defineConfig({ }); ``` -Either way, the values are inlined at build time -- the running app never shells out to git itself, and `resolveBuildIdentity`/`resolvePredictedIdentity` never ship as part of the app's own bundle. +Either way, the values are inlined at build time -- the running app never shells out to git itself, and `resolveBuildIdentity`/`resolvePredictedIdentity`/`predictNextVersion`/`loadCommitAnalyzer` never ship as part of the app's own bundle. ## Conventions diff --git a/src/resolve-predicted-identity.test.ts b/src/resolve-predicted-identity.test.ts index e2539b4..7ff7740 100644 --- a/src/resolve-predicted-identity.test.ts +++ b/src/resolve-predicted-identity.test.ts @@ -26,12 +26,11 @@ describe('resolvePredictedIdentity', () => { it('upgrades the displayed version for an unreleased build while keeping its commit URL', () => { const result = resolvePredictedIdentity(commit, '1.5.0'); expect(result).toEqual({ - kind: 'commit', + kind: 'predicted', version: '1.5.0', url: commit.url, date: commit.date, commit: commit.commit, - predicted: true, }); }); diff --git a/src/resolve-predicted-identity.ts b/src/resolve-predicted-identity.ts index 16a0232..ac80d9d 100644 --- a/src/resolve-predicted-identity.ts +++ b/src/resolve-predicted-identity.ts @@ -1,13 +1,13 @@ -import type { BuildIdentity } from './types'; +import type { BuildIdentity, DisplayIdentity } from './types'; /** - * Upgrades an unreleased build's displayed version to a predicted one (e.g. the version a commit-analyzer-style tool computes the next release will be), without ever upgrading its URL. + * Upgrades an unreleased build's displayed version to a predicted one (e.g. the version `predictNextVersion` computes the next release will be), without ever upgrading its URL. * - * A confirmed release always wins outright: if `build.kind === 'release'`, this returns `build` completely unchanged, prediction or not -- a real, already-tagged release can never be second-guessed by a prediction. Otherwise, when `predictedVersion` is a real, non-empty string, the returned `version` becomes that predicted label, `predicted` becomes `true`, and `url` is copied verbatim from `build` -- it still points at the real commit, never at a release page that doesn't exist yet. With no usable prediction, `build` is returned unchanged. + * A confirmed release always wins outright: if `build.kind === 'release'`, this returns `build` completely unchanged, prediction or not -- a real, already-tagged release can never be second-guessed by a prediction. Otherwise, when `predictedVersion` is a real, non-empty string, the result is `kind: 'predicted'` with `version` set to that predicted label -- `url`/`date`/`commit` are copied verbatim from `build`, still describing the real commit, never a release page that doesn't exist yet. With no usable prediction, `build` is returned unchanged (still a valid `DisplayIdentity`, since every `BuildIdentity` kind is also a `DisplayIdentity` kind). * - * This function does no git or filesystem access of its own: predicting the next version (e.g. by running a commit-analyzer) is entirely the caller's job. It exists to keep that prediction, and the guarantee `resolveBuildIdentity` makes about `url`, cleanly separate. + * This function does no git or filesystem access of its own: predicting the next version is entirely the caller's job, typically `predictNextVersion` (`./predict-next-version`). It exists to keep that prediction, and the guarantee `resolveBuildIdentity` makes about `url`, cleanly separate. */ -export function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): BuildIdentity & { readonly predicted?: boolean } { +export function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: string | undefined): DisplayIdentity { if (build.kind === 'release') { return build; } @@ -18,11 +18,10 @@ export function resolvePredictedIdentity(build: BuildIdentity, predictedVersion: } return { - kind: 'commit', + kind: 'predicted', version: trimmedPrediction, url: build.url, date: build.date, commit: build.commit, - predicted: true, }; } diff --git a/src/types.ts b/src/types.ts index 31de781..80df707 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,3 +33,14 @@ export interface ResolveBuildIdentityOptions { */ readonly tagName?: (version: string) => string; } + +/** + * What a build should actually display -- `BuildIdentity` refined by an optional prediction (`resolvePredictedIdentity`). All three kinds carry the identical field set; only the meaning of `kind`/`version` differs: `'release'` and `'commit'` mean exactly what they do on `BuildIdentity` (a confirmed release always wins outright, unchanged), and `'predicted'` means "not yet released, but a commit-analyzer-style tool predicts this commit will become `version` once it is" -- `url`/`date`/`commit` still describe the real underlying commit, never a release page that doesn't exist yet. + */ +export interface DisplayIdentity { + readonly kind: 'release' | 'predicted' | 'commit'; + readonly version: string; + readonly url: string; + readonly date: string; + readonly commit: string; +} From 973eb79b20c73c3d81887c94a60b2b5875ba0302 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 8 Sep 2026 08:52:06 +0100 Subject: [PATCH 3/4] feat: predict the next release version without semantic-release's own orchestrator semanticRelease() verifies push access to the remote as part of resolving branches before analysis ever runs, on every call including dryRun -- slow, and needing credentials a prediction has no real reason to hold. predictNextVersion calls @semantic-release/commit-analyzer's own analyzeCommits hook directly instead: no network, no registry lookups, no push check. It takes analyzeCommits as a parameter rather than importing the module itself, so it stays a pure, easily-fakeable function of its own arguments; loadCommitAnalyzer is the tested way to obtain a real implementation, kept as its own function since that module ships no types and analyzeCommits isn't part of its documented public API. @semantic-release/commit-analyzer is declared as an optional peer dependency: not required for resolveBuildIdentity/ resolvePredictedIdentity, only for loadCommitAnalyzer/ predictNextVersion. --- package.json | 9 ++ pnpm-lock.yaml | 3 + src/index.ts | 5 +- src/load-commit-analyzer.test.ts | 25 +++++ src/load-commit-analyzer.ts | 30 ++++++ src/predict-next-version.test.ts | 176 +++++++++++++++++++++++++++++++ src/predict-next-version.ts | 87 +++++++++++++++ 7 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 src/load-commit-analyzer.test.ts create mode 100644 src/load-commit-analyzer.ts create mode 100644 src/predict-next-version.test.ts create mode 100644 src/predict-next-version.ts diff --git a/package.json b/package.json index 45b7cf7..75ccf14 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,21 @@ "type": "git", "url": "git+https://github.com/ExaDev/build-identity.git" }, + "peerDependencies": { + "@semantic-release/commit-analyzer": "^13.0.1" + }, + "peerDependenciesMeta": { + "@semantic-release/commit-analyzer": { + "optional": true + } + }, "devDependencies": { "@arethetypeswrong/cli": "0.18.5", "@commitlint/cli": "21.2.1", "@commitlint/config-conventional": "21.2.0", "@exadev/eslint-config": "^2.10.4", "@semantic-release/changelog": "7.0.0", + "@semantic-release/commit-analyzer": "13.0.1", "@semantic-release/git": "11.0.1", "@types/node": "24.13.3", "@vitest/coverage-v8": "4.1.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cda59c..27572e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@semantic-release/changelog': specifier: 7.0.0 version: 7.0.0(semantic-release@25.0.8(typescript@6.0.3)) + '@semantic-release/commit-analyzer': + specifier: 13.0.1 + version: 13.0.1(semantic-release@25.0.8(typescript@6.0.3)) '@semantic-release/git': specifier: 11.0.1 version: 11.0.1(semantic-release@25.0.8(typescript@6.0.3)) diff --git a/src/index.ts b/src/index.ts index 947b3f7..d147eac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ export { resolveBuildIdentity } from './resolve-build-identity'; export { resolvePredictedIdentity } from './resolve-predicted-identity'; -export type { BuildIdentity, ResolveBuildIdentityOptions } from './types'; +export { predictNextVersion } from './predict-next-version'; +export { loadCommitAnalyzer } from './load-commit-analyzer'; +export type { BuildIdentity, DisplayIdentity, ResolveBuildIdentityOptions } from './types'; +export type { AnalyzeCommits, PredictNextVersionOptions, ReleaseLevel, ReleaseRule } from './predict-next-version'; diff --git a/src/load-commit-analyzer.test.ts b/src/load-commit-analyzer.test.ts new file mode 100644 index 0000000..3a9c485 --- /dev/null +++ b/src/load-commit-analyzer.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { loadCommitAnalyzer } from './load-commit-analyzer'; + +describe('loadCommitAnalyzer', () => { + it('resolves to a callable analyzeCommits function', async () => { + const analyzeCommits = await loadCommitAnalyzer(); + + expect(typeof analyzeCommits).toBe('function'); + }); + + it('resolves to a function that genuinely runs commit-analyzer -- not a stub -- when called with real commits and rules', async () => { + const analyzeCommits = await loadCommitAnalyzer(); + + const releaseType = await analyzeCommits( + { releaseRules: [{ type: 'feat', release: 'minor' }] }, + { + commits: [{ hash: 'abc123', message: 'feat: add a thing' }], + logger: { log: () => undefined, error: () => undefined }, + cwd: process.cwd(), + }, + ); + + expect(releaseType).toBe('minor'); + }); +}); diff --git a/src/load-commit-analyzer.ts b/src/load-commit-analyzer.ts new file mode 100644 index 0000000..d2ab314 --- /dev/null +++ b/src/load-commit-analyzer.ts @@ -0,0 +1,30 @@ +import type { AnalyzeCommits } from './predict-next-version'; + +// Read through a variable, never a string literal: TypeScript resolves an import()'s type by statically looking up a declaration for its specifier, and @semantic-release/commit-analyzer ships none. A literal specifier would force a choice between two bad options -- a hand-written ambient declaration (which either lies about a contract this undocumented plugin export never promised, or, left as a bare `declare module` with no members, implicitly types the *entire* module `any`, not just the one export used here) or none at all (a compile error). Reading the specifier through a variable sidesteps that lookup entirely: TypeScript never attempts to resolve types for a dynamically-computed specifier, so the result is genuinely `unknown` -- exactly what it should be for a module with no real, published contract -- verified by the runtime guard below rather than asserted by a type this package has no authority to make. +const COMMIT_ANALYZER_SPECIFIER = '@semantic-release/commit-analyzer'; + +function hasAnalyzeCommits(value: unknown): value is { analyzeCommits: AnalyzeCommits } { + if (typeof value !== 'object' || value === null) return false; + if (!('analyzeCommits' in value)) return false; + return typeof value.analyzeCommits === 'function'; +} + +/** + * Loads `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- the tested, correct way to obtain the `AnalyzeCommits` implementation `predictNextVersion` (`./predict-next-version`) needs. Split out from `predictNextVersion` itself so that function stays a pure, easily-fakeable function of its own arguments; this is the one place in the package that actually touches the untyped module. + * + * `@semantic-release/commit-analyzer` is an optional peer dependency (see this package's own `package.json`): not required to use `resolveBuildIdentity`/`resolvePredictedIdentity`, only to call this function. Throws a clear error, rather than a bare "Cannot find module", when it isn't installed or doesn't export `analyzeCommits` -- a version bump of the real package removing or renaming that export fails loudly here, not silently. + */ +export async function loadCommitAnalyzer(): Promise { + let commitAnalyzerModule: unknown; + try { + commitAnalyzerModule = await import(COMMIT_ANALYZER_SPECIFIER); + } catch (error) { + throw new Error('@semantic-release/commit-analyzer is not installed -- it is an optional peer dependency of @exadev/build-identity, required only for loadCommitAnalyzer/predictNextVersion.', { cause: error }); + } + + if (!hasAnalyzeCommits(commitAnalyzerModule)) { + throw new Error('@semantic-release/commit-analyzer has no analyzeCommits export.'); + } + + return commitAnalyzerModule.analyzeCommits; +} diff --git a/src/predict-next-version.test.ts b/src/predict-next-version.test.ts new file mode 100644 index 0000000..5ac77e0 --- /dev/null +++ b/src/predict-next-version.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { bumpVersion, isReleaseLevel, parseGitLogOutput, predictNextVersion, type AnalyzeCommits, type ReleaseRule } from './predict-next-version'; +import { loadCommitAnalyzer } from './load-commit-analyzer'; +import { createTestRepo, type TestRepo } from './test-repo'; + +const COMMIT_TYPE_RULES: readonly ReleaseRule[] = [ + { breaking: true, release: 'major' }, + { type: 'feat', release: 'minor' }, + { type: 'fix', release: 'patch' }, + { type: 'chore', release: false }, +]; + +let analyzeCommits: AnalyzeCommits; + +beforeAll(async () => { + analyzeCommits = await loadCommitAnalyzer(); +}); + +describe('parseGitLogOutput', () => { + it('returns an empty array for empty input', () => { + expect(parseGitLogOutput('')).toEqual([]); + }); + + it('parses a single record with a leading NUL', () => { + const raw = '\x00abc123\x00feat: add a thing\n'; + expect(parseGitLogOutput(raw)).toEqual([{ hash: 'abc123', message: 'feat: add a thing' }]); + }); + + it('parses several records in order', () => { + const raw = '\x00hash1\x00feat: first\n\x00hash2\x00fix: second\n\x00hash3\x00chore: third\n'; + expect(parseGitLogOutput(raw)).toEqual([ + { hash: 'hash1', message: 'feat: first' }, + { hash: 'hash2', message: 'fix: second' }, + { hash: 'hash3', message: 'chore: third' }, + ]); + }); + + it('preserves a multi-line commit body, trimming only the trailing newline git appends', () => { + const raw = '\x00abc123\x00feat: add a thing\n\nWith a body.\n\nAnd a footer.\n'; + expect(parseGitLogOutput(raw)).toEqual([{ hash: 'abc123', message: 'feat: add a thing\n\nWith a body.\n\nAnd a footer.' }]); + }); +}); + +describe('bumpVersion', () => { + it('increments major and resets minor/patch to 0', () => { + expect(bumpVersion('1.4.2', 'major')).toBe('2.0.0'); + }); + + it('increments minor and resets patch to 0, leaving major untouched', () => { + expect(bumpVersion('1.4.2', 'minor')).toBe('1.5.0'); + }); + + it('increments patch, leaving major/minor untouched', () => { + expect(bumpVersion('1.4.2', 'patch')).toBe('1.4.3'); + }); +}); + +describe('isReleaseLevel', () => { + it.each(['major', 'minor', 'patch'])('accepts %j', (value) => { + expect(isReleaseLevel(value)).toBe(true); + }); + + it.each([undefined, null, false, '', 'breaking', 0])('rejects %j', (value) => { + expect(isReleaseLevel(value)).toBe(false); + }); +}); + +describe('predictNextVersion', () => { + let repo: TestRepo | undefined; + + afterEach(() => { + repo?.cleanup(); + repo = undefined; + }); + + it('returns undefined when there are no commits since the last tag', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + + await expect(predictNextVersion(repo.root, COMMIT_TYPE_RULES, analyzeCommits)).resolves.toBeUndefined(); + }); + + it('predicts a minor bump from a feat commit', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + repo.commit('feat: add a thing'); + + await expect(predictNextVersion(repo.root, COMMIT_TYPE_RULES, analyzeCommits)).resolves.toBe('1.5.0'); + }); + + it('predicts a patch bump from a fix commit', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + repo.commit('fix: correct a thing'); + + await expect(predictNextVersion(repo.root, COMMIT_TYPE_RULES, analyzeCommits)).resolves.toBe('1.4.1'); + }); + + it('predicts a major bump from a breaking change footer, even on a feat commit', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + repo.commit('feat: add a thing\n\nBREAKING CHANGE: removes the old thing'); + + await expect(predictNextVersion(repo.root, COMMIT_TYPE_RULES, analyzeCommits)).resolves.toBe('2.0.0'); + }); + + it('takes the highest release level across several commits, not just the most recent', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + repo.commit('fix: correct a thing'); + repo.commit('feat: add a thing'); + + await expect(predictNextVersion(repo.root, COMMIT_TYPE_RULES, analyzeCommits)).resolves.toBe('1.5.0'); + }); + + it('returns undefined when every commit since the last tag is explicitly rule-excluded from releasing', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + repo.commit('chore: tidy up'); + + await expect(predictNextVersion(repo.root, COMMIT_TYPE_RULES, analyzeCommits)).resolves.toBeUndefined(); + }); + + it('uses a caller-supplied tagName function to find the last release, matching resolveBuildIdentity', async () => { + repo = createTestRepo({ name: 'fixture', version: '2.0.0' }); + repo.tag('release-2.0.0'); + repo.commit('feat: add a thing'); + + await expect(predictNextVersion(repo.root, COMMIT_TYPE_RULES, analyzeCommits, { tagName: (version) => `release-${version}` })).resolves.toBe('2.1.0'); + }); + + it('routes commit-analyzer narration through a caller-supplied logger', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + repo.commit('feat: add a thing'); + const logged: (readonly unknown[])[] = []; + + const log = (...args: readonly unknown[]): void => { + logged.push(args); + }; + + await predictNextVersion(repo.root, COMMIT_TYPE_RULES, analyzeCommits, { logger: { log, error: log } }); + + expect(logged.length).toBeGreaterThan(0); + }); + + it('never calls analyzeCommits when there are no commits to analyze', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + let called = false; + const spyAnalyzeCommits: AnalyzeCommits = async () => { + called = true; + return Promise.resolve(undefined); + }; + + await predictNextVersion(repo.root, COMMIT_TYPE_RULES, spyAnalyzeCommits); + + expect(called).toBe(false); + }); + + it('returns undefined, rather than throwing, when analyzeCommits resolves to something that is not a release level', async () => { + repo = createTestRepo({ name: 'fixture', version: '1.4.0' }); + repo.tag('v1.4.0'); + repo.commit('feat: add a thing'); + const noopAnalyzeCommits: AnalyzeCommits = async () => Promise.resolve(undefined); + + await expect(predictNextVersion(repo.root, COMMIT_TYPE_RULES, noopAnalyzeCommits)).resolves.toBeUndefined(); + }); + + it('throws rather than defaulting when package.json has no version field', async () => { + repo = createTestRepo({ name: 'fixture' }); + const { root } = repo; + + await expect(predictNextVersion(root, COMMIT_TYPE_RULES, analyzeCommits)).rejects.toThrow(/version/); + }); +}); diff --git a/src/predict-next-version.ts b/src/predict-next-version.ts new file mode 100644 index 0000000..895ee4a --- /dev/null +++ b/src/predict-next-version.ts @@ -0,0 +1,87 @@ +import { execFileSync } from 'node:child_process'; +import { defaultTagName, readPackageVersion } from './package-version'; + +const GIT_FORMAT = '%x00%H%x00%B'; +const FIELD_SEP = '\x00'; + +export interface RawCommit { + readonly hash: string; + readonly message: string; +} + +/** + * Pure parsing of `git log --format=%x00%H%x00%B` output -- split out so it's testable against a raw string fixture with no real git process. NUL is git's own pretty-format escape for a real NUL byte, the one byte git guarantees can never appear inside a commit hash or message (git refuses to store one), making it the only delimiter genuinely safe against arbitrary commit content. Each record is ``, with a leading NUL on the whole format string, so splitting the entire raw output on NUL and dropping the resulting leading empty token leaves an exact (hash, message) pairing for every commit. Git appends its own trailing newline per record (tformat behaviour); trim() absorbs it the same way it absorbs genuine trailing blank lines in a real commit body. + */ +export function parseGitLogOutput(raw: string): RawCommit[] { + const tokens = raw.split(FIELD_SEP); + tokens.shift(); + const commits: RawCommit[] = []; + for (let index = 0; index < tokens.length; index += 2) { + commits.push({ hash: tokens[index] ?? '', message: (tokens[index + 1] ?? '').trim() }); + } + return commits; +} + +function readCommitsSince(repoRoot: string, sinceTag: string): RawCommit[] { + const raw = execFileSync('git', ['log', `${sinceTag}..HEAD`, `--format=${GIT_FORMAT}`], { cwd: repoRoot, encoding: 'utf-8' }); + return parseGitLogOutput(raw); +} + +export type ReleaseLevel = 'major' | 'minor' | 'patch'; + +export function isReleaseLevel(value: unknown): value is ReleaseLevel { + return value === 'major' || value === 'minor' || value === 'patch'; +} + +/** One @semantic-release/commit-analyzer release rule -- either `{ type, release }` (a conventional-commit type, e.g. `"feat"`) or `{ breaking: true, release }` (any commit whose footer/body declares a breaking change, regardless of type). `release` also accepts `false`, commit-analyzer's own way to say "this type never triggers a release" -- needed to override a broader rule or preset default, distinct from simply omitting the type (which instead falls through to whatever the preset's own default is). This package hardcodes no rules of its own: a repo's commit-type-to-release-level convention is real, repo-specific configuration. */ +export type ReleaseRule = { readonly type: string; readonly release: ReleaseLevel | false } | { readonly breaking: true; readonly release: ReleaseLevel | false }; + +/** + * The shape of `@semantic-release/commit-analyzer`'s own `analyzeCommits` plugin hook -- untyped and undocumented as a standalone export (it exists only because semantic-release's own core loads plugins dynamically), so this package can promise no more about it than "callable with these two arguments, returns a promise." `predictNextVersion` takes an implementation of this type as a parameter, rather than loading `@semantic-release/commit-analyzer` itself, so it stays a pure function of its own arguments -- trivially testable with a fake analyzer, exactly how this file's own tests exercise it. `./load-commit-analyzer`'s `loadCommitAnalyzer` is the tested, correct way to obtain a real one; pass its result straight through when you don't need a different implementation. + */ +export type AnalyzeCommits = (pluginConfig: unknown, context: unknown) => Promise; + +/** A trivial major.minor.patch increment -- every version this package works with is a plain X.Y.Z, never a prerelease or build-metadata identifier (see `resolveBuildIdentity`'s own tag convention), so there is no broader semver grammar here worth delegating to a library for. */ +export function bumpVersion(version: string, releaseType: ReleaseLevel): string { + const [major = 0, minor = 0, patch = 0] = version.split('.').map(Number); + if (releaseType === 'major') return `${String(major + 1)}.0.0`; + if (releaseType === 'minor') return `${String(major)}.${String(minor + 1)}.0`; + return `${String(major)}.${String(minor)}.${String(patch + 1)}`; +} + +export interface PredictNextVersionOptions { + /** Matches `resolveBuildIdentity`'s own option of the same name -- pass the same function to both when a repo tags non-default, so "which tag marks the last release" stays one convention rather than two that could drift apart. Defaults to the `v${version}` convention. */ + tagName?: (version: string) => string; + /** Receives @semantic-release/commit-analyzer's own per-commit narration (e.g. "Analyzing commit: ...", "The release type for the commit is minor"). Defaults to discarding it. Pass your own to route it to stderr or a real logger -- never stdout, so a caller parsing a single predicted-version line from this process's own stdout is never at risk of it being contaminated. */ + logger?: { log: (...args: readonly unknown[]) => void; error: (...args: readonly unknown[]) => void }; +} + +const NOOP_LOGGER = { log: (): void => undefined, error: (): void => undefined }; + +/** + * Predicts the version this repo's next release would be, from commits since the last tagged release -- WITHOUT running semantic-release's own top-level orchestrator. That orchestrator's core (not any plugin) verifies push access to the remote as part of resolving branches before analysis ever runs: genuinely slow, and needing credentials a prediction has no real reason to hold. This calls `analyzeCommits` directly instead -- no network, no registry lookups -- exactly the same hook `@semantic-release/commit-analyzer` exports, supplied by the caller (see `AnalyzeCommits`'s own doc comment for why this package never imports that module itself). + * + * Returns `undefined` when there is genuinely no predicted release -- no commits since the last tag, or none of them are release-worthy under `releaseRules` -- the same "nothing to report" case `resolvePredictedIdentity` already treats as valid, not an error. Throws for a genuine setup problem instead of defaulting: `repoRoot` isn't a git repository, or `package.json` has no usable version. + * + * @param repoRoot Path to the git working tree to inspect (must contain `package.json` at its root, same as `resolveBuildIdentity`). + * @param releaseRules The commit-analyzer release rules this repo's own commit convention defines. + * @param analyzeCommits `@semantic-release/commit-analyzer`'s own `analyzeCommits` export -- `loadCommitAnalyzer` (`./load-commit-analyzer`) is the tested way to obtain one. + */ +export async function predictNextVersion(repoRoot: string, releaseRules: readonly ReleaseRule[], analyzeCommits: AnalyzeCommits, options: PredictNextVersionOptions = {}): Promise { + const lastVersion = readPackageVersion(repoRoot); + const tagName = (options.tagName ?? defaultTagName)(lastVersion); + const logger = options.logger ?? NOOP_LOGGER; + + const commits = readCommitsSince(repoRoot, tagName); + if (commits.length === 0) { + return undefined; + } + + const releaseType: unknown = await analyzeCommits({ releaseRules: releaseRules.map((rule) => ({ ...rule })) }, { commits, logger, cwd: repoRoot }); + + if (!isReleaseLevel(releaseType)) { + return undefined; + } + + return bumpVersion(lastVersion, releaseType); +} From f60d12c1588e4b9918b2b3c402f4cd8b55ce4955 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 8 Sep 2026 08:54:24 +0100 Subject: [PATCH 4/4] fix: raise vitest's per-test timeout for subprocess-spawning tests Most tests in this package spawn several real git subprocesses per test (createTestRepo, tag(), commit(), the resolve/predict functions themselves), and predict-next-version's own tests additionally load and run the real @semantic-release/commit-analyzer package -- both genuinely I/O- and process-bound. Vitest's 5000ms default assumes pure in-memory JS and was already too tight for this, flaking under any real system load. --- vitest.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 99f9ca3..94683f2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // Vitest's own 5000ms default assumes pure in-memory JS. Most of this package's tests spawn several real `git` subprocesses per test (createTestRepo, .tag(), .commit(), the resolve/predict functions themselves), and predict-next-version.test.ts additionally loads and runs the real @semantic-release/commit-analyzer package -- both genuinely I/O- and process-bound, not slow logic, so they need real headroom rather than a tighter number tuned to this machine's current load. + testTimeout: 20000, coverage: { enabled: true, provider: 'v8',