mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-26 06:34:20 +08:00
Read SCR_COLD as an attribute in the runtime ABI guard
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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/**",
|
||||
|
||||
@@ -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<T></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<T></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<T></code></dt>
|
||||
<dd>The written return type of a function, method or arrow function. <code>NoThrow<Int32></code> and <code>NoThrow<Immutable<T>></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<T></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<...></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>
|
||||
@@ -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.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"ffi",
|
||||
"native-objects",
|
||||
"threads",
|
||||
"contracts",
|
||||
"wasm",
|
||||
"platforms",
|
||||
"---Reference---",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
`}`,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4630-contract-int32.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4631-contract-nothrow.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4632-contract-immutable.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4640-bitwise-identity-ranges.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4641-nothrow-try-catch.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4642-acyclic-contract.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/string-char-code-integer-loops.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/contract-acyclic-positions.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/contract-acyclic.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/contract-immutable.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/contract-int32-escapes.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/contract-int32.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/contract-nothrow-handlers.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/contract-nothrow.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/contract-positions.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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.
|
||||
Vendored
+56
@@ -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 {}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+5
@@ -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':
|
||||
|
||||
@@ -31,6 +31,7 @@ for (const pkg of [
|
||||
"runtime-android-arm64",
|
||||
"compiler",
|
||||
"threads",
|
||||
"types",
|
||||
"cli-darwin-arm64",
|
||||
"cli-darwin-x64",
|
||||
"cli-linux-x64-gnu",
|
||||
|
||||
@@ -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());
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
@@ -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"));
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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"));
|
||||
@@ -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]!));
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user