mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-30 07:14:39 +08:00
Merge main into feat/program-lifetime-publication
This commit is contained in:
@@ -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/**",
|
||||
|
||||
@@ -28,6 +28,11 @@ Contract types are type aliases that mean nothing to TypeScript or Node.js but a
|
||||
<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>
|
||||
<tr>
|
||||
<td><code>ProgramLifetime</code></td>
|
||||
<td>Instances of the class are kept until the program exits.</td>
|
||||
@@ -115,6 +120,8 @@ scriptc recognizes a contract where it is written as the annotation of a declara
|
||||
<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>
|
||||
<dt><code>ProgramLifetime</code></dt>
|
||||
<dd>The <code>implements</code> clause of a class declaration: <code>class Node implements ProgramLifetime</code>.</dd>
|
||||
</dl>
|
||||
@@ -125,7 +132,36 @@ A contract anywhere else (a type alias, an interface member, an element or type
|
||||
|
||||
A `NoThrow` function must not contain a path to a throwing operation: a `throw`, a call to a function that may throw, a call through a function value that may hold such a function, an operation that throws in Node.js (such as reading a property of `undefined`), or recursion, whose stack guard can throw `RangeError`. scriptc already computes this for every function to omit exception checks; the contract turns the result into a guarantee and reports the first throwing path when it does not hold.
|
||||
|
||||
In this version a surrounding `try`/`catch` does not discharge the contract, and `async` functions and generators cannot be `NoThrow`.
|
||||
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
|
||||
|
||||
@@ -151,6 +187,77 @@ scriptc refuses an `Int32` slot whose writes it cannot all see (SC7003): a param
|
||||
|
||||
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.
|
||||
```
|
||||
|
||||
## ProgramLifetime
|
||||
|
||||
`ProgramLifetime` marks a class whose instances a program keeps until it exits, such as the nodes of a syntax tree, the symbols of a symbol table or interned types. scriptc then never reference counts, collects or frees instances of the class and of its subclasses: they are never freed, every retain and release on them compiles to nothing, and the cycle collector neither walks them nor needs to trace the objects that point to them. Node.js behavior is unchanged; only memory use differs, because an instance the program drops is never reclaimed.
|
||||
@@ -171,11 +278,13 @@ class Identifier extends SyntaxNode {
|
||||
}
|
||||
```
|
||||
|
||||
scriptc checks that the promise cannot change what the program computes (SC7007):
|
||||
scriptc checks that the promise cannot change what the program computes (SC7008):
|
||||
|
||||
- the class is the root of its hierarchy, so every value of any class type in the hierarchy is program-lifetime; built-in bases such as `Error`, `EventEmitter` and streams are refused because they hold runtime state that is released with the object;
|
||||
- the build is an executable: a library's host decides when the objects it receives are released.
|
||||
|
||||
A class can be both `ProgramLifetime` and `Acyclic`. `Acyclic` is still checked against the references the program writes, so a chain of references that passes through a program-lifetime instance and leads back is still a cycle (SC7007).
|
||||
|
||||
Instances can be published to other threads with `@scriptc/threads` like other class instances: `publish()` makes them immutable and shares them by reference, writes to a published instance throw the same `TypeError` as under Node.js, and instances that were not published stay writable and are cloned by `postMessage` and `workerData`.
|
||||
|
||||
Static code has no `WeakRef` or `FinalizationRegistry`, and a `WeakMap` or `WeakSet` entry whose key is never freed is never observable as removed, so nothing else can tell the difference.
|
||||
@@ -228,7 +337,7 @@ $ scriptc build reject.ts
|
||||
| ^~~~~~~~~~~~~~~
|
||||
15 | return parseDigit(text.charCodeAt(i));
|
||||
|
||||
hint: remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version
|
||||
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'
|
||||
|
||||
@@ -276,6 +385,10 @@ $ scriptc build reject.ts
|
||||
</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>
|
||||
<tr>
|
||||
<td>SC7008</td>
|
||||
<td>A <code>ProgramLifetime</code> class is not the root of its hierarchy, or is part of a library build.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -130,7 +130,7 @@ A program that passes its project's TypeScript check may still use unsupported o
|
||||
|
||||
### Contract types
|
||||
|
||||
[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result; aliases, interface members and nested type positions are reported. A surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances reach dynamic code, and `Immutable` refuses values that reach dynamic code or that another reference can still change. `ProgramLifetime` applies to root classes of executables only; instances the program drops are never reclaimed.
|
||||
[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result, `Acyclic` only on classes, interfaces and object type aliases, and `ProgramLifetime` only in the `implements` clause of a class declaration; 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. `ProgramLifetime` applies to root classes of executables only; instances the program drops are never reclaimed.
|
||||
|
||||
### three.js
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -727,3 +727,62 @@ test("ProgramLifetime hierarchies are leaves: never traced and never closing a c
|
||||
mod.contracts = { programLifetime: ["Node", "Sym"] };
|
||||
check(mod, ["object:Scope"]);
|
||||
});
|
||||
|
||||
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
|
||||
@@ -83,7 +141,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;
|
||||
@@ -93,8 +157,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,
|
||||
@@ -108,8 +175,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}`)) {
|
||||
@@ -119,15 +186,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":
|
||||
@@ -137,6 +215,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":
|
||||
@@ -148,17 +233,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;
|
||||
}
|
||||
};
|
||||
@@ -167,15 +263,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).
|
||||
@@ -249,5 +348,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 };
|
||||
}
|
||||
|
||||
@@ -6583,7 +6583,11 @@ export class LlEmitter {
|
||||
if (integerArrayLoop && s.init?.kind === "varDecl" && s.init.init) {
|
||||
const value = this.emitExpr(s.init.init);
|
||||
start = B.tmp();
|
||||
B.line(`${start} = fptoui double ${value.name} to ${this.sizeType}`);
|
||||
// The start is 0 or an enclosing array index plus one (at most
|
||||
// 2^32 - 1): on 64-bit targets the signed conversion is exact
|
||||
// and avoids x86-64's unsigned 64-bit conversion sequence.
|
||||
const conversion = this.sizeType === "i64" ? "fptosi" : "fptoui";
|
||||
B.line(`${start} = ${conversion} double ${value.name} to ${this.sizeType}`);
|
||||
this.integerArrayBindings.add(integerLoop.localId);
|
||||
}
|
||||
B.line(`store ${this.sizeType} ${start}, ptr ${integerSlot}`);
|
||||
|
||||
@@ -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";
|
||||
@@ -46,8 +51,12 @@ export function emitLiteralExpr(
|
||||
const integer = B.tmp();
|
||||
const number = B.tmp();
|
||||
B.line(`${integer} = load ${integerSlot.type}, ptr ${integerSlot.slot}`);
|
||||
// A 64-bit induction value is a safe integer, so its sign bit is
|
||||
// clear and the signed conversion is exact: one cvtsi2sd instead of
|
||||
// x86-64's multi-instruction unsigned 64-bit conversion.
|
||||
const signedConversion = integerSlot.signed === true || integerSlot.type === "i64";
|
||||
B.line(
|
||||
`${number} = ${integerSlot.signed ? "sitofp" : "uitofp"} ${integerSlot.type} ${integer} to double`,
|
||||
`${number} = ${signedConversion ? "sitofp" : "uitofp"} ${integerSlot.type} ${integer} to double`,
|
||||
);
|
||||
const uint32 = integerSlot.type === "i32" ? integer : B.tmp();
|
||||
if (integerSlot.type !== "i32")
|
||||
@@ -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 };
|
||||
@@ -564,3 +564,156 @@ test("explain names the first throwing operation and each call edge", () => {
|
||||
// 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"]);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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";
|
||||
|
||||
@@ -23,7 +23,16 @@ export type MayThrowReason =
|
||||
* 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; explain?: boolean } = {},
|
||||
@@ -83,8 +92,14 @@ 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.
|
||||
@@ -305,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);
|
||||
@@ -321,6 +336,7 @@ export function computeMayThrow(
|
||||
break;
|
||||
}
|
||||
case "callValue":
|
||||
if (guarded > 0) break;
|
||||
f.callsValue = true;
|
||||
if (explain) f.callValueLoc ??= rec.loc;
|
||||
break;
|
||||
@@ -328,6 +344,12 @@ export function computeMayThrow(
|
||||
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 };
|
||||
@@ -338,7 +360,31 @@ export function computeMayThrow(
|
||||
}
|
||||
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)) {
|
||||
@@ -434,6 +480,145 @@ export function computeMayThrow(
|
||||
};
|
||||
}
|
||||
|
||||
/** 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",
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
* in the whole program:
|
||||
*
|
||||
* - NoThrow<T>: the function is outside the may-throw fixpoint
|
||||
* (backend/may-throw.ts), or the diagnostic names the throwing path.
|
||||
* (backend/may-throw.ts, where handlers discharge what they catch), or
|
||||
* the diagnostic names the throwing path.
|
||||
* - Int32: every value stored into the slot is an int32 by construction,
|
||||
* proven by the same integer range analysis the int32 storage uses,
|
||||
* assuming every Int32 slot holds an int32 (sound by induction: every
|
||||
* write is checked under that assumption).
|
||||
* - Acyclic: the class or object type cannot lie on a reference cycle, by
|
||||
* the cycle analysis that decides tracing (backend/cycle-analysis.ts), or
|
||||
* the diagnostic shows the cycle.
|
||||
* - Immutable<T>: no mutation can reach a value that may come from an
|
||||
* Immutable slot, values stored into Immutable slots are fresh (nothing
|
||||
* else references them), and such values never escape to code the IR
|
||||
@@ -45,6 +49,13 @@ import { findInitializerBindings, withInitializerBindings } from "../ir/initiali
|
||||
import { unshareNodes } from "../ir/unshare.js";
|
||||
import { dynamicallyStoredFamilies, functionCallFacts, Int32Slots } from "../ir/int32-slots.js";
|
||||
import { computeMayThrow, type MayThrowReason } from "../backend/may-throw.js";
|
||||
import {
|
||||
computeTraced,
|
||||
explainCycle,
|
||||
type CycleEdgeLabel,
|
||||
type CycleExplanation,
|
||||
type CycleStep,
|
||||
} from "../backend/cycle-analysis.js";
|
||||
import { SourceLocations } from "../backend/source-locations.js";
|
||||
import {
|
||||
contractLocKey,
|
||||
@@ -239,6 +250,7 @@ class ContractChecker {
|
||||
run(): ContractCheckResult {
|
||||
this.checkProgramLifetime();
|
||||
this.checkNoThrow();
|
||||
this.checkAcyclic();
|
||||
const int32 = new Int32Contracts(this);
|
||||
int32.run();
|
||||
const immutable = new ImmutableContracts(this);
|
||||
@@ -278,7 +290,7 @@ class ContractChecker {
|
||||
const base = this.classes.get(cls.base);
|
||||
const baseName = base?.jsName ?? cls.base.replace(/^%(m\d+\.)?/, "");
|
||||
this.report(
|
||||
"SC7007",
|
||||
"SC7008",
|
||||
note.loc,
|
||||
`'${note.name}' is declared ProgramLifetime but extends '${baseName}'`,
|
||||
base === undefined || base.runtime === true
|
||||
@@ -289,7 +301,7 @@ class ContractChecker {
|
||||
}
|
||||
if (this.mod.lib !== undefined) {
|
||||
this.report(
|
||||
"SC7007",
|
||||
"SC7008",
|
||||
note.loc,
|
||||
`'${note.name}' is declared ProgramLifetime in a library build`,
|
||||
"a library's host decides when the objects it receives are released; ProgramLifetime is checked in executables only",
|
||||
@@ -302,6 +314,192 @@ class ContractChecker {
|
||||
this.record("programLifetime", roots);
|
||||
}
|
||||
|
||||
/* ── Acyclic ── */
|
||||
|
||||
private checkAcyclic(): void {
|
||||
if (this.notes.acyclic.length === 0) return;
|
||||
const traced = computeTraced(this.mod);
|
||||
const proven: string[] = [];
|
||||
// Object types are structural in the IR: name a record by the
|
||||
// interface or alias that declared it Acyclic.
|
||||
for (const { note, unit } of this.notes.acyclic)
|
||||
if (unit != null && unit.startsWith("record:")) this.unitNames.set(unit, note.name);
|
||||
for (const { note, classKey, unit } of this.notes.acyclic) {
|
||||
// A class may lower to several IR classes (generic instantiations);
|
||||
// an unused type has no unit and no values, so it holds trivially.
|
||||
const units =
|
||||
classKey !== undefined
|
||||
? this.classesAt(classKey).map((cls) => `object:${cls.name}`)
|
||||
: unit != null
|
||||
? [unit]
|
||||
: [];
|
||||
for (const key of units) {
|
||||
const isTraced = key.startsWith("union:")
|
||||
? traced.unions.has(key.slice("union:".length))
|
||||
: traced.shapes.has(key);
|
||||
if (!isTraced) {
|
||||
proven.push(key);
|
||||
continue;
|
||||
}
|
||||
const why = explainCycle(this.mod, key);
|
||||
this.report(
|
||||
"SC7007",
|
||||
note.loc,
|
||||
`'${note.name}' is declared Acyclic but can lie on a reference cycle: ${why ? this.cycleText(key, why) : "the cycle analysis traces it"}`,
|
||||
why ? this.cycleHint(why) : "remove Acyclic",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.record("acyclic", proven);
|
||||
}
|
||||
|
||||
private readonly unitNames = new Map<string, string>();
|
||||
|
||||
/** A unit key's spelling: a class name, a record's declared name or
|
||||
* `{ a, b }`, the arms of a union. */
|
||||
unitText(key: string): string {
|
||||
const named = this.unitNames.get(key);
|
||||
if (named !== undefined) return named;
|
||||
const colon = key.indexOf(":");
|
||||
const kind = key.slice(0, colon),
|
||||
id = key.slice(colon + 1);
|
||||
if (kind === "object" || kind === "subtree")
|
||||
return this.typeText({ kind: "object", className: id });
|
||||
if (kind === "union") return this.typeText({ kind: "union", unionId: id });
|
||||
const record = this.mod.records?.find((r) => r.id === id);
|
||||
return record ? `{ ${record.fields.map((f) => f.name).join(", ")} }` : "an object";
|
||||
}
|
||||
|
||||
private fieldText(field: string): string {
|
||||
if (field === "%dynProperties") return "(dynamic properties)";
|
||||
if (field === "<listeners>") return "(event listeners)";
|
||||
if (field === "<class>") return "(class object)";
|
||||
if (field === "<overflow>") return "[key]";
|
||||
return field;
|
||||
}
|
||||
|
||||
/** The path as `A.f → B.g → A`, folding union and subclass hops into the
|
||||
* field that reaches them. */
|
||||
private stepsText(steps: readonly CycleStep[], end: string): string {
|
||||
const parts: string[] = [];
|
||||
// A slot of class type C holding a subclass D reads `D (as a C)`.
|
||||
let asBase: string | null = null;
|
||||
const unit = (key: string): string => {
|
||||
const text = this.unitText(key);
|
||||
const suffix = asBase !== null && asBase !== text ? ` (as a ${asBase})` : "";
|
||||
asBase = null;
|
||||
return `${text}${suffix}`;
|
||||
};
|
||||
for (const step of steps) {
|
||||
if (step.label.kind === "subclass") {
|
||||
asBase ??= this.unitText(step.from);
|
||||
continue;
|
||||
}
|
||||
if (step.label.kind === "field")
|
||||
parts.push(`${unit(step.from)}.${this.fieldText(step.label.field)}`);
|
||||
}
|
||||
parts.push(unit(end));
|
||||
return parts.join(" → ");
|
||||
}
|
||||
|
||||
private cycleText(key: string, why: CycleExplanation): string {
|
||||
switch (why.kind) {
|
||||
case "hierarchy":
|
||||
return `its subclass '${this.unitText(why.member)}' shares its object layout, and ${this.cycleText(why.member, why.because)}`;
|
||||
case "intrinsic": {
|
||||
const holds =
|
||||
why.holds === "func"
|
||||
? "a function"
|
||||
: why.holds === "dyn"
|
||||
? "a dynamic value"
|
||||
: why.holds === "caught"
|
||||
? "a caught exception"
|
||||
: why.holds === "promise"
|
||||
? "a promise"
|
||||
: "a class object";
|
||||
const field =
|
||||
why.field === "%dynProperties"
|
||||
? `${this.unitText(why.owner)} instances reach dynamic code, whose properties`
|
||||
: why.field === ""
|
||||
? this.unitText(why.owner)
|
||||
: `${this.unitText(why.owner)}.${this.fieldText(why.field)}`;
|
||||
const path = why.steps.length > 0 ? `${this.stepsText(why.steps, why.owner)}, and ` : "";
|
||||
return `${path}${field} can hold ${holds}, which can reference any object, including a ${this.unitText(key)}`;
|
||||
}
|
||||
case "cycle": {
|
||||
const mutable = why.steps.find(
|
||||
(s): s is CycleStep & { label: CycleEdgeLabel & { kind: "field" } } =>
|
||||
s.label.kind === "field" && (s.label.mutable || s.label.via !== undefined),
|
||||
);
|
||||
let reason = "";
|
||||
if (mutable) {
|
||||
const where = `${this.unitText(mutable.from)}.${this.fieldText(mutable.label.field)}`;
|
||||
if (mutable.label.via !== undefined)
|
||||
reason = ` (${where} is ${mutable.label.via === "array" ? "an array" : `a ${mutable.label.via === "map" ? "Map" : "Set"}`}, whose contents can change)`;
|
||||
else {
|
||||
const write = this.writeOutsideConstructor(mutable.from, mutable.label.field);
|
||||
reason = ` (${where} can be assigned after construction${write !== null ? `, as at ${this.where(write)}` : ""})`;
|
||||
}
|
||||
}
|
||||
return `${this.stepsText(why.steps, key)}${reason}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private cycleHint(why: CycleExplanation): string {
|
||||
const inner = why.kind === "hierarchy" ? why.because : why;
|
||||
if (inner.kind === "intrinsic")
|
||||
return "a function, dynamic or promise field can capture the object itself; store plain data instead, or remove Acyclic";
|
||||
if (
|
||||
inner.kind === "cycle" &&
|
||||
!inner.steps.some(
|
||||
(s) => s.label.kind === "field" && s.label.mutable && s.label.via === undefined,
|
||||
)
|
||||
)
|
||||
return "an array, Map or Set can come to hold an object that refers back; make the element type unable to reach this type, or remove Acyclic";
|
||||
return "assign the reference only in the constructor (before `this` escapes), so a new object can only point to older ones, or remove Acyclic";
|
||||
}
|
||||
|
||||
/** The first store into the field outside its own class's constructors. */
|
||||
private writeOutsideConstructor(unit: string, field: string): SrcLoc | null {
|
||||
const colon = unit.indexOf(":");
|
||||
const kind = unit.slice(0, colon),
|
||||
id = unit.slice(colon + 1);
|
||||
let found: SrcLoc | null = null;
|
||||
for (const fn of this.mod.functions) {
|
||||
if (found !== null) break;
|
||||
const ctor = /^%(.*)\.constructor$/.exec(fn.name)?.[1];
|
||||
const self = ctor !== undefined ? fn.params[0]?.localId : undefined;
|
||||
walk(fn.body, {
|
||||
stmt: (s) => {
|
||||
if (found !== null) return;
|
||||
if (kind === "record" && s.kind === "recordSet" && s.shapeId === id && s.field === field)
|
||||
found = s.loc;
|
||||
else if (
|
||||
kind !== "record" &&
|
||||
s.kind === "fieldSet" &&
|
||||
s.field === field &&
|
||||
this.sameHierarchy(s.className, id) &&
|
||||
!(self !== undefined && s.obj.kind === "varRef" && s.obj.localId === self)
|
||||
)
|
||||
found = s.loc;
|
||||
},
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private sameHierarchy(a: string, b: string): boolean {
|
||||
const root = (name: string): string => {
|
||||
let cur = name;
|
||||
for (let cls = this.classes.get(cur); cls?.base !== undefined; cls = this.classes.get(cur))
|
||||
cur = cls.base;
|
||||
return cur;
|
||||
};
|
||||
return root(a) === root(b);
|
||||
}
|
||||
|
||||
/* ── NoThrow ── */
|
||||
|
||||
private checkNoThrow(): void {
|
||||
@@ -330,6 +528,8 @@ class ContractChecker {
|
||||
continue;
|
||||
}
|
||||
const steps: string[] = [];
|
||||
// The function's own stack guard throws on entry, outside any handler.
|
||||
const ownStackGuard = mt.stackChecks?.has(fn.name) === true;
|
||||
const seen = new Set<string>();
|
||||
let current: string | undefined = fn.name;
|
||||
while (current !== undefined && !seen.has(current) && steps.length < 8) {
|
||||
@@ -355,7 +555,11 @@ class ContractChecker {
|
||||
"SC7001",
|
||||
note.loc,
|
||||
`'${note.name}' is declared NoThrow but may throw: ${steps.join(" → ")}`,
|
||||
"remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version",
|
||||
ownStackGuard
|
||||
? "remove the recursion or remove NoThrow; a recursive function's stack guard throws on entry, outside any try/catch"
|
||||
: this.mod.workers === true
|
||||
? "remove the throwing operation from the path (or the recursion), or remove NoThrow; in worker programs try/catch does not discharge the contract, since a termination request unwinds through catch"
|
||||
: "remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow",
|
||||
);
|
||||
}
|
||||
this.record("noThrow", proven);
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* 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, and the ProgramLifetime class marker in
|
||||
* a class declaration's implements clause. The IR-side checks live in
|
||||
* variable and class field types, and the class markers (Acyclic,
|
||||
* ProgramLifetime) in heritage clauses. The IR-side checks live in
|
||||
* ir/contract-check.ts. */
|
||||
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
|
||||
import type { SrcLoc } from "../ir/ir.js";
|
||||
@@ -14,8 +14,14 @@ import * as ts from "./ts7/adapter.js";
|
||||
|
||||
export const CONTRACT_TYPES_MODULE = "@scriptc/types";
|
||||
|
||||
export type ContractMarker = "Int32" | "NoThrow" | "Immutable" | "ProgramLifetime";
|
||||
const MARKERS: ReadonlySet<string> = new Set(["Int32", "NoThrow", "Immutable", "ProgramLifetime"]);
|
||||
export type ContractMarker = "Int32" | "NoThrow" | "Immutable" | "Acyclic" | "ProgramLifetime";
|
||||
const MARKERS: ReadonlySet<string> = new Set([
|
||||
"Int32",
|
||||
"NoThrow",
|
||||
"Immutable",
|
||||
"Acyclic",
|
||||
"ProgramLifetime",
|
||||
]);
|
||||
|
||||
/** One written contract: what it promises and where the annotation is. */
|
||||
export interface ContractNote {
|
||||
@@ -26,6 +32,21 @@ export interface ContractNote {
|
||||
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
|
||||
@@ -37,7 +58,9 @@ export interface ContractAnnotations {
|
||||
bindings: Map<string, ContractNote>;
|
||||
/** Instance fields by `${class key}\0${field name}`. */
|
||||
fields: Map<string, ContractNote>;
|
||||
/** Class markers (`implements ProgramLifetime`) by class key. */
|
||||
/** Acyclic classes and object types. */
|
||||
acyclic: AcyclicNote[];
|
||||
/** ProgramLifetime classes (`implements ProgramLifetime`) by class key. */
|
||||
classes: Map<string, ContractNote>;
|
||||
diagnostics: ScrDiagnostic[];
|
||||
/** Number of annotations found (for timing and tests). */
|
||||
@@ -52,40 +75,44 @@ export function contractLocKey(loc: SrcLoc): string {
|
||||
* 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 {
|
||||
return markerOfName(checker, ref.typeName);
|
||||
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;
|
||||
}
|
||||
|
||||
/** markerOf for a written name: `Marker`, `sc.Marker` as a type name
|
||||
* (QualifiedName) or as a heritage expression (PropertyAccessExpression,
|
||||
/** The marker a heritage clause names (`implements Acyclic`,
|
||||
* `implements sc.ProgramLifetime`). */
|
||||
function markerOfName(checker: ts.TypeChecker, name: ts.Node): ContractMarker | null {
|
||||
let ident: ts.Identifier;
|
||||
let member: string;
|
||||
if (ts.isIdentifier(name)) {
|
||||
if (!MARKERS.has(name.text)) return null;
|
||||
ident = name;
|
||||
member = name.text;
|
||||
} else if (ts.isQualifiedName(name) && ts.isIdentifier(name.left)) {
|
||||
if (!MARKERS.has(name.right.text)) return null;
|
||||
ident = name.left;
|
||||
member = name.right.text;
|
||||
} else if (
|
||||
ts.isPropertyAccessExpression(name) &&
|
||||
ts.isIdentifier(name.expression) &&
|
||||
ts.isIdentifier(name.name)
|
||||
) {
|
||||
if (!MARKERS.has(name.name.text)) return null;
|
||||
ident = name.expression;
|
||||
member = name.name.text;
|
||||
} else return null;
|
||||
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) && ident === name) {
|
||||
if (ts.isImportSpecifier(decl) && !qualified) {
|
||||
importDecl = decl.parent?.parent?.parent;
|
||||
member = decl.propertyName?.text ?? decl.name.text;
|
||||
} else if (ts.isNamespaceImport(decl) && ident !== name) importDecl = decl.parent?.parent;
|
||||
} else if (ts.isNamespaceImport(decl) && qualified) importDecl = decl.parent?.parent;
|
||||
else return null;
|
||||
if (
|
||||
importDecl === undefined ||
|
||||
@@ -118,11 +145,13 @@ function contractDiag(
|
||||
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: [],
|
||||
classes: new Map(),
|
||||
diagnostics: [],
|
||||
count: 0,
|
||||
@@ -137,17 +166,19 @@ export function collectContractAnnotations(
|
||||
sf,
|
||||
(node) => {
|
||||
if (ts.isExpressionWithTypeArguments(node)) {
|
||||
const marker = markerOfName(checker, node.expression);
|
||||
const marker = markerOfHeritage(checker, node);
|
||||
if (marker === null) return;
|
||||
out.count++;
|
||||
placeHeritage(out, node, marker);
|
||||
placeHeritage(out, checker, node, marker, resolveUnit);
|
||||
return;
|
||||
}
|
||||
if (!ts.isTypeReferenceNode(node)) return;
|
||||
const marker = markerOf(checker, node);
|
||||
if (marker === null) return;
|
||||
out.count++;
|
||||
place(out, checker, node, marker);
|
||||
if (marker === "Acyclic") placeAcyclicAlias(out, checker, node, resolveUnit);
|
||||
else if (marker === "ProgramLifetime") unsupported(out, node, marker);
|
||||
else place(out, checker, node, marker);
|
||||
},
|
||||
kinds,
|
||||
);
|
||||
@@ -156,6 +187,17 @@ export function collectContractAnnotations(
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -178,28 +220,74 @@ function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMa
|
||||
);
|
||||
}
|
||||
|
||||
/** A marker written in a heritage clause: only `class C implements
|
||||
* ProgramLifetime` is a contract position. */
|
||||
/** The heritage positions of the class markers: `class C implements
|
||||
* Acyclic`, `interface I extends Acyclic` and `class C implements
|
||||
* ProgramLifetime` (a class declaration only). */
|
||||
function placeHeritage(
|
||||
out: ContractAnnotations,
|
||||
checker: ts.TypeChecker,
|
||||
node: ts.ExpressionWithTypeArguments,
|
||||
marker: ContractMarker,
|
||||
resolveUnit: ContractUnitResolver,
|
||||
): void {
|
||||
const clause = node.parent;
|
||||
const cls = clause?.parent;
|
||||
const owner = clause?.parent;
|
||||
if (marker === "ProgramLifetime") {
|
||||
if (
|
||||
clause === undefined ||
|
||||
!ts.isHeritageClause(clause) ||
|
||||
clause.token !== ts.SyntaxKind.ImplementsKeyword ||
|
||||
owner === undefined ||
|
||||
!ts.isClassDeclaration(owner)
|
||||
)
|
||||
return unsupported(out, node, marker);
|
||||
out.classes.set(contractLocKey(locOf(owner)), {
|
||||
marker,
|
||||
loc: locOf(node),
|
||||
name: nameText(owner.name),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
marker !== "ProgramLifetime" ||
|
||||
marker !== "Acyclic" ||
|
||||
clause === undefined ||
|
||||
!ts.isHeritageClause(clause) ||
|
||||
clause.token !== ts.SyntaxKind.ImplementsKeyword ||
|
||||
cls === undefined ||
|
||||
!ts.isClassDeclaration(cls)
|
||||
owner === undefined
|
||||
)
|
||||
return unsupported(out, node, marker);
|
||||
out.classes.set(contractLocKey(locOf(cls)), {
|
||||
marker,
|
||||
loc: locOf(node),
|
||||
name: nameText(cls.name),
|
||||
// `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)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,7 +297,6 @@ function place(
|
||||
ref: ts.TypeReferenceNode,
|
||||
marker: ContractMarker,
|
||||
): void {
|
||||
if (marker === "ProgramLifetime") return unsupported(out, ref, marker);
|
||||
let node: ts.Node = ref;
|
||||
let parent = ref.parent;
|
||||
// NoThrow<Int32> / NoThrow<Immutable<T>>: the inner marker is the result
|
||||
|
||||
@@ -1045,7 +1045,27 @@ export function lowerToIr(
|
||||
if (divergences.length > 0) result = { ...result, divergences };
|
||||
// Contract types (@scriptc/types): written annotations, checked against
|
||||
// the IR by the compile pipeline (contracts/check.ts).
|
||||
const contracts = collectContractAnnotations(program.getTypeChecker(), moduleOrder);
|
||||
// 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 = {
|
||||
|
||||
@@ -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,7 +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;
|
||||
const STRING_LENGTH_LIMIT = 2 ** 30;
|
||||
/** 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 {
|
||||
@@ -63,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
|
||||
@@ -345,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;
|
||||
}
|
||||
@@ -412,16 +533,6 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "strIntrinsic": {
|
||||
// Node's strings hold at most 2^29 - 24 code units (V8's limit).
|
||||
expr(e.receiver, facts);
|
||||
for (const arg of e.args) expr(arg, facts);
|
||||
if (e.type.kind === "f64") {
|
||||
if (e.method === "length") range = { min: 0, max: STRING_LENGTH_LIMIT };
|
||||
else if (e.method === "indexOf") range = { min: -1, max: STRING_LENGTH_LIMIT };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "bytesIntrinsic": {
|
||||
expr(e.receiver, facts);
|
||||
for (const arg of e.args) expr(arg, facts);
|
||||
@@ -456,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);
|
||||
|
||||
@@ -1002,6 +1002,10 @@ export interface IrContracts {
|
||||
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. */
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { compile, deserializeModule } from "../src/index.js";
|
||||
import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
|
||||
|
||||
test("array index counters convert to and from numbers with signed conversions on 64-bit targets", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-integer-induction-"));
|
||||
try {
|
||||
const entry = join(dir, "main.ts");
|
||||
const outPath = join(dir, "main.ir.json");
|
||||
const source = `
|
||||
function weigh(values: number[]): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
for (let j = i + 1; j < values.length; j++) total += (i / 2) * values[j]! + j;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
console.log(weigh([1, 2, 3, 4]));
|
||||
`;
|
||||
await writeFile(entry, source);
|
||||
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
const mod = deserializeModule(await readFile(outPath, "utf8"));
|
||||
const body = (ll: string): string =>
|
||||
ll.match(/define internal [^\n]+ @sc_(?:b)?f_weigh\([^\n]*\) #0 \{([\s\S]*?)\n\}/)![1]!;
|
||||
|
||||
const wide = body(emitLlvmModule(mod));
|
||||
expect(wide).toContain("integer induction");
|
||||
// Both counters are read as numbers (i / 2, + j): safe integers, so the
|
||||
// signed conversion is exact; the nested start converts back the same way.
|
||||
expect(wide).toMatch(/ = sitofp i64 %\w+ to double/);
|
||||
expect(wide).not.toMatch(/ = uitofp i64 /);
|
||||
expect(wide).toMatch(/ = fptosi double %\w+ to i64/);
|
||||
expect(wide).not.toMatch(/ = fptoui double %\w+ to i64/);
|
||||
|
||||
// 32-bit counters reach 2^32 - 2, beyond the signed i32 range.
|
||||
const narrow = body(emitLlvmModule(mod, { pointerBits: 32, wasi: true }));
|
||||
expect(narrow).toMatch(/ = uitofp i32 %\w+ to double/);
|
||||
expect(narrow).toMatch(/ = fptoui double %\w+ to i32/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -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/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/4720-cold-throw-paths.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4721-integer-induction-numbers.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-nothrow-handlers.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
#define SCR_ARR_MAX_LENGTH ((size_t)UINT32_MAX)
|
||||
#define SCR_ARR_MAX_INDEX (SCR_ARR_MAX_LENGTH - 1)
|
||||
/* A length as a JS number. Lengths never exceed 2^32 - 1, so the signed
|
||||
* conversion is exact; x86-64 has no single instruction for the unsigned
|
||||
* 64-bit one that a plain size_t cast compiles to. */
|
||||
#define SCR_ARR_LEN_F64(n) ((double)(int64_t)(n))
|
||||
/* Keep the packed representation proportional to the populated elements.
|
||||
* Indices below this cutoff always grow dense storage geometrically. Above
|
||||
* it, dense storage grows only when the new range stays populated: a
|
||||
@@ -62,15 +66,16 @@ static void scr_arr_trap_oob(double i, size_t len) {
|
||||
static size_t scr_arr_check_index(const ScrArr *a, double i, bool allow_append) {
|
||||
const double max_index = (double)SCR_ARR_MAX_INDEX;
|
||||
if (!(i >= 0) || i != trunc(i) || i > max_index ||
|
||||
(!allow_append && i >= (double)a->len)) {
|
||||
(!allow_append && i >= SCR_ARR_LEN_F64(a->len))) {
|
||||
scr_arr_trap_oob(i, a->len);
|
||||
}
|
||||
return (size_t)i;
|
||||
/* 0 <= i < 2^32: the signed conversion is exact and a single instruction. */
|
||||
return (size_t)(int64_t)i;
|
||||
}
|
||||
|
||||
static bool scr_arr_valid_index(double i, size_t *out) {
|
||||
if (!(i >= 0) || i != trunc(i) || i > (double)SCR_ARR_MAX_INDEX) return false;
|
||||
*out = (size_t)i;
|
||||
*out = (size_t)(int64_t)i;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -819,7 +824,7 @@ void scr_arr_release(ScrArr *a) {
|
||||
}
|
||||
}
|
||||
|
||||
double scr_arr_len(ScrArr *a) { return (double)a->len; }
|
||||
double scr_arr_len(ScrArr *a) { return SCR_ARR_LEN_F64(a->len); }
|
||||
|
||||
bool scr_arr_has(const ScrArr *a, double i) {
|
||||
size_t idx;
|
||||
@@ -841,7 +846,7 @@ double scr_arr_state(const ScrArr *a, double i) {
|
||||
}
|
||||
|
||||
double scr_arr_next_present(const ScrArr *a, double start) {
|
||||
if (!(start < (double)a->len)) return (double)a->len;
|
||||
if (!(start < SCR_ARR_LEN_F64(a->len))) return SCR_ARR_LEN_F64(a->len);
|
||||
size_t i = start > 0 ? (size_t)ceil(start) : 0;
|
||||
size_t end = a->cap < a->len ? a->cap : a->len;
|
||||
for (; i < end; i++) {
|
||||
@@ -851,7 +856,7 @@ double scr_arr_next_present(const ScrArr *a, double start) {
|
||||
if (pos < SCR_AX(a)->sparse_len && SCR_AX(a)->sparse[pos].index < a->len) {
|
||||
return (double)SCR_AX(a)->sparse[pos].index;
|
||||
}
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
|
||||
static void scr_arr_invalid_length(double length) {
|
||||
@@ -1271,10 +1276,13 @@ bool scr_arr_delete(ScrArr *a, double i) {
|
||||
/* ── push / pop ────────────────────────────────────────────────────────── */
|
||||
|
||||
static double scr_arr_push_slot(ScrArr *a, uint64_t slot) {
|
||||
size_t idx = scr_arr_check_index(a, (double)a->len, true);
|
||||
/* scr_arr_check_index(a, len, true) without the double round trip: the
|
||||
* length is an integer, so only the upper bound can fail. */
|
||||
if (a->len > SCR_ARR_MAX_INDEX) scr_arr_trap_oob(SCR_ARR_LEN_F64(a->len), a->len);
|
||||
size_t idx = a->len;
|
||||
scr_arr_store_owned(a, idx, slot);
|
||||
a->len++;
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
|
||||
double scr_arr_push_f64(ScrArr *a, double v) {
|
||||
@@ -1314,7 +1322,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) {
|
||||
scr_arr_grow_dense(a, old_len + add);
|
||||
scr_arr_copy_dense(a, old_len, src, 0, add, false, true);
|
||||
a->len += add;
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
if (src == a) {
|
||||
ScrArrStorage old = scr_arr_take_storage(a);
|
||||
@@ -1331,7 +1339,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) {
|
||||
}
|
||||
a->len = old_len + add;
|
||||
scr_arr_free_storage(&old);
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
a->len = old_len + add;
|
||||
for (size_t i = 0; i < add; i++) {
|
||||
@@ -1343,7 +1351,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) {
|
||||
scr_arr_store_state_owned(a, old_len + i, 0, SCR_ARR_UNDEFINED);
|
||||
}
|
||||
}
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
|
||||
double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) {
|
||||
@@ -1355,7 +1363,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) {
|
||||
scr_arr_grow_dense(a, old_len + add);
|
||||
scr_arr_copy_dense(a, old_len, src, 0, add, false, false);
|
||||
a->len += add;
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
if (src == a) {
|
||||
ScrArrStorage old = scr_arr_take_storage(a);
|
||||
@@ -1372,7 +1380,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) {
|
||||
}
|
||||
a->len = old_len + add;
|
||||
scr_arr_free_storage(&old);
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
a->len = old_len + add;
|
||||
for (size_t i = 0; i < src->cap; i++) {
|
||||
@@ -1384,7 +1392,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) {
|
||||
scr_arr_copy_index(a, old_len + SCR_AX(src)->sparse[i].index, src,
|
||||
SCR_AX(src)->sparse[i].index);
|
||||
}
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
|
||||
/* ── unshift / reverse ──────────────────────────────────────────────────
|
||||
@@ -1395,7 +1403,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) {
|
||||
* tail move by reading the relocated original block. */
|
||||
static double scr_arr_unshift_slot(ScrArr *a, uint64_t slot) {
|
||||
if (a->len == SCR_ARR_MAX_LENGTH) {
|
||||
scr_arr_trap_oob((double)a->len, a->len);
|
||||
scr_arr_trap_oob(SCR_ARR_LEN_F64(a->len), a->len);
|
||||
}
|
||||
size_t old_len = a->len;
|
||||
if (scr_arr_is_dense(a) && scr_arr_dense_ok(a, old_len + 1, 1)) {
|
||||
@@ -1404,7 +1412,7 @@ static double scr_arr_unshift_slot(ScrArr *a, uint64_t slot) {
|
||||
a->data[0] = slot;
|
||||
a->present[0] = SCR_ARR_VALUE;
|
||||
a->len++;
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
ScrArrStorage old = scr_arr_take_storage(a);
|
||||
a->len = 0;
|
||||
@@ -1418,7 +1426,7 @@ static double scr_arr_unshift_slot(ScrArr *a, uint64_t slot) {
|
||||
}
|
||||
a->len = old_len + 1;
|
||||
scr_arr_free_storage(&old);
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
|
||||
double scr_arr_unshift_f64(ScrArr *a, double v) {
|
||||
@@ -1456,7 +1464,7 @@ double scr_arr_unshift_many(ScrArr *a, size_t count, const uint64_t *slots) {
|
||||
scr_arr_free_storage(&old);
|
||||
}
|
||||
a->len = old_len + count;
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
|
||||
double scr_arr_unshift_spread(ScrArr *a, const ScrArr *src) {
|
||||
@@ -1471,7 +1479,7 @@ double scr_arr_unshift_spread(ScrArr *a, const ScrArr *src) {
|
||||
/* A self-spread reads the original values at their moved location. */
|
||||
scr_arr_copy_dense(a, 0, src, src == a ? add : 0, add, false, true);
|
||||
a->len += add;
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
ScrArrStorage old = scr_arr_take_storage(a);
|
||||
a->len = 0;
|
||||
@@ -1497,7 +1505,7 @@ double scr_arr_unshift_spread(ScrArr *a, const ScrArr *src) {
|
||||
}
|
||||
a->len = old_len + add;
|
||||
scr_arr_free_storage(&old);
|
||||
return (double)a->len;
|
||||
return SCR_ARR_LEN_F64(a->len);
|
||||
}
|
||||
|
||||
ScrArr *scr_arr_reverse(ScrArr *a) {
|
||||
@@ -1615,7 +1623,7 @@ void *scr_arr_shift_ref(ScrArr *a) { return scr_slot_to_ptr(scr_arr_shift_slot(a
|
||||
* out of the receiver (no retain/release churn); the tail slides down.
|
||||
* Borrows a. */
|
||||
ScrArr *scr_arr_splice(ScrArr *a, double start, double deleteCount) {
|
||||
double len = (double)a->len;
|
||||
double len = SCR_ARR_LEN_F64(a->len);
|
||||
double s0 = isnan(start) ? 0 : trunc(start);
|
||||
if (s0 < 0) s0 += len;
|
||||
size_t from = s0 <= 0 ? 0 : s0 >= len ? a->len : (size_t)s0;
|
||||
@@ -1679,7 +1687,7 @@ ScrArr *scr_arr_splice(ScrArr *a, double start, double deleteCount) {
|
||||
* appending the tail follows indexed copying (holes stay holes). */
|
||||
ScrArr *scr_arr_splice_insert(ScrArr *a, double start, double deleteCount,
|
||||
const ScrArr *items) {
|
||||
double len = (double)a->len;
|
||||
double len = SCR_ARR_LEN_F64(a->len);
|
||||
double s0 = isnan(start) ? 0 : trunc(start);
|
||||
if (s0 < 0) s0 += len;
|
||||
size_t from = s0 <= 0 ? 0 : s0 >= len ? a->len : (size_t)s0;
|
||||
@@ -1728,7 +1736,7 @@ ScrArr *scr_arr_splice_insert(ScrArr *a, double start, double deleteCount,
|
||||
* sparse-sized results keep the general implementation. */
|
||||
void scr_arr_splice_drop(ScrArr *a, double start, double deleteCount, size_t count,
|
||||
const uint64_t *slots) {
|
||||
double len = (double)a->len;
|
||||
double len = SCR_ARR_LEN_F64(a->len);
|
||||
double s0 = isnan(start) ? 0 : trunc(start);
|
||||
if (s0 < 0) s0 += len;
|
||||
size_t from = s0 <= 0 ? 0 : s0 >= len ? a->len : (size_t)s0;
|
||||
@@ -1916,7 +1924,7 @@ static void scr_join_append(ScrStr **out, const char *bytes, size_t n) {
|
||||
* references, exactly JS's shallow copy. Borrows a; returns +1. */
|
||||
ScrArr *scr_arr_slice(ScrArr *a, double start, double end) {
|
||||
/* ToIntegerOrInfinity + relative-index resolution over the LENGTH. */
|
||||
double len = (double)a->len;
|
||||
double len = SCR_ARR_LEN_F64(a->len);
|
||||
double s0 = isnan(start) ? 0 : trunc(start);
|
||||
double e0 = isnan(end) ? 0 : trunc(end);
|
||||
if (s0 < 0) s0 += len;
|
||||
|
||||
@@ -53,6 +53,18 @@ void arc4random_buf(void *buf, size_t n);
|
||||
#define SCR_TL
|
||||
#endif
|
||||
|
||||
/* Entry points that only run when a JS exception is being raised. noinline
|
||||
* keeps the throw machinery out of every caller: the LLVM helper never
|
||||
* imports noinline runtime bodies into program code, so a compiled function
|
||||
* whose only slow path throws stays small enough to inline and needs no
|
||||
* callee-saved registers on its fast path. cold tells the C compiler that
|
||||
* runtime paths reaching them are unlikely. */
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
#define SCR_COLD __attribute__((cold, noinline))
|
||||
#else
|
||||
#define SCR_COLD
|
||||
#endif
|
||||
|
||||
/* ── process ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Called once at the top of main: private stdout formatter buffer,
|
||||
@@ -336,7 +348,7 @@ bool scr_context_is_main(void);
|
||||
* report. Zero (the default) disables the check on that stack. */
|
||||
extern SCR_TL uintptr_t scr_stack_guard;
|
||||
void scr_stack_guard_init(void);
|
||||
void scr_stack_overflow(void);
|
||||
SCR_COLD void scr_stack_overflow(void);
|
||||
#if defined(SCR_WORKERS) && !defined(SCR_LIB) && defined(__APPLE__) && \
|
||||
(defined(__aarch64__) || defined(__x86_64__))
|
||||
/* Darwin worker executables: reading a thread-local variable calls its TLV
|
||||
@@ -797,9 +809,9 @@ void scr_publish_refuse(const char *message);
|
||||
* TypeErrors. scr_throw_published_array answers false (and throws nothing)
|
||||
* when Node's operation would not write the array. */
|
||||
struct ScrArr;
|
||||
void scr_throw_published_field(const char *prop, const char *owner);
|
||||
SCR_COLD void scr_throw_published_field(const char *prop, const char *owner);
|
||||
bool scr_throw_published_array(int op, const struct ScrArr *a, double index, double n);
|
||||
void scr_throw_published_collection(bool set);
|
||||
SCR_COLD void scr_throw_published_collection(bool set);
|
||||
|
||||
/* ── class hierarchies (single inheritance) ───────────────────────────
|
||||
* Classes in an `extends` hierarchy share a two-word object prefix: the
|
||||
@@ -1170,8 +1182,8 @@ ScrError *scr_domex_clone(ScrError *e, const struct ScrDyn *options);
|
||||
/* Throw a fresh DOMException through the exception cell (atob/btoa's
|
||||
* InvalidCharacterError sites). Copies both C strings; the _str form
|
||||
* takes ownership of the message. */
|
||||
void scr_throw_domex(const char *name, const char *message);
|
||||
void scr_throw_domex_str(const char *name, ScrStr *message);
|
||||
SCR_COLD void scr_throw_domex(const char *name, const char *message);
|
||||
SCR_COLD void scr_throw_domex_str(const char *name, ScrStr *message);
|
||||
|
||||
/* Switch runtime-side error allocation to collector-headered (called from
|
||||
* main() when the compiler's cycle fixpoint marks the hierarchy). */
|
||||
@@ -1179,7 +1191,7 @@ void scr_error_set_traced(void);
|
||||
|
||||
/* Allocate + initialize (name = the kind's builtin name, message retained
|
||||
* from the borrowed argument; NULL means ""). Returns +1. */
|
||||
ScrError *scr_error_new(int kind, ScrStr *message);
|
||||
SCR_COLD ScrError *scr_error_new(int kind, ScrStr *message);
|
||||
/* Initialize the error prefix of an already-allocated (zeroed) object —
|
||||
* the super(message) call of a compiled `extends Error` constructor. Both
|
||||
* arguments are borrowed. */
|
||||
@@ -1219,22 +1231,22 @@ bool scr_error_is(const void *obj);
|
||||
* takes ownership of message; the _msg form copies the C string. The _named
|
||||
* form (island bridge) takes ownership of both strings and picks the
|
||||
* builtin vtable whose class name matches `name` (Error otherwise). */
|
||||
void scr_throw_error(int kind, ScrStr *message);
|
||||
void scr_throw_error_msg(int kind, const char *message, size_t len);
|
||||
void scr_throw_error_named(ScrStr *name, ScrStr *message);
|
||||
SCR_COLD void scr_throw_error(int kind, ScrStr *message);
|
||||
SCR_COLD void scr_throw_error_msg(int kind, const char *message, size_t len);
|
||||
SCR_COLD void scr_throw_error_named(ScrStr *name, ScrStr *message);
|
||||
/* A read of a declare-d const nothing defines: throws Node's catchable
|
||||
* ReferenceError "<name> is not defined". Borrows name; always throws. */
|
||||
void scr_undef_global_read(ScrStr *name);
|
||||
SCR_COLD void scr_undef_global_read(ScrStr *name);
|
||||
/* The `code` slot (NodeJS.ErrnoException's .code): set stamps a fresh
|
||||
* string from the C literal (replacing any previous value); get answers
|
||||
* +1 or NULL when absent (the compiler's undefined arm). The _msg_code
|
||||
* thrower is scr_throw_error_msg with the code stamped on the payload. */
|
||||
void scr_error_set_code(ScrError *e, const char *code);
|
||||
ScrStr *scr_error_code(ScrError *e);
|
||||
void scr_throw_error_msg_code(int kind, const char *message, size_t len, const char *code);
|
||||
SCR_COLD void scr_throw_error_msg_code(int kind, const char *message, size_t len, const char *code);
|
||||
/* The compiler-resolved Node-parity throw (error.nodeThrow): builtin
|
||||
* error of `kind`, `code` stamped when non-empty. Borrows both. */
|
||||
void scr_throw_node_coded(double kind, const ScrStr *code, const ScrStr *msg);
|
||||
SCR_COLD void scr_throw_node_coded(double kind, const ScrStr *code, const ScrStr *msg);
|
||||
|
||||
/* ── string methods ─────────────────────────────────────────────────
|
||||
* ECMA-262 observable semantics over UTF-8 storage with UTF-16 code-unit
|
||||
@@ -3067,23 +3079,23 @@ static inline bool scr_exc_pending_inline(void) { return scr_exc_active->kind !=
|
||||
_Static_assert(offsetof(ScrExcCell, kind) == 0 && sizeof(ScrExcKind) == 4 && SCR_EXC_NONE == 0,
|
||||
"the LLVM emitter's inline pending check loads an i32 kind at offset 0");
|
||||
|
||||
void scr_throw_f64(double v);
|
||||
void scr_throw_bool(bool v);
|
||||
void scr_throw_str(ScrStr *v); /* takes ownership */
|
||||
SCR_COLD void scr_throw_f64(double v);
|
||||
SCR_COLD void scr_throw_bool(bool v);
|
||||
SCR_COLD void scr_throw_str(ScrStr *v); /* takes ownership */
|
||||
/* Takes ownership of v; retain/release must be non-NULL (the compiler
|
||||
* passes the payload type's `_v` adapters, like scr_union_new_ref); trace
|
||||
* is non-NULL iff the payload type carries a cycle header. */
|
||||
void scr_throw_ref(void *v, void *(*retain)(void *), void (*release)(void *),
|
||||
ScrTraceFn trace);
|
||||
SCR_COLD void scr_throw_ref(void *v, void *(*retain)(void *), void (*release)(void *),
|
||||
ScrTraceFn trace);
|
||||
/* Store a reference whose JS typeof was checked at the throw site. */
|
||||
void scr_throw_ref_classified(void *v, void *(*retain)(void *), void (*release)(void *),
|
||||
ScrTraceFn trace, bool object);
|
||||
void scr_throw_primitive_ref(void *v, void *(*retain)(void *), void (*release)(void *),
|
||||
ScrTraceFn trace);
|
||||
SCR_COLD void scr_throw_ref_classified(void *v, void *(*retain)(void *), void (*release)(void *),
|
||||
ScrTraceFn trace, bool object);
|
||||
SCR_COLD void scr_throw_primitive_ref(void *v, void *(*retain)(void *), void (*release)(void *),
|
||||
ScrTraceFn trace);
|
||||
/* Same ownership contract as scr_throw_ref; the payload must be a
|
||||
* hierarchy-class instance (vtable word present) — see SCR_EXC_OBJ. */
|
||||
void scr_throw_obj(void *v, void *(*retain)(void *), void (*release)(void *),
|
||||
ScrTraceFn trace);
|
||||
SCR_COLD void scr_throw_obj(void *v, void *(*retain)(void *), void (*release)(void *),
|
||||
ScrTraceFn trace);
|
||||
|
||||
/* Catch: discard the in-flight exception (releases a refcounted payload). */
|
||||
void scr_exc_clear(void);
|
||||
@@ -5303,22 +5315,22 @@ void scr_dyn_check_listener(const ScrDyn *cb, const char *argname);
|
||||
const char *scr_dyn_specific_type(const ScrDyn *v, char *buf, size_t cap);
|
||||
/* ERR_INVALID_ARG_TYPE with the runtime-rendered Received tail (the
|
||||
* error.argTypeThrow libCall). Borrows all three; always throws. */
|
||||
void scr_throw_arg_type(const ScrStr *argname, const ScrStr *expected, const ScrDyn *got);
|
||||
void scr_dyn_arg_type_fail(const char *argname, const char *expected, const ScrDyn *got);
|
||||
SCR_COLD void scr_throw_arg_type(const ScrStr *argname, const ScrStr *expected, const ScrDyn *got);
|
||||
SCR_COLD void scr_dyn_arg_type_fail(const char *argname, const char *expected, const ScrDyn *got);
|
||||
/* The property flavor ("The \"options.x\" property must be ...") — the
|
||||
* option-bag validators' gate (error.propTypeThrow). Always throws. */
|
||||
void scr_throw_prop_type(const ScrStr *name, const ScrStr *expected, const ScrDyn *got);
|
||||
void scr_dyn_prop_type_fail(const char *name, const char *expected, const ScrDyn *got);
|
||||
SCR_COLD void scr_throw_prop_type(const ScrStr *name, const ScrStr *expected, const ScrDyn *got);
|
||||
SCR_COLD void scr_dyn_prop_type_fail(const char *name, const char *expected, const ScrDyn *got);
|
||||
/* Node's ERR_INVALID_ARG_VALUE ("The argument 'encoding' is invalid
|
||||
* encoding. Received 'no'") — reason NULL renders "is invalid".
|
||||
* TypeError; always throws catchably. */
|
||||
void scr_dyn_arg_value_fail(const char *name, const char *reason, const ScrDyn *got);
|
||||
SCR_COLD void scr_dyn_arg_value_fail(const char *name, const char *reason, const ScrDyn *got);
|
||||
/* The ERR_INVALID_ARG_VALUE/%j "Received" renderer (inspect-lite:
|
||||
* strings quote, scalars print plain, deep shapes sketch). */
|
||||
const char *scr_dyn_inspect_lite(const ScrDyn *v, char *buf, size_t cap);
|
||||
/* A ladder's post-validation refuse: throws the compiler-rendered SC2020
|
||||
* statement-fence text verbatim (Node's validation errors run first). */
|
||||
void scr_throw_lowering_fence(const ScrStr *msg);
|
||||
SCR_COLD void scr_throw_lowering_fence(const ScrStr *msg);
|
||||
/* ERR_OUT_OF_RANGE's "Received" number rendering (Node's
|
||||
* addNumericalSeparator underscores past 2^32) — scr_bytes.c's renderer,
|
||||
* shared by the fs/net/tls option-ladder validators. */
|
||||
|
||||
@@ -21,6 +21,7 @@ function isLetter(ch: number): NoThrow<boolean> {
|
||||
- `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.
|
||||
- `ProgramLifetime` in a class's `implements` clause: instances of the class and its subclasses are never freed, so scriptc skips reference counting and cycle collection for them. Memory only: discarded instances are not reclaimed.
|
||||
|
||||
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.
|
||||
scriptc proves each contract or rejects the program with a diagnostic (SC7001-SC7008) that explains why it does not hold. A program that compiles behaves exactly as under Node.js. See https://scriptc.dev/docs/contracts.
|
||||
|
||||
Vendored
+16
-1
@@ -4,7 +4,8 @@
|
||||
* 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) and checks each one as an extra contract: the program either
|
||||
* 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. */
|
||||
@@ -40,6 +41,20 @@ export type Immutable<T> = T extends (...args: never[]) => unknown
|
||||
? { 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 {}
|
||||
|
||||
/** Class marker: `class Node implements ProgramLifetime { ... }`. Instances
|
||||
* of the class and of every subclass live until the process exits: scriptc
|
||||
* never reference counts them, never walks them for cycles and never frees
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// and the cycle collector never walks them. Node sees ordinary classes: the
|
||||
// annotation is erased. Everything else (the mortal scopes, arrays, maps and
|
||||
// closures around them) is still reference counted and collected.
|
||||
import type { ProgramLifetime } from "@scriptc/types";
|
||||
import type { Acyclic, ProgramLifetime } from "@scriptc/types";
|
||||
|
||||
abstract class SyntaxNode implements ProgramLifetime {
|
||||
parent: SyntaxNode | undefined = undefined;
|
||||
@@ -173,3 +173,19 @@ try {
|
||||
console.log("caught", (e as Error).message);
|
||||
}
|
||||
console.log(new Checked("ok").label);
|
||||
|
||||
// A class can be both: Acyclic is still checked against the fields as
|
||||
// written, and the instances are never freed.
|
||||
class Interned implements Acyclic, ProgramLifetime {
|
||||
readonly text: string;
|
||||
readonly next: Interned | null;
|
||||
constructor(text: string, next: Interned | null) {
|
||||
this.text = text;
|
||||
this.next = next;
|
||||
}
|
||||
}
|
||||
let interned: Interned | null = null;
|
||||
for (const word of ["alpha", "beta", "gamma"]) interned = new Interned(word, interned);
|
||||
const words: string[] = [];
|
||||
for (let w = interned; w !== null; w = w.next) words.push(w.text);
|
||||
console.log(words.join(" "));
|
||||
|
||||
@@ -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,128 @@
|
||||
// Small functions whose only slow path throws: the runtime's throw entry
|
||||
// points are cold and never inlined, so these helpers inline into hot loops
|
||||
// while their error paths keep Node's exact errors, unwinding and finally
|
||||
// order.
|
||||
|
||||
class Box {
|
||||
value: number;
|
||||
next: Box | undefined;
|
||||
constructor(value: number, next: Box | undefined) {
|
||||
this.value = value;
|
||||
this.next = next;
|
||||
}
|
||||
}
|
||||
|
||||
function valueOf(box: Box | undefined): number {
|
||||
if (box === undefined) throw new TypeError("no box");
|
||||
return box.value;
|
||||
}
|
||||
|
||||
function nextValue(box: Box): number {
|
||||
const next = box.next;
|
||||
if (next === undefined) throw new RangeError(`no next after ${box.value % 5}`);
|
||||
return next.value;
|
||||
}
|
||||
|
||||
function narrow(x: string | number | undefined): string {
|
||||
if (typeof x === "string") return x;
|
||||
if (x === undefined) throw new TypeError("missing value");
|
||||
throw new RangeError(`not a string: ${x}`);
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e instanceof Error) return `${e.name}: ${e.message}`;
|
||||
return `thrown ${typeof e}: ${String(e)}`;
|
||||
}
|
||||
|
||||
// Hot loop over inlined fast paths; the throwing path runs every 7th time.
|
||||
const boxes: (Box | undefined)[] = [];
|
||||
for (let i = 0; i < 50; i++) boxes.push(i % 7 === 3 ? undefined : new Box(i, i % 5 ? new Box(i * 2, undefined) : undefined));
|
||||
let sum = 0;
|
||||
let caught = 0;
|
||||
const messages = new Set<string>();
|
||||
for (let round = 0; round < 3; round++) {
|
||||
for (const box of boxes) {
|
||||
try {
|
||||
sum += valueOf(box);
|
||||
sum += nextValue(box!);
|
||||
} catch (e) {
|
||||
caught++;
|
||||
messages.add(describe(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(sum, caught);
|
||||
for (const m of [...messages].sort()) console.log(m);
|
||||
|
||||
// Throw sites of every primitive and object kind, with finally ordering.
|
||||
const log: string[] = [];
|
||||
function thrower(kind: number): number {
|
||||
try {
|
||||
switch (kind) {
|
||||
case 0:
|
||||
throw 42;
|
||||
case 1:
|
||||
throw "text";
|
||||
case 2:
|
||||
throw false;
|
||||
case 3:
|
||||
throw new Error("plain");
|
||||
case 4:
|
||||
throw { code: "E_OBJ" };
|
||||
case 5:
|
||||
return narrow(undefined).length;
|
||||
case 6:
|
||||
return narrow(7).length;
|
||||
default:
|
||||
return narrow("ok").length;
|
||||
}
|
||||
} finally {
|
||||
log.push(`finally ${kind}`);
|
||||
}
|
||||
}
|
||||
for (let kind = 0; kind < 8; kind++) {
|
||||
try {
|
||||
log.push(`result ${thrower(kind)}`);
|
||||
} catch (e) {
|
||||
log.push(typeof e === "object" && e !== null && !(e instanceof Error) ? `object ${JSON.stringify(e)}` : describe(e));
|
||||
}
|
||||
}
|
||||
console.log(log.join("\n"));
|
||||
|
||||
// Errors built without being thrown keep their fields.
|
||||
const built: Error[] = [];
|
||||
for (let i = 0; i < 4; i++) built.push(i % 2 ? new TypeError(`t${i}`) : new Error(`e${i}`));
|
||||
console.log(built.map((e) => `${e.name}/${e.message}`).join(","));
|
||||
|
||||
// Runtime-raised errors on otherwise fast paths.
|
||||
try {
|
||||
JSON.parse("{bad");
|
||||
} catch (e) {
|
||||
console.log(e instanceof SyntaxError);
|
||||
}
|
||||
for (const count of [2, -1]) {
|
||||
try {
|
||||
console.log("ab".repeat(count));
|
||||
} catch (e) {
|
||||
console.log(describe(e));
|
||||
}
|
||||
}
|
||||
for (const length of [3, -1]) {
|
||||
try {
|
||||
console.log(new Array<number>(length).length);
|
||||
} catch (e) {
|
||||
console.log(describe(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Deep recursion raises a catchable RangeError and the program continues.
|
||||
function depth(n: number): number {
|
||||
return n === 0 ? 0 : 1 + depth(n - 1);
|
||||
}
|
||||
try {
|
||||
depth(1e7);
|
||||
console.log("no overflow");
|
||||
} catch (e) {
|
||||
console.log(e instanceof RangeError, (e as Error).message);
|
||||
}
|
||||
console.log(depth(1000));
|
||||
@@ -0,0 +1,73 @@
|
||||
// Array index loops keep their counter in integer storage; every place the
|
||||
// counter is observed as a number (arithmetic, comparisons, strings, calls,
|
||||
// stores, nested starts) sees the exact double, including +0 at the start.
|
||||
|
||||
function take(n: number): string {
|
||||
return Object.is(n, -0) ? "-0" : `${n}`;
|
||||
}
|
||||
|
||||
const words = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"];
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
out.push(`${i}:${i / 2}:${i * 1.5}:${i - 3}:${take(i)}:${i === words.length - 1}:${(i + 0.25).toFixed(2)}`);
|
||||
}
|
||||
console.log(out.join(" "));
|
||||
|
||||
// Nested `j = i + 1` loops and counters stored into number arrays and maps.
|
||||
const pairs: number[] = [];
|
||||
const weights = new Map<number, number>();
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
for (let j = i + 1; j < words.length; j++) {
|
||||
if ((i + j) % 3 === 0) pairs.push(i * 10 + j);
|
||||
weights.set(j, (weights.get(j) ?? 0) + i / (j + 1));
|
||||
}
|
||||
}
|
||||
console.log(pairs.join(","));
|
||||
console.log([...weights].map(([k, v]) => `${k}=${v.toFixed(6)}`).join(" "));
|
||||
|
||||
// for-of with an index read back as a number and used as a key.
|
||||
const seen: Record<string, number> = {};
|
||||
let index = 0;
|
||||
for (const w of words) {
|
||||
seen[w] = index * index - 0.5;
|
||||
index++;
|
||||
}
|
||||
console.log(JSON.stringify(seen));
|
||||
|
||||
// Counters passed to functions taking numbers, compared with fractions and
|
||||
// negative values, and used to index a second array.
|
||||
const values = words.map((w) => w.length);
|
||||
let acc = 0;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
acc += Math.max(i - 2.5, -1) * values[i]!;
|
||||
if (i > 2.5 && i < 4.5) acc += 1000;
|
||||
acc += Math.hypot(i, 1);
|
||||
}
|
||||
console.log(acc.toFixed(8));
|
||||
|
||||
// Arrays that grow during the loop (the bound is re-read every iteration).
|
||||
const grow = [1, 2, 3];
|
||||
for (let i = 0; i < grow.length; i++) {
|
||||
if (grow.length < 12) grow.push(i + grow[i]!);
|
||||
}
|
||||
console.log(grow.join(","));
|
||||
|
||||
// Byte loops: the index as a number in arithmetic and as an element value.
|
||||
const bytes = new Uint8Array(20);
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37) & 0xff;
|
||||
let mix = 0;
|
||||
for (let i = 0; i < bytes.length; i++) mix = (mix * 31 + bytes[i]! + i / 4) % 1000003;
|
||||
console.log(Array.from(bytes).join(","), mix);
|
||||
|
||||
// A larger array so the counter passes 2^8 and 2^16 boundaries.
|
||||
const big: number[] = [];
|
||||
for (let i = 0; i < 70000; i++) big.push(i & 7);
|
||||
let total = 0;
|
||||
let last = -1;
|
||||
for (let i = 0; i < big.length; i++) {
|
||||
if (big[i] === 7) {
|
||||
total += i;
|
||||
last = i;
|
||||
}
|
||||
}
|
||||
console.log(total, last, String(last / 3));
|
||||
@@ -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,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"));
|
||||
@@ -1,7 +1,7 @@
|
||||
// ProgramLifetime is checked on the root class of a hierarchy, which every
|
||||
// subclass then inherits. Built-in bases keep runtime state that is
|
||||
// released with the object.
|
||||
import type { ProgramLifetime } from "@scriptc/types";
|
||||
import type { Acyclic, ProgramLifetime } from "@scriptc/types";
|
||||
|
||||
class Base {
|
||||
id = 0;
|
||||
@@ -13,4 +13,19 @@ class Derived extends Base implements ProgramLifetime {
|
||||
|
||||
class Failure extends Error implements ProgramLifetime {}
|
||||
|
||||
console.log(new Derived().name, new Base().id, new Failure("x").message);
|
||||
// Acyclic is checked on the reference graph as written: a reference
|
||||
// through a ProgramLifetime instance still closes a cycle.
|
||||
class Tree implements ProgramLifetime {
|
||||
holder: Holder | undefined = undefined;
|
||||
}
|
||||
|
||||
class Holder implements Acyclic {
|
||||
readonly tree: Tree;
|
||||
constructor(tree: Tree) {
|
||||
this.tree = tree;
|
||||
}
|
||||
}
|
||||
|
||||
const tree = new Tree();
|
||||
tree.holder = new Holder(tree);
|
||||
console.log(new Derived().name, new Base().id, new Failure("x").message, tree.holder.tree === tree);
|
||||
|
||||
@@ -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,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
|
||||
@@ -5,7 +5,7 @@ contract-nothrow.ts:11:35 - error SC7001: 'sumDigits' is declared NoThrow but ma
|
||||
| ^~~~~~~~~~~~~~~
|
||||
12 | let sum = 0;
|
||||
|
||||
hint: remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version
|
||||
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
|
||||
|
||||
@@ -14,7 +14,7 @@ contract-nothrow.ts:17:28 - error SC7001: 'depth' is declared NoThrow but may th
|
||||
| ^~~~~~~~~~~~~~~
|
||||
18 | return n <= 0 ? 0 : 1 + depth(n - 1);
|
||||
|
||||
hint: remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version
|
||||
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
|
||||
|
||||
@@ -23,7 +23,7 @@ contract-nothrow.ts:21:54 - error SC7001: 'apply' is declared NoThrow but may th
|
||||
| ^~~~~~~~~~~~~~~
|
||||
22 | return f(x);
|
||||
|
||||
hint: remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version
|
||||
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)
|
||||
|
||||
@@ -32,4 +32,4 @@ contract-nothrow.ts:28:31 - error SC7001: 'width' is declared NoThrow but may th
|
||||
| ^~~~~~~~~~~~~~~
|
||||
29 | return text.length;
|
||||
|
||||
hint: remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version
|
||||
hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow
|
||||
@@ -1,4 +1,4 @@
|
||||
contract-program-lifetime.ts:10:39 - error SC7007: 'Derived' is declared ProgramLifetime but extends 'Base'
|
||||
contract-program-lifetime.ts:10:39 - error SC7008: 'Derived' is declared ProgramLifetime but extends 'Base'
|
||||
|
||||
9 |
|
||||
10 | class Derived extends Base implements ProgramLifetime {
|
||||
@@ -7,11 +7,20 @@ contract-program-lifetime.ts:10:39 - error SC7007: 'Derived' is declared Program
|
||||
|
||||
hint: declare ProgramLifetime on the root class 'Base' instead; it applies to every subclass
|
||||
|
||||
contract-program-lifetime.ts:14:40 - error SC7007: 'Failure' is declared ProgramLifetime but extends 'Error'
|
||||
contract-program-lifetime.ts:14:40 - error SC7008: 'Failure' is declared ProgramLifetime but extends 'Error'
|
||||
|
||||
13 |
|
||||
14 | class Failure extends Error implements ProgramLifetime {}
|
||||
| ^~~~~~~~~~~~~~~
|
||||
15 |
|
||||
|
||||
hint: ProgramLifetime applies to classes the program owns end to end; built-in bases (errors, emitters, streams) keep runtime state that is released with the object
|
||||
hint: ProgramLifetime applies to classes the program owns end to end; built-in bases (errors, emitters, streams) keep runtime state that is released with the object
|
||||
|
||||
contract-program-lifetime.ts:22:25 - error SC7007: 'Holder' is declared Acyclic but can lie on a reference cycle: Holder.tree → Tree.holder → Holder (Tree.holder can be assigned after construction, as at contract-program-lifetime.ts:30:1)
|
||||
|
||||
21 |
|
||||
22 | class Holder implements Acyclic {
|
||||
| ^~~~~~~
|
||||
23 | readonly tree: Tree;
|
||||
|
||||
hint: assign the reference only in the constructor (before `this` escapes), so a new object can only point to older ones, or remove Acyclic
|
||||
Reference in New Issue
Block a user