Read SCR_COLD as an attribute in the runtime ABI guard

This commit is contained in:
Chris Tate
2026-10-12 09:21:23 -05:00
77 changed files with 5589 additions and 104 deletions
+2 -1
View File
@@ -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"
+1
View File
@@ -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/**",
+353
View File
@@ -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.
<table>
<thead>
<tr>
<th>Contract</th>
<th>Promise</th>
<th>What scriptc does with it</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>NoThrow&lt;T&gt;</code></td>
<td>The function never throws.</td>
<td>Callers need no exception check after the call.</td>
</tr>
<tr>
<td><code>Int32</code></td>
<td>Every value stored is a signed 32-bit integer by construction.</td>
<td>The variable, field, parameter or result is stored as a 32-bit integer.</td>
</tr>
<tr>
<td><code>Immutable&lt;T&gt;</code></td>
<td>No code writes to the value, through any reference.</td>
<td>A module constant built from literals becomes immortal data: never reference counted, never traced by the collector.</td>
</tr>
<tr>
<td><code>Acyclic</code></td>
<td>No chain of references leads from an object of the class or type back to itself.</td>
<td>Its objects carry no cycle-collector header and are freed by reference counting alone.</td>
</tr>
</tbody>
</table>
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<Map<string, number>> = 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<boolean> {
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.
<dl>
<dt><code>NoThrow&lt;T&gt;</code></dt>
<dd>The written return type of a function, method or arrow function. <code>NoThrow&lt;Int32&gt;</code> and <code>NoThrow&lt;Immutable&lt;T&gt;&gt;</code> combine the result contracts.</dd>
<dt><code>Int32</code></dt>
<dd>Parameters, variables, class instance fields and function results whose type is a plain <code>number</code>.</dd>
<dt><code>Immutable&lt;T&gt;</code></dt>
<dd>Parameters, variables, class instance fields and function results.</dd>
<dt><code>Acyclic</code></dt>
<dd>Classes (<code>class C implements Acyclic</code>), interfaces (<code>interface I extends Acyclic</code>) and object type aliases (<code>type T = Acyclic&lt;...&gt;</code> over an object type).</dd>
</dl>
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<number> {
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<T>` 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<number[]> = [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<number> {
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<number> {
| ^~~~~~~~~~~~~~~
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.
```
<table>
<thead>
<tr>
<th>Code</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td>SC7001</td>
<td>A <code>NoThrow</code> function may throw; the message shows the path to the throwing operation.</td>
</tr>
<tr>
<td>SC7002</td>
<td>A value stored into an <code>Int32</code> slot is not provably an int32; the hint shows the <code>| 0</code> form.</td>
</tr>
<tr>
<td>SC7003</td>
<td>An <code>Int32</code> slot has writes scriptc cannot see.</td>
</tr>
<tr>
<td>SC7004</td>
<td>A write can reach an <code>Immutable</code> value, or a stored value is not fresh.</td>
</tr>
<tr>
<td>SC7005</td>
<td>An <code>Immutable</code> value escapes to code scriptc cannot check.</td>
</tr>
<tr>
<td>SC7006</td>
<td>A contract is written where scriptc does not check it.</td>
</tr>
<tr>
<td>SC7007</td>
<td>An <code>Acyclic</code> class or type can lie on a reference cycle; the message shows the cycle.</td>
</tr>
</tbody>
</table>
+4
View File
@@ -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.
+1
View File
@@ -10,6 +10,7 @@
"ffi",
"native-objects",
"threads",
"contracts",
"wasm",
"platforms",
"---Reference---",
+1
View File
@@ -8,6 +8,7 @@ export const PAGE_TITLES: Record<string, string> = {
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",
+30
View File
@@ -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",
+1
View File
@@ -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",
@@ -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<IrModule["lib"]>;
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",
},
});
});
+242 -14
View File
@@ -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<string>; unions: Set<string> } {
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:<class>`,
* `record:<id>`, `union:<id>`, or `subtree:<class>` 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:<class>` or `record:<id>`) or
* union (`union:<id>`); 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<string>;
unions: Set<string>;
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<string>; 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<Node, CycleEdgeLabel> | null;
intrinsicField: { field: string; holds: IntrinsicKind } | null;
/** Dependency → whether any edge to it is mutable. */
edges: Map<Node, boolean>;
index: number;
@@ -86,8 +150,11 @@ export function computeTraced(mod: IrModule): { shapes: Set<string>; 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<string>; 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<string, Node>();
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<string, string[]>();
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<string>; 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<string>; 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<string>; 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<string>; 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<string>; 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<Node, Node | null>([[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<Node, { distance: number; via: Node | null }> => {
const seen = new Map<Node, { distance: number; via: Node | null }>([
[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 };
}
+38 -2
View File
@@ -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<typeof findInitializerBindings>;
/** 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<string>;
private numericLocals = new Map<string, IrLocal>();
private captureIds = new Set<string>();
/** 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":
@@ -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 };
}
@@ -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);
});
@@ -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 === ">>>") {
@@ -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",
@@ -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`,
`}`,
],
},
];
+192 -1
View File
@@ -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<IrFunction["generator"]>,
};
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"]);
});
+344 -14
View File
@@ -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<string>;
/** With `explain`: the reason each may-throw function was marked. */
why?: Map<string, MayThrowReason>;
indirect: boolean;
/** Worker programs: the functions that poll for termination on entry. */
workerEntryPolls?: Set<string>;
@@ -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<string, SrcLoc>;
callValueLoc?: SrcLoc;
}
const explain = options.explain === true;
const facts = new Map<string, Facts>();
const closureTargets = new Set<string>();
// 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<string>();
const pending: string[] = [];
const mark = (name: string): void => {
const why = explain ? new Map<string, MayThrowReason>() : 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<string>();
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<IrStmt, boolean>();
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<IrStmt> {
const dead = new Set<IrStmt>();
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
File diff suppressed because it is too large Load Diff
@@ -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");
@@ -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<string> = 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:<id>`, `object:<class>` or
* `union:<id>`; 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<string, ContractNote[]>;
/** Parameters and variables by their name's location. */
bindings: Map<string, ContractNote>;
/** Instance fields by `${class key}\0${field name}`. */
fields: Map<string, ContractNote>;
/** 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 "<anonymous>";
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>([
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<T>"
: `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<C>` 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<Int32> / NoThrow<Immutable<T>>: 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);
}
@@ -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,
@@ -1201,6 +1201,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
) {
return { kind: "object", className: "%Error" };
}
// Immutable<T> (@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<T>` 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<ts.Node>();
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<Config>`, `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
+143 -41
View File
@@ -54,6 +54,14 @@ export class Int32Slots {
readonly provenWrites = new Set<IrExpr>();
/** Disqualified candidates and the first reason, for tests. */
readonly reasons = new Map<string, string>();
/** 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<string>();
/** Int32 contract locals (`function\0localId`) and globals (`\0id`). */
private readonly int32Locals = new Set<string>();
/** 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<string, IrClassDef> = 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<string> {
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<string, string[]>();
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<string>();
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<string>();
@@ -463,6 +466,7 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
if (s.kind === "throw") expose(s.value.type);
},
});
const families = new Set<string>();
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<string, IrClassDef>,
subtree: (className: string) => string[],
): {
valueEscaped: Set<string>;
virtualTargets: Set<string>;
runtimeVirtual: (name: string) => boolean;
} {
const functions = new Map(mod.functions.map((fn) => [fn.name, fn]));
const escaped = new Set<string>();
const virtualTargets = new Set<string>();
const seenObjects = new Set<object>();
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<string, string[]>();
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<string>();
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 => {
+27 -1
View File
@@ -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();
});
@@ -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 => {
+162 -3
View File
@@ -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<BitwiseOp, (left: number, right: number) => 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();
});
+183 -19
View File
@@ -13,6 +13,10 @@ export type IntegerRanges = ReadonlyMap<IrExpr, IntegerRange | null>;
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<string, IntegerRange>;
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<string>();
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;
}
+27
View File
@@ -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:<class>`, `record:<id>`, `union:<id>`): 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 =
+11 -1
View File
@@ -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;
+124
View File
@@ -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));
});
+60
View File
@@ -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<IrExpr | IrStmt>();
const repeated = new Set<IrExpr | IrStmt>();
const affected = new Set<IrFunction>();
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,
),
};
}
+7
View File
@@ -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);
@@ -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";
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4630-contract-int32.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4631-contract-nothrow.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4632-contract-immutable.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4640-bitwise-identity-ranges.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4641-nothrow-try-catch.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4642-acyclic-contract.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/string-char-code-integer-loops.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/contract-acyclic-positions.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/contract-acyclic.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/contract-immutable.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/contract-int32-escapes.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/contract-int32.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/contract-nothrow-handlers.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/contract-nothrow.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/contract-positions.ts"
],
"diags": []
}
+26
View File
@@ -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<Map<string, number>> = new Map([["const", 1]]);
class Token {
pos: Int32 = 0;
}
function isLetter(ch: number): NoThrow<boolean> {
return (ch >= 97 && ch <= 122) || (ch >= 65 && ch <= 90);
}
```
## Contracts
- `NoThrow<T>` 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<T>` 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.
+56
View File
@@ -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> = 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> = T extends (...args: never[]) => unknown
? T
: T extends ReadonlyMap<infer K, infer V>
? ReadonlyMap<Immutable<K>, Immutable<V>>
: T extends ReadonlySet<infer E>
? ReadonlySet<Immutable<E>>
: T extends readonly unknown[]
? { readonly [I in keyof T]: Immutable<T[I]> }
: T extends object
? { readonly [K in keyof T]: Immutable<T[K]> }
: 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 extends object = AcyclicObject> = 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 {}
+26
View File
@@ -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"
}
}
+5
View File
@@ -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':
+1
View File
@@ -31,6 +31,7 @@ for (const pkg of [
"runtime-android-arm64",
"compiler",
"threads",
"types",
"cli-darwin-arm64",
"cli-darwin-x64",
"cli-linux-x64-gnu",
+88
View File
@@ -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());
+63
View File
@@ -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<boolean> {
return ch >= 48 && ch <= 57;
}
function isIdentifierStart(ch: number): NoThrow<boolean> {
return (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch === 95 || ch === 36;
}
function digitValue(ch: number): NoThrow<Int32> {
return isDigit(ch) ? (ch - 48) | 0 : -1;
}
class Scanner {
readonly text: string;
pos: Int32 = 0;
constructor(text: string) {
this.text = text;
}
peek(): NoThrow<number> {
return this.pos < this.text.length ? this.text.charCodeAt(this.pos) : -1;
}
skipSpaces(): NoThrow<void> {
while (this.peek() === 32) this.pos = (this.pos + 1) | 0;
}
scanNumber(): NoThrow<Int32> {
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<string> {
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));
+73
View File
@@ -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<Map<string, Keyword>> = new Map([
["let", new Keyword("let", 1)],
["const", new Keyword("const", 2)],
["function", new Keyword("function", 3)],
]);
const CHAR_CLASSES: Immutable<number[]> = [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<number[]> = buildTable(12);
function lookup(word: string): string {
const k = KEYWORDS.get(word);
return k === undefined ? "none" : k.describe();
}
function classify(codes: Immutable<number[]>, 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<number[]> = squares();
console.log(SQUARES.join(","), classify(SQUARES, 5));
@@ -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);
+99
View File
@@ -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<number> {
try {
return parseDigit(text.charCodeAt(i));
} catch {
return fallback;
}
}
function logged(text: string, stats: Stats): NoThrow<number> {
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<number> {
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<number> {
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<number> {
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"));
+76
View File
@@ -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<string, Slot> = { 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);
@@ -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(""));
@@ -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<Item> {
name = "box";
}
const item: Acyclic<Item> = { name: "a" };
const items: Acyclic<Item>[] = [item];
console.log(item.name, items.length, new Box().name);
+59
View File
@@ -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);
+46
View File
@@ -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<Counter> = new Counter();
const shared = [1, 2, 3];
const VIEW: Immutable<number[]> = shared;
const ROWS: Immutable<number[][]> = [[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<number[]> = fill(-1);
console.log(FILLED.length);
@@ -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);
+36
View File
@@ -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);
@@ -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<number> {
try {
return parseDigit(text.charCodeAt(0));
} finally {
counter.count++;
}
}
function rethrows(text: string): NoThrow<number> {
try {
return parseDigit(text.charCodeAt(0));
} catch (error) {
if (text.length > 1) throw error;
return -1;
}
}
function handled(text: string): NoThrow<number> {
try {
return parseDigit(text.charCodeAt(0));
} catch {
return -1;
}
}
console.log(cleanup("1", new Counter()), rethrows("2"), handled("x"));
+34
View File
@@ -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<number> {
let sum = 0;
for (let i = 0; i < text.length; i++) sum += parseDigit(text[i]!);
return sum;
}
function depth(n: number): NoThrow<number> {
return n <= 0 ? 0 : 1 + depth(n - 1);
}
function apply(f: (x: number) => number, x: number): NoThrow<number> {
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<number> {
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]!));
+19
View File
@@ -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<number> = (x) => x;
const table: Map<string, Immutable<number[]>> = new Map();
let last: NoThrow<number> = 0;
console.log(ids, point.x, id, callback(1), table.size, last);
@@ -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<Item> {
| ^~~~~~~~~~~~~
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<Item> = { name: "a" };
| ^~~~~~~~~~~~~
16 | const items: Acyclic<Item>[] = [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<Item> = { name: "a" };
16 | const items: Acyclic<Item>[] = [item];
| ^~~~~~~~~~~~~
17 |
hint: write `class C implements Acyclic` or `interface I extends Acyclic` (without a type argument), or `type T = Acyclic<{ ... }>`
@@ -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
@@ -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<number[]> = 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<number[]> = 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
@@ -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
@@ -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
@@ -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<number> {
| ^~~~~~~~~~~~~~~
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<number> {
| ^~~~~~~~~~~~~~~
24 | try {
hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow
@@ -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<number> {
| ^~~~~~~~~~~~~~~
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<number> {
| ^~~~~~~~~~~~~~~
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<number> {
| ^~~~~~~~~~~~~~~
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<number> {
| ^~~~~~~~~~~~~~~
29 | return text.length;
hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow
@@ -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<number> = (x) => x;
| ^~~~~~~~~~~~~~~
16 | const table: Map<string, Immutable<number[]>> = new Map();
hint: write it as the function's return type annotation: function f(): NoThrow<T>
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<number> = (x) => x;
16 | const table: Map<string, Immutable<number[]>> = new Map();
| ^~~~~~~~~~~~~~~~~~~
17 | let last: NoThrow<number> = 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<string, Immutable<number[]>> = new Map();
17 | let last: NoThrow<number> = 0;
| ^~~~~~~~~~~~~~~
18 |
hint: write it as the function's return type annotation: function f(): NoThrow<T>