How One esbuild Line in TypeScript Became 23 Lockfile Packages and an Install Script
In 2022 the TypeScript team moved the compiler’s own build to esbuild, a fast JavaScript bundler written in Go. The first commit of that work, 4139807e7 in microsoft/TypeScript, is titled “Add build via esbuild”. Its change to package.json is one line in devDependencies:
+ "esbuild": "^0.15.13",
package-lock.json grew from 742 packages to 765 in the same commit. We ran Remixware 0.3.1’s dependency gate on that commit against its parent, offline, to see what a reviewer would have been shown. The commands, hashes and full output are in the Remixware repository, and every number below comes from there.
What the Gate Printed
remixware deps . --base-ref 36e29448e9d3fb09da278472bf88ebab166f1084
It found 23 new packages and failed the change with exit code 1. The one with the most to say was esbuild itself:
INSTALL SCRIPT npm/esbuild 0.15.13, added (direct)
in package-lock.json
NOT ALLOWED: New, and not on the allow list. Add a deps allow rule with a
reason to the policy file, or drop it.
Runs an install script (preinstall, install or postinstall, or a node-gyp
build) when npm installs it. Accept it with a deps allow rule that has
install_scripts: true and a reason.
The other 22 are esbuild’s platform packages, such as esbuild-linux-64, esbuild-darwin-arm64 and @esbuild/android-arm, each at 0.15.13 and each listed as transitive and not on the allow list. The run ended like this:
Checks:
allow list ran, 23 added packages: 23 checked from the lockfiles
install scripts ran, 23 added or updated versions: 23 checked from the lockfiles
publisher changes not run, 23 added or updated versions: 23 not checked (needs
--online)
23 changes: 1 install-script, 22 not-allowed.
FAIL: 23 changes break the policy (fail on: not-allowed, install-script, publisher-changed, not-checked).
The install script check ran on all 23 without going online. TypeScript’s lockfile is version 2, and npm writes hasInstallScript into lockfiles of version 2 and 3, so the answer was already in the file.
Why a Reviewer Would Want to Look
esbuild ships its compiler as a native binary, one package per operating system and processor. npm installs only the one that fits the machine, and esbuild’s postinstall script makes sure that binary is in place. That’s a reasonable design for a native tool, and it’s also the kind of thing a team wants to know about before it happens on every developer’s laptop and every CI runner.
The diff a person reads shows one line. The lockfile shows 23 packages and one script. The gate puts the second view in front of the reviewer, who can then decide once and write the decision down.
Writing the Decision Down
Here is an example policy for that decision. It’s ours, written for this run; TypeScript doesn’t use Remixware.
{
"deps": {
"allow": [
{"package": "npm/esbuild", "install_scripts": true, "reason": "build tool; its install script fetches the binary for this platform"},
{"package": "npm/esbuild-*", "reason": "esbuild's platform binaries (0.15 and earlier)"},
{"package": "npm/@esbuild/*", "reason": "esbuild's platform binaries"}
]
}
}
With it, the same comparison passes with exit code 0 and lists all 23 as allowed, each with the reason that let it in. The first rule needs install_scripts: true. Without it, esbuild would still be allowed as a package and still fail for its script, since a rule for a package doesn’t accept what it runs at install time unless it says so.
The run also printed the three rules under “Allow rules this change adds to the policy”, because they weren’t on the base side. In a real pull request that list is what stops a change from quietly allowing itself. Five tested recipes for the dependency gate shows how --base-policy closes that door for pull requests you don’t trust.
What It Didn’t Check
The publisher check needs --online, and we left it off. It counts a publisher change only within the last 90 days by default, so on a 2022 release it would have nothing to say today. The dependency gate post explains both checks, and scripts/deps_case_studies.sh in the repository runs this comparison again from scratch.