feat(compiler): run optimization and backend analyses natively (#497)

- Compile scalar-record optimization and backend analyses on both native backends
- Use exhaustive typed IR traversal and shared cycle analysis
- Verify optimized IR and executable parity with focused sanitizer coverage
This commit is contained in:
Chris Tate
2026-09-27 16:41:16 -05:00
committed by GitHub
parent b778bf6e6e
commit 48e00aa779
16 changed files with 1567 additions and 382 deletions
+5 -100
View File
@@ -41,7 +41,7 @@ import type {
IrUnionDef,
SrcLoc,
} from "../../ir/ir.js";
import { ffiCallbackType, funcOf, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, mapOf, moduleEmbedsCompressedNpm, moduleUsesChildProcess, moduleUsesDgram, moduleUsesDynInvoke, moduleEmbedsBuiltin, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, POINTER_KINDS, type PointerKind, RUNTIME_EMITTER_CLASS, STRING, VOID } from "../../ir/ir.js";
import { ffiCallbackType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, moduleEmbedsCompressedNpm, moduleUsesChildProcess, moduleUsesDgram, moduleUsesDynInvoke, moduleEmbedsBuiltin, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, POINTER_KINDS, type PointerKind, RUNTIME_EMITTER_CLASS, VOID } from "../../ir/ir.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { scalarizeNumericRecords } from "../../ir/scalar-records.js";
import type { IntegerRanges } from "../../ir/integer-ranges.js";
@@ -61,6 +61,7 @@ import {
mangleWrapper,
} from "../mangle.js";
import { cCommentText, cFnPtrCast, cType, releaseCallC, cStringLiteral, cDecl, cNumberLiteral } from "./types.js";
import { computeTraced } from "../cycle-analysis.js";
import { computeMayThrow } from "./may-throw.js";
import { unionTruthyHelper, unionEqHelper, unionToStrHelper, unionJoinHelper, jsonWriteHelper, jsonIndentHelper, dynMatchHelper, dynCheckHelper, dynFuncBoxHelper, dynToStrHelper, caughtToDynHelper, toDynHelper, recordKeyGetHelper, recordKeySetHelper } from "./walkers.js";
import { VtSlot, ClassMeta, emitStructDefs, vtEntriesFor, vtSlotParams, emitVtableDecls, emitVtableInstances, emitVtAdapterDefs, emitHierarchyClassHelpers, emitClassObjs, emitCtorThunkDefs, errorVtStampLines, emitterVtStampLines, streamVtStampLines, traceAdapterC, traceArgC, boxNewC, arrNewC } from "./shapes.js";
@@ -553,105 +554,9 @@ export class CEmitter {
if (this.mayThrow.has(`%${cls.name}.${m}`)) this.mayThrowMethods.add(m);
}
}
// Cycle capability, as a greatest fixpoint over shapes and unions:
// start optimistic (everything cycle-capable), then repeatedly drop
// shapes with no cycle-capable field and unions with no cycle-capable
// arm until stable. Closures and promises are always cycle-capable
// (a captured box can hold anything; a rejection payload is an
// arbitrary thrown value); strings never are, and arrays/maps inherit
// their element/value type's capability (a record element can point
// back at the array holding it). The optimistic start is what keeps
// self- and mutually-recursive classes traced (`class A { next: A }`).
const shapeDefs = [
// The emitter class carries a synthetic closure-typed pseudo-field:
// its runtime registry OWNS listener closures, so the emitter
// hierarchy is unconditionally cycle-capable — the fixpoint must
// never drop it (the pseudo-field never reaches struct emission;
// runtime classes emit no structs).
...(mod.classes ?? []).map((c) => ({
key: `object:${c.name}`,
fields: c.name === RUNTIME_EMITTER_CLASS
? [...c.fields, { name: "<listeners>", type: funcOf([], VOID) }]
: c.fields,
})),
// An index-signature shape's overflow map participates like a field
// of map type: the shape is cycle-capable when the overflow VALUE
// type is (a record/object/union value in the map can point back at
// the record embedding it) — cycleCapable's map rule answers that.
...(mod.records ?? []).map((r) => ({
key: `record:${r.id}`,
fields: r.indexValue
? [...r.fields, { name: "<overflow>", type: mapOf(STRING, r.indexValue) }]
: r.fields,
})),
];
for (const s of shapeDefs) this.tracedShapes.add(s.key);
// A hierarchy is ONE unit of cycle capability: a base-typed slot can
// hold any subclass and retain touches the cycle header, so header
// presence must be uniform across an extends-hierarchy — it is
// cycle-capable iff ANY member is. Standalone classes and records are
// singleton units (today's behavior exactly).
const unitKeyOf = (key: string): string => {
if (!key.startsWith("object:")) return key;
const meta = this.classMeta.get(key.slice("object:".length));
return meta && meta.hierarchy ? `object:${meta.root.def.name}` : key;
};
const units = new Map<string, typeof shapeDefs>();
for (const s of shapeDefs) {
const unit = unitKeyOf(s.key);
let members = units.get(unit);
if (!members) units.set(unit, (members = []));
members.push(s);
}
for (const u of mod.unions ?? []) this.tracedUnions.add(u.id);
const cycleCapable = (t: IrType): boolean => {
switch (t.kind) {
case "func":
case "promise":
return true;
case "object":
return this.tracedShapes.has(`object:${t.className}`);
case "record":
return this.tracedShapes.has(`record:${t.shapeId}`);
case "union":
return this.tracedUnions.has(t.unionId);
// A map is cycle-capable exactly when its VALUE type is: a record/
// object/union value can hold the map that owns it, while string/
// array/scalar values cannot point back. Map-valued maps (an
// index-signature overflow over `Map<K, V>` values) recurse on the
// inner value. Terminates: IrTypes are finite trees, and the
// record/union cases read the fixpoint sets.
case "map":
return cycleCapable(t.value);
// An array is cycle-capable exactly when its ELEMENT type is —
// record/object/union elements (and cycle-capable inner arrays)
// can point back at the array. Terminates: element types are
// finite trees, and the record/union cases read the fixpoint sets.
case "array":
return cycleCapable(t.elem);
default:
return false;
}
};
let shrunk = true;
while (shrunk) {
shrunk = false;
for (const members of units.values()) {
if (
this.tracedShapes.has(members[0]!.key) &&
!members.some((s) => s.fields.some((f) => cycleCapable(f.type)))
) {
for (const s of members) this.tracedShapes.delete(s.key);
shrunk = true;
}
}
for (const u of mod.unions ?? []) {
if (this.tracedUnions.has(u.id) && !u.arms.some(cycleCapable)) {
this.tracedUnions.delete(u.id);
shrunk = true;
}
}
}
const traced = computeTraced(mod);
for (const shape of traced.shapes) this.tracedShapes.add(shape);
for (const union of traced.unions) this.tracedUnions.add(union);
if (sourceText !== undefined) {
this.lineStarts = [0];
for (let i = 0; i < sourceText.length; i++) {
+26 -34
View File
@@ -1,7 +1,8 @@
/* Cheap whole-module may-throw analysis (see computeMayThrow). Pure function
* of the IR module; the emitter consults the result to place unwind checks. */
import type { IrArrIntrinsicMethod, IrBytesIntrinsicMethod, IrLibFn, IrModule } from "../../ir/ir.js";
import type { IrExpr, IrStmt, IrModule } from "../../ir/ir.js";
import { isFfiCallbackParam, MAY_THROW_ARR_METHODS, MAY_THROW_BYTES_METHODS, MAY_THROW_LIB_FNS } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import { hasRetainedFfiCallback } from "../ffi-callbacks.js";
/** Cheap may-throw analysis (cost discipline: functions that transitively
@@ -51,16 +52,9 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
// Capture locals carry the same flag as the declaring binding.
const tdzIds = new Set(fn.locals.filter((l) => l.tdz).map((l) => l.id));
const mutableTdzIds = new Set(fn.locals.filter((l) => l.tdz && l.mutable).map((l) => l.id));
// The IR is plain JSON: a generic walk keyed on `kind` stays correct as
// nodes grow fields (types' own `kind`s never collide with these).
const visit = (node: unknown): void => {
if (Array.isArray(node)) {
for (const item of node) visit(item);
return;
}
if (node === null || typeof node !== "object") return;
const rec = node as Record<string, unknown>;
switch (rec["kind"]) {
// Traverse typed executable nodes without copying the IR into unknown.
const visit = (rec: IrExpr | IrStmt): boolean => {
switch (rec.kind) {
case "throw":
case "rethrow":
// The deferred JS compile fence throws catchably when executed.
@@ -70,13 +64,13 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
case "varRef":
case "incDec":
case "assignExpr":
if (tdzIds.has(rec["localId"] as string)) f.throws = true;
if (tdzIds.has(rec.localId)) f.throws = true;
break;
case "assign":
// A declaration is allowed to fill an empty box. Legacy const
// TDZ stores also initialize; only mutable subsequent stores
// introduce the new write-side exception edge.
if (rec["initializes"] !== true && mutableTdzIds.has(rec["localId"] as string)) f.throws = true;
if (rec.initializes !== true && mutableTdzIds.has(rec.localId)) f.throws = true;
break;
case "dynCheck":
case "caughtCheck":
@@ -87,14 +81,14 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
// callValue) — those adapters are emitter-synthesized, invisible
// to closureTargets, so they force the indirect answer below.
f.throws = true;
if ((rec["type"] as { kind?: string } | undefined)?.kind === "func") {
if (rec.type.kind === "func") {
sawDynFuncAdapter = true;
}
break;
case "fieldIncDec":
// A checked-dynamic field's ++/-- validates the number out of
// the box — that dynCheck throws catchably on non-numbers.
if (rec["fieldDyn"] === true) f.throws = true;
if (rec.fieldDyn === true) f.throws = true;
break;
case "dynCall":
// Prototype dispatch throws the same family (not-a-function,
@@ -126,7 +120,7 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
case "intrinsic":
// Module dependency evaluation has await's rejection behavior,
// while deliberately avoiding await's extra settled-promise turn.
if (rec["name"] === "module.await") f.throws = true;
if (rec.name === "module.await") f.throws = true;
break;
case "yieldExpr":
// A consumer .throw() surfaces at the yield (and .return()'s
@@ -145,9 +139,9 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
// that path throws the catchable TypeError. A SIGNATURE-FREE
// shape's write throws on a key MISS (scr_record_key_miss).
// Overflow shapes with typed value slots never do.
const shape = (mod.records ?? []).find((r) => r.id === rec["shapeId"]);
const shape = (mod.records ?? []).find((r) => r.id === rec.shapeId);
if (
rec["overflowOnly"] !== true &&
rec.overflowOnly !== true &&
shape && (!shape.indexValue || (shape.indexValue.kind === "dyn" && shape.fields.length > 0))
) {
f.throws = true;
@@ -161,7 +155,7 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
// the emitters place the pending check only for cycle-capable
// types, and a never-taken caller-side check costs one flag
// read.
const vt = (rec["value"] as { type?: { kind?: string } }).type?.kind;
const vt = rec.value.type.kind;
if (vt === "dyn" || vt === "record" || vt === "array" || vt === "union") f.throws = true;
break;
}
@@ -177,14 +171,14 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
case "libCall":
// The may-throw seed hook: throwing library calls (fs.*,
// json.parse) count exactly like a `throw` statement.
if (MAY_THROW_LIB_FNS.has(rec["fn"] as IrLibFn)) f.throws = true;
if (MAY_THROW_LIB_FNS.has(rec.fn)) f.throws = true;
break;
case "ffiCall":
// A native callback may run arbitrary scriptc code. With retained
// descriptors any manifest binding may pump a previously stored
// callback, so every FFI call is conservatively a checkpoint.
if (
callbackFfiImports.has(rec["import"] as string) ||
callbackFfiImports.has(rec.import) ||
manifestHasRetainedCallback
) f.throws = true;
break;
@@ -192,7 +186,7 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
// The size form (`new Uint8Array(n)`) throws Node's "Invalid
// typed array length" RangeError on a bad length; copy/array
// sources never throw.
const source = rec["source"] as { type?: { kind?: string } } | null;
const source = rec.source;
if (source && source.type?.kind === "f64") f.throws = true;
break;
}
@@ -200,12 +194,12 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
// setFrom and the numeric read/write families throw catchable
// RangeErrors (Node's bounds discipline); the rest trap or
// cannot fail.
if (MAY_THROW_BYTES_METHODS.has(rec["method"] as IrBytesIntrinsicMethod)) {
if (MAY_THROW_BYTES_METHODS.has(rec.method)) {
f.throws = true;
}
break;
case "arrIntrinsic":
if (MAY_THROW_ARR_METHODS.has(rec["method"] as IrArrIntrinsicMethod)) {
if (MAY_THROW_ARR_METHODS.has(rec.method)) {
f.throws = true;
}
break;
@@ -215,26 +209,26 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
case "regexIntrinsic":
// replaceAll and matchAll without /g throw Node's TypeError;
// split throws on a pattern with capture groups — all catchable.
if (rec["method"] === "replaceAll" || rec["method"] === "split" || rec["method"] === "matchAll" || rec["method"] === "matchAllInto") {
if (rec.method === "replaceAll" || rec.method === "split" || rec.method === "matchAll" || rec.method === "matchAllInto") {
f.throws = true;
}
break;
case "call": {
// Calling an ASYNC function never unwinds the caller: a body
// throw becomes a promise rejection (visible only at await).
const callee = rec["callee"] as string;
const callee = rec.callee;
if (!asyncFns.has(callee) && !genFns.has(callee)) f.callees.push(callee);
break;
}
case "new":
f.callees.push(`%${rec["className"] as string}.constructor`);
f.callees.push(`%${rec.className}.constructor`);
break;
case "newValue": {
// Construction through a class VALUE reaches the static class's
// constructor or any strict descendant's — a sound (slightly
// wide) cover is every constructor of the named class's
// hierarchy; classval flows never leave it.
const cls = ((rec["callee"] as { type?: { className?: string } }).type)?.className;
const cls = rec.callee.type.kind === "classval" ? rec.callee.type.className : undefined;
if (cls !== undefined) {
const descends = (name: string): boolean => {
for (let c = (mod.classes ?? []).find((k) => k.name === name); c; c = (mod.classes ?? []).find((k) => k.name === c!.base)) {
@@ -253,7 +247,7 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
// The concrete callee is any implementation of this method name
// on a class — a sound (slightly wide, cross-hierarchy) cover of
// the override set the dispatch can actually reach.
const impls = methodImpls.get(rec["method"] as string);
const impls = methodImpls.get(rec.method);
if (impls) f.callees.push(...impls);
break;
}
@@ -261,14 +255,12 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
f.callsValue = true;
break;
case "closure":
closureTargets.add(rec["fnName"] as string);
closureTargets.add(rec.fnName);
break;
}
for (const key of Object.keys(rec)) {
if (key !== "loc" && key !== "type") visit(rec[key]);
}
return true;
};
visit(fn.body);
everyStmtList(fn.body, { expr: visit, stmt: visit });
facts.set(fn.name, f);
}
@@ -0,0 +1,101 @@
import { expect, test } from "vitest";
import { F64, STRING, VOID, RUNTIME_EMITTER_CLASS, arrayOf, funcOf, mapOf, type IrModule, type IrRecordShape, type IrType } from "../ir/ir.js";
import { computeTraced } from "./cycle-analysis.js";
import { CEmitter } from "./c/c-emitter.js";
import { computeTraced as llvmTraced } from "./llvm/shapes.js";
const loc = { file: "cycles.ts", start: 0, end: 1 };
function module(): IrModule {
return { irVersion: 12, sourceFile: loc.file, entry: "main", functions: [{ name: "main", locals: [], params: [], returnType: VOID, body: [], loc }] };
}
const ref = (shapeId: string): IrType => ({ kind: "record", shapeId });
const shape = (id: string, types: IrType[]): IrRecordShape => ({ id, fields: types.map((type, i) => ({ name: `field${i}`, type })) });
function check(mod: IrModule, shapes: string[], unions: string[] = []): void {
const before = structuredClone(mod);
const actual = computeTraced(mod);
expect([...actual.shapes].sort()).toEqual(shapes.sort());
expect([...actual.unions].sort()).toEqual(unions.sort());
expect(llvmTraced(mod)).toEqual(actual);
const c = new CEmitter(mod);
expect(c.tracedShapes).toEqual(actual.shapes);
expect(c.tracedUnions).toEqual(actual.unions);
expect(mod).toEqual(before);
}
test("removes an entire acyclic dependency chain to a fixed point", () => {
const mod = module();
mod.records = [shape("a", [ref("b")]), shape("b", [ref("c")]), shape("c", [STRING, F64])];
mod.unions = [{ id: "tail", arms: [ref("a"), { kind: "undefinedT" }] }];
check(mod, []);
mod.records.reverse();
check(mod, []);
});
test("retains mutual cycles and every shape that can contain them", () => {
const mod = module();
mod.records = [shape("outer", [ref("a")]), shape("a", [ref("b")]), shape("b", [ref("a")]), shape("leaf", [F64])];
check(mod, ["record:outer", "record:a", "record:b"]);
});
test("tracks cycles through unions, array elements and map values", () => {
const mod = module();
mod.records = [shape("node", [arrayOf(mapOf(STRING, { kind: "union", unionId: "link" }))]), shape("leaf", [STRING])];
mod.unions = [
{ id: "link", arms: [ref("node"), { kind: "nullT" }] },
{ id: "leaf", arms: [ref("leaf"), F64] },
];
check(mod, ["record:node"], ["link"]);
});
test("index-signature overflow participates in the same cycle graph", () => {
const mod = module();
mod.records = [
{ ...shape("recursive", []), indexValue: arrayOf(ref("recursive")) },
{ ...shape("scalar", []), indexValue: mapOf(STRING, F64) },
{ ...shape("promises", []), indexValue: { kind: "promise", inner: F64 } },
];
check(mod, ["record:recursive", "record:promises"]);
});
test("closures and promises seed tracing even with scalar signatures", () => {
const mod = module();
mod.records = [shape("closure", [funcOf([], F64)]), shape("promise", [{ kind: "promise", inner: F64 }]), shape("bytes", [{ kind: "bytes", elem: "u8" }])];
check(mod, ["record:closure", "record:promise"]);
});
test("a derived field requires a uniform header for the entire hierarchy", () => {
const mod = module();
mod.classes = [
{ name: "Derived", loc, base: "Base", fields: [{ name: "callback", type: funcOf([], VOID) }] },
{ name: "Sibling", loc, base: "Base", fields: [] },
{ name: "Base", loc, fields: [] },
{ name: "Independent", loc, fields: [{ name: "data", type: STRING }] },
];
check(mod, ["object:Base", "object:Derived", "object:Sibling"]);
});
test("an acyclic hierarchy is removed as one unit", () => {
const mod = module();
mod.records = [shape("data", [F64])];
mod.classes = [
{ name: "Derived", loc, base: "Base", fields: [{ name: "data", type: ref("data") }] },
{ name: "Base", loc, fields: [] },
];
check(mod, []);
});
test("the runtime emitter and its descendants retain listener tracing", () => {
const mod = module();
mod.classes = [{ name: "Events", loc, base: RUNTIME_EMITTER_CLASS, fields: [] }, { name: RUNTIME_EMITTER_CLASS, loc, fields: [] }];
check(mod, ["object:Events", `object:${RUNTIME_EMITTER_CLASS}`]);
});
test("separate calls do not share mutable fixed-point state", () => {
const mod = module();
mod.records = [shape("same", [ref("same")])];
const previous = computeTraced(mod);
mod.records = [shape("same", [STRING])];
check(mod, []);
expect([...previous.shapes]).toEqual(["record:same"]);
});
@@ -0,0 +1,97 @@
import type { IrModule, IrType } from "../ir/ir.js";
import { funcOf, mapOf, RUNTIME_EMITTER_CLASS, STRING, VOID } from "../ir/ir.js";
/** Cycle capability shared by both native backends.
* Greatest fixpoint over shapes and unions: start optimistic (everything
* cycle-capable), repeatedly drop shapes with no cycle-capable field and
* unions with no cycle-capable arm until stable. Closures and promises
* are always cycle-capable; strings never are; arrays/maps inherit their
* element/value type's capability. A HIERARCHY is one unit of capability
* (a base-typed slot can hold any subclass and retain touches the cycle
* header, so header presence must be 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 tracedShapes = new Set<string>();
const tracedUnions = new Set<string>();
const classes = mod.classes ?? [];
const shapeDefs = [
...classes.map((c) => ({
key: `object:${c.name}`,
fields: c.name === RUNTIME_EMITTER_CLASS
? [...c.fields, { name: "<listeners>", type: funcOf([], VOID) }]
: c.fields,
})),
...(mod.records ?? []).map((r) => ({
key: `record:${r.id}`,
fields: r.indexValue
? [...r.fields, { name: "<overflow>", type: mapOf(STRING, r.indexValue) }]
: r.fields,
})),
];
for (const s of shapeDefs) tracedShapes.add(s.key);
for (const u of mod.unions ?? []) tracedUnions.add(u.id);
// Hierarchy units: root lookup over the base links (classes with a base
// or a subclass — and the runtime emitter class — form units under their
// root; standalone classes and records stay singleton units).
const baseOf = new Map(classes.map((c) => [c.name, c.base ?? null] as const));
const hasChildren = new Set<string>();
for (const cls of classes) if (cls.base !== undefined) hasChildren.add(cls.base);
const rootOf = (name: string): string => {
let cur = name;
for (let b = baseOf.get(cur); b !== null && b !== undefined; b = baseOf.get(cur)) cur = b;
return cur;
};
const unitKeyOf = (key: string): string => {
if (!key.startsWith("object:")) return key;
const name = key.slice("object:".length);
const inHierarchy =
typeof baseOf.get(name) === "string" || hasChildren.has(name) || name === RUNTIME_EMITTER_CLASS;
return inHierarchy ? `object:${rootOf(name)}` : key;
};
const units = new Map<string, typeof shapeDefs>();
for (const s of shapeDefs) {
const unit = unitKeyOf(s.key);
let members = units.get(unit);
if (!members) units.set(unit, (members = []));
members.push(s);
}
const cycleCapable = (t: IrType): boolean => {
switch (t.kind) {
case "func":
case "promise":
return true;
case "object":
return tracedShapes.has(`object:${t.className}`);
case "record":
return tracedShapes.has(`record:${t.shapeId}`);
case "union":
return tracedUnions.has(t.unionId);
case "map":
return cycleCapable(t.value);
case "array":
return cycleCapable(t.elem);
default:
return false;
}
};
let shrunk = true;
while (shrunk) {
shrunk = false;
for (const members of units.values()) {
if (
tracedShapes.has(members[0]!.key) &&
!members.some((s) => s.fields.some((f) => cycleCapable(f.type)))
) {
for (const s of members) tracedShapes.delete(s.key);
shrunk = true;
}
}
for (const u of mod.unions ?? []) {
if (tracedUnions.has(u.id) && !u.arms.some(cycleCapable)) {
tracedUnions.delete(u.id);
shrunk = true;
}
}
}
return { shapes: tracedShapes, unions: tracedUnions };
}
+2 -96
View File
@@ -11,7 +11,7 @@
* Anything outside the tier refuses loudly (LlvmUnsupportedError naming
* the type kind) — the tables never guess. */
import type { IrModule, IrRecordShape, IrType } from "../../ir/ir.js";
import { funcOf, isRefCounted, mapOf, POINTER_KINDS, runtimeRcStem, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, STRING, VOID } from "../../ir/ir.js";
import { isRefCounted, mapOf, POINTER_KINDS, runtimeRcStem, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, STRING } from "../../ir/ir.js";
import {
mangleClassRelease,
mangleClassRetain,
@@ -47,101 +47,7 @@ export interface ShapeHost {
* the emitter header. */
export const FN_ATTRS = "#0";
/* ── cycle capability (the CEmitter constructor's fixpoint, ported) ────
* Greatest fixpoint over shapes and unions: start optimistic (everything
* cycle-capable), repeatedly drop shapes with no cycle-capable field and
* unions with no cycle-capable arm until stable. Closures and promises
* are always cycle-capable; strings never are; arrays/maps inherit their
* element/value type's capability. A HIERARCHY is one unit of capability
* (a base-typed slot can hold any subclass and retain touches the cycle
* header, so header presence must be uniform across an extends tree): a
* unit is cycle-capable iff ANY member is — CEmitter's unit grouping,
* ported. */
export function computeTraced(mod: IrModule): { shapes: Set<string>; unions: Set<string> } {
const tracedShapes = new Set<string>();
const tracedUnions = new Set<string>();
const classes = mod.classes ?? [];
const shapeDefs = [
...classes.map((c) => ({
key: `object:${c.name}`,
fields: c.name === RUNTIME_EMITTER_CLASS
? [...c.fields, { name: "<listeners>", type: funcOf([], VOID) }]
: c.fields,
})),
...(mod.records ?? []).map((r) => ({
key: `record:${r.id}`,
fields: r.indexValue
? [...r.fields, { name: "<overflow>", type: mapOf(STRING, r.indexValue) }]
: r.fields,
})),
];
for (const s of shapeDefs) tracedShapes.add(s.key);
for (const u of mod.unions ?? []) tracedUnions.add(u.id);
// Hierarchy units: root lookup over the base links (classes with a base
// or a subclass — and the runtime emitter class — form units under their
// root; standalone classes and records stay singleton units).
const baseOf = new Map(classes.map((c) => [c.name, c.base ?? null] as const));
const hasChildren = new Set(classes.map((c) => c.base).filter((b): b is string => b !== undefined));
const rootOf = (name: string): string => {
let cur = name;
for (let b = baseOf.get(cur); b !== null && b !== undefined; b = baseOf.get(cur)) cur = b;
return cur;
};
const unitKeyOf = (key: string): string => {
if (!key.startsWith("object:")) return key;
const name = key.slice("object:".length);
const inHierarchy =
typeof baseOf.get(name) === "string" || hasChildren.has(name) || name === RUNTIME_EMITTER_CLASS;
return inHierarchy ? `object:${rootOf(name)}` : key;
};
const units = new Map<string, typeof shapeDefs>();
for (const s of shapeDefs) {
const unit = unitKeyOf(s.key);
let members = units.get(unit);
if (!members) units.set(unit, (members = []));
members.push(s);
}
const cycleCapable = (t: IrType): boolean => {
switch (t.kind) {
case "func":
case "promise":
return true;
case "object":
return tracedShapes.has(`object:${t.className}`);
case "record":
return tracedShapes.has(`record:${t.shapeId}`);
case "union":
return tracedUnions.has(t.unionId);
case "map":
return cycleCapable(t.value);
case "array":
return cycleCapable(t.elem);
default:
return false;
}
};
let shrunk = true;
while (shrunk) {
shrunk = false;
for (const members of units.values()) {
if (
tracedShapes.has(members[0]!.key) &&
!members.some((s) => s.fields.some((f) => cycleCapable(f.type)))
) {
for (const s of members) tracedShapes.delete(s.key);
shrunk = true;
}
}
for (const u of mod.unions ?? []) {
if (tracedUnions.has(u.id) && !u.arms.some(cycleCapable)) {
tracedUnions.delete(u.id);
shrunk = true;
}
}
}
return { shapes: tracedShapes, unions: tracedUnions };
}
export { computeTraced } from "../cycle-analysis.js";
/* ── RC dispatch ──────────────────────────────────────────────────────── */
+59 -42
View File
@@ -1,4 +1,6 @@
import type { IrExpr, IrModule } from "./ir.js";
import type { IrExpr, IrStmt, IrModule } from "./ir.js";
import { everyExprChild, everyStmtChild } from "./traverse.js";
export interface ConstantNumericTable {
symbol: string;
@@ -41,48 +43,63 @@ export function findConstantNumericTables(mod: IrModule): ReadonlyMap<string, Co
? candidates.get(expr.localId) : undefined;
}
function visit(value: unknown): void {
if (Array.isArray(value)) { value.forEach(visit); return; }
if (value === null || typeof value !== "object") return;
const node = value as Record<string, unknown>;
if (node["kind"] === "closure") {
for (const id of (node as IrExpr & { kind: "closure" }).captures) {
const candidate = candidates.get(id);
if (candidate) candidate.rejected = true;
}
}
if (node["kind"] === "arrIntrinsic") {
const read = node as IrExpr & { kind: "arrIntrinsic" };
const candidate = candidateFor(read.receiver);
if (candidate && ((read.method === "getNumber" && read.args.length === 1) ||
(read.method === "length" && read.args.length === 0))) {
if (read.method === "getNumber") candidate.reads++;
visit(read.args);
return;
}
}
if (node["kind"] === "arrayGet" || node["kind"] === "arrayHas" || node["kind"] === "arrayState") {
const read = node as IrExpr & { kind: "arrayGet" | "arrayHas" | "arrayState" };
if (candidateFor(read.arr)) { visit(read.index); return; }
}
const candidate = typeof node["localId"] === "string" ? candidates.get(node["localId"]) : undefined;
if (candidate) {
if (node["kind"] === "assign" && ++candidate.writes === 1) {
const init = node["value"] as IrExpr;
if (init.kind === "arrayLit" && !init.spreads?.length && init.elems.length > 0 && init.elems.length <= MAX_TABLE_ELEMENTS) {
const values = init.elems.map(literalNumber);
if (values.every((n): n is number => n !== null)) candidate.values = values;
}
if (candidate.values === null) candidate.rejected = true;
} else {
candidate.rejected = true;
}
}
for (const [key, child] of Object.entries(node)) {
if (key !== "type" && key !== "loc") visit(child);
}
function reject(id: string): void {
const candidate = candidates.get(id);
if (candidate) candidate.rejected = true;
}
visit(mod.functions);
function expr(node: IrExpr): boolean {
switch (node.kind) {
case "closure":
for (const id of node.captures) reject(id);
break;
case "arrIntrinsic": {
const candidate = candidateFor(node.receiver);
if (candidate && ((node.method === "getNumber" && node.args.length === 1) ||
(node.method === "length" && node.args.length === 0))) {
if (node.method === "getNumber") candidate.reads++;
return node.args.every(expr);
}
break;
}
case "arrayGet": case "arrayHas": case "arrayState":
if (candidateFor(node.arr)) return expr(node.index);
break;
case "varRef": case "assignExpr": case "incDec":
reject(node.localId);
break;
}
return everyExprChild(node, expr, stmt);
}
function stmt(node: IrStmt): boolean {
switch (node.kind) {
case "assign": {
const candidate = candidates.get(node.localId);
if (candidate) {
candidate.writes++;
if (candidate.writes === 1) {
const init = node.value;
if (init.kind === "arrayLit" && !init.spreads?.length && init.elems.length > 0 && init.elems.length <= MAX_TABLE_ELEMENTS) {
const values: number[] = [];
for (const elem of init.elems) {
const value = literalNumber(elem);
if (value !== null) values.push(value);
}
if (values.length === init.elems.length) candidate.values = values;
}
if (candidate.values === null) candidate.rejected = true;
} else candidate.rejected = true;
}
break;
}
case "varDecl": case "forOf": case "rethrow":
reject(node.localId);
break;
}
return everyStmtChild(node, expr, stmt);
}
for (const fn of mod.functions) fn.body.every(stmt);
const tables = new Map<string, ConstantNumericTable>();
for (const [id, candidate] of candidates) {
if (!candidate.rejected && candidate.writes === 1 && candidate.reads > 0 && candidate.values !== null) {
+8 -16
View File
@@ -1,5 +1,7 @@
import type { IrExpr, IrLocal, IrStmt } from "./ir.js";
import { everyStmtList } from "./traverse.js";
/** A canonical byte loop whose induction variable is mathematically an
* unsigned integer for every body entry. Backends may keep this binding in
* integer storage while the loop runs, converting to f64 at ordinary JS
@@ -10,23 +12,13 @@ export interface IntegerBytesForLoop {
}
/** True when a lowered subtree writes `localId`. Local ids are unique per
* function, so a generic structured walk is sufficient and includes writes
* function, so typed traversal includes writes
* nested in expressions, branches, nested loops, and try/finally bodies. */
function writesLocal(value: unknown, localId: string): boolean {
if (Array.isArray(value)) return value.some((item) => writesLocal(item, localId));
if (value === null || typeof value !== "object") return false;
const node = value as { kind?: unknown; localId?: unknown };
if (
(node.kind === "assign" || node.kind === "assignExpr" || node.kind === "incDec") &&
node.localId === localId
) {
return true;
}
for (const [key, child] of Object.entries(value)) {
if (key === "type" || key === "loc") continue;
if (writesLocal(child, localId)) return true;
}
return false;
function writesLocal(body: IrStmt[], localId: string): boolean {
return !everyStmtList(body, {
expr: (expr) => (expr.kind !== "assignExpr" && expr.kind !== "incDec") || expr.localId !== localId,
stmt: (stmt) => stmt.kind !== "assign" || stmt.localId !== localId,
});
}
function isUnitIncrement(update: IrStmt | null, localId: string): boolean {
+120 -94
View File
@@ -1,33 +1,16 @@
import { F64, isRefCounted, type IrExpr, type IrFunction, type IrLocal, type IrModule, type IrRecordShape, type IrStmt, type IrType } from "./ir.js";
import { F64, isRefCounted, type IrExpr, type IrFunction, type IrLocal, type IrModule, type IrRecordShape, type IrStmt } from "./ir.js";
import { everyExpr, everyExprChild, everyStmtChild, everyStmtList, transformStmtList } from "./traverse.js";
type Node = Record<string, unknown>;
const MAX_FIELDS = 4;
const MAX_CALLEE_NODES = 256;
const MAX_INLINE_NODES = 1024;
/** IR is a plain tree. Types and source locations are metadata, not uses. */
function everyNode(value: unknown, visit: (node: Node) => boolean): boolean {
if (Array.isArray(value)) return value.every((v) => everyNode(v, visit));
if (value === null || typeof value !== "object") return true;
const node = value as Node;
return visit(node) && Object.entries(node).every(([key, child]) =>
key === "type" || key === "loc" || everyNode(child, visit));
}
function mapTree<T>(value: T, visit: (node: Node) => Node): T {
if (Array.isArray(value)) return value.map((v) => mapTree(v, visit)) as T;
if (value === null || typeof value !== "object") return value;
const node = visit(value as Node);
return Object.fromEntries(Object.entries(node).map(([key, child]) =>
[key, key === "type" || key === "loc" ? child : mapTree(child, visit)])) as T;
}
interface Producer { fn: IrFunction; shape: IrRecordShape; size: number }
/** Splitting an expression frame must not shorten a reference temporary's
* lifetime across later arguments, fields, or the original call itself. */
function scalarTemporaries(value: unknown): boolean {
return everyNode(value, (node) => !node["type"] || !isRefCounted(node["type"] as IrType));
function scalarTemporaries(value: IrExpr): boolean {
return everyExpr(value, { expr: (expr) => !isRefCounted(expr.type), stmt: () => true });
}
function producer(fn: IrFunction, shapes: ReadonlyMap<string, IrRecordShape>): Producer | null {
@@ -41,22 +24,23 @@ function producer(fn: IrFunction, shapes: ReadonlyMap<string, IrRecordShape>): P
fn.params.some((p) => p.type.kind !== "f64" && p.type.kind !== "bool")) return null;
let size = 0;
let returns = 0;
const eligible = everyNode(fn.body, (node) => {
if (typeof node["kind"] !== "string") return true;
if (++size > MAX_CALLEE_NODES) return false;
switch (node["kind"]) {
case "closure": case "selfRef": case "tryCatch":
case "awaitExpr": case "awaitUnionExpr": case "yieldExpr":
return false;
case "return": {
returns++;
const value = (node as Extract<IrStmt, { kind: "return" }>).value;
return value?.kind === "recordLit" && value.type.kind === "record" && value.type.shapeId === shape.id &&
value.fields.length === shape.fields.length && value.fields.every((f) =>
!f.drop && !f.overflow && f.value.type.kind === "f64" && scalarTemporaries(f.value) && shape.fields.some((sf) => sf.name === f.name));
const eligible = everyStmtList(fn.body, {
expr: (expr) => {
if (++size > MAX_CALLEE_NODES) return false;
switch (expr.kind) {
case "closure": case "selfRef": case "awaitExpr": case "awaitUnionExpr": case "yieldExpr": return false;
default: return true;
}
default: return true;
}
},
stmt: (stmt) => {
if (++size > MAX_CALLEE_NODES || stmt.kind === "tryCatch") return false;
if (stmt.kind !== "return") return true;
returns++;
const value = stmt.value;
return value?.kind === "recordLit" && value.type.kind === "record" && value.type.shapeId === shape.id &&
value.fields.length === shape.fields.length && value.fields.every((f) =>
!f.drop && !f.overflow && f.value.type.kind === "f64" && scalarTemporaries(f.value) && shape.fields.some((sf) => sf.name === f.name));
},
});
return eligible && returns > 0 ? { fn, shape, size } : null;
}
@@ -66,22 +50,47 @@ function producer(fn: IrFunction, shapes: ReadonlyMap<string, IrRecordShape>): P
* keeps the original object. Count declarations too: no reinitialization. */
function fieldOnlyUses(fn: IrFunction, localId: string, shape: IrRecordShape): boolean {
let declarations = 0;
function visit(value: unknown): boolean {
if (Array.isArray(value)) return value.every(visit);
if (value === null || typeof value !== "object") return true;
const node = value as Node;
if (node["kind"] === "recordGet") {
const read = node as Extract<IrExpr, { kind: "recordGet" }>;
if (read.obj.kind === "varRef" && read.obj.localId === localId) {
return read.shapeId === shape.id && read.type.kind === "f64" && shape.fields.some((f) => f.name === read.field);
}
function expr(node: IrExpr): boolean {
if (node.kind === "recordGet" && node.obj.kind === "varRef" && node.obj.localId === localId) {
return node.shapeId === shape.id && node.type.kind === "f64" && shape.fields.some((f) => f.name === node.field);
}
if (node["localId"] === localId) {
if (node["kind"] !== "varDecl" || ++declarations !== 1) return false;
switch (node.kind) {
case "varRef": case "incDec": case "assignExpr":
if (node.localId === localId) return false;
break;
case "closure":
if (node.captures.includes(localId)) return false;
break;
}
return Object.entries(node).every(([key, child]) => key === "type" || key === "loc" || visit(child));
return everyExprChild(node, expr, stmt);
}
return visit(fn.body) && declarations === 1;
function stmt(node: IrStmt): boolean {
switch (node.kind) {
case "varDecl":
if (node.localId === localId && ++declarations !== 1) return false;
break;
case "assign": case "forOf": case "rethrow":
if (node.localId === localId) return false;
break;
}
return everyStmtChild(node, expr, stmt);
}
return fn.body.every(stmt) && declarations === 1;
}
/** Label ids participate in the same fresh-name namespace as local ids. */
function collectLabels(body: IrStmt[], used: Set<string>): void {
everyStmtList(body, {
expr: () => true,
stmt: (node) => {
switch (node.kind) {
case "while": case "doWhile": case "for": case "forOf": case "switch": case "block":
for (const label of node.labels ?? []) used.add(label);
break;
}
return true;
},
});
}
interface Replacement {
@@ -108,14 +117,17 @@ export function scalarizeNumericRecords(mod: IrModule): IrModule {
if (fn.async || fn.generator) return fn;
const locals = new Map(fn.locals.map((l) => [l.id, l]));
const used = new Set([...locals.keys(), ...(mod.globals ?? []).map((g) => g.id)]);
const loopHeaders = new Set<unknown>();
everyNode(fn.body, (node) => {
if (Array.isArray(node["labels"])) for (const label of node["labels"]) used.add(String(label));
// A for header accepts one declaration/assignment, not the statement
// block this pass produces. Leave those declarations on the heap path.
if (node["kind"] === "for") { loopHeaders.add(node["init"]); loopHeaders.add(node["update"]); }
collectLabels(fn.body, used);
// A header accepts one statement, not an inlined block. Local ids are
// unique within a function and fieldOnlyUses proves one declaration.
const loopHeaders = new Set<string>();
everyStmtList(fn.body, { expr: () => true, stmt: (node) => {
if (node.kind === "for") {
if (node.init?.kind === "varDecl") loopHeaders.add(node.init.localId);
if (node.update?.kind === "varDecl") loopHeaders.add(node.update.localId);
}
return true;
});
} });
let next = 0;
const fresh = (): string => {
let id: string;
@@ -126,21 +138,17 @@ export function scalarizeNumericRecords(mod: IrModule): IrModule {
const added: IrLocal[] = [];
const replacements = new Map<string, Replacement>();
let budget = MAX_INLINE_NODES;
everyNode(fn.body, (node) => {
if (node["kind"] !== "varDecl" || loopHeaders.has(node)) return true;
const decl = node as Extract<IrStmt, { kind: "varDecl" }>;
everyStmtList(fn.body, { expr: () => true, stmt: (decl) => {
if (decl.kind !== "varDecl" || loopHeaders.has(decl.localId)) return true;
const local = locals.get(decl.localId);
const call = decl.init;
if (!local || local.mutable || local.boxed || local.tdz || local.type.kind !== "record" || call?.kind !== "call") return true;
const p = producers.get(call.callee);
if (!p || p.fn.name === fn.name || p.size > budget || p.shape.id !== local.type.shapeId ||
call.args.length !== p.fn.params.length || !scalarTemporaries(call.args) || !fieldOnlyUses(fn, local.id, p.shape)) return true;
call.args.length !== p.fn.params.length || !call.args.every(scalarTemporaries) || !fieldOnlyUses(fn, local.id, p.shape)) return true;
budget -= p.size;
for (const l of p.fn.locals) used.add(l.id);
everyNode(p.fn.body, (n) => {
if (Array.isArray(n["labels"])) for (const l of n["labels"]) used.add(String(l));
return true;
});
collectLabels(p.fn.body, used);
const fields = new Map(p.shape.fields.map((f) => [f.name, fresh()]));
const renamed = new Map(p.fn.locals.map((l) => [l.id, fresh()]));
const labels = new Map<string, string>();
@@ -150,43 +158,61 @@ export function scalarizeNumericRecords(mod: IrModule): IrModule {
return labels.get(name)!;
};
for (const f of p.shape.fields) added.push({ id: fields.get(f.name)!, name: `${local.name}.${f.name}`, type: F64, mutable: true });
for (const l of p.fn.locals) added.push({ ...l, id: renamed.get(l.id)! });
const body = mapTree(p.fn.body, (n): Node => {
if (n["kind"] === "return") {
const ret = n as Extract<IrStmt, { kind: "return" }>;
const literal = ret.value as Extract<IrExpr, { kind: "recordLit" }>;
const assignments: IrStmt[] = literal.fields.map((f) => ({ kind: "assign", localId: fields.get(f.name)!, value: f.value, loc: ret.loc }));
return { kind: "block", body: [...assignments, { kind: "break", label: exit, loc: ret.loc }], loc: ret.loc };
}
const out = { ...n };
if (typeof out["localId"] === "string" && renamed.has(out["localId"])) out["localId"] = renamed.get(out["localId"]);
if (typeof out["label"] === "string" && out["label"] !== exit) out["label"] = label(out["label"]);
if (Array.isArray(out["labels"])) out["labels"] = out["labels"].map((l) => label(String(l)));
return out;
for (const l of p.fn.locals) added.push({ ...l!, id: renamed.get(l.id)! });
const body = transformStmtList(p.fn.body, {
expr: (expr) => {
switch (expr.kind) {
case "varRef": return { ...expr, localId: renamed.get(expr.localId) ?? expr.localId };
case "incDec": return { ...expr, localId: renamed.get(expr.localId) ?? expr.localId };
case "assignExpr": return { ...expr, localId: renamed.get(expr.localId) ?? expr.localId };
default: return expr;
}
},
stmt: (stmt): IrStmt => {
switch (stmt.kind) {
case "return": {
const literal = stmt.value;
if (literal?.kind !== "recordLit") return stmt; // producer proved this shape
const assignments: IrStmt[] = literal.fields.map((f): IrStmt => ({ kind: "assign", localId: fields.get(f.name)!, value: f.value, loc: stmt.loc }));
return { kind: "block", body: [...assignments, { kind: "break", label: exit, loc: stmt.loc }], loc: stmt.loc };
}
case "varDecl": return { ...stmt, localId: renamed.get(stmt.localId) ?? stmt.localId };
case "assign": return { ...stmt, localId: renamed.get(stmt.localId) ?? stmt.localId };
case "forOf": return stmt.labels
? { ...stmt, localId: renamed.get(stmt.localId) ?? stmt.localId, labels: stmt.labels.map(label) }
: { ...stmt, localId: renamed.get(stmt.localId) ?? stmt.localId };
case "break": return stmt.label && stmt.label !== exit ? { ...stmt, label: label(stmt.label) } : stmt;
case "continue": return stmt.label ? { ...stmt, label: label(stmt.label) } : stmt;
case "while": return stmt.labels ? { ...stmt, labels: stmt.labels.map(label) } : stmt;
case "doWhile": return stmt.labels ? { ...stmt, labels: stmt.labels.map(label) } : stmt;
case "for": return stmt.labels ? { ...stmt, labels: stmt.labels.map(label) } : stmt;
case "switch": return stmt.labels ? { ...stmt, labels: stmt.labels.map(label) } : stmt;
case "block": return stmt.labels ? { ...stmt, labels: stmt.labels.map(label) } : stmt;
default: return stmt;
}
},
});
const parameters: IrStmt[] = p.fn.params.map((param, i) => ({ kind: "varDecl", localId: renamed.get(param.localId)!, init: call.args[i]!, loc: decl.loc }));
const declarations: IrStmt[] = [...fields.values()].map((id) => ({ kind: "varDecl", localId: id, init: null, loc: decl.loc }));
const parameters: IrStmt[] = p.fn.params.map((param, i): IrStmt => ({ kind: "varDecl", localId: renamed.get(param.localId)!, init: call.args[i]!, loc: decl.loc }));
const declarations: IrStmt[] = [...fields.values()].map((id): IrStmt => ({ kind: "varDecl", localId: id!, init: null, loc: decl.loc }));
replacements.set(local.id, { fields, body: [...declarations, { kind: "block", labels: [exit], body: [...parameters, ...body], loc: decl.loc }] });
return true;
});
} });
if (replacements.size === 0) return fn;
changed = true;
const body = mapTree(fn.body, (node): Node => {
if (node["kind"] === "varDecl") {
const decl = node as Extract<IrStmt, { kind: "varDecl" }>;
const replacement = replacements.get(decl.localId);
if (replacement) return { kind: "block", body: replacement.body, loc: decl.loc };
}
if (node["kind"] === "recordGet") {
const read = node as Extract<IrExpr, { kind: "recordGet" }>;
if (read.obj.kind === "varRef") {
const id = replacements.get(read.obj.localId)?.fields.get(read.field);
if (id) return { kind: "varRef", localId: id, type: F64, loc: read.loc };
const body = transformStmtList(fn.body, {
stmt: (stmt) => {
const replacement = stmt.kind === "varDecl" ? replacements.get(stmt.localId) : undefined;
return replacement ? { kind: "block", body: replacement.body, loc: stmt.loc } : stmt;
},
expr: (expr) => {
if (expr.kind === "recordGet" && expr.obj.kind === "varRef") {
const id = replacements.get(expr.obj.localId)?.fields.get(expr.field);
if (id) return { kind: "varRef", localId: id, type: F64, loc: expr.loc };
}
}
return node;
return expr;
},
});
return { ...fn, locals: [...fn.locals.filter((l) => !replacements.has(l.id)), ...added], body };
return { ...fn!, locals: [...fn.locals.filter((l) => !replacements.has(l.id)), ...added], body };
});
return changed ? { ...mod, functions } : mod;
}
+123
View File
@@ -0,0 +1,123 @@
import { fileURLToPath } from "node:url";
import { afterAll, expect, test } from "vitest";
import * as ts from "../frontend/ts7/adapter.js";
import { BOOL, F64, VOID, type IrExpr, type IrStmt } from "./ir.js";
import { everyExpr, everyExprChild, everyStmtChild, everyStmtList, mapExprChildren, mapStmtChildren, transformStmtList } from "./traverse.js";
const loc = { file: "traverse.ts", start: 0, end: 1 };
const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const statement = (value: number): IrStmt => ({ kind: "exprStmt", expr: num(value), loc });
// Derive child slots from the declared IR, independently of the traversal
// tables. Exhaustive switches catch new variants; this also catches a new
// child field added to an existing variant. These structural samples need
// not be executable: a child's identity and position are the contract here.
const path = fileURLToPath(new URL("./ir.ts", import.meta.url));
const program = ts.createProgram([path], { noLib: true, noResolve: true, types: [] });
const source = program.getSourceFile(path)!;
afterAll(() => program.dispose());
function childType(type: ts.TypeNode): boolean {
if (ts.isTypeReferenceNode(type)) return ["IrExpr", "IrStmt"].includes(type.typeName.getText());
if (ts.isArrayTypeNode(type)) return childType(type.elementType);
if (ts.isUnionTypeNode(type)) return type.types.some(childType);
if (ts.isTypeLiteralNode(type)) return type.members.some((member) => ts.isPropertySignature(member) && member.type && childType(member.type));
return false;
}
for (const name of ["IrExpr", "IrStmt"]) {
const alias = source.statements.find((node): node is ts.TypeAliasDeclaration => ts.isTypeAliasDeclaration(node) && node.name.text === name)!;
if (!ts.isUnionTypeNode(alias.type)) throw new Error("IR must be a discriminated union");
for (const variant of alias.type.types) {
if (!ts.isTypeLiteralNode(variant)) throw new Error("IR variants must be records");
const members = variant.members.filter(ts.isPropertySignature);
const tag = members.find((member) => member.name.getText() === "kind")!.type!;
const kind = JSON.parse(tag.getText()) as string;
test(`${name}.${kind}: every declared child is visited and rewritten, metadata is preserved`, () => {
for (const presence of [true, false]) {
const expected: (IrExpr | IrStmt)[] = [];
function value(type: ts.TypeNode): unknown {
if (ts.isTypeReferenceNode(type)) {
const node = type.typeName.getText() === "IrExpr" ? num(expected.length) : statement(expected.length);
expected.push(node);
return node;
}
if (ts.isUnionTypeNode(type)) return presence ? value(type.types.find(childType)!) : null;
if (ts.isArrayTypeNode(type)) return [value(type.elementType), value(type.elementType)];
if (ts.isTypeLiteralNode(type)) {
const result: Record<string, unknown> = { metadata: "keep wrapper fields", drop: true, overflow: true };
for (const member of type.members) {
if (ts.isPropertySignature(member) && member.type && childType(member.type)) result[member.name.getText()] = value(member.type);
}
return result;
}
throw new Error("unexpected child slot");
}
const input: Record<string, unknown> = { kind, type: F64, loc, metadata: { kind: "throw", value: num(999) } };
for (const member of members) {
if (member.type && childType(member.type) && (presence || !member.postfixToken)) input[member.name.getText()] = value(member.type);
}
const before = structuredClone(input);
const seen: (IrExpr | IrStmt)[] = [];
const visit = (node: IrExpr | IrStmt): boolean => { seen.push(node); return true; };
const each = (node: unknown, e: (e: IrExpr) => boolean, s: (s: IrStmt) => boolean): boolean => name === "IrExpr"
? everyExprChild(node as IrExpr, e, s) : everyStmtChild(node as IrStmt, e, s);
expect(each(input, visit, visit)).toBe(true);
expect(seen).toEqual(expected);
expected.forEach((child, i) => expect(seen[i]).toBe(child));
// Every possible stopping position must suppress later children.
for (let stop = 0; stop < expected.length; stop++) {
let count = 0;
const stopAt = (): boolean => count++ !== stop;
expect(each(input, stopAt, stopAt)).toBe(false);
expect(count).toBe(stop + 1);
}
const replacements: (IrExpr | IrStmt)[] = [];
const expr = (): IrExpr => { const out = num(100 + replacements.length); replacements.push(out); return out; };
const stmt = (): IrStmt => { const out = statement(100 + replacements.length); replacements.push(out); return out; };
const output = name === "IrExpr" ? mapExprChildren(input as IrExpr, expr, stmt) : mapStmtChildren(input as IrStmt, expr, stmt);
seen.length = 0;
each(output, visit, visit);
expect(seen).toEqual(replacements);
replacements.forEach((child, i) => expect(seen[i]).toBe(child));
expect(input).toEqual(before);
expect(output.loc).toBe(loc);
expect((output as unknown as Record<string, unknown>)["metadata"]).toBe(input["metadata"]);
for (const member of members) {
const key = member.name.getText();
if (member.postfixToken && !presence) expect(Object.hasOwn(output, key)).toBe(false);
}
}
});
}
}
test("recursive preorder reaches statements inside expressions and stops the complete walk", () => {
const body: IrStmt[] = [{ kind: "if", cond: { kind: "boolLit", value: true, type: BOOL, loc }, then: [
{ kind: "exprStmt", expr: { kind: "seqExpr", stmts: [statement(1)], result: num(2), type: F64, loc }, loc },
], else_: [statement(3)], loc }, statement(4)];
const seen: string[] = [];
expect(everyStmtList(body, {
expr: (e) => { seen.push(e.kind); return e.kind !== "numLit"; },
stmt: (s) => { seen.push(s.kind); return true; },
})).toBe(false);
expect(seen).toEqual(["if", "boolLit", "exprStmt", "seqExpr", "exprStmt", "numLit"]);
expect(everyExpr(num(0), { expr: () => false, stmt: () => { throw new Error("unexpected child"); } })).toBe(false);
});
test("preorder transformations traverse replacement children exactly once", () => {
const body = [statement(1)];
const seen: string[] = [];
const result = transformStmtList(body, {
stmt: (s) => {
seen.push(s.kind);
return s.kind === "exprStmt" ? { kind: "return", value: s.expr, loc: s.loc } : s;
},
expr: (e) => {
seen.push(e.kind);
return e.kind === "numLit" ? { kind: "intrinsic", name: "console.log", args: [], type: VOID, loc: e.loc } : e;
},
});
expect(seen).toEqual(["exprStmt", "numLit"]);
expect(result).toEqual([{ kind: "return", value: { kind: "intrinsic", name: "console.log", args: [], type: VOID, loc }, loc }]);
expect(body).toEqual([statement(1)]);
});
+571
View File
@@ -0,0 +1,571 @@
import type { IrExpr, IrStmt } from "./ir.js";
/** Typed structural traversal of executable IR. Types, source locations,
* captures, labels and other metadata are deliberately not child nodes.
* Child order follows the IR schema; this visits all branches, including
* dormant callbacks and lazy operands, rather than simulating execution.
* Predicates can stop traversal without allocating a dynamic snapshot. */
export interface IrVisitor {
expr: (expr: IrExpr) => boolean;
stmt: (stmt: IrStmt) => boolean;
}
export interface IrTransform {
expr: (expr: IrExpr) => IrExpr;
stmt: (stmt: IrStmt) => IrStmt;
}
/** Visit immediate children, stopping on the first false result. */
export function everyExprChild(node: IrExpr, expr: (expr: IrExpr) => boolean, stmt: (stmt: IrStmt) => boolean): boolean {
switch (node.kind) {
case "numLit":
return true;
case "strLit":
return true;
case "moduleNsRef":
return true;
case "boolLit":
return true;
case "unitLit":
return true;
case "varRef":
return true;
case "bin":
return expr(node.left) && expr(node.right);
case "unary":
return expr(node.operand);
case "incDec":
return true;
case "fieldIncDec":
return expr(node.obj);
case "assignExpr":
return expr(node.value);
case "toBool":
return expr(node.operand);
case "logical":
return expr(node.left) && expr(node.right);
case "strConcat":
return expr(node.left) && expr(node.right);
case "strEq":
return expr(node.left) && expr(node.right);
case "strCmp":
return expr(node.left) && expr(node.right);
case "toString":
return expr(node.operand);
case "ternary":
return expr(node.cond) && expr(node.then) && expr(node.else_);
case "nullish":
return expr(node.left) && expr(node.right);
case "orDefault":
return expr(node.left) && expr(node.right);
case "optChain":
return expr(node.receiver) && expr(node.body);
case "chainRecv":
return true;
case "strIntrinsic":
return expr(node.receiver) && node.args.every((child) => expr(child));
case "regexLit":
return true;
case "templateStrings":
return true;
case "regexIntrinsic":
return expr(node.receiver) && node.args.every((child) => expr(child));
case "arrayLit":
return node.elems.every((child) => expr(child));
case "arrayNewLen":
return expr(node.length);
case "arrayGet":
return expr(node.arr) && expr(node.index);
case "arrayHas":
return expr(node.arr) && expr(node.index);
case "arrayState":
return expr(node.arr) && expr(node.index);
case "arrIntrinsic":
return expr(node.receiver) && node.args.every((child) => expr(child));
case "bytesNew":
return (node.source === null || expr(node.source));
case "bytesIntrinsic":
return expr(node.receiver) && node.args.every((child) => expr(child));
case "mapNew":
return (node.seed === undefined || node.seed.every((child) => (expr(child.key) && expr(child.value))));
case "mapIntrinsic":
return expr(node.receiver) && node.args.every((child) => expr(child));
case "setNew":
return (node.seed === undefined || expr(node.seed));
case "setIntrinsic":
return expr(node.receiver) && node.args.every((child) => expr(child));
case "call":
return node.args.every((child) => expr(child));
case "ffiCall":
return node.args.every((child) => expr(child));
case "closure":
return true;
case "callValue":
return expr(node.callee) && node.args.every((child) => expr(child));
case "selfRef":
return true;
case "yieldExpr":
return (node.value === null || expr(node.value));
case "genResume":
return expr(node.gen) && (node.arg === null || expr(node.arg));
case "awaitExpr":
return expr(node.value);
case "awaitUnionExpr":
return expr(node.value);
case "newPromise":
return expr(node.executor);
case "promiseWithResolvers":
return true;
case "new":
return node.args.every((child) => expr(child));
case "classRef":
return true;
case "newValue":
return expr(node.callee) && node.args.every((child) => expr(child));
case "instanceOfValue":
return expr(node.value) && expr(node.classValue);
case "upcast":
return expr(node.value);
case "promiseVoidWiden":
return expr(node.value);
case "downcast":
return expr(node.value);
case "instanceOf":
return expr(node.value);
case "virtualCall":
return node.args.every((child) => expr(child));
case "fieldGet":
return expr(node.obj);
case "recordLit":
return node.fields.every((child) => (expr(child.value)));
case "recordClone":
return expr(node.source) && node.overrides.every((child) => (expr(child.value)));
case "recordGet":
return expr(node.obj);
case "recordKeyGet":
return expr(node.obj) && expr(node.key);
case "dynFrom":
return expr(node.value);
case "dynFromJsval":
return expr(node.value);
case "dynCall":
return expr(node.callee) && node.args.every((child) => expr(child));
case "dynInvoke":
return expr(node.recv) && node.args.every((child) => expr(child));
case "dynArrLit":
return node.elems.every((child) => expr(child));
case "dynObjLit":
return (node.fields === undefined || node.fields.every((child) => (expr(child.key) && expr(child.value))));
case "dynTest":
return expr(node.value);
case "dynKeyGet":
return expr(node.key) && expr(node.value);
case "dynHasKey":
return expr(node.value);
case "dynScalarEq":
return expr(node.left) && expr(node.right);
case "seqExpr":
return node.stmts.every((child) => stmt(child)) && expr(node.result);
case "dynDestrCheck":
return expr(node.value);
case "dynIterN":
return expr(node.value);
case "recordOvfKeys":
return expr(node.obj);
case "recordOvfHas":
return expr(node.obj) && expr(node.key);
case "unionWrap":
return expr(node.value);
case "unionFuncEq":
return expr(node.union) && expr(node.func);
case "caughtTest":
return expr(node.value);
case "caughtNarrow":
return expr(node.value);
case "caughtCheck":
return expr(node.value);
case "caughtToDyn":
return expr(node.value);
case "unionNarrow":
return expr(node.value);
case "unionDisc":
return expr(node.value);
case "unionKeyGet":
return expr(node.key) && expr(node.value);
case "unionIsTag":
return expr(node.value);
case "unionEq":
return expr(node.left) && expr(node.right);
case "intrinsic":
return node.args.every((child) => expr(child));
case "libCall":
return node.args.every((child) => expr(child));
case "jsonStringify":
return expr(node.value);
case "dynCheck":
return expr(node.value);
case "jsMarshal":
return expr(node.value);
case "jsOp":
return node.args.every((child) => expr(child));
case "jsExit":
return expr(node.value);
case "jsBridgePromise":
return expr(node.value);
}
node satisfies never;
throw new Error("unhandled IR node");
}
/** Visit immediate children, stopping on the first false result. */
export function everyStmtChild(node: IrStmt, expr: (expr: IrExpr) => boolean, stmt: (stmt: IrStmt) => boolean): boolean {
switch (node.kind) {
case "varDecl":
return (node.init === null || expr(node.init));
case "assign":
return expr(node.value);
case "exprStmt":
return expr(node.expr);
case "if":
return expr(node.cond) && node.then.every((child) => stmt(child)) && (node.else_ === null || node.else_.every((child) => stmt(child)));
case "while":
return expr(node.cond) && node.body.every((child) => stmt(child));
case "doWhile":
return node.body.every((child) => stmt(child)) && expr(node.cond);
case "switch":
return expr(node.disc) && node.cases.every((child) => ((child.test === null || expr(child.test)) && child.body.every((child) => stmt(child))));
case "for":
return (node.init === null || stmt(node.init)) && (node.cond === null || expr(node.cond)) && (node.update === null || stmt(node.update)) && node.body.every((child) => stmt(child));
case "arraySet":
return expr(node.arr) && expr(node.index) && expr(node.value);
case "arraySetLength":
return expr(node.arr) && expr(node.length);
case "arraySetUndefined":
return expr(node.arr) && expr(node.index);
case "arrayDelete":
return expr(node.arr) && expr(node.index);
case "bytesSet":
return expr(node.arr) && expr(node.index) && expr(node.value);
case "forOf":
return expr(node.iterable) && node.body.every((child) => stmt(child));
case "return":
return (node.value === null || expr(node.value));
case "fieldSet":
return expr(node.obj) && expr(node.value);
case "recordSet":
return expr(node.obj) && expr(node.value);
case "recordKeySet":
return expr(node.obj) && expr(node.key) && expr(node.value);
case "recordKeyDelete":
return expr(node.obj) && expr(node.key);
case "break":
return true;
case "continue":
return true;
case "block":
return node.body.every((child) => stmt(child));
case "throw":
return expr(node.value);
case "runtimeFence":
return true;
case "rethrow":
return true;
case "tryCatch":
return node.tryBody.every((child) => stmt(child)) && (node.catchBody === null || node.catchBody.every((child) => stmt(child))) && (node.finallyBody === null || node.finallyBody.every((child) => stmt(child)));
}
node satisfies never;
throw new Error("unhandled IR node");
}
/** Copy a node with transformed immediate children; preserve all metadata. */
export function mapExprChildren(node: IrExpr, expr: (expr: IrExpr) => IrExpr, stmt: (stmt: IrStmt) => IrStmt): IrExpr {
switch (node.kind) {
case "numLit":
return node;
case "strLit":
return node;
case "moduleNsRef":
return node;
case "boolLit":
return node;
case "unitLit":
return node;
case "varRef":
return node;
case "bin":
return { ...node, left: expr(node.left), right: expr(node.right) };
case "unary":
return { ...node, operand: expr(node.operand) };
case "incDec":
return node;
case "fieldIncDec":
return { ...node, obj: expr(node.obj) };
case "assignExpr":
return { ...node, value: expr(node.value) };
case "toBool":
return { ...node, operand: expr(node.operand) };
case "logical":
return { ...node, left: expr(node.left), right: expr(node.right) };
case "strConcat":
return { ...node, left: expr(node.left), right: expr(node.right) };
case "strEq":
return { ...node, left: expr(node.left), right: expr(node.right) };
case "strCmp":
return { ...node, left: expr(node.left), right: expr(node.right) };
case "toString":
return { ...node, operand: expr(node.operand) };
case "ternary":
return { ...node, cond: expr(node.cond), then: expr(node.then), else_: expr(node.else_) };
case "nullish":
return { ...node, left: expr(node.left), right: expr(node.right) };
case "orDefault":
return { ...node, left: expr(node.left), right: expr(node.right) };
case "optChain":
return { ...node, receiver: expr(node.receiver), body: expr(node.body) };
case "chainRecv":
return node;
case "strIntrinsic":
return { ...node, receiver: expr(node.receiver), args: node.args.map((child) => expr(child)) };
case "regexLit":
return node;
case "templateStrings":
return node;
case "regexIntrinsic":
return { ...node, receiver: expr(node.receiver), args: node.args.map((child) => expr(child)) };
case "arrayLit":
return { ...node, elems: node.elems.map((child) => expr(child)) };
case "arrayNewLen":
return { ...node, length: expr(node.length) };
case "arrayGet":
return { ...node, arr: expr(node.arr), index: expr(node.index) };
case "arrayHas":
return { ...node, arr: expr(node.arr), index: expr(node.index) };
case "arrayState":
return { ...node, arr: expr(node.arr), index: expr(node.index) };
case "arrIntrinsic":
return { ...node, receiver: expr(node.receiver), args: node.args.map((child) => expr(child)) };
case "bytesNew":
return { ...node, source: node.source === null ? null : expr(node.source) };
case "bytesIntrinsic":
return { ...node, receiver: expr(node.receiver), args: node.args.map((child) => expr(child)) };
case "mapNew":
return node.seed === undefined ? node : { ...node, seed: node.seed.map((child) => ({ ...child, key: expr(child.key), value: expr(child.value) })) };
case "mapIntrinsic":
return { ...node, receiver: expr(node.receiver), args: node.args.map((child) => expr(child)) };
case "setNew":
return node.seed === undefined ? node : { ...node, seed: expr(node.seed) };
case "setIntrinsic":
return { ...node, receiver: expr(node.receiver), args: node.args.map((child) => expr(child)) };
case "call":
return { ...node, args: node.args.map((child) => expr(child)) };
case "ffiCall":
return { ...node, args: node.args.map((child) => expr(child)) };
case "closure":
return node;
case "callValue":
return { ...node, callee: expr(node.callee), args: node.args.map((child) => expr(child)) };
case "selfRef":
return node;
case "yieldExpr":
return { ...node, value: node.value === null ? null : expr(node.value) };
case "genResume":
return { ...node, gen: expr(node.gen), arg: node.arg === null ? null : expr(node.arg) };
case "awaitExpr":
return { ...node, value: expr(node.value) };
case "awaitUnionExpr":
return { ...node, value: expr(node.value) };
case "newPromise":
return { ...node, executor: expr(node.executor) };
case "promiseWithResolvers":
return node;
case "new":
return { ...node, args: node.args.map((child) => expr(child)) };
case "classRef":
return node;
case "newValue":
return { ...node, callee: expr(node.callee), args: node.args.map((child) => expr(child)) };
case "instanceOfValue":
return { ...node, value: expr(node.value), classValue: expr(node.classValue) };
case "upcast":
return { ...node, value: expr(node.value) };
case "promiseVoidWiden":
return { ...node, value: expr(node.value) };
case "downcast":
return { ...node, value: expr(node.value) };
case "instanceOf":
return { ...node, value: expr(node.value) };
case "virtualCall":
return { ...node, args: node.args.map((child) => expr(child)) };
case "fieldGet":
return { ...node, obj: expr(node.obj) };
case "recordLit":
return { ...node, fields: node.fields.map((child) => ({ ...child!, value: expr(child.value) })) };
case "recordClone":
return { ...node, source: expr(node.source), overrides: node.overrides.map((child) => ({ ...child!, value: expr(child.value) })) };
case "recordGet":
return { ...node, obj: expr(node.obj) };
case "recordKeyGet":
return { ...node, obj: expr(node.obj), key: expr(node.key) };
case "dynFrom":
return { ...node, value: expr(node.value) };
case "dynFromJsval":
return { ...node, value: expr(node.value) };
case "dynCall":
return { ...node, callee: expr(node.callee), args: node.args.map((child) => expr(child)) };
case "dynInvoke":
return { ...node, recv: expr(node.recv), args: node.args.map((child) => expr(child)) };
case "dynArrLit":
return { ...node, elems: node.elems.map((child) => expr(child)) };
case "dynObjLit":
return node.fields === undefined ? node : { ...node, fields: node.fields.map((child) => ({ ...child, key: expr(child.key), value: expr(child.value) })) };
case "dynTest":
return { ...node, value: expr(node.value) };
case "dynKeyGet":
return { ...node, key: expr(node.key), value: expr(node.value) };
case "dynHasKey":
return { ...node, value: expr(node.value) };
case "dynScalarEq":
return { ...node, left: expr(node.left), right: expr(node.right) };
case "seqExpr":
return { ...node, stmts: node.stmts.map((child) => stmt(child)), result: expr(node.result) };
case "dynDestrCheck":
return { ...node, value: expr(node.value) };
case "dynIterN":
return { ...node, value: expr(node.value) };
case "recordOvfKeys":
return { ...node, obj: expr(node.obj) };
case "recordOvfHas":
return { ...node, obj: expr(node.obj), key: expr(node.key) };
case "unionWrap":
return { ...node, value: expr(node.value) };
case "unionFuncEq":
return { ...node, union: expr(node.union), func: expr(node.func) };
case "caughtTest":
return { ...node, value: expr(node.value) };
case "caughtNarrow":
return { ...node, value: expr(node.value) };
case "caughtCheck":
return { ...node, value: expr(node.value) };
case "caughtToDyn":
return { ...node, value: expr(node.value) };
case "unionNarrow":
return { ...node, value: expr(node.value) };
case "unionDisc":
return { ...node, value: expr(node.value) };
case "unionKeyGet":
return { ...node, key: expr(node.key), value: expr(node.value) };
case "unionIsTag":
return { ...node, value: expr(node.value) };
case "unionEq":
return { ...node, left: expr(node.left), right: expr(node.right) };
case "intrinsic":
return { ...node, args: node.args.map((child) => expr(child)) };
case "libCall":
return { ...node, args: node.args.map((child) => expr(child)) };
case "jsonStringify":
return { ...node, value: expr(node.value) };
case "dynCheck":
return { ...node, value: expr(node.value) };
case "jsMarshal":
return { ...node, value: expr(node.value) };
case "jsOp":
return { ...node, args: node.args.map((child) => expr(child)) };
case "jsExit":
return { ...node, value: expr(node.value) };
case "jsBridgePromise":
return { ...node, value: expr(node.value) };
}
node satisfies never;
throw new Error("unhandled IR node");
}
/** Copy a node with transformed immediate children; preserve all metadata. */
export function mapStmtChildren(node: IrStmt, expr: (expr: IrExpr) => IrExpr, stmt: (stmt: IrStmt) => IrStmt): IrStmt {
switch (node.kind) {
case "varDecl":
return { ...node, init: node.init === null ? null : expr(node.init) };
case "assign":
return { ...node, value: expr(node.value) };
case "exprStmt":
return { ...node, expr: expr(node.expr) };
case "if":
return { ...node, cond: expr(node.cond), then: node.then.map((child) => stmt(child)), else_: node.else_ === null ? null : node.else_.map((child) => stmt(child)) };
case "while":
return { ...node, cond: expr(node.cond), body: node.body.map((child) => stmt(child)) };
case "doWhile":
return { ...node, body: node.body.map((child) => stmt(child)), cond: expr(node.cond) };
case "switch":
return { ...node, disc: expr(node.disc), cases: node.cases.map((child) => ({ ...child, test: child.test === null ? null : expr(child.test), body: child.body.map((child) => stmt(child)) })) };
case "for":
return { ...node, init: node.init === null ? null : stmt(node.init), cond: node.cond === null ? null : expr(node.cond), update: node.update === null ? null : stmt(node.update), body: node.body.map((child) => stmt(child)) };
case "arraySet":
return { ...node, arr: expr(node.arr), index: expr(node.index), value: expr(node.value) };
case "arraySetLength":
return { ...node, arr: expr(node.arr), length: expr(node.length) };
case "arraySetUndefined":
return { ...node, arr: expr(node.arr), index: expr(node.index) };
case "arrayDelete":
return { ...node, arr: expr(node.arr), index: expr(node.index) };
case "bytesSet":
return { ...node, arr: expr(node.arr), index: expr(node.index), value: expr(node.value) };
case "forOf":
return { ...node, iterable: expr(node.iterable), body: node.body.map((child) => stmt(child)) };
case "return":
return { ...node, value: node.value === null ? null : expr(node.value) };
case "fieldSet":
return { ...node, obj: expr(node.obj), value: expr(node.value) };
case "recordSet":
return { ...node, obj: expr(node.obj), value: expr(node.value) };
case "recordKeySet":
return { ...node, obj: expr(node.obj), key: expr(node.key), value: expr(node.value) };
case "recordKeyDelete":
return { ...node, obj: expr(node.obj), key: expr(node.key) };
case "break":
return node;
case "continue":
return node;
case "block":
return { ...node, body: node.body.map((child) => stmt(child)) };
case "throw":
return { ...node, value: expr(node.value) };
case "runtimeFence":
return node;
case "rethrow":
return node;
case "tryCatch":
return { ...node, tryBody: node.tryBody.map((child) => stmt(child)), catchBody: node.catchBody === null ? null : node.catchBody.map((child) => stmt(child)), finallyBody: node.finallyBody === null ? null : node.finallyBody.map((child) => stmt(child)) };
}
node satisfies never;
throw new Error("unhandled IR node");
}
/** Preorder traversal. A false predicate stops the complete walk. */
export function everyExpr(node: IrExpr, visitor: IrVisitor): boolean {
return visitor.expr(node) && everyExprChild(node,
(child) => everyExpr(child, visitor), (child) => everyStmt(child, visitor));
}
export function everyStmt(node: IrStmt, visitor: IrVisitor): boolean {
return visitor.stmt(node) && everyStmtChild(node,
(child) => everyExpr(child, visitor), (child) => everyStmt(child, visitor));
}
export function everyStmtList(body: IrStmt[], visitor: IrVisitor): boolean {
return body.every((node) => everyStmt(node, visitor));
}
/** Preorder rewrite: traverse the replacement's children, never mutate
* the input tree, and leave type/loc/label metadata to the caller. */
export function transformExpr(node: IrExpr, transform: IrTransform): IrExpr {
return mapExprChildren(transform.expr(node),
(child) => transformExpr(child, transform), (child) => transformStmt(child, transform));
}
export function transformStmt(node: IrStmt, transform: IrTransform): IrStmt {
return mapStmtChildren(transform.stmt(node),
(child) => transformExpr(child, transform), (child) => transformStmt(child, transform));
}
export function transformStmtList(body: IrStmt[], transform: IrTransform): IrStmt[] {
return body.map((node) => transformStmt(node, transform));
}
@@ -10318,6 +10318,18 @@
"<repo>/tests/fixtures/node-types/url-getters.ts"
],
"diags": []
},
"<repo>/tests/corpus/3103-scalar-record-nested-control-flow.ts": {
"order": [
"<repo>/tests/corpus/3103-scalar-record-nested-control-flow.ts"
],
"diags": []
},
"<repo>/tests/corpus/3104-native-analysis-tables-loops.ts": {
"order": [
"<repo>/tests/corpus/3104-native-analysis-tables-loops.ts"
],
"diags": []
}
}
}
@@ -0,0 +1,92 @@
let trace = 0;
function mark(value: number): number {
trace = trace * 10 + value;
return value;
}
function choose(value: number): { left: number; right: number } {
outer: for (let i = 0; i < 4; i++) {
switch (i) {
case 0: if (value === 0) return { right: mark(2), left: mark(1) }; break;
case 1: if (value < 0) continue outer; break;
default: if (i === value) return { left: value + mark(3), right: i + mark(4) };
}
}
return { right: -0, left: value };
}
function consume(value: number): number {
const a = choose(mark(value));
const b = choose(value + 1);
return a.left + a.right + b.left + b.right;
}
for (const value of [0, 2, -1]) {
trace = 0;
console.log("nested", value, consume(value), trace);
}
function branch(value: number): { result: number } {
let count = 0;
again: do {
if (value === 0) break again;
count++;
if (count < value) continue again;
return { result: count };
} while (count < 4);
return { result: -1 };
}
function branches(): void {
for (const value of [0, 1, 3, 7]) {
const output = branch(value);
console.log("branch", value, output.result);
}
}
branches();
function effect(value: number): number {
console.log("effect", value);
if (value === 9) throw new Error("nine");
return value;
}
function ordered(value: number): { first: number; second: number } {
return { second: effect(value), first: effect(value + 1) };
}
function completion(value: number): void {
try {
const result = ordered(effect(value));
console.log("result", result.first);
} catch (error) {
if (error instanceof Error) console.log("caught", error.message);
} finally {
console.log("cleanup", value);
}
}
completion(3);
completion(8);
completion(9);
function singleton(value: number): { value: number } { return { value }; }
function observations(): void {
const captured = singleton(11);
const read = (): number => captured.value;
console.log("capture", read());
const escaped = singleton(12);
const array = [escaped];
array[0]!.value = 13;
console.log("escape", escaped.value);
for (const header = singleton(14); header.value > 0;) {
console.log("header", header.value);
break;
}
const tuple = [singleton(15), singleton(16)];
console.log("tuple", tuple[0]!.value, tuple[1]!.value);
}
observations();
function signed(value: number): { zero: number; infinity: number } {
return { zero: value === 0 ? -0 : 0, infinity: value === 0 ? Infinity : -Infinity };
}
function numeric(): void {
const first = signed(0);
const second = signed(1);
console.log("numeric", 1 / first.zero, first.infinity, 1 / second.zero, second.infinity);
}
numeric();
@@ -0,0 +1,38 @@
const table = [7, -0, 2.5, Infinity, -Infinity];
function lookup(index: number): number { return table[index] * 1; }
console.log("table", lookup(0), 1 / lookup(1), lookup(2), lookup(3), lookup(4), table.length);
console.log("missing", lookup(-1), lookup(0.5), lookup(5));
const mutated = [10, 20, 30];
function index(): number { mutated[0] = 40; return 0; }
console.log("mutated-index", mutated[index()] * 1);
const escaped = [1, 2];
function expose(): number[] { return escaped; }
expose()[1] = 3;
console.log("escaped", escaped[1] * 1);
const bytes = new Uint8Array([1, 2, 3, 4]);
function sum(): number {
let total = 0;
for (let i = 0; i < bytes.length; i++) total += bytes[i];
return total;
}
function changed(): number {
let total = 0;
for (let i = 0; i < bytes.length; i++) {
if (i === 0) i++;
total += bytes[i];
}
return total;
}
function nested(): number {
let total = 0;
for (let i = 0; i < bytes.length; i++) {
try {
if (i === 0) total += bytes[++i];
else total += bytes[i];
} finally { total += 10; }
}
return total;
}
console.log("loops", sum(), changed(), nested());
+53
View File
@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { deserializeModule, serializeModule } from "../../../packages/compiler/src/ir/serialize.js";
import { validateModule } from "../../../packages/compiler/src/ir/validate.js";
import { scalarizeNumericRecords } from "../../../packages/compiler/src/ir/scalar-records.js";
import { findConstantNumericTables } from "../../../packages/compiler/src/ir/constant-tables.js";
import { matchIntegerBytesForLoop } from "../../../packages/compiler/src/ir/integer-loops.js";
import { everyStmtList } from "../../../packages/compiler/src/ir/traverse.js";
import { computeMayThrow } from "../../../packages/compiler/src/backend/c/may-throw.js";
import { computeTraced } from "../../../packages/compiler/src/backend/cycle-analysis.js";
try {
const mod = deserializeModule(readFileSync(process.argv[2]!, "utf8"));
if (process.argv[3] === "optimize") {
const before = validateModule(mod);
if (before.length > 0) throw new Error(JSON.stringify(before));
const original = serializeModule(mod);
const optimized = scalarizeNumericRecords(mod);
if (serializeModule(mod) !== original) throw new Error("optimizer mutated its input");
const after = validateModule(optimized);
if (after.length > 0) throw new Error(JSON.stringify(after));
console.log(serializeModule(optimized));
} else {
// Analysis deliberately accepts structural IR too: e.g. link/unwind
// decisions can be inspected without constructing executable FFI calls.
const may = computeMayThrow(mod);
const traced = computeTraced(mod);
const tables: { id: string; symbol: string; values: string[] }[] = [];
for (const [id, table] of findConstantNumericTables(mod)) {
tables.push({ id, symbol: table.symbol, values: table.values.map((value) => Object.is(value, -0) ? "-0" : String(value)) });
}
const loops: { fn: string; localId: string; receiver: string }[] = [];
for (const fn of mod.functions) {
const locals = new Map(fn.locals.map((local) => [local.id, local]));
everyStmtList(fn.body, { expr: () => true, stmt: (stmt) => {
if (stmt.kind === "for") {
const loop = matchIntegerBytesForLoop(stmt, locals);
if (loop && loop.limitReceiver.kind === "varRef") {
loops.push({ fn: fn.name, localId: loop.localId, receiver: loop.limitReceiver.localId });
}
}
return true;
} });
}
console.log(JSON.stringify({
mayThrow: [...may.fns], indirect: may.indirect,
tracedShapes: [...traced.shapes], tracedUnions: [...traced.unions], tables, loops,
}));
}
} catch (error) {
if (error instanceof Error) console.log(error.name + ": " + error.message);
else console.log("unexpected thrown value");
process.exitCode = 1;
}
@@ -0,0 +1,136 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { analyze, compile, compileC, deserializeModule, emitCModule, serializeModule, validateModule } from "@scriptc/compiler";
import { emitLlvmModule } from "../../packages/compiler/src/backend/llvm/emitter.js";
import { everyStmtList } from "../../packages/compiler/src/ir/traverse.js";
import { moduleUsesInspect, moduleUsesDynInvoke, moduleUsesRegex, moduleUsesCopying, type IrModule } from "../../packages/compiler/src/ir/ir.js";
import { backendAnalysisCases } from "./self-hosting-backend-cases.js";
const root = fileURLToPath(new URL("../..", import.meta.url));
const entry = join(root, "tests/fixtures/self-hosting/backend-analysis.ts");
const options = { cwd: root, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 };
interface Analysis {
mayThrow: string[];
indirect: boolean;
tracedShapes: string[];
tracedUnions: string[];
tables: { id: string; symbol: string; values: string[] }[];
loops: { fn: string; localId: string; receiver: string }[];
}
function counts(mod: IrModule): { calls: number; reads: number; records: number } {
const out = { calls: 0, reads: 0, records: 0 };
for (const fn of mod.functions) {
for (const local of fn.locals) if (local.type.kind === "record") out.records++;
everyStmtList(fn.body, { stmt: () => true, expr: (expr) => {
if (expr.kind === "call" && expr.type.kind === "record") out.calls++;
if (expr.kind === "recordGet") out.reads++;
return true;
} });
}
return out;
}
test("the production optimization and backend analysis pipeline lowers entirely statically", () => {
const { coverage } = analyze(entry, { dynamic: false });
expect(coverage.preflightFailed).toBe(false);
expect(coverage.diagnostics).toEqual([]);
expect(coverage.stats.statementsTotal).toBeGreaterThan(3800);
expect(coverage.stats.statementsFailed).toBe(0);
expect(coverage.stats.statementsIsland).toBe(0);
expect(coverage.stats.functionsSkipped).toBe(0);
});
for (const backend of ["c", "llvm"] as const) {
test(`self-hosting backend analysis and optimization (${backend})`, async () => {
const dir = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-native-analysis-"));
const sanitize = process.env["SCRIPTC_SAN"] === "1";
const executable = (name: string): string => join(dir, name + (process.platform === "win32" ? ".exe" : ""));
try {
const built = await compile(entry, {
outDir: dir, outPath: executable("stage"), backend, dynamic: false, optimization: "dev", sanitize,
});
if (!built.ok) throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
expect(built.backend).toBe(backend);
const run = (mod: IrModule, mode: string, name: string) => {
const path = join(dir, "input.json");
writeFileSync(path, serializeModule(mod));
const oracle = spawnSync(process.execPath, ["--import", "tsx", entry, path, mode], options);
const native = spawnSync(built.binaryPath, [path, mode], options);
for (const result of [oracle, native]) {
expect(result.error, name).toBeUndefined();
expect(result.signal, name).toBeNull();
expect(result.status, `${name}: ${result.stdout}\n${result.stderr}`).toBe(0);
expect(result.stderr.toString(), name).toBe("");
}
return { oracle: oracle.stdout.toString(), native: native.stdout.toString() };
};
for (const item of backendAnalysisCases()) {
const result = run(item.module, "analysis", item.name);
const actual = JSON.parse(result.native) as Analysis;
expect(actual, item.name).toEqual(JSON.parse(result.oracle));
if (item.mayThrow) expect(actual.mayThrow, item.name).toEqual(item.mayThrow);
if (item.indirect !== undefined) expect(actual.indirect, item.name).toBe(item.indirect);
if (item.shapes) expect(actual.tracedShapes, item.name).toEqual(item.shapes);
if (item.unions) expect(actual.tracedUnions, item.name).toEqual(item.unions);
if (item.tables) expect(actual.tables.map((t) => t.id), item.name).toEqual(item.tables);
if (item.loops) expect(actual.loops.map((l) => l.localId), item.name).toEqual(item.loops);
}
for (const source of [
"3103-scalar-record-nested-control-flow.ts",
"3104-native-analysis-tables-loops.ts",
"2481-mutual-recursive-records.ts",
"3094-json-replacer-traversal.ts",
]) {
const sourcePath = join(root, "tests/corpus", source);
const irPath = join(dir, "frontend.json");
const emitted = await compile(sourcePath, { outDir: dir, outPath: irPath, outputKind: "ir", dynamic: false });
if (!emitted.ok) throw new Error(emitted.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const original = deserializeModule(readFileSync(irPath, "utf8"));
const summary = run(original, "analysis", source);
const analysis = JSON.parse(summary.native) as Analysis;
expect(analysis).toEqual(JSON.parse(summary.oracle));
if (source.startsWith("3104")) {
expect(analysis.tables).toHaveLength(1);
expect(analysis.tables[0]!.values).toEqual(["7", "-0", "2.5", "Infinity", "-Infinity"]);
expect(analysis.loops).toHaveLength(1);
}
const result = run(original, "optimize", source);
const optimized = deserializeModule(result.native);
expect(optimized).toEqual(deserializeModule(result.oracle));
expect(validateModule(optimized)).toEqual([]);
if (source.startsWith("3103")) {
expect(counts(optimized).records).toBeLessThan(counts(original).records);
expect(counts(optimized).reads).toBeLessThan(counts(original).reads);
expect(counts(optimized).calls).toBeLessThan(counts(original).calls);
}
// Feed the native result to each production backend. This pins
// optimizer semantics beyond matching Node's JSON: the resulting
// native executable must agree with the original TypeScript.
const path = join(dir, backend === "c" ? "optimized.c" : "optimized.ll");
writeFileSync(path, backend === "c" ? emitCModule(optimized) : emitLlvmModule(optimized));
const outPath = executable("program");
await compileC({
cPath: path, outPath, sanitize,
inspect: moduleUsesInspect(optimized), dynInvoke: moduleUsesDynInvoke(optimized),
regex: moduleUsesRegex(optimized), copying: moduleUsesCopying(optimized),
});
const oracle = spawnSync(process.execPath, [sourcePath], options);
const program = spawnSync(outPath, [], options);
for (const process of [oracle, program]) {
expect(process.error, source).toBeUndefined();
expect(process.signal, source).toBeNull();
expect(process.status, `${source}: ${process.stderr}`).toBe(0);
}
expect(program.stdout, source).toEqual(oracle.stdout);
expect(program.stderr, source).toEqual(oracle.stderr);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
}
+124
View File
@@ -0,0 +1,124 @@
import { arrayOf, BOOL, F64, STRING, VOID, funcOf, type IrExpr, type IrFunction, type IrModule, type IrStmt, type IrType } from "../../packages/compiler/src/ir/ir.js";
import { IR_VERSION } from "../../packages/compiler/src/ir/serialize.js";
const loc = { file: "backend-analysis.ts", start: 0, end: 1 };
const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const ref = (localId: string, type: IrType = F64): IrExpr => ({ kind: "varRef", localId, type, loc });
const stmt = (expr: IrExpr): IrStmt => ({ kind: "exprStmt", expr, loc });
const call = (callee: string): IrExpr => ({ kind: "call", callee, args: [], type: VOID, loc });
const fn = (name: string, body: IrStmt[] = []): IrFunction => ({ name, params: [], locals: [], body, returnType: VOID, loc });
const module = (functions: IrFunction[] = [fn("main")]): IrModule => ({ irVersion: IR_VERSION, sourceFile: loc.file, entry: "main", functions });
export interface BackendAnalysisCase {
name: string;
module: IrModule;
mayThrow?: string[];
indirect?: boolean;
shapes?: string[];
unions?: string[];
tables?: string[];
loops?: string[];
}
/** Structural cases isolate analysis decisions independently of the runtime
* APIs they describe. Execution/validation is covered separately with real
* frontend output and native optimization followed by executable emission. */
export function backendAnalysisCases(): BackendAnalysisCase[] {
const cases: BackendAnalysisCase[] = [{ name: "empty", module: module(), mayThrow: [], indirect: false, shapes: [], unions: [], tables: [], loops: [] }];
const failure = fn("failure", [{ kind: "throw", value: num(1), loc }]);
const caller = fn("main", [stmt(call("middle"))]);
const middle = fn("middle", [stmt(call("failure"))]);
cases.push({ name: "transitive calls", module: module([caller, middle, failure]), mayThrow: ["failure", "middle", "main"], indirect: false });
const closure: IrExpr = { kind: "closure", fnName: "middle", captures: [], type: funcOf([], VOID), loc };
cases.push({ name: "indirect fixpoint", module: module([
fn("main", [stmt({ kind: "callValue", callee: closure, args: [], type: VOID, loc })]), middle, failure,
]), mayThrow: ["failure", "middle", "main"], indirect: true });
for (const suspension of ["async", "generator"] as const) {
const suspended: IrFunction = suspension === "async" ? { ...failure, async: true }
: { ...failure, generator: { yieldT: F64, nextT: F64, resultType: { kind: "record", shapeId: "result" } } };
cases.push({ name: `${suspension} call does not unwind`, module: module([
fn("main", [stmt(call("failure")), stmt({ ...closure, fnName: "failure" })]), suspended,
]), mayThrow: ["failure"], indirect: false });
}
const nested: IrStmt = { kind: "tryCatch", catchLocalId: null, catchBody: null, finallyBody: [
stmt({ kind: "seqExpr", stmts: [stmt(call("failure"))], result: num(0), type: F64, loc }),
], tryBody: [], loc };
cases.push({ name: "statement hidden in finally expression", module: module([fn("main", [nested]), failure]), mayThrow: ["failure", "main"] });
cases.push({ name: "lazy operands still contribute", module: module([
fn("main", [stmt({ kind: "ternary", cond: { kind: "boolLit", value: false, type: BOOL, loc }, then: call("failure"), else_: num(0), type: F64, loc })]), failure,
]), mayThrow: ["failure", "main"] });
const recursive = module([fn("main", [stmt(call("a"))]), fn("a", [stmt(call("b"))]), fn("b", [stmt(call("a")), stmt(call("failure"))]), failure]);
cases.push({ name: "recursive call graph", module: recursive, mayThrow: ["failure", "b", "a", "main"] });
const local = { id: "value", name: "value", type: F64, mutable: true, boxed: true as const, tdz: true as const };
for (const initializes of [true, false]) {
const entry = fn("main", [{ kind: "assign", localId: local.id, value: num(1), ...(initializes ? { initializes: true as const } : {}), loc }]);
entry.locals = [local];
cases.push({ name: `TDZ initialize=${initializes}`, module: module([entry]), mayThrow: initializes ? [] : ["main"] });
}
const virtual = module([fn("main", [stmt({ kind: "virtualCall", className: "Base", method: "run", args: [], type: VOID, loc })]), { ...failure, name: "%Child.run" }]);
virtual.classes = [{ name: "Base", fields: [], methods: ["run"], loc }, { name: "Child", base: "Base", fields: [], methods: ["run"], loc }];
cases.push({ name: "virtual dispatch", module: virtual, mayThrow: ["%Child.run", "main"] });
const construction = module([fn("main", [stmt({ kind: "newValue", callee: { kind: "classRef", className: "Base", type: { kind: "classval", className: "Base" }, loc }, args: [], type: { kind: "object", className: "Base" }, loc })]), { ...failure, name: "%Child.constructor" }]);
construction.classes = virtual.classes;
cases.push({ name: "indirect derived constructor", module: construction, mayThrow: ["%Child.constructor", "main"] });
const adapter = module([fn("main", [stmt({ kind: "dynCheck", value: ref("unknown", { kind: "dyn" }), type: funcOf([], VOID), loc })])]);
cases.push({ name: "synthetic checked callback", module: adapter, mayThrow: ["main"], indirect: true });
const graph = module();
const record = (shapeId: string): IrType => ({ kind: "record", shapeId });
graph.records = [
{ id: "outer", fields: [{ name: "next", type: arrayOf(record("cycle")) }] },
{ id: "cycle", fields: [], indexValue: { kind: "union", unionId: "recursive" } },
{ id: "leaf", fields: [{ name: "value", type: STRING }] },
{ id: "chain", fields: [{ name: "next", type: record("leaf") }] },
];
graph.unions = [{ id: "recursive", arms: [record("cycle"), { kind: "nullT" }] }, { id: "acyclic", arms: [record("chain"), F64] }];
cases.push({ name: "shape and union fixed point", module: graph, shapes: ["record:outer", "record:cycle"], unions: ["recursive"] });
const hierarchy = module();
hierarchy.classes = [
{ name: "Child", base: "Base", fields: [{ name: "callback", type: funcOf([], VOID) }], loc },
{ name: "Sibling", base: "Base", fields: [], loc }, { name: "Base", fields: [], loc },
];
cases.push({ name: "hierarchy uniform tracing", module: hierarchy, shapes: ["object:Child", "object:Sibling", "object:Base"] });
const tableId = "%g.table";
const tableType = arrayOf(F64);
const tableRead: IrExpr = { kind: "arrIntrinsic", method: "getNumber", receiver: ref(tableId, tableType), args: [num(0)], type: F64, loc };
const table = module([fn("main", [
{ kind: "assign", localId: tableId, value: { kind: "arrayLit", elems: [num(2), num(-0), num(Infinity)], type: tableType, loc }, loc }, stmt(tableRead),
])]);
table.globals = [{ id: tableId, name: "table", type: tableType, mutable: false }];
cases.push({ name: "numeric table", module: table, tables: [tableId] });
for (const use of ["capture", "index-write", "reassign", "length", "hidden-call"]) {
const changed = structuredClone(table);
const entry = changed.functions[0]!;
const mutation: IrStmt = { kind: "arraySet", arr: ref(tableId, tableType), index: num(0), value: num(3), loc };
if (use === "capture") entry.body.push(stmt({ kind: "closure", fnName: "callback", captures: [tableId], type: funcOf([], VOID), loc }));
if (use === "index-write") entry.body.push(stmt({ ...tableRead, args: [{ kind: "seqExpr", stmts: [mutation], result: num(0), type: F64, loc }] } as IrExpr));
if (use === "reassign") entry.body.push(entry.body[0]!);
if (use === "length") entry.body.push({ kind: "arraySetLength", arr: ref(tableId, tableType), length: num(1), loc });
if (use === "hidden-call") changed.functions.push(fn("unused", [stmt({ kind: "call", callee: "sink", args: [ref(tableId, tableType)], type: VOID, loc })]));
cases.push({ name: `table rejects ${use}`, module: changed, tables: [] });
}
const bytes: IrType = { kind: "bytes", elem: "u8" };
const loop: IrStmt & { kind: "for" } = { kind: "for",
init: { kind: "varDecl", localId: "i", init: num(0), loc },
cond: { kind: "bin", op: "<", left: ref("i"), right: { kind: "bytesIntrinsic", method: "length", receiver: ref("bytes", bytes), args: [], type: F64, loc }, type: BOOL, loc },
update: stmt({ kind: "incDec", localId: "i", op: "+", prefix: false, type: F64, loc }), body: [], loc,
};
const loops = module([fn("main", [{ kind: "block", body: [loop], loc }])]);
loops.functions[0]!.locals = [{ id: "i", name: "i", type: F64, mutable: true }, { id: "bytes", name: "bytes", type: bytes, mutable: false }];
cases.push({ name: "nested integer loop", module: loops, loops: ["i"] });
for (const mutation of ["expression", "finally", "capture", "negative-zero"]) {
const changed = structuredClone(loops);
const block = changed.functions[0]!.body[0] as IrStmt & { kind: "block" };
const changedLoop = block.body[0] as IrStmt & { kind: "for" };
if (mutation === "expression") changedLoop.body.push(stmt({ kind: "seqExpr", stmts: [], result: { kind: "assignExpr", localId: "i", value: num(2), type: F64, loc }, type: F64, loc }));
if (mutation === "finally") changedLoop.body.push({ kind: "tryCatch", tryBody: [], catchBody: null, catchLocalId: null, finallyBody: [{ kind: "assign", localId: "i", value: num(2), loc }], loc });
if (mutation === "capture") changed.functions[0]!.locals[0]!.boxed = true;
if (mutation === "negative-zero") changedLoop.init = { kind: "varDecl", localId: "i", init: num(-0), loc };
cases.push({ name: `integer loop rejects ${mutation}`, module: changed, loops: [] });
}
return cases;
}