Skip to content

Reject bindings of a module that get the same JavaScript name - #8982

Merged
cknitt merged 1 commit into
masterfrom
drift-8974-js-name-clash
Oct 23, 2026
Merged

cknitt merged 1 commit into
masterfrom
drift-8974-js-name-clash

Conversation

@cknitt

@cknitt cknitt commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Fixes #8974.

Ext_ident.convert is not injective: delete and $$delete both become $$delete, and an exotic name such as \"a-b" becomes a$b, the same as the plain a$b that #8791 allows. Since export names and module object keys are that conversion of the source name, two bindings could end up with one JavaScript name:

  • top level, ES modules: export { $$delete, $$delete$1 as $$delete }, which Node rejects;
  • top level, CommonJS: exports.$$delete assigned twice, so the first binding is lost;
  • nested modules: { $$delete: 3, $$delete: 4 }, so M.delete reads the wrong value.

Renaming one of the exports isn't possible, because importers compute an export's name from the source name alone. So the type checker now rejects such a module, at the second binding:

This value has the same JavaScript name $$delete
as the value at A.res:1:5-10.
Rename one of them.

Typemod.check_js_names runs on each structure's signature (after shadowed values are dropped) and on each written signature: module types, and .resi files at the top level. Every module block is built from one of these. Values, extension constructors and modules count; externals, types and internal.local modules don't. The exports of a compilation unit keep default as written, so default beside $$default is still allowed at the top level and rejected only in nested modules.

The new check also covers the case that Lam_coercion reported as "M is exported twice" (an exception and a module with the same name), so that check and the Bs_duplicate_exports error are removed, along with Hash_set_string, which was used only there. gpr_978 now expects the new message.

The flat A$B$make exports of nested make values are added after type checking and are outside this check. They already have one of their own: a top-level let \"A$B$make" beside A.B.make is rejected with "Cannot export A.B.make as A$B$make because that name is already exported."

Tests

  • super_errors fixtures js_name_clash_keyword, js_name_clash_nested (default/$$default), js_name_clash_mangled (\"a-b"/a$b), js_name_clash_module (exception and module) and js_name_clash_module_type.
  • super_errors_multi fixture Js_name_clash_interface for a .resi.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Xk6AeCYpCK4SZRtHYKHGoe

Why:
    `Ext_ident.convert` maps both `delete` and `$$delete` to `$$delete`,
    and `\"a-b"` like `a$b`, so two bindings could share an export name or
    a module object key: the ES export list repeated a name, which Node
    rejects, and CommonJS output and nested modules lost a binding. The
    type checker now rejects such a module, at the second binding. This
    replaces Lam_coercion's "is exported twice" check, which only covered
    equal source names and had no location.

Diagnostic added (JavaScript name clash):
Example:
    let delete = 1
    let $$delete = 2
Before (js):
    let $$delete = 1;
    let $$delete$1 = 2;
    export {
      $$delete,
      $$delete$1 as $$delete,
    }
After (diagnostic):
    This value has the same JavaScript name $$delete
    as the value at A.res:1:5-10.
    Rename one of them.

Diagnostic modified (exception and module of one name):
Example:
    exception M(int)
    module M = {
      let x = 3
    }
Before (diagnostic): M is exported twice
After (diagnostic):
    This module has the same JavaScript name M
    as the extension constructor at A.res:1:1-16.
    Rename one of them.

Review: Why not rename one of the two exports? Importers derive an export's name from the source name alone (`js_export_name`), so a renamed export would not be found by them; the clash has to be resolved in the source.
Review: Which signatures are checked? Each structure's signature and each written signature (module types and `.resi` files), since every module block is built from one of them. The exports of a compilation unit keep `default` as written, so only nested modules reject `default` beside `$$default`.

Signed-off-by: Christoph Knittel <ck@cca.io>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xk6AeCYpCK4SZRtHYKHGoe
@github-actions

Copy link
Copy Markdown

Semantic Diff

1 commit · Full page

Reject bindings of a module that get the same JavaScript name

a781efb · Christoph Knittel with Claude Opus 5.5 · Oct 23, 2026

Why

Ext_ident.convert maps both delete and $$delete to $$delete, and \"a-b" like a$b, so two bindings could share an export name or a module object key: the ES export list repeated a name, which Node rejects, and CommonJS output and nested modules lost a binding. The type checker now rejects such a module, at the second binding. This replaces Lam_coercion's "is exported twice" check, which only covered equal source names and had no location.

Diagnostic added (JavaScript name clash)

let delete = 1
let $$delete = 2

js · Before

let $$delete = 1;
let $$delete$1 = 2;
export {
  $$delete,
  $$delete$1 as $$delete,
}

diagnostic · After

This value has the same JavaScript name $$delete
as the value at A.res:1:5-10.
Rename one of them.

Diagnostic modified (exception and module of one name)

exception M(int)
module M = {
  let x = 3
}

diagnostic

-M is exported twice
+This module has the same JavaScript name M
+as the extension constructor at A.res:1:1-16.
+Rename one of them.

Review

  • Why not rename one of the two exports? Importers derive an export's name from the source name alone (js_export_name), so a renamed export would not be found by them; the clash has to be resolved in the source.
  • Which signatures are checked? Each structure's signature and each written signature (module types and .resi files), since every module block is built from one of them. The exports of a compilation unit keep default as written, so only nested modules reject default beside $$default.

@codecov

codecov Bot commented Oct 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.23%. Comparing base (9e2f371) to head (a781efb).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #8982   +/-   ##
=======================================
  Coverage   81.22%   81.23%           
=======================================
  Files         457      457           
  Lines       63862    63873   +11     
=======================================
+ Hits        51875    51889   +14     
+ Misses      11987    11984    -3     
Files with missing lines Coverage Δ
compiler/core/js_implementation.ml 83.89% <ø> (ø)
compiler/core/lam_coercion.ml 100.00% <ø> (+2.50%) ⬆️
compiler/depends/bs_exception.ml 25.00% <ø> (-30.56%) ⬇️
compiler/ml/typemod.ml 75.27% <100.00%> (+0.52%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pkg-pr-new

pkg-pr-new Bot commented Oct 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@8982

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@8982

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@8982

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@8982

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@8982

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@8982

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@8982

commit: a781efb

@github-actions

Copy link
Copy Markdown

@cknitt
cknitt marked this pull request as ready for review October 23, 2026 09:24
@cknitt

cknitt commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T09:30:00.534151Z a781efb Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: a781efbe70

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cknitt
cknitt requested a review from cristianoc October 23, 2026 09:34
@cknitt
cknitt enabled auto-merge (squash) October 23, 2026 09:35
@cknitt
cknitt merged commit baf3057 into master Oct 23, 2026
26 checks passed
@cknitt
cknitt deleted the drift-8974-js-name-clash branch October 23, 2026 10:52
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.

let delete and let $$delete produce a duplicate ES export and an overwritten CommonJS export

2 participants