Change classification
monochange change classify produces a release-aware severity report for packages affected by a pull request or local worktree.
Comparisons
The classifier resolves these comparisons:
| Kind | Base | Head | Purpose |
|---|---|---|---|
pullRequest | The remote default branch, or --base | The synthetic merge result of the base and --head | Changes introduced after the pull request merges |
sourceDelta | The merge base of the default branch and the source candidate | The source candidate | Changes authored on the pull request branch |
workingTree | HEAD | The staged, unstaged, deleted, and untracked files | A diagnostic view of local changes |
release | The package release owner’s latest reachable tag, or --release | The synthetic merge result | Accumulated change since the latest release |
releaseToDefault | The same release tag | The default branch | Change that has already accumulated before the pull request |
pullRequest and release are exact two-endpoint comparisons. sourceDelta uses the merge base only to identify work authored on the branch. When --head is HEAD, the source candidate materializes committed and local changes into one temporary Git commit. The bump comes from the net candidate comparison, while workingTree remains a diagnostic view. A local edit that reverses a committed breaking change therefore removes that break from the proposal instead of adding a second, contradictory signal.
The candidate is a tree object created by git merge-tree --write-tree. monochange uses a temporary index for local changes and does not change the real index or worktree. If Git cannot create the merge tree, the report marks the comparison as conflicted, falls back to the source candidate, and requires human review.
Package decision
Each package has a decision object with these fields:
| Field | Meaning |
|---|---|
compatibility_impact | breaking, additive, compatible, or unmodeled between the default branch and the candidate |
release_impact | The same verdict between the latest release and the candidate; absent when no release tag matched |
pull_request_changes | Whether the net candidate or the local working tree contains files that belong to this package |
proposed_changeset_bump | The highest current finding after the release cap: major, minor, patch, or none |
enforceable_minimum | The highest bump supported by high-confidence evidence, after the same release cap |
release_floor | The highest bump found between the latest release and the candidate |
confidence | Confidence of the finding that determines proposed_changeset_bump |
completeness | Whether the analyzer claims complete, partial, or unsupported coverage |
review_required | Whether the proposal needs human or agent review before it becomes release intent |
finding_ids | Stable identifiers for the findings that determine the proposal |
compatibility_impact describes the default-branch comparison, so it also sees breaks against an API the default branch has not released yet. release_impact describes the same candidate against the package’s latest release. When a release comparison is analyzed, a modeled finding cannot propose a bump higher than the release-relative bump for the package, because nobody holding the latest release can observe a break in an item that release never shipped. A pull request that refines an unreleased API therefore reports compatibility_impact: breaking with release_impact: additive and a minor proposal instead of an inflated major. Unmodeled findings stay uncapped: they are the review floor for surfaces the analyzers cannot model, and the release comparison cannot refute them. release_floor still reports the complete unreleased interval, so a pull request can propose patch while the release floor is major because an earlier merged pull request introduced the breaking change.
pull_request_changes decides what the contribution itself may propose. Findings from the release and releaseToDefault comparisons describe what the default branch accumulated since the latest release, so a package the pull request does not modify reports proposed_changeset_bump: none, enforceable_minimum: none, completeness: complete, and review_required: false while release_floor and release_impact keep the accumulated change. A pending changeset for such a package still reports action: review, because a changeset may intentionally describe a consumer-facing effect implemented in another package, but the report no longer escalates the bump or the review verdict for work someone else already merged. A package becomes pull-request-scoped through the net candidate or through the local working tree, so a branch change that a local edit reverts stays in scope and keeps requiring review.
none is conclusive only when completeness is complete and review_required is false. A changed package without modeled semantic evidence receives a low-confidence patch proposal instead of a false none result. A decision is also complete when every current finding is complete. A high-confidence major finding makes the bump decision complete even when another analyzer is partial because no unmodeled finding can require a higher bump.
The package-level action is create, update, keep, review, or no_changeset. The report includes packages targeted only by a pending changeset and marks them review; a changeset can intentionally describe a consumer-facing effect implemented in another package, so monochange does not assume that unmatched intent is stale. Public dependency propagation retains the dependent package’s release owner, comparisons, and existing changesets.
Skipping a pull request
Some pull requests have no pending work to classify. The release pull request monochange opens only bumps versions and deletes consumed changesets, so every package would report the unmodeled fallback and bury the real signal.
Configure [changesets.classification].skip_labels and pass the pull request labels with --label:
[changesets.classification]
skip_labels = ["release"]
monochange change classify --format json --label release
When any configured label is present, the report sets skipped: true, analyzes no packages, names the matched labels in matched_skip_labels, and exits successfully. The recommended bump is none. Set skip_labels = [] to classify every pull request.
Findings
Each finding records its rule_id, API surface, change kind, compatibility impact, bump, confidence, analyzer id, engine and version, coverage note, optional fallback reason, source location, before and after signatures, and comparison membership. Markdown and text reports print the evidence directly below each finding so pull request comments retain the same provenance as JSON.
unmodeled means the change is real but sits outside the public surface the analyzer models, so no compatibility verdict applies. The package is supported; only that file change is outside the model. A changed package with no modeled finding receives the low-confidence monochange/unclassified-source fallback, which proposes patch and requires review because no analyzer can rule out a break.
Identical evidence found in several comparisons shares one finding and lists every comparison. If the same item has different before or after signatures across the pull-request and release intervals, monochange emits distinct comparison-qualified finding ids so that an agent never applies one interval’s signature evidence to another interval.
monochange compares package manifests at both endpoints. Adding or removing a package produces a monochange/package-lifecycle finding with complete coverage and high confidence. Removing a package proposes major, even when the package has no modeled public symbols. Adding one proposes minor.
The built-in Cargo, JavaScript, Deno, and Dart source analyzers inspect syntax and package metadata. Their findings are partial and medium-confidence because they do not prove every language compatibility rule. Cargo packages can opt into a cargo-semver-checks matrix for stronger Rust evidence.
One Rust case carries a stronger verdict than the syntax default. A public const or static whose declared type is a slice (&[T]), a fixed-size array ([T; N]), or Vec<T> and whose initializer is a literal is compared element by element. Adding elements to the end is additive with a minor proposal and high confidence, matching cargo semver-checks, because the element type and every existing element stay the same. Removing an element, reordering elements, editing an existing element, or changing the declared element type stays breaking, and a non-literal initializer such as a path or a vec![value; count] repeat stays conservative because its elements cannot be enumerated.
CLI command-surface findings
Packages that register a CLI under [package.<id>].cli get their command surface classified automatically. monochange diffs the committed baseline in .monochange/cli-snapshots/<name>.json against a fresh capture from the configured snapshot command and appends findings with surface: "cli". Removed commands, options, or positionals and value narrowing are breaking and propose major; additions are additive and propose minor; description-only changes are compatible. See package CLI registration.
Skip the comparison with --skip-cli-snapshots or MONOCHANGE_SKIP_CLI_SNAPSHOTS=1.
TypeScript declaration compatibility
Use semantic detection when an npm package publishes TypeScript types:
monochange change classify \
--detection-level semantic \
--format json \
--dependency-propagation public
The npm adapter resolves the package’s explicit exports, types, or typings entrypoints, emits declarations for the before and after package snapshots, and asks the workspace’s TypeScript compiler to compare the resulting consumer contracts. It keeps import and require conditions separate. Declaration-only packages do not need a tsconfig.json.
The analyzer classifies evidence as follows:
| Evidence | Impact | Proposed bump |
|---|---|---|
| Removed entrypoint, export, or non-assignable consumer contract | breaking | major |
| Added entrypoint/export, overload, optional member, or input capability | additive | minor |
| Changed source with an equivalent or consumer-compatible declaration API | compatible | none |
| Unresolved config, wildcard export, generic/nominal identity, or failure | unmodeled | patch |
Complete TypeScript evidence requires node and a locally resolvable typescript package. Install TypeScript and the package’s dependencies in the workspace before classification. monochange reports the exact compiler version in finding.analyzer.version.
pnpm add --save-dev --workspace-root typescript
pnpm install --frozen-lockfile
Snapshots contain package files from each Git endpoint. monochange never substitutes a candidate package file into the baseline. An inherited config or dependency declaration that only exists in the current checkout is allowed so the compiler can proceed, but the finding records partial coverage and a fallback reason. Wildcard exports are also partial because an export pattern cannot be enumerated conclusively from package metadata alone.
Changed generic declarations and classes with private or protected identity are reported as inconclusive when TypeScript cannot compare the two snapshot identities safely. Runtime behavior, side effects, JavaScript-only packages, and unlisted dynamic entrypoints remain outside declaration compatibility. Review those changes even when the declaration result is none.
When the repository defines [package.*] entries, classification is limited to those configured packages. Package additional_paths and ignored_paths, plus [changesets.affected].ignored_paths, use the same path policy as changeset coverage. This keeps fixtures, tests, generated output, and other explicitly ignored paths from producing release recommendations.
Package discovery reads both comparison endpoints and joins packages by ecosystem and repository-relative manifest path. A package that exists only in the base remains in the report with its baseline package id, path policy, release owner, and tag format. Its before snapshot contains the package files, and its after snapshot is empty. This behavior also applies when the pull request removes the package’s [package.*] entry from monochange.toml.
Rust compatibility matrix
Install cargo-semver-checks, then opt into it for semantic classification:
cargo install cargo-semver-checks --locked
[ecosystems.cargo.semver_checks]
enabled = true
timeout_seconds = 300
[[ecosystems.cargo.semver_checks.matrix]]
name = "default"
feature_mode = "default"
[[ecosystems.cargo.semver_checks.matrix]]
name = "all-features"
feature_mode = "all"
[[ecosystems.cargo.semver_checks.matrix]]
name = "wasm"
feature_mode = "none"
features = ["wasm"]
target = "wasm32-unknown-unknown"
Install every configured target before classification, for example rustup target add wasm32-unknown-unknown. A matrix may contain at most 16 uniquely named cells. timeout_seconds applies to each cell and must be between 1 and 1800. Feature mode is default, all, none, or heuristic; the features, baseline_features, and current_features lists add common or endpoint-specific features.
The integration runs only with --detection-level semantic. monochange materializes each Git endpoint as a complete, isolated repository tree so workspace inheritance and path dependencies remain available. Each cell receives an isolated CARGO_TARGET_DIR. The JSON finding exposes the exact cell inputs, checked, failed, or skipped status, minimum bump, lint ids, authoritative lint references, and cargo-semver-checks version under coverage.checks.
Results merge conservatively:
| Matrix result | Classification behavior |
|---|---|
| Any checked cell proves a break | Propose major, even if another cell fails |
| A checked cell reports a minor requirement and none break | Propose at least minor |
| Every configured cell is checked and requires no bump | Report compatible Rust API evidence; retain syntax-derived additions as minor evidence |
| A tool, target, build, timeout, or matrix cell is incomplete | Keep conservative syntax findings and require review |
cargo-semver-checks’ active lint set is authoritative for the rules it runs, but compatible additions are not exhaustively enabled by default. monochange therefore retains syntax-derived additions even after a clean matrix. Runtime behavior, undocumented supported configurations, and downstream build behavior remain outside this proof.
Security: cargo-semver-checks asks Cargo and rustdoc to build both endpoints. Build scripts and procedural macros can execute repository code. Enable this only for code you are prepared to execute. In CI, use a normal
pull_requestjob with least-privilege credentials; do not run semantic classification on untrusted changes throughpull_request_targetor with publishing secrets.
Output and validation
Markdown output is intended for terminal output, pull request comments, and job summaries:
monochange change classify --format markdown --dependency-propagation public
JSON is the stable agent and automation interface. The top-level schema_version changes when the JSON contract changes:
- 0.3 adds
decision.pull_request_changesand scopesdecision.proposed_changeset_bump,decision.enforceable_minimum, anddecision.review_requiredto packages the pull request actually touches. - 0.2 adds
decision.release_impactand capsdecision.proposed_changeset_bump,decision.enforceable_minimum, anddecision.release_floorwith the release comparison. - 0.1 is the first contract published by the
monochange_classificationcrate: snake_case keys,unmodeledinstead ofunknown, and the top-levelskipped,summary, andmatched_skip_labelsfields.
monochange change classify --format json --dependency-propagation public
monochange changeset validate --api fails only when a pending changeset is lower than enforceable_minimum. --strict compares pending changesets with proposed_changeset_bump, including partial and medium-confidence evidence.
monochange changeset validate --api --format markdown
monochange changeset validate --api --strict --format markdown
The monochange_classify_changes MCP tool returns the same report under its report field. It accepts base, head, release, packages, detection_level, include_unchanged, and dependency_propagation inputs. Agents should use dependency_propagation: "public" for the same package coverage as the canonical CLI workflow.
CI requirements
Release comparison needs the relevant tags and history. A shallow checkout can make the latest release unavailable or produce an incomplete merge base. CI jobs that publish classification reports fetch the default branch and tags before running the command.
Creating or updating the pull request comment needs the pull-requests: write scope. With only issues: write the API returns 403 Resource not accessible by integration, and the action warns instead of publishing the comment; fork pull requests receive a read-only token, which produces the same warning. A classification job can always write the Markdown report to the job summary and expose it as an output even when the provider refuses the comment.
The change-classification GitHub Action runs the canonical JSON command, writes a job summary, and creates or updates one marker comment:
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Install Rust semantic analyzer
if: ${{ hashFiles('**/Cargo.toml') != '' }}
run: cargo install cargo-semver-checks --locked
- id: classify
uses: monochange/actions/change-classification@v0
with:
detection-level: semantic
dependency-propagation: public
Checking out the pull request head SHA keeps GitHub’s synthetic test-merge commit out of the source candidate. The action exposes json, markdown, recommendation, review-required, and summary outputs. Use recommendation for routing, but inspect the package decisions in json before writing changesets whenever review-required is true. The action accepts every report with classification schema_version 0.1 or newer. The evidence fields the action reads (packages, decisions, findings) are stable across those schema versions.
For complete TypeScript evidence, install the repository dependencies before this step. For configured Rust target cells, install those targets before the action. Keep the workflow on pull_request; the analyzer may execute changed build scripts and procedural macros. Comment creation is best-effort. Fork pull requests with read-only tokens still receive the action outputs and job summary.
The changeset-policy action can turn the same classification into an enforced gate. Pass from instead of changed-paths so monochange step affected-packages --verify --from <ref> derives changed packages from git history and compares each attached changeset bump with the classified change type:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: monochange/actions/changeset-policy@v0
with:
from: origin/main
comment-on-failure: true
A changeset that understates the classified change type fails the policy and names the package with the requested and recommended bumps. A higher bump only warns, and changesets with bump: none or an explicit target version skip the alignment check. from requires a full-history checkout and takes priority over changed-paths.