How it works
Ripedeps is a planner, not a guess. Plain deterministic code turns your package.json and the public registry data into one consistent set of versions. It ranks the candidates, decides for every package and explains each decision.
The pipeline
Seven steps, each with a clear job. The same input and the same registry data at the same moment always give the same answer. There is no AI model, no randomness and no hidden configuration.
What is read
The roots are the entries of dependencies, devDependencies and optionalDependencies. Besides them Ripedeps reads engines.node and an optional ripedeps entry. Nothing else in your file influences the plan, and everything else is preserved byte for byte.
A package listed in several sections counts once, and a warning tells you. Ranges that are not plain semver are left alone and reported as skipped:

| Skip reason | Example in package.json |
|---|---|
| Not a plain semver range | "latest", "workspace:*", "github:acme/lib", "npm:other@1", "file:../lib" |
| Open-ended range | "*", ">=1.0.0" |
| Complex range | "^1.0.0 || ^2.0.0", ">=1 <3" |
| Prerelease | "2.0.0-rc.1" |
| Unknown package or failed lookup | a name the registry does not know |
Candidate versions
For every root, Ripedeps first fixes the baseline: what your project effectively uses today. Then it builds the list of candidates above it. A version is a candidate when it is:
- a stable release that is not deprecated,
- within the step size you allow (see step size),
- old enough (see the age gate),
- compatible with your
engines.node: the lowest Node version you support must satisfy the candidate's ownengines.node.
"engines": { "node": ">=20" }With this field, a release that requires >=22 is not offered, and the explanation says so:
`pkg@2.1.0` requires node >=22, the project's engines.node is >=20The baseline always stays among the candidates, so a package is never moved backwards.
The age gate
The newest release is the least reviewed one. The age gate keeps versions younger than minAgeDays out of the candidate list, so a hijacked or broken release has time to be noticed and pulled before it reaches your file.
This is a real answer from the service, with a gate of 7 days and major upgrades allowed:
next 15.0.0 -> 16.4.2 (major step, 8 days old, age gate 7); newest version that qualifies; not taken: next@16.4.3 is 0 days old, the age gate is 7 daysThe age gate lowers the chance of installing a compromised release. It is not a vulnerability scanner and does not know about CVEs. If you must take a fix today, run Ripedeps with a gate of 0 days, or with a focus on that one package.
Step size
maxBump limits how far a version may move away from the one in your file. The step is measured from the lower bound of your range, and your range prefix (^, ~ or none) is kept.
maxBump | Allowed from ^1.2.3 | Example result |
|---|---|---|
patch | same major and minor | ^1.2.9 |
minor (default) | same major | ^1.9.0 |
major | anything | ^3.0.1 |
Focus
Without a focus, every package can move. With one, only the packages you name move, plus the packages that the newest versions of those packages point to, one level forward. Everything else is fixed. A name without a slash and without @also matches the scope, so trpc means @trpc/*.
{ "packageJson": "…", "focus": ["zod"], "minAgeDays": 7 }The real result for the sample file:
outside-focus axios
outside-focus express
outside-focus lodash
upgrade zod
outside-focus @types/node
outside-focus eslint
outside-focus prettier
outside-focus typescriptThe typical use: one vulnerable package
An advisory names a package in your file. Instead of a full upgrade, focus on it, and the diff shrinks to what is needed. If you need the fix right away, add a gate of 0 days for this run. Ripedeps does not know which packages are vulnerable, so naming them is your part.
@@ -2,7 +2,7 @@ "name": "my-app",
"version": "1.0.0",
"dependencies": {
- "axios": "^1.6.0",+ "axios": "^1.20.0", "express": "^4.18.2",
"lodash": "^4.17.20",
"zod": "^3.22.0"
Constraints between packages
Upgrades that break each other are the usual reason an update goes wrong. Ripedeps looks at two kinds of relation, but only between packages that are in your file:
- Peer: a candidate asks for a peer range, for example
react ^19. - Aligned dependency: a candidate depends on another root of yours with a range, so you do not end up with the same package in two versions.
A constraint holds when the chosen version of the other package satisfies the range. When it cannot hold, you get a warning with a concrete hint, such as the major upgrade that would resolve it.

With major upgrades allowed, next and react move together, as a real result shows:
@@ -1,8 +1,8 @@ {
"name": "web",
"dependencies": {
- "next": "^15.0.0",- "react": "^18.3.1",- "react-dom": "^18.3.1"+ "next": "^16.4.2",+ "react": "^19.3.1",+ "react-dom": "^19.3.1" }
}
Ranking
Several combinations of versions are usually possible. Ripedeps does not pick one by feel: it ranks them in a fixed order of priorities, and the best one wins:
- fewest peer violations,
- then fewest misaligned dependencies,
- then the least lag behind the newest candidate.
Alignment always beats freshness. The lag of a package counts a major gap as 10000, a minor gap as 100 and a patch difference as 1, so the levels cannot mix. When two combinations lag equally, the one with the higher versions wins.
The ranking is internal: it decides which combination is chosen. What you see in the response is the result, thedecision and the explanation for each package.
The solver
Choosing one version per package so that all constraints hold is a small combinatorial problem. Ripedeps solves it exactly, and the best answer is the first one in the ranking.
The packages are split into independent groups that are solved separately, each with a search budget. If the budget runs out, the best solution found so far is returned and meta.exact saysfalse.

@types and engines.node
Existing @types/* entries follow the package they describe, matched by name (@types/foo__barbelongs to @foo/bar): the highest types version with the same major and minor as the chosen runtime version, falling back to the same major.
@types/nodefollows yourengines.node: it is aligned to the lowest major you support, even across a major, and the explanation says so.- An
@typesentry without a runtime counterpart only gets minor and patch updates. - Ripedeps never adds or removes
@typesentries.
@types/node 20.10.0 -> 20.19.43 (minor step, 135 days old, age gate 7); newest version that qualifies; not taken: @types/node follows engines.node (lowest supported major 20)Decisions
The result of the ranking is one decision per package, the status in the response:
- upgrade: the range moves up to the chosen version.
- held: a newer version exists but is not taken: too young, too big a step, a newer Node needed, or it would break a peer or an aligned dependency.
- current: already the newest version that qualifies.
- outside-focus: not part of the focus, left as it is.
- skipped: not processable, and
skipReasonsays why.
The response also gives the best candidate (bestVersion) next to the chosen one. When they differ, the decision names what stood in the way. With a step limit of patch, a range that already admits a newer minor, such as ^1.2.0, moves only by the allowed step, and the explanation says so.
Explanations
Every decision is explained. Every package gets one sentence: what happened and why. For an upgrade it names the step, the age of the chosen version and whether it is the newest that qualifies. For a package that stays behind it names the blocked version and the first reason. The sentences are built from templates, never by a model, so they are stable and testable.
They are part of every API response and every MCP result, so an AI agent can relay them to you as the rationale.

axios 1.6.0 -> 1.20.0 (minor step, 59 days old, age gate 7); newest version that qualifies
express 4.18.2 -> 4.22.3 (minor step, 40 days old, age gate 7); newest version that qualifies; not taken: express@5.3.0 is a major step from 4.18.2, above the allowed minor level
eslint held at 9.10.0; not taken: eslint@10.13.0 is a major step from 9.10.0, above the allowed minor level
@types/node 20.10.0 -> 20.19.43 (minor step, 135 days old, age gate 7); newest version that qualifies; not taken: @types/node follows engines.node (lowest supported major 20)The reason kinds in the structured data are age-gate, engines, level-limit, peer,dependency and pulled-in.
The output
A change raises the lower bound of the range and keeps your prefix: ^18.2.0 becomes ^18.3.1,1.2.3 becomes 1.4.0. Ripedeps replaces the text in place, so your indentation and key order stay as they are. The diff has a/package.json and b/package.json headers, so patch -p1applies it.
Optionally, the settings used are written to your file as a ripedeps entry, so later runs start from the same limits. The notes list in the response tells you when that happened.
@@ -2,18 +2,22 @@ "name": "my-app",
"version": "1.0.0",
"dependencies": {
- "axios": "^1.6.0",- "express": "^4.18.2",- "lodash": "^4.17.20",- "zod": "^3.22.0"+ "axios": "^1.20.0",+ "express": "^4.22.3",+ "lodash": "^4.18.1",+ "zod": "^3.25.76" },
"devDependencies": {
- "@types/node": "^20.10.0",+ "@types/node": "^20.19.43", "eslint": "^9.10.0",
- "prettier": "^3.1.0",- "typescript": "^5.3.0"+ "prettier": "^3.9.10",+ "typescript": "^5.9.3" },
"engines": {
"node": ">=20"
+ },+ "ripedeps": {+ "minAgeDays": 7,+ "maxBump": "minor" }
}
Limits and non-goals
- The only input is package.json. Lockfiles are not read or written.
- No vulnerability database is consulted. Ripedeps reduces exposure to brand-new releases; it does not tell you what is vulnerable yet.
- Only stable versions are proposed; prerelease versions are ignored for security reasons.
- Ranges are never tightened or loosened. The prefix you chose stays.
- Request limits: file up to 256 KB, up to 300 dependencies, up to 50 focus entries.
- Package data comes from the public npm registry JSON API and is cached for a short time.
Ready to try it? Open the tool, or see the API, the MCP serverand the command line.
