#800·Bend

All foreign .js sources share one scope, so a private helper in one FFI module silently clobbers another's (C lane refuses)

Author: haasonsaasCreated Sep 18, 2026Updated Sep 18, 2026

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:

python
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:

javascript
function fmt(x) {
  return x + 1;
}

function alpha_run(a) {
  return fmt(a);
}

helper_b.js:

javascript
function fmt(x) {
  return x + 1000;
}

function beta_run(a) {
  return fmt(a);
}
bend helper.bend
bend helper.bend -o helper.js && node helper.js

What happened

Both lanes print:

1001
1001

Alpha.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:

javascript
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 here

C 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)