From a7362905bf0aaf924d18fccae79a525d4904860c Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 16:33:40 +0100 Subject: [PATCH 1/6] -mm=own phase 6: a global's initializer moves what it makes into the global A global's initializer runs in the global's own region, which no function's inference sees, so rc's retain for its `ts.GlobalResult` was left behind and the lowering failed the compile ("ownership inference left a retain behind", with no location). A global is a root: what its initializer makes moves into it. The signature pass erases a retain in a global's region when the value is the only reference to its block (retained once, released nowhere) and is fresh, constant data, or a record of constants, and every use of it ends in the `ts.GlobalResult`: directly, through views, as an element of an array literal, or copied into an object literal's box. Any other retain there is reported by the global's name. isConstantData (OwnershipFacts.h): what holds no block, a `ts.Constant` or a field of one, and a tuple of such. The phase 6 plan: docs/superpowers/plans/2026-10-02-own-phase-6.md. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-02-own-phase-6.md | 143 ++++++++++++++++++ tslang/lib/TypeScript/OwnershipFacts.h | 34 +++++ .../lib/TypeScript/OwnershipSignaturePass.cpp | 97 ++++++++++++ tslang/test/tester/CMakeLists.txt | 3 +- tslang/test/tester/own/own_global_init.ts | 41 +++++ 5 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 tslang/docs/superpowers/plans/2026-10-02-own-phase-6.md create mode 100644 tslang/test/tester/own/own_global_init.ts diff --git a/tslang/docs/superpowers/plans/2026-10-02-own-phase-6.md b/tslang/docs/superpowers/plans/2026-10-02-own-phase-6.md new file mode 100644 index 000000000..421fb8294 --- /dev/null +++ b/tslang/docs/superpowers/plans/2026-10-02-own-phase-6.md @@ -0,0 +1,143 @@ +# `-mm=own` Phase 6 Implementation Plan: the corpus report and diagnostics polish + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Spec §7 item 6, "refine the shapes the histogram says matter". After 7b, 364 of the 591 +corpus files compile under `-mm=own`. Of the 227 that fail, 137 stop at the catch-all +`'x' takes a second reference; -mm=own cannot prove a move or a borrow here yet`, which names no +rule. This phase measures what the catch-all hides, accepts the sound shapes, and names the rest. + +**Spec:** §5 (diagnostics), §6.5 (the corpus report). Results go into §20. + +## Global Constraints + +As in phase 7b (plan `2026-10-02-own-phase-7b.md`). Also: + +- Branch `own-phase-6` from `origin/main` (ad8ea908, with #441 merged). +- **The report tooling stays out of the tree.** These live in the scratchpad and are cited in §20: + - `corpus/run.sh`: the first error per file; + - `p6/shapedump.patch`: a temporary stderr dump at the four `reportSecondReference` sites, under + `OWN_SHAPES`: the retained value's definition, its root, its users, its type, whether it owns + a block, the source location, and for a `Load` the place it reads; + - `corpus/show.py`: buckets with source lines. + + Re-apply the patch with `git apply` for each histogram, and never commit it. +- **Re-histogram after each task.** Every fix moves files on to their next blocker. +- **Exit criterion:** every bucket that is the only blocker of 6 or more files is either accepted + or given a named message. The catch-all count is reported before and after. + +## The report on ad8ea908 (first error per file, 227 failures) + +| first error | files | +|---|---| +| takes a second reference (catch-all) | 137 | +| used after its value was moved | 22 | +| borrows a field and cannot be stored, returned or captured | 21 | +| borrows an element and cannot be ... | 9 | +| ownership inference left a retain behind (lowering) | 8 | +| other named rules, unsupported, frontend | 30 | + +The 137, split by the dump (447 sites). The last column counts files where the bucket is the only +one: + +| bucket | sites | files | only blocker | +|---|---|---|---| +| a slot read (`Load` of a `ts.Variable`): mixed | 97 | 39 | 17 | +| a store of a call's result: script-mode globals | 59 | 28 | 14 | +| tuple literals (`[[1, "a"], ...]`) | 52 | 23 | 13 | +| the retained value owns no block (`number \| null`) | 35 | 23 | 9 | +| a block argument (`c ? a : b`) | 18 | 11 | 7 | +| a function literal (`Cast` of a `SymbolRef`) | 24 | 13 | 6 | +| a read of a global (`AddressOf`) | 22 | 8 | 4 | +| a number/bool/bigint converted to a string | 42 | 11 | 0 | +| a string constant | 5 | 1 | 0 | +| other | 55 | 34 | 18 | + +## Findings that set the order + +- **Script-mode globals are an rc fault.** In top-level code, `const t = new C()` stores into + the global with no retain, and §9.30 then releases the value at the end of the block. 5ai fixed + this for an assignment (`t = new C()`, via `isOwnedGlobalSlot`) but not for a declaration + (`MLIRGenImpl.h`, the "not global scope" store). Own reads the store as a move and the release + as a second reference, which is correct on wrong IR. + - Under rc it is not observable today: nothing runs after `main`'s top-level code. A module + with top-level code, imported as source, does not compile ("Global is referenced by + parentless instruction! @puts"); with `-shared`, its exported `const` cannot be resolved. + Those are two pre-existing bugs, every model, outside this phase. +- **"Left a retain behind" (8 files) is a global initializer region.** `const b: I = {...}` at + module level builds a fresh value in the `ts.Global` region and retains it for + `ts.GlobalResult`. No own pass runs there, so the lowering's guard fails the compile. It is + loud, not a hole. + +## Rulings + +- **Globals.** A global is a root, and a fresh value moves into it. The retain for a + `ts.GlobalResult` of a fresh value, or of a view of one, is erased. Anything else retained in + a global region gets a named error, not the lowering's. +- **No block.** A `ts.Retain` whose root owns no block (a union or optional made from a number + or null) is erased: an SSA value never changes. + - A `ts.RetainSlot` is decided on its initializer's root, so it is erased only when nothing + else is ever stored into the slot. `let y: number | string = 1; y = "s"; let z = y;` must + stay an error, or move as it does today. Negative test and tooth. +- **Function literals.** A `ts.Cast` of a `ts.SymbolRef` to a function type holds no capture + box, so it owns no block. The same slot guard applies: a slot that a capturing closure is + later stored into is not covered. +- **Conversions.** A `ts.Cast` from a type that owns no block to `string` makes a fresh string. + It already carries `__owned_result`; `isFresh` should read that. +- **String constants.** A `ts.Constant` string is immortal: retaining it takes nothing. +- **Tuple literals.** A `ts.CreateTuple` whose owning fields are constants or moved fresh values + holds nothing of anyone else's, so rc's retain of it is its reference for the moves. This + extends `holdsConstantsOrMoves` to a `CreateTuple` that feeds a `ts.CreateArray`, a store or a + union. +- **Named messages.** A shape the dump recognises but this phase does not accept gets its own + message in §5's language, not the catch-all, for example: `'x' is a conditional expression's + result; -mm=own cannot prove which branch owns it yet`. + +## Review Focus + +1. Every erased retain is of a value that owns no block, is immortal, or moves into a root. +2. The slot guard: a `RetainSlot` decision on an initializer holds only if the slot is never + stored again. +3. The rc change adds a retain under every counting model: the rc suite must stay unchanged. It + does not branch on the model (§4.3), since gc erases retains. + +--- + +### Task 1: globals (rc, every counting model, a PR of its own) + +- [ ] A declaration of a global in top-level code goes through the owning-slot path: it takes + over an owned reference or retains, and releases the slot's old value, as an assignment + does. +- [ ] Tests: `own_global_script` (positive: `const`, `let` and `var` in top-level code, a class + and a string, read from a function); and the rc corpus unchanged. + +### Task 2: global initializer regions (own) + +- [ ] Erase the retain of a fresh value, or of a view of one, given to `ts.GlobalResult`. + Anything else retained in a global region: a named error. +- [ ] Tests: `own_global_init` (positive: an interface-typed object literal, a nested array + literal, an object with an accessor, at module level). + +### Task 3: no block, function literals, conversions, string constants + +- [ ] As the rulings say, with the slot guard. +- [ ] Tests: + - positive `own_no_block`: `number | null`, a function literal in a `let`, a number converted + to a string, string constants through a `?:`; + - negative `own_err_union_slot_reassigned` (the guard); + - teeth: drop the guard, and the negative compiles. + +### Task 4: tuple literals + +- [ ] `holdsConstantsOrMoves` for a `CreateTuple` of constants or moved fresh values. +- [ ] Tests: `own_tuple_literals`: `[number, string][]` literals, `push`/`unshift`/`splice` of a + literal tuple, a tuple in a union. + +### Task 5: named messages and the specific-rule buckets + +- [ ] Named messages for each recognised, unaccepted bucket left after tasks 1-4. +- [ ] Sample the use-after-move (22) and borrows-a-field (21) files with `--di`. A true positive + stays; a false positive becomes a shape. +- [ ] Re-run the report. Spec §20. diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index 6be74aa81..0d1fcd801 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -374,6 +374,40 @@ inline bool holdsNoBlock(mlir::Value value) return !ownsHeap(value) || (def && isLiteral(def)); } +// Data nothing owns: what holds no block, a `ts.Constant` (its strings are immortal) or a field of +// one, and a tuple built of such (`[1, "a"]`, an object literal's fields read out of its constant). +// Retaining it takes nothing anyone gives back. +inline bool isConstantData(mlir::Value value) +{ + if (holdsNoBlock(value)) + { + return true; + } + + auto *def = value.getDefiningOp(); + if (!def) + { + return false; + } + + if (mlir::isa(def)) + { + return true; + } + + if (auto extractOp = mlir::dyn_cast(def)) + { + return extractOp.getObject().getDefiningOp() != nullptr; + } + + if (mlir::isa(def)) + { + return llvm::all_of(def->getOperands(), [](mlir::Value operand) { return isConstantData(operand); }); + } + + return false; +} + // A captured variable's cell, as a closure body reaches it: read out of a field of its capture box // (or of any other block - a RefType loaded from somewhere is a cell). Its variable lives in // whatever function declared it, so an assignment through it may destroy a value that function, and diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 966dbc21e..6f1e5d119 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -50,6 +50,102 @@ class OwnershipSignaturePass : public mlir::PassWrapper(user)) + { + return true; + } + + if ((isView(user) && use.getOperandNumber() == 0) || mlir::isa(user)) + { + return movesIntoResult(user->getResult(0)); + } + + // copied into a block `ts.New` made, which itself moves in: an object literal's box + if (auto storeOp = mlir::dyn_cast(user); storeOp && use.getOperandNumber() == 0) + { + auto block = storeOp.getReference(); + return block.getDefiningOp() && + llvm::all_of(block.getUses(), [&](mlir::OpOperand &blockUse) { + return blockUse.getOwner() == storeOp.getOperation() || + (isView(blockUse.getOwner()) && movesIntoResult(blockUse.getOwner()->getResult(0))); + }); + } + + return false; + }); + } + + // A record copied out of a local a `ts.Constant` initializes, whose owning fields are given + // nothing that holds a block: it holds constants only - numbers, immortal string literals, + // functions - and nobody else's reference. + static bool holdsOnlyConstants(mlir::Value value) + { + auto loadOp = value.getDefiningOp(); + auto varOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::VariableOp(); + if (!varOp || !varOp.getInitializer() || !varOp.getInitializer().getDefiningOp()) + { + return false; + } + + return llvm::all_of(varOp->getUsers(), [](mlir::Operation *user) { + if (mlir::isa(user)) + { + return true; + } + + auto propertyRefOp = mlir::dyn_cast(user); + return propertyRefOp && llvm::all_of(propertyRefOp->getUsers(), [&](mlir::Operation *fieldUser) { + auto storeOp = mlir::dyn_cast(fieldUser); + return mlir::isa(fieldUser) || + (storeOp && storeOp.getReference() == propertyRefOp.getResult() && + holdsNoBlock(storeOp.getValue())); + }); + }); + } + + void moveIntoGlobals(mlir::ModuleOp module) + { + module.walk([&](mlir_ts::GlobalOp globalOp) { + llvm::SmallVector retains; + globalOp.walk([&](mlir_ts::RetainOp retainOp) { retains.push_back(retainOp); }); + for (auto retainOp : retains) + { + auto value = retainOp.getReference(); + + // nothing in the region gives the block back, or takes a second reference to it: + // the global is its one owner + auto released = false; + auto retained = 0; + forEachUse(rootOf(value), [&](mlir::Operation *user, mlir::Value) { + released = released || mlir::isa(user); + retained += mlir::isa(user); + }); + + if (retained == 1 && !released && (isFresh(value) || holdsOnlyConstants(value) || isConstantData(value)) && movesIntoResult(value)) + { + retainOp.erase(); + continue; + } + + retainOp.emitError("the initializer of global '") + << globalOp.getSymName() << "' takes a second reference; -mm=own cannot prove a move or a borrow here yet"; + signalPassFailure(); + } + }); + } + void runOnOperation() override { auto module = getOperation(); @@ -72,6 +168,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper Date: Fri, 2 Oct 2026 16:48:25 +0100 Subject: [PATCH 2/6] -mm=own phase 6: data nothing owns needs no owner rc retains a value wherever its type could hold a block. A number or null in a union, a function named by a `ts.SymbolRef`, a string literal, and a tuple of such hold none, so the retain takes nothing: it is erased instead of reported as a second reference. A `ts.RetainSlot` is decided on the slot's initializer, so only while nothing else is ever stored into the slot and no closure captures it. Such data has no use after a move either: under --opt, CSE merges equal literals (the left-out optional field of two tuple literals), and one value then goes into several places (checkValueMoves). isConstantData excludes two things a type-only reading takes for data: a `ts.New`, whose `value_ref` type owns nothing but which is the object its view shows (`00object` stores one into two records), and a value a `ts.NewInterface` view boxes into a block of its own each time (own_err_interface_two_views). Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipFacts.h | 40 +++++++++++++- .../lib/TypeScript/OwnershipInferencePass.cpp | 31 ++++++++++- tslang/test/tester/CMakeLists.txt | 3 +- tslang/test/tester/own/own_constant_data.ts | 54 +++++++++++++++++++ .../tester/own/own_err_literal_two_records.ts | 13 +++++ 5 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tslang/test/tester/own/own_constant_data.ts create mode 100644 tslang/test/tester/own/own_err_literal_two_records.ts diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index 0d1fcd801..d86d1ebfc 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -374,11 +374,43 @@ inline bool holdsNoBlock(mlir::Value value) return !ownsHeap(value) || (def && isLiteral(def)); } +// Some view of `value` is a `ts.NewInterface`, which boxes what it shows into a block. +inline bool boxedByView(mlir::Value value) +{ + llvm::SmallVector values{value}; + while (!values.empty()) + { + for (auto &use : values.pop_back_val().getUses()) + { + auto *user = use.getOwner(); + if (isView(user) && use.getOperandNumber() == 0) + { + if (mlir::isa(user)) + { + return true; + } + + values.push_back(user->getResult(0)); + } + } + } + + return false; +} + // Data nothing owns: what holds no block, a `ts.Constant` (its strings are immortal) or a field of // one, and a tuple built of such (`[1, "a"]`, an object literal's fields read out of its constant). // Retaining it takes nothing anyone gives back. inline bool isConstantData(mlir::Value value) { + // a view that boxes it makes a block of its own: a folded object literal given as an interface + // is a new block each time, which its own release destroys. And a `ts.New` is a block, though + // its `value_ref` type owns nothing: it is the object its view shows. + if (value.getDefiningOp() || boxedByView(value)) + { + return false; + } + if (holdsNoBlock(value)) { return true; @@ -390,7 +422,13 @@ inline bool isConstantData(mlir::Value value) return false; } - if (mlir::isa(def)) + if (mlir::isa(def)) + { + return true; + } + + // a function, which a `ts.SymbolRef` names, seen as a function value: it has no capture box + if (auto castOp = mlir::dyn_cast(def); castOp && castOp.getIn().getDefiningOp()) { return true; } diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index ad58058cd..cf53b70f5 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -263,6 +263,15 @@ class OwnershipInferencePass continue; } + // data nothing owns (isConstantData): a number or null in a union, a function, a + // literal, a tuple of literals. A slot's retain is decided on its initializer, so only + // while nothing else is ever stored into the slot. + if (value && isConstantData(value) && (mlir::isa(op) || slotKeepsInitializer(op))) + { + toErase.insert(op); + continue; + } + if (!value || isFresh(value) == false) { reportSecondReference(op); @@ -2737,6 +2746,23 @@ class OwnershipInferencePass // The value a retain acquires: a Retain's operand, or a RetainSlot's variable's initializer, // seen through its views. None for a variable with no initializer - its storage was hoisted // in front of a try and its value arrives by a store this phase does not follow. + // A `ts.RetainSlot`'s slot holds its initializer for as long as it lives: nothing is stored + // into it, and no closure captures it. + static bool slotKeepsInitializer(mlir::Operation *op) + { + auto slot = mlir::cast(op).getSlot(); + auto varOp = slot.getDefiningOp(); + if (!varOp || !varOp.getInitializer() || isCellVariable(slot)) + { + return false; + } + + return llvm::none_of(slot.getUsers(), [&](mlir::Operation *user) { + auto storeOp = mlir::dyn_cast(user); + return (storeOp && storeOp.getReference() == slot) || !mlir::isa(user); + }); + } + static mlir::Value retainedValue(mlir::Operation *op) { if (auto retainOp = mlir::dyn_cast(op)) @@ -3071,8 +3097,9 @@ class OwnershipInferencePass // Nothing to move: a number owns no block, and a string literal is the immortal global, // which any number of places may hold. Under --opt, CSE merges identical literals before // this pass, so one such value is routinely stored into several places. A read out of a - // container owns nothing either: checkPlaceRead decides it. - if (!ownsHeap(value) || isImmortalLiteral(value) || placeReadOf(value)) + // container owns nothing either: checkPlaceRead decides it. Nor does other data nothing + // owns: an empty optional, a function, a tuple of literals (isConstantData). + if (!ownsHeap(value) || isImmortalLiteral(value) || isConstantData(value) || placeReadOf(value)) { return true; } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index ab3ead506..2c5c0604e 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2568,7 +2568,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_string_length_borrow own_closure_borrow own_closure_escape own_generator own_interface_view own_async own_record_return own_generator_borrow own_global_script - own_global_init) + own_global_init own_constant_data) tslang_add_test(NAME test-jit-own-${own_test} COMMAND test-runner -jit -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") tslang_add_test(NAME test-compile-own-${own_test} COMMAND test-runner -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") # The promise: no counting at all in what own emits. @@ -2656,6 +2656,7 @@ set(own_error_cases "own_err_field_function_reassigned|borrows .* but is used here after it may be released or overwritten" "own_err_generator_outlives_arg|borrows .* but is used here after .* is released or overwritten" "own_err_generator_bounded_escapes|borrows its generator's arguments and cannot be stored, returned or captured" + "own_err_literal_two_records|is used here after its value was moved" "own_err_generator_yields_borrow|borrows the generator's argument and cannot be stored, returned or captured" "own_err_generator_map_outlives|borrows .* but is used here after .* is released or overwritten" "own_err_generator_place_overwritten|borrows a field but is used here after it may be released or overwritten" diff --git a/tslang/test/tester/own/own_constant_data.ts b/tslang/test/tester/own/own_constant_data.ts new file mode 100644 index 000000000..5d2db908b --- /dev/null +++ b/tslang/test/tester/own/own_constant_data.ts @@ -0,0 +1,54 @@ +// Data nothing owns needs no owner: a number or null in a union, a function, string literals, a +// tuple of literals. rc retains each where a value of its type could hold a block; own takes +// nothing for them. Compiled with --opt by the runner, where CSE merges equal literals. +type Account = [number, string, string, string?]; + +function twice(x: number) { + return x * 2; +} + +function apply(f: (x: number) => number, v: number) { + return f(v); +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function main() { + // a union made from a number, then from null + let n: number | null = 5; + let m = n; + let k: number | null = null; + assert(m == 5 && n == 5 && k == null); + + // functions as values + let f = twice; + const g = (x: number) => x + 1; + assert(apply(f, 3) == 6 && apply(g, 3) == 4); + + // tuples of literals: built, pushed, unshifted, spliced, destructured + let pairs: [number, string][] = [[1, "a"], [2, "b"]]; + pairs.push([3, "c"]); + pairs.unshift([0, "z"]); + pairs.splice(2, 0, [9, "y"]); + assert(churn() == 1000); + assert(pairs.length == 5 && pairs[0][1] == "z" && pairs[2][1] == "y" && pairs[4][1] == "c"); + + let [num, str] = [1, "foo"]; + assert(num == 1 && str == "foo"); + + // equal literals in two tuples, the optional field left out + const staff: Account[] = [ + [0, "A", "a@"], + [1, "B", "b@"], + [2, "C", "c@", "Lead"], + ]; + let count = 0; + for (const s of staff) count += s[3] ? 1 : 0; + assert(count == 1 && staff[2][3] == "Lead"); + + print("done."); +} diff --git a/tslang/test/tester/own/own_err_literal_two_records.ts b/tslang/test/tester/own/own_err_literal_two_records.ts new file mode 100644 index 000000000..7a0c850ca --- /dev/null +++ b/tslang/test/tester/own/own_err_literal_two_records.ts @@ -0,0 +1,13 @@ +// -mm=own rejects: `c` is one block, an object literal's, stored into two records, and neither +// record owns it alone. Its `ts.New` is a `value_ref`, a type that owns nothing, so a rule that +// reads the type alone takes it for data nobody owns. +function main() { + const c = { + toString() { + return "Hi"; + }, + }; + const r1 = { a: 1, c: c }; + const r2 = { a: 2, c }; + print(r1.a, r2.a); +} From 54959affa37fb8217da7804f2d25b7678a989788 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 16:53:15 +0100 Subject: [PATCH 3/6] -mm=own phase 6: a tuple local read by index keeps its initializer The slot guard for data nothing owns took any use of the slot but a load for a write, and a tuple local's `a[0]` reaches it through a `ts.PropertyRef`. A field ref whose uses only read is a read. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 20 +++++++++++++++---- tslang/test/tester/own/own_constant_data.ts | 5 +++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index cf53b70f5..a6a0ed90e 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2747,7 +2747,7 @@ class OwnershipInferencePass // seen through its views. None for a variable with no initializer - its storage was hoisted // in front of a try and its value arrives by a store this phase does not follow. // A `ts.RetainSlot`'s slot holds its initializer for as long as it lives: nothing is stored - // into it, and no closure captures it. + // into it or into a field of it, and no closure captures it. static bool slotKeepsInitializer(mlir::Operation *op) { auto slot = mlir::cast(op).getSlot(); @@ -2757,12 +2757,24 @@ class OwnershipInferencePass return false; } - return llvm::none_of(slot.getUsers(), [&](mlir::Operation *user) { - auto storeOp = mlir::dyn_cast(user); - return (storeOp && storeOp.getReference() == slot) || !mlir::isa(user); + return llvm::all_of(slot.getUsers(), [&](mlir::Operation *user) { + return mlir::isa(user) || onlyRead(user); }); } + // A read of what a slot holds, or of a field of it (`a[0]` of a tuple local), and nothing that + // writes through it. + static bool onlyRead(mlir::Operation *user) + { + if (mlir::isa(user)) + { + return true; + } + + auto propertyRefOp = mlir::dyn_cast(user); + return propertyRefOp && llvm::all_of(propertyRefOp->getUsers(), [](mlir::Operation *fieldUser) { return onlyRead(fieldUser); }); + } + static mlir::Value retainedValue(mlir::Operation *op) { if (auto retainOp = mlir::dyn_cast(op)) diff --git a/tslang/test/tester/own/own_constant_data.ts b/tslang/test/tester/own/own_constant_data.ts index 5d2db908b..415cf5dd6 100644 --- a/tslang/test/tester/own/own_constant_data.ts +++ b/tslang/test/tester/own/own_constant_data.ts @@ -37,6 +37,11 @@ function main() { assert(churn() == 1000); assert(pairs.length == 5 && pairs[0][1] == "z" && pairs[2][1] == "y" && pairs[4][1] == "c"); + // a tuple local, read by index + let t: [string, number] = ["asd", 1.0]; + const nested: [[number, string], number] = [[1.0, "x"], 2.0]; + assert(t[0] == "asd" && t[1] == 1.0 && nested[0][1] == "x"); + let [num, str] = [1, "foo"]; assert(num == 1 && str == "foo"); From ccf7f67b56302bfb97b13a84d3663696cbae4d0b Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 16:59:37 +0100 Subject: [PATCH 4/6] -mm=own phase 6: a record literal moves into the owning local it is made for `let a = { item: new C() }` builds the literal in a local a `ts.Constant` starts, stores the fresh value into its field, and reads the record once into `a`. That is the record built to be returned (recordRetainOf) with an owning local in place of the result slot: the fresh value moves into the record, and the record into `a`, whose `ts.RetainSlot` is rc's reference for the values moved in. `a`'s scope exit releases it. The assignment form stores the read into an owning local, and is accepted the same way. Measured (AOT, measure.ps1, a loop building and dropping such records): own 4.2 MB at both 20000 and 200000 iterations, as rc; none 7.7 -> 35.2. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 39 +++++++++++++++- tslang/test/tester/CMakeLists.txt | 2 +- tslang/test/tester/own/own_record_local.ts | 46 +++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 tslang/test/tester/own/own_record_local.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index a6a0ed90e..56713f0dc 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2919,11 +2919,28 @@ class OwnershipInferencePass continue; } + // `let a = { item: new C() }`: the read is what an owning local starts with, and rc's + // `ts.RetainSlot` of that local is the record's retain + if (auto ownerOp = mlir::dyn_cast(user)) + { + auto *slotRetain = ownerOp.getInitializer() == read.getResult() && isOwningVariable(ownerOp) + ? onlyRetainSlotOf(ownerOp) + : nullptr; + if (!slotRetain || retain) + { + return nullptr; + } + + retain = slotRetain; + continue; + } + + // stored into the result slot, or into an owning local (`a = { item: new C() }`) auto storeOp = mlir::dyn_cast(user); auto slot = storeOp && storeOp.getValue() == read.getResult() ? storeOp.getReference().getDefiningOp() : mlir_ts::VariableOp(); - if (!slot || !isResultSlot(slot)) + if (!slot || !(isResultSlot(slot) || isOwningVariable(slot))) { return nullptr; } @@ -2932,6 +2949,26 @@ class OwnershipInferencePass return retain; } + // The one `ts.RetainSlot` of a local; null when there are none or several. + static mlir::Operation *onlyRetainSlotOf(mlir_ts::VariableOp varOp) + { + mlir::Operation *found = nullptr; + for (auto *user : varOp->getUsers()) + { + if (mlir::isa(user)) + { + if (found) + { + return nullptr; + } + + found = user; + } + } + + return found; + } + // A local that owns nothing, is never captured, and is read only to be returned. static bool isResultSlot(mlir_ts::VariableOp varOp) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 2c5c0604e..f7327154a 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2568,7 +2568,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_string_length_borrow own_closure_borrow own_closure_escape own_generator own_interface_view own_async own_record_return own_generator_borrow own_global_script - own_global_init own_constant_data) + own_global_init own_constant_data own_record_local) tslang_add_test(NAME test-jit-own-${own_test} COMMAND test-runner -jit -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") tslang_add_test(NAME test-compile-own-${own_test} COMMAND test-runner -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") # The promise: no counting at all in what own emits. diff --git a/tslang/test/tester/own/own_record_local.ts b/tslang/test/tester/own/own_record_local.ts new file mode 100644 index 000000000..664252f09 --- /dev/null +++ b/tslang/test/tester/own/own_record_local.ts @@ -0,0 +1,46 @@ +// An inline record literal made for an owning local: `let a = { item: new Leaf(i) }`. MLIRGen +// builds it in a local a `ts.Constant` starts, stores the fresh value into its field, and reads the +// whole record once into `a`. The fresh value moves into the record, and the record into `a`, +// which releases it at the end of its scope; an assignment gives up the record `a` held. +class Leaf { + v: number; + s: string; + + constructor(v: number) { + this.v = v; + this.s = "leaf-" + v; + } +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function build(n: number) { + let total = 0; + for (let i = 0; i < n; i++) { + let a = { item: new Leaf(i) }; + total += a.item.v; + } + + return total; +} + +function main() { + let a = { item: new Leaf(1) }; + assert(churn() == 1000); + assert(a.item.v == 1 && a.item.s == "leaf-1"); + + // overwritten: the old record, and its leaf, are given up + a = { item: new Leaf(2) }; + assert(churn() == 1000); + assert(a.item.v == 2 && a.item.s == "leaf-2"); + + let b = { item: new Leaf(3), tag: "b" }; + assert(b.item.s == "leaf-3" && b.tag == "b"); + + assert(build(20000) == 199990000); + print("done."); +} From 947f721db87f1b9183f663c498664a8a09eafb72 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 17:03:58 +0100 Subject: [PATCH 5/6] -mm=own phase 6: spec section 20, the corpus report Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-02-own-phase-6.md | 23 +-- .../2026-09-24-own-memory-model-design.md | 148 +++++++++++++++++- 2 files changed, 160 insertions(+), 11 deletions(-) diff --git a/tslang/docs/superpowers/plans/2026-10-02-own-phase-6.md b/tslang/docs/superpowers/plans/2026-10-02-own-phase-6.md index 421fb8294..0e7f83100 100644 --- a/tslang/docs/superpowers/plans/2026-10-02-own-phase-6.md +++ b/tslang/docs/superpowers/plans/2026-10-02-own-phase-6.md @@ -107,23 +107,26 @@ one: ### Task 1: globals (rc, every counting model, a PR of its own) -- [ ] A declaration of a global in top-level code goes through the owning-slot path: it takes +- [x] A declaration of a global in top-level code goes through the owning-slot path: it takes over an owned reference or retains, and releases the slot's old value, as an assignment does. -- [ ] Tests: `own_global_script` (positive: `const`, `let` and `var` in top-level code, a class +- [x] Tests (PR #444): `own_global_script` (positive: `const`, `let` and `var` in top-level code, a class and a string, read from a function); and the rc corpus unchanged. ### Task 2: global initializer regions (own) -- [ ] Erase the retain of a fresh value, or of a view of one, given to `ts.GlobalResult`. +- [x] Erase the retain of a fresh value, or of a view of one, given to `ts.GlobalResult`. Anything else retained in a global region: a named error. -- [ ] Tests: `own_global_init` (positive: an interface-typed object literal, a nested array +- [x] Tests: `own_global_init` (positive: an interface-typed object literal, a nested array literal, an object with an accessor, at module level). ### Task 3: no block, function literals, conversions, string constants -- [ ] As the rulings say, with the slot guard. -- [ ] Tests: +- [x] As the rulings say, with the slot guard (`isConstantData`). Conversions to a string stayed + out: no file has them as its only blocker. +- [x] Tests (as built: `own_constant_data`, and the negative `own_err_literal_two_records` for the + `ts.New` exclusion; the slot guard is conservative, and no case was found where it changes + the verdict): - positive `own_no_block`: `number | null`, a function literal in a `let`, a number converted to a string, string constants through a `?:`; - negative `own_err_union_slot_reassigned` (the guard); @@ -131,11 +134,13 @@ one: ### Task 4: tuple literals -- [ ] `holdsConstantsOrMoves` for a `CreateTuple` of constants or moved fresh values. -- [ ] Tests: `own_tuple_literals`: `[number, string][]` literals, `push`/`unshift`/`splice` of a +- [x] As built: through `isConstantData` (a tuple of constants), and the slot guard reading a + field ref that only reads. Also: a record literal moved into the owning local it is made + for (`recordRetainOf`, `own_record_local`). +- [x] Tests: in `own_constant_data`; `own_tuple_literals`: `[number, string][]` literals, `push`/`unshift`/`splice` of a literal tuple, a tuple in a union. -### Task 5: named messages and the specific-rule buckets +### Task 5: named messages and the specific-rule buckets (the next PR) - [ ] Named messages for each recognised, unaccepted bucket left after tasks 1-4. - [ ] Sample the use-after-move (22) and borrows-a-field (21) files with `--di`. A true positive 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 3c7260ee3..59b3fc2d9 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 @@ -6,8 +6,9 @@ amendments folded in. Phase 0 merged as #399 (results §11); phase 1 (results §13); phase 3 (containers and unions) merged as #403 (results §14); phase 4 (function signatures and `any`) merged as #406-#410 (results §15); phase 5a (closures that do not escape) merged as #436 (results §16); phase 5b (closures that escape) merged as #437 (results §17); phase -7a (generators, async) merged as #439 (results §18); phase 7b (generators that borrow) on branch -`own-phase-7b` (results §19). Plans in `docs/superpowers/plans/`. +7a (generators, async) merged as #439 (results §18); phase 7b (generators that borrow) merged as +#441 (results §19); phase 6 (the corpus report) on branch `own-phase-6`, after #444 (results +§20). Plans in `docs/superpowers/plans/`. ## 1. Purpose @@ -1432,3 +1433,146 @@ Found, not fixed (every model): `a.map(f).map(g)` fails to compile ("the conditi - **A copy a borrowing field takes of a sub-field** (`const v = c.v` in the generator) is not borrowing, so it is the existing "cannot be stored" error. - **Without `--di`**, the bounded errors name "this value". + +## 20. Phase 6 results, 2026-10-02 (first PR) + +Plan: `docs/superpowers/plans/2026-10-02-own-phase-6.md`. Changed: `MLIRGenImpl.h` (#444, every +counting model), `OwnershipSignaturePass.cpp`, `OwnershipInferencePass.cpp`, `OwnershipFacts.h`. + +### 20.1 The report (spec §6.5) + +The tooling stays out of the tree: +- a script that compiles every corpus file under own and keeps the first error; +- a temporary dump, under `OWN_SHAPES`, at the four places that report the catch-all. It prints the + retained value's definition, its root, its users, its type, whether it owns a block, the source + location, and for a read of a slot, the slot's kind and what takes the value. + +On ad8ea908 (7b merged), 364 of 591 compile. The first error of the 227 that do not: + +| first error | files | +|---|---| +| `takes a second reference` (the catch-all, which names no rule) | 137 | +| used after its value was moved | 22 | +| borrows a field and cannot be stored, returned or captured | 21 | +| borrows an element and cannot be stored, returned or captured | 9 | +| `ownership inference left a retain behind` (the lowering's guard) | 8 | +| the rest: other named rules, unsupported, frontend | 30 | + +The 137, by the dump: 447 sites. The last column counts the files where the bucket is the only +blocker: + +| shape | sites | files | only blocker | +|---|---|---|---| +| a read of a slot (mixed) | 97 | 39 | 17 | +| a call's result stored: script-mode globals | 59 | 28 | 14 | +| tuple literals | 52 | 23 | 13 | +| the value owns no block (`number \| null`) | 35 | 23 | 9 | +| a block argument (`c ? a : b`) | 18 | 11 | 7 | +| a function literal | 24 | 13 | 6 | +| a read of a global | 22 | 8 | 4 | +| a number, bool or bigint converted to a string | 42 | 11 | 0 | +| other | 55 | 34 | 18 | + +### 20.2 What this PR accepts + +- **A global declared in top-level code (#444, rc).** `const t = new C()` there stored into the + global with no reference, and the end-of-block release gave the value's back: 5ai's bug, in + the declaration's store, which 5ai's fix (the assignment's) did not reach. + - The fix: the declaration's store takes the owning-slot path, as an assignment does. + - It is not observable under rc today. Nothing runs after a program's top-level code, and a + module with top-level code cannot be imported (§20.5). + - Under own, the store read as a move and the release as a second reference. +- **A global's initializer** runs in the global's region, which no function's inference sees, + so rc's retain for its `ts.GlobalResult` reached the lowering's guard. The signature pass now + erases such a retain when: + - the value is the only reference to its block: retained once, released nowhere in the region; + - it is fresh, constant data, or a record of constants; + - every use of it ends in the `ts.GlobalResult`: directly, through views, as an element of an + array literal, or copied into an object literal's box. + + Any other retain there is reported by the global's name. +- **Data nothing owns** (`isConstantData`, `OwnershipFacts.h`): what holds no block (a number or + null in a union), a `ts.Constant` or a field of one, a function named by a `ts.SymbolRef` and + its cast to a function value, and a tuple of such. + - A retain of it is erased. + - A `ts.RetainSlot` is decided on its slot's initializer, so only while nothing is stored into + the slot or a field of it, and no closure captures it. + - Such data has no use after a move: under `--opt`, CSE merges equal literals (the left-out + optional field of two tuple literals), and one value goes into several places. + - Two exclusions, from a type-only reading. The header's `ownsHeap` reads the type, and the + inference pass overrides it for a `ts.New` seen as its object; the two disagree by design. + - A `ts.New` is excluded: its `value_ref` owns nothing by type, but it is the object. Without + this, `00object` compiled again: one object literal stored into two records, which 7a + rightly rejects. + - A value a `ts.NewInterface` view boxes is excluded: each view is a block of its own. +- **A record literal made for an owning local**, `let a = { item: new C() }`, and its assignment + form. MLIRGen builds it in a local a `ts.Constant` starts, stores the fresh value into the + field, and reads the record once into `a`. That is 7a's record built to be returned, with an + owning local in place of the result slot: the fresh value moves into the record, the record + into `a`, and `a`'s `ts.RetainSlot` is the reference for what moved in. + +### 20.3 Measured and teeth + +AOT, `measure.ps1`, a loop that builds and drops `let a = { item: new Leaf(i) }`, in MB: + +| iterations | gc | rc | none | own | +|---|---|---|---|---| +| 20000 | 5.9 | 4.2 | 7.7 | 4.2 | +| 200000 | 5.9 | 4.2 | 35.2 | 4.2 | + +Teeth, each a temporary switch, with the JIT cache cleared: +- **The global declaration's retain reverted** (#444): `own_global_script` fails under own (AOT, + JIT, no-counting). rc passes either way, as §20.2 says. +- **The global initializer's erase skipped:** `own_global_init` fails. +- **The constant-data erase skipped:** `own_constant_data` fails. +- **Its no-use-after-move skipped:** `own_constant_data` fails under the runner's `--opt`. +- **The `ts.New` exclusion dropped:** `own_err_literal_two_records` compiles. +- **The boxing exclusion dropped:** `own_err_interface_two_views` still fails. It compiles only with + both exclusions dropped, so for that test each exclusion is a second guard for the other. +- **The record rule's two acceptances dropped:** `own_record_local` fails. + +### 20.4 Tests and the corpus + +- **Positive:** `own_global_script` (also under rc, none and gc), `own_global_init`, + `own_constant_data`, `own_record_local`. +- **Negative:** `own_err_literal_two_records`. + +Corpus: 364 → 402 of 591, none lost. It is 402 with the runner's `--opt --opt_level=3` as well; +the plain count can overstate (`01optional` compiled plain and failed under `--opt` before the +no-use-after-move rule). Each of the 38 passes test-runner `-mm=own`, AOT and JIT, with gc's +output, re-gated on the final build. The first errors after: + +| first error | files | +|---|---| +| `takes a second reference` | 101 | +| used after its value was moved | 24 | +| borrows a field and cannot be stored, returned or captured | 21 | +| borrows an element and cannot be stored, returned or captured | 9 | +| `left a retain behind` | 0 | + +Remaining shapes, by the dump, as the files where each is the only blocker: +- slot reads, 25. They split into: + - a record a `ts.Constant` starts, stored or taken; + - a parameter's value stored, pushed or cast; +- other, 24; +- a conditional's result (`return a > b ? a : b`), 7; +- tuples, 7; +- nested record literals (`{ inner: { item: new C() } }`) and arrays of record literals. + +These are the next PR's input: named messages for what the dump recognises, and the sampling +of the use-after-move and borrows-a-field files for false positives. + +### 20.5 Found, not fixed (every model) + +- **A global initialized from another global reads garbage.** `let a: number[] = [1, 2]; let b = a;` + at module level gives `b.length` = 140711081380496 under gc. With a class, the program faults. +- **A module with a top-level statement, imported as source,** fails with "Global is referenced + by parentless instruction! @puts" (invalid LLVM IR). +- **With `-shared`, an exported `const` from such a module** cannot be resolved by the importer. + +### 20.6 Known limits + +- **A borrow of data nothing owns past its owner's overwrite** is still an error: + `let m = n; n = null; use(m)`, with `n: number | null`. +- **Nested record literals, and arrays of record literals,** are the catch-all. +- **A conditional's result** is the catch-all. From c129d42333fa7f4a2dd251875c76cbd41f1328ae Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 17:45:55 +0100 Subject: [PATCH 6/6] -mm=own phase 6: spec 20.5 cites #446-#448 Co-Authored-By: Claude Opus 5.5 --- .../superpowers/specs/2026-09-24-own-memory-model-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 59b3fc2d9..0331ca121 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 @@ -1564,11 +1564,11 @@ of the use-after-move and borrows-a-field files for false positives. ### 20.5 Found, not fixed (every model) -- **A global initialized from another global reads garbage.** `let a: number[] = [1, 2]; let b = a;` +- **A global initialized from another global reads garbage** (#446). `let a: number[] = [1, 2]; let b = a;` at module level gives `b.length` = 140711081380496 under gc. With a class, the program faults. -- **A module with a top-level statement, imported as source,** fails with "Global is referenced +- **A module with a top-level statement, imported as source,** (#447) fails with "Global is referenced by parentless instruction! @puts" (invalid LLVM IR). -- **With `-shared`, an exported `const` from such a module** cannot be resolved by the importer. +- **With `-shared`, an exported `const` from such a module** (#448) cannot be resolved by the importer. ### 20.6 Known limits