Skip to content

Test the published package, and fix two defects it found - #190

Merged
patchstackdave merged 6 commits into
mainfrom
test/published-package-compatibility
Aug 28, 2026
Merged

patchstackdave merged 6 commits into
mainfrom
test/published-package-compatibility

Conversation

@patchstackdave

@patchstackdave patchstackdave commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Tests the published package against real consumers, and corrects two defects in what it publishes.

Stacked on #189 until that merges; the rebase is noted at the end.

What the check covers

Every other test in this repository exercises the source, or dist/ from within the repository. Neither reaches the questions a consumer's build asks, because the tarball's metadata answers them: which file an exports condition resolves to, which declarations TypeScript reads beside it, whether files omits something, whether the bin runs as the shim npm installs. A defect there leaves the suite green while nothing can import the package.

scripts/compat-matrix.mjs packs a tarball and installs it into isolated consumer projects, one per consumption path:

shape holds open
esm-js ESM import
cjs-js CommonJS require
esm-ts ESM TypeScript compilation
cjs-ts CommonJS TypeScript compilation
cjs-ts-no-node-types the public declarations compile without @types/node
cli the installed bin runs
encapsulation nothing outside exports resolves

It runs the same way locally (npm run test:consumers) and in CI across npm, pnpm, Yarn Classic, Yarn Berry and Bun at exact versions, with a Windows job for the .cmd launcher. The Node 18/20/22/24 matrix is unchanged.

Two corrections to what is published

Types are per-condition. exports routes import to dist/index.d.ts and require to dist/index.d.cts, with default last. TypeScript reads the declarations beside the condition it resolved: an ESM .d.ts under require tells it the target is an ES module, and it refuses the require with TS1479 even though the runtime works. ./protect now ships a .d.cts for the same reason.

The public declarations no longer name NodeJS.ProcessEnv. @types/node is not a dependency of this package, so nothing in its public surface may require it; an EnvLike alias describes the environment structurally. process.env satisfies it, and so does a plain object.

Controls

Each shape asserts a positive before asserting an absence. encapsulation resolves the public root first and fails if that does not work — otherwise every assertion in it is an expected failure and the case passes when the package is absent.

cjs-ts-no-node-types is the only TypeScript fixture without @types/node installed, which is what makes it able to hold that constraint.

A packaging test asserts that every file the export map names is inside files and present after a build. A map can name a path the build does not produce, and that surfaces as "cannot find module" in a consumer's project.

Windows

execFileSync creates a process directly and a batch file is not an executable image, so the interpreter is invoked explicitly: cmd /d /s /c "<line>" with windowsVerbatimArguments. /s strips exactly the outermost quote pair; the verbatim flag stops Node re-escaping a correct line.

The command word is quoted only when it is a path. A bare name must not be: npm is npm.cmd, resolved through PATH and PATHEXT, and its shim locates its own installation with %~dp0. Arguments are always quoted, because Windows temp and workspace paths contain spaces.

Managers

Exact versions — npm 11.6.0, pnpm 9.15.4, Yarn 1.22.22, Yarn 4.18.0, Bun 1.2.21 — and the version on PATH is printed before any fixture runs. Berry comes from @yarnpkg/cli-dist, which is the distribution carrying the yarn binary at 4.x; the yarn package on npm stops at 2.x. Berry treats a fixture as its own project only when a yarn.lock is present, so the script writes an empty one; that is harmless under Classic.

Build order

CI builds before it tests. Several tests assert against dist/, and one returns early and green when it is absent, so the check that the built edge bundle carries no static Node-builtin import was not being made.

Verified

All seven shapes pass locally under npm 11.6.0, Yarn Classic 1.22.22, Yarn Berry 4.18.0 and Bun. Full suite 1710 passed, 7 skipped. Windows is exercised by CI only.

Rebase

This branch carries #189's commits, so the two do not have separate review or rollback boundaries. Once #189 merges, this is rebuilt from the new main with only its own three commits and the matrix rerun.

🤖 Generated with Claude Code

@coderbuds

coderbuds Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

Comprehensive consumer packaging validation with clear, well-documented tests and CI integration.

🎯 Quality: 77% Good · 📦 Size: Large — consider splitting if possible

🛡️ Standards: no pre-flight fit check ran for this change — wire assess-change-fit into your coding agents to catch size before opening.

📈 This month: Your 147th PR — above team average · Averaging Good

See how your team is trending →

@patchstackdave

Copy link
Copy Markdown
Contributor Author

/review

patchstackdave and others added 6 commits August 28, 2026 09:47
Everything in the suite tested the source, or `dist/` from inside the repository. Neither can see the
questions a consumer actually hits, because the tarball's METADATA decides them: which file an `exports`
condition resolves to, which declarations TypeScript reads beside it, whether `files` left something out,
whether the bin runs when npm installs it as a shim.

`scripts/compat-matrix.mjs` packs a real tarball and installs it into isolated consumer projects — ESM
JavaScript, CommonJS JavaScript, ESM TypeScript, CommonJS TypeScript, CommonJS TypeScript without
`@types/node`, the installed bin, and export encapsulation. It runs identically locally
(`npm run test:consumers`) and in CI across npm, pnpm, Yarn and Bun, plus a Windows smoke test for the
generated `.cmd` launcher. The existing Node 18/20/22/24 matrix is untouched.

It found two shipped defects immediately.

## CommonJS TypeScript could not use this package at all

`exports` carried a single top-level `types` pointing at the ESM declarations. A CommonJS TypeScript
consumer therefore resolved `require` for the runtime and ESM types for the shape, so TypeScript concluded
the target was an ES module and refused the `require` outright — TS1479, at the import line, with no way
around it. CommonJS JavaScript worked perfectly, which is why nothing noticed.

Types are per-condition now, `import` to `.d.ts` and `require` to `.d.cts`, with `default` last. The root's
`.d.cts` was already being built and simply never referenced; `./protect` had no `.d.cts` at all despite
advertising a `require` condition, so the build now emits one — a copy, because tsup emits the root's two
declaration files byte-identical and there is nothing per-format to express.

Edge conditions stay ahead of `import`, so a Worker still gets the edge bundle. TypeScript does not apply
them and falls through to `import`/`require`, where it now finds the right declarations.

## The published types required a dependency the package does not declare

Two exported functions took `NodeJS.ProcessEnv`, which comes from `@types/node`. A consumer without it
could not compile against the public surface at all — the declarations referenced a namespace their project
had never heard of. An `EnvLike` alias replaces it: `process.env` satisfies it, and so does a plain object.

`cjs-ts-no-node-types` exists for exactly this and is not redundant with `cjs-ts`, since every other fixture
installs `@types/node` and would keep passing.

Both regressions confirmed to fail the matrix: reverting `exports` fails 2 of 7 shapes, restoring
`NodeJS.ProcessEnv` fails 1. Dropping the `protect.d.cts` copy fails the packaging test, which also now
checks that every file the export map names is inside `files` AND actually built — a map can name a path
the build never produces, and that surfaces three layers away as "cannot find module" in someone else's
project.

`module` is kept. Removing it needs evidence no bundler still reads it, and the bundled-consumer test that
would provide that is not written yet; keeping a redundant field costs nothing, guessing costs a consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six corrections to the compatibility check and the code it exercises.

**Windows.** `execFileSync` creates a process directly, and the package managers and installed binaries are
`.cmd` shims — a batch file is not an executable image, so it cannot be launched that way. Windows now goes
through the command interpreter with every token quoted, and every other platform keeps direct execution:
no shell, nothing to escape wrongly. Quoting wraps each token because a Windows temp path contains spaces,
and doubles `"`, which is cmd's own escape.

**The encapsulation case had no positive control.** Every assertion in it expects an import to fail, so it
passed whether or not the package was installed. It now resolves the public root first and fails if that
does not work — verified by removing the install for that shape, which turns the case red.

**Yarn Berry needs the fixture declared self-contained.** Berry walks up from the working directory looking
for a project root and adopts any `package.json` above it, which a temp directory often has. An empty
`yarn.lock` in the fixture is Berry's own documented answer, and it is harmless under Classic. Per-manager
preparation now exists for that.

**Manager versions are pinned and printed.** An unpinned manager silently changes what was exercised, and
Yarn Classic and Berry resolve differently enough that a pass under one is not a pass under the other — so
both are in the matrix, and the script reports the version it actually saw alongside Node and the platform.

**`EnvLike` had displaced the documentation of the function below it**, leaving two adjacent doc blocks and
a function with none. The type carries its own comment and sits above the function's.

**Comments state the invariant, not the history.** Several of the new comments narrated what used to
happen and how it was found, which belongs in the private engineering record rather than in a public
repository. They now say what holds and why it matters to a consumer.

Verified: full suite green, and all seven consumer shapes pass under npm, Yarn Classic and Bun locally.
Berry and Windows are exercised by CI — the Berry blocker above was reproduced and fixed from its own error
message, but the complete Berry run has not been observed outside CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…actly

Yarn Berry is installed from `@yarnpkg/cli-dist`, which is the distribution carrying the `yarn` binary at
4.x. The `yarn` package on npm stops at 2.x, so a `yarn@4` install cannot resolve and the job ends before
any fixture runs.

Windows quotes the command word only when it is a path. A bare name has to stay unquoted: `npm` there is
`npm.cmd`, found through `PATH` and `PATHEXT`, and the shim locates its own installation with `%~dp0` — the
directory of the batch file. Quoting the name makes that the working directory instead, so npm looks for
its CLI under whatever project is current. Absolute paths are quoted, since the binaries in
`node_modules/.bin` are addressed by path and a Windows temp or workspace path contains spaces. The
interpreter is invoked explicitly as `cmd /d /s /c "<line>"` with `windowsVerbatimArguments`: `/s` strips
exactly the outermost quote pair and takes the rest verbatim, and the verbatim flag stops Node re-escaping
a line that is already correct.

The build runs before the tests. Several tests only assert against `dist/`, and one of them returns early
and green when it is absent — so the check that the built edge bundle carries no static Node-builtin
import was not being made. Two comments claiming CI tests before building are corrected; they now describe
what happens.

Every manager version is exact — npm 11.6.0, pnpm 9.15.4, Yarn 1.22.22, Yarn 4.18.0, Bun 1.2.21 — and the
step prints the version on PATH before any fixture runs, so an install that resolved to something else is
visible rather than inferred from a later failure. A floating major means a passing run happened for a
reason nobody chose.

Berry needed no linker configuration: 4.x uses a `node_modules` layout for a project with no `.yarnrc.yml`,
so the two config steps asserting otherwise are removed. The empty `yarn.lock` the script writes is what
Berry actually requires.

Verified locally: all seven consumer shapes pass under npm 11.6.0, Yarn Classic 1.22.22, Yarn Berry 4.18.0
and Bun. Windows remains CI-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Berry resolved nothing on Linux: the fixtures installed but no shape could import the package. Its linker
depends on the Yarn version and on any `.yarnrc.yml` above the fixture, so the same script produced a
`node_modules` layout on one machine and Plug'n'Play on another — and under Plug'n'Play nothing is in
`node_modules`, so `node probe.mjs` cannot resolve the package at all.

The fixture now states `nodeLinker: node-modules` alongside the empty `yarn.lock`, in the script rather
than in the workflow, so it holds wherever the script runs. Confirmed to be the deciding factor: with
`nodeLinker: pnp` all seven shapes fail, with `node-modules` all seven pass.

Plug'n'Play is a real Berry configuration and this matrix does not cover it. The comment says so, because
a matrix that quietly exercises one layout while reading as "Yarn works" is the more expensive outcome.

Verified locally under npm, Yarn Classic 1.22.22, Yarn Berry 4.18.0 and Bun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The check read "Yarn Berry 4.18.0" while testing Berry with a `node_modules` layout only. Plug'n'Play is a
real Berry configuration and is not covered, so the label claimed more than the job holds. It now says
`Yarn Berry 4.18.0 (node-modules)`.

Renaming is safe here: the consumer jobs are not required status checks, so no protection rule references
the old name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pre-pull-request checklist named typecheck, test, build, while the section above it establishes that
the build has to come first: several tests only assert against `dist/`, and one returns early and green
without it. The checklist now matches, and points at that reason rather than restating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@patchstackdave
patchstackdave force-pushed the test/published-package-compatibility branch from c3d0705 to 387b611 Compare August 28, 2026 07:49
@patchstackdave
patchstackdave merged commit 675f311 into main Aug 28, 2026
12 checks passed
@patchstackdave
patchstackdave deleted the test/published-package-compatibility branch August 28, 2026 07:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants