Odel
semver checks

semver checks

Local
@kyungseopk1mTypeScriptMITUpdated 2w ago

Detect breaking changes in your TypeScript library's public API and recommend the SemVer bump.

npm version CI License: MIT Node.js Version

semver-checks

Catch the breaking changes your commit messages miss. semver-checks analyzes what actually changed in your TypeScript public API and recommends the correct semver bump.

npx semver-checks compare v1.0.0 HEAD

Why semver-checks?

Tools like semantic-release and changesets rely on developers writing correct commit messages. In practice, commit messages don't always reflect actual API impact — a "small refactor" that removes a required export gets published as a patch, and downstream consumers' builds break.

semver-checks analyzes your TypeScript public API directly using ts-morph and recommends the correct SemVer bump based on what actually changed in the type signatures — not what the commit message says.

This is not hypothetical. Run it across real releases and it flags breaking type changes that shipped as minors or patches — for example, p-limit 6.1.0 added a required property to its exported LimitFunction type and was published as a minor; semver-checks flags it MAJOR. It is most dependable on structural changes — removed or renamed exports, narrowed signatures, added required parameters and properties — which it detects reliably. Equivalence-preserving type rewrites are a known weak spot it can over-report; see Accuracy & Limitations for exactly where to trust it and where not to.

// v1.0.0
export interface Config {
  host: string;
  port: number;
}

// Developer writes: "fix: add missing timeout config"
// Published as patch — but this is a MAJOR change:
export interface Config {
  host: string;
  port: number;
  timeout: number;
}
//                                                    ^^^^^^^^^^^^^^^^ required-property-added
// v1.0.0
export function findUser(id: string): User | null;

// Developer writes: "refactor: simplify findUser return"
// Published as minor — but consumers checking `result === null` silently break at runtime:
export function findUser(id: string): User;
//                                    ^^^^ return-type-changed (MAJOR)

semver-checks is complementary to your existing release workflow. Use it as a verification step before publishing — it tells you whether your intended bump is safe, or whether you're about to ship a breaking change by accident.

Accuracy & Limitations

semver-checks grades every breaking change by confidence, so the CI gate stays trustworthy:

  • proven — the change is its own evidence (a removed export, property, interface method, class property or class method; a newly required parameter, interface method or interface property), a resolved type relation the analyzer decided is genuinely unrelated, or a rule that computed the answer itself (a dropped base that carried something; a required property added to a class anyone could have implemented by hand). --strict exits 1 on these, and only these — safe to leave on in CI. Proven is earned per rule, not inherited: a rule either computes its own confidence or is on that short list, and everything else is review-only. The list is short because it is empirical — see Measured below.
  • heuristic — a conservative MAJOR the analyzer could not prove (a type-text difference it couldn't resolve, or a one-directional change in an invariant position where a safe reading exists). These surface for human review but do not fail --strict; opt in with --strict-review if you want every MAJOR to gate.

This is the design's center of gravity: the equivalence-preserving rewrites and input-union widenings that make text-based type-semver tools cry wolf land in heuristic, off the default gate, while real under-bumps stay proven and on it. It is neither sound (zero false positives) nor complete (catches everything), so a proven MAJOR is a strong signal, not a theorem. That isolation only covers the over-reporting surfaces in Known limitations; the under-report and structural rows in the same table are a different axis, a silent patch or an outright failure, not a confidence question.

It is most reliable on conventional, single-entry packages with an explicitly-typed public surface: added / removed / renamed exports, function and method signature changes, added required parameters and properties, and removed members are detected dependably and reported as proven.

Measured, against a compiler. The scorecard that decides which rules are proven uses tsc as its oracle, not the author's published bump - the tool exists because authors get the bump wrong, so scoring it against that bump would be circular. Each of 111 adjacent minor/patch release pairs, across 24 packages, has a consumer program compiled against both sides; the pair is a real break iff the new side produces errors the old side did not. Major-version boundaries are excluded: there the author already knows the release is breaking, so the tool's verdict carries no decision value.

On that corpus --strict fires on 36 of 111 pairs. 35 of those 36 are real breaks, so precision is 97.2%. Recall is 81.4%: the corpus holds 43 real breaks and --strict stays quiet on 8 of them.

Read the recall, not the precision. An earlier revision of this file reported 100% recall on a 75-pair corpus, and that was a fact about which shapes the corpus contained rather than about the tool: widening a corpus to 111 pairs, and re-examining every pair it had scored safe by reading the shipped .d.ts and writing a consumer that uses the changed symbol the way the package's own README does, took the same number to 37.8%. Grading recovered it to 67.4%, giving the variance probe the package's own declarations to read took it to 79.1%, and reading a class the way an interface was already read took it from there. Nothing was suppressed to get the precision figure; the one remaining false positive is described below.

any confidence fires on all 43, so nothing here is a detection gap in the sense of the tool not noticing. Two of those 43 are a coincidence rather than a catch, described below. What kept --strict quiet on most of the 14 that used to remain was that the variance probe had no verdict to give: a serialized type text is printed by the checker, so it names the types the package declares about itself, and the probe resolved those names in a program that held nothing but the ES libs. ClassArray | ClassDictionary in clsx, P.Pattern<T> & UnknownProperties in ts-pattern and core.$ZodTypeDiscriminable<Disc> in zod all sent it home empty. The probe now reads both snapshots' own declarations (see What the probe will and will not answer about), which is what closes five of them.

Of the 8 left, two are not a probe question at all: on hono 4.12.18 -> 4.12.19 and ky 1.14.1 -> 1.14.2 the change that actually breaks a consumer is reported nowhere, and the findings that do fire are on other symbols entirely, so the any confidence figure is a per-pair coincidence on those two rather than a catch. hono 4.12.19 -> 4.12.20 reads the same from the summary but is not the same: its ./jsx#jsx.children finding is the break, held at review-only by the guard described under What the probe will and will not answer about. Of the rest, got 14.6.4 -> 14.6.5 needs signature-level variance, ts-pattern 5.6.0 -> 5.6.1 is a generic-constraint-changed, which fires on a loosened constraint as readily as on a tightened one, and bullmq 5.80.11 -> 5.80.12 replaces a callback type with one that mentions any, where the probe bails on purpose. The last two are the probe declining to guess: hono 4.12.28 -> 4.12.29 widens a return type with a second aws-lambda type the old text never named, so the verdict would rest on two stand-ins being unrelated, and valibot 1.3.1 -> 1.4.0 turns a tuple parameter readonly through an alias the scope could not resolve.

Two shapes account for most of what the grading now catches, and both were invisible on the narrower corpus:

  • A widening that breaks readers, not writers. An optional property gains | null, a union gains a member, a return type gains an alternative. Passing a value in still compiles; reading one back out into the old type does not. It appears in ioredis, bullmq, ky, commander, hono, got and clsx.
  • An interface gaining or losing members. Losing one breaks callers, gaining a required one breaks implementers, and subclassing hides both because a subclass inherits whatever was added. The interface rules are proven for that reason. On a class the added-member rule is proven only where a hand-written implementation was possible in the first place: a class declaring a private or protected instance member is compared nominally, so no object literal satisfies it and there is no implementer to break.

The control group is scripts/gate/naive-baseline.mjs, a 45-line exported-name-and-arity diff with no type resolution at all, kept to answer the obvious question: does the type analysis buy anything a much dumber tool does not already get? It scores 87.5% precision and 32.6% recall on the same corpus, against 97.2% and 81.4%. They overlap on 14 breaks, 21 belong to the type analysis alone, none to the baseline alone, and 8 defeat both.

The 21 are the answer to the question, and they are the shapes a name-and-arity diff cannot reach. Among them:

  • commander 11.0.0 -> 11.1.0 widened Command#executableDir()'s return type from string to string | null in a minor. No name moved, no parameter count changed.
  • @sinclair/typebox 0.34.51 -> 0.34.52 dropped the StringUtil namespace from its ./compiler entry, which takes resolving the surface of that subpath to see.
  • hono 4.12.29 -> 4.12.30 narrowed a parameter of WSContextInit#send.
  • ioredis 5.8.1 -> 5.8.2 added two members to the Command.FLAGS object, which breaks a consumer keying a lookup table off keyof typeof Command.FLAGS.
  • p-limit 6.0.0 -> 6.1.0 added a required property to the LimitFunction object alias, which breaks a hand-written test stub.

The baseline's own false positives point the other way: removals inside internal/ files or underscore-prefixed members, which no consumer could import in the first place. Reading declarations rather than files is what separates the public surface from the implementation.

One false positive remains, and it is a deliberate over-report rather than a defect. On zod 4.3.6 -> 4.4.0 the literal type of the exported version constant changed from { minor: 3 } to { minor: 4 }. That breaks a consumer who writes the literal down - either pinning it or comparing against it, since version.minor === 3 becomes a no-overlap error - and nothing else. Suppressing it would take a heuristic that guesses which exported constants are version stamps by name.

The same rule cuts the other way, and a grading pass learned it the hard way. A finding called breaking is also a hypothesis. Every promotion was checked against the corpus, which agreed, and then checked again by constructing the release that ought to have been harmless and compiling it. Eleven shapes came back where no consumer breaks and the gate failed anyway, none of them in the corpus:

  • A parameter renamed in a call signature, and an optional trailing parameter added to one. Names play no part in structural assignability and an optional parameter obliges nobody, but the signature set is compared as printed text. interface-call-signature-changed is review-only for that reason, which also costs a real break: got 14.6.4 to 14.6.5 added a leading overload, and separating that from adding an optional parameter takes signature-level variance the text comparison does not do.
  • A member moving between an interface and one of its bases, in either direction, and a base inlined into the interface that extended it. Inherited members are deliberately not flattened, so a move reads as a removal on one side and an addition on the other. The bases are resolved now, and a member still reachable through one is not reported at all.
  • A base dropped that declared nothing, the marker-interface case, and a base whose members the interface now declares itself. A dropped base is proven only when something it carried is no longer on the surface. A base still listed under the same name with different type arguments is not a drop at all and stays proven, since re-parameterizing moves the types of everything it contributes.
  • A base added whose members are all optional, which is how shared knobs get factored out in a minor.
  • A package that declares its own Element, Node, Response or Event. The variance probe resolves a type by name in a project of its own, which carried the DOM lib, so it answered about the global of that name instead. It carries only the ES libs now, and a name it cannot resolve reads as undecidable rather than as a verdict.

Each shape is pinned by a fixture, because the corpus contains none of them and its numbers did not move when they were fixed. That is the same blindness the recall figure above is about, seen from the other side.

Which gate to run

--strict and --strict-review are not two strengths of the same check. They carry different contracts, and the corpus above measures each separately.

FlagFails onCorpus (111 pairs, 43 real breaks)The contract it can hold
--strictproven MAJORs onlyfires on 36 pairs, 35 of them real: precision 97.2%, recall 81.4%A failure is a strong signal. A pass is not a clean bill of health
--strict-reviewevery MAJOR, heuristic includedfires on all 43 real breaks and on 10 pairs that hold none: recall 100%, precision 81.1% (43 of 53)Green means the analysis reported nothing breaking anywhere

So "green means safe" is a claim only --strict-review can make, and even there it is a claim about what the analysis reached rather than a proof: on two of the 43 it fires on a symbol other than the one that breaks, so the pair is red for the wrong reason. --strict is tuned to be quiet enough that a failure is worth acting on, which is exactly what makes it safe to leave on every build and exactly why it stays silent on 8 of the 43. Those 8 are listed above; each one is a guard declining to call two unresolved names unrelated, a rule graded review-only on purpose, or a limit in Known limitations.

A practical split: leave --strict on for every push, because a red build there is almost always a real under-bump, and run --strict-review where someone reads the output before it ships, on a release pull request or a prepublish step. One in five of its failures will be a MAJOR that turns out to be safe, which is a fine trade when a person is going to look at it and a bad one when nobody is.

What the probe will and will not answer about

The variance probe decides whether one serialized type text is assignable to the other by synthesizing both into one in-memory program and asking the compiler. That program is given the two snapshots' own declarations, rendered back into ambient form, the old side under one namespace and the new side under another. Without them a text naming anything the package declares about itself resolves to nothing and the probe reports undecidable, which is what kept --strict quiet on releases it had already noticed.

Five limits are deliberate, and each one is a place the probe answers "I cannot tell" rather than guessing:

  • Enums and classes are not rendered. The two sides go into separate namespaces, and those two constructs are nominal, so the same unchanged enum would appear as two unrelated types and every text mentioning it would probe as an unrelated change. A name left out this way is left unresolved, not stood in for, because a stand-in is an object type carrying a brand and an interface extending an omitted class would inherit a member the class never had.
  • A declaration mentioning any is not installed. any is assignable in both directions, so one member typed any makes its whole containing type bidirectionally assignable, and Sink compares equivalent to Sink & { write(chunk: Uint8Array): void }. The probe already bails when either compared text mentions any; installing a declaration that mentions one routes around that guard, because neither compared text has to say the word. The name is left to a stand-in instead, which is assignable to nothing it is not.
  • A name no declaration covers gets an opaque stand-in, shared by both sides so that the same spelling means the same type — the model the whole tool already runs on. Type texts routinely name types a package does not export (ts-pattern reaches for SelectionType, MergeGuards and eight more from the entry point that declares none of them), and without stand-ins every declaration mentioning one has to be dropped, which took that entry from 35 declarations to 3.
  • A verdict that rests on the stand-ins being distinct is thrown away. Two different unresolved names probe as unrelated types by construction, so a package that renamed an internal shape no consumer can even name would fail the gate. When the two texts name different stand-ins the relation goes back to undecidable and the finding stays review-only.
  • "These two are the same type" is not a claim assignability can support across a change of name. TypeScript ignores readonly on a property, compares a method bivariantly where it compares a function-typed property strictly, drops this parameters, and applies the excess-property check only to a fresh object literal, so two declarations can be mutually assignable while swapping one for the other stops a consumer compiling. When the probe answers "equivalent" and the two texts name different declarations, the finding is reported review-only rather than dropped. A pure textual rewrite (readonly T[] against ReadonlyArray<T>) names the same declarations on both sides and is still a no-op.

A rendered declaration the compiler rejects is dropped and the scope re-rendered until what is left compiles; if it cannot be made to compile at all, none of it is installed. A declaration that errors resolves to the error type, and the error type is assignable in both directions, which would turn a real break into a confident "equivalent".

A finding called safe is a hypothesis until it is compiled. Over two passes, eight findings recorded here or in the test suite as deliberate over-reports were re-examined by writing a consumer for the flagged symbol, and eight of them were real breaks: hono 4.12.29 -> 4.12.30 (the existing probe tested only the contravariant implementer position and nobody had compiled a caller), jose 6.2.4 -> 6.2.5, kysely 0.29.2 -> 0.29.3, lru-cache 11.5.0 -> 11.5.1, highlight.js 11.9.0 -> 11.10.0, ioredis 5.8.1 -> 5.8.2, axios 1.18.1 -> 1.19.0, and the clsx input-union widening this file used to cite as the archetypal safe change. Each is in the corpus now.

Reproduce with node scripts/gate/run.mjs; the corpus, the consumers, and the per-pair results are checked in.

Measured, against published bumps. A second, older scorecard uses the author's published bump as its oracle rather than a compiler, across 44 adjacent real-world npm release pairs (.d.ts.d.ts, seven API shapes). 32 are analyzable. Of those, 16 match the published bump exactly, 10 are stricter than it, and 6 are looser. The graded gate splits the 10 stricter rows: --strict fires on 6 — real breaks the author shipped under-bumped, including p-limit 6.1.0 and ky 1.14.0, which each added a required property to an exported type yet released as a minor (tsc confirms a TS2741 for implementers), commander 12.1.0, which removed a public method, and clsx 2.1.1, whose input-union widening this file used to cite as the archetypal false positive — while the other 4 demote to review-only and pass the gate. Most of the looser results are releases bumped for runtime-only reasons with no public type change.

The 12 unanalyzable rows are the honest part of this table: 3 exhaust memory on type-fest, and 9 are packages this tool refuses to guess about because it could extract no surface at all — picocolors and escape-string-regexp ship no bundled declarations, yargs and ts-toolbelt resolve to something empty. That count went up rather than down when extraction improved, because a failure that used to be reported as a clean patch is now an exit 2. Reproduce the scorecard with scripts/accuracy-probe.mjs (after npm run build), or spot-check your own dependencies:

npx semver-checks compare <pkg>@<previous> <pkg>@<latest>

Known limitations

Ten known gaps, grouped by direction: three over-report as review-only heuristic MAJORs that never fail --strict (equivalence-preserving type rewrites, input-position union widening, return-only generics); four under-report as a silent patch (shallow class reads, non-ambient class method overload reordering, skipped constructor comparison on generic/mixin subclasses, a handful of declaration-level distinctions); and three are structural rather than a grading question (multi-subpath double counting, memory limits on deeply recursive types, non-standard entry layouts). Full table below.

Full breakdown of all 10 known limitations
AreaWhat happensWhy
Equivalence-preserving refactorsReplacing a type with an equivalent one — an alias swap like Exclude<…>SetDifference<…>, or { [P in K]: T }Pick<T, K> — is reported as a type-alias-changed, but as a review-only (heuristic) MAJOR, off the --strict gate.Type aliases and variables are compared as normalized text, not by resolving both types and checking assignability; an unresolved comparison is graded heuristic.
Input-position widening in aliasesWidening a union used as an input (e.g. adding bigint to a parameter-only union) is reported MAJOR, though it accepts strictly more — graded heuristic (the relation is one-directional in an invariant position), so --strict does not gate on it.Variance is analyzed for function parameters and returns, but not inside a type alias body.
Type parameters added to functionsAdding a return-only type parameter (fn(): stringfn<T extends string>(): T) is reported MAJOR, though existing call sites still infer the same result — graded heuristic (a generic added to a callable), off the gate.The "required generic added" rule treats a callable-context addition as review-only; in a type/interface/class context, where the argument is always written explicitly, it stays proven.
Dual-format / multi-subpath double countingA package exposing the same symbols under several exports subpaths (. plus a JS wrapper like ./esm.mjs, or . plus ./lite) reports each change once per subpath.Each .-prefixed subpath is analyzed independently; identical changes across subpaths are not yet de-duplicated.
Deeply recursive conditional typesExtremely type-heavy libraries (e.g. type-fest) can exhaust memory during extraction. Raising the heap — NODE_OPTIONS=--max-old-space-size=8192 npx semver-checks … — gets some through; there is no in-process guard, so a hard OOM still aborts.Declaration extraction has no depth/size bound on deeply recursive conditional / mapped types.
Non-standard entry layoutsA few packages whose types live only beside a JS target — no types condition, no top-level types, no root index.d.ts — can't be auto-resolved; pass --entry.Sibling-.d.ts-of-JS-target resolution is not implemented.
Class declarations are read shallowlyChanging a class's extends base, making a class or one of its members abstract, or making a class method required (m?(): voidm(): void) is reported as no change at all — a patch. The interface equivalent of the last one is detected.Only the class's own members, their types, and their static/optional/readonly flags are compared. The extends clause is now extracted, but it is read only to resolve a base when something else asks what it carried (an interface extending a class, a member hoisted onto one); no rule diffs a class's own heritage, and abstract is not extracted at all.
Class method overload reordering is undetectedIn a plain .ts source (not an ambient .d.ts/declare class), reordering a class method's overload signatures is reported as no change at all (a patch), even though TypeScript resolves an overloaded call against the first matching signature in declaration order, so the reorder is a real break. Disjoint overloads (where call resolution never actually changes) still break, too: ReturnType<typeof method> always resolves to the last signature, so reordering swaps what that utility type produces.getMethods() on a non-ambient class returns one implementation node per method, so overloads are merged into a single signature (e.g. bar(x: number): void; bar(x: string): void extracts as one x: number | string signature) before comparison, losing declaration order. Ambient declarations, which have no implementation node, extract each overload separately and detect the reorder.
Constructor comparison is skipped on generic/mixin subclassesA class with no explicit constructor of its own and an extends clause has its constructor comparison skipped entirely, rather than judged against the constructor it inherits, whatever shape the base takes (a plain class, a generic instantiation like extends Base<string>, a class expression, or a mixin factory).Declaration nodes alone can't instantiate a base's type arguments, so the inherited constructor can't be resolved reliably for generics, class expressions, or mixins. Rather than risk a wrong answer, the comparison is skipped whenever a class both lacks an explicit constructor and extends something; a class with no extends at all still defaults to an implicit public zero-arg constructor.
Some declaration-level distinctions are invisibleA value export narrowed to a type-only one (export declare class Cdeclare class C; export type { C }), an enum becoming a const enum, and a second interface declaration merged into the first are all reported as no change.Exports are resolved to their declarations without recording whether the export itself was type-only, and only the first declaration of a merged symbol is read.

When a type can't be resolved in isolation (imported types, bare generics, anything involving any), semver-checks falls back to the conservative MAJOR verdict by design — see Does it have false positives?.

Quick Start

npm install --save-dev semver-checks

Compare a git tag to the current working tree:

npx semver-checks compare v1.0.0 HEAD

Compare the published npm release against your working tree — answers "is my current change a breaking release?" without needing git tags:

npx semver-checks compare your-package@latest

A <package>@<version> argument is fetched from the npm registry (via npm pack) and used as the old version. Concrete versions, ranges, and common dist-tags are auto-detected (your-package@1.2.3, your-package@^1, your-package@next). For an uncommon dist-tag, make the intent explicit with the npm: prefix or --old-as npm (npm:your-package@my-custom-tag) so it isn't mistaken for a git ref.

Compare two local directories:

npx semver-checks compare ./old ./new

Existing relative paths without a ./ prefix are also treated as local directories:

npx semver-checks compare packages/core packages/core-next

If a git ref collides with an existing path name, force ref interpretation explicitly:

npx semver-checks compare main HEAD --old-as ref

Output as JSON, Markdown (for PR comments), or GitHub Actions annotations:

npx semver-checks compare v1.0.0 HEAD --format json
npx semver-checks compare v1.0.0 HEAD --format markdown
npx semver-checks compare v1.0.0 HEAD --format github

Fail in CI if breaking changes are detected (exit 1):

npx semver-checks compare v1.0.0 HEAD --strict

Inspect the API surface of the current or a past version:

npx semver-checks snapshot
npx semver-checks snapshot --ref v1.0.0
npx semver-checks snapshot --npm lodash@4.17.21

Multiple entry points

When package.json declares an "exports" map with several subpaths, every subpath with a declared .d.ts entry is extracted and compared independently. Adding a subpath is a MINOR change and removing one is MAJOR; a change inside a subpath is reported with a # separator (e.g. ./utils#helper). No flags are needed — the map is auto-detected.

For projects without an "exports" map, pass multiple entries explicitly by repeating --entry or comma-separating them:

npx semver-checks compare v1.0.0 HEAD --entry src/index.ts --entry src/utils.ts
npx semver-checks compare v1.0.0 HEAD --entry src/index.ts,src/utils.ts

Example output

semver-checks — Recommended bump: MAJOR
  major: 2 (confident: 1, review: 1)  minor: 1  patch: 0

  Breaking Changes — confident (MAJOR)
  ✗ Required property 'timeout' was added to interface 'Config'
      now: number

  Needs review — couldn't prove safe (MAJOR)
  ? Type alias 'UserId' changed
      before: string | number
      after:  string

  New Features (MINOR)
  + Export 'createConfig' was added

--strict exits 1 on the confident break only; the review-only item passes the gate unless you opt into --strict-review.

Programmatic API

import { compare, extract } from "semver-checks";

const report = await compare({
  oldSource: { type: "git", ref: "v1.0.0" },
  newSource: { type: "path", path: "." },
});

console.log(report.recommended); // 'major' | 'minor' | 'patch'
console.log(report.changes); // ApiChange[]
console.log(report.summary); // { major: 2, minor: 1, patch: 0 }
interface CompareOptions {
  oldSource: SourceRef;
  newSource: SourceRef;
  entry?: string | string[]; // Optional: specify one or more entry points
  installDeps?: boolean; // Optional: install deps before analyzing local path sources
}

type SourceRef =
  | { type: "path"; path: string }
  | { type: "git"; ref: string; cwd?: string }
  | { type: "npm"; spec: string }; // e.g. { type: 'npm', spec: 'lodash@4.17.21' }

interface SemverReport {
  recommended: "major" | "minor" | "patch";
  changes: ApiChange[];
  summary: {
    major: number;
    minor: number;
    patch: number;
    majorProven: number;
    majorReview: number;
  };
}

interface ApiChange {
  kind: ChangeKind;
  severity: "major" | "minor" | "patch";
  symbolPath: string;
  message: string;
  oldValue?: string;
  newValue?: string;
  confidence?: "proven" | "heuristic";
}

You can also extract a snapshot independently:

import { extract } from "semver-checks";

const snapshot = await extract({ projectPath: "." });
// Snapshots are keyed by export subpath ('.' is the root entry; additional
// subpaths come from the package.json "exports" map).
console.log(Object.keys(snapshot.entrypoints["."])); // root entry's symbol names

Change Rules

Breaking changes (MAJOR)

RuleDescription
export-removedA public export was removed
entrypoint-removedA public export subpath was removed
required-param-addedA required parameter was added to a function
param-removedA parameter was removed
param-type-changedA parameter's type changed
return-type-changedA function's return type changed
property-removedAn interface property was removed
required-property-addedA required property was added to an interface
property-type-changedAn interface property's type changed
interface-property-became-requiredAn optional interface property or method became required
interface-property-became-readonlyAn interface property changed from mutable to readonly
interface-method-removedAn interface method was removed
required-interface-method-addedA required interface method was added
interface-method-signature-changedAn interface method's signature changed
enum-member-removedAn enum member was removed
enum-member-value-changedAn enum member's value changed
class-constructor-changedA class constructor's signature changed
class-constructor-visibility-narrowedA class constructor's visibility was narrowed (e.g. publicprivate)
class-method-removedA public class method was removed
class-method-signature-changedA public class method's signature changed
class-method-became-staticA class method changed from instance to static
class-method-became-instanceA class method changed from static to instance
class-property-removedA public class property was removed
class-property-type-changedA public class property's type changed
class-property-became-staticA class property changed from instance to static
class-property-became-instanceA class property changed from static to instance
class-property-became-requiredAn optional class property became required (review-only)
required-class-property-addedA required instance class property was added (proven when the class was structurally implementable)
class-property-became-readonlyA public class property changed from mutable to readonly
generic-param-requiredA required generic parameter was added
generic-param-removedA generic parameter was removed
generic-constraint-changedA generic parameter's constraint changed
generic-param-default-changedA generic parameter's default type changed or was removed
overload-removedA function overload was removed
interface-call-signature-changedAn interface's call signatures changed
interface-construct-signature-changedAn interface's construct signatures changed
index-signature-changedAn interface's index signatures changed
interface-heritage-changedAn interface's extends clause changed
type-alias-changedA type alias definition changed
variable-type-changedAn exported variable's type changed

New features (MINOR)

RuleDescription
export-addedA new public export was added
entrypoint-addedA new public export subpath was added
optional-param-addedAn optional parameter was added
optional-property-addedAn optional property was added to an interface
interface-method-addedAn optional interface method was added
interface-property-became-optionalA required interface property or method became optional
interface-property-became-mutableAn interface property changed from readonly to mutable
enum-member-addedAn enum member was added
overload-addedA function overload was added
class-constructor-visibility-widenedA class constructor's visibility was widened (e.g. privatepublic)
generic-param-with-defaultA generic parameter with a default was added
generic-param-default-addedA default was added to an existing generic parameter
class-method-addedA public class method was added
class-property-addedAn optional or static public class property was added
class-property-became-optionalA required class property became optional
class-property-became-mutableA public class property changed from readonly to mutable
param-type-widenedA parameter's type was widened — existing callers still type-check (contravariant)
return-type-narrowedA function's return type was narrowed — existing consumers still type-check (covariant)

CLI Reference

compare

semver-checks compare <old> [new] [options]
OptionShortDescriptionDefault
--entry <path>-eEntry file path (e.g., src/index.ts); repeat or comma-separate for multiple entriesAuto-detect
--format <type>-ftext, json, markdown, or githubtext
--strict-sExit 1 if a confident (proven) breaking change is found — safe to gate CI onfalse
--strict-reviewExit 1 if any breaking change is found, including review-only (heuristic) onesfalse
--declared <bump>The bump this release declares: major, minor, patch, none, or auto. Exit 1 when it understates a proven break(off)
--install-depsInstall dependencies before analyzing local path inputsfalse
--old-as <kind>Force <old> to be interpreted as path, ref (or git), or npmAuto-detect
--new-as <kind>Force [new] to be interpreted as path, ref (or git), or npmAuto-detect

Arguments:

  • <old>: an npm spec (pkg@version), a git ref (tag, branch, commit SHA), or a local directory path for the old version
  • [new]: npm spec, git ref, or path for the new version; defaults to . (current directory)

Exit codes:

CodeMeaning
0Analyzed; the gate you asked for passed
1Analyzed; the gate failed (--declared on a declaration that understates a proven break, --strict on a proven break, --strict-review on any)
2Could not answer — an input didn't resolve, or a side's API surface could not be extracted

Exit 2 covers the case where extraction produced nothing usable: no API symbols at all, or only opaque (any) ones. That is reported as a failure rather than a clean patch, because a comparison of two empty surfaces is indistinguishable from a release that genuinely changed nothing. Pass --entry to point at the declaration file when a package's entry point can't be auto-resolved.

Output formats:

  • text — colored human-readable summary (default)
  • json — the structured SemverReport
  • markdown — a Markdown summary suitable for a PR comment or $GITHUB_STEP_SUMMARY
  • githubGitHub Actions workflow commands (::error:: / ::warning::) that surface inline on the PR

All four carry the --declared verdict when one was asked for.

If an argument matches an existing filesystem path, semver-checks treats it as a path source even without a ./ prefix. A <package>@<version> shape that is not an existing path is resolved from the npm registry. A plain ref (v1.2.3, main) has no @version and is resolved as a git ref. A git ref that happens to share the name@version shape (e.g. a lerna/monorepo tag like pkg@1.0.0) would be auto-detected as an npm spec — force git resolution with --old-as ref in that case. Use --old-as ref / --new-as ref (or --old-as npm) when auto-detection guesses wrong. The action takes the same two as old-as / new-as.

When using git refs, the command must run inside a git repository. The ref is resolved against the working directory's repo.

--declared: does the bump this release announces hold?

--strict answers "is anything here breaking". On a release pull request that is the wrong question, because a release that declares a major is allowed to break things. --declared asks the question that is actually open: does the bump written down cover what the API surface did?

# Read the declaration from the repository, compare it against the analysis
semver-checks compare your-package@latest . --declared auto --format markdown

auto looks in two places, in order:

  1. .changeset/*.md frontmatter (and .changeset/pre/*.md) for a release type declared for this package. The name is taken from the new side's package.json. Several changesets for one package merge to the highest of them, the way changeset version would. The files changesets itself skips are skipped here too: dot files, anything that is not Markdown, and README.md / AGENTS.md / CLAUDE.md / GEMINI.md.
  2. The package.json version field on each side, when no changeset names the package. The field that moved is read as written; a prerelease on either side (1.0.0-rc.1 to 1.0.0) moves none of the three numbers, so the run exits 2 asking for an explicit --declared rather than calling it none.

Or state it yourself: --declared minor.

Below 1.0 the requirement shifts down a field rather than the declaration shifting up, because that is where a caret range stops: ^0.5.0 refuses 0.6.0, so a break there needs a minor, not a major. It shifts once and no further. ^0.0.5 does refuse 0.0.6, but ~0.0.5 and 0.0.x both accept it, so on a 0.0.x package the only bump that puts a break out of every range is still 0.1.0.

That has to be decided on the requirement side, because a 0.x release announces its break as 0.5.0 -> 0.6.0 in version fields and as minor in a changeset (changeset version runs semver.inc, so a major there would have written 1.0.0). Reading the two sources differently would give one release two opposite verdicts.

The verdict draws the same graded-confidence line --strict and --strict-review already do. Only a proven break fails the run. Everything else the analysis found, an addition to the public surface or a major it could not prove, argues for a higher bump and is reported as a ⚠️ on a passing run. That second reading is the same one recommended bump already shows, and it stays out of the gate because a build that fails on a finding the analyzer could not prove is a build people stop trusting.

DeclaredWhat was foundVerdictExit
majora proven break✅ ok, the release admits to it0
minora proven break❌ mismatch1
patcha proven break❌ mismatch1
patcha new export, no proven break⚠️ review, argues for minor0
minora new export✅ ok0
patcha review-only (heuristic) major⚠️ review, argues for major0
majora review-only (heuristic) major✅ ok0
nonenothing on the public surface moved✅ ok0

The table is for a package past 1.0; below that a required major reads as a required minor, as described above. The exit codes are the defaults. --strict-review promotes ⚠️ review to 1 as well, which is what it is for; the report itself does not repeat the exit code, because it is written before the flags are read.

--declared subsumes --strict, whose question it already answers. --strict-review keeps its meaning instead of being cancelled: it promotes the ⚠️ review verdict to a failure, so a release that understates a review-only finding fails for whoever asked for that.

If auto cannot read a declaration at all, the run exits 2 rather than passing quietly.

snapshot

semver-checks snapshot [path] [options]
OptionShortDescription
--ref <ref>-rUse a git ref instead of a local path
--npm <spec>Snapshot a published npm package (e.g. lodash@4.17.21)
--entry <path>-eEntry file path; repeat or comma-separate for multiple entries
--install-depsInstall dependencies before analyzing a local path

Arguments:

  • [path]: project path; defaults to . (current directory)

Exits 2 when nothing usable could be extracted, rather than printing an empty surface — same reasoning as compare above.

Global options

OptionDescription
--mcpStart semver-checks as an MCP server over stdio

Environment variables

VariableDescription
SEMVER_CHECKS_VERBOSE=1Print warnings for skipped symbols, type resolution failures, and dependency install issues

MCP Server

semver-checks ships as a Model Context Protocol (MCP) server, letting AI agents (Claude Code, Codex, Cursor, etc.) call it as a tool directly.

Setup

# Claude Code
claude mcp add semver-checks -- npx -y semver-checks --mcp

Use npx -y for global-on-demand installs so the MCP server does not block on an interactive "install this package?" prompt.

Or add it to your .claude/settings.json:

{
  "mcpServers": {
    "semver-checks": {
      "command": "npx",
      "args": ["-y", "semver-checks", "--mcp"]
    }
  }
}

For a locally installed version:

{
  "mcpServers": {
    "semver-checks": {
      "command": "/path/to/node_modules/.bin/semver-checks",
      "args": ["--mcp"]
    }
  }
}

Relative paths and git refs are resolved from the MCP server process's current working directory. For reliable results, launch the server from the repository you want to inspect, or pass absolute filesystem paths for local sources.

Available Tools

ToolDescription
semver_compareCompare two versions and get a SemVer recommendation + change list
semver_snapshotExtract the public API surface of a project as a JSON snapshot

semver_compare

ArgumentTypeRequiredDescription
oldstringYesFilesystem path, git ref (tag, branch, SHA), or npm spec (lodash@4.17.21)
newstringSame three forms. Defaults to .
entrystring | string[]Entry file(s) (e.g. src/index.ts). Comma-separated or an array. Auto-detected if omitted; an empty value is refused
oldAs"path" | "git" (or "ref") | "npm"Force interpretation of old
newAs"path" | "git" (or "ref") | "npm"Force interpretation of new
declared"major" | "minor" | "patch" | "none" | "auto"Grade the bump the release declares, the same gate --declared applies on the CLI
maxChangesintegerHow many changes to include. Defaults to 50
installDepsbooleanInstall dependencies before analysis

A name@version that is not an existing path resolves to npm on its own, so comparing a working tree against the published release needs no checkout:

{ "old": "your-lib@latest", "new": ".", "declared": "auto" }

declared adds a declaration object to the report with the same verdicts the CLI prints: mismatch when a proven break outranks what the release wrote down, review when the changes argue for more than that, ok otherwise. "auto" reads the declaration from .changeset/*.md and then the two package.json versions, and errors when it finds neither rather than reporting a pass.

Every report carries a gate object saying what each CLI gate would do with it, which is what the exit code carries on the command line: { "strict": "pass" | "fail", "strictReview": "pass" | "fail" }. The two are different contracts, not two strengths of one check, and Which gate to run has the numbers: a passing strict is not a clean bill of health, because a real break the analyzer could not prove stays review-only. Passing declared changes the question both gates answer, exactly as it does on the CLI: the release is graded against the bump it writes down, so a proven break it already declares passes both, and strictReview promotes the ⚠️ review verdict rather than firing on every major.

Failures come back with a code the caller can branch on, in the message text and in an error field: invalid_argument for something a different call would fix (a misspelled argument, a value outside an enum, a wrong type, a source that is empty or malformed) and analysis_failed for a call that was well-formed and could not be answered (a ref or a package that does not exist, a project that would not compile). Retrying the second with the same arguments is wasted work.

The message text reads Error: [code] message. The Error: prefix is what it has always been, but the code sits inside it, so a consumer that took the message as everything past a fixed offset needs the error field or a parse rather than a slice.

maxChanges bounds the response. A large major release runs to hundreds of findings, which is more than a tool result can carry, so changes come back ordered proven breaks first, then additions, then review-only ones, and the list stops at the cap. summary always counts every change, and an omitted field reports the number left out along with the maxChanges value that would return all of them.

semver_snapshot

ArgumentTypeRequiredDescription
pathstringFilesystem path, git ref, or npm spec. Defaults to .
entrystring | string[]Entry file(s). Comma-separated or an array
pathAs"path" | "git" (or "ref") | "npm"Force interpretation of path
detailbooleanReturn each symbol's full type shape instead of its kind
maxBytesintegerByte budget for the returned symbols. Defaults to 40000
installDepsbooleanInstall dependencies before analysis

The three source forms are the same ones semver_compare takes, so zod@4.4.0 snapshots from the registry without a checkout.

By default each symbol comes back as its kind, because the full shapes are what make a snapshot large: for a package the size of zod they run more than an order of magnitude past the names and kinds that describe the same surface. detail asks for the shapes, and maxBytes bounds either form, with an omitted field reporting the count left out and the total. A response that drops nothing carries no such field.

The budget covers the symbols, and no symbol is exempt from it. A wide interface under detail can cost tens of kilobytes on its own, so a budget smaller than that returns no symbols and says so in omitted, rather than overshooting the number you passed. Only the entrypoint keys and the omitted field sit on top of the budget. Snapshots are serialized without indentation, which is what makes the budget a count rather than an estimate.

CI Integration

GitHub Action

semver-checks ships a reusable composite action. The most ergonomic setup compares the published latest release against the PR's working tree, so it needs no git tags and posts inline annotations on the diff:

name: SemVer Check

on:
  pull_request:
    branches: [main]

jobs:
  semver-checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci

      - uses: kyungseopk1m/semver-checks@v0.13.0
        with:
          old: "your-package@latest" # the published version to compare against
          format: "github" # inline ::error:: / ::warning:: annotations
          strict: "true" # fail the PR on a confident (proven) breaking change
InputDescriptionDefault
oldOld version — an npm spec (pkg@latest), git ref, or path(required)
newNew version — git ref or path.
entryEntry file (auto-detected from package.json when omitted)(auto)
old-asForce old to be read as path, git, or npm. What a monorepo tag like pkg@1.2.3 needs, since auto-detection reads that shape as an npm spec(auto-detect)
new-asForce new to be read as path, git, or npm(auto-detect)
install-depsInstall dependencies before analysis. Only affects local path inputsfalse
formattext, json, markdown, or githubgithub
strictFail the step (exit 1) on a confident (proven) breaking changefalse
strict-reviewFail the step (exit 1) on any breaking change, including review-only (heuristic) onesfalse
declaredThe bump this release declares: major, minor, patch, none, or auto. Fails the step when it understates a proven break, and takes over from strict(off)
commentPost the report to the PR as a single comment, edited in place on later pushes (forces format: markdown)false
tokenToken used to post the comment. A composite action cannot read the secrets context, so it has to be passed in${{ github.token }}
versionsemver-checks version to run via npx(matches the action ref)

A full example lives in examples/github-actions.yml.

On a release pull request

The check that has no equivalent elsewhere: does the bump this release declares cover what its API surface did? declared: auto reads the bump from .changeset/*.md and falls back to the two package.json versions, and comment: true puts the verdict on the pull request as one comment that is edited in place rather than one per push.

permissions:
  contents: read
  pull-requests: write # required for `comment`

jobs:
  semver-checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci

      - uses: kyungseopk1m/semver-checks@v0.13.0
        with:
          old: "your-package@latest"
          declared: "auto"
          comment: "true"

The step fails when the declaration understates a proven break, and passes with a note when it only understates an addition. token defaults to github.token; pass it explicitly if your setup needs a different one. A pull request from a fork gets a read-only token, so the comment is skipped with a warning there rather than failing the step.

Without the action

Run the CLI directly — for example, compare the published release to the working tree:

- name: Check for breaking changes
  run: npx semver-checks compare your-package@latest --format github --strict

Or compare against a git tag:

- name: Check for breaking changes
  run: npx semver-checks compare v$(node -p "require('./package.json').version") HEAD --strict

Comparison with Other Tools

semver-checkssemantic-releasechangesetsnpm-check-updates
InputTypeScript ASTCommit messagesManual YAMLpackage.json
DetectionTyped API rulesKeyword matchingDeveloper-declaredVersion range only
RecommendationAutomaticBased on message formatManual per changeDependency updates only

semver-checks is a verification layer, not a release tool. Use it alongside semantic-release or changesets to check whether the declared bump matches the API changes.

How It Works

  1. Extract: Parse old and new TypeScript source files using ts-morph, building a typed API snapshot (functions, interfaces, enums, classes, type aliases, variables, namespaces)
  2. Diff: Compare the two snapshots symbol by symbol — detect additions, removals, and signature changes
  3. Classify: Assign each diff a major, minor, or patch severity
  4. Report: Return a structured SemverReport with the recommended bump and per-change details

For git ref comparisons, the ref is extracted to a temporary directory via git archive, dependencies are installed there if needed, and the directory is cleaned up after extraction. For npm specs, the published tarball is downloaded with npm pack and extracted to a temporary directory (no dependency install — the tarball already bundles its build output), then cleaned up. Local path comparisons do not install dependencies unless you opt in with --install-deps or installDeps: true.

FAQ

Will semver-checks catch every semver violation?

No. It catches API surface changes that are mechanically detectable from TypeScript's static type system: removed exports, signature changes, type changes, optionality changes, and similar structural changes. It does not detect behavioral changes, documentation changes, or changes hidden behind conditional compilation. When a package ships distinct ESM and CJS declaration files for the same entry point (for example, divergent import.types and require.types), only one surface is analyzed, so a break confined to the other surface can be missed. See [Ac