diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 950ce7796..85141d1ba 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -380,7 +380,7 @@ jobs:
- name: Check version sync
run: |
VERSION="${{ needs.check-release.outputs.version }}"
- for pkg in packages/runtime packages/runtime-* packages/llvm-* packages/compiler packages/threads packages/cli packages/cli-*; do
+ for pkg in packages/runtime packages/runtime-* packages/llvm-* packages/compiler packages/threads packages/types packages/cli packages/cli-*; do
if [ "$(node -p "require('./$pkg/package.json').private === true")" = "true" ]; then
continue
fi
@@ -478,6 +478,7 @@ jobs:
for pkg in packages/cli-*; do publish_dir "$pkg"; done
publish_dir packages/compiler
publish_dir packages/threads
+ publish_dir packages/types
publish_dir packages/cli
echo "All package uploads accepted; npm registry propagation may continue after this job." >> "$GITHUB_STEP_SUMMARY"
diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx
index eb5277533..64abc94a8 100644
--- a/docs/content/docs/cli.mdx
+++ b/docs/content/docs/cli.mdx
@@ -54,7 +54,7 @@ fib.s
Different output kinds accumulate in `.scriptc/`; rebuilding a kind updates its file.
-The IR and LLVM source outputs require only Node. On supported macOS, Linux, and Windows hosts, assembly and object outputs use the matching native helper installed with scriptc and do not invoke an external compiler, archiver, linker, or SDK. The object is a relocatable program object with undefined scr_* runtime symbols and a required scr_runtime_abi_v12 marker, not a standalone library. External consumption is experimental and requires the exact runtime version reported by --print=native-link-info. That option still writes the object, performs no link, and prints a versioned JSON recipe with the target, main entry, installed precompiled runtime pack, FFI inputs, and system libraries. It never reports private scriptc cache paths. See Native Program Objects for complete C-driver and direct-linker examples. --emit=exe is the default. LLVM builds emit the program object through the helper and link precompiled runtime objects; runtime development with AddressSanitizer compiles an instrumented C runtime.
+The IR and LLVM source outputs require only Node. On supported macOS, Linux, and Windows hosts, assembly and object outputs use the matching native helper installed with scriptc and do not invoke an external compiler, archiver, linker, or SDK. The object is a relocatable program object with undefined scr_* runtime symbols and a required scr_runtime_abi_v13 marker, not a standalone library. External consumption is experimental and requires the exact runtime version reported by --print=native-link-info. That option still writes the object, performs no link, and prints a versioned JSON recipe with the target, main entry, installed precompiled runtime pack, FFI inputs, and system libraries. It never reports private scriptc cache paths. See Native Program Objects for complete C-driver and direct-linker examples. --emit=exe is the default. LLVM builds emit the program object through the helper and link precompiled runtime objects; runtime development with AddressSanitizer compiles an instrumented C runtime.
## scriptc run
diff --git a/docs/content/docs/contracts.mdx b/docs/content/docs/contracts.mdx
new file mode 100644
index 000000000..bb91e341e
--- /dev/null
+++ b/docs/content/docs/contracts.mdx
@@ -0,0 +1,242 @@
+---
+title: "Contract Types"
+---
+
+Contract types are type aliases that mean nothing to TypeScript or Node.js but are promises scriptc checks. You import them from `@scriptc/types` with `import type`, which TypeScript erases, so Node.js never loads the package and runs the program exactly as without the annotations. scriptc proves each contract or rejects the program with a diagnostic that explains why the contract does not hold. A program that compiles behaves exactly as under Node.js; the contract only adds an optimization that scriptc can now rely on.
+
+
+
+
+ Contract
+ Promise
+ What scriptc does with it
+
+
+
+
+ NoThrow<T>
+ The function never throws.
+ Callers need no exception check after the call.
+
+
+ Int32
+ Every value stored is a signed 32-bit integer by construction.
+ The variable, field, parameter or result is stored as a 32-bit integer.
+
+
+ Immutable<T>
+ No code writes to the value, through any reference.
+ A module constant built from literals becomes immortal data: never reference counted, never traced by the collector.
+
+
+
+
+Add `@scriptc/types` to your project's development dependencies. It contains only type declarations.
+
+## Example
+
+```ts:main.ts
+import type { Immutable, Int32, NoThrow } from "@scriptc/types";
+
+const KEYWORDS: Immutable> = new Map([
+ ["const", 1],
+ ["let", 2],
+ ["function", 3],
+]);
+
+class Token {
+ pos: Int32;
+ end: Int32;
+ kind: number;
+ constructor(pos: Int32, end: Int32, kind: number) {
+ this.pos = pos;
+ this.end = end;
+ this.kind = kind;
+ }
+}
+
+function isLetter(ch: number): NoThrow {
+ return (ch >= 97 && ch <= 122) || (ch >= 65 && ch <= 90);
+}
+
+function scan(text: string): Token[] {
+ const tokens: Token[] = [];
+ let pos: Int32 = 0;
+ while (pos < text.length) {
+ if (!isLetter(text.charCodeAt(pos))) {
+ pos = (pos + 1) | 0;
+ continue;
+ }
+ let end: Int32 = pos;
+ while (end < text.length && isLetter(text.charCodeAt(end))) end = (end + 1) | 0;
+ tokens.push(new Token(pos, end, KEYWORDS.get(text.slice(pos, end)) ?? 0));
+ pos = end;
+ }
+ return tokens;
+}
+
+for (const t of scan("const answer = 42; function f() {}")) console.log(t.pos, t.end, t.kind);
+```
+
+Node.js 24 runs the program directly:
+
+```console
+$ node main.ts
+0 5 1
+6 12 0
+19 27 3
+28 29 0
+```
+
+The scriptc executable prints the same output. `KEYWORDS` is immortal, `Token.pos`, `Token.end` and the scanner's positions are 32-bit integers, and the calls to `isLetter` carry no exception checks:
+
+```console
+$ scriptc build main.ts -o scan >/dev/null
+$ ./scan
+0 5 1
+6 12 0
+19 27 3
+28 29 0
+```
+
+## Where contracts apply
+
+scriptc recognizes a contract where it is written as the annotation of a declaration, and only when the name is imported from `@scriptc/types` (a named import, possibly renamed, or a namespace import such as `sc.Int32`). TypeScript does not keep alias names in inferred types, so a contract never spreads by inference: write it where the value is declared.
+
+
+ NoThrow<T>
+ The written return type of a function, method or arrow function. NoThrow<Int32> and NoThrow<Immutable<T>> combine the result contracts.
+ Int32
+ Parameters, variables, class instance fields and function results whose type is a plain number.
+ Immutable<T>
+ Parameters, variables, class instance fields and function results.
+
+
+A contract anywhere else (a type alias, an interface member, an element or type argument such as `Int32[]`, a function type) would promise something scriptc does not check, so the build reports it with SC7006.
+
+## NoThrow
+
+A `NoThrow` function must not contain a path to a throwing operation: a `throw`, a call to a function that may throw, a call through a function value that may hold such a function, an operation that throws in Node.js (such as reading a property of `undefined`), or recursion, whose stack guard can throw `RangeError`. scriptc already computes this for every function to omit exception checks; the contract turns the result into a guarantee and reports the first throwing path when it does not hold.
+
+In this version a surrounding `try`/`catch` does not discharge the contract, and `async` functions and generators cannot be `NoThrow`.
+
+## Int32
+
+Each value stored into an `Int32` slot (initializers, assignments, compound assignments, `++` and `--`, arguments and returns) must be an int32 in Node.js semantics, so storing it as a 32-bit integer cannot change it:
+
+- an integer literal in range;
+- the result of `|`, `&`, `^`, `~`, `<<`, `>>` or `Math.imul`;
+- another `Int32` value;
+- arithmetic whose range scriptc can bound, such as `i + 1` under a loop condition `i < text.length`.
+
+`x >>> 0` is a uint32 and is not accepted. Anything else needs an explicit `| 0`, which makes Node.js compute the same 32-bit value. Reads keep JavaScript semantics: `a + b` of two `Int32` values is an ordinary number that may exceed the int32 range.
+
+scriptc refuses an `Int32` slot whose writes it cannot all see (SC7003): a parameter of a function used as a value, or a field of a class whose instances reach `any` or dynamic code.
+
+## Immutable
+
+`Immutable` is a deep read-only view, so TypeScript already rejects direct writes such as `KEYWORDS.set(...)`. scriptc also checks the writes TypeScript cannot see:
+
+- a method that writes `this`, called on the value;
+- a function that writes a parameter, given the value;
+- a cast such as `value as number[]`, an `any` alias, `Object.assign`, or index-signature writes;
+- a value stored into an `Immutable` slot that another reference can still change.
+
+A value stored into an `Immutable` slot must be fresh: a literal, a `new` expression whose constructor keeps `this` to itself, or a function that returns what it builds. Values that reach dynamic code, or functions scriptc cannot see, are refused.
+
+## Diagnostics
+
+Each failed contract names the declaration and the reason:
+
+```ts:reject.ts
+import type { Immutable, Int32, NoThrow } from "@scriptc/types";
+
+const LIMITS: Immutable = [10, 20];
+
+function widen(limits: number[]): void {
+ limits.push(30);
+}
+
+function parseDigit(ch: number): number {
+ if (ch < 48 || ch > 57) throw new RangeError("not a digit");
+ return ch - 48;
+}
+
+function digitAt(text: string, i: number): NoThrow {
+ return parseDigit(text.charCodeAt(i));
+}
+
+function middle(start: Int32, end: Int32): Int32 {
+ return (start + end) / 2;
+}
+
+widen(LIMITS as number[]);
+console.log(digitAt("7", 0), middle(2, 5), LIMITS.length);
+```
+
+```console
+$ scriptc build reject.ts
+/tmp/app/reject.ts:6:3 - error SC7004: 'limits.push(30)' writes to a value that may be Immutable ('limits' may be an Immutable value passed to 'widen' at reject.ts:22:1)
+
+ 5 | function widen(limits: number[]): void {
+ 6 | limits.push(30);
+ | ^~~~~~~~~~~~~~~
+ 7 | }
+
+ hint: Immutable values cannot be written through any reference; copy the value before changing it
+
+/tmp/app/reject.ts:14:44 - error SC7001: 'digitAt' is declared NoThrow but may throw: calls 'parseDigit' at reject.ts:15:10 → throw statement at reject.ts:10:27
+
+ 13 |
+ 14 | function digitAt(text: string, i: number): NoThrow {
+ | ^~~~~~~~~~~~~~~
+ 15 | return parseDigit(text.charCodeAt(i));
+
+ hint: remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version
+
+/tmp/app/reject.ts:19:10 - error SC7002: the value returned from 'middle' (Int32) is not provably an int32: '(start + end) / 2'
+
+ 18 | function middle(start: Int32, end: Int32): Int32 {
+ 19 | return (start + end) / 2;
+ | ^~~~~~~~~~~~~~~~~
+ 20 | }
+
+ hint: make the int32 conversion explicit, for example '((start + end) / 2) | 0', so Node computes the same value
+
+3 errors.
+```
+
+
+
+
+ Code
+ Meaning
+
+
+
+
+ SC7001
+ A NoThrow function may throw; the message shows the path to the throwing operation.
+
+
+ SC7002
+ A value stored into an Int32 slot is not provably an int32; the hint shows the | 0 form.
+
+
+ SC7003
+ An Int32 slot has writes scriptc cannot see.
+
+
+ SC7004
+ A write can reach an Immutable value, or a stored value is not fresh.
+
+
+ SC7005
+ An Immutable value escapes to code scriptc cannot check.
+
+
+ SC7006
+ A contract is written where scriptc does not check it.
+
+
+
diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx
index f0d5efabb..5fcd664a8 100644
--- a/docs/content/docs/how-it-works.mdx
+++ b/docs/content/docs/how-it-works.mdx
@@ -68,7 +68,7 @@ Without `-o`, artifacts are written to `.scriptc/` next to the input. Different
### External linking
-Program objects define `main` and leave selected `scr_*` runtime functions undefined. A strong reference to `scr_runtime_abi_v12` checks runtime compatibility at link time.
+Program objects define `main` and leave selected `scr_*` runtime functions undefined. A strong reference to `scr_runtime_abi_v13` checks runtime compatibility at link time.
`--print=native-link-info` emits the program object and a JSON recipe containing the runtime pack and link order for external builds. This object ABI is experimental and requires an exact runtime version. See [Native Program Objects](/docs/native-objects) for examples.
diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx
index cf354b971..c2860ee27 100644
--- a/docs/content/docs/limitations.mdx
+++ b/docs/content/docs/limitations.mdx
@@ -128,6 +128,10 @@ scriptc checks programs against the `es2025` standard library and its own ambien
A program that passes its project's TypeScript check may still use unsupported operations. A second preflight using the project's declarations distinguishes those sites from ordinary type errors and reports scriptc diagnostics with rewrite hints. Declared APIs without a compiler implementation, such as unsupported console members, produce `SC2020`.
+### Contract types
+
+[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result; aliases, interface members and nested type positions are reported. A surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances reach dynamic code, and `Immutable` refuses values that reach dynamic code or that another reference can still change.
+
### three.js
With `--npm-static=three`, native and WASI builds support published package imports and tested CPU workflows for math, bounds, procedural geometry, curves, shapes, extrusion, text geometry, scene graphs, cameras, lights, materials, textures, and render-target data. Mesh workflows include instancing, batching, morph targets, skeletal deformation, LOD, and sprites. Raycasting includes mesh intersections, sorted results, recursive traversal, layers, distance limits, camera rays, lines, and points.
diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json
index e756322c1..a92346155 100644
--- a/docs/content/docs/meta.json
+++ b/docs/content/docs/meta.json
@@ -10,6 +10,7 @@
"ffi",
"native-objects",
"threads",
+ "contracts",
"wasm",
"platforms",
"---Reference---",
diff --git a/docs/content/docs/native-objects.mdx b/docs/content/docs/native-objects.mdx
index 609065160..caa5baefd 100644
--- a/docs/content/docs/native-objects.mdx
+++ b/docs/content/docs/native-objects.mdx
@@ -8,7 +8,7 @@ title: "Native Program Objects"
The external object ABI is **experimental**. Its `scr_*` function and data surface may change before 1.0, so consumers must use the exact runtime-pack version reported by the same compiler installation. This is stricter than semver compatibility.
-The object intentionally leaves its selected runtime symbols undefined. It also holds a strong reference to `scr_runtime_abi_v12`, which the matching runtime defines. Linking an object against a runtime with another ABI marker fails at link time with the missing versioned symbol; it cannot become a latent runtime incompatibility.
+The object intentionally leaves its selected runtime symbols undefined. It also holds a strong reference to `scr_runtime_abi_v13`, which the matching runtime defines. Linking an object against a runtime with another ABI marker fails at link time with the missing versioned symbol; it cannot become a latent runtime incompatibility.
## Machine-readable link information
diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts
index 3f36f89b2..1b240d4d3 100644
--- a/docs/src/lib/page-titles.ts
+++ b/docs/src/lib/page-titles.ts
@@ -8,6 +8,7 @@ export const PAGE_TITLES: Record = {
ffi: "Native FFI",
"native-objects": "Native Program Objects",
threads: "Sharing Data Between Workers",
+ contracts: "Contract Types",
wasm: "WebAssembly Modules",
platforms: "Platform Support",
compatibility: "Node.js 24 Compatibility",
diff --git a/docs/test/fixtures/migration-baseline.json b/docs/test/fixtures/migration-baseline.json
index dfac93ebe..f938545d4 100644
--- a/docs/test/fixtures/migration-baseline.json
+++ b/docs/test/fixtures/migration-baseline.json
@@ -227,6 +227,36 @@
}
]
},
+ {
+ "href": "/docs/contracts",
+ "title": "Contract Types",
+ "headings": [
+ {
+ "text": "Example",
+ "id": "example"
+ },
+ {
+ "text": "Where contracts apply",
+ "id": "where-contracts-apply"
+ },
+ {
+ "text": "NoThrow",
+ "id": "nothrow"
+ },
+ {
+ "text": "Int32",
+ "id": "int32"
+ },
+ {
+ "text": "Immutable",
+ "id": "immutable"
+ },
+ {
+ "text": "Diagnostics",
+ "id": "diagnostics"
+ }
+ ]
+ },
{
"legacy": "/wasm",
"href": "/docs/wasm",
diff --git a/examples/native-object/README.md b/examples/native-object/README.md
index 486612a0e..3086e995c 100644
--- a/examples/native-object/README.md
+++ b/examples/native-object/README.md
@@ -15,4 +15,4 @@ $ ./app-ld
`cc` uses the C compiler as a linker driver. `ld` links the same precompiled runtime objects directly with the selected Apple SDK. The object defines `main`; it is a complete program object, not a library to load into another process. Use `scriptc build --lib --profile ...` for a host-callable static library.
-The external object ABI is experimental. Always consume the runtime pack at the exact `runtime_pack.version` reported by the same scriptc installation. The object requires `scr_runtime_abi_v12`, so a mismatched runtime fails during the link instead of starting with an incompatible ABI.
+The external object ABI is experimental. Always consume the runtime pack at the exact `runtime_pack.version` reported by the same scriptc installation. The object requires `scr_runtime_abi_v13`, so a mismatched runtime fails during the link instead of starting with an incompatible ABI.
diff --git a/package.json b/package.json
index 885d6f4e6..ddd89df37 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
},
"devDependencies": {
"@scriptc/threads": "workspace:*",
+ "@scriptc/types": "workspace:*",
"@types/node": "^24.0.0",
"effect": "3.22.2",
"js-yaml": "4.3.0",
diff --git a/packages/cli/README.md b/packages/cli/README.md
index b4876f9b0..a8dfbc798 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -34,7 +34,7 @@ Builds use a bounded persistent cache. Unchanged source can reuse the validated
- `scriptc run ` — compile and run
- `scriptc coverage ` — what compiles statically, and why the rest doesn't
-`scriptc build app.ts --emit=ir|llvm|asm|obj` selects serialized typed IR, textual LLVM IR, target assembly, or a relocatable program object as the one primary artifact. `--emit=exe` is the default. Assembly/object emission uses the matching helper on supported macOS, Linux, and Windows hosts (and produces WASI artifacts when that target is selected). Objects retain undefined `scr_*` runtime references plus the `scr_runtime_abi_v12` compatibility marker; they are not library archives. External consumption is experimental and requires the exact matching runtime. `scriptc build app.ts --print=native-link-info -o app.o` prints the versioned JSON target/runtime/link recipe without performing a link. `--emit=asm|obj --sanitize` is rejected until ASan pipeline parity is available.
+`scriptc build app.ts --emit=ir|llvm|asm|obj` selects serialized typed IR, textual LLVM IR, target assembly, or a relocatable program object as the one primary artifact. `--emit=exe` is the default. Assembly/object emission uses the matching helper on supported macOS, Linux, and Windows hosts (and produces WASI artifacts when that target is selected). Objects retain undefined `scr_*` runtime references plus the `scr_runtime_abi_v13` compatibility marker; they are not library archives. External consumption is experimental and requires the exact matching runtime. `scriptc build app.ts --print=native-link-info -o app.o` prints the versioned JSON target/runtime/link recipe without performing a link. `--emit=asm|obj --sanitize` is rejected until ASan pipeline parity is available.
For embedder-hosted modules that are not installed npm packages, coverage can map an exact bare specifier to a local declaration with repeatable `--external-types ` options. This is analysis-only: the types unblock application measurement, while runtime module uses remain reported as blockers.
diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts
index ad7791c66..4e1616a8c 100644
--- a/packages/cli/test/native-link-info.test.ts
+++ b/packages/cli/test/native-link-info.test.ts
@@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
minimum_os: "14.0",
},
program: { object, entry_symbol: "main" },
- runtime_abi: { version: 12, marker: "scr_runtime_abi_v12" },
+ runtime_abi: { version: 13, marker: "scr_runtime_abi_v13" },
runtime_pack: {
kind: "precompiled",
package: "@scriptc/runtime-darwin-arm64",
@@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
() => null,
(failure: { stderr?: string }) => failure,
);
- expect(error?.stderr).toContain("scr_runtime_abi_v12");
+ expect(error?.stderr).toContain("scr_runtime_abi_v13");
});
});
diff --git a/packages/compiler/src/backend/cycle-mutability.ts b/packages/compiler/src/backend/cycle-mutability.ts
index 67196efd7..e5a183db5 100644
--- a/packages/compiler/src/backend/cycle-mutability.ts
+++ b/packages/compiler/src/backend/cycle-mutability.ts
@@ -292,14 +292,11 @@ export function constructorThisAliases(fn: IrFunction, thisId: string): Set {
- if (s.kind === "fieldSet" && isThisRef(s.obj)) {
- if (options.selfStores === true) {
- // `this` itself, possibly wrapped into the field's union type.
- const self = s.value.kind === "unionWrap" ? s.value.value : s.value;
- if (isThisRef(self)) return wrapper(s.obj) && wrapper(self);
- }
- return wrapper(s.obj) && expr(s.value);
- }
+ if (s.kind === "fieldSet" && isThisRef(s.obj)) return wrapper(s.obj) && expr(s.value);
if (s.kind === "exprStmt" && isThisRef(s.expr)) return wrapper(s.expr);
// Declaring an alias is not an escape; its uses are checked like `this`.
if (s.kind === "varDecl" && aliases.has(s.localId) && s.init !== null) return wrapper(s.init);
diff --git a/packages/compiler/src/backend/immutability.test.ts b/packages/compiler/src/backend/immutability.test.ts
new file mode 100644
index 000000000..98ecf91ef
--- /dev/null
+++ b/packages/compiler/src/backend/immutability.test.ts
@@ -0,0 +1,172 @@
+import { expect, test } from "vitest";
+import {
+ DYN,
+ F64,
+ STRING,
+ VOID,
+ type IrExpr,
+ type IrModule,
+ type IrStmt,
+ type IrType,
+} from "../ir/ir.js";
+import { computeConstructionFacts, computeImmutableFields, stableGlobals } from "./immutability.js";
+
+const loc = { file: "immutability.ts", start: 0, end: 1 };
+const obj = (className: string): IrType => ({ kind: "object", className });
+const value = (type: IrType): IrExpr => ({ kind: "varRef", localId: "v", type, loc });
+const thisRef = (className: string): IrExpr => ({
+ kind: "varRef",
+ localId: "this.0",
+ type: obj(className),
+ loc,
+});
+const setThis = (className: string, field: string): IrStmt => ({
+ kind: "fieldSet",
+ obj: thisRef(className),
+ className,
+ field,
+ value: value(F64),
+ loc,
+});
+const call = (callee: string, args: IrExpr[]): IrStmt => ({
+ kind: "exprStmt",
+ expr: { kind: "call", callee, args, type: VOID, loc },
+ loc,
+});
+function ctor(className: string, body: IrStmt[]): IrModule["functions"][number] {
+ return {
+ name: `%${className}.constructor`,
+ params: [{ localId: "this.0", name: "this", type: obj(className) }],
+ locals: [],
+ returnType: VOID,
+ body,
+ loc,
+ };
+}
+function module(
+ classes: NonNullable,
+ functions: IrModule["functions"],
+): IrModule {
+ return {
+ irVersion: 15,
+ sourceFile: loc.file,
+ entry: "main",
+ classes,
+ functions: [
+ { name: "main", locals: [], params: [], returnType: VOID, body: [], loc },
+ ...functions,
+ ],
+ };
+}
+const fields = (...names: string[]) => names.map((name) => ({ name, type: F64 }));
+
+/** The fields of `className` whose constructor stores are private. */
+function privateFields(mod: IrModule, className: string): string[] {
+ const facts = computeConstructionFacts(mod);
+ const fn = mod.functions.find((f) => f.name === `%${className}.constructor`)!;
+ return fn.body
+ .filter((s): s is Extract => s.kind === "fieldSet")
+ .filter((s) => facts.privateStores.has(s))
+ .map((s) => s.field);
+}
+
+test("constructor stores before this escapes are private", () => {
+ const mod = module(
+ [{ name: "P", fields: fields("a", "b"), loc }],
+ [ctor("P", [setThis("P", "a"), call("register", [thisRef("P")]), setThis("P", "b")])],
+ );
+ expect(privateFields(mod, "P")).toEqual(["a"]);
+});
+
+test("storing this into its own field ends private construction", () => {
+ // A later read of the field yields `this` again, so any use of it
+ // (publishing it, handing it to a worker) would expose the object.
+ const self: IrStmt = { ...setThis("P", "self"), value: thisRef("P") } as IrStmt;
+ const mod = module(
+ [{ name: "P", fields: [...fields("a", "b"), { name: "self", type: obj("P") }], loc }],
+ [ctor("P", [setThis("P", "a"), self, setThis("P", "b")])],
+ );
+ expect(privateFields(mod, "P")).toEqual(["a"]);
+});
+
+test("a base constructor's escape makes every subclass store public", () => {
+ const superCall = call("%Base.constructor", [
+ { kind: "upcast", value: thisRef("Sub"), type: obj("Base"), loc },
+ ]);
+ const mod = module(
+ [
+ { name: "Base", fields: fields("a"), loc },
+ { name: "Sub", base: "Base", fields: fields("a", "b"), loc },
+ ],
+ [
+ ctor("Base", [setThis("Base", "a"), call("register", [thisRef("Base")])]),
+ ctor("Sub", [superCall, setThis("Sub", "b")]),
+ ],
+ );
+ expect(privateFields(mod, "Base")).toEqual(["a"]);
+ expect(privateFields(mod, "Sub")).toEqual([]);
+});
+
+test("a constructor called on an existing object keeps every guard", () => {
+ const mod = module([{ name: "P", fields: fields("a"), loc }], [ctor("P", [setThis("P", "a")])]);
+ expect(privateFields(mod, "P")).toEqual(["a"]);
+ mod.functions[0]!.body.push(call("%P.constructor", [value(obj("P"))]));
+ expect(privateFields(mod, "P")).toEqual([]);
+});
+
+test("library builds keep every guard", () => {
+ const mod = module([{ name: "P", fields: fields("a"), loc }], [ctor("P", [setThis("P", "a")])]);
+ mod.lib = {} as NonNullable;
+ expect(privateFields(mod, "P")).toEqual([]);
+});
+
+test("construction-only fields exclude later writes and dynamic stores", () => {
+ const mod = module(
+ [{ name: "P", fields: fields("a", "b"), loc }],
+ [ctor("P", [setThis("P", "a"), setThis("P", "b")])],
+ );
+ mod.functions[0]!.body.push(
+ { kind: "fieldSet", obj: value(obj("P")), className: "P", field: "b", value: value(F64), loc },
+ { kind: "exprStmt", expr: { kind: "dynFrom", value: value(obj("P")), type: DYN, loc }, loc },
+ );
+ const facts = computeImmutableFields(mod)!;
+ expect(facts.classField("P", "a")).toBe(true);
+ expect(facts.classField("P", "b")).toBe(false);
+ // A runtime store into a dynamic value can reach P through its capsule.
+ mod.functions[0]!.body.push({
+ kind: "exprStmt",
+ expr: {
+ kind: "libCall",
+ fn: "dyn.keySet",
+ args: [value(DYN), value(STRING), value(DYN)],
+ type: VOID,
+ loc,
+ },
+ loc,
+ });
+ expect(computeImmutableFields(mod)!.classField("P", "a")).toBe(false);
+});
+
+test("module constants are stable once assigned exactly once", () => {
+ const mod = module([], []);
+ mod.globals = [
+ { id: "%g.k", name: "k", type: STRING, mutable: false },
+ { id: "%g.m", name: "m", type: STRING, mutable: true },
+ { id: "%g.t", name: "t", type: STRING, mutable: false, tdz: true },
+ { id: "%g.twice", name: "twice", type: STRING, mutable: false },
+ ];
+ const assign = (localId: string): IrStmt => ({
+ kind: "assign",
+ localId,
+ value: value(STRING),
+ loc,
+ });
+ mod.functions[0]!.body.push(
+ assign("%g.k"),
+ assign("%g.m"),
+ assign("%g.t"),
+ assign("%g.twice"),
+ assign("%g.twice"),
+ );
+ expect([...stableGlobals(mod)]).toEqual(["%g.k"]);
+});
diff --git a/packages/compiler/src/backend/immutability.ts b/packages/compiler/src/backend/immutability.ts
new file mode 100644
index 000000000..a0b792496
--- /dev/null
+++ b/packages/compiler/src/backend/immutability.ts
@@ -0,0 +1,214 @@
+/* Static immutability facts the backend uses to drop run-time immutability
+ * checks (see computeConstructionFacts). Pure functions of the IR module. */
+import type { IrExpr, IrModule, IrStmt } from "../ir/ir.js";
+import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
+import {
+ computeCycleMutability,
+ constructorEscapePoint,
+ constructorThisAliases,
+ fieldKey,
+ type CycleMutability,
+} from "./cycle-mutability.js";
+
+/** Fields that are never written once their object can be observed outside
+ * its own construction (cycle-mutability.ts derives the facts from every
+ * store in the IR, including typed-ref commits from checked-dynamic code,
+ * and disables them for library builds and dynamic islands).
+ *
+ * Outside constructors, a reference read from such a field stays valid for
+ * as long as its owner object is alive: nothing can replace it and release
+ * the old value. Constructors themselves may still write their own
+ * `this`'s fields, so the facts do not apply while one runs. */
+export class ImmutableFields {
+ constructor(
+ private readonly mutability: CycleMutability,
+ private readonly unitOfClass: (className: string) => string,
+ ) {}
+
+ /** `obj.field` on a class instance is construction-only. */
+ classField(className: string, field: string): boolean {
+ return this.unit(this.unitOfClass(className), field);
+ }
+
+ /** `obj.field` on a record is never written after the literal. */
+ recordField(shapeId: string, field: string): boolean {
+ return this.unit(`record:${shapeId}`, field);
+ }
+
+ private unit(unit: string, field: string): boolean {
+ return (
+ !this.mutability.all &&
+ !this.mutability.units.has(unit) &&
+ !this.mutability.fields.has(fieldKey(unit, field))
+ );
+ }
+}
+
+export function computeImmutableFields(mod: IrModule): ImmutableFields | null {
+ if (mod.lib !== undefined) return null;
+ const baseOf = new Map((mod.classes ?? []).map((c) => [c.name, c.base ?? null] as const));
+ const roots = new Map();
+ const rootOf = (name: string): string => {
+ const known = roots.get(name);
+ if (known !== undefined) return known;
+ let cur = name;
+ for (let base = baseOf.get(cur); base !== null && base !== undefined; base = baseOf.get(cur))
+ cur = base;
+ roots.set(name, cur);
+ return cur;
+ };
+ const unitOfClass = (name: string): string => `object:${rootOf(name)}`;
+ const mutability = computeCycleMutability(mod, unitOfClass);
+ return mutability.all ? null : new ImmutableFields(mutability, unitOfClass);
+}
+
+/** Stores that provably target an object no other code can reach yet.
+ *
+ * A published object (@scriptc/threads `publish`) rejects every write, so
+ * the emitter guards each store into a type the program may publish. A
+ * store into the constructor's own `this` cannot target a published object
+ * when nothing outside the construction can hold `this` at that point:
+ *
+ * - the constructor runs on a fresh object: it is only reached through
+ * `new` (or a construct thunk) for its class or a subclass, never by a
+ * direct call on an existing object (`%C.constructor` called anywhere but
+ * as the direct super call of a subclass constructor disqualifies C and
+ * every ancestor, which that call reaches with the same receiver);
+ * - no ancestor constructor, which runs before this body continues, lets
+ * `this` escape, and the store lies in a top-level statement before the
+ * first one in which this constructor lets it escape
+ * (cycle-mutability's constructorEscapePoint: `this`, or a constant alias
+ * of it, may only be a field-store target, a field-read receiver or the
+ * direct base constructor's receiver; storing it anywhere, including
+ * into one of its own fields whose later reads would hand it out again,
+ * and closures and class references capturing it count as escapes).
+ * A subclass constructor's code only runs after this body returns.
+ *
+ * Library builds keep every guard: their constructors can be invoked by an
+ * embedder in ways the module does not show. */
+export interface ConstructionFacts {
+ /** `fieldSet` statements whose target is a still-private `this`. */
+ privateStores: Set;
+}
+
+export function computeConstructionFacts(mod: IrModule): ConstructionFacts {
+ const facts: ConstructionFacts = { privateStores: new Set() };
+ if (mod.lib !== undefined) return facts;
+ const classes = new Map((mod.classes ?? []).map((c) => [c.name, c] as const));
+ const ctorClass = new Map([...classes.keys()].map((name) => [`%${name}.constructor`, name]));
+ const fnByName = new Map(mod.functions.map((fn) => [fn.name, fn] as const));
+ const thisOf = (name: string): string | null =>
+ fnByName.get(`%${name}.constructor`)?.params[0]?.localId ?? null;
+ const isThisRef = (e: IrExpr, thisId: string | null): boolean => {
+ if (thisId === null) return false;
+ if (e.kind === "varRef") return e.localId === thisId;
+ if (e.kind === "upcast") return isThisRef(e.value, thisId);
+ if (e.kind === "seqExpr") return isThisRef(e.result, thisId);
+ return false;
+ };
+
+ // Constructors reached with a receiver that may not be fresh.
+ const reused = new Set();
+ const markReused = (name: string): void => {
+ for (let c: string | undefined = name; c !== undefined && classes.has(c);) {
+ if (reused.has(c)) break;
+ reused.add(c);
+ c = classes.get(c)!.base;
+ }
+ };
+ for (const fn of mod.functions) {
+ const own = ctorClass.get(fn.name);
+ const thisId = own !== undefined ? thisOf(own) : null;
+ const baseCtor =
+ own !== undefined && classes.get(own)!.base !== undefined
+ ? `%${classes.get(own)!.base}.constructor`
+ : null;
+ const expr = (e: IrExpr): boolean => {
+ if (e.kind === "call") {
+ const target = ctorClass.get(e.callee);
+ if (
+ target !== undefined &&
+ !(e.callee === baseCtor && e.args.length > 0 && isThisRef(e.args[0]!, thisId))
+ )
+ markReused(target);
+ } else if (e.kind === "closure") {
+ const target = ctorClass.get(e.fnName);
+ if (target !== undefined) markReused(target);
+ }
+ return everyExprChild(e, expr, stmt);
+ };
+ const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt);
+ fn.body.every(stmt);
+ }
+
+ // The first escaping statement of each constructor (body length: none).
+ // A class without a constructor function of its own (runtime roots) is
+ // treated as escaping at once.
+ const points = new Map();
+ const escapePoint = (name: string): number => {
+ const known = points.get(name);
+ if (known !== undefined) return known;
+ const fn = fnByName.get(`%${name}.constructor`);
+ const base = classes.get(name)?.base;
+ const thisId = thisOf(name);
+ const point =
+ fn === undefined || thisId === null || classes.get(name)?.runtime === true
+ ? 0
+ : constructorEscapePoint(fn, thisId, base !== undefined ? `%${base}.constructor` : null);
+ points.set(name, point);
+ return point;
+ };
+ const ancestorEscapes = (name: string): boolean => {
+ for (let c = classes.get(name)?.base; c !== undefined; c = classes.get(c)?.base) {
+ const fn = fnByName.get(`%${c}.constructor`);
+ if (!classes.has(c) || fn === undefined || escapePoint(c) < fn.body.length) return true;
+ }
+ return false;
+ };
+
+ for (const [name] of classes) {
+ const fn = fnByName.get(`%${name}.constructor`);
+ if (fn === undefined || reused.has(name) || ancestorEscapes(name)) continue;
+ const point = escapePoint(name);
+ if (point === 0) continue;
+ const aliases = constructorThisAliases(fn, thisOf(name)!);
+ const isOwn = (e: IrExpr): boolean =>
+ (e.kind === "varRef" && aliases.has(e.localId)) ||
+ (e.kind === "upcast" && isOwn(e.value)) ||
+ (e.kind === "seqExpr" && isOwn(e.result));
+ const expr = (e: IrExpr): boolean => everyExprChild(e, expr, stmt);
+ const stmt = (s: IrStmt): boolean => {
+ if (s.kind === "fieldSet" && isOwn(s.obj)) facts.privateStores.add(s);
+ return everyStmtChild(s, expr, stmt);
+ };
+ for (let i = 0; i < point; i++) stmt(fn.body[i]!);
+ }
+ return facts;
+}
+
+/** Module constants whose value never changes once initialized: immutable,
+ * not TDZ-checked (every read follows the initialization), no import-cycle
+ * init flag, and at most one assignment in the whole module. Such a global
+ * owns its value for the rest of the program, so a read of it can be
+ * borrowed across any later operand or call. */
+export function stableGlobals(mod: IrModule): Set {
+ const candidates = new Set(
+ (mod.globals ?? [])
+ .filter((g) => !g.mutable && g.tdz !== true && g.initFlag === undefined)
+ .map((g) => g.id),
+ );
+ const assigned = new Set();
+ const expr = (e: IrExpr): boolean => {
+ if (e.kind === "assignExpr" && candidates.has(e.localId)) candidates.delete(e.localId);
+ return everyExprChild(e, expr, stmt);
+ };
+ const stmt = (s: IrStmt): boolean => {
+ if (s.kind === "assign" && candidates.has(s.localId)) {
+ if (assigned.has(s.localId)) candidates.delete(s.localId);
+ assigned.add(s.localId);
+ }
+ return everyStmtChild(s, expr, stmt);
+ };
+ for (const fn of mod.functions) fn.body.every(stmt);
+ return candidates;
+}
diff --git a/packages/compiler/src/backend/llvm/borrowed-inputs.ts b/packages/compiler/src/backend/llvm/borrowed-inputs.ts
index af921a105..16839afd6 100644
--- a/packages/compiler/src/backend/llvm/borrowed-inputs.ts
+++ b/packages/compiler/src/backend/llvm/borrowed-inputs.ts
@@ -1,4 +1,5 @@
import type { IrExpr } from "../../ir/ir.js";
+import type { Writes } from "./reference-writes.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
/** For a runtime input whose ABI borrows rather than consumes ownership.
@@ -14,18 +15,33 @@ export function emitBorrowedInput(host: LlvmEmitterContext, value: IrExpr): LlVa
* while every operation preserves existing owners. Compute suffix facts
* once, keeping left-to-right evaluation and the owning fallback for each
* argument independently. The consumer must borrow all inputs and preserve
- * reference edges itself; mutating runtime operations use emitBorrowedInput. */
+ * reference edges itself; mutating runtime operations use emitBorrowedInput.
+ * With `consumerWrites` (the locations the consumer may overwrite), a
+ * projection also borrows when neither the consumer nor any later operand
+ * can write a location its read depends on (ReferenceWrites). */
export function borrowableInputs(
host: LlvmEmitterContext,
inputs: readonly IrExpr[],
consumerPreserves = true,
+ consumerWrites?: Writes,
): boolean[] {
const result: boolean[] = new Array(inputs.length);
let preserves = consumerPreserves;
+ const writes = host.referenceWrites;
+ let suffix: Writes =
+ consumerWrites !== undefined
+ ? consumerWrites
+ : consumerPreserves
+ ? null
+ : (writes?.heapAll ?? null);
for (let i = inputs.length - 1; i >= 0; i--) {
const value = inputs[i]!;
- result[i] = host.canBorrowCallArgument(value) || (preserves && host.canBorrowReceiver(value));
+ result[i] =
+ host.canBorrowCallArgument(value) ||
+ (preserves && host.canBorrowReceiver(value)) ||
+ (writes !== null && host.canBorrowReceiver(value) && host.unwrittenRead(value, suffix));
preserves = preserves && host.referenceEffects.preserves(value);
+ if (writes !== null) suffix = writes.union([suffix, writes.expr(value)]);
}
return result;
}
@@ -36,3 +52,21 @@ export function emitBorrowedInputs(host: LlvmEmitterContext, inputs: readonly Ir
borrowed[index] ? host.emitReadReceiver(value) : host.emitExpr(value),
);
}
+
+/** The writes after each input: the consumer's plus every later input's
+ * (null entries: nothing; an empty array without ReferenceWrites). */
+export function inputSuffixWrites(
+ host: LlvmEmitterContext,
+ inputs: readonly IrExpr[],
+ consumerWrites: Writes,
+): Writes[] {
+ const writes = host.referenceWrites;
+ if (writes === null) return [];
+ const out: Writes[] = new Array(inputs.length);
+ let suffix = consumerWrites;
+ for (let i = inputs.length - 1; i >= 0; i--) {
+ out[i] = suffix;
+ suffix = writes.union([suffix, writes.expr(inputs[i]!)]);
+ }
+ return out;
+}
diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts
index 25a735356..c409fb87b 100644
--- a/packages/compiler/src/backend/llvm/common.ts
+++ b/packages/compiler/src/backend/llvm/common.ts
@@ -9,6 +9,20 @@ import type {
} from "../../ir/ir.js";
import type { LlvmEmitterContext } from "./expr-context.js";
+/** Field numbers of %ScrArr, which mirrors scr_runtime.h's ScrArr field for
+ * field (its static assertions pin `cyc` and `data`). */
+export const SCR_ARR_FIELD = {
+ rc: 0,
+ len: 1,
+ cap: 2,
+ elem: 3,
+ cyc: 4,
+ ops: 5,
+ data: 6,
+ present: 7,
+ ext: 8,
+} as const;
+
/** Lines that decide whether the double `index` is an integer in
* `[0, length)`, where `length` is an unsigned `sizeType` count, and give
* that integer as an i64. `%{ok}` is the answer; `%{wide}` is meaningful
diff --git a/packages/compiler/src/backend/llvm/dense-array-access.test.ts b/packages/compiler/src/backend/llvm/dense-array-access.test.ts
index 4ff7b1b16..8101008a5 100644
--- a/packages/compiler/src/backend/llvm/dense-array-access.test.ts
+++ b/packages/compiler/src/backend/llvm/dense-array-access.test.ts
@@ -77,7 +77,17 @@ test.each([32, 64] as const)("strict scalar reads check dense storage inline (%i
test("reference reads retain inline and keep a cold +1 runtime fallback", () => {
const read: IrExpr = { kind: "arrayGet", arr: ref("s", words), index, type: STRING, loc };
- const llvm = emitLlvmModule(module("word", STRING, [{ kind: "return", value: read, loc }]));
+ // An element removal in the body keeps the read from being lent as a
+ // borrowed return: the result must own its reference.
+ const truncate: IrStmt = {
+ kind: "arraySetLength",
+ arr: ref("s", words),
+ length: { kind: "numLit", value: 1, type: F64, loc },
+ loc,
+ };
+ const llvm = emitLlvmModule(
+ module("word", STRING, [truncate, { kind: "return", value: read, loc }]),
+ );
const fn = body(llvm, "word");
expect(fn).toMatch(/icmp ne ptr .*, null/);
expect(fn).toContain("@scr_str_retain_v(");
diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts
index 34428db8b..dd0c20588 100644
--- a/packages/compiler/src/backend/llvm/dense-array-access.ts
+++ b/packages/compiler/src/backend/llvm/dense-array-access.ts
@@ -8,9 +8,10 @@
* `undefined`, sparse indices, noncanonical numeric properties (negative,
* fractional, NaN, infinite, 2^32-1), and out-of-range reads branch to the
* unchanged runtime call, which is marked cold. The ScrArr field indices
- * match the C layout in scr_runtime.h: len 1, cap 2, data 7, present 8. */
+ * (SCR_ARR_FIELD) match the C layout in scr_runtime.h. */
import { isRefCounted, type IrExpr, type IrType } from "../../ir/ir.js";
+import { SCR_ARR_FIELD } from "./common.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { exactInteger, widenInteger } from "./integer-values.js";
@@ -85,10 +86,14 @@ function emitDenseGuard(
data = B.tmp(),
statesPtr = B.tmp(),
states = B.tmp();
- B.line(`${dataPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 7`);
+ B.line(
+ `${dataPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.data}`,
+ );
host.markMemoryPointer(dataPtr, "array:header");
B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
- B.line(`${statesPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 8`);
+ B.line(
+ `${statesPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.present}`,
+ );
host.markMemoryPointer(statesPtr, "array:header");
B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`);
const widen = (value: string): string => {
@@ -389,9 +394,9 @@ export function emitDenseArrayPush(
join = B.newLabel("arr.push.join");
B.condBr(room, fast, slow);
B.startBlock(fast);
- const dataPtr = header(7),
+ const dataPtr = header(SCR_ARR_FIELD.data),
data = B.tmp(),
- statesPtr = header(8),
+ statesPtr = header(SCR_ARR_FIELD.present),
states = B.tmp();
B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`);
diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts
index b474068cc..56b18e2b4 100644
--- a/packages/compiler/src/backend/llvm/dyn.ts
+++ b/packages/compiler/src/backend/llvm/dyn.ts
@@ -1,6 +1,6 @@
import { typedRefConstructor } from "./shapes.js";
import { bindLiveArrayOps } from "./expr-stream-bridges.js";
-import { BYTES_ELEM_NUM, f64Lit } from "./common.js";
+import { BYTES_ELEM_NUM, SCR_ARR_FIELD, f64Lit } from "./common.js";
import { emitUnionPeek, emitUnionTag } from "./union-repr.js";
import { InternalCompilerError } from "../../errors.js";
/* ScrDyn helpers for the LLVM backend: per-type match predicates
@@ -1199,8 +1199,8 @@ export class LlDyn {
: [
{ index: 1, type: host.sizeType, name: "length" },
{ index: 2, type: host.sizeType, name: "capacity" },
- { index: 7, type: "ptr" as const, name: "data" },
- { index: 8, type: "ptr" as const, name: "presence" },
+ { index: SCR_ARR_FIELD.data, type: "ptr" as const, name: "data" },
+ { index: SCR_ARR_FIELD.present, type: "ptr" as const, name: "presence" },
];
members.forEach((member) => {
const cachedPtr = B.tmp();
diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts
index 958df8cbe..8b8c166ca 100644
--- a/packages/compiler/src/backend/llvm/emitter.test.ts
+++ b/packages/compiler/src/backend/llvm/emitter.test.ts
@@ -99,6 +99,26 @@ test("separate LLVM parts preserve complete module bytes in native, WASI, and de
}
});
+test("the shared indexed-read helpers carry an inline hint", () => {
+ const helper = (name: string): IrFunction => ({
+ name,
+ params: [],
+ returnType: F64,
+ locals: [],
+ body: [{ kind: "return", value: { kind: "numLit", value: 1, type: F64, loc }, loc }],
+ loc,
+ });
+ const module = moduleFor([]);
+ const llvm = emitLlvmModule({
+ ...module,
+ functions: [...module.functions, helper("%arr.idxOr.0"), helper("plain")],
+ });
+ const define = (name: string): string =>
+ llvm.split("\n").find((line) => line.startsWith("define") && line.endsWith(`{ ; ${name}`))!;
+ expect(define("%arr.idxOr.0")).toMatch(/\) inlinehint #0 \{/);
+ expect(define("plain")).not.toContain("inlinehint");
+});
+
test("throwing-call count does not multiply identical scope cleanup", () => {
for (const boxed of [false, true]) {
const small = emitLlvmModule(moduleFor(Array.from({ length: 4 }, call), { boxed }));
diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts
index 5ac705048..9f405c3bb 100644
--- a/packages/compiler/src/backend/llvm/emitter.ts
+++ b/packages/compiler/src/backend/llvm/emitter.ts
@@ -99,6 +99,7 @@ import { scalarizeNumericRecords } from "../../ir/scalar-records.js";
import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js";
import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js";
import { analyzeIntegerRanges, INT32_RANGE, type IntegerRanges } from "../../ir/integer-ranges.js";
+import { unshareNodes } from "../../ir/unshare.js";
import { analyzeInt32Slots, type Int32Slots } from "../../ir/int32-slots.js";
import { analyzeFinalFields, constructorClass, type FinalFields } from "../../ir/final-fields.js";
import { foldConstantGlobals } from "../../ir/constant-globals.js";
@@ -159,10 +160,11 @@ import {
type MapReadLifetimes,
} from "./map-read-lifetimes.js";
import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js";
-import { emitBorrowedInput } from "./borrowed-inputs.js";
-import { analyzeWalks, findWalkBorrows } from "./walk-borrows.js";
+import { borrowableInputs, emitBorrowedInput } from "./borrowed-inputs.js";
+import { analyzeWalks, findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js";
import { findReboundParameters } from "./rebound-parameters.js";
import { ReferenceEffects } from "./reference-effects.js";
+import { ReferenceWrites, type Writes } from "./reference-writes.js";
import { LlvmDebugInfo } from "./debug-info.js";
import { StackCallbacks } from "./stack-callbacks.js";
import { findLoopArrayBorrows } from "./loop-array-borrows.js";
@@ -197,6 +199,7 @@ import { emitBorrowedResultCall, emitCallExpr } from "./expr-calls.js";
import { emitDynamicExpr } from "./expr-dynamic.js";
import { emitIntrinsicExpr, emitSerializationExpr, emitAsyncExpr } from "./expr-async.js";
import { PublishWalkers } from "./publish.js";
+import { computeImmutableFields, stableGlobals, type ImmutableFields } from "../immutability.js";
import { computePublishedTypes, type PublishedTypes } from "../publication.js";
import { emitJsInteropExpr, emitExpr } from "./expr-dispatch.js";
import {
@@ -301,7 +304,7 @@ import { LlWalkers } from "./walkers.js";
import { isObjectArm, NULLABLE_ABSENT, NULLABLE_NULL, NullableUnions } from "./nullable-unions.js";
import { emitNullablePresent, emitUnionPeek, emitUnionTag } from "./union-repr.js";
import { literalCapWord, literalCapWord32 } from "./string-key-hash.js";
-import { NUMBER_MAP_ENTRY_DECL } from "./map-number-lookup.js";
+import { NUMBER_MAP_ENTRY_DECL, STRING_MAP_ENTRY_DECL } from "./map-number-lookup.js";
import { STRING_READ_HELPERS } from "./string-reads.js";
import {
emitConcatInputs,
@@ -407,7 +410,10 @@ export interface LlvmTargetOptions {
detachedExit?: boolean;
}
-export function emitLlvmModule(mod: IrModule, options: LlvmTargetOptions = {}): string {
+export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string {
+ // Facts are keyed by node identity: emit the same module whether it was
+ // lowered in memory or read back from its serialized form.
+ const mod = unshareNodes(input);
// Keep source storage intact for debugger inspection in dev builds.
return new LlEmitter(
options.debugSources === undefined
@@ -419,9 +425,10 @@ export function emitLlvmModule(mod: IrModule, options: LlvmTargetOptions = {}):
/** Keep large translation units below the host's single-string limit. */
export function emitLlvmModuleSource(
- mod: IrModule,
+ input: IrModule,
options: LlvmTargetOptions = {},
): string | readonly string[] {
+ const mod = unshareNodes(input);
const parts = new LlEmitter(
options.debugSources === undefined
? scalarizeNumericRecords(specializeNumericCalls(foldConstantGlobals(mod), mangleFunction))
@@ -598,6 +605,12 @@ export class LlEmitter {
* fiberless-async analysis scans it for suspension entry points. */
private readonly fnDefText = new Map();
readonly referenceEffects: ReferenceEffects;
+ /** Field-sensitive reference writes (null in debug builds). */
+ referenceWrites: ReferenceWrites | null = null;
+ private readonly borrowedReturnKeyCache = new Map();
+ /** The statement list being emitted and the current index, for blocks
+ * whose bindings end with the list (emitBlock and function bodies). */
+ private stmtCursor: { stmts: IrStmt[]; index: number } | null = null;
readonly optionalArrayReads: OptionalArrayReads;
readonly checkedNarrows: CheckedNarrows;
callArrayReads = new Map();
@@ -641,6 +654,9 @@ export class LlEmitter {
readonly ffiHasRetainedCallback: boolean;
private readonly ffiHasForeignCallback: boolean;
private readonly globalTypes = new Map();
+ /** Module constants that keep one value once initialized
+ * (immutability.ts stableGlobals). */
+ private stableGlobals: ReadonlySet = new Set();
private readonly tdzGlobals = new Map();
readonly constantNumericTables: ReadonlyMap;
/** May-throw analysis (computeMayThrow): pending
@@ -656,6 +672,13 @@ export class LlEmitter {
/** Static types whose values can be published (@scriptc/threads): their
* stores carry the frozen-object guard. Null without publish calls. */
readonly publishedTypes: PublishedTypes | null;
+ /** Construction-only class and record fields (immutability.ts); null
+ * where the facts are disabled. */
+ private readonly immutableFields: ImmutableFields | null;
+ /** Constructor function names: their bodies may still write their own
+ * `this`'s construction-only fields. */
+ private readonly constructorFunctions: ReadonlySet;
+ private inConstructor = false;
/** Method names with at least one may-throw implementation — the
* virtualCall pending check's key. */
readonly mayThrowMethods = new Set();
@@ -744,6 +767,9 @@ export class LlEmitter {
private readonly initializerBindings: ReturnType;
/** Whole-program int32 storage and parameter/result facts. */
readonly int32Slots: Int32Slots;
+ /** Immutable module constants (contract types) whose graphs become
+ * immortal once initialized: their reads need no reference counting. */
+ readonly immortalGlobals: ReadonlySet;
private numericLocals = new Map();
private captureIds = new Set();
/** Active induction bindings retain their width independently of size_t. */
@@ -831,6 +857,7 @@ export class LlEmitter {
this.constantNumericTables = findConstantNumericTables(mod);
this.initializerBindings = findInitializerBindings(mod);
this.int32Slots = analyzeInt32Slots(mod);
+ this.immortalGlobals = new Set(mod.contracts?.immortalGlobals ?? []);
this.finalFields = analyzeFinalFields(mod);
this.sizeType = options.pointerBits === 32 ? "i32" : "i64";
this.ffiExtendNarrowIntegers =
@@ -884,6 +911,8 @@ export class LlEmitter {
this.workerEntryPolls = mt.workerEntryPolls ?? null;
this.stackChecks = mt.stackChecks ?? null;
this.publishedTypes = computePublishedTypes(mod);
+ this.immutableFields = computeImmutableFields(mod);
+ this.constructorFunctions = new Set((mod.classes ?? []).map((c) => `%${c.name}.constructor`));
this.indirectMayThrow = mt.indirect || mod.workers === true;
for (const cls of mod.classes ?? []) {
for (const m of cls.methods ?? []) {
@@ -907,6 +936,15 @@ export class LlEmitter {
(call) => this.optionalArrayReads.get(call) !== null,
mod.classes ?? [],
);
+ if (this.debug === null)
+ this.referenceWrites = new ReferenceWrites(
+ this.fnByName,
+ mod.classes ?? [],
+ (call) => this.optionalArrayReads.get(call) !== null,
+ new Set((mod.globals ?? []).map((g) => g.id)),
+ { unions: mod.unions, records: mod.records },
+ this.immutableFields,
+ );
const walkParameters = new Map>();
if (this.debug === null) {
const host = {
@@ -914,8 +952,9 @@ export class LlEmitter {
borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e),
};
for (const fn of this.fnByName.values()) {
- if (!this.referenceEffects.functions.has(fn.name)) continue;
- const walks = findWalkBorrows(fn, host);
+ const preserving = this.referenceEffects.functions.has(fn.name);
+ if (!preserving && this.referenceWrites === null) continue;
+ const walks = findWalkBorrows(fn, preserving ? host : this.unwrittenWalkHost(fn, host));
if (walks.size === 0) continue;
this.walkBorrowsByFunction.set(fn.name, walks);
const indexes = new Set();
@@ -1023,6 +1062,7 @@ export class LlEmitter {
this.globalTypes.set(g.id, g.type);
if (g.tdz) this.tdzGlobals.set(g.id, g.name);
}
+ this.stableGlobals = stableGlobals(mod);
// Runtime error, EventEmitter, and stream classes have known layouts.
// Subclasses embed the ScrEmitter/ScrStream prefixes from classes.ts.
const classes = mod.classes ?? [];
@@ -1881,12 +1921,14 @@ export class LlEmitter {
`%ScrFfiTable = type { ptr, ${this.sizeType}, ${this.sizeType}, ptr, i8, ptr, ptr, ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ptr }`,
`%ScrRegex = type { ${this.sizeType}, ptr, ptr, ptr, double, ptr, ptr }`,
// ScrArr mirrors scr_runtime.h field-for-field: { rc, len, cap, elem,
- // elem_retain, elem_release, elem_trace, data, present, ext }, where
- // ext points at the out-of-line sparse, property and metadata storage
- // (NULL until first used). Live dynamic stream commits swap its dense,
- // presence and out-of-line storage while preserving the target
- // object's identity.
- `%ScrArr = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, i32, ptr, ptr, ptr, ptr, ptr, ptr }`,
+ // cyc, ops, data, present, ext } (field numbers in SCR_ARR_FIELD),
+ // where ops points at the element type's constant RC descriptor
+ // (%ScrArrOps, SCR_ELEM_REF only) and ext at the out-of-line sparse,
+ // property and metadata storage (NULL until first used). Live dynamic
+ // stream commits swap its dense, presence and out-of-line storage while
+ // preserving the target object's identity.
+ `%ScrArr = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, i32, i32, ptr, ptr, ptr, ptr }`,
+ `%ScrArrOps = type { ptr, ptr, ptr }`,
// The ScrMap prefix { rc, key_kind, val_kind, val_retain, val_release,
// val_trace, key_retain, key_release, key_trace } — the inline RC
// fast paths test the two trace slots (shapes.ts).
@@ -1899,9 +1941,13 @@ export class LlEmitter {
...(this.decls.has(NUMBER_MAP_ENTRY_DECL)
? [
`%ScrMapIx = type { ${this.sizeType}, i32, i32, ptr, ptr, ptr, ptr, ptr, ptr, ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ${this.sizeType}, ptr, ${this.sizeType}, ptr, ptr, ptr, ${this.sizeType} }`,
- `%ScrMapEntry = type { i64, i64, i64 }`,
]
: []),
+ // String-keyed reference reads load the value slot of the entry
+ // scr_map_entry_str returns (map-number-lookup.ts).
+ ...(this.decls.has(NUMBER_MAP_ENTRY_DECL) || this.decls.has(STRING_MAP_ENTRY_DECL)
+ ? [`%ScrMapEntry = type { i64, i64, i64 }`]
+ : []),
// The runtime error prefix { rc, vt, name, message, code, cause } and the
// class-object shape { rc, pre, post, ctor, name } — field reads on
// builtin errors and classval loads GEP through these.
@@ -2067,7 +2113,7 @@ export class LlEmitter {
out.push(
`@${inst.sym}_data = internal constant [${n} x ptr] [ ${inst.slots.map((s) => `ptr ${s}`).join(", ")} ]`,
`@${inst.sym}_present = internal constant [${n} x i8] ${present}`,
- `@${inst.sym} = internal global %ScrArr { ${this.sizeType} -1, ${this.sizeType} ${n}, ${this.sizeType} ${n}, i32 2, ptr null, ptr null, ptr null, ptr @${inst.sym}_data, ptr @${inst.sym}_present, ptr null }`,
+ `@${inst.sym} = internal global %ScrArr { ${this.sizeType} -1, ${this.sizeType} ${n}, ${this.sizeType} ${n}, i32 2, i32 0, ptr null, ptr @${inst.sym}_data, ptr @${inst.sym}_present, ptr null }`,
);
}
if (this.templateStringsInstances.size > 0) out.push(``);
@@ -5089,6 +5135,7 @@ export class LlEmitter {
const numericFn = withInitializerBindings(fn, initializerBindings);
this.numericLocals = new Map(numericFn.locals.map((l) => [l.id, l]));
this.borrowedParameters.clear();
+ this.referenceWrites?.enter(fn);
this.reboundOwnerSlots.clear();
this.projectedParameters.clear();
const projectedParameterIndexes = this.callLifetimes.parameters.get(fn.name);
@@ -5129,6 +5176,7 @@ export class LlEmitter {
this.callLifetimes,
this.referenceEffects,
this.optionalArrayReads,
+ this.referenceWrites,
)
: new Set();
this.localArrayReads = findLocalArrayReads(
@@ -5169,6 +5217,7 @@ export class LlEmitter {
this.currentReturnType = fn.returnType;
this.currentGenerator = fn.generator ?? null;
this.currentBorrowedReturn = this.borrowedReturns.has(fn.name);
+ this.inConstructor = this.constructorFunctions.has(fn.name);
this.currentWasiCoro = null;
this.logArgSlots = 0;
@@ -5335,7 +5384,7 @@ export class LlEmitter {
}
if (this.stackChecks?.has(fn.name)) this.emitStackCheck();
if (this.workerEntryPolls?.has(fn.name)) this.emitPendingCheck(undefined, true);
- this.emitStmts(fn.body);
+ this.emitStmts(fn.body, true);
// Implicit exit of a void function: release the function scope unless
// the body already terminated its final block (return, or a throw
// whose unwind released everything down to depth 0).
@@ -5399,7 +5448,13 @@ export class LlEmitter {
// Lifted functions receive their closure first (the callValue ABI).
if (fn.captures !== undefined) params.unshift("ptr %sc_env");
const ret = this.llType(fn.returnType);
- const attrs = coro !== null ? "#1" : FN_ATTRS;
+ let attrs = coro !== null ? "#1" : FN_ATTRS;
+ // The shared indexed-read helper (lowerSafeIndexRead) is a dense bounds
+ // test, a state test and a load behind cold runtime fallbacks, called
+ // from every `a[i]` of one element type. Without the hint LLVM keeps the
+ // hot instances out of line once they have many callers: every read pays
+ // a call, and an integer index converts to a double and back across it.
+ if (fn.name.startsWith("%arr.idxOr.")) attrs = `inlinehint ${attrs}`;
const debug = this.debugScope === null ? "" : ` !dbg ${this.debugScope}`;
const symbol = borrowed ? mangleBorrowedFunction(fn.name) : mangleFunction(fn.name);
const body = `define internal ${ret} @${symbol}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`;
@@ -5504,11 +5559,17 @@ export class LlEmitter {
this.canBorrowCallArgument(value.then) &&
this.canBorrowCallArgument(value.else_)
);
+ // A construction-only field of a stable owner keeps its value until the
+ // owner dies: no later operand or callee can replace and release it.
+ if ((value.kind === "fieldGet" || value.kind === "recordGet") && this.immutableRead(value))
+ return this.canBorrowCallArgument(value.obj);
// An optional chain's bound receiver stays unchanged until its body
// finishes (see emitReadReceiver).
if (value.kind === "chainRecv") return this.chainSlots.has(value.id);
if (value.kind !== "varRef") return false;
const binding = this.binding(value.localId);
+ // A module constant owns its value for the rest of the program.
+ if (binding.kind === "global") return this.stableGlobals.has(value.localId);
return (
binding.kind === "local" &&
binding.local !== undefined &&
@@ -5521,15 +5582,213 @@ export class LlEmitter {
);
}
+ /** A plain-pointer read of a construction-only field outside any
+ * constructor (ImmutableFields). */
+ private immutableRead(e: ExprOf<"fieldGet" | "recordGet">): boolean {
+ if (this.inConstructor || this.immutableFields === null) return false;
+ const t = e.type.kind;
+ if (t !== "object" && t !== "record" && t !== "array" && t !== "string") return false;
+ return e.kind === "fieldGet"
+ ? this.immutableFields.classField(e.className, e.field)
+ : this.immutableFields.recordField(e.shapeId, e.field);
+ }
+
+ /** A projection read whose locations nothing writes while a borrowed
+ * read of it is in use (ReferenceWrites), given the writes of that
+ * extent. The root binding itself must stay alive across it too, which
+ * its own location (`l:`, `g:`, `bx`) covers. */
+ unwrittenRead(e: IrExpr, writes: Writes): boolean {
+ const rw = this.referenceWrites;
+ if (rw === null) return false;
+ const keys = rw.readKeys(e, (call) => this.borrowedReturnKeys(call.callee));
+ return keys !== null && !rw.conflicts(keys, writes);
+ }
+
+ /** A stable binding initialized by a projection can borrow it when the
+ * rest of its block (its whole lifetime) writes none of the projection's
+ * locations: the source edge then keeps the value alive, and uses that
+ * escape still retain their own copies. */
+ private unwrittenAlias(s: IrStmt & { kind: "varDecl" }): boolean {
+ const cursor = this.stmtCursor;
+ const rw = this.referenceWrites;
+ if (rw === null || s.init === null || cursor === null || cursor.stmts[cursor.index] !== s)
+ return false;
+ if (
+ !this.plainReference(s.init.type) ||
+ !this.canBorrowReceiver(s.init) ||
+ !this.unownedWalk(s.init)
+ )
+ return false;
+ const writes = rw.union([rw.expr(s.init), rw.stmts(cursor.stmts.slice(cursor.index + 1))]);
+ return this.unwrittenRead(s.init, writes);
+ }
+
+ /** An optional element read into a projection-only local can borrow the
+ * element when the rest of the block writes no array element and none of
+ * the locations keeping the array alive (which must itself be read
+ * without a frame owner). */
+ private unwrittenLocalArrayRead(s: IrStmt & { kind: "varDecl" }, read: LocalArrayRead): boolean {
+ const cursor = this.stmtCursor;
+ const rw = this.referenceWrites;
+ if (rw === null || s.init === null || cursor === null || cursor.stmts[cursor.index] !== s)
+ return false;
+ if (!this.unownedWalk(read.array)) return false;
+ const keys = rw.readKeys(read.array);
+ if (keys === null || rw.expr(read.index) !== null) return false;
+ const writes = rw.union([rw.expr(s.init), rw.stmts(cursor.stmts.slice(cursor.index + 1))]);
+ return !rw.conflicts(["a", ...keys], writes);
+ }
+
+ /** An optional element read passed to a projection-only parameter can
+ * borrow the element when the callee and later arguments (`writes`)
+ * write no array element and nothing keeping the array alive. */
+ unwrittenCallArrayRead(read: LocalArrayRead, writes: Writes): boolean {
+ const rw = this.referenceWrites;
+ if (rw === null) return false;
+ const keys = rw.readKeys(read.array);
+ if (keys === null || rw.expr(read.index) !== null) return false;
+ return !rw.conflicts(["a", ...keys], writes);
+ }
+
+ /** `return x` of a local whose slot is an owned scope entry of the same
+ * type: the return can take over the local's reference. */
+ private movableReturn(
+ value: IrExpr | null,
+ ): { scope: LlScopeEntry[]; entry: LlScopeEntry; index: number } | null {
+ if (value === null || value.kind !== "varRef" || !isRefCounted(value.type)) return null;
+ if (this.currentBorrowedReturn || this.currentWasiCoro !== null) return null;
+ const binding = this.binding(value.localId);
+ if (binding.kind !== "local" || binding.local === undefined || binding.local.boxed) return null;
+ if (!typeEquals(binding.type, value.type) || !typeEquals(this.currentReturnType, value.type))
+ return null;
+ for (const scope of this.scopes) {
+ const index = scope.findIndex((entry) => entry.slot === binding.slot);
+ if (index < 0) continue;
+ const entry = scope[index]!;
+ if (entry.boxed || entry.lazy || !typeEquals(entry.type, value.type)) return null;
+ return { scope, entry, index };
+ }
+ return null;
+ }
+
+ /** A walk host for a body that may write the heap: each walk must read
+ * only locations the whole body (with its callees) never writes;
+ * rebinding the walk locals themselves releases nothing. */
+ private unwrittenWalkHost(fn: IrFunction, host: WalkBorrowHost): WalkBorrowHost {
+ return {
+ ...host,
+ unwritten: (value, candidates) =>
+ this.referenceWrites?.unwrittenWalk(fn, value, candidates, (call) =>
+ this.borrowedReturnKeys(call.callee),
+ ) ?? false,
+ };
+ }
+
+ /** emitReadReceiver yields a plain pointer for `e` that no statement
+ * frame owns, so it can outlive the statement (walk-borrows' walks, from
+ * any unboxed plain-pointer binding, plus element reads). */
+ private unownedWalk(e: IrExpr): boolean {
+ switch (e.kind) {
+ case "varRef": {
+ const binding = this.binding(e.localId);
+ if (binding.kind === "global") return this.plainReference(binding.type);
+ return (
+ binding.kind === "local" &&
+ binding.local !== undefined &&
+ !binding.local.boxed &&
+ !binding.local.tdz &&
+ this.plainReference(binding.local.type) &&
+ !this.stringSlices.has(e.localId) &&
+ !this.storedSplits.has(e.localId) &&
+ !this.localUnionStorage.has(e.localId)
+ );
+ }
+ case "fieldGet":
+ case "recordGet":
+ return this.borrowsWithoutOwning(e) && this.unownedWalk(e.obj);
+ case "unionNarrow":
+ case "downcast":
+ case "upcast":
+ return this.borrowsWithoutOwning(e) && this.unownedWalk(e.value);
+ case "unionWrap":
+ if (!this.borrowsWithoutOwning(e)) return false;
+ return e.value.kind === "unitLit" || this.unownedWalk(e.value);
+ case "ternary": {
+ const arm = (v: IrExpr): boolean =>
+ (v.kind === "libCall" && v.fn === "error.nodeThrow") || this.unownedWalk(v);
+ return this.borrowsWithoutOwning(e) && arm(e.then) && arm(e.else_);
+ }
+ case "call":
+ return (
+ this.borrowsWithoutOwning(e) &&
+ e.args.every((arg) => !isRefCounted(arg.type) || this.unownedWalk(arg))
+ );
+ case "arrayGet":
+ return (
+ !this.isStackUnionSource(e) &&
+ this.optionalArrayReads.get(e) === null &&
+ this.unownedWalk(e.arr)
+ );
+ case "seqExpr": {
+ const guarded = guardedValue(e);
+ return guarded !== null && this.unownedWalk(guarded);
+ }
+ default:
+ return false;
+ }
+ }
+
+ /** Every location a borrowed-return callee's walks can read (its body's
+ * field, record and element reads, through nested borrowed returns). */
+ private borrowedReturnKeys(callee: string): string[] | null {
+ if (!this.borrowedReturns.has(callee)) return null;
+ const known = this.borrowedReturnKeyCache.get(callee);
+ if (known) return known;
+ const rw = this.referenceWrites!;
+ const keys = new Set();
+ const seen = new Set([callee]);
+ const pending = [callee];
+ while (pending.length > 0) {
+ const fn = this.fnByName.get(pending.pop()!);
+ if (!fn) continue;
+ everyStmtList(fn.body, {
+ stmt: () => true,
+ expr: (e) => {
+ if (e.kind === "fieldGet") keys.add(rw.fieldKey(e.className, e.field));
+ else if (e.kind === "recordGet") {
+ keys.add(`r:${e.field}`);
+ keys.add("r:*");
+ } else if (e.kind === "arrayGet") keys.add("a");
+ else if (e.kind === "call" && this.borrowedReturns.has(e.callee) && !seen.has(e.callee)) {
+ seen.add(e.callee);
+ pending.push(e.callee);
+ }
+ return true;
+ },
+ });
+ }
+ const result = [...keys];
+ this.borrowedReturnKeyCache.set(callee, result);
+ return result;
+ }
+
// ── statements ──────────────────────────────────────────────────────────
- private emitStmts(stmts: IrStmt[]): void {
- for (const s of stmts) {
- // Statements after a terminator are unreachable (dead code after
- // return/break/continue). Skip them so no dropped SSA definition
- // can leak forward.
- if (this.B.isTerminated()) return;
- this.emitStmt(s);
+ private emitStmts(stmts: IrStmt[], scoped = false): void {
+ const saved = this.stmtCursor;
+ const cursor = scoped ? { stmts, index: 0 } : null;
+ try {
+ for (let i = 0; i < stmts.length; i++) {
+ // Statements after a terminator are unreachable (dead code after
+ // return/break/continue). Skip them so no dropped SSA definition
+ // can leak forward.
+ if (this.B.isTerminated()) return;
+ if (cursor) cursor.index = i;
+ this.stmtCursor = cursor;
+ this.emitStmt(stmts[i]!);
+ }
+ } finally {
+ this.stmtCursor = saved;
}
}
@@ -5542,7 +5801,7 @@ export class LlEmitter {
const scope: LlScopeEntry[] = [];
this.scopes.push(scope);
setup?.(scope);
- this.emitStmts(stmts);
+ this.emitStmts(stmts, true);
const ended = endsWithJump(stmts);
this.scopes.pop();
if (!ended) this.releaseScope(scope);
@@ -5606,7 +5865,13 @@ export class LlEmitter {
}
const localRead = this.localArrayReads.get(s.localId);
if (localRead) {
- const owner = emitLocalArrayRead(this, localRead, b.slot);
+ const owner = emitLocalArrayRead(
+ this,
+ !localRead.borrow && this.unwrittenLocalArrayRead(s, localRead)
+ ? { ...localRead, borrow: true }
+ : localRead,
+ b.slot,
+ );
if (owner) this.scopes[this.scopes.length - 1]!.push(owner);
break;
}
@@ -5695,7 +5960,7 @@ export class LlEmitter {
b.local &&
isRefCounted(b.type) &&
this.stableCallBindings.has(s.localId) &&
- this.canBorrowCallArgument(s.init)
+ (this.canBorrowCallArgument(s.init) || this.unwrittenAlias(s))
) {
const v = this.emitReadReceiver(s.init);
B.line(`store ptr ${v.name}, ptr ${b.slot}`);
@@ -5761,6 +6026,33 @@ export class LlEmitter {
}
B.line(`store ${this.llType(b.type)} ${v.name}, ptr ${b.slot}`);
this.storeIntegerView(s.localId, v, s.value);
+ if (b.kind === "global" && this.immortalGlobals.has(s.localId)) {
+ // An Immutable module constant (contracts/check.ts proved it is
+ // fresh, publishable and never written): its graph becomes
+ // immortal, so reads skip reference counting and the collector
+ // never visits it.
+ const lines = this.publishWalkers.emitPublish(v.name, v.type, s.loc);
+ for (const line of lines) B.line(line.trimStart());
+ // Reads of this global skip retains from here on, which is only
+ // sound once the graph really is immortal. Node.js never fails
+ // here, so a refusal means the contract proof was wrong: stop
+ // rather than keep running with a mortal graph read as immortal.
+ if (lines.length > 0) {
+ this.declare(`declare zeroext i1 @scr_exc_pending()`);
+ this.declare(`declare void @scr_trap(ptr)`);
+ const refused = B.tmp();
+ B.line(`${refused} = call zeroext i1 @scr_exc_pending()`);
+ const stop = B.newLabel("immutable.refused");
+ const done = B.newLabel("immutable.published");
+ B.condBr(refused, stop, done);
+ B.startBlock(stop);
+ B.line(
+ `call void @scr_trap(ptr ${this.cstr("scriptc: internal error: an Immutable value could not be published\n")})`,
+ );
+ B.terminate("unreachable");
+ B.startBlock(done);
+ }
+ }
break;
}
case "exprStmt":
@@ -5877,21 +6169,21 @@ export class LlEmitter {
const obj = this.emitStableReceiver(s.obj, [s.value]);
// (The guard precedes the value here: the nullable store
// evaluates it itself.)
- this.emitPublishedFieldGuard(obj.name, s.className, s.field);
+ this.emitStoreGuard(obj.name, s);
this.emitNullableFieldStore(obj.name, s.className, s.field, nullable, s.value);
break;
}
if (s.kind === "fieldSet" && this.int32Slots.isField(s.className, s.field)) {
const obj = this.emitStableReceiver(s.obj, [s.value]);
const v = this.emitExpr(s.value);
- this.emitPublishedFieldGuard(obj.name, s.className, s.field);
+ this.emitStoreGuard(obj.name, s);
const { ptr } = this.classFieldPtr(obj.name, s.className, s.field);
this.storeInt32Field(ptr, v, s.value);
break;
}
const obj = this.emitStableReceiver(s.obj, [s.value]);
const v = this.emitExpr(s.value);
- if (s.kind === "fieldSet") this.emitPublishedFieldGuard(obj.name, s.className, s.field);
+ if (s.kind === "fieldSet") this.emitStoreGuard(obj.name, s);
else if (this.publishedTypes?.recordGuarded(s.shapeId) === true)
this.emitPublishedFieldGuard(obj.name, null, s.field);
const { ptr, type } =
@@ -6596,10 +6888,29 @@ export class LlEmitter {
// the actual ret. The parked value owns a synthetic slot-backed
// scope entry during each copy so a throwing finally releases it.
let v: LlValue | null = null;
- if (s.value !== null && this.currentBorrowedReturn) {
+ const moved = this.finallyStack.length === 0 ? this.movableReturn(s.value) : null;
+ if (moved !== null) {
+ // Returning an owned local hands its reference to the caller: no
+ // retain now and no release of the local below.
+ const t = B.tmp();
+ B.line(`${t} = load ptr, ptr ${moved.entry.slot}`);
+ v = { name: t, type: s.value!.type };
+ moved.scope.splice(moved.scope.indexOf(moved.entry), 1);
+ this.releaseForJump(0, 0);
+ moved.scope.splice(moved.index, 0, moved.entry);
+ } else if (s.value !== null && this.currentBorrowedReturn) {
// A borrowed-return body hands back its walk without a reference
- // (it has no try statements, so no finally can intervene).
- v = this.emitReadReceiver(s.value);
+ // (it has no try statements, so no finally can intervene). An
+ // optional element read lends the element its array keeps alive.
+ const read = this.optionalArrayReads.get(s.value);
+ if (read) {
+ const slot = B.slot();
+ B.entryAllocas.push(`${slot} = alloca ptr`);
+ emitLocalArrayRead(this, { ...read, borrow: true }, slot);
+ const t = B.tmp();
+ B.line(`${t} = load ptr, ptr ${slot}`);
+ v = { name: t, type: s.value.type };
+ } else v = this.emitReadReceiver(s.value);
} else if (s.value !== null) {
v = this.emitExpr(s.value);
this.moveTemp(v);
@@ -6631,7 +6942,7 @@ export class LlEmitter {
this.scopes = savedScopes;
this.finallyStack = savedFinally;
this.tryStack = savedTry;
- } else {
+ } else if (moved === null) {
this.releaseForJump(0, 0);
}
if (!B.isTerminated()) {
@@ -6886,7 +7197,7 @@ export class LlEmitter {
const caseLabels = s.cases.map(() => B.newLabel("sw.c"));
const defaultIdx = s.cases.findIndex((c) => c.test === null);
const fallback = defaultIdx >= 0 ? caseLabels[defaultIdx]! : end;
- if (!emitLiteralSwitch(this, disc, s.cases, caseLabels, fallback)) {
+ if (!emitLiteralSwitch(this, disc, s.disc, s.cases, caseLabels, fallback)) {
s.cases.forEach((c, i) => {
if (c.test === null) return;
// Lazy source-order test evaluation (a test after the match never
@@ -7342,6 +7653,13 @@ export class LlEmitter {
];
}
+ /** The frozen-object guard of a class field store, unless the store
+ * provably targets a still-private `this` (PublishedTypes.privateStores). */
+ private emitStoreGuard(obj: string, s: Extract): void {
+ if (this.publishedTypes?.privateStores.has(s) === true) return;
+ this.emitPublishedFieldGuard(obj, s.className, s.field);
+ }
+
/** The frozen-object guard of a class field or record field store:
* Node's "Cannot assign to read only property" TypeError when the target
* is published. `className` null means a record (`#`). */
@@ -7498,6 +7816,10 @@ export class LlEmitter {
if (result.owner) this.ownSlot(result.owner.slot, result.owner.type);
return result.value;
}
+ // A borrowed-return helper (such as an optional element read) lends its
+ // result like any borrowed-return call.
+ if (e.kind === "call" && this.borrowedReturns.has(e.callee) && this.canBorrowReceiver(e))
+ return emitBorrowedResultCall(this, e);
const arrayRead = this.optionalArrayReads.get(e);
if (arrayRead) return emitProjectedArrayRead(this, arrayRead);
const narrow = this.checkedNarrows.get(e);
@@ -7763,17 +8085,21 @@ export class LlEmitter {
const wrap = this.borrowableNullableWrap(e);
return wrap === "unit" || (wrap === "ref" && this.canBorrowReceiver(e.value));
}
- // A borrowed-return callee preserves edges, so its result stays
- // reachable from borrowable arguments until the consumer runs.
- case "call":
- return (
- this.borrowedReturns.has(e.callee) &&
- e.args.every(
- (arg) =>
- (!isRefCounted(arg.type) || this.canBorrowReceiver(arg)) &&
- this.referenceEffects.preserves(arg),
- )
- );
+ // A borrowed-return result stays reachable from its borrowed
+ // arguments until the consumer runs. Each argument is lent without a
+ // snapshot, so neither the callee nor a later argument may replace
+ // what it was read from: a callee that preserves every edge cannot,
+ // and any other one must leave the argument's locations unwritten.
+ case "call": {
+ if (!this.borrowedReturns.has(e.callee)) return false;
+ if (!e.args.every((arg) => this.referenceEffects.preserves(arg))) return false;
+ const preserving = this.referenceEffects.functions.has(e.callee);
+ if (preserving)
+ return e.args.every((arg) => !isRefCounted(arg.type) || this.canBorrowReceiver(arg));
+ if (this.referenceWrites === null) return false;
+ const lent = borrowableInputs(this, e.args, false, this.referenceWrites.summary(e.callee));
+ return e.args.every((arg, i) => !isRefCounted(arg.type) || lent[i] === true);
+ }
// A required element read: the (borrowed) array owns the element
// while an edge-preserving index computes.
case "arrayGet":
@@ -7821,17 +8147,20 @@ export class LlEmitter {
const indexes = this.callLifetimes.borrowed.get(fn.name);
return new Set(fn.params.filter((_, i) => indexes?.has(i)).map((p) => p.localId));
};
- const hostFor = (fn: IrFunction) => {
+ const hostFor = (fn: IrFunction): WalkBorrowHost => {
const allowed = borrowedIds(fn);
- return {
+ const host = {
pointerLocal: (local: IrLocal) => pointer(local.type),
borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e),
borrowedReturn: (callee: string) => this.borrowedReturns.has(callee),
parameterAllowed: (id: string) => allowed.has(id),
};
+ return this.referenceEffects.functions.has(fn.name) ? host : this.unwrittenWalkHost(fn, host);
};
- const preserving = [...this.fnByName.values()].filter((fn) =>
- this.referenceEffects.functions.has(fn.name),
+ // With ReferenceWrites, a body that writes none of its walks' locations
+ // qualifies like one that preserves every edge.
+ const preserving = [...this.fnByName.values()].filter(
+ (fn) => this.referenceEffects.functions.has(fn.name) || this.referenceWrites !== null,
);
const candidates = preserving.filter((fn) => {
if (!pointer(fn.returnType) || fn.async || fn.generator || fn.captures) return false;
@@ -7851,6 +8180,8 @@ export class LlEmitter {
if (this.borrowedReturns.has(fn.name)) continue;
if (!analyzeWalks(fn, hostFor(fn)).returnsWalk) continue;
this.borrowedReturns.add(fn.name);
+ // Nested borrowed returns extend the locations a caller depends on.
+ this.borrowedReturnKeyCache.clear();
changed = true;
}
}
@@ -7875,6 +8206,14 @@ export class LlEmitter {
return this.nullableFieldGet(e) === null;
case "recordGet":
return true;
+ // emitReadReceiver lends a required element of a borrowed array when
+ // computing the index removes no edge.
+ case "arrayGet":
+ return (
+ e.arr.type.kind === "array" &&
+ this.optionalArrayReads.get(e) === null &&
+ this.referenceEffects.preserves(e.index)
+ );
case "unionNarrow":
return this.nullableUnions.has(e.unionId);
case "downcast":
diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts
index d707de555..afd2ae7b0 100644
--- a/packages/compiler/src/backend/llvm/expr-calls.ts
+++ b/packages/compiler/src/backend/llvm/expr-calls.ts
@@ -25,7 +25,13 @@ import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl } from "./
import { canStackUnion, emitStackUnion } from "./stack-unions.js";
import { emitCallArrayRead } from "./local-array-reads.js";
import { emitStackMapRead } from "./map-read-lifetimes.js";
-import { borrowableInputs, emitBorrowedInput, emitBorrowedInputs } from "./borrowed-inputs.js";
+import {
+ borrowableInputs,
+ emitBorrowedInput,
+ emitBorrowedInputs,
+ inputSuffixWrites,
+} from "./borrowed-inputs.js";
+import type { Writes } from "./reference-writes.js";
export function emitCallExpr(
host: LlvmEmitterContext,
@@ -58,12 +64,18 @@ export function emitCallExpr(
const borrowed = host.callLifetimes.borrowed.get(e.callee);
const projected = host.callLifetimes.parameters.get(e.callee);
const inputs = borrowed
- ? borrowableInputs(host, e.args, host.referenceEffects.functions.has(e.callee))
+ ? borrowableInputs(
+ host,
+ e.args,
+ host.referenceEffects.functions.has(e.callee),
+ host.referenceWrites?.summary(e.callee),
+ )
: [];
// Borrowed snapshots live through this call, not through the whole
// enclosing expression. Otherwise a large initializer accumulates
// owners and repeats their cleanup at every later throwing call.
if (borrowed) host.frames.push([]);
+ let suffixes: Writes[] | undefined;
const args = e.args.map((a, index) => {
if (borrowed?.has(index)) {
// A heap-borrowing helper may retain or return its argument.
@@ -73,7 +85,18 @@ export function emitCallExpr(
const callback = host.stackCallbacks.emit(host, a);
if (callback) return callback;
const read = host.callArrayReads.get(a);
- if (read) return emitCallArrayRead(host, read);
+ if (read) {
+ suffixes ??= inputSuffixWrites(
+ host,
+ e.args,
+ host.referenceWrites?.summary(e.callee) ?? null,
+ );
+ const lend =
+ !read.borrow &&
+ index < suffixes.length &&
+ host.unwrittenCallArrayRead(read, suffixes[index]!);
+ return emitCallArrayRead(host, lend ? { ...read, borrow: true } : read);
+ }
const mapRead = host.mapReadLifetimes.arguments.get(a);
if (mapRead) {
const result = emitStackMapRead(host, mapRead);
@@ -739,6 +762,7 @@ export function emitCallExpr(
host,
e.args,
host.referenceEffects.functions.has(ctorName),
+ host.referenceWrites?.constructWrites(e.className),
);
host.frames.push([]);
const args = e.args.map((a, i) => {
@@ -951,7 +975,15 @@ export function emitCallExpr(
// (LlEmitter.computeVirtualBorrowing): those are passed like borrowed
// arguments of a direct call, owned snapshots living in this frame.
const borrowed = host.virtualSlotBorrowed(meta.root.def.name, slotIdx);
- const inputs = borrowed.size > 0 ? borrowableInputs(host, e.args, false) : [];
+ const inputs =
+ borrowed.size > 0
+ ? borrowableInputs(
+ host,
+ e.args,
+ false,
+ host.referenceWrites?.virtualWrites(e.className, e.method),
+ )
+ : [];
if (borrowed.size > 0) host.frames.push([]);
const args = e.args.map((a, index) =>
borrowed.has(index) && inputs[index] ? host.emitReadReceiver(a) : host.emitExpr(a),
diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts
index 4b5c0f876..05dce98fe 100644
--- a/packages/compiler/src/backend/llvm/expr-containers.ts
+++ b/packages/compiler/src/backend/llvm/expr-containers.ts
@@ -4,6 +4,7 @@ import { isStableReceiverOperand, undefinedArmTag } from "../../ir/analysis.js";
import { type IrExpr, type IrType, isRefCounted, typeEquals, typeKey } from "../../ir/ir.js";
import { mangleResolveThunk } from "../mangle.js";
import {
+ arrOps,
elemAccess,
FN_ATTRS,
mapKeyAccess,
@@ -23,11 +24,13 @@ import {
borrowsMapReadInputs,
borrowsMapMutationReceiver,
inlineNumberKey,
+ entryStringKey,
} from "./map-read-lifetimes.js";
import {
emitNumberMapGetRef,
emitNumberMapGetScalar,
emitNumberMapHas,
+ emitStringMapGetRef,
} from "./map-number-lookup.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import { emitStringSliceRead } from "./string-slices.js";
@@ -1131,12 +1134,15 @@ export function emitMapLikeIntrinsic(
return host.own({ name: t, type: e.type });
}
const numberKey = inlineNumberKey(k);
+ const stringKey = entryStringKey(k);
if (value.kind === "union") {
const isnull = B.tmp();
const t = B.tmp();
let raw: string;
if (numberKey !== null) {
raw = emitNumberMapGetRef(host, r.name, numberKey, value);
+ } else if (stringKey !== null) {
+ raw = emitStringMapGetRef(host, r.name, stringKey, value);
} else {
host.declare(`declare ptr @scr_map_get_${kAcc}_ref(ptr, ${kTy})`);
raw = B.tmp();
@@ -1209,6 +1215,10 @@ export function emitMapLikeIntrinsic(
const raw = emitNumberMapGetRef(host, r.name, numberKey, value);
return host.wrapNullable(raw, raw, value, valueTag, e.type, undefTag);
}
+ if (stringKey !== null && isRefCounted(value)) {
+ const raw = emitStringMapGetRef(host, r.name, stringKey, value);
+ return host.wrapNullable(raw, raw, value, valueTag, e.type, undefTag);
+ }
host.declare(`declare ptr @scr_map_get_${kAcc}_ref(ptr, ${kTy})`);
const raw = B.tmp();
B.line(`${raw} = call ptr @scr_map_get_${kAcc}_ref(ptr ${r.name}, ${k.args})`);
@@ -1302,10 +1312,18 @@ export function emitMapLikeIntrinsic(
return { name: "", type: e.type };
case "toArray": {
if (receiverType.kind !== "set") throw new InternalCompilerError("unreachable");
- // Fresh +1 elem[] of the live entries in insertion order.
- host.declare(`declare ptr @scr_set_to_arr_${kAcc}(ptr)`);
+ // Fresh +1 elem[] of the live entries in insertion order; a REF result
+ // takes its element type's array descriptor.
const t = B.tmp();
- B.line(`${t} = call ptr @scr_set_to_arr_${kAcc}(ptr ${r.name})`);
+ if (kAcc === "ref") {
+ host.declare(`declare ptr @scr_set_to_arr_ref(ptr, ptr)`);
+ B.line(
+ `${t} = call ptr @scr_set_to_arr_ref(ptr ${r.name}, ptr ${arrOps(host.shapeHost, receiverType.elem)})`,
+ );
+ } else {
+ host.declare(`declare ptr @scr_set_to_arr_${kAcc}(ptr)`);
+ B.line(`${t} = call ptr @scr_set_to_arr_${kAcc}(ptr ${r.name})`);
+ }
return host.own({ name: t, type: e.type });
}
default: {
diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts
index ea7061c4a..6807b0d07 100644
--- a/packages/compiler/src/backend/llvm/expr-primitives.ts
+++ b/packages/compiler/src/backend/llvm/expr-primitives.ts
@@ -89,8 +89,12 @@ export function emitLiteralExpr(
B.line(`${uint32} = load i32, ptr ${integerView}`);
return { name: t, type: e.type, uint32 };
}
- if (isRefCounted(e.type))
+ if (isRefCounted(e.type)) {
+ // An immortal Immutable constant: nothing to count.
+ if (b.kind === "global" && host.immortalGlobals.has(e.localId))
+ return host.markImmortal({ name: t, type: e.type });
return host.own({ name: host.retainValue(t, e.type), type: e.type });
+ }
return { name: t, type: e.type };
}
default: {
diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts
index d6cd7ff4c..cdce1000c 100644
--- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts
+++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts
@@ -16,7 +16,7 @@ import {
import { mangleFunction, mangleGlobal, mangleRecordStruct } from "../mangle.js";
import { BlockBuilder } from "./blocks.js";
import { classFieldIndex, classStructSym } from "./classes.js";
-import { llvmCommentText } from "./common.js";
+import { SCR_ARR_FIELD, llvmCommentText } from "./common.js";
import {
FN_ATTRS,
elemAccess,
@@ -176,9 +176,9 @@ export function streamTypedRefCommitAdapter(
const storageMembers = [
{ name: "len", index: 1, type: host.sizeType },
{ name: "cap", index: 2, type: host.sizeType },
- { name: "data", index: 7, type: "ptr" },
- { name: "present", index: 8, type: "ptr" },
- { name: "ext", index: 9, type: "ptr" },
+ { name: "data", index: SCR_ARR_FIELD.data, type: "ptr" },
+ { name: "present", index: SCR_ARR_FIELD.present, type: "ptr" },
+ { name: "ext", index: SCR_ARR_FIELD.ext, type: "ptr" },
];
for (const member of storageMembers) {
lines.push(
diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts
index abe88c9d1..73b79b741 100644
--- a/packages/compiler/src/backend/llvm/local-array-reads.ts
+++ b/packages/compiler/src/backend/llvm/local-array-reads.ts
@@ -12,7 +12,7 @@ import { everyStmtList } from "../../ir/traverse.js";
import { analyzeCallLifetimes, type CallLifetimes } from "./call-lifetimes.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { ReferenceEffects } from "./reference-effects.js";
-import { exactIndexLines } from "./common.js";
+import { SCR_ARR_FIELD, exactIndexLines } from "./common.js";
export interface LocalArrayRead {
type: IrType;
@@ -513,7 +513,9 @@ function emitDenseReferenceArrayRead(
statePtr = B.tmp(),
denseState = B.tmp(),
densePresent = B.tmp();
- B.line(`${statesPtr} = getelementptr inbounds %ScrArr, ptr ${array.name}, i32 0, i32 8`);
+ B.line(
+ `${statesPtr} = getelementptr inbounds %ScrArr, ptr ${array.name}, i32 0, i32 ${SCR_ARR_FIELD.present}`,
+ );
host.markMemoryPointer(statesPtr, "array:header");
B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`);
B.line(`${statePtr} = getelementptr inbounds i8, ptr ${states}, ${host.sizeType} ${offset}`);
@@ -526,7 +528,9 @@ function emitDenseReferenceArrayRead(
data = B.tmp(),
valuePtr = B.tmp(),
raw = B.tmp();
- B.line(`${dataPtr} = getelementptr inbounds %ScrArr, ptr ${array.name}, i32 0, i32 7`);
+ B.line(
+ `${dataPtr} = getelementptr inbounds %ScrArr, ptr ${array.name}, i32 0, i32 ${SCR_ARR_FIELD.data}`,
+ );
host.markMemoryPointer(dataPtr, "array:header");
B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
B.line(`${valuePtr} = getelementptr inbounds i64, ptr ${data}, ${host.sizeType} ${offset}`);
diff --git a/packages/compiler/src/backend/llvm/loop-array-borrows.ts b/packages/compiler/src/backend/llvm/loop-array-borrows.ts
index 75435e7a9..07ae3ed9f 100644
--- a/packages/compiler/src/backend/llvm/loop-array-borrows.ts
+++ b/packages/compiler/src/backend/llvm/loop-array-borrows.ts
@@ -3,18 +3,22 @@ import { everyStmtList } from "../../ir/traverse.js";
import type { CallLifetimes } from "./call-lifetimes.js";
import type { ReferenceEffects } from "./reference-effects.js";
import type { OptionalArrayReads } from "./local-array-reads.js";
+import type { ReferenceWrites } from "./reference-writes.js";
/** Limit a borrow to a loop whose complete iteration preserves heap edges.
* Initialization and unrelated work elsewhere in the function may mutate.
* The array remains owned by its unchanged local or global binding; the
* iteration binding can only project it or forward it to proven consumers.
* Callbacks, suspension, capture, reference writes, and escaping uses keep
- * the ordinary owned read. Missing elements retain the runtime's checks. */
+ * the ordinary owned read. Missing elements retain the runtime's checks.
+ * With ReferenceWrites, an iteration that writes no array element (nor the
+ * binding holding the array) suffices, whatever else it writes. */
export function findLoopArrayBorrows(
fn: IrFunction,
lifetimes: CallLifetimes,
effects: ReferenceEffects,
optionalReads?: OptionalArrayReads,
+ writes?: ReferenceWrites | null,
): ReadonlySet {
const result = new Set();
if (fn.async || fn.generator) return result;
@@ -45,19 +49,28 @@ export function findLoopArrayBorrows(
!local.tdz &&
projected?.has(loop.localId) &&
bindings?.has(loop.localId) &&
- effects.preservesScope(loop.body) &&
- effects.preserves(loop.iterable)
+ ((effects.preservesScope(loop.body) && effects.preserves(loop.iterable)) ||
+ // The loop owns the evaluated array; only element removals can
+ // release an element it lends.
+ (writes && !writes.conflicts(["a"], writes.stmts(loop.body))))
)
result.add(loop);
return true;
}
if (loop.kind !== "for" && loop.kind !== "while" && loop.kind !== "doWhile") return true;
- if (
- !effects.preservesScope(loop.body) ||
- (loop.cond && !effects.preserves(loop.cond)) ||
- (loop.kind === "for" && loop.update && !effects.preservesScope([loop.update]))
- )
- return true;
+ const preserving =
+ effects.preservesScope(loop.body) &&
+ (!loop.cond || effects.preserves(loop.cond)) &&
+ (loop.kind !== "for" || !loop.update || effects.preservesScope([loop.update]));
+ const loopWrites =
+ preserving || !writes
+ ? null
+ : writes.union([
+ writes.stmts(loop.body),
+ loop.cond ? writes.expr(loop.cond) : null,
+ loop.kind === "for" && loop.update ? writes.stmt(loop.update) : null,
+ ]);
+ if (!preserving && !writes) return true;
everyStmtList(loop.body, {
expr: () => true,
stmt: (stmt) => {
@@ -71,7 +84,12 @@ export function findLoopArrayBorrows(
return true;
const source =
stmt.init.kind === "arrayGet" ? stmt.init.arr : optionalReads?.get(stmt.init)?.array;
- if (source && stableSource(source)) result.add(stmt);
+ if (!source || !stableSource(source)) return true;
+ if (preserving) result.add(stmt);
+ else {
+ const keys = writes!.readKeys(source);
+ if (keys && !writes!.conflicts(["a", ...keys], loopWrites)) result.add(stmt);
+ }
return true;
},
});
diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.ts b/packages/compiler/src/backend/llvm/map-number-lookup.ts
index af0b91bed..552770852 100644
--- a/packages/compiler/src/backend/llvm/map-number-lookup.ts
+++ b/packages/compiler/src/backend/llvm/map-number-lookup.ts
@@ -7,6 +7,9 @@
* scr_map_entry_f64 otherwise. The generated code then reads the entry's
* value slot itself and retains a reference value with the value type's own
* retain, which is the adapter the collection was constructed with.
+ * String-keyed reference reads use the same entry contract through
+ * scr_map_entry_str, so their retain is inline rather than the runtime's
+ * indirect adapter call.
*/
import type { IrType } from "../../ir/ir.js";
import type { LlvmEmitterContext } from "./expr-context.js";
@@ -31,6 +34,16 @@ export function emitNumberMapSlot(host: LlvmEmitterContext, entry: string, type:
return value;
}
+export const STRING_MAP_ENTRY_DECL = "declare ptr @scr_map_entry_str(ptr, ptr)";
+
+/** The entry for a string `key`, or null when absent. Valid until the next mutation. */
+export function emitStringMapEntry(host: LlvmEmitterContext, map: string, key: string): string {
+ host.declare(STRING_MAP_ENTRY_DECL);
+ const entry = host.B.tmp();
+ host.B.line(`${entry} = call ptr @scr_map_entry_str(ptr ${map}, ptr ${key})`);
+ return entry;
+}
+
/** Map.get of a reference value: a +1 value, or null when absent — the
* contract of scr_map_get_f64_ref. */
export function emitNumberMapGetRef(
@@ -39,8 +52,24 @@ export function emitNumberMapGetRef(
key: string,
value: IrType,
): string {
+ return emitMapEntryGetRef(host, emitNumberMapEntry(host, map, key), value);
+}
+
+/** Map.get of a reference value under a string key, with scr_map_get_str_ref's
+ * contract. The value type's own retain replaces the collection's adapter, so
+ * the retain is inline instead of an indirect call in the runtime. */
+export function emitStringMapGetRef(
+ host: LlvmEmitterContext,
+ map: string,
+ key: string,
+ value: IrType,
+): string {
+ return emitMapEntryGetRef(host, emitStringMapEntry(host, map, key), value);
+}
+
+/** The +1 value of a found entry, or null for a missing (null) entry. */
+function emitMapEntryGetRef(host: LlvmEmitterContext, entry: string, value: IrType): string {
const B = host.B;
- const entry = emitNumberMapEntry(host, map, key);
const found = B.tmp();
B.line(`${found} = icmp ne ptr ${entry}, null`);
const hit = B.newLabel("mapn.hit"),
diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts
index cd1fdf455..3d75432ec 100644
--- a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts
+++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts
@@ -111,6 +111,11 @@ test("local map results keep scalar and owned reference payloads without heap wr
expect(ir).toContain("call ptr @sc_map_entry_f64(");
expect(ir).not.toContain("@scr_map_get_");
} else if (value === BOOL) expect(ir).toMatch(/zext i8 %\w+ to i64/);
+ else if (value === STRING) {
+ // String keys read reference values through the entry and retain inline.
+ expect(ir).toContain("call ptr @scr_map_entry_str(");
+ expect(ir).not.toContain("@scr_map_get_str_ref");
+ }
if (value === STRING) expect(ir).toContain("@scr_str_release");
}
}
diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts
index 27a2fd195..7de971af7 100644
--- a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts
+++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts
@@ -9,7 +9,11 @@ import {
import { everyStmtList } from "../../ir/traverse.js";
import type { CallLifetimes } from "./call-lifetimes.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
-import { emitNumberMapGetRef, emitNumberMapGetScalar } from "./map-number-lookup.js";
+import {
+ emitNumberMapGetRef,
+ emitNumberMapGetScalar,
+ emitStringMapGetRef,
+} from "./map-number-lookup.js";
import { mapKeyAccess, mapKeyParamType } from "./shapes.js";
function hasTypedCollectionReceiver(
@@ -167,6 +171,11 @@ export function inlineNumberKey(key: { access: string; value?: string }): string
return key.access === "f64" && key.value !== undefined ? key.value : null;
}
+/** The key's string operand when a reference read can take the entry path. */
+export function entryStringKey(key: { access: string; value?: string }): string | null {
+ return key.access === "str" && key.value !== undefined ? key.value : null;
+}
+
export interface StackMapRead {
value: LlValue;
owner: { slot: string; type: IrType } | null;
@@ -205,9 +214,14 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead):
B.line(`store i64 ${scalar.bits}, ptr ${payload}`);
}
} else if (isRefCounted(read.value)) {
- const raw = B.tmp();
- host.declare(`declare ptr @scr_map_get_${access}_ref(ptr, ${keyType})`);
- B.line(`${raw} = call ptr @scr_map_get_${access}_ref(ptr ${receiver.name}, ${key.args})`);
+ const stringKey = entryStringKey(key);
+ let raw: string;
+ if (stringKey !== null) raw = emitStringMapGetRef(host, receiver.name, stringKey, read.value);
+ else {
+ raw = B.tmp();
+ host.declare(`declare ptr @scr_map_get_${access}_ref(ptr, ${keyType})`);
+ B.line(`${raw} = call ptr @scr_map_get_${access}_ref(ptr ${receiver.name}, ${key.args})`);
+ }
B.line(`${found} = icmp ne ptr ${raw}, null`);
B.line(`store i64 0, ptr ${payload}`);
B.line(`store ptr ${raw}, ptr ${payload}`);
diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts
new file mode 100644
index 000000000..fac6fd35c
--- /dev/null
+++ b/packages/compiler/src/backend/llvm/reference-writes.ts
@@ -0,0 +1,1095 @@
+import {
+ isRefCounted,
+ type IrClassDef,
+ type IrExpr,
+ type IrFunction,
+ type IrRecordShape,
+ type IrStmt,
+ type IrType,
+ type IrUnionDef,
+} from "../../ir/ir.js";
+import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
+import { borrowsMapMutationReceiver, borrowsMapReadInputs } from "./map-read-lifetimes.js";
+import { preservesRegexInputs, VirtualTargets } from "./reference-effects.js";
+import { borrowsStringInputs } from "./string-lifetimes.js";
+import { preservesDynTest } from "./checked-value-lifetimes.js";
+import { isStableReceiverOperand } from "../../ir/analysis.js";
+import type { ImmutableFields } from "../immutability.js";
+
+type Call = IrExpr & { kind: "call" };
+
+/** A set of reference locations, as a bitset over the interned keys; `null`
+ * is the empty set. Key 0 (`*`) is an unknown heap write: it covers every
+ * heap location but no local of the current function. */
+export type Writes = Uint32Array | null;
+
+/** Every heap location (key 0). */
+const HEAP_KEY = "*";
+
+/** Array element slots of every reference array share one location: arrays
+ * are covariant, so element types do not separate them. */
+const ARRAY_KEY = "a";
+/** Entries of typed maps and sets. */
+const MAP_KEY = "m";
+/** Captured-binding boxes. */
+const BOX_KEY = "bx";
+/** Record fields written through a computed key. */
+const ANY_RECORD_KEY = "r:*";
+
+/** The pseudo-function standing for every indirect call target. */
+const ANY_FUNCTION = "%anyFunctionValue";
+
+/** Value-producing kinds that only move an existing function value. */
+const FUNCTION_MOVES = new Set([
+ "closure",
+ "selfRef",
+ "varRef",
+ "fieldGet",
+ "recordGet",
+ "arrayGet",
+ "call",
+ "virtualCall",
+ "callValue",
+ "ternary",
+ "logical",
+ "nullish",
+ "orDefault",
+ "unionNarrow",
+ "seqExpr",
+ "assignExpr",
+ "optChain",
+ "chainRecv",
+ "upcast",
+ "downcast",
+ "mapIntrinsic",
+ "setIntrinsic",
+ "arrIntrinsic",
+ "recordKeyGet",
+ "unionKeyGet",
+]);
+
+/** Runtime library families that run no user code and never write a
+ * program reference (scalars, strings, byte buffers, files, clocks). */
+const INERT_LIB_PREFIXES = [
+ "math.",
+ "num.",
+ "number.",
+ "string.",
+ "str.",
+ "bigint.",
+ "buffer.",
+ "arrayBuffer.",
+ "sharedArrayBuffer.",
+ "atomics.",
+ "perf.",
+ "date.",
+ "text.",
+ "path.",
+ "fs.",
+ "stats.",
+ "spawnRes.",
+];
+const INERT_LIB_CALLS = new Set([
+ "dyn.compare",
+ "error.new",
+ "error.nodeThrow",
+ "dyn.typedRefIs",
+ "dyn.bagGet",
+ "json.parse",
+ "process.stderrWrite",
+ "process.stdoutWrite",
+ "process.envGet",
+ "process.envPairs",
+ "process.cwd",
+ "process.argv",
+ "process.platform",
+ "process.execPath",
+ "process.isTTY",
+ "worker.data",
+ "worker.isMainThread",
+ "worker.parentPort",
+ "worker.root",
+ "cp.spawnSyncOpts",
+]);
+
+/** Array methods that never release an existing element or run user code. */
+const PRESERVING_ARRAY_METHODS = new Set([
+ "length",
+ "getNumber",
+ "indexEq",
+ "push",
+ "pushSpread",
+ "concatSpread",
+ "unshift",
+ "unshiftSpread",
+ "indexOf",
+ "includes",
+ "nextPresent",
+ "join",
+ "slice",
+ "flatCopy",
+ "flatOne",
+ "toReversed",
+ "toSpliced",
+ "with",
+ "withUndefined",
+ "toSortedPrimitive",
+ // Permutes primitive payloads in place: every element keeps its owner.
+ "sortPrimitive",
+]);
+
+/** Which heap reference locations evaluating code may overwrite or remove.
+ *
+ * ReferenceEffects answers one bit (does anything remove any edge?). This
+ * analysis keeps the location, so a borrow that reads `x.f` only conflicts
+ * with code that can write field `f` of x's hierarchy (or array elements,
+ * map entries, a global or a captured box, for those reads), not with
+ * every reference store. Locations:
+ *
+ * - `f:.` class fields (all classes of a hierarchy
+ * that name the field share it, so redeclarations alias);
+ * - `r:` record fields, plus `r:*` for computed-key writes and
+ * deletions (every record read also depends on `r:*`);
+ * - `a` reference array elements, `m` map/set entries, `bx` boxes;
+ * - `g:` module globals, `l:` the current function's own locals
+ * (dropped from summaries: a callee cannot rebind its caller's locals);
+ * - ALL for anything unknown: dynamic code, callbacks, suspension, unknown
+ * runtime operations.
+ *
+ * Stores a constructor makes into its own `this` write a fresh object that
+ * no borrow outside the construction can reach, so `new` (and `super`
+ * calls from constructors) use a constructor summary without them, as long
+ * as constructors are invoked only through `new` and `super`.
+ *
+ * Facts belong to one finalized module and one emission. */
+export class ReferenceWrites {
+ private readonly keys = new Map();
+ private readonly roots = new Map();
+ private readonly fieldOwners = new Map();
+ private readonly classes = new Map();
+ private readonly virtualTargets: VirtualTargets;
+ private readonly summaries = new Map();
+ private readonly exprCache = new Map();
+ private readonly stmtCache = new Map();
+ private words = 0;
+ /** The unknown heap write alone. */
+ heapAll: Uint32Array = new Uint32Array(1).fill(1);
+ /** Constructors only ever run on fresh objects. */
+ private readonly freshConstructors: boolean;
+ private readonly unions: ReadonlyMap;
+ private readonly records: ReadonlyMap;
+ private readonly inertJson = new Map();
+ /** Closure bodies an indirect call can reach. */
+ private readonly functionValues = new Set();
+ private callTargets!: CallTargets;
+ /** Some function value comes from outside the module's closures. */
+ private foreignFunctionValues = false;
+ private current: IrFunction | null = null;
+ private currentBoxed = new Set();
+ private currentLocals = new Set();
+ private constructors: ReadonlySet = new Set();
+ private inConstructor = false;
+
+ constructor(
+ private readonly functions: ReadonlyMap,
+ classes: readonly IrClassDef[],
+ private readonly intrinsicCall: (call: Call) => boolean,
+ private readonly globals: ReadonlySet,
+ types: {
+ unions?: readonly IrUnionDef[] | undefined;
+ records?: readonly IrRecordShape[] | undefined;
+ } = {},
+ /** Fields no code writes once their object is constructed
+ * (immutability.ts, from cycle-mutability's construction-only facts). */
+ private readonly immutable: ImmutableFields | null = null,
+ ) {
+ this.key(HEAP_KEY);
+ this.unions = new Map((types.unions ?? []).map((u) => [u.id, u]));
+ this.records = new Map((types.records ?? []).map((r) => [r.id, r]));
+ for (const cls of classes) this.classes.set(cls.name, cls);
+ this.virtualTargets = new VirtualTargets(classes);
+ const constructors = new Set(classes.map((cls) => `%${cls.name}.constructor`));
+ this.constructors = constructors;
+ this.freshConstructors = [...functions.values()].every((fn) =>
+ fn.body.every((s) => this.onlySuperCalls(fn, s, constructors)),
+ );
+ this.callTargets = new CallTargets(functions, this.virtualTargets);
+ this.solve(constructors);
+ }
+
+ /** Every direct call of a constructor is a `super` call from a
+ * constructor on its own `this`. */
+ private onlySuperCalls(fn: IrFunction, s: IrStmt, constructors: ReadonlySet): boolean {
+ const thisId = constructors.has(fn.name) ? fn.params[0]?.localId : undefined;
+ const expr = (e: IrExpr): boolean => {
+ if (e.kind === "call" && constructors.has(e.callee)) {
+ const self = e.args[0];
+ if (thisId === undefined || !self || !isThisRef(self, thisId)) return false;
+ }
+ return everyExprChild(e, expr, stmt);
+ };
+ const stmt = (node: IrStmt): boolean => everyStmtChild(node, expr, stmt);
+ return stmt(s);
+ }
+
+ private key(k: string): number {
+ let id = this.keys.get(k);
+ if (id === undefined) {
+ id = this.keys.size;
+ this.keys.set(k, id);
+ }
+ return id;
+ }
+
+ /** The hierarchy root of a class (runtime bases included). */
+ private root(className: string): string {
+ let r = this.roots.get(className);
+ if (r !== undefined) return r;
+ r = className;
+ for (let cls = this.classes.get(className); cls?.base !== undefined;) {
+ r = cls.base;
+ cls = this.classes.get(cls.base);
+ }
+ this.roots.set(className, r);
+ return r;
+ }
+
+ fieldKey(className: string, field: string): string {
+ return `f:${this.root(className)}.${field}`;
+ }
+
+ // ── phase 1: per-function local writes and call edges ─────────────────
+
+ private solve(constructors: ReadonlySet): void {
+ interface Node {
+ local: Set;
+ edges: Set;
+ }
+ const nodes = new Map();
+ const scan = (fn: IrFunction, external: boolean): Node => {
+ const node: Node = { local: new Set(), edges: new Set() };
+ if (fn.async || fn.generator) {
+ node.local.add(0);
+ return node;
+ }
+ const thisId = external && constructors.has(fn.name) ? (fn.params[0]?.localId ?? null) : null;
+ const boxed = boxedLocals(fn);
+ const sink: WriteSink = {
+ add: (k) => {
+ // Interned for queries; a callee's locals are private to it.
+ const id = this.key(k);
+ if (!k.startsWith("l:")) node.local.add(id);
+ },
+ all: () => node.local.add(0),
+ call: (callee, fresh) => {
+ if (callee === ANY_FUNCTION) {
+ node.edges.add(ANY_FUNCTION);
+ return true;
+ }
+ if (!this.functions.has(callee)) return false;
+ node.edges.add(
+ fresh && this.freshConstructors && constructors.has(callee) ? `${callee}#ext` : callee,
+ );
+ return true;
+ },
+ };
+ const expr = (e: IrExpr): boolean => {
+ this.nodeWrites(e, sink, boxed, fn, constructors);
+ if (e.kind === "closure") this.functionValues.add(e.fnName);
+ else if (
+ e.type?.kind === "func" &&
+ !FUNCTION_MOVES.has(e.kind) &&
+ !(e.kind === "libCall" && e.fn === "error.nodeThrow")
+ )
+ this.foreignFunctionValues = true;
+ return everyExprChild(e, expr, stmt);
+ };
+ const stmt = (s: IrStmt): boolean => {
+ if (thisId !== null && s.kind === "fieldSet" && isThisRef(s.obj, thisId)) {
+ // A store into the fresh `this`: no outside borrow can reach it.
+ } else this.stmtWrites(s, sink, boxed);
+ return everyStmtChild(s, expr, stmt);
+ };
+ fn.body.every(stmt);
+ return node;
+ };
+ for (const fn of this.functions.values()) {
+ nodes.set(fn.name, scan(fn, false));
+ if (this.freshConstructors && constructors.has(fn.name))
+ nodes.set(`${fn.name}#ext`, scan(fn, true));
+ }
+ // Indirect calls reach any closure body; values made elsewhere
+ // (dynamic conversions) reach unknown code.
+ nodes.set(ANY_FUNCTION, {
+ local: new Set(this.foreignFunctionValues ? [0] : []),
+ edges: new Set([...this.functionValues].filter((name) => this.functions.has(name))),
+ });
+ this.words = (this.keys.size + 31) >>> 5;
+ this.heapAll = new Uint32Array(this.words);
+ this.heapAll[0] = 1;
+ // Tarjan's SCCs, iteratively; components come out callees first.
+ const index = new Map();
+ const low = new Map();
+ const onStack = new Set();
+ const stack: string[] = [];
+ let counter = 0;
+ for (const start of nodes.keys()) {
+ if (index.has(start)) continue;
+ const work: { name: string; edges: Iterator }[] = [];
+ const enter = (name: string): void => {
+ index.set(name, counter);
+ low.set(name, counter++);
+ stack.push(name);
+ onStack.add(name);
+ work.push({ name, edges: nodes.get(name)!.edges.values() });
+ };
+ enter(start);
+ while (work.length > 0) {
+ const top = work[work.length - 1]!;
+ const next = top.edges.next();
+ if (!next.done) {
+ const callee = next.value;
+ if (!nodes.has(callee)) continue;
+ if (!index.has(callee)) enter(callee);
+ else if (onStack.has(callee))
+ low.set(top.name, Math.min(low.get(top.name)!, index.get(callee)!));
+ continue;
+ }
+ work.pop();
+ if (work.length > 0) {
+ const parent = work[work.length - 1]!.name;
+ low.set(parent, Math.min(low.get(parent)!, low.get(top.name)!));
+ }
+ if (low.get(top.name) !== index.get(top.name)) continue;
+ const members: string[] = [];
+ for (;;) {
+ const m = stack.pop()!;
+ onStack.delete(m);
+ members.push(m);
+ if (m === top.name) break;
+ }
+ const bits = new Uint32Array(this.words);
+ const inComponent = new Set(members);
+ for (const m of members) {
+ const node = nodes.get(m)!;
+ for (const id of node.local) setBit(bits, id);
+ for (const callee of node.edges) {
+ if (inComponent.has(callee)) continue;
+ const s = this.summaries.get(callee);
+ if (s === undefined || s === null) continue;
+ orInto(bits, s);
+ }
+ }
+ // An unknown heap write subsumes the precise heap keys.
+ const summary: Writes = (bits[0]! & 1) !== 0 ? this.heapAll : anyBit(bits) ? bits : null;
+ for (const m of members) this.summaries.set(m, summary);
+ }
+ }
+ }
+
+ /** Writes of one expression node itself (children are separate). */
+ private nodeWrites(
+ e: IrExpr,
+ sink: WriteSink,
+ boxed: ReadonlySet,
+ fn: IrFunction | null,
+ constructors: ReadonlySet,
+ ): void {
+ switch (e.kind) {
+ case "call": {
+ const fresh =
+ fn !== null &&
+ constructors.has(e.callee) &&
+ constructors.has(fn.name) &&
+ e.args[0] !== undefined &&
+ isThisRef(e.args[0], fn.params[0]?.localId ?? "");
+ if (!sink.call(e.callee, fresh) && !this.intrinsicCall(e)) sink.all();
+ return;
+ }
+ case "new":
+ if (!sink.call(`%${e.className}.constructor`, true)) sink.all();
+ return;
+ case "virtualCall": {
+ const targets = this.virtualTargets.targets(e.className, e.method);
+ if (targets === null) sink.all();
+ else for (const t of targets) if (!sink.call(t, false)) sink.all();
+ return;
+ }
+ case "assignExpr":
+ case "incDec":
+ if (isRefCounted(e.type)) sink.add(this.localKey(e.localId, boxed));
+ return;
+ case "callValue": {
+ const targets = fn !== null ? this.callTargets.of(e.callee, fn) : null;
+ if (targets === null) {
+ if (!sink.call(ANY_FUNCTION, false)) sink.all();
+ } else for (const t of targets) if (!sink.call(t, false)) sink.all();
+ return;
+ }
+ case "arrIntrinsic":
+ if (!PRESERVING_ARRAY_METHODS.has(e.method)) {
+ // The comparator sort orders a private snapshot.
+ if (e.method === "sortValues") {
+ const comparator = e.args[1];
+ const targets =
+ fn !== null && comparator !== undefined ? this.callTargets.of(comparator, fn) : null;
+ if (targets === null) {
+ if (!sink.call(ANY_FUNCTION, false)) sink.all();
+ } else for (const t of targets) if (!sink.call(t, false)) sink.all();
+ }
+ sink.add(ARRAY_KEY);
+ }
+ return;
+ case "mapIntrinsic":
+ case "setIntrinsic":
+ if (borrowsMapReadInputs(e)) return;
+ if (borrowsMapMutationReceiver(e)) sink.add(MAP_KEY);
+ else if (e.receiver.type.kind === "map" || e.receiver.type.kind === "set") {
+ // Typed collections: copies and iteration bookkeeping run no
+ // user code; leaving an iteration may compact removed entries.
+ if (e.method === "iterEnter" || e.method === "iterExit") sink.add(MAP_KEY);
+ else if (
+ e.method !== "clone" &&
+ e.method !== "keySet" &&
+ e.method !== "valueSet" &&
+ e.method !== "toArray"
+ )
+ sink.all();
+ } else sink.all();
+ return;
+ case "intrinsic":
+ // A TDZ test reads; publication only makes objects immortal.
+ if (e.name !== "module.tdzCheck" && e.name !== "threads.publish") sink.all();
+ return;
+ case "dynFrom":
+ case "dynArrLit":
+ case "dynObjLit":
+ return;
+ case "dynCheck":
+ // Identity and kind tests of class and primitive targets.
+ if (!this.checkedWithoutCode(e.type)) sink.all();
+ return;
+ case "jsonStringify":
+ if (!this.jsonInert(e.value.type)) sink.all();
+ return;
+ case "bytesNew":
+ case "bytesIntrinsic":
+ return;
+ case "strIntrinsic":
+ if (!borrowsStringInputs(e.method)) sink.all();
+ return;
+ case "regexIntrinsic":
+ if (!preservesRegexInputs(e.method)) sink.all();
+ return;
+ case "libCall":
+ if (!this.inertLibCall(e)) sink.all();
+ return;
+ case "toString":
+ if (!this.primitive(e.operand.type)) sink.all();
+ return;
+ case "dynTest":
+ if (!preservesDynTest(e.test)) sink.all();
+ return;
+ // Reads, tests, arithmetic and fresh allocations.
+ case "numLit":
+ case "boolLit":
+ case "strLit":
+ case "unitLit":
+ case "varRef":
+ case "bin":
+ case "unary":
+ case "toBool":
+ case "logical":
+ case "nullish":
+ case "ternary":
+ case "seqExpr":
+ case "fieldGet":
+ case "recordGet":
+ case "unionNarrow":
+ case "unionIsTag":
+ case "unionWrap":
+ case "downcast":
+ case "upcast":
+ case "instanceOf":
+ case "strConcat":
+ case "strEq":
+ case "strCmp":
+ case "arrayGet":
+ case "arrayHas":
+ case "arrayState":
+ case "unionEq":
+ case "unionFuncEq":
+ case "dynScalarEq":
+ case "caughtTest":
+ case "caughtNarrow":
+ case "caughtCheck":
+ case "classRef":
+ case "recordLit":
+ case "arrayLit":
+ case "arrayNewLen":
+ case "closure":
+ case "mapNew":
+ case "setNew":
+ case "regexLit":
+ case "recordHas":
+ case "recordKeyGet":
+ case "recordOvfHas":
+ case "recordOvfKeys":
+ case "fieldAbsent":
+ case "unionDisc":
+ case "unionKeyGet":
+ case "fieldIncDec":
+ case "optChain":
+ case "chainRecv":
+ case "orDefault":
+ case "instanceOfValue":
+ case "promiseVoidWiden":
+ return;
+ default:
+ sink.all();
+ }
+ }
+
+ /** Writes of one statement node itself. */
+ private stmtWrites(s: IrStmt, sink: WriteSink, boxed: ReadonlySet): void {
+ switch (s.kind) {
+ case "assign":
+ if (isRefCounted(s.value.type)) sink.add(this.localKey(s.localId, boxed));
+ return;
+ case "fieldSet":
+ if (isRefCounted(s.value.type)) sink.add(this.fieldKey(s.className, s.field));
+ return;
+ case "recordSet":
+ if (isRefCounted(s.value.type)) sink.add(`r:${s.field}`);
+ return;
+ case "recordKeySet":
+ case "recordKeyDelete":
+ sink.add(ANY_RECORD_KEY);
+ return;
+ case "arraySet":
+ case "arraySetUndefined":
+ case "arrayDelete":
+ case "arraySetLength":
+ sink.add(ARRAY_KEY);
+ return;
+ case "varDecl":
+ case "exprStmt":
+ case "return":
+ case "throw":
+ case "runtimeFence":
+ case "if":
+ case "for":
+ case "forOf":
+ case "while":
+ case "doWhile":
+ case "block":
+ case "switch":
+ case "break":
+ case "continue":
+ case "bytesSet":
+ case "tryCatch":
+ case "rethrow":
+ return;
+ default:
+ sink.all();
+ }
+ }
+
+ /** Library calls that run no user code: inert families without callback
+ * arguments, and an Error options bag that is a typed record (no
+ * accessors) or absent. */
+ private inertLibCall(e: IrExpr & { kind: "libCall" }): boolean {
+ if (e.fn === "error.nodeThrow" || isStableReceiverOperand(e, "")) return true;
+ if (e.fn === "error.newOptions")
+ return e.args.every(
+ (arg) =>
+ arg.type.kind !== "dyn" ||
+ (arg.kind === "dynFrom" &&
+ (arg.value.type.kind === "record" || this.primitive(arg.value.type))),
+ );
+ if (!INERT_LIB_CALLS.has(e.fn) && !INERT_LIB_PREFIXES.some((p) => e.fn.startsWith(p)))
+ return false;
+ return e.args.every(
+ (arg) =>
+ arg.type.kind !== "func" &&
+ (arg.type.kind !== "dyn" ||
+ (e.fn !== "dyn.compare" && e.fn.startsWith("dyn.")) ||
+ // A wrapped primitive has no methods to call back into.
+ (arg.kind === "dynFrom" && this.primitive(arg.value.type))),
+ );
+ }
+
+ /** Numbers, booleans, strings and unit values (and unions of them). */
+ private primitive(t: IrType): boolean {
+ switch (t.kind) {
+ case "f64":
+ case "bool":
+ case "string":
+ case "undefinedT":
+ case "nullT":
+ return true;
+ case "union":
+ return this.unions.get(t.unionId)?.arms.every((arm) => this.primitive(arm)) ?? false;
+ default:
+ return false;
+ }
+ }
+
+ /** A dynamic-to-typed check that only tests identity or kind. */
+ private checkedWithoutCode(t: IrType): boolean {
+ if (t.kind === "object" || this.primitive(t)) return true;
+ if (t.kind !== "union") return false;
+ return (
+ this.unions
+ .get(t.unionId)
+ ?.arms.every((arm) => arm.kind === "object" || this.primitive(arm)) ?? false
+ );
+ }
+
+ /** JSON.stringify of this type calls no toJSON or accessor: primitives
+ * and records/arrays/unions of them (class instances may define both). */
+ private jsonInert(t: IrType): boolean {
+ if (this.primitive(t)) return true;
+ const key = JSON.stringify(t);
+ const known = this.inertJson.get(key);
+ if (known !== undefined) return known;
+ this.inertJson.set(key, true); // recursive shapes: assume, then confirm
+ let result: boolean;
+ if (t.kind === "array") result = this.jsonInert(t.elem);
+ else if (t.kind === "record")
+ result = this.records.get(t.shapeId)?.fields.every((f) => this.jsonInert(f.type)) ?? false;
+ else if (t.kind === "union")
+ result = this.unions.get(t.unionId)?.arms.every((arm) => this.jsonInert(arm)) ?? false;
+ else result = false;
+ this.inertJson.set(key, result);
+ return result;
+ }
+
+ private localKey(id: string, boxed: ReadonlySet, locals?: ReadonlySet): string {
+ if (this.globals.has(id)) return `g:${id}`;
+ // A binding of another function (never expected) counts as a box.
+ if (boxed.has(id) || (locals !== undefined && !locals.has(id))) return BOX_KEY;
+ return `l:${id}`;
+ }
+
+ // ── queries ────────────────────────────────────────────────────────────
+
+ /** Start answering expression queries for one function body. */
+ enter(fn: IrFunction): void {
+ this.current = fn;
+ this.currentBoxed = boxedLocals(fn);
+ this.currentLocals = new Set(fn.locals.map((local) => local.id));
+ this.inConstructor = this.constructors.has(fn.name);
+ for (const p of fn.params) this.currentLocals.add(p.localId);
+ this.exprCache.clear();
+ this.stmtCache.clear();
+ }
+
+ private readonly bodyWrites = new Map();
+
+ /** Whether a walk of `fn` reads only locations its whole body never
+ * writes; the candidate walk locals' own rebinding is exempt. */
+ unwrittenWalk(
+ fn: IrFunction,
+ value: IrExpr,
+ candidates: ReadonlySet,
+ call: (e: Call) => readonly string[] | null,
+ ): boolean {
+ if (this.current !== fn) this.enter(fn);
+ const keys = this.readKeys(value, call);
+ if (keys === null) return false;
+ let body = this.bodyWrites.get(fn.name);
+ if (body === undefined) {
+ body = this.stmts(fn.body);
+ this.bodyWrites.set(fn.name, body);
+ }
+ return !this.conflicts(
+ keys.filter((k) => !(k.startsWith("l:") && candidates.has(k.slice(2)))),
+ body,
+ );
+ }
+
+ /** The transitive writes of a call to a module function (`#ext` for a
+ * constructor reached through `new`). */
+ summary(name: string): Writes {
+ const known = this.summaries.get(name);
+ return known === undefined ? this.heapAll : known;
+ }
+
+ constructWrites(className: string): Writes {
+ const ctor = `%${className}.constructor`;
+ if (!this.functions.has(ctor)) return this.heapAll;
+ return this.summary(this.freshConstructors ? `${ctor}#ext` : ctor);
+ }
+
+ virtualWrites(className: string, method: string): Writes {
+ const targets = this.virtualTargets.targets(className, method);
+ if (targets === null) return this.heapAll;
+ return this.union(targets.map((t) => this.summary(t)));
+ }
+
+ /** Writes of evaluating an expression, with its operands. */
+ expr(e: IrExpr): Writes {
+ const known = this.exprCache.get(e);
+ if (known !== undefined) return known;
+ const parts: Writes[] = [];
+ const sink = this.querySink(parts);
+ this.nodeWrites(e, sink, this.currentBoxed, this.current, new Set());
+ everyExprChild(
+ e,
+ (child) => (parts.push(this.expr(child)), true),
+ (child) => (parts.push(this.stmt(child)), true),
+ );
+ const result = this.union(parts);
+ this.exprCache.set(e, result);
+ return result;
+ }
+
+ stmt(s: IrStmt): Writes {
+ const known = this.stmtCache.get(s);
+ if (known !== undefined) return known;
+ const parts: Writes[] = [];
+ this.stmtWrites(s, this.querySink(parts), this.currentBoxed);
+ everyStmtChild(
+ s,
+ (child) => (parts.push(this.expr(child)), true),
+ (child) => (parts.push(this.stmt(child)), true),
+ );
+ const result = this.union(parts);
+ this.stmtCache.set(s, result);
+ return result;
+ }
+
+ stmts(list: readonly IrStmt[]): Writes {
+ return this.union(list.map((s) => this.stmt(s)));
+ }
+
+ private querySink(parts: Writes[]): WriteSink {
+ return {
+ add: (k) => parts.push(this.bitsOf([k])),
+ all: () => parts.push(this.heapAll),
+ call: (callee, fresh) => {
+ if (callee === ANY_FUNCTION) {
+ parts.push(this.summary(ANY_FUNCTION));
+ return true;
+ }
+ if (!this.functions.has(callee)) return false;
+ const ctor = fresh && callee.endsWith(".constructor") && this.freshConstructors;
+ parts.push(this.summary(ctor ? `${callee}#ext` : callee));
+ return true;
+ },
+ };
+ }
+
+ union(parts: readonly Writes[]): Writes {
+ let out: Uint32Array | null = null;
+ for (const p of parts) {
+ if (p === null) continue;
+ if (out === null) out = new Uint32Array(this.words);
+ orInto(out, p);
+ }
+ return out;
+ }
+
+ /** A bitset over known keys; keys nothing writes are dropped. */
+ bitsOf(keys: readonly string[]): Writes {
+ let out: Uint32Array | null = null;
+ for (const k of keys) {
+ const id = this.keys.get(k);
+ if (id === undefined || id >= this.words * 32) continue;
+ if (out === null) out = new Uint32Array(this.words);
+ setBit(out, id);
+ }
+ return out;
+ }
+
+ /** Whether writes may touch any of the read keys. */
+ conflicts(reads: readonly string[], writes: Writes): boolean {
+ if (writes === null) return false;
+ const heap = (writes[0]! & 1) !== 0;
+ for (const k of reads) {
+ if (heap && !k.startsWith("l:")) return true;
+ const id = this.keys.get(k);
+ if (id === undefined || id >= this.words * 32) continue;
+ if ((writes[id >>> 5]! & (1 << (id & 31))) !== 0) return true;
+ }
+ return false;
+ }
+
+ /** The locations whose writes could release the object a borrowed read
+ * of `e` yields, or null when `e` is not such a projection. `call`
+ * resolves borrowed-return callees to the locations their walks read. */
+ readKeys(e: IrExpr, call?: (e: Call) => readonly string[] | null): string[] | null {
+ const out: string[] = [];
+ const walk = (node: IrExpr): boolean => {
+ switch (node.kind) {
+ case "varRef":
+ out.push(this.localKey(node.localId, this.currentBoxed, this.currentLocals));
+ return true;
+ case "fieldGet":
+ // A construction-only field of an existing object never changes;
+ // only a constructor can still be writing it (its own `this`).
+ if (this.inConstructor || !this.immutable?.classField(node.className, node.field))
+ out.push(this.fieldKey(node.className, node.field));
+ return walk(node.obj);
+ case "recordGet":
+ if (this.inConstructor || !this.immutable?.recordField(node.shapeId, node.field))
+ out.push(`r:${node.field}`, ANY_RECORD_KEY);
+ return walk(node.obj);
+ case "arrayGet":
+ out.push(ARRAY_KEY);
+ return this.expr(node.index) === null && walk(node.arr);
+ case "unionNarrow":
+ case "upcast":
+ case "downcast":
+ return walk(node.value);
+ case "unionWrap":
+ return node.value.kind === "unitLit" || walk(node.value);
+ case "ternary":
+ return this.expr(node.cond) === null && armWalk(node.then) && armWalk(node.else_);
+ case "seqExpr": {
+ // Guarded projections: `(check, value)` where the checks only
+ // read and throw.
+ if (this.stmts(node.stmts) !== null) return false;
+ return walk(node.result);
+ }
+ case "strLit":
+ return true;
+ case "call": {
+ const keys = call?.(node) ?? null;
+ if (keys === null) return false;
+ out.push(...keys);
+ return node.args.every((arg) => !isRefCounted(arg.type) || walk(arg));
+ }
+ default:
+ return false;
+ }
+ };
+ const armWalk = (node: IrExpr): boolean =>
+ (node.kind === "libCall" && node.fn === "error.nodeThrow") || walk(node);
+ return walk(e) ? out : null;
+ }
+}
+
+interface WriteSink {
+ add(key: string): void;
+ all(): void;
+ /** False when the callee is not a module function. */
+ call(callee: string, fresh: boolean): boolean;
+}
+
+function isThisRef(e: IrExpr, thisId: string): boolean {
+ if (e.kind === "varRef") return e.localId === thisId;
+ if (e.kind === "upcast") return isThisRef(e.value, thisId);
+ return false;
+}
+
+/** Locals living in boxes: declared boxed or captured from an enclosing
+ * function. */
+function boxedLocals(fn: IrFunction): Set {
+ const out = new Set();
+ for (const local of fn.locals) if (local.boxed) out.add(local.id);
+ for (const c of fn.captures ?? []) out.add(c.localId);
+ for (const c of fn.classCaptures ?? []) out.add(c.localId);
+ return out;
+}
+
+/** The closure bodies a function value can be, when it flows only from
+ * closure expressions through stable locals and the function-typed
+ * parameters of functions that are only called directly (null: any
+ * function value). A small flow analysis over parameter positions: each
+ * position collects the targets every direct, virtual and `new` call site
+ * passes, to a fixpoint. */
+class CallTargets {
+ private readonly sites = new Map();
+ private readonly opaque = new Set();
+ private readonly params = new Map | null>();
+ private readonly definitions = new Map>();
+ /** Functions creating each closure body. */
+ private readonly creators = new Map();
+
+ constructor(
+ private readonly functions: ReadonlyMap,
+ virtualTargets: VirtualTargets,
+ ) {
+ const site = (callee: string, caller: IrFunction, args: readonly IrExpr[]): void => {
+ let list = this.sites.get(callee);
+ if (!list) this.sites.set(callee, (list = []));
+ list.push({ caller, args });
+ };
+ for (const fn of functions.values()) {
+ if (fn.captures !== undefined || fn.classCaptures !== undefined) this.opaque.add(fn.name);
+ const expr = (e: IrExpr): boolean => {
+ if (e.kind === "call") site(e.callee, fn, e.args);
+ else if (e.kind === "new") site(`%${e.className}.constructor`, fn, [e, ...e.args]);
+ else if (e.kind === "virtualCall") {
+ const targets = virtualTargets.targets(e.className, e.method);
+ for (const t of targets ?? []) site(t, fn, e.args);
+ } else if (e.kind === "closure") {
+ this.opaque.add(e.fnName);
+ let list = this.creators.get(e.fnName);
+ if (!list) this.creators.set(e.fnName, (list = []));
+ list.push(fn);
+ }
+ return everyExprChild(e, expr, stmt);
+ };
+ const stmt = (st: IrStmt): boolean => everyStmtChild(st, expr, stmt);
+ fn.body.every(stmt);
+ }
+ // Fixpoint over function-typed parameter positions.
+ const positions: [IrFunction, number][] = [];
+ for (const fn of functions.values())
+ fn.params.forEach((p, i) => {
+ if (p.type.kind === "func") {
+ positions.push([fn, i]);
+ this.params.set(`${fn.name}#${i}`, new Set());
+ }
+ });
+ for (let changed = true; changed;) {
+ changed = false;
+ for (const [fn, i] of positions) {
+ const key = `${fn.name}#${i}`;
+ const known = this.params.get(key);
+ if (known === null) continue;
+ const sites = this.sites.get(fn.name);
+ let next: Set | null = new Set(known!);
+ if (this.opaque.has(fn.name) || !sites || this.assigned(fn, fn.params[i]!.localId))
+ next = null;
+ else
+ for (const { caller, args } of sites) {
+ const arg = args[i];
+ const t = arg === undefined ? null : this.resolve(arg, caller, 0);
+ if (t === null) {
+ next = null;
+ break;
+ }
+ for (const name of t) next.add(name);
+ }
+ if (next === null || next.size !== known!.size) {
+ this.params.set(key, next);
+ changed = true;
+ }
+ }
+ }
+ }
+
+ of(callee: IrExpr, fn: IrFunction): readonly string[] | null {
+ const t = this.resolve(callee, fn, 0);
+ return t === null ? null : [...t];
+ }
+
+ private resolve(e: IrExpr, fn: IrFunction, depth: number): ReadonlySet | null {
+ if (depth > 8) return null;
+ switch (e.kind) {
+ case "closure":
+ return new Set([e.fnName]);
+ case "ternary":
+ case "logical":
+ case "nullish":
+ case "orDefault": {
+ const arms = e.kind === "ternary" ? [e.then, e.else_] : [e.left, e.right];
+ const out = new Set();
+ for (const arm of arms) {
+ if (arm.type.kind !== "func") {
+ if (arm.kind === "libCall" && arm.fn === "error.nodeThrow") continue;
+ if (e.kind === "ternary") return null;
+ continue; // the tested operand of `a ?? f`, `a || f`
+ }
+ const t = this.resolve(arm, fn, depth + 1);
+ if (t === null) return null;
+ for (const name of t) out.add(name);
+ }
+ return out;
+ }
+ case "varRef": {
+ if ((fn.captures ?? []).some((c) => c.localId === e.localId)) {
+ // A captured binding nothing rebinds holds what it held in every
+ // function creating this closure.
+ const creators = this.creators.get(fn.name);
+ if (!creators || this.assigned(fn, e.localId)) return null;
+ const out = new Set();
+ for (const creator of creators) {
+ const t = this.resolve(e, creator, depth + 1);
+ if (t === null) return null;
+ for (const name of t) out.add(name);
+ }
+ return out;
+ }
+ const index = fn.params.findIndex((p) => p.localId === e.localId);
+ if (index >= 0) {
+ if (this.assigned(fn, e.localId)) return null;
+ return this.params.get(`${fn.name}#${index}`) ?? null;
+ }
+ const def = this.definition(fn, e.localId);
+ return def ? this.resolve(def, fn, depth + 1) : null;
+ }
+ case "call": {
+ // A function value returned by a module function: whatever every
+ // return yields (adapters return closures over their argument).
+ const callee = this.functions.get(e.callee);
+ if (!callee || callee.async || callee.generator) return null;
+ const out = new Set();
+ let ok = true;
+ let any = false;
+ const stmt = (st: IrStmt): boolean => {
+ if (st.kind === "return") {
+ any = true;
+ const t = st.value ? this.resolve(st.value, callee, depth + 1) : null;
+ if (t === null) return (ok = false);
+ for (const name of t) out.add(name);
+ }
+ return everyStmtChild(st, () => true, stmt);
+ };
+ callee.body.every(stmt);
+ return ok && any ? out : null;
+ }
+ default:
+ return null;
+ }
+ }
+
+ /** The single initializer of a never-assigned, unboxed local. */
+ private definition(fn: IrFunction, id: string): IrExpr | null {
+ let defs = this.definitions.get(fn);
+ if (!defs) {
+ defs = new Map();
+ const boxed = new Set(fn.locals.filter((l) => l.boxed).map((l) => l.id));
+ const counts = new Map();
+ const expr = (e: IrExpr): boolean => {
+ if (e.kind === "assignExpr" || e.kind === "incDec") counts.set(e.localId, 2);
+ return everyExprChild(e, expr, stmt);
+ };
+ const stmt = (st: IrStmt): boolean => {
+ if (st.kind === "varDecl") {
+ counts.set(st.localId, (counts.get(st.localId) ?? 0) + 1);
+ defs!.set(st.localId, st.init);
+ } else if (st.kind === "assign") counts.set(st.localId, 2);
+ return everyStmtChild(st, expr, stmt);
+ };
+ fn.body.every(stmt);
+ for (const [local, n] of counts) if (n !== 1 || boxed.has(local)) defs.set(local, null);
+ this.definitions.set(fn, defs);
+ }
+ return defs.get(id) ?? null;
+ }
+
+ private assigned(fn: IrFunction, id: string): boolean {
+ this.definition(fn, id);
+ const defs = this.definitions.get(fn)!;
+ return defs.has(id);
+ }
+}
+
+function setBit(bits: Uint32Array, id: number): void {
+ const w = id >>> 5;
+ bits[w] = bits[w]! | (1 << (id & 31));
+}
+
+function orInto(bits: Uint32Array, other: Uint32Array): void {
+ for (let w = 0; w < bits.length; w++) bits[w] = bits[w]! | other[w]!;
+}
+
+function anyBit(bits: Uint32Array): boolean {
+ for (let w = 0; w < bits.length; w++) if (bits[w] !== 0) return true;
+ return false;
+}
diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts
index 7f3e28d5d..2a3b983e7 100644
--- a/packages/compiler/src/backend/llvm/shapes.ts
+++ b/packages/compiler/src/backend/llvm/shapes.ts
@@ -27,7 +27,7 @@ import {
mangleRecordTrace,
} from "../mangle.js";
import { cycTypeDescriptor, emitObjectAlloc, emitObjectFree } from "./alloc.js";
-import { llvmCommentText } from "./common.js";
+import { SCR_ARR_FIELD, llvmCommentText } from "./common.js";
import type { NullableUnions } from "./nullable-unions.js";
import { LlvmUnsupportedError } from "./unsupported.js";
@@ -197,7 +197,7 @@ export function releaseSym(host: ShapeHost, t: IrType): string {
/** How a runtime family decides whether an object carries a ScrCycHdr:
* never, always, or per object through the trace slots its C release
- * tests (`a->elem_trace`, `m->key_trace || m->val_trace`). */
+ * tests (`a->cyc`, `m->key_trace || m->val_trace`). */
type RcCycle = "none" | "always" | "arr" | "map";
interface InlineRcFamily {
@@ -318,9 +318,9 @@ export function inlineRcDecls(host: ShapeHost): string[] {
function rcHeaderTest(cycle: "arr" | "map", yes: string, no: string): string[] {
if (cycle === "arr") {
return [
- ` %tracep = getelementptr inbounds %ScrArr, ptr %o, i32 0, i32 6 ; elem_trace`,
- ` %trace = load ptr, ptr %tracep`,
- ` %headered = icmp ne ptr %trace, null`,
+ ` %cycp = getelementptr inbounds %ScrArr, ptr %o, i32 0, i32 ${SCR_ARR_FIELD.cyc} ; cyc`,
+ ` %cyc = load i32, ptr %cycp`,
+ ` %headered = icmp ne i32 %cyc, 0`,
` br i1 %headered, label %${yes}, label %${no}`,
];
}
@@ -566,7 +566,7 @@ function elemKindNum(elem: IrType): number {
/** Array construction call text: ref elements
* (records, unions, closures — and cycle-capable inner arrays, whose
* SCR_ELEM_ARR spelling would hide them from the outer array's trace)
- * construct through scr_arr_new_ref with the element type's `_v` RC entry
+ * construct through scr_arr_new_ops with the element type's `_v` RC entry
* points; every other element kind keeps the plain scr_arr_new call. */
export function arrNewCall(host: ShapeHost, elem: IrType, capText: string): string {
const useRef =
@@ -590,9 +590,21 @@ export function arrNewCall(host: ShapeHost, elem: IrType, capText: string): stri
host.declare(`declare ptr @scr_arr_new(i32, ${host.sizeType})`);
return `call ptr @scr_arr_new(i32 ${elemKindNum(elem)}, ${host.sizeType} ${capText})`;
}
+ host.declare(`declare ptr @scr_arr_new_ops(ptr, ${host.sizeType})`);
+ return `call ptr @scr_arr_new_ops(ptr ${arrOps(host, elem)}, ${host.sizeType} ${capText})`;
+}
+
+/** The constant %ScrArrOps descriptor (retain, release, trace) of a REF
+ * element type, defined once per module and shared by all its arrays. */
+export function arrOps(host: ShapeHost, elem: IrType): string {
const v = vAdapters(host, elem);
- host.declare(`declare ptr @scr_arr_new_ref(ptr, ptr, ptr, ${host.sizeType})`);
- return `call ptr @scr_arr_new_ref(ptr ${v.retain}, ptr ${v.release}, ptr ${traceArg(host, elem)}, ${host.sizeType} ${capText})`;
+ const trace = traceArg(host, elem);
+ const part = (sym: string): string => sym.replace(/^@/, "").replace(/[^A-Za-z0-9_.]/g, "_");
+ const name = `@sc_arrops.${part(v.retain)}.${part(v.release)}.${part(trace)}`;
+ host.declare(
+ `${name} = internal constant %ScrArrOps { ptr ${v.retain}, ptr ${v.release}, ptr ${trace} }`,
+ );
+ return name;
}
/* ── capture boxes ────────────────────────────────────────────────────── */
diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts
index 7aec30dd6..89c707c19 100644
--- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts
+++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts
@@ -167,18 +167,23 @@ test("string operands preserve their left-to-right snapshot when a later assignm
expect(facts(f).parameters.has("replace")).toBe(false);
});
-test("a later global write keeps mutable string arguments owned", () => {
+test("a callee's global write cannot replace the caller's mutable string locals", () => {
const source = fn("source", [], str("next"));
source.body.unshift({ kind: "assign", localId: "%g.changed", value: str("effect"), loc });
const compare = fn("compare", ["text"], equal(ref("text"), call("source", [], STRING)));
- // A later write anywhere in this function rules out parameter borrowing.
+ // A write anywhere in this function rules out parameter borrowing, but
+ // the rebound local itself owns its value across the later operand.
compare.body.unshift({ kind: "assign", localId: "text", value: str("first"), loc });
const module = mod(source, compare);
module.globals = [{ id: "%g.changed", name: "changed", type: STRING, mutable: true }];
const ir = body(module, "sc_f_compare");
const sourceCall = ir.indexOf("@sc_f_source");
expect(sourceCall).toBeGreaterThan(0);
- expect(ir.slice(0, sourceCall)).toContain("@scr_str_retain_v");
+ expect(ir.slice(0, sourceCall)).not.toContain("@scr_str_retain_v");
+ // Reading the global the later operand replaces still takes a snapshot.
+ compare.body = [ret(equal(ref("%g.changed"), call("source", [], STRING)))];
+ const global = body(module, "sc_bf_compare");
+ expect(global.slice(0, global.indexOf("@sc_f_source"))).toContain("@scr_str_retain_v");
});
test("immutable lexical owners survive arbitrary later operands", () => {
@@ -214,8 +219,22 @@ test("reference fields borrow across preserving calls but snapshot across writes
source.locals.push({ id: "changed", name: "changed", type: STRING, mutable: true });
source.body.unshift({ kind: "assign", localId: "changed", value: str("effect"), loc });
expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v");
+ // A global write replaces no record field: the projection still borrows.
source.body.unshift({ kind: "assign", localId: "%g.changed", value: str("effect"), loc });
- module.globals = [{ id: "%g.changed", name: "changed", type: STRING, mutable: true }];
+ module.globals = [
+ { id: "%g.changed", name: "changed", type: STRING, mutable: true },
+ { id: "%g.record", name: "record", type: record, mutable: true },
+ ];
+ expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v");
+ // Writing the projected field of any record does replace it.
+ source.body.unshift({
+ kind: "recordSet",
+ obj: ref("%g.record", record),
+ shapeId: "text",
+ field: "value",
+ value: str("effect"),
+ loc,
+ });
expect(body(module, "sc_bf_compare")).toContain("@scr_str_retain_v");
f.body = [ret(equal(str("constant"), projection))];
expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v");
@@ -287,7 +306,10 @@ const methodCases: {
inline?: boolean;
}[] = [
{ method: "length", args: [], result: F64, target: "utf16_len", inline: true },
- { method: "charCodeAt", args: [num(1)], result: F64, target: "char_code_at", inline: true },
+ // An exact integer index skips the double conversion; any other index
+ // enters through the double ABI.
+ { method: "charCodeAt", args: [num(1)], result: F64, target: "char_code_at_int", inline: true },
+ { method: "charCodeAt", args: [num(1.5)], result: F64, target: "char_code_at", inline: true },
{ method: "charAt", args: [num(1)], result: STRING, target: "char_at" },
{ method: "indexOf", args: [ref("argument"), num(1)], result: F64, target: "index_of" },
{ method: "includes", args: [ref("argument")], result: BOOL, target: "includes" },
@@ -321,7 +343,7 @@ const methodCases: {
];
test.each(methodCases)(
- "$method passes borrowed string inputs through its runtime ABI",
+ "$method ($target) passes borrowed string inputs through its runtime ABI",
({ method, args, result, target, inline }) => {
const f = fn(
"method",
@@ -342,7 +364,7 @@ test.each(methodCases)(
},
);
-test("string search snapshots both receiver and needle before a numeric argument writes globally", () => {
+test("string search borrows rebound locals but snapshots a global a later argument writes", () => {
const position = fn("position", [], num(0));
position.body.unshift({ kind: "assign", localId: "%g.changed", value: str("effect"), loc });
const search = fn(
@@ -361,9 +383,16 @@ test("string search snapshots both receiver and needle before a numeric argument
const searchCall = ir.indexOf("@scr_str_index_of");
expect(start).toBeGreaterThan(0);
expect(searchCall).toBeGreaterThan(start);
- // Literals need no owner, but both reads take independent snapshots.
- expect(ir.slice(0, start).match(/@scr_str_retain_v/g)).toHaveLength(2);
- expect(ir.slice(searchCall)).toContain("@scr_str_release");
+ // The rebound locals own their values across the call: no snapshots.
+ expect(ir.slice(0, start)).not.toContain("@scr_str_retain_v");
+ // A receiver read from the global the numeric argument replaces does.
+ search.body = [
+ ret(intrinsic(ref("%g.changed"), "indexOf", [ref("needle"), call("position", [], F64)], F64)),
+ ];
+ const global = body(module, "sc_bf_search");
+ const call2 = global.indexOf("@sc_f_position");
+ expect(global.slice(0, call2).match(/@scr_str_retain_v/g)).toHaveLength(1);
+ expect(global.slice(global.indexOf("@scr_str_index_of"))).toContain("@scr_str_release");
});
test("global string ownership cannot be inferred from an immutable-looking reference", () => {
diff --git a/packages/compiler/src/backend/llvm/switch-dispatch.test.ts b/packages/compiler/src/backend/llvm/switch-dispatch.test.ts
index e49b9e36e..57c12728e 100644
--- a/packages/compiler/src/backend/llvm/switch-dispatch.test.ts
+++ b/packages/compiler/src/backend/llvm/switch-dispatch.test.ts
@@ -7,6 +7,11 @@ const number = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, l
const string = (value: string): IrExpr => ({ kind: "strLit", value, type: STRING, loc });
function emit(type: IrType, tests: (IrExpr | null)[], pointerBits: 32 | 64 = 64): string {
+ return emitWith({ kind: "varRef", localId: "value", type, loc }, tests, pointerBits);
+}
+
+function emitWith(disc: IrExpr, tests: (IrExpr | null)[], pointerBits: 32 | 64 = 64): string {
+ const type = disc.kind === "varRef" ? disc.type : F64;
const mod: IrModule = {
irVersion: 15,
sourceFile: loc.file,
@@ -22,7 +27,7 @@ function emit(type: IrType, tests: (IrExpr | null)[], pointerBits: 32 | 64 = 64)
body: [
{
kind: "switch",
- disc: { kind: "varRef", localId: "value", type, loc },
+ disc,
cases: tests.map((test, index) => ({
test,
body: [{ kind: "return", value: number(index), loc }],
@@ -39,22 +44,37 @@ function emit(type: IrType, tests: (IrExpr | null)[], pointerBits: 32 | 64 = 64)
)![0];
}
-test("integer dispatch guards range and fractions before selecting a native case", () => {
- for (const [values, conversion, bits] of [
- [[-2147483648, -1, 0, 2147483647], "fptosi", "i32"],
- [[0, 1, 2147483648, 4294967295], "fptoui", "i32"],
- [[-9007199254740991, -1, 0, 9007199254740991], "fptosi", "i64"],
+test("integer dispatch round-trips a frozen conversion before selecting a native case", () => {
+ for (const [values, bits] of [
+ [[-2147483648, -1, 0, 2147483647], "i32"],
+ [[0, 1, 2147483648, 4294967295], "i64"],
+ [[-9007199254740991, -1, 0, 9007199254740991], "i64"],
] as const) {
const body = emit(F64, values.map(number));
expect(body).toContain(`switch ${bits}`);
- expect(body).toContain("fcmp oge double");
- expect(body).toContain("fcmp ole double");
- expect(body).toContain(`${conversion} double`);
+ expect(body).toContain(`fptosi double`);
+ expect(body).toContain(`freeze ${bits}`);
+ expect(body).toContain(`sitofp ${bits}`);
expect(body).toContain("fcmp oeq double");
- expect(body.indexOf("br i1")).toBeLessThan(body.indexOf(`${conversion} double`));
+ // NaN, infinities and out-of-range values fail the exact round trip;
+ // no separate range tests are needed.
+ expect(body).not.toContain("fcmp oge double");
+ expect(body).not.toContain("fcmp ole double");
+ expect(body.indexOf("freeze")).toBeLessThan(body.indexOf("br i1"));
}
});
+test("a proven integer discriminant selects without converting", () => {
+ const value: IrExpr = { kind: "varRef", localId: "value", type: F64, loc };
+ const disc: IrExpr = { kind: "bin", op: "|", left: value, right: number(0), type: F64, loc };
+ const body = emitWith(disc, [1, 2, 3, 4].map(number));
+ expect(body).toContain("switch i32");
+ // The ToInt32 coercion of `value | 0` converts once; dispatch adds no
+ // round trip of its own.
+ expect(body).not.toContain("freeze");
+ expect(body).not.toContain("fcmp oeq double");
+});
+
test("duplicate numeric labels keep the first destination and share zero", () => {
const body = emit(F64, [-0, 0, 1, 2, 3, 1].map(number));
const dispatch = body.split("\n").find((line) => line.includes("switch i32"))!;
diff --git a/packages/compiler/src/backend/llvm/switch-dispatch.ts b/packages/compiler/src/backend/llvm/switch-dispatch.ts
index 6e11ffdfb..b02ab2d9e 100644
--- a/packages/compiler/src/backend/llvm/switch-dispatch.ts
+++ b/packages/compiler/src/backend/llvm/switch-dispatch.ts
@@ -1,5 +1,6 @@
import type { IrExpr, IrStmt } from "../../ir/ir.js";
-import { f64Lit } from "./common.js";
+import { withinInt32 } from "../../ir/integer-ranges.js";
+import { exactInteger, widenInteger } from "./integer-values.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
type SwitchCase = Extract["cases"][number];
@@ -16,6 +17,7 @@ function integerLiteral(expr: IrExpr): number | null {
function emitIntegerSwitch(
host: LlvmEmitterContext,
disc: LlValue,
+ discExpr: IrExpr | undefined,
cases: readonly SwitchCase[],
labels: readonly string[],
fallback: string,
@@ -36,30 +38,49 @@ function emitIntegerSwitch(
if (targets.size < 4) return false;
const B = host.B;
- const unsigned = min >= 0 && max > 2147483647 && max <= 4294967295;
- const bits = unsigned || (min >= -2147483648 && max <= 2147483647) ? "i32" : "i64";
- const low = B.tmp();
- const high = B.tmp();
- const inside = B.tmp();
- const convert = B.newLabel("sw.convert");
- const dispatch = B.newLabel("sw.integer");
- B.line(`${low} = fcmp oge double ${disc.name}, ${f64Lit(min)}`);
- B.line(`${high} = fcmp ole double ${disc.name}, ${f64Lit(max)}`);
- B.line(`${inside} = and i1 ${low}, ${high}`);
- B.condBr(inside, convert, fallback);
- B.startBlock(convert);
- const integer = B.tmp();
+ const bits = min >= -2147483648 && max <= 2147483647 ? "i32" : "i64";
+ let selector: string;
+ const integer = exactInteger(host, disc, discExpr);
+ if (integer) {
+ // A proven exact integer selects directly. Narrowing is exact when its
+ // range fits the case width; otherwise dispatch on the full value.
+ const wide = widenInteger(host, integer);
+ if (bits === "i64" || !withinInt32(integer.range)) {
+ selector = wide;
+ return terminateSwitch(host, "i64", selector, targets, fallback);
+ }
+ selector = B.tmp();
+ B.line(`${selector} = trunc i64 ${wide} to i32`);
+ return terminateSwitch(host, bits, selector, targets, fallback);
+ }
+ // freeze keeps NaN and out-of-range conversions from producing poison.
+ // The exact round trip then rejects every value that is not an integer
+ // of the case width: NaN and infinities compare unordered, a frozen
+ // out-of-range conversion converts back to a different value, and -0
+ // selects case 0 exactly like strict equality.
+ const raw = B.tmp();
const restored = B.tmp();
const exact = B.tmp();
- // Ordered range checks dominate conversion: NaN and out-of-range values
- // must not produce poison. The round trip rejects fractional inputs.
- B.line(`${integer} = ${unsigned ? "fptoui" : "fptosi"} double ${disc.name} to ${bits}`);
- B.line(`${restored} = ${unsigned ? "uitofp" : "sitofp"} ${bits} ${integer} to double`);
+ const dispatch = B.newLabel("sw.integer");
+ selector = B.tmp();
+ B.line(`${raw} = fptosi double ${disc.name} to ${bits}`);
+ B.line(`${selector} = freeze ${bits} ${raw}`);
+ B.line(`${restored} = sitofp ${bits} ${selector} to double`);
B.line(`${exact} = fcmp oeq double ${disc.name}, ${restored}`);
B.condBr(exact, dispatch, fallback);
B.startBlock(dispatch);
- B.terminate(
- `switch ${bits} ${integer}, label %${fallback} [ ${[...targets]
+ return terminateSwitch(host, bits, selector, targets, fallback);
+}
+
+function terminateSwitch(
+ host: LlvmEmitterContext,
+ bits: "i32" | "i64",
+ selector: string,
+ targets: ReadonlyMap,
+ fallback: string,
+): true {
+ host.B.terminate(
+ `switch ${bits} ${selector}, label %${fallback} [ ${[...targets]
.map(([value, label]) => `${bits} ${value}, label %${label}`)
.join(" ")} ]`,
);
@@ -121,11 +142,13 @@ function emitStringSwitch(
export function emitLiteralSwitch(
host: LlvmEmitterContext,
disc: LlValue,
+ discExpr: IrExpr | undefined,
cases: readonly SwitchCase[],
labels: readonly string[],
fallback: string,
): boolean {
- if (disc.type.kind === "f64") return emitIntegerSwitch(host, disc, cases, labels, fallback);
+ if (disc.type.kind === "f64")
+ return emitIntegerSwitch(host, disc, discExpr, cases, labels, fallback);
if (disc.type.kind === "string") return emitStringSwitch(host, disc, cases, labels, fallback);
return false;
}
diff --git a/packages/compiler/src/backend/llvm/walk-borrows.ts b/packages/compiler/src/backend/llvm/walk-borrows.ts
index 7ba048573..dc3e03c19 100644
--- a/packages/compiler/src/backend/llvm/walk-borrows.ts
+++ b/packages/compiler/src/backend/llvm/walk-borrows.ts
@@ -17,6 +17,11 @@ export interface WalkBorrowHost {
/** Parameters that may become candidates; once calling conventions are
* fixed, only borrowed parameters qualify. Defaults to every parameter. */
parameterAllowed?(localId: string): boolean;
+ /** For bodies that may write the heap: whether a walk only reads
+ * locations the whole body never writes, given the current candidates
+ * (whose own rebinding releases nothing). Absent: the body preserves
+ * every edge. */
+ unwritten?(value: IrExpr, candidates: ReadonlySet): boolean;
}
export interface WalkFacts {
@@ -34,7 +39,9 @@ export interface WalkFacts {
* calling convention.
*
* Soundness rests on the whole body preserving heap edges (the caller
- * passes only functions in ReferenceEffects.functions): neither the body
+ * passes only functions in ReferenceEffects.functions, or supplies
+ * `unwritten` to admit only walks over locations the body never writes;
+ * the argument below then applies to those locations): neither the body
* nor anything it calls removes a field, element or global reference, so
* every object reachable from a parameter at entry stays reachable (and
* alive) until the function returns. Such a local therefore needs no
@@ -133,6 +140,10 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts {
case "fieldGet":
case "recordGet":
return host.borrowsWithoutOwning(e) && walk(e.obj);
+ // A required element of a walked array (the read still traps when the
+ // element is missing).
+ case "arrayGet":
+ return host.borrowsWithoutOwning(e) && walk(e.arr);
case "unionNarrow":
case "downcast":
case "upcast":
@@ -155,12 +166,20 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts {
return false;
}
};
- const walkOrThrow = (e: IrExpr): boolean =>
- (e.kind === "libCall" && e.fn === "error.nodeThrow") || walk(e);
+ const isThrow = (e: IrExpr): boolean => e.kind === "libCall" && e.fn === "error.nodeThrow";
+ const walkOrThrow = (e: IrExpr): boolean => isThrow(e) || walk(e);
for (let changed = true; changed;) {
changed = false;
for (const id of candidates) {
- if (definitions.get(id)!.every((value) => value === null || walk(value))) continue;
+ if (
+ definitions
+ .get(id)!
+ .every(
+ (value) =>
+ value === null || (walk(value) && host.unwritten?.(value, candidates) !== false),
+ )
+ )
+ continue;
candidates.delete(id);
changed = true;
}
@@ -169,6 +188,11 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts {
const returnsWalk =
!guarded &&
returns.length > 0 &&
- returns.every((value) => value !== null && walkOrThrow(value));
+ returns.every(
+ (value) =>
+ value !== null &&
+ walkOrThrow(value) &&
+ (isThrow(value) || host.unwritten?.(value, candidates) !== false),
+ );
return { locals: result, returnsWalk };
}
diff --git a/packages/compiler/src/backend/may-throw.test.ts b/packages/compiler/src/backend/may-throw.test.ts
index fee54361c..c593de309 100644
--- a/packages/compiler/src/backend/may-throw.test.ts
+++ b/packages/compiler/src/backend/may-throw.test.ts
@@ -526,3 +526,41 @@ test("throw-only, storage and primitive library operations stay bounded in worke
// Only a library call that can reach a dynamic value's conversion polls.
expect([...computeMayThrow(mod).workerEntryPolls!].sort()).toEqual(["dynamic"]);
});
+
+test("explain names the first throwing operation and each call edge", () => {
+ const message: IrExpr = {
+ kind: "strLit",
+ value: "Cannot read properties of undefined (reading 'length')",
+ type: STRING,
+ loc,
+ };
+ const code: IrExpr = { kind: "strLit", value: "", type: STRING, loc };
+ const typeError: IrExpr = { kind: "numLit", value: 1, type: F64, loc };
+ const nodeThrow: IrExpr = {
+ kind: "libCall",
+ fn: "error.nodeThrow",
+ args: [typeError, code, message],
+ type: VOID,
+ loc,
+ };
+ const callLoc = { file: "tdz.ts", start: 5, end: 9 };
+ const mod = moduleWith(
+ fn("thrower", [exprStmt(nodeThrow)], []),
+ fn(
+ "caller",
+ [exprStmt({ kind: "call", callee: "thrower", args: [], type: VOID, loc: callLoc })],
+ [],
+ ),
+ fn("quiet", [exprStmt(value)], []),
+ );
+ const result = computeMayThrow(mod, { explain: true });
+ expect([...result.fns].sort()).toEqual(["caller", "thrower"]);
+ expect(result.why?.get("thrower")).toEqual({
+ kind: "seed",
+ what: "throws TypeError: Cannot read properties of undefined (reading 'length')",
+ loc,
+ });
+ expect(result.why?.get("caller")).toEqual({ kind: "call", callee: "thrower", loc: callLoc });
+ // Without the option the analysis records nothing extra.
+ expect(computeMayThrow(mod).why).toBeUndefined();
+});
diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts
index 55effebad..8f0bab18e 100644
--- a/packages/compiler/src/backend/may-throw.ts
+++ b/packages/compiler/src/backend/may-throw.ts
@@ -1,12 +1,19 @@
/* Cheap whole-module may-throw analysis (see computeMayThrow). Pure function
* of the IR module; the emitter consults the result to place unwind checks. */
-import type { IrExpr, IrStmt, IrModule, IrType } from "../ir/ir.js";
+import type { IrExpr, IrStmt, IrModule, IrType, SrcLoc } from "../ir/ir.js";
import { isFfiCallbackParam, MAY_THROW_ARR_METHODS, MAY_THROW_BYTES_METHODS } from "../ir/ir.js";
import { MAY_THROW_LIB_FNS } from "../ir/builtin-effects.js";
import { everyStmtList } from "../ir/traverse.js";
import { hasRetainedFfiCallback } from "./ffi-callbacks.js";
import { computePublishedTypes, writesPublished } from "./publication.js";
+/** Why a function may throw (computeMayThrow's `explain` option): its own
+ * throwing operation, or the first call edge to a function that may throw. */
+export type MayThrowReason =
+ | { kind: "seed"; what: string; loc: SrcLoc | null }
+ | { kind: "call"; callee: string; loc: SrcLoc | null }
+ | { kind: "indirect"; target: string; loc: SrcLoc | null };
+
/** Cheap may-throw analysis (cost discipline: functions that transitively
* CANNOT throw pay for no pending-exception checks). A function may throw
* iff it contains a `throw`, calls a may-throw function (direct `call` or
@@ -19,9 +26,11 @@ import { computePublishedTypes, writesPublished } from "./publication.js";
* surface a catchable rejection/error and seed the fixpoint like a `throw`. */
export function computeMayThrow(
mod: IrModule,
- options: { stackChecks?: boolean } = {},
+ options: { stackChecks?: boolean; explain?: boolean } = {},
): {
fns: Set;
+ /** With `explain`: the reason each may-throw function was marked. */
+ why?: Map;
indirect: boolean;
/** Worker programs: the functions that poll for termination on entry. */
workerEntryPolls?: Set;
@@ -37,7 +46,13 @@ export function computeMayThrow(
throws: boolean;
callees: string[];
callsValue: boolean;
+ /** With `explain`: the first throwing operation, the first site of each
+ * callee, and the first indirect call. */
+ seed?: MayThrowReason;
+ calleeLocs?: Map;
+ callValueLoc?: SrcLoc;
}
+ const explain = options.explain === true;
const facts = new Map();
const closureTargets = new Set();
// FUNC-targeted dynChecks synthesize adapter closures outside the IR's
@@ -70,6 +85,7 @@ export function computeMayThrow(
}
for (const fn of mod.functions) {
const f: Facts = { throws: false, callees: [], callsValue: false };
+ if (explain) f.calleeLocs = new Map();
// TDZ reads and non-initializing writes can throw ReferenceError.
// Capture locals carry the same flag as the declaring binding.
const tdzIds = new Set([...tdzGlobals, ...fn.locals.filter((l) => l.tdz).map((l) => l.id)]);
@@ -79,6 +95,8 @@ export function computeMayThrow(
]);
// Traverse typed executable nodes without copying the IR into unknown.
const visit = (rec: IrExpr | IrStmt): boolean => {
+ const threw = f.throws;
+ const callees = f.callees.length;
if (published !== null && writesPublished(published, rec)) f.throws = true;
switch (rec.kind) {
case "throw":
@@ -304,19 +322,40 @@ export function computeMayThrow(
}
case "callValue":
f.callsValue = true;
+ if (explain) f.callValueLoc ??= rec.loc;
break;
case "closure":
closureTargets.add(rec.fnName);
break;
}
+ if (explain) {
+ if (!threw && f.throws)
+ f.seed = { kind: "seed", what: describeThrowingNode(rec), loc: rec.loc };
+ for (let i = callees; i < f.callees.length; i++)
+ if (!f.calleeLocs!.has(f.callees[i]!)) f.calleeLocs!.set(f.callees[i]!, rec.loc);
+ if (rec.kind === "newValue" && !f.calleeLocs!.has("\0classValue"))
+ f.calleeLocs!.set("\0classValue", rec.loc);
+ }
return true;
};
everyStmtList(fn.body, { expr: visit, stmt: visit });
// A worker can be terminated at any of its polls: the noncatchable
// stop unwinds like an exception from every function that polls.
- if (terminations?.polls.has(fn.name)) f.throws = true;
+ if (terminations?.polls.has(fn.name)) {
+ if (explain && !f.throws)
+ f.seed = { kind: "seed", what: "polls for worker termination", loc: fn.loc };
+ f.throws = true;
+ }
// A stack-overflow check throws Node's RangeError from the entry.
- if (stackChecks?.has(fn.name)) f.throws = true;
+ if (stackChecks?.has(fn.name)) {
+ if (explain && !f.throws)
+ f.seed = {
+ kind: "seed",
+ what: "can recurse (directly or through callbacks), so the stack guard may throw RangeError",
+ loc: fn.loc,
+ };
+ f.throws = true;
+ }
facts.set(fn.name, f);
}
@@ -326,13 +365,23 @@ export function computeMayThrow(
const indirectCallers: string[] = [];
const may = new Set();
const pending: string[] = [];
- const mark = (name: string): void => {
+ const why = explain ? new Map() : null;
+ const mark = (name: string, reason?: MayThrowReason): void => {
if (may.has(name)) return;
may.add(name);
pending.push(name);
+ if (why !== null && reason !== undefined) why.set(name, reason);
};
+ const callEdge = (caller: string, callee: string): MayThrowReason | undefined =>
+ why === null
+ ? undefined
+ : { kind: "call", callee, loc: facts.get(caller)?.calleeLocs?.get(callee) ?? null };
+ const indirectEdge = (caller: string, target: string): MayThrowReason | undefined =>
+ why === null
+ ? undefined
+ : { kind: "indirect", target, loc: facts.get(caller)?.callValueLoc ?? null };
for (const [name, f] of facts) {
- if (f.throws) mark(name);
+ if (f.throws) mark(name, f.seed);
if (f.callsValue) indirectCallers.push(name);
for (const callee of new Set(f.callees)) {
let list = callers.get(callee);
@@ -347,32 +396,128 @@ export function computeMayThrow(
// is reached at most once, without materializing every descendant edge.
const throwingHierarchies = new Set();
let indirect = sawDynFuncAdapter;
- if (indirect) for (const name of indirectCallers) mark(name);
+ if (indirect) for (const name of indirectCallers) mark(name, indirectEdge(name, ""));
for (let i = 0; i < pending.length; i++) {
const name = pending[i]!;
if (!indirect && closureTargets.has(name) && !asyncFns.has(name) && !genFns.has(name)) {
indirect = true;
- for (const caller of indirectCallers) mark(caller);
+ for (const caller of indirectCallers) mark(caller, indirectEdge(caller, name));
}
- for (const caller of callers.get(name) ?? []) mark(caller);
+ for (const caller of callers.get(name) ?? []) mark(caller, callEdge(caller, name));
let cls = constructorClasses.get(name);
while (cls !== undefined && !throwingHierarchies.has(cls)) {
const def = classes.get(cls);
if (!def) break;
throwingHierarchies.add(cls);
const valueCallers = classValueCallers.get(cls);
- if (valueCallers) for (const caller of valueCallers) mark(caller);
+ if (valueCallers)
+ for (const caller of valueCallers)
+ mark(
+ caller,
+ why === null
+ ? undefined
+ : {
+ kind: "call",
+ callee: name,
+ loc: facts.get(caller)?.calleeLocs?.get("\0classValue") ?? null,
+ },
+ );
cls = def.base;
}
}
return {
fns: may,
+ ...(why !== null ? { why } : {}),
indirect,
...(terminations ? { workerEntryPolls: entryPolls(mod, terminations.entry, stackChecks) } : {}),
...(stackChecks ? { stackChecks } : {}),
};
}
+/** Error constructors by runtime error kind (SCR_ERR_* in scr_runtime.h). */
+const ERROR_KIND_NAMES: readonly string[] = [
+ "Error",
+ "TypeError",
+ "RangeError",
+ "SyntaxError",
+ "DOMException",
+ "ReferenceError",
+ "EvalError",
+ "URIError",
+];
+
+/** A short description of a node that seeds the may-throw fixpoint. */
+function describeThrowingNode(rec: IrExpr | IrStmt): string {
+ switch (rec.kind) {
+ case "throw":
+ return "throw statement";
+ case "rethrow":
+ return "rethrow";
+ case "runtimeFence":
+ return "deferred compile fence";
+ case "varRef":
+ case "incDec":
+ case "assignExpr":
+ case "assign":
+ return "binding accessed before initialization (ReferenceError)";
+ case "dynCheck":
+ case "caughtCheck":
+ return "checked cast of a dynamic value (TypeError)";
+ case "dynCall":
+ case "dynInvoke":
+ return "call through a dynamic value";
+ case "dynKeyGet":
+ case "dynHasKey":
+ return "property access on a dynamic value (undefined or null receiver)";
+ case "dynDestrCheck":
+ case "dynIterN":
+ return "destructuring a dynamic value";
+ case "awaitExpr":
+ case "awaitUnionExpr":
+ return "await (a rejected promise throws)";
+ case "yieldExpr":
+ case "genResume":
+ return "generator resumption";
+ case "libCall": {
+ // Compiler-resolved Node throws: name the error and its message.
+ if (rec.fn === "error.nodeThrow" || rec.fn === "error.new") {
+ const nodeThrow = rec.fn === "error.nodeThrow";
+ const kind = rec.args[0];
+ const message = nodeThrow ? rec.args[2] : rec.args[0];
+ const name = nodeThrow
+ ? kind?.kind === "numLit"
+ ? (ERROR_KIND_NAMES[kind.value] ?? "Error")
+ : "Error"
+ : rec.type.kind === "object"
+ ? rec.type.className.replace(/^%/, "")
+ : "Error";
+ return message?.kind === "strLit" ? `throws ${name}: ${message.value}` : `throws ${name}`;
+ }
+ return `library call ${rec.fn}`;
+ }
+ case "intrinsic":
+ return `intrinsic ${rec.name}`;
+ case "strIntrinsic":
+ case "regexIntrinsic":
+ case "arrIntrinsic":
+ case "bytesIntrinsic":
+ case "mapIntrinsic":
+ case "setIntrinsic":
+ return `${rec.kind} ${rec.method}`;
+ case "arraySetLength":
+ return "array length assignment (RangeError)";
+ case "jsonStringify":
+ return "JSON.stringify of a possibly circular value (TypeError)";
+ case "toString":
+ return "string conversion of a dynamic value";
+ case "fieldSet":
+ case "recordSet":
+ return "write into a possibly published object (TypeError)";
+ default:
+ return rec.kind;
+ }
+}
+
/** The termination polls a worker program places on function entry. A
* stack-checked synchronous function needs none: it may throw (the stack
* guard seeds the fixpoint), so every caller tests the pending state after
diff --git a/packages/compiler/src/backend/publication.ts b/packages/compiler/src/backend/publication.ts
index 7040539dd..4f21bec5f 100644
--- a/packages/compiler/src/backend/publication.ts
+++ b/packages/compiler/src/backend/publication.ts
@@ -4,6 +4,7 @@
import type { IrExpr, IrModule, IrStmt, IrType } from "../ir/ir.js";
import { typeKey } from "../ir/ir.js";
import { everyStmtList } from "../ir/traverse.js";
+import { computeConstructionFacts } from "./immutability.js";
/** Array methods that write their receiver (the guarded ones). */
export const MUTATING_ARRAY_METHODS: ReadonlySet = new Set([
@@ -45,11 +46,19 @@ export class PublishedTypes {
readonly records = new Set();
/** typeKeys of array, map and set types. */
readonly containers = new Set();
+ /** Constructor stores into a `this` nothing else can reach yet
+ * (immutability.ts): never a published target, so never guarded. */
+ privateStores: ReadonlySet = new Set();
classGuarded(className: string): boolean {
return this.classes.has(className);
}
+ /** Whether a class field store needs the guard. */
+ fieldStoreGuarded(s: IrStmt): boolean {
+ return s.kind === "fieldSet" && this.classes.has(s.className) && !this.privateStores.has(s);
+ }
+
recordGuarded(shapeId: string): boolean {
return this.records.has(shapeId);
}
@@ -83,6 +92,7 @@ export function computePublishedTypes(mod: IrModule): PublishedTypes | null {
const records = new Map((mod.records ?? []).map((r) => [r.id, r]));
const unions = new Map((mod.unions ?? []).map((u) => [u.id, u]));
const out = new PublishedTypes();
+ out.privateStores = computeConstructionFacts(mod).privateStores;
const seen = new Set();
const stack = [...roots];
const addHierarchy = (className: string): void => {
@@ -142,6 +152,7 @@ export function computePublishedTypes(mod: IrModule): PublishedTypes | null {
export function writesPublished(published: PublishedTypes, node: IrExpr | IrStmt): boolean {
switch (node.kind) {
case "fieldSet":
+ return published.fieldStoreGuarded(node);
case "fieldIncDec":
return published.classGuarded(node.className);
case "recordSet":
diff --git a/packages/compiler/src/backend/runtime-abi.ts b/packages/compiler/src/backend/runtime-abi.ts
index cdbfbacc5..a8b30c680 100644
--- a/packages/compiler/src/backend/runtime-abi.ts
+++ b/packages/compiler/src/backend/runtime-abi.ts
@@ -2,8 +2,8 @@
* of the link contract: a program object keeps this symbol undefined and the
* matching runtime defines it, so incompatible manual links fail before the
* program can start. */
-export const RUNTIME_ABI_VERSION = 12 as const;
-export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v12" as const;
+export const RUNTIME_ABI_VERSION = 13 as const;
+export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v13" as const;
/** Object consumption is useful today, but the complete scr_* surface may
* still change before 1.0. The versioned marker prevents accidental mixing;
diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts
index 8939fd4cc..7fd8dc415 100644
--- a/packages/compiler/src/backend/runtime-pack-targets.test.ts
+++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts
@@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
- runtime_abi: { version: 12, marker: "scr_runtime_abi_v12" },
+ runtime_abi: { version: 13, marker: "scr_runtime_abi_v13" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" },
flavors: {
diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts
index e71cacd8a..42a30cf2c 100644
--- a/packages/compiler/src/backend/runtime-pack.test.ts
+++ b/packages/compiler/src/backend/runtime-pack.test.ts
@@ -113,7 +113,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
- runtime_abi: { version: 12, marker: "scr_runtime_abi_v12" },
+ runtime_abi: { version: 13, marker: "scr_runtime_abi_v13" },
compiler: {
command: "clang",
identity: "fixture clang",
diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts
new file mode 100644
index 000000000..23559eb22
--- /dev/null
+++ b/packages/compiler/src/contracts/check.ts
@@ -0,0 +1,1516 @@
+/* Contract types (@scriptc/types), the IR side. The frontend collects the
+ * written annotations (frontend/contract-types.ts) keyed by the source
+ * locations the lowering stamps on IR functions, bindings and classes; this
+ * pass binds them to IR names and checks each contract against every write
+ * in the whole program:
+ *
+ * - NoThrow: the function is outside the may-throw fixpoint
+ * (backend/may-throw.ts), or the diagnostic names the throwing path.
+ * - Int32: every value stored into the slot is an int32 by construction,
+ * proven by the same integer range analysis the int32 storage uses,
+ * assuming every Int32 slot holds an int32 (sound by induction: every
+ * write is checked under that assumption).
+ * - Immutable: no mutation can reach a value that may come from an
+ * Immutable slot, values stored into Immutable slots are fresh (nothing
+ * else references them), and such values never escape to code the IR
+ * cannot see. Module constants built from fresh literals become immortal.
+ *
+ * Verified facts land on the IR as `contracts` (IrContracts). */
+import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
+import type {
+ IrClassDef,
+ IrContracts,
+ IrExpr,
+ IrFunction,
+ IrModule,
+ IrStmt,
+ IrType,
+ SrcLoc,
+} from "../ir/ir.js";
+import { isRefCounted, typeKey } from "../ir/ir.js";
+import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
+import {
+ analyzeIntegerRanges,
+ INT32_RANGE,
+ integerArithmeticRange,
+ withinInt32,
+ type IntegerRange,
+ type IntegerRanges,
+ type IntegerSlotFacts,
+} from "../ir/integer-ranges.js";
+import { findInitializerBindings, withInitializerBindings } from "../ir/initializer-bindings.js";
+import { unshareNodes } from "../ir/unshare.js";
+import { dynamicallyStoredFamilies, functionCallFacts, Int32Slots } from "../ir/int32-slots.js";
+import { computeMayThrow, type MayThrowReason } from "../backend/may-throw.js";
+import { SourceLocations } from "../backend/source-locations.js";
+import {
+ contractLocKey,
+ type ContractAnnotations,
+ type ContractNote,
+} from "../frontend/contract-types.js";
+
+/** The largest array index: 2^32 - 2 (ECMA-262 array index). */
+const MAX_ARRAY_INDEX = 2 ** 32 - 2;
+
+export interface ContractCheckResult {
+ diagnostics: ScrDiagnostic[];
+ contracts: IrContracts | null;
+}
+
+/** Walk every expression and statement of a function body, parents first. */
+function walk(
+ body: readonly IrStmt[],
+ visit: { expr?: (e: IrExpr) => void; stmt?: (s: IrStmt) => void },
+): void {
+ const expr = (e: IrExpr): boolean => {
+ visit.expr?.(e);
+ return everyExprChild(e, expr, stmt);
+ };
+ const stmt = (s: IrStmt): boolean => {
+ visit.stmt?.(s);
+ return everyStmtChild(s, expr, stmt);
+ };
+ for (const s of body) stmt(s);
+}
+
+function baseName(file: string): string {
+ const slash = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\"));
+ return slash >= 0 ? file.slice(slash + 1) : file;
+}
+
+export function checkContracts(
+ mod: IrModule,
+ annotations: ContractAnnotations,
+ sourceTexts: ReadonlyMap,
+): ContractCheckResult {
+ // Range proofs are keyed by node identity: check the module emission sees.
+ const checker = new ContractChecker(unshareNodes(mod), annotations, sourceTexts);
+ return checker.run();
+}
+
+class ContractChecker {
+ readonly diagnostics: ScrDiagnostic[] = [];
+ private readonly reported = new Set();
+ private readonly positions: SourceLocations;
+ readonly functions = new Map();
+ readonly classes = new Map();
+ private readonly fnByLoc = new Map();
+ private readonly localsByLoc = new Map();
+ private readonly globalsByLoc = new Map();
+ private readonly classesByLoc = new Map();
+ private readonly contracts: IrContracts = {};
+ /** Whether any verified fact was recorded. */
+ private recorded = false;
+
+ constructor(
+ readonly mod: IrModule,
+ readonly notes: ContractAnnotations,
+ readonly sources: ReadonlyMap,
+ ) {
+ this.positions = new SourceLocations(sources);
+ for (const fn of mod.functions) {
+ this.functions.set(fn.name, fn);
+ // Synthetic dynamic-class helpers (%dyn.*) carry the location of the
+ // code that first needed them, which is not their declaration.
+ if (!fn.name.startsWith("%dyn.")) push(this.fnByLoc, contractLocKey(fn.loc), fn);
+ for (const local of fn.locals)
+ if (local.source)
+ push(this.localsByLoc, contractLocKey(local.source.loc), { fn, localId: local.id });
+ }
+ for (const g of mod.globals ?? [])
+ if (g.source) push(this.globalsByLoc, contractLocKey(g.source.loc), g.id);
+ for (const cls of mod.classes ?? []) {
+ this.classes.set(cls.name, cls);
+ push(this.classesByLoc, contractLocKey(cls.loc), cls);
+ }
+ }
+
+ /* ── reporting ── */
+
+ where(loc: SrcLoc | null): string {
+ if (loc === null) return "?";
+ const pos = this.positions.position(loc);
+ return pos ? `${baseName(pos.file)}:${pos.line}:${pos.column}` : baseName(loc.file);
+ }
+
+ text(loc: SrcLoc, max = 60): string {
+ const source = this.sources.get(loc.file);
+ if (source === undefined || loc.end <= loc.start) return "";
+ const raw = source.slice(loc.start, loc.end).replace(/\s+/g, " ").trim();
+ return raw.length > max ? `${raw.slice(0, max - 3)}...` : raw;
+ }
+
+ report(code: `SC${number}`, loc: SrcLoc, message: string, hint: string): void {
+ const key = `${code}\0${contractLocKey(loc)}\0${message}`;
+ if (this.reported.has(key)) return;
+ this.reported.add(key);
+ this.diagnostics.push({ code, loc, message, hint });
+ }
+
+ displayName(fnName: string): string {
+ const fn = this.functions.get(fnName);
+ const method = /^%(.*)\.([^.]+)$/.exec(fnName);
+ if (method && !fnName.startsWith("%fn") && !fnName.startsWith("%init")) {
+ const cls = this.classes.get(method[1]!);
+ const owner = cls?.jsName ?? method[1]!;
+ return `${owner}.${method[2]}`;
+ }
+ if (fn?.sourceName !== undefined && fn.sourceName !== "") return fn.sourceName;
+ if (fn !== undefined && /^%fn\d+$/.test(fnName)) return `the function at ${this.where(fn.loc)}`;
+ return fnName.replace(/^%/, "");
+ }
+
+ /** A short TypeScript-like spelling of an IR type, for messages. */
+ typeText(t: IrType): string {
+ switch (t.kind) {
+ case "f64":
+ return "number";
+ case "bool":
+ return "boolean";
+ case "undefinedT":
+ return "undefined";
+ case "nullT":
+ return "null";
+ case "object":
+ return this.classes.get(t.className)?.jsName ?? t.className.replace(/^%(m\d+\.)?/, "");
+ case "union": {
+ const def = this.mod.unions?.find((u) => u.id === t.unionId);
+ return def ? def.arms.map((a) => this.typeText(a)).join(" | ") : "a union";
+ }
+ default:
+ return t.kind;
+ }
+ }
+
+ /** Why a throwing parameter access exists although the source type looks
+ * safe: the frontend compiles a parameter as `T | undefined` (or null)
+ * when a caller can pass a missing value, typically a non-null asserted
+ * element read `xs[i]!`, so the TypeError Node raises inside the callee
+ * happens there too. Names the parameter and the first such call. */
+ private widenedParams(fnName: string | undefined): string {
+ const fn = fnName === undefined ? undefined : this.functions.get(fnName);
+ if (fn === undefined) return "";
+ const nullable = (t: IrType): boolean =>
+ t.kind === "undefinedT" ||
+ t.kind === "nullT" ||
+ (t.kind === "union" &&
+ this.mod.unions
+ ?.find((u) => u.id === t.unionId)
+ ?.arms.some((a) => a.kind === "undefinedT" || a.kind === "nullT") === true);
+ const params = fn.params.filter((p) => nullable(p.type));
+ if (params.length === 0) return "";
+ const p = params[0]!;
+ const index = fn.params.indexOf(p);
+ let site: SrcLoc | null = null;
+ for (const caller of this.mod.functions) {
+ if (site !== null) break;
+ walk(caller.body, {
+ expr: (e) => {
+ if (site !== null || e.kind !== "call" || e.callee !== fn.name) return;
+ const arg = e.args[index];
+ if (arg !== undefined && nullable(arg.type)) site = arg.loc;
+ },
+ });
+ }
+ const name = p.name;
+ const because = site !== null ? `: the argument at ${this.where(site)} can be missing` : "";
+ return ` (parameter '${name}' is compiled as ${this.typeText(p.type)}${because})`;
+ }
+
+ /* ── binding annotations to IR ── */
+
+ bindingsOf(key: string): { fn: IrFunction | null; id: string }[] {
+ const locals = (this.localsByLoc.get(key) ?? []).map((l) => ({ fn: l.fn, id: l.localId }));
+ const globals = (this.globalsByLoc.get(key) ?? []).map((id) => ({ fn: null, id }));
+ return [...locals, ...globals];
+ }
+
+ functionsAt(key: string): readonly IrFunction[] {
+ return this.fnByLoc.get(key) ?? [];
+ }
+
+ classesAt(key: string): readonly IrClassDef[] {
+ return this.classesByLoc.get(key) ?? [];
+ }
+
+ run(): ContractCheckResult {
+ this.checkNoThrow();
+ const int32 = new Int32Contracts(this);
+ int32.run();
+ const immutable = new ImmutableContracts(this);
+ immutable.run();
+ if (this.diagnostics.length > 0) return { diagnostics: this.diagnostics, contracts: null };
+ const c = this.contracts;
+ return { diagnostics: [], contracts: this.recorded ? c : null };
+ }
+
+ record(field: keyof IrContracts, values: readonly string[]): void {
+ const list = [...new Set(values)].sort();
+ if (list.length === 0) return;
+ this.contracts[field] = list;
+ this.recorded = true;
+ }
+
+ /* ── NoThrow ── */
+
+ private checkNoThrow(): void {
+ const targets: { fn: IrFunction; note: ContractNote }[] = [];
+ for (const [key, list] of this.notes.functions)
+ for (const note of list)
+ if (note.marker === "NoThrow")
+ for (const fn of this.fnByLoc.get(key) ?? []) targets.push({ fn, note });
+ if (targets.length === 0) return;
+ // Target-independent: the native stack guard (Linux, macOS) counts, so a
+ // contract holds on every platform.
+ const mt = computeMayThrow(this.mod, { stackChecks: true, explain: true });
+ const proven: string[] = [];
+ for (const { fn, note } of targets) {
+ if (fn.async === true || fn.generator !== undefined) {
+ this.report(
+ "SC7001",
+ note.loc,
+ `'${note.name}' is declared NoThrow, but async functions and generators report errors through promises and resumption, which the contract does not cover`,
+ "remove NoThrow from async functions and generators",
+ );
+ continue;
+ }
+ if (!mt.fns.has(fn.name)) {
+ proven.push(fn.name);
+ continue;
+ }
+ const steps: string[] = [];
+ const seen = new Set();
+ let current: string | undefined = fn.name;
+ while (current !== undefined && !seen.has(current) && steps.length < 8) {
+ seen.add(current);
+ const reason: MayThrowReason | undefined = mt.why?.get(current);
+ if (reason === undefined) break;
+ if (reason.kind === "seed") {
+ steps.push(`${reason.what} at ${this.where(reason.loc)}${this.widenedParams(current)}`);
+ current = undefined;
+ } else if (reason.kind === "call") {
+ steps.push(`calls '${this.displayName(reason.callee)}' at ${this.where(reason.loc)}`);
+ current = reason.callee;
+ } else {
+ steps.push(
+ reason.target === ""
+ ? `calls a function value at ${this.where(reason.loc)} (dynamic callbacks may throw)`
+ : `calls a function value at ${this.where(reason.loc)}, and the function value '${this.displayName(reason.target)}' may throw`,
+ );
+ current = reason.target === "" ? undefined : reason.target;
+ }
+ }
+ this.report(
+ "SC7001",
+ note.loc,
+ `'${note.name}' is declared NoThrow but may throw: ${steps.join(" → ")}`,
+ "remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version",
+ );
+ }
+ this.record("noThrow", proven);
+ }
+}
+
+/** The explicit int32 form of a written expression: `x++` and compound
+ * assignments become an assignment of the `| 0` result. */
+function int32Rewrite(text: string): string {
+ const step = /^(.+?)\s*(\+\+|--)$/.exec(text) ?? /^(\+\+|--)\s*(.+)$/.exec(text);
+ if (step) {
+ const target = step[1] === "++" || step[1] === "--" ? step[2]! : step[1]!;
+ const op = (step[1] === "++" || step[1] === "--" ? step[1] : step[2]!)[0]!;
+ return `${target} = (${target} ${op} 1) | 0`;
+ }
+ const compound = /^([^=]+?)\s*(\*\*|>>>|<<|>>|[-+*/%&|^])=\s*(.+)$/.exec(text);
+ if (compound) return `${compound[1]} = (${compound[1]} ${compound[2]} ${compound[3]}) | 0`;
+ const assignment = /^([^=]+?)\s*=\s*([^=].*)$/.exec(text);
+ if (assignment) return `${assignment[1]} = ${orZero(assignment[2]!)}`;
+ return orZero(text);
+}
+
+/** `text | 0`, parenthesized unless `text` binds tighter than `|` for sure
+ * (a name, member chain, literal or call without nested parentheses). */
+function orZero(text: string): string {
+ return /^[\w$.]+(\([^()]*\))?$/.test(text) ? `${text} | 0` : `(${text}) | 0`;
+}
+
+function push(map: Map, key: K, value: V): void {
+ const list = map.get(key);
+ if (list) list.push(value);
+ else map.set(key, [value]);
+}
+
+/* ─────────────────────────────── Int32 ─────────────────────────────── */
+
+class Int32Contracts {
+ private readonly slots: Int32Slots;
+ /** Field families, with the note that declared them. */
+ private readonly fields = new Map();
+ private readonly params = new Map(); // fn\0localId
+ private readonly returns = new Map(); // fn
+ private readonly locals = new Map(); // fn\0localId or \0globalId
+
+ constructor(private readonly c: ContractChecker) {
+ this.slots = new Int32Slots(c.classes);
+ }
+
+ run(): void {
+ if (!this.bind()) return;
+ if (this.fields.size + this.params.size + this.returns.size + this.locals.size === 0) return;
+ this.structural();
+ this.writes();
+ this.c.record("int32Fields", [...this.fields.keys()]);
+ this.c.record("int32Params", [...this.params.keys()]);
+ this.c.record("int32Returns", [...this.returns.keys()]);
+ this.c.record("int32Locals", [...this.locals.keys()]);
+ }
+
+ private refuse(note: ContractNote, why: string, hint: string): void {
+ this.c.report(
+ "SC7003",
+ note.loc,
+ `the Int32 contract on '${note.name}' cannot be checked: ${why}`,
+ hint,
+ );
+ }
+
+ private bind(): boolean {
+ const c = this.c;
+ for (const [key, list] of c.notes.functions)
+ for (const note of list) {
+ if (note.marker !== "Int32") continue;
+ for (const fn of c.functionsAt(key)) {
+ if (fn.async === true || fn.generator !== undefined) {
+ this.refuse(
+ note,
+ "async functions and generators return promises or iterators",
+ "annotate the awaited value where it is stored instead",
+ );
+ continue;
+ }
+ if (fn.returnType.kind !== "f64") {
+ this.refuse(
+ note,
+ "the result is not a plain number",
+ "write Int32 only on a number result",
+ );
+ continue;
+ }
+ this.returns.set(fn.name, note);
+ }
+ }
+ for (const [key, note] of c.notes.bindings) {
+ if (note.marker !== "Int32") continue;
+ for (const b of c.bindingsOf(key)) {
+ const type = b.fn
+ ? b.fn.locals.find((l) => l.id === b.id)?.type
+ : c.mod.globals?.find((g) => g.id === b.id)?.type;
+ if (type?.kind !== "f64") {
+ this.refuse(
+ note,
+ "the binding is not a plain number (optional and union types are not int32 slots)",
+ "declare it as a required Int32",
+ );
+ continue;
+ }
+ if (b.fn === null) {
+ this.locals.set(`\0${b.id}`, note);
+ continue;
+ }
+ this.locals.set(`${b.fn.name}\0${b.id}`, note);
+ if (b.fn.params.some((p) => p.localId === b.id))
+ this.params.set(`${b.fn.name}\0${b.id}`, note);
+ }
+ }
+ for (const [key, note] of c.notes.fields) {
+ if (note.marker !== "Int32") continue;
+ const cut = key.lastIndexOf("\0");
+ const field = key.slice(cut + 1);
+ for (const cls of c.classesAt(key.slice(0, cut))) {
+ const def = cls.fields.find((f) => f.name === field);
+ if (!def) {
+ this.refuse(
+ note,
+ `the compiled class has no field '${field}'`,
+ "annotate an ordinary instance field",
+ );
+ continue;
+ }
+ if (def.type.kind !== "f64") {
+ this.refuse(
+ note,
+ "the field is not a plain number",
+ "declare the field as a required Int32",
+ );
+ continue;
+ }
+ const family = this.slots.family(cls.name, field);
+ if (family !== null) this.fields.set(family, note);
+ }
+ }
+ // Captured bindings: every capture of an Int32 local shares its box.
+ this.propagateCaptures();
+ return true;
+ }
+
+ private propagateCaptures(): void {
+ const c = this.c;
+ for (let changed = true; changed;) {
+ changed = false;
+ for (const fn of c.mod.functions)
+ walk(fn.body, {
+ expr: (e) => {
+ if (e.kind !== "closure") return;
+ const lifted = c.functions.get(e.fnName);
+ e.captures.forEach((id, i) => {
+ const inner = lifted?.captures?.[i];
+ if (!inner) return;
+ const outerKey = `${fn.name}\0${id}`;
+ const innerKey = `${lifted!.name}\0${inner.localId}`;
+ const outer = this.locals.get(outerKey);
+ const innerNote = this.locals.get(innerKey);
+ if (outer && !innerNote) {
+ this.locals.set(innerKey, outer);
+ changed = true;
+ } else if (innerNote && !outer) {
+ this.locals.set(outerKey, innerNote);
+ changed = true;
+ }
+ });
+ },
+ });
+ }
+ }
+
+ /** Slots whose writes the IR does not show. */
+ private structural(): void {
+ const c = this.c;
+ const mod = c.mod;
+ if (this.fields.size > 0) {
+ const dynamic = dynamicallyStoredFamilies(mod, this.slots);
+ for (const [family, note] of this.fields) {
+ const owner = family.slice(0, family.indexOf("\0"));
+ let generic = false;
+ for (
+ let cls = c.classes.get(owner);
+ cls;
+ cls = cls.base ? c.classes.get(cls.base) : undefined
+ )
+ if (
+ cls.runtime === true ||
+ cls.genericOf !== undefined ||
+ (cls.base && !c.classes.has(cls.base))
+ )
+ generic = true;
+ for (const cls of c.classes.values())
+ if (cls.genericOf !== undefined && (cls.genericOf === owner || cls.name === owner))
+ generic = true;
+ if (generic)
+ this.refuse(
+ note,
+ "the class is generic or extends a runtime-provided class",
+ "move the field to a non-generic class",
+ );
+ else if (dynamic.has(family))
+ this.refuse(
+ note,
+ "instances of the class reach dynamic code (any, unknown casts, islands) where a store to the field is not checked",
+ "keep instances of this class out of any-typed values, or remove Int32",
+ );
+ }
+ }
+ if (this.params.size > 0) {
+ const children = new Map();
+ for (const cls of c.classes.values()) if (cls.base) push(children, cls.base, cls.name);
+ const subtree = (name: string): string[] => {
+ const out: string[] = [];
+ const stack = [name];
+ while (stack.length) {
+ const next = stack.pop()!;
+ out.push(next);
+ for (const child of children.get(next) ?? []) stack.push(child);
+ }
+ return out;
+ };
+ const calls = functionCallFacts(mod, c.classes, subtree);
+ for (const [key, note] of this.params) {
+ const fnName = key.slice(0, key.indexOf("\0"));
+ if (calls.valueEscaped.has(fnName))
+ this.refuse(
+ note,
+ `'${c.displayName(fnName)}' is used as a value (callback, class value or export), so its callers are not all visible`,
+ "wrap it in a function that takes a number and passes `x | 0`",
+ );
+ else if (calls.runtimeVirtual(fnName))
+ this.refuse(
+ note,
+ "the runtime calls this method",
+ "remove Int32 from runtime-dispatched methods",
+ );
+ }
+ }
+ }
+
+ private int32Facts(fnName: string): IntegerSlotFacts {
+ const params = new Map();
+ for (const key of this.params.keys())
+ if (key.startsWith(`${fnName}\0`)) params.set(key.slice(fnName.length + 1), INT32_RANGE);
+ return {
+ params,
+ field: (className, field) => {
+ const family = this.slots.family(className, field);
+ return family !== null && this.fields.has(family) ? INT32_RANGE : null;
+ },
+ call: (callee) => (this.returns.has(callee) ? INT32_RANGE : null),
+ boxed: (localId) => (this.locals.has(`${fnName}\0${localId}`) ? INT32_RANGE : null),
+ locals: (localId) =>
+ this.locals.has(`${fnName}\0${localId}`) || this.locals.has(`\0${localId}`)
+ ? INT32_RANGE
+ : null,
+ };
+ }
+
+ private writes(): void {
+ const c = this.c;
+ const methodImpls = new Map();
+ for (const cls of c.classes.values())
+ for (const m of cls.methods ?? []) push(methodImpls, m, `%${cls.name}.${m}`);
+ const bindings = findInitializerBindings(c.mod);
+ const fail = (note: ContractNote, value: IrExpr | null, at: SrcLoc, what: string): void => {
+ const shown = value ? c.text(value.loc) : "";
+ c.report(
+ "SC7002",
+ at,
+ `${what} '${note.name}' (Int32) is not provably an int32${shown ? `: '${shown}'` : ""}`,
+ shown
+ ? `make the int32 conversion explicit, for example '${int32Rewrite(shown)}', so Node computes the same value`
+ : "make the int32 conversion explicit with '| 0', so Node computes the same value",
+ );
+ };
+ for (const fn of c.mod.functions) {
+ const numeric = withInitializerBindings(fn, bindings.get(fn.name) ?? []);
+ const ranges = analyzeIntegerRanges(numeric, this.int32Facts(fn.name));
+ const int32 = (e: IrExpr): boolean => e.type.kind === "f64" && withinInt32(ranges.get(e));
+ const localNote = (id: string): ContractNote | undefined =>
+ this.locals.get(`${fn.name}\0${id}`) ?? this.locals.get(`\0${id}`);
+ const checkArgs = (callee: string, args: IrExpr[], offset: number, at: SrcLoc): void => {
+ const target = c.functions.get(callee);
+ if (!target) return;
+ target.params.forEach((param, i) => {
+ const note = this.params.get(`${callee}\0${param.localId}`);
+ if (!note) return;
+ const arg = args[i - offset];
+ if (i < offset) return;
+ if (arg === undefined) fail(note, null, at, "a call passes no argument for");
+ else if (!int32(arg)) fail(note, arg, arg.loc, "the argument for");
+ });
+ };
+ const incDecStored = (e: IrExpr, prefix: boolean, op: "+" | "-"): boolean => {
+ const result = ranges.get(e);
+ if (!result) return false;
+ if (prefix) return withinInt32(result);
+ return withinInt32(integerArithmeticRange(op, result, { min: 1, max: 1 }));
+ };
+ walk(fn.body, {
+ stmt: (s) => {
+ if (s.kind === "varDecl" || s.kind === "assign") {
+ const note = localNote(s.localId);
+ const value = s.kind === "varDecl" ? s.init : s.value;
+ if (note && value && !int32(value))
+ fail(note, value, value.loc, "the value stored into");
+ } else if (
+ s.kind === "forOf" ||
+ (s.kind === "tryCatch" && s.catchLocalId !== undefined)
+ ) {
+ const id = s.kind === "forOf" ? s.localId : s.catchLocalId!;
+ const note = localNote(id);
+ if (note)
+ fail(
+ note,
+ null,
+ s.loc,
+ s.kind === "forOf"
+ ? "a for-of loop element stored into"
+ : "a caught value stored into",
+ );
+ } else if (s.kind === "fieldSet") {
+ const family = this.slots.family(s.className, s.field);
+ const note = family !== null ? this.fields.get(family) : undefined;
+ if (note && !int32(s.value)) fail(note, s.value, s.value.loc, "the value stored into");
+ } else if (s.kind === "return") {
+ const note = this.returns.get(fn.name);
+ if (note && (!s.value || !int32(s.value)))
+ fail(note, s.value, s.value?.loc ?? s.loc, "the value returned from");
+ }
+ },
+ expr: (e) => {
+ if (e.kind === "assignExpr") {
+ const note = localNote(e.localId);
+ if (note && !int32(e.value)) fail(note, e.value, e.value.loc, "the value stored into");
+ } else if (e.kind === "incDec") {
+ const note = localNote(e.localId);
+ if (note && !incDecStored(e, e.prefix, e.op))
+ c.report(
+ "SC7002",
+ e.loc,
+ `'${c.text(e.loc)}' can step '${note.name}' (Int32) outside the int32 range`,
+ `bound the value by an Int32 comparison in the loop condition, or write '${note.name} = (${note.name} ${e.op} 1) | 0'`,
+ );
+ } else if (e.kind === "fieldIncDec") {
+ const family = this.slots.family(e.className, e.field);
+ const note = family !== null ? this.fields.get(family) : undefined;
+ if (note) {
+ const target = c.text(e.obj.loc);
+ const field = `${target}.${e.field}`;
+ c.report(
+ "SC7002",
+ e.loc,
+ `'${c.text(e.loc)}' can step the Int32 field '${note.name}' outside the int32 range`,
+ `write '${field} = (${field} ${e.op} 1) | 0'`,
+ );
+ }
+ } else if (e.kind === "call") checkArgs(e.callee, e.args, 0, e.loc);
+ else if (e.kind === "new") checkArgs(`%${e.className}.constructor`, e.args, 1, e.loc);
+ else if (e.kind === "virtualCall")
+ for (const impl of methodImpls.get(e.method) ?? []) checkArgs(impl, e.args, 0, e.loc);
+ },
+ });
+ }
+ }
+}
+
+/* ───────────────────────────── Immutable ───────────────────────────── */
+
+/** Read-only and searching array intrinsics: the receiver is not written,
+ * and arguments are compared or copied out, never stored. */
+const ARR_READS = new Set([
+ "length",
+ "getNumber",
+ "indexEq",
+ "indexOf",
+ "includes",
+ "nextPresent",
+ "join",
+ "slice",
+ "flatCopy",
+ "flatOne",
+ "toReversed",
+ "toSpliced",
+ "toSortedPrimitive",
+ "concatSpread",
+]);
+const MAP_READS = new Set([
+ "get",
+ "has",
+ "size",
+ "iterCount",
+ "iterLive",
+ "iterKey",
+ "iterValue",
+ "iterEnter",
+ "iterExit",
+ "clone",
+ "keySet",
+ "valueSet",
+]);
+const SET_READS = new Set([
+ "has",
+ "size",
+ "iterCount",
+ "iterLive",
+ "iterKey",
+ "iterEnter",
+ "iterExit",
+ "clone",
+]);
+/** Element reads whose result can be part of the receiver's graph. */
+const MAP_ELEMENT_READS = new Set(["get", "iterKey", "iterValue"]);
+const SET_ELEMENT_READS = new Set(["iterKey"]);
+/** Copies: a fresh container holding the receiver's elements. */
+const ARR_COPIES = new Set([
+ "slice",
+ "flatCopy",
+ "flatOne",
+ "toReversed",
+ "toSpliced",
+ "concatSpread",
+ "toSortedPrimitive",
+]);
+const COLLECTION_COPIES = new Set(["clone", "keySet", "valueSet"]);
+/** Expression kinds whose result is the same value as their child. */
+const PASS_THROUGH = new Set([
+ "upcast",
+ "downcast",
+ "unionWrap",
+ "unionNarrow",
+ "ternary",
+ "logical",
+ "nullish",
+ "orDefault",
+ "seqExpr",
+ "chainRecv",
+ "optChain",
+ "assignExpr",
+]);
+/** Observations: the value is inspected, never stored or written. */
+const OBSERVE = new Set([
+ "toBool",
+ "strEq",
+ "unionEq",
+ "unionFuncEq",
+ "unionIsTag",
+ "unionDisc",
+ "instanceOf",
+ "instanceOfValue",
+ "dynScalarEq",
+ "fieldAbsent",
+ "recordHas",
+ "recordOvfHas",
+ "arrayHas",
+ "arrayState",
+ "jsonStringify",
+ "toString",
+ "strConcat",
+ "bin",
+ "unary",
+ "unionKeyGet",
+ "recordKeyGet",
+ "recordOvfKeys",
+]);
+/** Library calls that only read their arguments (console output). */
+const LIB_READS = /^(console\.|inspect\.|json\.stringify)/;
+
+class ImmutableContracts {
+ /** Annotated slots: storing into them requires a fresh or immutable value. */
+ private readonly annotated = new Map();
+ /** Slots that may hold an immutable value (annotated plus inferred). */
+ private readonly mayHold = new Set();
+ /** The first store that made an inferred slot hold an immutable value. */
+ private readonly via = new Map();
+ /** Functions whose results are fresh (factory functions). */
+ private readonly freshFns = new Set();
+ private readonly methodImpls = new Map();
+ private readonly slots: Int32Slots;
+ /** freshLocalsOf results, valid once the factory set is final. */
+ private readonly freshLocalsCache = new Map>();
+ private factoriesFinal = false;
+
+ constructor(private readonly c: ContractChecker) {
+ this.slots = new Int32Slots(c.classes);
+ for (const cls of c.classes.values())
+ for (const m of cls.methods ?? []) push(this.methodImpls, m, `%${cls.name}.${m}`);
+ }
+
+ private refuse(note: ContractNote, why: string, hint: string): void {
+ this.c.report(
+ "SC7005",
+ note.loc,
+ `the Immutable contract on '${note.name}' cannot be checked: ${why}`,
+ hint,
+ );
+ }
+
+ run(): void {
+ this.bind();
+ if (this.annotated.size === 0) return;
+ for (const key of this.annotated.keys()) this.mayHold.add(key);
+ this.computeFreshFunctions();
+ // Grow the may-hold set to a fixpoint, then report from the final pass.
+ for (let round = 0; round < 64; round++) {
+ const before = this.mayHold.size;
+ this.pass(false);
+ if (this.mayHold.size === before) break;
+ }
+ this.pass(true);
+ this.immortalGlobals();
+ }
+
+ private bind(): void {
+ const c = this.c;
+ for (const [key, note] of c.notes.bindings) {
+ if (note.marker !== "Immutable") continue;
+ for (const b of c.bindingsOf(key))
+ this.annotated.set(b.fn ? `l:${b.fn.name}\0${b.id}` : `g:${b.id}`, note);
+ }
+ for (const [key, list] of c.notes.functions)
+ for (const note of list)
+ if (note.marker === "Immutable")
+ for (const fn of c.functionsAt(key)) this.annotated.set(`r:${fn.name}`, note);
+ for (const [key, note] of c.notes.fields) {
+ if (note.marker !== "Immutable") continue;
+ const cut = key.lastIndexOf("\0");
+ const field = key.slice(cut + 1);
+ for (const cls of c.classesAt(key.slice(0, cut))) {
+ const family = this.slots.family(cls.name, field);
+ if (family === null)
+ this.refuse(
+ note,
+ `the compiled class has no field '${field}'`,
+ "annotate an ordinary instance field",
+ );
+ else this.annotated.set(`f:${family}`, note);
+ }
+ }
+ }
+
+ private localSlot(fn: IrFunction, id: string): string {
+ return id.startsWith("%g.") ? `g:${id}` : `l:${fn.name}\0${id}`;
+ }
+ private fieldSlot(className: string, field: string): string | null {
+ const family = this.slots.family(className, field);
+ return family === null ? null : `f:${family}`;
+ }
+
+ /** A factory: every return is fresh, built from literals and locals that
+ * only it references. */
+ private computeFreshFunctions(): void {
+ const c = this.c;
+ for (let changed = true; changed;) {
+ changed = false;
+ for (const fn of c.mod.functions) {
+ if (this.freshFns.has(fn.name) || fn.async || fn.generator || fn.captures?.length) continue;
+ if (!isRefCounted(fn.returnType) || fn.returnType.kind === "string") continue;
+ const freshLocals = this.freshLocalsOf(fn);
+ let returns = 0;
+ let ok = true;
+ walk(fn.body, {
+ stmt: (s) => {
+ if (s.kind !== "return") return;
+ returns++;
+ if (!s.value || !this.fresh(s.value, freshLocals)) ok = false;
+ },
+ });
+ if (ok && returns > 0) {
+ this.freshFns.add(fn.name);
+ changed = true;
+ }
+ }
+ }
+ this.factoriesFinal = true;
+ }
+
+ /** Locals of `fn` that only ever hold fresh values and are used only as
+ * intrinsic receivers, field-write targets, element writes and returns:
+ * the value leaves the function only by `return`. */
+ private freshLocalsOf(fn: IrFunction): Set {
+ const cached = this.factoriesFinal ? this.freshLocalsCache.get(fn.name) : undefined;
+ if (cached !== undefined) return cached;
+ const result = this.computeFreshLocals(fn);
+ if (this.factoriesFinal) this.freshLocalsCache.set(fn.name, result);
+ return result;
+ }
+
+ private computeFreshLocals(fn: IrFunction): Set {
+ const candidates = new Set(
+ fn.locals.filter((l) => !l.boxed && isRefCounted(l.type)).map((l) => l.id),
+ );
+ for (const p of fn.params) candidates.delete(p.localId);
+ const allowed = new Set();
+ let ranges: IntegerRanges | undefined;
+ // An element write at a negative, fractional or too-large index adds an
+ // ordinary property instead, and publication refuses arrays that carry
+ // properties: only writes at provable array indexes keep a value fresh.
+ // Typed-array writes never add properties.
+ const arrayIndex = (index: IrExpr): boolean => {
+ ranges ??= analyzeIntegerRanges(fn);
+ const range = ranges.get(index);
+ return (
+ range !== null && range !== undefined && range.min >= 0 && range.max <= MAX_ARRAY_INDEX
+ );
+ };
+ walk(fn.body, {
+ stmt: (s) => {
+ if (s.kind === "return" && s.value?.kind === "varRef") allowed.add(s.value);
+ if ((s.kind === "fieldSet" || s.kind === "recordSet") && s.obj.kind === "varRef")
+ allowed.add(s.obj);
+ if (s.kind === "bytesSet" && s.arr.kind === "varRef") allowed.add(s.arr);
+ if (s.kind === "arraySet" && s.arr.kind === "varRef" && arrayIndex(s.index))
+ allowed.add(s.arr);
+ },
+ expr: (e) => {
+ if (
+ (e.kind === "arrIntrinsic" || e.kind === "mapIntrinsic" || e.kind === "setIntrinsic") &&
+ e.receiver.kind === "varRef"
+ )
+ allowed.add(e.receiver);
+ if ((e.kind === "arrayGet" || e.kind === "arrayHas") && e.arr.kind === "varRef")
+ allowed.add(e.arr);
+ if ((e.kind === "fieldGet" || e.kind === "recordGet") && e.obj.kind === "varRef")
+ allowed.add(e.obj);
+ },
+ });
+ for (let changed = true; changed;) {
+ changed = false;
+ walk(fn.body, {
+ stmt: (s) => {
+ const value = s.kind === "varDecl" ? s.init : s.kind === "assign" ? s.value : undefined;
+ const target = s.kind === "varDecl" || s.kind === "assign" ? s.localId : "";
+ if (value && candidates.has(target) && !this.fresh(value, candidates)) {
+ candidates.delete(target);
+ changed = true;
+ }
+ if (s.kind === "forOf" && candidates.delete(s.localId)) changed = true;
+ },
+ expr: (e) => {
+ if (e.kind === "varRef" && candidates.has(e.localId) && !allowed.has(e)) {
+ candidates.delete(e.localId);
+ changed = true;
+ }
+ if ((e.kind === "assignExpr" || e.kind === "incDec") && candidates.delete(e.localId))
+ changed = true;
+ },
+ });
+ }
+ return candidates;
+ }
+
+ /** A value nothing else references: a literal, a fresh construction or
+ * factory result, a primitive, or a value that is already immutable. */
+ private fresh(
+ e: IrExpr,
+ freshLocals: ReadonlySet,
+ derived?: (e: IrExpr) => boolean,
+ ): boolean {
+ if (!isRefCounted(e.type) || e.type.kind === "string") return true;
+ if (derived?.(e)) return true;
+ switch (e.kind) {
+ case "strLit":
+ case "unitLit":
+ return true;
+ case "varRef":
+ return freshLocals.has(e.localId);
+ case "arrayLit":
+ return e.elems.every((x) => this.fresh(x, freshLocals, derived));
+ case "recordLit":
+ return e.fields.every((f) => this.fresh(f.value, freshLocals, derived));
+ case "mapNew":
+ return (e.seed ?? []).every(
+ (p) =>
+ this.fresh(p.key, freshLocals, derived) && this.fresh(p.value, freshLocals, derived),
+ );
+ case "setNew":
+ return e.seed === undefined || this.fresh(e.seed, freshLocals, derived);
+ case "new":
+ return (
+ this.constructorKeepsThis(e.className) &&
+ e.args.every((a) => this.fresh(a, freshLocals, derived))
+ );
+ case "call":
+ return (
+ this.freshFns.has(e.callee) && e.args.every((a) => this.fresh(a, freshLocals, derived))
+ );
+ case "upcast":
+ case "downcast":
+ case "unionWrap":
+ case "unionNarrow":
+ return this.fresh(e.value, freshLocals, derived);
+ case "ternary":
+ return (
+ this.fresh(e.then, freshLocals, derived) && this.fresh(e.else_, freshLocals, derived)
+ );
+ default:
+ return false;
+ }
+ }
+
+ /** The constructor chain uses `this` only to read and write its own
+ * fields: the new object is referenced from nowhere else. */
+ private constructorKeepsThis(className: string): boolean {
+ const c = this.c;
+ for (
+ let cls = c.classes.get(className);
+ cls;
+ cls = cls.base ? c.classes.get(cls.base) : undefined
+ ) {
+ if (cls.runtime === true) return false;
+ const ctor = c.functions.get(`%${cls.name}.constructor`);
+ if (!ctor) continue;
+ const self = ctor.params[0]?.localId;
+ if (self === undefined) return false;
+ const allowed = new Set();
+ walk(ctor.body, {
+ stmt: (s) => {
+ if (s.kind === "fieldSet" && s.obj.kind === "varRef") allowed.add(s.obj);
+ },
+ expr: (e) => {
+ if (e.kind === "fieldGet" && e.obj.kind === "varRef") allowed.add(e.obj);
+ if (
+ e.kind === "call" &&
+ e.callee === `%${cls.base}.constructor` &&
+ e.args[0]?.kind === "varRef"
+ )
+ allowed.add(e.args[0]);
+ },
+ });
+ let ok = true;
+ walk(ctor.body, {
+ expr: (e) => {
+ if (e.kind === "varRef" && e.localId === self && !allowed.has(e)) ok = false;
+ if (e.kind === "closure" && e.captures.includes(self)) ok = false;
+ },
+ });
+ if (!ok) return false;
+ }
+ return true;
+ }
+
+ /** One pass over the program: derived values, stores, escapes and
+ * mutations. With `report`, violations become diagnostics. */
+ private pass(report: boolean): void {
+ const c = this.c;
+ for (const fn of c.mod.functions) {
+ const derivedMemo = new Map();
+ const freshLocals = this.freshLocalsOf(fn);
+ const derived = (e: IrExpr): boolean => {
+ if (!isRefCounted(e.type) || e.type.kind === "string") return false;
+ const memo = derivedMemo.get(e);
+ if (memo !== undefined) return memo;
+ derivedMemo.set(e, false);
+ const result = this.derivedExpr(fn, e, derived);
+ derivedMemo.set(e, result);
+ return result;
+ };
+ const hold = (slot: string | null, value: IrExpr, at: string): void => {
+ if (slot === null || !derived(value)) return;
+ if (!this.mayHold.has(slot)) {
+ this.mayHold.add(slot);
+ this.via.set(slot, at);
+ }
+ };
+ const store = (slot: string | null, value: IrExpr, how?: string): void => {
+ if (slot === null) return;
+ const note = this.annotated.get(slot);
+ if (note && report && !this.fresh(value, freshLocals, derived)) {
+ c.report(
+ "SC7004",
+ value.loc,
+ `the value stored into '${note.name}' (Immutable) may be referenced elsewhere: '${c.text(value.loc)}' is not a fresh literal, construction or factory result`,
+ "build the value in place (a literal, `new`, or a function that returns what it builds), so no mutable reference to it remains",
+ );
+ }
+ hold(slot, value, how ?? `stored at ${c.where(value.loc)}`);
+ };
+ const mutate = (target: IrExpr, at: SrcLoc, what: string): void => {
+ if (!report || !derived(target)) return;
+ const shown = c.text(target.loc);
+ const origin = this.originOf(fn, target);
+ const subject =
+ origin === "" && shown !== "" && shown !== c.text(at) ? ` ('${shown}')` : "";
+ c.report(
+ "SC7004",
+ at,
+ `${what} writes to a value that may be Immutable${subject}${origin}`,
+ "Immutable values cannot be written through any reference; copy the value before changing it",
+ );
+ };
+ const escape = (value: IrExpr, at: SrcLoc, what: string): void => {
+ if (!report || !derived(value)) return;
+ c.report(
+ "SC7005",
+ at,
+ `a value that may be Immutable escapes to ${what}, where writes are not checked: '${c.text(value.loc)}'${this.originOf(fn, value)}`,
+ "keep Immutable values in typed code, or copy the value before handing it out",
+ );
+ };
+ const args = (callee: string, list: IrExpr[], offset: number, at: SrcLoc): void => {
+ const target = c.functions.get(callee);
+ list.forEach((arg, i) => {
+ const param = target?.params[i + offset];
+ if (!target || !param)
+ return escape(arg, arg.loc, `a call to '${c.displayName(callee)}'`);
+ store(
+ `l:${callee}\0${param.localId}`,
+ arg,
+ `passed to '${c.displayName(callee)}' at ${c.where(at)}`,
+ );
+ });
+ };
+ walk(fn.body, {
+ stmt: (s) => {
+ switch (s.kind) {
+ case "varDecl":
+ if (s.init) store(this.localSlot(fn, s.localId), s.init);
+ break;
+ case "assign":
+ store(this.localSlot(fn, s.localId), s.value);
+ break;
+ case "return":
+ if (s.value)
+ store(
+ `r:${fn.name}`,
+ s.value,
+ `returned from '${c.displayName(fn.name)}' at ${c.where(s.loc)}`,
+ );
+ break;
+ case "fieldSet":
+ mutate(s.obj, s.loc, `'${c.text(s.loc)}'`);
+ store(this.fieldSlot(s.className, s.field), s.value);
+ break;
+ case "recordSet":
+ mutate(s.obj, s.loc, `'${c.text(s.loc)}'`);
+ store(`s:${s.shapeId}\0${s.field}`, s.value);
+ break;
+ case "recordKeySet":
+ mutate(s.obj, s.loc, `'${c.text(s.loc)}'`);
+ escape(s.value, s.loc, "a dynamically keyed property");
+ break;
+ case "recordKeyDelete":
+ mutate(s.obj, s.loc, `'${c.text(s.loc)}'`);
+ break;
+ case "arraySet":
+ mutate(s.arr, s.loc, `'${c.text(s.loc)}'`);
+ store(`e:${typeKey(s.arr.type)}`, s.value);
+ break;
+ case "arraySetLength":
+ case "arraySetUndefined":
+ case "arrayDelete":
+ case "bytesSet":
+ mutate(s.arr, s.loc, `'${c.text(s.loc)}'`);
+ break;
+ case "forOf": {
+ const slot = this.localSlot(fn, s.localId);
+ const elems =
+ this.mayHold.has(`e:${typeKey(s.iterable.type)}`) ||
+ this.mayHold.has(`se:${typeKey(s.iterable.type)}`);
+ if ((elems || derived(s.iterable)) && !this.mayHold.has(slot)) {
+ this.mayHold.add(slot);
+ this.via.set(slot, `iterated at ${c.where(s.loc)}`);
+ }
+ break;
+ }
+ case "throw":
+ escape(s.value, s.loc, "a throw");
+ break;
+ }
+ },
+ expr: (e) => {
+ switch (e.kind) {
+ case "assignExpr":
+ store(this.localSlot(fn, e.localId), e.value);
+ return;
+ case "call":
+ return args(e.callee, e.args, 0, e.loc);
+ case "new":
+ return args(`%${e.className}.constructor`, e.args, 1, e.loc);
+ case "virtualCall":
+ for (const impl of this.methodImpls.get(e.method) ?? []) args(impl, e.args, 0, e.loc);
+ return;
+ case "fieldIncDec":
+ return mutate(e.obj, e.loc, `'${c.text(e.loc)}'`);
+ case "arrIntrinsic":
+ if (ARR_COPIES.has(e.method))
+ this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc));
+ if (!ARR_READS.has(e.method)) mutate(e.receiver, e.loc, `'${c.text(e.loc)}'`);
+ for (const a of e.args) {
+ if (e.method === "indexOf" || e.method === "includes" || e.method === "indexEq")
+ continue;
+ store(`e:${typeKey(e.receiver.type)}`, a);
+ }
+ return;
+ case "mapIntrinsic":
+ if (COLLECTION_COPIES.has(e.method))
+ this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc));
+ if (!MAP_READS.has(e.method)) mutate(e.receiver, e.loc, `'${c.text(e.loc)}'`);
+ if (e.method === "set") {
+ if (e.args[0]) store(`mk:${typeKey(e.receiver.type)}`, e.args[0]);
+ if (e.args[1]) store(`mv:${typeKey(e.receiver.type)}`, e.args[1]);
+ }
+ return;
+ case "setIntrinsic":
+ if (COLLECTION_COPIES.has(e.method))
+ this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc));
+ if (!SET_READS.has(e.method)) mutate(e.receiver, e.loc, `'${c.text(e.loc)}'`);
+ if (e.method === "add" && e.args[0])
+ store(`se:${typeKey(e.receiver.type)}`, e.args[0]);
+ return;
+ case "arrayLit":
+ for (const x of e.elems) store(`e:${typeKey(e.type)}`, x);
+ return;
+ case "recordLit":
+ if (e.type.kind === "record")
+ for (const f of e.fields) store(`s:${e.type.shapeId}\0${f.name}`, f.value);
+ return;
+ case "mapNew":
+ for (const p of e.seed ?? []) {
+ store(`mk:${typeKey(e.type)}`, p.key);
+ store(`mv:${typeKey(e.type)}`, p.value);
+ }
+ return;
+ case "closure": {
+ const lifted = c.functions.get(e.fnName);
+ e.captures.forEach((id, i) => {
+ const inner = lifted?.captures?.[i];
+ const outer = this.localSlot(fn, id);
+ if (!inner) return;
+ const innerSlot = `l:${lifted!.name}\0${inner.localId}`;
+ if (this.mayHold.has(outer) && !this.mayHold.has(innerSlot)) {
+ this.mayHold.add(innerSlot);
+ this.via.set(innerSlot, `captured at ${c.where(e.loc)}`);
+ }
+ });
+ return;
+ }
+ case "libCall":
+ if (!LIB_READS.test(e.fn))
+ for (const a of e.args) escape(a, a.loc, `the library call ${e.fn}`);
+ return;
+ case "callValue":
+ for (const a of e.args) escape(a, a.loc, "a call through a function value");
+ if (e.receiver) escape(e.receiver, e.receiver.loc, "a call through a function value");
+ return;
+ }
+ if (PASS_THROUGH.has(e.kind) || OBSERVE.has(e.kind)) return;
+ if (e.kind === "fieldGet" || e.kind === "recordGet" || e.kind === "arrayGet") return;
+ // Any other parent: a derived child goes somewhere this pass does
+ // not follow (dynamic conversions, islands, runtime helpers).
+ everyExprChild(
+ e,
+ (child) => {
+ escape(child, child.loc, describeSink(e));
+ return true;
+ },
+ () => true,
+ );
+ },
+ });
+ }
+ }
+
+ /** A copy of a container whose elements may be immutable: the copy's
+ * element slots may hold them too (the copy itself is fresh). */
+ private copyElements(
+ receiver: IrExpr,
+ result: IrType,
+ receiverDerived: boolean,
+ where: string,
+ ): void {
+ const at = `copied at ${where}`;
+ const rk = typeKey(receiver.type);
+ const sources = [`e:${rk}`, `mk:${rk}`, `mv:${rk}`, `se:${rk}`];
+ if (!receiverDerived && !sources.some((slot) => this.mayHold.has(slot))) return;
+ const key = typeKey(result);
+ for (const slot of [`e:${key}`, `se:${key}`, `mk:${key}`, `mv:${key}`])
+ if (!this.mayHold.has(slot)) {
+ this.mayHold.add(slot);
+ this.via.set(slot, at);
+ }
+ }
+
+ private originOf(fn: IrFunction, e: IrExpr): string {
+ let root = e;
+ for (let steps = 0; steps < 16; steps++) {
+ if (root.kind === "fieldGet" || root.kind === "recordGet") root = root.obj;
+ else if (root.kind === "arrayGet") root = root.arr;
+ else if (
+ root.kind === "upcast" ||
+ root.kind === "downcast" ||
+ root.kind === "unionWrap" ||
+ root.kind === "unionNarrow" ||
+ root.kind === "assignExpr"
+ )
+ root = root.value;
+ else if (root.kind === "varRef") {
+ // Compiler temporaries (no source binding): follow their initializer.
+ const id = root.localId;
+ const local = fn.locals.find((l) => l.id === id);
+ if (local === undefined || local.source !== undefined) break;
+ let init: IrExpr | null = null;
+ walk(fn.body, {
+ stmt: (s) => {
+ if (s.kind === "varDecl" && s.localId === id && s.init) init ??= s.init;
+ },
+ });
+ if (init === null) break;
+ root = init;
+ } else break;
+ }
+ if (root.kind === "varRef") {
+ const slot = this.localSlot(fn, root.localId);
+ const note = this.annotated.get(slot);
+ if (note) return ` (from '${note.name}')`;
+ const via = this.via.get(slot);
+ if (via) {
+ const local = fn.locals.find((l) => l.id === root.localId);
+ return ` ('${local?.name ?? root.localId}' may be an Immutable value ${via})`;
+ }
+ }
+ return "";
+ }
+
+ /** Whether `e` may evaluate to (part of) an Immutable value. */
+ private derivedExpr(fn: IrFunction, e: IrExpr, derived: (e: IrExpr) => boolean): boolean {
+ switch (e.kind) {
+ case "varRef":
+ return this.mayHold.has(this.localSlot(fn, e.localId));
+ case "fieldGet": {
+ const slot = this.fieldSlot(e.className, e.field);
+ return (slot !== null && this.mayHold.has(slot)) || derived(e.obj);
+ }
+ case "recordGet":
+ return (
+ this.mayHold.has(
+ `s:${e.obj.type.kind === "record" ? e.obj.type.shapeId : ""}\0${e.field}`,
+ ) || derived(e.obj)
+ );
+ case "arrayGet":
+ return this.mayHold.has(`e:${typeKey(e.arr.type)}`) || derived(e.arr);
+ case "mapIntrinsic":
+ return (
+ MAP_ELEMENT_READS.has(e.method) &&
+ (this.mayHold.has(`mv:${typeKey(e.receiver.type)}`) ||
+ this.mayHold.has(`mk:${typeKey(e.receiver.type)}`) ||
+ derived(e.receiver))
+ );
+ case "setIntrinsic":
+ return (
+ SET_ELEMENT_READS.has(e.method) &&
+ (this.mayHold.has(`se:${typeKey(e.receiver.type)}`) || derived(e.receiver))
+ );
+ case "call":
+ return this.mayHold.has(`r:${e.callee}`);
+ case "virtualCall":
+ return (this.methodImpls.get(e.method) ?? []).some((impl) => this.mayHold.has(`r:${impl}`));
+ case "ternary":
+ return derived(e.then) || derived(e.else_);
+ case "logical":
+ case "nullish":
+ return derived(e.left) || derived(e.right);
+ case "seqExpr":
+ return derived(e.result);
+ case "assignExpr":
+ return derived(e.value);
+ case "upcast":
+ case "downcast":
+ case "unionWrap":
+ case "unionNarrow":
+ return derived(e.value);
+ default: {
+ if (!PASS_THROUGH.has(e.kind)) return false;
+ let any = false;
+ everyExprChild(
+ e,
+ (child) => {
+ any ||= derived(child);
+ return !any;
+ },
+ () => true,
+ );
+ return any;
+ }
+ }
+ }
+
+ /** Module constants with an Immutable annotation, written once from a
+ * fresh value of a publishable type: the backend makes them immortal. */
+ private immortalGlobals(): void {
+ const c = this.c;
+ if (c.mod.lib !== undefined || c.diagnostics.length > 0) return;
+ const out: string[] = [];
+ for (const g of c.mod.globals ?? []) {
+ if (
+ g.mutable ||
+ g.tdz === true ||
+ g.initFlag !== undefined ||
+ !this.annotated.has(`g:${g.id}`)
+ )
+ continue;
+ if (!this.publishable(g.type, new Set())) continue;
+ let writes = 0;
+ let fresh = true;
+ for (const fn of c.mod.functions) {
+ const freshLocals = this.freshLocalsOf(fn);
+ walk(fn.body, {
+ stmt: (s) => {
+ if ((s.kind === "assign" || s.kind === "varDecl") && s.localId === g.id) {
+ writes++;
+ const value = s.kind === "assign" ? s.value : s.init;
+ if (!value || !this.fresh(value, freshLocals)) fresh = false;
+ }
+ },
+ expr: (e) => {
+ if (e.kind === "assignExpr" && e.localId === g.id) writes++;
+ },
+ });
+ }
+ if (writes === 1 && fresh) out.push(g.id);
+ }
+ c.record("immortalGlobals", out);
+ }
+
+ /** Static types the publication walkers handle without a refusal. */
+ private publishable(t: IrType, seen: Set): boolean {
+ const c = this.c;
+ switch (t.kind) {
+ case "f64":
+ case "bool":
+ case "string":
+ case "undefinedT":
+ case "nullT":
+ return true;
+ case "array":
+ case "set":
+ return this.publishable(t.elem, seen);
+ case "map":
+ return this.publishable(t.key, seen) && this.publishable(t.value, seen);
+ case "record": {
+ const key = `r:${t.shapeId}`;
+ if (seen.has(key)) return true;
+ seen.add(key);
+ const shape = c.mod.records?.find((r) => r.id === t.shapeId);
+ return (
+ !!shape && !shape.indexValue && shape.fields.every((f) => this.publishable(f.type, seen))
+ );
+ }
+ case "union": {
+ const key = `u:${t.unionId}`;
+ if (seen.has(key)) return true;
+ seen.add(key);
+ const def = c.mod.unions?.find((u) => u.id === t.unionId);
+ return !!def && def.arms.every((arm) => this.publishable(arm, seen));
+ }
+ case "object": {
+ const key = `o:${t.className}`;
+ if (seen.has(key)) return true;
+ seen.add(key);
+ // Subclasses can stand behind a base-typed reference.
+ for (const cls of c.classes.values()) {
+ let under = false;
+ for (
+ let x: IrClassDef | undefined = cls;
+ x;
+ x = x.base ? c.classes.get(x.base) : undefined
+ )
+ if (x.name === t.className) under = true;
+ if (!under) continue;
+ for (
+ let x: IrClassDef | undefined = cls;
+ x;
+ x = x.base ? c.classes.get(x.base) : undefined
+ )
+ if (x.runtime === true || x.localCaptures !== undefined) return false;
+ if (!cls.fields.every((f) => f.name.startsWith("%") || this.publishable(f.type, seen)))
+ return false;
+ }
+ return true;
+ }
+ default:
+ return false;
+ }
+ }
+}
+
+function describeSink(e: IrExpr): string {
+ switch (e.kind) {
+ case "dynFrom":
+ case "dynObjLit":
+ case "dynArrLit":
+ return "dynamic (any or unknown) code";
+ case "jsOp":
+ case "jsMarshal":
+ case "jsExit":
+ return "the JavaScript island";
+ case "intrinsic":
+ return `the runtime (${e.name})`;
+ default:
+ return `an operation scriptc does not follow (${e.kind})`;
+ }
+}
diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts
index ccb02660b..e62055818 100644
--- a/packages/compiler/src/executable/prepare.ts
+++ b/packages/compiler/src/executable/prepare.ts
@@ -8,6 +8,7 @@ import {
} from "../diagnostics/diagnostic.js";
import type { IrModule, SrcLoc } from "../ir/ir.js";
import { validateModule } from "../ir/validate.js";
+import { checkContracts } from "../contracts/check.js";
import {
fenceSpeculativeWasiFunctions,
moduleWasiUnavailableSurface,
@@ -89,6 +90,12 @@ export function prepareExecutableModule(
}
}
sourceTexts = fe.sourceTexts();
+ if (lowered.contracts) {
+ const checked = checkContracts(lowered.module, lowered.contracts, sourceTexts);
+ timing("contracts");
+ if (checked.diagnostics.length > 0) return fail(checked.diagnostics);
+ if (checked.contracts) lowered.module.contracts = checked.contracts;
+ }
} finally {
fe.dispose();
timing("frontend-dispose");
diff --git a/packages/compiler/src/frontend/contract-types.ts b/packages/compiler/src/frontend/contract-types.ts
new file mode 100644
index 000000000..7c622fdee
--- /dev/null
+++ b/packages/compiler/src/frontend/contract-types.ts
@@ -0,0 +1,203 @@
+/* Contract types (@scriptc/types): written annotations scriptc checks as
+ * extra contracts. The package is types only (`export type` aliases that
+ * TypeScript erases), so Node.js never loads it; scriptc recognizes the
+ * aliases by import provenance, like threads-import.ts recognizes the
+ * @scriptc/threads exports. TypeScript drops alias identity from inferred
+ * types, so only annotations the program WRITES count: parameter, return,
+ * variable and class field types. The IR-side checks live in
+ * ir/contract-check.ts. */
+import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
+import type { SrcLoc } from "../ir/ir.js";
+import { locOf } from "./program.js";
+import * as ts from "./ts7/adapter.js";
+
+export const CONTRACT_TYPES_MODULE = "@scriptc/types";
+
+export type ContractMarker = "Int32" | "NoThrow" | "Immutable";
+const MARKERS: ReadonlySet = new Set(["Int32", "NoThrow", "Immutable"]);
+
+/** One written contract: what it promises and where the annotation is. */
+export interface ContractNote {
+ marker: ContractMarker;
+ /** The annotation (the marker's type reference). */
+ loc: SrcLoc;
+ /** The annotated declaration's name, for messages. */
+ name: string;
+}
+
+/** Contracts keyed by the declaration they annotate. Keys are SrcLoc keys
+ * (contractLocKey) of the nodes the lowering stamps on the IR: function
+ * declarations (IrFunction.loc), binding names (IrLocal/IrGlobal
+ * source.loc), and classes (IrClassDef.loc) plus the field name. */
+export interface ContractAnnotations {
+ /** NoThrow and result markers (Int32, Immutable) by function. */
+ functions: Map;
+ /** Parameters and variables by their name's location. */
+ bindings: Map;
+ /** Instance fields by `${class key}\0${field name}`. */
+ fields: Map;
+ diagnostics: ScrDiagnostic[];
+ /** Number of annotations found (for timing and tests). */
+ count: number;
+}
+
+export function contractLocKey(loc: SrcLoc): string {
+ return `${loc.file}\0${loc.start}\0${loc.end}`;
+}
+
+/** The marker a type reference names when its name is a named or namespace
+ * import binding of @scriptc/types (`import type { Int32 } from
+ * "@scriptc/types"`, `import type * as sc from "@scriptc/types"`). */
+function markerOf(checker: ts.TypeChecker, ref: ts.TypeReferenceNode): ContractMarker | null {
+ const name = ref.typeName;
+ let ident: ts.Identifier;
+ let member: string;
+ if (ts.isIdentifier(name)) {
+ if (!MARKERS.has(name.text)) return null;
+ ident = name;
+ member = name.text;
+ } else if (ts.isQualifiedName(name) && ts.isIdentifier(name.left)) {
+ if (!MARKERS.has(name.right.text)) return null;
+ ident = name.left;
+ member = name.right.text;
+ } else return null;
+ const symbol = checker.getSymbolAtLocation(ident);
+ const decl = symbol ? checker.declarationsOf(symbol)[0] : undefined;
+ if (decl === undefined) return null;
+ let importDecl: ts.Node | undefined;
+ if (ts.isImportSpecifier(decl) && ident === name) {
+ importDecl = decl.parent?.parent?.parent;
+ member = decl.propertyName?.text ?? decl.name.text;
+ } else if (ts.isNamespaceImport(decl) && ident !== name) importDecl = decl.parent?.parent;
+ else return null;
+ if (
+ importDecl === undefined ||
+ !ts.isImportDeclaration(importDecl) ||
+ !ts.isStringLiteral(importDecl.moduleSpecifier) ||
+ importDecl.moduleSpecifier.text !== CONTRACT_TYPES_MODULE
+ )
+ return null;
+ return MARKERS.has(member) ? (member as ContractMarker) : null;
+}
+
+function nameText(node: ts.Node | undefined): string {
+ if (node === undefined) return "";
+ if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) return node.text;
+ return node.getText();
+}
+
+function contractDiag(
+ code: `SC${number}`,
+ loc: SrcLoc,
+ message: string,
+ hint: string,
+): ScrDiagnostic {
+ return { code, loc, message, hint };
+}
+
+/** Collect every contract annotation in the program's own TypeScript
+ * modules. Files that do not mention the package cannot import a marker
+ * (re-exports are not followed), so they are skipped without a walk. */
+export function collectContractAnnotations(
+ checker: ts.TypeChecker,
+ files: readonly ts.SourceFile[],
+): ContractAnnotations {
+ const out: ContractAnnotations = {
+ functions: new Map(),
+ bindings: new Map(),
+ fields: new Map(),
+ diagnostics: [],
+ count: 0,
+ };
+ const kinds = new Set([ts.SyntaxKind.TypeReference]);
+ for (const sf of files) {
+ if (sf.isDeclarationFile || !sf.text.includes(CONTRACT_TYPES_MODULE)) continue;
+ ts.walkPreorder(
+ sf,
+ (node) => {
+ if (!ts.isTypeReferenceNode(node)) return;
+ const marker = markerOf(checker, node);
+ if (marker === null) return;
+ out.count++;
+ place(out, checker, node, marker);
+ },
+ kinds,
+ );
+ }
+ return out;
+}
+
+function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMarker): void {
+ const where =
+ marker === "NoThrow"
+ ? "NoThrow wraps a function's written return type"
+ : marker === "Int32"
+ ? "Int32 is checked on parameters, variables, class instance fields and function results"
+ : "Immutable is checked on parameters, variables, class instance fields and function results";
+ out.diagnostics.push(
+ contractDiag(
+ "SC7006",
+ locOf(node),
+ `the ${marker} contract is not checked in this position (${where})`,
+ marker === "NoThrow"
+ ? "write it as the function's return type annotation: function f(): NoThrow"
+ : `write ${marker} directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed`,
+ ),
+ );
+}
+
+function place(
+ out: ContractAnnotations,
+ checker: ts.TypeChecker,
+ ref: ts.TypeReferenceNode,
+ marker: ContractMarker,
+): void {
+ let node: ts.Node = ref;
+ let parent = ref.parent;
+ // NoThrow / NoThrow>: the inner marker is the result
+ // contract of the function the NoThrow annotates.
+ if (marker !== "NoThrow" && parent !== undefined && ts.isTypeReferenceNode(parent)) {
+ const wrapper = markerOf(checker, parent);
+ if (wrapper === "NoThrow" && parent.typeArguments?.[0] === ref) {
+ node = parent;
+ parent = parent.parent;
+ }
+ }
+ const note = (name: string): ContractNote => ({ marker, loc: locOf(ref), name });
+ if (parent === undefined) return unsupported(out, ref, marker);
+ if (ts.isFunctionLike(parent) && parent.type === node) {
+ if (!ts.isFunctionLikeDeclaration(parent)) return unsupported(out, ref, marker);
+ const key = contractLocKey(locOf(parent));
+ const list = out.functions.get(key) ?? [];
+ list.push(note(ts.isConstructorDeclaration(parent) ? "constructor" : nameText(parent.name)));
+ out.functions.set(key, list);
+ return;
+ }
+ if (marker === "NoThrow") return unsupported(out, ref, marker);
+ if (
+ (ts.isParameter(parent) || ts.isVariableDeclaration(parent)) &&
+ parent.type === node &&
+ ts.isIdentifier(parent.name)
+ ) {
+ out.bindings.set(contractLocKey(locOf(parent.name)), note(parent.name.text));
+ return;
+ }
+ if (ts.isPropertyDeclaration(parent) && parent.type === node) {
+ const cls = parent.parent;
+ const isStatic =
+ ts.getModifiers(parent)?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true;
+ if (
+ !isStatic &&
+ cls !== undefined &&
+ (ts.isClassDeclaration(cls) || ts.isClassExpression(cls)) &&
+ (ts.isIdentifier(parent.name) || ts.isPrivateIdentifier(parent.name))
+ ) {
+ out.fields.set(
+ `${contractLocKey(locOf(cls))}\0${parent.name.text}`,
+ note(`${nameText(cls.name)}.${parent.name.text}`),
+ );
+ return;
+ }
+ }
+ unsupported(out, ref, marker);
+}
diff --git a/packages/compiler/src/frontend/lowering/coercions/unions.ts b/packages/compiler/src/frontend/lowering/coercions/unions.ts
index 95f770e7d..40edb3a70 100644
--- a/packages/compiler/src/frontend/lowering/coercions/unions.ts
+++ b/packages/compiler/src/frontend/lowering/coercions/unions.ts
@@ -400,6 +400,74 @@ export function checkedNarrowCall(
};
}
+/** A checked extraction found a value arm other than the one the checker
+ * promised (a narrowing the program invalidated before the read). */
+export const OTHER_ARM_FAILURE_MESSAGE =
+ "a value of another type is not representable in the target union (a value narrowed or asserted past it still held it)";
+
+/** checkedNarrowCall's in-place form with the TypeError of the consuming
+ * operation: `messageFor` names Node's error for finding another arm
+ * (`o.f is not a function`), or null for the stranded-value message. Null
+ * when the operand cannot be evaluated more than once.
+ *
+ * The failure path stays small for wide unions: arms are grouped by their
+ * message (every non-nullish arm without its own shares one), so it tests
+ * at most the null and undefined arms before a final throw. */
+export function checkedArmExtraction(
+ lowerer: Lowerer,
+ value: IrExpr,
+ tag: number,
+ arm: IrType,
+ messageFor: (found: IrType) => string | null,
+): IrExpr | null {
+ if (value.type.kind !== "union" || !isSafeToRepeat(value)) return null;
+ const unionId = value.type.unionId;
+ const from = lowerer.unions.get(unionId);
+ const stored = from?.arms[tag];
+ if (!from || from.arms.length < 2 || !stored || !typeEquals(stored, arm)) return null;
+ const loc = value.loc;
+ const isTag = (t: number): IrExpr => ({
+ kind: "unionIsTag",
+ unionId,
+ tag: t,
+ negated: false,
+ value,
+ type: BOOL,
+ loc,
+ });
+ const groups = new Map();
+ from.arms.forEach((found, t) => {
+ if (t === tag) return;
+ const message =
+ messageFor(found) ??
+ (isUnitType(found)
+ ? unionNarrowFailureMessage(found, (type) => lowerer.fmt(type))
+ : OTHER_ARM_FAILURE_MESSAGE);
+ const tags = groups.get(message);
+ if (tags) tags.push(t);
+ else groups.set(message, [t]);
+ });
+ // The largest group needs no test of its own: it is whatever remains.
+ const ordered = [...groups].sort((a, b) => a[1].length - b[1].length);
+ const [lastMessage] = ordered.pop()!;
+ let failure: IrExpr = nodeThrowExpr(1, "", lastMessage, arm, loc);
+ for (const [message, tags] of ordered.reverse()) {
+ let cond = isTag(tags[0]!);
+ for (const t of tags.slice(1))
+ cond = { kind: "logical", op: "||", left: cond, right: isTag(t), type: BOOL, loc };
+ const thrown = nodeThrowExpr(1, "", message, arm, loc);
+ failure = { kind: "ternary", cond, then: thrown, else_: failure, type: arm, loc };
+ }
+ return {
+ kind: "ternary",
+ cond: isTag(tag),
+ then: { kind: "unionNarrow", unionId, tag, value, type: arm, loc },
+ else_: failure,
+ type: arm,
+ loc,
+ };
+}
+
/** The operand of a checked extraction in either form checkedNarrowCall
* produces, or null for every other expression. */
export function checkedNarrowOperand(lowerer: Lowerer, value: IrExpr): IrExpr | null {
diff --git a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts
index 9ed011125..b86a9c728 100644
--- a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts
@@ -509,10 +509,13 @@ export function finishOptionalChain(
return { kind: "optChain", id, receiver, body, type: DYN, loc };
}
let type = lowerer.irTypeOf(expr);
+ // The checker can call a receiver present that holds a nullish value at
+ // runtime (runtime-optional reads; a stale narrowing restored to its
+ // declared type). Either nullish arm short-circuits to undefined.
const hiddenOptionalResult =
(type.kind !== "union" || lowerer.armTag(type.unionId, UNDEFINED_T) < 0) &&
receiver.type.kind === "union" &&
- lowerer.armTag(receiver.type.unionId, UNDEFINED_T) >= 0 &&
+ (lowerer.unions.get(receiver.type.unionId)?.arms.some(isUnitType) ?? false) &&
body.type.kind !== "generator" &&
body.type.kind !== "jsval"
? lowerer.withUndefinedArmOf(body.type)
diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts
index 7db085f22..2a972b8ea 100644
--- a/packages/compiler/src/frontend/lowering/lower-calls.ts
+++ b/packages/compiler/src/frontend/lowering/lower-calls.ts
@@ -176,6 +176,7 @@ import {
completeArgs,
} from "./call-arguments.js";
import { isThisParameter, type ParamShape } from "./call-signatures.js";
+import { checkedArmExtraction } from "./coercions/unions.js";
import {
requireObjLitGenericReceiver,
objLitGenericFnNodeOf,
@@ -8996,8 +8997,19 @@ export function lowerObjectMethodCall(
});
let callee = target ? lowerer.fieldGetExpr(target, locOf(access), access) : null;
if (callee?.type.kind === "union" && callableType !== null) {
- const helper = lowerer.narrowedArmHelper(callee.type.unionId, callableType, locOf(access));
- if (helper !== null) {
+ // Calling a cleared callback field throws Node's call TypeError.
+ const inPlace = checkedArmExtraction(
+ lowerer,
+ callee,
+ lowerer.armTag(callee.type.unionId, callableType),
+ callableType,
+ () => `${access.getText()} is not a function`,
+ );
+ const helper = inPlace
+ ? null
+ : lowerer.narrowedArmHelper(callee.type.unionId, callableType, locOf(access));
+ if (inPlace) callee = inPlace;
+ else if (helper !== null) {
callee = lowerer.checkedNarrowCall(helper, callee, callableType, locOf(access));
}
}
diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts
index 2bae9c82e..078c13ea9 100644
--- a/packages/compiler/src/frontend/lowering/lower-exprs.ts
+++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts
@@ -20,6 +20,7 @@ import { dirname } from "node:path";
import * as posix from "node:path/posix";
import type { Lowerer } from "./lowerer.js";
import { checkedClassAssertion, checkedClassUnionAssertion } from "./class-assertions.js";
+import { checkedArmExtraction } from "./coercions/unions.js";
import { narrowClassUnion, narrowStoredClassValue } from "./class-unions.js";
import { lowerUnionFieldRead, lowerUnionFieldWrite } from "./expressions/union-fields.js";
import { captureContextArguments } from "./function-context.js";
@@ -1073,6 +1074,13 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
// tests, so only side-effect-free reads compose — and when every arm
// agrees the whole expression folds to that one string (dropping only
// the pure read, the trust-the-checker bet lowerUnitComparison makes).
+ // An effectful producer (a getter) evaluates once into a hidden local.
+ const prelude: IrStmt[] = [];
+ if (operand.type.kind === "union" && !isSafeToRepeat(operand)) {
+ const local = lowerer.declareHiddenLocal("%typeofOperand", operand.type);
+ prelude.push({ kind: "varDecl", localId: local.id, init: operand, loc });
+ operand = varRef(local.id, operand.type, loc);
+ }
if (operand.type.kind === "union" && isSafeToRepeat(operand)) {
const def = lowerer.unions.get(operand.type.unionId);
const answers = def?.arms.map(typeofAnswer);
@@ -1123,7 +1131,9 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
loc,
};
}
- return result;
+ return prelude.length > 0
+ ? { kind: "seqExpr", stmts: prelude, result, type: STRING, loc }
+ : result;
}
}
if (operand.type.kind === "dyn") {
@@ -3662,6 +3672,10 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
narrowed.className !== expr.type.className &&
lowerer.isSubclassOf(narrowed.className, expr.type.className)
) {
+ if (lowerer.narrowing.untrusted(node, narrowed)) {
+ const checked = checkedClassAssertion(lowerer, expr, narrowed, expr.loc);
+ if (checked) return checked;
+ }
return { kind: "downcast", value: expr, type: narrowed, loc: expr.loc };
}
return expr;
@@ -3708,6 +3722,8 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
const arrayTags = lowerer.arrayValueTags(expr.type.unionId);
if (arrayTags.length === 1) {
const arm = def!.arms[arrayTags[0]!]!;
+ const checked = checkedStaleArm(lowerer, node, expr, arrayTags[0]!, arm);
+ if (checked) return checked;
return {
kind: "unionNarrow",
unionId: expr.type.unionId,
@@ -3745,6 +3761,14 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
const values = literalValues(lowerer.checker.getTypeOfSymbol(property));
const arm = values && literalUnionArm(def, values, (id) => lowerer.shapes.get(id));
if (arm) {
+ const checked = checkedStaleArm(
+ lowerer,
+ node,
+ expr,
+ lowerer.armTag(expr.type.unionId, arm),
+ arm,
+ );
+ if (checked) return checked;
return {
kind: "unionNarrow",
unionId: expr.type.unionId,
@@ -3779,6 +3803,10 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
) &&
lowerer.isSubclassOf(narrowed.className, valueType.className)
) {
+ const checked = checkedStaleArm(lowerer, node, expr, valueTag, valueType, narrowed);
+ if (checked) {
+ return checkedClassAssertion(lowerer, checked, narrowed, expr.loc) ?? checked;
+ }
return {
kind: "downcast",
value: {
@@ -3795,6 +3823,8 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
}
return expr;
}
+ const checked = checkedStaleArm(lowerer, node, expr, tag, narrowed);
+ if (checked) return checked;
return {
kind: "unionNarrow",
unionId: expr.type.unionId,
@@ -3805,6 +3835,66 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
};
}
+/** The checked form of maybeNarrow's arm extraction for a narrowing the
+ * checker may hold past code that replaced the value (a call clearing the
+ * property; see NarrowingStability). Every other arm throws the TypeError
+ * Node raises for the operation consuming the read (`Cannot read
+ * properties of undefined (reading 'x')`, `o.f is not a function`), or the
+ * stranded-value TypeError where Node would carry the value on. Null when
+ * the narrowing is provably current. `narrowed` is the checker's type when
+ * it refines the arm (a subclass view). */
+function checkedStaleArm(
+ lowerer: Lowerer,
+ node: ts.Node,
+ value: IrExpr,
+ tag: number,
+ arm: IrType,
+ narrowed: IrType = arm,
+): IrExpr | null {
+ if (value.type.kind !== "union" || tag < 0) return null;
+ if (!lowerer.narrowing.untrusted(node, narrowed)) return null;
+ const inPlace = checkedArmExtraction(lowerer, value, tag, arm, (found) =>
+ staleReadMessage(node, found),
+ );
+ if (inPlace) return inPlace;
+ const helper = lowerer.narrowedArmHelper(value.type.unionId, arm, value.loc);
+ return helper ? lowerer.checkedNarrowCall(helper, value, arm, value.loc) : null;
+}
+
+/** Node's TypeError for the operation consuming `node` when it holds a
+ * value of type `found`, or null where Node would not throw. */
+function staleReadMessage(node: ts.Node, found: IrType): string | null {
+ let child: ts.Node = node;
+ let parent = node.parent;
+ while (ts.isParenthesizedExpression(parent)) {
+ child = parent;
+ parent = parent.parent;
+ }
+ if (ts.isCallExpression(parent) && parent.expression === child && !parent.questionDotToken) {
+ return found.kind === "func" ? null : `${node.getText()} is not a function`;
+ }
+ const unit = found.kind === "undefinedT" ? "undefined" : found.kind === "nullT" ? "null" : null;
+ if (unit === null) return null;
+ if (
+ !(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) ||
+ parent.expression !== child ||
+ parent.questionDotToken
+ )
+ return null;
+ let key: string | null = null;
+ if (ts.isPropertyAccessExpression(parent)) key = parent.name.text;
+ else if (ts.isStringLiteral(parent.argumentExpression)) key = parent.argumentExpression.text;
+ else if (ts.isNumericLiteral(parent.argumentExpression))
+ key = String(Number(parent.argumentExpression.text));
+ if (key === null) return null;
+ const store = parent.parent;
+ return ts.isBinaryExpression(store) &&
+ store.left === parent &&
+ store.operatorToken.kind === ts.SyntaxKind.EqualsToken
+ ? `Cannot set properties of ${unit} (setting '${key}')`
+ : `Cannot read properties of ${unit} (reading '${key}')`;
+}
+
/** A runtime-optional slot can be assigned while a truthy guard is in
* force. TypeScript then types a direct member receiver as the plain arm,
* even though an OOB-safe assignment may have restored undefined. Check
diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts
index 184ffc8a4..b7fd026cd 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.ts
@@ -71,12 +71,14 @@ import {
import { everyExprChild, everyStmtChild, transformStmtList } from "../../ir/traverse.js";
import { RuntimeOptionalProvenance } from "./runtime-optional-provenance.js";
import { presentAtUse, reassignedBindings } from "./runtime-optional-guards.js";
+import { NarrowingStability } from "./narrowing-stability.js";
import { ArrayElementStates } from "./runtime-optional-elements.js";
import { indexReadInBounds } from "./runtime-optional-bounds.js";
import { ArrayOwnership } from "./runtime-optional-ownership.js";
import { StringIndexBounds } from "./string-index-bounds.js";
import { NanCodedReads } from "./nan-coded-reads.js";
import { RuntimeOptionalLocals } from "./runtime-optional-locals.js";
+import { collectContractAnnotations, type ContractAnnotations } from "../contract-types.js";
import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js";
import { UnregisteredClassTypes } from "./unregistered-class-types.js";
import type { WidthLift } from "./width-lift.js";
@@ -726,6 +728,9 @@ export interface LowerResult {
/** Present iff diagnostics is empty. */
module: IrModule | null;
diagnostics: ScrDiagnostic[];
+ /** The program's written contract types (@scriptc/types), keyed by the
+ * source locations the IR carries; absent when it writes none. */
+ contracts?: ContractAnnotations;
/** JS statements whose compile fences DEFERRED to runtime (runtimeFence
* statements in the module) — off the build, on the coverage report. */
runtimeFences: ScrDiagnostic[];
@@ -1038,6 +1043,17 @@ export function lowerToIr(
const divergences = scanDivergences(resultLowerer, moduleOrder);
timing("divergence-scan", { found: divergences.length });
if (divergences.length > 0) result = { ...result, divergences };
+ // Contract types (@scriptc/types): written annotations, checked against
+ // the IR by the compile pipeline (contracts/check.ts).
+ const contracts = collectContractAnnotations(program.getTypeChecker(), moduleOrder);
+ timing("contract-annotations", { found: contracts.count });
+ if (contracts.diagnostics.length > 0)
+ result = {
+ ...result,
+ module: null,
+ diagnostics: [...result.diagnostics, ...contracts.diagnostics],
+ };
+ else if (contracts.count > 0) result = { ...result, contracts };
if (options.coverage !== true) return result;
const remainder = new Lowerer(program, entry, moduleOrder, dynamic, {
frontendServices: options.frontendServices,
@@ -1883,6 +1899,10 @@ export class Lowerer {
* discriminant was captured. A case match narrows the captured value,
* not that mutable binding; preserve its tagged representation on reads. */
readonly unstableSwitchBindings = new Set();
+ /** Whether a checker narrowing of a mutable member or closure-assigned
+ * binding still holds at its use (calls do not reset TypeScript's
+ * narrowing). Stale ones read the declared storage or check it. */
+ readonly narrowing = new NarrowingStability(this);
/** All storage slots widened for runtime absence, including slots whose
* current control-flow branch has temporarily narrowed the value. */
readonly runtimeOptionalStorageLocals = new Set();
@@ -5690,6 +5710,7 @@ export class Lowerer {
run(): LowerResult {
const parts = this.splitFiles();
+ this.narrowing.setFiles(parts.map((fp) => fp.sf));
// The coverage remainder deliberately visits every body that reachable
// emit skipped. Those files are already phase-managed, so an ordinary
// checker miss would stay a one-node IPC query instead of falling back
@@ -6059,6 +6080,7 @@ export class Lowerer {
emitReachable(extraRoots?: readonly string[]): { reachable: Set; result: LowerResult } {
this.deferredModuleInitializers = new DeferredModuleInitializers();
const parts = this.splitFiles();
+ this.narrowing.setFiles(parts.map((fp) => fp.sf));
// Direct lowering callers do not necessarily run program preflight.
// Establish the same managed header/top-level batch here before
// collection, while the production path simply finds warm memos.
@@ -7043,6 +7065,11 @@ export class Lowerer {
// receiver-kind check downstream sees the narrowed arm.
const narrowed = this.chainNarrowedType.get(node);
if (narrowed) return narrowed;
+ // A stale narrowing of a mutable member or closure-assigned binding in
+ // an absence/type test (or a slot accepting the declared type) reads
+ // as declared, so the test inspects the stored value.
+ const widened = this.narrowing.widenedType(node);
+ if (widened) return widened;
const t = this.checker.getTypeAtLocation(node);
// ALIASED-TYPEOF narrowing: inside a branch a `type === 'string'`
// test proves (type = typeof val, both never reassigned), references
diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts
new file mode 100644
index 000000000..577356fc6
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts
@@ -0,0 +1,155 @@
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { expect, test } from "vitest";
+import { compile, deserializeModule, validateModule } from "../../index.js";
+import type { IrModule } from "../../ir/ir.js";
+
+async function lower(source: string): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "scriptc-narrowing-stability-"));
+ try {
+ const entry = join(dir, "main.ts"),
+ output = join(dir, "main.ir.json");
+ await writeFile(entry, source);
+ const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" });
+ if (!result.ok)
+ throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"));
+ const module = deserializeModule(await readFile(output, "utf8"));
+ expect(validateModule(module)).toEqual([]);
+ return module;
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+/** The TypeError messages a function's checked reads can throw. */
+function thrownMessages(module: IrModule, name: string): string[] {
+ const fn = module.functions.find((f) => f.name === name || f.name.endsWith(`.${name}`));
+ if (!fn) throw new Error(`no function ${name}`);
+ const messages: string[] = [];
+ JSON.stringify(fn.body, (_key, value: unknown) => {
+ const node = value as { kind?: string; fn?: string; args?: { value?: unknown }[] };
+ if (node?.kind === "libCall" && node.fn === "error.nodeThrow")
+ messages.push(String(node.args?.[2]?.value));
+ return value;
+ });
+ return messages;
+}
+
+const SOURCE = `
+class Tree {
+ parent: Tree | undefined;
+ depth: number;
+ constructor(parent: Tree | undefined) {
+ this.parent = parent;
+ this.depth = parent ? parent.depth + 1 : 0;
+ }
+}
+class Box {
+ label: string | undefined = undefined;
+ clear(): void { this.label = undefined; }
+}
+function visit(t: Tree): number { return t.depth; }
+function constructionOnly(t: Tree): number {
+ if (t.parent) {
+ visit(t);
+ return t.parent.depth;
+ }
+ return -1;
+}
+function noCall(b: Box): number {
+ if (b.label !== undefined) return b.label.length + b.label.length;
+ return 0;
+}
+function libraryCalls(b: Box, xs: number[]): number {
+ if (b.label !== undefined) {
+ xs.push(Math.max(1, 2));
+ console.log("x");
+ return b.label.length;
+ }
+ return 0;
+}
+function userCall(b: Box): number {
+ if (b.label !== undefined) {
+ visit(new Tree(undefined));
+ return b.label.length;
+ }
+ return 0;
+}
+function methodCall(b: Box): number {
+ if (b.label !== undefined) {
+ b.clear();
+ return b.label.length;
+ }
+ return 0;
+}
+function loopBackEdge(b: Box): number {
+ let n = 0;
+ if (b.label !== undefined) {
+ for (let i = 0; i < 2; i++) {
+ n += b.label.length;
+ b.clear();
+ }
+ }
+ return n;
+}
+const b = new Box();
+b.label = "abc";
+console.log(constructionOnly(new Tree(new Tree(undefined))), noCall(b), libraryCalls(b, []),
+ userCall(b), methodCall(b), loopBackEdge(b));
+`;
+
+test("narrowed reads keep the unchecked extraction where nothing can change the field", async () => {
+ const module = await lower(SOURCE);
+ expect(thrownMessages(module, "constructionOnly")).toEqual([]);
+ expect(thrownMessages(module, "noCall")).toEqual([]);
+ expect(thrownMessages(module, "libraryCalls")).toEqual([]);
+});
+
+test("narrowed reads after code that can clear the field check it with Node's TypeError", async () => {
+ const module = await lower(SOURCE);
+ const read = "Cannot read properties of undefined (reading 'length')";
+ expect(thrownMessages(module, "userCall")).toContain(read);
+ expect(thrownMessages(module, "methodCall")).toContain(read);
+ expect(thrownMessages(module, "loopBackEdge")).toContain(read);
+});
+
+test("standard-library arguments read as declared only where the lowering is generic", async () => {
+ // JSON.stringify and clearInterval lower only the narrowed argument types;
+ // console methods and type-parameter slots take the declared value.
+ const module = await lower(`
+interface Opts {
+ p?: { [k: string]: number };
+}
+function touch(): number {
+ return 1;
+}
+let handle: ReturnType | null = null;
+const stop = (): void => {
+ handle = null;
+};
+function specialized(o: Opts): string {
+ o.p = { a: 1 };
+ touch();
+ return JSON.stringify(o.p);
+}
+function clearAfterCall(): void {
+ if (handle !== null) {
+ touch();
+ clearInterval(handle);
+ }
+}
+function formatted(o: Opts, out: (Opts["p"])[]): void {
+ o.p = { a: 2 };
+ touch();
+ console.log(o.p);
+ out.push(o.p);
+}
+handle = setInterval(() => {}, 1000);
+console.log(specialized({}));
+clearAfterCall();
+stop();
+formatted({}, []);
+`);
+ expect(thrownMessages(module, "formatted")).toEqual([]);
+});
diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts
new file mode 100644
index 000000000..a1585a24b
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts
@@ -0,0 +1,1194 @@
+import * as ts from "../ts7/adapter.js";
+import { InternalCompilerError } from "../../errors.js";
+import { typeEquals } from "../../ir/ir.js";
+import type { IrType } from "../../ir/ir.js";
+import type { Lowerer } from "./lowerer.js";
+
+/** Whether TypeScript's control-flow narrowing of a mutable reference still
+ * holds when the narrowed read executes.
+ *
+ * TypeScript narrows property references (`o.p`, `this.a.b`) and bindings
+ * after assignments and tests, and keeps that narrowing across calls: a
+ * callback, method or getter that clears or replaces the property (or a
+ * closure that reassigns the binding) leaves the reference typed by its old
+ * value. Lowering bridges a narrowed union read with an UNCHECKED payload
+ * extraction and folds `?.`, truthiness and `typeof` by the narrowed type,
+ * so such a read would interpret the wrong arm. This analysis decides when
+ * the narrowing is provably current:
+ *
+ * - every property in the reference is construction-only (no store to its
+ * name anywhere in the program except `this.name = ...` directly in a
+ * constructor body, no accessor of that name, no reflective or computed
+ * store into class instances) and the root binding is never assigned
+ * from another function; or
+ * - nothing between the earliest occurrence of the same reference in the
+ * use's function and the use can run other code or store the property
+ * through another reference: no call or construction (except standard
+ * library operations that cannot call back), tagged template, `await`,
+ * `yield`, user accessor, user iteration or user conversion hook, and no
+ * store to a property of the same name through a different reference.
+ * A loop around the use that starts inside that span contributes its
+ * whole body, since its back edge reaches the use again.
+ *
+ * TypeScript never carries a property narrowing into a nested function,
+ * and every narrowing (an assignment, test, predicate or assertion of the
+ * reference) is an occurrence of the reference, so a narrowed use always
+ * has one earlier in its own function. Spurious occurrences only move the
+ * span start earlier, which keeps the answer conservative. */
+export class NarrowingStability {
+ private files: readonly ts.SourceFile[] | null = null;
+ private programFacts: ProgramFacts | null = null;
+ private readonly facts = new Map();
+ private readonly verdicts = new Map();
+ private readonly effects = new Map();
+ private readonly capturedMutable = new Map();
+ private readonly identifierWrites = new Map>();
+ private readonly widened = new Map();
+ private readonly plainContainers = new Map();
+
+ constructor(private readonly lowerer: Lowerer) {}
+
+ setFiles(files: readonly ts.SourceFile[]): void {
+ this.files = files;
+ this.programFacts = null;
+ }
+
+ /** The declared type a narrowed read in an absence or type test (or in a
+ * slot that accepts that declared type) must observe instead of the
+ * checker's stale narrowing, or null to keep the checker's type. */
+ widenedType(node: ts.Node): ts.Type | null {
+ if (!ts.isPropertyAccessExpression(node) && !ts.isIdentifier(node)) return null;
+ // Program-level queries before the program's files are split concern
+ // declarations, never narrowed reads in function bodies.
+ if (this.files === null) return null;
+ const position = widePosition(node);
+ if (position === null) return null;
+ const cached = this.widened.get(node);
+ if (cached !== undefined) return cached;
+ let result: ts.Type | null = null;
+ try {
+ result = this.computeWidened(node, position);
+ } finally {
+ this.widened.set(node, result);
+ }
+ return result;
+ }
+
+ /** The checker narrows this reference below its declared type, and the
+ * narrowing may be stale at runtime: its read must check the stored
+ * value instead of trusting the narrowed type. */
+ untrusted(node: ts.Node, narrowed: IrType): boolean {
+ // Explicit assertions (`x!`, `x as T`) are the program's own claim and
+ // keep their established stance; only flow narrowing is in question.
+ if (this.files === null) return false;
+ if (ts.isPropertyAccessExpression(node)) {
+ const declared = this.declaredPropertyType(node);
+ if (declared === null) return false;
+ const declaredIr = this.lowerer.mapTypeOf(declared);
+ if (declaredIr === null || typeEquals(declaredIr, narrowed)) return false;
+ return !this.trusted(node);
+ }
+ if (ts.isIdentifier(node)) {
+ const symbol = this.bindingOf(node);
+ if (!symbol || !this.isCapturedMutable(symbol)) return false;
+ return !this.trusted(node);
+ }
+ return false;
+ }
+
+ private bindingOf(node: ts.Identifier): ts.Symbol | null {
+ const symbol = this.lowerer.resolveValueSymbol(node);
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias)
+ return this.lowerer.checker.getAliasedSymbol(symbol);
+ return symbol;
+ }
+
+ private computeWidened(
+ node: ts.PropertyAccessExpression | ts.Identifier,
+ position: WidePosition,
+ ): ts.Type | null {
+ const checker = this.lowerer.checker;
+ let declared: ts.Type | null;
+ if (ts.isPropertyAccessExpression(node)) {
+ declared = this.declaredPropertyType(node);
+ } else {
+ const symbol = this.bindingOf(node);
+ declared = symbol && this.isCapturedMutable(symbol) ? checker.getTypeOfSymbol(symbol) : null;
+ }
+ if (declared === null) return null;
+ const narrowedTs = checker.getTypeAtLocation(node);
+ if (narrowedTs === declared) return null;
+ const declaredIr = this.lowerer.mapTypeOf(declared);
+ const narrowedIr = this.lowerer.mapTypeOf(narrowedTs);
+ if (declaredIr === null || narrowedIr === null || typeEquals(declaredIr, narrowedIr))
+ return null;
+ // A union narrowed to some of its arms, or a class narrowed to a
+ // subclass by instanceof (whose members dispatch on the instance).
+ const classNarrowing = declaredIr.kind === "object" && narrowedIr.kind === "object";
+ if (declaredIr.kind !== "union" && !classNarrowing) return null;
+ if (position === "member") {
+ // A member the declared type itself supports (every arm has it, so
+ // no arm is nullish) reads the current value's member, as Node does.
+ // Calls dispatch only through a single class.
+ const member = memberUse(node);
+ if (member === null || (member.call && !classNarrowing)) return null;
+ if (!checker.getPropertyOfType(declared, member.access.name.text)) return null;
+ } else if (position === "chain") {
+ if (classNarrowing) return null;
+ // A chain body is lowered for the checker's present receiver type:
+ // only a narrowing that removed nothing but null/undefined can be
+ // restored to the guarded declared receiver.
+ const present = this.lowerer.mapTypeOf(checker.getNonNullableType(declared));
+ if (present === null || !typeEquals(present, narrowedIr)) return null;
+ } else if (position === "context") {
+ if (!this.argumentSlotIsGeneric(node)) return null;
+ const contextual = checker.getContextualType(node);
+ if (contextual === undefined) return null;
+ if (
+ !(contextual.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) &&
+ !checker.isTypeAssignableTo(declared, contextual)
+ ) {
+ // A slot that takes the declared type minus null/undefined after an
+ // absence test (`if (acc !== null) return acc`): the checker's
+ // narrowing can also hold an arm an assignment chose before the
+ // call that stored another one. Restore every present arm, the way
+ // a binding narrowed only by the test reads.
+ if (classNarrowing) return null;
+ const present = checker.getNonNullableType(declared);
+ if (
+ present === declared ||
+ !checker.isTypeAssignableTo(narrowedTs, present) ||
+ !checker.isTypeAssignableTo(present, contextual)
+ )
+ return null;
+ return this.trusted(node) ? null : present;
+ }
+ }
+ return this.trusted(node) ? null : declared;
+ }
+
+ /** A read in a call or construction argument (directly or inside a literal
+ * argument) reaches the callee's own lowering with the declared type. User
+ * callees coerce any value of their parameter type, and so do the
+ * standard library's generic slots: a parameter typed by a type parameter
+ * (`push(...items: T[])`, `Map.set(key: K, value: V)`) and the console
+ * methods, which format any value. Other standard-library lowerings are
+ * specialized to the argument types programs pass and can refuse the
+ * declared union (`JSON.stringify` of a possibly-undefined value,
+ * `clearInterval` of a nullable handle). Those arguments keep the checker's
+ * narrowing, and maybeNarrow reads them through the checked extraction. */
+ private argumentSlotIsGeneric(node: ts.Expression): boolean {
+ const slot = enclosingArgument(node);
+ if (slot === null) return true;
+ const { call, argument } = slot;
+ const checker = this.lowerer.checker;
+ const signature = checker.getResolvedSignature(call);
+ const declaration = signature ? checker.signatureDeclaration(signature) : undefined;
+ if (!declaration || !this.lowerer.isStdlibFile(declaration.getSourceFile())) return true;
+ const callee = skipOuter(call.expression);
+ if (
+ ts.isPropertyAccessExpression(callee) &&
+ this.lowerer.isStdlibGlobal(skipOuter(callee.expression), "console")
+ )
+ return true;
+ const index = call.arguments?.indexOf(argument) ?? -1;
+ const params = declaration.parameters ?? [];
+ if (index < 0 || params.length === 0) return false;
+ const param = params[Math.min(index, params.length - 1)]!;
+ if (index >= params.length && param.dotDotDotToken === undefined) return false;
+ return param.type !== undefined && this.mentionsTypeParameter(param.type);
+ }
+
+ private mentionsTypeParameter(node: ts.Node): boolean {
+ if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) {
+ const symbol = this.lowerer.checker.getSymbolAtLocation(node.typeName);
+ if (symbol && symbol.flags & ts.SymbolFlags.TypeParameter) return true;
+ }
+ return (
+ ts.forEachChild(node, (child) => this.mentionsTypeParameter(child) || undefined) ?? false
+ );
+ }
+
+ /** The type of the accessed property as declared on the receiver's
+ * (instantiated) type, before any flow narrowing of this reference. */
+ private declaredPropertyType(node: ts.PropertyAccessExpression): ts.Type | null {
+ const checker = this.lowerer.checker;
+ const receiver = checker.getTypeAtLocation(node.expression);
+ const property =
+ checker.getPropertyOfType(receiver, node.name.text) ?? checker.getSymbolAtLocation(node.name);
+ if (!property) return null;
+ return checker.getTypeOfSymbol(property);
+ }
+
+ private trusted(node: ts.Expression): boolean {
+ const cached = this.verdicts.get(node);
+ if (cached !== undefined) return cached;
+ const verdict = this.computeTrusted(node);
+ this.verdicts.set(node, verdict);
+ return verdict;
+ }
+
+ private computeTrusted(node: ts.Expression): boolean {
+ const chain = referenceChain(node);
+ if (chain === null) return true;
+ const program = this.program();
+ // A getter answers a fresh value on every read: neither its own result
+ // nor anything read through it keeps a narrowing.
+ for (const link of chain.links) {
+ if (!program.accessors.has(link.name)) continue;
+ const symbol = this.lowerer.checker.getSymbolAtLocation(
+ ts.isPropertyAccessExpression(link.node) ? link.node.name : link.node,
+ );
+ if (
+ !symbol ||
+ (symbol.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor)) !== 0
+ )
+ return false;
+ }
+ const rootStable =
+ chain.root.kind === ts.SyntaxKind.ThisKeyword ||
+ (ts.isIdentifier(chain.root) && !this.rootCapturedMutable(chain.root));
+ if (
+ rootStable &&
+ chain.links.length > 0 &&
+ chain.links.every((link) => this.constructionOnly(link.name))
+ )
+ return true;
+ if (chain.links.length === 0 && rootStable) return true;
+ return this.currentSinceNarrowing(node, chain);
+ }
+
+ /** The root binding can change between the narrowing and the use, or
+ * names a namespace whose exported bindings are assigned as variables
+ * (invisible to the member-store census). */
+ private rootCapturedMutable(root: ts.Identifier): boolean {
+ const symbol = this.bindingOf(root);
+ if (symbol === null) return true;
+ if (symbol.flags & (ts.SymbolFlags.Module | ts.SymbolFlags.Enum)) return true;
+ return this.isCapturedMutable(symbol);
+ }
+
+ /** No intervening code between the reference's earliest occurrence in
+ * the use's function and the use can change it. */
+ private currentSinceNarrowing(node: ts.Expression, chain: ReferenceChain): boolean {
+ const boundary = flowBoundary(node);
+ const facts = this.functionFacts(boundary);
+ const last = chain.links[chain.links.length - 1];
+ const refKey = last ? `.${last.name}` : (chain.root as ts.Identifier).text;
+ const candidates = facts.refs.get(refKey) ?? [];
+ const useStart = node.getStart();
+ let start = -1;
+ for (const candidate of candidates) {
+ if (candidate === node) continue;
+ const candidateStart = candidate.getStart();
+ if (candidateStart >= useStart) break;
+ if (referenceKey(candidate) !== chain.key) continue;
+ start = candidateStart;
+ // An assignment narrows once it completes: code its right side runs
+ // happens before the stored value exists.
+ const parent = candidate.parent;
+ if (
+ ts.isBinaryExpression(parent) &&
+ parent.left === candidate &&
+ parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
+ parent.end <= useStart
+ )
+ start = parent.end;
+ break;
+ }
+ if (start < 0) return false;
+ // A loop around the use that starts after the narrowing reaches the
+ // use again through its back edge: its whole body (and header) runs in
+ // between.
+ let end = useStart;
+ for (const loop of facts.loops) {
+ const loopStart = loop.getStart();
+ if (loopStart >= start && loopStart <= useStart && loop.end >= node.end)
+ end = Math.max(end, loop.end);
+ }
+ // Effects and stores are ordered by where they take effect (a call
+ // after its arguments, a store after its right side). Code enclosing
+ // the use takes effect after it unless a loop brings the use around.
+ for (const { node: effect } of between(facts.effects, start, end)) {
+ if (this.isEffect(effect)) return false;
+ }
+ if (chain.links.length === 0) return true;
+ const names = new Set(chain.links.map((link) => link.name));
+ for (const { node: write } of between(facts.writes, start, end)) {
+ const name = accessName(write);
+ if (name === null) {
+ if (!this.isPlainContainerWrite(write)) return false;
+ continue;
+ }
+ if (!names.has(name)) continue;
+ const key = referenceKey(write);
+ // A store through the same reference (or a prefix of it) is a flow
+ // assignment the checker already accounts for.
+ if (key !== null && (key === chain.key || chain.key.startsWith(`${key}.`))) continue;
+ return false;
+ }
+ return true;
+ }
+
+ private isPlainContainerWrite(write: ts.Expression): boolean {
+ if (!ts.isElementAccessExpression(write)) return false;
+ const cached = this.plainContainers.get(write);
+ if (cached !== undefined) return cached;
+ const checker = this.lowerer.checker;
+ const type = checker.getTypeAtLocation(write.expression);
+ const plain = isPlainContainerType(checker, type);
+ this.plainContainers.set(write, plain);
+ return plain;
+ }
+
+ private constructionOnly(name: string): boolean {
+ const program = this.program();
+ return !program.wildcard && !program.mutable.has(name) && !program.accessors.has(name);
+ }
+
+ /** A `let`/`var`/parameter binding assigned from a function other than
+ * the one that declares it: any call may change it. */
+ private isCapturedMutable(symbol: ts.Symbol): boolean {
+ const cached = this.capturedMutable.get(symbol);
+ if (cached !== undefined) return cached;
+ let result = false;
+ try {
+ const decl = this.lowerer.checker.valueDeclarationOf(symbol);
+ const mutableBinding =
+ decl !== undefined &&
+ ((ts.isVariableDeclaration(decl) &&
+ ts.isVariableDeclarationList(decl.parent) &&
+ (decl.parent.flags & ts.NodeFlags.Const) === 0) ||
+ ts.isParameter(decl));
+ if (decl && mutableBinding) {
+ const home = flowBoundary(decl);
+ const writes = this.writesIn(decl.getSourceFile()).get(symbol.name) ?? [];
+ for (const write of writes) {
+ if (flowBoundary(write) === home) continue;
+ if (this.lowerer.resolveValueSymbol(write) === symbol) {
+ result = true;
+ break;
+ }
+ }
+ }
+ } finally {
+ this.capturedMutable.set(symbol, result);
+ }
+ return result;
+ }
+
+ private writesIn(file: ts.SourceFile): Map {
+ let writes = this.identifierWrites.get(file);
+ if (writes) return writes;
+ const found = new Map();
+ writes = found;
+ this.identifierWrites.set(file, found);
+ const add = (target: ts.Node): void => {
+ forEachWriteTarget(target, (t) => {
+ if (!ts.isIdentifier(t)) return;
+ let list = found.get(t.text);
+ if (!list) found.set(t.text, (list = []));
+ list.push(t);
+ });
+ };
+ ts.walkPreorder(file, (node) => {
+ const target = writeTargetOf(node);
+ if (target) add(target);
+ });
+ return writes;
+ }
+
+ private program(): ProgramFacts {
+ if (this.programFacts) return this.programFacts;
+ if (this.files === null) {
+ throw new InternalCompilerError("narrowing stability queried before the program files");
+ }
+ const facts: ProgramFacts = {
+ mutable: new Set(),
+ accessors: new Set(),
+ wildcard: this.lowerer.dynamic,
+ conversion: false,
+ iteration: false,
+ };
+ this.programFacts = facts;
+ for (const file of this.files) {
+ if (file.isDeclarationFile) continue;
+ ts.walkPreorder(file, (node) => {
+ if (ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) {
+ const name = memberName(node.name);
+ if (name !== null) facts.accessors.add(name);
+ else facts.wildcard = true;
+ }
+ if (
+ ts.isMethodDeclaration(node) ||
+ ts.isPropertyDeclaration(node) ||
+ ts.isPropertyAssignment(node) ||
+ ts.isGetAccessorDeclaration(node)
+ ) {
+ const name = memberName(node.name);
+ if (name === "toString" || name === "valueOf" || name === "toJSON")
+ facts.conversion = true;
+ if (ts.isComputedPropertyName(node.name)) {
+ const text = node.name.expression.getText();
+ if (text.includes("toPrimitive")) facts.conversion = true;
+ if (text.includes("iterator") || text.includes("asyncIterator")) facts.iteration = true;
+ }
+ }
+ if (
+ (ts.isFunctionDeclaration(node) ||
+ ts.isFunctionExpression(node) ||
+ ts.isMethodDeclaration(node)) &&
+ node.asteriskToken
+ ) {
+ facts.iteration = true;
+ }
+ if (ts.isCallExpression(node) && reflectiveStore(node)) facts.wildcard = true;
+ const target = writeTargetOf(node);
+ if (target) {
+ forEachWriteTarget(target, (t) => {
+ if (ts.isIdentifier(t)) return;
+ const name = accessName(t);
+ if (name === null) {
+ if (!this.isPlainContainerWrite(t)) facts.wildcard = true;
+ return;
+ }
+ if (isConstructorThisStore(t)) return;
+ facts.mutable.add(name);
+ });
+ }
+ });
+ }
+ return facts;
+ }
+
+ private functionFacts(boundary: ts.Node): FunctionFacts {
+ let facts = this.facts.get(boundary);
+ if (facts) return facts;
+ const program = this.program();
+ const found: FunctionFacts = { effects: [], writes: [], loops: [], refs: new Map() };
+ facts = found;
+ this.facts.set(boundary, found);
+ const addRef = (key: string, node: ts.Expression): void => {
+ let list = found.refs.get(key);
+ if (!list) found.refs.set(key, (list = []));
+ list.push(node);
+ };
+ const effect = (node: ts.Node, at = node.end): void => {
+ found.effects.push({ node, at });
+ };
+ ts.walkPreorder(boundary, (node) => {
+ if (node !== boundary && isFlowBoundary(node)) return "skip";
+ if (ts.isClassLikeDeclaration(node) && node !== boundary) {
+ effect(node);
+ return "skip";
+ }
+ switch (node.kind) {
+ case ts.SyntaxKind.CallExpression:
+ case ts.SyntaxKind.NewExpression:
+ case ts.SyntaxKind.TaggedTemplateExpression:
+ case ts.SyntaxKind.Decorator:
+ case ts.SyntaxKind.AwaitExpression:
+ case ts.SyntaxKind.YieldExpression:
+ case ts.SyntaxKind.SpreadElement:
+ effect(node);
+ break;
+ case ts.SyntaxKind.ForOfStatement:
+ // The iterator steps between the iterable and the body.
+ effect(node, (node as ts.ForOfStatement).expression.end);
+ found.loops.push(node);
+ break;
+ case ts.SyntaxKind.ForStatement:
+ case ts.SyntaxKind.ForInStatement:
+ case ts.SyntaxKind.WhileStatement:
+ case ts.SyntaxKind.DoStatement:
+ found.loops.push(node);
+ break;
+ case ts.SyntaxKind.ArrayBindingPattern:
+ case ts.SyntaxKind.ArrayLiteralExpression:
+ if (program.iteration) effect(node, node.parent?.end ?? node.end);
+ break;
+ case ts.SyntaxKind.TemplateSpan:
+ if (program.conversion) effect(node, (node as ts.TemplateSpan).expression.end);
+ break;
+ case ts.SyntaxKind.BinaryExpression:
+ if (program.conversion) effect(node);
+ break;
+ }
+ if (ts.isPropertyAccessExpression(node)) {
+ addRef(`.${node.name.text}`, node);
+ if (program.accessors.has(node.name.text)) effect(node);
+ } else if (ts.isElementAccessExpression(node)) {
+ const name = accessName(node);
+ if (name !== null) {
+ addRef(`.${name}`, node);
+ if (program.accessors.has(name)) effect(node);
+ }
+ } else if (ts.isIdentifier(node) && isValueIdentifier(node)) {
+ addRef(node.text, node);
+ }
+ const target = writeTargetOf(node);
+ if (target) {
+ forEachWriteTarget(target, (t) => {
+ if (ts.isPropertyAccessExpression(t) || ts.isElementAccessExpression(t))
+ found.writes.push({ node: t, at: node.end });
+ });
+ }
+ return undefined;
+ });
+ found.effects.sort((a, b) => a.at - b.at);
+ found.writes.sort((a, b) => a.at - b.at);
+ return facts;
+ }
+
+ /** Whether evaluating this candidate can run code other than standard
+ * library operations that cannot call back into the program. */
+ private isEffect(node: ts.Node): boolean {
+ const cached = this.effects.get(node);
+ if (cached !== undefined) return cached;
+ let result = true;
+ try {
+ result = this.computeEffect(node);
+ } finally {
+ this.effects.set(node, result);
+ }
+ return result;
+ }
+
+ private computeEffect(node: ts.Node): boolean {
+ const checker = this.lowerer.checker;
+ if (ts.isCallExpression(node)) return !this.isInertLibraryCall(node);
+ if (ts.isNewExpression(node)) return !this.isInertConstruction(node);
+ if (ts.isForOfStatement(node)) {
+ if (node.awaitModifier) return true;
+ return this.program().iteration && !this.isBuiltinIterable(node.expression);
+ }
+ if (ts.isSpreadElement(node)) return !this.isBuiltinIterable(node.expression);
+ if (ts.isArrayBindingPattern(node)) {
+ const owner = node.parent;
+ return !(
+ ts.isVariableDeclaration(owner) &&
+ owner.initializer !== undefined &&
+ this.isBuiltinIterable(owner.initializer)
+ );
+ }
+ if (ts.isArrayLiteralExpression(node)) {
+ const parent = node.parent;
+ if (
+ !ts.isBinaryExpression(parent) ||
+ parent.left !== node ||
+ parent.operatorToken.kind !== ts.SyntaxKind.EqualsToken
+ )
+ return false;
+ return !this.isBuiltinIterable(parent.right);
+ }
+ if (ts.isTemplateSpan(node)) return !this.isPrimitive(node.expression);
+ if (ts.isBinaryExpression(node)) {
+ if (!CONVERTING_OPERATORS.has(node.operatorToken.kind)) return false;
+ return !this.isPrimitive(node.left) || !this.isPrimitive(node.right);
+ }
+ if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
+ const symbol = checker.getSymbolAtLocation(
+ ts.isPropertyAccessExpression(node) ? node.name : node.argumentExpression,
+ );
+ if (!symbol) return true;
+ return (
+ (symbol.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor)) !== 0 &&
+ !this.lowerer.isStdlibSymbol(symbol)
+ );
+ }
+ if (ts.isClassLikeDeclaration(node)) {
+ if (node.modifiers?.some((m) => m.kind === ts.SyntaxKind.Decorator)) return true;
+ for (const clause of node.heritageClauses ?? []) {
+ for (const heritage of clause.types) {
+ const e = heritage.expression;
+ if (!ts.isIdentifier(e) && !ts.isPropertyAccessExpression(e)) return true;
+ }
+ }
+ return node.members.some(
+ (member) =>
+ ts.isClassStaticBlockDeclaration(member) ||
+ (member.name !== undefined && ts.isComputedPropertyName(member.name)) ||
+ member.modifiers?.some((m) => m.kind === ts.SyntaxKind.Decorator) === true ||
+ (ts.isPropertyDeclaration(member) &&
+ member.initializer !== undefined &&
+ member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true),
+ );
+ }
+ return true;
+ }
+
+ private isInertLibraryCall(call: ts.CallExpression): boolean {
+ if (call.expression.kind === ts.SyntaxKind.SuperKeyword) return false;
+ if (call.expression.kind === ts.SyntaxKind.ImportKeyword) return false;
+ if (!this.inertArguments(call.arguments)) return false;
+ const callee = skipOuter(call.expression);
+ if (ts.isIdentifier(callee)) {
+ if (!INERT_GLOBAL_FUNCTIONS.has(callee.text)) return false;
+ if (!this.lowerer.isStdlibGlobal(callee, callee.text)) return false;
+ return call.arguments.every((arg) => this.isPrimitive(arg));
+ }
+ if (!ts.isPropertyAccessExpression(callee)) return false;
+ const method = callee.name.text;
+ const receiver = skipOuter(callee.expression);
+ if (ts.isIdentifier(receiver)) {
+ const members = INERT_NAMESPACE_MEMBERS.get(receiver.text);
+ if (members !== undefined && this.lowerer.isStdlibGlobal(receiver, receiver.text))
+ return members === "*" || members.has(method);
+ }
+ const checker = this.lowerer.checker;
+ const type = checker.getTypeAtLocation(callee.expression);
+ if (type.flags & PRIMITIVE_FLAGS) return true;
+ if (checker.isArrayType(type) || checker.isTupleType(type))
+ return !ARRAY_CONVERTING_METHODS.has(method);
+ const symbol = type.getSymbol();
+ return (
+ symbol !== undefined &&
+ BUILTIN_COLLECTIONS.has(symbol.name) &&
+ this.lowerer.isStdlibSymbol(symbol)
+ );
+ }
+
+ private isInertConstruction(node: ts.NewExpression): boolean {
+ const callee = skipOuter(node.expression);
+ if (!ts.isIdentifier(callee) || !INERT_CONSTRUCTORS.has(callee.text)) return false;
+ if (!this.lowerer.isStdlibGlobal(callee, callee.text)) return false;
+ const args = node.arguments ?? [];
+ if (!this.inertArguments(args)) return false;
+ return args.every((arg) => this.isPrimitive(arg) || this.isBuiltinIterable(arg));
+ }
+
+ /** No argument is a function the callee could invoke. */
+ private inertArguments(args: readonly ts.Expression[]): boolean {
+ const checker = this.lowerer.checker;
+ for (const arg of args) {
+ const e = skipOuter(arg);
+ if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) return false;
+ if (ts.isSpreadElement(e)) continue;
+ if (
+ ts.isStringLiteral(e) ||
+ ts.isNumericLiteral(e) ||
+ ts.isNoSubstitutionTemplateLiteral(e) ||
+ e.kind === ts.SyntaxKind.TrueKeyword ||
+ e.kind === ts.SyntaxKind.FalseKeyword
+ )
+ continue;
+ const type = checker.getTypeAtLocation(e);
+ if (type.flags & PRIMITIVE_FLAGS) continue;
+ if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false;
+ if (checker.getCallSignatures(type).length > 0) return false;
+ if (this.program().conversion && !(type.flags & ts.TypeFlags.Object)) return false;
+ }
+ return true;
+ }
+
+ private isPrimitive(node: ts.Expression): boolean {
+ return (this.lowerer.checker.getTypeAtLocation(node).flags & PRIMITIVE_FLAGS) !== 0;
+ }
+
+ private isBuiltinIterable(node: ts.Expression): boolean {
+ const checker = this.lowerer.checker;
+ const type = checker.getTypeAtLocation(node);
+ if (type.flags & ts.TypeFlags.StringLike) return true;
+ return checker.isArrayType(type) || checker.isTupleType(type);
+ }
+}
+
+interface ProgramFacts {
+ /** Property names stored anywhere outside a constructor's own `this`. */
+ mutable: Set;
+ /** Names of user-declared accessors. */
+ accessors: Set;
+ /** A computed or reflective store can reach any class instance field. */
+ wildcard: boolean;
+ /** The program defines `toString`/`valueOf`/`toJSON`/`Symbol.toPrimitive`. */
+ conversion: boolean;
+ /** The program defines generators or `Symbol.iterator` members. */
+ iteration: boolean;
+}
+
+/** A node and the source position at which its evaluation takes effect. */
+interface Timed {
+ node: T;
+ at: number;
+}
+
+interface FunctionFacts {
+ /** Candidates that may run other code, ordered by `at`. */
+ effects: Timed[];
+ /** Member store targets, ordered by when the store happens. */
+ writes: Timed[];
+ loops: ts.Node[];
+ /** References by final name (`.name` for members), in source order. */
+ refs: Map;
+}
+
+interface ReferenceChain {
+ key: string;
+ root: ts.Node;
+ links: { name: string; node: ts.PropertyAccessExpression | ts.ElementAccessExpression }[];
+}
+
+type WidePosition = "chain" | "member" | "test" | "context";
+
+const PRIMITIVE_FLAGS =
+ ts.TypeFlags.StringLike |
+ ts.TypeFlags.NumberLike |
+ ts.TypeFlags.BooleanLike |
+ ts.TypeFlags.BigIntLike |
+ ts.TypeFlags.Undefined |
+ ts.TypeFlags.Null |
+ ts.TypeFlags.Void;
+
+const CONVERTING_OPERATORS = new Set([
+ ts.SyntaxKind.PlusToken,
+ ts.SyntaxKind.MinusToken,
+ ts.SyntaxKind.AsteriskToken,
+ ts.SyntaxKind.SlashToken,
+ ts.SyntaxKind.PercentToken,
+ ts.SyntaxKind.AsteriskAsteriskToken,
+ ts.SyntaxKind.LessThanToken,
+ ts.SyntaxKind.GreaterThanToken,
+ ts.SyntaxKind.LessThanEqualsToken,
+ ts.SyntaxKind.GreaterThanEqualsToken,
+ ts.SyntaxKind.EqualsEqualsToken,
+ ts.SyntaxKind.ExclamationEqualsToken,
+ ts.SyntaxKind.AmpersandToken,
+ ts.SyntaxKind.BarToken,
+ ts.SyntaxKind.CaretToken,
+ ts.SyntaxKind.LessThanLessThanToken,
+ ts.SyntaxKind.GreaterThanGreaterThanToken,
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken,
+ ts.SyntaxKind.PlusEqualsToken,
+ ts.SyntaxKind.MinusEqualsToken,
+ ts.SyntaxKind.AsteriskEqualsToken,
+ ts.SyntaxKind.SlashEqualsToken,
+ ts.SyntaxKind.PercentEqualsToken,
+]);
+
+/** Global functions that never call back when given primitive arguments. */
+const INERT_GLOBAL_FUNCTIONS = new Set([
+ "parseInt",
+ "parseFloat",
+ "isNaN",
+ "isFinite",
+ "encodeURIComponent",
+ "decodeURIComponent",
+ "encodeURI",
+ "decodeURI",
+ "String",
+ "Number",
+ "Boolean",
+]);
+
+/** Standard namespaces whose listed members never call back into the
+ * program when given no function argument. */
+const INERT_NAMESPACE_MEMBERS = new Map | "*">([
+ ["Math", "*"],
+ ["console", "*"],
+ [
+ "Number",
+ new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseFloat", "parseInt"]),
+ ],
+ ["String", new Set(["fromCharCode", "fromCodePoint"])],
+ [
+ "Object",
+ new Set(["keys", "values", "entries", "hasOwn", "getPrototypeOf", "is", "getOwnPropertyNames"]),
+ ],
+ ["Array", new Set(["isArray"])],
+ ["Date", new Set(["now"])],
+]);
+
+/** Array methods that convert their elements through user hooks. */
+const ARRAY_CONVERTING_METHODS = new Set([
+ "join",
+ "toString",
+ "toLocaleString",
+ "sort",
+ "toSorted",
+]);
+
+const BUILTIN_COLLECTIONS = new Set([
+ "Map",
+ "Set",
+ "WeakMap",
+ "WeakSet",
+ "ReadonlyMap",
+ "ReadonlySet",
+]);
+
+const INERT_CONSTRUCTORS = new Set([
+ "Map",
+ "Set",
+ "WeakMap",
+ "WeakSet",
+ "Array",
+ "Error",
+ "TypeError",
+ "RangeError",
+ "SyntaxError",
+ "Date",
+ "RegExp",
+ "Uint8Array",
+ "Int8Array",
+ "Uint16Array",
+ "Int16Array",
+ "Uint32Array",
+ "Int32Array",
+ "Float32Array",
+ "Float64Array",
+ "BigInt64Array",
+ "BigUint64Array",
+]);
+
+/** Where a stale narrowed read can keep its declared representation: the
+ * receiver of `?.`, an absence/type/truthiness test, or a slot whose
+ * contextual type may accept the declared type. */
+function widePosition(node: ts.Expression): WidePosition | null {
+ let child: ts.Node = node;
+ let parent = node.parent;
+ while (ts.isParenthesizedExpression(parent)) {
+ child = parent;
+ parent = parent.parent;
+ }
+ if (
+ (ts.isPropertyAccessExpression(parent) ||
+ ts.isElementAccessExpression(parent) ||
+ ts.isCallExpression(parent)) &&
+ parent.expression === child
+ ) {
+ if (parent.questionDotToken !== undefined) return "chain";
+ return memberUse(node) !== null ? "member" : null;
+ }
+ if (ts.isBinaryExpression(parent)) {
+ switch (parent.operatorToken.kind) {
+ case ts.SyntaxKind.QuestionQuestionToken:
+ case ts.SyntaxKind.BarBarToken:
+ case ts.SyntaxKind.AmpersandAmpersandToken:
+ case ts.SyntaxKind.InstanceOfKeyword:
+ return parent.left === child ? "test" : null;
+ case ts.SyntaxKind.EqualsEqualsEqualsToken:
+ case ts.SyntaxKind.ExclamationEqualsEqualsToken:
+ case ts.SyntaxKind.EqualsEqualsToken:
+ case ts.SyntaxKind.ExclamationEqualsToken:
+ return "test";
+ case ts.SyntaxKind.EqualsToken:
+ return parent.right === child ? "context" : null;
+ default:
+ return null;
+ }
+ }
+ if (ts.isPrefixUnaryExpression(parent))
+ return parent.operator === ts.SyntaxKind.ExclamationToken ? "test" : null;
+ if (ts.isTypeOfExpression(parent)) return "test";
+ if (ts.isTemplateSpan(parent)) return "test";
+ if (
+ (ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent)) &&
+ parent.expression === child
+ )
+ return "test";
+ if (ts.isConditionalExpression(parent) && parent.condition === child) return "test";
+ if (ts.isForStatement(parent) && parent.condition === child) return "test";
+ if (
+ (ts.isCallExpression(parent) || ts.isNewExpression(parent)) &&
+ parent.arguments?.some((arg) => arg === child)
+ )
+ return "context";
+ // Only slots whose type IS the contextual type: an array or object
+ // literal (or an unannotated arrow body) infers its own type from the
+ // narrowed element instead, unless the literal itself sits in a typed
+ // slot (`const xs: Expr[] = [x]`, `{ value }` passed as a parameter).
+ if (ts.isReturnStatement(parent)) return "context";
+ if (
+ (ts.isArrayLiteralExpression(parent) ||
+ (ts.isPropertyAssignment(parent) && parent.initializer === child) ||
+ ts.isShorthandPropertyAssignment(parent)) &&
+ typedLiteral(parent)
+ )
+ return "context";
+ if (ts.isVariableDeclaration(parent) && parent.initializer === child && parent.type)
+ return "context";
+ return null;
+}
+
+/** The call or construction whose argument holds `node`, directly or as an
+ * element or property of literal arguments, with that argument. */
+function enclosingArgument(
+ node: ts.Expression,
+): { call: ts.CallExpression | ts.NewExpression; argument: ts.Expression } | null {
+ let child: ts.Node = node;
+ let parent = node.parent;
+ while (
+ ts.isParenthesizedExpression(parent) ||
+ ts.isArrayLiteralExpression(parent) ||
+ ts.isObjectLiteralExpression(parent) ||
+ ts.isShorthandPropertyAssignment(parent) ||
+ (ts.isPropertyAssignment(parent) && parent.initializer === child)
+ ) {
+ child = parent;
+ parent = parent.parent;
+ }
+ if (!(ts.isCallExpression(parent) || ts.isNewExpression(parent))) return null;
+ const argument = parent.arguments?.find((arg) => arg === child);
+ return argument ? { call: parent, argument } : null;
+}
+
+/** The array or object literal holding `parent` (an element's literal or a
+ * property assignment) is itself lowered against a declared slot type. */
+function typedLiteral(
+ parent: ts.ArrayLiteralExpression | ts.PropertyAssignment | ts.ShorthandPropertyAssignment,
+): boolean {
+ let literal: ts.Node | undefined = ts.isArrayLiteralExpression(parent) ? parent : parent.parent;
+ while (literal) {
+ let slot: ts.Node | undefined = literal.parent;
+ while (slot && ts.isParenthesizedExpression(slot)) {
+ literal = slot;
+ slot = slot.parent;
+ }
+ if (!slot) return false;
+ if (ts.isVariableDeclaration(slot)) return slot.initializer === literal && !!slot.type;
+ if (ts.isCallExpression(slot) || ts.isNewExpression(slot))
+ return slot.arguments?.some((arg) => arg === literal) ?? false;
+ if (ts.isBinaryExpression(slot))
+ return slot.operatorToken.kind === ts.SyntaxKind.EqualsToken && slot.right === literal;
+ if (ts.isReturnStatement(slot)) {
+ let fn: ts.Node | undefined = slot.parent;
+ while (fn && !ts.isFunctionLike(fn)) fn = fn.parent;
+ return fn !== undefined && ts.isFunctionLike(fn) && fn.type !== undefined;
+ }
+ if (ts.isArrayLiteralExpression(slot)) {
+ literal = slot;
+ continue;
+ }
+ if (ts.isPropertyAssignment(slot) && slot.initializer === literal) {
+ literal = slot.parent;
+ continue;
+ }
+ return false;
+ }
+ return false;
+}
+
+/** The plain member read or method call `node` is the receiver of
+ * (`node.name`, `node.name(...)`; not a store target or optional step). */
+function memberUse(
+ node: ts.Expression,
+): { access: ts.PropertyAccessExpression; call: boolean } | null {
+ let child: ts.Node = node;
+ let parent = node.parent;
+ while (ts.isParenthesizedExpression(parent)) {
+ child = parent;
+ parent = parent.parent;
+ }
+ if (!ts.isPropertyAccessExpression(parent) || parent.expression !== child) return null;
+ if (parent.questionDotToken !== undefined) return null;
+ const outer = parent.parent;
+ if (outer && writeTargetOf(outer) === parent) return null;
+ const call = outer !== undefined && ts.isCallExpression(outer) && outer.expression === parent;
+ if (call && (outer as ts.CallExpression).questionDotToken !== undefined) return null;
+ return { access: parent, call };
+}
+
+/** The function-like body, class member initializer or file whose flow
+ * contains `node`. */
+function flowBoundary(node: ts.Node): ts.Node {
+ let n: ts.Node | undefined = node.parent;
+ while (n && !isFlowBoundary(n)) n = n.parent;
+ return n ?? node.getSourceFile();
+}
+
+function isFlowBoundary(node: ts.Node): boolean {
+ return (
+ ts.isFunctionLike(node) ||
+ ts.isClassStaticBlockDeclaration(node) ||
+ ts.isPropertyDeclaration(node) ||
+ ts.isSourceFile(node)
+ );
+}
+
+function skipOuter(node: ts.Expression): ts.Expression {
+ let e = node;
+ while (
+ ts.isParenthesizedExpression(e) ||
+ ts.isNonNullExpression(e) ||
+ ts.isAsExpression(e) ||
+ ts.isTypeAssertion(e) ||
+ ts.isSatisfiesExpression(e)
+ ) {
+ e = e.expression;
+ }
+ return e;
+}
+
+/** A member access's name when it is static (`o.p`, `o["p"]`, `o[0]`). */
+function accessName(node: ts.Node): string | null {
+ if (ts.isPropertyAccessExpression(node)) return node.name.text;
+ if (ts.isElementAccessExpression(node)) {
+ const arg = node.argumentExpression;
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) return arg.text;
+ if (ts.isNumericLiteral(arg)) return String(Number(arg.text));
+ }
+ return null;
+}
+
+/** The normalized spelling TypeScript's reference matching would equate:
+ * parentheses, non-null and type assertions erased, `o["p"]` as `o.p`. */
+function referenceKey(node: ts.Node): string | null {
+ const e = skipOuter(node as ts.Expression);
+ if (ts.isIdentifier(e)) return e.text;
+ if (e.kind === ts.SyntaxKind.ThisKeyword) return "this";
+ if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) {
+ const name = accessName(e);
+ if (name === null) return null;
+ const receiver = referenceKey(e.expression);
+ return receiver === null ? null : `${receiver}.${name}`;
+ }
+ return null;
+}
+
+function referenceChain(node: ts.Expression): ReferenceChain | null {
+ const key = referenceKey(node);
+ if (key === null) return null;
+ const links: ReferenceChain["links"] = [];
+ let e = skipOuter(node);
+ while (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) {
+ links.unshift({ name: accessName(e)!, node: e });
+ e = skipOuter(e.expression);
+ }
+ return { key, root: e, links };
+}
+
+function memberName(name: ts.Node): string | null {
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
+ if (ts.isNumericLiteral(name)) return String(Number(name.text));
+ if (ts.isPrivateIdentifier(name)) return name.text;
+ return null;
+}
+
+/** An identifier that reads or declares a binding (not a member name). */
+function isValueIdentifier(node: ts.Identifier): boolean {
+ const parent = node.parent;
+ if (ts.isPropertyAccessExpression(parent) && parent.name === node) return false;
+ if (
+ (ts.isPropertyAssignment(parent) ||
+ ts.isPropertyDeclaration(parent) ||
+ ts.isMethodDeclaration(parent) ||
+ ts.isPropertySignature(parent) ||
+ ts.isGetAccessorDeclaration(parent) ||
+ ts.isSetAccessorDeclaration(parent)) &&
+ parent.name === node
+ )
+ return false;
+ return true;
+}
+
+/** The store target expression of an assignment-like node. */
+function writeTargetOf(node: ts.Node): ts.Node | null {
+ if (ts.isBinaryExpression(node)) {
+ const op = node.operatorToken.kind;
+ if (op >= ts.SyntaxKind.FirstAssignment && op <= ts.SyntaxKind.LastAssignment) return node.left;
+ return null;
+ }
+ if (
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
+ node.operator === ts.SyntaxKind.MinusMinusToken)
+ )
+ return node.operand;
+ if (ts.isDeleteExpression(node)) return node.expression;
+ if (
+ (ts.isForOfStatement(node) || ts.isForInStatement(node)) &&
+ !ts.isVariableDeclarationList(node.initializer)
+ )
+ return node.initializer;
+ return null;
+}
+
+/** Each stored-to reference in a target, through destructuring patterns. */
+function forEachWriteTarget(target: ts.Node, visit: (target: ts.Expression) => void): void {
+ const e = skipOuter(target as ts.Expression);
+ if (ts.isIdentifier(e) || ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) {
+ visit(e);
+ return;
+ }
+ if (ts.isArrayLiteralExpression(e)) {
+ for (const element of e.elements) {
+ forEachWriteTarget(ts.isSpreadElement(element) ? element.expression : element, visit);
+ }
+ return;
+ }
+ if (ts.isObjectLiteralExpression(e)) {
+ for (const property of e.properties) {
+ if (ts.isPropertyAssignment(property)) forEachWriteTarget(property.initializer, visit);
+ else if (ts.isShorthandPropertyAssignment(property) && ts.isIdentifier(property.name))
+ visit(property.name);
+ else if (ts.isSpreadAssignment(property)) forEachWriteTarget(property.expression, visit);
+ }
+ return;
+ }
+ if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
+ // A destructuring default: `[a = 1] = xs`.
+ forEachWriteTarget(e.left, visit);
+ }
+}
+
+/** `this.p = ...` directly in a constructor body, before the object can
+ * be observed by code that narrowed it. */
+function isConstructorThisStore(target: ts.Expression): boolean {
+ if (!ts.isPropertyAccessExpression(target) && !ts.isElementAccessExpression(target)) return false;
+ if (skipOuter(target.expression).kind !== ts.SyntaxKind.ThisKeyword) return false;
+ return ts.isConstructorDeclaration(flowBoundary(target));
+}
+
+/** `Object.assign(target, ...)` and similar reflective stores into an
+ * existing object. */
+function reflectiveStore(call: ts.CallExpression): boolean {
+ const callee = skipOuter(call.expression);
+ if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.expression)) return false;
+ const owner = callee.expression.text;
+ const method = callee.name.text;
+ const reflective =
+ (owner === "Object" &&
+ (method === "assign" ||
+ method === "defineProperty" ||
+ method === "defineProperties" ||
+ method === "setPrototypeOf")) ||
+ (owner === "Reflect" &&
+ (method === "set" ||
+ method === "defineProperty" ||
+ method === "deleteProperty" ||
+ method === "setPrototypeOf"));
+ if (!reflective) return false;
+ const target = call.arguments[0];
+ return target === undefined || !ts.isObjectLiteralExpression(skipOuter(target));
+}
+
+function isPlainContainerType(checker: ts.TypeChecker, type: ts.Type): boolean {
+ if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false;
+ const parts = type.isUnionType() ? ts.constituentTypes(type) : [type];
+ return parts.every((part) => {
+ if (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)) return true;
+ if (checker.isArrayType(part) || checker.isTupleType(part) || checker.isArrayLikeType(part))
+ return true;
+ const symbol = part.getSymbol();
+ if (symbol && symbol.flags & ts.SymbolFlags.Class) return false;
+ return checker.getIndexInfosOfType(part).length > 0;
+ });
+}
+
+/** Entries of an `at`-ordered list taking effect in `(start, end]`. */
+function between(
+ entries: readonly Timed[],
+ start: number,
+ end: number,
+): Timed[] {
+ let lo = 0;
+ let hi = entries.length;
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1;
+ if (entries[mid]!.at <= start) lo = mid + 1;
+ else hi = mid;
+ }
+ const out: Timed[] = [];
+ for (let i = lo; i < entries.length && entries[i]!.at <= end; i++) out.push(entries[i]!);
+ return out;
+}
diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts
index a16965d4c..20e139e63 100644
--- a/packages/compiler/src/frontend/program.ts
+++ b/packages/compiler/src/frontend/program.ts
@@ -13,7 +13,7 @@
* the order-parity suite holding both lanes to identical diagnostics and
* module order over the whole corpus. The phase-4 flip deleted the 5.9.3
* pipeline; its recorded verdicts live on as the canary baselines
- * (test/ts7/baselines/order-parity.json), and the rationale prose the old
+ * (test/ts7/baselines/order-parity/), and the rationale prose the old
* program.ts carried was folded in here where behavior demands it.
*
* Differences from the retired 5.9.3 lane, each deliberate and each
diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts
index 3d7f263b6..bbf2a8e07 100644
--- a/packages/compiler/src/frontend/type-mapper.ts
+++ b/packages/compiler/src/frontend/type-mapper.ts
@@ -1201,6 +1201,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
) {
return { kind: "object", className: "%Error" };
}
+ // Immutable (@scriptc/types) is a deep-readonly view: the value is
+ // still the T it maps.
+ const immutableSource = immutableSourceType(type, ctx);
+ if (immutableSource !== null) return mapType(immutableSource, ctx);
if (resolveTypeParam && type.flags & ts.TypeFlags.TypeParameter) {
const bound = resolveTypeParam(type);
if (bound) {
@@ -4534,6 +4538,43 @@ function mapBrandedPrimitiveIntersection(widened: ts.Type, ctx: TypeMapperCtx):
return primitive && markerParts > 0 ? primitive : null;
}
+/** The declaration file of @scriptc/types (installed, pnpm-linked or the
+ * workspace package). */
+const CONTRACT_TYPES_FILE = /[\\/](?:@scriptc[\\/]types|packages[\\/]types)[\\/]index\.d\.ts$/;
+
+/** `Immutable` over a class, interface or object literal type resolves to
+ * a mapped type declared in @scriptc/types; TypeScript drops the alias, but
+ * homomorphic mapped members keep their original declarations. The source
+ * type is the non-generic declaration that declares every member (the most
+ * derived class or interface). Null for every other type. */
+function immutableSourceType(type: ts.Type, ctx: TypeMapperCtx): ts.Type | null {
+ if (!isMappedShape(type)) return null;
+ const { checker } = ctx;
+ const symbol = type.getSymbol();
+ const decl = symbol ? checker.declarationsOf(symbol)[0] : undefined;
+ if (decl === undefined || !CONTRACT_TYPES_FILE.test(decl.getSourceFile().fileName)) return null;
+ const props = checker.getPropertiesOfType(type);
+ const seen = new Set();
+ for (const prop of props) {
+ const owner = checker.declarationsOf(prop)[0]?.parent;
+ if (owner === undefined || seen.has(owner)) continue;
+ seen.add(owner);
+ let source: ts.Type | null = null;
+ if (ts.isTypeLiteralNode(owner)) source = checker.getTypeFromTypeNode(owner);
+ else if (
+ (ts.isClassDeclaration(owner) || ts.isInterfaceDeclaration(owner)) &&
+ owner.name !== undefined &&
+ (owner.typeParameters?.length ?? 0) === 0
+ ) {
+ const ownerSymbol = checker.getSymbolAtLocation(owner.name);
+ if (ownerSymbol) source = checker.getDeclaredTypeOfSymbol(ownerSymbol);
+ }
+ if (source !== null && checker.getPropertiesOfType(source).length === props.length)
+ return source;
+ }
+ return null;
+}
+
/** True for MAPPED-type results — `Partial`, `Record<"a", n>`,
* whatever `Pick`/`Omit` reduce to. Their shape is computed by the checker,
* not declared anywhere: the only declaration behind them is the utility
diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts
index 2baf80819..ffd6ae468 100644
--- a/packages/compiler/src/ir/int32-slots.ts
+++ b/packages/compiler/src/ir/int32-slots.ts
@@ -54,6 +54,14 @@ export class Int32Slots {
readonly provenWrites = new Set();
/** Disqualified candidates and the first reason, for tests. */
readonly reasons = new Map();
+ /** Slots an Int32 contract pins (`field:`, `param:`, `ret:`, `box:` keys):
+ * the contract check proved every write, so they are never dropped. */
+ private readonly pinned = new Set();
+ /** Int32 contract locals (`function\0localId`) and globals (`\0id`). */
+ private readonly int32Locals = new Set();
+ /** Drops the analysis attempted on pinned slots. The contract check and
+ * this analysis use the same range proofs, so this stays empty. */
+ readonly pinViolations: string[] = [];
constructor(private readonly classes: ReadonlyMap = new Map()) {}
@@ -89,7 +97,8 @@ export class Int32Slots {
this.fields.size === 0 &&
this.returns.size === 0 &&
this.paramFacts.size === 0 &&
- this.boxes.size === 0
+ this.boxes.size === 0 &&
+ this.int32Locals.size === 0
)
return {};
const result: IntegerSlotFacts = {
@@ -97,6 +106,11 @@ export class Int32Slots {
call: (callee) => (this.returns.has(callee) ? INT32_RANGE : null),
boxed: (localId) => (this.hasBox(this.boxGroup(fnName, localId)) ? INT32_RANGE : null),
};
+ if (this.int32Locals.size > 0)
+ result.locals = (localId) =>
+ this.int32Locals.has(`${fnName}\0${localId}`) || this.int32Locals.has(`\0${localId}`)
+ ? INT32_RANGE
+ : null;
const params = this.paramFacts.get(fnName);
if (params) result.params = params;
return result;
@@ -108,11 +122,27 @@ export class Int32Slots {
/* ── construction (analyzeInt32Slots only) ── */
+ pin(key: string): void {
+ this.pinned.add(key);
+ }
+ isPinnedField(family: string): boolean {
+ return this.pinned.has(`field:${family}`);
+ }
+ addInt32Local(key: string): void {
+ this.int32Locals.add(key);
+ }
+ private refusePinned(key: string): boolean {
+ if (!this.pinned.has(key)) return false;
+ this.pinViolations.push(key);
+ return true;
+ }
+
addBox(group: string, members: readonly string[]): void {
this.boxes.add(group);
for (const member of members) this.boxGroupOf.set(member, group);
}
dropBox(group: string): boolean {
+ if (this.refusePinned(`box:${group}`)) return false;
return this.boxes.delete(group);
}
hasBox(group: string | null): boolean {
@@ -123,6 +153,7 @@ export class Int32Slots {
this.fields.add(family);
}
dropField(family: string): boolean {
+ if (this.refusePinned(`field:${family}`)) return false;
return this.fields.delete(family);
}
hasFamily(family: string): boolean {
@@ -134,6 +165,7 @@ export class Int32Slots {
params.set(localId, INT32_RANGE);
}
dropParam(fnName: string, localId: string): boolean {
+ if (this.refusePinned(`param:${fnName}\0${localId}`)) return false;
const params = this.paramFacts.get(fnName);
if (!params?.delete(localId)) return false;
if (params.size === 0) this.paramFacts.delete(fnName);
@@ -146,6 +178,7 @@ export class Int32Slots {
this.returns.add(fnName);
}
dropReturn(fnName: string): boolean {
+ if (this.refusePinned(`ret:${fnName}`)) return false;
return this.returns.delete(fnName);
}
hasReturn(fnName: string): boolean {
@@ -337,15 +370,12 @@ export function dynamicFieldStores(
return { all: dynWritesAll, names: dynWritten };
}
-export function analyzeInt32Slots(mod: IrModule): Int32Slots {
+/** The f64 class field families a runtime store through a live dynamic view
+ * can write: the class reaches checked-dynamic code (directly or through
+ * another exposed type), and some dynamic store in the program can name the
+ * field. */
+export function dynamicallyStoredFamilies(mod: IrModule, slots: Int32Slots): Set {
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
- const slots = new Int32Slots(classes);
- if (mod.lib) return slots;
- const reason = (key: string, why: string): void => {
- if (!slots.reasons.has(key)) slots.reasons.set(key, why);
- };
-
- /* ── candidate fields ── */
const children = new Map();
for (const cls of classes.values())
if (cls.base) {
@@ -363,33 +393,6 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
}
return out;
};
- const genericFamilies = new Set();
- for (const cls of classes.values())
- if (cls.genericOf !== undefined) {
- genericFamilies.add(cls.name);
- genericFamilies.add(cls.genericOf);
- }
- const plainClass = (cls: IrClassDef): boolean => {
- for (let c: IrClassDef | undefined = cls; c; c = c.base ? classes.get(c.base) : undefined) {
- if (c.runtime || genericFamilies.has(c.name)) return false;
- if (c.base && !classes.has(c.base)) return false;
- }
- return true;
- };
- const dropFamily = (family: string, why: string): void => {
- reason(`field:${family}`, why);
- slots.dropField(family);
- };
- for (const cls of classes.values())
- for (const field of cls.fields) {
- if (field.type.kind !== "f64" || field.name.startsWith("%")) continue;
- const family = slots.family(cls.name, field.name)!;
- if (plainClass(cls)) {
- if (!slots.reasons.has(`field:${family}`)) slots.addField(family);
- } else dropFamily(family, "runtime-provided or generic class");
- }
-
- /* ── dynamic, reflective and runtime exposure ── */
const records = new Map((mod.records ?? []).map((r) => [r.id, r]));
const unions = new Map((mod.unions ?? []).map((u) => [u.id, u]));
const exposedClasses = new Set();
@@ -463,6 +466,7 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
if (s.kind === "throw") expose(s.value.type);
},
});
+ const families = new Set();
for (const cls of classes.values()) {
if (!exposeAll && !exposedClasses.has(cls.name)) continue;
for (const field of cls.fields)
@@ -471,12 +475,27 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
!field.name.startsWith("%") &&
(dynWritesAll || dynWritten.has(field.name))
)
- dropFamily(slots.family(cls.name, field.name)!, "dynamic stores can name the field");
+ families.add(slots.family(cls.name, field.name)!);
}
+ return families;
+}
- /* ── functions: escapes, parameter and return candidates ── */
+/** How functions can be reached besides direct calls: as values (closures,
+ * the entry, constructors behind class values: `valueEscaped`), through
+ * virtual dispatch (`virtualTargets`), or as vtable entries the runtime
+ * calls (`runtimeVirtual`: methods of runtime-rooted hierarchies). */
+export function functionCallFacts(
+ mod: IrModule,
+ classes: ReadonlyMap,
+ subtree: (className: string) => string[],
+): {
+ valueEscaped: Set;
+ virtualTargets: Set;
+ runtimeVirtual: (name: string) => boolean;
+} {
const functions = new Map(mod.functions.map((fn) => [fn.name, fn]));
const escaped = new Set();
+ const virtualTargets = new Set();
const seenObjects = new Set();
const scan = (
value: unknown,
@@ -520,9 +539,9 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
walkFunction(fn, {
expr: (e) => {
if (e.kind !== "virtualCall") return;
- for (const name of subtree(e.className)) escaped.add(`%${name}.${e.method}`);
+ for (const name of subtree(e.className)) virtualTargets.add(`%${name}.${e.method}`);
for (let c = classes.get(e.className); c; c = c.base ? classes.get(c.base) : undefined)
- escaped.add(`%${c.name}.${e.method}`);
+ virtualTargets.add(`%${c.name}.${e.method}`);
},
});
// A class value can reach `new` through its construct thunk; one that
@@ -563,8 +582,91 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
if (e.kind === "classRef" && !testedOnly.has(e)) escaped.add(`%${e.className}.constructor`);
},
});
- const virtual = (name: string): boolean =>
+ const runtimeVirtual = (name: string): boolean =>
runtimePrefixes.some((prefix) => name.startsWith(prefix));
+ return { valueEscaped: escaped, virtualTargets, runtimeVirtual };
+}
+
+export function analyzeInt32Slots(mod: IrModule): Int32Slots {
+ const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
+ const slots = new Int32Slots(classes);
+ if (mod.lib) return slots;
+ const reason = (key: string, why: string): void => {
+ if (!slots.reasons.has(key)) slots.reasons.set(key, why);
+ };
+ // Int32 contracts (ir/contract-check.ts proved every write): pinned
+ // candidates the fixpoint below never drops, plus local facts.
+ const contracts = mod.contracts;
+ for (const family of contracts?.int32Fields ?? []) {
+ slots.pin(`field:${family}`);
+ slots.addField(family);
+ }
+ for (const key of contracts?.int32Params ?? []) {
+ const cut = key.indexOf("\0");
+ slots.pin(`param:${key}`);
+ slots.addParam(key.slice(0, cut), key.slice(cut + 1));
+ }
+ for (const fnName of contracts?.int32Returns ?? []) {
+ slots.pin(`ret:${fnName}`);
+ slots.addReturn(fnName);
+ }
+ for (const key of contracts?.int32Locals ?? []) slots.addInt32Local(key);
+
+ /* ── candidate fields ── */
+ const children = new Map();
+ for (const cls of classes.values())
+ if (cls.base) {
+ const list = children.get(cls.base);
+ if (list) list.push(cls.name);
+ else children.set(cls.base, [cls.name]);
+ }
+ const subtree = (name: string): string[] => {
+ const out: string[] = [];
+ const stack = [name];
+ while (stack.length) {
+ const next = stack.pop()!;
+ out.push(next);
+ for (const child of children.get(next) ?? []) stack.push(child);
+ }
+ return out;
+ };
+ const genericFamilies = new Set();
+ for (const cls of classes.values())
+ if (cls.genericOf !== undefined) {
+ genericFamilies.add(cls.name);
+ genericFamilies.add(cls.genericOf);
+ }
+ const plainClass = (cls: IrClassDef): boolean => {
+ for (let c: IrClassDef | undefined = cls; c; c = c.base ? classes.get(c.base) : undefined) {
+ if (c.runtime || genericFamilies.has(c.name)) return false;
+ if (c.base && !classes.has(c.base)) return false;
+ }
+ return true;
+ };
+ const dropFamily = (family: string, why: string): void => {
+ reason(`field:${family}`, why);
+ slots.dropField(family);
+ };
+ for (const cls of classes.values())
+ for (const field of cls.fields) {
+ if (field.type.kind !== "f64" || field.name.startsWith("%")) continue;
+ const family = slots.family(cls.name, field.name)!;
+ if (plainClass(cls)) {
+ if (!slots.reasons.has(`field:${family}`)) slots.addField(family);
+ } else dropFamily(family, "runtime-provided or generic class");
+ }
+
+ /* ── dynamic, reflective and runtime exposure ── */
+ for (const family of dynamicallyStoredFamilies(mod, slots)) {
+ if (slots.isPinnedField(family)) continue;
+ dropFamily(family, "dynamic stores can name the field");
+ }
+
+ /* ── functions: escapes, parameter and return candidates ── */
+ const functions = new Map(mod.functions.map((fn) => [fn.name, fn]));
+ const calls = functionCallFacts(mod, classes, subtree);
+ const escaped = new Set([...calls.valueEscaped, ...calls.virtualTargets]);
+ const virtual = calls.runtimeVirtual;
const paramKey = (fnName: string, localId: string): string => `param:${fnName}\0${localId}`;
const dropParam = (fnName: string, localId: string, why: string): boolean => {
diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts
index 876f18c79..5e840bddf 100644
--- a/packages/compiler/src/ir/integer-ranges.ts
+++ b/packages/compiler/src/ir/integer-ranges.ts
@@ -76,6 +76,9 @@ export interface IntegerSlotFacts {
field?: (className: string, field: string) => IntegerRange | null;
call?: (callee: string) => IntegerRange | null;
boxed?: (localId: string) => IntegerRange | null;
+ /** Locals and globals an Int32 contract covers (ir/contract-check.ts):
+ * every write anywhere is proven int32, so every read is one. */
+ locals?: (localId: string) => IntegerRange | null;
}
/** Structured local dataflow. Calls cannot write uncaptured local slots,
@@ -101,8 +104,10 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
const loopLocals = new Map(fn.locals.filter((l) => !captures.has(l.id)).map((l) => [l.id, l]));
// A captured binding has no local dataflow: only a proof that covers
// every write of the shared box anywhere applies to its reads.
- const boxedRange = (localId: string): IntegerRange | null =>
- eligible.has(localId) ? null : (slots.boxed?.(localId) ?? null);
+ const boxedRange = (localId: string): IntegerRange | null => {
+ const boxed = eligible.has(localId) ? null : (slots.boxed?.(localId) ?? null);
+ return boxed ?? slots.locals?.(localId) ?? null;
+ };
const slotRange = (e: IrExpr): IntegerRange | null =>
e.type.kind !== "f64"
? null
@@ -292,7 +297,11 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
? { min: bound.value, max: bound.value }
: ranges.get(bound);
if (!boundRange) return;
- const previous = facts.get(ref.localId);
+ // An Int32 contract local holds an int32 even where the dataflow has
+ // no fact for it (loop headers, after unknown regions).
+ const previous =
+ facts.get(ref.localId) ??
+ (eligible.has(ref.localId) ? (slots.locals?.(ref.localId) ?? undefined) : undefined);
if (!previous) return;
if (!truth)
op =
@@ -584,8 +593,20 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
if (s.cond) refine(s.cond, true, inside);
body(s.body, inside);
// Continue can bypass any body assignment. The update therefore
- // starts from header invariants rather than the body's exit facts.
- if (s.update) body([s.update], new Map(loop));
+ // starts from header invariants rather than the body's exit facts,
+ // plus what the condition proved about bindings the body never
+ // writes (they still hold the values the condition tested).
+ if (s.update) {
+ const entry = new Map(loop);
+ if (s.cond) {
+ const refined = new Map(loop);
+ refine(s.cond, true, refined);
+ const written = new Set();
+ for (const node of s.body) for (const id of writes(node)) written.add(id);
+ for (const [id, range] of refined) if (!written.has(id)) entry.set(id, range);
+ }
+ body([s.update], entry);
+ }
havoc(facts, [s], facts);
break;
}
diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts
index f7ac00459..4b687f438 100644
--- a/packages/compiler/src/ir/ir.ts
+++ b/packages/compiler/src/ir/ir.ts
@@ -983,6 +983,29 @@ export interface IrModule {
* emissions stay conformance-identical by construction. Absent on every
* executable build (the backends emit main() exactly as always). */
lib?: IrLibSection;
+ /** Contract types (@scriptc/types) the frontend verified against every
+ * write in the program (ir/contract-check.ts). Backends may rely on them
+ * unconditionally. Absent when the program writes no contract. */
+ contracts?: IrContracts;
+}
+
+/** Verified contract facts, keyed by IR names. */
+export interface IrContracts {
+ /** Int32 class field families: `${topmost declaring class}\0${field}`. */
+ int32Fields?: string[];
+ /** Int32 parameters: `${function}\0${localId}`. */
+ int32Params?: string[];
+ /** Functions whose every return is an int32. */
+ int32Returns?: string[];
+ /** Int32 locals (`${function}\0${localId}`, including every capture of a
+ * captured binding) and module globals (`\0${globalId}`). */
+ int32Locals?: string[];
+ /** Functions that cannot throw (NoThrow). */
+ noThrow?: string[];
+ /** Immutable module constants built from fresh literals: the backend
+ * makes their graphs immortal once initialized, and reads through them
+ * need no reference counting. */
+ immortalGlobals?: string[];
}
export type IrFfiValueParamClass =
diff --git a/packages/compiler/src/ir/serialize.ts b/packages/compiler/src/ir/serialize.ts
index 9455191d4..ade5c50f9 100644
--- a/packages/compiler/src/ir/serialize.ts
+++ b/packages/compiler/src/ir/serialize.ts
@@ -6,7 +6,7 @@
* already close to it). The compact encoding is therefore produced as ordered
* text parts and decoded from bytes one function at a time; neither direction
* materializes the complete document as a single string. */
-import type { IrFunction, IrModule } from "./ir.js";
+import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js";
// Version 15 expands the native Error prefix with filesystem metadata.
// Older modules embed incompatible subclass offsets and must be rejected.
@@ -99,6 +99,16 @@ export function deserializeModule(json: string): IrModule {
return checkedVersion(JSON.parse(json, decodeNumber) as IrModule);
}
+/** A node as it reads back from the compact encoding: a fresh object, as
+ * is every node inside it. */
+export function reparseExpr(node: IrExpr): IrExpr {
+ return JSON.parse(JSON.stringify(node, encodeNumber), decodeNumber) as IrExpr;
+}
+
+export function reparseStmt(node: IrStmt): IrStmt {
+ return JSON.parse(JSON.stringify(node, encodeNumber), decodeNumber) as IrStmt;
+}
+
const QUOTE = 0x22;
const BACKSLASH = 0x5c;
const COMMA = 0x2c;
diff --git a/packages/compiler/src/ir/unshare.test.ts b/packages/compiler/src/ir/unshare.test.ts
new file mode 100644
index 000000000..d8a6346a8
--- /dev/null
+++ b/packages/compiler/src/ir/unshare.test.ts
@@ -0,0 +1,124 @@
+import { expect, test } from "vitest";
+import { emitLlvmModule } from "../backend/llvm/emitter.js";
+import { analyzeIntegerRanges } from "./integer-ranges.js";
+import { F64, VOID, type IrExpr, type IrModule, type IrStmt } from "./ir.js";
+import { deserializeModule, serializeModule } from "./serialize.js";
+import { everyStmtList } from "./traverse.js";
+import { unshareNodes } from "./unshare.js";
+import { validateModule } from "./validate.js";
+
+const loc = { file: "unshare.ts", start: 0, end: 1 };
+const num = (value: number): IrExpr => ({
+ kind: "numLit",
+ value,
+ type: F64,
+ loc,
+});
+const add = (left: IrExpr, right: IrExpr): IrExpr => ({
+ kind: "bin",
+ op: "+",
+ left,
+ right,
+ type: F64,
+ loc,
+});
+
+/** One varRef object read at two sites: `x` is unknown at the first and
+ * exactly 5 at the second, as lowering helpers that reuse a read produce;
+ * and one `p + 2` expression stored at two sites. */
+function sharedRead(): { mod: IrModule; read: IrExpr } {
+ const read: IrExpr = { kind: "varRef", localId: "x", type: F64, loc };
+ const twice = add({ kind: "varRef", localId: "p", type: F64, loc }, num(2));
+ const body: IrStmt[] = [
+ {
+ kind: "varDecl",
+ localId: "x",
+ init: { kind: "varRef", localId: "p", type: F64, loc },
+ loc,
+ },
+ { kind: "varDecl", localId: "a", init: add(read, num(1)), loc },
+ { kind: "assign", localId: "x", value: num(5), loc },
+ { kind: "varDecl", localId: "b", init: add(read, num(1)), loc },
+ // A repeated node with children: its leaves repeat with it.
+ { kind: "varDecl", localId: "c", init: twice, loc },
+ { kind: "assign", localId: "c", value: twice, loc },
+ {
+ kind: "return",
+ value: add(
+ { kind: "varRef", localId: "a", type: F64, loc },
+ {
+ kind: "varRef",
+ localId: "b",
+ type: F64,
+ loc,
+ },
+ ),
+ loc,
+ },
+ ];
+ const mod: IrModule = {
+ irVersion: 15,
+ sourceFile: loc.file,
+ entry: "main",
+ functions: [
+ { name: "main", loc, params: [], locals: [], returnType: VOID, body: [] },
+ {
+ name: "f",
+ loc,
+ params: [{ name: "p", localId: "p", type: F64 }],
+ locals: ["p", "x", "a", "b", "c"].map((id) => ({
+ id,
+ name: id,
+ type: F64,
+ mutable: true,
+ })),
+ returnType: F64,
+ body,
+ },
+ ],
+ };
+ return { mod, read };
+}
+
+function nodes(mod: IrModule): (IrExpr | IrStmt)[] {
+ const out: (IrExpr | IrStmt)[] = [];
+ const visit = (node: IrExpr | IrStmt): boolean => (out.push(node), true);
+ for (const fn of mod.functions) everyStmtList(fn.body, { expr: visit, stmt: visit });
+ return out;
+}
+
+test("every occurrence gets its own node without mutating the input", () => {
+ const { mod, read } = sharedRead();
+ const before = serializeModule(mod);
+ const tree = unshareNodes(mod);
+ expect(serializeModule(mod)).toBe(before);
+ expect(serializeModule(tree)).toBe(before);
+ const all = nodes(tree);
+ expect(new Set(all).size).toBe(all.length);
+ expect(all).not.toContain(read);
+ // The entry function had no shared node and is kept as it is.
+ expect(tree.functions[0]).toBe(mod.functions[0]);
+ expect(unshareNodes(tree)).toBe(tree);
+});
+
+test("ranges are per occurrence once nodes are unshared", () => {
+ const { mod, read } = sharedRead();
+ // Keyed by identity, the shared read holds the join of both sites.
+ expect(analyzeIntegerRanges(mod.functions[1]!).get(read)).toBeNull();
+ const fn = unshareNodes(mod).functions[1]!;
+ const reads = nodes({ ...mod, functions: [fn] }).filter(
+ (node) => node.kind === "varRef" && node.localId === "x",
+ );
+ const ranges = analyzeIntegerRanges(fn);
+ expect(reads.map((node) => ranges.get(node as IrExpr) ?? null)).toEqual([
+ null,
+ { min: 5, max: 5 },
+ ]);
+});
+
+test("emission matches the module read back from its serialized form", () => {
+ const { mod } = sharedRead();
+ expect(validateModule(mod)).toEqual([]);
+ const restored = deserializeModule(serializeModule(mod, true));
+ expect(emitLlvmModule(mod)).toBe(emitLlvmModule(restored));
+});
diff --git a/packages/compiler/src/ir/unshare.ts b/packages/compiler/src/ir/unshare.ts
new file mode 100644
index 000000000..36dd6176f
--- /dev/null
+++ b/packages/compiler/src/ir/unshare.ts
@@ -0,0 +1,60 @@
+import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js";
+import { reparseExpr, reparseStmt } from "./serialize.js";
+import { everyExprChild, everyStmtChild, mapExprChildren, mapStmtChildren } from "./traverse.js";
+
+/** Give every occurrence of an executable node its own object.
+ *
+ * Lowering helpers may place one node at several sites (a binding's varRef
+ * read in a loop condition and in its body, a shared `break`). Analyses key
+ * their facts by node identity (integer ranges, int32 write proofs, borrow
+ * and lifetime facts), so a shared node carries one fact for all of its
+ * sites: integer ranges keep the join, which loses what one site proves. A
+ * serialized module gives every occurrence its own node; this makes the
+ * in-memory module equivalent, so analyses and emission behave the same on
+ * a module lowered in memory and on one read back from its artifact.
+ * Functions without a repeated node are kept as they are; the input is
+ * never mutated. */
+export function unshareNodes(mod: IrModule): IrModule {
+ const seen = new Set();
+ const repeated = new Set();
+ const affected = new Set();
+ let current: IrFunction | null = null;
+ // A repeated node marks the function holding the later occurrence. Its
+ // subtree is walked again, so every node under it is repeated as well.
+ const visit = (node: IrExpr | IrStmt): void => {
+ if (!seen.has(node)) seen.add(node);
+ else {
+ repeated.add(node);
+ affected.add(current!);
+ }
+ };
+ const findExpr = (node: IrExpr): boolean => {
+ visit(node);
+ return everyExprChild(node, findExpr, findStmt);
+ };
+ const findStmt = (node: IrStmt): boolean => {
+ visit(node);
+ return everyStmtChild(node, findExpr, findStmt);
+ };
+ for (const fn of mod.functions) {
+ current = fn;
+ for (const node of fn.body) findStmt(node);
+ }
+ if (affected.size === 0) return mod;
+ // Rebuilding a function copies every node that has children; a repeated
+ // childless node reads back as a fresh object at each of its sites there.
+ const expr = (node: IrExpr): IrExpr => {
+ const copy = mapExprChildren(node, expr, stmt);
+ return copy !== node || !repeated.has(node) ? copy : reparseExpr(node);
+ };
+ const stmt = (node: IrStmt): IrStmt => {
+ const copy = mapStmtChildren(node, expr, stmt);
+ return copy !== node || !repeated.has(node) ? copy : reparseStmt(node);
+ };
+ return {
+ ...mod,
+ functions: mod.functions.map((fn) =>
+ affected.has(fn) ? { ...fn, body: fn.body.map(stmt) } : fn,
+ ),
+ };
+}
diff --git a/packages/compiler/src/library/prepare.ts b/packages/compiler/src/library/prepare.ts
index fbd04bb95..88106433c 100644
--- a/packages/compiler/src/library/prepare.ts
+++ b/packages/compiler/src/library/prepare.ts
@@ -55,6 +55,7 @@ import {
} from "../ir/ir.js";
import { moduleLibAsyncSurface, moduleLibNondeterministicSurface } from "./ir-policy.js";
import { validateModule } from "../ir/validate.js";
+import { checkContracts } from "../contracts/check.js";
/** The marshalling-class fit over IR types (design §4.2 + the ratified
* integer plumbing classes): number is every f64-backed class, bool/string
@@ -609,6 +610,12 @@ export function prepareLibrary(
const fenced = evaluateLibraryFences(mod, profile);
if (fenced.length > 0) return fail(fenced);
mod.lib = resolved.lib;
+ if (lowered.contracts) {
+ const checked = checkContracts(mod, lowered.contracts, sourceTexts);
+ timing("contracts");
+ if (checked.diagnostics.length > 0) return fail(checked.diagnostics);
+ if (checked.contracts) mod.contracts = checked.contracts;
+ }
if (buildPlatform === "wasi") {
fenceSpeculativeWasiFunctions(mod);
const unavailable = moduleWasiUnavailableSurface(mod);
diff --git a/packages/compiler/test/bytes-element-emission.test.ts b/packages/compiler/test/bytes-element-emission.test.ts
index 08da691ee..80e3e0ecb 100644
--- a/packages/compiler/test/bytes-element-emission.test.ts
+++ b/packages/compiler/test/bytes-element-emission.test.ts
@@ -314,7 +314,10 @@ test("canonical byte loops keep their induction variable and indices integral",
expect(ll).toContain("alloca i64 ; integer induction index");
expect(ll).toContain("icmp ult i64");
- expect(ll).not.toContain("bytes.index.valid");
+ // The proven in-bounds index needs no element check at all. Do not assert
+ // on `fptosi double` here: the fixture's inline ToUint32 coercion of the
+ // running sum legitimately uses it.
+ expect(ll).not.toContain("bytes.index");
});
test("a body mutation keeps the byte loop on the general f64 path", () => {
diff --git a/packages/compiler/test/call-lifetimes.test.ts b/packages/compiler/test/call-lifetimes.test.ts
index dde71c67c..646e901c9 100644
--- a/packages/compiler/test/call-lifetimes.test.ts
+++ b/packages/compiler/test/call-lifetimes.test.ts
@@ -329,11 +329,15 @@ console.log(alias(new Item()).value, snapshot(new Item()).value);
// The rebound parameter keeps borrowing (its new value lives in an owner
// slot), but the alias saved before the rebinding must own its value.
// `new Item()` calls the borrowed constructor body, so no retain transfers
- // the fresh instance into it: the saved alias and the returned value.
+ // the fresh instance into it. The saved alias retains once, and `return
+ // saved` moves that owned scope entry into the result instead of retaining
+ // it again and releasing the local at scope exit: the only releases are the
+ // rebound parameter's owner slot (before the store and at exit).
const snapshot = body(llvm, "sc_bf_snapshot");
- expect(snapshot.match(/@sc_retain_Item/g)).toHaveLength(2);
+ expect(snapshot.match(/@sc_retain_Item/g)).toHaveLength(1);
expect(snapshot).toContain("@sc_bf__x25_Item_constructor");
- expect(snapshot).toContain("@sc_release_Item");
+ expect(snapshot.match(/@sc_release_Item/g)).toHaveLength(2);
+ expect(snapshot).toMatch(/(%t\d+) = load ptr, ptr %sc_l_saved_0\n[^]*ret ptr \1\n/);
});
test("unchanged captured parameters are boxed when a closure first needs them", async () => {
diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts
index d85579558..49fd801b6 100644
--- a/packages/compiler/test/native-codegen-integration.test.ts
+++ b/packages/compiler/test/native-codegen-integration.test.ts
@@ -312,7 +312,7 @@ ${[0, 1, 2, 3, 4, 5].map((index) => ` call void @add${index}(i64 ${index + 2})`
expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"]));
expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"]));
expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]);
- expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v12");
+ expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v13");
const clangExe = join(dir, "clang-program");
const linkDriver = join(dir, "clang-driver.c");
@@ -646,7 +646,7 @@ ${[0, 1, 2, 3, 4, 5].map((index) => ` call void @add${index}(i64 ${index + 2})`
.split("\n");
expect(undefinedSymbols).toContain("_sf_scale");
expect(undefinedSymbols).toContain("_sf_callback_mix");
- expect(undefinedSymbols).toContain("_scr_runtime_abi_v12");
+ expect(undefinedSymbols).toContain("_scr_runtime_abi_v13");
});
test("helper and clang object paths retain the same outbound FFI C ABI", async () => {
diff --git a/packages/compiler/test/string-and-array-lifetimes.test.ts b/packages/compiler/test/string-and-array-lifetimes.test.ts
index 4f59d8bbc..183a1a426 100644
--- a/packages/compiler/test/string-and-array-lifetimes.test.ts
+++ b/packages/compiler/test/string-and-array-lifetimes.test.ts
@@ -217,15 +217,39 @@ test("a throwing later source argument releases the retained snapshot on its exc
const module = await lower(`
class Item { value = 7; }
function read(item: Item, ignored: number): number { return item.value + ignored; }
+function fail(items: Item[]): number { items.pop(); throw new Error("failed"); }
+function work(items: Item[]): number { return read(items[0], fail(items)); }
+try { console.log(work([new Item()])); } catch {}
+`);
+ // The later argument removes the element, so the snapshot must own it.
+ const work = body(emitLlvmModule(module), "sc_bf_work");
+ const failure = work.indexOf("@sc_bf_fail");
+ expect(failure).toBeGreaterThan(0);
+ expect(work.slice(0, failure)).toContain("@sc_retain_Item");
+ expect(work.slice(failure)).toMatch(PENDING_CHECK);
+ const exceptionEdge = work.slice(failure, work.indexOf("@sc_bf_read"));
+ expect(exceptionEdge).toContain("@sc_release_Item");
+ expect(work).not.toContain("@scr_union_release");
+});
+
+test("a throwing later source argument that writes no element leaves the snapshot borrowed", async () => {
+ const module = await lower(`
+class Item { value = 7; }
+function read(item: Item, ignored: number): number { return item.value + ignored; }
function fail(): number { throw new Error("failed"); }
function work(items: Item[]): number { return read(items[0], fail()); }
try { console.log(work([new Item()])); } catch {}
`);
+ // Neither the callee nor the later argument writes an array element (the
+ // Error constructor's stores into its own fresh object do not count), so
+ // the borrowed parameter's array keeps the element alive and no edge,
+ // normal or exceptional, owes a release.
const work = body(emitLlvmModule(module), "sc_bf_work");
const failure = work.indexOf("@sc_f_fail");
expect(failure).toBeGreaterThan(0);
expect(work.slice(failure)).toMatch(PENDING_CHECK);
- expect(work.slice(failure)).toContain("@sc_release_Item");
+ expect(work).not.toContain("@sc_retain_Item");
+ expect(work).not.toContain("@sc_release_Item");
expect(work).not.toContain("@scr_union_release");
});
diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json
deleted file mode 100644
index 2c3a31530..000000000
--- a/packages/compiler/test/ts7/baselines/order-parity.json
+++ /dev/null
@@ -1,15894 +0,0 @@
-{
- "entries": {
- "/tests/corpus/001-hello.ts": {
- "order": [
- "/tests/corpus/001-hello.ts"
- ],
- "diags": []
- },
- "/tests/corpus/002-log-args.ts": {
- "order": [
- "/tests/corpus/002-log-args.ts"
- ],
- "diags": []
- },
- "/tests/corpus/100-number-format.ts": {
- "order": [
- "/tests/corpus/100-number-format.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1000-json-stringify-basics.ts": {
- "order": [
- "/tests/corpus/1000-json-stringify-basics.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1001-json-escapes-unicode.ts": {
- "order": [
- "/tests/corpus/1001-json-escapes-unicode.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1002-json-parse-cast.ts": {
- "order": [
- "/tests/corpus/1002-json-parse-cast.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1003-json-parse-unions.ts": {
- "order": [
- "/tests/corpus/1003-json-parse-unions.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1004-json-parse-errors.ts": {
- "order": [
- "/tests/corpus/1004-json-parse-errors.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1005-json-nested.ts": {
- "order": [
- "/tests/corpus/1005-json-nested.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1006-json-fs-config.ts": {
- "order": [
- "/tests/corpus/1006-json-fs-config.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1007-json-rc-stress.ts": {
- "order": [
- "/tests/corpus/1007-json-rc-stress.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1008-json-null-arms.ts": {
- "order": [
- "/tests/corpus/1008-json-null-arms.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1009-json-optional-fields.ts": {
- "order": [
- "/tests/corpus/1009-json-optional-fields.ts"
- ],
- "diags": []
- },
- "/tests/corpus/101-arithmetic.ts": {
- "order": [
- "/tests/corpus/101-arithmetic.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1010-json-stringify-space.ts": {
- "order": [
- "/tests/corpus/1010-json-stringify-space.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1011-json-unknown-typeof.ts": {
- "order": [
- "/tests/corpus/1011-json-unknown-typeof.ts"
- ],
- "diags": []
- },
- "/tests/corpus/102-comparisons.ts": {
- "order": [
- "/tests/corpus/102-comparisons.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1020-async-basics.ts": {
- "order": [
- "/tests/corpus/1020-async-basics.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1021-async-ordering.ts": {
- "order": [
- "/tests/corpus/1021-async-ordering.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1022-async-exceptions.ts": {
- "order": [
- "/tests/corpus/1022-async-exceptions.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1023-async-rc-stress.ts": {
- "order": [
- "/tests/corpus/1023-async-rc-stress.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1024-async-pending-exit.ts": {
- "order": [
- "/tests/corpus/1024-async-pending-exit.ts"
- ],
- "diags": []
- },
- "/tests/corpus/1025-async-promise-capture.ts": {
- "order": [
- "/tests/corpus/1025-async-promise-capture.ts"
- ],
- "diags": []
- },
- "