Pack it. Install it cleanly. Import every public entrypoint. Compile it with the consumer's TypeScript. Catch the release that looked perfect from inside the repository.
Your source tests can all pass while the published package is unusable.
Workspace hoisting hides undeclared dependencies. Source imports bypass the
exports map. Files that exist beside the source never make it into the
tarball. npm link proves the repository works, not the artifact.
package-contract closes that gap with real consumer projects:
source tree
│
├─ npm pack
▼
immutable tarball ──► isolated npm install
│
├─ real Node.js imports and requires
├─ real TypeScript compiler API
├─ every safely enumerable public entrypoint
└─ explicit API, asset, lazy import, and bin actions
It also runs Publint and
Are the Types Wrong against the same
tarball. If either tool already explains a failure, package-contract stays
quiet by default. The useful output is the residual: reproduced consumer
failures that static analysis did not already explain.
npm install --save-dev package-contract
npx package-contract check .A healthy package prints nothing and exits with code 0.
A broken package gives you the consumer, subpath, exact command, and bounded evidence:
PC1001 Package evaluation failed
. (ESM, Node 24.16.0)
$ node <consumer>/probe-a28c638f5fd2.mjs
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'<consumer>/node_modules/example/lazy.js'
That is the complete zero-configuration workflow. No config file is required.
- Runtime dependencies that only worked because a workspace hoisted them
- Dynamic imports whose target was omitted from the tarball
- Top-level code that reads an asset which was never packed
- JavaScript self-references blocked by the published
exportsmap - Declaration files that import undeclared type dependencies
- ESM, CommonJS, and declaration paths that disagree in real consumers
- Export patterns that resolve differently from their apparent intent
- Explicit API calls, asset reads, and package executables that fail after pack
Every failure comes from execution or compilation. Unsupported states are
not-evaluated, never reported as compatible.
| Consumer | Runtime | TypeScript resolution |
|---|---|---|
| ESM | Current Node executable | Runtime only |
| CommonJS | Current Node executable | Runtime only |
| ESM | Current Node executable | Node16 |
| ESM | Current Node executable | NodeNext |
| ESM | Current Node executable | Bundler |
| CommonJS | Current Node executable | Node16 |
| CommonJS | Current Node executable | NodeNext |
Only profiles the package claims to support are executed. Explicit export
subpaths are tested directly. Safe wildcard patterns are expanded from
JavaScript files in the packed artifact. Ambiguous patterns are
not-evaluated.
TypeScript is a peer dependency. The runner resolves the invoking project's compiler and supports TypeScript 5.6 through 6.x plus the validated 7.0.x native API, starting at 7.0.2.
# Check a directory by packing it first
package-contract check .
# Check the exact tarball you intend to publish
package-contract check ./example-1.2.3.tgz
# Stable machine-readable output
package-contract check . --json
# GitHub workflow annotations
package-contract check . --reporter github
# Show failures already explained by Publint or ATTW
package-contract check . --include-explained
# Refuse network access; an empty isolated cache can make checks incomplete
package-contract check . --offline
# Compare two artifacts under the same profiles and captured npm cache
package-contract compare ./before.tgz ./after.tgz
# Materialize a minimal consumer for one diagnostic
package-contract check . --repro <diagnostic-id>Exit code 0 means no visible residual error and no unexpected incomplete evaluation. Exit code 1 means at least one residual error reproduced. Exit code 2 means the invocation or check could not complete, including an offline cache miss or an inconclusive comparison.
See the full CLI reference.
import {
comparePackages,
defineConsumer,
testPackage,
} from "package-contract";
const node = { executable: process.execPath, version: process.versions.node };
const esmNodeNext = defineConsumer({
moduleSystem: "esm",
runtime: node,
typescriptResolution: "nodenext",
});
const report = await testPackage(
{ kind: "directory", path: "." },
{ profiles: [esmNodeNext] },
);
if (report.diagnostics.length > 0) {
process.exitCode = 1;
}
const comparison = await comparePackages(
{ kind: "tarball", path: "./before.tgz" },
{ kind: "tarball", path: "./after.tgz" },
);The public surface is deliberately small:
defineConsumer(profile)validates and freezes a consumer profile.testPackage(input, options)returns a deterministic package report.comparePackages(before, after, options)classifies regressions, fixes, and unchanged diagnostics.
Those are the only exported runtime functions. Report, profile, diagnostic, and action types are exported for TypeScript consumers. See the complete library API reference.
Zero-configuration probes only assert resolution and evaluation. Add explicit actions when the consumer must call an API, trigger a lazy import, read an exported asset, or execute a bin:
const report = await testPackage(
{ kind: "directory", path: "." },
{
actions: [
{ kind: "export", subpath: ".", exportName: "createClient" },
{
kind: "call",
subpath: ".",
exportName: "loadAdapter",
arguments: ["memory"],
},
{ kind: "read-file", subpath: ".", exportName: "schemaUrl" },
],
bins: [{ name: "example", arguments: ["--help"] }],
},
);Actions are validated, sorted, deeply frozen, recorded in the report, and preserved in generated reproductions. Read the consumer action guide.
name: Package contract
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run build
- run: npm pack
- run: npx package-contract check ./package-contract-*.tgz --reporter githubFor release gates, prefer checking the exact saved tarball rather than packing again in a later step.
| Tool | Its job | Why package-contract still runs |
|---|---|---|
| Publint | Static package metadata and file analysis | It cannot observe evaluation-time behavior or a clean consumer dependency graph |
| Are the Types Wrong | Static runtime-to-types resolution analysis | It does not compile a real consumer with the invoking TypeScript |
npm pack --dry-run |
Packed file inventory | A file list has no runtime semantics |
| pkg.pr.new | Installable preview packages | Verification remains manual |
Publint and Are the Types Wrong are runtime dependencies, not competitors. Their structured findings prevent duplicate diagnostics. The exact dependency rationale is documented in docs/dependencies.md.
Reports normalize path separators, line endings, ANSI sequences, locale, and ordering. Temporary source, tarball, consumer, cache, and compiler paths are redacted before serialization. Diagnostic identity is a stable hash of code, profile, and subpath.
Byte identity is guaranteed for the same tarball, environment, tool versions, profile schema, and resolved dependency graph. Reports record those inputs and the normalized lockfile digest.
Normal mode may use the npm registry to install package dependencies. Strict
offline mode passes --offline; a cache miss becomes not-evaluated.
Comparisons populate a private cache with the before artifact, then install the
after artifact offline from that captured state. Missing or different
dependency graph digests make the comparison inconclusive.
package-contract is a release test runner, not a sandbox or malware scanner.
npm packcan execute lifecycle scripts from a directory under test.- Runtime probes deliberately execute the packed package.
- Consumer installation disables lifecycle scripts by default.
- Local directories and tarballs are trusted inputs in v1.
- Registry URLs, Git URLs, and arbitrary npm specifiers are rejected.
- Child processes have closed stdin, bounded output, timeouts, scrubbed environments, isolated npm configuration, and process-tree termination.
- Tarballs are copied into a private immutable artifact before validation and installation.
Do not run it on code you would not otherwise build and test. Read the complete security policy.
- Node.js 24 or newer
- npm synthetic consumers
- TypeScript 5.6 through 6.x, plus 7.0.2 through 7.0.x
- ESM package distribution
- Local package directories and
.tgzfiles only
Browser, bundler-runtime, Deno, Bun, Workers, pnpm, and Yarn consumer matrices
are outside v1. Bundler in the matrix refers to TypeScript's
moduleResolution: "Bundler", not execution in a simulated bundler.
The project began with ten deliberately broken packages. Six failures were empirically invisible to Publint 0.3.22 and Are the Types Wrong 0.18.5, which met the product's feasibility gate. The feasibility report records the exact classifications.
The clean-package validation currently includes full discovered matrices for Nanoid, p-limit, Picocolors, SemVer, and Zod with zero residual findings. See the recorded validation.
Packaging behavior is subtle, and focused real-world fixtures are especially valuable. Please read CONTRIBUTING.md before opening a pull request or proposing a diagnostic.
MIT © 2026 Alex Berriman