From 9d223b726514cbc8b1563b8e5e51b7b70b14519d Mon Sep 17 00:00:00 2001 From: oxura Date: Sun, 11 Oct 2026 16:28:25 +0600 Subject: [PATCH 1/3] fix(compiler)!: stabilize JS constructor tags across modules --- bend2/comp.ts | 30 ++++++----- guide/GUIDE.md | 5 +- tests/io/constructor_interchange.bend | 11 ++++ tests/io/constructor_interchange.js | 75 +++++++++++++++++++++++++++ tests/io/marshal_tag_unknown.bend | 35 ------------- tests/io/marshal_tag_unknown.js | 9 ---- 6 files changed, 105 insertions(+), 60 deletions(-) create mode 100644 tests/io/constructor_interchange.bend create mode 100644 tests/io/constructor_interchange.js delete mode 100644 tests/io/marshal_tag_unknown.bend delete mode 100644 tests/io/marshal_tag_unknown.js diff --git a/bend2/comp.ts b/bend2/comp.ts index ff0ca30fb..98638a01f 100644 --- a/bend2/comp.ts +++ b/bend2/comp.ts @@ -2750,7 +2750,8 @@ function c_ids(src: string, m = ""): string { const q = [m === "" ? k : m + ":" + k, k].find((q) => q in FL.book.ctrs || q in FL.book.tlds || FL.ids.has(p + "_" + q)) ?? die(`${p}(${k}) names no constructor or def`); - return FL.js ? JSON.stringify(Bend.name_key(q)) : name_id(p + "_", q); + return FL.js ? JSON.stringify(p === "CID" && q in FL.book.ctrs + ? js_tag(q) : Bend.name_key(q)) : name_id(p + "_", q); }); } @@ -2957,6 +2958,13 @@ function js_open(sc: Scope, x: Of<"Let">): HTerm { : Bend.Var(emit_hold(sc, [js_expr(sc, v, null)], x.k[j])[0], 0))); } +// Constructor tags name the constructor within its declaring file, not +// the namespace a loading book gives that file. Matches stay type-local; +// foreign effect identities and public def names keep their namespaces. +function js_tag(k: Name): string { + return k.slice(k.indexOf(":") + 1); +} + function js_key(n: Name): string { return (n === "__proto__" ? `["${n}"]` : `"${n}"`) + ": "; } @@ -2996,7 +3004,7 @@ function js_expr(sc: Scope, tm: HTerm, ty0: HTerm | null): string { return tpl(native[x.k].intr, exprs); } const fs = ctr_live(FL.book.ctrs[x.k]); - return `{$: "${Bend.name_key(x.k)}"${exprs.map((z, j) => + return `{$: "${js_tag(x.k)}"${exprs.map((z, j) => ", " + js_key(fs[j][1]) + z).join("")}}`; } case "Let": { @@ -3082,7 +3090,7 @@ function js_match(sc: Scope, x: HTerm, ty: HTerm | null, args: string[]): void { ? [wd(j)] : [wd(j) + "[\"head\"]", wd(j + 1)].slice(0, e)]); } else { const native = OPTIMIZED[adt.k]; - const is = (c: Name) => `${s}.$ === "${Bend.name_key(c)}"`; + const is = (c: Name) => `${s}.$ === "${js_tag(c)}"`; lv = mat_ctrs(x, adt).map(([k, h]): [string, HTerm, string[]] => k === "" || k === "_" ? [k && mat_ops(is), h, [s]] : native === undefined ? [is(k), h, ctr_live(FL.book.ctrs[k]).map(([, f]) => `${s}["${f}"]`)] @@ -3179,18 +3187,14 @@ function js_marshal(A: HTerm | null, out: boolean): string { const f = js_marshal(B, out); return f === "" ? [] : [put(f, "r", `"${n}"`)]; }); - return [`case "${Bend.name_key(c.k)}": r = ${fs.length ? "{...v}" + return [`case "${js_tag(c.k)}": r = ${fs.length ? "{...v}" : "v"};`, ...fs, "break;"].join(" "); }); const tk = Bend.name_key(t.k); - const tags = cs.map((c) => Bend.name_key(c.k)).join(", "); + const tags = cs.map((c) => js_tag(c.k)).join(", "); body.push("switch (v.$) {", ...arms, - // TODO(#1105): a tag is the key the loading book gives its constructor, - // so it depends on the root file; make tags the same in every book `default: throw "bend: ${tk} has no tag " + v?.$ + " (its tags: ${ - tags}); a tag names its constructor as the" - + " loading file sees it, which a later version will make the same" - + " everywhere (#1105)";`, "}"); + tags})";`, "}"); } FL.spins.push({ ...seg_new("", BOX, ["v"]), lines: [`function ${name}(v, q) {`, "const own = q === undefined;", "if (own) q = [];", "let r;", ...body, @@ -3247,7 +3251,7 @@ export function js_book(book: Bend.Book): string { const show = show_main(); return `${lib}\n${RUNTIME_MAIN}\ncli(process.argv.slice(1));\nio_exit(${ js_sat("main")}, ${JSON.stringify(show && show.map((c) => - typeof c === "string" ? Bend.name_key(c) : c))});`; + typeof c === "string" ? [js_tag(c), Bend.name_key(c)] : c))});`; } // RuntimeC @@ -6131,9 +6135,9 @@ function show_val(D, d, v, chain) { const fs = Object.values(typeof v === "boolean" ? { $: v ? "True" : "False" } : v); let a = d + 3; - for (; D[a + 1] !== fs[0]; a += 4 + 2 * D[a + 2]) {} + for (; D[a + 1][0] !== fs[0]; a += 4 + 2 * D[a + 2]) {} const o = "{[("[D[a + 3]]; - let s = o === "{" ? fs[0] + "{" : chain === o ? "" : o; + let s = o === "{" ? D[a + 1][1] + "{" : chain === o ? "" : o; for (const [j, f] of fs.slice(1).entries()) { if (o === "[" ? j === 0 && chain === o : j > 0) { s += ", "; diff --git a/guide/GUIDE.md b/guide/GUIDE.md index 87d0162f9..165ac40ad 100644 --- a/guide/GUIDE.md +++ b/guide/GUIDE.md @@ -412,9 +412,8 @@ them, so proofs, termination and the GPU never touch host code. In the other direction, a JS file may `import Game from "./game.bend"` (with `bend2/main.ts` preloaded), or from the `./game.mjs` that `-o game.mjs` writes, and call every non-IO def, with constructors as `{$: "Name", field: value}` and `Nat` as -`BigInt`. A value crosses without a copy: an `Array` -argument is the caller's own array, updated in place, so copy it first if you -keep it. +`BigInt`. Tags use file-local constructor names across modules. Arrays stay +in place: copy one first if you keep it. Marshalled ADT nodes are copied. ### Monads diff --git a/tests/io/constructor_interchange.bend b/tests/io/constructor_interchange.bend new file mode 100644 index 000000000..c6ee433bf --- /dev/null +++ b/tests/io/constructor_interchange.bend @@ -0,0 +1,11 @@ +import Base + +def Host.constructor_interchange() -> IO(U32): + import "./constructor_interchange.js" + +def main() -> IO(Unit): + do IO: + status : U32 <- Host.constructor_interchange() + IO.print(U32.show(status)) + +#|0 diff --git a/tests/io/constructor_interchange.js b/tests/io/constructor_interchange.js new file mode 100644 index 000000000..a42444065 --- /dev/null +++ b/tests/io/constructor_interchange.js @@ -0,0 +1,75 @@ +function constructor_interchange() { + const fs = require("node:fs"); + const path = require("node:path"); + const child = require("node:child_process"); + const dir = fs.mkdtempSync(path.join(process.cwd(), ".bend-constructor-interchange-")); + try { + fs.writeFileSync(path.join(dir, "a.bend"), `import Base + +type Counter is Data: + Counter{count: Nat} + +type Choice is Data: + Left{count: Nat} + Right{count: Nat} + +type Flag is Data: + Off{} + On{} + +def Counter.make() -> Counter: Counter{5n} +def Choice.left() -> Choice: Left{7n} +def Choice.right() -> Choice: Right{7n} +def Flag.off() -> Flag: Off{} +def Flag.on() -> Flag: On{} +`); + fs.writeFileSync(path.join(dir, "b.bend"), `import Base +import ./a.bend as A + +def dec(c: A.Counter) -> Nat: + A.Counter{count} = c + Nat.sub(count, 1n) + +def score(c: A.Choice) -> Nat: + match c: + case A.Left{count}: (count + 1n : Nat) + case A.Right{count}: (count + 2n : Nat) + +def flag(c: A.Flag) -> U32: + match c: + case A.Off{}: 0 + case A.On{}: 1 +`); + for (const name of ["a", "b"]) { + const built = child.spawnSync("bun", + [path.resolve("bend2/main.ts"), path.join(dir, name + ".bend"), + "-o", path.join(dir, name + ".mjs")], { + encoding: "utf8", + env: { ...process.env, HOME: dir, BEND_ORIGIN: "http://127.0.0.1:9" }, + }); + if (built.status !== 0) { + throw new Error(built.stderr); + } + } + const A = require(path.join(dir, "a.mjs")).default; + const B = require(path.join(dir, "b.mjs")).default; + if (B.dec(A["Counter.make"]()) !== 4n + || B.score(A["Choice.left"]()) !== 8n + || B.score(A["Choice.right"]()) !== 9n + || B.flag(A["Flag.off"]()) !== 0 + || B.flag(A["Flag.on"]()) !== 1) { + return 1; + } + let refused = false; + try { + B.dec({ $: "Bogus", count: 5n }); + } catch (_) { + refused = true; + } + return refused ? 0 : 2; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +io_eff(CID(Host.constructor_interchange), constructor_interchange); diff --git a/tests/io/marshal_tag_unknown.bend b/tests/io/marshal_tag_unknown.bend deleted file mode 100644 index 76e6f55be..000000000 --- a/tests/io/marshal_tag_unknown.bend +++ /dev/null @@ -1,35 +0,0 @@ -# io (io_marshal_tag_unknown): a value the host hands in converts by -# its constructor tag, so a tag its type lacks fail-stops at the seam, -# naming the tag and the type's tags; it passed through unconverted, and -# its BigInt Nat failed far off (#1105) -import Base - -type Box is Data: - Box{n: Nat} - -law good: - IO(Box) - -def good(): - import "./marshal_tag_unknown.js" - -law bad: - IO(Box) - -def bad(): - import "./marshal_tag_unknown.js" - -def n_of(b: Box) -> Nat: - Box{n} = b - n - -def main() -> IO(Unit): - do IO: - a : Box <- good() - u : Unit <- IO.print(Nat.show(Nat.add(n_of(a), 1n))) - b : Box <- bad() - IO.print(Nat.show(Nat.add(n_of(b), 1n))) - -#|6 -#|bend: Box has no tag Bogus (its tags: Box); a tag names its constructor as the loading file sees it, which a later version will make the same everywhere (#1105) -#|exit 1 diff --git a/tests/io/marshal_tag_unknown.js b/tests/io/marshal_tag_unknown.js deleted file mode 100644 index 9b6019288..000000000 --- a/tests/io/marshal_tag_unknown.js +++ /dev/null @@ -1,9 +0,0 @@ -function good() { - return {$: CID(Box), n: 5n}; -} -function bad() { - return {$: "Bogus", n: 5n}; -} - -io_eff(CID(good), good); -io_eff(CID(bad), bad); From 059c4ce086f8f69c5a99da5478228768f37e3cca Mon Sep 17 00:00:00 2001 From: oxura Date: Sun, 11 Oct 2026 17:37:07 +0600 Subject: [PATCH 2/3] fix(test): support no-PATH interop lanes and effect result rejection --- CHANGELOG.md | 5 +++ guide/GUIDE.md | 5 +-- tests/io/constructor_interchange.js | 10 ++++-- tests/io/marshal_tag_unknown.bend | 11 +++++++ tests/io/marshal_tag_unknown.js | 9 +++++ tests/io/marshal_tag_unknown_test.js | 49 ++++++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 tests/io/marshal_tag_unknown.bend create mode 100644 tests/io/marshal_tag_unknown.js create mode 100644 tests/io/marshal_tag_unknown_test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ea5ee75..a46f8d4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ Each release names what changed for a user. `bend update` installs the latest one; the GitHub release carries the same notes. +## Unreleased + +- JS constructor tags use their file-local names, so separately loaded modules + can exchange a constructor without a loading-namespace mismatch (#1105). + ## 2.0.36 (2026-10-07) - **HOC's proving agent is now named BendAI**: `bend login` logs in to diff --git a/guide/GUIDE.md b/guide/GUIDE.md index 165ac40ad..87d0162f9 100644 --- a/guide/GUIDE.md +++ b/guide/GUIDE.md @@ -412,8 +412,9 @@ them, so proofs, termination and the GPU never touch host code. In the other direction, a JS file may `import Game from "./game.bend"` (with `bend2/main.ts` preloaded), or from the `./game.mjs` that `-o game.mjs` writes, and call every non-IO def, with constructors as `{$: "Name", field: value}` and `Nat` as -`BigInt`. Tags use file-local constructor names across modules. Arrays stay -in place: copy one first if you keep it. Marshalled ADT nodes are copied. +`BigInt`. A value crosses without a copy: an `Array` +argument is the caller's own array, updated in place, so copy it first if you +keep it. ### Monads diff --git a/tests/io/constructor_interchange.js b/tests/io/constructor_interchange.js index a42444065..7f96e52d1 100644 --- a/tests/io/constructor_interchange.js +++ b/tests/io/constructor_interchange.js @@ -40,15 +40,19 @@ def flag(c: A.Flag) -> U32: case A.Off{}: 0 case A.On{}: 1 `); + const bun = typeof Bun !== "undefined"; + const command = bun ? process.execPath : "bun"; + const prefix = bun && Bun.main.startsWith("/$bunfs/") + ? [] : [path.resolve("bend2/main.ts")]; for (const name of ["a", "b"]) { - const built = child.spawnSync("bun", - [path.resolve("bend2/main.ts"), path.join(dir, name + ".bend"), + const built = child.spawnSync(command, + [...prefix, path.join(dir, name + ".bend"), "-o", path.join(dir, name + ".mjs")], { encoding: "utf8", env: { ...process.env, HOME: dir, BEND_ORIGIN: "http://127.0.0.1:9" }, }); if (built.status !== 0) { - throw new Error(built.stderr); + throw built.error ?? new Error(built.stderr); } } const A = require(path.join(dir, "a.mjs")).default; diff --git a/tests/io/marshal_tag_unknown.bend b/tests/io/marshal_tag_unknown.bend new file mode 100644 index 000000000..73bfe7485 --- /dev/null +++ b/tests/io/marshal_tag_unknown.bend @@ -0,0 +1,11 @@ +import Base + +def Host.marshal_tag_unknown_test() -> IO(U32): + import "./marshal_tag_unknown_test.js" + +def main() -> IO(Unit): + do IO: + status : U32 <- Host.marshal_tag_unknown_test() + IO.print(U32.show(status)) + +#|0 diff --git a/tests/io/marshal_tag_unknown.js b/tests/io/marshal_tag_unknown.js new file mode 100644 index 000000000..9b6019288 --- /dev/null +++ b/tests/io/marshal_tag_unknown.js @@ -0,0 +1,9 @@ +function good() { + return {$: CID(Box), n: 5n}; +} +function bad() { + return {$: "Bogus", n: 5n}; +} + +io_eff(CID(good), good); +io_eff(CID(bad), bad); diff --git a/tests/io/marshal_tag_unknown_test.js b/tests/io/marshal_tag_unknown_test.js new file mode 100644 index 000000000..76bbb15cb --- /dev/null +++ b/tests/io/marshal_tag_unknown_test.js @@ -0,0 +1,49 @@ +function marshal_tag_unknown_test() { + const fs = require("node:fs"); + const path = require("node:path"); + const child = require("node:child_process"); + const dir = fs.mkdtempSync(path.join(process.cwd(), ".bend-tag-result-")); + try { + fs.copyFileSync(path.resolve("tests/io/marshal_tag_unknown.js"), + path.join(dir, "marshal_tag_unknown.js")); + fs.writeFileSync(path.join(dir, "main.bend"), `import Base + +type Box is Data: + Box{n: Nat} + +def good() -> IO(Box): + import "./marshal_tag_unknown.js" + +def bad() -> IO(Box): + import "./marshal_tag_unknown.js" + +def n_of(b: Box) -> Nat: + Box{n} = b + n + +def main() -> IO(Unit): + do IO: + a : Box <- good() + u : Unit <- IO.print(Nat.show(Nat.add(n_of(a), 1n))) + b : Box <- bad() + IO.print(Nat.show(Nat.add(n_of(b), 1n))) +`); + const bun = typeof Bun !== "undefined"; + const command = bun ? process.execPath : "bun"; + const prefix = bun && Bun.main.startsWith("/$bunfs/") + ? [] : [path.resolve("bend2/main.ts")]; + const ran = child.spawnSync(command, + [...prefix, path.join(dir, "main.bend")], { + encoding: "utf8", + env: { ...process.env, HOME: dir, BEND_ORIGIN: "http://127.0.0.1:9" }, + }); + if (ran.error) { + throw ran.error; + } + return ran.status === 1 && ran.stdout.trim() === "6" ? 0 : 1; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +io_eff(CID(Host.marshal_tag_unknown_test), marshal_tag_unknown_test); From 11ff026e0c79aac16365b495b25e066e158ba9d8 Mon Sep 17 00:00:00 2001 From: oxura Date: Mon, 12 Oct 2026 18:06:10 +0600 Subject: [PATCH 3/3] docs: remove constructor tag changelog entry from PR --- CHANGELOG.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a46f8d4c4..f9ea5ee75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,6 @@ Each release names what changed for a user. `bend update` installs the latest one; the GitHub release carries the same notes. -## Unreleased - -- JS constructor tags use their file-local names, so separately loaded modules - can exchange a constructor without a loading-namespace mismatch (#1105). - ## 2.0.36 (2026-10-07) - **HOC's proving agent is now named BendAI**: `bend login` logs in to