Install your package like a stranger would.

packproof packs your project, installs the tarball into an empty throwaway clean room, then actually imports every entry point and runs every bin. If it's broken for the people who npm install it, you find out in ten seconds — not from an issue titled "doesn't work".

npm version MIT license zero dependencies
$ npx packproof
my-package@2.3.0 — 3 files packed
   shipped files — 3 files, no credentials or cruft
   npm install <tarball>
  ✗ import "my-package" [undeclared-dependency]
      "kleur" is required at runtime but is only in devDependencies,
      where it works for you and for nobody else. Move it to dependencies.
  ✗ bin "my-cli" [bin-not-executable]
      the shell could not execute my-cli. A missing
      "#!/usr/bin/env node" shebang is the usual cause.

packproof: 2 problems your users would hit.

Why your test suite can't catch this

Your CI runs inside your source tree. In that tree every devDependency is installed, every file exists whether or not it's in "files", and your bin runs as node ./bin/cli.js rather than as a program on $PATH. Each of those three differences hides a real bug that ships:

The bugWhy your tree hides it
devDependency imported at runtimeit's installed locally, so the import resolves — and fails for everyone else
file missing from "files"it's on disk, so the read succeeds; it was never in the tarball
bin with no shebangyou never exec it as a program, so the shell never tries to parse it

npm pack and npm install report no problem at all for a package with all three. They aren't lying — packing and installing genuinely succeed. Nothing has tried to use it.

Or prove a version that's already published

Everything above asks "would this tree survive publishing?". --registry asks it afterwards, about any package on the registry — yours or a stranger's. Nothing is packed: packproof resolves the spec, downloads the exact dist.tarball the registry serves, verifies it against the published dist.integrity, and clean-rooms those bytes.

$ npx packproof --registry chalk@4.1.2 --lazy
chalk@4.1.2 — 7 files, published to registry.npmjs.org
   fetch chalk@4.1.2 from registry — 11.3 kB, sha512 integrity verified
   shipped files — 7 files, no credentials or cruft
   npm install <tarball>
   import "chalk"
   require("chalk")
   --lazy deep probe — 3 shipped source files scanned, every import declared

packproof: this package works when installed.
# this package, at its latest published tag
packproof --registry

# an exact version, or any dist-tag
packproof --registry my-package@2.3.0
packproof --registry my-package@next
Why you'd want it
audit a releasea publish gate proves the tree you had; this proves the artifact users download. Those differ more often than you'd like — a stale files list, a republish, CI packing a different commit
vet a dependencydoes the package you're about to adopt even import cleanly with nothing else installed?
reproduce a bug reportagainst the version the reporter actually has, not against main

An exact version or a dist-tag is required — ranges like ^1.2.0 are refused rather than resolved, because the answer is about one specific set of bytes and shouldn't change under you. If the download doesn't match the registry's hash you get integrity-mismatch and nothing is installed. --registry-url points at a private or mirrored registry.

A monorepo is several promises, not one

Each package you publish out of a monorepo is a separate promise to a stranger, so --workspaces keeps them separate: it reads the workspaces globs from your root package.json (a pnpm-workspace.yaml works too) and packs and clean-rooms each package independently — a fresh empty project per package, never a shared one. A shared clean room would let one package satisfy another's missing dependency, which is exactly the bug packproof exists to find.

$ npx packproof --workspaces
acme — 3 packages from package.json, one clean room each

@acme/core@2.1.0 packages/core — 14 files packed
   npm install <tarball>
   import "@acme/core"

@acme/cli@2.1.0 packages/cli — 9 files packed
  ✗ npm install <tarball> [workspace-sibling-dependency]
      depends on @acme/core@workspace:*, another package in this
      workspace. A stranger installs from the registry, not from your
      checkout, so this tarball cannot be proved on its own…

- @acme/docs apps/docs — skipped, private

packproof: 1 problem your users would hit, in 1 of 2 packages (@acme/cli).
# the whole workspace
packproof --workspaces

# one package, by name or by directory
packproof --workspace @acme/core
packproof --workspace packages/core

# one JUnit report, one testsuite per package
packproof --workspaces --format=junit --out packproof.xml

Private packages are skipped — nobody installs them — unless you ask with --include-private. Each package keeps its own path prefix, so --format=github annotates packages/core/src/a.js and not src/a.js, and --format=junit emits one <testsuite> per package inside a single <testsuites>. Exit code 1 if any package has a problem your users would hit.

A sibling dependency
workspace-sibling-dependencya clean room installs from the registry, so a tarball that depends on another package in your workspace can only be proved once that sibling is published at a version the range resolves to. packproof reports this as its own kind — not undeclared-dependency, because the dependency is declared; it just isn't reachable from outside your checkout
workspace:* is the sharp casenpm pack leaves a workspace: range in the tarball verbatim, so publishing it ships a version nobody can install. packproof says so rather than linking a sibling in and calling it a pass
if the sibling is publishedthe install resolves it from the registry, the package passes, and packproof notes where the dependency came from

Once packproof knows about sibling dependencies, it knows enough to say what order to publish them in. With more than one package in the workspace, --workspaces prints a release order for free: a topological sort over the packages' own dependencies on each other, leaves first.

release order — 3 steps, leaves first: publish each step before the next
  1. @acme/core
  2. @acme/utils   — needs @acme/core
  3. @acme/cli     — needs @acme/core, @acme/utils

If two packages depend on each other — directly, or through a longer chain — there is no order that works, and packproof says that instead of guessing:

release order — none exists [workspace-dependency-cycle]
  @acme/a → @acme/b → @acme/a
  these packages depend on each other, so none of them can be published first at a
  version the others' ranges resolve to. Break the cycle — or publish them together,
  by hand, once.

If nothing depends on anything else, packproof says any order works instead of printing a trivial one. Private packages are left out of the order unless --include-private is given, and the order rides along in --json as releaseOrder (waves, steps, cycles) for scripting a real publish loop. Releasing a monorepo honestly means publishing the leaves first, then proving the packages above them with --registry.

What's in the tarball that shouldn't be

npm publish ships whatever your files field and .npmignore leave in, and it never asks twice. A .npmrc with a live registry token, a .env, an SSH key, .aws/credentials — all of them install perfectly, which is why nothing catches them. packproof already holds the tarball's own file list, so every run reads it:

$ npx packproof
my-app@1.4.0 — 41 files packed
  ✗ shipped files [shipped-secret]
      this tarball contains files that normally hold credentials. Check whether they are
      real, and keep them out with "files" in package.json or .npmignore. A published
      version cannot be unpublished after 72 hours, and anything already published
      should be treated as leaked.
      .npmrc — an npm config file, which is where npm keeps registry auth tokens
      .env.production — an environment file, which is where secrets normally live
      also accidental: .DS_Store, coverage/lcov.info

Cruft is not a failure. A .DS_Store, a shipped node_modules, coverage output, a .tgz inside the .tgz — those cost your users bytes but leak nothing, so they ride along as a note on a passing check — unless you ask otherwise with --strict:

   shipped files — 41 files; 2 look accidental — .DS_Store, coverage/lcov.info

This check is paths, not contents. packproof says "this is the kind of file that holds a credential" — it never opens the file to guess whether a secret is inside, so it can't be wrong about what it claims to have seen, and an empty .npmrc is still reported. A token hard-coded into dist/index.js is out of scope, and so is anything you didn't ship: the list it reads is the tarball's. Test certificates are left alone — a plain .pem passes, private.pem and id_rsa do not — and .env.example is understood to be a template. It runs before the install, so a leaked credential is named even when nothing installs.

What stopped shipping

Every other check here asks whether what you shipped works. This one asks whether you shipped everything you used to — because that failure is invisible to execution. A template, a .wasm blob, a locale JSON, a .d.ts, a whole dist/: anything nothing loads at import time can drop out of the tarball when someone edits files or adds an .npmignore rule, and every probe still passes. The last release is the only honest baseline for what a package is supposed to contain, and it's one request away.

packproof --diff                              # vs your latest published version
packproof --diff 1.4.2                        # vs an exact earlier version
packproof --registry pkg@2.0.0 --diff 1.9.0   # two published releases, compared
$ npx packproof --diff
my-pkg@2.0.0 — 31 files packed
   npm install <tarball>
   import "my-pkg"
   shipped files — 31 files, no credentials or cruft
  ✗ shipped files vs my-pkg@1.9.0 [dropped-entry-point]
      this release stops shipping something the last one resolved imports to. Nothing
      installed here caught it because nothing loads these paths — an import of them
      just stops working. Check "files" in package.json and your .npmignore, or say
      plainly in the changelog that this is a breaking change.
      43 files → 31
      1 path the published my-pkg@1.9.0 package.json pointed at is not in this tarball: dist/locales/index.js
      also gone: dist/locales/de.json, dist/locales/fr.json and 9 more

The line between failing and merely reporting is the same one the leaked-files check draws: a fact, never a guess about what you meant.

What's goneWhat packproof does
a path the published package.json pointed at — main, module, types, browser, exports (wildcards included), binfails as dropped-entry-point: somebody's import 'pkg/thing' used to land on a file and now lands on nothing
every .d.ts, where the last version had themfails as dropped-types: nothing at runtime imports a declaration file, so no probe on earth would notice
anything elsenamed, on a passing check: deleting an internal file is normal, and deciding for you whether you meant it isn't packproof's job — --strict makes it a dropped-file failure

A first release has nothing to compare against and says so rather than failing. If the registry is unreachable or the version you named doesn't exist, that's a diff-unavailable failure on its own line and the rest of the report still stands. It compares paths: a rename reads as a deletion beside an addition, both printed, and you decide. With --workspaces, a bare --diff compares each package against its own published latest; one explicit version across several packages is refused rather than guessed at.

The Node you promised

"engines": { "node": ">=18" } is a promise, and nothing in the npm toolchain keeps it. npm install prints a warning at worst; your CI runs one Node, usually the newest; and the first person to find out that your "Node 18 and up" package uses a Node 20 builtin is a stranger on Node 18.

No flag needed. If the manifest declares engines.node, packproof finds every Node on the machine — the one it's running as, plus nvm, fnm, n, volta and asdf version directories — picks the oldest one the range accepts, and imports every entry point again under it.

$ npx packproof
my-pkg@2.0.0 — 12 files packed
   npm install <tarball>
   import "my-pkg"
  ✗ engines.node ">=18" [engines-unsatisfied]
      this package says it runs on node 18 and up, but importing "my-pkg" under
      node v18.20.4 fails: it imports a builtin module that Node version does not
      have. Either raise engines.node to a version that works, or stop using what
      does not exist down there. Anyone on node v18.20.4 installs this and it does
      not load.
      Error: No such built-in module: node:sqlite

The interesting part is what happens when it can't check. A green line that verified nothing is worse than no line at all, so packproof never prints one:

What it foundWhat it says
a Node of the floor's own majorverifiedimported under node v18.20.4, which is the oldest version this range claims
only newer Nodes the range acceptsengines-partly-verifiedimported under node v22.11.0 … no node 18 on this machine, so the floor itself is still unverified
no Node here the range accepts at allengines-unverifiedno node this range accepts is installed here, so packproof did not verify the claim
a range it can't parse, like lts/hydrogenpackproof does not understand this range, so it did not verify it
*, or no engines field at allaccepts any version, so there is nothing to verify — or no check line at all, because nothing was promised

Only the first row is a verified claim; the rest are still passes. Not having a Node 18 on your laptop is a fact about your laptop, not a bug in the package — which is also why --strict does not promote these.

Naming the Node yourself — --node

The automatic search can only use Nodes that happen to be installed. When you do have the Node you promise — under a version manager, in a container, built somewhere odd — --node points packproof at it and closes the gap.

packproof --node 18                      # a version, matched against the Nodes it can find
packproof --node 18.20.4                  # exactly that one
packproof --node ./vendor/node18/bin/node # or just say where it is
packproof --node 18 --node 22             # repeatable: one check line each

--node replaces the automatic choice rather than adding to it: you named the interpreters you care about, and each one costs a full re-import of every entry point. A value packproof can't use is an error (exit 2) before anything is packed — a version that isn't installed lists the ones that are, and a path is run (<path> --version) and rejected with what it actually printed if that isn't a node.

The case worth being careful about is a Node your range does not accept:

$ packproof --node 16
   engines.node ">=18" on node v16.20.2 — you asked for --node 16, and
    engines.node ">=18" does not accept it — importing "my-pkg" there fails because
    it imports a builtin module that Node version does not have, which the manifest
    already told you. Not counted against the package

It runs, and it says exactly what happened — but it's a pass, both ways round (engines-outside-range). --node 16 against ">=18" is a question, not an accusation: failing the run would mean blaming a package for breaking a promise it never made. If it works down there, the note says that too — your declared floor may be higher than the one you have. The one exception is a package with no engines.node at all: there's no promise to shelter behind and npm will install it for anyone on that Node, so a failed import is a real failure and the line says the manifest is silent about it.

Without --node, the other way to verify the floor is one extra CI lane that runs on it:

# the floor you promise, and the version you develop on
strategy:
  matrix:
    node: [18, 22]
steps:
  - uses: actions/setup-node@v4
    with: { node-version: ${{ matrix.node }} }
  - run: npx packproof

The peers — the one thing a clean room lies about

A peerDependency is a sentence addressed to whoever installs you: you bring this, not me. The clean room is the one place that sentence gets quietly rewritten, because npm 7+ auto-installs required peers. The room ends up holding the very thing the consumer is supposed to provide, every import passes, and nothing in the report mentions the one dependency that was never packproof's to prove.

The opposite promise is the dangerous one. npm never installs a peer marked peerDependenciesMeta.optional — so a package that says "you may skip this" and then imports it at load time hard-crashes for everyone who believed it:

✗ import "chartify" [optional-peer-required]
      "d3" is marked optional in peerDependenciesMeta, but it is imported at
      load time. npm never installs an optional peer, so everyone who took the
      manifest at its word gets this crash. Drop the optional flag, or move the
      import inside the code path that needs it.

When peers are declared, packproof installs the tarball a second time with --legacy-peer-deps — npm 6 behaviour, where the consumer's half of the bargain is genuinely missing — and re-imports every entry point there:

✓ peerDependencies — 1 declared, and the consumer installs it: react@^18
✓ import "my-lib" with peers absent — needs "react" at load time. That is a
      declared peer, so installing it is the consumer's job — npm 7+ does it
      automatically, pnpm and yarn 1 do not.

That's a note, not a failure. A required peer being the consumer's job is exactly what the manifest said, and packproof doesn't punish a package for meaning it. What it won't do is stay silent about it.

With the peers absentVerdict
everything still importspass, nothing to say
an entry needs a required peerpass, with a note naming what the consumer has to install
an entry needs an optional peerfailoptional-peer-required
an entry needs something nobody declared as a peerpass, with a note — --legacy-peer-deps drops your dependencies' peers too, and those aren't your promise
the peer-free room won't installpass, saying plainly that nothing was tested

No peerDependencies, no second install: the common case pays one line of report and no time at all.

Run less of it — and be told you did

The slow part of packproof is the honest part: a real npm install of the real tarball into an empty directory. Some lanes don't want it. A pre-commit hook asking "did I just stage a credential" needs no install at all. A release job asking "did a file quietly stop shipping" needs one HTTP request. Name the checks you want:

packproof --skip install                  # the fast lane: file list only, no install
packproof --only shipped-files            # same thing, said the other way
packproof --diff --only shipped-files,diff
packproof --skip engines,peers,lazy       # everything else, minus three probes
idWhat it does
shipped-filesthe tarball's own file list: credentials fail, cruft is noted
difffile list against an already-published version (needs --diff)
installnpm install of the tarball into an empty project
entriesimport every entry point in exports/main/module
requirerequire() every entry point too
binsexecute every declared bin
enginesimport again under the oldest Node engines.node accepts, or the ones --node names
peersimport again with the declared peerDependencies genuinely absent
lazyimports hidden inside functions are declared too (needs --lazy)

The reason this feature is worth a section: a run that skipped checks says so. A green packproof that quietly looked at three of eight things is precisely the lie the tool exists to prevent — so the verdict line will not make a claim the run didn't earn.

$ npx packproof --skip install
 shipped files — 17 files, no credentials or cruft
- install — did not run, skipped with --skip
- entries — did not run, needs the install check, which is not running
- require — did not run, needs the install check, which is not running
- bins — did not run, needs the install check, which is not running
- engines — did not run, needs the install check, which is not running
- lazy — did not run, needs the install check, which is not running

packproof: everything this run looked at is fine — but it never installed the
package, so it proves nothing about installing it.

It carries into every format: --json gains skippedChecks (each with the reason it didn't run) alongside fullRun and installed, JUnit emits real <skipped> testcases and a skipped="n" count so a dashboard shows the hole rather than a shorter green bar, and --format=github adds a notice naming what was dropped.

Contradictions are refused with exit 2 rather than resolved by guessing. --only entries --skip install can't be satisfied — nothing can be imported out of a clean room that was never filled. --only bins --skip bins asks for and against the same thing. --only diff without --diff has nothing to compare. An unknown id prints the real ids instead of guessing at your typo. And asking for an import probe implies the install, because that's a prerequisite and not a preference.

Say your lane once — and see it in the log

Between --only/--skip, --node, --lazy, --strict, --bin-args and --format, a repo that's settled on a lane ends up retyping it in every CI job, every README line and every teammate's shell. The day one copy drifts, two of them are lying about what gets proved. Write it down once, in a packproof.json beside your package.json:

{
  "skip": ["install"],
  "node": ["18", "22"],
  "lazy": true,
  "strict": true
}
$ npx packproof
config — packproof.json: skip=install, node=18,22, lazy=true, strict=true
packproof@1.4.0 — 17 files packed
 shipped files — 17 files, no credentials or cruft

That first line is not decoration and it is not optional. A run whose behaviour came out of a file the reader of the log can't see is exactly the quiet lie packproof exists to prevent — so a run that read a file names the file, what it applied, and what a flag overrode. It's in --json (config.summary, config.applied, config.overridden), in --format=github as a notice, and in JUnit as a suite property.

keytypesame as
onlystring or array--only
skipstring or array--skip
nodestring or array--node
binArgsstring or array--bin-args
lazyboolean--lazy
strictboolean--strict
ignoreScriptsboolean--ignore-scripts
workspacesboolean--workspaces
includePrivateboolean--include-private
diffboolean--diff against the published latest
formathuman/json/github/junit--format
registryUrlstring--registry-url

A flag always beats the file — not merged, not concatenated: type --skip and the file's skip isn't consulted at all. An unknown key is an error (exit 2) naming the real ones, with a did-you-mean for a near miss (unknown key "bin-args" — did you mean "binArgs"?), because a typo'd setting that silently does nothing is a green run that proved less than it claimed; wrong types are refused the same way, and $schema and "//" are ignored so you can keep a comment. A string is accepted wherever an array is.

Discovery is one directory deep: beside the target's package.json and nowhere else — no parent-directory search, and deliberately no "packproof" key in package.json, which ships inside the tarball packproof is checking. --config <path> names a file explicitly and a missing one is exit 2, not a shrug; --no-config ignores any. Nothing per-invocation lives there — --registry, --diff <version>, --out, --keep, --workspace are properties of one run, not of the repo. A repo with no config file behaves exactly as it always has, byte for byte.

What a check is worth — and what it isn't

A green packproof run means something specific. That's only worth having if you can find out what the specific thing is, without reading the source or taking a website's word for it. packproof --why <check-id> prints it, per check group: what a pass establishes, stated as narrowly as it's true — and what it says nothing about.

$ npx packproof --why lazy
lazy — imports hidden inside functions are declared too (needs --lazy)

  Needs the install check: without an install there is nothing to import, so
  --skip install drops this check too — and the run says it did.

  A passing lazy check proves
    - that import and require specifiers written inside functions and branches
      — code merely loading the package never reaches — are declared
      dependencies too
    - the one thing execution cannot reach: a lazy require of a devDependency
      on an error path that only fires in production

  It cannot
    - execute anything. It is a static scan of the shipped text: comments and
      template-literal prose are blanked out first, but nothing is run, so a
      specifier that resolves is only proved *declared*, not proved *working*
    - see a specifier your code computes. require(name) or import(base + mod)
      is not a literal and cannot be read
    - look outside the tarball. It reads only files that actually shipped, so
      anything excluded from `files` is out of scope

The second list is not optional. A test in packproof's own suite fails if a check id exists without one, so a future check group can't ship without someone writing down what it doesn't prove. Bare --why lists the nine checks, --why all prints every one, and --why --json hands you the same content as data for your own docs page. It reads nothing and runs nothing — no package.json, no config file, no pack, no network — so it answers in an empty directory.

It describes the checks, not your package: it can't tell you why a particular check failed for you, which is the report's job. What it guarantees is that the limits live next to the code that has them.

In CI, on the right line

A failure that scrolls past in a build log costs the same as no failure at all. --format=github emits GitHub Actions workflow commands instead of prose, so a --lazy finding shows up as a red annotation on the offending line of the diff.

# .github/workflows/publish.yml
- run: npx packproof --lazy --format=github
$ npx packproof --lazy --format=github
::error file=src/render.js,line=42,title=undeclared-dependency::require("kleur") in src/render.js:42%0A"kleur" is only in devDependencies…

The file and line come from --lazy, the one check that knows a location, so --lazy --format=github is the pairing worth wiring up. A failure with no file — the install itself, a broken bin — degrades to a bare ::error title=<kind>::, which still lands in the log and the job summary. A clean run emits one ::notice, so the step is never silently empty. Escaping is exactly what Actions requires (%%25, newlines → %0A, plus : and , in property values); get it wrong and Actions truncates the line without telling you.

Everywhere else, --format=junit writes a JUnit XML report — one <testcase> per check, a <failure type="<kind>"> per problem, file/line attributes where known — which GitLab, Jenkins, CircleCI, Buildkite and dorny/test-reporter all ingest.

packproof --format=junit --out packproof.xml

A packproof error is written as an <error> testcase, so a crash can never be read as green. Exit codes don't change with the format: 0 clean, 1 a problem your users would hit, 2 packproof itself failed. In --registry mode no file= is emitted at all — those bytes came from the registry and needn't match anything in your checkout.

Install

# no install needed
npx packproof

# …or keep it around
npm i -D packproof

Node 18+. Zero dependencies.

In CI, before you publish

# .github/workflows/release.yml
- run: npx packproof

Or as a publish gate

{
  "scripts": { "prepublishOnly": "publint && packproof" }
}

Exit code is 0 when clean, 1 when your users would hit something, 2 on a packproof error. Pass a directory to pack it, or an existing .tgz to test exactly the bytes you already built.

What gets checked

install

npm install <tarball> into a directory containing nothing else — no hoisted devDependencies to bail you out.

entry points

Every specifier a consumer could reach, derived from exports (subpaths included) or main/module. Probed with a real dynamic import() in a fresh process.

require()

The same specifiers through createRequire, because CJS consumers are still most of the ecosystem. Skipped for "type": "module", where failing is correct.

shipped files

The tarball's own path list, read for what should never have been in it: .npmrc, .env, private keys, key stores. Cruft is a note; credentials fail.

the last release

--diff compares the shipped paths with an already-published version. A file that silently stopped shipping is caught before the release, not after.

bins

Every entry in bin, actually executed from node_modules/.bin — the only way a missing shebang or a lost chmod shows itself.

the Node floor

If engines.node is declared, every entry point is imported again under the oldest Node installed here that the range accepts — and when there's no such Node, it says so instead of passing quietly, or you name one with --node.

peers

npm 7+ installs your required peers into the clean room for you, which is the one thing the room gets wrong. When peers are declared, packproof builds a second room without them and re-imports.

Failures are classified, not just dumped

"It broke" is not actionable. "Your devDependency leaked" is.

KindMeaning
undeclared-dependencyimported at runtime but only in devDependencies
missing-dependencyimported at runtime and declared nowhere at all
missing-filea file inside your own package wasn't in the tarball
bin-not-executablethe shell couldn't run it — usually a missing shebang
bin-missingthe bin target didn't ship
install-failedthe tarball wouldn't install at all
load-errorit loaded and then threw — a real crash on import
shipped-secretthe tarball contains a file that normally holds a credential
dropped-entry-point--diff only: a path the published version resolved imports to is gone from this tarball
dropped-types--diff only: the last version shipped type declarations and this one ships none
diff-unavailable--diff only: the version to compare against could not be read, so nothing can be claimed about it
shipped-cruft--strict only: accidental-looking files in the tarball, a note by default
dropped-file--strict only: a file gone since the last release that nothing declared, a note by default
bin-nonzero-exit--strict only: a bin that loads and runs but exits nonzero, a note by default
engines-unsatisfiedthe package doesn't load on a Node version its own engines.node says it supports
optional-peer-requireda peer marked optional that the package can't be imported without
integrity-mismatch--registry only: the downloaded tarball isn't the bytes the registry has on record

Options

FlagWhat it does
--workspacesprove every package the workspace declares, one clean room each; private packages are skipped
--workspace <name>only this workspace package — its name or its directory; repeatable, and implies --workspaces
--include-privatedon't skip private workspace packages
--registry [spec]prove a published package instead of the local tree — pkg, pkg@1.2.3 or pkg@tag, defaulting to this package at its latest tag
--registry-url <u>registry to ask (default https://registry.npmjs.org)
--diff [version]compare the shipped file list against an already-published version (default: this package's latest) — a path the published package.json pointed at that is gone fails the run, anything else that stopped shipping is named
--jsonmachine-readable output (same as --format=json)
--format <fmt>human (default), json, github (Actions annotations) or junit (JUnit XML)
--out <file>write the formatted report to a file instead of stdout
--keepkeep the clean room and print its path, so you can go poke at it yourself
--ignore-scriptsinstall with --ignore-scripts (packproof otherwise runs your postinstall, like a user would)
--skip-requireonly probe ESM import, not require() — the older spelling of --skip require
--lazyalso read back every shipped .js/.mjs/.cjs file and check the imports that never execute
--strictfail on everything packproof would otherwise only note — accidental files in the tarball, a file that stopped shipping without being a declared entry point, and a bin that runs but exits nonzero. Same findings, stricter verdict.
--node <ver|path>run the engines check under this Node instead of the oldest installed one the range happens to accept — a version like 18 or 18.20.4, or a path to a node binary. Repeatable. A Node your engines.node excludes still runs, and the report says so both ways round rather than counting it against the package
--only <checks>run only these check groups — repeatable and comma-separated. Ids: shipped-files, diff, install, entries, require, bins, engines, peers, lazy
--skip <checks>run everything except these. Skipping install drops every probe that needs one — and the run then says, in every format, that it never installed the package
--bin-args <args>args passed to each bin (default --version)
--config <path>read this config file instead of looking for a packproof.json beside the target's package.json — a missing one is exit 2, not a shrug
--no-configignore any packproof.json
--why [check-id]what a check group proves and what it does not — bare for the list, all for every one, and it works with --json. Reads nothing, runs nothing, needs no package.json

Programmatic use

import { packproof } from 'packproof';

const result = await packproof('.');
if (!result.ok) console.error(result.failures.map((f) => f.kind));

// or a version that's already out there
await packproof('.', { registry: 'chalk@4.1.2' });

packproof vs publint

Use both. They do different things and neither subsumes the other.

publint statically lints your published manifest: module formats, exports correctness, file extensions, shebangs, deprecated fields. It's excellent, fast, needs no install, and it covers whole categories of correctness packproof never looks at.

packproof doesn't read your manifest for opinions. It installs your package and runs it — which catches the class of bug static analysis structurally cannot see: whether the code, at runtime, reaches for something that isn't there. A devDependency imported three files deep behind a conditional isn't a manifest problem. It's a fact about execution.

publint answers "is this package declared correctly?" packproof answers "does this package work once installed?" Run publint && packproof.

And there's one gap neither of those closes on its own: a devDependency require()d only inside a function body passes npm pack, passes npm install, passes publint, and passes packproof's own execution checks — no probe ever runs that line, so no probe can notice. packproof --lazy reads the shipped source and catches it.

Honest limitations

Loading a module runs its top level, so a devDependency imported lazily inside a function packproof never calls is never run — pass --lazy and it's found by reading the shipped source instead. That scan is static: comments and template-literal prose are blanked first, but a specifier your code computes at runtime isn't a literal and can't be seen, and only files that actually shipped are ever read. ESM stops at the first unresolved import, so one module with two missing things reports one, then the other after you fix it. Bins run with --version by default; if yours doesn't support it a nonzero exit is a note, not a failure — --strict promotes that note, and packproof's two other notes, to failures without looking for anything extra. It performs a real npm install, so: the network, and a few seconds. In a workspace, a package that depends on an unpublished sibling can't be fully proved — packproof reports workspace-sibling-dependency rather than faking it, and workspace discovery is glob matching (*, **, ?, ! negations), not a package manager, so anything more exotic is missed rather than guessed at. --diff compares paths against one named version, not contents and not your whole publish history, and it needs the network — when it can't reach the registry it fails loudly rather than passing quietly. The peers check tests absence, not version ranges: it proves what happens when a declared peer is missing, never installs a peer at some other version to see whether your ^18 was honest, and since --legacy-peer-deps also removes your dependencies' peers, a missing package nobody here declared is reported as a note rather than counted against you. The engines.node check can only use Nodes that are installed: packproof won't download one and won't pretend, so if the oldest Node your range accepts isn't on the machine, the check passes with a note naming the Node it actually used and saying the floor is unverified — and it re-imports entry points only, not bins. --node names an interpreter but doesn't install one either: --node 18 still needs a Node 18 to exist somewhere packproof looks, and it refuses the run rather than quietly falling back. --registry trusts the registry’s own hash: it proves the bytes you downloaded are the bytes on record for that version, which is not provenance and not a signature check. A packproof.json is read, never written, and never inherited — packproof won't create one, merge two, or look in a parent directory — but it can't see the copy of your lane that lives somewhere else: a workflow that types out its own flags is still free to disagree with the file, and the only sign of that is the config line saying which settings the flags beat, so read it. And --only/--skip make a run weaker — that's the point, since the alternative is usually no run at all — but the exit code of packproof --skip install means "nothing I looked at is wrong", not "this package installs". packproof never prints the second claim after a run that didn't earn it; if your CI reads the exit code and nothing else, keep one full run somewhere before you publish. All of this is also in the tool: packproof --why <check-id> prints the limits of a single check at the moment you’re deciding whether to trust it.