Reparse CJS export assignments only when not shadowed by locals#3345
Reparse CJS export assignments only when not shadowed by locals#3345ahejlsberg wants to merge 9 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves CommonJS export assignment detection in JavaScript parsing by tracking whether module / exports identifiers are shadowed by locals, and suppressing the CommonJS reparsing step when shadowing is present (addressing #2656).
Changes:
- Add parser-level
shadowFlagstracking formodule/exportsbindings and use it to gate CommonJS reparsing. - Extend binding/checking to better handle CommonJS export constructs (including a new checker path for
CommonJSExport). - Add new compiler tests + baseline updates covering shadowed
module/exportsscenarios and the original regression.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
internal/parser/parser.go |
Introduces ShadowFlags state and updates parsing to track and scope shadowing. |
internal/parser/reparser.go |
Gates CommonJS reparsing (module.exports =, exports.x =) on shadowFlags. |
internal/checker/checker.go |
Adds CommonJSExport checking to emit TS2309-style diagnostics in ESM contexts. |
internal/binder/binder.go |
Treats additional JS assignment kinds as expando assignments for binding. |
internal/ast/utilities.go |
Extends declaration-name helpers to recognize module.exports = assignments. |
testdata/tests/cases/compiler/*.ts |
Adds new regression/coverage tests for shadowed module/exports. |
testdata/baselines/reference/** |
Updates expected baselines impacted by the new detection/checking behavior. |
| saveShadowFlags := p.shadowFlags | ||
| statements := p.parseList(PCBlockStatements, (*Parser).parseStatement) | ||
| p.shadowFlags = saveShadowFlags |
There was a problem hiding this comment.
Saving/restoring shadowFlags for every { ... } block treats shadowing as purely block-scoped. That’s incorrect for var (and function declarations in non-strict modes), which are function/global scoped even when declared inside a nested block.
Example: if (cond) { var module = {}; } module.exports = 1; should treat module as shadowed after the block, but restoring shadowFlags here will clear the shadow and may incorrectly reparse module.exports as a CommonJS export assignment.
Suggestion: track shadowing with a function-scope layer (for var/hoisted decls) separate from a block-scope layer (for let/const/catch), or otherwise propagate function-scoped shadowing out of the block before restoring.
| saveShadowFlags := p.shadowFlags | |
| statements := p.parseList(PCBlockStatements, (*Parser).parseStatement) | |
| p.shadowFlags = saveShadowFlags | |
| statements := p.parseList(PCBlockStatements, (*Parser).parseStatement) |
There was a problem hiding this comment.
This is a known limitation. Tracking in the parser can't fully handle hoisting because usages may precede a declaration.
| @@ -1326,6 +1337,7 @@ func (p *Parser) parseForOrForInOrForOfStatement() *ast.Node { | |||
| p.parseExpected(ast.KindCloseParenToken) | |||
| result = p.factory.NewForStatement(initializer, condition, incrementor, p.parseStatement()) | |||
| } | |||
| p.shadowFlags = saveShadowFlags | |||
| p.finishNode(result, pos) | |||
There was a problem hiding this comment.
Restoring shadowFlags at the end of parsing a for (...) ... statement will drop shadowing introduced by var in the initializer, even though var bindings are visible after the loop.
Example: for (var module of xs) {} module.exports = 1; should treat module as shadowed in the subsequent statement, but the restore here will clear it and may incorrectly reparse module.exports.
Suggestion: only unwind block-scoped shadowing here, while preserving function-scoped shadowing (e.g., var bindings) so it remains in effect after the loop.
There was a problem hiding this comment.
Again, this is a known limitation.
| func (p *Parser) updateShadowFlags(name *ast.Node) { | ||
| if p.isJavaScript() && name != nil { | ||
| if ast.IsModuleIdentifier(name) { | ||
| p.shadowFlags |= ShadowFlagsModule | ||
| } | ||
| if ast.IsExportsIdentifier(name) { | ||
| p.shadowFlags |= ShadowFlagsExports | ||
| } | ||
| } |
There was a problem hiding this comment.
shadowFlags is parser-global state, so it needs to be saved/restored for all constructs that introduce a new binding scope. The current saves/restores cover blocks, for, catch, and some function/class forms, but arrow functions and switch case blocks don’t appear to reset shadowFlags when exiting their scope.
That can cause bindings like (module) => {} or switch { case 0: let exports; } to permanently set ShadowFlagsModule/Exports and suppress later CommonJS export reparsing at the outer scope.
Suggestion: apply the same save/restore pattern around arrow function parameter+body parsing and around parseCaseBlock (or otherwise centralize scope management for shadow tracking).
There was a problem hiding this comment.
Arrow functions are now handled. Case blocks don't have their own scope so need no additional handing.
|
What's funny is that I think That being said, that does not mean we have to do so for these, just that their ESM loader won't error if you bind them from ESM (they'll just be undefined I think). |
I played with it a bit, and sure enough, it doesn't do any scope analysis. So you could argue we should just leave it the way it is without this PR. However, in #2656 it is unfortunate that we detect CJS when there are ESM constructs that clearly make the example an ES module. Strada detects ESM at parse time and then ignores CJS constructs at bind time because the module is marked ESM. We can't really do that with reparsing because the ESM constructs may come later in the file (as is the case in #2656). So not clear what to do there. |
| jsdocScannerInfoHasSeeOrLink | ||
| ) | ||
|
|
||
| type ShadowFlags int32 |
There was a problem hiding this comment.
The type doesn't need to be exported either, technically
Yeah. #3167 was attempting to solve this in the binder for that reason. All in all I'm getting less and less happy with reparsing; we are basically doing a bit of binder-esque work in the parser with this PR 😄 |
Agreed. At this point I'm thinking the easiest way to solve this is to get rid of the |
This PR improves our detection of CJS export assignments (
module.exports = ...andexports.xxx = ...) by tracking local shadowing declarations ofmoduleandexportsidentifiers in the parser and only reparsing CJS export assignments when no shadowing local declarations are present. Tracking in the parser allows us to preserve our reparsing strategy which we'd likely have to abandon to fully solve the issue in the binder. The parser tracking can't account for hoisted declarations and thus technically isn't 100% accurate, but it is far better than nothing.In addition to #2656, this fixes an issue that exists in both Strada and Corsa where a simple file like the following
is considered a CJS module even though it shouldn't be.
Fixes #2656.