Skip to content

fix(ext/node): re-export inner spec for module.exports = require(X).Y#34363

Merged
bartlomieju merged 3 commits into
mainfrom
fix/cjs-runtime-export-introspection
May 28, 2026
Merged

fix(ext/node): re-export inner spec for module.exports = require(X).Y#34363
bartlomieju merged 3 commits into
mainfrom
fix/cjs-runtime-export-introspection

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

deno_ast's CJS analyzer recognizes the bare-call form
module.exports = require("./inner") and surfaces the inner specifier as
a re-export, but the very common member-access form
module.exports = require("./inner").Y falls through unhandled. As a
result, packages whose main entry has that shape (graphql-tag@2 is the
canonical example) expose no named exports — import { gql } from "npm:graphql-tag" fails with "does not provide an export named 'gql'".

When static analysis comes back with no exports and no re-exports, walk
the program's top-level statements looking for the
module.exports = require(LIT)[.IDENT]* pattern and treat the require
specifier as a re-export. The existing recursive re-export machinery
then resolves the inner module and picks up its named exports, which
produces the same set of names Node exposes for these packages.

Fixes #25311

deno_ast's CJS analyzer recognizes `module.exports = require("./inner")`
but not the member-access form `module.exports = require("./inner").Y`,
which causes packages like graphql-tag@2 to surface no named exports —
`import { gql } from "npm:graphql-tag"` fails with
"does not provide an export named 'gql'".

When static analysis returns no exports and no reexports, scan the
program's top-level statements for the `module.exports = require(LIT)[.IDENT]*`
pattern and surface the require specifier as a re-export. The existing
recursive re-export machinery then picks up the inner module's named
exports.

Fixes #25311.
@bartlomieju bartlomieju changed the title fix(node): re-export inner spec for module.exports = require(X).Y fix(ext/node): re-export inner spec for module.exports = require(X).Y May 25, 2026

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. reexports.push(spec) treats every module.exports = require("x").member assignment as if the module re-exported all of x. That over-exposes named exports when member is only a sub-object, for example module.exports = require("node:crypto").webcrypto would advertise names from node:crypto instead of only properties on webcrypto. Please narrow this to the specific member shape you can prove is safe, or add a guard/test that prevents importing unrelated names through the wrapper.

Addresses the review feedback on the previous commit: treating every
`module.exports = require(X).MEMBER` shape as a wildcard re-export of
all of `X`'s named exports over-exposed names that may not actually be
properties of `MEMBER`. For example, a wrapper that does
`module.exports = require("node:crypto").webcrypto` would advertise
names like `createHash` that aren't reachable through `webcrypto`.

Carry the member name through analysis (new `member_reexports` on
`ModuleExportsAndReExports` / `CjsAnalysisExports`). At the recursive
analyze step, resolve the inner specifier, look up the identifier bound
to `exports.MEMBER` in the inner module, and advertise only the
property names statically assigned to that identifier. When the value
can't be statically resolved to such an identifier, advertise nothing
under the member shape. A regression test fixture demonstrates both:
properties attached to the member surface as named exports, while a
sibling named export that the member does not carry is correctly not
advertised.
@bartlomieju

Copy link
Copy Markdown
Member Author

@fibibot Addressed in b036048. The previous commit treated every module.exports = require(X).MEMBER shape as a wildcard re-export of all of X's named exports, which over-exposed names that aren't actually properties of MEMBER (the node:crypto.webcrypto case you flagged).

Now the analyzer carries the member name through (member_reexports on ModuleExportsAndReExports / CjsAnalysisExports) and the recursive analyze step resolves the inner specifier, finds the identifier bound to exports.MEMBER in the inner module, and only advertises the property names statically assigned to that identifier (IDENT.X = ...). When the member value can't be resolved to such an identifier, no names are advertised under the member shape.

Added a regression fixture (@denotest/cjs-module-exports-require-member-narrow) where inner exposes a named export (loose) that is NOT a property of the re-exported member (safe). The loose_is_not_advertised spec test asserts that import { loose } from ... fails at runtime with "does not provide an export named 'loose'", and the attached_works test confirms that names actually attached to the member still surface.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One blocker remains: member-shaped reexports are resolved for the entry module, but not for modules reached through normal CJS reexports.

  1. analysis.member_reexports is handled for the top-level analysis before returning from analyze_all_exports, but the recursive branch in analyze_reexports only follows analysis.reexports and extends analysis.exports. For index.js: module.exports = require("./wrapper") where wrapper.js is module.exports = require("./inner").safe, the wrapper function

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One blocker remains: member-shaped reexports are resolved for the entry module, but not for modules reached through normal CJS reexports.

  1. analysis.member_reexports is handled for the top-level analysis before returning from analyze_all_exports, but the recursive branch in analyze_reexports only follows analysis.reexports and extends analysis.exports. For index.js: module.exports = require("./wrapper") where wrapper.js is module.exports = require("./inner").safe, the wrapper function's attached property is never advertised. Call resolve_member_reexports(&reexport_specifier, &analysis.member_reexports, ...) in that branch too, and add a nested fixture for this shape.

The entry-module path called `resolve_member_reexports` for member-shaped
re-exports, but the recursive branch in `analyze_reexports` only walked
`analysis.reexports` and extended `analysis.exports`, ignoring
`analysis.member_reexports` on modules reached through normal CJS
re-exports. So for `main.js: module.exports = require("./wrapper")` where
`wrapper.js` is `module.exports = require("./inner").safe`, the wrapper's
narrowed names never surfaced on the entry.

Resolve member re-exports inside the recursive loop too, using the
wrapper as the referrer. Add a nested fixture
(`@denotest/cjs-module-exports-require-member-nested`) and spec tests
covering both that the wrapper's attached property is advertised through
the chain and that the inner module's unrelated `loose` export is not.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new head fixes the recursive blocker: analyze_reexports() now calls resolve_member_reexports(&reexport_specifier, &analysis.member_reexports, ...) before extending exports, so module.exports = require("./wrapper") picks up names attached to require("./inner").safe. The nested fixture covers both attached being advertised and loose staying hidden. Holding approval until CI is green; the run is still in progress and deno_core test linux-x86_64 is red without logs available yet.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI is red on deno_core test linux-x86_64; that shard is outside this PR's CJS export-analysis path, and gh run view --log-failed did not expose a concrete failure log. CI red; flake-watcher will triage, so I'm still not approving this head.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI is green now, promoting prior review to APPROVE.

@bartlomieju
bartlomieju merged commit fd5c160 into main May 28, 2026
268 of 270 checks passed
@bartlomieju
bartlomieju deleted the fix/cjs-runtime-export-introspection branch May 28, 2026 13:18
}
}

fn find_module_exports_require_member(

@dsherret dsherret May 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is all this code here instead of in deno_ast?

Edit: Ah, actually I guess it is different and this is probably fine. It just feels like this should be in deno_ast with the rest of the cjs analysis though.

Comment on lines +330 to +331
let parsed = module_export_analyzer
.parse_module(specifier, media_type, source_arc)?;

@dsherret dsherret May 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems inefficient. Maybe I'm not getting why parsing the whole module is required. Won't this potentially cause a module to be parsed many times?

Comment on lines +128 to +134
let ident = find_member_value_ident(&stmts, member)?;

// Collect property names statically assigned to that identifier
// at the top level: `IDENT.X = ...`.
let mut props: Vec<String> = stmts
.iter()
.filter_map(|stmt| match_identifier_property_assignment(stmt, &ident))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loops over the statements twice. I feel like it could do it once?

bartlomieju added a commit that referenced this pull request May 30, 2026
bartlomieju added a commit that referenced this pull request Jun 2, 2026
…#34689)

Importing a named export from graphql-tag still failed with "does not
provide an export named 'gql'", even though #34363 added handling for
the `module.exports = require("X").MEMBER` entry shape that graphql-tag@2
uses. That fix narrows the wrapper's named exports to the names
statically attached to MEMBER inside X, which works when the attachments
are top-level but not for graphql-tag's actual inner file: a UMD bundle
that performs its `exports.gql = ...` and `gql.* = ...` assignments
inside the factory IIFE and builds the value through a namespace alias. Static
narrowing finds nothing there, so the wrapper advertised no names at
all.

When the member's attached names cannot be determined statically, fall
back to re-exporting the inner module wholesale rather than advertising
nothing. This matches Node, which over-approximates `require(X).Y` to
the inner module's full set of named exports. The narrow path is unchanged
for the cases where the attachments are statically known, so unrelated
names still do not leak through there.

Closes #16708
Closes #25311
littledivy pushed a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
…denoland#34363)

deno_ast's CJS analyzer recognizes the bare-call form
`module.exports = require("./inner")` and surfaces the inner specifier
as a re-export, but the very common member-access form
`module.exports = require("./inner").Y` falls through unhandled. As a
result, packages whose main entry has that shape (graphql-tag@2 is the
canonical example) expose no named exports — `import { gql } from
"npm:graphql-tag"` fails with "does not provide an export named 'gql'".

When static analysis comes back with no exports and no re-exports, walk
the program's top-level statements looking for the
`module.exports = require(LIT)[.IDENT]*` pattern and treat the require
specifier as a re-export. The existing recursive re-export machinery
then resolves the inner module and picks up its named exports, which
produces the same set of names Node exposes for these packages.

Fixes denoland#25311
littledivy pushed a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: No named export gql in graphq-tag found

3 participants