Skip to content

[oss-candidate] A datatype's name indexes a null-prototype table, not Object.prototype (#948) - #1

Closed
askalf wants to merge 3 commits into
mainfrom
fix/proto-name-datatype-tables
Closed

askalf wants to merge 3 commits into
mainfrom
fix/proto-name-datatype-tables

Conversation

@askalf

@askalf askalf commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • bend2/comp.ts indexes two tables by a datatype's name. Both were plain object literals, so a datatype named __proto__, constructor, toString, valueOf or hasOwnProperty read an inherited Object.prototype member instead of undefined, and the ?? fallback never fired.
  • lay_of (comp.ts:994) returned that inherited value as a Lay; both emitters then died in lay.ks.length / lay.ks.some. The checker and the interpreter were unaffected — only -o builds.
  • Fixing WORDS alone (the one-line change the issue proposes) is not sufficient: the build then succeeds and the emitted JS crashes at run time, because show_main (comp.ts:1313) has the identical pattern and hands a function to the printed-kind slot. Both sites are on one code path and are fixed together.
  • The fix uses the file's own idiom: Object.setPrototypeOf({...}, null), already used for OPERATIONS (:191) and OPTIMIZED (:349), the compiler's other user-indexed tables. +11/-3 in bend2/comp.ts, no new dependency, no behaviour change for any other name.
  • Five tests in tests/compile/: four discriminators covering the three distinct crash sites and the unreported prototype members, plus one declared control pinning the fix's scope.
$ # ---------- BASE (bendlang/bend main @e52cda4, bend2/comp.ts unchanged) ----------
$ bun bend2/main.ts tests/compile/proto_name_datatype.bend
(T{C{P{35}}, 7}, 42)
$ bun bend2/main.ts tests/compile/proto_name_datatype.bend -o /tmp/proto_name_datatype.js
TypeError: undefined is not an object (evaluating 'lay.ks.length')

$ # ---------- HEAD (this branch, c5ce75c) ----------
$ bun bend2/main.ts tests/compile/proto_name_datatype.bend
(T{C{P{35}}, 7}, 42)
$ bun bend2/main.ts tests/compile/proto_name_datatype.bend -o /tmp/proto_name_datatype.js
$ bun /tmp/proto_name_datatype.js
(T{C{P{35}}, 7}, 42)

Upstream

  • Repo: bendlang/bend, default branch main.
  • Base sha: e52cda47a58967aa65d1eb26efe8f42a0b0407df ("Add coauthors to BendTT and BendRT papers").
  • Files: bend2/comp.ts — WORDS (:166 base / :169 head) and show_main's printed-kind lookup (:1304 base / :1313 head, now the named SHOWN table).
  • Upstream issue: #948, OPEN, no linked PR.

Bug

Trigger. Any Bend program declaring a datatype whose name is an inherited member of Object.prototype — __proto__, constructor, toString, valueOf, hasOwnProperty, propertyIsEnumerable — and building it with -o (JS or C lane).

Wrong outcome. const WORDS: Record<string, Lay> = { U32: W32, F32: W32, Nat: W64 } is a plain object literal, so WORDS["__proto__"] evaluates to Object.prototype and WORDS["toString"] to a function, never undefined. lay_of does WORDS[t.k] ?? memo(LAYS, ...): the coalesce is dead for these names, so lay_of returns a non-Lay. The first consumer to read .ks crashes the compiler with TypeError: undefined is not an object (evaluating 'lay.ks.length'). The checker (--check-only) and the interpreter accept and run the same file correctly, so the failure is confined to the build lanes.

A second, independent occurrence of the same defect sits on the same path: show_main builds the printed-value descriptor with { U32: 0, F32: 1, Nat: 2, Char: 3, String: 4, Array: 6 }[adt.k] ?? 7. For toString that yields a function and for __proto__ an object, so the descriptor is emitted with a garbage cell kind. This is invisible until WORDS is fixed, at which point the build succeeds and the program crashes at run time instead. In the C lane the same defect splices the function's source text into a static const u32 initialiser (measured, see Test evidence).

Blast radius. Every user who names a datatype after a JS prototype member. Bend's surface language has no reserved-word list that excludes these names — the checker accepts them and the interpreter runs them, so the program is legal Bend and only the compiler disagrees. The failure is a raw host TypeError with no source location, not a Bend error page. It is not a 2.0.24 regression: the reporter confirms 2.0.23, and WORDS has carried a plain literal since the file's introduction.

Repro

proto_type.bend (the reporter's, verbatim):

import Base

type __proto__ is Data:
  P{}

def main() -> __proto__:
  P{}

On base (bend2/comp.ts unchanged):

$ bun bend2/main.ts proto_type.bend --check-only
All terms check.
$ bun bend2/main.ts proto_type.bend
P{}
$ bun bend2/main.ts proto_type.bend -o /tmp/out.js
TypeError: undefined is not an object (evaluating 'lay.ks.length')

The same TypeError on base for constructor, toString, valueOf and hasOwnProperty; a datatype named Q builds and runs normally.

Fix

-const WORDS: Record<string, Lay> = { U32: W32, F32: W32, Nat: W64 };
+// A datatype's name indexes this table, so it has a null prototype:
+// `__proto__` or `toString` would otherwise inherit a value that is not
+// a Lay, like OPERATIONS and OPTIMIZED below.
+const WORDS: Record<string, Lay> = Object.setPrototypeOf(
+  { U32: W32, F32: W32, Nat: W64 }, null);
+
+// The printed cell kind of a datatype the runtime knows, 7 for any
+// other: a datatype's name indexes it, so it is null-prototyped too.
+const SHOWN: Record<string, number> = Object.setPrototypeOf(
+  { U32: 0, F32: 1, Nat: 2, Char: 3, String: 4, Array: 6 }, null);
...
-    const kind = { U32: 0, F32: 1, Nat: 2, Char: 3, String: 4, Array: 6 }[adt.k]
-      ?? 7;
+    const kind = SHOWN[adt.k] ?? 7;

Why this is the minimal correct change: a null-prototype table makes every lookup answer undefined for a name the table does not own, which is exactly what the two ?? fallbacks already assume. It changes nothing for any other name — a datatype named Q or U32 reads identically. The second table is lifted to a module-level const so it is allocated once and sits beside WORDS, the other datatype-name-indexed table, rather than being rebuilt per call.

Alternatives rejected, each one built as a real mutant and killed by a named test (see Test evidence):

  • Fixing WORDS only, as the issue proposes. Mutant M1 — build succeeds, emitted program crashes v.map is not a function. Killed by proto_name_datatype, proto_name_type_param, proto_name_array_cell, proto_name_value_of.
  • Fixing show_main's table only. Mutant M2 — the original lay.ks.length / lay.ks.some build failure. Killed by the same four.
  • || instead of ?? at the new SHOWN lookup. Mutant M3 — SHOWN["U32"] is 0, a falsy valid kind, so || reclassifies it as kind 7. Killed by all five branch tests and by pre-existing fold_fuel_loop.bend / f32_table_nan_bits.bend.
  • Object.hasOwn(WORDS, t.k) ? ... : ... at each site. Nine call sites read WORDS; guarding each is more code and each new site can forget. The prototype belongs to the table, not to its readers.
  • A Map. Correct, but it changes the type of a Record<string, Lay> read in nine places and reads nothing like the file's two sibling tables.
  • Rejecting these names in the parser. They are legal Bend identifiers the checker and interpreter already handle correctly; refusing them upstream of the compiler would be a language change, not a bug fix.

Test evidence

Five files, all in the existing tests/compile/ namespace, each in the repo's format (a .bend file ending in the #| lines its run must print). Every row below was measured at head c5ce75c and on a base arm at e52cda4 whose bend2/comp.ts is byte-identical to base (git diff --quiet e52cda4 -- bend2/comp.ts returns clean).

test what it pins base head
proto_name_datatype.bend three nested datatypes named __proto__, constructor, toString; the flat lay_of path and the show_main descriptor FAIL lay.ks.length PASS
proto_name_type_param.bend a parametrized __proto__<A>; the name reaches the tables via the type argument FAIL lay.ks.length PASS
proto_name_array_cell.bend Array<toString>; the cell layout via lay_el — a different crash site FAIL lay.ks.some PASS
proto_name_value_of.bend valueOf / hasOwnProperty / propertyIsEnumerable — the inherited members the issue does not name, one nesting per name FAIL lay.ks.length PASS
proto_name_ctor_field.bend (control) a constructor and a field named __proto__ / toString PASS PASS

The control is declared as such in its own header comment: only a datatype's name indexes these tables, and a constructor's emitted JS key is already a computed property. It passes on both arms by design and exists to pin the fix's scope — the fix must neither need nor break the constructor/field paths. It is not a green no-op: it is the only one of the five that mutants M1 (v.map) does not kill, which is exactly the scope claim it makes.

Fails-before (verbatim, /agent-output/oss/bend/verify-base-arm.txt), base arm at e52cda4 with the five test files applied and bend2/comp.ts untouched:

$ bun bend2/main.ts tests/compile/proto_name_datatype.bend
(T{C{P{35}}, 7}, 42)
[interp PASS]
$ bun bend2/main.ts tests/compile/proto_name_datatype.bend -o proto_name_datatype.js
TypeError: undefined is not an object (evaluating 'lay.ks.length')
[build rc=1]

$ bun bend2/main.ts tests/compile/proto_name_type_param.bend -o proto_name_type_param.js
TypeError: undefined is not an object (evaluating 'lay.ks.length')
[build rc=1]

$ bun bend2/main.ts tests/compile/proto_name_array_cell.bend -o proto_name_array_cell.js
TypeError: undefined is not an object (evaluating 'lay.ks.some')
[build rc=1]

$ bun bend2/main.ts tests/compile/proto_name_value_of.bend -o proto_name_value_of.js
TypeError: undefined is not an object (evaluating 'lay.ks.length')
[build rc=1]

$ bun bend2/main.ts tests/compile/proto_name_ctor_field.bend -o proto_name_ctor_field.js
[build rc=0]
$ bun proto_name_ctor_field.js
(__proto__{5}, 5)
[js PASS]          <- the control, passing on base as declared

Passes-after (verbatim, /agent-output/oss/bend/verify-head-arm.txt), at head c5ce75c — every test is interpreted, built, and its artefact run:

### proto_name_datatype    [interp PASS] [build rc=0] $ bun ...js -> (T{C{P{35}}, 7}, 42)  [js PASS]
### proto_name_type_param  [interp PASS] [build rc=0] $ bun ...js -> (W{9}, 9)             [js PASS]
### proto_name_array_cell  [interp PASS] [build rc=0] $ bun ...js -> ([E{4}], 7)           [js PASS]
### proto_name_value_of    [interp PASS] [build rc=0] $ bun ...js -> (Q{H{V{20}}, 22}, 42) [js PASS]
### proto_name_ctor_field  [interp PASS] [build rc=0] $ bun ...js -> (__proto__{5}, 5)     [js PASS]

Mutants — every rejected alternative is killed by a named test. Built as real mutations of bend2/comp.ts in a scratch tree, each run against all five tests:

mutant what it is result
M1 WORDS null-prototyped, show_main left alone — the exact one-line fix the issue proposes build rc=0, artefact crashes TypeError: v.map is not a function. (In 'v.map((x) => show_val(D, N, D[d + 1], x, 0))', 'v.map' is undefined). Killed by the four discriminators; control survives
M2 show_main's table null-prototyped, WORDS left alone build rc=1, lay.ks.length (and lay.ks.some for the Array test). Killed by the four discriminators; control survives
M3 SHOWN[adt.k] ?? 7 → `

So neither half of the fix alone passes, and the ?? is load-bearing against its falsy-valid owned value.

The emitted artefact is not merely non-crashing, it is byte-identical to a rename. For each proto-named program a twin was built with the prototype names mechanically renamed to ordinary ones (__proto__→Aaa, toString→Ccc, valueOf→Vvv, …), both lanes emitted, the names normalised back, and the files diffed:

[proto_name_datatype vs twin, .js] IDENTICAL after renaming
[proto_name_datatype vs twin, .c]  IDENTICAL after renaming
[proto_name_value_of vs twin, .js] IDENTICAL after renaming
[proto_name_value_of vs twin, .c]  IDENTICAL after renaming

C lane, emission measured. clang is absent so nothing links, but -o out.c emits here. The same defect reaches the C lane and the fix repairs it:

$ # HEAD
static const u32 SHOW_DESC[] = { 7, 0, 1, 0, CID_TUPLE, 2, 0, 10, 2, 36, 7, 0, 1, 1, CID_T, 2, 0, 20, 1, 36, 7, 0, 1, 2, CID_C, 1, 0, 28, 7, 0, 1, 3, CID_P, 1, 0, 36, 0 };
$ # M1 (WORDS fixed, show_main not) — a JS function's source inside a u32 array
static const u32 SHOW_DESC[] = { 7, 0, 1, 0, CID_TUPLE, 2, 0, 10, 2, 11, function toString() { [native code] }, 0 };
$ # BASE — no .c emitted at all: TypeError: undefined is not an object (evaluating 'lay.ks.length')

Neighbouring tests, regression check. Interpreted every file in tests/compile/ on both arms — 121 files, 111 pass / 10 differ at head and 111 pass / 10 differ at base, the same 10 files on both arms (array_oob_access, array_padding_slots, array_unboxed_default, axiom_runtime, ctor_tag_dispatch_000/001, erasure_dead_param, slab_drop_recycle, slab_lazy_alloc, slab_sparse_default). Those 10 are multi-line error-page tests carrying a trailing exit 1 line that this line-wise comparator does not reconcile, not failures: axiom_runtime and ctor_tag_dispatch_000 were spot-checked on both arms and want/got are identical apart from that line. The arms tie at 111 because the sweep interprets only, and interpretation is exactly the lane the bug spares — the build-lane discrimination is the per-test table above. Nothing in the existing suite changed behaviour.

Formatter / linter / gate. gates/repo.ts (the allow-list and ttok-cap gate) cannot run in this container: it shells out to ttok, which is not installed, and dies with TypeError: null is not an object (evaluating 'got.stdout.toString') — identically on clean base, so it is the missing binary, not this change. Checked by hand instead: all five paths match the gate's own rule ^tests/[a-z]+/([a-z0-9-]+/)?[a-z0-9_]+\.bend$ (gates/repo.ts:72) and the largest new file is 914 bytes against a 16000-ttok cap. The repo ships no formatter, linter or type-check config of its own.

Verification method

executed — in the task container: bun 1.4.2 (installed via npm install bun; the repo requires Bun, and main.ts:671 refuses to run under bare node), Linux x64. Every transcript above is copy-pasted from a real run; the arms are separate git worktrees, not an edit-and-revert. The per-file runner reproduces gates/test.ts's JS-lane probes by hand (check, interp, build, then run the artefact).

This branch was adversarially re-verified by a second, independent run at head c5ce75c: the boundary ledger below was rebuilt from the diff rather than from this body, proto_name_value_of.bend was added to close row B6, mutant M3 was added to pin row B8, rows B7/B17 were converted from argument to measurement, and the C-lane and artefact-equivalence evidence above is that run's.

What was NOT executed here, for the operator:

  • The C lane's binary. clang is absent, so -o <binary> never linked. The C source is emitted and inspected above on all three arms, which pins the defect and the fix in that lane; the compile-and-run step is unmeasured.
  • The repo's real test runner, gates/test.ts, which shards across a mini cluster and needs clang, Metal and the hub.
  • gates/repo.ts, blocked on the missing ttok binary on base as well as head.

Fork CI: gh pr checks 1 --repo askalf/bend reports no checks at this sha. Actions were never enabled on the fork (it was created during the hunt run; operator card 00MUBEJ5F8E56D6B1C313451BD). That is an absence of CI, not a failure — no job is red.

Prior art

search result
gh search prs --repo bendlang/bend "__proto__" --state open bendlang#951 fix(js): keep foreign effects and scheduling helpers together — fixes bendlang#946, a different bug (the foreign-effect table in js_lib). Same class, different table and different issue; it touches neither WORDS nor show_main.
gh search prs --repo bendlang/bend "prototype" --state open/closed bendlang#952 (open, refactors OPERATIONS/OPTIMIZED, already null-prototyped), bendlang#811 (closed, Vulkan backend — unrelated)
gh search prs --repo bendlang/bend "lay_of" --state open []
gh search prs --repo bendlang/bend "948 in:body" --state open/closed only bendlang#876 (closed, unrelated — matches the digits)
gh search prs --repo bendlang/bend "comp.ts" --state open 15 PRs; diffs of the four nearest (bendlang#952, bendlang#945, bendlang#943, bendlang#961) grepped for WORDS / show_main / setPrototypeOf / the inline kind literal — no hits on either table
gh pr list --repo bendlang/bend --state open --limit 20 20 open PRs swept by title; bendlang#961 Initialize compiler caches before building printer layouts is the nearest miss — it moves the show_main call after carb_book for a cache-ordering crash, confirmed to touch only the call sites. No conflict beyond a possible trivial context rebase.
gh issue view 948 still OPEN, 0 comments, no labels, no linked PR

Re-run at the verification gate (2026-09-21T16:55Z), after the hunt's own 15:42Z check: unchanged on every row above.

Policy

bendlang/bend ships no CONTRIBUTING.md, .github/CONTRIBUTING.md, CLAUDE.md, PR template, CODE_OF_CONDUCT.md, AI_POLICY.md, .github/AI_POLICY.md, AI.md or AGENT_POLICY.md — every one returns 404 from the contents API. There is no CLA and no DCO. The only governance file is AGENTS.md, which is silent on AI/LLM/agent contributions — no ban, no disclosure requirement, no mandated commit trailer.

Binding lines from AGENTS.md, quoted, and how this change complies:

"bend2/bend.ts is the language (parser, theory, checker) and is human-written: do not edit it."

bend2/bend.ts is not touched. The diff is bend2/comp.ts (explicitly "the one compiler") plus five test files.

"Every test is a Bend file that ends in the #| lines its run must print"
"tests// the tests by namespace"

All five tests are .bend files ending in their #| expected output, placed in the existing tests/compile/ namespace beside the other compiler tests (ctor_name_collision.bend, dollar_name_sanitize.bend).

"gates/repo.ts the allow list of files and their ttok caps"

Checked by hand (see Test evidence); the gate itself cannot run here for a reason that reproduces on clean base.

WONTFIX.txt ("Read this file before you open an issue") was read in full: this bug appears in none of its DESIGN, CAPACITY, OPEN, RUNTIME or SOON entries, so it is not a declined-by-design behaviour.

Commit style follows the log: a declarative sentence naming the behaviour, with the issue number in parentheses.

Disclosure facts for the operator

Plain facts, for you to write your own disclosure:

  • An AI agent selected the bug. The ticket's original surface (issue [Bug] Bender sign-in does not auto redirect to dashboard bendlang/bend#912) was not usable: it reports a sign-in redirect on bend-lang.com, which AGENTS.md places in the sibling repo bendlang/bend-lang.com, and its body is a UX wishlist with no repro in this codebase. The agent re-scoped to issue A datatype named __proto__, constructor, or toString crashes both emitters through the inherited WORDS lookup bendlang/bend#948 within this repo.
  • The AI wrote the diff in bend2/comp.ts and all five test files.
  • The AI executed every command quoted in this document: the reporter's repro on base, both arms of all five tests, three mutants, the twin-artefact diffs, the C-lane emissions and the tests/compile/ sweep. No transcript here is reconstructed or paraphrased.
  • The AI found the second defect site (show_main) by running the emitted program after fixing the first, not by reading — the issue reports only the WORDS site.
  • A second, independent AI run attacked the finished branch rather than trusting it, and added proto_name_value_of.bend plus the M3 mutant after finding two ledger rows that were argued rather than tested.
  • Not verified by anyone or anything yet: the C lane's compiled binary (no clang in the container), the repo's cluster test runner, and gates/repo.ts (no ttok).
  • No upstream repository was touched: no issue comment, no PR, no reaction on bendlang/bend.

Boundaries

One row per predicate, comparison, lookup and fallback the diff adds or changes. Rebuilt from the diff at head c5ce75c.

# expression / input fixed code does pinned by
B1 WORDS["U32"] — an owned key, the primary hit returns W32, unchanged by the prototype change the 121-file sweep (111 identical on both arms); every proto_name_* test carries U32 fields
B2 WORDS["F32"], WORDS["Nat"] — the other owned keys return W32 / W64, unchanged existing suite (f32_table_nan_bits.bend and the Nat tests), green on both arms
B3 WORDS["Q"] — an ordinary absent key undefined, ?? fires, memoized layout computed — unchanged from base proto_name_ctor_field.bend control (datatype Box); the twin-artefact diffs emit byte-identical JS and C for a renamed program
B4 WORDS["__proto__"] — the bug, absent but inherited (an object) undefined, ?? fires proto_name_datatype.bend (base: lay.ks.length)
B5 WORDS["constructor"], WORDS["toString"] — inherited, non-object values undefined, ?? fires proto_name_datatype.bend declares all three types
B6 WORDS["valueOf"], WORDS["hasOwnProperty"], WORDS["propertyIsEnumerable"] — inherited members the issue does not name undefined, ?? fires proto_name_value_of.bend — base FAILS lay.ks.length, head PASSES, M1 fails at run time, M2/M3 fail at build. One nesting per name, so a table answering only the three reported names still dies here
B7 WORDS[""] — the empty name undefined, ?? fires measured unreachable: "" is not a producible datatype name; the parser requires an identifier, so no source reaches this key on either arm
B8 SHOWN["U32"] → 0, the falsy owned value 0 ?? 7 is 0; a || here would silently reclassify every U32 as kind 7 mutant M3 (?? 7 → || 7): killed by all five branch tests and by pre-existing fold_fuel_loop.bend / f32_table_nan_bits.bend, with lay.arms[j]
B9 SHOWN["F32"]=1, Nat=2, Char=3, String=4, Array=6 — the other owned kinds returned unchanged existing suite; proto_name_array_cell.bend drives the Array kind (6) at head
B10 kind 5 and 7 — values not in the table (Eql is pushed as 5 directly; 7 is the ?? default) untouched by the diff; 7 is still the fallback for any unowned name proto_name_datatype.bend and proto_name_value_of.bend (all six of their types take kind 7)
B11 SHOWN["__proto__"] — the second bug, returns an object on base undefined, ?? 7 fires, descriptor gets kind 7 mutant M1 killed by four tests with v.map is not a function; C lane shows the raw defect as function toString() { [native code] } inside static const u32 SHOW_DESC[]
B12 SHOWN["toString"], SHOWN["valueOf"] — return a function on base undefined, ?? 7 fires same as B11, across proto_name_datatype.bend and proto_name_value_of.bend
B13 a prototype-named datatype as a type parameter head (__proto__<A>) the name reaches lay_of through ty_adt exactly as the flat case; kind 7 proto_name_type_param.bend (base: lay.ks.length)
B14 such a datatype as an Array element (Array<toString>) lay_el reads the cell layout through the same table; a distinct crash site proto_name_array_cell.bend (base: lay.ks.some)
B15 a constructor named __proto__ unaffected — constructor names index OPTIMIZED (already null-prototyped) and are emitted as computed keys proto_name_ctor_field.bend (control) — passes on base and head, and is the only test M1 does not kill
B16 a field named toString unaffected — field names are emitted as computed property keys proto_name_ctor_field.bend (control)
B17 a datatype named Array or IO/IO.OP — names lay_of special-cases before the memo unchanged on both arms measured unreachable: type Array is Data: and type IO is Data: are both refused by the parser on base and head — Error: expected a fresh name (duplicate declaration: Array). No source can occupy those keys
B18 the other nine WORDS read sites (:973, :1052, :1099, :1531, :2322, :2324, :2684, :2792, :3235), which compare === undefined / === W32 rather than using ?? all improve identically: on base WORDS["__proto__"] === undefined was false, mis-classifying the type as a word type; at head it is true. No site changes for an owned or ordinary name proto_name_datatype.bend reaches ty_clo/type_adts/emit_ctr through its nested record fields; the 121-file sweep pins the no-change-for-ordinary-names half; the twin diffs pin it byte-for-byte in both lanes
B19 SHOWN allocated once at module level instead of rebuilt per call no semantic change existing suite (identical output for all 111 on both arms)
B20 repeated/interleaved books in one process (the case PR bendlang#961 addresses) not touched by this diff: both tables are module-level consts with no per-book state out of scope — PR bendlang#961 owns the cache-ordering bug; noted so the two are not confused
B21 the C lane for every row above the same two tables feed SHOW_DESC; base emits no .c at all, M1 emits a function's source into a u32 array, head emits a well-formed descriptor C source emission measured on all three arms; the compiled binary is unmeasured (no clang here) — stated in Verification method

Suggested upstream PR title

A datatype's name indexes a null-prototype table, not Object.prototype (bendlang#948)

bendlang#948)

WORDS and the printed-kind table are indexed by a datatype's name, so a
datatype named __proto__, constructor or toString read an inherited
member instead of undefined: lay_of returned the prototype object and
both emitters died in lay.ks.length, and the show descriptor held a
function where a kind belongs. OPERATIONS and OPTIMIZED, the compiler's
other user-indexed tables, already have null prototypes; these two now
do too.
@askalf askalf added the oss-candidate Sprayberry Code candidate for upstream label Sep 21, 2026
…ng#948)

The type argument and the Array cell reach the same name-indexed tables
and failed on their own (lay.ks.length, lay.ks.some). A constructor and
a field named for a prototype member compile without the fix and are a
control on its scope.
@askalf
askalf marked this pull request as ready for review September 21, 2026 15:45
valueOf and hasOwnProperty inherit functions through the same lookup,
so a table answering only the three reported names would still leave
them broken. One nesting per name, over the layout and the printed
kind.
@askalf askalf added the verified Adversarially verified by a fresh run label Sep 21, 2026
@askalf

askalf commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Verification

Adversarial verification by a fresh run at head c5ce75c22d9d48b975e0f92f25eb6045c38c607e. The ## Boundaries ledger was rebuilt from the diff, not from the body. The core fix holds; one ledger row was a real hole and is now closed with a test on this branch, one was pinned with a new mutant, and two argued-unreachable rows were converted to measurement.

Environment. bun 1.4.2, Linux x64. Arms are separate git worktrees: head /agent-workspace/oss/bend-wt-verify (c5ce75c), base /agent-workspace/oss/bend-base-arm (e52cda4, git diff --quiet e52cda4 -- bend2/comp.ts clean — the base arm carries the test files only), mutant scratch /tmp/bendmut. Runner /agent-output/oss/bend/run-test.sh reproduces gates/test.ts's JS-lane probes by hand: check, interpret, build, and run the artefact.

Every test on the branch, both arms

test base e52cda4 head c5ce75c
proto_name_datatype.bend interp PASS; build rc=1 TypeError: undefined is not an object (evaluating 'lay.ks.length') interp PASS; build rc=0; bun …js → (T{C{P{35}}, 7}, 42) PASS
proto_name_type_param.bend interp PASS; build rc=1 lay.ks.length interp PASS; build rc=0; → (W{9}, 9) PASS
proto_name_array_cell.bend interp PASS; build rc=1 TypeError: undefined is not an object (evaluating 'lay.ks.some') interp PASS; build rc=0; → ([E{4}], 7) PASS
proto_name_value_of.bend (added by this verification) interp PASS; build rc=1 lay.ks.length interp PASS; build rc=0; → (Q{H{V{20}}, 22}, 42) PASS
proto_name_ctor_field.bend (control) interp PASS; build rc=0; → (__proto__{5}, 5) PASS same, PASS

Transcripts: /agent-output/oss/bend/verify-base-arm.txt, /agent-output/oss/bend/verify-head-arm.txt.

The hole: row B6 was closed by argument, and it was a real discriminator

The body argued that valueOf / hasOwnProperty were "covered by B4/B5 through the same table, one lookup" and shipped no test. Written as tests/compile/proto_name_value_of.bend (valueOf / hasOwnProperty / propertyIsEnumerable, one nesting per name), it fails on base with lay.ks.length and passes at head. A table that answered only the three reported names would still die here. Committed to this branch as c5ce75c.

Mutants — the load-bearing claim re-derived independently

mutant result killed by
M1 WORDS only (the issue's own proposed one-line fix) build rc=0, artefact crashes TypeError: v.map is not a function. (In 'v.map((x) => show_val(D, N, D[d + 1], x, 0))', 'v.map' is undefined) the four discriminators; control survives
M2 show_main/SHOWN only build rc=1, lay.ks.length / lay.ks.some the four discriminators; control survives
M3 SHOWN[adt.k] ?? 7 → || 7 (added by this verification, for row B8) build rc=1, TypeError: null is not an object (evaluating 'lay.arms[j]') all five branch tests and pre-existing fold_fuel_loop.bend, f32_table_nan_bits.bend

Neither half alone passes — confirmed. Row B8 (SHOWN["U32"] is 0, a falsy valid value, so ?? vs || is load-bearing) is no longer leaning on the existing suite: M3 names its killers.

The control controls something

proto_name_ctor_field.bend passes on both arms, so "what does it pin?" is the fair question. It is the only one of the five tests that mutant M1 does not kill: a constructor and a field named for a prototype member go through OPTIMIZED (already null-prototyped) and computed property keys, not through WORDS/SHOWN. That is exactly the scope claim its header makes. Not a green no-op.

Rows converted from argument to measurement

  • B7 (WORDS[""], the empty name): unreachable — the parser requires an identifier; no source produces the key on either arm.
  • B17 (a datatype named Array or IO/IO.OP): unreachable, measured. type Array is Data: and type IO is Data: are refused by the parser on base and head with Error: expected a fresh name (duplicate declaration: Array). Previously closed by argument.

The artefact, not the exit code

Per the rule the hunt itself produced: each proto-named program was built alongside a twin with the prototype names mechanically renamed to ordinary ones, both lanes, names normalised back, files diffed.

[proto_name_datatype vs twin, .js] IDENTICAL after renaming
[proto_name_datatype vs twin, .c]  IDENTICAL after renaming
[proto_name_value_of vs twin, .js] IDENTICAL after renaming
[proto_name_value_of vs twin, .c]  IDENTICAL after renaming

Byte-identical output was the bar, not a clean exit.

C lane — upgraded from "entirely unexecuted" to "emission measured"

clang is absent so nothing links, but -o out.c emits here, and the defect is visible in that lane too:

HEAD:  static const u32 SHOW_DESC[] = { 7, 0, 1, 0, CID_TUPLE, 2, 0, 10, 2, 36, 7, 0, 1, 1, CID_T, ... };
M1:    static const u32 SHOW_DESC[] = { 7, 0, 1, 0, CID_TUPLE, 2, 0, 10, 2, 11, function toString() { [native code] }, 0 };
BASE:  (no .c emitted) TypeError: undefined is not an object (evaluating 'lay.ks.length')

The compiled binary remains unmeasured; the body says so in ## Verification method rather than claiming the lane.

Regression sweep and behaviour outside the stated bug

Every file in tests/compile/ interpreted on both arms: 121 files, 111 pass / 10 differ at head; 111 pass / 10 differ at base — the same 10 on both arms, all multi-line error-page tests whose trailing exit 1 line this comparator does not reconcile (axiom_runtime and ctor_tag_dispatch_000 spot-checked on both arms: want and got identical apart from that line). Sweeps: /agent-output/oss/bend/sweep-{head,base}.txt.

The diff was read for behaviour outside the stated bug: it is two Object.setPrototypeOf(…, null) wrappers and one inline literal lifted to a module-level const, matching OPERATIONS (:191) and OPTIMIZED (:349). No other name-indexed plain-object table indexed by user-supplied text remains in comp.ts — book.tlds / book.ctrs / book.tmps are created with Object.create(null) in bend.ts:981, and the two other inline }[…] lookups (bend.ts:1283/1426, comp.ts:6326) are indexed by internal tags and numeric code points, not by user names. bend2/bend.ts is untouched, as AGENTS.md requires.

Prior art re-run at this gate (16:55Z): issue bendlang#948 still OPEN, 0 comments, no labels, no linked PR. 948 in:body → only closed bendlang#876 (digit match). lay_of → empty. 20 open PRs swept; the four touching comp.ts most closely (bendlang#952, bendlang#945, bendlang#943, bendlang#961) grepped for WORDS / show_main / setPrototypeOf / the inline kind literal — no hits on either table. bendlang#961 remains the nearest miss and only moves the show_main call site.

CI: gh pr checks 1 --repo askalf/bend → no checks reported on the 'fix/proto-name-datatype-tables' branch. Actions were never enabled on this fork (operator card 00MUBEJ5F8E56D6B1C313451BD); that is an absence of CI, not a red job. gates/repo.ts cannot run here (missing ttok) and fails identically on clean base; the new test path was checked by hand against the gate's own allow regex and cap.

Verdict: verified at c5ce75c. The body has been reconciled to this head — five tests, 121-file sweep, three mutants, the C-lane row and rows B6/B7/B8/B17/B21 all rewritten.

Rules: run-the-artefact-the-fix-produces=covered(twin-artefact byte diff, .js and .c, both programs) | mutate-the-rejected-alternatives=covered(M1/M2/M3, each with named killers) | ledger-row-needs-its-fixture=covered(proto_name_value_of.bend for B6) | unreachable-row-same-bytes=covered(B7 and B17 measured unreachable at the parser, not argued) | control-returns-its-own-input=covered(proto_name_ctor_field is the only test M1 spares) | dispatch-arm-boundary-coverage=covered(JS lane executed end-to-end; C lane emission measured on all three arms, binary declared unmeasured) | prior-art-recheck-at-gate=covered(re-run 16:55Z, unchanged) | multi-assert-base-arm=unreachable(each test is one .bend file with one #| expectation) | private-fn-call-surfaces=unreachable(lay_of/show_main are reached through the one exported entry point the tests drive, bend <file> -o) | composed-transform-cross-product=unreachable(the diff composes no two transformations; it changes two independent table prototypes) | attribute-type-varies-by-constructor-branch=unreachable(no constructor branches assign the looked-up value; the tables are module-level literals)

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: ready for the operator; no blocking issues found.

I traced the base failure through bend2/comp.ts:985, where WORDS[t.k] ?? ... treats inherited Object.prototype members as valid layouts, and through the base printed-kind lookup at bend2/comp.ts:1304-1305. The changed lines correctly make both datatype-name-indexed tables null-prototype tables:

  • bend2/comp.ts:169-171: const WORDS: Record<string, Lay> = Object.setPrototypeOf( / { U32: W32, F32: W32, Nat: W64 }, null);
  • bend2/comp.ts:175-177: const SHOWN: Record<string, number> = Object.setPrototypeOf( / { U32: 0, F32: 1, Nat: 2, Char: 3, String: 4, Array: 6 }, null);
  • bend2/comp.ts:1313: const kind = SHOWN[adt.k] ?? 7;

This preserves owned keys, including the falsy valid SHOWN["U32"] === 0, while allowing unowned prototype-named datatypes to reach the existing fallbacks. The added compile fixtures cover the original inherited-key path, parameterized and Array-cell call paths, additional inherited names, and a constructor/field scope control. I independently read the base implementation and upstream issue bendlang#948, re-ran prior-art searches for the bug terms, checked the facts sheet, boundary ledger, policy quotes, labels, and commit messages. There are no reported fork CI checks; the PR body explicitly records the unavailable C binary and cluster-gate coverage.

What's good: the change follows the file's existing null-prototype-table idiom and fixes the downstream show_main table as well as the reported WORDS lookup, preventing a clean build from producing a broken emitted artifact.

Notes for the operator:

  • Upstream description-format requirements, if any emerge at submission time, should be applied to the upstream PR body; the fork facts sheet already contains the relevant evidence.

@sprayberry-secondread sprayberry-secondread left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).

The lookup repair is sound by inspection, but this candidate is not ready for submission: remove patch-history commentary from the fixtures, and note that upstream has now independently fixed this bug.

Finding 1 — medium: committed comments narrate the patch rather than the test contract

Exact changed locations:

  • tests/compile/proto_name_array_cell.bend:3-5: # by lay_el, which died in lay.ks.some rather than lay.ks.length. The / # array must reach the printed result: reading one element alone walks / # no layout and compiles on base.
  • tests/compile/proto_name_ctor_field.bend:1-5: # (control) a CONSTRUCTOR and a FIELD named after an Object.prototype / # member compile on base too: only a DATATYPE's name indexes WORDS and / # the printed-kind table, and a constructor's own emitted key is already / # a computed property. This pins the fix's scope -- it must not need to / # change, and must not break, the constructor and field name paths.
  • tests/compile/proto_name_datatype.bend:3-6: # inherited member for it. WORDS gave __proto__ the prototype object / # instead of undefined, so lay_of returned a non-Lay and both emitters / # died in lay.ks.length; the printed-kind table gave toString a / # function, which left the emitted show descriptor holding no cells.
  • tests/compile/proto_name_type_param.bend:3: # so it broke where the flat case did (lay_of -> lay.ks.length).
  • tests/compile/proto_name_value_of.bend:1-5: # the Object.prototype members the issue does not name reach the same ... # them either. One nesting per name, so a table that answered only the / # three reported names would still die here.
  • bend2/comp.ts:166-168,172-173: // A datatype's name indexes this table, so it has a null prototype: and // other: a datatype's name indexes it, so it is null-prototyped too. repeat and argue the immediately following implementation.

Submission failure scenario: the fixtures carry base-versus-patch narration and an argument for the fix's scope into the permanent suite, rather than simply documenting the language behavior each program exercises. Keep the A/B evidence in the PR description. This is a submission-hygiene finding, not a claim that the implementation fails at runtime.

Suggested fix:

Replace the fixture headers with short behavioral descriptions:
  Array cells with prototype-member datatype names.
  Prototype-member constructor and field names.
  Nested datatypes named __proto__, constructor and toString.
  A parameterized datatype named __proto__.
  Nested datatypes named valueOf, hasOwnProperty and propertyIsEnumerable.

Drop the implementation-justification comments above WORDS.
Describe SHOWN only as the runtime's printed datatype kinds, if needed.
Keep historical failures and control classification in the PR evidence.

Upstream status and maintainer fit

My fresh searches for 948 and "null prototype" in upstream PRs returned no matches, but the module's commit history finds direct prior art: 9969b56d7490ffb18446475b0fea97ec23590677, “A wide record, a hidden cycle and a datatype named proto all compile.” It explicitly fixes bendlang#948 using a null-prototype WORDS and a list-based printer kind lookup. Current upstream source contains both repairs; #948 is closed. The fork body's “OPEN” status is stale. Do not submit this as an unfixed upstream bug; check whether any remaining regression coverage is useful separately. This status note is not counted as another code finding.

The module-level SHOWN table is a reasonable immutable lookup, not a per-book cache, and avoids allocating a literal for every node. The null-prototype idiom already appears in OPERATIONS/OPTIMIZED. Upstream has since chosen a different printer representation; that is concrete integration evidence, not a reason to call this representation incorrect.

I read the recent module history and merged outside PRs #855, #844, #861, and the file list for #958. bendlang#844 adds a 38-line compile fixture for a small layout repair; bendlang#855 adds separate fixtures for different compiler paths. Separate files for datatype, type-parameter, and Array-cell paths are therefore defensible, as are the short snake_case names and #| outputs used by neighboring fixtures. AGENTS.md specifies that format and leaves bend2/bend.ts human-written; this patch respects both. I found no basis in that guidance to demand a changelog, issue-first step, or squash. The declarative title fits recent compiler commit subjects. The five fixtures total 145 lines against an 11-line production addition; the path distinctions justify executable coverage, but not the lengthy explanatory headers flagged above.

Independently rebuilt boundaries and assertion read

The production diff adds no numeric bounds or loops. Its changed operations are the two table initializations and SHOWN[adt.k] ?? 7; WORDS consumers acquire new lookup semantics without textual changes.

Operation / boundary input Fixed behavior Pin or reachability
WORDS owned names U32/F32/Nat Same W32/W32/W64 objects All new fixtures contain U32; existing f32_table_nan_bits covers F32. Nat remains an unchanged owned entry.
WORDS inherited names: proto, constructor, toString Missing entry; existing fallback computes a Lay proto_name_datatype; parameterized path in proto_name_type_param
WORDS valueOf/hasOwnProperty/propertyIsEnumerable Same missing-entry fallback proto_name_value_of
WORDS ordinary missing name Normal layout computation Box in proto_name_ctor_field
WORDS through Array element layout Same repaired lookup via lay_el proto_name_array_cell
SHOWN owned value zero (U32) Preserves 0, not fallback 7 U32 output fields in every fixture
SHOWN other owned values 1/2/3/4/6 Preserves built-in kind Array kind 6 in proto_name_array_cell; remaining entries unchanged, existing built-in coverage
SHOWN missing/inherited name yields undefined Coalesces to kind 7 and emits constructor descriptors Nested printed values in datatype/value_of/type_param/array_cell fixtures
Empty string, null/undefined name, empty collection Not a source datatype identifier; null ADT is refused before SHOWN access Guard at comp.ts:1310 and source identifier contract; no new source case
Negative, zero, limit, limit+1, maximum numeric key No numeric index or limit introduced; name lookup, not array indexing Numeric data does not become adt.k; no new numeric boundary
Constructor/field names rather than datatype names Not keys of the changed tables proto_name_ctor_field
JS versus C; interpreter Shared layout/descriptor repair for emitters; interpreter unaffected Fixtures run across lanes in gates/test.ts; local C binary evidence remains absent
Repeated books / module lifetime SHOWN has only constant built-in entries, no per-book mutation Static read of table and sole reader; no new stateful branch

I read every new #| assertion: (T{C{P{35}}, 7}, 42), (W{9}, 9), ([E{4}], 7), (Q{H{V{20}}, 22}, 42), and (__proto__{5}, 5). The first four force the problematic datatype into the printed result, not merely into a computation that could disappear before descriptor construction. They distinguish the base compiler failure in both emitter paths by static trace. The constructor/field fixture is intentionally non-discriminating and protects a separate unchanged surface, not evidence that the datatype bug is fixed. Interpreter results are likewise controls, not regression proof for compilation.

What is good / limits

I can confirm the original bug from the removed base lines and their consumers: WORDS["__proto__"] returns Object.prototype rather than a Lay; repairing only that table still lets the old printer lookup return a function for toString, bypassing kind 7. Both fixes belong together. I found no new correctness defect in the executable change.

Reviewed the full diff, surrounding layout/printer code, test harness contract, neighboring tests, all three commit messages, and upstream evidence above. No test suite or local build was run in this review. gh pr checks reports no checks; that is absence, not passing CI. The disclosed lack of compiled C execution is appropriately explicit and should remain so. No em dash or other listed lexical tell was found in the title or commit messages; the committed-comment tells are enumerated above.

SECOND READ: NOT READY — committed comments narrate patch history and justify the fix instead of stating test behavior; upstream has also already repaired bendlang#948.

@askalf

askalf commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Dropped — fixed upstream before this branch existed

Closing this candidate. The bug is real and the fix was correct, but bendlang/bend repaired it themselves 26 minutes before our first commit, and this branch can never be submitted.

The upstream fix

9969b56d7490ffb18446475b0fea97ec23590677, authored 2026-09-21T15:10:19Z, an ancestor of main (compare 9969b56...main → behind 0, ahead 14). Its message names the issue:

WORDS has no prototype, as OPERATIONS and OPTIMIZED do, and the printer finds a type's kind in a list, so a datatype named __proto__, constructor or toString compiles on both emitters. (bendlang#948)

It touches bend2/comp.ts and adds tests/compile/proto_type_name.bend. Both halves of the defect are repaired there:

  • bend2/comp.ts:166-167 — const WORDS: Record<string, Lay> = Object.setPrototypeOf({ U32: W32, F32: W32, Nat: W64 }, null); — the same repair as ours.
  • bend2/comp.ts:1280-1281 — the printed-kind lookup became "U32 F32 Nat Char String . Array".split(" ").indexOf(adt.k) & 7, where an unknown name gives -1 & 7 === 7. A different shape from our module-level null-prototype SHOWN table, reaching the same fallback. Upstream chose a list; there is nothing left for our table to fix.

Issue #948 was closed as completed at 2026-09-21T17:55:14Z.

Settled by execution, not by reading

Cut a worktree on origin/main (a495242), restored all five fixtures from this branch into it, and ran the JS lane by hand with run-test.sh (check, interpret, build, run the artefact):

### proto_name_datatype  @ /agent-workspace/oss/bend-wt-1790082804
[interp PASS]
[build rc=0]
[js rc=0]
[js PASS]
### proto_name_type_param  @ ...
[interp PASS]
[build rc=0]
[js rc=0]
[js PASS]
### proto_name_array_cell  @ ...
[interp PASS]
[build rc=0]
[js rc=0]
[js PASS]
### proto_name_value_of  @ ...
[interp PASS]
[build rc=0]
[js rc=0]
[js PASS]
### proto_name_ctor_field  @ ...
[interp PASS]
[build rc=0]
[js rc=0]
[js PASS]

Five of five pass, zero failures. The four discriminators fail on our base e52cda4 with TypeError: undefined is not an object (evaluating 'lay.ks.length') / lay.ks.some — on current main they all build and their artefacts print the expected #| lines. Transcript: /agent-output/oss/bend/upstream-main-arm.txt.

Why the prior-art gate missed it

The hunt searched upstream PRs and issues by number and by term, and was clean. Upstream fixed this as a direct commit to the default branch — no PR to find — and the issue stayed open for a further 2h45m after the code landed, so it still read as huntable from outside.

The verification's own findings stand on their merits (the valueOf/hasOwnProperty hole in row B6 was real, and mutants M1/M2 correctly showed neither half of the fix suffices alone). None of that is submittable now.

The Second Read's other finding — fixture comments narrating patch history rather than test behaviour — is correct and is recorded in the rule book, but is moot here: nothing goes upstream.

@askalf askalf closed this Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

oss-candidate Sprayberry Code candidate for upstream verified Adversarially verified by a fresh run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants