Skip to content

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

@proggeramlug

require.main === module is true inside 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 emits
require.main = module; for every compiled CommonJS module. So the standard entry-point check is trivially
true everywhere.

Minimal repro (no dependencies)

A throwaway CJS package containing console.log(require.main === module), required from another file:

  • Node: false
  • Perry: true

Why 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, call process.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.1 compiled from source, a plain
import 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 TypeScript namespace + export = handling, surfaced by
compiling 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.main should be set to the entry module only, so it compares equal in the process entry point and
unequal 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.

Activity

  1. added
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 19, 2026
  2. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    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.main must 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.

  3. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    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/U is the CLI entry point: it prints Usage: dotenv run [--help] [-q|--quiet] [--debug] [--override] [--fast] [-f|--file <paths>] [--] <command> [args...] and sets process.exitCode = 1. So under this bug, a plain import 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.main anywhere. Their config.js reads process.argv via lib/cli-options, but never tests entry-ness. The package restructured into a bundled dist/ 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 both require.main checks 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.

  4. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    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.main outside bin/: 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.main to module.parent, process.mainModule, import.meta.main and argv[1]. Only commander matched, and its hits are in Readme.md and typings/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 one dist/index.cjs and 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 bundled dist/, and that trend runs one way.

  5. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    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.7 6.0.1 — wrong major
    #10701 uuid ^11.0.0 14.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.mainModule or import.meta.main in 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.

  6. added 2 commits that reference this issue on Sep 19, 2026
    d3e0581
    974da5d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions