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/.oxlintrc.jsonc b/.oxlintrc.jsonc
index 7de4019ae..52cbf5026 100644
--- a/.oxlintrc.jsonc
+++ b/.oxlintrc.jsonc
@@ -269,6 +269,7 @@
"packages/compiler/src/frontend/lowering/**",
"packages/compiler/src/backend/llvm/**",
"packages/compiler/src/backend/loop-polls.test.ts",
+ "packages/compiler/src/backend/may-throw.test.ts",
"packages/compiler/src/ir/**",
"packages/compiler/src/library/*.test.ts",
"packages/compiler/test/**",
diff --git a/docs/content/docs/contracts.mdx b/docs/content/docs/contracts.mdx
new file mode 100644
index 000000000..e8cafa2a5
--- /dev/null
+++ b/docs/content/docs/contracts.mdx
@@ -0,0 +1,353 @@
+---
+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.
+
+
+ Acyclic
+ No chain of references leads from an object of the class or type back to itself.
+ Its objects carry no cycle-collector header and are freed by reference counting alone.
+
+
+
+
+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.
+ Acyclic
+ Classes (class C implements Acyclic), interfaces (interface I extends Acyclic) and object type aliases (type T = Acyclic<...> over an object type).
+
+
+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.
+
+A `try` statement discharges what its handlers take. A throwing call inside a `try` with a `catch`, or inside a `try` whose `finally` always ends with `return`, `break` or `continue` (which discards the pending exception), cannot leave the function:
+
+```ts:digits.ts
+import type { NoThrow } from "@scriptc/types";
+
+function parseDigit(ch: number): number {
+ if (ch < 48 || ch > 57) throw new RangeError("not a digit");
+ return ch - 48;
+}
+
+function digitOr(text: string, i: number, fallback: number): NoThrow {
+ try {
+ return parseDigit(text.charCodeAt(i));
+ } catch {
+ return fallback;
+ }
+}
+
+console.log(digitOr("7", 0, -1), digitOr("x", 0, -1));
+```
+
+```console
+$ node digits.ts
+7 -1
+$ scriptc build digits.ts -o digits >/dev/null
+$ ./digits
+7 -1
+```
+
+The `catch` and `finally` bodies are checked like any other code: a rethrow, a throwing call in the `catch`, or a `try`/`finally` without `catch` whose `finally` can complete normally (and so re-raises the exception) still fails the contract. A recursive function cannot be `NoThrow`, because its stack guard throws on entry, outside any handler. In programs that use worker threads, `try` does not discharge the contract, since a termination request unwinds through `catch`. `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.
+
+## Acyclic
+
+scriptc frees objects by reference counting and finds unreachable reference cycles with a cycle collector. Objects of a type that can never lie on a cycle need neither the collector's object header nor its bookkeeping. scriptc already proves this for many types; `Acyclic` turns the proof into a guarantee and shows the cycle when it does not hold.
+
+A reference can close a cycle when it is stored after its object was constructed (a later write can point an older object at a newer one), when it sits in an array, `Map` or `Set` (whose contents change), or when a field holds a function, a promise or a dynamic value (which can reference anything). Scalar fields never matter. A subclass shares its base class's object layout, so a class hierarchy is acyclic only as a whole.
+
+```ts:tree.ts
+import type { Acyclic } from "@scriptc/types";
+
+class Tree implements Acyclic {
+ readonly left: Tree | null;
+ readonly right: Tree | null;
+ constructor(left: Tree | null, right: Tree | null) {
+ this.left = left;
+ this.right = right;
+ }
+ count(): number {
+ return 1 + (this.left?.count() ?? 0) + (this.right?.count() ?? 0);
+ }
+}
+
+function build(depth: number): Tree {
+ return depth === 0 ? new Tree(null, null) : new Tree(build(depth - 1), build(depth - 1));
+}
+
+console.log(build(20).count());
+```
+
+```console
+$ node tree.ts
+2097151
+$ scriptc build tree.ts -o tree >/dev/null
+$ ./tree
+2097151
+```
+
+When a cycle can form, the diagnostic shows its path:
+
+```ts:graph.ts
+import type { Acyclic } from "@scriptc/types";
+
+class Graph implements Acyclic {
+ readonly nodes: GraphNode[] = [];
+}
+
+class GraphNode {
+ readonly graph: Graph;
+ constructor(graph: Graph) {
+ this.graph = graph;
+ }
+}
+
+const graph = new Graph();
+graph.nodes.push(new GraphNode(graph));
+console.log(graph.nodes.length);
+```
+
+```console
+$ scriptc build graph.ts
+/tmp/app/graph.ts:3:24 - error SC7007: 'Graph' is declared Acyclic but can lie on a reference cycle: Graph.nodes → GraphNode.graph → Graph (Graph.nodes is an array, whose contents can change)
+
+ 2 |
+ 3 | class Graph implements Acyclic {
+ | ^~~~~~~
+ 4 | readonly nodes: GraphNode[] = [];
+
+ hint: an array, Map or Set can come to hold an object that refers back; make the element type unable to reach this type, or remove Acyclic
+
+1 error.
+```
+
+## 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), catch it with try/catch, or remove NoThrow
+
+/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.
+
+
+ SC7007
+ An Acyclic class or type can lie on a reference cycle; the message shows the cycle.
+
+
+
diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx
index cf354b971..e0c472112 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, and `Acyclic` only on classes, interfaces and object type aliases; other aliases, interface members and nested type positions are reported. In programs that use worker threads, 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/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/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/compiler/src/backend/cycle-analysis.test.ts b/packages/compiler/src/backend/cycle-analysis.test.ts
index ca1ae760d..3ce624d23 100644
--- a/packages/compiler/src/backend/cycle-analysis.test.ts
+++ b/packages/compiler/src/backend/cycle-analysis.test.ts
@@ -15,7 +15,7 @@ import {
type IrStmt,
type IrType,
} from "../ir/ir.js";
-import { computeTraced } from "./cycle-analysis.js";
+import { computeTraced, explainCycle } from "./cycle-analysis.js";
import { computeTraced as llvmTraced } from "./llvm/shapes.js";
const loc = { file: "cycles.ts", start: 0, end: 1 };
@@ -690,3 +690,62 @@ test("library builds keep the mutable-graph analysis", () => {
mod.lib = {} as NonNullable;
checkExact(mod, ["object:Tree"], ["link"]);
});
+
+test("explanations name a cycle through a mutable edge, or the intrinsic field reached", () => {
+ const mod = module();
+ mod.records = [
+ shape("outer", [ref("a")]),
+ shape("a", [ref("b"), F64]),
+ shape("b", [ref("a")]),
+ shape("holder", [ref("callback")]),
+ shape("callback", [funcOf([], F64)]),
+ shape("items", [arrayOf(ref("items"))]),
+ ];
+ const mutated = withWrites(mod);
+ expect(explainCycle(mutated, "record:outer")).toBeNull();
+ expect(explainCycle(mutated, "record:a")).toEqual({
+ kind: "cycle",
+ steps: [
+ { from: "record:a", label: { kind: "field", field: "field0", mutable: true } },
+ { from: "record:b", label: { kind: "field", field: "field0", mutable: true } },
+ ],
+ });
+ expect(explainCycle(mutated, "record:holder")).toEqual({
+ kind: "intrinsic",
+ steps: [{ from: "record:holder", label: { kind: "field", field: "field0", mutable: true } }],
+ owner: "record:callback",
+ field: "field0",
+ holds: "func",
+ });
+ // Without writes, only the array keeps its cycle: containers are mutable.
+ expect(explainCycle(mod, "record:a")).toBeNull();
+ expect(explainCycle(mod, "record:items")).toEqual({
+ kind: "cycle",
+ steps: [
+ {
+ from: "record:items",
+ label: { kind: "field", field: "field0", mutable: true, via: "array" },
+ },
+ ],
+ });
+ expect(explainCycle(mod, "record:missing")).toBeNull();
+});
+
+test("a hierarchy member's explanation names the member that needs the header", () => {
+ const mod = module();
+ mod.classes = [
+ { name: "Derived", loc, base: "Base", fields: [{ name: "callback", type: funcOf([], VOID) }] },
+ { name: "Base", loc, fields: [] },
+ ];
+ expect(explainCycle(mod, "object:Base")).toEqual({
+ kind: "hierarchy",
+ member: "object:Derived",
+ because: {
+ kind: "intrinsic",
+ steps: [],
+ owner: "object:Derived",
+ field: "callback",
+ holds: "func",
+ },
+ });
+});
diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts
index 8b6a5b210..1a9b6e4ab 100644
--- a/packages/compiler/src/backend/cycle-analysis.ts
+++ b/packages/compiler/src/backend/cycle-analysis.ts
@@ -32,6 +32,64 @@ import { computeCycleMutability, fieldKey } from "./cycle-mutability.js";
* uniform across an extends tree): a unit is cycle-capable iff ANY member
* is — every backend uses the same grouping. */
export function computeTraced(mod: IrModule): { shapes: Set; unions: Set } {
+ const { shapes, unions } = analyzeCycles(mod, false);
+ return { shapes, unions };
+}
+
+/** Values that can reference any object. */
+export type IntrinsicKind = "func" | "dyn" | "classval" | "promise" | "caught";
+
+/** Why an edge of the cycle graph exists (explanations only). */
+export type CycleEdgeLabel =
+ /** A field (or union arm) of the owning shape. `mutable` edges can be
+ * stored after the owner's construction; `via` names the container
+ * (`array`, `map`, `set`) the reference sits in, whose contents change. */
+ | { kind: "field"; field: string; mutable: boolean; via?: "array" | "map" | "set" }
+ /** A union arm. */
+ | { kind: "arm" }
+ /** A slot of a class type can hold the class itself or a subclass. */
+ | { kind: "subclass"; className: string };
+
+/** One step of a cycle explanation: from a unit (`object:`,
+ * `record:`, `union:`, or `subtree:` for a slot that can
+ * hold any class in a subtree) through an edge. */
+export interface CycleStep {
+ from: string;
+ label: CycleEdgeLabel;
+}
+
+export type CycleExplanation =
+ /** A cycle through at least one mutable edge: the steps lead from the
+ * explained unit back to it. */
+ | { kind: "cycle"; steps: CycleStep[] }
+ /** The unit reaches a field that holds a value able to reference any
+ * object (a function, a dynamic or caught value, a class value, a
+ * promise), so it can lie on a cycle through that value. */
+ | {
+ kind: "intrinsic";
+ steps: CycleStep[];
+ owner: string;
+ field: string;
+ holds: IntrinsicKind;
+ }
+ /** Headers are uniform per class hierarchy: the explained class is
+ * traced because another member of its hierarchy is. */
+ | { kind: "hierarchy"; member: string; because: CycleExplanation };
+
+/** Why computeTraced traces a shape (`object:` or `record:`) or
+ * union (`union:`); null when it is untraced or unknown. */
+export function explainCycle(mod: IrModule, key: string): CycleExplanation | null {
+ return analyzeCycles(mod, true).explain(key);
+}
+
+function analyzeCycles(
+ mod: IrModule,
+ labels: boolean,
+): {
+ shapes: Set;
+ unions: Set;
+ explain: (key: string) => CycleExplanation | null;
+} {
const classes = mod.classes ?? [];
// Hierarchy units: root lookup over the base links (classes with a base
// or a subclass — and the runtime emitter class — form units under their
@@ -76,7 +134,13 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set
})),
];
interface Node {
+ /** The unit key (explanations). */
+ name: string;
intrinsic: boolean;
+ /** With labels: the first edge label per dependency (a mutable one
+ * preferred), and the field that made the node intrinsic. */
+ labels: Map | null;
+ intrinsicField: { field: string; holds: IntrinsicKind } | null;
/** Dependency → whether any edge to it is mutable. */
edges: Map;
index: number;
@@ -86,8 +150,11 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set
reverse: Node[];
traced: boolean;
}
- const node = (): Node => ({
+ const node = (name: string): Node => ({
+ name,
intrinsic: false,
+ labels: labels ? new Map() : null,
+ intrinsicField: null,
edges: new Map(),
index: -1,
low: 0,
@@ -101,8 +168,8 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set
// in C's subtree, and of nothing else. Reachability is computed per
// class; tracing is then decided per hierarchy unit below.
const shapes = new Map();
- const unions = new Map((mod.unions ?? []).map((u) => [u.id, node()]));
- for (const s of shapeDefs) shapes.set(s.key, node());
+ const unions = new Map((mod.unions ?? []).map((u) => [u.id, node(`union:${u.id}`)]));
+ for (const s of shapeDefs) shapes.set(s.key, node(s.key));
const children = new Map();
for (const c of classes)
if (c.base !== undefined && shapes.has(`object:${c.base}`)) {
@@ -112,15 +179,26 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set
}
// Created up front and linked without recursion (hierarchies can be
// thousands deep).
- const subtrees = new Map([...children.keys()].map((name) => [name, node()] as const));
+ const subtrees = new Map(
+ [...children.keys()].map((name) => [name, node(`subtree:${name}`)] as const),
+ );
const subtreeOf = (className: string): Node | undefined =>
subtrees.get(className) ?? shapes.get(`object:${className}`);
for (const [name, sub] of subtrees) {
sub.edges.set(shapes.get(`object:${name}`)!, false);
- for (const kid of children.get(name)!) sub.edges.set(subtreeOf(kid)!, false);
+ sub.labels?.set(shapes.get(`object:${name}`)!, { kind: "subclass", className: name });
+ for (const kid of children.get(name)!) {
+ sub.edges.set(subtreeOf(kid)!, false);
+ sub.labels?.set(subtreeOf(kid)!, { kind: "subclass", className: kid });
+ }
}
// Each field/arm is an OR of intrinsic capability and referenced nodes.
- const addType = (from: Node, t: IrType, mutable: boolean): void => {
+ const addType = (
+ from: Node,
+ t: IrType,
+ mutable: boolean,
+ label: CycleEdgeLabel | null = null,
+ ): void => {
if (from.intrinsic) return;
switch (t.kind) {
case "func":
@@ -130,6 +208,13 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set
case "caught":
from.intrinsic = true;
from.edges.clear();
+ if (from.labels !== null) {
+ from.labels.clear();
+ from.intrinsicField = {
+ field: label?.kind === "field" ? label.field : "",
+ holds: t.kind,
+ };
+ }
break;
case "object":
case "record":
@@ -140,17 +225,28 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set
: t.kind === "object"
? subtreeOf(t.className)
: shapes.get(`record:${t.shapeId}`);
- if (dependency)
- from.edges.set(dependency, (from.edges.get(dependency) ?? false) || mutable);
+ if (dependency) {
+ const before = from.edges.get(dependency);
+ from.edges.set(dependency, (before ?? false) || mutable);
+ if (
+ from.labels !== null &&
+ label !== null &&
+ (before === undefined || (!before && mutable))
+ )
+ from.labels.set(
+ dependency,
+ label.kind === "field" ? { ...label, mutable: label.mutable || mutable } : label,
+ );
+ }
break;
}
case "map":
- addType(from, t.key, true);
- addType(from, t.value, true);
+ addType(from, t.key, true, label?.kind === "field" ? { ...label, via: "map" } : label);
+ addType(from, t.value, true, label?.kind === "field" ? { ...label, via: "map" } : label);
break;
case "set":
case "array":
- addType(from, t.elem, true);
+ addType(from, t.elem, true, label?.kind === "field" ? { ...label, via: t.kind } : label);
break;
}
};
@@ -158,15 +254,18 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set
const unitMutable = mutability.all || mutability.units.has(s.unit);
for (const field of s.fields) {
if (mutability.emptyPropertyBags && field.name === DYN_CLASS_PROPERTIES) continue;
+ const mutable = unitMutable || mutability.fields.has(fieldKey(s.unit, field.name));
addType(
shapes.get(s.key)!,
field.type,
- unitMutable || mutability.fields.has(fieldKey(s.unit, field.name)),
+ mutable,
+ labels ? { kind: "field", field: field.name, mutable } : null,
);
}
}
for (const u of mod.unions ?? [])
- for (const arm of u.arms) addType(unions.get(u.id)!, arm, mutability.all);
+ for (const arm of u.arms)
+ addType(unions.get(u.id)!, arm, mutability.all, labels ? { kind: "arm" } : null);
// Tarjan's strongly connected components, iteratively (shape chains can
// be thousands deep).
@@ -240,5 +339,134 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set
for (const s of shapeDefs) if (shapes.get(s.key)!.traced) tracedUnits.add(s.unit);
const tracedShapes = new Set(shapeDefs.filter((s) => tracedUnits.has(s.unit)).map((s) => s.key));
const tracedUnions = new Set([...unions].filter(([, n]) => n.traced).map(([id]) => id));
- return { shapes: tracedShapes, unions: tracedUnions };
+
+ /** Shortest path from `start` to a node satisfying `goal`, following
+ * only edges `allow` admits. The steps exclude the goal itself. */
+ const path = (
+ start: Node,
+ goal: (n: Node) => boolean,
+ allow: (from: Node, to: Node) => boolean,
+ ): { steps: CycleStep[]; end: Node } | null => {
+ const previous = new Map([[start, null]]);
+ const queue = [start];
+ for (let i = 0; i < queue.length; i++) {
+ const n = queue[i]!;
+ if (goal(n)) {
+ const steps: CycleStep[] = [];
+ for (
+ let at = n, from = previous.get(n)!;
+ from !== null;
+ at = from, from = previous.get(from)!
+ )
+ steps.unshift({ from: from.name, label: from.labels?.get(at) ?? { kind: "arm" } });
+ return { steps, end: n };
+ }
+ for (const next of n.edges.keys())
+ if (!previous.has(next) && allow(n, next)) {
+ previous.set(next, n);
+ queue.push(next);
+ }
+ }
+ return null;
+ };
+ /** BFS distances from `start` over `next` within one component; `via`
+ * is the neighbor one step closer to `start`. */
+ const distances = (
+ start: Node,
+ next: (n: Node) => readonly Node[],
+ component: number,
+ ): Map => {
+ const seen = new Map([
+ [start, { distance: 0, via: null }],
+ ]);
+ const queue = [start];
+ for (let i = 0; i < queue.length; i++) {
+ const at = queue[i]!;
+ const distance = seen.get(at)!.distance + 1;
+ for (const m of next(at))
+ if (m.component === component && !seen.has(m)) {
+ seen.set(m, { distance, via: at });
+ queue.push(m);
+ }
+ }
+ return seen;
+ };
+ const explainNode = (n: Node): CycleExplanation | null => {
+ if (!n.traced) return null;
+ const component = n.component;
+ const inside = (_from: Node, to: Node): boolean => to.component === component;
+ const intrinsic = (target: Node, steps: CycleStep[]): CycleExplanation => ({
+ kind: "intrinsic",
+ steps,
+ owner: target.name,
+ field: target.intrinsicField?.field ?? "",
+ holds: target.intrinsicField?.holds ?? "dyn",
+ });
+ if (componentCapable[component]!) {
+ const members = all.filter((m) => m.component === component);
+ const lead = members.find((m) => m.intrinsic);
+ if (lead !== undefined) {
+ const found = path(n, (m) => m === lead, inside);
+ if (found) return intrinsic(lead, found.steps);
+ }
+ // The shortest cycle through `n` and a mutable internal edge: BFS
+ // distances from `n` forward and to `n` backward, within the component.
+ const forward = distances(n, (m) => [...m.edges.keys()], component);
+ const backward = distances(n, (m) => m.reverse, component);
+ let best: { from: Node; to: Node; length: number } | null = null;
+ for (const from of members) {
+ const there = forward.get(from);
+ if (there === undefined) continue;
+ for (const [to, mutable] of from.edges) {
+ const back = backward.get(to);
+ if (!mutable || back === undefined) continue;
+ const length = there.distance + 1 + back.distance;
+ if (best === null || length < best.length) best = { from, to, length };
+ }
+ }
+ if (best !== null) {
+ const steps: CycleStep[] = [];
+ for (let at: Node = best.from; at !== n;) {
+ const previous = forward.get(at)!.via!;
+ steps.unshift({
+ from: previous.name,
+ label: previous.labels?.get(at) ?? { kind: "arm" },
+ });
+ at = previous;
+ }
+ steps.push({
+ from: best.from.name,
+ label: best.from.labels?.get(best.to) ?? { kind: "arm" },
+ });
+ for (let at: Node = best.to; at !== n;) {
+ const next = backward.get(at)!.via!;
+ steps.push({ from: at.name, label: at.labels?.get(next) ?? { kind: "arm" } });
+ at = next;
+ }
+ return { kind: "cycle", steps };
+ }
+ }
+ const found = path(
+ n,
+ (m) => m.intrinsic,
+ () => true,
+ );
+ return found ? intrinsic(found.end, found.steps) : null;
+ };
+ const explain = (key: string): CycleExplanation | null => {
+ const unionId = key.startsWith("union:") ? key.slice("union:".length) : null;
+ const n = unionId !== null ? unions.get(unionId) : shapes.get(key);
+ if (n === undefined) return null;
+ const own = explainNode(n);
+ if (own !== null || unionId !== null) return own;
+ const def = shapeDefs.find((d) => d.key === key);
+ if (def === undefined || !tracedUnits.has(def.unit)) return null;
+ for (const other of shapeDefs) {
+ if (other.unit !== def.unit || other.key === key) continue;
+ const because = explainNode(shapes.get(other.key)!);
+ if (because !== null) return { kind: "hierarchy", member: other.key, because };
+ }
+ return null;
+ };
+ return { shapes: tracedShapes, unions: tracedUnions, explain };
}
diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts
index a94bcb58b..e45799f18 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";
@@ -409,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
@@ -421,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))
@@ -762,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. */
@@ -849,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 =
@@ -6017,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":
diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts
index c452240ac..05dce98fe 100644
--- a/packages/compiler/src/backend/llvm/expr-containers.ts
+++ b/packages/compiler/src/backend/llvm/expr-containers.ts
@@ -34,7 +34,12 @@ import {
} from "./map-number-lookup.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import { emitStringSliceRead } from "./string-slices.js";
-import { STRING_CHAR_CODE_AT_DECL, STRING_UTF16_LEN_DECL } from "./string-reads.js";
+import {
+ STRING_CHAR_CODE_AT_DECL,
+ STRING_CHAR_CODE_AT_INT_MARKER,
+ STRING_UTF16_LEN_DECL,
+} from "./string-reads.js";
+import { exactInteger, widenInteger } from "./integer-values.js";
import { emitDenseArrayNumber, emitDenseArrayPush } from "./dense-array-access.js";
import { emitUnionWiden } from "./expr-records.js";
@@ -296,6 +301,13 @@ export function emitStrIntrinsic(
case "charCodeAt": {
host.declare(STRING_CHAR_CODE_AT_DECL);
const t = B.tmp();
+ const index = e.args[0] ? exactInteger(host, args[0]!, e.args[0]) : null;
+ if (index) {
+ host.declare(STRING_CHAR_CODE_AT_INT_MARKER);
+ const wide = widenInteger(host, index);
+ B.line(`${t} = call double @sc_str_char_code_at_int(ptr ${r.name}, i64 ${wide})`);
+ return { name: t, type: e.type };
+ }
B.line(`${t} = call double @sc_str_char_code_at(ptr ${r.name}, double ${args[0]!.name})`);
return { name: t, type: e.type };
}
diff --git a/packages/compiler/src/backend/llvm/expr-primitives.test.ts b/packages/compiler/src/backend/llvm/expr-primitives.test.ts
index 8e929f851..f8c26648f 100644
--- a/packages/compiler/src/backend/llvm/expr-primitives.test.ts
+++ b/packages/compiler/src/backend/llvm/expr-primitives.test.ts
@@ -135,3 +135,58 @@ test("reference identity and instanceof borrow their operands", () => {
expect(body, name).not.toMatch(/call void @sc_release_Node/);
}
});
+
+test("identity bitwise forms over a proven int32 return the operand's number", () => {
+ const text: IrExpr = { kind: "varRef", localId: "text", type: STRING, loc };
+ const length: IrExpr = {
+ kind: "strIntrinsic",
+ method: "length",
+ receiver: text,
+ args: [],
+ type: F64,
+ loc,
+ };
+ const x: IrExpr = { kind: "varRef", localId: "x", type: F64, loc };
+ const zero: IrExpr = { kind: "numLit", value: 0, type: F64, loc };
+ const returning = (name: string, value: IrExpr) => ({
+ name,
+ params: [
+ { name: "text", localId: "text", type: STRING },
+ { name: "x", localId: "x", type: F64 },
+ ],
+ returnType: F64,
+ locals: [
+ { id: "text", name: "text", type: STRING, mutable: false },
+ { id: "x", name: "x", type: F64, mutable: false },
+ ],
+ loc,
+ body: [{ kind: "return" as const, loc, value }],
+ });
+ const bin = (op: "|" | ">>>" | "&", left: IrExpr, right: IrExpr): IrExpr => ({
+ kind: "bin",
+ op,
+ left,
+ right,
+ type: F64,
+ loc,
+ });
+ const mod = fixture();
+ mod.functions = [
+ { name: "__main", params: [], returnType: VOID, locals: [], body: [], loc },
+ returning("length_or", bin("|", length, zero)),
+ returning("length_ushr", bin(">>>", length, zero)),
+ returning("unknown_or", bin("|", x, zero)),
+ returning("length_and", bin("&", length, { kind: "numLit", value: 255, type: F64, loc })),
+ ];
+ const llvm = emitLlvmModule(mod);
+ const body = (name: string): string =>
+ new RegExp(`^define internal [^\\n]*@sc_[bf]+_${name}\\([^]*?^}`, "m").exec(llvm)![0];
+ for (const name of ["length_or", "length_ushr"]) {
+ expect(body(name)).not.toMatch(/ = (or|lshr) i32 /);
+ expect(body(name)).not.toMatch(/ = [su]itofp i32 /);
+ expect(body(name)).not.toContain("uint32.coerce");
+ }
+ // Unproven operands keep the full ToInt32; other operators still compute.
+ expect(body("unknown_or")).toContain("uint32.coerce.slow");
+ expect(body("length_and")).toMatch(/ = and i32 .*, 255$/m);
+});
diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts
index 59927de3a..5dfe9cfec 100644
--- a/packages/compiler/src/backend/llvm/expr-primitives.ts
+++ b/packages/compiler/src/backend/llvm/expr-primitives.ts
@@ -12,7 +12,12 @@ import { emitStringInputs } from "./string-lifetimes.js";
import { emitStringParts, numberPart, stringParts } from "./string-construction.js";
import { emitBorrowedInputs } from "./borrowed-inputs.js";
import { exactInteger, widenInteger, integerNumber } from "./integer-values.js";
-import { integerArithmeticRange } from "../../ir/integer-ranges.js";
+import {
+ bitwiseIdentityOperand,
+ integerArithmeticRange,
+ withinInt32,
+ withinUint32,
+} from "../../ir/integer-ranges.js";
import { emitArrayValues } from "./expr-containers.js";
import { emitCheckedRemainder, emitSignedIntegerRemainder } from "./integer-remainder.js";
import { emitDenseArrayGet, emitDenseArrayState } from "./dense-array-access.js";
@@ -93,8 +98,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: {
@@ -274,6 +283,25 @@ export function emitOperatorExpr(
e.loc,
);
}
+ // `x | 0` and the other identity forms over an operand the range
+ // analysis proves an int32 (a uint32 for `>>>`) return that operand:
+ // the conversion is the identity and ranges exclude -0, so the
+ // operand's double is the result bit for bit. Its integer views stay.
+ const identity = bitwiseIdentityOperand(e);
+ if (identity !== null) {
+ const operand = identity === "left" ? e.left : e.right;
+ const value = identity === "left" ? l : r;
+ const range = host.integerRanges.get(operand);
+ if (e.op === ">>>" ? withinUint32(range) : withinInt32(range)) {
+ const uint32 = host.emitToUint32(value.name, operand, value.uint32);
+ return {
+ name: value.name,
+ type: e.type,
+ uint32,
+ ...(value.integer ? { integer: value.integer } : {}),
+ };
+ }
+ }
const left = host.emitToUint32(l.name, e.left, l.uint32);
let right = host.emitToUint32(r.name, e.right, r.uint32);
if (e.op === "<<" || e.op === ">>" || e.op === ">>>") {
diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts
index bfcaeeb6c..89c707c19 100644
--- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts
+++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts
@@ -306,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" },
@@ -340,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",
diff --git a/packages/compiler/src/backend/llvm/string-reads.ts b/packages/compiler/src/backend/llvm/string-reads.ts
index 7cbdbd97b..2a368979e 100644
--- a/packages/compiler/src/backend/llvm/string-reads.ts
+++ b/packages/compiler/src/backend/llvm/string-reads.ts
@@ -15,6 +15,9 @@ import { exactIndexLines } from "./common.js";
export const STRING_UTF16_LEN_DECL = "declare double @scr_str_utf16_len(ptr)";
export const STRING_CHAR_CODE_AT_DECL = "declare double @scr_str_char_code_at(ptr, double)";
+/** Requests the integer-index helper below; an LLVM comment line among the
+ * declarations, so it never declares anything itself. */
+export const STRING_CHAR_CODE_AT_INT_MARKER = "; uses @sc_str_char_code_at_int";
/** Load the proven-ASCII flag of `%s` into `%ascii`. */
function asciiTest(): string[] {
@@ -85,4 +88,32 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [
];
},
},
+ {
+ // An index the compiler proved to be an exact integer (a counted-loop
+ // counter, an int32 slot or local) needs no double round trip: one
+ // unsigned compare rejects negative and out-of-range indices alike.
+ decl: STRING_CHAR_CODE_AT_INT_MARKER,
+ define: (sz, attrs) => [
+ `define internal double @sc_str_char_code_at_int(ptr %s, i64 %i) ${attrs} {`,
+ `entry:`,
+ ...asciiTest(),
+ ` br i1 %ascii, label %bounds, label %slow`,
+ `bounds:`,
+ ...loadLength(sz),
+ ...(sz === "i64" ? [] : [` %len64 = zext ${sz} %len to i64`]),
+ ` %ok = icmp ult i64 %i, ${sz === "i64" ? "%len" : "%len64"}`,
+ ` br i1 %ok, label %read, label %slow`,
+ `read:`,
+ ` %data = getelementptr inbounds %ScrStr, ptr %s, i32 1`,
+ ` %p = getelementptr inbounds i8, ptr %data, i64 %i`,
+ ` %byte = load i8, ptr %p`,
+ ` %unit = uitofp i8 %byte to double`,
+ ` ret double %unit`,
+ `slow:`,
+ ` %d = sitofp i64 %i to double`,
+ ` %r = call double @scr_str_char_code_at(ptr %s, double %d)`,
+ ` ret double %r`,
+ `}`,
+ ],
+ },
];
diff --git a/packages/compiler/src/backend/may-throw.test.ts b/packages/compiler/src/backend/may-throw.test.ts
index fee54361c..383e12ce5 100644
--- a/packages/compiler/src/backend/may-throw.test.ts
+++ b/packages/compiler/src/backend/may-throw.test.ts
@@ -12,7 +12,7 @@ import {
type IrModule,
type IrStmt,
} from "../ir/ir.js";
-import { computeMayThrow } from "./may-throw.js";
+import { completesNormally, computeMayThrow } from "./may-throw.js";
const loc = { file: "tdz.ts", start: 0, end: 1 };
const value: IrExpr = { kind: "numLit", value: 1, type: F64, loc };
@@ -526,3 +526,194 @@ 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();
+});
+
+test("handlers discharge the exceptions they take, and only those", () => {
+ const call = (callee: string): IrStmt =>
+ exprStmt({ kind: "call", callee, args: [], type: VOID, loc });
+ const throwing: IrStmt = { kind: "throw", value, loc };
+ const ret: IrStmt = { kind: "return", value: null, loc };
+ const tryCatch = (
+ tryBody: IrStmt[],
+ catchBody: IrStmt[] | null,
+ finallyBody: IrStmt[] | null = null,
+ ): IrStmt => ({ kind: "tryCatch", tryBody, catchBody, catchLocalId: null, finallyBody, loc });
+ const plain = (name: string, body: IrStmt[]): IrFunction => fn(name, body, []);
+ const cond: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc };
+ const mod = moduleWith(
+ plain("thrower", [throwing]),
+ plain("caught", [tryCatch([call("thrower"), throwing], [])]),
+ plain("callsCaught", [call("caught")]),
+ plain("rethrows", [tryCatch([call("thrower")], [throwing])]),
+ plain("finallyOnly", [tryCatch([call("thrower")], null, [exprStmt(value)])]),
+ plain("finallyReturns", [tryCatch([call("thrower")], [throwing], [ret])]),
+ plain("finallyMayComplete", [
+ tryCatch([call("thrower")], null, [{ kind: "if", cond, then: [ret], else_: null, loc }]),
+ ]),
+ plain("finallyThrows", [tryCatch([], [], [call("thrower")])]),
+ plain("nested", [tryCatch([tryCatch([call("thrower")], [throwing])], [])]),
+ plain("indirectCaught", [
+ tryCatch(
+ [
+ exprStmt({
+ kind: "callValue",
+ callee: {
+ kind: "closure",
+ fnName: "thrower",
+ captures: [],
+ type: funcOf([], VOID),
+ loc,
+ },
+ args: [],
+ type: VOID,
+ loc,
+ }),
+ ],
+ [],
+ ),
+ ]),
+ plain("caller", []),
+ );
+ const answer = computeMayThrow(mod);
+ expect([...answer.fns].sort()).toEqual([
+ "finallyMayComplete",
+ "finallyOnly",
+ "finallyThrows",
+ "rethrows",
+ "thrower",
+ ]);
+ // The function value can still throw at other call sites.
+ expect(answer.indirect).toBe(true);
+ // Worker termination and generator returns unwind through catch.
+ expect(computeMayThrow({ ...mod, workers: true }).fns).toContain("caught");
+ const generator: IrFunction = {
+ ...plain("caughtGenerator", [tryCatch([call("thrower")], [])]),
+ generator: {} as unknown as NonNullable,
+ };
+ expect(computeMayThrow(moduleWith(...mod.functions, generator)).fns).toContain("caughtGenerator");
+ // Explanations skip the handled path and name the rethrow.
+ const explained = computeMayThrow(mod, { explain: true });
+ expect(explained.why?.get("rethrows")).toMatchObject({ kind: "seed", what: "throw statement" });
+});
+
+test("completesNormally is conservative about abrupt completion", () => {
+ const cond: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc };
+ const ret: IrStmt = { kind: "return", value: null, loc };
+ const brk: IrStmt = { kind: "break", loc };
+ expect(completesNormally([])).toBe(true);
+ expect(completesNormally([exprStmt(value), ret])).toBe(false);
+ expect(completesNormally([brk])).toBe(false);
+ expect(completesNormally([{ kind: "if", cond, then: [ret], else_: [brk], loc }])).toBe(false);
+ expect(completesNormally([{ kind: "if", cond, then: [ret], else_: null, loc }])).toBe(true);
+ expect(completesNormally([{ kind: "block", body: [ret], loc }])).toBe(false);
+ // A labeled block can be the target of a break inside it.
+ expect(
+ completesNormally([
+ { kind: "block", body: [{ kind: "break", label: "l", loc }], labels: ["l"], loc },
+ ]),
+ ).toBe(true);
+ // A loop that runs forever completes only through a break that leaves it.
+ expect(completesNormally([{ kind: "while", cond, body: [ret], loc }])).toBe(false);
+ expect(completesNormally([{ kind: "while", cond, body: [brk], loc }])).toBe(true);
+ expect(
+ completesNormally([
+ { kind: "while", cond, body: [{ kind: "while", cond, body: [brk], loc }, ret], loc },
+ ]),
+ ).toBe(false);
+ const unknown: IrExpr = { kind: "varRef", localId: "flag", type: BOOL, loc };
+ expect(completesNormally([{ kind: "while", cond: unknown, body: [ret], loc }])).toBe(true);
+ const tryCatch = (catchBody: IrStmt[] | null, finallyBody: IrStmt[] | null = null): IrStmt => ({
+ kind: "tryCatch",
+ tryBody: [ret],
+ catchBody,
+ catchLocalId: null,
+ finallyBody,
+ loc,
+ });
+ expect(completesNormally([tryCatch([ret])])).toBe(false);
+ expect(completesNormally([tryCatch([])])).toBe(true);
+ expect(completesNormally([tryCatch(null, [])])).toBe(false);
+ expect(completesNormally([tryCatch([], [ret])])).toBe(false);
+ const kind: IrExpr = { kind: "numLit", value: 1, type: F64, loc };
+ const switchOf = (cases: { test: IrExpr | null; body: IrStmt[] }[]): IrStmt => ({
+ kind: "switch",
+ disc: kind,
+ cases,
+ loc,
+ });
+ expect(
+ completesNormally([
+ switchOf([
+ { test: kind, body: [] },
+ { test: null, body: [ret] },
+ ]),
+ ]),
+ ).toBe(false);
+ expect(completesNormally([switchOf([{ test: kind, body: [ret] }])])).toBe(true);
+ expect(
+ completesNormally([
+ switchOf([
+ { test: kind, body: [brk] },
+ { test: null, body: [ret] },
+ ]),
+ ]),
+ ).toBe(true);
+});
+
+test("unreachable statements do not seed", () => {
+ const ret: IrStmt = { kind: "return", value: null, loc };
+ const trap: IrStmt = { kind: "runtimeFence", code: "SC9002", message: "unreachable", loc };
+ const tryCatch: IrStmt = {
+ kind: "tryCatch",
+ tryBody: [exprStmt({ kind: "call", callee: "thrower", args: [], type: VOID, loc }), ret],
+ catchBody: [ret],
+ catchLocalId: null,
+ finallyBody: null,
+ loc,
+ };
+ const answer = computeMayThrow(
+ moduleWith(
+ fn("thrower", [{ kind: "throw", value, loc }], []),
+ fn("caller", [tryCatch, trap], []),
+ fn("reachable", [{ ...tryCatch, catchBody: [] }, trap], []),
+ ),
+ );
+ expect([...answer.fns].sort()).toEqual(["reachable", "thrower"]);
+});
diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts
index 55effebad..432e1a81b 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 { everyExprChild, everyStmtChild, 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
@@ -16,12 +23,23 @@ import { computePublishedTypes, writesPublished } from "./publication.js";
* Fixpoint over the call graph; runtime traps (array OOB etc.) are aborts,
* not exceptions, so most intrinsics never contribute. The module evaluator's
* internal await and throwing library calls are the exceptions: both can
- * surface a catchable rejection/error and seed the fixpoint like a `throw`. */
+ * surface a catchable rejection/error and seed the fixpoint like a `throw`.
+ *
+ * Handlers discharge what they handle: a throwing operation or call inside
+ * a `try` with a `catch`, or inside a `try`/`catch` whose `finally` never
+ * completes normally (it returns, breaks or continues, which discards the
+ * pending exception), cannot propagate out of that statement, so it adds no
+ * seed and no call edge. The `catch` and `finally` bodies themselves are
+ * analyzed like any other code, which keeps rethrows exact. Worker programs
+ * and generator bodies keep the conservative answer: a termination request
+ * and a generator's `.return()` unwind through `catch`. */
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 +55,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
@@ -68,8 +92,15 @@ export function computeMayThrow(
list.push(`%${cls.name}.${m}`);
}
}
+ // Termination requests unwind through every handler of a worker program.
+ const handlersCatchAll = mod.workers !== true;
for (const fn of mod.functions) {
const f: Facts = { throws: false, callees: [], callsValue: false };
+ // Inside a region whose exceptions a handler of this function takes:
+ // nothing seeds and no call edge can carry an exception out.
+ let guarded = 0;
+ const handlers = handlersCatchAll && !fn.generator;
+ 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 +110,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":
@@ -287,7 +320,7 @@ export function computeMayThrow(
// wide) cover is every constructor of the named class's
// hierarchy; classval flows never leave it.
const cls = rec.callee.type.kind === "classval" ? rec.callee.type.className : undefined;
- if (cls !== undefined) {
+ if (cls !== undefined && guarded === 0) {
let list = classValueCallers.get(cls);
if (!list) classValueCallers.set(cls, (list = new Set()));
list.add(fn.name);
@@ -303,20 +336,72 @@ export function computeMayThrow(
break;
}
case "callValue":
+ if (guarded > 0) break;
f.callsValue = true;
+ if (explain) f.callValueLoc ??= rec.loc;
break;
case "closure":
closureTargets.add(rec.fnName);
break;
}
+ if (guarded > 0) {
+ // A handler of this function takes the exception.
+ f.throws = threw;
+ f.callees.length = callees;
+ return true;
+ }
+ 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 });
+ const walkExpr = (node: IrExpr): boolean =>
+ visit(node) && everyExprChild(node, walkExpr, walkStmt);
+ // Unreachable statements cannot throw, such as the frontend's trap after
+ // a body that returns from both its try and its catch.
+ const dead = unreachableStatements(fn.body);
+ const walkStmt = (node: IrStmt): boolean => {
+ if (dead.has(node)) return true;
+ if (node.kind !== "tryCatch" || !handlers)
+ return visit(node) && everyStmtChild(node, walkExpr, walkStmt);
+ visit(node);
+ // The catch takes everything the try body raises. A finally that
+ // never completes normally discards whatever the try body or the
+ // catch left pending; one that can complete normally re-raises it.
+ const discards = node.finallyBody !== null && !completesNormally(node.finallyBody);
+ const guardTry = node.catchBody !== null || discards;
+ if (guardTry) guarded++;
+ node.tryBody.every(walkStmt);
+ if (guardTry) guarded--;
+ if (discards) guarded++;
+ node.catchBody?.every(walkStmt);
+ if (discards) guarded--;
+ node.finallyBody?.every(walkStmt);
+ return true;
+ };
+ fn.body.every(walkStmt);
// 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 +411,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 +442,267 @@ 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 } : {}),
};
}
+/** Whether control can reach the end of a statement list. Conservative:
+ * false only when some statement certainly completes abruptly: `return`,
+ * `throw`, `break` or `continue` (which leave the list), an `if` whose
+ * branches both do, a `try` whose body and `catch` (or `finally`) do, a
+ * `switch` with a `default` whose last case does and that no `break`
+ * leaves, or a `while (true)`/`for (;;)` loop that no `break` leaves. */
+export function completesNormally(stmts: readonly IrStmt[]): boolean {
+ return completion().list(stmts);
+}
+
+function completion(): {
+ list: (stmts: readonly IrStmt[]) => boolean;
+ statement: (s: IrStmt) => boolean;
+} {
+ const memo = new Map();
+ const list = (stmts: readonly IrStmt[]): boolean => stmts.every(statement);
+ const statement = (s: IrStmt): boolean => {
+ let known = memo.get(s);
+ if (known === undefined) {
+ known = compute(s);
+ memo.set(s, known);
+ }
+ return known;
+ };
+ const compute = (s: IrStmt): boolean => {
+ switch (s.kind) {
+ case "return":
+ case "throw":
+ case "rethrow":
+ case "runtimeFence":
+ case "break":
+ case "continue":
+ return false;
+ case "if":
+ return list(s.then) || list(s.else_ ?? []);
+ case "block":
+ return list(s.body) || breaksTo(s.body, s.labels ?? [], false);
+ case "tryCatch":
+ return (
+ (list(s.tryBody) || (s.catchBody !== null && list(s.catchBody))) &&
+ (s.finallyBody === null || list(s.finallyBody))
+ );
+ case "switch": {
+ const last = s.cases[s.cases.length - 1];
+ return (
+ last === undefined ||
+ !s.cases.some((c) => c.test === null) ||
+ list(last.body) ||
+ s.cases.some((c) => breaksTo(c.body, s.labels ?? [], true))
+ );
+ }
+ case "while":
+ case "doWhile":
+ case "for": {
+ const forever = s.cond === null || (s.cond.kind === "boolLit" && s.cond.value === true);
+ return !forever || breaksTo(s.body, s.labels ?? [], true);
+ }
+ default:
+ return true;
+ }
+ };
+ return { list, statement };
+}
+
+/** Whether a `break` in the statements leaves the construct that owns
+ * them: one naming its labels, or (for loops and switches, `unlabeled`)
+ * an unlabeled `break` that no nested loop or switch captures first. */
+function breaksTo(
+ stmts: readonly IrStmt[],
+ labels: readonly string[],
+ unlabeled: boolean,
+): boolean {
+ const walk = (s: IrStmt, captured: boolean): boolean => {
+ switch (s.kind) {
+ case "break":
+ return s.label === undefined ? unlabeled && !captured : labels.includes(s.label);
+ case "if":
+ return (
+ s.then.some((c) => walk(c, captured)) || (s.else_ ?? []).some((c) => walk(c, captured))
+ );
+ case "block":
+ return s.body.some((c) => walk(c, captured));
+ case "tryCatch":
+ return [s.tryBody, s.catchBody ?? [], s.finallyBody ?? []].some((body) =>
+ body.some((c) => walk(c, captured)),
+ );
+ case "switch":
+ return s.cases.some((c) => c.body.some((inner) => walk(inner, true)));
+ case "while":
+ case "doWhile":
+ case "for":
+ case "forOf":
+ return s.body.some((c) => walk(c, true));
+ default:
+ return false;
+ }
+ };
+ return stmts.some((s) => walk(s, false));
+}
+
+/** Statements no execution reaches: those after a statement that cannot
+ * complete normally in the same statement list. */
+function unreachableStatements(body: readonly IrStmt[]): Set {
+ const dead = new Set();
+ const { statement } = completion();
+ const list = (stmts: readonly IrStmt[]): void => {
+ let live = true;
+ for (const s of stmts) {
+ if (!live) {
+ dead.add(s);
+ continue;
+ }
+ for (const inner of nestedLists(s)) list(inner);
+ if (!statement(s)) live = false;
+ }
+ };
+ list(body);
+ return dead;
+}
+
+function nestedLists(s: IrStmt): readonly (readonly IrStmt[])[] {
+ switch (s.kind) {
+ case "if":
+ return s.else_ === null ? [s.then] : [s.then, s.else_];
+ case "block":
+ case "while":
+ case "doWhile":
+ case "for":
+ case "forOf":
+ return [s.body];
+ case "switch":
+ return s.cases.map((c) => c.body);
+ case "tryCatch":
+ return [s.tryBody, s.catchBody ?? [], s.finallyBody ?? []];
+ default:
+ return [];
+ }
+}
+
+/** 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/contracts/check.ts b/packages/compiler/src/contracts/check.ts
new file mode 100644
index 000000000..db2517580
--- /dev/null
+++ b/packages/compiler/src/contracts/check.ts
@@ -0,0 +1,1720 @@
+/* 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, where handlers discharge what they catch), 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).
+ * - Acyclic: the class or object type cannot lie on a reference cycle, by
+ * the cycle analysis that decides tracing (backend/cycle-analysis.ts), or
+ * the diagnostic shows the cycle.
+ * - 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 {
+ computeTraced,
+ explainCycle,
+ type CycleEdgeLabel,
+ type CycleExplanation,
+ type CycleStep,
+} from "../backend/cycle-analysis.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();
+ this.checkAcyclic();
+ 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;
+ }
+
+ /* ── Acyclic ── */
+
+ private checkAcyclic(): void {
+ if (this.notes.acyclic.length === 0) return;
+ const traced = computeTraced(this.mod);
+ const proven: string[] = [];
+ // Object types are structural in the IR: name a record by the
+ // interface or alias that declared it Acyclic.
+ for (const { note, unit } of this.notes.acyclic)
+ if (unit != null && unit.startsWith("record:")) this.unitNames.set(unit, note.name);
+ for (const { note, classKey, unit } of this.notes.acyclic) {
+ // A class may lower to several IR classes (generic instantiations);
+ // an unused type has no unit and no values, so it holds trivially.
+ const units =
+ classKey !== undefined
+ ? this.classesAt(classKey).map((cls) => `object:${cls.name}`)
+ : unit != null
+ ? [unit]
+ : [];
+ for (const key of units) {
+ const isTraced = key.startsWith("union:")
+ ? traced.unions.has(key.slice("union:".length))
+ : traced.shapes.has(key);
+ if (!isTraced) {
+ proven.push(key);
+ continue;
+ }
+ const why = explainCycle(this.mod, key);
+ this.report(
+ "SC7007",
+ note.loc,
+ `'${note.name}' is declared Acyclic but can lie on a reference cycle: ${why ? this.cycleText(key, why) : "the cycle analysis traces it"}`,
+ why ? this.cycleHint(why) : "remove Acyclic",
+ );
+ break;
+ }
+ }
+ this.record("acyclic", proven);
+ }
+
+ private readonly unitNames = new Map();
+
+ /** A unit key's spelling: a class name, a record's declared name or
+ * `{ a, b }`, the arms of a union. */
+ unitText(key: string): string {
+ const named = this.unitNames.get(key);
+ if (named !== undefined) return named;
+ const colon = key.indexOf(":");
+ const kind = key.slice(0, colon),
+ id = key.slice(colon + 1);
+ if (kind === "object" || kind === "subtree")
+ return this.typeText({ kind: "object", className: id });
+ if (kind === "union") return this.typeText({ kind: "union", unionId: id });
+ const record = this.mod.records?.find((r) => r.id === id);
+ return record ? `{ ${record.fields.map((f) => f.name).join(", ")} }` : "an object";
+ }
+
+ private fieldText(field: string): string {
+ if (field === "%dynProperties") return "(dynamic properties)";
+ if (field === "") return "(event listeners)";
+ if (field === "") return "(class object)";
+ if (field === "") return "[key]";
+ return field;
+ }
+
+ /** The path as `A.f → B.g → A`, folding union and subclass hops into the
+ * field that reaches them. */
+ private stepsText(steps: readonly CycleStep[], end: string): string {
+ const parts: string[] = [];
+ // A slot of class type C holding a subclass D reads `D (as a C)`.
+ let asBase: string | null = null;
+ const unit = (key: string): string => {
+ const text = this.unitText(key);
+ const suffix = asBase !== null && asBase !== text ? ` (as a ${asBase})` : "";
+ asBase = null;
+ return `${text}${suffix}`;
+ };
+ for (const step of steps) {
+ if (step.label.kind === "subclass") {
+ asBase ??= this.unitText(step.from);
+ continue;
+ }
+ if (step.label.kind === "field")
+ parts.push(`${unit(step.from)}.${this.fieldText(step.label.field)}`);
+ }
+ parts.push(unit(end));
+ return parts.join(" → ");
+ }
+
+ private cycleText(key: string, why: CycleExplanation): string {
+ switch (why.kind) {
+ case "hierarchy":
+ return `its subclass '${this.unitText(why.member)}' shares its object layout, and ${this.cycleText(why.member, why.because)}`;
+ case "intrinsic": {
+ const holds =
+ why.holds === "func"
+ ? "a function"
+ : why.holds === "dyn"
+ ? "a dynamic value"
+ : why.holds === "caught"
+ ? "a caught exception"
+ : why.holds === "promise"
+ ? "a promise"
+ : "a class object";
+ const field =
+ why.field === "%dynProperties"
+ ? `${this.unitText(why.owner)} instances reach dynamic code, whose properties`
+ : why.field === ""
+ ? this.unitText(why.owner)
+ : `${this.unitText(why.owner)}.${this.fieldText(why.field)}`;
+ const path = why.steps.length > 0 ? `${this.stepsText(why.steps, why.owner)}, and ` : "";
+ return `${path}${field} can hold ${holds}, which can reference any object, including a ${this.unitText(key)}`;
+ }
+ case "cycle": {
+ const mutable = why.steps.find(
+ (s): s is CycleStep & { label: CycleEdgeLabel & { kind: "field" } } =>
+ s.label.kind === "field" && (s.label.mutable || s.label.via !== undefined),
+ );
+ let reason = "";
+ if (mutable) {
+ const where = `${this.unitText(mutable.from)}.${this.fieldText(mutable.label.field)}`;
+ if (mutable.label.via !== undefined)
+ reason = ` (${where} is ${mutable.label.via === "array" ? "an array" : `a ${mutable.label.via === "map" ? "Map" : "Set"}`}, whose contents can change)`;
+ else {
+ const write = this.writeOutsideConstructor(mutable.from, mutable.label.field);
+ reason = ` (${where} can be assigned after construction${write !== null ? `, as at ${this.where(write)}` : ""})`;
+ }
+ }
+ return `${this.stepsText(why.steps, key)}${reason}`;
+ }
+ }
+ }
+
+ private cycleHint(why: CycleExplanation): string {
+ const inner = why.kind === "hierarchy" ? why.because : why;
+ if (inner.kind === "intrinsic")
+ return "a function, dynamic or promise field can capture the object itself; store plain data instead, or remove Acyclic";
+ if (
+ inner.kind === "cycle" &&
+ !inner.steps.some(
+ (s) => s.label.kind === "field" && s.label.mutable && s.label.via === undefined,
+ )
+ )
+ return "an array, Map or Set can come to hold an object that refers back; make the element type unable to reach this type, or remove Acyclic";
+ return "assign the reference only in the constructor (before `this` escapes), so a new object can only point to older ones, or remove Acyclic";
+ }
+
+ /** The first store into the field outside its own class's constructors. */
+ private writeOutsideConstructor(unit: string, field: string): SrcLoc | null {
+ const colon = unit.indexOf(":");
+ const kind = unit.slice(0, colon),
+ id = unit.slice(colon + 1);
+ let found: SrcLoc | null = null;
+ for (const fn of this.mod.functions) {
+ if (found !== null) break;
+ const ctor = /^%(.*)\.constructor$/.exec(fn.name)?.[1];
+ const self = ctor !== undefined ? fn.params[0]?.localId : undefined;
+ walk(fn.body, {
+ stmt: (s) => {
+ if (found !== null) return;
+ if (kind === "record" && s.kind === "recordSet" && s.shapeId === id && s.field === field)
+ found = s.loc;
+ else if (
+ kind !== "record" &&
+ s.kind === "fieldSet" &&
+ s.field === field &&
+ this.sameHierarchy(s.className, id) &&
+ !(self !== undefined && s.obj.kind === "varRef" && s.obj.localId === self)
+ )
+ found = s.loc;
+ },
+ });
+ }
+ return found;
+ }
+
+ private sameHierarchy(a: string, b: string): boolean {
+ const root = (name: string): string => {
+ let cur = name;
+ for (let cls = this.classes.get(cur); cls?.base !== undefined; cls = this.classes.get(cur))
+ cur = cls.base;
+ return cur;
+ };
+ return root(a) === root(b);
+ }
+
+ /* ── 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[] = [];
+ // The function's own stack guard throws on entry, outside any handler.
+ const ownStackGuard = mt.stackChecks?.has(fn.name) === true;
+ 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(" → ")}`,
+ ownStackGuard
+ ? "remove the recursion or remove NoThrow; a recursive function's stack guard throws on entry, outside any try/catch"
+ : this.mod.workers === true
+ ? "remove the throwing operation from the path (or the recursion), or remove NoThrow; in worker programs try/catch does not discharge the contract, since a termination request unwinds through catch"
+ : "remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow",
+ );
+ }
+ 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..21004bb71
--- /dev/null
+++ b/packages/compiler/src/frontend/contract-types.ts
@@ -0,0 +1,315 @@
+/* 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" | "Acyclic";
+const MARKERS: ReadonlySet = new Set(["Int32", "NoThrow", "Immutable", "Acyclic"]);
+
+/** 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;
+}
+
+/** An Acyclic type: a class (by its declaration's location key, like
+ * `fields`) or an object type written as an interface or type alias (by
+ * the IR unit it lowers to: `record:`, `object:` or
+ * `union:`; null when the program never uses the type, so no value of
+ * it exists). */
+export interface AcyclicNote {
+ note: ContractNote;
+ classKey?: string;
+ unit?: string | null;
+}
+
+/** Maps a TypeScript type to the IR unit the program lowered it to, or
+ * null when the program has no such unit. */
+export type ContractUnitResolver = (type: ts.Type) => string | null;
+
+/** 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;
+ /** Acyclic classes and object types. */
+ acyclic: AcyclicNote[];
+ 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;
+ if (ts.isIdentifier(name)) return markerNamed(checker, name, name.text, false);
+ if (ts.isQualifiedName(name) && ts.isIdentifier(name.left))
+ return markerNamed(checker, name.left, name.right.text, true);
+ return null;
+}
+
+/** The marker a heritage clause names (`implements Acyclic`,
+ * `implements sc.Acyclic`). */
+function markerOfHeritage(
+ checker: ts.TypeChecker,
+ node: ts.ExpressionWithTypeArguments,
+): ContractMarker | null {
+ const e = node.expression;
+ if (ts.isIdentifier(e)) return markerNamed(checker, e, e.text, false);
+ if (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && ts.isIdentifier(e.name))
+ return markerNamed(checker, e.expression, e.name.text, true);
+ return null;
+}
+
+/** Resolve `ident` (the whole name, or the namespace of a qualified one)
+ * to an import of @scriptc/types and answer the imported marker. */
+function markerNamed(
+ checker: ts.TypeChecker,
+ ident: ts.Identifier,
+ written: string,
+ qualified: boolean,
+): ContractMarker | null {
+ if (!MARKERS.has(written)) return null;
+ let member = written;
+ 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) && !qualified) {
+ importDecl = decl.parent?.parent?.parent;
+ member = decl.propertyName?.text ?? decl.name.text;
+ } else if (ts.isNamespaceImport(decl) && qualified) 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[],
+ resolveUnit: ContractUnitResolver = () => null,
+): ContractAnnotations {
+ const out: ContractAnnotations = {
+ functions: new Map(),
+ bindings: new Map(),
+ fields: new Map(),
+ acyclic: [],
+ diagnostics: [],
+ count: 0,
+ };
+ const kinds = new Set([
+ ts.SyntaxKind.TypeReference,
+ ts.SyntaxKind.ExpressionWithTypeArguments,
+ ]);
+ for (const sf of files) {
+ if (sf.isDeclarationFile || !sf.text.includes(CONTRACT_TYPES_MODULE)) continue;
+ ts.walkPreorder(
+ sf,
+ (node) => {
+ if (ts.isExpressionWithTypeArguments(node)) {
+ const marker = markerOfHeritage(checker, node);
+ if (marker === null) return;
+ out.count++;
+ placeHeritage(out, checker, node, marker, resolveUnit);
+ return;
+ }
+ if (!ts.isTypeReferenceNode(node)) return;
+ const marker = markerOf(checker, node);
+ if (marker === null) return;
+ out.count++;
+ if (marker === "Acyclic") placeAcyclicAlias(out, checker, node, resolveUnit);
+ else place(out, checker, node, marker);
+ },
+ kinds,
+ );
+ }
+ return out;
+}
+
+function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMarker): void {
+ if (marker === "Acyclic") {
+ out.diagnostics.push(
+ contractDiag(
+ "SC7006",
+ locOf(node),
+ "the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases)",
+ "write `class C implements Acyclic` or `interface I extends Acyclic` (without a type argument), or `type T = Acyclic<{ ... }>`",
+ ),
+ );
+ return;
+ }
+ 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`,
+ ),
+ );
+}
+
+/** `class C implements Acyclic` and `interface I extends Acyclic`. */
+function placeHeritage(
+ out: ContractAnnotations,
+ checker: ts.TypeChecker,
+ node: ts.ExpressionWithTypeArguments,
+ marker: ContractMarker,
+ resolveUnit: ContractUnitResolver,
+): void {
+ const clause = node.parent;
+ const owner = clause?.parent;
+ if (
+ marker !== "Acyclic" ||
+ clause === undefined ||
+ !ts.isHeritageClause(clause) ||
+ owner === undefined
+ )
+ return unsupported(out, node, marker);
+ // `implements Acyclic` would make the class implement C's own type,
+ // which the lowering treats as a protocol of C.
+ if ((node.typeArguments?.length ?? 0) > 0) return unsupported(out, node, marker);
+ const note = (name: string): ContractNote => ({ marker, loc: locOf(node), name });
+ if (
+ (ts.isClassDeclaration(owner) || ts.isClassExpression(owner)) &&
+ clause.token === ts.SyntaxKind.ImplementsKeyword
+ ) {
+ out.acyclic.push({ note: note(nameText(owner.name)), classKey: contractLocKey(locOf(owner)) });
+ return;
+ }
+ if (ts.isInterfaceDeclaration(owner) && clause.token === ts.SyntaxKind.ExtendsKeyword) {
+ const symbol = checker.getSymbolAtLocation(owner.name);
+ const unit = symbol ? resolveUnit(checker.getDeclaredTypeOfSymbol(symbol)) : null;
+ out.acyclic.push({ note: note(owner.name.text), unit });
+ return;
+ }
+ unsupported(out, node, marker);
+}
+
+/** `type T = Acyclic<{ ... }>`: the aliased object type. */
+function placeAcyclicAlias(
+ out: ContractAnnotations,
+ checker: ts.TypeChecker,
+ ref: ts.TypeReferenceNode,
+ resolveUnit: ContractUnitResolver,
+): void {
+ const alias = ref.parent;
+ if (alias === undefined || !ts.isTypeAliasDeclaration(alias) || alias.type !== ref)
+ return unsupported(out, ref, "Acyclic");
+ out.acyclic.push({
+ note: { marker: "Acyclic", loc: locOf(ref), name: alias.name.text },
+ unit: resolveUnit(checker.getTypeFromTypeNode(ref)),
+ });
+}
+
+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/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts
index 3351baded..028afebc2 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.ts
@@ -78,6 +78,7 @@ 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";
@@ -727,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[];
@@ -1039,6 +1043,37 @@ 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).
+ // Acyclic object types name the IR unit the program lowered them to; a
+ // type the module never uses has no unit (mapping it now only touches the
+ // lowerer's tables, never the finished module).
+ const lowered = result.module;
+ const contracts = collectContractAnnotations(program.getTypeChecker(), moduleOrder, (type) => {
+ if (lowered === null) return null;
+ const mapped = panicSafe(() => resultLowerer.mapTypeOf(type), null);
+ if (mapped?.kind === "object")
+ return lowered.classes?.some((c) => c.name === mapped.className)
+ ? `object:${mapped.className}`
+ : null;
+ if (mapped?.kind === "record")
+ return lowered.records?.some((r) => r.id === mapped.shapeId)
+ ? `record:${mapped.shapeId}`
+ : null;
+ if (mapped?.kind === "union")
+ return lowered.unions?.some((u) => u.id === mapped.unionId)
+ ? `union:${mapped.unionId}`
+ : null;
+ return null;
+ });
+ 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,
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-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts
index 89b2d57bf..0f7b9ae13 100644
--- a/packages/compiler/src/ir/integer-loops.test.ts
+++ b/packages/compiler/src/ir/integer-loops.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from "vitest";
-import { BOOL, F64, arrayOf, type IrExpr, type IrLocal, type IrStmt } from "./ir.js";
+import { BOOL, F64, STRING, arrayOf, type IrExpr, type IrLocal, type IrStmt } from "./ir.js";
import { matchIntegerArrayForLoop, matchIntegerCountedForLoop } from "./integer-loops.js";
const loc = { file: "integer-array-loop.ts", start: 0, end: 0 };
@@ -182,3 +182,29 @@ test("an unproven start versions the loop on an exact integer start", () => {
large.body = Array.from({ length: 81 }, () => large.body[0]!);
expect(matchIntegerCountedForLoop(large, withStart)).toBeNull();
});
+
+test("a string read at the counter is an integer consumer", () => {
+ const withText = new Map(locals);
+ withText.set("limit", { id: "limit", name: "limit", type: F64, mutable: true });
+ withText.set("s", { id: "s", name: "s", type: STRING, mutable: false });
+ const read = (index: IrExpr): IrStmt => ({
+ kind: "exprStmt",
+ expr: {
+ kind: "strIntrinsic",
+ method: "charCodeAt",
+ receiver: { kind: "varRef", localId: "s", type: STRING, loc },
+ args: [index],
+ type: F64,
+ loc,
+ },
+ loc,
+ });
+ const s = counted(ref("limit"));
+ s.body = [read(ref("i"))];
+ expect(matchIntegerCountedForLoop(s, withText)?.localId).toBe("i");
+ s.body = [read({ kind: "bin", op: "+", left: ref("i"), right: number(1), type: F64, loc })];
+ expect(matchIntegerCountedForLoop(s, withText)?.localId).toBe("i");
+ // A read at another index does not consume the counter.
+ s.body = [read(ref("limit"))];
+ expect(matchIntegerCountedForLoop(s, withText)).toBeNull();
+});
diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts
index 6daaf5bd1..c854a99c5 100644
--- a/packages/compiler/src/ir/integer-loops.ts
+++ b/packages/compiler/src/ir/integer-loops.ts
@@ -175,6 +175,9 @@ export function matchIntegerCountedForLoop(
e.args.some(counter)
)
useful = true;
+ // A string read at the counter indexes with the integer directly.
+ if (e.kind === "strIntrinsic" && e.method === "charCodeAt" && e.args[0] && counter(e.args[0]))
+ useful = true;
return (!guarded || ++cost <= 80) && everyExprChild(e, expr, bodyStmt);
};
const bodyStmt = (s: IrStmt): boolean => {
diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts
index 8e8fff024..ac8fc743d 100644
--- a/packages/compiler/src/ir/integer-ranges.test.ts
+++ b/packages/compiler/src/ir/integer-ranges.test.ts
@@ -1,6 +1,12 @@
import { expect, test } from "vitest";
-import { analyzeIntegerRanges } from "./integer-ranges.js";
-import { BOOL, F64, type IrExpr, type IrFunction, type IrStmt } from "./ir.js";
+import {
+ analyzeIntegerRanges,
+ bitwiseIdentityOperand,
+ bitwiseRange,
+ type BitwiseOp,
+ type BitwiseOperand,
+} from "./integer-ranges.js";
+import { BOOL, F64, STRING, type IrExpr, type IrFunction, type IrStmt } from "./ir.js";
const loc = { file: "test.ts", start: 0, end: 0 };
const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 });
@@ -29,7 +35,11 @@ test("tracks exact additions and bitwise conversions through assignments", () =>
const sum = bin("+", ref(), bin("<<", ref(), num(3)));
const unsigned = bin(">>>", ref(), num(0));
const ranges = analyzeIntegerRanges(
- fn([assign(bin("|", num(0), num(0))), assign(sum), { loc, kind: "return", value: unsigned }]),
+ fn([
+ assign(bin("|", ref("unknown"), num(0))),
+ assign(sum),
+ { loc, kind: "return", value: unsigned },
+ ]),
);
expect(ranges.get(sum)).toEqual({ min: -4294967296, max: 4294967294 });
expect(ranges.get(unsigned)).toEqual({ min: 0, max: 4294967295 });
@@ -419,3 +429,152 @@ test("whole-program slot facts seed parameters, field reads and call results", (
});
expect([seeded.get(field), seeded.get(result), seeded.get(param)]).toEqual([INT32, INT32, INT32]);
});
+
+test("string lengths and found indices are int32, other string reads stay unknown", () => {
+ const text: IrExpr = { loc, kind: "strLit", value: "abc", type: STRING };
+ const read = (method: "length" | "indexOf" | "charCodeAt", args: IrExpr[] = []): IrExpr => ({
+ loc,
+ kind: "strIntrinsic",
+ method,
+ receiver: text,
+ args,
+ type: F64,
+ });
+ const length = read("length");
+ const found = read("indexOf", [{ loc, kind: "strLit", value: "b", type: STRING }]);
+ const code = read("charCodeAt", [num(0)]);
+ const next = bin("+", length, num(1));
+ const ranges = analyzeIntegerRanges(
+ fn([observe(length), observe(found), observe(code), observe(next)]),
+ );
+ expect(ranges.get(length)).toEqual({ min: 0, max: 2147483647 });
+ expect(ranges.get(found)).toEqual({ min: -1, max: 2147483647 });
+ // charCodeAt can be NaN.
+ expect(ranges.get(code)).toBeNull();
+ expect(ranges.get(next)).toEqual({ min: 1, max: 2147483648 });
+});
+
+const BITWISE: Record number> = {
+ "&": (left, right) => left & right,
+ "|": (left, right) => left | right,
+ "^": (left, right) => left ^ right,
+ "<<": (left, right) => left << right,
+ ">>": (left, right) => left >> right,
+ ">>>": (left, right) => left >>> right,
+};
+
+test("bitwise result ranges contain every JS result over their operands", () => {
+ const ranges: { min: number; max: number }[] = [
+ { min: 0, max: 0 },
+ { min: 0, max: 1 },
+ { min: 0, max: 100 },
+ { min: 3, max: 5 },
+ { min: -1, max: -1 },
+ { min: -7, max: -2 },
+ { min: -5, max: 9 },
+ { min: 0, max: 31 },
+ { min: 30, max: 33 },
+ { min: 2 ** 31 - 2, max: 2 ** 31 + 1 },
+ { min: 2 ** 31, max: 2 ** 32 - 1 },
+ { min: 2 ** 32 - 1, max: 2 ** 32 + 1 },
+ { min: -(2 ** 31) - 1, max: -(2 ** 31) + 1 },
+ { min: -(2 ** 31), max: 2 ** 31 - 1 },
+ { min: 0, max: 2 ** 32 - 1 },
+ { min: -(2 ** 53), max: 2 ** 53 },
+ ];
+ const literals = [
+ -0,
+ 0.5,
+ -1.5,
+ NaN,
+ Infinity,
+ -Infinity,
+ 2 ** 31,
+ 2 ** 32,
+ 2 ** 32 + 3,
+ 2 ** 53 + 2,
+ ];
+ const samples = (operand: BitwiseOperand): number[] => {
+ if (typeof operand === "number") return [operand];
+ if (operand === null) return [...literals, 7, -7, 2 ** 31 - 1, -(2 ** 31)];
+ const { min, max } = operand;
+ const values = new Set([min, max, Math.floor((min + max) / 2), min + 1, max - 1]);
+ if (max - min <= 64) for (let value = min; value <= max; value++) values.add(value);
+ return [...values].filter((value) => value >= min && value <= max);
+ };
+ const operands: BitwiseOperand[] = [null, ...ranges, ...literals, 0, -1, 3, 31, 32, 255];
+ for (const op of Object.keys(BITWISE) as BitwiseOp[])
+ for (const left of operands)
+ for (const right of operands) {
+ const range = bitwiseRange(op, left, right);
+ for (const a of samples(left))
+ for (const b of samples(right)) {
+ const result = BITWISE[op](a, b);
+ if (result < range.min || result > range.max || Object.is(result, -0))
+ throw new Error(
+ `${a} ${op} ${b} = ${result} outside [${range.min}, ${range.max}] (operands ${JSON.stringify(left)}, ${JSON.stringify(right)})`,
+ );
+ }
+ }
+});
+
+test("bitwise ranges keep known operand ranges", () => {
+ const small = { min: 0, max: 100 };
+ expect(bitwiseRange("|", small, 0)).toEqual(small);
+ expect(bitwiseRange("|", 0, small)).toEqual(small);
+ expect(bitwiseRange("^", small, 2 ** 32)).toEqual(small);
+ expect(bitwiseRange("&", small, -1)).toEqual(small);
+ expect(bitwiseRange("&", null, 255)).toEqual({ min: 0, max: 255 });
+ expect(bitwiseRange("|", small, { min: 1, max: 2 })).toEqual({ min: 1, max: 127 });
+ expect(bitwiseRange(">>", small, 1)).toEqual({ min: 0, max: 50 });
+ expect(bitwiseRange(">>", { min: -9, max: 9 }, null)).toEqual({ min: -9, max: 9 });
+ expect(bitwiseRange("<<", small, 2)).toEqual({ min: 0, max: 400 });
+ expect(bitwiseRange(">>>", null, 1)).toEqual({ min: 0, max: 2 ** 31 - 1 });
+ expect(bitwiseRange(">>>", { min: -1, max: -1 }, 0)).toEqual({
+ min: 2 ** 32 - 1,
+ max: 2 ** 32 - 1,
+ });
+ expect(bitwiseRange("|", { min: 2 ** 31, max: 2 ** 31 }, 0)).toEqual({
+ min: -(2 ** 31),
+ max: -(2 ** 31),
+ });
+ expect(bitwiseRange("|", null, 0)).toEqual(INT32);
+ expect(bitwiseRange("|", NaN, 0)).toEqual({ min: 0, max: 0 });
+ // The analysis carries operand ranges through `| 0` and shifts.
+ const length: IrExpr = {
+ loc,
+ kind: "strIntrinsic",
+ method: "length",
+ receiver: { loc, kind: "varRef", localId: "s", type: STRING },
+ args: [],
+ type: F64,
+ };
+ const truncated = bin("|", length, num(0));
+ const half = bin(">>", length, num(1));
+ const analyzed = analyzeIntegerRanges(
+ fn([observe(truncated), observe(half), observe(bin("|", ref("unknown"), num(0)))]),
+ );
+ expect(analyzed.get(truncated)).toEqual({ min: 0, max: 2 ** 31 - 1 });
+ expect(analyzed.get(half)).toEqual({ min: 0, max: 2 ** 30 - 1 });
+});
+
+test("bitwise identity operands follow ToInt32 and the shift-count mask", () => {
+ const x = ref();
+ const identity = (op: BitwiseOp, left: IrExpr, right: IrExpr) =>
+ bitwiseIdentityOperand(bin(op, left, right) as IrExpr & { kind: "bin" });
+ expect(identity("|", x, num(0))).toBe("left");
+ expect(identity("|", num(0), x)).toBe("right");
+ expect(identity("|", x, num(-0))).toBe("left");
+ expect(identity("|", x, num(2 ** 32))).toBe("left");
+ expect(identity("^", x, num(NaN))).toBe("left");
+ expect(identity("&", x, num(-1))).toBe("left");
+ expect(identity("&", num(2 ** 32 - 1), x)).toBe("right");
+ expect(identity("<<", x, num(0))).toBe("left");
+ expect(identity(">>", x, num(32))).toBe("left");
+ expect(identity(">>>", x, num(0))).toBe("left");
+ expect(identity("|", x, num(1))).toBeNull();
+ expect(identity("&", x, num(0))).toBeNull();
+ expect(identity(">>", x, num(1))).toBeNull();
+ expect(identity("<<", num(0), x)).toBeNull();
+ expect(identity(">>>", num(0), x)).toBeNull();
+});
diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts
index 1d4c5e2ea..5841b1b31 100644
--- a/packages/compiler/src/ir/integer-ranges.ts
+++ b/packages/compiler/src/ir/integer-ranges.ts
@@ -13,6 +13,10 @@ export type IntegerRanges = ReadonlyMap;
const SIGNED: IntegerRange = { min: -2147483648, max: 2147483647 };
const UNSIGNED: IntegerRange = { min: 0, max: 4294967295 };
const EXACT_LIMIT = 2 ** 53;
+/** The most UTF-16 code units a runtime string holds: SCR_STR_MAX_CAP
+ * (scr_runtime.h) caps a string's UTF-8 bytes, and a UTF-16 length never
+ * exceeds the UTF-8 byte length. */
+const STRING_MAX_UNITS = 0x7fffffff;
type Facts = Map;
function union(left: IntegerRange, right: IntegerRange): IntegerRange {
@@ -62,6 +66,132 @@ export const INT32_RANGE: IntegerRange = SIGNED;
export function withinInt32(range: IntegerRange | null | undefined): boolean {
return !!range && range.min >= SIGNED.min && range.max <= SIGNED.max;
}
+export function withinUint32(range: IntegerRange | null | undefined): boolean {
+ return !!range && range.min >= UNSIGNED.min && range.max <= UNSIGNED.max;
+}
+
+export type BitwiseOp = "&" | "|" | "^" | "<<" | ">>" | ">>>";
+export function isBitwiseOp(op: string): op is BitwiseOp {
+ return op === "&" || op === "|" || op === "^" || op === "<<" || op === ">>" || op === ">>>";
+}
+
+/** What an operand of a bitwise operator is known to be: a range of exact
+ * integers, or, for a numeric literal that is not one (2^53 + 2, 0.5, NaN,
+ * Infinity), its value. Unknown operands are null. */
+export type BitwiseOperand = IntegerRange | number | null;
+function operandView(e: IrExpr, range: IntegerRange | null): BitwiseOperand {
+ return range ?? (e.kind === "numLit" ? e.value : null);
+}
+
+/** The values ToInt32 can produce from the operand. A range inside int32 is
+ * its own image; [2^31, 2^32) shifts down by 2^32; anything wider or unknown
+ * wraps to the whole int32 range. */
+function int32View(operand: BitwiseOperand): IntegerRange {
+ if (typeof operand === "number") return { min: operand | 0, max: operand | 0 };
+ if (withinInt32(operand)) return operand!;
+ if (operand && operand.min > SIGNED.max && operand.max <= UNSIGNED.max)
+ return { min: operand.min - 2 ** 32, max: operand.max - 2 ** 32 };
+ return SIGNED;
+}
+/** The values ToUint32 can produce from the operand. */
+function uint32View(operand: BitwiseOperand): IntegerRange {
+ if (typeof operand === "number") return { min: operand >>> 0, max: operand >>> 0 };
+ if (withinUint32(operand)) return operand!;
+ if (operand && operand.min >= SIGNED.min && operand.max < 0)
+ return { min: operand.min + 2 ** 32, max: operand.max + 2 ** 32 };
+ return UNSIGNED;
+}
+/** The shift counts (`ToUint32(count) & 31`) the operand can produce. */
+function shiftCounts(operand: BitwiseOperand): IntegerRange {
+ const counts = uint32View(operand);
+ return counts.max - counts.min < 32 && (counts.min & 31) <= (counts.max & 31)
+ ? { min: counts.min & 31, max: counts.max & 31 }
+ : { min: 0, max: 31 };
+}
+/** The smallest 2^k - 1 at or above a non-negative int32 or uint32. */
+function bitCover(value: number): number {
+ let cover = 0;
+ while (cover < value) cover = cover * 2 + 1;
+ return cover;
+}
+
+/** The result range of a JS bitwise operator over its operands' ranges.
+ * Every result is an exact integer and never -0. */
+export function bitwiseRange(
+ op: BitwiseOp,
+ leftOperand: BitwiseOperand,
+ rightOperand: BitwiseOperand,
+): IntegerRange {
+ if (op === ">>>") {
+ const value = uint32View(leftOperand),
+ counts = shiftCounts(rightOperand);
+ return {
+ min: Math.floor(value.min / 2 ** counts.max),
+ max: Math.floor(value.max / 2 ** counts.min),
+ };
+ }
+ const left = int32View(leftOperand);
+ if (op === ">>" || op === "<<") {
+ const counts = shiftCounts(rightOperand);
+ if (op === ">>")
+ return {
+ min: Math.min(
+ Math.floor(left.min / 2 ** counts.min),
+ Math.floor(left.min / 2 ** counts.max),
+ ),
+ max: Math.max(
+ Math.floor(left.max / 2 ** counts.min),
+ Math.floor(left.max / 2 ** counts.max),
+ ),
+ };
+ const min = Math.min(left.min * 2 ** counts.min, left.min * 2 ** counts.max),
+ max = Math.max(left.max * 2 ** counts.min, left.max * 2 ** counts.max);
+ return withinInt32({ min, max }) ? { min, max } : SIGNED;
+ }
+ const right = int32View(rightOperand);
+ if (op === "&") {
+ // Clearing bits of a non-negative value cannot raise it; a negative
+ // operand keeps the result at or below the other operand's maximum.
+ if (left.min >= 0 && right.min >= 0) return { min: 0, max: Math.min(left.max, right.max) };
+ if (left.min >= 0) return { min: 0, max: left.max };
+ if (right.min >= 0) return { min: 0, max: right.max };
+ if (left.max < 0 && right.max < 0)
+ return { min: SIGNED.min, max: Math.min(left.max, right.max) };
+ return SIGNED;
+ }
+ if (left.min === 0 && left.max === 0) return right;
+ if (right.min === 0 && right.max === 0) return left;
+ if (left.min >= 0 && right.min >= 0) {
+ const max = bitCover(Math.max(left.max, right.max));
+ return op === "|" ? { min: Math.max(left.min, right.min), max } : { min: 0, max };
+ }
+ if (op === "|") {
+ // Setting bits of a negative int32 raises it toward -1.
+ if (left.max < 0 && right.max < 0) return { min: Math.max(left.min, right.min), max: -1 };
+ if (left.max < 0) return { min: left.min, max: -1 };
+ if (right.max < 0) return { min: right.min, max: -1 };
+ }
+ return SIGNED;
+}
+
+/** The operand a bitwise operator returns unchanged when that operand is an
+ * int32 (a uint32 for `>>>`): `x | 0`, `x ^ 0`, `x & -1`, `x << 0`, `x >> 0`
+ * and `x >>> 0`, with the literal on either side where the operator is
+ * commutative. Literals count by their converted value (`x | 2 ** 32`). */
+export function bitwiseIdentityOperand(e: IrExpr & { kind: "bin" }): "left" | "right" | null {
+ const neutral = (literal: IrExpr): boolean => {
+ if (literal.kind !== "numLit") return false;
+ if (e.op === "&") return (literal.value | 0) === -1;
+ if (e.op === "|" || e.op === "^") return (literal.value | 0) === 0;
+ return false;
+ };
+ if (e.op === "<<" || e.op === ">>" || e.op === ">>>")
+ return e.right.kind === "numLit" && ((e.right.value >>> 0) & 31) === 0 ? "left" : null;
+ if (e.op !== "&" && e.op !== "|" && e.op !== "^") return null;
+ if (neutral(e.right)) return "left";
+ if (neutral(e.left)) return "right";
+ return null;
+}
/** Whole-program facts about storage outside the analyzed function, each
* proven by the caller (int32-slots.ts): every value a field slot, a
@@ -72,6 +202,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,
@@ -97,8 +230,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
@@ -288,7 +423,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 =
@@ -335,25 +474,17 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
const left = expr(e.left, facts);
const right = expr(e.right, facts);
if (e.type.kind !== "f64") break;
- if (e.op === ">>>") {
- const shift = e.right.kind === "numLit" ? e.right.value & 31 : 0;
- range = { min: 0, max: 4294967295 >>> shift };
- } else if (e.op === "&") {
- const mask =
- e.left.kind === "numLit"
- ? e.left.value | 0
- : e.right.kind === "numLit"
- ? e.right.value | 0
- : -1;
- range = mask >= 0 ? { min: 0, max: mask } : SIGNED;
- } else if (e.op === "|" || e.op === "^" || e.op === "<<" || e.op === ">>") range = SIGNED;
+ if (isBitwiseOp(e.op))
+ range = bitwiseRange(e.op, operandView(e.left, left), operandView(e.right, right));
else if (left && right) range = integerArithmeticRange(e.op, left, right);
break;
}
case "unary": {
const operand = expr(e.operand, facts);
- if (e.op === "~") range = SIGNED;
- else if (e.op === "-" && operand && (operand.max < 0 || operand.min > 0))
+ if (e.op === "~") {
+ const value = int32View(operandView(e.operand, operand));
+ range = { min: -value.max - 1, max: -value.min - 1 };
+ } else if (e.op === "-" && operand && (operand.max < 0 || operand.min > 0))
range = { min: -operand.max, max: -operand.min };
break;
}
@@ -436,6 +567,27 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
);
if (e.method === "length") range = UNSIGNED;
break;
+ case "strIntrinsic":
+ // The conservative child handling of unknown forms; only the result
+ // range is new: a length or a found index is an int32.
+ havoc(facts, [e], facts);
+ everyExprChild(
+ e,
+ (child) => {
+ expr(child, new Map(facts));
+ return true;
+ },
+ (child) => {
+ body([child], new Map(facts));
+ return true;
+ },
+ );
+ if (e.type.kind === "f64") {
+ if (e.method === "length") range = { min: 0, max: STRING_MAX_UNITS };
+ // An empty needle is found at the clamped position, up to the length.
+ else if (e.method === "indexOf") range = { min: -1, max: STRING_MAX_UNITS };
+ }
+ break;
case "fieldGet":
expr(e.obj, facts);
range = slotRange(e);
@@ -559,8 +711,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..f7409c7fa 100644
--- a/packages/compiler/src/ir/ir.ts
+++ b/packages/compiler/src/ir/ir.ts
@@ -983,6 +983,33 @@ 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[];
+ /** Acyclic classes and object types, as cycle-analysis units
+ * (`object:`, `record:`, `union:`): proven unable to lie
+ * on a reference cycle, so they carry no cycle header. */
+ acyclic?: 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/llvm-runtime-abi.test.ts b/packages/compiler/test/llvm-runtime-abi.test.ts
index 89314b85e..e58119f37 100644
--- a/packages/compiler/test/llvm-runtime-abi.test.ts
+++ b/packages/compiler/test/llvm-runtime-abi.test.ts
@@ -94,7 +94,7 @@ interface HeaderTypes {
* unrecognized so header growth can never slip past the guard unmapped. */
function cTypeToLl(raw: string, types: HeaderTypes): string {
const t = raw
- .replace(/\b(const|struct|restrict|volatile|extern|_Noreturn)\b/g, " ")
+ .replace(/\b(const|struct|restrict|volatile|extern|_Noreturn|SCR_COLD)\b/g, " ")
.replace(/\s+/g, " ")
.trim();
if (t.includes("*")) return "ptr";
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4630-contract-int32.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4630-contract-int32.ts.json
new file mode 100644
index 000000000..7015d87d3
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4630-contract-int32.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/4630-contract-int32.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4631-contract-nothrow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4631-contract-nothrow.ts.json
new file mode 100644
index 000000000..7fa843eb8
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4631-contract-nothrow.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/4631-contract-nothrow.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4632-contract-immutable.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4632-contract-immutable.ts.json
new file mode 100644
index 000000000..80cbcaeae
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4632-contract-immutable.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/4632-contract-immutable.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4640-bitwise-identity-ranges.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4640-bitwise-identity-ranges.ts.json
new file mode 100644
index 000000000..ad0654d9b
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4640-bitwise-identity-ranges.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/4640-bitwise-identity-ranges.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4641-nothrow-try-catch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4641-nothrow-try-catch.ts.json
new file mode 100644
index 000000000..dc79311c9
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4641-nothrow-try-catch.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/4641-nothrow-try-catch.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4642-acyclic-contract.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4642-acyclic-contract.ts.json
new file mode 100644
index 000000000..b39901f63
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4642-acyclic-contract.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/4642-acyclic-contract.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/string-char-code-integer-loops.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/string-char-code-integer-loops.ts.json
new file mode 100644
index 000000000..f6a4a62d4
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/string-char-code-integer-loops.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/string-char-code-integer-loops.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-acyclic-positions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-acyclic-positions.ts.json
new file mode 100644
index 000000000..d31d7aad1
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-acyclic-positions.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/diagnostics/contract-acyclic-positions.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-acyclic.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-acyclic.ts.json
new file mode 100644
index 000000000..ca9f3db35
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-acyclic.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/diagnostics/contract-acyclic.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-immutable.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-immutable.ts.json
new file mode 100644
index 000000000..b0b7fb5cf
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-immutable.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/diagnostics/contract-immutable.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32-escapes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32-escapes.ts.json
new file mode 100644
index 000000000..6599c31dd
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32-escapes.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/diagnostics/contract-int32-escapes.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32.ts.json
new file mode 100644
index 000000000..e2fd8f354
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/diagnostics/contract-int32.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-nothrow-handlers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-nothrow-handlers.ts.json
new file mode 100644
index 000000000..66cdd09ff
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-nothrow-handlers.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/diagnostics/contract-nothrow-handlers.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-nothrow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-nothrow.ts.json
new file mode 100644
index 000000000..8be0da8bb
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-nothrow.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/diagnostics/contract-nothrow.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-positions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-positions.ts.json
new file mode 100644
index 000000000..40073027d
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-positions.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/diagnostics/contract-positions.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/types/README.md b/packages/types/README.md
new file mode 100644
index 000000000..2723c9f8b
--- /dev/null
+++ b/packages/types/README.md
@@ -0,0 +1,26 @@
+# @scriptc/types
+
+Contract types for scriptc: type aliases with no meaning in TypeScript or Node.js that scriptc checks as extra promises. Import them with `import type`, which TypeScript erases, so Node.js never loads this package.
+
+```ts
+import type { Immutable, Int32, NoThrow } from "@scriptc/types";
+
+const KEYWORDS: Immutable> = new Map([["const", 1]]);
+
+class Token {
+ pos: Int32 = 0;
+}
+
+function isLetter(ch: number): NoThrow {
+ return (ch >= 97 && ch <= 122) || (ch >= 65 && ch <= 90);
+}
+```
+
+## Contracts
+
+- `NoThrow` on a function's return type: the function never throws. Callers need no exception checks.
+- `Int32` on a parameter, variable, class instance field or function result: every stored value is a signed 32-bit integer by construction (an integer literal in range, a bitwise result such as `x | 0`, `Math.imul`, another `Int32`, or arithmetic scriptc can bound). The slot is stored as a 32-bit integer.
+- `Immutable` on a parameter, variable, class instance field or function result: a deep read-only view, and no code writes to the value through any reference. A module constant built from literals becomes immortal data.
+- `Acyclic` on a class (`class C implements Acyclic`), interface (`interface I extends Acyclic`) or object type alias (`type T = Acyclic<{ ... }>`): no chain of references can lead from such an object back to itself. Its objects carry no cycle-collector header.
+
+scriptc proves each contract or rejects the program with a diagnostic (SC7001-SC7007) that explains why it does not hold. A program that compiles behaves exactly as under Node.js. See https://scriptc.dev/docs/contracts.
diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts
new file mode 100644
index 000000000..ae833f020
--- /dev/null
+++ b/packages/types/index.d.ts
@@ -0,0 +1,56 @@
+/* scriptc contract types.
+ *
+ * Type aliases with no runtime semantics: import them with `import type`,
+ * which TypeScript erases, so Node.js never loads this package and behaves
+ * exactly as without the annotations. scriptc recognizes the aliases where
+ * they are written (parameter, return, field, variable and property
+ * annotations, `implements` and `extends` clauses, and type aliases) and
+ * checks each one as an extra contract: the program either
+ * compiles with identical behavior, plus the optimization the contract
+ * enables, or fails with a diagnostic that explains why the contract does
+ * not hold. */
+
+/** A number that is always a signed 32-bit integer. Every value stored into
+ * an `Int32` slot (initializers, assignments, compound assignments, `++` and
+ * `--`, arguments and returns) must be an int32 by construction: an integer
+ * literal in range, a bitwise result such as `x | 0`, another `Int32` value,
+ * a string or array length, or arithmetic whose range scriptc can bound.
+ * scriptc then stores the slot as a 32-bit integer. */
+export type Int32 = number;
+
+/** Return-type wrapper: the function never throws. scriptc proves that no
+ * throw, throwing call or throwing operation can escape the function, or
+ * reports the path that can throw. Callers then need no exception checks
+ * after calls to it. */
+export type NoThrow = T;
+
+/** A deeply read-only view of `T`. TypeScript already rejects direct writes
+ * through it; scriptc also checks that no write reaches the value through
+ * any other path (methods that write `this`, aliases, casts, dynamic code).
+ * A module constant initialized from a literal or constructor becomes
+ * immortal data: never reference counted or collected. */
+export type Immutable = T extends (...args: never[]) => unknown
+ ? T
+ : T extends ReadonlyMap
+ ? ReadonlyMap, Immutable>
+ : T extends ReadonlySet
+ ? ReadonlySet>
+ : T extends readonly unknown[]
+ ? { readonly [I in keyof T]: Immutable }
+ : T extends object
+ ? { readonly [K in keyof T]: Immutable }
+ : T;
+
+/** A class or object type whose instances can never lie on a reference
+ * cycle. Write it as `class C implements Acyclic`, `interface I extends
+ * Acyclic` or `type T = Acyclic<{ ... }>`. scriptc proves that no chain of
+ * references can lead from such an object back to itself (for example,
+ * every reference field is assigned only in the constructor, and none holds
+ * a function), or reports the cycle that can form. Its objects then carry
+ * no cycle-collector header and are freed by reference counting alone. */
+export type Acyclic = T;
+
+/** What `implements Acyclic` and `extends Acyclic` name: an empty interface
+ * only this package declares, so the clause adds no members and refers to
+ * nothing else in the program. */
+export interface AcyclicObject {}
diff --git a/packages/types/package.json b/packages/types/package.json
new file mode 100644
index 000000000..b38d72760
--- /dev/null
+++ b/packages/types/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@scriptc/types",
+ "version": "0.2.8",
+ "description": "Contract types for scriptc: type-only annotations (Int32, NoThrow, Immutable) that scriptc checks and optimizes, with no runtime semantics",
+ "license": "Apache-2.0",
+ "homepage": "https://scriptc.dev",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vercel-labs/scriptc.git",
+ "directory": "packages/types"
+ },
+ "type": "module",
+ "types": "index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./index.d.ts"
+ }
+ },
+ "files": [
+ "index.d.ts",
+ "README.md"
+ ],
+ "engines": {
+ "node": ">=24.0.0"
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1de030dbc..1f9f0cf64 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
'@scriptc/threads':
specifier: workspace:*
version: link:packages/threads
+ '@scriptc/types':
+ specifier: workspace:*
+ version: link:packages/types
'@types/node':
specifier: ^24.0.0
version: 24.13.3
@@ -201,6 +204,8 @@ importers:
packages/threads: {}
+ packages/types: {}
+
tests/fixtures/effect4:
dependencies:
'@effect/platform-node':
diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs
index 8aba38c6b..6a3ad27d5 100644
--- a/scripts/sync-versions.mjs
+++ b/scripts/sync-versions.mjs
@@ -31,6 +31,7 @@ for (const pkg of [
"runtime-android-arm64",
"compiler",
"threads",
+ "types",
"cli-darwin-arm64",
"cli-darwin-x64",
"cli-linux-x64-gnu",
diff --git a/tests/corpus/4630-contract-int32.ts b/tests/corpus/4630-contract-int32.ts
new file mode 100644
index 000000000..39f6cc9fe
--- /dev/null
+++ b/tests/corpus/4630-contract-int32.ts
@@ -0,0 +1,88 @@
+// Int32 contract types (@scriptc/types): every value stored into an Int32
+// slot is an int32 by construction, so Node computes the same values and
+// scriptc stores the slots as 32-bit integers. `import type` is erased:
+// Node never loads the package.
+import type { Int32 } from "@scriptc/types";
+
+class Cursor {
+ pos: Int32 = 0;
+ end: Int32;
+ readonly id: Int32;
+ constructor(end: Int32, id: Int32) {
+ this.end = end;
+ this.id = id;
+ }
+ advance(n: Int32): void {
+ this.pos = (this.pos + n) | 0;
+ }
+ atEnd(): boolean {
+ return this.pos >= this.end;
+ }
+}
+
+class Shape {
+ area(scale: Int32): Int32 {
+ return scale & 0;
+ }
+}
+
+class Square extends Shape {
+ side: Int32;
+ constructor(side: Int32) {
+ super();
+ this.side = side;
+ }
+ override area(scale: Int32): Int32 {
+ return Math.imul(Math.imul(this.side, this.side), scale);
+ }
+}
+
+function fnv1a(text: string): Int32 {
+ let h: Int32 = 0x811c9dc5 | 0;
+ for (let i: Int32 = 0; i < text.length; i++) {
+ h = Math.imul(h ^ text.charCodeAt(i), 16777619);
+ }
+ return h;
+}
+
+function clamp(i: Int32, lo: Int32, hi: Int32): Int32 {
+ return i < lo ? lo : i > hi ? hi : i;
+}
+
+let nextId: Int32 = 0;
+function allocateId(): Int32 {
+ nextId = (nextId + 1) | 0;
+ return nextId;
+}
+
+function total(values: number[]): Int32 {
+ let sum: Int32 = 0;
+ values.forEach((v) => {
+ sum = (sum + v) | 0;
+ });
+ return sum;
+}
+
+function countdown(from: Int32): Int32 {
+ let steps: Int32 = 0;
+ for (let i: Int32 = from; i > 0; i--) steps = (steps + 1) | 0;
+ return steps;
+}
+
+const cursor = new Cursor(10, allocateId());
+while (!cursor.atEnd()) cursor.advance(3);
+console.log("cursor", cursor.pos, cursor.end, cursor.id);
+
+const shapes: Shape[] = [new Shape(), new Square(7), new Square(46341)];
+for (const s of shapes) console.log("area", s.area(2));
+
+console.log("fnv1a", fnv1a(""), fnv1a("scriptc"), fnv1a("contract types"));
+console.log("clamp", clamp(-5, 0, 9), clamp(4, 0, 9), clamp(99, 0, 9));
+console.log("ids", allocateId(), allocateId());
+console.log("total", total([1, 2.5, 2147483647, 3]));
+console.log("wrap", (2147483647 + 1) | 0, ~0, -1 >>> 28 | 0);
+console.log("countdown", countdown(5), countdown(-3));
+const big = new Cursor(2147483647, -2147483648);
+big.advance(2147483647);
+big.advance(1);
+console.log("big", big.pos, big.end, big.id, big.atEnd());
diff --git a/tests/corpus/4631-contract-nothrow.ts b/tests/corpus/4631-contract-nothrow.ts
new file mode 100644
index 000000000..4a6ca30c6
--- /dev/null
+++ b/tests/corpus/4631-contract-nothrow.ts
@@ -0,0 +1,63 @@
+// NoThrow contract types (@scriptc/types): scriptc proves these functions
+// cannot throw, so callers need no exception checks after the calls.
+import type { Int32, NoThrow } from "@scriptc/types";
+
+function isDigit(ch: number): NoThrow {
+ return ch >= 48 && ch <= 57;
+}
+
+function isIdentifierStart(ch: number): NoThrow {
+ return (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch === 95 || ch === 36;
+}
+
+function digitValue(ch: number): NoThrow {
+ return isDigit(ch) ? (ch - 48) | 0 : -1;
+}
+
+class Scanner {
+ readonly text: string;
+ pos: Int32 = 0;
+ constructor(text: string) {
+ this.text = text;
+ }
+ peek(): NoThrow {
+ return this.pos < this.text.length ? this.text.charCodeAt(this.pos) : -1;
+ }
+ skipSpaces(): NoThrow {
+ while (this.peek() === 32) this.pos = (this.pos + 1) | 0;
+ }
+ scanNumber(): NoThrow {
+ let value: Int32 = 0;
+ while (isDigit(this.peek())) {
+ value = (Math.imul(value, 10) + digitValue(this.peek())) | 0;
+ this.pos = (this.pos + 1) | 0;
+ }
+ return value;
+ }
+ scanWord(): NoThrow {
+ const start = this.pos;
+ while (isIdentifierStart(this.peek()) || isDigit(this.peek())) this.pos = (this.pos + 1) | 0;
+ return this.text.slice(start, this.pos);
+ }
+}
+
+function tokens(text: string): string[] {
+ const scanner = new Scanner(text);
+ const out: string[] = [];
+ for (;;) {
+ scanner.skipSpaces();
+ const ch = scanner.peek();
+ if (ch < 0) break;
+ if (isDigit(ch)) out.push(`num:${scanner.scanNumber()}`);
+ else if (isIdentifierStart(ch)) out.push(`word:${scanner.scanWord()}`);
+ else {
+ out.push(`punct:${String.fromCharCode(ch)}`);
+ scanner.pos = (scanner.pos + 1) | 0;
+ }
+ }
+ return out;
+}
+
+console.log(tokens("let x1 = 42 + y; // 7").join(" "));
+console.log(tokens(" 2147483648 $id _x").join(" "));
+console.log(digitValue(55), digitValue(65), isIdentifierStart(36));
diff --git a/tests/corpus/4632-contract-immutable.ts b/tests/corpus/4632-contract-immutable.ts
new file mode 100644
index 000000000..a4e412cb7
--- /dev/null
+++ b/tests/corpus/4632-contract-immutable.ts
@@ -0,0 +1,73 @@
+// Immutable contract types (@scriptc/types): no write can reach these
+// values, and module constants built from fresh literals become immortal
+// in scriptc (never reference counted or collected). Node sees ordinary
+// objects: the annotations are erased.
+import type { Immutable } from "@scriptc/types";
+
+class Keyword {
+ readonly text: string;
+ readonly kind: number;
+ constructor(text: string, kind: number) {
+ this.text = text;
+ this.kind = kind;
+ }
+ describe(): string {
+ return `${this.text}#${this.kind}`;
+ }
+}
+
+const KEYWORDS: Immutable> = new Map([
+ ["let", new Keyword("let", 1)],
+ ["const", new Keyword("const", 2)],
+ ["function", new Keyword("function", 3)],
+]);
+
+const CHAR_CLASSES: Immutable = [0, 0, 1, 1, 2, 2, 3, 3];
+
+const LIMITS: Immutable<{ name: string; max: number; tags: string[] }> = {
+ name: "limits",
+ max: 64,
+ tags: ["a", "b"],
+};
+
+function buildTable(size: number): number[] {
+ const table: number[] = [];
+ for (let i = 0; i < size; i++) table.push((i * 7) % 5);
+ return table;
+}
+
+const TABLE: Immutable = buildTable(12);
+
+function lookup(word: string): string {
+ const k = KEYWORDS.get(word);
+ return k === undefined ? "none" : k.describe();
+}
+
+function classify(codes: Immutable, i: number): number {
+ return i < codes.length ? codes[i]! : -1;
+}
+
+let hits = 0;
+for (const word of ["let", "var", "function", "const", "class"]) {
+ const result = lookup(word);
+ if (result !== "none") hits++;
+ console.log(word, result);
+}
+console.log("hits", hits, "size", KEYWORDS.size);
+console.log("classes", classify(CHAR_CLASSES, 3), classify(CHAR_CLASSES, 9), CHAR_CLASSES.join(","));
+console.log("limits", LIMITS.name, LIMITS.max, LIMITS.tags.length, LIMITS.tags.join("|"));
+console.log("table", TABLE.join(""), TABLE.indexOf(4), TABLE.includes(9));
+const copy = TABLE.slice(0, 4);
+copy.push(99);
+console.log("copy", copy.join(","), "original", TABLE.length);
+for (const [text, keyword] of KEYWORDS) console.log("entry", text, keyword.kind);
+
+// Element writes at provable array indexes keep a factory's result fresh.
+function squares(): number[] {
+ const table: number[] = [];
+ for (let i = 0; i < 6; i++) table[i] = i * i;
+ return table;
+}
+
+const SQUARES: Immutable = squares();
+console.log(SQUARES.join(","), classify(SQUARES, 5));
diff --git a/tests/corpus/4640-bitwise-identity-ranges.ts b/tests/corpus/4640-bitwise-identity-ranges.ts
new file mode 100644
index 000000000..6aadbe162
--- /dev/null
+++ b/tests/corpus/4640-bitwise-identity-ranges.ts
@@ -0,0 +1,68 @@
+// `x | 0` and the other identity bitwise forms. Operands the range analysis
+// proves int32 (uint32 for `>>>`) come back unchanged; every other operand
+// takes the exact ToInt32/ToUint32: signed zero, NaN, infinities, fractions
+// and the 2^31, 2^32 and 2^53 boundaries.
+function show(label: string, value: number): void {
+ console.log(label, value, Object.is(value, -0) ? "negative zero" : "", 1 / value);
+}
+
+const inputs = [
+ 0, -0, 0.5, -0.5, 1.5, -1.5, 0.9999999999999999, NaN, Infinity, -Infinity, 2147483647,
+ 2147483648, 2147483649, -2147483647, -2147483648, -2147483649, 4294967295, 4294967296,
+ 4294967297, -4294967296, 9007199254740991, 9007199254740992, -9007199254740992,
+ 18014398509481988, 1e21, -1e21, 5e-324,
+];
+
+function convert(x: number): void {
+ show("or", x | 0);
+ show("zero-or", 0 | x);
+ show("xor", x ^ 0);
+ show("and", x & -1);
+ show("and-u32", x & 4294967295);
+ show("shl", x << 0);
+ show("sar", x >> 0);
+ show("shr", x >>> 0);
+ show("sar32", x >> 32);
+ show("or-2^32", x | 4294967296);
+ show("xor-nan", x ^ NaN);
+}
+
+function proven(text: string, n: number): void {
+ const length = text.length;
+ show("length|0", length | 0);
+ show("length>>>0", length >>> 0);
+ show("zero-or-length", 0 | length);
+ const masked = n & 0xff;
+ show("masked|0", masked | 0);
+ show("masked^0+1", (masked ^ 0) + 1);
+ show("masked<<23", masked << 23);
+ show("masked<<24", masked << 24);
+ show("~masked", ~masked);
+ show("~masked>>>0", ~masked >>> 0);
+ const half = length >> 1;
+ show("half%3", half % 3);
+ show("ushr%7", (n >>> 1) % 7);
+ const negative = -(length + 1);
+ show("negative|0", negative | 0);
+ show("negative>>>0", negative >>> 0);
+ show("negative>>3", negative >> 3);
+ show("negative-zero|0", -length | 0);
+ show("product-zero|0", masked * -1);
+ show("sum", (masked + length) | 0);
+ const wide = length * 4294967296;
+ show("wide|0", wide | 0);
+ show("wide>>>0", wide >>> 0);
+ show("or-pair", (masked | (length & 3)) | 0);
+ let acc = 0;
+ for (let i = 0; i < length; i++) acc = (acc * 31 + text.charCodeAt(i)) | 0;
+ show("acc", acc);
+ for (let f = 0.5; f < 3; f++) show("fraction-counter", f | 0);
+}
+
+for (const x of inputs) {
+ console.log("input", x);
+ convert(x);
+}
+for (const text of ["", "a", "hello, world", "a much longer piece of text to hash"])
+ for (const n of [0, -1, 255, 256, -256, 2147483647, -2147483648, 4294967295, 0.5, NaN])
+ proven(text, n);
diff --git a/tests/corpus/4641-nothrow-try-catch.ts b/tests/corpus/4641-nothrow-try-catch.ts
new file mode 100644
index 000000000..d29bbd7eb
--- /dev/null
+++ b/tests/corpus/4641-nothrow-try-catch.ts
@@ -0,0 +1,99 @@
+// try/catch discharges NoThrow: a throwing call inside a `try` whose
+// `catch` takes every exception, or whose `finally` never completes
+// normally, cannot propagate. finally ordering and rethrows stay exact.
+import type { NoThrow } from "@scriptc/types";
+
+class Stats {
+ caught = 0;
+ finals = 0;
+ events: string[] = [];
+}
+
+function parseDigit(ch: number): number {
+ if (ch < 48 || ch > 57) throw new RangeError(`not a digit: ${ch}`);
+ return ch - 48;
+}
+
+function digitOr(text: string, i: number, fallback: number): NoThrow {
+ try {
+ return parseDigit(text.charCodeAt(i));
+ } catch {
+ return fallback;
+ }
+}
+
+function logged(text: string, stats: Stats): NoThrow {
+ try {
+ stats.events.push(`try ${text}`);
+ return parseDigit(text.charCodeAt(0));
+ } catch (error) {
+ stats.events.push(error instanceof RangeError ? "catch RangeError" : "catch other");
+ stats.caught++;
+ return -1;
+ } finally {
+ stats.events.push("finally");
+ stats.finals++;
+ }
+}
+
+// The catch rethrows, but the finally returns, which discards the exception.
+function swallowed(text: string, stats: Stats): NoThrow {
+ try {
+ return parseDigit(text.charCodeAt(0));
+ } catch (error) {
+ stats.caught++;
+ throw error;
+ } finally {
+ stats.finals++;
+ // eslint-disable-next-line no-unsafe-finally
+ return -2;
+ }
+}
+
+// An inner finally re-raises; the outer catch takes it.
+function nested(text: string, stats: Stats): NoThrow {
+ try {
+ try {
+ return parseDigit(text.charCodeAt(0)) * 10;
+ } finally {
+ stats.events.push("inner finally");
+ }
+ } catch {
+ stats.events.push("outer catch");
+ return -3;
+ }
+}
+
+// Node's own TypeError from reading a property of undefined is caught too.
+function lengthAt(items: (string | undefined)[], i: number): NoThrow {
+ try {
+ return items[i]!.length;
+ } catch {
+ return -4;
+ }
+}
+
+// Without NoThrow, a finally that completes normally keeps propagating.
+function propagates(text: string, stats: Stats): number {
+ try {
+ return parseDigit(text.charCodeAt(0));
+ } finally {
+ stats.events.push(`propagates finally ${text}`);
+ }
+}
+
+const stats = new Stats();
+for (const text of ["7", "x", "", "42"]) {
+ console.log(text, digitOr(text, 0, -9), logged(text, stats), swallowed(text, stats), nested(text, stats));
+}
+const items: (string | undefined)[] = ["ab", undefined, "cde"];
+console.log(lengthAt(items, 0), lengthAt(items, 1), lengthAt(items, 2));
+for (const text of ["3", "?"]) {
+ try {
+ console.log("propagates", propagates(text, stats));
+ } catch (error) {
+ console.log("escaped", error instanceof RangeError, (error as Error).message);
+ }
+}
+console.log(stats.caught, stats.finals);
+console.log(stats.events.join("\n"));
diff --git a/tests/corpus/4642-acyclic-contract.ts b/tests/corpus/4642-acyclic-contract.ts
new file mode 100644
index 000000000..dffbba761
--- /dev/null
+++ b/tests/corpus/4642-acyclic-contract.ts
@@ -0,0 +1,76 @@
+// Acyclic contract types (@scriptc/types): scriptc proves these classes and
+// object types cannot lie on a reference cycle, so their objects carry no
+// cycle-collector header and are freed by reference counting alone.
+import type { Acyclic, Int32 } from "@scriptc/types";
+import type * as sc from "@scriptc/types";
+
+class Cons implements Acyclic {
+ readonly head: number;
+ readonly tail: Cons | undefined;
+ constructor(head: number, tail: Cons | undefined) {
+ this.head = head;
+ this.tail = tail;
+ }
+}
+
+class Tree implements Acyclic {
+ readonly left: Tree | null;
+ readonly right: Tree | null;
+ readonly value: number;
+ constructor(left: Tree | null, right: Tree | null, value: number) {
+ this.left = left;
+ this.right = right;
+ this.value = value;
+ }
+ sum(): number {
+ return this.value + (this.left === null ? 0 : this.left.sum()) + (this.right === null ? 0 : this.right.sum());
+ }
+}
+
+// Mutable scalar fields do not matter: only references can form cycles.
+class Counter implements sc.Acyclic {
+ count = 0;
+ label: string;
+ constructor(label: string) {
+ this.label = label;
+ }
+}
+
+// `implements Acyclic` names an interface only @scriptc/types declares, so
+// other object types (here a Record) keep their layouts, and the Int32
+// field of a class stored in one stays checkable.
+class Slot implements Acyclic {
+ index: Int32 = 0;
+}
+
+interface Point extends Acyclic {
+ readonly x: number;
+ readonly y: number;
+}
+
+type Segment = Acyclic<{ readonly from: Point; readonly to: Point; readonly label: string }>;
+
+function build(depth: number): Tree {
+ return depth === 0 ? new Tree(null, null, 1) : new Tree(build(depth - 1), build(depth - 1), depth);
+}
+
+let list: Cons | undefined = undefined;
+for (let i = 0; i < 100000; i++) list = new Cons(i, list);
+let total = 0;
+for (let c = list; c !== undefined; c = c.tail) total += c.head;
+console.log(total);
+console.log(build(16).sum());
+
+const counter = new Counter("ticks");
+for (let i = 0; i < 10; i++) counter.count++;
+counter.label = `${counter.label}!`;
+console.log(counter.label, counter.count);
+
+const segments: Segment[] = [];
+for (let i = 0; i < 5; i++) segments.push({ from: { x: i, y: 0 }, to: { x: i, y: i * i }, label: `s${i}` });
+console.log(segments.map((s) => `${s.label}:${s.to.y - s.from.y}`).join(" "));
+
+const slots: Record = { a: new Slot() };
+for (let i = 0; i < 3; i++) slots["a"]!.index = (slots["a"]!.index + 2) | 0;
+slots["b"] = new Slot();
+console.log(Object.keys(slots).join(","), slots["a"]!.index, slots["b"]!.index);
diff --git a/tests/corpus/string-char-code-integer-loops.ts b/tests/corpus/string-char-code-integer-loops.ts
new file mode 100644
index 000000000..79028ab3b
--- /dev/null
+++ b/tests/corpus/string-char-code-integer-loops.ts
@@ -0,0 +1,82 @@
+// charCodeAt at counted-loop counters and other proven integers: in-range
+// reads, negative and past-the-end indices, non-ASCII and surrogate text,
+// and counters near the 2^31 and 2^53 bounds. (Lone surrogates are left
+// out: UTF-8 storage turns them into U+FFFD, see the limitations page.)
+const texts = ["", "a", "plain ascii text", "caf\u00e9 cr\u00e8me", "emoji \ud83d\ude00 pair", "\u4e2d\u6587\u5b57"];
+
+function codes(s: string): string {
+ let out = "";
+ for (let i = 0; i < s.length; i++) out += s.charCodeAt(i) + ",";
+ return out;
+}
+function inclusive(s: string): string {
+ let out = "";
+ for (let i = 0; i <= s.length; i++) out += s.charCodeAt(i) + ",";
+ return out;
+}
+function neighbours(s: string): string {
+ let out = "";
+ for (let i = 0; i < s.length; i++) out += `${s.charCodeAt(i - 1)}/${s.charCodeAt(i + 1)}/${s.charCodeAt(i * 2)},`;
+ return out;
+}
+function backwards(s: string): string {
+ let out = "";
+ for (let i = s.length - 1; i >= -2; i--) out += s.charCodeAt(i) + ",";
+ return out;
+}
+function hash(s: string): number {
+ let h = 0x811c9dc5 | 0;
+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
+ return h >>> 0;
+}
+for (const s of texts) {
+ console.log(JSON.stringify(s), s.length, codes(s), inclusive(s), neighbours(s), backwards(s), hash(s));
+}
+
+// A counter that starts negative or at -0.
+function fromStart(s: string, start: number): string {
+ let out = "";
+ for (let i = start; i < 3; i++) out += `${Object.is(i, -0) ? "-0" : i}:${s.charCodeAt(i)},`;
+ return out;
+}
+for (const start of [-3, -0, 0, 1]) console.log("start", start, fromStart("xyz", start), fromStart("", start));
+
+// Counters far beyond any string length.
+function wide(s: string, start: number): string {
+ let out = "";
+ let steps = 0;
+ for (let i = start; i < start + 4; i++) {
+ out += `${i}:${s.charCodeAt(i)}:${s.charCodeAt(i - start)},`;
+ if (++steps === 4) break;
+ }
+ return out;
+}
+for (const start of [2147483646, 4294967294, 9007199254740989]) console.log("wide", wide("abcd", start));
+
+// Bounds derived from indexOf and length.
+function afterSlash(path: string): number {
+ let sum = 0;
+ for (let i = path.indexOf("/") + 1; i < path.length; i++) sum += path.charCodeAt(i);
+ return sum;
+}
+function countDots(path: string): number {
+ const n = path.length;
+ let dots = 0;
+ for (let i = 0; i < n; i++) if (path.charCodeAt(i) === 46) dots++;
+ return dots;
+}
+for (const path of ["a/b.c", "/x/./y/../z", "no-slash", "", "\u00e9/\u00e8.\u00ea"]) {
+ console.log("path", JSON.stringify(path), afterSlash(path), countDots(path), path.indexOf("."), path.length);
+}
+
+// The receiver changes inside the loop.
+function changing(seed: string): string {
+ let s = seed;
+ let out = "";
+ for (let i = 0; i < 6; i++) {
+ out += s.charCodeAt(i) + ",";
+ s = i % 2 === 0 ? s + "\u00df" : s.slice(1);
+ }
+ return out;
+}
+console.log("changing", changing("ab"), changing(""));
diff --git a/tests/diagnostics/contract-acyclic-positions.ts b/tests/diagnostics/contract-acyclic-positions.ts
new file mode 100644
index 000000000..c4996761c
--- /dev/null
+++ b/tests/diagnostics/contract-acyclic-positions.ts
@@ -0,0 +1,18 @@
+// Acyclic is checked on classes, interfaces and object type aliases.
+// Elsewhere it would promise something scriptc does not check, so the build
+// reports it.
+import type { Acyclic } from "@scriptc/types";
+
+interface Item {
+ name: string;
+}
+
+// A type argument would make the class implement its own type.
+class Box implements Acyclic- {
+ name = "box";
+}
+
+const item: Acyclic
- = { name: "a" };
+const items: Acyclic
- [] = [item];
+
+console.log(item.name, items.length, new Box().name);
diff --git a/tests/diagnostics/contract-acyclic.ts b/tests/diagnostics/contract-acyclic.ts
new file mode 100644
index 000000000..cc4a8e7c1
--- /dev/null
+++ b/tests/diagnostics/contract-acyclic.ts
@@ -0,0 +1,59 @@
+// Acyclic contracts that do not hold: each diagnostic shows a reference
+// cycle that can form.
+import type { Acyclic } from "@scriptc/types";
+
+class ListNode implements Acyclic {
+ value: number;
+ next: ListNode | undefined = undefined;
+ constructor(value: number) {
+ this.value = value;
+ }
+}
+
+class Task implements Acyclic {
+ readonly name: string;
+ readonly done: () => void;
+ constructor(name: string, done: () => void) {
+ this.name = name;
+ this.done = done;
+ }
+}
+
+class Graph implements Acyclic {
+ readonly nodes: GraphNode[] = [];
+}
+
+class GraphNode {
+ readonly graph: Graph;
+ constructor(graph: Graph) {
+ this.graph = graph;
+ }
+}
+
+class Shape implements Acyclic {
+ readonly id: number;
+ constructor(id: number) {
+ this.id = id;
+ }
+}
+
+class Group extends Shape {
+ readonly members: Shape[] = [];
+}
+
+interface Cell extends Acyclic {
+ value: number;
+ link: Cell | null;
+}
+
+const a = new ListNode(1);
+a.next = new ListNode(2);
+a.next.next = a;
+const t: Task = new Task("t", () => console.log(t.name));
+const g = new Graph();
+g.nodes.push(new GraphNode(g));
+const group = new Group(1);
+group.members.push(group);
+const c: Cell = { value: 1, link: null };
+c.link = c;
+console.log(a.value, t.name, g.nodes.length, group.members.length, c.value);
diff --git a/tests/diagnostics/contract-immutable.ts b/tests/diagnostics/contract-immutable.ts
new file mode 100644
index 000000000..a9a0d9aed
--- /dev/null
+++ b/tests/diagnostics/contract-immutable.ts
@@ -0,0 +1,46 @@
+// Immutable contracts that do not hold: writes that reach the value
+// through methods, aliases and casts, and stores of values that other
+// references can still change.
+import type { Immutable } from "@scriptc/types";
+
+class Counter {
+ count = 0;
+ bump(): void {
+ this.count++;
+ }
+}
+
+const COUNTER: Immutable
= new Counter();
+
+const shared = [1, 2, 3];
+const VIEW: Immutable = shared;
+
+const ROWS: Immutable = [[1], [2]];
+
+const CONFIG: Immutable<{ name: string }> = { name: "x" };
+
+function rename(config: { name: string }): void {
+ config.name = "y";
+}
+
+function grow(rows: number[][]): void {
+ rows[0]!.push(4);
+}
+
+COUNTER.bump();
+rename(CONFIG);
+grow(ROWS as number[][]);
+shared.push(4);
+console.log(COUNTER.count, VIEW.length, ROWS.length, CONFIG.name);
+
+// A write at an index that may be negative, fractional or too large adds a
+// property instead of an element, so the factory's result is not fresh.
+function fill(at: number): number[] {
+ const table: number[] = [];
+ for (let i = 0; i < 4; i++) table[i] = i;
+ table[at] = -1;
+ return table;
+}
+
+const FILLED: Immutable = fill(-1);
+console.log(FILLED.length);
diff --git a/tests/diagnostics/contract-int32-escapes.ts b/tests/diagnostics/contract-int32-escapes.ts
new file mode 100644
index 000000000..fcbbcbfd9
--- /dev/null
+++ b/tests/diagnostics/contract-int32-escapes.ts
@@ -0,0 +1,17 @@
+// Int32 slots whose writes the program does not show: a parameter of a
+// function used as a callback, and a field of instances that reach
+// dynamic code.
+import type { Int32 } from "@scriptc/types";
+
+function double(x: Int32): Int32 {
+ return (x * 2) | 0;
+}
+
+class Box {
+ size: Int32 = 0;
+}
+
+const box = new Box();
+const loose: any = box;
+loose.size = 0.5;
+console.log([1.5, 2].map(double).join(","), box.size);
diff --git a/tests/diagnostics/contract-int32.ts b/tests/diagnostics/contract-int32.ts
new file mode 100644
index 000000000..f6e900626
--- /dev/null
+++ b/tests/diagnostics/contract-int32.ts
@@ -0,0 +1,36 @@
+// Int32 contracts that do not hold: each write that is not an int32 by
+// construction is reported with the expression and the `| 0` fix.
+import type { Int32 } from "@scriptc/types";
+
+class Span {
+ pos: Int32 = 0;
+ end: Int32 = 0;
+ readonly id: Int32;
+ constructor(id: number) {
+ this.id = id;
+ }
+ advance(): void {
+ this.pos++;
+ }
+}
+
+function width(span: Span): Int32 {
+ return span.end - span.pos;
+}
+
+function half(x: Int32): Int32 {
+ return x / 2;
+}
+
+let count: Int32 = 0;
+function bump(): void {
+ count += 1;
+}
+
+const sizes = [1, 2, 3];
+const len: Int32 = sizes.length;
+
+const span = new Span(1.5);
+span.advance();
+bump();
+console.log(width(span), half(7), count, len);
diff --git a/tests/diagnostics/contract-nothrow-handlers.ts b/tests/diagnostics/contract-nothrow-handlers.ts
new file mode 100644
index 000000000..be9790fff
--- /dev/null
+++ b/tests/diagnostics/contract-nothrow-handlers.ts
@@ -0,0 +1,40 @@
+// try/catch discharges NoThrow only for the exceptions its handlers take: a
+// finally that can complete normally re-raises, and a rethrow in a catch
+// propagates. `handled` holds its contract.
+import type { NoThrow } from "@scriptc/types";
+
+class Counter {
+ count = 0;
+}
+
+function parseDigit(ch: number): number {
+ if (ch < 48 || ch > 57) throw new RangeError("not a digit");
+ return ch - 48;
+}
+
+function cleanup(text: string, counter: Counter): NoThrow {
+ try {
+ return parseDigit(text.charCodeAt(0));
+ } finally {
+ counter.count++;
+ }
+}
+
+function rethrows(text: string): NoThrow {
+ try {
+ return parseDigit(text.charCodeAt(0));
+ } catch (error) {
+ if (text.length > 1) throw error;
+ return -1;
+ }
+}
+
+function handled(text: string): NoThrow {
+ try {
+ return parseDigit(text.charCodeAt(0));
+ } catch {
+ return -1;
+ }
+}
+
+console.log(cleanup("1", new Counter()), rethrows("2"), handled("x"));
diff --git a/tests/diagnostics/contract-nothrow.ts b/tests/diagnostics/contract-nothrow.ts
new file mode 100644
index 000000000..6d971dac5
--- /dev/null
+++ b/tests/diagnostics/contract-nothrow.ts
@@ -0,0 +1,34 @@
+// NoThrow contracts that do not hold: each diagnostic names the path from
+// the annotated function to the operation that can throw.
+import type { NoThrow } from "@scriptc/types";
+
+function parseDigit(ch: string): number {
+ const code = ch.charCodeAt(0) - 48;
+ if (code < 0 || code > 9) throw new RangeError(`not a digit: ${ch}`);
+ return code;
+}
+
+function sumDigits(text: string): NoThrow {
+ let sum = 0;
+ for (let i = 0; i < text.length; i++) sum += parseDigit(text[i]!);
+ return sum;
+}
+
+function depth(n: number): NoThrow {
+ return n <= 0 ? 0 : 1 + depth(n - 1);
+}
+
+function apply(f: (x: number) => number, x: number): NoThrow {
+ return f(x);
+}
+
+// The source type says `string`, but a caller passes a non-null asserted
+// element that can be missing; Node then throws inside the callee, so the
+// parameter is compiled as `string | undefined` and the read can throw.
+function width(text: string): NoThrow {
+ return text.length;
+}
+
+const words = "a b".split(" ");
+console.log(sumDigits("123"), depth(3), apply((x) => x + 1, 1), apply((x) => parseDigit(String(x)), 2));
+console.log(width(words[0]!), width(words[1]!));
diff --git a/tests/diagnostics/contract-positions.ts b/tests/diagnostics/contract-positions.ts
new file mode 100644
index 000000000..8223e0ae0
--- /dev/null
+++ b/tests/diagnostics/contract-positions.ts
@@ -0,0 +1,19 @@
+// Contract types are checked where they annotate a declaration directly.
+// Elsewhere they would promise something scriptc does not check, so the
+// build reports them.
+import type { Immutable, Int32, NoThrow } from "@scriptc/types";
+
+type NodeId = Int32;
+
+interface Point {
+ x: Int32;
+}
+
+const ids: Int32[] = [1, 2];
+const point: Point = { x: 1 };
+const id: NodeId = 3;
+const callback: (x: number) => NoThrow = (x) => x;
+const table: Map> = new Map();
+let last: NoThrow = 0;
+
+console.log(ids, point.x, id, callback(1), table.size, last);
diff --git a/tests/harness/__snapshots__/contract-acyclic-positions.ts.txt b/tests/harness/__snapshots__/contract-acyclic-positions.ts.txt
new file mode 100644
index 000000000..1cdc278fb
--- /dev/null
+++ b/tests/harness/__snapshots__/contract-acyclic-positions.ts.txt
@@ -0,0 +1,26 @@
+contract-acyclic-positions.ts:11:22 - error SC7006: the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases)
+
+ 10 | // A type argument would make the class implement its own type.
+ 11 | class Box implements Acyclic- {
+ | ^~~~~~~~~~~~~
+ 12 | name = "box";
+
+ hint: write `class C implements Acyclic` or `interface I extends Acyclic` (without a type argument), or `type T = Acyclic<{ ... }>`
+
+contract-acyclic-positions.ts:15:13 - error SC7006: the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases)
+
+ 14 |
+ 15 | const item: Acyclic
- = { name: "a" };
+ | ^~~~~~~~~~~~~
+ 16 | const items: Acyclic
- [] = [item];
+
+ hint: write `class C implements Acyclic` or `interface I extends Acyclic` (without a type argument), or `type T = Acyclic<{ ... }>`
+
+contract-acyclic-positions.ts:16:14 - error SC7006: the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases)
+
+ 15 | const item: Acyclic
- = { name: "a" };
+ 16 | const items: Acyclic
- [] = [item];
+ | ^~~~~~~~~~~~~
+ 17 |
+
+ hint: write `class C implements Acyclic` or `interface I extends Acyclic` (without a type argument), or `type T = Acyclic<{ ... }>`
\ No newline at end of file
diff --git a/tests/harness/__snapshots__/contract-acyclic.ts.txt b/tests/harness/__snapshots__/contract-acyclic.ts.txt
new file mode 100644
index 000000000..77ea7fa4d
--- /dev/null
+++ b/tests/harness/__snapshots__/contract-acyclic.ts.txt
@@ -0,0 +1,44 @@
+contract-acyclic.ts:5:27 - error SC7007: 'ListNode' is declared Acyclic but can lie on a reference cycle: ListNode.next → ListNode (ListNode.next can be assigned after construction, as at contract-acyclic.ts:50:1)
+
+ 4 |
+ 5 | class ListNode implements Acyclic {
+ | ^~~~~~~
+ 6 | value: number;
+
+ hint: assign the reference only in the constructor (before `this` escapes), so a new object can only point to older ones, or remove Acyclic
+
+contract-acyclic.ts:13:23 - error SC7007: 'Task' is declared Acyclic but can lie on a reference cycle: Task.done can hold a function, which can reference any object, including a Task
+
+ 12 |
+ 13 | class Task implements Acyclic {
+ | ^~~~~~~
+ 14 | readonly name: string;
+
+ hint: a function, dynamic or promise field can capture the object itself; store plain data instead, or remove Acyclic
+
+contract-acyclic.ts:22:24 - error SC7007: 'Graph' is declared Acyclic but can lie on a reference cycle: Graph.nodes → GraphNode.graph → Graph (Graph.nodes is an array, whose contents can change)
+
+ 21 |
+ 22 | class Graph implements Acyclic {
+ | ^~~~~~~
+ 23 | readonly nodes: GraphNode[] = [];
+
+ hint: an array, Map or Set can come to hold an object that refers back; make the element type unable to reach this type, or remove Acyclic
+
+contract-acyclic.ts:33:24 - error SC7007: 'Shape' is declared Acyclic but can lie on a reference cycle: its subclass 'Group' shares its object layout, and Group.members → Group (as a Shape) (Group.members is an array, whose contents can change)
+
+ 32 |
+ 33 | class Shape implements Acyclic {
+ | ^~~~~~~
+ 34 | readonly id: number;
+
+ hint: an array, Map or Set can come to hold an object that refers back; make the element type unable to reach this type, or remove Acyclic
+
+contract-acyclic.ts:44:24 - error SC7007: 'Cell' is declared Acyclic but can lie on a reference cycle: Cell.link → Cell (Cell.link can be assigned after construction, as at contract-acyclic.ts:58:1)
+
+ 43 |
+ 44 | interface Cell extends Acyclic {
+ | ^~~~~~~
+ 45 | value: number;
+
+ hint: assign the reference only in the constructor (before `this` escapes), so a new object can only point to older ones, or remove Acyclic
\ No newline at end of file
diff --git a/tests/harness/__snapshots__/contract-immutable.ts.txt b/tests/harness/__snapshots__/contract-immutable.ts.txt
new file mode 100644
index 000000000..a4614d16e
--- /dev/null
+++ b/tests/harness/__snapshots__/contract-immutable.ts.txt
@@ -0,0 +1,44 @@
+contract-immutable.ts:9:5 - error SC7004: 'this.count++' writes to a value that may be Immutable ('this' may be an Immutable value passed to 'Counter.bump' at contract-immutable.ts:30:1)
+
+ 8 | bump(): void {
+ 9 | this.count++;
+ | ^~~~~~~~~~~~
+ 10 | }
+
+ hint: Immutable values cannot be written through any reference; copy the value before changing it
+
+contract-immutable.ts:16:35 - error SC7004: the value stored into 'VIEW' (Immutable) may be referenced elsewhere: 'shared' is not a fresh literal, construction or factory result
+
+ 15 | const shared = [1, 2, 3];
+ 16 | const VIEW: Immutable
= shared;
+ | ^~~~~~
+ 17 |
+
+ hint: build the value in place (a literal, `new`, or a function that returns what it builds), so no mutable reference to it remains
+
+contract-immutable.ts:23:3 - error SC7004: 'config.name = "y"' writes to a value that may be Immutable ('config' may be an Immutable value passed to 'rename' at contract-immutable.ts:31:1)
+
+ 22 | function rename(config: { name: string }): void {
+ 23 | config.name = "y";
+ | ^~~~~~~~~~~~~~~~~
+ 24 | }
+
+ hint: Immutable values cannot be written through any reference; copy the value before changing it
+
+contract-immutable.ts:27:3 - error SC7004: 'rows[0]!.push(4)' writes to a value that may be Immutable ('rows' may be an Immutable value passed to 'grow' at contract-immutable.ts:32:1)
+
+ 26 | function grow(rows: number[][]): void {
+ 27 | rows[0]!.push(4);
+ | ^~~~~~~~~~~~~~~~
+ 28 | }
+
+ hint: Immutable values cannot be written through any reference; copy the value before changing it
+
+contract-immutable.ts:45:37 - error SC7004: the value stored into 'FILLED' (Immutable) may be referenced elsewhere: 'fill(-1)' is not a fresh literal, construction or factory result
+
+ 44 |
+ 45 | const FILLED: Immutable = fill(-1);
+ | ^~~~~~~~
+ 46 | console.log(FILLED.length);
+
+ hint: build the value in place (a literal, `new`, or a function that returns what it builds), so no mutable reference to it remains
\ No newline at end of file
diff --git a/tests/harness/__snapshots__/contract-int32-escapes.ts.txt b/tests/harness/__snapshots__/contract-int32-escapes.ts.txt
new file mode 100644
index 000000000..5ba985a48
--- /dev/null
+++ b/tests/harness/__snapshots__/contract-int32-escapes.ts.txt
@@ -0,0 +1,26 @@
+contract-int32-escapes.ts:6:20 - error SC7003: the Int32 contract on 'x' cannot be checked: 'double' is used as a value (callback, class value or export), so its callers are not all visible
+
+ 5 |
+ 6 | function double(x: Int32): Int32 {
+ | ^~~~~
+ 7 | return (x * 2) | 0;
+
+ hint: wrap it in a function that takes a number and passes `x | 0`
+
+contract-int32-escapes.ts:11:9 - error SC7003: the Int32 contract on 'Box.size' cannot be checked: instances of the class reach dynamic code (any, unknown casts, islands) where a store to the field is not checked
+
+ 10 | class Box {
+ 11 | size: Int32 = 0;
+ | ^~~~~
+ 12 | }
+
+ hint: keep instances of this class out of any-typed values, or remove Int32
+
+contract-int32-escapes.ts:16:1 - error SC7002: the value stored into 'Box.size' (Int32) is not provably an int32: 'loose.size = 0.5'
+
+ 15 | const loose: any = box;
+ 16 | loose.size = 0.5;
+ | ^~~~~~~~~~~~~~~~
+ 17 | console.log([1.5, 2].map(double).join(","), box.size);
+
+ hint: make the int32 conversion explicit, for example 'loose.size = 0.5 | 0', so Node computes the same value
\ No newline at end of file
diff --git a/tests/harness/__snapshots__/contract-int32.ts.txt b/tests/harness/__snapshots__/contract-int32.ts.txt
new file mode 100644
index 000000000..2f3a65c1d
--- /dev/null
+++ b/tests/harness/__snapshots__/contract-int32.ts.txt
@@ -0,0 +1,53 @@
+contract-int32.ts:10:15 - error SC7002: the value stored into 'Span.id' (Int32) is not provably an int32: 'id'
+
+ 9 | constructor(id: number) {
+ 10 | this.id = id;
+ | ^~
+ 11 | }
+
+ hint: make the int32 conversion explicit, for example 'id | 0', so Node computes the same value
+
+contract-int32.ts:13:5 - error SC7002: the value stored into 'Span.pos' (Int32) is not provably an int32: 'this.pos++'
+
+ 12 | advance(): void {
+ 13 | this.pos++;
+ | ^~~~~~~~~~
+ 14 | }
+
+ hint: make the int32 conversion explicit, for example 'this.pos = (this.pos + 1) | 0', so Node computes the same value
+
+contract-int32.ts:18:10 - error SC7002: the value returned from 'width' (Int32) is not provably an int32: 'span.end - span.pos'
+
+ 17 | function width(span: Span): Int32 {
+ 18 | return span.end - span.pos;
+ | ^~~~~~~~~~~~~~~~~~~
+ 19 | }
+
+ hint: make the int32 conversion explicit, for example '(span.end - span.pos) | 0', so Node computes the same value
+
+contract-int32.ts:22:10 - error SC7002: the value returned from 'half' (Int32) is not provably an int32: 'x / 2'
+
+ 21 | function half(x: Int32): Int32 {
+ 22 | return x / 2;
+ | ^~~~~
+ 23 | }
+
+ hint: make the int32 conversion explicit, for example '(x / 2) | 0', so Node computes the same value
+
+contract-int32.ts:27:3 - error SC7002: the value stored into 'count' (Int32) is not provably an int32: 'count += 1'
+
+ 26 | function bump(): void {
+ 27 | count += 1;
+ | ^~~~~~~~~~
+ 28 | }
+
+ hint: make the int32 conversion explicit, for example 'count = (count + 1) | 0', so Node computes the same value
+
+contract-int32.ts:31:20 - error SC7002: the value stored into 'len' (Int32) is not provably an int32: 'sizes.length'
+
+ 30 | const sizes = [1, 2, 3];
+ 31 | const len: Int32 = sizes.length;
+ | ^~~~~~~~~~~~
+ 32 |
+
+ hint: make the int32 conversion explicit, for example 'sizes.length | 0', so Node computes the same value
\ No newline at end of file
diff --git a/tests/harness/__snapshots__/contract-nothrow-handlers.ts.txt b/tests/harness/__snapshots__/contract-nothrow-handlers.ts.txt
new file mode 100644
index 000000000..89321671c
--- /dev/null
+++ b/tests/harness/__snapshots__/contract-nothrow-handlers.ts.txt
@@ -0,0 +1,17 @@
+contract-nothrow-handlers.ts:15:51 - error SC7001: 'cleanup' is declared NoThrow but may throw: calls 'parseDigit' at contract-nothrow-handlers.ts:17:12 → throw statement at contract-nothrow-handlers.ts:11:27
+
+ 14 |
+ 15 | function cleanup(text: string, counter: Counter): NoThrow {
+ | ^~~~~~~~~~~~~~~
+ 16 | try {
+
+ hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow
+
+contract-nothrow-handlers.ts:23:34 - error SC7001: 'rethrows' is declared NoThrow but may throw: rethrow at contract-nothrow-handlers.ts:27:26
+
+ 22 |
+ 23 | function rethrows(text: string): NoThrow {
+ | ^~~~~~~~~~~~~~~
+ 24 | try {
+
+ hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow
\ No newline at end of file
diff --git a/tests/harness/__snapshots__/contract-nothrow.ts.txt b/tests/harness/__snapshots__/contract-nothrow.ts.txt
new file mode 100644
index 000000000..698934c27
--- /dev/null
+++ b/tests/harness/__snapshots__/contract-nothrow.ts.txt
@@ -0,0 +1,35 @@
+contract-nothrow.ts:11:35 - error SC7001: 'sumDigits' is declared NoThrow but may throw: calls 'parseDigit' at contract-nothrow.ts:13:48 → throw statement at contract-nothrow.ts:7:29
+
+ 10 |
+ 11 | function sumDigits(text: string): NoThrow {
+ | ^~~~~~~~~~~~~~~
+ 12 | let sum = 0;
+
+ hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow
+
+contract-nothrow.ts:17:28 - error SC7001: 'depth' is declared NoThrow but may throw: can recurse (directly or through callbacks), so the stack guard may throw RangeError at contract-nothrow.ts:17:1
+
+ 16 |
+ 17 | function depth(n: number): NoThrow {
+ | ^~~~~~~~~~~~~~~
+ 18 | return n <= 0 ? 0 : 1 + depth(n - 1);
+
+ hint: remove the recursion or remove NoThrow; a recursive function's stack guard throws on entry, outside any try/catch
+
+contract-nothrow.ts:21:54 - error SC7001: 'apply' is declared NoThrow but may throw: calls a function value at contract-nothrow.ts:22:10, and the function value 'the function at contract-nothrow.ts:33:71' may throw → calls 'parseDigit' at contract-nothrow.ts:33:78 → throw statement at contract-nothrow.ts:7:29
+
+ 20 |
+ 21 | function apply(f: (x: number) => number, x: number): NoThrow {
+ | ^~~~~~~~~~~~~~~
+ 22 | return f(x);
+
+ hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow
+
+contract-nothrow.ts:28:31 - error SC7001: 'width' is declared NoThrow but may throw: throws TypeError: Cannot read properties of undefined (reading 'length') at contract-nothrow.ts:29:10 (parameter 'text' is compiled as string | undefined: the argument at contract-nothrow.ts:34:19 can be missing)
+
+ 27 | // parameter is compiled as `string | undefined` and the read can throw.
+ 28 | function width(text: string): NoThrow {
+ | ^~~~~~~~~~~~~~~
+ 29 | return text.length;
+
+ hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow
\ No newline at end of file
diff --git a/tests/harness/__snapshots__/contract-positions.ts.txt b/tests/harness/__snapshots__/contract-positions.ts.txt
new file mode 100644
index 000000000..d43cd07e3
--- /dev/null
+++ b/tests/harness/__snapshots__/contract-positions.ts.txt
@@ -0,0 +1,53 @@
+contract-positions.ts:6:15 - error SC7006: the Int32 contract is not checked in this position (Int32 is checked on parameters, variables, class instance fields and function results)
+
+ 5 |
+ 6 | type NodeId = Int32;
+ | ^~~~~
+ 7 |
+
+ hint: write Int32 directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed
+
+contract-positions.ts:9:6 - error SC7006: the Int32 contract is not checked in this position (Int32 is checked on parameters, variables, class instance fields and function results)
+
+ 8 | interface Point {
+ 9 | x: Int32;
+ | ^~~~~
+ 10 | }
+
+ hint: write Int32 directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed
+
+contract-positions.ts:12:12 - error SC7006: the Int32 contract is not checked in this position (Int32 is checked on parameters, variables, class instance fields and function results)
+
+ 11 |
+ 12 | const ids: Int32[] = [1, 2];
+ | ^~~~~
+ 13 | const point: Point = { x: 1 };
+
+ hint: write Int32 directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed
+
+contract-positions.ts:15:32 - error SC7006: the NoThrow contract is not checked in this position (NoThrow wraps a function's written return type)
+
+ 14 | const id: NodeId = 3;
+ 15 | const callback: (x: number) => NoThrow = (x) => x;
+ | ^~~~~~~~~~~~~~~
+ 16 | const table: Map> = new Map();
+
+ hint: write it as the function's return type annotation: function f(): NoThrow
+
+contract-positions.ts:16:26 - error SC7006: the Immutable contract is not checked in this position (Immutable is checked on parameters, variables, class instance fields and function results)
+
+ 15 | const callback: (x: number) => NoThrow = (x) => x;
+ 16 | const table: Map> = new Map();
+ | ^~~~~~~~~~~~~~~~~~~
+ 17 | let last: NoThrow = 0;
+
+ hint: write Immutable directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed
+
+contract-positions.ts:17:11 - error SC7006: the NoThrow contract is not checked in this position (NoThrow wraps a function's written return type)
+
+ 16 | const table: Map> = new Map();
+ 17 | let last: NoThrow = 0;
+ | ^~~~~~~~~~~~~~~
+ 18 |
+
+ hint: write it as the function's return type annotation: function f(): NoThrow
\ No newline at end of file