Turns a deno.lock diff into a short, prioritised list of the changes that
actually matter for supply-chain risk — including the one category
package-lock.json doesn't even have: a raw-URL import with no version, no
registry, and no audit trail at all.
1 identity changed (0 added, 0 removed, 1 updated, 0 moved registries, 0 remote added, 0 remote removed, 0 remote changed)
MAJOR jsr:@core/unknownutil@3.18.1 -> 4.0.1 major version jump
That's npm run measure, run against a real deno.lock from
hasundue/molt before and after it bumped
@core/unknownutil:
raw deno.lock diff: 9 changed lines across 529 lines of JSON
deno-lockfile-diff: 3 lines (1 signals)
denoland/deno_lockfile is the
official Rust crate that parses deno.lock — it's a library other tools
(and Deno itself) embed, not something you run in CI. There is no deno lockfile diff, no deno.lock equivalent of npm audit, and — checked
before writing a line of this — no existing diff/audit tool for deno.lock
either. Reviewing a deno.lock change today means reading raw JSON, and
deno.lock is worse to read raw than package-lock.json: three separate
namespaces (jsr, npm, remote) with independent integrity hashes, plus
specifiers indirection between what a deno.json range asked for and what
actually resolved.
The usual workarounds don't fix this:
- "Just read the diff." A
deno.lock'sjsr/npmmaps are sorted, so a single dependency's version bump doesn't touch the lines around it — except when it does, because the sort key includes the version, and changing it can shuffle the entry's position enough that a plain line diff reports far more churn than actually happened. In the fixture above, one real change was 9 diff lines; nothing about that tells you which 9, or which of them is the one that matters. - Treating
remoteimports as "just another dependency." They aren't. Ajsr:/npm:entry has a registry, a package name, and a version history behind it. Aremoteentry is a bare URL fetched once and pinned by hash — there is no registry to audit, no maintainer account to check, and often no version in the URL at all. A new one appearing in a lockfile is qualitatively riskier than a newnpm:dependency, and a raw JSON diff presents them identically. - Diffing
deno.jsoninstead.deno.json's^1.0.8didn't change; it just resolved to a different1.0.8(an integrity mismatch, this tool's loudest signal) or pulled in something new transitively. That only shows up in the lockfile, which is exactly the part nobody reads.
This tool doesn't replace deno_lockfile — it's built on the schema that
crate defines. It answers a different question than "can I parse this
file": "here are the N things in this deno.lock change a human should
actually look at, and here's why each one is there."
npm install -g deno-lockfile-diff
# or, without installing:
npx deno-lockfile-diff deno.lock --base origin/mainNode >= 20.6. No runtime dependencies. Works on any machine with Node — Deno itself is not required, since this tool only reads the lockfile Deno already wrote.
# Against a git ref (uses `git show <ref>:<path>`)
deno-lockfile-diff deno.lock --base origin/main
deno-lockfile-diff deno.lock # --base defaults to HEAD
# Against two files directly — works with no git repository at all,
# and neither file needs to be named deno.lock
deno-lockfile-diff old/deno.lock new/deno.lock
# CI
deno-lockfile-diff deno.lock --base origin/main --fail-on remote-added,integrity-mismatch
deno-lockfile-diff deno.lock --base origin/main --json > report.json--base accepts either a git ref (origin/main, a commit sha, HEAD~3) or,
if it names a file that exists on disk, that file is read directly instead —
so the same flag works whether or not you're in a git repository.
Never makes a network call. Everything it knows comes from the two lockfiles you give it — a CI tool that could reach the network to double- check something isn't a CI tool you can trust in a sandboxed runner, so this one is offline by construction, with nothing to configure to make it so.
| Flag | Default | What it does |
|---|---|---|
--base <ref-or-path> |
HEAD |
What to diff against. Git ref via git show, or a literal file path. Only used with one lockfile argument. |
--json |
off | Print the full result as JSON instead of the summary. |
--fail-on <signals> |
(none) | Comma-separated signal kinds that make the exit code 1. See below. |
--help |
Print usage. |
Signal kinds, for --fail-on, in the priority order they're printed:
remote-added, added, scripts-gained, major, registry-moved, integrity-mismatch, removed, remote-removed
By default the tool never fails your build — it prints what it found and
exits 0. You opt into gating CI, e.g.:
deno-lockfile-diff deno.lock --base origin/main \
--fail-on remote-added,integrity-mismatch,major| Signal | What it means | Why it's a signal |
|---|---|---|
REMOTE+ |
A raw-URL import (remote map) that wasn't in the base lockfile. |
The highest-risk category this tool watches. A jsr:/npm: dependency has a registry, a name, and a version; a remote entry is a bare URL — no version requirement, no registry to audit, sometimes not even a version in the URL. Printed with the host, since "a new import from raw.githubusercontent.com" and "a new import from jsr.io's own CDN" are different amounts of alarming. |
ADDED |
A jsr:/npm: package that wasn't in the base lockfile at all. |
Shown with the dependency that pulled it in (new transitive dep via jsr:@foo/bar@2.1.0, or new direct dependency) — you can't judge an addition without knowing who asked for it. |
SCRIPTS |
An npm package went from no lifecycle script to having one ("scripts": true in the lockfile). |
Arbitrary code execution on every install. Version 5 lockfiles only — see below. |
MAJOR |
A jsr: or npm: dependency's major version number went up. |
Where behavior is most likely to change on purpose, and where an attacker forcing a "just take the update" review past a tired reviewer does the most damage. |
MOVED |
A dependency with the same name is a pure removal from one registry (jsr/npm) and a pure addition to the other, in the same diff. |
A dependency moving registries is a real, if rare, supply-chain-relevant event — different maintainers, different publish process, different trust chain — and looks like an unrelated add+remove unless it's called out as one thing. |
INTEGRITY |
The exact same version (or, for remote, the exact same URL) now has a different integrity hash. |
The loudest signal this tool has: nothing about the declared version — or the URL — changed, but the bytes behind it did. For jsr:/npm: that's what a same-version repack (compromised maintainer account, registry incident) looks like. For remote, it's worse: a "pinned" raw URL is supposed to be immutable forever, so its hash changing at all means either the host silently rewrote content at a URL that was never really immutable, or something is watching for exactly this kind of import to tamper with. |
REMOVED |
A jsr:/npm: package or remote URL that was in the base lockfile is gone. |
Lowest priority, but still worth a glance — a removal that wasn't the point of the PR is a sign the diff includes more than you meant to review. |
Packages that changed but tripped none of the above (the overwhelming
majority in a real diff — patch/minor bumps with no integrity surprise) are
counted in the summary line but don't get a line of their own. That's the
whole point: the summary says "54 identities changed," the signal list says
which of those are worth your time — and in a diff that's mostly real
additions, as the freshframework/fresh example below is, that can
legitimately be most of them. This tool compresses noise; it doesn't
manufacture a big reduction out of a diff that's already mostly signal.
deno.lock is genuinely not a port of package-lock.json's shape. This was
confirmed against denoland/deno_lockfile's own source
(src/lib.rs, src/transforms.rs) and against real lockfiles pulled from
public repositories — not assumed from documentation, since a schema that's
"maintained by a Rust crate + a from-scratch parser here" is exactly the
kind of thing worth double-checking against the source that actually writes
it:
- Versions 4 and 5 (5 is current as of writing) share one on-disk shape:
top-level
jsr,npm,remote,workspace,specifiers, andredirectsmaps — no nesting.jsrandnpmentries carry their owndependenciesas an array of specifier strings ("jsr:@std/fmt@1","npm:left-pad@1.3.0", or, for an npm package depending on another npm package, a bare"left-pad"/"left-pad@1.3.0"). Version 5 additionally splitsoptionalDependenciesout ofdependenciesand addsos/cpu/tarball/deprecated/scripts/binto npm entries —scriptsis the field theSCRIPTSsignal reads, which is why that signal is version-5-only. - Version 3 nests
jsr,npm, andspecifiersone level deeper, under apackageskey, and an npm entry'sdependenciesthere is a{ name: id }object instead of an array.remoteandworkspaceare not nested — they sit at the top level in v3 too. - Version 2 predates
jsrentirely. In the wild it's remote-only (a bare{ url: hash }map); a nested pre-jsrnpmshape ({ specifiers, packages }, different again from both v3 and v4/v5) is technically possible per the crate's own test suite but wasn't found in any real lockfile checked, and is refused with a clear message rather than guessed at. - A
remoteentry has no name, no version, and no registry — just a URL key and an integrity hash. It's the one part of this schema that has no analogue inpackage-lock.jsonat all, and it's the reason this tool exists as something new rather than a port. specifiersis the indirection between a requested range ("jsr:@std/path@^1.0.8", as written indeno.jsonor a bare import) and the concrete version it resolved to — v3 keeps the resolved value fully schemed ("jsr:@std/path@1.0.8"); v4/v5 store just the bare version ("1.0.8"). This tool resolves through it to answer "why is this package here," the same way it resolves an npm package's owndependenciesarray.redirects(v4/v5) maps an unpinned/"always latest" URL spec to the concrete resolved URL. Not modeled — see "What it does not do."
No version tagged v1 exists in the sense of a "version" field at all:
the very first Deno lockfile format was a bare { url: hash } object with
no version key whatsoever, which is what versions 2 through 5 formally
supersede.
denoland/docland— a real, unmodified version 2, remote-only lockfile (178 URLs).hasundue/molt— two real commits'deno.lock(version 3), the@core/unknownutil3.18.1 -> 4.0.1 major bump used above.freshframework/fresh(formerlydenoland/fresh) — three real commits'deno.lock(version 5, workspace with multiple members), including the actual PR that replaced rawesm.sh/deno.landURL imports for@docsearch/jsandimagescriptwith propernpm:/jsr:dependencies — as real a "what doesREMOTE+/REMOVEDlook like in practice" example as a public repository offers. Seetest/fixturesfor all of these, andexamples/measure.tsfor both runs with real output.- No real, unmodified
deno.locktagged"version": "4"was found. Deno rewrites the"version"field to the newest it understands on every write, so a real repository's lockfile only stays at v4 between an upgrade and its nextdeno.lockwrite — by the time a project is old enough to be worth checking, it's already moved to v5. Version 4 support is instead verified against the small, authoritative v4 fixture embedded indenoland/deno_lockfile's own test suite (LOCKFILE_JSONinsrc/lib.rs) — real data from the crate that defines the format, even though it isn't a snapshot of somebody's actual project.
- It does not scan for known CVEs. No vulnerability database, no
network calls of any kind. Pair it with
deno info's own auditing or a registry-side scanner for "is this known bad?" — this tool only answers "did this lockfile change in a way worth a human's ten seconds?" - It does not fetch or inspect anything a URL points to. Everything it
knows comes from the two lockfiles you give it — never a
deno install, never a request to the host behind aremoteURL, never JSR's or npm's registry API. That's what "never makes a network call" means in practice. redirectsare not modeled. They resolve an unpinned URL spec to a concrete one, which is specifier-resolution bookkeeping, not code — no signal reads them.SCRIPTSis version-5-only, for the reason in the schema section above: it's the one field that literally doesn't exist in the v3/v4 on-disk shape, so there is nothing to read on those versions, rather than something being skipped.MOVED(registry-moved) is a same-name heuristic. It fires when a bare package name is a pure removal from one registry and a pure addition to the other in the same diff.jsr:names are scoped (@std/path) andnpm:names usually aren't, so this only catches a move where the name string is identical on both sides — a package genuinely moving registries under a different name looks like an unrelatedADDED+REMOVEDpair instead, the same edge a name-matching heuristic always has.- A version bump and a peer/hash-suffix–only difference can look alike. Packages are matched by (registry, name), then by version within that name; a name with exactly one leftover version on each side after exact matches is treated as a version bump of "the same" dependency. That is usually right and occasionally wrong for an unusual multi-version scenario (see lockfile-review's own README for the npm side of this same trade-off — the heuristic here is deliberately the same shape).
- The "why is this here" path is one hop, not the full chain. An
ADDEDsignal names the immediate requirer (via npm:foo@2.1.0), not the full chain back to a workspace member. Cheap to compute correctly and orients a reviewer; a full shortest-path search wasn't judged worth the added complexity. - Workspace members are flattened, not distinguished. Every member's
dependenciescounts as "declared directly by the project" forviareporting; which specific member declared it isn't tracked separately. - No monorepo package-boundary enforcement. This tool answers "did the dependency graph change," not "did member A start depending on member B in a way your workspace rules forbid."
Checked before starting: neither deno-lockfile-diff nor a handful of
adjacent names (deno-lockfile-audit, deno-lock-diff) existed on the npm
registry or on JSR as of this writing, so this tool ships under the
straightforwardly descriptive name.
Both tools answer the same question — "what in this lockfile diff actually
needs a human" — for two lockfile families that don't share a schema and
don't share risk shapes. lockfile-review reads npm/pnpm, where the
distinctive risk is a lifecycle install script slipped into a transitive
dependency. This tool reads deno.lock, where the distinctive risk is a raw
URL with no registry behind it at all — a category npm's lockfile format
doesn't have and this one's does. Same posture (offline, prioritised,
opt-in CI gating via --fail-on, never invents a signal it can't back with
a real field in the lockfile), same CLI shape, genuinely different schemas
and genuinely different top signal.
Tests are TypeScript run directly by Node's test runner — no build, no install:
node --test "test/*.test.ts" # full suite, needs node 24+ for type stripping
node examples/measure.ts
npm run build && npm run test:dist # what CI runs against node 20 and 22test/fixtures/real-v2-docland.json, real-v3-molt-{base,head}.json, and
real-v5-fresh-{a,b,c}.json are real, unmodified deno.lock files fetched
from public commits (see above) — not hand-written. Version-4 coverage and
every hand-constructable edge case (registry-moved, a fabricated
integrity clash, scripts-gained) are built from the official
denoland/deno_lockfile v4 test fixture or constructed inline in the test
file, the same split real-vs-inline lockfile-review uses.
MIT