DEV Community

Cover image for I Got Tired of Running Publint, attw, and Knip Separately—So I Built One Tool
Dreamy Developer
Dreamy Developer

Posted on

I Got Tired of Running Publint, attw, and Knip Separately—So I Built One Tool

If you publish npm packages, you probably know the pre-publish ritual: run publint, read the output. Run attw (are-the-types-wrong), read a different output. Run knip, read a third output. Mentally merge all three in your head, fix whatever's broken, repeat.

I do this every time I'm about to cut a release, and at some point last week I just got annoyed enough to fix it. So I built pubcheck — it runs all three tools together and gives you one pass/fail verdict instead of three separate reports to reconcile.

pubcheck  ✔ publint   ✔ attw   ✖ knip (3 issues)

✖ FAIL — 0 errors, 3 warnings

knip
  ✖ unused-exports: formatDate (src/utils.ts)
  ✖ unused-exports: Config (src/types.ts)
  ✖ unused-dependencies: lodash
Enter fullscreen mode Exit fullscreen mode

Exit code 0 or 1, so it slots straight into a prepublishOnly script or CI.

Why these three specifically

Each one catches something real, and none of them catches what the other two do:

  • publint checks whether your package.json actually makes sense — broken exports maps, a main field pointing at a file that doesn't exist, ESM/CJS mismatches. This is the stuff that works fine on your machine and then breaks for exactly one user with a weird bundler config.
  • attw checks whether your type declarations resolve the same way your actual code does, across node10/node16/bundler resolution. You can ship perfectly valid JS and completely broken types and never notice, because tsc on your own repo doesn't catch it — only checking the published artifact does.
  • Knip finds dead code and unused dependencies. Not really a "will this break for someone" check, more of a "why is this package 40kb bigger than it needs to be" check.

None of them overlap much, which is exactly why running only one gives you false confidence.

The build

Nothing exotic — TypeScript, tsup for the dual ESM/CJS build, commander for the CLI, cosmiconfig for the config file. publint and attw's core package both expose clean programmatic APIs so I call those directly. Knip I ended up shelling out to instead of using its programmatic export — it mutates process.cwd() and console output internally in ways that felt too fragile to embed, so I just run knip --reporter json as a subprocess and parse that. The JSON reporter is the one output shape Knip actually documents as stable.

The three checks run in parallel with Promise.allSettled, so a crash in one doesn't take down the others — you get a runError on that tool's result instead of the whole CLI dying.

The bug that almost shipped

debugging story
Here's the part I actually want to talk about, because it's the kind of thing that makes you second-guess a "quick weekend project."

Right after publishing, I ran pubcheck against an already-installed copy of itself sitting in node_modules — just poking at it, not expecting anything interesting. publint came back with nine errors, every single one some variant of "this file is not published, is it specified in pkg.files?" — for files that were obviously right there on disk.

First theory: race condition. attw calls npm pack internally to inspect what actually ships, and I had it writing the tarball straight into the project directory while publint was scanning that same directory at the same time (they all run in parallel, remember). Seemed plausible, I "fixed" it by packing to an isolated temp directory instead, republished, tested again.

Same nine errors. Exactly the same, in the exact same order. Which was the tell I'd missed the first time — a race condition gives you inconsistent results between runs. Identical output every time means something deterministic, not timing-dependent.

Turned out the actual cause was almost funny: publint auto-detects which package manager to use for its own internal packing step by walking up the directory tree looking for a lockfile. My test project had a yarn.lock sitting a couple of levels up (because I'd installed with yarn add), so publint quietly decided to use yarn pack instead of npm pack — and yarn pack (classic Yarn 1) has a real, reproducible bug when the target directory is nested inside someone else's node_modules. It silently returns basically nothing, so every file check fails.

None of that has anything to do with what actually gets published, though — npm publish is what ships to the registry regardless of what package manager the author used locally to develop it. So the fix was just forcing publint's pack option to "npm" explicitly instead of letting it auto-detect. One line. Took a lot longer to find than to fix.

I'm mentioning this mostly because it's a good reminder that "it works when I test it" and "it works" are not the same claim, and the fastest way to close that gap is to immediately go run your own tool against something adjacent to your own use case — in my case, literally just checking my own package once it was sitting in someone else's node_modules, the way a real user would actually encounter it.

Trying it

yarn add -D pubcheck-cli
npx pubcheck
Enter fullscreen mode Exit fullscreen mode

or without installing:

npx pubcheck-cli
Enter fullscreen mode Exit fullscreen mode

There's also a programmatic API if you want the verdict object in your own scripts instead of parsing stdout:

import { runCheck } from "pubcheck-cli";

const verdict = await runCheck({ cwd: "./packages/my-lib" });
if (!verdict.pass) process.exit(1);
Enter fullscreen mode Exit fullscreen mode

It's early — 0.1.x, and I found (and fixed) two real bugs in the first day it was live, which tells you something about how much more testing across different setups it probably still needs. If you publish packages and try it, I'd genuinely like to know if it breaks on your setup. Repo's at github.com/Dreamyplayer/pubcheck-cli, issues and PRs welcome.

Top comments (0)