All foreign .js sources share one scope, so a private helper in one FFI module silently clobbers another's (C lane refuses)
Summary
js_lib concatenates every foreign .js source into a single IIFE and reads the
effects back out of one flat table:
const effs = rows.length === 0 ? "" : "const $0eff = (() => {\n"
+ srcs.join("\n") + "\nreturn {\n" + rows.join("\n") + "\n};\n})();\n\n";All foreign modules therefore share one function scope. A top-level name in one
foreign file collides with the same name in an unrelated foreign file, and because
duplicate function declarations are legal inside a function body, the last one
silently wins. Every earlier definition, and every call already closed over it,
is rebound.
This needs no Bend-level name collision: the two defs can have completely distinct
names and distinct FFI slots. It is enough that two independent .js files each
declare a private helper with the same name -- fmt, parse, encode, and so on,
which is close to unavoidable once more than a couple of FFI modules exist.
tests/io/effect_runtime_shadow.bend fixed the splice-vs-runtime direction of this
by moving the splice into one function scope behind a typeof-guarded table. That
stops a foreign def from clobbering a runtime.js helper, but foreign files still
share that one scope with each other, so splice-vs-splice is still open.
The C lane refuses the same program.
What you did
helper.bend:
import Base
law Alpha.run:
for a: U32
IO(U32)
def Alpha.run(a):
import "./helper_a.js"
law Beta.run:
for a: U32
IO(U32)
def Beta.run(a):
import "./helper_b.js"
law main:
IO(Unit)
def main():
do IO<Unit>:
x : U32 <- Alpha.run(1)
u : Unit <- IO.print(U32.show(x))
y : U32 <- Beta.run(1)
IO.print(U32.show(y))helper_a.js:
function fmt(x) {
return x + 1;
}
function alpha_run(a) {
return fmt(a);
}helper_b.js:
function fmt(x) {
return x + 1000;
}
function beta_run(a) {
return fmt(a);
}bend helper.bend
bend helper.bend -o helper.js && node helper.jsWhat happened
Both lanes print:
1001
1001Alpha.run(1) is 1001, not 2: helper_b.js's fmt replaced helper_a.js's,
and alpha_run now calls it. Expected 2 then 1001.
The emitted splice shows it directly:
const $0eff = (() => {
function fmt(x) {
return x + 1;
}
function alpha_run(a) {
return fmt(a);
}
...
function fmt(x) {
return x + 1000;
}
function beta_run(a) {
return fmt(a);
}
return {
alpha_run: typeof alpha_run === "function" ? alpha_run : undefined,
...The same program in the C lane, with each helper as a static in its own .c,
does not build:
helper2.c:3192:13: error: redefinition of 'fmt'
3192 | static Term fmt(Term x) {
| ^
helper2.c:3162:13: note: previous definition is hereC is loud about exactly what JS accepts silently.
Expected
Give each foreign source its own scope rather than sharing one. Wrapping each spliced file in its own IIFE that returns just the names the table needs would keep the existing typeof-guarded shape while making a foreign module's internal helpers private to it. Failing that, detect duplicate top-level declarations across spliced sources and refuse, the way the C lane effectively does.
Version / system
- bend 2.0.5 (main @ 0b7e2b1)
- Darwin arm64
- Apple clang version 21.0.0 (clang-2100.3.34.2)
Source: HigherOrderCO/Bend