13 minutes read
Upgrade check
offing upgrade-check --to <runtime@version> [path] [--json] answers, offline and from files already in the repository, whether a project can move to a target runtime. It lists each blocker, warning, and unknown with the exact lockfile or manifest entry behind it. Agents get the same answer through the check_upgrade MCP tool.
offing upgrade-check --to php@8.4 .
offing upgrade-check --to nodejs@22 . --json
The text output starts with the verdict, then one line per issue:
php@8.4: blocked (1 blockers, 0 warnings, 0 unknown)
composer.lock:9 acme/http-client@2.1.0 acme/http-client@2.1.0 requires php ^7.4, which excludes PHP 8.4. [lockfile: require.php: ^7.4]
The JSON output is an offing.upgrade.v1 document that conforms to schemas/upgrade.v1.schema.json. Exit codes: 1 when the verdict is blocked, 0 for ready and unknown, 2 for bad input.
An analyzer only runs when the repository has the files it reads. Each issue names its analyzer, its level (blocker, warning, or unknown), the affected subject, the path and line, and the evidence value exactly as written.
blocker: the project will not install or run on the target.warning: it works but is deprecated on the target.unknown: the evidence needed to judge it is missing.
What it cannot know
- Native extensions and build-time requirements. A package may fail to build on the target for reasons its metadata does not state.
- Packages without declared support ranges. If a package never declares which runtime it supports, the analyzer reports
unknownor nothing, neverreadyby assumption. - Code paths outside the source. Dynamic loading,
eval, generated code, and behavior that only appears at runtime are not visible here. - Your CI images and operating system. The runtime inside a container image or on a runner is not part of this check.
- Unresolved dependencies. When a lockfile does not record a support range for a package, no analyzer can judge that package.
- Anything at install time. Offing never runs a package manager, Compiler, or test suite, and no scan uses the network.
An unknown verdict means Offing found no evidence for that runtime in this repository. It is not a statement that the upgrade is safe.
The worked commands below read the examples under examples/upgrade/. Their manifests and lockfiles are committed with scanner-neutral names (decision 0027), so dist/tools/examples.js copies an example to a temporary directory, restores the real names there, and runs the command inside it. Run npm run build first; nothing is written to the repository.
Composer
The Composer analyzer (composer-lock) runs for php targets when composer.lock exists.
Files and fields
composer.lockpackages[*].require.php— ablocker.composer.lockpackages-dev[*].require.php— awarning.composer.locktop-levelplatform.php— ablocker. This is the platform requirement the lockfile was resolved against.composer.locktop-levelplatform-overrides.php— ablocker.composer.jsonrequire.php— ablocker, with evidence kindmanifest.
Each issue carries the constraint exactly as written (for example ^7.4 or >=8.1 <8.5) and the line of the field it came from. Package subjects are <name>@<version>; platform subjects are platform.php and platform-overrides.php; the project's own constraint uses the subject composer.json.
Version syntax
The matcher understands Composer's constraint syntax:
- caret ranges:
^1.2.3,^0.3,^0.0.3 - tilde ranges:
~1.2,~8.4.0 - comparisons:
>=,>,<=,<,!=,<>,==,= - wildcards:
*,1.*,1.0.x - hyphen ranges:
1.0 - 2.0 - OR:
||and| - AND: space- or comma-joined terms, such as
>=8.1 <8.5or>=8.1, <8.5 - pre-release suffixes:
8.4.0RC1,8.4.0-beta1
A target written as a release line (8.4) is tested as both 8.4.0 and 8.4.99, so an upper bound written as <8.5 is not mistaken for a blocker. A target written with three or more parts (8.4.0) is tested as written.
What it reports as unknown
- A
require.phpvalue that is not a constraint this matcher understands, such as a truncated OR (^8.4 ||) or a value containing an unevaluated${...}expression.
A missing require.php, a * constraint, or a constraint that includes the target produces no issue. Composer itself is never run, and the lockfile is parsed as untrusted JSON.
Go modules and vendored requirements
Analyzer id go-modules; runtime go. It applies when the repository root has a go.mod.
Files and fields it reads
go.mod: thegodirective and its line.go.work: thegodirective and its line, when the workspace file is in the repository root.vendor/modules.txt: every# <module> <version>header and the## explicit; go <version>(or## go <version>) annotation that follows it. Package lines are ignored.- The bundled
registry/go.jsonrules forpkg:generic/golang.
Version syntax it understands
Release numbers as Go writes them: 1.23, 1.23.0, and a leading go (go1.23.0) are accepted and compared part by part. A target that is not a release number, such as nightly, is reported as unknown; it is never compared.
What it reports
blocker,manifest: the target is below thegodirective ofgo.modorgo.work. Since Go 1.21 thegoline is a required minimum, not advice: a Go toolchain refuses a module that declares a newer Go version. The evidence is the directive value as written, at its line.blocker,lockfile: the target is below thegoversiongo mod vendorrecorded for a dependency invendor/modules.txt. The subject is<module>@<version>and the evidence is the annotation line exactly as written.warning,rule: the target itself matches a bundled lifecycle rule for Go, so it is deprecated or end-of-support. The evidence is the rule id.unknown:vendor/modules.txtis absent, or records nogoversion. Dependencies' Go requirements are not in the repository without vendoring, so Offing reportsunknownfor them instead of assuming they are compatible. It does not rungo mod tidyor any Go command to find out.
Sources
- Go Modules Reference:
godirective: thegoline is a minimum required Go version since Go 1.21, and must be greater than or equal to thegoline of every dependency. - Go Modules Reference:
go.workfiles: the workspacegodirective names the toolchain version the workspace is written for. - Go Modules Reference: vendoring:
go mod vendorwritesvendor/modules.txtand records thegoversion from each dependency'sgo.modsince Go 1.17. - Go toolchains: how the
godirective selects a toolchain.
Dart and Flutter
Applies when pubspec.lock exists. Runtimes: dart and flutter.
| File | Field | What it means |
|---|---|---|
pubspec.lock |
sdks.dart |
The Dart SDK bound shared by every resolved package: the intersection of their individual bounds. |
pubspec.lock |
sdks.flutter |
The Flutter SDK bound shared by every resolved package. |
pubspec.yaml |
environment.sdk |
The Dart SDK bound the project itself declares. |
pubspec.yaml |
environment.flutter |
The Flutter SDK bound the project itself declares. |
A dart@x target is checked against the Dart bounds and a flutter@x target against the Flutter bounds; the other runtime's bounds are not consulted. A target outside a pubspec.lock bound is a blocker with subject resolved package set and kind: "lockfile": the resolved set would not install. A target outside a pubspec.yaml bound is a blocker with subject environment.sdk or environment.flutter and kind: "manifest". When neither file records a bound for the target runtime, one unknown issue is reported; it names the absent sdks key as evidence.
A target with fewer than three components is checked as x.0.0 or x.y.0, so dart@3.5 is checked as 3.5.0.
Version syntax: a constraint is any or one or more space-joined terms, each ^, >=, <=, >, <, or a bare version followed by x.y.z and an optional pre-release or build suffix. Caret follows pub's rules: ^1.2.3 means >=1.2.3 <2.0.0, ^0.2.3 means >=0.2.3 <0.3.0, and ^0.0.3 means >=0.0.3 <0.0.4. A bound the parser cannot read (a template, || range, or missing components) is treated as missing evidence, so the answer is unknown rather than ready.
The parsers are the ones the Dart detector uses: src/detectors/dart/parse-pubspec-lock.ts and parse-pubspec.ts.
node dist/tools/examples.js upgrade/dart-sdk upgrade-check --to dart@3.5
blocked: dart@3.5 (1 blocker, 0 warnings, 0 unknown)
pubspec.lock:12 resolved package set The resolved package set requires Dart >=3.6.0 <4.0.0, which excludes 3.5.0. [lockfile >=3.6.0 <4.0.0]
npm lockfile
Runtime: nodejs. Applies when package-lock.json or npm-shrinkwrap.json exists with lockfileVersion 2 or 3. A version 1 lockfile is reported as one unknown issue because it does not record package engines.
- Files read:
package-lock.jsonandnpm-shrinkwrap.json. Each issue names the file it came from. - Fields read:
lockfileVersion; for eachpackages["node_modules/..."]entry,version,engines.node,dev, anddevOptional. - Version syntax: semver ranges in
engines.node, tested withsemver.satisfiesandincludePrerelease: false. A bare target major such as22is tested as22.0.0; the bundled lifecycle data does not list per-minor Node.js releases. - Levels:
blockerfor a production dependency whose range excludes the target;warningfor an entry markeddev: trueordevOptional: true;unknownfor anengines.nodevalue that is not a valid semver range. - Evidence:
engines.node: <range as written>, at the line of theengineskey. - Deduplication: identical name, version, and range across nested paths keep the first path.
node dist/tools/examples.js upgrade/npm-lock upgrade-check --to nodejs@22
nodejs@22: blocked (1 blockers, 0 warnings, 0 unknown)
package-lock.json:16 sharp@0.30.7 sharp@0.30.7 declares engines.node ">=14.0.0 <17", which excludes Node.js 22. [lockfile: engines.node: >=14.0.0 <17]
pnpm lockfile
Runtime: nodejs. Applies when pnpm-lock.yaml exists with lockfileVersion 6.x or 9.x.
- Files read:
pnpm-lock.yaml. In 6.x,packagesentries carryengines,dev, anddevOptional. In 9.x,engineslives underpackages, while the dev flag is resolved fromimportersandsnapshots. - Fields read:
lockfileVersion; for eachpackagesentry, the key (<name>@<version>, with an optional leading/and an optional peer suffix) andengines.node; in 6.x,devanddevOptional; in 9.x, thedependencies,optionalDependencies, anddevDependenciesof everyimportersentry, and thedependenciesandoptionalDependenciesof everysnapshotsentry. - Version syntax: semver ranges in
engines.node, tested withsemver.satisfiesandincludePrerelease: false. A bare target major such as22is tested as22.0.0; the bundled lifecycle data does not list per-minor Node.js releases. - Levels:
blockerfor a production dependency whose range excludes the target;warningfor a dev dependency (dev: trueordevOptional: truein 6.x, or reachable only from a dev dependency in 9.x);unknownfor anengines.nodevalue that is not a valid semver range. - Evidence:
engines.node: <range as written>, at the line of theengineskey. - Deduplication: identical name, version, and range across peer variants keep the first line.
Poetry (poetry.lock)
Applies when poetry.lock exists. Runtime: python.
Files and fields it reads:
- Every
[[package]]entry inpoetry.lock:name,version,python-versions, andcategory(Poetry 1.1) orgroups(later Poetry versions). Both group forms are handled. - The project's own
[tool.poetry.dependencies]pythonconstraint inpyproject.toml, as a string or as a table withversion.
Version syntax it understands: PEP 440 specifiers >=, <=, <, >, == (including .*), !=, and ~=, joined with commas for AND; Poetry's ^ (caret), ~ (tilde), and || (OR) forms; and *, which matches every version. Comparisons follow PEP 440 ordering, including pre-releases, post-releases, development releases, and epochs.
What it reports:
blockerfor a main-group package whosepython-versionsexcludes the target release, and for a project-levelpythonconstraint that excludes it.warningfor a package in thedevor any other non-main group whosepython-versionsexcludes the target.unknownfor apython-versionsvalue or project constraint the matcher cannot parse.
Evidence is the value as written: python-versions: <value> with kind: "lockfile" at that field's line, or python: <value> with kind: "manifest" at the python line in pyproject.toml. The subject is <name>@<version>, or python for the project constraint.
Example:
blocked
poetry.lock:12 requests@2.28.0 requests@2.28.0 declares python-versions ">=3.7,<3.11", which excludes Python 3.13. [lockfile: python-versions: >=3.7,<3.11]
pyproject.toml:9 python The project's [tool.poetry.dependencies] python constraint ">=3.8,<3.12" excludes Python 3.13. [manifest: python: >=3.8,<3.12]
uv and PDM (uv.lock, pdm.lock)
Applies when uv.lock or pdm.lock exists. Runtime: python. Each issue names the file it came from.
Files and fields it reads:
- The top-level
requires-pythoninuv.lock: the project's own range. - Every
[[package]]entry inuv.lock:name,version,resolution-markers,sdist, and the python and ABI tags of each wheel name. - The
[metadata]requires_pythoninpdm.lock, when present. - Every
[[package]]entry inpdm.lock:name,version,requires_python, andgroups.
Version syntax it understands: PEP 440 specifiers >=, <=, <, >, == (including .*), !=, and ~=, joined with commas for AND, plus || alternatives; the same comparisons in resolution-markers clauses on python_version and python_full_version; and wheel python tags that name a minor version (cp39, py38, cp39-abi3). A wheel tagged abi3 allows every release at or above its tag. Markers that mention other variables (sys_platform, platform_system) do not bound Python.
What it reports:
blockerwhen the project's own range (top-levelrequires-pythoninuv.lock,[metadata]requires_pythoninpdm.lock) excludes the target, when a uv package has no resolution that admits the target, and when adefault-group PDM package'srequires_pythonexcludes it.warningfor a PDM package in thedevor any other non-defaultgroup whoserequires_pythonexcludes the target.uv.lockhas no groups.unknownfor a range, marker, or wheel tag the matcher cannot parse, and for an invalid TOML file.
A uv package is reported only when every one of its resolutions states a Python bound and every bound excludes the target. Wheel tags are read only when the entry has no sdist, and a package with no bound at all is reported as nothing: wheel availability is never treated as evidence.
Evidence is the value as written: requires-python: <range> or requires_python: <range> for the project range, resolution-markers: <marker> for an excluding resolution, wheels: <python tag>-<ABI tag>, ... for version-specific wheels, and requires_python: <range> for a PDM package, all with kind: "lockfile" at that field's line. The subject is <name>@<version>, or python for the project range.
Example:
python@3.13: blocked (1 blockers, 1 warnings, 0 unknown)
uv.lock:7 twisted@24.3.0 twisted@24.3.0 resolves only for Python versions matching "python_full_version < '3.13'", which excludes Python 3.13. [lockfile: resolution-markers: python_full_version < '3.13']
pdm.lock:4 black@24.3.0 black@24.3.0 is in the dev group and its requires_python ">=3.9,<3.13" excludes Python 3.13. [lockfile: requires_python: >=3.9,<3.13]
Python standard library
Applies when the repository has at least one Python source file.
- Reads: every
.pyfile the usage analysis reads (the same traversal as a scan:.git, virtual environments, build output, and dependency directories are skipped, as are files over 1 MB) and the module-import and symbol rules inregistry/python.jsonwhose package URL ispkg:generic/python. - Version syntax: PEP 440 ranges from the rule's
vers, compared with the target release, as inpython@3.13. - Reports: every
import moduleandfrom module import nameof a module removed by the target, and every use of a removed or deprecated function that the symbol rules describe. Aremovedrule whose range includes the target is a blocker; adeprecatedrule whose range includes the target is a warning. Each issue is one usage location; the subject is the module or symbol and the evidence is the rule id. unknown: this analyzer reports none. A rule whose range does not include the target, or a target release the PEP 440 comparison cannot read, produces no issue.
Node.js core APIs
Analyzer id node-apis; runtime nodejs. Applies when the repository's JavaScript or TypeScript source uses an API described by a nodejs.symbol.* rule, found with the same parser-backed usage analysis the npm detector uses.
- Reads: every JavaScript and TypeScript source file the usage analysis reads (the same traversal as a scan:
.git, dependency and build directories, and files over 1 MB are skipped) and thenodejs.symbol.*rules inregistry/nodejs.jsonwhose package URL ispkg:generic/nodejs. - Version syntax: semver ranges from the rule's
vers, compared with the target release, as innodejs@24. A rule whose range does not include the target produces no issue. - Reports: every use of an API the target removed or deprecated. A
removedrule whose range includes the target is ablocker; adeprecatedrule whose range includes the target is awarning. Each issue is one usage location; the subject is the API name and the evidence is the rule id. - Levels come from rule data only: the
eventandversfields decide. Summaries and titles are never parsed for versions, so a rule that mentions a runtime-deprecation version in its text counts as a warning from that version only when the range is encoded invers. unknown: this analyzer reports none. A target release the semver comparison cannot read, or a source file without a matched symbol, produces no issue.
The removal versions in the registry are sourced to the official Node.js deprecations page.
Target lifecycle
Analyzer id target-lifecycle; runtimes nodejs, python, go, java, dotnet, ruby, php, dart. Applies when the repository pins one of these runtimes in a file this analyzer can read; a repository without any such pin is not judged by it.
The target itself
The target release is looked up with lookupProduct against the bundled registry, and only the runtime's own lifecycle rules (nodejs.runtime.*, python.runtime.*, go.runtime.*, java.jdk.*, dotnet.runtime.*, ruby.runtime.*, php.runtime.*, dart.sdk.*) decide; module or platform rules that share the product never flag the release itself.
- A target that is end-of-life, removed, or out of support — or whose dated end has already passed — is a
blocker: "the target itself is end-of-life; choose". The suggested line is the rule's replacementfrom the bundled data; nothing is ever fetched. - A target whose dated end falls within 180 days of the day checked is a
warningwith the deadline and the same suggestion. - The evidence is the rule id; the issue is located at the repository's first pin of that runtime, so the report shows where the choice has to be made.
Files that must change
Every pin of the target runtime that differs from the target is listed as a warning with subject pin to update, at its exact path and line; a pin that already names the target is not listed.
- Reads:
.nvmrc,.node-version,.python-version(version line, moving names such aslts/*are skipped);.ruby-version,.php-version,.java-version, andglobal.jsonthrough the existing runtime-pin parsers; every.tool-versionsline of a known tool (apath:orref:entry names no version and is skipped);FROMimages of runtime repositories in anyDockerfile,Containerfile, or named variant (depth- and count-bounded walk, symlinks and build directories skipped); andactions/setup-{node,python,ruby,java,dotnet,go,dart}version inputs in.github/workflows/*.yml|yaml, scalar or list, parsed as data and never evaluated. - Version syntax:
semver.coercenormalizes what a pin stands for (20,v20.11,18.19.0,node:20-alpine,8.0.x,temurin-22); the comparison respects the precision the target was written with, so the target24matches any24.xpin while the target24.12requires that release line. unknown: this analyzer reports none. A runtime with no pins in the repository simply does not apply, and a target the bundled data does not flag produces only the pin list.
offing upgrade-check --to nodejs@18 tests/fixtures/upgrade/target-lifecycle/example
The example repository pins Node.js 20 in .nvmrc, a Dockerfile, and .tool-versions, so the end-of-life target is blocked with the suggestion and all three pins are listed. See tests/fixtures/upgrade/target-lifecycle/example.