fix(ext/node): re-export inner spec for module.exports = require(X).Y#34363
Conversation
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.
fibibot
left a comment
There was a problem hiding this comment.
reexports.push(spec)treats everymodule.exports = require("x").memberassignment as if the module re-exported all ofx. That over-exposes named exports whenmemberis only a sub-object, for examplemodule.exports = require("node:crypto").webcryptowould advertise names fromnode:cryptoinstead of only properties onwebcrypto. 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.
|
@fibibot Addressed in b036048. The previous commit treated every Now the analyzer carries the member name through ( Added a regression fixture ( |
fibibot
left a comment
There was a problem hiding this comment.
One blocker remains: member-shaped reexports are resolved for the entry module, but not for modules reached through normal CJS reexports.
analysis.member_reexportsis handled for the top-levelanalysisbefore returning fromanalyze_all_exports, but the recursive branch inanalyze_reexportsonly followsanalysis.reexportsand extendsanalysis.exports. Forindex.js: module.exports = require("./wrapper")wherewrapper.jsismodule.exports = require("./inner").safe, the wrapper function
fibibot
left a comment
There was a problem hiding this comment.
One blocker remains: member-shaped reexports are resolved for the entry module, but not for modules reached through normal CJS reexports.
analysis.member_reexportsis handled for the top-levelanalysisbefore returning fromanalyze_all_exports, but the recursive branch inanalyze_reexportsonly followsanalysis.reexportsand extendsanalysis.exports. Forindex.js: module.exports = require("./wrapper")wherewrapper.jsismodule.exports = require("./inner").safe, the wrapper function'sattachedproperty is never advertised. Callresolve_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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
CI is green now, promoting prior review to APPROVE.
| } | ||
| } | ||
|
|
||
| fn find_module_exports_require_member( |
There was a problem hiding this comment.
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.
| let parsed = module_export_analyzer | ||
| .parse_module(specifier, media_type, source_arc)?; |
There was a problem hiding this comment.
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?
| 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)) |
There was a problem hiding this comment.
This loops over the statements twice. I feel like it could do it once?
…#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
…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
deno_ast's CJS analyzer recognizes the bare-call form
module.exports = require("./inner")and surfaces the inner specifier asa re-export, but the very common member-access form
module.exports = require("./inner").Yfalls through unhandled. As aresult, 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 requirespecifier 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