build: verify the published tarball instead of grepping the bundle - #1578
build: verify the published tarball instead of grepping the bundle#1578thymikee wants to merge 3 commits into
Conversation
Replaces the bundle-dependency grep with one gate that packs the tarball npm would publish and proves it sound from a clean consumer install: publint and attw on the tarball, a two-way dependency-closure audit, an import of every `exports` subpath, and the CLI smoke run — all from outside the workspace, where no pnpm link can mask an unresolvable specifier. Also stops the build from emitting a publishable bundle in the first place: a missing workspace link now fails `pnpm build` instead of warning and exiting 0, which is how 0.20.4 shipped an unresolvable `@agent-device/ad-script` import. publint found 12 real defects in the current package — every `exports` entry listed `types` after `import`, so TypeScript resolved declarations by accident rather than by condition. The dependency audit found `pngjs` declared as a runtime dependency while tsdown inlines it, an install every user paid for and no shipped code reached; it moves to devDependencies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NamFJUgn9DGHrT2za11JbD
|
Size Report
Startup median (7 runs, lower is better):
Top changed chunks: no changes in the largest emitted chunks. |
pnpm 11.17 requires Node >= 22.13, so `pnpm check:package` could not start on the 22.12 floor the Packaged CLI job exists to cover. The gate needs only `node` and `npm`, so the job invokes the script directly. Splits the dependency-closure audit into a collector and a message builder to clear Fallow's complexity threshold, and classifies both packaging linters in ignoreDependencies: they are subprocess CLIs with no importable API here, which dependency analysis cannot follow to an import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NamFJUgn9DGHrT2za11JbD
|
Blocking — the new “all shipped bare specifiers” guarantee is incomplete and has no behavioral regression pin.
Please traverse/reject literal CommonJS resolution forms (including aliased The exact 0.20.4 ESM artifact is caught and CI is green, but the generalized guarantee is not yet durable. |
The dependency-closure audit derived shipped imports from the ESM module
record alone, so it could not see a package resolved through `require` or a
`createRequire` result: neither produces a module-record entry. A lazy
`createRequire('@agent-device/…')` would therefore clear the audit, the
all-export probe and the exercised CLI paths, reintroducing the 0.20.4
published-install failure class for another command.
Measuring the built bundle turned up a second, larger hole in the same
reader. The shipped files are minified, and the minifier rewrites every
string literal to a no-substitution template literal, so the dynamic-import
extraction — which accepted quoted strings only — matched 0 of the 99
dynamic imports the bundle contains. The lazy `import()` path that broke
0.20.4 was reported as covered while checking nothing.
Specifiers now come from the module record plus an AST walk over every
literal runtime-resolution form: `import()`, `require()`,
`require.resolve()`, an immediately-invoked `createRequire(...)`, and calls
through a `createRequire` result under any import or minified alias. Both
spellings of a string literal count everywhere, and `.cjs` joins the
scanned extensions.
Computed specifiers stay explicitly out of scope, and are pinned as such.
Rejecting them is not available: minifiers reuse short identifiers across
scopes, and the packed bundle really does contain an unrelated
`a(h[t],f,g,l,e,m)` that no name-based match can distinguish from a require
call. Those are covered by the gate's runtime half instead, which resolves
them for real. Bare-identifier calls need the one-string-argument shape for
the same reason.
The audit moves to scripts/lib/shipped-imports.ts so fixture packages can
exercise it. The gate needs a real `npm pack` behind minutes of Swift and
Android builds, so every check that runs it can only watch a healthy
package pass — which is how a reader that matched nothing looked covered.
The new fixtures assert the failure direction per resolution form: 16 of
the 22 fail against the previous reader, and the 6 that pass are the
quoted-spelling and pinned-limitation cases. A wiring assertion keeps the
audit and both runtime probes attached to the gate, since fixtures alone
would stay green if the call were deleted.
Verified against the real built bundle: the closure resolves to exactly the
two declared dependencies, so the stricter reader adds no false positives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NamFJUgn9DGHrT2za11JbD
|
Fixed in e72e156. You were right, and measuring the built bundle turned up a second hole in the same reader that was worse than the one you named. Your finding. The larger one. The shipped files are minified, and the minifier rewrites every string literal to a no-substitution template literal. The dynamic-import extraction accepted quoted strings only, so it matched 0 of the 99 dynamic imports in the built bundle. The lazy Specifiers now come from the module record plus an AST walk over every literal resolution form — Narrowed explicitly, per your second option. Computed specifiers stay out of scope and are pinned as such. Rejecting them isn't available: minifiers reuse short identifiers across scopes, and the packed Required validation. The audit moved to
All 22 green after. On your point that deleting the audit would leave the wiring tests green: a wiring assertion now pins Both existing smokes are retained untouched — the clean-tarball path and the Node 22.12 packaged-CLI job. I also ran the new reader against the real built bundle: the closure resolves to exactly Generated by Claude Code |
|
Re-reviewed head The package audit now covers literal/minified-template All CI is green, including the Node 22.12 clean-install package verification and full macOS Package Smoke. The documented residual limit—genuinely computed specifiers and deeper alias/assignment chains—is reasonable and backed by runtime export/CLI probes. No remaining blocking findings; ready for human merge. |
Summary
Follow-up to #1577. That PR added a grep for one specifier prefix (
@agent-device/*) indist/, and taught CI to run one more command (devices --json) that happened to touch the broken path. Both are shaped like the specific incident. This replaces them with a gate shaped like the question: does the tarball npm would publish actually work when installed?pnpm check:package(scripts/check-package.ts) packs the tarball, then verifies it from outside the workspace — where no pnpm link can mask an unresolvable specifier:publint --stricton the tarball — packaging metadata (exports/bin/types conditions, file existence).attw --profile esm-onlyon the tarball — declaration resolution for the module systems this package actually supports..js/.d.tsis a Node builtin or a declareddependenciesentry, and every declared entry is imported by something.exportssubpaths, and runs the CLI (--version,help,devices --json,doctor --remote --json) — the last two being the lazily-loaded daemon and remote-config halves where the 0.20.4 failure actually surfaced.Net: one script instead of one script plus ~15 lines of bespoke CI shell, and it is runnable locally rather than only on a runner.
The build no longer emits a publishable bundle in the first place. 0.20.4 shipped because rolldown warned about unresolved workspace imports and exited 0, and
prepackpacked the result.tsdown.config.tsnow escalatesUNRESOLVED_IMPORTto a build failure — the cheapest gate, at the source, with a message naming the fix.Two real defects the established tools found
exportsentries listedtypesafterimport. Conditions are order-sensitive: TypeScript matchedimportfirst and only found declarations because a sibling.d.tshappened to sit next to each.js. Resolution by accident, not by condition.publintreported all 12 as errors; the order is nowtypesfirst andpublint --strictis clean.pngjswas a runtimedependencythat no shipped code imports.tsdowninlines it viadeps.alwaysBundle, so every user installed a package nothing resolved. It moves todevDependencies(still needed to build). The closure audit is what surfaced this, and it now keeps the manifest and the bundle in agreement in both directions.Where the gate runs
package:npm— last, after the Apple/Android payloads exist, soprepack/npm publishverifies the complete real tarball. Publishing can no longer skip it. (Inner pack uses--ignore-scripts, so it cannot re-enterprepack.)check:tooling— replacescheck:bundle-dependencies.Packaged CLI Node 22.12— build on the toolchain Node, verify on theengines.nodefloor. The hand-rolled pack/global-install/run shell is gone.pnpm check:affected— newpackagecheck id, selected when a publicexportsentry changes.Validation
Both new gates were proven red before green, against the actual 0.20.4 failure mode rather than a synthetic one. Reproducing it required removing the workspace links (
node_modules/@agent-device) — the config migration in #1577 alone does not reproduce it, since these aredevDependenciesthat tsdown bundles by default:pnpm buildfails withUNRESOLVED_IMPORT(previously: warned, exit 0, publishabledist/).check:package: against adist/built with links removed, it fails naming every leaked specifier and file (@agent-device/ad-replay in dist/src/session.js, …) plusyamlas newly-unimported. Restoring the links returns it to green.exportsentry toimport-before-typesfails the gate; restored, clean.Green on the branch:
check:package(~11s end to end, leaves no temp dir and no running daemon),pnpm build,typecheck,lint,format:check,check:layering,depgraph:test,check:production-exports,check:mcp-metadata,check:bundle-owner-files,check:affected:test(35/35), andvitest relatedover the changed files (580 files / 5023 tests). Also confirmed theexportsreordering does not disturbpackageEntryFiles, which readsentry.importby key rather than positionally.Not covered locally: the macOS
Build npm packagejob, which is whereprepackexercises the gate against a tarball that includes the Swift and Android payloads.Tradeoffs and gaps
check:toolingnow touches the network.check:packageinstalls the tarball from the registry, sopnpm checkis no longer fully hermetic. Unavoidable for a check whose whole point is resolving the package the way a user does — and it is the same registrypnpm installalready needs. It adds ~11s.requirecomputed at runtime is invisible to the static audit. Neither alone closes the class.attwruns with--profile esm-only, which ignores the node10 and CJS-consumer rules. Those fail for this package by design (type: module,engines.node >= 22.12); every ESM and bundler resolution stays enforced.prepackjob — the gate verifies the JS/type contract and dependency closure, which are deterministic in both lanes.11 files touched. Scope stayed on packaging; the one adjacent edit is registering the new gate in the affected-check catalog, which that catalog's completeness assertion requires.
Docs
No docs or skills changes: no command behavior changes, and the gate is reachable through the existing aggregate scripts.
Generated by Claude Code