From dace1e29fc33e39ac320e3c8b0b222dca268f591 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 28 Oct 2026 23:07:47 +0000 Subject: [PATCH 1/2] -mm=own: untyped literals given later, and fields taken out of temporaries An object literal a variable is declared with, with no type, makes its this-free arrows closures when they name no local around them (spec 62): `const o = { w: (x: number) => x * 2 }; apply(o); o.w(1)` compiles under own. MLIRGen marks such a literal UntypedVariableInitializer. A literal returned or given with no receiver keeps its methods, which an interface's vtable takes, and so does an arrow that may capture a local. A field read out of a call's own result in one branch of `??` or `?:`, retained right after the read and the result released after that, is a move out of the field under own (spec 63): `let result = initial ?? next(xs, 0).value`. The retain goes, and an empty value is stored into the field before the release. rc: a branch that yields a field read out of a temporary it made now retains it before the temporary goes (consumeConditionalResults). Before, `const item = given ?? pair(n).item` read freed memory, and with `let` the program died. Tests: own_literal_variable_closures, own_temporary_field_take (own, rc, gc, none). Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 58 ++++++++++++ tslang/lib/TypeScript/MLIRGenImpl.h | 30 ++++-- tslang/lib/TypeScript/MLIRGenVariables.cpp | 5 + .../TypeScript/OwnedReturnConsumptionPass.cpp | 27 +++++- .../lib/TypeScript/OwnershipInferencePass.h | 5 + .../OwnershipInferencePlaceReads.cpp | 93 +++++++++++++++++++ tslang/test/tester/CMakeLists.txt | 4 +- .../own/own_literal_variable_closures.ts | 66 +++++++++++++ .../tester/own/own_temporary_field_take.ts | 75 +++++++++++++++ tslang/ts-new-parser/enums.h | 5 +- 10 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 tslang/test/tester/own/own_literal_variable_closures.ts create mode 100644 tslang/test/tester/own/own_temporary_field_take.ts diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 2d4a94502..293874aa1 100644 --- a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md +++ b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md @@ -5217,3 +5217,61 @@ the object out of it (`ts.GetValueFromUnion`), casts it to an opaque pointer, an `own_union_narrowed_calls` calls the narrowed name twice with a churn between, under own, rc, gc and none; own emits no counting. Negative: `own_err_union_narrowed_moved`, which stores the narrowed value into a global and then calls through it. + +## 62. An object literal given to a variable with no type, 2026-10-28 + +`const o = { w: (x: number) => x * 2 }; apply(o); o.w(1)` was `'this value' borrows a field and cannot +be stored, returned or captured` at `apply(o)`, and the read after it a use after a move: ยง60 left the +members of a literal with no receiver methods bound to the literal. + +- **A literal a variable declared with no type is given** (MLIRGen marks it + `InternalFlags::UntypedVariableInitializer`) is one more whose this-free arrows are closures + (`markClosureMembers`), as one made for an object type is. A cast of it to an interface copies the + closure into the interface's object field by field (`const a: Adder = o`), as it copies any field. +- **Not an arrow that names a local around it** (any identifier the enclosing scopes' symbol table + holds, its own parameters included when they shadow one): copied out of the literal by such a cast, + a closure's captures would still be the literal's, freed with it (`const o = { area: () => box.w }; + return o;` for a `Shape` result). It stays a method, as before. +- **Not a literal returned or given with no receiver** (`return { id, area: () => box.w }`): MLIRGen + builds it for no type and casts it to the interface, which takes the literal's methods for its + vtable; a closure field of a capture's type does not fit that slot. + +`own_literal_variable_closures` gives such literals to a function-typed field and calls them after, casts +one to an interface after reading its number field, and gives one holding a class object to a +function-typed field and reads the object after, churning between steps, under own, rc, gc and none. +`00interface_type_tag_in_vtable` keeps its directly returned capturing literal. + +Not here: a literal whose arrows capture a local (`const o = { w: (x: number) => x * k }; apply(o)`) +is still rejected under own. Found, not fixed (every model): `x` of an `any` that holds an object +literal crashes in the cast. + +## 63. A field taken out of a temporary in a branch, 2026-10-28 + +`let result = initial ?? next(xs, 0).value` (DefaultLib's `reduce` shape, `value: any`) was `'this +value' borrows a field and cannot be stored, returned or captured`. The `??` builds a `ts.If`: the +branch that calls `next` reads the field, rc retains the read, the temporary is released at the end of +the branch, and the merge goes to the local. + +- **The take** (`temporaryFieldTakeOf`, checkPlaceRead). A read of a field of a call's own result + (`callResultIsOwn`), with rc's retain of it right after the read, the result released once after + that retain in the same block, and no other read of that field, is a move out of the field: the walk + is made again with the retain and its taker (a local, a store) taken for uses, and nothing may use the + borrow after the taker. rc's retain goes, and right before the release a `ts.Default` of the field's + type is stored into the field, so the release frees the object and nothing of what moved. A string is + moved the same way. +- **rc, every branch retains** (`consumeConditionalResults`, OwnedReturnConsumptionPass). A branch that + yields a field read out of a temporary made in it gave the temporary back before the `ts.If` ended, + and with it what the field held; the receiver's retain after the merge came too late. rc then read + freed memory for a class or a string field (`const item = given ?? pair(n).item` read 0, and with + `let` the program died): only an `any` field, whose other branch boxed a fresh value, was retained in + the branch. Such a branch now retains what it yields, as a branch beside one that carries a fresh + value always did, and the `ts.If` carries the reference. + +`own_temporary_field_take` reduces strings through `initial ?? next(xs, 0).value`, picks with a `?:`, +and gives class objects read out of temporaries in both branches of a `?:` to a `const` and a `let`, +churning between steps, under own, rc, gc and none. Teeth: without the store of the empty value, the +own build fails its count (exit 3). + +Not here: the field of a named local of a class type (`const r = next(xs, 0); let v = initial ?? +r.value`) is still a borrow that cannot be stored, and a parameter on the other branch (`given ?? +pair(n).item`) is kept, which callers cannot give up. diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index a410adec1..5a545de2c 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -11618,14 +11618,18 @@ class MLIRGenImpl // not the literal's) is a value in a field like any other, made as a closure, not a method bound to the // literal object: a method read out of the literal into a function-typed field (`{ w: x => x + 1 }` // given for `{ w: (x: number) => number }`) hands out the literal, which nothing owns then. Only a literal - // made for an object type is one: a literal with no receiver may become an interface, whose vtable is - // built from the literal's methods, and so may one made for an interface. An arrow whose untyped - // parameters have no function type to take theirs from stays a method too. + // made for an object type is one, or the value of a variable declared with no type (`const o = { w: ... }`, + // given later to `apply(o)`), which a cast to an interface copies field by field. A literal returned or + // given with no receiver may be built for an interface, whose vtable takes the literal's methods, and so + // may one made for an interface. An arrow whose untyped parameters have no function type to take theirs + // from stays a method too. void markClosureMembers(ObjectLiteralInfo &oli, const GenContext &genContext) { oli.closureMembers.clear(); auto receiverType = oli.receiverType ? oli.receiverType : oli.memberReceiverType; - auto objectTypeReceiver = receiverType && isa(MLIRTypeHelper::stripValueType(receiverType)); + auto untypedVariable = !receiverType && !!(oli.objectLiteral->internalFlags & InternalFlags::UntypedVariableInitializer); + auto objectTypeReceiver = receiverType ? isa(MLIRTypeHelper::stripValueType(receiverType)) + : untypedVariable; for (auto &item : oli.objectLiteral->properties) { auto closure = false; @@ -11635,16 +11639,26 @@ class MLIRGenImpl Node initializer = propertyAssignment->initializer; if (initializer == SyntaxKind::ArrowFunction) { - auto saysThis = false; - FilterVisitorAST thisVisitor(SyntaxKind::ThisKeyword, [&](auto) { saysThis = true; }); - FilterVisitorAST superVisitor(SyntaxKind::SuperKeyword, [&](auto) { saysThis = true; }); + auto bound = false; + FilterVisitorAST thisVisitor(SyntaxKind::ThisKeyword, [&](auto) { bound = true; }); + FilterVisitorAST superVisitor(SyntaxKind::SuperKeyword, [&](auto) { bound = true; }); thisVisitor.visit(initializer); superVisitor.visit(initializer); + // ...nor, in a variable's literal, a local around it: a closure that captures one + // is copied out of the literal by a cast to an interface, its captures still the literal's + if (untypedVariable) + { + FilterVisitorAST localVisitor(SyntaxKind::Identifier, [&](auto identifier) { + bound = bound || symbolTable.count(MLIRHelper::getName(identifier.template as())); + }); + localVisitor.visit(initializer); + } + auto arrow = initializer.as(); auto typed = llvm::all_of(arrow->parameters, [](ParameterDeclaration parameter) { return !!parameter->type; }) || getObjectLiteralMemberReceiverFuncType(oli, TupleFieldName(propertyAssignment->name, genContext)); - closure = !saysThis && typed; + closure = !bound && typed; } } diff --git a/tslang/lib/TypeScript/MLIRGenVariables.cpp b/tslang/lib/TypeScript/MLIRGenVariables.cpp index a5833d1fb..c1c2cd5f0 100644 --- a/tslang/lib/TypeScript/MLIRGenVariables.cpp +++ b/tslang/lib/TypeScript/MLIRGenVariables.cpp @@ -841,6 +841,11 @@ namespace mlirgen } #endif + if (!item->type && item->initializer == SyntaxKind::ObjectLiteralExpression) + { + item->initializer->internalFlags |= InternalFlags::UntypedVariableInitializer; + } + auto initFunc = [&](mlir::Location location, const GenContext &genContext) { // A module imported as source only declares what it defines, so a module-level // variable gets its type and no initializer code. Not a local: a function body is only diff --git a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp index 0513705e6..f91e6a32b 100644 --- a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp +++ b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp @@ -1563,7 +1563,32 @@ class OwnedReturnConsumptionPass definingOp->getParentRegion() == resultOp->getParentRegion(); }; - if (llvm::none_of(yields, carriesOwned)) + // ...or some branch yields a field read out of a temporary it made (`given ?? pair(n).item`): + // the temporary is given back before the branch ends, and with it what the field holds, + // so the receiver's retain after the `ts.If` comes too late. The branch retains it first. + auto borrowsBranchTemporary = [&](mlir_ts::ResultOp resultOp) { + auto value = producerOf(resultOp->getOperand(0)); + while (auto loadOp = value.getDefiningOp()) + { + auto propertyRefOp = loadOp.getReference().getDefiningOp(); + if (!propertyRefOp) + { + return false; + } + + value = propertyRefOp.getObjectRef(); + auto *definingOp = value.getDefiningOp(); + if (definingOp && ::typescript::mayTakeOverReference(value) && + definingOp->getParentRegion() == resultOp->getParentRegion()) + { + return true; + } + } + + return false; + }; + + if (llvm::none_of(yields, carriesOwned) && llvm::none_of(yields, borrowsBranchTemporary)) { return; } diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.h b/tslang/lib/TypeScript/OwnershipInferencePass.h index 096c23913..d390f7640 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.h +++ b/tslang/lib/TypeScript/OwnershipInferencePass.h @@ -817,6 +817,8 @@ class OwnershipInferencePass mlir::Operation *taker = nullptr; mlir::Value field; mlir_ts::VariableOp slot; + // a field of a temporary (spec 63): the temporary's release, before which the field is emptied + mlir::Operation *release = nullptr; }; // Is `kept`, what keeps the borrow `read` (a read of a field of an owning record local), one @@ -826,6 +828,9 @@ class OwnershipInferencePass // ...made once for each value read, with nothing using the borrow after it, and nothing reading // the field or the record before the local ends? bool fieldTakeHolds(mlir::Operation *read, const FieldTake &take, llvm::ArrayRef uses); + // Is `kept` rc's retain of `read`, a read of a field of a call's own result released as a + // temporary right after, with nothing else reading that field (spec 63)? + bool temporaryFieldTakeOf(mlir::Operation *read, mlir::Operation *kept, FieldTake &take); // Can `call` be given a copy of `value`, a string read out of a place, in its place (callCopies)? static bool copiesInto(mlir::Operation *call, mlir::Value value); // Does `store` put `value`, a string read out of a place, into another place with no retain of diff --git a/tslang/lib/TypeScript/OwnershipInferencePlaceReads.cpp b/tslang/lib/TypeScript/OwnershipInferencePlaceReads.cpp index b746e2628..7b3ac7eae 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePlaceReads.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePlaceReads.cpp @@ -831,6 +831,39 @@ namespace own_inference } } + // ...or out of a field of a call's own result, released as a temporary right after the read + // (spec 63): `init ?? next(xs, 0).value`. The temporary frees nothing of it then, so nothing + // after the move can see the release. + if (!take.taker && kept && !countedHandle && temporaryFieldTakeOf(read, kept, take)) + { + takenOps.insert(take.retain); + takenOps.insert(take.taker); + kept = walk(); + takenOps.clear(); + auto usedAfter = llvm::any_of(uses, [&](auto &use) { + return use.op != take.taker && use.op != take.retain && reachableAfter(take.taker, use.op, use.kills); + }); + if (kept || letKept || usedAfter) + { + take = {}; + kept = walk(); + } + else + { + if (!dryRun) + { + mlir::OpBuilder builder(take.release); + auto fieldType = mlir::cast(take.field.getType()).getElementType(); + auto empty = builder.create(take.release->getLoc(), fieldType); + builder.create(take.release->getLoc(), empty, take.field); + } + + bookkeeping.push_back(take.retain); + toErase.insert(bookkeeping.begin(), bookkeeping.end()); + return true; + } + } + if (take.taker) { bookkeeping.push_back(take.retain); @@ -1106,6 +1139,66 @@ namespace own_inference return true; } + bool OwnershipInferencePass::temporaryFieldTakeOf(mlir::Operation *read, mlir::Operation *kept, FieldTake &take) + { + // rc's retain of the read itself, right after it: nothing runs between the read and the move. The + // walk may meet the taker first (`r = next(xs, 0).value`). A string too: it moves, as nothing + // else holds it. + auto loadOp = mlir::dyn_cast(read); + auto retainOp = loadOp ? mlir::dyn_cast_or_null(read->getNextNode()) : mlir_ts::RetainOp(); + if (!retainOp || retainOp.getReference() != loadOp.getResult() || + (kept != retainOp && takerOf(retainOp, loadOp.getResult()) != kept)) + { + return false; + } + + kept = retainOp; + + // a field read in place out of a call's own result, not a borrow of what the call was given + auto field = loadOp.getReference().getDefiningOp(); + auto temporary = field ? field.getObjectRef() : mlir::Value(); + auto *call = temporary ? temporary.getDefiningOp() : nullptr; + if (!call || !callResultIsOwn(call) || !field->hasOneUse()) + { + return false; + } + + // released once, as a temporary, after the move: the release is the only end of what the field + // holds, and no other read of the result reaches that field + mlir::Operation *release = nullptr; + for (auto *user : temporary.getUsers()) + { + if (mlir::isa(user) && !release) + { + release = user; + continue; + } + + auto propertyRefOp = mlir::dyn_cast(user); + if (!propertyRefOp || (propertyRefOp != field && propertyRefOp.getPosition() == field.getPosition())) + { + return false; + } + } + + if (!release || release->getBlock() != kept->getBlock() || !kept->isBeforeInBlock(release)) + { + return false; + } + + auto *taker = takerOf(kept, loadOp.getResult()); + if (!taker) + { + return false; + } + + take.retain = kept; + take.taker = taker; + take.field = field.getResult(); + take.release = release; + return true; + } + bool OwnershipInferencePass::keptParamOutlives(const Chain &chain, llvm::function_ref outlives) { for (auto &root : chain.roots) diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index f3e0beabb..cf05a12ab 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3978,7 +3978,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_generator_this_param own_copy_within_returned_param own_swap_elements own_callback_forwarded own_optional_record_read own_param_default_kept own_literal_into_array own_generator_take own_rest_array_borrows own_string_copies - own_captured_string_callback own_callback_and_call own_callback_captured own_literal_closures own_union_narrowed_calls + own_captured_string_callback own_callback_and_call own_callback_captured own_literal_closures own_literal_variable_closures own_temporary_field_take own_union_narrowed_calls own_callback_shapes own_callback_extended own_callback_interface own_generic_specializations own_callback_cast own_callback_interface_slot) tslang_add_test(NAME test-jit-own-${own_test} COMMAND test-runner -jit -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") @@ -4016,7 +4016,7 @@ foreach(keeps_mm rc gc none) own_copy_within_returned_param own_swap_elements own_callback_forwarded own_optional_record_read own_param_default_kept own_literal_into_array own_generator_take own_rest_array_borrows own_string_copies - own_captured_string_callback own_callback_and_call own_callback_captured own_literal_closures own_union_narrowed_calls + own_captured_string_callback own_callback_and_call own_callback_captured own_literal_closures own_literal_variable_closures own_temporary_field_take own_union_narrowed_calls own_callback_shapes own_callback_extended own_callback_interface own_generic_specializations own_callback_cast own_callback_interface_slot) tslang_add_test(NAME test-jit-${keeps_mm}-${keeps_test} COMMAND test-runner -jit -mm=${keeps_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${keeps_test}.ts") diff --git a/tslang/test/tester/own/own_literal_variable_closures.ts b/tslang/test/tester/own/own_literal_variable_closures.ts new file mode 100644 index 000000000..57182ed77 --- /dev/null +++ b/tslang/test/tester/own/own_literal_variable_closures.ts @@ -0,0 +1,66 @@ +// An arrow function member of an object literal a variable is declared with, with no type, is a closure +// in a field when it says neither `this` nor a local around it (spec 62): the literal given later to a +// function-typed field (`apply(o)`), and used after, hands out no literal object, and a cast to an +// interface copies the function as it would a method. Each step churns, so anything freed too early reads +// garbage; every model runs it. +class Item { + constructor(public n: number) {} +} + +interface Adder { + add(x: number): number; +} + +let seen = 0; + +function churn() { + const junk: Item[] = []; + for (let i = 0; i < 64; i++) junk.push(new Item(999)); +} + +function apply(o: { w: (x: number) => number }) { + return o.w(3); +} + +function useAdder(a: Adder) { + return a.add(2); +} + +function test() { + let s: number = 0; + const o = { w: (x: number) => x * 2 }; + s += apply(o); + churn(); + s += o.w(1); + s += apply(o); + + const a = { add: (x: number) => x + 10, n: 4 }; + s += useAdder(a); + churn(); + s += a.n; + const i: Adder = a; + s += i.add(1); + + const h = { w: (x: number) => x + 1, item: new Item(5) }; + s += apply(h); + churn(); + s += h.item.n; + + const g = { w: (x: number) => x + seen }; + seen = 1; + s += apply(g); + churn(); + return s; +} + +function main() { + let ok = 0; + for (let i = 0; i < 300; i++) { + seen = 0; + if (test() == 6 + 2 + 6 + 12 + 4 + 11 + 4 + 5 + 4) ok++; + churn(); + } + + assert(ok == 300, "ok"); + print("done."); +} diff --git a/tslang/test/tester/own/own_temporary_field_take.ts b/tslang/test/tester/own/own_temporary_field_take.ts new file mode 100644 index 000000000..b9c9c1c92 --- /dev/null +++ b/tslang/test/tester/own/own_temporary_field_take.ts @@ -0,0 +1,75 @@ +// A field read out of a call's own result in one branch of a `??` or a `?:`, kept, and the result +// released right after (spec 63): rc retains it in the branch, before the release, and own takes it out +// of the field, so the result's release frees nothing of it. `initial ?? next(xs, 0).value` of +// DefaultLib's reduce, a string, and a class object given a `const` or a `let`. Each step churns, so a +// value freed with the temporary reads garbage; every model runs it. +class Item { + constructor(public n: number) {} +} + +class Res { + constructor(public value: any, public done: boolean) {} +} + +class Pair { + constructor(public item: Item, public tag: number) {} +} + +function churn() { + const junk: Item[] = []; + for (let i = 0; i < 64; i++) junk.push(new Item(999)); +} + +function next(xs: string[], i: number): Res { + return new Res(xs[i], i >= xs.length); +} + +function pair(n: number): Pair { + return new Pair(new Item(n), n); +} + +function red(xs: string[], f: (a: string, b: string) => string, initial?: string): string { + let result = initial ?? next(xs, 0).value; + churn(); + for (let i = initial ? 0 : 1; i < xs.length; i++) { + result = f(result, xs[i]); + } + + return result; +} + +function pick(xs: string[], initial?: string): string { + let result = initial ? initial : next(xs, 0).value; + churn(); + return result; +} + +function itemOf(n: number): number { + const item = n > 0 ? pair(n).item : pair(-n).item; + churn(); + let other = n > 0 ? pair(n + 1).item : pair(0).item; + churn(); + return item.n * 10 + other.n; +} + +function test() { + let s: number = 0; + if (red(["a", "b", "c"], (a, b) => a + b) == "abc") s++; + if (red(["a", "b"], (a, b) => a + b, "z") == "zab") s++; + if (pick(["q"]) == "q") s++; + if (pick(["q"], "p") == "p") s++; + s += itemOf(5); + s += itemOf(-2); + return s; +} + +function main() { + let ok = 0; + for (let i = 0; i < 300; i++) { + if (test() == 4 + 56 + 20) ok++; + churn(); + } + + assert(ok == 300, "ok"); + print("done."); +} diff --git a/tslang/ts-new-parser/enums.h b/tslang/ts-new-parser/enums.h index f92036580..418176301 100644 --- a/tslang/ts-new-parser/enums.h +++ b/tslang/ts-new-parser/enums.h @@ -567,7 +567,10 @@ enum class InternalFlags : number GeneratorNext = 1 << 13, // the `undefined` of a finished generator's {value: undefined, done: true}: a `value` type that cannot hold // undefined takes its zero (mlirGenObjectLiteralFields) - GeneratorDoneValue = 1 << 14 + GeneratorDoneValue = 1 << 14, + // an object literal a variable declared with no type is given (`const o = { w: ... }`): its this-free arrows + // are closures (markClosureMembers) + UntypedVariableInitializer = 1 << 15 }; ENUM_OPS(InternalFlags) From a3c3e3ad84c0c37e2a466aa0692d76ae2e71f2ae Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 28 Oct 2026 23:21:30 +0000 Subject: [PATCH 2/2] Cast from any to an interface throws for an object literal, and gives null for null An object literal boxed as it is (typeof 'object' or 'tuple') was read as the interface's (this, vtable) pair, and the call through it crashed under every model. The box says only that it holds a literal, not which fields, so no vtable can be laid out: ___unbox_interface now throws "Can't cast from any: an object literal has no vtable for interface ...". An interface value made of a literal before it was boxed still round-trips. A boxed null (or undefined) cast to an interface was not null under rc, none and own, where it is a box of its own; it now gives the null interface. Test: 00any_literal_to_interface. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 6 ++- tslang/lib/TypeScript/MLIRGenCast.cpp | 10 +++++ tslang/test/tester/CMakeLists.txt | 3 ++ .../tests/00any_literal_to_interface.ts | 39 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tslang/test/tester/tests/00any_literal_to_interface.ts diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 293874aa1..4a50e4075 100644 --- a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md +++ b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md @@ -5242,8 +5242,10 @@ function-typed field and reads the object after, churning between steps, under o `00interface_type_tag_in_vtable` keeps its directly returned capturing literal. Not here: a literal whose arrows capture a local (`const o = { w: (x: number) => x * k }; apply(o)`) -is still rejected under own. Found, not fixed (every model): `x` of an `any` that holds an object -literal crashes in the cast. +is still rejected under own. Found and fixed (every model): `x` of an `any` that holds an object +literal read its block as the interface's pair and crashed; it now throws, since the box does not say which +fields the literal has (`00any_literal_to_interface`). A boxed null cast so was not null under rc, none and +own; it is the null interface now. ## 63. A field taken out of a temporary in a branch, 2026-10-28 diff --git a/tslang/lib/TypeScript/MLIRGenCast.cpp b/tslang/lib/TypeScript/MLIRGenCast.cpp index 1aa029dab..44a8845af 100644 --- a/tslang/lib/TypeScript/MLIRGenCast.cpp +++ b/tslang/lib/TypeScript/MLIRGenCast.cpp @@ -2592,6 +2592,9 @@ namespace mlirgen // - any other class of this module can't: no vtable for the interface was made for it, and none // is made here (one made here would be a way to the class's fields that nothing else in the // module shows), so the cast throws. A class object no class answers for throws too; + // - an object literal boxed as it is (`typeof` 'object' or 'tuple') throws: the box does not say + // which fields it has, so nothing can lay out a vtable for it; + // - null and undefined give the null interface; // - anything else - an interface value over an object literal or a class of another module, a // number used as a token - is read as the interface's pair, as before: an `any` holding the // interface it is cast to round-trips. @@ -2680,6 +2683,13 @@ namespace mlirgen } ss << S("}\n"); + + // an object literal, boxed as it is: the box says only that it is one, not what fields it has, so + // no vtable can be found for it here, and its block read as the interface's pair called garbage + ss << S("if (typeof a == 'object' || typeof a == 'tuple') throw \"Can't cast from any: an object literal has no vtable for interface '") + << stows(interfaceInfo->fullName.str()) << S("'\";\n"); + // null and undefined have a box of their own under the counting models, not the null pair + ss << S("if (typeof a == 'null' || typeof a == 'undefined') {\nconst n: TYPE_ANY_INTERFACE_ALIAS | null = null;\nreturn n;\n}\n"); ss << S("return a;\n}\n"); auto src = ss.str(); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index cf05a12ab..78f848e9b 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -753,6 +753,7 @@ tslang_add_test(NAME test-compile-00-spread COMMAND test-runner "${PROJECT_SOURC tslang_add_test(NAME test-compile-01-spread COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/01spread.ts") tslang_add_test(NAME test-compile-00-types-unknown1 COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00types_unknown1.ts") tslang_add_test(NAME test-compile-00-any-to-tuple COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00any_to_tuple.ts") +tslang_add_test(NAME test-compile-00-any-literal-to-interface COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00any_literal_to_interface.ts") tslang_add_test(NAME test-compile-00-unknown-round-trip COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00unknown_round_trip.ts") tslang_add_test(NAME test-compile-00-types-indexed-access-type COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00types_indexedaccesstype.ts") tslang_add_test(NAME test-compile-00-types-mapped-type COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00types_mappedtype.ts") @@ -1376,6 +1377,7 @@ tslang_add_test(NAME test-jit-00-spread COMMAND test-runner -jit "${PROJECT_SOUR tslang_add_test(NAME test-jit-01-spread COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/01spread.ts") tslang_add_test(NAME test-jit-00-types-unknown1 COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00types_unknown1.ts") tslang_add_test(NAME test-jit-00-any-to-tuple COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00any_to_tuple.ts") +tslang_add_test(NAME test-jit-00-any-literal-to-interface COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00any_literal_to_interface.ts") tslang_add_test(NAME test-jit-00-unknown-round-trip COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00unknown_round_trip.ts") tslang_add_test(NAME test-jit-00-types-indexed-access-type COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00types_indexedaccesstype.ts") tslang_add_test(NAME test-jit-00-types-mapped-type COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00types_mappedtype.ts") @@ -2477,6 +2479,7 @@ set(TSLANG_CORPUS 00template_literal_escapes.ts 00types_unknown1.ts 00any_to_tuple.ts + 00any_literal_to_interface.ts 00unknown_round_trip.ts 00types_utility.ts 00types.ts diff --git a/tslang/test/tester/tests/00any_literal_to_interface.ts b/tslang/test/tester/tests/00any_literal_to_interface.ts new file mode 100644 index 000000000..a6b8fc1bc --- /dev/null +++ b/tslang/test/tester/tests/00any_literal_to_interface.ts @@ -0,0 +1,39 @@ +// `any` to an interface of an object literal boxed as it is throws: the box says only that it holds an +// object literal ('object', or 'tuple' for one of fields only), not which fields, so no vtable can be +// laid out for it. Its block was read as the interface's (this, vtable) pair, and the call through it +// crashed, under every memory model. An interface value made of a literal before it was boxed still +// round-trips. A boxed null gives the null interface: under rc, none and own it was a box of its own, +// read as a pair that was not null. +interface Adder { + add(x: number): number; +} + +function asAdder(x: any) { + return x; +} + +function throws(x: any) { + let thrown = false; + try { + asAdder(x); + } catch (e) { + thrown = true; + } + + return thrown; +} + +function main() { + const fields = { add: (x: number) => x + 10 }; + assert(throws(fields), "a literal of fields only"); + + const methods = { add(x: number) { return x + 20; } }; + assert(throws(methods), "a literal with a method"); + + const i: Adder = { add(x: number) { return x + 30; } }; + assert(asAdder(i).add(1) == 31, "an interface value round-trips"); + + assert(asAdder(null) == null, "null"); + + print("done."); +}