Skip to content

ci: check package exports and declarations with publint and attw - #39

Merged
jackmisner merged 5 commits into
mainfrom
chore/package-correctness-check
Oct 23, 2026
Merged

jackmisner merged 5 commits into
mainfrom
chore/package-correctness-check

Conversation

@jackmisner

Copy link
Copy Markdown
Owner

Why

Nothing in CI looked at this package the way a consumer does. npm run type-check is tsc --noEmit over src/ only, and npm run build succeeding says nothing about whether the emitted declarations resolve for someone importing the package.

That gap surfaced while reviewing the tsup 9 bump (#38): confirming it had not broken the /react and /server declarations meant packing the tarball by hand and type-checking a throwaway consumer against it. A real regression there would have shipped on a green CI.

What changed

  • publint and @arethetypeswrong/cli as devDependencies, behind one script:
    check:package = npm run build && publint --strict && attw --pack . --profile node16
  • .github/workflows/package-check.yml, a reusable workflow called from ci.yml as a package-check job (needs: format), with permissions: contents: read
  • a Check package exports and declarations step in publish.yml's ci gate job, which is the job publish hangs off via needs: ci
  • a package-check Dependabot group for the two tools
  • README.md and CLAUDE.md record the node10 resolution non-target and the check's policy

The check runs once rather than per Node version: the build output is byte-identical across 22/24/26, verified by diffing the bundles while reviewing #38.

How it was verified

The check has to fail on breakage, so each case below is a deliberate break of the real package, run through the real tools and reverted:

Case Result
exports["./react"].import.types → non-existent file publint exit 1, "the file does not exist"
root require.types → the ESM declaration publint exit 1, "interpreted as ESM when resolving with the require condition"
dist removed from files publint exit 1, 15 unpublished-file errors
built dist/react/index.d.ts imports a chunk the build never emitted publint exit 0, attw exit 1, InternalResolutionError on ./react
unmodified package (control) exit 0

The fourth case is the one that matters: the first three all short-circuit on publint, so without it attw would have gone in unproven. It is also the regression class that motivated the PR, and publint does not catch it. The inverse holds too — attw exits 0 on a tarball containing no declarations at all, so publint is what catches that. Neither tool is redundant; CLAUDE.md records this so neither gets dropped as duplicated effort.

Also run on the final tree: type-check, lint, format:check, 635 tests across 21 files, npm run build, and check:package from a cold npm ci.

The node10 decision

attw --pack . fails on main today, before this branch: legacy resolution cannot see exports subpaths, so ./react and ./server report Resolution failed. attw runs with --profile node16, which drops only the node10 row from the exit code while still printing it.

My first rationale for that was wrong and the branch says so. I argued typesVersions would publish types that lie, on the grounds that an exports-blind bundler still has nothing to resolve. Review showed that cohort already fails loudly at bundle time and cannot use the subpaths either way. The cohort that actually hits this is a Node 20+ CommonJS TypeScript project on the default resolution: require('@jackmisner/utm-toolkit/server') works at runtime while tsc gives TS2307. For them typesVersions would be accurate, not a lie.

It is still left out, but as a scope call rather than a correctness one, and the constraint is now documented instead of implied. --ignore-rules no-resolution was rejected on evidence: it suppresses NoResolution across every profile and paints node10 green, hiding future regressions.

Not in this PR

  • typesVersions for the two subpaths — changes the published package surface, so it gets its own PR.
  • permissions: contents: read on ci-node22/24/26.yml — they still inherit contents: write + pull-requests: write from ci.yml. Same finding as the one fixed here, pre-existing.
  • The publish job's own redundant build — Build package followed by npm publish, whose prepublishOnly runs clean && build && test, so that build's output is deleted before it is used. The same waste this PR removed from the ci job, pre-existing in the other job.
  • Required status checks — ruleset 12017343 has only deletion, non_fast_forward and pull_request. No CI check blocks a merge to main today, this one included. Being handled separately, once the real check-context names are readable off this PR's run.

Review

Reviewed by three agents over 78e2750...8d17573 and again over the fix commits, plus a claim audit on the final diff. Findings acted on: the token narrowing, the redundant build, the Dependabot group, the corrected node10 rationale, a wrong bundler TypeScript floor (it needs 5.0, not 4.7 — TS 4.9.5 rejects it with TS6046), and a missing Platform Support cross-reference. The audit also caught CLAUDE.md describing the node10 row as failing, which would lead a reader to expect a red check when --profile node16 prints it marked (ignored) and leaves it out of the exit code.

Nothing in CI looked at the package from a consumer's point of view.
`npm run type-check` covers src/ only, and `npm run build` succeeding
says nothing about whether the emitted declarations resolve for someone
importing the package. Reviewing the tsup 9 bump meant packing the
tarball and type-checking a throwaway consumer against it by hand to
rule out a dts regression on the react/server subpaths.

attw is what closes that gap: a declaration file that imports a chunk
the build never emitted passes publint and fails attw with an internal
resolution error on exactly those subpaths. publint covers the
complementary cases — missing files, unpublished files, crossed
ESM/CJS types conditions.

attw runs with --profile node16. The node10 profile fails today for the
./react and ./server subpaths because legacy resolution cannot see
exports subpaths. typesVersions would turn those cells green, but only
for types: `require('@jackmisner/utm-toolkit/react')` still has nothing
to resolve on disk for that cohort, so it would publish types that lie.
Declaring node10 unsupported is the honest option.

The job runs once rather than per Node version; the build output is
byte-identical across 22/24/26.
… its deps

Three review findings.

The package-check job inherited `contents: write` and `pull-requests: write`
from ci.yml and needs neither; it only reads the repo, installs, builds and
packs. A called workflow can only reduce the caller's scope, so declaring
`contents: read` here is enough.

publish.yml built twice: the standalone step, then `check:package`'s own
`npm run build`. tsup runs with `clean: true`, so the first build's output was
deleted before anything looked at it.

publint and @arethetypeswrong/cli get their own Dependabot group. Both are
pre-1.0, where a breaking change ships as a minor, and either can start
failing `check:package` against unchanged package code. In the grouped
npm-minor-patch PR that would block every unrelated bump behind it.
…olicy

A Node 20+ CommonJS TypeScript consumer with the default moduleResolution
gets TS2307 on @jackmisner/utm-toolkit/server and /react while the same
install resolves fine at runtime, because TypeScript's legacy resolution
cannot see exports subpaths. Nothing said so; the README's "All three ship
dual ESM/CJS builds with TypeScript declarations" reads as a promise that
those subpaths type-resolve anywhere.

The earlier rationale for --profile node16 was that typesVersions would
publish types that lie. That is wrong about who is affected: the cohort it
describes, exports-blind bundlers, already fails loudly at bundle time and
cannot use the subpaths either way. For the cohort that actually hits this
the runtime works, so typesVersions would be accurate rather than a lie. It
is still deliberately not added, but as a scope and maintenance call rather
than a correctness one, and that is now written down where the other
support-matrix decisions live.
… in Platform Support

`moduleResolution: bundler` needs TypeScript 5.0, not 4.7: on 4.9.5 it is
rejected with TS6046 while `node16` is accepted, so the single version floor
covered two of the three settings and not the third.

Platform Support is where a server consumer checks what is supported, and its
Node.js list ended by calling /server the supported server surface with no
mention of the one constraint a TypeScript consumer of it hits. The TypeScript
Types section above shows that exact subpath type import with no caveat, so a
reader could land there and copy an import that does not resolve under their
default settings.

The typesVersions clause recorded who decided rather than what to do; it now
reads as the instruction a dev reaching for it needs.
"The node10 row fails" sets up a reader to expect check:package to go red.
Under --profile node16 attw still prints the row, marks every cell (ignored)
and leaves it out of the exit code, so the wording described the wrong
consequence.

Scopes the runtime claim to Node, since an exports-unaware resolver is
exactly what a consumer stuck on node10 resolution may be using, and trims
the Dependabot group comment to the part the YAML cannot express: that the
absent update-types filter is deliberate.
@jackmisner jackmisner added documentation Improvements or additions to documentation github_actions Pull requests that update GitHub Actions code labels Oct 23, 2026
@jackmisner
jackmisner merged commit 36a1705 into main Oct 23, 2026
5 checks passed
@jackmisner jackmisner mentioned this pull request Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation github_actions Pull requests that update GitHub Actions code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant