From f25aaf8deb699591716ec9290e861cfc07803d09 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 09:29:42 +0100 Subject: [PATCH 01/11] -mm=own: @nodrops C declarations and four false errors the default library hit Toward building the default library under -mm=own (62 errors in lib.ts, now 14): - `@nodrops declare function f(...)`: a C function that destroys nothing a TypeScript caller can reach. The signature pass resolves a call to it as it resolves one to an imported own library's no-drops function, so a borrow of a field can be lent to it. A declaration had no facts, so every body that reached strncmp, boundedCopy and the like might destroy anything. - An owning local returned on more than one way out (`if (c) return r; return r;`) moves once on each: receivers no path joins are decided alone, as the receivers separated by assignments already were. - A string concatenation reads its operands into a block of its own: its result is no longer walked as the borrow (`v[i] = v[i] + s`). - `ts.Assert` only reads its message. - `return [...a]`: the spread's builder is moved out by the return, as it is moved into the `let` it is read into. Tests: own_declared_no_drops, own_return_paths, own_string_concat_element (run under own, AOT and JIT), own_err_declared_drops. Co-Authored-By: Claude Opus 5.5 --- tslang/include/TypeScript/Defines.h | 8 ++ tslang/lib/TypeScript/LowerToLLVM.cpp | 5 + tslang/lib/TypeScript/MLIRGenImpl.h | 13 +++ .../lib/TypeScript/OwnershipInferencePass.cpp | 94 +++++++++++++++++-- .../lib/TypeScript/OwnershipSignaturePass.cpp | 6 +- tslang/test/tester/CMakeLists.txt | 4 +- .../test/tester/own/own_declared_no_drops.ts | 42 +++++++++ .../test/tester/own/own_err_declared_drops.ts | 22 +++++ tslang/test/tester/own/own_return_paths.ts | 74 +++++++++++++++ .../tester/own/own_string_concat_element.ts | 41 ++++++++ 10 files changed, 297 insertions(+), 12 deletions(-) create mode 100644 tslang/test/tester/own/own_declared_no_drops.ts create mode 100644 tslang/test/tester/own/own_err_declared_drops.ts create mode 100644 tslang/test/tester/own/own_return_paths.ts create mode 100644 tslang/test/tester/own/own_string_concat_element.ts diff --git a/tslang/include/TypeScript/Defines.h b/tslang/include/TypeScript/Defines.h index 604eb062c..c8427076a 100644 --- a/tslang/include/TypeScript/Defines.h +++ b/tslang/include/TypeScript/Defines.h @@ -224,6 +224,14 @@ #define SHARED_LIB_BORROW_FACTS_ATTR_NAME "ts.borrow_facts" // a module attribute, so it has to carry the dialect's prefix #define SHARED_LIB_OWN_NO_DROPS_ATTR_NAME "ts.own_imported_no_drops" +// `@nodrops declare function strncmp(...)`: a function defined outside TypeScript, a C function, +// that destroys nothing a TypeScript caller can reach. -mm=own calls it the way it calls an +// imported function in SHARED_LIB_OWN_NO_DROPS_ATTR_NAME, so a borrow of a field can be given to +// it. A declaration has no body to check, so this is the declaration's word, and only a +// declaration can give it: never on one that may run TypeScript code (it takes a callback) or +// one re-parsed from a TypeScript library, whose functions may destroy what they are given. +#define NO_DROPS_DECORATOR "nodrops" +#define OWN_DECLARED_NO_DROPS_ATTR_NAME "ts.own_declared_no_drops" // The process-heap allocator of a Windows module (TypeScript/ProcessHeap.h), which ProcessHeapPass // calls instead of the C runtime's. Defined by the async runtime library a program links, diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index 4546de577..41d030174 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -2049,6 +2049,11 @@ struct FuncOpLowering : public TsLlvmPattern continue; } + // read by -mm=own only + if (name == OWN_DECLARED_NO_DROPS_ATTR_NAME) { + continue; + } + // module-local: a private symbol alone still comes out external if (name == "internal_linkage") { newFuncOp->setAttr("llvm.linkage", LLVM::LinkageAttr::get(getContext(), LLVM::linkage::Linkage::Internal)); diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 1b968d96e..751d85d1e 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -3177,6 +3177,19 @@ class MLIRGenImpl addAttr("func.varargs", mlir::BoolAttr::get(builder.getContext(), true)); } + // a body's facts come from the body: the decorator is a declaration's alone + if (name == NO_DROPS_DECORATOR) + { + if (functionLikeDeclarationBaseAST->body) + { + emitWarning(location, "'@" NO_DROPS_DECORATOR "' is ignored on a function with a body"); + } + else + { + addAttr(OWN_DECLARED_NO_DROPS_ATTR_NAME, mlir::UnitAttr::get(builder.getContext())); + } + } + if (name == "used") { builder.create(location, fullName); } diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 2d03e4f93..c111ad1e3 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -344,13 +344,19 @@ class OwnershipInferencePass continue; } - // an array a spread builds, read into the owning local it is made for + // an array a spread builds, read into the owning local it is made for, or returned if (value && mlir::isa(op) && isBuiltArrayRead(op, value)) { toErase.insert(op); continue; } + if (value && mlir::isa(op) && isBuiltArrayReturned(op, value)) + { + toErase.insert(op); + continue; + } + if (!value || isFresh(value) == false) { reportSecondReference(op); @@ -1855,15 +1861,12 @@ class OwnershipInferencePass // Receivers of one owning local that each read a value of its own (#528): `h.c = a; a = new C(); // g.c = a`. An assignment comes between every two of their reads on every path from one to the - // other, so each is decided alone, as the one receiver of the value it reads. + // other, so each is decided alone, as the one receiver of the value it reads. So are receivers + // no path joins at all, assigned or not: `if (c) return a; return a;` moves `a` once on each + // way out. bool takeValuesOfTheirOwn(mlir::Value slot, llvm::ArrayRef receivers) { auto assignments = slotAssignments(slot); - if (assignments.empty()) - { - return false; - } - llvm::SmallVector kills(assignments.begin(), assignments.end()); if (auto varOp = slot.getDefiningOp()) { @@ -2342,8 +2345,11 @@ class OwnershipInferencePass } } - // a copy (spec 22.2) reads the borrow into a block of its own, which points into nothing - if (mlir::isa(user)) + // a copy (spec 22.2) reads the borrow into a block of its own, which points into + // nothing, and so does a concatenation: `s + t` is a new string whatever s and t are + // (a `+` of two strings lowers to ts.StringConcat, which always allocates) + if (mlir::isa(user) || + (mlir::isa(user) && mlir::isa(user->getResult(0).getType()))) { continue; } @@ -3631,6 +3637,70 @@ class OwnershipInferencePass }); } + // `return [...a]`: the builder of isBuiltArrayRead, read once, when it is done, and returned. + // The return's `ts.Retain` (`retain`) is the reference the caller gets: the fresh array moves + // into the builder and then out of the function, and nothing else holds it. The builder's + // other reads only bind its `push` (`ts.ThisSymbolRef @__array_push`) or are not used. + bool isBuiltArrayReturned(mlir::Operation *retain, mlir::Value value) + { + auto read = value.getDefiningOp(); + auto builder = read ? read.getReference().getDefiningOp() : mlir_ts::VariableOp(); + if (!builder || isOwningVariable(builder) || builder.getCaptured().value_or(false) || !builder.getInitializer() || + !mlir::isa_and_nonnull(builder.getInitializer().getDefiningOp())) + { + return false; + } + + // the read is retained and returned - stored into the function's result slot - and nothing else + auto returned = false; + for (auto *user : read->getUsers()) + { + if (user == retain) + { + continue; + } + + auto storeOp = mlir::dyn_cast(user); + auto resultSlot = storeOp && storeOp.getValue() == read.getResult() + ? storeOp.getReference().getDefiningOp() + : mlir_ts::VariableOp(); + if (!resultSlot || !isResultSlot(resultSlot) || returned) + { + return false; + } + + returned = true; + } + + if (!returned) + { + return false; + } + + // only filled, and only before it is read + return llvm::all_of(builder->getUses(), [&](mlir::OpOperand &use) { + auto *user = use.getOwner(); + if (user == read) + { + return true; + } + + if (reachableAfter(read, user, builder)) + { + return false; + } + + if (auto loadOp = mlir::dyn_cast(user)) + { + return llvm::all_of(loadOp->getUsers(), [](mlir::Operation *loadUser) { + return mlir::isa(loadUser) && loadUser->use_empty(); + }); + } + + return mlir::isa(user) && use.getOperandNumber() == 0; + }); + } + // The one `ts.RetainSlot` of a local; null when there are none or several. static mlir::Operation *onlyRetainSlotOf(mlir_ts::VariableOp varOp) { @@ -3709,6 +3779,12 @@ class OwnershipInferencePass return true; } + // `assert(c, message)` prints the message when it fails, and keeps it either way + if (mlir::isa(user)) + { + return true; + } + // `s.value`'s place and `Shared.count(s)` read the handle and keep nothing (spec 23.1) if (mlir::isa(user)) { diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 05c34e093..ac6e9fc92 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -669,11 +669,13 @@ class OwnershipSignaturePass : public mlir::PassWrapperhasAttr(OWN_DECLARED_NO_DROPS_ATTR_NAME))) { return false; } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 31fef36a2..6afd4c8d8 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3314,7 +3314,8 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_nullable_view own_declared_constant own_spread_build own_interface_field_call own_string_copy_field own_string_copy_after_move own_string_copy_nullable own_string_copy_exported own_string_copy_written own_string_copy_mixed_return - own_string_copy_branch own_array_param_push) + own_string_copy_branch own_array_param_push + own_declared_no_drops own_return_paths own_string_concat_element) 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. @@ -3490,6 +3491,7 @@ set(own_error_cases "own_err_call_drops_indirectly|borrows .* but is used here after it may be released or overwritten" "own_err_call_given_borrow|borrows .* but is used here after it may be released or overwritten" "own_err_call_this_borrow|borrows .* but is used here after it may be released or overwritten" + "own_err_declared_drops|borrows .* but is used here after it may be released or overwritten" "own_err_exported_virtual_call|borrows .* but is used here after it may be released or overwritten" "own_err_borrowed_result_stored|borrows the result of 'firstOf' and cannot be stored, returned or captured" "own_err_borrowed_result_outlives|borrows the result of 'firstOf' but is used here after it may be released or overwritten" diff --git a/tslang/test/tester/own/own_declared_no_drops.ts b/tslang/test/tester/own/own_declared_no_drops.ts new file mode 100644 index 000000000..c38347d05 --- /dev/null +++ b/tslang/test/tester/own/own_declared_no_drops.ts @@ -0,0 +1,42 @@ +// -mm=own: a C function declared `@nodrops` destroys nothing a TypeScript caller can reach, so a +// borrow of a field survives a call that reaches it, as it survives a call to a function defined +// here that destroys nothing. Without the mark the declaration may destroy anything +// (own_err_declared_drops). +@nodrops declare function strlen(s: string): index; + +namespace __S { + export function size(this: string, extra: string): int { + return strlen(this) + strlen(extra); + } +} + +class W { + constructor(private value: string) {} + + size(extra: string): int { + return this.value.size(extra); + } +} + +class Pad { + constructor(public s: string) {} +} + +function churn() { + const junk: Pad[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new Pad("junk" + i)); + } +} + +function main() { + let t = 0; + for (let i = 0; i < 10000; i++) { + const w = new W("abc" + (i % 10)); + churn(); + t += w.size("de"); + } + + assert(t == 60000); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_declared_drops.ts b/tslang/test/tester/own/own_err_declared_drops.ts new file mode 100644 index 000000000..85eecdc8e --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_drops.ts @@ -0,0 +1,22 @@ +// -mm=own rejects: `strlen` is declared without `@nodrops`, so the method that calls it may +// destroy anything, and `this.value` cannot be lent to it (own_declared_no_drops). +declare function strlen(s: string): index; + +namespace __S { + export function size(this: string): int { + return strlen(this); + } +} + +class W { + constructor(private value: string) {} + + size(): int { + return this.value.size(); + } +} + +function main() { + const w = new W("abc"); + print(w.size()); +} diff --git a/tslang/test/tester/own/own_return_paths.ts b/tslang/test/tester/own/own_return_paths.ts new file mode 100644 index 000000000..a6533f30d --- /dev/null +++ b/tslang/test/tester/own/own_return_paths.ts @@ -0,0 +1,74 @@ +// -mm=own: an owning local returned on more than one way out moves once on each: no path reaches +// one return from another. And an array a spread builds is moved out when it is returned +// (`return [...r]`), as it is when it is read into a `let`. +class Node { + constructor(public v: int) {} +} + +class Pairs { + [index: number]: string; + + get(i: number) { + return "p" + i; + } + + get length() { + return 2; + } +} + +function node(x: int): Node { + let r = new Node(x); + if (x % 2 == 0) { + return r; + } + + r.v += 100; + return r; +} + +function list(x: int): string[] { + let r: string[] = []; + if (x == 0) { + return r; + } + + for (let i = 0; i < x; i++) { + r.push("s" + i); + } + + return r; +} + +function spread(p: Pairs): string[] { + return [...p]; +} + +function copy(a: string[]): string[] { + return [...a, ...a]; +} + +function churn() { + const junk: Node[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new Node(-1)); + } +} + +function main() { + let t = 0; + const p = new Pairs(); + for (let i = 0; i < 10000; i++) { + const n = node(i); + const l = list(i % 4); + const s = spread(p); + const c = copy(l); + churn(); + t += (n.v - i) + l.length + s.length + c.length; + assert(s[1] == "p1"); + } + + // node: +100 on the odd half; list: 0+1+2+3 per 4; spread: 2; copy: twice list + assert(t == 5000 * 100 + 2500 * 6 + 10000 * 2 + 2500 * 12); + print("done."); +} diff --git a/tslang/test/tester/own/own_string_concat_element.ts b/tslang/test/tester/own/own_string_concat_element.ts new file mode 100644 index 000000000..e24b30b7f --- /dev/null +++ b/tslang/test/tester/own/own_string_concat_element.ts @@ -0,0 +1,41 @@ +// -mm=own: a concatenation reads its operands into a string of its own, so a borrowed element +// may be overwritten with what was made from it (`v[i] = v[i] + s`). And `assert` only reads its +// message, which may borrow an element. +export class Headers { + values: string[] = []; + + append(i: int, value: string) { + if (i < this.values.length) { + this.values[i] = this.values[i] + ", " + value; + } else { + this.values.push(value); + } + } +} + +function check(c: boolean, data: string[]) { + assert(c, data[0]); +} + +function churn() { + const junk: string[] = []; + for (let i = 0; i < 64; i++) { + junk.push("junk" + i); + } +} + +function main() { + let t = 0; + for (let i = 0; i < 10000; i++) { + const h = new Headers(); + h.append(0, "a"); + h.append(0, "b"); + churn(); + h.append(0, "c"); + check(h.values[0] == "a, b, c", h.values); + t += h.values[0].length; + } + + assert(t == 70000); + print("done."); +} From 64d0377c8d8258a5b8d9be8e097f53c8fbf5db6e Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 09:29:54 +0100 Subject: [PATCH 02/11] Docs: @final classes design and plan Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-19-final-classes.md | 177 ++++++++++++++++++ .../specs/2026-10-19-final-classes-design.md | 155 +++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 tslang/docs/superpowers/plans/2026-10-19-final-classes.md create mode 100644 tslang/docs/superpowers/specs/2026-10-19-final-classes-design.md diff --git a/tslang/docs/superpowers/plans/2026-10-19-final-classes.md b/tslang/docs/superpowers/plans/2026-10-19-final-classes.md new file mode 100644 index 000000000..26ca2a4cb --- /dev/null +++ b/tslang/docs/superpowers/plans/2026-10-19-final-classes.md @@ -0,0 +1,177 @@ +# `@final` Classes Implementation Plan + +> **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:** Add a `@final` class decorator. No class may extend a `@final` class, and calls on a value whose static type is a `@final` class are direct, not through the vtable. Then mark the DefaultLib classes, so that seven `-mm=own` errors in `lib.ts` go away. + +**Architecture:** MLIRGen reads `@final` into `ClassInfo::isFinal`. It rejects `extends` of such a class and `@final` combined with `abstract` or `@cpp`, and `DeclarationPrinter` prints the decorator. The member-access chain (`ClassMembersAccess` -> `ClassBaseClassAccess` -> `ClassMethodAccess` / `ClassAccessorAccess`) carries a `finalReceiver` flag, which turns the virtual reference into the direct `ThisSymbolRefOp`. No ownership pass is expected to change. Task 3 fixes a real pass imprecision if one shows up. + +**Tech Stack:** C++17, MLIR (ts dialect), CMake/CTest, gtest (`unittests/MLIRGen`), the tslang test-runner, TypeScript sources for the DefaultLib. + +**Spec:** `tslang/docs/superpowers/specs/2026-10-19-final-classes-design.md` + +## Global Constraints + +- Base: the staged `own-nodrops-declarations` commit must land first, and the user runs it (GPG): `git commit -F C:\Users\duzha\AppData\Local\Temp\claude\I--TypeScriptCompiler\fa46240b-10d4-4a2a-8a46-50b5c5be8c2f\scratchpad\msg_own_nodrops.txt`. Then branch `own-final-classes` from it with `git checkout -b own-final-classes` (in `I:\TypeScriptCompiler`). +- DefaultLib work continues on its existing branch `own-default-lib` in `I:\TypeScriptCompilerDefaultLib`. +- The sources use CRLF. Edit them with the Edit tool or a Python script that writes `newline=''`. Never use `sed -i`, which rewrites the file to LF. +- Never bypass commit signing (`--no-gpg-sign`, `-c commit.gpgsign=false`). On `gpg: signing failed: Timeout`, retry from PowerShell, pause about 30s and retry, then leave the change staged and hand the exact command to the user. +- Build: `cmake --build I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release --config Release --target tslang --parallel 8`. Tests: `ctest -C Release -j 16 --timeout 300` in that folder. Without `-C Release` nothing runs. +- Behaviour under gc, rc and none must not change. Only the dispatch changes. +- A `@final` class keeps its vtable, its `.instanceOf` and its interface vtables. +- The direct call applies only when the method's owning class is neither `isDynamicImport` nor `isCppVTable`, and the method is not `isAbstract`. +- Error texts, pinned by the tests: + - `class 'X' cannot extend 'B': it is @final` + - `class 'X' cannot be both @final and abstract` + - `class 'X' cannot be both @final and @cpp` + +## Review Focus + +- A generic `@final class Box`: each specialization is `@final` too, and its method calls are direct. This is pinned in Task 2's `final_dispatch.ts`. +- A method read as a value (`const fn = f.m; fn()`): the direct `ThisSymbolRef` still binds `this`. Pinned in Task 2. +- A call through an optional or nullable `F | null` (`g?.m()`): it still runs the right method. Pinned in Task 2. +- `this.m()` inside the `@final` class's own constructor and methods is direct and correct. Pinned in Task 2's IR check. +- A `@final` class used from a DLL loaded at run time (JIT `-shared`): it keeps vtable calls and still links. Pinned in Task 4's JIT run. + +--- + +### Task 1: `@final` is parsed, stored, enforced and printed + +**Files:** +- Modify: `tslang/include/TypeScript/Defines.h` (beside `#define CPP_VTABLE "cpp"`, about line 241) +- Modify: `tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h` (`ClassInfo` fields about 785-800, and the constructor initializer list about 815) +- Modify: `tslang/lib/TypeScript/MLIRGenClasses.cpp` (class decorator loop about 361; the `ExtendsKeyword` branch of `mlirGenClassHeritageClause` about 1224-1245) +- Modify: `tslang/lib/TypeScript/DeclarationPrinter.cpp` (`print(ClassInfo::TypePtr)`, about 436) +- Test: `tslang/unittests/MLIRGen/DeclarationPrinter.cpp` +- Test: `tslang/test/tester/final/extends_final.ts`, `extends_declared_final.ts`, `final_abstract.ts`, `final_cpp.ts` +- Modify: `tslang/test/tester/CMakeLists.txt` (a new `final_error_cases` block after the `call_arity_cases` block, about line 2775, in the same shape) + +**Interfaces:** +- Produces: + - `#define FINAL_DECORATOR "final"` + - `bool ClassInfo::isFinal` (default `false`), set for definitions and for `declare class` declarations alike + - `DeclarationPrinter` writes `@final` on its own line before a final class + +- [ ] **Step 1: Write the failing printer unit tests.** Add `final_class_prints_final_decorator` (`ci->isFinal = true`; `EXPECT_THAT(text, testing::HasSubstr("@final"))` and `HasSubstr("class Shape")`) and `non_final_class_has_no_final_decorator` (`Not(HasSubstr("@final"))`), next to `abstract_class_prints_abstract_keyword`. +- [ ] **Step 2: Write the error-case files.** Each has one offence and nothing else that fails: + - `extends_final.ts`: `@final class B { m() { return 1; } }` and `class X extends B {}`. Expect `class 'X' cannot extend 'B': it is @final`. + - `extends_declared_final.ts`: `@final declare class D { m(): int; }` and `class E extends D {}`. Expect `class 'E' cannot extend 'D': it is @final`. This is the lib.d.ts shape. + - `final_abstract.ts`: `@final abstract class A { abstract m(): int; }`. Expect `class 'A' cannot be both @final and abstract`. + - `final_cpp.ts`: `@final @cpp declare class P { m(): int; }`. Expect `class 'P' cannot be both @final and @cpp`. +- [ ] **Step 3: Register them.** Add `final_error_cases` as `"name|regex"` entries. Run each with `$ --emit=obj --no-default-lib -mm=none -o ${CMAKE_CURRENT_BINARY_DIR}/final-.obj`. Use `PASS_REGULAR_EXPRESSION` set to the message and `FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed"`, exactly as `call_arity_cases` does. The test names are `test-compile-final-`. +- [ ] **Step 4: Confirm the new tests fail.** Reconfigure (`cmake .`), build `tslang` and `MLIRTypeScriptUnitTests`, then run `ctest -C Release -R "final-|unittest-MLIRGen"`. Expected: the four `test-compile-final-*` tests and the two new unit tests fail. +- [ ] **Step 5: Implement.** + - Add the define and the field. + - In the decorator loop, `if (name == FINAL_DECORATOR) newClassPtr->isFinal = true;`. After the loop, emit the two combination errors at the class location. Use `newClassPtr->isAbstract` and `newClassPtr->isCppVTable` and fail the declaration the way the loop's other errors do. + - In the `ExtendsKeyword` branch, right after `auto classInfo = getClassInfoByFullName(baseName);`, emit the extends error at the `extendingType` location when `classInfo && classInfo->isFinal`, and fail. + - In the printer, print `"@" FINAL_DECORATOR` and a newline when `classType->isFinal`, in the same place as `@cpp`. +- [ ] **Step 6: Run the tests.** `ctest -C Release -R "final-|unittest-MLIRGen"` should all pass. Then run the full suite: expected 0 failed. +- [ ] **Step 7: Commit.** `@final classes: parse, store, reject extends/abstract/@cpp, print`. + +### Task 2: direct dispatch on a `@final` receiver + +**Files:** +- Modify: `tslang/lib/TypeScript/MLIRGenImpl.h`: + - declarations about 8153-8164 + - `ClassMethodAccess` about 7958-8075, with the virtual branch at about 8045 +- Modify: `tslang/lib/TypeScript/MLIRGenAccessCall.cpp`: + - `ClassAccessorAccess` about 583-811, with the virtual reference at about 705 + - `ClassBaseClassAccess` about 935-950 + - `ClassMembersAccess` (the `ClassInfo` overload) about 985-1050 + - the overload re-resolution call at about 1983 +- Test: `tslang/test/tester/final/final_dispatch.ts`, `final_ir_direct.ts`, `final_ir_base.ts` +- Modify: `tslang/test/tester/CMakeLists.txt` (after `final_error_cases`) + +**Interfaces:** +- Consumes: `ClassInfo::isFinal` (Task 1). +- Produces: a trailing parameter `bool finalReceiver = false`, after `const GenContext &genContext`, on these four functions. Declarations and definitions must match. + - `ClassMembersAccess(mlir::Location, mlir::Value thisValue, ClassInfo::TypePtr, mlir::StringRef name, bool isSuperClass, mlir::Value argument, mlir_ts::AccessLevel, const GenContext &, bool finalReceiver = false)` + - `ClassBaseClassAccess(ClassInfo::TypePtr classInfo, ClassInfo::TypePtr baseClass, int index, mlir::Location, mlir::Value thisValue, StringRef name, mlir::Value argument, mlir_ts::AccessLevel, const GenContext &, bool finalReceiver = false)` + - `ClassMethodAccess(ClassInfo::TypePtr, mlir::Location, mlir::Value thisValue, int methodIndex, bool isSuperClass, mlir_ts::AccessLevel, const GenContext &, bool finalReceiver = false)` + - `ClassAccessorAccess(ClassInfo::TypePtr, mlir::Location, mlir::Value thisValue, int accessorIndex, bool isSuperClass, mlir_ts::AccessLevel, const GenContext &, bool finalReceiver = false)` + +- [ ] **Step 1: Write `final_dispatch.ts`.** It runs and asserts, and ends with `print("done.")`: + - a non-final `class B { m(): int { return 1; } n(): int { return 10; } get g(): int { return 100; } }` + - `interface I { m(): int; }` + - `@final class F extends B implements I { m(): int { return 2; } get g(): int { return 200; } k(): int { return this.m() + this.n(); } }` + - `@final class Box { constructor(public v: T) {} get(): T { return this.v; } }` + + Asserts, in `main`: + - `f.m() == 2`, `f.n() == 10`, `f.g == 200`, `f.k() == 12` + - `(f).m() == 2`, `(f).m() == 2`, `f instanceof B` + - `const fn = f.m; fn() == 2` + - `let o: F | null = f; o?.m() == 2` + - `new Box(7).get() == 7` +- [ ] **Step 2: Write the IR checks.** + - `final_ir_direct.ts` holds the classes above and only calls on `F`- and `Box`-typed values, including `this.m()` inside `F.k` and a call in `F`'s constructor. + - `final_ir_base.ts` holds the same classes and only calls `const b: B = new F(); b.m();`. +- [ ] **Step 3: Register them.** + - `final_dispatch` with `tslang_add_test(NAME test-compile-final-dispatch-${mm} COMMAND test-runner -mm=${mm} ...)` and `test-jit-final-dispatch-${mm}`, in a `foreach(mm gc rc none own)`. + - `final_ir_direct` with `add_test` running `tslang --emit=mlir --no-default-lib`. Use `PASS_REGULAR_EXPRESSION "ThisSymbolRef"` and `FAIL_REGULAR_EXPRESSION "ThisVirtualSymbolRef|error|Stack dump"`. + - `final_ir_base` the same way, with `PASS_REGULAR_EXPRESSION "ThisVirtualSymbolRef"` and `FAIL_REGULAR_EXPRESSION "error|Stack dump"`. +- [ ] **Step 4: Confirm the expected results before the change.** Reconfigure, build, then `ctest -C Release -R "final-dispatch|final-ir"`. Expected: `final-ir-direct` fails (it has `ThisVirtualSymbolRef`), `final-ir-base` passes, and the `final-dispatch-*` runs pass. If `own` fails here, note the error for Task 3 and continue. +- [ ] **Step 5: Implement.** + - At the top of the `ClassInfo` overload of `ClassMembersAccess`, compute `finalReceiver = finalReceiver || (!isSuperClass && classInfo->isFinal);`. + - Pass that flag into `ClassMethodAccess`, `ClassAccessorAccess` and `ClassBaseClassAccess`. `ClassBaseClassAccess` passes it on into its recursive `ClassMembersAccess`. + - At about 1983, pass `classInfo->isFinal`. + - In `ClassMethodAccess` and `ClassAccessorAccess`, compute `direct = finalReceiver && !methodInfo.isAbstract && !classInfo->isDynamicImport && !classInfo->isCppVTable` (for the accessor, from the accessor's get/set method info). When `direct` is true, skip the virtual branch and fall through to the existing `ThisSymbolRefOp` path that non-virtual methods already take. + - Check every other `VirtualSymbolRefOp` / `ThisVirtualSymbolRefOp` creation in these two files. Either apply the rule or add a one-line comment saying why it does not apply. Interface calls stay unchanged. +- [ ] **Step 6: Run the tests.** `ctest -C Release -R "final-"` should all pass. Then run the full suite: expected 0 failed. +- [ ] **Step 7: Commit.** `@final classes: direct calls on a @final receiver`. + +### Task 3: own resolves calls on `@final` classes + +**Files:** +- Test: `tslang/test/tester/own/own_final_fresh_result.ts`, `own/own_final_field_lend.ts` +- Modify: `tslang/test/tester/CMakeLists.txt` (append both to the `foreach(own_test ...)` list after `own_string_concat_element`) +- Modify, only if Step 2 fails: `tslang/lib/TypeScript/OwnershipInferencePass.cpp` / `OwnershipSignaturePass.cpp` / `OwnershipFacts.h` + +**Interfaces:** +- Consumes: the direct calls from Task 2. + +- [ ] **Step 1: Write the own tests.** Both are exported classes, the lib.ts shape, with a `churn()` of same-size objects between the call and the use, and `print("done.")`. Use 10000 iterations and assert the exact totals. + - `own_final_fresh_result.ts`: `@final export class Re { exec(s: string): Res | null { ... } }` returning `new Res(...)` or `null`. Also `@final export class D { t = 1; toString() { return "x" + this.t; } prim(h: string): string | number { if (h === "number") return this.t; return this.toString(); } }`. Use both: `const r = re.exec(s); if (r != null) ...`, and `d.prim("string")`. + - `own_final_field_lend.ts`: `@final export class Re { test(s: string): boolean { return s.length > 2; } }` and `export class W { constructor(private value: string) {} has(re: Re) { return re.test(this.value); } }`. +- [ ] **Step 2: Run them under own.** Run `Release/test-runner.exe -mm=own ` and `-jit -mm=own ` in `__build/.../test/tester`. Expected: exit 0. +- [ ] **Step 3: Handle a gap.** If either fails, reduce it to a minimal repro under `--no-default-lib` and find the cause in the pass. Then decide: + - **Real imprecision:** fix it, and add a positive own test plus, if the fix narrows an error, an `own_err_*` case. + - **Correct error:** stop and report it with the repro. The spec says such a case is decided separately. + + Never special-case `@final` inside an ownership pass. A direct call is the whole mechanism. +- [ ] **Step 4: Register and run.** `ctest -C Release -R "own"` should all pass. Then run the full suite: expected 0 failed. +- [ ] **Step 5: Commit.** `-mm=own: tests for calls on @final classes` (plus any fix, described in the message). + +### Task 4: `@final` across a module boundary + +**Files:** +- Test: `tslang/test/tester/tests/export_final_class.ts`, `tests/import_final_class.ts` +- Modify: `tslang/test/tester/CMakeLists.txt` (beside `test-compile-shared-export-import-class-interface`, about line 1490) + +**Interfaces:** +- Consumes: Tasks 1-2. `__decls` carries `@final` because the printer prints it. + +- [ ] **Step 1: Write the pair.** + - `export_final_class.ts`: `@final export class Counter { n = 0; inc(): int { return ++this.n; } get value(): int { return this.n; } }`. + - `import_final_class.ts` imports it, calls `inc()` 1000 times and asserts `c.value == 1000`, then prints `done.`. +- [ ] **Step 2: Register them.** Add `test-compile-shared-export-import-final-class` (`test-runner -shared`) and `test-jit-shared-export-import-final-class` (`test-runner -jit -shared`, the dynamic import) for `gc` and `rc`, through a `foreach(mm gc rc)` that passes `-mm=${mm}`. +- [ ] **Step 3: Run.** `ctest -C Release -R "import-final-class"` should all pass. A JIT link failure means the dynamic-import exclusion from Task 2 is not taking effect: fix it there. +- [ ] **Step 4: Full suite, then commit.** Run the full suite: expected 0 failed. Commit message: `@final classes: -shared import, compiled and dynamic`. + +### Task 5: DefaultLib classes are `@final` + +**Files (in `I:\TypeScriptCompilerDefaultLib`):** +- Modify: `src/lib.ts`: add `@final` on its own line before `export class` (the codebase's decorator placement, as with `@cpp`) for `Boolean`, `Number`, `BigInt`, `Date`, `MatchIndicesResults`, `MatchResults`, `RegExp`, `String`, `ArrayBuffer`, `Headers` and `Response`. +- Modify: `src/lib.d.ts`: add `@final` on its own line before `declare class` for the same classes, except `ArrayBuffer`, which has no declaration. +- Modify: `src/lib.ts`, `Headers` constructor: revert the `k + ""` / `v + ""` copies and their comment to `for (const [k, v] of init) this.set(k, v);`. + +**Interfaces:** +- Consumes: a `tslang.exe` built from Tasks 1-3. + +- [ ] **Step 1: Record the baseline.** Run `scratchpad/ownlib.sh` and the same with `OPTS="--di --opt_level=0"`, and keep the error lists. Expected: 14 (release) and 18 (debug) on the current branch heads. +- [ ] **Step 2: Mark the classes and revert the Headers copies.** +- [ ] **Step 3: Compile under own again.** Re-run both `ownlib.sh` variants. Expected: no errors at the String wrapper `match`/`replace`/`replaceAll`/`search`/`split` lines, at `Date[Symbol.toPrimitive]`, at `___String.match`'s `if (result == null)` (debug), or in the `Headers` constructor. What remains is only `matchAll` (its function line, its `next`, and the mislocated `lib.ts:151`), `String.iterator` (debug) and `Response.constructor`. Any other error goes back through Task 3, Step 3. +- [ ] **Step 4: Check gc and rc.** + - Build: `cmd /c "cd /d I:\TypeScriptCompilerDefaultLib && .\build.bat release gc"`, then the same with `rc`. Both should exit 0. + - Test: `pwsh -NoProfile -File .\tests.ps1 -Model gc`, then `-Model rc`. Expected: `Finished release, compile` and `Finished release, jit` all passing (gc 167/167; rc 166/167 with 1 skipped). The debug passes link against a stale debug runtime and are not a criterion. +- [ ] **Step 5: Run the compiler's DefaultLib tests.** `ctest -C Release -R "defaultlib"` in the compiler build should pass. +- [ ] **Step 6: Commit (DefaultLib, unsigned repo).** Message: `@final on the library's classes: own resolves their methods; Headers no longer copies`. diff --git a/tslang/docs/superpowers/specs/2026-10-19-final-classes-design.md b/tslang/docs/superpowers/specs/2026-10-19-final-classes-design.md new file mode 100644 index 000000000..b258700ad --- /dev/null +++ b/tslang/docs/superpowers/specs/2026-10-19-final-classes-design.md @@ -0,0 +1,155 @@ +# `@final` classes + +Date: 2026-10-19. Status: design, awaiting approval. + +## Why + +The default library does not build under `-mm=own`. After the `@nodrops` work (compiler branch +`own-nodrops-declarations`, DefaultLib branch `own-default-lib`) 14 errors remain in `src/lib.ts`. +Seven of them have one cause: a method call on an exported class goes through the vtable, and +another module could subclass that class and override the method. So own cannot resolve the call +(`OwnershipSignaturePass::resolveFamily` requires every family member to be private and defined in +this module), and an unresolved call: + +- may destroy anything, so a borrow of a field cannot be lent to it. This affects the `String` + wrapper's `match`, `replace`, `replaceAll`, `search` and `split`: each one hands `this.value` to a + `___String` function that calls a `RegExp` method. +- returns a value that is not fresh (spec 2026-09-24 section on freshness: only a direct call of a + function defined in this module is fresh), so it cannot be moved into a union or compared and + then returned. This affects `Date[Symbol.toPrimitive]`'s `return this.toString()` (lib.ts + 432/435) and, in the debug build only, `___String.match`'s `if (result == null)` after + `expr.exec(this)`. + +The user's ruling: DefaultLib classes are final in practice, and nobody overrides them. A class +should be able to say so with a `@final` decorator. + +## Goals + +- `@final` on a class declaration: no class may extend it. This is enforced as a compile error, in + the module that defines the class and in every module that sees its declaration. +- A method or accessor call on a value whose static type is a `@final` class is a direct call to + the implementation that type has, not a vtable call. Own then resolves it with the rules it + already has for direct calls: no-drops from the body, a fresh result, result-borrows facts, and + stack promotion and borrow facts the same way. +- Mark the DefaultLib classes `@final` in `lib.ts` and in their `lib.d.ts` declarations: `RegExp`, + `MatchResults`, `MatchIndicesResults`, `String`, `Number`, `Boolean`, `BigInt`, `Date`, `Headers` + and `Response`. `ArrayBuffer` has no `lib.d.ts` declaration, so it is marked in `lib.ts` only. + `console` and `Math` are `static` classes with no instances and stay unmarked. +- The seven errors listed above go away. The `Headers` constructor's `k + ""` copies, a workaround + for the same cause, are reverted. + +## Non-goals + +- The other remaining errors are a separate decision ("an exported function cannot keep or borrow + a parameter": `matchAll`, `String.iterator`, `Response.constructor`, and the mislocated + `lib.ts:151` error, which comes from `matchAll`'s generated `next`). They stay. +- Own facts crossing a static link. A user program under own that calls `re.exec(s.value)` still + sees the library's methods as declarations without facts. That is a follow-up project (the same + mechanism as the borrow facts of 2026-10-18). +- `@final` on individual methods. It is class-level only. +- Adding `-mm=own` to DefaultLib's `build.bat` / `build.sh` loops. That waits until `lib.ts` + compiles under own with no errors. + +## Anti-goals + +- Changing a program's behaviour under gc, rc or none. A direct call reaches the same function + the vtable would have reached, because no subclass can exist. Only the dispatch changes. +- Dropping the vtable of a `@final` class. Interfaces the class implements, `instanceof`, and calls + through a non-final base type still use it. +- Breaking the cross-module paths. A `@final` class from a DLL loaded at run time (`isDynamicImport`, + no import library) must keep its vtable calls, because a direct call there would be a symbol that + cannot be linked. + +## Design + +### Parsing and storage + +- `#define FINAL_DECORATOR "final"` in `Defines.h`, beside `CPP_VTABLE`. +- `ClassInfo` gets a `bool isFinal` (in `MLIRGenStore.h`, default false). It is set in the class + decorator loop in `MLIRGenClasses.cpp` (the `iterateDecorators(classDeclarationAST, ...)` block at + about line 361), on declarations (`declare class`, `lib.d.ts`) as well as definitions. +- `DeclarationPrinter` prints `@final` before a final class, as it prints `@cpp` (about line 436), + so `-shared` importers (`__decls`) see it. + +### Enforcement + +- Where a class's `extends` clause is resolved (the `heritageClauses` loops in + `MLIRGenClasses.cpp`), a base class with `isFinal` is an error at the `extends` expression: + `class 'X' cannot extend 'RegExp': it is @final`. It applies in every memory model. +- `@final` together with `abstract` is an error, since an abstract class must be extended. + `@final` with `@cpp` is also an error: a C++ binding is dispatched through the C++ vtable. + +### Dispatch + +A method or accessor reference for a class instance is virtual at two sites: +`ClassMethodAccess` in `MLIRGenImpl.h` (about line 8045, +`if (methodInfo.isAbstract || methodInfo.isVirtual && !isStorageType)`, creating +`ThisVirtualSymbolRefOp` at about 8067) and `ClassAccessorAccess` in `MLIRGenAccessCall.cpp` +(about line 705). Each gains one exception: when the *receiver's* static type is a `@final` +class that is not `isDynamicImport`, it emits the direct `ThisSymbolRefOp` to `funcName`. + +The `classInfo` these functions see is not always the receiver's. For an inherited member, +`ClassMembersAccess` recurses through `ClassBaseClassAccess` with the base's `ClassInfo` +(`MLIRGenAccessCall.cpp` about 947/1012). So the receiver's finality has to be passed down +through `ClassMembersAccess` -> `ClassBaseClassAccess` -> `ClassMethodAccess` / +`ClassAccessorAccess`, as a flag or as the receiver's `ClassInfo`. The decision then reads: + +- A method the final class defines or overrides is called directly. +- A method it inherits from a non-final base without overriding it is called directly too, as + `Base.m`. With no subclass, that is the only implementation a value of this static type can have. +- A call through a variable typed as the non-final base, or as an interface, is unchanged. + +The index accessor (`ClassIndexAccess`, as in `MatchResults[i]`) already emits a direct +`ThisIndexAccessorOp`, and `super.` calls are already direct (their `this` is a storage type), so +neither changes. The builder should confirm that no other `VirtualSymbolRefOp` / +`ThisVirtualSymbolRefOp` creation applies to a class instance. + +Direct calls name the method by symbol, so for user programs they also get past a known +`lib.d.ts` / `lib.ts` mismatch. `lib.d.ts`'s `RegExp` leaves out `split`, which `lib.ts` defines, +so a user program's vtable index for `[Symbol.dispose]` does not match the library's. Public +methods and accessors of exported classes are already in the import library, so the direct +calls link, compiled or JIT. Under rc, `OwnedReturnConsumptionPass` treats a direct call to a +`lib.d.ts` declaration as it treats a virtual one. + +### Own + +No change in the ownership passes is planned. A `ThisSymbolRef` call is what `resolveValue` +already resolves (`addDefined`, or `addImported` for an own library over a DLL import), and it +canonicalizes to the `SymbolCallInternal` that `isFresh` accepts. `noDrops` does not depend on a +function being open, so the public `RegExp.exec` called directly gets its body's facts. + +The builder checks this first, on the three lib.ts shapes (`re.exec(s)` returning +`MatchResults | null`, `this.toString()` returned as `string | number`, and +`this.value.match(expr)`, whose callee destroys its own local `MatchResults` after `[...result]`). +If one still fails because of a real imprecision in a pass, fixing it properly is part of this +project, with a minimal repro, a fix and an own test, as the `@nodrops` branch did for four such +imprecisions. *(Builder's call, made by the designer: the user left scope open.)* A workaround +that only bends a pass for these shapes is not. If a remaining error turns out to be correct +(the code really is unsafe under own), it is reported, and the library change for it is decided +separately. + +## Testing + +- Compiler `test/tester`: + - Positive own tests (`own/`, run AOT and JIT): a `@final` class with a virtual method whose + result is returned through a union and compared with null, and a wrapper class that lends a + field to a `@final` class's method. Each comes with a `churn()` of same-size objects (the + use-after-free rule from the rc work). + - Error cases: `extends` of a `@final` class, `@final abstract`, `@final @cpp`. + - An IR check that a call on a `@final` static type emits no `ThisVirtualSymbolRef`, while one + through its interface still does. + - A `-shared` pair: an exporting module with a `@final` class and an importer that calls it and + fails to extend it. +- The full suite stays green (4682/4682 on the base branch). +- DefaultLib: + - Release builds of gc and rc pass `tests.ps1`, compiled and JIT. + - `lib.ts` under own: the seven listed errors are gone (release and `--di`), and the remaining + count is only the non-goal sites. + +## Left to the builder + +- The error message wording, and whether to give the user a fix-it hint. +- Whether `isFinal` also skips emitting `.instanceOf` overrides. It should not: `instanceof` + still needs them. +- Branch layout. Stack this on `own-nodrops-declarations` (whose commit is still waiting on GPG + signing), or rebase once that lands. From b0804f04325de518c77fc7ffc2157eb0123d9f4c Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 09:36:00 +0100 Subject: [PATCH 03/11] @final classes: parse, store, reject extends/abstract/@cpp, print Co-Authored-By: Claude Opus 5.5 --- tslang/include/TypeScript/Defines.h | 3 +++ .../TypeScript/MLIRLogic/MLIRGenStore.h | 5 +++- tslang/lib/TypeScript/DeclarationPrinter.cpp | 7 ++++++ tslang/lib/TypeScript/MLIRGenClasses.cpp | 24 +++++++++++++++++++ tslang/test/tester/CMakeLists.txt | 21 ++++++++++++++++ .../tester/final/extends_declared_final.ts | 10 ++++++++ tslang/test/tester/final/extends_final.ts | 13 ++++++++++ tslang/test/tester/final/final_abstract.ts | 8 +++++++ tslang/test/tester/final/final_cpp.ts | 9 +++++++ .../unittests/MLIRGen/DeclarationPrinter.cpp | 20 ++++++++++++++++ 10 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tslang/test/tester/final/extends_declared_final.ts create mode 100644 tslang/test/tester/final/extends_final.ts create mode 100644 tslang/test/tester/final/final_abstract.ts create mode 100644 tslang/test/tester/final/final_cpp.ts diff --git a/tslang/include/TypeScript/Defines.h b/tslang/include/TypeScript/Defines.h index c8427076a..5b2b39b24 100644 --- a/tslang/include/TypeScript/Defines.h +++ b/tslang/include/TypeScript/Defines.h @@ -247,6 +247,9 @@ #define LINK_NAME "linkname" // A class laid over an existing C++ object: its vtable is the C++ one (see ClassInfo::isCppVTable) #define CPP_VTABLE "cpp" +// `@final class C`: no class may extend C, so a call on a value of type C is a direct call to the +// method C has (docs/superpowers/specs/2026-10-19-final-classes-design.md) +#define FINAL_DECORATOR "final" // the old name of @cpp, an error now: unknown, it would leave the class an ordinary one #define CPP_VTABLE_OLD_NAME "cppvtable" // A @cpp method's C++ return type has a destructor or a copy constructor (see diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h b/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h index 284669664..addebfee2 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h @@ -795,6 +795,9 @@ struct ClassInfo // vtable's slots from 0 in declaration order, a base class's first: no `.instanceOf`, `.new`, // interface or static-field slots, no RTTI, and no vtable or constructor of its own. bool isCppVTable; + // `@final`: nothing extends it, so a method of a value of this type is called directly. The + // vtable stays: interfaces, `instanceof` and calls through a base type use it. + bool isFinal; // a @cpp class with methods that have bodies: C++ methods implemented here, so C++ can // call them through its objects. It has a vtable (the C++ one's layout: the slots and nothing // else, a slot left to a declared C++ method null), may have a constructor and field @@ -813,7 +816,7 @@ struct ClassInfo ClassInfo() : isDeclaration(false), hasNew(false), hasConstructor(false), constructorAccessLevel(mlir_ts::AccessLevel::Public), hasInitializers(false), hasStaticConstructor(false), hasStaticInitializers(false), hasVirtualTable(false), isAbstract(false), isExport(false), isImport(false), - isPublic(false), isDynamicImport(false), hasRTTI(false), isCppVTable(false), + isPublic(false), isDynamicImport(false), hasRTTI(false), isCppVTable(false), isFinal(false), processingAtEvaluation(ProcessingStages::NotSet), processing(ProcessingStages::NotSet) { } diff --git a/tslang/lib/TypeScript/DeclarationPrinter.cpp b/tslang/lib/TypeScript/DeclarationPrinter.cpp index 1544e8cb2..8f5342c88 100644 --- a/tslang/lib/TypeScript/DeclarationPrinter.cpp +++ b/tslang/lib/TypeScript/DeclarationPrinter.cpp @@ -437,6 +437,13 @@ namespace typescript newline(); } + // an importer calls a @final class's methods directly and may not extend it + if (classType->isFinal) + { + os << "@" FINAL_DECORATOR; + newline(); + } + // `abstract` is layout-relevant, not just semantic: mlirGenClassNew skips // synthesizing the `.new` method for abstract classes, so dropping the // modifier makes the reimporting module insert a `.new` vtable slot the diff --git a/tslang/lib/TypeScript/MLIRGenClasses.cpp b/tslang/lib/TypeScript/MLIRGenClasses.cpp index 2caeb893b..79c7bd597 100644 --- a/tslang/lib/TypeScript/MLIRGenClasses.cpp +++ b/tslang/lib/TypeScript/MLIRGenClasses.cpp @@ -93,6 +93,16 @@ namespace mlirgen return {mlir::failure(), ""}; } + // an abstract class is there to be extended, and a C++ binding is called through the C++ + // vtable: neither can be @final + if (newClassPtr->isFinal && (newClassPtr->isAbstract || newClassPtr->isCppVTable)) + { + emitError(loc(classDeclarationAST)) + << "class '" << newClassPtr->name << "' cannot be both @" FINAL_DECORATOR " and " + << (newClassPtr->isAbstract ? "abstract" : "@" CPP_VTABLE); + return {mlir::failure(), ""}; + } + if (isGenericClass && genContext.typeParamsWithArgs.size() > 0) { // a concrete instantiation of a generic class (e.g. Box) reprocesses the @@ -391,6 +401,11 @@ namespace mlirgen newClassPtr->isDeclaration = true; } } + + if (name == FINAL_DECORATOR) + { + newClassPtr->isFinal = true; + } }); getClassesMap().insert({namePtr, newClassPtr}); @@ -1238,6 +1253,15 @@ namespace mlirgen fieldInfos.push_back({fieldId, baseClassType.getStorageType(), false, mlir_ts::AccessLevel::Public}); auto classInfo = getClassInfoByFullName(baseName); + // calls on a @final class are direct (ClassMethodAccess): an override + // would never run + if (classInfo && classInfo->isFinal) + { + emitError(loc(extendingType)) << "class '" << newClassPtr->name << "' cannot extend '" + << classInfo->name << "': it is @" FINAL_DECORATOR; + return; + } + if (std::find(baseClassInfos.begin(), baseClassInfos.end(), classInfo) == baseClassInfos.end()) { baseClassInfos.push_back(classInfo); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 6afd4c8d8..bf6d37e7e 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2772,6 +2772,27 @@ foreach(call_arity_case ${call_arity_cases}) FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") endforeach() +# `@final` (docs/superpowers/specs/2026-10-19-final-classes-design.md): a class nobody may extend. +# Each file in final/ breaks one rule; the test passes when the compiler prints the message. +set(final_error_cases + "extends_final|class 'X' cannot extend 'B': it is @final" + "extends_declared_final|class 'E' cannot extend 'D': it is @final" + "final_abstract|class 'A' cannot be both @final and abstract" + "final_cpp|class 'P' cannot be both @final and @cpp") +foreach(final_error_case ${final_error_cases}) + string(FIND "${final_error_case}" "|" final_error_sep) + string(SUBSTRING "${final_error_case}" 0 ${final_error_sep} final_error_name) + math(EXPR final_error_sep "${final_error_sep} + 1") + string(SUBSTRING "${final_error_case}" ${final_error_sep} -1 final_error_message) + add_test(NAME test-compile-final-${final_error_name} + COMMAND $ --emit=obj --no-default-lib -mm=none + "${PROJECT_SOURCE_DIR}/test/tester/final/${final_error_name}.ts" + -o "${CMAKE_CURRENT_BINARY_DIR}/final-${final_error_name}.obj") + set_tests_properties(test-compile-final-${final_error_name} + PROPERTIES PASS_REGULAR_EXPRESSION "${final_error_message}" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +endforeach() + # A `continue` or `break` naming a label that no statement around it has is an error (a label # outside the function doesn't count); it compiled to a jump with nowhere to go. add_test(NAME test-compile-diagnostic-location-continue-label diff --git a/tslang/test/tester/final/extends_declared_final.ts b/tslang/test/tester/final/extends_declared_final.ts new file mode 100644 index 000000000..b5dbc3884 --- /dev/null +++ b/tslang/test/tester/final/extends_declared_final.ts @@ -0,0 +1,10 @@ +// The default library's shape: a @final declaration (lib.d.ts) cannot be extended either. +@final +declare class D { + m(): int; +} + +class E extends D {} + +function main() { +} diff --git a/tslang/test/tester/final/extends_final.ts b/tslang/test/tester/final/extends_final.ts new file mode 100644 index 000000000..c3d351f45 --- /dev/null +++ b/tslang/test/tester/final/extends_final.ts @@ -0,0 +1,13 @@ +// A @final class cannot be extended: calls on it are direct, so an override would never run. +@final +class B { + m() { + return 1; + } +} + +class X extends B {} + +function main() { + print(new X().m()); +} diff --git a/tslang/test/tester/final/final_abstract.ts b/tslang/test/tester/final/final_abstract.ts new file mode 100644 index 000000000..30945c589 --- /dev/null +++ b/tslang/test/tester/final/final_abstract.ts @@ -0,0 +1,8 @@ +// An abstract class exists to be extended, which @final forbids. +@final +abstract class A { + abstract m(): int; +} + +function main() { +} diff --git a/tslang/test/tester/final/final_cpp.ts b/tslang/test/tester/final/final_cpp.ts new file mode 100644 index 000000000..ff8be8085 --- /dev/null +++ b/tslang/test/tester/final/final_cpp.ts @@ -0,0 +1,9 @@ +// A C++ binding is dispatched through the C++ vtable, which @final would bypass. +@final +@cpp +declare class P { + m(): int; +} + +function main() { +} diff --git a/tslang/unittests/MLIRGen/DeclarationPrinter.cpp b/tslang/unittests/MLIRGen/DeclarationPrinter.cpp index 0525e011e..821d494fa 100644 --- a/tslang/unittests/MLIRGen/DeclarationPrinter.cpp +++ b/tslang/unittests/MLIRGen/DeclarationPrinter.cpp @@ -423,6 +423,26 @@ TEST_F(DeclarationPrinterTest, concrete_class_has_no_abstract_keyword) EXPECT_THAT(text, testing::HasSubstr("class Shape")); } +// `@final` decides how an importer dispatches (directly, not through the vtable) and whether it +// may extend the class, so the reimported declaration has to keep it. +TEST_F(DeclarationPrinterTest, final_class_prints_final_decorator) +{ + auto ci = makeClass("Shape"); + ci->isFinal = true; + setClassFields(ci, {}); + auto text = printClass(ci); + EXPECT_THAT(text, testing::HasSubstr("@final")); + EXPECT_THAT(text, testing::HasSubstr("class Shape")); +} + +TEST_F(DeclarationPrinterTest, non_final_class_has_no_final_decorator) +{ + auto ci = makeClass("Shape"); + setClassFields(ci, {}); + auto text = printClass(ci); + EXPECT_THAT(text, testing::Not(testing::HasSubstr("@final"))); +} + TEST_F(DeclarationPrinterTest, abstract_method_prints_abstract_keyword) { auto ci = makeClass("Shape"); From 4a31087ac67447875e92be6cc7971261eda02678 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 09:45:18 +0100 Subject: [PATCH 04/11] @final classes: direct calls on a @final receiver A method or accessor of a value whose static type is a @final class is called through ThisSymbolRef, not the vtable; the receiver's finality is carried through the walk to the base classes. A method read as a value (const fn = f.m) is bound by a new ThisSymbolRef lowering. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/LowerToAffineLoops.cpp | 2 +- tslang/lib/TypeScript/LowerToLLVM.cpp | 30 +++++++- tslang/lib/TypeScript/MLIRGenAccessCall.cpp | 33 ++++++--- tslang/lib/TypeScript/MLIRGenImpl.h | 21 ++++-- tslang/test/tester/CMakeLists.txt | 16 +++++ tslang/test/tester/final/final_dispatch.ts | 73 ++++++++++++++++++++ tslang/test/tester/final/final_ir_base.ts | 44 ++++++++++++ tslang/test/tester/final/final_ir_direct.ts | 55 +++++++++++++++ 8 files changed, 258 insertions(+), 16 deletions(-) create mode 100644 tslang/test/tester/final/final_dispatch.ts create mode 100644 tslang/test/tester/final/final_ir_base.ts create mode 100644 tslang/test/tester/final/final_ir_direct.ts diff --git a/tslang/lib/TypeScript/LowerToAffineLoops.cpp b/tslang/lib/TypeScript/LowerToAffineLoops.cpp index fab4a4c41..70c91d423 100644 --- a/tslang/lib/TypeScript/LowerToAffineLoops.cpp +++ b/tslang/lib/TypeScript/LowerToAffineLoops.cpp @@ -2937,7 +2937,7 @@ void AddTsAffineLegalOps(ConversionTarget &target) mlir_ts::LoadOp, mlir_ts::LoadSaveOp, mlir_ts::NewOp, mlir_ts::CreateTupleOp, mlir_ts::DeconstructTupleOp, mlir_ts::CreateArrayOp, mlir_ts::NewEmptyArrayOp, mlir_ts::NewArrayOp, mlir_ts::DeleteOp, mlir_ts::PropertyRefOp, mlir_ts::InsertPropertyOp, mlir_ts::ExtractPropertyOp, mlir_ts::LogicalBinaryOp, mlir_ts::UndefOp, mlir_ts::VariableOp, mlir_ts::DebugVariableOp, mlir_ts::AllocaOp, - mlir_ts::InvokeOp, /*mlir_ts::ResultOp,*/ mlir_ts::VirtualSymbolRefOp, mlir_ts::ThisVirtualSymbolRefOp, mlir_ts::InterfaceSymbolRefOp, + mlir_ts::InvokeOp, /*mlir_ts::ResultOp,*/ mlir_ts::VirtualSymbolRefOp, mlir_ts::ThisVirtualSymbolRefOp, mlir_ts::ThisSymbolRefOp, mlir_ts::InterfaceSymbolRefOp, mlir_ts::ExtractInterfaceThisOp, mlir_ts::ExtractInterfaceVTableOp, mlir_ts::ArrayPushOp, mlir_ts::ArrayPopOp, mlir_ts::ArrayUnshiftOp, mlir_ts::ArrayShiftOp, mlir_ts::ArraySpliceOp, mlir_ts::ArrayViewOp, mlir_ts::NewInterfaceOp, mlir_ts::VTableOffsetRefOp, mlir_ts::GetThisOp, mlir_ts::GetMethodOp, mlir_ts::DebuggerOp, diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index 41d030174..5251e7310 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -6365,6 +6365,34 @@ struct ThisVirtualSymbolRefOpLowering : public TsLlvmPattern +{ + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::ThisSymbolRefOp thisSymbolRefOp, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + auto boundFunc = dyn_cast(thisSymbolRefOp.getType()); + if (!boundFunc) + { + thisSymbolRefOp->emitError("unsupported this symbol reference type"); + return failure(); + } + + Location loc = thisSymbolRefOp.getLoc(); + auto thisOpaque = rewriter.create(loc, mlir_ts::OpaqueType::get(rewriter.getContext()), + transformed.getThisVal()); + auto method = rewriter.create( + loc, mlir_ts::FunctionType::get(rewriter.getContext(), boundFunc.getInputs(), boundFunc.getResults()), + thisSymbolRefOp.getIdentifierAttr()); + rewriter.replaceOpWithNewOp(thisSymbolRefOp, boundFunc, thisOpaque, method); + return success(); + } +}; + struct InterfaceSymbolRefOpLowering : public TsLlvmPattern { using TsLlvmPattern::TsLlvmPattern; @@ -8694,7 +8722,7 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() SharedNewOpLowering, SharedValueRefOpLowering, SharedCountOpLowering, StringCompareOpLowering, AnyCompareOpLowering, CharToStringOpLowering, UndefOpLowering, CopyStructOpLowering, MemoryCopyOpLowering, MemoryMoveOpLowering, LoadSaveValueLowering, ThrowUnwindOpLowering, ThrowCallOpLowering, VariableOpLowering, DebugVariableOpLowering, AllocaOpLowering, InvokeOpLowering, - InvokeHybridOpLowering, VirtualSymbolRefOpLowering, ThisVirtualSymbolRefOpLowering, InterfaceSymbolRefOpLowering, + InvokeHybridOpLowering, VirtualSymbolRefOpLowering, ThisVirtualSymbolRefOpLowering, ThisSymbolRefOpLowering, InterfaceSymbolRefOpLowering, NewInterfaceOpLowering, VTableOffsetRefOpLowering, LoadBoundRefOpLowering, StoreBoundRefOpLowering, CreateBoundRefOpLowering, CreateBoundFunctionOpLowering, GetThisOpLowering, GetMethodOpLowering, TypeOfOpLowering, TypeOfAnyOpLowering, DebuggerOpLowering, UnreachableOpLowering, SymbolCallInternalOpLowering, CallInternalOpLowering, CallHybridInternalOpLowering, diff --git a/tslang/lib/TypeScript/MLIRGenAccessCall.cpp b/tslang/lib/TypeScript/MLIRGenAccessCall.cpp index 30689233b..80a39b162 100644 --- a/tslang/lib/TypeScript/MLIRGenAccessCall.cpp +++ b/tslang/lib/TypeScript/MLIRGenAccessCall.cpp @@ -582,7 +582,8 @@ namespace mlirgen mlir::Value MLIRGenImpl::ClassAccessorAccess(ClassInfo::TypePtr classInfo, mlir::Location location, mlir::Value thisValue, int accessorIndex, - bool isSuperClass, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext) + bool isSuperClass, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext, + bool finalReceiver) { auto accessorInfo = classInfo->accessors[accessorIndex]; @@ -668,7 +669,11 @@ namespace mlirgen auto getMethodInfo = findMethodInfo(getFunc); auto setMethodInfo = findMethodInfo(setFunc); - auto isVirtualAccessor = !isStorageType && + // a @final receiver's accessor is the one this class has, called directly, as a method + // is (ClassMethodAccess) + auto direct = finalReceiver && !classInfo->isDynamicImport && !classInfo->isCppVTable && + !(getMethodInfo && getMethodInfo->isAbstract) && !(setMethodInfo && setMethodInfo->isAbstract); + auto isVirtualAccessor = !direct && !isStorageType && ((getMethodInfo && (getMethodInfo->isAbstract || getMethodInfo->isVirtual)) || (setMethodInfo && (setMethodInfo->isAbstract || setMethodInfo->isVirtual))); @@ -933,7 +938,8 @@ namespace mlirgen } mlir::Value MLIRGenImpl::ClassBaseClassAccess(ClassInfo::TypePtr classInfo, ClassInfo::TypePtr baseClass, int index, - mlir::Location location, mlir::Value thisValue, StringRef name, mlir::Value argument, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext) + mlir::Location location, mlir::Value thisValue, StringRef name, mlir::Value argument, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext, + bool finalReceiver) { // first base is "super." @@ -944,7 +950,8 @@ namespace mlirgen return value; } - auto value = ClassMembersAccess(location, thisValue, baseClass, name, true, argument, accessingFromLevel, genContext); + auto value = ClassMembersAccess(location, thisValue, baseClass, name, true, argument, accessingFromLevel, genContext, + finalReceiver); if (value) { return value; @@ -983,10 +990,16 @@ namespace mlirgen } mlir::Value MLIRGenImpl::ClassMembersAccess(mlir::Location location, mlir::Value thisValue, ClassInfo::TypePtr classInfo, - mlir::StringRef name, bool isSuperClass, mlir::Value argument, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext) + mlir::StringRef name, bool isSuperClass, mlir::Value argument, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext, + bool finalReceiver) { assert(classInfo); + // A value whose own type is @final has no subclass: whichever class up the chain has the + // member, that member is the one it runs. Kept through the walk to the base classes, which + // reach here as `isSuperClass`. + finalReceiver = finalReceiver || (!isSuperClass && classInfo->isFinal); + LLVM_DEBUG(llvm::dbgs() << "\n\t looking for member: " << name << " in class '" << classInfo->fullName << "'\n";); // indexer access @@ -1009,7 +1022,8 @@ namespace mlirgen auto methodIndex = classInfo->getMethodIndex(name); if (methodIndex >= 0) { - return ClassMethodAccess(classInfo, location, thisValue, methodIndex, isSuperClass, accessingFromLevel, genContext); + return ClassMethodAccess(classInfo, location, thisValue, methodIndex, isSuperClass, accessingFromLevel, genContext, + finalReceiver); } // static generic methods @@ -1023,7 +1037,8 @@ namespace mlirgen auto accessorIndex = classInfo->getAccessorIndex(name); if (accessorIndex >= 0) { - return ClassAccessorAccess(classInfo, location, thisValue, accessorIndex, isSuperClass, accessingFromLevel, genContext); + return ClassAccessorAccess(classInfo, location, thisValue, accessorIndex, isSuperClass, accessingFromLevel, genContext, + finalReceiver); } for (auto [index, baseClass] : enumerate(classInfo->baseClasses)) @@ -1031,7 +1046,7 @@ namespace mlirgen auto effectiveAccessingFromLevel = accessingFromLevel == mlir_ts::AccessLevel::Private ? mlir_ts::AccessLevel::Protected : accessingFromLevel; auto value = ClassBaseClassAccess(classInfo, baseClass, index, location, - thisValue, name, argument, effectiveAccessingFromLevel, genContext); + thisValue, name, argument, effectiveAccessingFromLevel, genContext, finalReceiver); if (value) { return value; @@ -1981,7 +1996,7 @@ namespace mlirgen // a base class's method takes the object cast to that class, as the member access does auto resolved = ClassMethodAccess(chosen.owner, location, thisValue, chosen.methodIndex, chosen.owner != classInfo, - chosenMethod.accessLevel, genContext); + chosenMethod.accessLevel, genContext, classInfo->isFinal); if (resolved && funcRefValue.use_empty()) { funcRefValue.getDefiningOp()->erase(); diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 751d85d1e..757f5ae7f 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -7955,8 +7955,11 @@ class MLIRGenImpl #endif } + // `finalReceiver`: the value's own static type is a @final class (ClassMembersAccess), so the + // method this class has for it is the only one it can have, and it is called directly. mlir::Value ClassMethodAccess(ClassInfo::TypePtr classInfo, - mlir::Location location, mlir::Value thisValue, int methodIndex, bool isSuperClass, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext) { + mlir::Location location, mlir::Value thisValue, int methodIndex, bool isSuperClass, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext, + bool finalReceiver = false) { LLVM_DEBUG(llvm::dbgs() << "\n!! method index access: " << methodIndex << "\n";); @@ -8043,7 +8046,12 @@ class MLIRGenImpl // TODO: check if you can split calls such as "this.method" and "super.method" ... auto isStorageType = isa(thisValue.getType()); - if (methodInfo.isAbstract || /*!baseClass &&*/ methodInfo.isVirtual && !isStorageType) + // A @final receiver's method is the one this class has (ClassMembersAccess): the + // direct reference below, which the vtable would give anyway. Not for a class that + // lives in a DLL loaded at run time, whose method has no symbol to link against, nor a + // C++ binding, called through the C++ vtable. + auto direct = finalReceiver && !methodInfo.isAbstract && !classInfo->isDynamicImport && !classInfo->isCppVTable; + if (!direct && (methodInfo.isAbstract || /*!baseClass &&*/ methodInfo.isVirtual && !isStorageType)) { LLVM_DEBUG(dbgs() << "\n!! Virtual call: func '" << funcName << "'\n";); @@ -8152,16 +8160,19 @@ class MLIRGenImpl mlir::Value ClassAccessorAccess(ClassInfo::TypePtr classInfo, mlir::Location location, mlir::Value thisValue, int accessorIndex, - bool isSuperClass, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext); + bool isSuperClass, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext, + bool finalReceiver = false); mlir::Value ClassIndexAccess(ClassInfo::TypePtr classInfo, mlir::Location location, mlir::Value thisValue, mlir::Value argument, bool isSuperClass, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext); mlir::Value ClassBaseClassAccess(ClassInfo::TypePtr classInfo, ClassInfo::TypePtr baseClass, int index, - mlir::Location location, mlir::Value thisValue, StringRef name, mlir::Value argument, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext); + mlir::Location location, mlir::Value thisValue, StringRef name, mlir::Value argument, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext, + bool finalReceiver = false); mlir::Value ClassMembersAccess(mlir::Location location, mlir::Value thisValue, ClassInfo::TypePtr classInfo, - mlir::StringRef name, bool isSuperClass, mlir::Value argument, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext); + mlir::StringRef name, bool isSuperClass, mlir::Value argument, mlir_ts::AccessLevel accessingFromLevel, const GenContext &genContext, + bool finalReceiver = false); bool classHasField(ClassInfo::TypePtr classInfo, mlir::StringRef name, SmallVector &fieldPath) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index bf6d37e7e..6279467ba 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2792,6 +2792,22 @@ foreach(final_error_case ${final_error_cases}) PROPERTIES PASS_REGULAR_EXPRESSION "${final_error_message}" FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") endforeach() +# A call on a @final class is direct, and the program runs the same under every model. +foreach(final_mm gc rc none own) + tslang_add_test(NAME test-compile-final-dispatch-${final_mm} COMMAND test-runner -mm=${final_mm} "${PROJECT_SOURCE_DIR}/test/tester/final/final_dispatch.ts") + tslang_add_test(NAME test-jit-final-dispatch-${final_mm} COMMAND test-runner -jit -mm=${final_mm} "${PROJECT_SOURCE_DIR}/test/tester/final/final_dispatch.ts") +endforeach() +# ...with no vtable call left on a @final receiver, while one through a non-final base keeps it +add_test(NAME test-final-ir-direct + COMMAND $ --emit=mlir --no-default-lib + "${PROJECT_SOURCE_DIR}/test/tester/final/final_ir_direct.ts") +set_tests_properties(test-final-ir-direct PROPERTIES PASS_REGULAR_EXPRESSION "ThisSymbolRef" + FAIL_REGULAR_EXPRESSION "VirtualSymbolRef|error|Stack dump") +add_test(NAME test-final-ir-base + COMMAND $ --emit=mlir --no-default-lib + "${PROJECT_SOURCE_DIR}/test/tester/final/final_ir_base.ts") +set_tests_properties(test-final-ir-base PROPERTIES PASS_REGULAR_EXPRESSION "ThisVirtualSymbolRef" + FAIL_REGULAR_EXPRESSION "error|Stack dump") # A `continue` or `break` naming a label that no statement around it has is an error (a label # outside the function doesn't count); it compiled to a jump with nowhere to go. diff --git a/tslang/test/tester/final/final_dispatch.ts b/tslang/test/tester/final/final_dispatch.ts new file mode 100644 index 000000000..7d2ce90ed --- /dev/null +++ b/tslang/test/tester/final/final_dispatch.ts @@ -0,0 +1,73 @@ +// `@final`: a call on a value of a @final class runs the method that class has, directly, and a +// call through its base or its interface still goes through the vtable to the same method. +class B { + m(): int { + return 1; + } + + n(): int { + return 10; + } + + get g(): int { + return 100; + } +} + +interface I { + m(): int; +} + +@final +class F extends B implements I { + base: int; + + constructor() { + super(); + this.base = this.n(); + } + + m(): int { + return 2; + } + + get g(): int { + return 200; + } + + k(): int { + return this.m() + this.n(); + } +} + +@final +class Box { + constructor(public v: T) {} + + get(): T { + return this.v; + } +} + +function main() { + const f = new F(); + assert(f.m() == 2); + assert(f.n() == 10); + assert(f.g == 200); + assert(f.k() == 12); + assert(f.base == 10); + + assert((f).m() == 2); + assert((f).m() == 2); + assert(f instanceof B); + + const fn = f.m; + assert(fn() == 2); + + let o: F | null = f; + assert(o?.m() == 2); + + assert(new Box(7).get() == 7); + + print("done."); +} diff --git a/tslang/test/tester/final/final_ir_base.ts b/tslang/test/tester/final/final_ir_base.ts new file mode 100644 index 000000000..cfdfcaa3d --- /dev/null +++ b/tslang/test/tester/final/final_ir_base.ts @@ -0,0 +1,44 @@ +// A call through a non-final base type stays a vtable call, though the object is a @final F. +class B { + m(): int { + return 1; + } + + n(): int { + return 10; + } + + get g(): int { + return 100; + } +} + +interface I { + m(): int; +} + +@final +class F extends B implements I { + m(): int { + return 2; + } + + get g(): int { + return 200; + } + +} + +@final +class Box { + constructor(public v: T) {} + + get(): T { + return this.v; + } +} + +function main() { + const b: B = new F(); + print(b.m()); +} diff --git a/tslang/test/tester/final/final_ir_direct.ts b/tslang/test/tester/final/final_ir_direct.ts new file mode 100644 index 000000000..051f00577 --- /dev/null +++ b/tslang/test/tester/final/final_ir_direct.ts @@ -0,0 +1,55 @@ +// Only calls on @final types (F, Box), here and in F's own constructor and methods: none of +// them goes through the vtable. +class B { + m(): int { + return 1; + } + + n(): int { + return 10; + } + + get g(): int { + return 100; + } +} + +interface I { + m(): int; +} + +@final +class F extends B implements I { + base: int; + + constructor() { + super(); + this.base = this.n(); + } + + m(): int { + return 2; + } + + get g(): int { + return 200; + } + + k(): int { + return this.m() + this.n(); + } +} + +@final +class Box { + constructor(public v: T) {} + + get(): T { + return this.v; + } +} + +function main() { + const f = new F(); + print(f.m(), f.n(), f.g, f.k(), new Box(7).get()); +} From db39b0ef1beeb92cba24a52fb59af6d8fc5e294c Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 09:48:02 +0100 Subject: [PATCH 05/11] -mm=own: tests for calls on @final classes A @final class's method result is fresh and a field may be lent to it: both error without @final. No ownership pass needed a change. Co-Authored-By: Claude Opus 5.5 --- tslang/test/tester/CMakeLists.txt | 3 +- .../test/tester/own/own_final_field_lend.ts | 43 ++++++++++++ .../test/tester/own/own_final_fresh_result.ts | 68 +++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tslang/test/tester/own/own_final_field_lend.ts create mode 100644 tslang/test/tester/own/own_final_fresh_result.ts diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 6279467ba..3f60ce90d 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3352,7 +3352,8 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_string_copy_field own_string_copy_after_move own_string_copy_nullable own_string_copy_exported own_string_copy_written own_string_copy_mixed_return own_string_copy_branch own_array_param_push - own_declared_no_drops own_return_paths own_string_concat_element) + own_declared_no_drops own_return_paths own_string_concat_element + own_final_fresh_result own_final_field_lend) 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_final_field_lend.ts b/tslang/test/tester/own/own_final_field_lend.ts new file mode 100644 index 000000000..83ac8a1c4 --- /dev/null +++ b/tslang/test/tester/own/own_final_field_lend.ts @@ -0,0 +1,43 @@ +// -mm=own: a borrow of a field can be lent to a method of a @final class that destroys nothing: +// the call is direct, so its body's facts are known. Through the vtable another module's override +// might destroy anything (the default library's String wrapper lending `this.value` to RegExp). +@final +export class Re { + test(s: string): boolean { + return s.length > 2; + } +} + +export class W { + constructor(private value: string) {} + + has(re: Re): boolean { + return re.test(this.value); + } +} + +class Pad { + constructor(public s: string) {} +} + +function churn() { + const junk: Pad[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new Pad("junk" + i)); + } +} + +function main() { + const re = new Re(); + let t = 0; + for (let i = 0; i < 10000; i++) { + const w = new W(i % 2 == 0 ? "abc" + i : "a"); + churn(); + if (w.has(re)) { + t++; + } + } + + assert(t == 5000); + print("done."); +} diff --git a/tslang/test/tester/own/own_final_fresh_result.ts b/tslang/test/tester/own/own_final_fresh_result.ts new file mode 100644 index 000000000..0c2a2eb3c --- /dev/null +++ b/tslang/test/tester/own/own_final_fresh_result.ts @@ -0,0 +1,68 @@ +// -mm=own: a method of a @final class is called directly, so its result is fresh, as a direct +// call's is: it may be compared with null and returned, or widened into a union and returned. A +// virtual call's result is not (another module could override the method), which is what kept +// the default library's RegExp.exec and Date[Symbol.toPrimitive] from compiling under own. +export class Res { + constructor(public v: int) {} +} + +@final +export class Re { + exec(s: string): Res | null { + if (s.length > 3) { + return new Res(s.length); + } + + return null; + } +} + +@final +export class D { + t = 1; + + toString() { + return "x" + this.t; + } + + prim(h: string): string | number { + if (h === "number") { + return this.t; + } + + return this.toString(); + } +} + +export function find(re: Re, s: string): int { + const r = re.exec(s); + if (r == null) { + return 0; + } + + return r.v; +} + +function churn() { + const junk: Res[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new Res(-1)); + } +} + +function main() { + const re = new Re(); + const d = new D(); + let t = 0; + for (let i = 0; i < 10000; i++) { + t += find(re, i % 3 == 0 ? "abcd" : "ab"); + const p = d.prim("string"); + churn(); + if (typeof p == "string") { + t += p.length; + } + } + + assert(t == 3334 * 4 + 10000 * 2); + print("done."); +} From 75267708ceb9e8c569244aafdc7a0e81dc2f344e Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 09:50:53 +0100 Subject: [PATCH 06/11] @final classes: -shared import, compiled and dynamic An importer calls a @final class's methods directly and links (import library and the JIT's run-time DLL), and cannot extend it: __decls keeps @final. Co-Authored-By: Claude Opus 5.5 --- tslang/test/tester/CMakeLists.txt | 9 +++++++++ tslang/test/tester/tests/export_final_class.ts | 14 ++++++++++++++ tslang/test/tester/tests/import_final_class.ts | 14 ++++++++++++++ tslang/test/tester/tests/import_final_extends.ts | 8 ++++++++ 4 files changed, 45 insertions(+) create mode 100644 tslang/test/tester/tests/export_final_class.ts create mode 100644 tslang/test/tester/tests/import_final_class.ts create mode 100644 tslang/test/tester/tests/import_final_extends.ts diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 3f60ce90d..8aaedb6cb 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -1488,6 +1488,15 @@ tslang_add_test(NAME test-compile-shared-decl-emit-class COMMAND test-runner -sh # shared libs tests (exports/imports) tslang_add_test(NAME test-compile-shared-export-import-class-interface COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_interface.ts") +# a @final class across the boundary: direct calls from the importer, compiled and under the JIT +foreach(final_mm gc rc) + tslang_add_test(NAME test-compile-shared-export-import-final-class-${final_mm} COMMAND test-runner -shared -mm=${final_mm} "${PROJECT_SOURCE_DIR}/test/tester/tests/import_final_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts") + tslang_add_test(NAME test-jit-shared-export-import-final-class-${final_mm} COMMAND test-runner -jit -shared -mm=${final_mm} "${PROJECT_SOURCE_DIR}/test/tester/tests/import_final_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts") +endforeach() +add_test(NAME test-compile-shared-import-final-extends + COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_final_extends.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts") +set_tests_properties(test-compile-shared-import-final-extends PROPERTIES PASS_REGULAR_EXPRESSION "class 'X' cannot extend 'Counter': it is @final" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") tslang_add_test(NAME test-compile-shared-export-import-object-literal-with-class-types COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_with_class_types.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_with_class_types.ts") # Cross-module `class extends` across a real DLL boundary. Formerly disabled as a # known issue; fixed by three co-operating changes (see diff --git a/tslang/test/tester/tests/export_final_class.ts b/tslang/test/tester/tests/export_final_class.ts new file mode 100644 index 000000000..7c0fb6518 --- /dev/null +++ b/tslang/test/tester/tests/export_final_class.ts @@ -0,0 +1,14 @@ +// The library side of a @final class across a module boundary: the importer calls its methods and +// accessor directly, by name. See import_final_class.ts. +@final +export class Counter { + n = 0; + + inc(): int { + return ++this.n; + } + + get value(): int { + return this.n; + } +} diff --git a/tslang/test/tester/tests/import_final_class.ts b/tslang/test/tester/tests/import_final_class.ts new file mode 100644 index 000000000..4da279a9b --- /dev/null +++ b/tslang/test/tester/tests/import_final_class.ts @@ -0,0 +1,14 @@ +// A @final class from another module: its declaration (__decls) keeps @final, so the calls here are +// direct, and they link both against the import library and, under the JIT, to the DLL loaded at +// run time (where the class is a dynamic import and its calls stay vtable calls). +import './export_final_class' + +function main() { + const c = new Counter(); + for (let i = 0; i < 1000; i++) { + c.inc(); + } + + assert(c.value == 1000); + print("done."); +} diff --git a/tslang/test/tester/tests/import_final_extends.ts b/tslang/test/tester/tests/import_final_extends.ts new file mode 100644 index 000000000..a470b2ebb --- /dev/null +++ b/tslang/test/tester/tests/import_final_extends.ts @@ -0,0 +1,8 @@ +// A @final class from another module cannot be extended: its declaration (__decls) keeps @final. +import './export_final_class' + +class X extends Counter {} + +function main() { + print(new X().inc()); +} From 3bb8e7cf558ad9f49edeaeefe4ced419b8a3e698 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 09:53:37 +0100 Subject: [PATCH 07/11] Ownership verifier: skip import_final_extends.ts, an error by design Co-Authored-By: Claude Opus 5.5 --- tslang/test/tester/verify-ownership.cmake | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tslang/test/tester/verify-ownership.cmake b/tslang/test/tester/verify-ownership.cmake index 14e80aabb..6d0c02b72 100644 --- a/tslang/test/tester/verify-ownership.cmake +++ b/tslang/test/tester/verify-ownership.cmake @@ -43,7 +43,9 @@ set(not_compilable_alone export_cppvtable_class.ts import_cppvtable_class.ts export_cppvtable_hidden_return.ts - import_cppvtable_hidden_return.ts) + import_cppvtable_hidden_return.ts + # an error by design: it extends a @final class (test-compile-shared-import-final-extends) + import_final_extends.ts) file(GLOB corpus "${TESTS_DIR}/*.ts") list(SORT corpus) From 6c46d421cc60ed8c651398415c1fdd9cb8934919 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 10:07:00 +0100 Subject: [PATCH 08/11] -mm=own: a cast to null takes nothing With --di, the variable that if (r == null) narrows is recorded as a cast of the class value to null, which holds no block; it was read as a second reference to a fresh call result. Found building the default library's String.match with debug info. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 6 ++-- tslang/test/tester/CMakeLists.txt | 9 +++++- .../test/tester/own/own_null_narrowing_di.ts | 30 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 tslang/test/tester/own/own_null_narrowing_di.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index c111ad1e3..4a4d69b87 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -3804,10 +3804,12 @@ class OwnershipInferencePass return true; } + // ...and so does a cast to `null` or `undefined`, which holds no block: `--di` records the + // `r` that `if (r == null)` narrows as one if (auto castOp = mlir::dyn_cast(user)) { - return mlir::isa(castOp.getType()) || castOp.getType().isInteger(1) || - isBoundForCall(castOp); + return mlir::isa(castOp.getType()) || + castOp.getType().isInteger(1) || isBoundForCall(castOp); } if (mlir::isa(user)) diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 8aaedb6cb..bffce060e 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3362,7 +3362,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_string_copy_exported own_string_copy_written own_string_copy_mixed_return own_string_copy_branch own_array_param_push own_declared_no_drops own_return_paths own_string_concat_element - own_final_fresh_result own_final_field_lend) + own_final_fresh_result own_final_field_lend own_null_narrowing_di) 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. @@ -3374,6 +3374,13 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o FAIL_REGULAR_EXPRESSION "__tslang_inc_ref|__tslang_dec_ref|error:|Stack dump") endforeach() +# ...and built with debug info, whose records of narrowed variables are ops of their own +add_test(NAME test-own-di-null-narrowing + COMMAND $ --emit=mlir-affine --di --opt_level=0 --no-default-lib -mm=own + "${PROJECT_SOURCE_DIR}/test/tester/own/own_null_narrowing_di.ts") +set_tests_properties(test-own-di-null-narrowing PROPERTIES PASS_REGULAR_EXPRESSION "ts.Func" + FAIL_REGULAR_EXPRESSION "error|Stack dump") + # A global declared in top-level code owns what it is given, under every model that counts. foreach(global_mm rc none gc) tslang_add_test(NAME test-jit-${global_mm}-own-global-script COMMAND test-runner -jit -mm=${global_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_global_script.ts") diff --git a/tslang/test/tester/own/own_null_narrowing_di.ts b/tslang/test/tester/own/own_null_narrowing_di.ts new file mode 100644 index 000000000..329413405 --- /dev/null +++ b/tslang/test/tester/own/own_null_narrowing_di.ts @@ -0,0 +1,30 @@ +// @strict-null false +// -mm=own with debug info: in `if (r == null)` the narrowed `r` is recorded for the debugger as a +// cast of the class value to `null`, which holds no block and takes nothing. It was read as a +// second reference to the call's fresh result (the default library's String.match, built --di). +export class R { + constructor(public v: int) {} +} + +function mk(i: int): R | null { + return i % 2 == 0 ? new R(i) : null; +} + +export function f(i: int): int { + const r = mk(i); + if (r == null) { + return 0; + } + + return r.v; +} + +function main() { + let t = 0; + for (let i = 0; i < 10; i++) { + t += f(i); + } + + assert(t == 0 + 2 + 4 + 6 + 8); + print("done."); +} From 5549a9fae7bf44def6054153cbf2744dbcaed5bd Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 10:16:50 +0100 Subject: [PATCH 09/11] A generator method of an exported class is exported The generator wrapper declaration dropped the DllExport/DllImport/IsPublic flags the class gave the method, so a public generator method was private. Through the vtable nobody noticed; a direct call on a @final class (the default library's String[Symbol.iterator] under the JIT) found no symbol. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/MLIRGenFunctions.cpp | 6 ++++++ tslang/test/tester/CMakeLists.txt | 7 +++++++ tslang/test/tester/tests/export_final_class.ts | 7 +++++++ tslang/test/tester/tests/import_final_class.ts | 7 +++++++ 4 files changed, 27 insertions(+) diff --git a/tslang/lib/TypeScript/MLIRGenFunctions.cpp b/tslang/lib/TypeScript/MLIRGenFunctions.cpp index 71b2734b3..76a7e9154 100644 --- a/tslang/lib/TypeScript/MLIRGenFunctions.cpp +++ b/tslang/lib/TypeScript/MLIRGenFunctions.cpp @@ -1009,6 +1009,12 @@ namespace mlirgen // to ensure correct full name methodOp->parent = functionLikeDeclarationBaseAST->parent; + // what the class gave the method (mlirGenClassMethodMember): exported, imported, public. + // The wrapper is the method's function; without them a public generator method of an + // exported class was private, and an importer calling it by name found no symbol. + methodOp->internalFlags |= functionLikeDeclarationBaseAST->internalFlags & + (InternalFlags::DllExport | InternalFlags::DllImport | InternalFlags::IsPublic); + LLVM_DEBUG(printDebug(methodOp);); return methodOp; diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index bffce060e..2438edf11 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -1493,6 +1493,13 @@ foreach(final_mm gc rc) tslang_add_test(NAME test-compile-shared-export-import-final-class-${final_mm} COMMAND test-runner -shared -mm=${final_mm} "${PROJECT_SOURCE_DIR}/test/tester/tests/import_final_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts") tslang_add_test(NAME test-jit-shared-export-import-final-class-${final_mm} COMMAND test-runner -jit -shared -mm=${final_mm} "${PROJECT_SOURCE_DIR}/test/tester/tests/import_final_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts") endforeach() +# A generator method of an exported class is exported like any other method: an importer that +# calls it directly (a @final class, the default library's String[Symbol.iterator]) links by name +add_test(NAME test-export-generator-method + COMMAND $ --emit=llvm --no-default-lib + "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts" -o -) +set_tests_properties(test-export-generator-method PROPERTIES PASS_REGULAR_EXPRESSION "define dllexport ptr @Counter.upTo" + FAIL_REGULAR_EXPRESSION "error|Stack dump") add_test(NAME test-compile-shared-import-final-extends COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_final_extends.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts") set_tests_properties(test-compile-shared-import-final-extends PROPERTIES PASS_REGULAR_EXPRESSION "class 'X' cannot extend 'Counter': it is @final" diff --git a/tslang/test/tester/tests/export_final_class.ts b/tslang/test/tester/tests/export_final_class.ts index 7c0fb6518..302ea627d 100644 --- a/tslang/test/tester/tests/export_final_class.ts +++ b/tslang/test/tester/tests/export_final_class.ts @@ -11,4 +11,11 @@ export class Counter { get value(): int { return this.n; } + + // a generator method is exported as any other is: the importer calls it by name + *upTo(k: int) { + for (let i = 1; i <= k; i++) { + yield i; + } + } } diff --git a/tslang/test/tester/tests/import_final_class.ts b/tslang/test/tester/tests/import_final_class.ts index 4da279a9b..8d01070e9 100644 --- a/tslang/test/tester/tests/import_final_class.ts +++ b/tslang/test/tester/tests/import_final_class.ts @@ -10,5 +10,12 @@ function main() { } assert(c.value == 1000); + + let sum = 0; + for (const v of c.upTo(4)) { + sum += v; + } + + assert(sum == 10); print("done."); } From 6040c31f46de63c22bde5e3b56ec94faf902c032 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 10:46:56 +0100 Subject: [PATCH 10/11] @final classes: direct calls only into this module; no other class poses as one From the final review: a static importer of a @final class whose base its library does not export failed to link (the inherited member has no symbol in the import library), so a member is called directly only when its class is defined in this module. And a class that fits a @final class member by member is no longer assignable to it: the direct call would run the @final class's body on the other class's fields. Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-10-19-final-classes-design.md | 5 +++++ tslang/lib/TypeScript/MLIRGenAccessCall.cpp | 7 ++++--- tslang/lib/TypeScript/MLIRGenCast.cpp | 8 ++++++++ tslang/lib/TypeScript/MLIRGenImpl.h | 10 ++++++---- tslang/test/tester/CMakeLists.txt | 12 ++++++++++++ tslang/test/tester/class-cast/to_final.ts | 19 +++++++++++++++++++ tslang/test/tester/tests/export_final_base.ts | 19 +++++++++++++++++++ .../tester/tests/import_final_base_static.ts | 13 +++++++++++++ .../test/tester/tests/import_final_class.ts | 7 ++++--- 9 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 tslang/test/tester/class-cast/to_final.ts create mode 100644 tslang/test/tester/tests/export_final_base.ts create mode 100644 tslang/test/tester/tests/import_final_base_static.ts diff --git a/tslang/docs/superpowers/specs/2026-10-19-final-classes-design.md b/tslang/docs/superpowers/specs/2026-10-19-final-classes-design.md index b258700ad..63f46c019 100644 --- a/tslang/docs/superpowers/specs/2026-10-19-final-classes-design.md +++ b/tslang/docs/superpowers/specs/2026-10-19-final-classes-design.md @@ -111,6 +111,11 @@ methods and accessors of exported classes are already in the import library, so calls link, compiled or JIT. Under rc, `OwnedReturnConsumptionPass` treats a direct call to a `lib.d.ts` declaration as it treats a virtual one. +**Amended after the final review (2026-10-19).** + +- **Direct calls only into this module.** A member is called directly only when the class that has it is defined in this module, not declared there (`isDeclaration`, `isImport`, `isDynamicImport`). A `@static import` links by name against the import library, which has no symbol for a member of a base the library does not export, so inheriting such a member broke the link. A user program's calls into the default library's `@final` classes therefore stay vtable calls. That costs nothing the goals need: own facts do not cross a static link anyway. The extends error still applies to declared classes. +- **No other class can pose as a `@final` one.** An unrelated class that fits a `@final` class member by member is not assignable to it (`type Y is not assignable to type F: 'F' is @final`), because the direct call would run F's body on Y's fields. + ### Own No change in the ownership passes is planned. A `ThisSymbolRef` call is what `resolveValue` diff --git a/tslang/lib/TypeScript/MLIRGenAccessCall.cpp b/tslang/lib/TypeScript/MLIRGenAccessCall.cpp index 80a39b162..d92ff016e 100644 --- a/tslang/lib/TypeScript/MLIRGenAccessCall.cpp +++ b/tslang/lib/TypeScript/MLIRGenAccessCall.cpp @@ -669,9 +669,10 @@ namespace mlirgen auto getMethodInfo = findMethodInfo(getFunc); auto setMethodInfo = findMethodInfo(setFunc); - // a @final receiver's accessor is the one this class has, called directly, as a method - // is (ClassMethodAccess) - auto direct = finalReceiver && !classInfo->isDynamicImport && !classInfo->isCppVTable && + // a @final receiver's accessor is the one this class has, called directly when the class + // is defined in this module, as a method is (ClassMethodAccess) + auto direct = finalReceiver && !classInfo->isDeclaration && !classInfo->isImport && + !classInfo->isDynamicImport && !classInfo->isCppVTable && !(getMethodInfo && getMethodInfo->isAbstract) && !(setMethodInfo && setMethodInfo->isAbstract); auto isVirtualAccessor = !direct && !isStorageType && ((getMethodInfo && (getMethodInfo->isAbstract || getMethodInfo->isVirtual)) || diff --git a/tslang/lib/TypeScript/MLIRGenCast.cpp b/tslang/lib/TypeScript/MLIRGenCast.cpp index aaa89b924..bab72652b 100644 --- a/tslang/lib/TypeScript/MLIRGenCast.cpp +++ b/tslang/lib/TypeScript/MLIRGenCast.cpp @@ -1949,6 +1949,14 @@ namespace mlirgen return false; } + // only an F is an F: a call on a @final class is a direct call to its own method, which + // would run on the other class's fields + if (classInfo->isFinal) + { + mismatch = "'" + classInfo->name.str() + "' is @" FINAL_DECORATOR; + return true; + } + // the data fields, inherited ones included; not the internal ones (.vtbl) nor the storage // a derived class embeds for its base std::function &)> collectFields = diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 757f5ae7f..e47d0deba 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -8047,10 +8047,12 @@ class MLIRGenImpl // TODO: check if you can split calls such as "this.method" and "super.method" ... auto isStorageType = isa(thisValue.getType()); // A @final receiver's method is the one this class has (ClassMembersAccess): the - // direct reference below, which the vtable would give anyway. Not for a class that - // lives in a DLL loaded at run time, whose method has no symbol to link against, nor a - // C++ binding, called through the C++ vtable. - auto direct = finalReceiver && !methodInfo.isAbstract && !classInfo->isDynamicImport && !classInfo->isCppVTable; + // direct reference below, which the vtable would give anyway. Only for a method of a + // class defined in this module: one declared here (an import, lib.d.ts) may have no + // symbol to link against - a base its library does not export, a DLL loaded at run + // time - and a C++ binding is called through the C++ vtable. + auto direct = finalReceiver && !methodInfo.isAbstract && !classInfo->isDeclaration && !classInfo->isImport && + !classInfo->isDynamicImport && !classInfo->isCppVTable; if (!direct && (methodInfo.isAbstract || /*!baseClass &&*/ methodInfo.isVirtual && !isStorageType)) { LLVM_DEBUG(dbgs() << "\n!! Virtual call: func '" << funcName << "'\n";); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 2438edf11..a0251c94b 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -1500,6 +1500,9 @@ add_test(NAME test-export-generator-method "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts" -o -) set_tests_properties(test-export-generator-method PROPERTIES PASS_REGULAR_EXPRESSION "define dllexport ptr @Counter.upTo" FAIL_REGULAR_EXPRESSION "error|Stack dump") +# ...and linked against its import library (`@static import`), with members from a base its +# library does not export +tslang_add_test(NAME test-compile-time-shared-final-base COMMAND test-runner -shared -compile-time "${PROJECT_SOURCE_DIR}/test/tester/tests/import_final_base_static.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_base.ts") add_test(NAME test-compile-shared-import-final-extends COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_final_extends.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts") set_tests_properties(test-compile-shared-import-final-extends PROPERTIES PASS_REGULAR_EXPRESSION "class 'X' cannot extend 'Counter': it is @final" @@ -3917,6 +3920,15 @@ set_tests_properties(test-compile-class-cast-unrelated-error PROPERTIES PASS_REGULAR_EXPRESSION "type A is not assignable to type B: 'v' is number, not string" FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +# ...and so is any other class cast to a @final one, whose calls are direct +add_test(NAME test-compile-class-cast-to-final-error + COMMAND $ --emit=obj --no-default-lib -mm=none + "${PROJECT_SOURCE_DIR}/test/tester/class-cast/to_final.ts" + -o "${CMAKE_CURRENT_BINARY_DIR}/class-cast-to-final.obj") +set_tests_properties(test-compile-class-cast-to-final-error + PROPERTIES PASS_REGULAR_EXPRESSION "type Y is not assignable to type F: 'F' is @final" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") + # An object literal with methods or accessors assigned to a class is an error: a class instance # takes its methods from its class. The cast reinterpreted the pointer, so the class read garbage # fields and crashed on a method call (#494). diff --git a/tslang/test/tester/class-cast/to_final.ts b/tslang/test/tester/class-cast/to_final.ts new file mode 100644 index 000000000..a5af8113a --- /dev/null +++ b/tslang/test/tester/class-cast/to_final.ts @@ -0,0 +1,19 @@ +// Y fits F member by member, but F is @final: a call on an F is a direct call to F's method, so a +// Y passed off as an F would run F's body on a Y's fields. +class Y { + m(): int { + return 7; + } +} + +@final +class F { + m(): int { + return 2; + } +} + +function main() { + const f: F = new Y(); + print(f.m()); +} diff --git a/tslang/test/tester/tests/export_final_base.ts b/tslang/test/tester/tests/export_final_base.ts new file mode 100644 index 000000000..129923744 --- /dev/null +++ b/tslang/test/tester/tests/export_final_base.ts @@ -0,0 +1,19 @@ +// A @final exported class whose base is not exported: its importer (import_final_base_static.ts) +// links against the import library, which has no symbols for Base's members, so a call there of +// a member K inherits goes through the vtable. +class Base { + bm(): int { + return 1; + } + + get s2(): int { + return 11; + } +} + +@final +export class K extends Base { + km(): int { + return 2; + } +} diff --git a/tslang/test/tester/tests/import_final_base_static.ts b/tslang/test/tester/tests/import_final_base_static.ts new file mode 100644 index 000000000..d4e2fbbf6 --- /dev/null +++ b/tslang/test/tester/tests/import_final_base_static.ts @@ -0,0 +1,13 @@ +// A static import of a @final class (export_final_base.ts): the importer links against the +// import library, by name. A member K inherits from a base its library does not export has no +// symbol there, so only a member of a class defined in this module is called directly. +@static +import './export_final_base' + +function main() { + const k = new K(); + assert(k.km() == 2); + assert(k.bm() == 1); + assert(k.s2 == 11); + print("done."); +} diff --git a/tslang/test/tester/tests/import_final_class.ts b/tslang/test/tester/tests/import_final_class.ts index 8d01070e9..ab7c37dcb 100644 --- a/tslang/test/tester/tests/import_final_class.ts +++ b/tslang/test/tester/tests/import_final_class.ts @@ -1,6 +1,7 @@ -// A @final class from another module: its declaration (__decls) keeps @final, so the calls here are -// direct, and they link both against the import library and, under the JIT, to the DLL loaded at -// run time (where the class is a dynamic import and its calls stay vtable calls). +// A @final class from another module, imported from its DLL (test-runner -shared builds the DLL +// first, so the import is dynamic): its declaration (__decls) keeps @final, and its calls stay +// vtable calls, as every call into a class declared here does. The static import is +// import_final_base_static.ts. import './export_final_class' function main() { From b120d6ae8f3fa3e85b84785fb35a74c53219d872 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 11:27:03 +0100 Subject: [PATCH 11/11] test-export-generator-method: Windows only It checks the IR for dllexport, which ELF does not have; the Linux CI job failed on it. Co-Authored-By: Claude Opus 5.5 --- tslang/test/tester/CMakeLists.txt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index a0251c94b..175c8f916 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -1494,12 +1494,15 @@ foreach(final_mm gc rc) tslang_add_test(NAME test-jit-shared-export-import-final-class-${final_mm} COMMAND test-runner -jit -shared -mm=${final_mm} "${PROJECT_SOURCE_DIR}/test/tester/tests/import_final_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts") endforeach() # A generator method of an exported class is exported like any other method: an importer that -# calls it directly (a @final class, the default library's String[Symbol.iterator]) links by name -add_test(NAME test-export-generator-method - COMMAND $ --emit=llvm --no-default-lib - "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts" -o -) -set_tests_properties(test-export-generator-method PROPERTIES PASS_REGULAR_EXPRESSION "define dllexport ptr @Counter.upTo" - FAIL_REGULAR_EXPRESSION "error|Stack dump") +# calls it by name (the default library's String[Symbol.iterator] under the JIT) finds it. Checked +# on Windows, where an exported function is `dllexport` in the IR; ELF has no such mark. +if (WIN32) + add_test(NAME test-export-generator-method + COMMAND $ --emit=llvm --no-default-lib + "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_class.ts" -o -) + set_tests_properties(test-export-generator-method PROPERTIES PASS_REGULAR_EXPRESSION "define dllexport ptr @Counter.upTo" + FAIL_REGULAR_EXPRESSION "error|Stack dump") +endif() # ...and linked against its import library (`@static import`), with members from a base its # library does not export tslang_add_test(NAME test-compile-time-shared-final-base COMMAND test-runner -shared -compile-time "${PROJECT_SOURCE_DIR}/test/tester/tests/import_final_base_static.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_final_base.ts")