From 86c4edab5410f50c55fbc477f90d0adb843d213b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 13:00:46 -0700 Subject: [PATCH 01/10] Read charCodeAt at proven integer indices without a double round trip String lengths and found indices are now int32 facts for the integer range analysis: runtime strings hold at most SCR_STR_MAX_CAP UTF-8 bytes, and a UTF-16 length never exceeds the UTF-8 byte length. Loops bounded by a string length therefore get integer counters, and charCodeAt at the counter counts as an integer consumer for counted-loop versioning. A charCodeAt whose index the backend holds as an exact integer calls @sc_str_char_code_at_int: on a proven-ASCII string one unsigned compare against the length rejects negative and out-of-range indices, and every other case defers to scr_str_char_code_at with the exact double. --- .../src/backend/llvm/expr-containers.ts | 14 +++- .../compiler/src/backend/llvm/string-reads.ts | 31 +++++++ .../compiler/src/ir/integer-loops.test.ts | 28 ++++++- packages/compiler/src/ir/integer-loops.ts | 3 + .../compiler/src/ir/integer-ranges.test.ts | 26 +++++- packages/compiler/src/ir/integer-ranges.ts | 25 ++++++ .../corpus/string-char-code-integer-loops.ts | 82 +++++++++++++++++++ 7 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 tests/corpus/string-char-code-integer-loops.ts diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index a3ee2a63d..4b5c0f876 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -31,7 +31,12 @@ import { } from "./map-number-lookup.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; import { emitStringSliceRead } from "./string-slices.js"; -import { STRING_CHAR_CODE_AT_DECL, STRING_UTF16_LEN_DECL } from "./string-reads.js"; +import { + STRING_CHAR_CODE_AT_DECL, + STRING_CHAR_CODE_AT_INT_MARKER, + STRING_UTF16_LEN_DECL, +} from "./string-reads.js"; +import { exactInteger, widenInteger } from "./integer-values.js"; import { emitDenseArrayNumber, emitDenseArrayPush } from "./dense-array-access.js"; import { emitUnionWiden } from "./expr-records.js"; @@ -293,6 +298,13 @@ export function emitStrIntrinsic( case "charCodeAt": { host.declare(STRING_CHAR_CODE_AT_DECL); const t = B.tmp(); + const index = e.args[0] ? exactInteger(host, args[0]!, e.args[0]) : null; + if (index) { + host.declare(STRING_CHAR_CODE_AT_INT_MARKER); + const wide = widenInteger(host, index); + B.line(`${t} = call double @sc_str_char_code_at_int(ptr ${r.name}, i64 ${wide})`); + return { name: t, type: e.type }; + } B.line(`${t} = call double @sc_str_char_code_at(ptr ${r.name}, double ${args[0]!.name})`); return { name: t, type: e.type }; } diff --git a/packages/compiler/src/backend/llvm/string-reads.ts b/packages/compiler/src/backend/llvm/string-reads.ts index 7cbdbd97b..2a368979e 100644 --- a/packages/compiler/src/backend/llvm/string-reads.ts +++ b/packages/compiler/src/backend/llvm/string-reads.ts @@ -15,6 +15,9 @@ import { exactIndexLines } from "./common.js"; export const STRING_UTF16_LEN_DECL = "declare double @scr_str_utf16_len(ptr)"; export const STRING_CHAR_CODE_AT_DECL = "declare double @scr_str_char_code_at(ptr, double)"; +/** Requests the integer-index helper below; an LLVM comment line among the + * declarations, so it never declares anything itself. */ +export const STRING_CHAR_CODE_AT_INT_MARKER = "; uses @sc_str_char_code_at_int"; /** Load the proven-ASCII flag of `%s` into `%ascii`. */ function asciiTest(): string[] { @@ -85,4 +88,32 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [ ]; }, }, + { + // An index the compiler proved to be an exact integer (a counted-loop + // counter, an int32 slot or local) needs no double round trip: one + // unsigned compare rejects negative and out-of-range indices alike. + decl: STRING_CHAR_CODE_AT_INT_MARKER, + define: (sz, attrs) => [ + `define internal double @sc_str_char_code_at_int(ptr %s, i64 %i) ${attrs} {`, + `entry:`, + ...asciiTest(), + ` br i1 %ascii, label %bounds, label %slow`, + `bounds:`, + ...loadLength(sz), + ...(sz === "i64" ? [] : [` %len64 = zext ${sz} %len to i64`]), + ` %ok = icmp ult i64 %i, ${sz === "i64" ? "%len" : "%len64"}`, + ` br i1 %ok, label %read, label %slow`, + `read:`, + ` %data = getelementptr inbounds %ScrStr, ptr %s, i32 1`, + ` %p = getelementptr inbounds i8, ptr %data, i64 %i`, + ` %byte = load i8, ptr %p`, + ` %unit = uitofp i8 %byte to double`, + ` ret double %unit`, + `slow:`, + ` %d = sitofp i64 %i to double`, + ` %r = call double @scr_str_char_code_at(ptr %s, double %d)`, + ` ret double %r`, + `}`, + ], + }, ]; diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index 89b2d57bf..0f7b9ae13 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { BOOL, F64, arrayOf, type IrExpr, type IrLocal, type IrStmt } from "./ir.js"; +import { BOOL, F64, STRING, arrayOf, type IrExpr, type IrLocal, type IrStmt } from "./ir.js"; import { matchIntegerArrayForLoop, matchIntegerCountedForLoop } from "./integer-loops.js"; const loc = { file: "integer-array-loop.ts", start: 0, end: 0 }; @@ -182,3 +182,29 @@ test("an unproven start versions the loop on an exact integer start", () => { large.body = Array.from({ length: 81 }, () => large.body[0]!); expect(matchIntegerCountedForLoop(large, withStart)).toBeNull(); }); + +test("a string read at the counter is an integer consumer", () => { + const withText = new Map(locals); + withText.set("limit", { id: "limit", name: "limit", type: F64, mutable: true }); + withText.set("s", { id: "s", name: "s", type: STRING, mutable: false }); + const read = (index: IrExpr): IrStmt => ({ + kind: "exprStmt", + expr: { + kind: "strIntrinsic", + method: "charCodeAt", + receiver: { kind: "varRef", localId: "s", type: STRING, loc }, + args: [index], + type: F64, + loc, + }, + loc, + }); + const s = counted(ref("limit")); + s.body = [read(ref("i"))]; + expect(matchIntegerCountedForLoop(s, withText)?.localId).toBe("i"); + s.body = [read({ kind: "bin", op: "+", left: ref("i"), right: number(1), type: F64, loc })]; + expect(matchIntegerCountedForLoop(s, withText)?.localId).toBe("i"); + // A read at another index does not consume the counter. + s.body = [read(ref("limit"))]; + expect(matchIntegerCountedForLoop(s, withText)).toBeNull(); +}); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index 6daaf5bd1..c854a99c5 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -175,6 +175,9 @@ export function matchIntegerCountedForLoop( e.args.some(counter) ) useful = true; + // A string read at the counter indexes with the integer directly. + if (e.kind === "strIntrinsic" && e.method === "charCodeAt" && e.args[0] && counter(e.args[0])) + useful = true; return (!guarded || ++cost <= 80) && everyExprChild(e, expr, bodyStmt); }; const bodyStmt = (s: IrStmt): boolean => { diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts index 8e8fff024..ffa54bea0 100644 --- a/packages/compiler/src/ir/integer-ranges.test.ts +++ b/packages/compiler/src/ir/integer-ranges.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; import { analyzeIntegerRanges } from "./integer-ranges.js"; -import { BOOL, F64, type IrExpr, type IrFunction, type IrStmt } from "./ir.js"; +import { BOOL, F64, STRING, type IrExpr, type IrFunction, type IrStmt } from "./ir.js"; const loc = { file: "test.ts", start: 0, end: 0 }; const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 }); @@ -419,3 +419,27 @@ test("whole-program slot facts seed parameters, field reads and call results", ( }); expect([seeded.get(field), seeded.get(result), seeded.get(param)]).toEqual([INT32, INT32, INT32]); }); + +test("string lengths and found indices are int32, other string reads stay unknown", () => { + const text: IrExpr = { loc, kind: "strLit", value: "abc", type: STRING }; + const read = (method: "length" | "indexOf" | "charCodeAt", args: IrExpr[] = []): IrExpr => ({ + loc, + kind: "strIntrinsic", + method, + receiver: text, + args, + type: F64, + }); + const length = read("length"); + const found = read("indexOf", [{ loc, kind: "strLit", value: "b", type: STRING }]); + const code = read("charCodeAt", [num(0)]); + const next = bin("+", length, num(1)); + const ranges = analyzeIntegerRanges( + fn([observe(length), observe(found), observe(code), observe(next)]), + ); + expect(ranges.get(length)).toEqual({ min: 0, max: 2147483647 }); + expect(ranges.get(found)).toEqual({ min: -1, max: 2147483647 }); + // charCodeAt can be NaN. + expect(ranges.get(code)).toBeNull(); + expect(ranges.get(next)).toEqual({ min: 1, max: 2147483648 }); +}); diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts index 1d4c5e2ea..876f18c79 100644 --- a/packages/compiler/src/ir/integer-ranges.ts +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -13,6 +13,10 @@ export type IntegerRanges = ReadonlyMap; const SIGNED: IntegerRange = { min: -2147483648, max: 2147483647 }; const UNSIGNED: IntegerRange = { min: 0, max: 4294967295 }; const EXACT_LIMIT = 2 ** 53; +/** The most UTF-16 code units a runtime string holds: SCR_STR_MAX_CAP + * (scr_runtime.h) caps a string's UTF-8 bytes, and a UTF-16 length never + * exceeds the UTF-8 byte length. */ +const STRING_MAX_UNITS = 0x7fffffff; type Facts = Map; function union(left: IntegerRange, right: IntegerRange): IntegerRange { @@ -436,6 +440,27 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = { ); if (e.method === "length") range = UNSIGNED; break; + case "strIntrinsic": + // The conservative child handling of unknown forms; only the result + // range is new: a length or a found index is an int32. + havoc(facts, [e], facts); + everyExprChild( + e, + (child) => { + expr(child, new Map(facts)); + return true; + }, + (child) => { + body([child], new Map(facts)); + return true; + }, + ); + if (e.type.kind === "f64") { + if (e.method === "length") range = { min: 0, max: STRING_MAX_UNITS }; + // An empty needle is found at the clamped position, up to the length. + else if (e.method === "indexOf") range = { min: -1, max: STRING_MAX_UNITS }; + } + break; case "fieldGet": expr(e.obj, facts); range = slotRange(e); diff --git a/tests/corpus/string-char-code-integer-loops.ts b/tests/corpus/string-char-code-integer-loops.ts new file mode 100644 index 000000000..79028ab3b --- /dev/null +++ b/tests/corpus/string-char-code-integer-loops.ts @@ -0,0 +1,82 @@ +// charCodeAt at counted-loop counters and other proven integers: in-range +// reads, negative and past-the-end indices, non-ASCII and surrogate text, +// and counters near the 2^31 and 2^53 bounds. (Lone surrogates are left +// out: UTF-8 storage turns them into U+FFFD, see the limitations page.) +const texts = ["", "a", "plain ascii text", "caf\u00e9 cr\u00e8me", "emoji \ud83d\ude00 pair", "\u4e2d\u6587\u5b57"]; + +function codes(s: string): string { + let out = ""; + for (let i = 0; i < s.length; i++) out += s.charCodeAt(i) + ","; + return out; +} +function inclusive(s: string): string { + let out = ""; + for (let i = 0; i <= s.length; i++) out += s.charCodeAt(i) + ","; + return out; +} +function neighbours(s: string): string { + let out = ""; + for (let i = 0; i < s.length; i++) out += `${s.charCodeAt(i - 1)}/${s.charCodeAt(i + 1)}/${s.charCodeAt(i * 2)},`; + return out; +} +function backwards(s: string): string { + let out = ""; + for (let i = s.length - 1; i >= -2; i--) out += s.charCodeAt(i) + ","; + return out; +} +function hash(s: string): number { + let h = 0x811c9dc5 | 0; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193); + return h >>> 0; +} +for (const s of texts) { + console.log(JSON.stringify(s), s.length, codes(s), inclusive(s), neighbours(s), backwards(s), hash(s)); +} + +// A counter that starts negative or at -0. +function fromStart(s: string, start: number): string { + let out = ""; + for (let i = start; i < 3; i++) out += `${Object.is(i, -0) ? "-0" : i}:${s.charCodeAt(i)},`; + return out; +} +for (const start of [-3, -0, 0, 1]) console.log("start", start, fromStart("xyz", start), fromStart("", start)); + +// Counters far beyond any string length. +function wide(s: string, start: number): string { + let out = ""; + let steps = 0; + for (let i = start; i < start + 4; i++) { + out += `${i}:${s.charCodeAt(i)}:${s.charCodeAt(i - start)},`; + if (++steps === 4) break; + } + return out; +} +for (const start of [2147483646, 4294967294, 9007199254740989]) console.log("wide", wide("abcd", start)); + +// Bounds derived from indexOf and length. +function afterSlash(path: string): number { + let sum = 0; + for (let i = path.indexOf("/") + 1; i < path.length; i++) sum += path.charCodeAt(i); + return sum; +} +function countDots(path: string): number { + const n = path.length; + let dots = 0; + for (let i = 0; i < n; i++) if (path.charCodeAt(i) === 46) dots++; + return dots; +} +for (const path of ["a/b.c", "/x/./y/../z", "no-slash", "", "\u00e9/\u00e8.\u00ea"]) { + console.log("path", JSON.stringify(path), afterSlash(path), countDots(path), path.indexOf("."), path.length); +} + +// The receiver changes inside the loop. +function changing(seed: string): string { + let s = seed; + let out = ""; + for (let i = 0; i < 6; i++) { + out += s.charCodeAt(i) + ","; + s = i % 2 === 0 ? s + "\u00df" : s.slice(1); + } + return out; +} +console.log("changing", changing("ab"), changing("")); From 2039d88e91fa7a16b19f4f0775d5b32f6024ecb7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 13:05:27 -0700 Subject: [PATCH 02/10] Record the preflight/order baseline of the charCodeAt loop fixture --- packages/compiler/test/ts7/baselines/order-parity.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 06376bc1b..cbccf0670 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15834,6 +15834,12 @@ "/tests/corpus/final-field-borrows.ts" ], "diags": [] + }, + "/tests/corpus/string-char-code-integer-loops.ts": { + "order": [ + "/tests/corpus/string-char-code-integer-loops.ts" + ], + "diags": [] } } } From 2162b0af6506900fec247f21deaf0e151f11f705 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 14:24:59 -0700 Subject: [PATCH 03/10] Identity bitwise forms over proven int32 operands return the operand `x | 0`, `x ^ 0`, `x & -1`, `x << 0`, `x >> 0` (and `x >>> 0` for a uint32) over an operand the integer range analysis proves an int32 now return the operand's number and its integer views instead of converting to i32 and back: ranges exclude -0, so the result is the operand bit for bit. The range analysis computes bitwise result ranges from the operand ranges (exact ToInt32/ToUint32 views, shift-count ranges) instead of the blanket int32 range, so `(n & 0xff) | 0`, `s.length >> 1` or `x >>> 1` keep tight bounds for later integer consumers. --- .../src/backend/llvm/expr-primitives.test.ts | 55 +++++++ .../src/backend/llvm/expr-primitives.ts | 26 +++- .../compiler/src/ir/integer-ranges.test.ts | 141 ++++++++++++++++- packages/compiler/src/ir/integer-ranges.ts | 146 ++++++++++++++++-- .../test/ts7/baselines/order-parity.json | 6 + tests/corpus/4640-bitwise-identity-ranges.ts | 68 ++++++++ 6 files changed, 424 insertions(+), 18 deletions(-) create mode 100644 tests/corpus/4640-bitwise-identity-ranges.ts diff --git a/packages/compiler/src/backend/llvm/expr-primitives.test.ts b/packages/compiler/src/backend/llvm/expr-primitives.test.ts index 8e929f851..f8c26648f 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.test.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.test.ts @@ -135,3 +135,58 @@ test("reference identity and instanceof borrow their operands", () => { expect(body, name).not.toMatch(/call void @sc_release_Node/); } }); + +test("identity bitwise forms over a proven int32 return the operand's number", () => { + const text: IrExpr = { kind: "varRef", localId: "text", type: STRING, loc }; + const length: IrExpr = { + kind: "strIntrinsic", + method: "length", + receiver: text, + args: [], + type: F64, + loc, + }; + const x: IrExpr = { kind: "varRef", localId: "x", type: F64, loc }; + const zero: IrExpr = { kind: "numLit", value: 0, type: F64, loc }; + const returning = (name: string, value: IrExpr) => ({ + name, + params: [ + { name: "text", localId: "text", type: STRING }, + { name: "x", localId: "x", type: F64 }, + ], + returnType: F64, + locals: [ + { id: "text", name: "text", type: STRING, mutable: false }, + { id: "x", name: "x", type: F64, mutable: false }, + ], + loc, + body: [{ kind: "return" as const, loc, value }], + }); + const bin = (op: "|" | ">>>" | "&", left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "bin", + op, + left, + right, + type: F64, + loc, + }); + const mod = fixture(); + mod.functions = [ + { name: "__main", params: [], returnType: VOID, locals: [], body: [], loc }, + returning("length_or", bin("|", length, zero)), + returning("length_ushr", bin(">>>", length, zero)), + returning("unknown_or", bin("|", x, zero)), + returning("length_and", bin("&", length, { kind: "numLit", value: 255, type: F64, loc })), + ]; + const llvm = emitLlvmModule(mod); + const body = (name: string): string => + new RegExp(`^define internal [^\\n]*@sc_[bf]+_${name}\\([^]*?^}`, "m").exec(llvm)![0]; + for (const name of ["length_or", "length_ushr"]) { + expect(body(name)).not.toMatch(/ = (or|lshr) i32 /); + expect(body(name)).not.toMatch(/ = [su]itofp i32 /); + expect(body(name)).not.toContain("uint32.coerce"); + } + // Unproven operands keep the full ToInt32; other operators still compute. + expect(body("unknown_or")).toContain("uint32.coerce.slow"); + expect(body("length_and")).toMatch(/ = and i32 .*, 255$/m); +}); diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 6807b0d07..89ea3ca4c 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -12,7 +12,12 @@ import { emitStringInputs } from "./string-lifetimes.js"; import { emitStringParts, numberPart, stringParts } from "./string-construction.js"; import { emitBorrowedInputs } from "./borrowed-inputs.js"; import { exactInteger, widenInteger, integerNumber } from "./integer-values.js"; -import { integerArithmeticRange } from "../../ir/integer-ranges.js"; +import { + bitwiseIdentityOperand, + integerArithmeticRange, + withinInt32, + withinUint32, +} from "../../ir/integer-ranges.js"; import { emitArrayValues } from "./expr-containers.js"; import { emitCheckedRemainder, emitSignedIntegerRemainder } from "./integer-remainder.js"; import { emitDenseArrayGet, emitDenseArrayState } from "./dense-array-access.js"; @@ -274,6 +279,25 @@ export function emitOperatorExpr( e.loc, ); } + // `x | 0` and the other identity forms over an operand the range + // analysis proves an int32 (a uint32 for `>>>`) return that operand: + // the conversion is the identity and ranges exclude -0, so the + // operand's double is the result bit for bit. Its integer views stay. + const identity = bitwiseIdentityOperand(e); + if (identity !== null) { + const operand = identity === "left" ? e.left : e.right; + const value = identity === "left" ? l : r; + const range = host.integerRanges.get(operand); + if (e.op === ">>>" ? withinUint32(range) : withinInt32(range)) { + const uint32 = host.emitToUint32(value.name, operand, value.uint32); + return { + name: value.name, + type: e.type, + uint32, + ...(value.integer ? { integer: value.integer } : {}), + }; + } + } const left = host.emitToUint32(l.name, e.left, l.uint32); let right = host.emitToUint32(r.name, e.right, r.uint32); if (e.op === "<<" || e.op === ">>" || e.op === ">>>") { diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts index 8e8fff024..424685328 100644 --- a/packages/compiler/src/ir/integer-ranges.test.ts +++ b/packages/compiler/src/ir/integer-ranges.test.ts @@ -1,6 +1,12 @@ import { expect, test } from "vitest"; -import { analyzeIntegerRanges } from "./integer-ranges.js"; -import { BOOL, F64, type IrExpr, type IrFunction, type IrStmt } from "./ir.js"; +import { + analyzeIntegerRanges, + bitwiseIdentityOperand, + bitwiseRange, + type BitwiseOp, + type BitwiseOperand, +} from "./integer-ranges.js"; +import { BOOL, F64, STRING, type IrExpr, type IrFunction, type IrStmt } from "./ir.js"; const loc = { file: "test.ts", start: 0, end: 0 }; const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 }); @@ -29,7 +35,11 @@ test("tracks exact additions and bitwise conversions through assignments", () => const sum = bin("+", ref(), bin("<<", ref(), num(3))); const unsigned = bin(">>>", ref(), num(0)); const ranges = analyzeIntegerRanges( - fn([assign(bin("|", num(0), num(0))), assign(sum), { loc, kind: "return", value: unsigned }]), + fn([ + assign(bin("|", ref("unknown"), num(0))), + assign(sum), + { loc, kind: "return", value: unsigned }, + ]), ); expect(ranges.get(sum)).toEqual({ min: -4294967296, max: 4294967294 }); expect(ranges.get(unsigned)).toEqual({ min: 0, max: 4294967295 }); @@ -419,3 +429,128 @@ test("whole-program slot facts seed parameters, field reads and call results", ( }); expect([seeded.get(field), seeded.get(result), seeded.get(param)]).toEqual([INT32, INT32, INT32]); }); + +const BITWISE: Record number> = { + "&": (left, right) => left & right, + "|": (left, right) => left | right, + "^": (left, right) => left ^ right, + "<<": (left, right) => left << right, + ">>": (left, right) => left >> right, + ">>>": (left, right) => left >>> right, +}; + +test("bitwise result ranges contain every JS result over their operands", () => { + const ranges: { min: number; max: number }[] = [ + { min: 0, max: 0 }, + { min: 0, max: 1 }, + { min: 0, max: 100 }, + { min: 3, max: 5 }, + { min: -1, max: -1 }, + { min: -7, max: -2 }, + { min: -5, max: 9 }, + { min: 0, max: 31 }, + { min: 30, max: 33 }, + { min: 2 ** 31 - 2, max: 2 ** 31 + 1 }, + { min: 2 ** 31, max: 2 ** 32 - 1 }, + { min: 2 ** 32 - 1, max: 2 ** 32 + 1 }, + { min: -(2 ** 31) - 1, max: -(2 ** 31) + 1 }, + { min: -(2 ** 31), max: 2 ** 31 - 1 }, + { min: 0, max: 2 ** 32 - 1 }, + { min: -(2 ** 53), max: 2 ** 53 }, + ]; + const literals = [ + -0, + 0.5, + -1.5, + NaN, + Infinity, + -Infinity, + 2 ** 31, + 2 ** 32, + 2 ** 32 + 3, + 2 ** 53 + 2, + ]; + const samples = (operand: BitwiseOperand): number[] => { + if (typeof operand === "number") return [operand]; + if (operand === null) return [...literals, 7, -7, 2 ** 31 - 1, -(2 ** 31)]; + const { min, max } = operand; + const values = new Set([min, max, Math.floor((min + max) / 2), min + 1, max - 1]); + if (max - min <= 64) for (let value = min; value <= max; value++) values.add(value); + return [...values].filter((value) => value >= min && value <= max); + }; + const operands: BitwiseOperand[] = [null, ...ranges, ...literals, 0, -1, 3, 31, 32, 255]; + for (const op of Object.keys(BITWISE) as BitwiseOp[]) + for (const left of operands) + for (const right of operands) { + const range = bitwiseRange(op, left, right); + for (const a of samples(left)) + for (const b of samples(right)) { + const result = BITWISE[op](a, b); + if (result < range.min || result > range.max || Object.is(result, -0)) + throw new Error( + `${a} ${op} ${b} = ${result} outside [${range.min}, ${range.max}] (operands ${JSON.stringify(left)}, ${JSON.stringify(right)})`, + ); + } + } +}); + +test("bitwise ranges keep known operand ranges", () => { + const small = { min: 0, max: 100 }; + expect(bitwiseRange("|", small, 0)).toEqual(small); + expect(bitwiseRange("|", 0, small)).toEqual(small); + expect(bitwiseRange("^", small, 2 ** 32)).toEqual(small); + expect(bitwiseRange("&", small, -1)).toEqual(small); + expect(bitwiseRange("&", null, 255)).toEqual({ min: 0, max: 255 }); + expect(bitwiseRange("|", small, { min: 1, max: 2 })).toEqual({ min: 1, max: 127 }); + expect(bitwiseRange(">>", small, 1)).toEqual({ min: 0, max: 50 }); + expect(bitwiseRange(">>", { min: -9, max: 9 }, null)).toEqual({ min: -9, max: 9 }); + expect(bitwiseRange("<<", small, 2)).toEqual({ min: 0, max: 400 }); + expect(bitwiseRange(">>>", null, 1)).toEqual({ min: 0, max: 2 ** 31 - 1 }); + expect(bitwiseRange(">>>", { min: -1, max: -1 }, 0)).toEqual({ + min: 2 ** 32 - 1, + max: 2 ** 32 - 1, + }); + expect(bitwiseRange("|", { min: 2 ** 31, max: 2 ** 31 }, 0)).toEqual({ + min: -(2 ** 31), + max: -(2 ** 31), + }); + expect(bitwiseRange("|", null, 0)).toEqual(INT32); + expect(bitwiseRange("|", NaN, 0)).toEqual({ min: 0, max: 0 }); + // The analysis carries operand ranges through `| 0` and shifts. + const length: IrExpr = { + loc, + kind: "strIntrinsic", + method: "length", + receiver: { loc, kind: "varRef", localId: "s", type: STRING }, + args: [], + type: F64, + }; + const truncated = bin("|", length, num(0)); + const half = bin(">>", length, num(1)); + const analyzed = analyzeIntegerRanges( + fn([observe(truncated), observe(half), observe(bin("|", ref("unknown"), num(0)))]), + ); + expect(analyzed.get(truncated)).toEqual({ min: 0, max: 2 ** 30 }); + expect(analyzed.get(half)).toEqual({ min: 0, max: 2 ** 29 }); +}); + +test("bitwise identity operands follow ToInt32 and the shift-count mask", () => { + const x = ref(); + const identity = (op: BitwiseOp, left: IrExpr, right: IrExpr) => + bitwiseIdentityOperand(bin(op, left, right) as IrExpr & { kind: "bin" }); + expect(identity("|", x, num(0))).toBe("left"); + expect(identity("|", num(0), x)).toBe("right"); + expect(identity("|", x, num(-0))).toBe("left"); + expect(identity("|", x, num(2 ** 32))).toBe("left"); + expect(identity("^", x, num(NaN))).toBe("left"); + expect(identity("&", x, num(-1))).toBe("left"); + expect(identity("&", num(2 ** 32 - 1), x)).toBe("right"); + expect(identity("<<", x, num(0))).toBe("left"); + expect(identity(">>", x, num(32))).toBe("left"); + expect(identity(">>>", x, num(0))).toBe("left"); + expect(identity("|", x, num(1))).toBeNull(); + expect(identity("&", x, num(0))).toBeNull(); + expect(identity(">>", x, num(1))).toBeNull(); + expect(identity("<<", num(0), x)).toBeNull(); + expect(identity(">>>", num(0), x)).toBeNull(); +}); diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts index 43bec075d..08eb9f87c 100644 --- a/packages/compiler/src/ir/integer-ranges.ts +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -63,6 +63,132 @@ export const INT32_RANGE: IntegerRange = SIGNED; export function withinInt32(range: IntegerRange | null | undefined): boolean { return !!range && range.min >= SIGNED.min && range.max <= SIGNED.max; } +export function withinUint32(range: IntegerRange | null | undefined): boolean { + return !!range && range.min >= UNSIGNED.min && range.max <= UNSIGNED.max; +} + +export type BitwiseOp = "&" | "|" | "^" | "<<" | ">>" | ">>>"; +export function isBitwiseOp(op: string): op is BitwiseOp { + return op === "&" || op === "|" || op === "^" || op === "<<" || op === ">>" || op === ">>>"; +} + +/** What an operand of a bitwise operator is known to be: a range of exact + * integers, or, for a numeric literal that is not one (2^53 + 2, 0.5, NaN, + * Infinity), its value. Unknown operands are null. */ +export type BitwiseOperand = IntegerRange | number | null; +function operandView(e: IrExpr, range: IntegerRange | null): BitwiseOperand { + return range ?? (e.kind === "numLit" ? e.value : null); +} + +/** The values ToInt32 can produce from the operand. A range inside int32 is + * its own image; [2^31, 2^32) shifts down by 2^32; anything wider or unknown + * wraps to the whole int32 range. */ +function int32View(operand: BitwiseOperand): IntegerRange { + if (typeof operand === "number") return { min: operand | 0, max: operand | 0 }; + if (withinInt32(operand)) return operand!; + if (operand && operand.min > SIGNED.max && operand.max <= UNSIGNED.max) + return { min: operand.min - 2 ** 32, max: operand.max - 2 ** 32 }; + return SIGNED; +} +/** The values ToUint32 can produce from the operand. */ +function uint32View(operand: BitwiseOperand): IntegerRange { + if (typeof operand === "number") return { min: operand >>> 0, max: operand >>> 0 }; + if (withinUint32(operand)) return operand!; + if (operand && operand.min >= SIGNED.min && operand.max < 0) + return { min: operand.min + 2 ** 32, max: operand.max + 2 ** 32 }; + return UNSIGNED; +} +/** The shift counts (`ToUint32(count) & 31`) the operand can produce. */ +function shiftCounts(operand: BitwiseOperand): IntegerRange { + const counts = uint32View(operand); + return counts.max - counts.min < 32 && (counts.min & 31) <= (counts.max & 31) + ? { min: counts.min & 31, max: counts.max & 31 } + : { min: 0, max: 31 }; +} +/** The smallest 2^k - 1 at or above a non-negative int32 or uint32. */ +function bitCover(value: number): number { + let cover = 0; + while (cover < value) cover = cover * 2 + 1; + return cover; +} + +/** The result range of a JS bitwise operator over its operands' ranges. + * Every result is an exact integer and never -0. */ +export function bitwiseRange( + op: BitwiseOp, + leftOperand: BitwiseOperand, + rightOperand: BitwiseOperand, +): IntegerRange { + if (op === ">>>") { + const value = uint32View(leftOperand), + counts = shiftCounts(rightOperand); + return { + min: Math.floor(value.min / 2 ** counts.max), + max: Math.floor(value.max / 2 ** counts.min), + }; + } + const left = int32View(leftOperand); + if (op === ">>" || op === "<<") { + const counts = shiftCounts(rightOperand); + if (op === ">>") + return { + min: Math.min( + Math.floor(left.min / 2 ** counts.min), + Math.floor(left.min / 2 ** counts.max), + ), + max: Math.max( + Math.floor(left.max / 2 ** counts.min), + Math.floor(left.max / 2 ** counts.max), + ), + }; + const min = Math.min(left.min * 2 ** counts.min, left.min * 2 ** counts.max), + max = Math.max(left.max * 2 ** counts.min, left.max * 2 ** counts.max); + return withinInt32({ min, max }) ? { min, max } : SIGNED; + } + const right = int32View(rightOperand); + if (op === "&") { + // Clearing bits of a non-negative value cannot raise it; a negative + // operand keeps the result at or below the other operand's maximum. + if (left.min >= 0 && right.min >= 0) return { min: 0, max: Math.min(left.max, right.max) }; + if (left.min >= 0) return { min: 0, max: left.max }; + if (right.min >= 0) return { min: 0, max: right.max }; + if (left.max < 0 && right.max < 0) + return { min: SIGNED.min, max: Math.min(left.max, right.max) }; + return SIGNED; + } + if (left.min === 0 && left.max === 0) return right; + if (right.min === 0 && right.max === 0) return left; + if (left.min >= 0 && right.min >= 0) { + const max = bitCover(Math.max(left.max, right.max)); + return op === "|" ? { min: Math.max(left.min, right.min), max } : { min: 0, max }; + } + if (op === "|") { + // Setting bits of a negative int32 raises it toward -1. + if (left.max < 0 && right.max < 0) return { min: Math.max(left.min, right.min), max: -1 }; + if (left.max < 0) return { min: left.min, max: -1 }; + if (right.max < 0) return { min: right.min, max: -1 }; + } + return SIGNED; +} + +/** The operand a bitwise operator returns unchanged when that operand is an + * int32 (a uint32 for `>>>`): `x | 0`, `x ^ 0`, `x & -1`, `x << 0`, `x >> 0` + * and `x >>> 0`, with the literal on either side where the operator is + * commutative. Literals count by their converted value (`x | 2 ** 32`). */ +export function bitwiseIdentityOperand(e: IrExpr & { kind: "bin" }): "left" | "right" | null { + const neutral = (literal: IrExpr): boolean => { + if (literal.kind !== "numLit") return false; + if (e.op === "&") return (literal.value | 0) === -1; + if (e.op === "|" || e.op === "^") return (literal.value | 0) === 0; + return false; + }; + if (e.op === "<<" || e.op === ">>" || e.op === ">>>") + return e.right.kind === "numLit" && ((e.right.value >>> 0) & 31) === 0 ? "left" : null; + if (e.op !== "&" && e.op !== "|" && e.op !== "^") return null; + if (neutral(e.right)) return "left"; + if (neutral(e.left)) return "right"; + return null; +} /** Whole-program facts about storage outside the analyzed function, each * proven by the caller (int32-slots.ts): every value a field slot, a @@ -345,25 +471,17 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = { const left = expr(e.left, facts); const right = expr(e.right, facts); if (e.type.kind !== "f64") break; - if (e.op === ">>>") { - const shift = e.right.kind === "numLit" ? e.right.value & 31 : 0; - range = { min: 0, max: 4294967295 >>> shift }; - } else if (e.op === "&") { - const mask = - e.left.kind === "numLit" - ? e.left.value | 0 - : e.right.kind === "numLit" - ? e.right.value | 0 - : -1; - range = mask >= 0 ? { min: 0, max: mask } : SIGNED; - } else if (e.op === "|" || e.op === "^" || e.op === "<<" || e.op === ">>") range = SIGNED; + if (isBitwiseOp(e.op)) + range = bitwiseRange(e.op, operandView(e.left, left), operandView(e.right, right)); else if (left && right) range = integerArithmeticRange(e.op, left, right); break; } case "unary": { const operand = expr(e.operand, facts); - if (e.op === "~") range = SIGNED; - else if (e.op === "-" && operand && (operand.max < 0 || operand.min > 0)) + if (e.op === "~") { + const value = int32View(operandView(e.operand, operand)); + range = { min: -value.max - 1, max: -value.min - 1 }; + } else if (e.op === "-" && operand && (operand.max < 0 || operand.min > 0)) range = { min: -operand.max, max: -operand.min }; break; } diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1580650f6..894d6a896 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15878,6 +15878,12 @@ ], "diags": [] }, + "/tests/corpus/4640-bitwise-identity-ranges.ts": { + "order": [ + "/tests/corpus/4640-bitwise-identity-ranges.ts" + ], + "diags": [] + }, "/tests/diagnostics/contract-immutable.ts": { "order": [ "/tests/diagnostics/contract-immutable.ts" diff --git a/tests/corpus/4640-bitwise-identity-ranges.ts b/tests/corpus/4640-bitwise-identity-ranges.ts new file mode 100644 index 000000000..6aadbe162 --- /dev/null +++ b/tests/corpus/4640-bitwise-identity-ranges.ts @@ -0,0 +1,68 @@ +// `x | 0` and the other identity bitwise forms. Operands the range analysis +// proves int32 (uint32 for `>>>`) come back unchanged; every other operand +// takes the exact ToInt32/ToUint32: signed zero, NaN, infinities, fractions +// and the 2^31, 2^32 and 2^53 boundaries. +function show(label: string, value: number): void { + console.log(label, value, Object.is(value, -0) ? "negative zero" : "", 1 / value); +} + +const inputs = [ + 0, -0, 0.5, -0.5, 1.5, -1.5, 0.9999999999999999, NaN, Infinity, -Infinity, 2147483647, + 2147483648, 2147483649, -2147483647, -2147483648, -2147483649, 4294967295, 4294967296, + 4294967297, -4294967296, 9007199254740991, 9007199254740992, -9007199254740992, + 18014398509481988, 1e21, -1e21, 5e-324, +]; + +function convert(x: number): void { + show("or", x | 0); + show("zero-or", 0 | x); + show("xor", x ^ 0); + show("and", x & -1); + show("and-u32", x & 4294967295); + show("shl", x << 0); + show("sar", x >> 0); + show("shr", x >>> 0); + show("sar32", x >> 32); + show("or-2^32", x | 4294967296); + show("xor-nan", x ^ NaN); +} + +function proven(text: string, n: number): void { + const length = text.length; + show("length|0", length | 0); + show("length>>>0", length >>> 0); + show("zero-or-length", 0 | length); + const masked = n & 0xff; + show("masked|0", masked | 0); + show("masked^0+1", (masked ^ 0) + 1); + show("masked<<23", masked << 23); + show("masked<<24", masked << 24); + show("~masked", ~masked); + show("~masked>>>0", ~masked >>> 0); + const half = length >> 1; + show("half%3", half % 3); + show("ushr%7", (n >>> 1) % 7); + const negative = -(length + 1); + show("negative|0", negative | 0); + show("negative>>>0", negative >>> 0); + show("negative>>3", negative >> 3); + show("negative-zero|0", -length | 0); + show("product-zero|0", masked * -1); + show("sum", (masked + length) | 0); + const wide = length * 4294967296; + show("wide|0", wide | 0); + show("wide>>>0", wide >>> 0); + show("or-pair", (masked | (length & 3)) | 0); + let acc = 0; + for (let i = 0; i < length; i++) acc = (acc * 31 + text.charCodeAt(i)) | 0; + show("acc", acc); + for (let f = 0.5; f < 3; f++) show("fraction-counter", f | 0); +} + +for (const x of inputs) { + console.log("input", x); + convert(x); +} +for (const text of ["", "a", "hello, world", "a much longer piece of text to hash"]) + for (const n of [0, -1, 255, 256, -256, 2147483647, -2147483648, 4294967295, 0.5, NaN]) + proven(text, n); From 3ce9370b35986acec34c093a2847f8fb4ac2c220 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 14:30:03 -0700 Subject: [PATCH 04/10] Acyclic contract type: classes and object types that cannot lie on a cycle `class C implements Acyclic`, `interface I extends Acyclic` and `type T = Acyclic<{ ... }>` promise that no chain of references leads from such an object back to itself. The check is the cycle analysis that decides tracing, so a proven type carries no cycle-collector header. When it does not hold, SC7007 shows the cycle: the path of fields, and why it can close (a field assigned after construction, with the first such write; an array, Map or Set; a function, dynamic or promise field; a subclass sharing the layout). cycle-analysis.ts gains explainCycle over the same graph with edge labels; computeTraced is unchanged. Acyclic elsewhere is reported with SC7006. Docs, @scriptc/types, corpus 4642, diagnostics snapshots and TypeScript baselines included. --- docs/content/docs/contracts.mdx | 82 +++++++ docs/content/docs/limitations.mdx | 2 +- .../src/backend/cycle-analysis.test.ts | 61 ++++- .../compiler/src/backend/cycle-analysis.ts | 213 ++++++++++++++++-- packages/compiler/src/contracts/check.ts | 197 ++++++++++++++++ .../compiler/src/frontend/contract-types.ts | 143 ++++++++++-- .../compiler/src/frontend/lowering/lowerer.ts | 22 +- packages/compiler/src/ir/ir.ts | 4 + .../test/ts7/baselines/order-parity.json | 18 ++ packages/types/README.md | 3 +- packages/types/index.d.ts | 12 +- tests/corpus/4642-acyclic-contract.ts | 64 ++++++ .../diagnostics/contract-acyclic-positions.ts | 13 ++ tests/diagnostics/contract-acyclic.ts | 59 +++++ .../contract-acyclic-positions.ts.txt | 17 ++ .../__snapshots__/contract-acyclic.ts.txt | 44 ++++ 16 files changed, 918 insertions(+), 36 deletions(-) create mode 100644 tests/corpus/4642-acyclic-contract.ts create mode 100644 tests/diagnostics/contract-acyclic-positions.ts create mode 100644 tests/diagnostics/contract-acyclic.ts create mode 100644 tests/harness/__snapshots__/contract-acyclic-positions.ts.txt create mode 100644 tests/harness/__snapshots__/contract-acyclic.ts.txt diff --git a/docs/content/docs/contracts.mdx b/docs/content/docs/contracts.mdx index bb91e341e..a93d9aa14 100644 --- a/docs/content/docs/contracts.mdx +++ b/docs/content/docs/contracts.mdx @@ -28,6 +28,11 @@ Contract types are type aliases that mean nothing to TypeScript or Node.js but a No code writes to the value, through any reference. A module constant built from literals becomes immortal data: never reference counted, never traced by the collector. + + Acyclic + No chain of references leads from an object of the class or type back to itself. + Its objects carry no cycle-collector header and are freed by reference counting alone. + @@ -110,6 +115,8 @@ scriptc recognizes a contract where it is written as the annotation of a declara
Parameters, variables, class instance fields and function results whose type is a plain number.
Immutable<T>
Parameters, variables, class instance fields and function results.
+
Acyclic
+
Classes (class C implements Acyclic), interfaces (interface I extends Acyclic) and object type aliases (type T = Acyclic<{ ... }>).
A contract anywhere else (a type alias, an interface member, an element or type argument such as `Int32[]`, a function type) would promise something scriptc does not check, so the build reports it with SC7006. @@ -144,6 +151,77 @@ scriptc refuses an `Int32` slot whose writes it cannot all see (SC7003): a param A value stored into an `Immutable` slot must be fresh: a literal, a `new` expression whose constructor keeps `this` to itself, or a function that returns what it builds. Values that reach dynamic code, or functions scriptc cannot see, are refused. +## Acyclic + +scriptc frees objects by reference counting and finds unreachable reference cycles with a cycle collector. Objects of a type that can never lie on a cycle need neither the collector's object header nor its bookkeeping. scriptc already proves this for many types; `Acyclic` turns the proof into a guarantee and shows the cycle when it does not hold. + +A reference can close a cycle when it is stored after its object was constructed (a later write can point an older object at a newer one), when it sits in an array, `Map` or `Set` (whose contents change), or when a field holds a function, a promise or a dynamic value (which can reference anything). Scalar fields never matter. A subclass shares its base class's object layout, so a class hierarchy is acyclic only as a whole. + +```ts:tree.ts +import type { Acyclic } from "@scriptc/types"; + +class Tree implements Acyclic { + readonly left: Tree | null; + readonly right: Tree | null; + constructor(left: Tree | null, right: Tree | null) { + this.left = left; + this.right = right; + } + count(): number { + return 1 + (this.left?.count() ?? 0) + (this.right?.count() ?? 0); + } +} + +function build(depth: number): Tree { + return depth === 0 ? new Tree(null, null) : new Tree(build(depth - 1), build(depth - 1)); +} + +console.log(build(20).count()); +``` + +```console +$ node tree.ts +2097151 +$ scriptc build tree.ts -o tree >/dev/null +$ ./tree +2097151 +``` + +When a cycle can form, the diagnostic shows its path: + +```ts:graph.ts +import type { Acyclic } from "@scriptc/types"; + +class Graph implements Acyclic { + readonly nodes: GraphNode[] = []; +} + +class GraphNode { + readonly graph: Graph; + constructor(graph: Graph) { + this.graph = graph; + } +} + +const graph = new Graph(); +graph.nodes.push(new GraphNode(graph)); +console.log(graph.nodes.length); +``` + +```console +$ scriptc build graph.ts +/tmp/app/graph.ts:3:24 - error SC7007: 'Graph' is declared Acyclic but can lie on a reference cycle: Graph.nodes → GraphNode.graph → Graph (Graph.nodes is an array, whose contents can change) + + 2 | + 3 | class Graph implements Acyclic { + | ^~~~~~~ + 4 | readonly nodes: GraphNode[] = []; + + hint: an array, Map or Set can come to hold an object that refers back; make the element type unable to reach this type, or remove Acyclic + +1 error. +``` + ## Diagnostics Each failed contract names the declaration and the reason: @@ -238,5 +316,9 @@ $ scriptc build reject.ts SC7006 A contract is written where scriptc does not check it. + + SC7007 + An Acyclic class or type can lie on a reference cycle; the message shows the cycle. + diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index c2860ee27..dad409620 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -130,7 +130,7 @@ A program that passes its project's TypeScript check may still use unsupported o ### Contract types -[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result; aliases, interface members and nested type positions are reported. A surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances reach dynamic code, and `Immutable` refuses values that reach dynamic code or that another reference can still change. +[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result, and `Acyclic` only on classes, interfaces and object type aliases; other aliases, interface members and nested type positions are reported. A surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances reach dynamic code, and `Immutable` refuses values that reach dynamic code or that another reference can still change. ### three.js diff --git a/packages/compiler/src/backend/cycle-analysis.test.ts b/packages/compiler/src/backend/cycle-analysis.test.ts index ca1ae760d..3ce624d23 100644 --- a/packages/compiler/src/backend/cycle-analysis.test.ts +++ b/packages/compiler/src/backend/cycle-analysis.test.ts @@ -15,7 +15,7 @@ import { type IrStmt, type IrType, } from "../ir/ir.js"; -import { computeTraced } from "./cycle-analysis.js"; +import { computeTraced, explainCycle } from "./cycle-analysis.js"; import { computeTraced as llvmTraced } from "./llvm/shapes.js"; const loc = { file: "cycles.ts", start: 0, end: 1 }; @@ -690,3 +690,62 @@ test("library builds keep the mutable-graph analysis", () => { mod.lib = {} as NonNullable; checkExact(mod, ["object:Tree"], ["link"]); }); + +test("explanations name a cycle through a mutable edge, or the intrinsic field reached", () => { + const mod = module(); + mod.records = [ + shape("outer", [ref("a")]), + shape("a", [ref("b"), F64]), + shape("b", [ref("a")]), + shape("holder", [ref("callback")]), + shape("callback", [funcOf([], F64)]), + shape("items", [arrayOf(ref("items"))]), + ]; + const mutated = withWrites(mod); + expect(explainCycle(mutated, "record:outer")).toBeNull(); + expect(explainCycle(mutated, "record:a")).toEqual({ + kind: "cycle", + steps: [ + { from: "record:a", label: { kind: "field", field: "field0", mutable: true } }, + { from: "record:b", label: { kind: "field", field: "field0", mutable: true } }, + ], + }); + expect(explainCycle(mutated, "record:holder")).toEqual({ + kind: "intrinsic", + steps: [{ from: "record:holder", label: { kind: "field", field: "field0", mutable: true } }], + owner: "record:callback", + field: "field0", + holds: "func", + }); + // Without writes, only the array keeps its cycle: containers are mutable. + expect(explainCycle(mod, "record:a")).toBeNull(); + expect(explainCycle(mod, "record:items")).toEqual({ + kind: "cycle", + steps: [ + { + from: "record:items", + label: { kind: "field", field: "field0", mutable: true, via: "array" }, + }, + ], + }); + expect(explainCycle(mod, "record:missing")).toBeNull(); +}); + +test("a hierarchy member's explanation names the member that needs the header", () => { + const mod = module(); + mod.classes = [ + { name: "Derived", loc, base: "Base", fields: [{ name: "callback", type: funcOf([], VOID) }] }, + { name: "Base", loc, fields: [] }, + ]; + expect(explainCycle(mod, "object:Base")).toEqual({ + kind: "hierarchy", + member: "object:Derived", + because: { + kind: "intrinsic", + steps: [], + owner: "object:Derived", + field: "callback", + holds: "func", + }, + }); +}); diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts index 8b6a5b210..ed970b850 100644 --- a/packages/compiler/src/backend/cycle-analysis.ts +++ b/packages/compiler/src/backend/cycle-analysis.ts @@ -32,6 +32,64 @@ import { computeCycleMutability, fieldKey } from "./cycle-mutability.js"; * uniform across an extends tree): a unit is cycle-capable iff ANY member * is — every backend uses the same grouping. */ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set } { + const { shapes, unions } = analyzeCycles(mod, false); + return { shapes, unions }; +} + +/** Values that can reference any object. */ +export type IntrinsicKind = "func" | "dyn" | "classval" | "promise" | "caught"; + +/** Why an edge of the cycle graph exists (explanations only). */ +export type CycleEdgeLabel = + /** A field (or union arm) of the owning shape. `mutable` edges can be + * stored after the owner's construction; `via` names the container + * (`array`, `map`, `set`) the reference sits in, whose contents change. */ + | { kind: "field"; field: string; mutable: boolean; via?: "array" | "map" | "set" } + /** A union arm. */ + | { kind: "arm" } + /** A slot of a class type can hold the class itself or a subclass. */ + | { kind: "subclass"; className: string }; + +/** One step of a cycle explanation: from a unit (`object:`, + * `record:`, `union:`, or `subtree:` for a slot that can + * hold any class in a subtree) through an edge. */ +export interface CycleStep { + from: string; + label: CycleEdgeLabel; +} + +export type CycleExplanation = + /** A cycle through at least one mutable edge: the steps lead from the + * explained unit back to it. */ + | { kind: "cycle"; steps: CycleStep[] } + /** The unit reaches a field that holds a value able to reference any + * object (a function, a dynamic or caught value, a class value, a + * promise), so it can lie on a cycle through that value. */ + | { + kind: "intrinsic"; + steps: CycleStep[]; + owner: string; + field: string; + holds: IntrinsicKind; + } + /** Headers are uniform per class hierarchy: the explained class is + * traced because another member of its hierarchy is. */ + | { kind: "hierarchy"; member: string; because: CycleExplanation }; + +/** Why computeTraced traces a shape (`object:` or `record:`) or + * union (`union:`); null when it is untraced or unknown. */ +export function explainCycle(mod: IrModule, key: string): CycleExplanation | null { + return analyzeCycles(mod, true).explain(key); +} + +function analyzeCycles( + mod: IrModule, + labels: boolean, +): { + shapes: Set; + unions: Set; + explain: (key: string) => CycleExplanation | null; +} { const classes = mod.classes ?? []; // Hierarchy units: root lookup over the base links (classes with a base // or a subclass — and the runtime emitter class — form units under their @@ -76,7 +134,13 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set })), ]; interface Node { + /** The unit key (explanations). */ + name: string; intrinsic: boolean; + /** With labels: the first edge label per dependency (a mutable one + * preferred), and the field that made the node intrinsic. */ + labels: Map | null; + intrinsicField: { field: string; holds: IntrinsicKind } | null; /** Dependency → whether any edge to it is mutable. */ edges: Map; index: number; @@ -86,8 +150,11 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set reverse: Node[]; traced: boolean; } - const node = (): Node => ({ + const node = (name: string): Node => ({ + name, intrinsic: false, + labels: labels ? new Map() : null, + intrinsicField: null, edges: new Map(), index: -1, low: 0, @@ -101,8 +168,8 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set // in C's subtree, and of nothing else. Reachability is computed per // class; tracing is then decided per hierarchy unit below. const shapes = new Map(); - const unions = new Map((mod.unions ?? []).map((u) => [u.id, node()])); - for (const s of shapeDefs) shapes.set(s.key, node()); + const unions = new Map((mod.unions ?? []).map((u) => [u.id, node(`union:${u.id}`)])); + for (const s of shapeDefs) shapes.set(s.key, node(s.key)); const children = new Map(); for (const c of classes) if (c.base !== undefined && shapes.has(`object:${c.base}`)) { @@ -112,15 +179,26 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set } // Created up front and linked without recursion (hierarchies can be // thousands deep). - const subtrees = new Map([...children.keys()].map((name) => [name, node()] as const)); + const subtrees = new Map( + [...children.keys()].map((name) => [name, node(`subtree:${name}`)] as const), + ); const subtreeOf = (className: string): Node | undefined => subtrees.get(className) ?? shapes.get(`object:${className}`); for (const [name, sub] of subtrees) { sub.edges.set(shapes.get(`object:${name}`)!, false); - for (const kid of children.get(name)!) sub.edges.set(subtreeOf(kid)!, false); + sub.labels?.set(shapes.get(`object:${name}`)!, { kind: "subclass", className: name }); + for (const kid of children.get(name)!) { + sub.edges.set(subtreeOf(kid)!, false); + sub.labels?.set(subtreeOf(kid)!, { kind: "subclass", className: kid }); + } } // Each field/arm is an OR of intrinsic capability and referenced nodes. - const addType = (from: Node, t: IrType, mutable: boolean): void => { + const addType = ( + from: Node, + t: IrType, + mutable: boolean, + label: CycleEdgeLabel | null = null, + ): void => { if (from.intrinsic) return; switch (t.kind) { case "func": @@ -130,6 +208,13 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set case "caught": from.intrinsic = true; from.edges.clear(); + if (from.labels !== null) { + from.labels.clear(); + from.intrinsicField = { + field: label?.kind === "field" ? label.field : "", + holds: t.kind, + }; + } break; case "object": case "record": @@ -140,17 +225,28 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set : t.kind === "object" ? subtreeOf(t.className) : shapes.get(`record:${t.shapeId}`); - if (dependency) - from.edges.set(dependency, (from.edges.get(dependency) ?? false) || mutable); + if (dependency) { + const before = from.edges.get(dependency); + from.edges.set(dependency, (before ?? false) || mutable); + if ( + from.labels !== null && + label !== null && + (before === undefined || (!before && mutable)) + ) + from.labels.set( + dependency, + label.kind === "field" ? { ...label, mutable: label.mutable || mutable } : label, + ); + } break; } case "map": - addType(from, t.key, true); - addType(from, t.value, true); + addType(from, t.key, true, label?.kind === "field" ? { ...label, via: "map" } : label); + addType(from, t.value, true, label?.kind === "field" ? { ...label, via: "map" } : label); break; case "set": case "array": - addType(from, t.elem, true); + addType(from, t.elem, true, label?.kind === "field" ? { ...label, via: t.kind } : label); break; } }; @@ -158,15 +254,18 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set const unitMutable = mutability.all || mutability.units.has(s.unit); for (const field of s.fields) { if (mutability.emptyPropertyBags && field.name === DYN_CLASS_PROPERTIES) continue; + const mutable = unitMutable || mutability.fields.has(fieldKey(s.unit, field.name)); addType( shapes.get(s.key)!, field.type, - unitMutable || mutability.fields.has(fieldKey(s.unit, field.name)), + mutable, + labels ? { kind: "field", field: field.name, mutable } : null, ); } } for (const u of mod.unions ?? []) - for (const arm of u.arms) addType(unions.get(u.id)!, arm, mutability.all); + for (const arm of u.arms) + addType(unions.get(u.id)!, arm, mutability.all, labels ? { kind: "arm" } : null); // Tarjan's strongly connected components, iteratively (shape chains can // be thousands deep). @@ -240,5 +339,91 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set for (const s of shapeDefs) if (shapes.get(s.key)!.traced) tracedUnits.add(s.unit); const tracedShapes = new Set(shapeDefs.filter((s) => tracedUnits.has(s.unit)).map((s) => s.key)); const tracedUnions = new Set([...unions].filter(([, n]) => n.traced).map(([id]) => id)); - return { shapes: tracedShapes, unions: tracedUnions }; + + /** Shortest path from `start` to a node satisfying `goal`, following + * only edges `allow` admits. The steps exclude the goal itself. */ + const path = ( + start: Node, + goal: (n: Node) => boolean, + allow: (from: Node, to: Node) => boolean, + ): { steps: CycleStep[]; end: Node } | null => { + const previous = new Map([[start, null]]); + const queue = [start]; + for (let i = 0; i < queue.length; i++) { + const n = queue[i]!; + if (goal(n)) { + const steps: CycleStep[] = []; + for ( + let at = n, from = previous.get(n)!; + from !== null; + at = from, from = previous.get(from)! + ) + steps.unshift({ from: from.name, label: from.labels?.get(at) ?? { kind: "arm" } }); + return { steps, end: n }; + } + for (const next of n.edges.keys()) + if (!previous.has(next) && allow(n, next)) { + previous.set(next, n); + queue.push(next); + } + } + return null; + }; + const explainNode = (n: Node): CycleExplanation | null => { + if (!n.traced) return null; + const component = n.component; + const inside = (_from: Node, to: Node): boolean => to.component === component; + const intrinsic = (target: Node, steps: CycleStep[]): CycleExplanation => ({ + kind: "intrinsic", + steps, + owner: target.name, + field: target.intrinsicField?.field ?? "", + holds: target.intrinsicField?.holds ?? "dyn", + }); + if (componentCapable[component]!) { + const members = all.filter((m) => m.component === component); + const lead = members.find((m) => m.intrinsic); + if (lead !== undefined) { + const found = path(n, (m) => m === lead, inside); + if (found) return intrinsic(lead, found.steps); + } + for (const from of members) + for (const [to, mutable] of from.edges) { + if (!mutable || to.component !== component) continue; + const there = from === n ? { steps: [] } : path(n, (m) => m === from, inside); + const back = to === n ? { steps: [] } : path(to, (m) => m === n, inside); + if (!there || !back) continue; + return { + kind: "cycle", + steps: [ + ...there.steps, + { from: from.name, label: from.labels?.get(to) ?? { kind: "arm" } }, + ...back.steps, + ], + }; + } + } + const found = path( + n, + (m) => m.intrinsic, + () => true, + ); + return found ? intrinsic(found.end, found.steps) : null; + }; + const explain = (key: string): CycleExplanation | null => { + const unionId = key.startsWith("union:") ? key.slice("union:".length) : null; + const n = unionId !== null ? unions.get(unionId) : shapes.get(key); + if (n === undefined) return null; + const own = explainNode(n); + if (own !== null || unionId !== null) return own; + const def = shapeDefs.find((d) => d.key === key); + if (def === undefined || !tracedUnits.has(def.unit)) return null; + for (const other of shapeDefs) { + if (other.unit !== def.unit || other.key === key) continue; + const because = explainNode(shapes.get(other.key)!); + if (because !== null) return { kind: "hierarchy", member: other.key, because }; + } + return null; + }; + return { shapes: tracedShapes, unions: tracedUnions, explain }; } diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts index fc5dcc7ee..2f8f9cee7 100644 --- a/packages/compiler/src/contracts/check.ts +++ b/packages/compiler/src/contracts/check.ts @@ -10,6 +10,9 @@ * proven by the same integer range analysis the int32 storage uses, * assuming every Int32 slot holds an int32 (sound by induction: every * write is checked under that assumption). + * - Acyclic: the class or object type cannot lie on a reference cycle, by + * the cycle analysis that decides tracing (backend/cycle-analysis.ts), or + * the diagnostic shows the cycle. * - Immutable: no mutation can reach a value that may come from an * Immutable slot, values stored into Immutable slots are fresh (nothing * else references them), and such values never escape to code the IR @@ -40,6 +43,13 @@ import { import { findInitializerBindings, withInitializerBindings } from "../ir/initializer-bindings.js"; import { dynamicallyStoredFamilies, functionCallFacts, Int32Slots } from "../ir/int32-slots.js"; import { computeMayThrow, type MayThrowReason } from "../backend/may-throw.js"; +import { + computeTraced, + explainCycle, + type CycleEdgeLabel, + type CycleExplanation, + type CycleStep, +} from "../backend/cycle-analysis.js"; import { SourceLocations } from "../backend/source-locations.js"; import { contractLocKey, @@ -229,6 +239,7 @@ class ContractChecker { run(): ContractCheckResult { this.checkNoThrow(); + this.checkAcyclic(); const int32 = new Int32Contracts(this); int32.run(); const immutable = new ImmutableContracts(this); @@ -245,6 +256,192 @@ class ContractChecker { this.recorded = true; } + /* ── Acyclic ── */ + + private checkAcyclic(): void { + if (this.notes.acyclic.length === 0) return; + const traced = computeTraced(this.mod); + const proven: string[] = []; + // Object types are structural in the IR: name a record by the + // interface or alias that declared it Acyclic. + for (const { note, unit } of this.notes.acyclic) + if (unit != null && unit.startsWith("record:")) this.unitNames.set(unit, note.name); + for (const { note, classKey, unit } of this.notes.acyclic) { + // A class may lower to several IR classes (generic instantiations); + // an unused type has no unit and no values, so it holds trivially. + const units = + classKey !== undefined + ? this.classesAt(classKey).map((cls) => `object:${cls.name}`) + : unit != null + ? [unit] + : []; + for (const key of units) { + const isTraced = key.startsWith("union:") + ? traced.unions.has(key.slice("union:".length)) + : traced.shapes.has(key); + if (!isTraced) { + proven.push(key); + continue; + } + const why = explainCycle(this.mod, key); + this.report( + "SC7007", + note.loc, + `'${note.name}' is declared Acyclic but can lie on a reference cycle: ${why ? this.cycleText(key, why) : "the cycle analysis traces it"}`, + why ? this.cycleHint(why) : "remove Acyclic", + ); + break; + } + } + this.record("acyclic", proven); + } + + private readonly unitNames = new Map(); + + /** A unit key's spelling: a class name, a record's declared name or + * `{ a, b }`, the arms of a union. */ + unitText(key: string): string { + const named = this.unitNames.get(key); + if (named !== undefined) return named; + const colon = key.indexOf(":"); + const kind = key.slice(0, colon), + id = key.slice(colon + 1); + if (kind === "object" || kind === "subtree") + return this.typeText({ kind: "object", className: id }); + if (kind === "union") return this.typeText({ kind: "union", unionId: id }); + const record = this.mod.records?.find((r) => r.id === id); + return record ? `{ ${record.fields.map((f) => f.name).join(", ")} }` : "an object"; + } + + private fieldText(field: string): string { + if (field === "%dynProperties") return "(dynamic properties)"; + if (field === "") return "(event listeners)"; + if (field === "") return "(class object)"; + if (field === "") return "[key]"; + return field; + } + + /** The path as `A.f → B.g → A`, folding union and subclass hops into the + * field that reaches them. */ + private stepsText(steps: readonly CycleStep[], end: string): string { + const parts: string[] = []; + // A slot of class type C holding a subclass D reads `D (as a C)`. + let asBase: string | null = null; + const unit = (key: string): string => { + const text = this.unitText(key); + const suffix = asBase !== null && asBase !== text ? ` (as a ${asBase})` : ""; + asBase = null; + return `${text}${suffix}`; + }; + for (const step of steps) { + if (step.label.kind === "subclass") { + asBase ??= this.unitText(step.from); + continue; + } + if (step.label.kind === "field") + parts.push(`${unit(step.from)}.${this.fieldText(step.label.field)}`); + } + parts.push(unit(end)); + return parts.join(" → "); + } + + private cycleText(key: string, why: CycleExplanation): string { + switch (why.kind) { + case "hierarchy": + return `its subclass '${this.unitText(why.member)}' shares its object layout, and ${this.cycleText(why.member, why.because)}`; + case "intrinsic": { + const holds = + why.holds === "func" + ? "a function" + : why.holds === "dyn" + ? "a dynamic value" + : why.holds === "caught" + ? "a caught exception" + : why.holds === "promise" + ? "a promise" + : "a class object"; + const field = + why.field === "%dynProperties" + ? `${this.unitText(why.owner)} instances reach dynamic code, whose properties` + : why.field === "" + ? this.unitText(why.owner) + : `${this.unitText(why.owner)}.${this.fieldText(why.field)}`; + const path = why.steps.length > 0 ? `${this.stepsText(why.steps, why.owner)}, and ` : ""; + return `${path}${field} can hold ${holds}, which can reference any object, including a ${this.unitText(key)}`; + } + case "cycle": { + const mutable = why.steps.find( + (s): s is CycleStep & { label: CycleEdgeLabel & { kind: "field" } } => + s.label.kind === "field" && (s.label.mutable || s.label.via !== undefined), + ); + let reason = ""; + if (mutable) { + const where = `${this.unitText(mutable.from)}.${this.fieldText(mutable.label.field)}`; + if (mutable.label.via !== undefined) + reason = ` (${where} is ${mutable.label.via === "array" ? "an array" : `a ${mutable.label.via === "map" ? "Map" : "Set"}`}, whose contents can change)`; + else { + const write = this.writeOutsideConstructor(mutable.from, mutable.label.field); + reason = ` (${where} can be assigned after construction${write !== null ? `, as at ${this.where(write)}` : ""})`; + } + } + return `${this.stepsText(why.steps, key)}${reason}`; + } + } + } + + private cycleHint(why: CycleExplanation): string { + const inner = why.kind === "hierarchy" ? why.because : why; + if (inner.kind === "intrinsic") + return "a function, dynamic or promise field can capture the object itself; store plain data instead, or remove Acyclic"; + if ( + inner.kind === "cycle" && + !inner.steps.some( + (s) => s.label.kind === "field" && s.label.mutable && s.label.via === undefined, + ) + ) + return "an array, Map or Set can come to hold an object that refers back; make the element type unable to reach this type, or remove Acyclic"; + return "assign the reference only in the constructor (before `this` escapes), so a new object can only point to older ones, or remove Acyclic"; + } + + /** The first store into the field outside its own class's constructors. */ + private writeOutsideConstructor(unit: string, field: string): SrcLoc | null { + const colon = unit.indexOf(":"); + const kind = unit.slice(0, colon), + id = unit.slice(colon + 1); + let found: SrcLoc | null = null; + for (const fn of this.mod.functions) { + if (found !== null) break; + const ctor = /^%(.*)\.constructor$/.exec(fn.name)?.[1]; + const self = ctor !== undefined ? fn.params[0]?.localId : undefined; + walk(fn.body, { + stmt: (s) => { + if (found !== null) return; + if (kind === "record" && s.kind === "recordSet" && s.shapeId === id && s.field === field) + found = s.loc; + else if ( + kind !== "record" && + s.kind === "fieldSet" && + s.field === field && + this.sameHierarchy(s.className, id) && + !(self !== undefined && s.obj.kind === "varRef" && s.obj.localId === self) + ) + found = s.loc; + }, + }); + } + return found; + } + + private sameHierarchy(a: string, b: string): boolean { + const root = (name: string): string => { + let cur = name; + for (let cls = this.classes.get(cur); cls?.base !== undefined; cls = this.classes.get(cur)) + cur = cls.base; + return cur; + }; + return root(a) === root(b); + } + /* ── NoThrow ── */ private checkNoThrow(): void { diff --git a/packages/compiler/src/frontend/contract-types.ts b/packages/compiler/src/frontend/contract-types.ts index 7c622fdee..4d92b3fa9 100644 --- a/packages/compiler/src/frontend/contract-types.ts +++ b/packages/compiler/src/frontend/contract-types.ts @@ -13,8 +13,8 @@ import * as ts from "./ts7/adapter.js"; export const CONTRACT_TYPES_MODULE = "@scriptc/types"; -export type ContractMarker = "Int32" | "NoThrow" | "Immutable"; -const MARKERS: ReadonlySet = new Set(["Int32", "NoThrow", "Immutable"]); +export type ContractMarker = "Int32" | "NoThrow" | "Immutable" | "Acyclic"; +const MARKERS: ReadonlySet = new Set(["Int32", "NoThrow", "Immutable", "Acyclic"]); /** One written contract: what it promises and where the annotation is. */ export interface ContractNote { @@ -25,6 +25,21 @@ export interface ContractNote { name: string; } +/** An Acyclic type: a class (by its declaration's location key, like + * `fields`) or an object type written as an interface or type alias (by + * the IR unit it lowers to: `record:`, `object:` or + * `union:`; null when the program never uses the type, so no value of + * it exists). */ +export interface AcyclicNote { + note: ContractNote; + classKey?: string; + unit?: string | null; +} + +/** Maps a TypeScript type to the IR unit the program lowered it to, or + * null when the program has no such unit. */ +export type ContractUnitResolver = (type: ts.Type) => string | null; + /** Contracts keyed by the declaration they annotate. Keys are SrcLoc keys * (contractLocKey) of the nodes the lowering stamps on the IR: function * declarations (IrFunction.loc), binding names (IrLocal/IrGlobal @@ -36,6 +51,8 @@ export interface ContractAnnotations { bindings: Map; /** Instance fields by `${class key}\0${field name}`. */ fields: Map; + /** Acyclic classes and object types. */ + acyclic: AcyclicNote[]; diagnostics: ScrDiagnostic[]; /** Number of annotations found (for timing and tests). */ count: number; @@ -50,25 +67,43 @@ export function contractLocKey(loc: SrcLoc): string { * "@scriptc/types"`, `import type * as sc from "@scriptc/types"`). */ function markerOf(checker: ts.TypeChecker, ref: ts.TypeReferenceNode): ContractMarker | null { const name = ref.typeName; - let ident: ts.Identifier; - let member: string; - if (ts.isIdentifier(name)) { - if (!MARKERS.has(name.text)) return null; - ident = name; - member = name.text; - } else if (ts.isQualifiedName(name) && ts.isIdentifier(name.left)) { - if (!MARKERS.has(name.right.text)) return null; - ident = name.left; - member = name.right.text; - } else return null; + if (ts.isIdentifier(name)) return markerNamed(checker, name, name.text, false); + if (ts.isQualifiedName(name) && ts.isIdentifier(name.left)) + return markerNamed(checker, name.left, name.right.text, true); + return null; +} + +/** The marker a heritage clause names (`implements Acyclic`, + * `implements sc.Acyclic`). */ +function markerOfHeritage( + checker: ts.TypeChecker, + node: ts.ExpressionWithTypeArguments, +): ContractMarker | null { + const e = node.expression; + if (ts.isIdentifier(e)) return markerNamed(checker, e, e.text, false); + if (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && ts.isIdentifier(e.name)) + return markerNamed(checker, e.expression, e.name.text, true); + return null; +} + +/** Resolve `ident` (the whole name, or the namespace of a qualified one) + * to an import of @scriptc/types and answer the imported marker. */ +function markerNamed( + checker: ts.TypeChecker, + ident: ts.Identifier, + written: string, + qualified: boolean, +): ContractMarker | null { + if (!MARKERS.has(written)) return null; + let member = written; const symbol = checker.getSymbolAtLocation(ident); const decl = symbol ? checker.declarationsOf(symbol)[0] : undefined; if (decl === undefined) return null; let importDecl: ts.Node | undefined; - if (ts.isImportSpecifier(decl) && ident === name) { + if (ts.isImportSpecifier(decl) && !qualified) { importDecl = decl.parent?.parent?.parent; member = decl.propertyName?.text ?? decl.name.text; - } else if (ts.isNamespaceImport(decl) && ident !== name) importDecl = decl.parent?.parent; + } else if (ts.isNamespaceImport(decl) && qualified) importDecl = decl.parent?.parent; else return null; if ( importDecl === undefined || @@ -101,25 +136,38 @@ function contractDiag( export function collectContractAnnotations( checker: ts.TypeChecker, files: readonly ts.SourceFile[], + resolveUnit: ContractUnitResolver = () => null, ): ContractAnnotations { const out: ContractAnnotations = { functions: new Map(), bindings: new Map(), fields: new Map(), + acyclic: [], diagnostics: [], count: 0, }; - const kinds = new Set([ts.SyntaxKind.TypeReference]); + const kinds = new Set([ + ts.SyntaxKind.TypeReference, + ts.SyntaxKind.ExpressionWithTypeArguments, + ]); for (const sf of files) { if (sf.isDeclarationFile || !sf.text.includes(CONTRACT_TYPES_MODULE)) continue; ts.walkPreorder( sf, (node) => { + if (ts.isExpressionWithTypeArguments(node)) { + const marker = markerOfHeritage(checker, node); + if (marker === null) return; + out.count++; + placeHeritage(out, checker, node, marker, resolveUnit); + return; + } if (!ts.isTypeReferenceNode(node)) return; const marker = markerOf(checker, node); if (marker === null) return; out.count++; - place(out, checker, node, marker); + if (marker === "Acyclic") placeAcyclicAlias(out, checker, node, resolveUnit); + else place(out, checker, node, marker); }, kinds, ); @@ -128,6 +176,17 @@ export function collectContractAnnotations( } function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMarker): void { + if (marker === "Acyclic") { + out.diagnostics.push( + contractDiag( + "SC7006", + locOf(node), + "the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases)", + "write `class C implements Acyclic`, `interface I extends Acyclic` or `type T = Acyclic<{ ... }>`", + ), + ); + return; + } const where = marker === "NoThrow" ? "NoThrow wraps a function's written return type" @@ -146,6 +205,56 @@ function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMa ); } +/** `class C implements Acyclic` and `interface I extends Acyclic`. */ +function placeHeritage( + out: ContractAnnotations, + checker: ts.TypeChecker, + node: ts.ExpressionWithTypeArguments, + marker: ContractMarker, + resolveUnit: ContractUnitResolver, +): void { + const clause = node.parent; + const owner = clause?.parent; + if ( + marker !== "Acyclic" || + clause === undefined || + !ts.isHeritageClause(clause) || + owner === undefined + ) + return unsupported(out, node, marker); + const note = (name: string): ContractNote => ({ marker, loc: locOf(node), name }); + if ( + (ts.isClassDeclaration(owner) || ts.isClassExpression(owner)) && + clause.token === ts.SyntaxKind.ImplementsKeyword + ) { + out.acyclic.push({ note: note(nameText(owner.name)), classKey: contractLocKey(locOf(owner)) }); + return; + } + if (ts.isInterfaceDeclaration(owner) && clause.token === ts.SyntaxKind.ExtendsKeyword) { + const symbol = checker.getSymbolAtLocation(owner.name); + const unit = symbol ? resolveUnit(checker.getDeclaredTypeOfSymbol(symbol)) : null; + out.acyclic.push({ note: note(owner.name.text), unit }); + return; + } + unsupported(out, node, marker); +} + +/** `type T = Acyclic<{ ... }>`: the aliased object type. */ +function placeAcyclicAlias( + out: ContractAnnotations, + checker: ts.TypeChecker, + ref: ts.TypeReferenceNode, + resolveUnit: ContractUnitResolver, +): void { + const alias = ref.parent; + if (alias === undefined || !ts.isTypeAliasDeclaration(alias) || alias.type !== ref) + return unsupported(out, ref, "Acyclic"); + out.acyclic.push({ + note: { marker: "Acyclic", loc: locOf(ref), name: alias.name.text }, + unit: resolveUnit(checker.getTypeFromTypeNode(ref)), + }); +} + function place( out: ContractAnnotations, checker: ts.TypeChecker, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 6515b2b6d..d7b6b28c1 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1044,7 +1044,27 @@ export function lowerToIr( if (divergences.length > 0) result = { ...result, divergences }; // Contract types (@scriptc/types): written annotations, checked against // the IR by the compile pipeline (contracts/check.ts). - const contracts = collectContractAnnotations(program.getTypeChecker(), moduleOrder); + // Acyclic object types name the IR unit the program lowered them to; a + // type the module never uses has no unit (mapping it now only touches the + // lowerer's tables, never the finished module). + const lowered = result.module; + const contracts = collectContractAnnotations(program.getTypeChecker(), moduleOrder, (type) => { + if (lowered === null) return null; + const mapped = panicSafe(() => resultLowerer.mapTypeOf(type), null); + if (mapped?.kind === "object") + return lowered.classes?.some((c) => c.name === mapped.className) + ? `object:${mapped.className}` + : null; + if (mapped?.kind === "record") + return lowered.records?.some((r) => r.id === mapped.shapeId) + ? `record:${mapped.shapeId}` + : null; + if (mapped?.kind === "union") + return lowered.unions?.some((u) => u.id === mapped.unionId) + ? `union:${mapped.unionId}` + : null; + return null; + }); timing("contract-annotations", { found: contracts.count }); if (contracts.diagnostics.length > 0) result = { diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 4b687f438..f7409c7fa 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1002,6 +1002,10 @@ export interface IrContracts { int32Locals?: string[]; /** Functions that cannot throw (NoThrow). */ noThrow?: string[]; + /** Acyclic classes and object types, as cycle-analysis units + * (`object:`, `record:`, `union:`): proven unable to lie + * on a reference cycle, so they carry no cycle header. */ + acyclic?: string[]; /** Immutable module constants built from fresh literals: the backend * makes their graphs immortal once initialized, and reads through them * need no reference counting. */ diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1580650f6..37d492be9 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11789,6 +11789,18 @@ ], "diags": [] }, + "/tests/diagnostics/contract-acyclic-positions.ts": { + "order": [ + "/tests/diagnostics/contract-acyclic-positions.ts" + ], + "diags": [] + }, + "/tests/diagnostics/contract-acyclic.ts": { + "order": [ + "/tests/diagnostics/contract-acyclic.ts" + ], + "diags": [] + }, "/tests/diagnostics/create-require.ts": { "order": [ "/tests/diagnostics/create-require.ts" @@ -15878,6 +15890,12 @@ ], "diags": [] }, + "/tests/corpus/4642-acyclic-contract.ts": { + "order": [ + "/tests/corpus/4642-acyclic-contract.ts" + ], + "diags": [] + }, "/tests/diagnostics/contract-immutable.ts": { "order": [ "/tests/diagnostics/contract-immutable.ts" diff --git a/packages/types/README.md b/packages/types/README.md index 5436cee18..2723c9f8b 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -21,5 +21,6 @@ function isLetter(ch: number): NoThrow { - `NoThrow` on a function's return type: the function never throws. Callers need no exception checks. - `Int32` on a parameter, variable, class instance field or function result: every stored value is a signed 32-bit integer by construction (an integer literal in range, a bitwise result such as `x | 0`, `Math.imul`, another `Int32`, or arithmetic scriptc can bound). The slot is stored as a 32-bit integer. - `Immutable` on a parameter, variable, class instance field or function result: a deep read-only view, and no code writes to the value through any reference. A module constant built from literals becomes immortal data. +- `Acyclic` on a class (`class C implements Acyclic`), interface (`interface I extends Acyclic`) or object type alias (`type T = Acyclic<{ ... }>`): no chain of references can lead from such an object back to itself. Its objects carry no cycle-collector header. -scriptc proves each contract or rejects the program with a diagnostic (SC7001-SC7006) that explains why it does not hold. A program that compiles behaves exactly as under Node.js. See https://scriptc.dev/docs/contracts. +scriptc proves each contract or rejects the program with a diagnostic (SC7001-SC7007) that explains why it does not hold. A program that compiles behaves exactly as under Node.js. See https://scriptc.dev/docs/contracts. diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 28d26ec01..6827b2fef 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -4,7 +4,8 @@ * which TypeScript erases, so Node.js never loads this package and behaves * exactly as without the annotations. scriptc recognizes the aliases where * they are written (parameter, return, field, variable and property - * annotations) and checks each one as an extra contract: the program either + * annotations, `implements` and `extends` clauses, and type aliases) and + * checks each one as an extra contract: the program either * compiles with identical behavior, plus the optimization the contract * enables, or fails with a diagnostic that explains why the contract does * not hold. */ @@ -39,3 +40,12 @@ export type Immutable = T extends (...args: never[]) => unknown : T extends object ? { readonly [K in keyof T]: Immutable } : T; + +/** A class or object type whose instances can never lie on a reference + * cycle. Write it as `class C implements Acyclic`, `interface I extends + * Acyclic` or `type T = Acyclic<{ ... }>`. scriptc proves that no chain of + * references can lead from such an object back to itself (for example, + * every reference field is assigned only in the constructor, and none holds + * a function), or reports the cycle that can form. Its objects then carry + * no cycle-collector header and are freed by reference counting alone. */ +export type Acyclic> = T; diff --git a/tests/corpus/4642-acyclic-contract.ts b/tests/corpus/4642-acyclic-contract.ts new file mode 100644 index 000000000..bc743d354 --- /dev/null +++ b/tests/corpus/4642-acyclic-contract.ts @@ -0,0 +1,64 @@ +// Acyclic contract types (@scriptc/types): scriptc proves these classes and +// object types cannot lie on a reference cycle, so their objects carry no +// cycle-collector header and are freed by reference counting alone. +import type { Acyclic } from "@scriptc/types"; +import type * as sc from "@scriptc/types"; + +class Cons implements Acyclic { + readonly head: number; + readonly tail: Cons | undefined; + constructor(head: number, tail: Cons | undefined) { + this.head = head; + this.tail = tail; + } +} + +class Tree implements Acyclic { + readonly left: Tree | null; + readonly right: Tree | null; + readonly value: number; + constructor(left: Tree | null, right: Tree | null, value: number) { + this.left = left; + this.right = right; + this.value = value; + } + sum(): number { + return this.value + (this.left === null ? 0 : this.left.sum()) + (this.right === null ? 0 : this.right.sum()); + } +} + +// Mutable scalar fields do not matter: only references can form cycles. +class Counter implements sc.Acyclic { + count = 0; + label: string; + constructor(label: string) { + this.label = label; + } +} + +interface Point extends Acyclic { + readonly x: number; + readonly y: number; +} + +type Segment = Acyclic<{ readonly from: Point; readonly to: Point; readonly label: string }>; + +function build(depth: number): Tree { + return depth === 0 ? new Tree(null, null, 1) : new Tree(build(depth - 1), build(depth - 1), depth); +} + +let list: Cons | undefined = undefined; +for (let i = 0; i < 100000; i++) list = new Cons(i, list); +let total = 0; +for (let c = list; c !== undefined; c = c.tail) total += c.head; +console.log(total); +console.log(build(16).sum()); + +const counter = new Counter("ticks"); +for (let i = 0; i < 10; i++) counter.count++; +counter.label = `${counter.label}!`; +console.log(counter.label, counter.count); + +const segments: Segment[] = []; +for (let i = 0; i < 5; i++) segments.push({ from: { x: i, y: 0 }, to: { x: i, y: i * i }, label: `s${i}` }); +console.log(segments.map((s) => `${s.label}:${s.to.y - s.from.y}`).join(" ")); diff --git a/tests/diagnostics/contract-acyclic-positions.ts b/tests/diagnostics/contract-acyclic-positions.ts new file mode 100644 index 000000000..6dc163ea9 --- /dev/null +++ b/tests/diagnostics/contract-acyclic-positions.ts @@ -0,0 +1,13 @@ +// Acyclic is checked on classes, interfaces and object type aliases. +// Elsewhere it would promise something scriptc does not check, so the build +// reports it. +import type { Acyclic } from "@scriptc/types"; + +interface Item { + name: string; +} + +const item: Acyclic = { name: "a" }; +const items: Acyclic[] = [item]; + +console.log(item.name, items.length); diff --git a/tests/diagnostics/contract-acyclic.ts b/tests/diagnostics/contract-acyclic.ts new file mode 100644 index 000000000..cc4a8e7c1 --- /dev/null +++ b/tests/diagnostics/contract-acyclic.ts @@ -0,0 +1,59 @@ +// Acyclic contracts that do not hold: each diagnostic shows a reference +// cycle that can form. +import type { Acyclic } from "@scriptc/types"; + +class ListNode implements Acyclic { + value: number; + next: ListNode | undefined = undefined; + constructor(value: number) { + this.value = value; + } +} + +class Task implements Acyclic { + readonly name: string; + readonly done: () => void; + constructor(name: string, done: () => void) { + this.name = name; + this.done = done; + } +} + +class Graph implements Acyclic { + readonly nodes: GraphNode[] = []; +} + +class GraphNode { + readonly graph: Graph; + constructor(graph: Graph) { + this.graph = graph; + } +} + +class Shape implements Acyclic { + readonly id: number; + constructor(id: number) { + this.id = id; + } +} + +class Group extends Shape { + readonly members: Shape[] = []; +} + +interface Cell extends Acyclic { + value: number; + link: Cell | null; +} + +const a = new ListNode(1); +a.next = new ListNode(2); +a.next.next = a; +const t: Task = new Task("t", () => console.log(t.name)); +const g = new Graph(); +g.nodes.push(new GraphNode(g)); +const group = new Group(1); +group.members.push(group); +const c: Cell = { value: 1, link: null }; +c.link = c; +console.log(a.value, t.name, g.nodes.length, group.members.length, c.value); diff --git a/tests/harness/__snapshots__/contract-acyclic-positions.ts.txt b/tests/harness/__snapshots__/contract-acyclic-positions.ts.txt new file mode 100644 index 000000000..c1dc6db77 --- /dev/null +++ b/tests/harness/__snapshots__/contract-acyclic-positions.ts.txt @@ -0,0 +1,17 @@ +contract-acyclic-positions.ts:10:13 - error SC7006: the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases) + + 9 | + 10 | const item: Acyclic = { name: "a" }; + | ^~~~~~~~~~~~~ + 11 | const items: Acyclic[] = [item]; + + hint: write `class C implements Acyclic`, `interface I extends Acyclic` or `type T = Acyclic<{ ... }>` + +contract-acyclic-positions.ts:11:14 - error SC7006: the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases) + + 10 | const item: Acyclic = { name: "a" }; + 11 | const items: Acyclic[] = [item]; + | ^~~~~~~~~~~~~ + 12 | + + hint: write `class C implements Acyclic`, `interface I extends Acyclic` or `type T = Acyclic<{ ... }>` \ No newline at end of file diff --git a/tests/harness/__snapshots__/contract-acyclic.ts.txt b/tests/harness/__snapshots__/contract-acyclic.ts.txt new file mode 100644 index 000000000..77ea7fa4d --- /dev/null +++ b/tests/harness/__snapshots__/contract-acyclic.ts.txt @@ -0,0 +1,44 @@ +contract-acyclic.ts:5:27 - error SC7007: 'ListNode' is declared Acyclic but can lie on a reference cycle: ListNode.next → ListNode (ListNode.next can be assigned after construction, as at contract-acyclic.ts:50:1) + + 4 | + 5 | class ListNode implements Acyclic { + | ^~~~~~~ + 6 | value: number; + + hint: assign the reference only in the constructor (before `this` escapes), so a new object can only point to older ones, or remove Acyclic + +contract-acyclic.ts:13:23 - error SC7007: 'Task' is declared Acyclic but can lie on a reference cycle: Task.done can hold a function, which can reference any object, including a Task + + 12 | + 13 | class Task implements Acyclic { + | ^~~~~~~ + 14 | readonly name: string; + + hint: a function, dynamic or promise field can capture the object itself; store plain data instead, or remove Acyclic + +contract-acyclic.ts:22:24 - error SC7007: 'Graph' is declared Acyclic but can lie on a reference cycle: Graph.nodes → GraphNode.graph → Graph (Graph.nodes is an array, whose contents can change) + + 21 | + 22 | class Graph implements Acyclic { + | ^~~~~~~ + 23 | readonly nodes: GraphNode[] = []; + + hint: an array, Map or Set can come to hold an object that refers back; make the element type unable to reach this type, or remove Acyclic + +contract-acyclic.ts:33:24 - error SC7007: 'Shape' is declared Acyclic but can lie on a reference cycle: its subclass 'Group' shares its object layout, and Group.members → Group (as a Shape) (Group.members is an array, whose contents can change) + + 32 | + 33 | class Shape implements Acyclic { + | ^~~~~~~ + 34 | readonly id: number; + + hint: an array, Map or Set can come to hold an object that refers back; make the element type unable to reach this type, or remove Acyclic + +contract-acyclic.ts:44:24 - error SC7007: 'Cell' is declared Acyclic but can lie on a reference cycle: Cell.link → Cell (Cell.link can be assigned after construction, as at contract-acyclic.ts:58:1) + + 43 | + 44 | interface Cell extends Acyclic { + | ^~~~~~~ + 45 | value: number; + + hint: assign the reference only in the constructor (before `this` escapes), so a new object can only point to older ones, or remove Acyclic \ No newline at end of file From ad2fcfbc2775b89f5ed1240d5b7d655bacad533b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 14:30:03 -0700 Subject: [PATCH 05/10] try/catch discharges NoThrow: handlers take what they catch The may-throw analysis now models handlers. A throwing operation or call inside a `try` with a `catch`, or inside a try/catch whose `finally` never completes normally (it returns, breaks or continues, discarding the pending exception), adds no seed and no call edge. The catch and finally bodies are analyzed like any other code, so rethrows and a finally that re-raises stay exact. Worker programs (a termination request unwinds through catch) and generator bodies (a `.return()` does too) keep the conservative answer. Unreachable statements no longer seed: the frontend's SC9002 trap after a body that returns from both its try and its catch (or ends in a switch with a default, or an infinite loop) is dead code. On tsc-ts this drops 14 functions from the may-throw set and 44 pending-exception checks. A NoThrow function may now contain handled calls; the SC7001 hint names the remedy that applies (catch it, remove the recursion, or the worker exception). --- .oxlintrc.jsonc | 1 + docs/content/docs/contracts.mdx | 33 ++- docs/content/docs/limitations.mdx | 2 +- .../compiler/src/backend/may-throw.test.ts | 155 +++++++++++++- packages/compiler/src/backend/may-throw.ts | 193 +++++++++++++++++- packages/compiler/src/contracts/check.ts | 11 +- .../test/ts7/baselines/order-parity.json | 12 ++ tests/corpus/4641-nothrow-try-catch.ts | 99 +++++++++ .../diagnostics/contract-nothrow-handlers.ts | 40 ++++ .../contract-nothrow-handlers.ts.txt | 17 ++ .../__snapshots__/contract-nothrow.ts.txt | 8 +- 11 files changed, 557 insertions(+), 14 deletions(-) create mode 100644 tests/corpus/4641-nothrow-try-catch.ts create mode 100644 tests/diagnostics/contract-nothrow-handlers.ts create mode 100644 tests/harness/__snapshots__/contract-nothrow-handlers.ts.txt diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 7de4019ae..52cbf5026 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -269,6 +269,7 @@ "packages/compiler/src/frontend/lowering/**", "packages/compiler/src/backend/llvm/**", "packages/compiler/src/backend/loop-polls.test.ts", + "packages/compiler/src/backend/may-throw.test.ts", "packages/compiler/src/ir/**", "packages/compiler/src/library/*.test.ts", "packages/compiler/test/**", diff --git a/docs/content/docs/contracts.mdx b/docs/content/docs/contracts.mdx index bb91e341e..eeae66472 100644 --- a/docs/content/docs/contracts.mdx +++ b/docs/content/docs/contracts.mdx @@ -118,7 +118,36 @@ A contract anywhere else (a type alias, an interface member, an element or type A `NoThrow` function must not contain a path to a throwing operation: a `throw`, a call to a function that may throw, a call through a function value that may hold such a function, an operation that throws in Node.js (such as reading a property of `undefined`), or recursion, whose stack guard can throw `RangeError`. scriptc already computes this for every function to omit exception checks; the contract turns the result into a guarantee and reports the first throwing path when it does not hold. -In this version a surrounding `try`/`catch` does not discharge the contract, and `async` functions and generators cannot be `NoThrow`. +A `try` statement discharges what its handlers take. A throwing call inside a `try` with a `catch`, or inside a `try` whose `finally` always ends with `return`, `break` or `continue` (which discards the pending exception), cannot leave the function: + +```ts:digits.ts +import type { NoThrow } from "@scriptc/types"; + +function parseDigit(ch: number): number { + if (ch < 48 || ch > 57) throw new RangeError("not a digit"); + return ch - 48; +} + +function digitOr(text: string, i: number, fallback: number): NoThrow { + try { + return parseDigit(text.charCodeAt(i)); + } catch { + return fallback; + } +} + +console.log(digitOr("7", 0, -1), digitOr("x", 0, -1)); +``` + +```console +$ node digits.ts +7 -1 +$ scriptc build digits.ts -o digits >/dev/null +$ ./digits +7 -1 +``` + +The `catch` and `finally` bodies are checked like any other code: a rethrow, a throwing call in the `catch`, or a `try`/`finally` without `catch` whose `finally` can complete normally (and so re-raises the exception) still fails the contract. A recursive function cannot be `NoThrow`, because its stack guard throws on entry, outside any handler. In programs that use worker threads, `try` does not discharge the contract, since a termination request unwinds through `catch`. `async` functions and generators cannot be `NoThrow`. ## Int32 @@ -192,7 +221,7 @@ $ scriptc build reject.ts | ^~~~~~~~~~~~~~~ 15 | return parseDigit(text.charCodeAt(i)); - hint: remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version + hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow /tmp/app/reject.ts:19:10 - error SC7002: the value returned from 'middle' (Int32) is not provably an int32: '(start + end) / 2' diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index c2860ee27..33343e915 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -130,7 +130,7 @@ A program that passes its project's TypeScript check may still use unsupported o ### Contract types -[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result; aliases, interface members and nested type positions are reported. A surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances reach dynamic code, and `Immutable` refuses values that reach dynamic code or that another reference can still change. +[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result; aliases, interface members and nested type positions are reported. In programs that use worker threads, a surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances reach dynamic code, and `Immutable` refuses values that reach dynamic code or that another reference can still change. ### three.js diff --git a/packages/compiler/src/backend/may-throw.test.ts b/packages/compiler/src/backend/may-throw.test.ts index c593de309..383e12ce5 100644 --- a/packages/compiler/src/backend/may-throw.test.ts +++ b/packages/compiler/src/backend/may-throw.test.ts @@ -12,7 +12,7 @@ import { type IrModule, type IrStmt, } from "../ir/ir.js"; -import { computeMayThrow } from "./may-throw.js"; +import { completesNormally, computeMayThrow } from "./may-throw.js"; const loc = { file: "tdz.ts", start: 0, end: 1 }; const value: IrExpr = { kind: "numLit", value: 1, type: F64, loc }; @@ -564,3 +564,156 @@ test("explain names the first throwing operation and each call edge", () => { // Without the option the analysis records nothing extra. expect(computeMayThrow(mod).why).toBeUndefined(); }); + +test("handlers discharge the exceptions they take, and only those", () => { + const call = (callee: string): IrStmt => + exprStmt({ kind: "call", callee, args: [], type: VOID, loc }); + const throwing: IrStmt = { kind: "throw", value, loc }; + const ret: IrStmt = { kind: "return", value: null, loc }; + const tryCatch = ( + tryBody: IrStmt[], + catchBody: IrStmt[] | null, + finallyBody: IrStmt[] | null = null, + ): IrStmt => ({ kind: "tryCatch", tryBody, catchBody, catchLocalId: null, finallyBody, loc }); + const plain = (name: string, body: IrStmt[]): IrFunction => fn(name, body, []); + const cond: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; + const mod = moduleWith( + plain("thrower", [throwing]), + plain("caught", [tryCatch([call("thrower"), throwing], [])]), + plain("callsCaught", [call("caught")]), + plain("rethrows", [tryCatch([call("thrower")], [throwing])]), + plain("finallyOnly", [tryCatch([call("thrower")], null, [exprStmt(value)])]), + plain("finallyReturns", [tryCatch([call("thrower")], [throwing], [ret])]), + plain("finallyMayComplete", [ + tryCatch([call("thrower")], null, [{ kind: "if", cond, then: [ret], else_: null, loc }]), + ]), + plain("finallyThrows", [tryCatch([], [], [call("thrower")])]), + plain("nested", [tryCatch([tryCatch([call("thrower")], [throwing])], [])]), + plain("indirectCaught", [ + tryCatch( + [ + exprStmt({ + kind: "callValue", + callee: { + kind: "closure", + fnName: "thrower", + captures: [], + type: funcOf([], VOID), + loc, + }, + args: [], + type: VOID, + loc, + }), + ], + [], + ), + ]), + plain("caller", []), + ); + const answer = computeMayThrow(mod); + expect([...answer.fns].sort()).toEqual([ + "finallyMayComplete", + "finallyOnly", + "finallyThrows", + "rethrows", + "thrower", + ]); + // The function value can still throw at other call sites. + expect(answer.indirect).toBe(true); + // Worker termination and generator returns unwind through catch. + expect(computeMayThrow({ ...mod, workers: true }).fns).toContain("caught"); + const generator: IrFunction = { + ...plain("caughtGenerator", [tryCatch([call("thrower")], [])]), + generator: {} as unknown as NonNullable, + }; + expect(computeMayThrow(moduleWith(...mod.functions, generator)).fns).toContain("caughtGenerator"); + // Explanations skip the handled path and name the rethrow. + const explained = computeMayThrow(mod, { explain: true }); + expect(explained.why?.get("rethrows")).toMatchObject({ kind: "seed", what: "throw statement" }); +}); + +test("completesNormally is conservative about abrupt completion", () => { + const cond: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; + const ret: IrStmt = { kind: "return", value: null, loc }; + const brk: IrStmt = { kind: "break", loc }; + expect(completesNormally([])).toBe(true); + expect(completesNormally([exprStmt(value), ret])).toBe(false); + expect(completesNormally([brk])).toBe(false); + expect(completesNormally([{ kind: "if", cond, then: [ret], else_: [brk], loc }])).toBe(false); + expect(completesNormally([{ kind: "if", cond, then: [ret], else_: null, loc }])).toBe(true); + expect(completesNormally([{ kind: "block", body: [ret], loc }])).toBe(false); + // A labeled block can be the target of a break inside it. + expect( + completesNormally([ + { kind: "block", body: [{ kind: "break", label: "l", loc }], labels: ["l"], loc }, + ]), + ).toBe(true); + // A loop that runs forever completes only through a break that leaves it. + expect(completesNormally([{ kind: "while", cond, body: [ret], loc }])).toBe(false); + expect(completesNormally([{ kind: "while", cond, body: [brk], loc }])).toBe(true); + expect( + completesNormally([ + { kind: "while", cond, body: [{ kind: "while", cond, body: [brk], loc }, ret], loc }, + ]), + ).toBe(false); + const unknown: IrExpr = { kind: "varRef", localId: "flag", type: BOOL, loc }; + expect(completesNormally([{ kind: "while", cond: unknown, body: [ret], loc }])).toBe(true); + const tryCatch = (catchBody: IrStmt[] | null, finallyBody: IrStmt[] | null = null): IrStmt => ({ + kind: "tryCatch", + tryBody: [ret], + catchBody, + catchLocalId: null, + finallyBody, + loc, + }); + expect(completesNormally([tryCatch([ret])])).toBe(false); + expect(completesNormally([tryCatch([])])).toBe(true); + expect(completesNormally([tryCatch(null, [])])).toBe(false); + expect(completesNormally([tryCatch([], [ret])])).toBe(false); + const kind: IrExpr = { kind: "numLit", value: 1, type: F64, loc }; + const switchOf = (cases: { test: IrExpr | null; body: IrStmt[] }[]): IrStmt => ({ + kind: "switch", + disc: kind, + cases, + loc, + }); + expect( + completesNormally([ + switchOf([ + { test: kind, body: [] }, + { test: null, body: [ret] }, + ]), + ]), + ).toBe(false); + expect(completesNormally([switchOf([{ test: kind, body: [ret] }])])).toBe(true); + expect( + completesNormally([ + switchOf([ + { test: kind, body: [brk] }, + { test: null, body: [ret] }, + ]), + ]), + ).toBe(true); +}); + +test("unreachable statements do not seed", () => { + const ret: IrStmt = { kind: "return", value: null, loc }; + const trap: IrStmt = { kind: "runtimeFence", code: "SC9002", message: "unreachable", loc }; + const tryCatch: IrStmt = { + kind: "tryCatch", + tryBody: [exprStmt({ kind: "call", callee: "thrower", args: [], type: VOID, loc }), ret], + catchBody: [ret], + catchLocalId: null, + finallyBody: null, + loc, + }; + const answer = computeMayThrow( + moduleWith( + fn("thrower", [{ kind: "throw", value, loc }], []), + fn("caller", [tryCatch, trap], []), + fn("reachable", [{ ...tryCatch, catchBody: [] }, trap], []), + ), + ); + expect([...answer.fns].sort()).toEqual(["reachable", "thrower"]); +}); diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 8f0bab18e..432e1a81b 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -3,7 +3,7 @@ import type { IrExpr, IrStmt, IrModule, IrType, SrcLoc } from "../ir/ir.js"; import { isFfiCallbackParam, MAY_THROW_ARR_METHODS, MAY_THROW_BYTES_METHODS } from "../ir/ir.js"; import { MAY_THROW_LIB_FNS } from "../ir/builtin-effects.js"; -import { everyStmtList } from "../ir/traverse.js"; +import { everyExprChild, everyStmtChild, everyStmtList } from "../ir/traverse.js"; import { hasRetainedFfiCallback } from "./ffi-callbacks.js"; import { computePublishedTypes, writesPublished } from "./publication.js"; @@ -23,7 +23,16 @@ export type MayThrowReason = * Fixpoint over the call graph; runtime traps (array OOB etc.) are aborts, * not exceptions, so most intrinsics never contribute. The module evaluator's * internal await and throwing library calls are the exceptions: both can - * surface a catchable rejection/error and seed the fixpoint like a `throw`. */ + * surface a catchable rejection/error and seed the fixpoint like a `throw`. + * + * Handlers discharge what they handle: a throwing operation or call inside + * a `try` with a `catch`, or inside a `try`/`catch` whose `finally` never + * completes normally (it returns, breaks or continues, which discards the + * pending exception), cannot propagate out of that statement, so it adds no + * seed and no call edge. The `catch` and `finally` bodies themselves are + * analyzed like any other code, which keeps rethrows exact. Worker programs + * and generator bodies keep the conservative answer: a termination request + * and a generator's `.return()` unwind through `catch`. */ export function computeMayThrow( mod: IrModule, options: { stackChecks?: boolean; explain?: boolean } = {}, @@ -83,8 +92,14 @@ export function computeMayThrow( list.push(`%${cls.name}.${m}`); } } + // Termination requests unwind through every handler of a worker program. + const handlersCatchAll = mod.workers !== true; for (const fn of mod.functions) { const f: Facts = { throws: false, callees: [], callsValue: false }; + // Inside a region whose exceptions a handler of this function takes: + // nothing seeds and no call edge can carry an exception out. + let guarded = 0; + const handlers = handlersCatchAll && !fn.generator; if (explain) f.calleeLocs = new Map(); // TDZ reads and non-initializing writes can throw ReferenceError. // Capture locals carry the same flag as the declaring binding. @@ -305,7 +320,7 @@ export function computeMayThrow( // wide) cover is every constructor of the named class's // hierarchy; classval flows never leave it. const cls = rec.callee.type.kind === "classval" ? rec.callee.type.className : undefined; - if (cls !== undefined) { + if (cls !== undefined && guarded === 0) { let list = classValueCallers.get(cls); if (!list) classValueCallers.set(cls, (list = new Set())); list.add(fn.name); @@ -321,6 +336,7 @@ export function computeMayThrow( break; } case "callValue": + if (guarded > 0) break; f.callsValue = true; if (explain) f.callValueLoc ??= rec.loc; break; @@ -328,6 +344,12 @@ export function computeMayThrow( closureTargets.add(rec.fnName); break; } + if (guarded > 0) { + // A handler of this function takes the exception. + f.throws = threw; + f.callees.length = callees; + return true; + } if (explain) { if (!threw && f.throws) f.seed = { kind: "seed", what: describeThrowingNode(rec), loc: rec.loc }; @@ -338,7 +360,31 @@ export function computeMayThrow( } return true; }; - everyStmtList(fn.body, { expr: visit, stmt: visit }); + const walkExpr = (node: IrExpr): boolean => + visit(node) && everyExprChild(node, walkExpr, walkStmt); + // Unreachable statements cannot throw, such as the frontend's trap after + // a body that returns from both its try and its catch. + const dead = unreachableStatements(fn.body); + const walkStmt = (node: IrStmt): boolean => { + if (dead.has(node)) return true; + if (node.kind !== "tryCatch" || !handlers) + return visit(node) && everyStmtChild(node, walkExpr, walkStmt); + visit(node); + // The catch takes everything the try body raises. A finally that + // never completes normally discards whatever the try body or the + // catch left pending; one that can complete normally re-raises it. + const discards = node.finallyBody !== null && !completesNormally(node.finallyBody); + const guardTry = node.catchBody !== null || discards; + if (guardTry) guarded++; + node.tryBody.every(walkStmt); + if (guardTry) guarded--; + if (discards) guarded++; + node.catchBody?.every(walkStmt); + if (discards) guarded--; + node.finallyBody?.every(walkStmt); + return true; + }; + fn.body.every(walkStmt); // A worker can be terminated at any of its polls: the noncatchable // stop unwinds like an exception from every function that polls. if (terminations?.polls.has(fn.name)) { @@ -434,6 +480,145 @@ export function computeMayThrow( }; } +/** Whether control can reach the end of a statement list. Conservative: + * false only when some statement certainly completes abruptly: `return`, + * `throw`, `break` or `continue` (which leave the list), an `if` whose + * branches both do, a `try` whose body and `catch` (or `finally`) do, a + * `switch` with a `default` whose last case does and that no `break` + * leaves, or a `while (true)`/`for (;;)` loop that no `break` leaves. */ +export function completesNormally(stmts: readonly IrStmt[]): boolean { + return completion().list(stmts); +} + +function completion(): { + list: (stmts: readonly IrStmt[]) => boolean; + statement: (s: IrStmt) => boolean; +} { + const memo = new Map(); + const list = (stmts: readonly IrStmt[]): boolean => stmts.every(statement); + const statement = (s: IrStmt): boolean => { + let known = memo.get(s); + if (known === undefined) { + known = compute(s); + memo.set(s, known); + } + return known; + }; + const compute = (s: IrStmt): boolean => { + switch (s.kind) { + case "return": + case "throw": + case "rethrow": + case "runtimeFence": + case "break": + case "continue": + return false; + case "if": + return list(s.then) || list(s.else_ ?? []); + case "block": + return list(s.body) || breaksTo(s.body, s.labels ?? [], false); + case "tryCatch": + return ( + (list(s.tryBody) || (s.catchBody !== null && list(s.catchBody))) && + (s.finallyBody === null || list(s.finallyBody)) + ); + case "switch": { + const last = s.cases[s.cases.length - 1]; + return ( + last === undefined || + !s.cases.some((c) => c.test === null) || + list(last.body) || + s.cases.some((c) => breaksTo(c.body, s.labels ?? [], true)) + ); + } + case "while": + case "doWhile": + case "for": { + const forever = s.cond === null || (s.cond.kind === "boolLit" && s.cond.value === true); + return !forever || breaksTo(s.body, s.labels ?? [], true); + } + default: + return true; + } + }; + return { list, statement }; +} + +/** Whether a `break` in the statements leaves the construct that owns + * them: one naming its labels, or (for loops and switches, `unlabeled`) + * an unlabeled `break` that no nested loop or switch captures first. */ +function breaksTo( + stmts: readonly IrStmt[], + labels: readonly string[], + unlabeled: boolean, +): boolean { + const walk = (s: IrStmt, captured: boolean): boolean => { + switch (s.kind) { + case "break": + return s.label === undefined ? unlabeled && !captured : labels.includes(s.label); + case "if": + return ( + s.then.some((c) => walk(c, captured)) || (s.else_ ?? []).some((c) => walk(c, captured)) + ); + case "block": + return s.body.some((c) => walk(c, captured)); + case "tryCatch": + return [s.tryBody, s.catchBody ?? [], s.finallyBody ?? []].some((body) => + body.some((c) => walk(c, captured)), + ); + case "switch": + return s.cases.some((c) => c.body.some((inner) => walk(inner, true))); + case "while": + case "doWhile": + case "for": + case "forOf": + return s.body.some((c) => walk(c, true)); + default: + return false; + } + }; + return stmts.some((s) => walk(s, false)); +} + +/** Statements no execution reaches: those after a statement that cannot + * complete normally in the same statement list. */ +function unreachableStatements(body: readonly IrStmt[]): Set { + const dead = new Set(); + const { statement } = completion(); + const list = (stmts: readonly IrStmt[]): void => { + let live = true; + for (const s of stmts) { + if (!live) { + dead.add(s); + continue; + } + for (const inner of nestedLists(s)) list(inner); + if (!statement(s)) live = false; + } + }; + list(body); + return dead; +} + +function nestedLists(s: IrStmt): readonly (readonly IrStmt[])[] { + switch (s.kind) { + case "if": + return s.else_ === null ? [s.then] : [s.then, s.else_]; + case "block": + case "while": + case "doWhile": + case "for": + case "forOf": + return [s.body]; + case "switch": + return s.cases.map((c) => c.body); + case "tryCatch": + return [s.tryBody, s.catchBody ?? [], s.finallyBody ?? []]; + default: + return []; + } +} + /** Error constructors by runtime error kind (SCR_ERR_* in scr_runtime.h). */ const ERROR_KIND_NAMES: readonly string[] = [ "Error", diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts index fc5dcc7ee..9adbf8d40 100644 --- a/packages/compiler/src/contracts/check.ts +++ b/packages/compiler/src/contracts/check.ts @@ -5,7 +5,8 @@ * in the whole program: * * - NoThrow: the function is outside the may-throw fixpoint - * (backend/may-throw.ts), or the diagnostic names the throwing path. + * (backend/may-throw.ts, where handlers discharge what they catch), or + * the diagnostic names the throwing path. * - Int32: every value stored into the slot is an int32 by construction, * proven by the same integer range analysis the int32 storage uses, * assuming every Int32 slot holds an int32 (sound by induction: every @@ -273,6 +274,8 @@ class ContractChecker { continue; } const steps: string[] = []; + // The function's own stack guard throws on entry, outside any handler. + const ownStackGuard = mt.stackChecks?.has(fn.name) === true; const seen = new Set(); let current: string | undefined = fn.name; while (current !== undefined && !seen.has(current) && steps.length < 8) { @@ -298,7 +301,11 @@ class ContractChecker { "SC7001", note.loc, `'${note.name}' is declared NoThrow but may throw: ${steps.join(" → ")}`, - "remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version", + ownStackGuard + ? "remove the recursion or remove NoThrow; a recursive function's stack guard throws on entry, outside any try/catch" + : this.mod.workers === true + ? "remove the throwing operation from the path (or the recursion), or remove NoThrow; in worker programs try/catch does not discharge the contract, since a termination request unwinds through catch" + : "remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow", ); } this.record("noThrow", proven); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1580650f6..734bfce13 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15878,6 +15878,12 @@ ], "diags": [] }, + "/tests/corpus/4641-nothrow-try-catch.ts": { + "order": [ + "/tests/corpus/4641-nothrow-try-catch.ts" + ], + "diags": [] + }, "/tests/diagnostics/contract-immutable.ts": { "order": [ "/tests/diagnostics/contract-immutable.ts" @@ -15896,6 +15902,12 @@ ], "diags": [] }, + "/tests/diagnostics/contract-nothrow-handlers.ts": { + "order": [ + "/tests/diagnostics/contract-nothrow-handlers.ts" + ], + "diags": [] + }, "/tests/diagnostics/contract-nothrow.ts": { "order": [ "/tests/diagnostics/contract-nothrow.ts" diff --git a/tests/corpus/4641-nothrow-try-catch.ts b/tests/corpus/4641-nothrow-try-catch.ts new file mode 100644 index 000000000..d29bbd7eb --- /dev/null +++ b/tests/corpus/4641-nothrow-try-catch.ts @@ -0,0 +1,99 @@ +// try/catch discharges NoThrow: a throwing call inside a `try` whose +// `catch` takes every exception, or whose `finally` never completes +// normally, cannot propagate. finally ordering and rethrows stay exact. +import type { NoThrow } from "@scriptc/types"; + +class Stats { + caught = 0; + finals = 0; + events: string[] = []; +} + +function parseDigit(ch: number): number { + if (ch < 48 || ch > 57) throw new RangeError(`not a digit: ${ch}`); + return ch - 48; +} + +function digitOr(text: string, i: number, fallback: number): NoThrow { + try { + return parseDigit(text.charCodeAt(i)); + } catch { + return fallback; + } +} + +function logged(text: string, stats: Stats): NoThrow { + try { + stats.events.push(`try ${text}`); + return parseDigit(text.charCodeAt(0)); + } catch (error) { + stats.events.push(error instanceof RangeError ? "catch RangeError" : "catch other"); + stats.caught++; + return -1; + } finally { + stats.events.push("finally"); + stats.finals++; + } +} + +// The catch rethrows, but the finally returns, which discards the exception. +function swallowed(text: string, stats: Stats): NoThrow { + try { + return parseDigit(text.charCodeAt(0)); + } catch (error) { + stats.caught++; + throw error; + } finally { + stats.finals++; + // eslint-disable-next-line no-unsafe-finally + return -2; + } +} + +// An inner finally re-raises; the outer catch takes it. +function nested(text: string, stats: Stats): NoThrow { + try { + try { + return parseDigit(text.charCodeAt(0)) * 10; + } finally { + stats.events.push("inner finally"); + } + } catch { + stats.events.push("outer catch"); + return -3; + } +} + +// Node's own TypeError from reading a property of undefined is caught too. +function lengthAt(items: (string | undefined)[], i: number): NoThrow { + try { + return items[i]!.length; + } catch { + return -4; + } +} + +// Without NoThrow, a finally that completes normally keeps propagating. +function propagates(text: string, stats: Stats): number { + try { + return parseDigit(text.charCodeAt(0)); + } finally { + stats.events.push(`propagates finally ${text}`); + } +} + +const stats = new Stats(); +for (const text of ["7", "x", "", "42"]) { + console.log(text, digitOr(text, 0, -9), logged(text, stats), swallowed(text, stats), nested(text, stats)); +} +const items: (string | undefined)[] = ["ab", undefined, "cde"]; +console.log(lengthAt(items, 0), lengthAt(items, 1), lengthAt(items, 2)); +for (const text of ["3", "?"]) { + try { + console.log("propagates", propagates(text, stats)); + } catch (error) { + console.log("escaped", error instanceof RangeError, (error as Error).message); + } +} +console.log(stats.caught, stats.finals); +console.log(stats.events.join("\n")); diff --git a/tests/diagnostics/contract-nothrow-handlers.ts b/tests/diagnostics/contract-nothrow-handlers.ts new file mode 100644 index 000000000..be9790fff --- /dev/null +++ b/tests/diagnostics/contract-nothrow-handlers.ts @@ -0,0 +1,40 @@ +// try/catch discharges NoThrow only for the exceptions its handlers take: a +// finally that can complete normally re-raises, and a rethrow in a catch +// propagates. `handled` holds its contract. +import type { NoThrow } from "@scriptc/types"; + +class Counter { + count = 0; +} + +function parseDigit(ch: number): number { + if (ch < 48 || ch > 57) throw new RangeError("not a digit"); + return ch - 48; +} + +function cleanup(text: string, counter: Counter): NoThrow { + try { + return parseDigit(text.charCodeAt(0)); + } finally { + counter.count++; + } +} + +function rethrows(text: string): NoThrow { + try { + return parseDigit(text.charCodeAt(0)); + } catch (error) { + if (text.length > 1) throw error; + return -1; + } +} + +function handled(text: string): NoThrow { + try { + return parseDigit(text.charCodeAt(0)); + } catch { + return -1; + } +} + +console.log(cleanup("1", new Counter()), rethrows("2"), handled("x")); diff --git a/tests/harness/__snapshots__/contract-nothrow-handlers.ts.txt b/tests/harness/__snapshots__/contract-nothrow-handlers.ts.txt new file mode 100644 index 000000000..89321671c --- /dev/null +++ b/tests/harness/__snapshots__/contract-nothrow-handlers.ts.txt @@ -0,0 +1,17 @@ +contract-nothrow-handlers.ts:15:51 - error SC7001: 'cleanup' is declared NoThrow but may throw: calls 'parseDigit' at contract-nothrow-handlers.ts:17:12 → throw statement at contract-nothrow-handlers.ts:11:27 + + 14 | + 15 | function cleanup(text: string, counter: Counter): NoThrow { + | ^~~~~~~~~~~~~~~ + 16 | try { + + hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow + +contract-nothrow-handlers.ts:23:34 - error SC7001: 'rethrows' is declared NoThrow but may throw: rethrow at contract-nothrow-handlers.ts:27:26 + + 22 | + 23 | function rethrows(text: string): NoThrow { + | ^~~~~~~~~~~~~~~ + 24 | try { + + hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow \ No newline at end of file diff --git a/tests/harness/__snapshots__/contract-nothrow.ts.txt b/tests/harness/__snapshots__/contract-nothrow.ts.txt index 412ba2352..698934c27 100644 --- a/tests/harness/__snapshots__/contract-nothrow.ts.txt +++ b/tests/harness/__snapshots__/contract-nothrow.ts.txt @@ -5,7 +5,7 @@ contract-nothrow.ts:11:35 - error SC7001: 'sumDigits' is declared NoThrow but ma | ^~~~~~~~~~~~~~~ 12 | let sum = 0; - hint: remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version + hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow contract-nothrow.ts:17:28 - error SC7001: 'depth' is declared NoThrow but may throw: can recurse (directly or through callbacks), so the stack guard may throw RangeError at contract-nothrow.ts:17:1 @@ -14,7 +14,7 @@ contract-nothrow.ts:17:28 - error SC7001: 'depth' is declared NoThrow but may th | ^~~~~~~~~~~~~~~ 18 | return n <= 0 ? 0 : 1 + depth(n - 1); - hint: remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version + hint: remove the recursion or remove NoThrow; a recursive function's stack guard throws on entry, outside any try/catch contract-nothrow.ts:21:54 - error SC7001: 'apply' is declared NoThrow but may throw: calls a function value at contract-nothrow.ts:22:10, and the function value 'the function at contract-nothrow.ts:33:71' may throw → calls 'parseDigit' at contract-nothrow.ts:33:78 → throw statement at contract-nothrow.ts:7:29 @@ -23,7 +23,7 @@ contract-nothrow.ts:21:54 - error SC7001: 'apply' is declared NoThrow but may th | ^~~~~~~~~~~~~~~ 22 | return f(x); - hint: remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version + hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow contract-nothrow.ts:28:31 - error SC7001: 'width' is declared NoThrow but may throw: throws TypeError: Cannot read properties of undefined (reading 'length') at contract-nothrow.ts:29:10 (parameter 'text' is compiled as string | undefined: the argument at contract-nothrow.ts:34:19 can be missing) @@ -32,4 +32,4 @@ contract-nothrow.ts:28:31 - error SC7001: 'width' is declared NoThrow but may th | ^~~~~~~~~~~~~~~ 29 | return text.length; - hint: remove the throwing operation from the path (or the recursion), or remove NoThrow; a surrounding try/catch does not discharge the contract in this version \ No newline at end of file + hint: remove the throwing operation from the path (or the recursion), catch it with try/catch, or remove NoThrow \ No newline at end of file From 4dc638b328641b4a8f1aabb2bc8dac58c5710a30 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 14:37:37 -0700 Subject: [PATCH 06/10] Acyclic: compile explainCycle in the self-hosted compiler; MDX-safe docs An empty step list literal is typed explicitly so the native compiler maps it to the step array type, and the positions list avoids braces, which the docs pipeline would parse as an MDX expression. --- docs/content/docs/contracts.mdx | 2 +- packages/compiler/src/backend/cycle-analysis.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/contracts.mdx b/docs/content/docs/contracts.mdx index a93d9aa14..d3596ca34 100644 --- a/docs/content/docs/contracts.mdx +++ b/docs/content/docs/contracts.mdx @@ -116,7 +116,7 @@ scriptc recognizes a contract where it is written as the annotation of a declara
Immutable<T>
Parameters, variables, class instance fields and function results.
Acyclic
-
Classes (class C implements Acyclic), interfaces (interface I extends Acyclic) and object type aliases (type T = Acyclic<{ ... }>).
+
Classes (class C implements Acyclic), interfaces (interface I extends Acyclic) and object type aliases (type T = Acyclic<...> over an object type).
A contract anywhere else (a type alias, an interface member, an element or type argument such as `Int32[]`, a function type) would promise something scriptc does not check, so the build reports it with SC7006. diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts index ed970b850..a8ec52235 100644 --- a/packages/compiler/src/backend/cycle-analysis.ts +++ b/packages/compiler/src/backend/cycle-analysis.ts @@ -390,8 +390,9 @@ function analyzeCycles( for (const from of members) for (const [to, mutable] of from.edges) { if (!mutable || to.component !== component) continue; - const there = from === n ? { steps: [] } : path(n, (m) => m === from, inside); - const back = to === n ? { steps: [] } : path(to, (m) => m === n, inside); + const none: { steps: CycleStep[] } = { steps: [] }; + const there = from === n ? none : path(n, (m) => m === from, inside); + const back = to === n ? none : path(to, (m) => m === n, inside); if (!there || !back) continue; return { kind: "cycle", From 73ee5e17e9b79c5e84f81627bb4db4a48e180dcd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 14:47:51 -0700 Subject: [PATCH 07/10] Acyclic: a dedicated default interface; shortest cycle in explanations `implements Acyclic` resolved to Record, and the frontend indexes implemented types as class protocols by symbol, so every Record value in the program took checked identity (on tsc-ts: Int32 field contracts failed with SC7003). The default is now AcyclicObject, an empty interface only @scriptc/types declares, and type arguments in heritage clauses are reported (they would make a class implement its own type). Corpus 4642 stores an Acyclic class with an Int32 field in a Record. explainCycle picks the shortest cycle through the unit and a mutable edge (BFS distances forward and backward within the component): on tsc-ts `Node.parent -> Node` instead of a 20-step path through the checker. --- .../compiler/src/backend/cycle-analysis.ts | 70 +++++++++++++++---- .../compiler/src/frontend/contract-types.ts | 5 +- packages/types/index.d.ts | 7 +- tests/corpus/4642-acyclic-contract.ts | 16 ++++- .../diagnostics/contract-acyclic-positions.ts | 7 +- .../contract-acyclic-positions.ts.txt | 29 +++++--- 6 files changed, 105 insertions(+), 29 deletions(-) diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts index a8ec52235..1a9b6e4ab 100644 --- a/packages/compiler/src/backend/cycle-analysis.ts +++ b/packages/compiler/src/backend/cycle-analysis.ts @@ -369,6 +369,28 @@ function analyzeCycles( } return null; }; + /** BFS distances from `start` over `next` within one component; `via` + * is the neighbor one step closer to `start`. */ + const distances = ( + start: Node, + next: (n: Node) => readonly Node[], + component: number, + ): Map => { + const seen = new Map([ + [start, { distance: 0, via: null }], + ]); + const queue = [start]; + for (let i = 0; i < queue.length; i++) { + const at = queue[i]!; + const distance = seen.get(at)!.distance + 1; + for (const m of next(at)) + if (m.component === component && !seen.has(m)) { + seen.set(m, { distance, via: at }); + queue.push(m); + } + } + return seen; + }; const explainNode = (n: Node): CycleExplanation | null => { if (!n.traced) return null; const component = n.component; @@ -387,22 +409,42 @@ function analyzeCycles( const found = path(n, (m) => m === lead, inside); if (found) return intrinsic(lead, found.steps); } - for (const from of members) + // The shortest cycle through `n` and a mutable internal edge: BFS + // distances from `n` forward and to `n` backward, within the component. + const forward = distances(n, (m) => [...m.edges.keys()], component); + const backward = distances(n, (m) => m.reverse, component); + let best: { from: Node; to: Node; length: number } | null = null; + for (const from of members) { + const there = forward.get(from); + if (there === undefined) continue; for (const [to, mutable] of from.edges) { - if (!mutable || to.component !== component) continue; - const none: { steps: CycleStep[] } = { steps: [] }; - const there = from === n ? none : path(n, (m) => m === from, inside); - const back = to === n ? none : path(to, (m) => m === n, inside); - if (!there || !back) continue; - return { - kind: "cycle", - steps: [ - ...there.steps, - { from: from.name, label: from.labels?.get(to) ?? { kind: "arm" } }, - ...back.steps, - ], - }; + const back = backward.get(to); + if (!mutable || back === undefined) continue; + const length = there.distance + 1 + back.distance; + if (best === null || length < best.length) best = { from, to, length }; } + } + if (best !== null) { + const steps: CycleStep[] = []; + for (let at: Node = best.from; at !== n;) { + const previous = forward.get(at)!.via!; + steps.unshift({ + from: previous.name, + label: previous.labels?.get(at) ?? { kind: "arm" }, + }); + at = previous; + } + steps.push({ + from: best.from.name, + label: best.from.labels?.get(best.to) ?? { kind: "arm" }, + }); + for (let at: Node = best.to; at !== n;) { + const next = backward.get(at)!.via!; + steps.push({ from: at.name, label: at.labels?.get(next) ?? { kind: "arm" } }); + at = next; + } + return { kind: "cycle", steps }; + } } const found = path( n, diff --git a/packages/compiler/src/frontend/contract-types.ts b/packages/compiler/src/frontend/contract-types.ts index 4d92b3fa9..21004bb71 100644 --- a/packages/compiler/src/frontend/contract-types.ts +++ b/packages/compiler/src/frontend/contract-types.ts @@ -182,7 +182,7 @@ function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMa "SC7006", locOf(node), "the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases)", - "write `class C implements Acyclic`, `interface I extends Acyclic` or `type T = Acyclic<{ ... }>`", + "write `class C implements Acyclic` or `interface I extends Acyclic` (without a type argument), or `type T = Acyclic<{ ... }>`", ), ); return; @@ -222,6 +222,9 @@ function placeHeritage( owner === undefined ) return unsupported(out, node, marker); + // `implements Acyclic` would make the class implement C's own type, + // which the lowering treats as a protocol of C. + if ((node.typeArguments?.length ?? 0) > 0) return unsupported(out, node, marker); const note = (name: string): ContractNote => ({ marker, loc: locOf(node), name }); if ( (ts.isClassDeclaration(owner) || ts.isClassExpression(owner)) && diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 6827b2fef..ae833f020 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -48,4 +48,9 @@ export type Immutable = T extends (...args: never[]) => unknown * every reference field is assigned only in the constructor, and none holds * a function), or reports the cycle that can form. Its objects then carry * no cycle-collector header and are freed by reference counting alone. */ -export type Acyclic> = T; +export type Acyclic = T; + +/** What `implements Acyclic` and `extends Acyclic` name: an empty interface + * only this package declares, so the clause adds no members and refers to + * nothing else in the program. */ +export interface AcyclicObject {} diff --git a/tests/corpus/4642-acyclic-contract.ts b/tests/corpus/4642-acyclic-contract.ts index bc743d354..dffbba761 100644 --- a/tests/corpus/4642-acyclic-contract.ts +++ b/tests/corpus/4642-acyclic-contract.ts @@ -1,7 +1,7 @@ // Acyclic contract types (@scriptc/types): scriptc proves these classes and // object types cannot lie on a reference cycle, so their objects carry no // cycle-collector header and are freed by reference counting alone. -import type { Acyclic } from "@scriptc/types"; +import type { Acyclic, Int32 } from "@scriptc/types"; import type * as sc from "@scriptc/types"; class Cons implements Acyclic { @@ -13,7 +13,7 @@ class Cons implements Acyclic { } } -class Tree implements Acyclic { +class Tree implements Acyclic { readonly left: Tree | null; readonly right: Tree | null; readonly value: number; @@ -36,6 +36,13 @@ class Counter implements sc.Acyclic { } } +// `implements Acyclic` names an interface only @scriptc/types declares, so +// other object types (here a Record) keep their layouts, and the Int32 +// field of a class stored in one stays checkable. +class Slot implements Acyclic { + index: Int32 = 0; +} + interface Point extends Acyclic { readonly x: number; readonly y: number; @@ -62,3 +69,8 @@ console.log(counter.label, counter.count); const segments: Segment[] = []; for (let i = 0; i < 5; i++) segments.push({ from: { x: i, y: 0 }, to: { x: i, y: i * i }, label: `s${i}` }); console.log(segments.map((s) => `${s.label}:${s.to.y - s.from.y}`).join(" ")); + +const slots: Record = { a: new Slot() }; +for (let i = 0; i < 3; i++) slots["a"]!.index = (slots["a"]!.index + 2) | 0; +slots["b"] = new Slot(); +console.log(Object.keys(slots).join(","), slots["a"]!.index, slots["b"]!.index); diff --git a/tests/diagnostics/contract-acyclic-positions.ts b/tests/diagnostics/contract-acyclic-positions.ts index 6dc163ea9..c4996761c 100644 --- a/tests/diagnostics/contract-acyclic-positions.ts +++ b/tests/diagnostics/contract-acyclic-positions.ts @@ -7,7 +7,12 @@ interface Item { name: string; } +// A type argument would make the class implement its own type. +class Box implements Acyclic { + name = "box"; +} + const item: Acyclic = { name: "a" }; const items: Acyclic[] = [item]; -console.log(item.name, items.length); +console.log(item.name, items.length, new Box().name); diff --git a/tests/harness/__snapshots__/contract-acyclic-positions.ts.txt b/tests/harness/__snapshots__/contract-acyclic-positions.ts.txt index c1dc6db77..1cdc278fb 100644 --- a/tests/harness/__snapshots__/contract-acyclic-positions.ts.txt +++ b/tests/harness/__snapshots__/contract-acyclic-positions.ts.txt @@ -1,17 +1,26 @@ -contract-acyclic-positions.ts:10:13 - error SC7006: the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases) +contract-acyclic-positions.ts:11:22 - error SC7006: the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases) - 9 | - 10 | const item: Acyclic = { name: "a" }; + 10 | // A type argument would make the class implement its own type. + 11 | class Box implements Acyclic { + | ^~~~~~~~~~~~~ + 12 | name = "box"; + + hint: write `class C implements Acyclic` or `interface I extends Acyclic` (without a type argument), or `type T = Acyclic<{ ... }>` + +contract-acyclic-positions.ts:15:13 - error SC7006: the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases) + + 14 | + 15 | const item: Acyclic = { name: "a" }; | ^~~~~~~~~~~~~ - 11 | const items: Acyclic[] = [item]; + 16 | const items: Acyclic[] = [item]; - hint: write `class C implements Acyclic`, `interface I extends Acyclic` or `type T = Acyclic<{ ... }>` + hint: write `class C implements Acyclic` or `interface I extends Acyclic` (without a type argument), or `type T = Acyclic<{ ... }>` -contract-acyclic-positions.ts:11:14 - error SC7006: the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases) +contract-acyclic-positions.ts:16:14 - error SC7006: the Acyclic contract is not checked in this position (Acyclic is checked on classes, interfaces and object type aliases) - 10 | const item: Acyclic = { name: "a" }; - 11 | const items: Acyclic[] = [item]; + 15 | const item: Acyclic = { name: "a" }; + 16 | const items: Acyclic[] = [item]; | ^~~~~~~~~~~~~ - 12 | + 17 | - hint: write `class C implements Acyclic`, `interface I extends Acyclic` or `type T = Acyclic<{ ... }>` \ No newline at end of file + hint: write `class C implements Acyclic` or `interface I extends Acyclic` (without a type argument), or `type T = Acyclic<{ ... }>` \ No newline at end of file From b5e4bcb54e523b097ace407f1dafdf8689bdb602 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 12 Oct 2026 03:23:13 -0700 Subject: [PATCH 08/10] runtime: keep throw entry points cold and out of line With runtime bitcode import, scr_throw_obj, scr_error_new and the other throw entry points were inlined into the error paths of small compiled functions. The inlined bodies needed callee-saved registers that every call then saved and restored, and they made the helpers too large to inline into their callers (1047 inlined throw bodies in tsc-ts). Mark the 25 functions that only run while raising a JS exception cold and noinline. The LLVM helper never imports noinline bodies, so the throw paths stay calls. tsc-ts Excalidraw: -0.44% instructions. --- .../corpus/4720-cold-throw-paths.ts.json | 6 + packages/runtime/src/scr_runtime.h | 70 ++++++---- tests/corpus/4720-cold-throw-paths.ts | 128 ++++++++++++++++++ 3 files changed, 175 insertions(+), 29 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4720-cold-throw-paths.ts.json create mode 100644 tests/corpus/4720-cold-throw-paths.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4720-cold-throw-paths.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4720-cold-throw-paths.ts.json new file mode 100644 index 000000000..91c4b6d74 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4720-cold-throw-paths.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4720-cold-throw-paths.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 7a52b2699..260d810c9 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -53,6 +53,18 @@ void arc4random_buf(void *buf, size_t n); #define SCR_TL #endif +/* Entry points that only run when a JS exception is being raised. noinline + * keeps the throw machinery out of every caller: the LLVM helper never + * imports noinline runtime bodies into program code, so a compiled function + * whose only slow path throws stays small enough to inline and needs no + * callee-saved registers on its fast path. cold tells the C compiler that + * runtime paths reaching them are unlikely. */ +#if defined(__GNUC__) || defined(__clang__) +#define SCR_COLD __attribute__((cold, noinline)) +#else +#define SCR_COLD +#endif + /* ── process ──────────────────────────────────────────────────────────── */ /* Called once at the top of main: private stdout formatter buffer, @@ -336,7 +348,7 @@ bool scr_context_is_main(void); * report. Zero (the default) disables the check on that stack. */ extern SCR_TL uintptr_t scr_stack_guard; void scr_stack_guard_init(void); -void scr_stack_overflow(void); +SCR_COLD void scr_stack_overflow(void); #if defined(SCR_WORKERS) && !defined(SCR_LIB) && defined(__APPLE__) && \ (defined(__aarch64__) || defined(__x86_64__)) /* Darwin worker executables: reading a thread-local variable calls its TLV @@ -797,9 +809,9 @@ void scr_publish_refuse(const char *message); * TypeErrors. scr_throw_published_array answers false (and throws nothing) * when Node's operation would not write the array. */ struct ScrArr; -void scr_throw_published_field(const char *prop, const char *owner); +SCR_COLD void scr_throw_published_field(const char *prop, const char *owner); bool scr_throw_published_array(int op, const struct ScrArr *a, double index, double n); -void scr_throw_published_collection(bool set); +SCR_COLD void scr_throw_published_collection(bool set); /* ── class hierarchies (single inheritance) ─────────────────────────── * Classes in an `extends` hierarchy share a two-word object prefix: the @@ -1170,8 +1182,8 @@ ScrError *scr_domex_clone(ScrError *e, const struct ScrDyn *options); /* Throw a fresh DOMException through the exception cell (atob/btoa's * InvalidCharacterError sites). Copies both C strings; the _str form * takes ownership of the message. */ -void scr_throw_domex(const char *name, const char *message); -void scr_throw_domex_str(const char *name, ScrStr *message); +SCR_COLD void scr_throw_domex(const char *name, const char *message); +SCR_COLD void scr_throw_domex_str(const char *name, ScrStr *message); /* Switch runtime-side error allocation to collector-headered (called from * main() when the compiler's cycle fixpoint marks the hierarchy). */ @@ -1179,7 +1191,7 @@ void scr_error_set_traced(void); /* Allocate + initialize (name = the kind's builtin name, message retained * from the borrowed argument; NULL means ""). Returns +1. */ -ScrError *scr_error_new(int kind, ScrStr *message); +SCR_COLD ScrError *scr_error_new(int kind, ScrStr *message); /* Initialize the error prefix of an already-allocated (zeroed) object — * the super(message) call of a compiled `extends Error` constructor. Both * arguments are borrowed. */ @@ -1219,22 +1231,22 @@ bool scr_error_is(const void *obj); * takes ownership of message; the _msg form copies the C string. The _named * form (island bridge) takes ownership of both strings and picks the * builtin vtable whose class name matches `name` (Error otherwise). */ -void scr_throw_error(int kind, ScrStr *message); -void scr_throw_error_msg(int kind, const char *message, size_t len); -void scr_throw_error_named(ScrStr *name, ScrStr *message); +SCR_COLD void scr_throw_error(int kind, ScrStr *message); +SCR_COLD void scr_throw_error_msg(int kind, const char *message, size_t len); +SCR_COLD void scr_throw_error_named(ScrStr *name, ScrStr *message); /* A read of a declare-d const nothing defines: throws Node's catchable * ReferenceError " is not defined". Borrows name; always throws. */ -void scr_undef_global_read(ScrStr *name); +SCR_COLD void scr_undef_global_read(ScrStr *name); /* The `code` slot (NodeJS.ErrnoException's .code): set stamps a fresh * string from the C literal (replacing any previous value); get answers * +1 or NULL when absent (the compiler's undefined arm). The _msg_code * thrower is scr_throw_error_msg with the code stamped on the payload. */ void scr_error_set_code(ScrError *e, const char *code); ScrStr *scr_error_code(ScrError *e); -void scr_throw_error_msg_code(int kind, const char *message, size_t len, const char *code); +SCR_COLD void scr_throw_error_msg_code(int kind, const char *message, size_t len, const char *code); /* The compiler-resolved Node-parity throw (error.nodeThrow): builtin * error of `kind`, `code` stamped when non-empty. Borrows both. */ -void scr_throw_node_coded(double kind, const ScrStr *code, const ScrStr *msg); +SCR_COLD void scr_throw_node_coded(double kind, const ScrStr *code, const ScrStr *msg); /* ── string methods ───────────────────────────────────────────────── * ECMA-262 observable semantics over UTF-8 storage with UTF-16 code-unit @@ -3067,23 +3079,23 @@ static inline bool scr_exc_pending_inline(void) { return scr_exc_active->kind != _Static_assert(offsetof(ScrExcCell, kind) == 0 && sizeof(ScrExcKind) == 4 && SCR_EXC_NONE == 0, "the LLVM emitter's inline pending check loads an i32 kind at offset 0"); -void scr_throw_f64(double v); -void scr_throw_bool(bool v); -void scr_throw_str(ScrStr *v); /* takes ownership */ +SCR_COLD void scr_throw_f64(double v); +SCR_COLD void scr_throw_bool(bool v); +SCR_COLD void scr_throw_str(ScrStr *v); /* takes ownership */ /* Takes ownership of v; retain/release must be non-NULL (the compiler * passes the payload type's `_v` adapters, like scr_union_new_ref); trace * is non-NULL iff the payload type carries a cycle header. */ -void scr_throw_ref(void *v, void *(*retain)(void *), void (*release)(void *), - ScrTraceFn trace); +SCR_COLD void scr_throw_ref(void *v, void *(*retain)(void *), void (*release)(void *), + ScrTraceFn trace); /* Store a reference whose JS typeof was checked at the throw site. */ -void scr_throw_ref_classified(void *v, void *(*retain)(void *), void (*release)(void *), - ScrTraceFn trace, bool object); -void scr_throw_primitive_ref(void *v, void *(*retain)(void *), void (*release)(void *), - ScrTraceFn trace); +SCR_COLD void scr_throw_ref_classified(void *v, void *(*retain)(void *), void (*release)(void *), + ScrTraceFn trace, bool object); +SCR_COLD void scr_throw_primitive_ref(void *v, void *(*retain)(void *), void (*release)(void *), + ScrTraceFn trace); /* Same ownership contract as scr_throw_ref; the payload must be a * hierarchy-class instance (vtable word present) — see SCR_EXC_OBJ. */ -void scr_throw_obj(void *v, void *(*retain)(void *), void (*release)(void *), - ScrTraceFn trace); +SCR_COLD void scr_throw_obj(void *v, void *(*retain)(void *), void (*release)(void *), + ScrTraceFn trace); /* Catch: discard the in-flight exception (releases a refcounted payload). */ void scr_exc_clear(void); @@ -5303,22 +5315,22 @@ void scr_dyn_check_listener(const ScrDyn *cb, const char *argname); const char *scr_dyn_specific_type(const ScrDyn *v, char *buf, size_t cap); /* ERR_INVALID_ARG_TYPE with the runtime-rendered Received tail (the * error.argTypeThrow libCall). Borrows all three; always throws. */ -void scr_throw_arg_type(const ScrStr *argname, const ScrStr *expected, const ScrDyn *got); -void scr_dyn_arg_type_fail(const char *argname, const char *expected, const ScrDyn *got); +SCR_COLD void scr_throw_arg_type(const ScrStr *argname, const ScrStr *expected, const ScrDyn *got); +SCR_COLD void scr_dyn_arg_type_fail(const char *argname, const char *expected, const ScrDyn *got); /* The property flavor ("The \"options.x\" property must be ...") — the * option-bag validators' gate (error.propTypeThrow). Always throws. */ -void scr_throw_prop_type(const ScrStr *name, const ScrStr *expected, const ScrDyn *got); -void scr_dyn_prop_type_fail(const char *name, const char *expected, const ScrDyn *got); +SCR_COLD void scr_throw_prop_type(const ScrStr *name, const ScrStr *expected, const ScrDyn *got); +SCR_COLD void scr_dyn_prop_type_fail(const char *name, const char *expected, const ScrDyn *got); /* Node's ERR_INVALID_ARG_VALUE ("The argument 'encoding' is invalid * encoding. Received 'no'") — reason NULL renders "is invalid". * TypeError; always throws catchably. */ -void scr_dyn_arg_value_fail(const char *name, const char *reason, const ScrDyn *got); +SCR_COLD void scr_dyn_arg_value_fail(const char *name, const char *reason, const ScrDyn *got); /* The ERR_INVALID_ARG_VALUE/%j "Received" renderer (inspect-lite: * strings quote, scalars print plain, deep shapes sketch). */ const char *scr_dyn_inspect_lite(const ScrDyn *v, char *buf, size_t cap); /* A ladder's post-validation refuse: throws the compiler-rendered SC2020 * statement-fence text verbatim (Node's validation errors run first). */ -void scr_throw_lowering_fence(const ScrStr *msg); +SCR_COLD void scr_throw_lowering_fence(const ScrStr *msg); /* ERR_OUT_OF_RANGE's "Received" number rendering (Node's * addNumericalSeparator underscores past 2^32) — scr_bytes.c's renderer, * shared by the fs/net/tls option-ladder validators. */ diff --git a/tests/corpus/4720-cold-throw-paths.ts b/tests/corpus/4720-cold-throw-paths.ts new file mode 100644 index 000000000..7ee74edfd --- /dev/null +++ b/tests/corpus/4720-cold-throw-paths.ts @@ -0,0 +1,128 @@ +// Small functions whose only slow path throws: the runtime's throw entry +// points are cold and never inlined, so these helpers inline into hot loops +// while their error paths keep Node's exact errors, unwinding and finally +// order. + +class Box { + value: number; + next: Box | undefined; + constructor(value: number, next: Box | undefined) { + this.value = value; + this.next = next; + } +} + +function valueOf(box: Box | undefined): number { + if (box === undefined) throw new TypeError("no box"); + return box.value; +} + +function nextValue(box: Box): number { + const next = box.next; + if (next === undefined) throw new RangeError(`no next after ${box.value % 5}`); + return next.value; +} + +function narrow(x: string | number | undefined): string { + if (typeof x === "string") return x; + if (x === undefined) throw new TypeError("missing value"); + throw new RangeError(`not a string: ${x}`); +} + +function describe(e: unknown): string { + if (e instanceof Error) return `${e.name}: ${e.message}`; + return `thrown ${typeof e}: ${String(e)}`; +} + +// Hot loop over inlined fast paths; the throwing path runs every 7th time. +const boxes: (Box | undefined)[] = []; +for (let i = 0; i < 50; i++) boxes.push(i % 7 === 3 ? undefined : new Box(i, i % 5 ? new Box(i * 2, undefined) : undefined)); +let sum = 0; +let caught = 0; +const messages = new Set(); +for (let round = 0; round < 3; round++) { + for (const box of boxes) { + try { + sum += valueOf(box); + sum += nextValue(box!); + } catch (e) { + caught++; + messages.add(describe(e)); + } + } +} +console.log(sum, caught); +for (const m of [...messages].sort()) console.log(m); + +// Throw sites of every primitive and object kind, with finally ordering. +const log: string[] = []; +function thrower(kind: number): number { + try { + switch (kind) { + case 0: + throw 42; + case 1: + throw "text"; + case 2: + throw false; + case 3: + throw new Error("plain"); + case 4: + throw { code: "E_OBJ" }; + case 5: + return narrow(undefined).length; + case 6: + return narrow(7).length; + default: + return narrow("ok").length; + } + } finally { + log.push(`finally ${kind}`); + } +} +for (let kind = 0; kind < 8; kind++) { + try { + log.push(`result ${thrower(kind)}`); + } catch (e) { + log.push(typeof e === "object" && e !== null && !(e instanceof Error) ? `object ${JSON.stringify(e)}` : describe(e)); + } +} +console.log(log.join("\n")); + +// Errors built without being thrown keep their fields. +const built: Error[] = []; +for (let i = 0; i < 4; i++) built.push(i % 2 ? new TypeError(`t${i}`) : new Error(`e${i}`)); +console.log(built.map((e) => `${e.name}/${e.message}`).join(",")); + +// Runtime-raised errors on otherwise fast paths. +try { + JSON.parse("{bad"); +} catch (e) { + console.log(e instanceof SyntaxError); +} +for (const count of [2, -1]) { + try { + console.log("ab".repeat(count)); + } catch (e) { + console.log(describe(e)); + } +} +for (const length of [3, -1]) { + try { + console.log(new Array(length).length); + } catch (e) { + console.log(describe(e)); + } +} + +// Deep recursion raises a catchable RangeError and the program continues. +function depth(n: number): number { + return n === 0 ? 0 : 1 + depth(n - 1); +} +try { + depth(1e7); + console.log("no overflow"); +} catch (e) { + console.log(e instanceof RangeError, (e as Error).message); +} +console.log(depth(1000)); From e1ccfeeb845645b290a15c979a93495b0ea79ba1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 12 Oct 2026 03:23:13 -0700 Subject: [PATCH 09/10] codegen: signed conversions for array counters and lengths Integer induction counters live in unsigned 64-bit slots, so every read as a number was a uitofp i64 (a 6-7 instruction sequence on x86-64) and every nested start an fptoui. The runtime's push path also round-tripped the array length through a double for its bound check. The values are at most 2^32 - 1: on 64-bit targets convert counters with sitofp and loop starts with fptosi, convert array lengths and validated indices through int64_t in scr_array.c, and test the push bound on the integer length. 32-bit targets keep the unsigned conversions. tsc-ts Excalidraw: uitofp i64 sequences 32.5 M -> 4.0 M, -0.66% instructions on top of the throw-path change. --- packages/compiler/src/backend/llvm/emitter.ts | 6 +- .../src/backend/llvm/expr-primitives.ts | 6 +- .../test/integer-induction-conversion.test.ts | 47 ++++++++++++ .../4721-integer-induction-numbers.ts.json | 6 ++ packages/runtime/src/scr_array.c | 56 ++++++++------ .../corpus/4721-integer-induction-numbers.ts | 73 +++++++++++++++++++ 6 files changed, 168 insertions(+), 26 deletions(-) create mode 100644 packages/compiler/test/integer-induction-conversion.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4721-integer-induction-numbers.ts.json create mode 100644 tests/corpus/4721-integer-induction-numbers.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 648a55fe4..a94bcb58b 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -6542,7 +6542,11 @@ export class LlEmitter { if (integerArrayLoop && s.init?.kind === "varDecl" && s.init.init) { const value = this.emitExpr(s.init.init); start = B.tmp(); - B.line(`${start} = fptoui double ${value.name} to ${this.sizeType}`); + // The start is 0 or an enclosing array index plus one (at most + // 2^32 - 1): on 64-bit targets the signed conversion is exact + // and avoids x86-64's unsigned 64-bit conversion sequence. + const conversion = this.sizeType === "i64" ? "fptosi" : "fptoui"; + B.line(`${start} = ${conversion} double ${value.name} to ${this.sizeType}`); this.integerArrayBindings.add(integerLoop.localId); } B.line(`store ${this.sizeType} ${start}, ptr ${integerSlot}`); diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index ea7061c4a..59927de3a 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -46,8 +46,12 @@ export function emitLiteralExpr( const integer = B.tmp(); const number = B.tmp(); B.line(`${integer} = load ${integerSlot.type}, ptr ${integerSlot.slot}`); + // A 64-bit induction value is a safe integer, so its sign bit is + // clear and the signed conversion is exact: one cvtsi2sd instead of + // x86-64's multi-instruction unsigned 64-bit conversion. + const signedConversion = integerSlot.signed === true || integerSlot.type === "i64"; B.line( - `${number} = ${integerSlot.signed ? "sitofp" : "uitofp"} ${integerSlot.type} ${integer} to double`, + `${number} = ${signedConversion ? "sitofp" : "uitofp"} ${integerSlot.type} ${integer} to double`, ); const uint32 = integerSlot.type === "i32" ? integer : B.tmp(); if (integerSlot.type !== "i32") diff --git a/packages/compiler/test/integer-induction-conversion.test.ts b/packages/compiler/test/integer-induction-conversion.test.ts new file mode 100644 index 000000000..941bc1c83 --- /dev/null +++ b/packages/compiler/test/integer-induction-conversion.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +test("array index counters convert to and from numbers with signed conversions on 64-bit targets", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-integer-induction-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + const source = ` +function weigh(values: number[]): number { + let total = 0; + for (let i = 0; i < values.length; i++) { + for (let j = i + 1; j < values.length; j++) total += (i / 2) * values[j]! + j; + } + return total; +} +console.log(weigh([1, 2, 3, 4])); +`; + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + const body = (ll: string): string => + ll.match(/define internal [^\n]+ @sc_(?:b)?f_weigh\([^\n]*\) #0 \{([\s\S]*?)\n\}/)![1]!; + + const wide = body(emitLlvmModule(mod)); + expect(wide).toContain("integer induction"); + // Both counters are read as numbers (i / 2, + j): safe integers, so the + // signed conversion is exact; the nested start converts back the same way. + expect(wide).toMatch(/ = sitofp i64 %\w+ to double/); + expect(wide).not.toMatch(/ = uitofp i64 /); + expect(wide).toMatch(/ = fptosi double %\w+ to i64/); + expect(wide).not.toMatch(/ = fptoui double %\w+ to i64/); + + // 32-bit counters reach 2^32 - 2, beyond the signed i32 range. + const narrow = body(emitLlvmModule(mod, { pointerBits: 32, wasi: true })); + expect(narrow).toMatch(/ = uitofp i32 %\w+ to double/); + expect(narrow).toMatch(/ = fptoui double %\w+ to i32/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4721-integer-induction-numbers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4721-integer-induction-numbers.ts.json new file mode 100644 index 000000000..28a8e4ffe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4721-integer-induction-numbers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4721-integer-induction-numbers.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index 6cf69bed8..392e9e1c3 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -7,6 +7,10 @@ #define SCR_ARR_MAX_LENGTH ((size_t)UINT32_MAX) #define SCR_ARR_MAX_INDEX (SCR_ARR_MAX_LENGTH - 1) +/* A length as a JS number. Lengths never exceed 2^32 - 1, so the signed + * conversion is exact; x86-64 has no single instruction for the unsigned + * 64-bit one that a plain size_t cast compiles to. */ +#define SCR_ARR_LEN_F64(n) ((double)(int64_t)(n)) /* Keep the packed representation proportional to the populated elements. * Indices below this cutoff always grow dense storage geometrically. Above * it, dense storage grows only when the new range stays populated: a @@ -62,15 +66,16 @@ static void scr_arr_trap_oob(double i, size_t len) { static size_t scr_arr_check_index(const ScrArr *a, double i, bool allow_append) { const double max_index = (double)SCR_ARR_MAX_INDEX; if (!(i >= 0) || i != trunc(i) || i > max_index || - (!allow_append && i >= (double)a->len)) { + (!allow_append && i >= SCR_ARR_LEN_F64(a->len))) { scr_arr_trap_oob(i, a->len); } - return (size_t)i; + /* 0 <= i < 2^32: the signed conversion is exact and a single instruction. */ + return (size_t)(int64_t)i; } static bool scr_arr_valid_index(double i, size_t *out) { if (!(i >= 0) || i != trunc(i) || i > (double)SCR_ARR_MAX_INDEX) return false; - *out = (size_t)i; + *out = (size_t)(int64_t)i; return true; } @@ -819,7 +824,7 @@ void scr_arr_release(ScrArr *a) { } } -double scr_arr_len(ScrArr *a) { return (double)a->len; } +double scr_arr_len(ScrArr *a) { return SCR_ARR_LEN_F64(a->len); } bool scr_arr_has(const ScrArr *a, double i) { size_t idx; @@ -841,7 +846,7 @@ double scr_arr_state(const ScrArr *a, double i) { } double scr_arr_next_present(const ScrArr *a, double start) { - if (!(start < (double)a->len)) return (double)a->len; + if (!(start < SCR_ARR_LEN_F64(a->len))) return SCR_ARR_LEN_F64(a->len); size_t i = start > 0 ? (size_t)ceil(start) : 0; size_t end = a->cap < a->len ? a->cap : a->len; for (; i < end; i++) { @@ -851,7 +856,7 @@ double scr_arr_next_present(const ScrArr *a, double start) { if (pos < SCR_AX(a)->sparse_len && SCR_AX(a)->sparse[pos].index < a->len) { return (double)SCR_AX(a)->sparse[pos].index; } - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } static void scr_arr_invalid_length(double length) { @@ -1271,10 +1276,13 @@ bool scr_arr_delete(ScrArr *a, double i) { /* ── push / pop ────────────────────────────────────────────────────────── */ static double scr_arr_push_slot(ScrArr *a, uint64_t slot) { - size_t idx = scr_arr_check_index(a, (double)a->len, true); + /* scr_arr_check_index(a, len, true) without the double round trip: the + * length is an integer, so only the upper bound can fail. */ + if (a->len > SCR_ARR_MAX_INDEX) scr_arr_trap_oob(SCR_ARR_LEN_F64(a->len), a->len); + size_t idx = a->len; scr_arr_store_owned(a, idx, slot); a->len++; - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } double scr_arr_push_f64(ScrArr *a, double v) { @@ -1314,7 +1322,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) { scr_arr_grow_dense(a, old_len + add); scr_arr_copy_dense(a, old_len, src, 0, add, false, true); a->len += add; - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } if (src == a) { ScrArrStorage old = scr_arr_take_storage(a); @@ -1331,7 +1339,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) { } a->len = old_len + add; scr_arr_free_storage(&old); - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } a->len = old_len + add; for (size_t i = 0; i < add; i++) { @@ -1343,7 +1351,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) { scr_arr_store_state_owned(a, old_len + i, 0, SCR_ARR_UNDEFINED); } } - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { @@ -1355,7 +1363,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { scr_arr_grow_dense(a, old_len + add); scr_arr_copy_dense(a, old_len, src, 0, add, false, false); a->len += add; - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } if (src == a) { ScrArrStorage old = scr_arr_take_storage(a); @@ -1372,7 +1380,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { } a->len = old_len + add; scr_arr_free_storage(&old); - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } a->len = old_len + add; for (size_t i = 0; i < src->cap; i++) { @@ -1384,7 +1392,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { scr_arr_copy_index(a, old_len + SCR_AX(src)->sparse[i].index, src, SCR_AX(src)->sparse[i].index); } - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } /* ── unshift / reverse ────────────────────────────────────────────────── @@ -1395,7 +1403,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { * tail move by reading the relocated original block. */ static double scr_arr_unshift_slot(ScrArr *a, uint64_t slot) { if (a->len == SCR_ARR_MAX_LENGTH) { - scr_arr_trap_oob((double)a->len, a->len); + scr_arr_trap_oob(SCR_ARR_LEN_F64(a->len), a->len); } size_t old_len = a->len; if (scr_arr_is_dense(a) && scr_arr_dense_ok(a, old_len + 1, 1)) { @@ -1404,7 +1412,7 @@ static double scr_arr_unshift_slot(ScrArr *a, uint64_t slot) { a->data[0] = slot; a->present[0] = SCR_ARR_VALUE; a->len++; - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } ScrArrStorage old = scr_arr_take_storage(a); a->len = 0; @@ -1418,7 +1426,7 @@ static double scr_arr_unshift_slot(ScrArr *a, uint64_t slot) { } a->len = old_len + 1; scr_arr_free_storage(&old); - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } double scr_arr_unshift_f64(ScrArr *a, double v) { @@ -1456,7 +1464,7 @@ double scr_arr_unshift_many(ScrArr *a, size_t count, const uint64_t *slots) { scr_arr_free_storage(&old); } a->len = old_len + count; - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } double scr_arr_unshift_spread(ScrArr *a, const ScrArr *src) { @@ -1471,7 +1479,7 @@ double scr_arr_unshift_spread(ScrArr *a, const ScrArr *src) { /* A self-spread reads the original values at their moved location. */ scr_arr_copy_dense(a, 0, src, src == a ? add : 0, add, false, true); a->len += add; - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } ScrArrStorage old = scr_arr_take_storage(a); a->len = 0; @@ -1497,7 +1505,7 @@ double scr_arr_unshift_spread(ScrArr *a, const ScrArr *src) { } a->len = old_len + add; scr_arr_free_storage(&old); - return (double)a->len; + return SCR_ARR_LEN_F64(a->len); } ScrArr *scr_arr_reverse(ScrArr *a) { @@ -1615,7 +1623,7 @@ void *scr_arr_shift_ref(ScrArr *a) { return scr_slot_to_ptr(scr_arr_shift_slot(a * out of the receiver (no retain/release churn); the tail slides down. * Borrows a. */ ScrArr *scr_arr_splice(ScrArr *a, double start, double deleteCount) { - double len = (double)a->len; + double len = SCR_ARR_LEN_F64(a->len); double s0 = isnan(start) ? 0 : trunc(start); if (s0 < 0) s0 += len; size_t from = s0 <= 0 ? 0 : s0 >= len ? a->len : (size_t)s0; @@ -1679,7 +1687,7 @@ ScrArr *scr_arr_splice(ScrArr *a, double start, double deleteCount) { * appending the tail follows indexed copying (holes stay holes). */ ScrArr *scr_arr_splice_insert(ScrArr *a, double start, double deleteCount, const ScrArr *items) { - double len = (double)a->len; + double len = SCR_ARR_LEN_F64(a->len); double s0 = isnan(start) ? 0 : trunc(start); if (s0 < 0) s0 += len; size_t from = s0 <= 0 ? 0 : s0 >= len ? a->len : (size_t)s0; @@ -1728,7 +1736,7 @@ ScrArr *scr_arr_splice_insert(ScrArr *a, double start, double deleteCount, * sparse-sized results keep the general implementation. */ void scr_arr_splice_drop(ScrArr *a, double start, double deleteCount, size_t count, const uint64_t *slots) { - double len = (double)a->len; + double len = SCR_ARR_LEN_F64(a->len); double s0 = isnan(start) ? 0 : trunc(start); if (s0 < 0) s0 += len; size_t from = s0 <= 0 ? 0 : s0 >= len ? a->len : (size_t)s0; @@ -1916,7 +1924,7 @@ static void scr_join_append(ScrStr **out, const char *bytes, size_t n) { * references, exactly JS's shallow copy. Borrows a; returns +1. */ ScrArr *scr_arr_slice(ScrArr *a, double start, double end) { /* ToIntegerOrInfinity + relative-index resolution over the LENGTH. */ - double len = (double)a->len; + double len = SCR_ARR_LEN_F64(a->len); double s0 = isnan(start) ? 0 : trunc(start); double e0 = isnan(end) ? 0 : trunc(end); if (s0 < 0) s0 += len; diff --git a/tests/corpus/4721-integer-induction-numbers.ts b/tests/corpus/4721-integer-induction-numbers.ts new file mode 100644 index 000000000..15e27615c --- /dev/null +++ b/tests/corpus/4721-integer-induction-numbers.ts @@ -0,0 +1,73 @@ +// Array index loops keep their counter in integer storage; every place the +// counter is observed as a number (arithmetic, comparisons, strings, calls, +// stores, nested starts) sees the exact double, including +0 at the start. + +function take(n: number): string { + return Object.is(n, -0) ? "-0" : `${n}`; +} + +const words = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"]; +const out: string[] = []; +for (let i = 0; i < words.length; i++) { + out.push(`${i}:${i / 2}:${i * 1.5}:${i - 3}:${take(i)}:${i === words.length - 1}:${(i + 0.25).toFixed(2)}`); +} +console.log(out.join(" ")); + +// Nested `j = i + 1` loops and counters stored into number arrays and maps. +const pairs: number[] = []; +const weights = new Map(); +for (let i = 0; i < words.length; i++) { + for (let j = i + 1; j < words.length; j++) { + if ((i + j) % 3 === 0) pairs.push(i * 10 + j); + weights.set(j, (weights.get(j) ?? 0) + i / (j + 1)); + } +} +console.log(pairs.join(",")); +console.log([...weights].map(([k, v]) => `${k}=${v.toFixed(6)}`).join(" ")); + +// for-of with an index read back as a number and used as a key. +const seen: Record = {}; +let index = 0; +for (const w of words) { + seen[w] = index * index - 0.5; + index++; +} +console.log(JSON.stringify(seen)); + +// Counters passed to functions taking numbers, compared with fractions and +// negative values, and used to index a second array. +const values = words.map((w) => w.length); +let acc = 0; +for (let i = 0; i < values.length; i++) { + acc += Math.max(i - 2.5, -1) * values[i]!; + if (i > 2.5 && i < 4.5) acc += 1000; + acc += Math.hypot(i, 1); +} +console.log(acc.toFixed(8)); + +// Arrays that grow during the loop (the bound is re-read every iteration). +const grow = [1, 2, 3]; +for (let i = 0; i < grow.length; i++) { + if (grow.length < 12) grow.push(i + grow[i]!); +} +console.log(grow.join(",")); + +// Byte loops: the index as a number in arithmetic and as an element value. +const bytes = new Uint8Array(20); +for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37) & 0xff; +let mix = 0; +for (let i = 0; i < bytes.length; i++) mix = (mix * 31 + bytes[i]! + i / 4) % 1000003; +console.log(Array.from(bytes).join(","), mix); + +// A larger array so the counter passes 2^8 and 2^16 boundaries. +const big: number[] = []; +for (let i = 0; i < 70000; i++) big.push(i & 7); +let total = 0; +let last = -1; +for (let i = 0; i < big.length; i++) { + if (big[i] === 7) { + total += i; + last = i; + } +} +console.log(total, last, String(last / 3)); From bc9a33c9c79436db816b3b0f5597904c5df68e8f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 12 Oct 2026 08:54:54 -0500 Subject: [PATCH 10/10] Expect the runtime string-length bound in the bitwise range test --- packages/compiler/src/ir/integer-ranges.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts index 3d566fe9c..ac8fc743d 100644 --- a/packages/compiler/src/ir/integer-ranges.test.ts +++ b/packages/compiler/src/ir/integer-ranges.test.ts @@ -554,8 +554,8 @@ test("bitwise ranges keep known operand ranges", () => { const analyzed = analyzeIntegerRanges( fn([observe(truncated), observe(half), observe(bin("|", ref("unknown"), num(0)))]), ); - expect(analyzed.get(truncated)).toEqual({ min: 0, max: 2 ** 30 }); - expect(analyzed.get(half)).toEqual({ min: 0, max: 2 ** 29 }); + expect(analyzed.get(truncated)).toEqual({ min: 0, max: 2 ** 31 - 1 }); + expect(analyzed.get(half)).toEqual({ min: 0, max: 2 ** 30 - 1 }); }); test("bitwise identity operands follow ToInt32 and the shift-count mask", () => {