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".
$ 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.
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 bug | Why your tree hides it |
|---|---|
devDependency imported at runtime | it'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 shebang | you 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.
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 release | a 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 dependency | does the package you're about to adopt even import cleanly with nothing else installed? |
| reproduce a bug report | against 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.
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-dependency | a 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 case | npm 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 published | the 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.
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.
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 gone | What packproof does |
|---|---|
a path the published package.json pointed at — main, module, types, browser, exports (wildcards included), bin | fails 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 them | fails as dropped-types: nothing at runtime imports a declaration file, so no probe on earth would notice |
| anything else | named, 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.
"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 found | What it says |
|---|---|
| a Node of the floor's own major | verified — imported under node v18.20.4, which is the oldest version this range claims |
| only newer Nodes the range accepts | engines-partly-verified — imported 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 all | engines-unverified — no node this range accepts is installed here, so packproof did not verify the claim |
a range it can't parse, like lts/hydrogen | packproof does not understand this range, so it did not verify it |
*, or no engines field at all | accepts 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.
--nodeThe 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
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 absent | Verdict |
|---|---|
| everything still imports | pass, nothing to say |
| an entry needs a required peer | pass, with a note naming what the consumer has to install |
| an entry needs an optional peer | fail — optional-peer-required |
| an entry needs something nobody declared as a peer | pass, with a note — --legacy-peer-deps drops your dependencies' peers too, and those aren't your promise |
| the peer-free room won't install | pass, saying plainly that nothing was tested |
No peerDependencies, no second install: the common case pays one line of report and no time at all.
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
| id | What it does |
|---|---|
shipped-files | the tarball's own file list: credentials fail, cruft is noted |
diff | file list against an already-published version (needs --diff) |
install | npm install of the tarball into an empty project |
entries | import every entry point in exports/main/module |
require | require() every entry point too |
bins | execute every declared bin |
engines | import again under the oldest Node engines.node accepts, or the ones --node names |
peers | import again with the declared peerDependencies genuinely absent |
lazy | imports 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.
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.
| key | type | same as |
|---|---|---|
only | string or array | --only |
skip | string or array | --skip |
node | string or array | --node |
binArgs | string or array | --bin-args |
lazy | boolean | --lazy |
strict | boolean | --strict |
ignoreScripts | boolean | --ignore-scripts |
workspaces | boolean | --workspaces |
includePrivate | boolean | --include-private |
diff | boolean | --diff against the published latest |
format | human/json/github/junit | --format |
registryUrl | string | --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.
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.
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.
# no install needed npx packproof # …or keep it around npm i -D packproof
Node 18+. Zero dependencies.
# .github/workflows/release.yml
- run: npx packproof
{
"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.
npm install <tarball> into a directory containing nothing else — no hoisted devDependencies to bail you out.
Every specifier a consumer could reach, derived from exports (subpaths included) or main/module. Probed with a real dynamic import() in a fresh process.
The same specifiers through createRequire, because CJS consumers are still most of the ecosystem. Skipped for "type": "module", where failing is correct.
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.
--diff compares the shipped paths with an already-published version. A file that silently stopped shipping is caught before the release, not after.
Every entry in bin, actually executed from node_modules/.bin — the only way a missing shebang or a lost chmod shows itself.
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.
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.
"It broke" is not actionable. "Your devDependency leaked" is.
| Kind | Meaning |
|---|---|
undeclared-dependency | imported at runtime but only in devDependencies |
missing-dependency | imported at runtime and declared nowhere at all |
missing-file | a file inside your own package wasn't in the tarball |
bin-not-executable | the shell couldn't run it — usually a missing shebang |
bin-missing | the bin target didn't ship |
install-failed | the tarball wouldn't install at all |
load-error | it loaded and then threw — a real crash on import |
shipped-secret | the 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-unsatisfied | the package doesn't load on a Node version its own engines.node says it supports |
optional-peer-required | a 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 |
| Flag | What it does |
|---|---|
--workspaces | prove 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-private | don'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 |
--json | machine-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 |
--keep | keep the clean room and print its path, so you can go poke at it yourself |
--ignore-scripts | install with --ignore-scripts (packproof otherwise runs your postinstall, like a user would) |
--skip-require | only probe ESM import, not require() — the older spelling of --skip require |
--lazy | also read back every shipped .js/.mjs/.cjs file and check the imports that never execute |
--strict | fail 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-config | ignore 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 |
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' });
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.
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.