Repository navigation
require.main === module is true in EVERY compiled CommonJS module, so any package with a CLI entry guard runs its CLI branch when merely imported #10735
Description
Activity
- addedpackage-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
on Sep 19, 2026 Picked up — a fix agent is working this now, so please don't start a second one on it.
Prioritised ahead of the native-binding removal queue deliberately: every removal's acceptance run is contaminated while this is live, so a package that fails for this reason looks like it failed for its own reason. Expect some already-recorded package failures to turn out to be this bug rather than what they were attributed to; that re-check is part of the task.
Scope being covered, all against Node 26.5.1:
- entry module:
require.main === module->true - a required dependency ->
false - a dependency required two levels deep
- a module required from both the entry and a dependency (required once, cached —
require.mainmust not change between the two) - whatever Node actually does for an ESM entry importing CJS, rather than assuming it is
undefined - the real-world shape: a dependency whose body is
if (require.main === module) { console.log("CLI"); process.exit(1); }— importing it must print nothing and exit 0
Fix direction is to thread the entry module Perry already knows about, not to add a new mechanism. Since this touches the CJS preamble emitted for every module, it carries an instruction-count A/B.
- entry module:
Confirming the real-world instance cited above by reading the package sources, and establishing the version boundary — which turns out to matter for the removal queue.
dotenv 18.0.1 (
dist/index.cjs) ends with:require.main===module&&ve(process.argv.slice(2));
and carries a second guard internally:
require.main===S&&U(process.argv.slice(2))
ve/Uis the CLI entry point: it printsUsage: dotenv run [--help] [-q|--quiet] [--debug] [--override] [--fast] [-f|--file <paths>] [--] <command> [args...]and setsprocess.exitCode = 1. So under this bug, a plainimport dotenv from "dotenv"takes the CLI branch — which is the behaviour reported when the binding was removed.dotenv 16.4.5 and 17.2.3 contain no
require.mainanywhere. Theirconfig.jsreadsprocess.argvvialib/cli-options, but never tests entry-ness. The package restructured into a bundleddist/at 18, and the guard arrived with it.Two consequences worth recording:
1. The guard survives bundling. 18.0.1 ships a pre-bundled
dist/, and bothrequire.mainchecks are intact in the bundled output. This widens the blast radius rather than narrowing it: bundling is precisely what takes a package's CLI entry point and puts it in the same file an ordinary library import reaches. Any package that bundles a CLI and a library together is a candidate.2. PR #10691 (remove dotenv native binding) is provably unaffected, because it declares
"dotenv": "^17.2.3"— resolving within 17.x, never reaching 18. Its acceptance run cannot have been contaminated by this bug. Noting it here so the two do not get entangled: a failure on 18.0.1 belongs to this issue, not to that PR.dotenv 18.0.1 is being used as a real-world validation fixture for the fix, alongside the synthetic cases. It is stronger evidence than a purpose-written fixture precisely because it wasn't written to suit the fix: with the bug, importing it must print the usage banner and exit non-zero; with the fix, it must print nothing, behave as a library, and exit 0 byte-identically to Node 26.5.1.
Bounding the practical blast radius, since the earlier framing here ("any package with a CLI entry guard") was an inference from a single observation and is wider than what the packages we actually compile look like.
Scanned 21 packages — the native-binding removal queue plus the next probe wave — for entry-detection idioms in importable code (excluding
bin/):Removal queue,
require.mainoutsidebin/: uuid 14.0.2, nanoid 6.0.1, lru-cache 11.5.3, commander 15.0.0, decimal.js 10.6.0, pg 8.23.0, mysql2 3.24.4, axios 1.20.0, validator 13.15.35, jsonwebtoken 9.0.3 — none.Next probe wave: moment 2.31.0, cheerio 1.2.0, nodemailer 10.0.10, ioredis 6.0.0, undici 8.10.2, cron 4.4.0, node-forge 1.4.0, exponential-backoff 3.1.3, ws 8.21.3, redis 6.2.1 — none.
Only dotenv 18.0.1 matches.
Method notes, since a scan that finds nothing is worth less than one that demonstrates it can find something:
- The pattern was widened beyond
require.maintomodule.parent,process.mainModule,import.meta.mainandargv[1]. Only commander matched, and its hits are inReadme.mdandtypings/index.d.ts— documentation and type declarations, not executable code. - Control: the same scan across dotenv 16.4.5 / 17.2.3 / 18.0.1 lights up on 18.0.1 alone. So the scan can find a positive; a clean result elsewhere is a finding rather than a broken regex.
- Caveat: these are current versions, not the versions each PR declares. dotenv is precisely a case where the guard exists in one major and not the previous two, so this is suggestive rather than conclusive for any consumer pinning an older major.
What this changes. No native-binding removal currently queued has an acceptance run contaminated by this bug — the one package that had the guard is the one it was found in. Removals do not need to be sequenced behind this fix.
What it does not change. This is still worth fixing early, on the general argument rather than on the current queue's needs: bundling is the scaling factor. Unbundled, a CLI guard sits in a
bin/file that a library import never loads. Bundled, the CLI and the library share onedist/index.cjsand the guard lands on the path everyone imports — which is exactly how dotenv 18 reaches it. The defect scales with how many dependencies ship a bundleddist/, and that trend runs one way.- The pattern was widened beyond
Closing the version caveat on the scan above — the result is now conclusive for the removal queue rather than suggestive.
I pulled each removal PR's declared dependency spec instead of relying on the latest published version. Two had drifted a major:
PR declares previously scanned #10693 nanoid ^5.0.76.0.1 — wrong major #10701 uuid ^11.0.014.0.2 — wrong major The rest were within their declared ranges already (pg
^8, axios^1, mysql2^3, dotenv^17.2.3, decimal.js^10.6.0, lru-cache^11.5.2, commander^15.0.0).Re-scanned nanoid 5.0.7 and uuid 11.0.0 at their declared majors: both clean — no
require.main,module.parent,process.mainModuleorimport.meta.mainin importable code.So no removal in the queue installs a package version carrying an entry-detection guard, and none of their acceptance runs can be contaminated by this bug.
The two mismatches are worth recording as method rather than trivia, because they are the same shape that made the dotenv case confusing: a guard present in one major and absent in its neighbours, with the scan run against a version nobody installs. Either direction produces a wrong answer that looks clean — a false positive against a PR that never installs the offending version, or a false clean when the declared major is the one that carries it. Scan the version the consumer actually declares, not the one
npm pack <name>gives you.- added 2 commits that reference this issue
on Sep 19, 2026
require.main === moduleistrueinside every compiled CommonJS module, not just the process entry point.crates/perry/src/commands/compile/cjs_wrap/wrap.rs:1138— Perry's CJS preamble unconditionally emitsrequire.main = module;for every compiled CommonJS module. So the standard entry-point check is triviallytrue everywhere.
Minimal repro (no dependencies)
A throwaway CJS package containing
console.log(require.main === module), required from another file:falsetrueWhy this matters more than it looks
if (require.main === module) { … }is the CommonJS idiom for "am I being run directly, or merely imported?".Packages use it to decide whether to run CLI behaviour — parse
process.argv, print usage, callprocess.exit().Under Perry every such package takes the CLI branch when it is merely imported as a library.
Observed concretely: with the native dotenv binding removed and real
dotenv@18.0.1compiled from source, a plainimport dotenv from "dotenv"prints dotenv's bundled CLI usage banner and the process exits 1 instead of 0— purely from this. The library functions themselves work correctly.
This was masked, and that is the interesting part
The defect is general and pre-existing, not introduced by any recent change. It was invisible because the native
dotenv binding intercepted the import, so the real npm source was never compiled. Removing the binding is what
first exercises the path.
That is worth noting for the binding-removal campaign generally (#10678 and the removal PRs): every binding we
delete stops masking whatever compiler defects the real package's code exercises. This is the second finding of
that shape today — the first being
agent-base's TypeScriptnamespace+export =handling, surfaced bycompiling real axios (#10662 → PR #10673). Expect more, and treat them as latent debt being uncovered rather
than as regressions caused by the removals.
Fix direction
require.mainshould be set to the entry module only, so it compares equal in the process entry point andunequal everywhere else. The preamble needs to distinguish the entry module from a required dependency — that
distinction already exists elsewhere in the compile path, since Perry knows which module is the entry.
Found while rebasing PR #10691 (remove dotenv native binding); not fixed there, since it is a compiler-wide CJS
defect rather than anything dotenv-specific.