From 5d4509702f5e5032a39c6447d0dab4446688355f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 11:17:17 -0700 Subject: [PATCH 01/13] wip: contract types prototype --- .github/workflows/release.yml | 3 +- package.json | 1 + packages/compiler/src/backend/llvm/emitter.ts | 12 + .../src/backend/llvm/expr-primitives.ts | 6 +- packages/compiler/src/backend/may-throw.ts | 131 +- packages/compiler/src/contracts/check.ts | 1192 +++++++++++++++++ packages/compiler/src/executable/prepare.ts | 7 + .../compiler/src/frontend/contract-types.ts | 203 +++ .../compiler/src/frontend/lowering/lowerer.ts | 15 + packages/compiler/src/frontend/type-mapper.ts | 40 + packages/compiler/src/ir/int32-slots.ts | 185 ++- packages/compiler/src/ir/integer-ranges.ts | 43 +- packages/compiler/src/ir/ir.ts | 23 + packages/compiler/src/library/prepare.ts | 7 + packages/types/index.d.ts | 41 + packages/types/package.json | 26 + pnpm-lock.yaml | 5 + scripts/sync-versions.mjs | 1 + tests/corpus/4630-contract-int32.ts | 88 ++ tests/corpus/4631-contract-nothrow.ts | 63 + tests/corpus/4632-contract-immutable.ts | 63 + tests/diagnostics/contract-immutable.ts | 34 + tests/diagnostics/contract-int32-escapes.ts | 17 + tests/diagnostics/contract-int32.ts | 36 + tests/diagnostics/contract-nothrow.ts | 25 + tests/diagnostics/contract-positions.ts | 19 + 26 files changed, 2228 insertions(+), 58 deletions(-) create mode 100644 packages/compiler/src/contracts/check.ts create mode 100644 packages/compiler/src/frontend/contract-types.ts create mode 100644 packages/types/index.d.ts create mode 100644 packages/types/package.json create mode 100644 tests/corpus/4630-contract-int32.ts create mode 100644 tests/corpus/4631-contract-nothrow.ts create mode 100644 tests/corpus/4632-contract-immutable.ts create mode 100644 tests/diagnostics/contract-immutable.ts create mode 100644 tests/diagnostics/contract-int32-escapes.ts create mode 100644 tests/diagnostics/contract-int32.ts create mode 100644 tests/diagnostics/contract-nothrow.ts create mode 100644 tests/diagnostics/contract-positions.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 950ce7796..85141d1ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -380,7 +380,7 @@ jobs: - name: Check version sync run: | VERSION="${{ needs.check-release.outputs.version }}" - for pkg in packages/runtime packages/runtime-* packages/llvm-* packages/compiler packages/threads packages/cli packages/cli-*; do + for pkg in packages/runtime packages/runtime-* packages/llvm-* packages/compiler packages/threads packages/types packages/cli packages/cli-*; do if [ "$(node -p "require('./$pkg/package.json').private === true")" = "true" ]; then continue fi @@ -478,6 +478,7 @@ jobs: for pkg in packages/cli-*; do publish_dir "$pkg"; done publish_dir packages/compiler publish_dir packages/threads + publish_dir packages/types publish_dir packages/cli echo "All package uploads accepted; npm registry propagation may continue after this job." >> "$GITHUB_STEP_SUMMARY" diff --git a/package.json b/package.json index 885d6f4e6..ddd89df37 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@scriptc/threads": "workspace:*", + "@scriptc/types": "workspace:*", "@types/node": "^24.0.0", "effect": "3.22.2", "js-yaml": "4.3.0", diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 897954cf8..9735609ff 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -733,6 +733,9 @@ export class LlEmitter { private readonly initializerBindings: ReturnType; /** Whole-program int32 storage and parameter/result facts. */ readonly int32Slots: Int32Slots; + /** Immutable module constants (contract types) whose graphs become + * immortal once initialized: their reads need no reference counting. */ + readonly immortalGlobals: ReadonlySet; private numericLocals = new Map(); private captureIds = new Set(); /** Active induction bindings retain their width independently of size_t. */ @@ -820,6 +823,7 @@ export class LlEmitter { this.constantNumericTables = findConstantNumericTables(mod); this.initializerBindings = findInitializerBindings(mod); this.int32Slots = analyzeInt32Slots(mod); + this.immortalGlobals = new Set(mod.contracts?.immortalGlobals ?? []); this.finalFields = analyzeFinalFields(mod); this.sizeType = options.pointerBits === 32 ? "i32" : "i64"; this.ffiExtendNarrowIntegers = @@ -5609,6 +5613,14 @@ export class LlEmitter { } B.line(`store ${this.llType(b.type)} ${v.name}, ptr ${b.slot}`); this.storeIntegerView(s.localId, v, s.value); + if (b.kind === "global" && this.immortalGlobals.has(s.localId)) { + // An Immutable module constant (contracts/check.ts proved it is + // fresh, publishable and never written): its graph becomes + // immortal, so reads skip reference counting and the collector + // never visits it. + for (const line of this.publishWalkers.emitPublish(v.name, v.type, s.loc)) + B.line(line.trimStart()); + } break; } case "exprStmt": diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index ea7061c4a..6807b0d07 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -89,8 +89,12 @@ export function emitLiteralExpr( B.line(`${uint32} = load i32, ptr ${integerView}`); return { name: t, type: e.type, uint32 }; } - if (isRefCounted(e.type)) + if (isRefCounted(e.type)) { + // An immortal Immutable constant: nothing to count. + if (b.kind === "global" && host.immortalGlobals.has(e.localId)) + return host.markImmortal({ name: t, type: e.type }); return host.own({ name: host.retainValue(t, e.type), type: e.type }); + } return { name: t, type: e.type }; } default: { diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 21d0fb900..65da6777f 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -1,12 +1,19 @@ /* Cheap whole-module may-throw analysis (see computeMayThrow). Pure function * of the IR module; the emitter consults the result to place unwind checks. */ -import type { IrExpr, IrStmt, IrModule, IrType } from "../ir/ir.js"; +import type { IrExpr, IrStmt, IrModule, IrType, SrcLoc } from "../ir/ir.js"; import { isFfiCallbackParam, MAY_THROW_ARR_METHODS, MAY_THROW_BYTES_METHODS } from "../ir/ir.js"; import { MAY_THROW_LIB_FNS } from "../ir/builtin-effects.js"; import { everyStmtList } from "../ir/traverse.js"; import { hasRetainedFfiCallback } from "./ffi-callbacks.js"; import { computePublishedTypes, writesPublished } from "./publication.js"; +/** Why a function may throw (computeMayThrow's `explain` option): its own + * throwing operation, or the first call edge to a function that may throw. */ +export type MayThrowReason = + | { kind: "seed"; what: string; loc: SrcLoc | null } + | { kind: "call"; callee: string; loc: SrcLoc | null } + | { kind: "indirect"; target: string; loc: SrcLoc | null }; + /** Cheap may-throw analysis (cost discipline: functions that transitively * CANNOT throw pay for no pending-exception checks). A function may throw * iff it contains a `throw`, calls a may-throw function (direct `call` or @@ -19,9 +26,11 @@ import { computePublishedTypes, writesPublished } from "./publication.js"; * surface a catchable rejection/error and seed the fixpoint like a `throw`. */ export function computeMayThrow( mod: IrModule, - options: { stackChecks?: boolean } = {}, + options: { stackChecks?: boolean; explain?: boolean } = {}, ): { fns: Set; + /** With `explain`: the reason each may-throw function was marked. */ + why?: Map; indirect: boolean; /** Worker programs: the functions that poll for termination on entry. */ workerEntryPolls?: Set; @@ -37,7 +46,13 @@ export function computeMayThrow( throws: boolean; callees: string[]; callsValue: boolean; + /** With `explain`: the first throwing operation, the first site of each + * callee, and the first indirect call. */ + seed?: MayThrowReason; + calleeLocs?: Map; + callValueLoc?: SrcLoc; } + const explain = options.explain === true; const facts = new Map(); const closureTargets = new Set(); // FUNC-targeted dynChecks synthesize adapter closures outside the IR's @@ -70,6 +85,7 @@ export function computeMayThrow( } for (const fn of mod.functions) { const f: Facts = { throws: false, callees: [], callsValue: false }; + if (explain) f.calleeLocs = new Map(); // TDZ reads and non-initializing writes can throw ReferenceError. // Capture locals carry the same flag as the declaring binding. const tdzIds = new Set([...tdzGlobals, ...fn.locals.filter((l) => l.tdz).map((l) => l.id)]); @@ -79,6 +95,8 @@ export function computeMayThrow( ]); // Traverse typed executable nodes without copying the IR into unknown. const visit = (rec: IrExpr | IrStmt): boolean => { + const threw = f.throws; + const callees = f.callees.length; if (published !== null && writesPublished(published, rec)) f.throws = true; switch (rec.kind) { case "throw": @@ -304,19 +322,39 @@ export function computeMayThrow( } case "callValue": f.callsValue = true; + if (explain) f.callValueLoc ??= rec.loc; break; case "closure": closureTargets.add(rec.fnName); break; } + if (explain) { + if (!threw && f.throws) f.seed = { kind: "seed", what: describeThrowingNode(rec), loc: rec.loc }; + for (let i = callees; i < f.callees.length; i++) + if (!f.calleeLocs!.has(f.callees[i]!)) f.calleeLocs!.set(f.callees[i]!, rec.loc); + if (rec.kind === "newValue" && !f.calleeLocs!.has("\0classValue")) + f.calleeLocs!.set("\0classValue", rec.loc); + } return true; }; everyStmtList(fn.body, { expr: visit, stmt: visit }); // A worker can be terminated at any of its polls: the noncatchable // stop unwinds like an exception from every function that polls. - if (terminations?.polls.has(fn.name)) f.throws = true; + if (terminations?.polls.has(fn.name)) { + if (explain && !f.throws) + f.seed = { kind: "seed", what: "polls for worker termination", loc: fn.loc }; + f.throws = true; + } // A stack-overflow check throws Node's RangeError from the entry. - if (stackChecks?.has(fn.name)) f.throws = true; + if (stackChecks?.has(fn.name)) { + if (explain && !f.throws) + f.seed = { + kind: "seed", + what: "can recurse (directly or through callbacks), so the stack guard may throw RangeError", + loc: fn.loc, + }; + f.throws = true; + } facts.set(fn.name, f); } @@ -326,13 +364,23 @@ export function computeMayThrow( const indirectCallers: string[] = []; const may = new Set(); const pending: string[] = []; - const mark = (name: string): void => { + const why = explain ? new Map() : null; + const mark = (name: string, reason?: MayThrowReason): void => { if (may.has(name)) return; may.add(name); pending.push(name); + if (why !== null && reason !== undefined) why.set(name, reason); }; + const callEdge = (caller: string, callee: string): MayThrowReason | undefined => + why === null + ? undefined + : { kind: "call", callee, loc: facts.get(caller)?.calleeLocs?.get(callee) ?? null }; + const indirectEdge = (caller: string, target: string): MayThrowReason | undefined => + why === null + ? undefined + : { kind: "indirect", target, loc: facts.get(caller)?.callValueLoc ?? null }; for (const [name, f] of facts) { - if (f.throws) mark(name); + if (f.throws) mark(name, f.seed); if (f.callsValue) indirectCallers.push(name); for (const callee of new Set(f.callees)) { let list = callers.get(callee); @@ -347,32 +395,95 @@ export function computeMayThrow( // is reached at most once, without materializing every descendant edge. const throwingHierarchies = new Set(); let indirect = sawDynFuncAdapter; - if (indirect) for (const name of indirectCallers) mark(name); + if (indirect) for (const name of indirectCallers) mark(name, indirectEdge(name, "")); for (let i = 0; i < pending.length; i++) { const name = pending[i]!; if (!indirect && closureTargets.has(name) && !asyncFns.has(name) && !genFns.has(name)) { indirect = true; - for (const caller of indirectCallers) mark(caller); + for (const caller of indirectCallers) mark(caller, indirectEdge(caller, name)); } - for (const caller of callers.get(name) ?? []) mark(caller); + for (const caller of callers.get(name) ?? []) mark(caller, callEdge(caller, name)); let cls = constructorClasses.get(name); while (cls !== undefined && !throwingHierarchies.has(cls)) { const def = classes.get(cls); if (!def) break; throwingHierarchies.add(cls); const valueCallers = classValueCallers.get(cls); - if (valueCallers) for (const caller of valueCallers) mark(caller); + if (valueCallers) + for (const caller of valueCallers) + mark( + caller, + why === null + ? undefined + : { + kind: "call", + callee: name, + loc: facts.get(caller)?.calleeLocs?.get("\0classValue") ?? null, + }, + ); cls = def.base; } } return { fns: may, + ...(why !== null ? { why } : {}), indirect, ...(terminations ? { workerEntryPolls: terminations.entry } : {}), ...(stackChecks ? { stackChecks } : {}), }; } +/** A short description of a node that seeds the may-throw fixpoint. */ +function describeThrowingNode(rec: IrExpr | IrStmt): string { + const detail = rec as { method?: string; fn?: string; name?: string }; + switch (rec.kind) { + case "throw": + return "throw statement"; + case "rethrow": + return "rethrow"; + case "runtimeFence": + return "deferred compile fence"; + case "varRef": + case "incDec": + case "assignExpr": + case "assign": + return "binding accessed before initialization (ReferenceError)"; + case "dynCheck": + case "caughtCheck": + return "checked cast of a dynamic value (TypeError)"; + case "dynCall": + case "dynInvoke": + return "call through a dynamic value"; + case "dynKeyGet": + case "dynHasKey": + return "property access on a dynamic value (undefined or null receiver)"; + case "dynDestrCheck": + case "dynIterN": + return "destructuring a dynamic value"; + case "awaitExpr": + case "awaitUnionExpr": + return "await (a rejected promise throws)"; + case "yieldExpr": + case "genResume": + return "generator resumption"; + case "libCall": + return `library call ${detail.fn ?? ""}`.trimEnd(); + case "intrinsic": + return `intrinsic ${detail.name ?? ""}`.trimEnd(); + case "arraySetLength": + return "array length assignment (RangeError)"; + case "jsonStringify": + return "JSON.stringify of a possibly circular value (TypeError)"; + case "toString": + return "string conversion of a dynamic value"; + case "fieldSet": + case "recordSet": + return "write into a possibly published object (TypeError)"; + default: + return detail.method !== undefined ? `${rec.kind} ${detail.method}` : rec.kind; + } +} + /** Statement and expression kinds that cannot reenter scriptc code except * through the direct call edges recorded below (`call`, `new`, * `virtualCall`), and cannot block in the runtime. Everything else, such as diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts new file mode 100644 index 000000000..f09324864 --- /dev/null +++ b/packages/compiler/src/contracts/check.ts @@ -0,0 +1,1192 @@ +/* Contract types (@scriptc/types), the IR side. The frontend collects the + * written annotations (frontend/contract-types.ts) keyed by the source + * locations the lowering stamps on IR functions, bindings and classes; this + * pass binds them to IR names and checks each contract against every write + * in the whole program: + * + * - NoThrow: the function is outside the may-throw fixpoint + * (backend/may-throw.ts), or the diagnostic names the throwing path. + * - Int32: every value stored into the slot is an int32 by construction, + * proven by the same integer range analysis the int32 storage uses, + * assuming every Int32 slot holds an int32 (sound by induction: every + * write is checked under that assumption). + * - 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 + * cannot see. Module constants built from fresh literals become immortal. + * + * Verified facts land on the IR as `contracts` (IrContracts). */ +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import type { + IrClassDef, + IrContracts, + IrExpr, + IrFunction, + IrModule, + IrStmt, + IrType, + SrcLoc, +} from "../ir/ir.js"; +import { isRefCounted, typeKey } from "../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../ir/traverse.js"; +import { + analyzeIntegerRanges, + INT32_RANGE, + integerArithmeticRange, + withinInt32, + type IntegerRange, + type IntegerSlotFacts, +} from "../ir/integer-ranges.js"; +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 { SourceLocations } from "../backend/source-locations.js"; +import { + contractLocKey, + type ContractAnnotations, + type ContractNote, +} from "../frontend/contract-types.js"; + +export interface ContractCheckResult { + diagnostics: ScrDiagnostic[]; + contracts: IrContracts | null; +} + +/** Walk every expression and statement of a function body, parents first. */ +function walk( + body: readonly IrStmt[], + visit: { expr?: (e: IrExpr) => void; stmt?: (s: IrStmt) => void }, +): void { + const expr = (e: IrExpr): boolean => { + visit.expr?.(e); + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + visit.stmt?.(s); + return everyStmtChild(s, expr, stmt); + }; + for (const s of body) stmt(s); +} + +function baseName(file: string): string { + const slash = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")); + return slash >= 0 ? file.slice(slash + 1) : file; +} + +export function checkContracts( + mod: IrModule, + annotations: ContractAnnotations, + sourceTexts: ReadonlyMap, +): ContractCheckResult { + const checker = new ContractChecker(mod, annotations, sourceTexts); + return checker.run(); +} + +class ContractChecker { + readonly diagnostics: ScrDiagnostic[] = []; + private readonly reported = new Set(); + private readonly positions: SourceLocations; + readonly functions = new Map(); + readonly classes = new Map(); + private readonly fnByLoc = new Map(); + private readonly localsByLoc = new Map(); + private readonly globalsByLoc = new Map(); + private readonly classesByLoc = new Map(); + private readonly contracts: IrContracts = {}; + + constructor( + readonly mod: IrModule, + readonly notes: ContractAnnotations, + readonly sources: ReadonlyMap, + ) { + this.positions = new SourceLocations(sources); + for (const fn of mod.functions) { + this.functions.set(fn.name, fn); + push(this.fnByLoc, contractLocKey(fn.loc), fn); + for (const local of fn.locals) + if (local.source) push(this.localsByLoc, contractLocKey(local.source.loc), { fn, localId: local.id }); + } + for (const g of mod.globals ?? []) + if (g.source) push(this.globalsByLoc, contractLocKey(g.source.loc), g.id); + for (const cls of mod.classes ?? []) { + this.classes.set(cls.name, cls); + push(this.classesByLoc, contractLocKey(cls.loc), cls); + } + } + + /* ── reporting ── */ + + where(loc: SrcLoc | null): string { + if (loc === null) return "?"; + const pos = this.positions.position(loc); + return pos ? `${baseName(pos.file)}:${pos.line}:${pos.column}` : baseName(loc.file); + } + + text(loc: SrcLoc, max = 60): string { + const source = this.sources.get(loc.file); + if (source === undefined || loc.end <= loc.start) return ""; + const raw = source.slice(loc.start, loc.end).replace(/\s+/g, " ").trim(); + return raw.length > max ? `${raw.slice(0, max - 3)}...` : raw; + } + + report(code: `SC${number}`, loc: SrcLoc, message: string, hint: string): void { + const key = `${code}\0${contractLocKey(loc)}\0${message}`; + if (this.reported.has(key)) return; + this.reported.add(key); + this.diagnostics.push({ code, loc, message, hint }); + } + + displayName(fnName: string): string { + const fn = this.functions.get(fnName); + const method = /^%(.*)\.([^.]+)$/.exec(fnName); + if (method && !fnName.startsWith("%fn") && !fnName.startsWith("%init")) { + const cls = this.classes.get(method[1]!); + const owner = cls?.jsName ?? method[1]!; + return `${owner}.${method[2]}`; + } + if (fn?.sourceName !== undefined && fn.sourceName !== "") return fn.sourceName; + if (fn !== undefined && /^%fn\d+$/.test(fnName)) return `the function at ${this.where(fn.loc)}`; + return fnName.replace(/^%/, ""); + } + + /* ── binding annotations to IR ── */ + + bindingsOf(key: string): { fn: IrFunction | null; id: string }[] { + const locals = (this.localsByLoc.get(key) ?? []).map((l) => ({ fn: l.fn, id: l.localId })); + const globals = (this.globalsByLoc.get(key) ?? []).map((id) => ({ fn: null, id })); + return [...locals, ...globals]; + } + + functionsAt(key: string): readonly IrFunction[] { + return this.fnByLoc.get(key) ?? []; + } + + classesAt(key: string): readonly IrClassDef[] { + return this.classesByLoc.get(key) ?? []; + } + + run(): ContractCheckResult { + this.checkNoThrow(); + const int32 = new Int32Contracts(this); + int32.run(); + const immutable = new ImmutableContracts(this); + immutable.run(); + if (this.diagnostics.length > 0) return { diagnostics: this.diagnostics, contracts: null }; + const c = this.contracts; + const empty = Object.values(c).every((v) => v === undefined || (v as unknown[]).length === 0); + return { diagnostics: [], contracts: empty ? null : c }; + } + + record(field: keyof IrContracts, values: Iterable): void { + const list = [...new Set(values)].sort(); + if (list.length > 0) this.contracts[field] = list; + } + + /* ── NoThrow ── */ + + private checkNoThrow(): void { + const targets: { fn: IrFunction; note: ContractNote }[] = []; + for (const [key, list] of this.notes.functions) + for (const note of list) + if (note.marker === "NoThrow") + for (const fn of this.fnByLoc.get(key) ?? []) targets.push({ fn, note }); + if (targets.length === 0) return; + // Target-independent: the native stack guard (Linux, macOS) counts, so a + // contract holds on every platform. + const mt = computeMayThrow(this.mod, { stackChecks: true, explain: true }); + const proven: string[] = []; + for (const { fn, note } of targets) { + if (fn.async === true || fn.generator !== undefined) { + this.report( + "SC7001", + note.loc, + `'${note.name}' is declared NoThrow, but async functions and generators report errors through promises and resumption, which the contract does not cover`, + "remove NoThrow from async functions and generators", + ); + continue; + } + if (!mt.fns.has(fn.name)) { + proven.push(fn.name); + continue; + } + const steps: string[] = []; + const seen = new Set(); + let current: string | undefined = fn.name; + while (current !== undefined && !seen.has(current) && steps.length < 8) { + seen.add(current); + const reason: MayThrowReason | undefined = mt.why?.get(current); + if (reason === undefined) break; + if (reason.kind === "seed") { + steps.push(`${reason.what} at ${this.where(reason.loc)}`); + current = undefined; + } else if (reason.kind === "call") { + steps.push(`calls '${this.displayName(reason.callee)}' at ${this.where(reason.loc)}`); + current = reason.callee; + } else { + steps.push( + reason.target === "" + ? `calls a function value at ${this.where(reason.loc)} (dynamic callbacks may throw)` + : `calls a function value at ${this.where(reason.loc)}, and the function value '${this.displayName(reason.target)}' may throw`, + ); + current = reason.target === "" ? undefined : reason.target; + } + } + this.report( + "SC7001", + note.loc, + `'${note.name}' is declared NoThrow but may throw: ${steps.join(" → ")}`, + "handle the error inside the function (try/catch around the throwing call), or remove NoThrow; a throw inside try/catch still counts in this version", + ); + } + this.record("noThrow", proven); + } +} + +/** The explicit int32 form of a written expression: `x++` and compound + * assignments become an assignment of the `| 0` result. */ +function int32Rewrite(text: string): string { + const step = /^(.+?)\s*(\+\+|--)$/.exec(text) ?? /^(\+\+|--)\s*(.+)$/.exec(text); + if (step) { + const target = step[1] === "++" || step[1] === "--" ? step[2]! : step[1]!; + const op = (step[1] === "++" || step[1] === "--" ? step[1] : step[2]!)[0]!; + return `${target} = (${target} ${op} 1) | 0`; + } + const compound = /^([^=]+?)\s*(\*\*|>>>|<<|>>|[-+*/%&|^])=\s*(.+)$/.exec(text); + if (compound) return `${compound[1]} = (${compound[1]} ${compound[2]} ${compound[3]}) | 0`; + const assignment = /^([^=]+?)\s*=\s*([^=].*)$/.exec(text); + if (assignment) return `${assignment[1]} = (${assignment[2]}) | 0`; + return `(${text}) | 0`; +} + +function push(map: Map, key: K, value: V): void { + const list = map.get(key); + if (list) list.push(value); + else map.set(key, [value]); +} + +/* ─────────────────────────────── Int32 ─────────────────────────────── */ + +class Int32Contracts { + private readonly slots: Int32Slots; + /** Field families, with the note that declared them. */ + private readonly fields = new Map(); + private readonly params = new Map(); // fn\0localId + private readonly returns = new Map(); // fn + private readonly locals = new Map(); // fn\0localId or \0globalId + + constructor(private readonly c: ContractChecker) { + this.slots = new Int32Slots(c.classes); + } + + run(): void { + if (!this.bind()) return; + if (this.fields.size + this.params.size + this.returns.size + this.locals.size === 0) return; + this.structural(); + this.writes(); + this.c.record("int32Fields", this.fields.keys()); + this.c.record("int32Params", this.params.keys()); + this.c.record("int32Returns", this.returns.keys()); + this.c.record("int32Locals", this.locals.keys()); + } + + private refuse(note: ContractNote, why: string, hint: string): void { + this.c.report("SC7003", note.loc, `the Int32 contract on '${note.name}' cannot be checked: ${why}`, hint); + } + + private bind(): boolean { + const c = this.c; + for (const [key, list] of c.notes.functions) + for (const note of list) { + if (note.marker !== "Int32") continue; + for (const fn of c.functionsAt(key)) { + if (fn.async === true || fn.generator !== undefined) { + this.refuse(note, "async functions and generators return promises or iterators", "annotate the awaited value where it is stored instead"); + continue; + } + if (fn.returnType.kind !== "f64") { + this.refuse(note, "the result is not a plain number", "write Int32 only on a number result"); + continue; + } + this.returns.set(fn.name, note); + } + } + for (const [key, note] of c.notes.bindings) { + if (note.marker !== "Int32") continue; + for (const b of c.bindingsOf(key)) { + const type = b.fn + ? b.fn.locals.find((l) => l.id === b.id)?.type + : c.mod.globals?.find((g) => g.id === b.id)?.type; + if (type?.kind !== "f64") { + this.refuse(note, "the binding is not a plain number (optional and union types are not int32 slots)", "declare it as a required Int32"); + continue; + } + if (b.fn === null) { + this.locals.set(`\0${b.id}`, note); + continue; + } + this.locals.set(`${b.fn.name}\0${b.id}`, note); + if (b.fn.params.some((p) => p.localId === b.id)) this.params.set(`${b.fn.name}\0${b.id}`, note); + } + } + for (const [key, note] of c.notes.fields) { + if (note.marker !== "Int32") continue; + const cut = key.lastIndexOf("\0"); + const field = key.slice(cut + 1); + for (const cls of c.classesAt(key.slice(0, cut))) { + const def = cls.fields.find((f) => f.name === field); + if (!def) { + this.refuse(note, `the compiled class has no field '${field}'`, "annotate an ordinary instance field"); + continue; + } + if (def.type.kind !== "f64") { + this.refuse(note, "the field is not a plain number", "declare the field as a required Int32"); + continue; + } + const family = this.slots.family(cls.name, field); + if (family !== null) this.fields.set(family, note); + } + } + // Captured bindings: every capture of an Int32 local shares its box. + this.propagateCaptures(); + return true; + } + + private propagateCaptures(): void { + const c = this.c; + for (let changed = true; changed; ) { + changed = false; + for (const fn of c.mod.functions) + walk(fn.body, { + expr: (e) => { + if (e.kind !== "closure") return; + const lifted = c.functions.get(e.fnName); + e.captures.forEach((id, i) => { + const inner = lifted?.captures?.[i]; + if (!inner) return; + const outerKey = `${fn.name}\0${id}`; + const innerKey = `${lifted!.name}\0${inner.localId}`; + const outer = this.locals.get(outerKey); + const innerNote = this.locals.get(innerKey); + if (outer && !innerNote) { + this.locals.set(innerKey, outer); + changed = true; + } else if (innerNote && !outer) { + this.locals.set(outerKey, innerNote); + changed = true; + } + }); + }, + }); + } + } + + /** Slots whose writes the IR does not show. */ + private structural(): void { + const c = this.c; + const mod = c.mod; + if (this.fields.size > 0) { + const dynamic = dynamicallyStoredFamilies(mod, this.slots); + for (const [family, note] of this.fields) { + const owner = family.slice(0, family.indexOf("\0")); + let generic = false; + for (let cls = c.classes.get(owner); cls; cls = cls.base ? c.classes.get(cls.base) : undefined) + if (cls.runtime === true || cls.genericOf !== undefined || (cls.base && !c.classes.has(cls.base))) generic = true; + for (const cls of c.classes.values()) + if (cls.genericOf !== undefined && (cls.genericOf === owner || cls.name === owner)) generic = true; + if (generic) + this.refuse(note, "the class is generic or extends a runtime-provided class", "move the field to a non-generic class"); + else if (dynamic.has(family)) + this.refuse( + note, + "instances of the class reach dynamic code (any, unknown casts, islands) where a store to the field is not checked", + "keep instances of this class out of any-typed values, or remove Int32", + ); + } + } + if (this.params.size > 0) { + const children = new Map(); + for (const cls of c.classes.values()) + if (cls.base) push(children, cls.base, cls.name); + const subtree = (name: string): string[] => { + const out: string[] = []; + const stack = [name]; + while (stack.length) { + const next = stack.pop()!; + out.push(next); + for (const child of children.get(next) ?? []) stack.push(child); + } + return out; + }; + const calls = functionCallFacts(mod, c.classes, subtree); + for (const [key, note] of this.params) { + const fnName = key.slice(0, key.indexOf("\0")); + if (calls.valueEscaped.has(fnName)) + this.refuse( + note, + `'${c.displayName(fnName)}' is used as a value (callback, class value or export), so its callers are not all visible`, + "wrap it in a function that takes a number and passes `x | 0`", + ); + else if (calls.runtimeVirtual(fnName)) + this.refuse(note, "the runtime calls this method", "remove Int32 from runtime-dispatched methods"); + } + } + } + + private int32Facts(fnName: string): IntegerSlotFacts { + const params = new Map(); + for (const key of this.params.keys()) + if (key.startsWith(`${fnName}\0`)) params.set(key.slice(fnName.length + 1), INT32_RANGE); + return { + params, + field: (className, field) => { + const family = this.slots.family(className, field); + return family !== null && this.fields.has(family) ? INT32_RANGE : null; + }, + call: (callee) => (this.returns.has(callee) ? INT32_RANGE : null), + boxed: (localId) => (this.locals.has(`${fnName}\0${localId}`) ? INT32_RANGE : null), + locals: (localId) => + this.locals.has(`${fnName}\0${localId}`) || this.locals.has(`\0${localId}`) ? INT32_RANGE : null, + }; + } + + private writes(): void { + const c = this.c; + const methodImpls = new Map(); + for (const cls of c.classes.values()) + for (const m of cls.methods ?? []) push(methodImpls, m, `%${cls.name}.${m}`); + const bindings = findInitializerBindings(c.mod); + const fail = (note: ContractNote, value: IrExpr | null, at: SrcLoc, what: string): void => { + const shown = value ? c.text(value.loc) : ""; + c.report( + "SC7002", + at, + `${what} '${note.name}' (Int32) is not provably an int32${shown ? `: '${shown}'` : ""}`, + shown + ? `make the int32 conversion explicit, for example '${int32Rewrite(shown)}', so Node computes the same value` + : "make the int32 conversion explicit with '| 0', so Node computes the same value", + ); + }; + for (const fn of c.mod.functions) { + const numeric = withInitializerBindings(fn, bindings.get(fn.name) ?? []); + const ranges = analyzeIntegerRanges(numeric, this.int32Facts(fn.name)); + const int32 = (e: IrExpr): boolean => e.type.kind === "f64" && withinInt32(ranges.get(e)); + const localNote = (id: string): ContractNote | undefined => + this.locals.get(`${fn.name}\0${id}`) ?? this.locals.get(`\0${id}`); + const checkArgs = (callee: string, args: IrExpr[], offset: number, at: SrcLoc): void => { + const target = c.functions.get(callee); + if (!target) return; + target.params.forEach((param, i) => { + const note = this.params.get(`${callee}\0${param.localId}`); + if (!note) return; + const arg = args[i - offset]; + if (i < offset) return; + if (arg === undefined) fail(note, null, at, "a call passes no argument for"); + else if (!int32(arg)) fail(note, arg, arg.loc, "the argument for"); + }); + }; + const incDecStored = (e: IrExpr & { prefix: boolean; op: "+" | "-" }): boolean => { + const result = ranges.get(e); + if (!result) return false; + if (e.prefix) return withinInt32(result); + return withinInt32(integerArithmeticRange(e.op, result, { min: 1, max: 1 })); + }; + walk(fn.body, { + stmt: (s) => { + if (s.kind === "varDecl" || s.kind === "assign") { + const note = localNote(s.localId); + const value = s.kind === "varDecl" ? s.init : s.value; + if (note && value && !int32(value)) fail(note, value, value.loc, "the value stored into"); + } else if (s.kind === "forOf" || (s.kind === "tryCatch" && s.catchLocalId !== undefined)) { + const id = s.kind === "forOf" ? s.localId : s.catchLocalId!; + const note = localNote(id); + if (note) fail(note, null, s.loc, s.kind === "forOf" ? "a for-of loop element stored into" : "a caught value stored into"); + } else if (s.kind === "fieldSet") { + const family = this.slots.family(s.className, s.field); + const note = family !== null ? this.fields.get(family) : undefined; + if (note && !int32(s.value)) fail(note, s.value, s.value.loc, "the value stored into"); + } else if (s.kind === "return") { + const note = this.returns.get(fn.name); + if (note && (!s.value || !int32(s.value))) fail(note, s.value, s.value?.loc ?? s.loc, "the value returned from"); + } + }, + expr: (e) => { + if (e.kind === "assignExpr") { + const note = localNote(e.localId); + if (note && !int32(e.value)) fail(note, e.value, e.value.loc, "the value stored into"); + } else if (e.kind === "incDec") { + const note = localNote(e.localId); + if (note && !incDecStored(e)) + c.report( + "SC7002", + e.loc, + `'${c.text(e.loc)}' can step '${note.name}' (Int32) outside the int32 range`, + `bound the value by an Int32 comparison in the loop condition, or write '${note.name} = (${note.name} ${e.op} 1) | 0'`, + ); + } else if (e.kind === "fieldIncDec") { + const family = this.slots.family(e.className, e.field); + const note = family !== null ? this.fields.get(family) : undefined; + if (note) { + const target = c.text(e.obj.loc); + const field = `${target}.${e.field}`; + c.report( + "SC7002", + e.loc, + `'${c.text(e.loc)}' can step the Int32 field '${note.name}' outside the int32 range`, + `write '${field} = (${field} ${e.op} 1) | 0'`, + ); + } + } else if (e.kind === "call") checkArgs(e.callee, e.args, 0, e.loc); + else if (e.kind === "new") checkArgs(`%${e.className}.constructor`, e.args, 1, e.loc); + else if (e.kind === "virtualCall") + for (const impl of methodImpls.get(e.method) ?? []) checkArgs(impl, e.args, 0, e.loc); + }, + }); + } + } +} + +/* ───────────────────────────── Immutable ───────────────────────────── */ + +/** Read-only and searching array intrinsics: the receiver is not written, + * and arguments are compared or copied out, never stored. */ +const ARR_READS = new Set(["length", "getNumber", "indexEq", "indexOf", "includes", "nextPresent", "join", "slice", "flatCopy", "flatOne", "toReversed", "toSpliced", "toSortedPrimitive", "concatSpread"]); +const MAP_READS = new Set(["get", "has", "size", "iterCount", "iterLive", "iterKey", "iterValue", "iterEnter", "iterExit", "clone", "keySet", "valueSet"]); +const SET_READS = new Set(["has", "size", "iterCount", "iterLive", "iterKey", "iterEnter", "iterExit", "clone"]); +/** Element reads whose result can be part of the receiver's graph. */ +const MAP_ELEMENT_READS = new Set(["get", "iterKey", "iterValue"]); +const SET_ELEMENT_READS = new Set(["iterKey"]); +/** Copies: a fresh container holding the receiver's elements. */ +const ARR_COPIES = new Set(["slice", "flatCopy", "flatOne", "toReversed", "toSpliced", "concatSpread", "toSortedPrimitive"]); +const COLLECTION_COPIES = new Set(["clone", "keySet", "valueSet"]); +/** Expression kinds whose result is the same value as their child. */ +const PASS_THROUGH = new Set(["upcast", "downcast", "unionWrap", "unionNarrow", "ternary", "logical", "nullish", "orDefault", "seqExpr", "chainRecv", "optChain", "assignExpr"]); +/** Observations: the value is inspected, never stored or written. */ +const OBSERVE = new Set(["toBool", "strEq", "unionEq", "unionFuncEq", "unionIsTag", "unionDisc", "instanceOf", "instanceOfValue", "dynScalarEq", "fieldAbsent", "recordHas", "recordOvfHas", "arrayHas", "arrayState", "jsonStringify", "toString", "strConcat", "bin", "unary", "unionKeyGet", "recordKeyGet", "recordOvfKeys"]); +/** Library calls that only read their arguments (console output). */ +const LIB_READS = /^(console\.|inspect\.|json\.stringify)/; + +class ImmutableContracts { + /** Annotated slots: storing into them requires a fresh or immutable value. */ + private readonly annotated = new Map(); + /** Slots that may hold an immutable value (annotated plus inferred). */ + private readonly mayHold = new Set(); + /** The first store that made an inferred slot hold an immutable value. */ + private readonly via = new Map(); + /** Functions whose results are fresh (factory functions). */ + private readonly freshFns = new Set(); + private readonly methodImpls = new Map(); + private readonly slots: Int32Slots; + /** freshLocalsOf results, valid once the factory set is final. */ + private readonly freshLocalsCache = new Map>(); + private factoriesFinal = false; + + constructor(private readonly c: ContractChecker) { + this.slots = new Int32Slots(c.classes); + for (const cls of c.classes.values()) + for (const m of cls.methods ?? []) push(this.methodImpls, m, `%${cls.name}.${m}`); + } + + private refuse(note: ContractNote, why: string, hint: string): void { + this.c.report("SC7005", note.loc, `the Immutable contract on '${note.name}' cannot be checked: ${why}`, hint); + } + + run(): void { + this.bind(); + if (this.annotated.size === 0) return; + for (const key of this.annotated.keys()) this.mayHold.add(key); + this.computeFreshFunctions(); + // Grow the may-hold set to a fixpoint, then report from the final pass. + for (let round = 0; round < 64; round++) { + const before = this.mayHold.size; + this.pass(false); + if (this.mayHold.size === before) break; + } + this.pass(true); + this.immortalGlobals(); + } + + private bind(): void { + const c = this.c; + for (const [key, note] of c.notes.bindings) { + if (note.marker !== "Immutable") continue; + for (const b of c.bindingsOf(key)) + this.annotated.set(b.fn ? `l:${b.fn.name}\0${b.id}` : `g:${b.id}`, note); + } + for (const [key, list] of c.notes.functions) + for (const note of list) + if (note.marker === "Immutable") + for (const fn of c.functionsAt(key)) this.annotated.set(`r:${fn.name}`, note); + for (const [key, note] of c.notes.fields) { + if (note.marker !== "Immutable") continue; + const cut = key.lastIndexOf("\0"); + const field = key.slice(cut + 1); + for (const cls of c.classesAt(key.slice(0, cut))) { + const family = this.slots.family(cls.name, field); + if (family === null) this.refuse(note, `the compiled class has no field '${field}'`, "annotate an ordinary instance field"); + else this.annotated.set(`f:${family}`, note); + } + } + } + + private localSlot(fn: IrFunction, id: string): string { + return id.startsWith("%g.") ? `g:${id}` : `l:${fn.name}\0${id}`; + } + private fieldSlot(className: string, field: string): string | null { + const family = this.slots.family(className, field); + return family === null ? null : `f:${family}`; + } + + /** A factory: every return is fresh, built from literals and locals that + * only it references. */ + private computeFreshFunctions(): void { + const c = this.c; + for (let changed = true; changed; ) { + changed = false; + for (const fn of c.mod.functions) { + if (this.freshFns.has(fn.name) || fn.async || fn.generator || fn.captures?.length) continue; + if (!isRefCounted(fn.returnType) || fn.returnType.kind === "string") continue; + const freshLocals = this.freshLocalsOf(fn); + let returns = 0; + let ok = true; + walk(fn.body, { + stmt: (s) => { + if (s.kind !== "return") return; + returns++; + if (!s.value || !this.fresh(s.value, freshLocals)) ok = false; + }, + }); + if (ok && returns > 0) { + this.freshFns.add(fn.name); + changed = true; + } + } + } + this.factoriesFinal = true; + } + + /** Locals of `fn` that only ever hold fresh values and are used only as + * intrinsic receivers, field-write targets, element writes and returns: + * the value leaves the function only by `return`. */ + private freshLocalsOf(fn: IrFunction): Set { + const cached = this.factoriesFinal ? this.freshLocalsCache.get(fn.name) : undefined; + if (cached !== undefined) return cached; + const result = this.computeFreshLocals(fn); + if (this.factoriesFinal) this.freshLocalsCache.set(fn.name, result); + return result; + } + + private computeFreshLocals(fn: IrFunction): Set { + const candidates = new Set(fn.locals.filter((l) => !l.boxed && isRefCounted(l.type)).map((l) => l.id)); + for (const p of fn.params) candidates.delete(p.localId); + const allowed = new Set(); + walk(fn.body, { + stmt: (s) => { + if (s.kind === "return" && s.value?.kind === "varRef") allowed.add(s.value); + if ((s.kind === "fieldSet" || s.kind === "recordSet") && s.obj.kind === "varRef") allowed.add(s.obj); + if ((s.kind === "arraySet" || s.kind === "bytesSet") && s.arr.kind === "varRef") allowed.add(s.arr); + }, + expr: (e) => { + if ((e.kind === "arrIntrinsic" || e.kind === "mapIntrinsic" || e.kind === "setIntrinsic") && e.receiver.kind === "varRef") + allowed.add(e.receiver); + if ((e.kind === "arrayGet" || e.kind === "arrayHas") && e.arr.kind === "varRef") allowed.add(e.arr); + if ((e.kind === "fieldGet" || e.kind === "recordGet") && e.obj.kind === "varRef") allowed.add(e.obj); + }, + }); + for (let changed = true; changed; ) { + changed = false; + walk(fn.body, { + stmt: (s) => { + const value = s.kind === "varDecl" ? s.init : s.kind === "assign" ? s.value : undefined; + if (value && candidates.has((s as { localId: string }).localId) && !this.fresh(value, candidates)) { + candidates.delete((s as { localId: string }).localId); + changed = true; + } + if (s.kind === "forOf" && candidates.delete(s.localId)) changed = true; + }, + expr: (e) => { + if (e.kind === "varRef" && candidates.has(e.localId) && !allowed.has(e)) { + candidates.delete(e.localId); + changed = true; + } + if ((e.kind === "assignExpr" || e.kind === "incDec") && candidates.delete(e.localId)) changed = true; + }, + }); + } + return candidates; + } + + /** A value nothing else references: a literal, a fresh construction or + * factory result, a primitive, or a value that is already immutable. */ + private fresh(e: IrExpr, freshLocals: ReadonlySet, derived?: (e: IrExpr) => boolean): boolean { + if (!isRefCounted(e.type) || e.type.kind === "string") return true; + if (derived?.(e)) return true; + switch (e.kind) { + case "strLit": + case "unitLit": + return true; + case "varRef": + return freshLocals.has(e.localId); + case "arrayLit": + return e.elems.every((x) => this.fresh(x, freshLocals, derived)); + case "recordLit": + return e.fields.every((f) => this.fresh(f.value, freshLocals, derived)); + case "mapNew": + return (e.seed ?? []).every((p) => this.fresh(p.key, freshLocals, derived) && this.fresh(p.value, freshLocals, derived)); + case "setNew": + return e.seed === undefined || this.fresh(e.seed, freshLocals, derived); + case "new": + return this.constructorKeepsThis(e.className) && e.args.every((a) => this.fresh(a, freshLocals, derived)); + case "call": + return this.freshFns.has(e.callee) && e.args.every((a) => this.fresh(a, freshLocals, derived)); + case "upcast": + case "downcast": + case "unionWrap": + case "unionNarrow": + return this.fresh(e.value, freshLocals, derived); + case "ternary": + return this.fresh(e.then, freshLocals, derived) && this.fresh(e.else_, freshLocals, derived); + default: + return false; + } + } + + /** The constructor chain uses `this` only to read and write its own + * fields: the new object is referenced from nowhere else. */ + private constructorKeepsThis(className: string): boolean { + const c = this.c; + for (let cls = c.classes.get(className); cls; cls = cls.base ? c.classes.get(cls.base) : undefined) { + if (cls.runtime === true) return false; + const ctor = c.functions.get(`%${cls.name}.constructor`); + if (!ctor) continue; + const self = ctor.params[0]?.localId; + if (self === undefined) return false; + const allowed = new Set(); + walk(ctor.body, { + stmt: (s) => { + if (s.kind === "fieldSet" && s.obj.kind === "varRef") allowed.add(s.obj); + }, + expr: (e) => { + if (e.kind === "fieldGet" && e.obj.kind === "varRef") allowed.add(e.obj); + if (e.kind === "call" && e.callee === `%${cls.base}.constructor` && e.args[0]?.kind === "varRef") allowed.add(e.args[0]); + }, + }); + let ok = true; + walk(ctor.body, { + expr: (e) => { + if (e.kind === "varRef" && e.localId === self && !allowed.has(e)) ok = false; + if (e.kind === "closure" && e.captures.includes(self)) ok = false; + }, + }); + if (!ok) return false; + } + return true; + } + + /** One pass over the program: derived values, stores, escapes and + * mutations. With `report`, violations become diagnostics. */ + private pass(report: boolean): void { + const c = this.c; + for (const fn of c.mod.functions) { + const derivedMemo = new Map(); + const freshLocals = this.freshLocalsOf(fn); + const derived = (e: IrExpr): boolean => { + if (!isRefCounted(e.type) || e.type.kind === "string") return false; + const memo = derivedMemo.get(e); + if (memo !== undefined) return memo; + derivedMemo.set(e, false); + const result = this.derivedExpr(fn, e, derived); + derivedMemo.set(e, result); + return result; + }; + const hold = (slot: string | null, value: IrExpr, at: string): void => { + if (slot === null || !derived(value)) return; + if (!this.mayHold.has(slot)) { + this.mayHold.add(slot); + this.via.set(slot, at); + } + }; + const store = (slot: string | null, value: IrExpr, how?: string): void => { + if (slot === null) return; + const note = this.annotated.get(slot); + if (note && report && !this.fresh(value, freshLocals, derived)) { + c.report( + "SC7004", + value.loc, + `the value stored into '${note.name}' (Immutable) may be referenced elsewhere: '${c.text(value.loc)}' is not a fresh literal, construction or factory result`, + "build the value in place (a literal, `new`, or a function that returns what it builds), so no mutable reference to it remains", + ); + } + hold(slot, value, how ?? `stored at ${c.where(value.loc)}`); + }; + const mutate = (target: IrExpr, at: SrcLoc, what: string): void => { + if (!report || !derived(target)) return; + const shown = c.text(target.loc); + const origin = this.originOf(fn, target); + const subject = origin === "" && shown !== "" && shown !== c.text(at) ? ` ('${shown}')` : ""; + c.report( + "SC7004", + at, + `${what} writes to a value that may be Immutable${subject}${origin}`, + "Immutable values cannot be written through any reference; copy the value before changing it", + ); + }; + const escape = (value: IrExpr, at: SrcLoc, what: string): void => { + if (!report || !derived(value)) return; + c.report( + "SC7005", + at, + `a value that may be Immutable escapes to ${what}, where writes are not checked: '${c.text(value.loc)}'${this.originOf(fn, value)}`, + "keep Immutable values in typed code, or copy the value before handing it out", + ); + }; + const args = (callee: string, list: IrExpr[], offset: number, at: SrcLoc): void => { + const target = c.functions.get(callee); + list.forEach((arg, i) => { + const param = target?.params[i + offset]; + if (!target || !param) return escape(arg, arg.loc, `a call to '${c.displayName(callee)}'`); + store( + `l:${callee}\0${param.localId}`, + arg, + `passed to '${c.displayName(callee)}' at ${c.where(at)}`, + ); + }); + }; + walk(fn.body, { + stmt: (s) => { + switch (s.kind) { + case "varDecl": + if (s.init) store(this.localSlot(fn, s.localId), s.init); + break; + case "assign": + store(this.localSlot(fn, s.localId), s.value); + break; + case "return": + if (s.value) store(`r:${fn.name}`, s.value, `returned from '${c.displayName(fn.name)}' at ${c.where(s.loc)}`); + break; + case "fieldSet": + mutate(s.obj, s.loc, `'${c.text(s.loc)}'`); + store(this.fieldSlot(s.className, s.field), s.value); + break; + case "recordSet": + mutate(s.obj, s.loc, `'${c.text(s.loc)}'`); + store(`s:${s.shapeId}\0${s.field}`, s.value); + break; + case "recordKeySet": + case "recordKeyDelete": + mutate((s as { obj: IrExpr }).obj, s.loc, `'${c.text(s.loc)}'`); + if (s.kind === "recordKeySet") escape(s.value, s.loc, "a dynamically keyed property"); + break; + case "arraySet": + mutate(s.arr, s.loc, `'${c.text(s.loc)}'`); + store(`e:${typeKey(s.arr.type)}`, s.value); + break; + case "arraySetLength": + case "arraySetUndefined": + case "arrayDelete": + case "bytesSet": + mutate((s as { arr: IrExpr }).arr, s.loc, `'${c.text(s.loc)}'`); + break; + case "forOf": { + const slot = this.localSlot(fn, s.localId); + const elems = + this.mayHold.has(`e:${typeKey(s.iterable.type)}`) || + this.mayHold.has(`se:${typeKey(s.iterable.type)}`); + if ((elems || derived(s.iterable)) && !this.mayHold.has(slot)) { + this.mayHold.add(slot); + this.via.set(slot, `iterated at ${c.where(s.loc)}`); + } + break; + } + case "throw": + escape(s.value, s.loc, "a throw"); + break; + } + }, + expr: (e) => { + switch (e.kind) { + case "assignExpr": + store(this.localSlot(fn, e.localId), e.value); + return; + case "call": + return args(e.callee, e.args, 0, e.loc); + case "new": + return args(`%${e.className}.constructor`, e.args, 1, e.loc); + case "virtualCall": + for (const impl of this.methodImpls.get(e.method) ?? []) args(impl, e.args, 0, e.loc); + return; + case "fieldIncDec": + return mutate(e.obj, e.loc, `'${c.text(e.loc)}'`); + case "arrIntrinsic": + if (ARR_COPIES.has(e.method)) this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc)); + if (!ARR_READS.has(e.method)) mutate(e.receiver, e.loc, `'${c.text(e.loc)}'`); + for (const a of e.args) { + if (e.method === "indexOf" || e.method === "includes" || e.method === "indexEq") continue; + store(`e:${typeKey(e.receiver.type)}`, a); + } + return; + case "mapIntrinsic": + if (COLLECTION_COPIES.has(e.method)) this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc)); + if (!MAP_READS.has(e.method)) mutate(e.receiver, e.loc, `'${c.text(e.loc)}'`); + if (e.method === "set") { + if (e.args[0]) store(`mk:${typeKey(e.receiver.type)}`, e.args[0]); + if (e.args[1]) store(`mv:${typeKey(e.receiver.type)}`, e.args[1]); + } + return; + case "setIntrinsic": + if (COLLECTION_COPIES.has(e.method)) this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc)); + if (!SET_READS.has(e.method)) mutate(e.receiver, e.loc, `'${c.text(e.loc)}'`); + if (e.method === "add" && e.args[0]) store(`se:${typeKey(e.receiver.type)}`, e.args[0]); + return; + case "arrayLit": + for (const x of e.elems) store(`e:${typeKey(e.type)}`, x); + return; + case "recordLit": + if (e.type.kind === "record") + for (const f of e.fields) store(`s:${e.type.shapeId}\0${f.name}`, f.value); + return; + case "mapNew": + for (const p of e.seed ?? []) { + store(`mk:${typeKey(e.type)}`, p.key); + store(`mv:${typeKey(e.type)}`, p.value); + } + return; + case "closure": { + const lifted = c.functions.get(e.fnName); + e.captures.forEach((id, i) => { + const inner = lifted?.captures?.[i]; + const outer = this.localSlot(fn, id); + if (!inner) return; + const innerSlot = `l:${lifted!.name}\0${inner.localId}`; + if (this.mayHold.has(outer) && !this.mayHold.has(innerSlot)) { + this.mayHold.add(innerSlot); + this.via.set(innerSlot, `captured at ${c.where(e.loc)}`); + } + }); + return; + } + case "libCall": + if (!LIB_READS.test(e.fn)) for (const a of e.args) escape(a, a.loc, `the library call ${e.fn}`); + return; + case "callValue": + for (const a of e.args) escape(a, a.loc, "a call through a function value"); + if (e.receiver) escape(e.receiver, e.receiver.loc, "a call through a function value"); + return; + } + if (PASS_THROUGH.has(e.kind) || OBSERVE.has(e.kind)) return; + if (e.kind === "fieldGet" || e.kind === "recordGet" || e.kind === "arrayGet") return; + // Any other parent: a derived child goes somewhere this pass does + // not follow (dynamic conversions, islands, runtime helpers). + everyExprChild( + e, + (child) => { + escape(child, child.loc, describeSink(e)); + return true; + }, + () => true, + ); + }, + }); + } + } + + /** A copy of a container whose elements may be immutable: the copy's + * element slots may hold them too (the copy itself is fresh). */ + private copyElements(receiver: IrExpr, result: IrType, receiverDerived: boolean, where: string): void { + const at = `copied at ${where}`; + const rk = typeKey(receiver.type); + const sources = [`e:${rk}`, `mk:${rk}`, `mv:${rk}`, `se:${rk}`]; + if (!receiverDerived && !sources.some((slot) => this.mayHold.has(slot))) return; + const key = typeKey(result); + for (const slot of [`e:${key}`, `se:${key}`, `mk:${key}`, `mv:${key}`]) + if (!this.mayHold.has(slot)) { + this.mayHold.add(slot); + this.via.set(slot, at); + } + } + + private originOf(fn: IrFunction, e: IrExpr): string { + let root = e; + for (let steps = 0; steps < 16; steps++) { + if (root.kind === "fieldGet" || root.kind === "recordGet") root = root.obj; + else if (root.kind === "arrayGet") root = root.arr; + else if (PASS_THROUGH.has(root.kind) && "value" in root) root = (root as { value: IrExpr }).value; + else if (root.kind === "varRef") { + // Compiler temporaries (no source binding): follow their initializer. + const id = root.localId; + const local = fn.locals.find((l) => l.id === id); + if (local === undefined || local.source !== undefined) break; + let init: IrExpr | null = null; + walk(fn.body, { + stmt: (s) => { + if (s.kind === "varDecl" && s.localId === id && s.init) init ??= s.init; + }, + }); + if (init === null) break; + root = init; + } else break; + } + if (root.kind === "varRef") { + const slot = this.localSlot(fn, root.localId); + const note = this.annotated.get(slot); + if (note) return ` (from '${note.name}')`; + const via = this.via.get(slot); + if (via) { + const local = fn.locals.find((l) => l.id === root.localId); + return ` ('${local?.name ?? root.localId}' may be an Immutable value ${via})`; + } + } + return ""; + } + + /** Whether `e` may evaluate to (part of) an Immutable value. */ + private derivedExpr(fn: IrFunction, e: IrExpr, derived: (e: IrExpr) => boolean): boolean { + switch (e.kind) { + case "varRef": + return this.mayHold.has(this.localSlot(fn, e.localId)); + case "fieldGet": { + const slot = this.fieldSlot(e.className, e.field); + return (slot !== null && this.mayHold.has(slot)) || derived(e.obj); + } + case "recordGet": + return this.mayHold.has(`s:${(e.obj.type as { shapeId?: string }).shapeId ?? ""}\0${e.field}`) || derived(e.obj); + case "arrayGet": + return this.mayHold.has(`e:${typeKey(e.arr.type)}`) || derived(e.arr); + case "mapIntrinsic": + return ( + MAP_ELEMENT_READS.has(e.method) && + (this.mayHold.has(`mv:${typeKey(e.receiver.type)}`) || this.mayHold.has(`mk:${typeKey(e.receiver.type)}`) || derived(e.receiver)) + ); + case "setIntrinsic": + return SET_ELEMENT_READS.has(e.method) && (this.mayHold.has(`se:${typeKey(e.receiver.type)}`) || derived(e.receiver)); + case "call": + return this.mayHold.has(`r:${e.callee}`); + case "virtualCall": + return (this.methodImpls.get(e.method) ?? []).some((impl) => this.mayHold.has(`r:${impl}`)); + case "ternary": + return derived(e.then) || derived(e.else_); + case "logical": + case "nullish": + return derived(e.left) || derived(e.right); + case "seqExpr": + return derived(e.result); + case "assignExpr": + return derived(e.value); + case "upcast": + case "downcast": + case "unionWrap": + case "unionNarrow": + return derived(e.value); + default: { + if (!PASS_THROUGH.has(e.kind)) return false; + let any = false; + everyExprChild( + e, + (child) => { + any ||= derived(child); + return !any; + }, + () => true, + ); + return any; + } + } + } + + /** Module constants with an Immutable annotation, written once from a + * fresh value of a publishable type: the backend makes them immortal. */ + private immortalGlobals(): void { + const c = this.c; + if (c.mod.lib !== undefined || c.diagnostics.length > 0) return; + const out: string[] = []; + for (const g of c.mod.globals ?? []) { + if (g.mutable || g.tdz === true || g.initFlag !== undefined || !this.annotated.has(`g:${g.id}`)) continue; + if (!this.publishable(g.type, new Set())) continue; + let writes = 0; + let fresh = true; + for (const fn of c.mod.functions) { + const freshLocals = this.freshLocalsOf(fn); + walk(fn.body, { + stmt: (s) => { + if ((s.kind === "assign" || s.kind === "varDecl") && s.localId === g.id) { + writes++; + const value = s.kind === "assign" ? s.value : s.init; + if (!value || !this.fresh(value, freshLocals)) fresh = false; + } + }, + expr: (e) => { + if (e.kind === "assignExpr" && e.localId === g.id) writes++; + }, + }); + } + if (writes === 1 && fresh) out.push(g.id); + } + c.record("immortalGlobals", out); + } + + /** Static types the publication walkers handle without a refusal. */ + private publishable(t: IrType, seen: Set): boolean { + const c = this.c; + switch (t.kind) { + case "f64": + case "bool": + case "string": + case "undefinedT": + case "nullT": + return true; + case "array": + case "set": + return this.publishable(t.elem, seen); + case "map": + return this.publishable(t.key, seen) && this.publishable(t.value, seen); + case "record": { + const key = `r:${t.shapeId}`; + if (seen.has(key)) return true; + seen.add(key); + const shape = c.mod.records?.find((r) => r.id === t.shapeId); + return !!shape && !shape.indexValue && shape.fields.every((f) => this.publishable(f.type, seen)); + } + case "union": { + const key = `u:${t.unionId}`; + if (seen.has(key)) return true; + seen.add(key); + const def = c.mod.unions?.find((u) => u.id === t.unionId); + return !!def && def.arms.every((arm) => this.publishable(arm, seen)); + } + case "object": { + const key = `o:${t.className}`; + if (seen.has(key)) return true; + seen.add(key); + // Subclasses can stand behind a base-typed reference. + for (const cls of c.classes.values()) { + let under = false; + for (let x: IrClassDef | undefined = cls; x; x = x.base ? c.classes.get(x.base) : undefined) + if (x.name === t.className) under = true; + if (!under) continue; + for (let x: IrClassDef | undefined = cls; x; x = x.base ? c.classes.get(x.base) : undefined) + if (x.runtime === true || x.localCaptures !== undefined) return false; + if (!cls.fields.every((f) => f.name.startsWith("%") || this.publishable(f.type, seen))) return false; + } + return true; + } + default: + return false; + } + } +} + +function describeSink(e: IrExpr): string { + switch (e.kind) { + case "dynFrom": + case "dynObjLit": + case "dynArrLit": + return "dynamic (any or unknown) code"; + case "jsOp": + case "jsMarshal": + case "jsExit": + return "the JavaScript island"; + case "intrinsic": + return `the runtime (${(e as { name: string }).name})`; + default: + return `an operation scriptc does not follow (${e.kind})`; + } +} diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index ccb02660b..e62055818 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -8,6 +8,7 @@ import { } from "../diagnostics/diagnostic.js"; import type { IrModule, SrcLoc } from "../ir/ir.js"; import { validateModule } from "../ir/validate.js"; +import { checkContracts } from "../contracts/check.js"; import { fenceSpeculativeWasiFunctions, moduleWasiUnavailableSurface, @@ -89,6 +90,12 @@ export function prepareExecutableModule( } } sourceTexts = fe.sourceTexts(); + if (lowered.contracts) { + const checked = checkContracts(lowered.module, lowered.contracts, sourceTexts); + timing("contracts"); + if (checked.diagnostics.length > 0) return fail(checked.diagnostics); + if (checked.contracts) lowered.module.contracts = checked.contracts; + } } finally { fe.dispose(); timing("frontend-dispose"); diff --git a/packages/compiler/src/frontend/contract-types.ts b/packages/compiler/src/frontend/contract-types.ts new file mode 100644 index 000000000..7c622fdee --- /dev/null +++ b/packages/compiler/src/frontend/contract-types.ts @@ -0,0 +1,203 @@ +/* Contract types (@scriptc/types): written annotations scriptc checks as + * extra contracts. The package is types only (`export type` aliases that + * TypeScript erases), so Node.js never loads it; scriptc recognizes the + * aliases by import provenance, like threads-import.ts recognizes the + * @scriptc/threads exports. TypeScript drops alias identity from inferred + * types, so only annotations the program WRITES count: parameter, return, + * variable and class field types. The IR-side checks live in + * ir/contract-check.ts. */ +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import type { SrcLoc } from "../ir/ir.js"; +import { locOf } from "./program.js"; +import * as ts from "./ts7/adapter.js"; + +export const CONTRACT_TYPES_MODULE = "@scriptc/types"; + +export type ContractMarker = "Int32" | "NoThrow" | "Immutable"; +const MARKERS: ReadonlySet = new Set(["Int32", "NoThrow", "Immutable"]); + +/** One written contract: what it promises and where the annotation is. */ +export interface ContractNote { + marker: ContractMarker; + /** The annotation (the marker's type reference). */ + loc: SrcLoc; + /** The annotated declaration's name, for messages. */ + name: string; +} + +/** Contracts keyed by the declaration they annotate. Keys are SrcLoc keys + * (contractLocKey) of the nodes the lowering stamps on the IR: function + * declarations (IrFunction.loc), binding names (IrLocal/IrGlobal + * source.loc), and classes (IrClassDef.loc) plus the field name. */ +export interface ContractAnnotations { + /** NoThrow and result markers (Int32, Immutable) by function. */ + functions: Map; + /** Parameters and variables by their name's location. */ + bindings: Map; + /** Instance fields by `${class key}\0${field name}`. */ + fields: Map; + diagnostics: ScrDiagnostic[]; + /** Number of annotations found (for timing and tests). */ + count: number; +} + +export function contractLocKey(loc: SrcLoc): string { + return `${loc.file}\0${loc.start}\0${loc.end}`; +} + +/** The marker a type reference names when its name is a named or namespace + * import binding of @scriptc/types (`import type { Int32 } from + * "@scriptc/types"`, `import type * as sc from "@scriptc/types"`). */ +function markerOf(checker: ts.TypeChecker, ref: ts.TypeReferenceNode): ContractMarker | null { + const name = ref.typeName; + 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; + 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) { + importDecl = decl.parent?.parent?.parent; + member = decl.propertyName?.text ?? decl.name.text; + } else if (ts.isNamespaceImport(decl) && ident !== name) importDecl = decl.parent?.parent; + else return null; + if ( + importDecl === undefined || + !ts.isImportDeclaration(importDecl) || + !ts.isStringLiteral(importDecl.moduleSpecifier) || + importDecl.moduleSpecifier.text !== CONTRACT_TYPES_MODULE + ) + return null; + return MARKERS.has(member) ? (member as ContractMarker) : null; +} + +function nameText(node: ts.Node | undefined): string { + if (node === undefined) return ""; + if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) return node.text; + return node.getText(); +} + +function contractDiag( + code: `SC${number}`, + loc: SrcLoc, + message: string, + hint: string, +): ScrDiagnostic { + return { code, loc, message, hint }; +} + +/** Collect every contract annotation in the program's own TypeScript + * modules. Files that do not mention the package cannot import a marker + * (re-exports are not followed), so they are skipped without a walk. */ +export function collectContractAnnotations( + checker: ts.TypeChecker, + files: readonly ts.SourceFile[], +): ContractAnnotations { + const out: ContractAnnotations = { + functions: new Map(), + bindings: new Map(), + fields: new Map(), + diagnostics: [], + count: 0, + }; + const kinds = new Set([ts.SyntaxKind.TypeReference]); + for (const sf of files) { + if (sf.isDeclarationFile || !sf.text.includes(CONTRACT_TYPES_MODULE)) continue; + ts.walkPreorder( + sf, + (node) => { + if (!ts.isTypeReferenceNode(node)) return; + const marker = markerOf(checker, node); + if (marker === null) return; + out.count++; + place(out, checker, node, marker); + }, + kinds, + ); + } + return out; +} + +function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMarker): void { + const where = + marker === "NoThrow" + ? "NoThrow wraps a function's written return type" + : marker === "Int32" + ? "Int32 is checked on parameters, variables, class instance fields and function results" + : "Immutable is checked on parameters, variables, class instance fields and function results"; + out.diagnostics.push( + contractDiag( + "SC7006", + locOf(node), + `the ${marker} contract is not checked in this position (${where})`, + marker === "NoThrow" + ? "write it as the function's return type annotation: function f(): NoThrow" + : `write ${marker} directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed`, + ), + ); +} + +function place( + out: ContractAnnotations, + checker: ts.TypeChecker, + ref: ts.TypeReferenceNode, + marker: ContractMarker, +): void { + let node: ts.Node = ref; + let parent = ref.parent; + // NoThrow / NoThrow>: the inner marker is the result + // contract of the function the NoThrow annotates. + if (marker !== "NoThrow" && parent !== undefined && ts.isTypeReferenceNode(parent)) { + const wrapper = markerOf(checker, parent); + if (wrapper === "NoThrow" && parent.typeArguments?.[0] === ref) { + node = parent; + parent = parent.parent; + } + } + const note = (name: string): ContractNote => ({ marker, loc: locOf(ref), name }); + if (parent === undefined) return unsupported(out, ref, marker); + if (ts.isFunctionLike(parent) && parent.type === node) { + if (!ts.isFunctionLikeDeclaration(parent)) return unsupported(out, ref, marker); + const key = contractLocKey(locOf(parent)); + const list = out.functions.get(key) ?? []; + list.push(note(ts.isConstructorDeclaration(parent) ? "constructor" : nameText(parent.name))); + out.functions.set(key, list); + return; + } + if (marker === "NoThrow") return unsupported(out, ref, marker); + if ( + (ts.isParameter(parent) || ts.isVariableDeclaration(parent)) && + parent.type === node && + ts.isIdentifier(parent.name) + ) { + out.bindings.set(contractLocKey(locOf(parent.name)), note(parent.name.text)); + return; + } + if (ts.isPropertyDeclaration(parent) && parent.type === node) { + const cls = parent.parent; + const isStatic = + ts.getModifiers(parent)?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true; + if ( + !isStatic && + cls !== undefined && + (ts.isClassDeclaration(cls) || ts.isClassExpression(cls)) && + (ts.isIdentifier(parent.name) || ts.isPrivateIdentifier(parent.name)) + ) { + out.fields.set( + `${contractLocKey(locOf(cls))}\0${parent.name.text}`, + note(`${nameText(cls.name)}.${parent.name.text}`), + ); + return; + } + } + unsupported(out, ref, marker); +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 184ffc8a4..6515b2b6d 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -77,6 +77,7 @@ import { ArrayOwnership } from "./runtime-optional-ownership.js"; import { StringIndexBounds } from "./string-index-bounds.js"; import { NanCodedReads } from "./nan-coded-reads.js"; import { RuntimeOptionalLocals } from "./runtime-optional-locals.js"; +import { collectContractAnnotations, type ContractAnnotations } from "../contract-types.js"; import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js"; import { UnregisteredClassTypes } from "./unregistered-class-types.js"; import type { WidthLift } from "./width-lift.js"; @@ -726,6 +727,9 @@ export interface LowerResult { /** Present iff diagnostics is empty. */ module: IrModule | null; diagnostics: ScrDiagnostic[]; + /** The program's written contract types (@scriptc/types), keyed by the + * source locations the IR carries; absent when it writes none. */ + contracts?: ContractAnnotations; /** JS statements whose compile fences DEFERRED to runtime (runtimeFence * statements in the module) — off the build, on the coverage report. */ runtimeFences: ScrDiagnostic[]; @@ -1038,6 +1042,17 @@ export function lowerToIr( const divergences = scanDivergences(resultLowerer, moduleOrder); timing("divergence-scan", { found: divergences.length }); if (divergences.length > 0) result = { ...result, divergences }; + // Contract types (@scriptc/types): written annotations, checked against + // the IR by the compile pipeline (contracts/check.ts). + const contracts = collectContractAnnotations(program.getTypeChecker(), moduleOrder); + timing("contract-annotations", { found: contracts.count }); + if (contracts.diagnostics.length > 0) + result = { + ...result, + module: null, + diagnostics: [...result.diagnostics, ...contracts.diagnostics], + }; + else if (contracts.count > 0) result = { ...result, contracts }; if (options.coverage !== true) return result; const remainder = new Lowerer(program, entry, moduleOrder, dynamic, { frontendServices: options.frontendServices, diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 3d7f263b6..4dfd8eb60 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1201,6 +1201,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return { kind: "object", className: "%Error" }; } + // Immutable (@scriptc/types) is a deep-readonly view: the value is + // still the T it maps. + const immutableSource = immutableSourceType(type, ctx); + if (immutableSource !== null) return mapType(immutableSource, ctx); if (resolveTypeParam && type.flags & ts.TypeFlags.TypeParameter) { const bound = resolveTypeParam(type); if (bound) { @@ -4534,6 +4538,42 @@ function mapBrandedPrimitiveIntersection(widened: ts.Type, ctx: TypeMapperCtx): return primitive && markerParts > 0 ? primitive : null; } +/** The declaration file of @scriptc/types (installed, pnpm-linked or the + * workspace package). */ +const CONTRACT_TYPES_FILE = /[\\/](?:@scriptc[\\/]types|packages[\\/]types)[\\/]index\.d\.ts$/; + +/** `Immutable` over a class, interface or object literal type resolves to + * a mapped type declared in @scriptc/types; TypeScript drops the alias, but + * homomorphic mapped members keep their original declarations. The source + * type is the non-generic declaration that declares every member (the most + * derived class or interface). Null for every other type. */ +function immutableSourceType(type: ts.Type, ctx: TypeMapperCtx): ts.Type | null { + if (!isMappedShape(type)) return null; + const { checker } = ctx; + const symbol = type.getSymbol(); + const decl = symbol ? checker.declarationsOf(symbol)[0] : undefined; + if (decl === undefined || !CONTRACT_TYPES_FILE.test(decl.getSourceFile().fileName)) return null; + const props = checker.getPropertiesOfType(type); + const seen = new Set(); + for (const prop of props) { + const owner = checker.declarationsOf(prop)[0]?.parent; + if (owner === undefined || seen.has(owner)) continue; + seen.add(owner); + let source: ts.Type | null = null; + if (ts.isTypeLiteralNode(owner)) source = checker.getTypeFromTypeNode(owner); + else if ( + (ts.isClassDeclaration(owner) || ts.isInterfaceDeclaration(owner)) && + owner.name !== undefined && + (owner.typeParameters?.length ?? 0) === 0 + ) { + const ownerSymbol = checker.getSymbolAtLocation(owner.name); + if (ownerSymbol) source = checker.getDeclaredTypeOfSymbol(ownerSymbol); + } + if (source !== null && checker.getPropertiesOfType(source).length === props.length) return source; + } + return null; +} + /** True for MAPPED-type results — `Partial`, `Record<"a", n>`, * whatever `Pick`/`Omit` reduce to. Their shape is computed by the checker, * not declared anywhere: the only declaration behind them is the utility diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 2baf80819..6cf59e00e 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -54,6 +54,14 @@ export class Int32Slots { readonly provenWrites = new Set(); /** Disqualified candidates and the first reason, for tests. */ readonly reasons = new Map(); + /** Slots an Int32 contract pins (`field:`, `param:`, `ret:`, `box:` keys): + * the contract check proved every write, so they are never dropped. */ + private readonly pinned = new Set(); + /** Int32 contract locals (`function\0localId`) and globals (`\0id`). */ + private readonly int32Locals = new Set(); + /** Drops the analysis attempted on pinned slots. The contract check and + * this analysis use the same range proofs, so this stays empty. */ + readonly pinViolations: string[] = []; constructor(private readonly classes: ReadonlyMap = new Map()) {} @@ -89,7 +97,8 @@ export class Int32Slots { this.fields.size === 0 && this.returns.size === 0 && this.paramFacts.size === 0 && - this.boxes.size === 0 + this.boxes.size === 0 && + this.int32Locals.size === 0 ) return {}; const result: IntegerSlotFacts = { @@ -97,6 +106,11 @@ export class Int32Slots { call: (callee) => (this.returns.has(callee) ? INT32_RANGE : null), boxed: (localId) => (this.hasBox(this.boxGroup(fnName, localId)) ? INT32_RANGE : null), }; + if (this.int32Locals.size > 0) + result.locals = (localId) => + this.int32Locals.has(`${fnName}\0${localId}`) || this.int32Locals.has(`\0${localId}`) + ? INT32_RANGE + : null; const params = this.paramFacts.get(fnName); if (params) result.params = params; return result; @@ -108,11 +122,27 @@ export class Int32Slots { /* ── construction (analyzeInt32Slots only) ── */ + pin(key: string): void { + this.pinned.add(key); + } + isPinnedField(family: string): boolean { + return this.pinned.has(`field:${family}`); + } + addInt32Local(key: string): void { + this.int32Locals.add(key); + } + private refusePinned(key: string): boolean { + if (!this.pinned.has(key)) return false; + this.pinViolations.push(key); + return true; + } + addBox(group: string, members: readonly string[]): void { this.boxes.add(group); for (const member of members) this.boxGroupOf.set(member, group); } dropBox(group: string): boolean { + if (this.refusePinned(`box:${group}`)) return false; return this.boxes.delete(group); } hasBox(group: string | null): boolean { @@ -123,6 +153,7 @@ export class Int32Slots { this.fields.add(family); } dropField(family: string): boolean { + if (this.refusePinned(`field:${family}`)) return false; return this.fields.delete(family); } hasFamily(family: string): boolean { @@ -134,6 +165,7 @@ export class Int32Slots { params.set(localId, INT32_RANGE); } dropParam(fnName: string, localId: string): boolean { + if (this.refusePinned(`param:${fnName}\0${localId}`)) return false; const params = this.paramFacts.get(fnName); if (!params?.delete(localId)) return false; if (params.size === 0) this.paramFacts.delete(fnName); @@ -146,6 +178,7 @@ export class Int32Slots { this.returns.add(fnName); } dropReturn(fnName: string): boolean { + if (this.refusePinned(`ret:${fnName}`)) return false; return this.returns.delete(fnName); } hasReturn(fnName: string): boolean { @@ -337,15 +370,12 @@ export function dynamicFieldStores( return { all: dynWritesAll, names: dynWritten }; } -export function analyzeInt32Slots(mod: IrModule): Int32Slots { +/** The f64 class field families a runtime store through a live dynamic view + * can write: the class reaches checked-dynamic code (directly or through + * another exposed type), and some dynamic store in the program can name the + * field. */ +export function dynamicallyStoredFamilies(mod: IrModule, slots: Int32Slots): Set { const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); - const slots = new Int32Slots(classes); - if (mod.lib) return slots; - const reason = (key: string, why: string): void => { - if (!slots.reasons.has(key)) slots.reasons.set(key, why); - }; - - /* ── candidate fields ── */ const children = new Map(); for (const cls of classes.values()) if (cls.base) { @@ -363,33 +393,6 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { } return out; }; - const genericFamilies = new Set(); - for (const cls of classes.values()) - if (cls.genericOf !== undefined) { - genericFamilies.add(cls.name); - genericFamilies.add(cls.genericOf); - } - const plainClass = (cls: IrClassDef): boolean => { - for (let c: IrClassDef | undefined = cls; c; c = c.base ? classes.get(c.base) : undefined) { - if (c.runtime || genericFamilies.has(c.name)) return false; - if (c.base && !classes.has(c.base)) return false; - } - return true; - }; - const dropFamily = (family: string, why: string): void => { - reason(`field:${family}`, why); - slots.dropField(family); - }; - for (const cls of classes.values()) - for (const field of cls.fields) { - if (field.type.kind !== "f64" || field.name.startsWith("%")) continue; - const family = slots.family(cls.name, field.name)!; - if (plainClass(cls)) { - if (!slots.reasons.has(`field:${family}`)) slots.addField(family); - } else dropFamily(family, "runtime-provided or generic class"); - } - - /* ── dynamic, reflective and runtime exposure ── */ const records = new Map((mod.records ?? []).map((r) => [r.id, r])); const unions = new Map((mod.unions ?? []).map((u) => [u.id, u])); const exposedClasses = new Set(); @@ -463,6 +466,7 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { if (s.kind === "throw") expose(s.value.type); }, }); + const families = new Set(); for (const cls of classes.values()) { if (!exposeAll && !exposedClasses.has(cls.name)) continue; for (const field of cls.fields) @@ -471,12 +475,28 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { !field.name.startsWith("%") && (dynWritesAll || dynWritten.has(field.name)) ) - dropFamily(slots.family(cls.name, field.name)!, "dynamic stores can name the field"); + families.add(slots.family(cls.name, field.name)!); } + return families; - /* ── functions: escapes, parameter and return candidates ── */ +} + +/** How functions can be reached besides direct calls: as values (closures, + * the entry, constructors behind class values: `valueEscaped`), through + * virtual dispatch (`virtualTargets`), or as vtable entries the runtime + * calls (`runtimeVirtual`: methods of runtime-rooted hierarchies). */ +export function functionCallFacts( + mod: IrModule, + classes: ReadonlyMap, + subtree: (className: string) => string[], +): { + valueEscaped: Set; + virtualTargets: Set; + runtimeVirtual: (name: string) => boolean; +} { const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); const escaped = new Set(); + const virtualTargets = new Set(); const seenObjects = new Set(); const scan = ( value: unknown, @@ -520,9 +540,9 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { walkFunction(fn, { expr: (e) => { if (e.kind !== "virtualCall") return; - for (const name of subtree(e.className)) escaped.add(`%${name}.${e.method}`); + for (const name of subtree(e.className)) virtualTargets.add(`%${name}.${e.method}`); for (let c = classes.get(e.className); c; c = c.base ? classes.get(c.base) : undefined) - escaped.add(`%${c.name}.${e.method}`); + virtualTargets.add(`%${c.name}.${e.method}`); }, }); // A class value can reach `new` through its construct thunk; one that @@ -563,8 +583,91 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { if (e.kind === "classRef" && !testedOnly.has(e)) escaped.add(`%${e.className}.constructor`); }, }); - const virtual = (name: string): boolean => + const runtimeVirtual = (name: string): boolean => runtimePrefixes.some((prefix) => name.startsWith(prefix)); + return { valueEscaped: escaped, virtualTargets, runtimeVirtual }; +} + +export function analyzeInt32Slots(mod: IrModule): Int32Slots { + const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); + const slots = new Int32Slots(classes); + if (mod.lib) return slots; + const reason = (key: string, why: string): void => { + if (!slots.reasons.has(key)) slots.reasons.set(key, why); + }; + // Int32 contracts (ir/contract-check.ts proved every write): pinned + // candidates the fixpoint below never drops, plus local facts. + const contracts = mod.contracts; + for (const family of contracts?.int32Fields ?? []) { + slots.pin(`field:${family}`); + slots.addField(family); + } + for (const key of contracts?.int32Params ?? []) { + const cut = key.indexOf("\0"); + slots.pin(`param:${key}`); + slots.addParam(key.slice(0, cut), key.slice(cut + 1)); + } + for (const fnName of contracts?.int32Returns ?? []) { + slots.pin(`ret:${fnName}`); + slots.addReturn(fnName); + } + for (const key of contracts?.int32Locals ?? []) slots.addInt32Local(key); + + /* ── candidate fields ── */ + const children = new Map(); + for (const cls of classes.values()) + if (cls.base) { + const list = children.get(cls.base); + if (list) list.push(cls.name); + else children.set(cls.base, [cls.name]); + } + const subtree = (name: string): string[] => { + const out: string[] = []; + const stack = [name]; + while (stack.length) { + const next = stack.pop()!; + out.push(next); + for (const child of children.get(next) ?? []) stack.push(child); + } + return out; + }; + const genericFamilies = new Set(); + for (const cls of classes.values()) + if (cls.genericOf !== undefined) { + genericFamilies.add(cls.name); + genericFamilies.add(cls.genericOf); + } + const plainClass = (cls: IrClassDef): boolean => { + for (let c: IrClassDef | undefined = cls; c; c = c.base ? classes.get(c.base) : undefined) { + if (c.runtime || genericFamilies.has(c.name)) return false; + if (c.base && !classes.has(c.base)) return false; + } + return true; + }; + const dropFamily = (family: string, why: string): void => { + reason(`field:${family}`, why); + slots.dropField(family); + }; + for (const cls of classes.values()) + for (const field of cls.fields) { + if (field.type.kind !== "f64" || field.name.startsWith("%")) continue; + const family = slots.family(cls.name, field.name)!; + if (plainClass(cls)) { + if (!slots.reasons.has(`field:${family}`)) slots.addField(family); + } else dropFamily(family, "runtime-provided or generic class"); + } + + /* ── dynamic, reflective and runtime exposure ── */ + for (const family of dynamicallyStoredFamilies(mod, slots)) { + if (slots.isPinnedField(family)) continue; + dropFamily(family, "dynamic stores can name the field"); + } + + /* ── functions: escapes, parameter and return candidates ── */ + const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); + const calls = functionCallFacts(mod, classes, subtree); + const escaped = new Set([...calls.valueEscaped, ...calls.virtualTargets]); + const virtual = calls.runtimeVirtual; const paramKey = (fnName: string, localId: string): string => `param:${fnName}\0${localId}`; const dropParam = (fnName: string, localId: string, why: string): boolean => { diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts index 1d4c5e2ea..0680069c4 100644 --- a/packages/compiler/src/ir/integer-ranges.ts +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -13,6 +13,7 @@ export type IntegerRanges = ReadonlyMap; const SIGNED: IntegerRange = { min: -2147483648, max: 2147483647 }; const UNSIGNED: IntegerRange = { min: 0, max: 4294967295 }; const EXACT_LIMIT = 2 ** 53; +const STRING_LENGTH_LIMIT = 2 ** 30; type Facts = Map; function union(left: IntegerRange, right: IntegerRange): IntegerRange { @@ -72,6 +73,9 @@ export interface IntegerSlotFacts { field?: (className: string, field: string) => IntegerRange | null; call?: (callee: string) => IntegerRange | null; boxed?: (localId: string) => IntegerRange | null; + /** Locals and globals an Int32 contract covers (ir/contract-check.ts): + * every write anywhere is proven int32, so every read is one. */ + locals?: (localId: string) => IntegerRange | null; } /** Structured local dataflow. Calls cannot write uncaptured local slots, @@ -97,8 +101,10 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = { const loopLocals = new Map(fn.locals.filter((l) => !captures.has(l.id)).map((l) => [l.id, l])); // A captured binding has no local dataflow: only a proof that covers // every write of the shared box anywhere applies to its reads. - const boxedRange = (localId: string): IntegerRange | null => - eligible.has(localId) ? null : (slots.boxed?.(localId) ?? null); + const boxedRange = (localId: string): IntegerRange | null => { + const boxed = eligible.has(localId) ? null : (slots.boxed?.(localId) ?? null); + return boxed ?? slots.locals?.(localId) ?? null; + }; const slotRange = (e: IrExpr): IntegerRange | null => e.type.kind !== "f64" ? null @@ -288,7 +294,11 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = { ? { min: bound.value, max: bound.value } : ranges.get(bound); if (!boundRange) return; - const previous = facts.get(ref.localId); + // An Int32 contract local holds an int32 even where the dataflow has + // no fact for it (loop headers, after unknown regions). + const previous = + facts.get(ref.localId) ?? + (eligible.has(ref.localId) ? (slots.locals?.(ref.localId) ?? undefined) : undefined); if (!previous) return; if (!truth) op = @@ -402,6 +412,17 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = { } break; } + case "strIntrinsic": { + // Node's strings hold at most 2^29 - 24 code units (V8's limit). + expr(e.receiver, facts); + for (const arg of e.args) expr(arg, facts); + if (e.type.kind === "f64") { + if (e.method === "length") range = { min: 0, max: STRING_LENGTH_LIMIT }; + else if (e.method === "indexOf") + range = { min: -1, max: STRING_LENGTH_LIMIT }; + } + break; + } case "bytesIntrinsic": { expr(e.receiver, facts); for (const arg of e.args) expr(arg, facts); @@ -559,8 +580,20 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = { if (s.cond) refine(s.cond, true, inside); body(s.body, inside); // Continue can bypass any body assignment. The update therefore - // starts from header invariants rather than the body's exit facts. - if (s.update) body([s.update], new Map(loop)); + // starts from header invariants rather than the body's exit facts, + // plus what the condition proved about bindings the body never + // writes (they still hold the values the condition tested). + if (s.update) { + const entry = new Map(loop); + if (s.cond) { + const refined = new Map(loop); + refine(s.cond, true, refined); + const written = new Set(); + for (const node of s.body) for (const id of writes(node)) written.add(id); + for (const [id, range] of refined) if (!written.has(id)) entry.set(id, range); + } + body([s.update], entry); + } havoc(facts, [s], facts); break; } diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index f7ac00459..4b687f438 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -983,6 +983,29 @@ export interface IrModule { * emissions stay conformance-identical by construction. Absent on every * executable build (the backends emit main() exactly as always). */ lib?: IrLibSection; + /** Contract types (@scriptc/types) the frontend verified against every + * write in the program (ir/contract-check.ts). Backends may rely on them + * unconditionally. Absent when the program writes no contract. */ + contracts?: IrContracts; +} + +/** Verified contract facts, keyed by IR names. */ +export interface IrContracts { + /** Int32 class field families: `${topmost declaring class}\0${field}`. */ + int32Fields?: string[]; + /** Int32 parameters: `${function}\0${localId}`. */ + int32Params?: string[]; + /** Functions whose every return is an int32. */ + int32Returns?: string[]; + /** Int32 locals (`${function}\0${localId}`, including every capture of a + * captured binding) and module globals (`\0${globalId}`). */ + int32Locals?: string[]; + /** Functions that cannot throw (NoThrow). */ + noThrow?: string[]; + /** Immutable module constants built from fresh literals: the backend + * makes their graphs immortal once initialized, and reads through them + * need no reference counting. */ + immortalGlobals?: string[]; } export type IrFfiValueParamClass = diff --git a/packages/compiler/src/library/prepare.ts b/packages/compiler/src/library/prepare.ts index fbd04bb95..88106433c 100644 --- a/packages/compiler/src/library/prepare.ts +++ b/packages/compiler/src/library/prepare.ts @@ -55,6 +55,7 @@ import { } from "../ir/ir.js"; import { moduleLibAsyncSurface, moduleLibNondeterministicSurface } from "./ir-policy.js"; import { validateModule } from "../ir/validate.js"; +import { checkContracts } from "../contracts/check.js"; /** The marshalling-class fit over IR types (design §4.2 + the ratified * integer plumbing classes): number is every f64-backed class, bool/string @@ -609,6 +610,12 @@ export function prepareLibrary( const fenced = evaluateLibraryFences(mod, profile); if (fenced.length > 0) return fail(fenced); mod.lib = resolved.lib; + if (lowered.contracts) { + const checked = checkContracts(mod, lowered.contracts, sourceTexts); + timing("contracts"); + if (checked.diagnostics.length > 0) return fail(checked.diagnostics); + if (checked.contracts) mod.contracts = checked.contracts; + } if (buildPlatform === "wasi") { fenceSpeculativeWasiFunctions(mod); const unavailable = moduleWasiUnavailableSurface(mod); diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts new file mode 100644 index 000000000..28d26ec01 --- /dev/null +++ b/packages/types/index.d.ts @@ -0,0 +1,41 @@ +/* scriptc contract types. + * + * Type aliases with no runtime semantics: import them with `import type`, + * which TypeScript erases, so Node.js never loads this package and behaves + * exactly as without the annotations. scriptc recognizes the aliases where + * they are written (parameter, return, field, variable and property + * annotations) and checks each one as an extra contract: the program either + * compiles with identical behavior, plus the optimization the contract + * enables, or fails with a diagnostic that explains why the contract does + * not hold. */ + +/** A number that is always a signed 32-bit integer. Every value stored into + * an `Int32` slot (initializers, assignments, compound assignments, `++` and + * `--`, arguments and returns) must be an int32 by construction: an integer + * literal in range, a bitwise result such as `x | 0`, another `Int32` value, + * a string or array length, or arithmetic whose range scriptc can bound. + * scriptc then stores the slot as a 32-bit integer. */ +export type Int32 = number; + +/** Return-type wrapper: the function never throws. scriptc proves that no + * throw, throwing call or throwing operation can escape the function, or + * reports the path that can throw. Callers then need no exception checks + * after calls to it. */ +export type NoThrow = T; + +/** A deeply read-only view of `T`. TypeScript already rejects direct writes + * through it; scriptc also checks that no write reaches the value through + * any other path (methods that write `this`, aliases, casts, dynamic code). + * A module constant initialized from a literal or constructor becomes + * immortal data: never reference counted or collected. */ +export type Immutable = T extends (...args: never[]) => unknown + ? T + : T extends ReadonlyMap + ? ReadonlyMap, Immutable> + : T extends ReadonlySet + ? ReadonlySet> + : T extends readonly unknown[] + ? { readonly [I in keyof T]: Immutable } + : T extends object + ? { readonly [K in keyof T]: Immutable } + : T; diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 000000000..b38d72760 --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,26 @@ +{ + "name": "@scriptc/types", + "version": "0.2.8", + "description": "Contract types for scriptc: type-only annotations (Int32, NoThrow, Immutable) that scriptc checks and optimizes, with no runtime semantics", + "license": "Apache-2.0", + "homepage": "https://scriptc.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel-labs/scriptc.git", + "directory": "packages/types" + }, + "type": "module", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts" + } + }, + "files": [ + "index.d.ts", + "README.md" + ], + "engines": { + "node": ">=24.0.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1de030dbc..1f9f0cf64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@scriptc/threads': specifier: workspace:* version: link:packages/threads + '@scriptc/types': + specifier: workspace:* + version: link:packages/types '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -201,6 +204,8 @@ importers: packages/threads: {} + packages/types: {} + tests/fixtures/effect4: dependencies: '@effect/platform-node': diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs index 8aba38c6b..6a3ad27d5 100644 --- a/scripts/sync-versions.mjs +++ b/scripts/sync-versions.mjs @@ -31,6 +31,7 @@ for (const pkg of [ "runtime-android-arm64", "compiler", "threads", + "types", "cli-darwin-arm64", "cli-darwin-x64", "cli-linux-x64-gnu", diff --git a/tests/corpus/4630-contract-int32.ts b/tests/corpus/4630-contract-int32.ts new file mode 100644 index 000000000..39f6cc9fe --- /dev/null +++ b/tests/corpus/4630-contract-int32.ts @@ -0,0 +1,88 @@ +// Int32 contract types (@scriptc/types): every value stored into an Int32 +// slot is an int32 by construction, so Node computes the same values and +// scriptc stores the slots as 32-bit integers. `import type` is erased: +// Node never loads the package. +import type { Int32 } from "@scriptc/types"; + +class Cursor { + pos: Int32 = 0; + end: Int32; + readonly id: Int32; + constructor(end: Int32, id: Int32) { + this.end = end; + this.id = id; + } + advance(n: Int32): void { + this.pos = (this.pos + n) | 0; + } + atEnd(): boolean { + return this.pos >= this.end; + } +} + +class Shape { + area(scale: Int32): Int32 { + return scale & 0; + } +} + +class Square extends Shape { + side: Int32; + constructor(side: Int32) { + super(); + this.side = side; + } + override area(scale: Int32): Int32 { + return Math.imul(Math.imul(this.side, this.side), scale); + } +} + +function fnv1a(text: string): Int32 { + let h: Int32 = 0x811c9dc5 | 0; + for (let i: Int32 = 0; i < text.length; i++) { + h = Math.imul(h ^ text.charCodeAt(i), 16777619); + } + return h; +} + +function clamp(i: Int32, lo: Int32, hi: Int32): Int32 { + return i < lo ? lo : i > hi ? hi : i; +} + +let nextId: Int32 = 0; +function allocateId(): Int32 { + nextId = (nextId + 1) | 0; + return nextId; +} + +function total(values: number[]): Int32 { + let sum: Int32 = 0; + values.forEach((v) => { + sum = (sum + v) | 0; + }); + return sum; +} + +function countdown(from: Int32): Int32 { + let steps: Int32 = 0; + for (let i: Int32 = from; i > 0; i--) steps = (steps + 1) | 0; + return steps; +} + +const cursor = new Cursor(10, allocateId()); +while (!cursor.atEnd()) cursor.advance(3); +console.log("cursor", cursor.pos, cursor.end, cursor.id); + +const shapes: Shape[] = [new Shape(), new Square(7), new Square(46341)]; +for (const s of shapes) console.log("area", s.area(2)); + +console.log("fnv1a", fnv1a(""), fnv1a("scriptc"), fnv1a("contract types")); +console.log("clamp", clamp(-5, 0, 9), clamp(4, 0, 9), clamp(99, 0, 9)); +console.log("ids", allocateId(), allocateId()); +console.log("total", total([1, 2.5, 2147483647, 3])); +console.log("wrap", (2147483647 + 1) | 0, ~0, -1 >>> 28 | 0); +console.log("countdown", countdown(5), countdown(-3)); +const big = new Cursor(2147483647, -2147483648); +big.advance(2147483647); +big.advance(1); +console.log("big", big.pos, big.end, big.id, big.atEnd()); diff --git a/tests/corpus/4631-contract-nothrow.ts b/tests/corpus/4631-contract-nothrow.ts new file mode 100644 index 000000000..4a6ca30c6 --- /dev/null +++ b/tests/corpus/4631-contract-nothrow.ts @@ -0,0 +1,63 @@ +// NoThrow contract types (@scriptc/types): scriptc proves these functions +// cannot throw, so callers need no exception checks after the calls. +import type { Int32, NoThrow } from "@scriptc/types"; + +function isDigit(ch: number): NoThrow { + return ch >= 48 && ch <= 57; +} + +function isIdentifierStart(ch: number): NoThrow { + return (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch === 95 || ch === 36; +} + +function digitValue(ch: number): NoThrow { + return isDigit(ch) ? (ch - 48) | 0 : -1; +} + +class Scanner { + readonly text: string; + pos: Int32 = 0; + constructor(text: string) { + this.text = text; + } + peek(): NoThrow { + return this.pos < this.text.length ? this.text.charCodeAt(this.pos) : -1; + } + skipSpaces(): NoThrow { + while (this.peek() === 32) this.pos = (this.pos + 1) | 0; + } + scanNumber(): NoThrow { + let value: Int32 = 0; + while (isDigit(this.peek())) { + value = (Math.imul(value, 10) + digitValue(this.peek())) | 0; + this.pos = (this.pos + 1) | 0; + } + return value; + } + scanWord(): NoThrow { + const start = this.pos; + while (isIdentifierStart(this.peek()) || isDigit(this.peek())) this.pos = (this.pos + 1) | 0; + return this.text.slice(start, this.pos); + } +} + +function tokens(text: string): string[] { + const scanner = new Scanner(text); + const out: string[] = []; + for (;;) { + scanner.skipSpaces(); + const ch = scanner.peek(); + if (ch < 0) break; + if (isDigit(ch)) out.push(`num:${scanner.scanNumber()}`); + else if (isIdentifierStart(ch)) out.push(`word:${scanner.scanWord()}`); + else { + out.push(`punct:${String.fromCharCode(ch)}`); + scanner.pos = (scanner.pos + 1) | 0; + } + } + return out; +} + +console.log(tokens("let x1 = 42 + y; // 7").join(" ")); +console.log(tokens(" 2147483648 $id _x").join(" ")); +console.log(digitValue(55), digitValue(65), isIdentifierStart(36)); diff --git a/tests/corpus/4632-contract-immutable.ts b/tests/corpus/4632-contract-immutable.ts new file mode 100644 index 000000000..2eafc33c0 --- /dev/null +++ b/tests/corpus/4632-contract-immutable.ts @@ -0,0 +1,63 @@ +// Immutable contract types (@scriptc/types): no write can reach these +// values, and module constants built from fresh literals become immortal +// in scriptc (never reference counted or collected). Node sees ordinary +// objects: the annotations are erased. +import type { Immutable } from "@scriptc/types"; + +class Keyword { + readonly text: string; + readonly kind: number; + constructor(text: string, kind: number) { + this.text = text; + this.kind = kind; + } + describe(): string { + return `${this.text}#${this.kind}`; + } +} + +const KEYWORDS: Immutable> = new Map([ + ["let", new Keyword("let", 1)], + ["const", new Keyword("const", 2)], + ["function", new Keyword("function", 3)], +]); + +const CHAR_CLASSES: Immutable = [0, 0, 1, 1, 2, 2, 3, 3]; + +const LIMITS: Immutable<{ name: string; max: number; tags: string[] }> = { + name: "limits", + max: 64, + tags: ["a", "b"], +}; + +function buildTable(size: number): number[] { + const table: number[] = []; + for (let i = 0; i < size; i++) table.push((i * 7) % 5); + return table; +} + +const TABLE: Immutable = buildTable(12); + +function lookup(word: string): string { + const k = KEYWORDS.get(word); + return k === undefined ? "none" : k.describe(); +} + +function classify(codes: Immutable, i: number): number { + return i < codes.length ? codes[i]! : -1; +} + +let hits = 0; +for (const word of ["let", "var", "function", "const", "class"]) { + const result = lookup(word); + if (result !== "none") hits++; + console.log(word, result); +} +console.log("hits", hits, "size", KEYWORDS.size); +console.log("classes", classify(CHAR_CLASSES, 3), classify(CHAR_CLASSES, 9), CHAR_CLASSES.join(",")); +console.log("limits", LIMITS.name, LIMITS.max, LIMITS.tags.length, LIMITS.tags.join("|")); +console.log("table", TABLE.join(""), TABLE.indexOf(4), TABLE.includes(9)); +const copy = TABLE.slice(0, 4); +copy.push(99); +console.log("copy", copy.join(","), "original", TABLE.length); +for (const [text, keyword] of KEYWORDS) console.log("entry", text, keyword.kind); diff --git a/tests/diagnostics/contract-immutable.ts b/tests/diagnostics/contract-immutable.ts new file mode 100644 index 000000000..c6c49af45 --- /dev/null +++ b/tests/diagnostics/contract-immutable.ts @@ -0,0 +1,34 @@ +// Immutable contracts that do not hold: writes that reach the value +// through methods, aliases and casts, and stores of values that other +// references can still change. +import type { Immutable } from "@scriptc/types"; + +class Counter { + count = 0; + bump(): void { + this.count++; + } +} + +const COUNTER: Immutable = new Counter(); + +const shared = [1, 2, 3]; +const VIEW: Immutable = shared; + +const ROWS: Immutable = [[1], [2]]; + +const CONFIG: Immutable<{ name: string }> = { name: "x" }; + +function rename(config: { name: string }): void { + config.name = "y"; +} + +function grow(rows: number[][]): void { + rows[0]!.push(4); +} + +COUNTER.bump(); +rename(CONFIG); +grow(ROWS as number[][]); +shared.push(4); +console.log(COUNTER.count, VIEW.length, ROWS.length, CONFIG.name); diff --git a/tests/diagnostics/contract-int32-escapes.ts b/tests/diagnostics/contract-int32-escapes.ts new file mode 100644 index 000000000..fcbbcbfd9 --- /dev/null +++ b/tests/diagnostics/contract-int32-escapes.ts @@ -0,0 +1,17 @@ +// Int32 slots whose writes the program does not show: a parameter of a +// function used as a callback, and a field of instances that reach +// dynamic code. +import type { Int32 } from "@scriptc/types"; + +function double(x: Int32): Int32 { + return (x * 2) | 0; +} + +class Box { + size: Int32 = 0; +} + +const box = new Box(); +const loose: any = box; +loose.size = 0.5; +console.log([1.5, 2].map(double).join(","), box.size); diff --git a/tests/diagnostics/contract-int32.ts b/tests/diagnostics/contract-int32.ts new file mode 100644 index 000000000..f6e900626 --- /dev/null +++ b/tests/diagnostics/contract-int32.ts @@ -0,0 +1,36 @@ +// Int32 contracts that do not hold: each write that is not an int32 by +// construction is reported with the expression and the `| 0` fix. +import type { Int32 } from "@scriptc/types"; + +class Span { + pos: Int32 = 0; + end: Int32 = 0; + readonly id: Int32; + constructor(id: number) { + this.id = id; + } + advance(): void { + this.pos++; + } +} + +function width(span: Span): Int32 { + return span.end - span.pos; +} + +function half(x: Int32): Int32 { + return x / 2; +} + +let count: Int32 = 0; +function bump(): void { + count += 1; +} + +const sizes = [1, 2, 3]; +const len: Int32 = sizes.length; + +const span = new Span(1.5); +span.advance(); +bump(); +console.log(width(span), half(7), count, len); diff --git a/tests/diagnostics/contract-nothrow.ts b/tests/diagnostics/contract-nothrow.ts new file mode 100644 index 000000000..8a5081ff8 --- /dev/null +++ b/tests/diagnostics/contract-nothrow.ts @@ -0,0 +1,25 @@ +// NoThrow contracts that do not hold: each diagnostic names the path from +// the annotated function to the operation that can throw. +import type { NoThrow } from "@scriptc/types"; + +function parseDigit(ch: string): number { + const code = ch.charCodeAt(0) - 48; + if (code < 0 || code > 9) throw new RangeError(`not a digit: ${ch}`); + return code; +} + +function sumDigits(text: string): NoThrow { + let sum = 0; + for (let i = 0; i < text.length; i++) sum += parseDigit(text[i]!); + return sum; +} + +function depth(n: number): NoThrow { + return n <= 0 ? 0 : 1 + depth(n - 1); +} + +function apply(f: (x: number) => number, x: number): NoThrow { + return f(x); +} + +console.log(sumDigits("123"), depth(3), apply((x) => x + 1, 1), apply((x) => parseDigit(String(x)), 2)); diff --git a/tests/diagnostics/contract-positions.ts b/tests/diagnostics/contract-positions.ts new file mode 100644 index 000000000..8223e0ae0 --- /dev/null +++ b/tests/diagnostics/contract-positions.ts @@ -0,0 +1,19 @@ +// Contract types are checked where they annotate a declaration directly. +// Elsewhere they would promise something scriptc does not check, so the +// build reports them. +import type { Immutable, Int32, NoThrow } from "@scriptc/types"; + +type NodeId = Int32; + +interface Point { + x: Int32; +} + +const ids: Int32[] = [1, 2]; +const point: Point = { x: 1 }; +const id: NodeId = 3; +const callback: (x: number) => NoThrow = (x) => x; +const table: Map> = new Map(); +let last: NoThrow = 0; + +console.log(ids, point.x, id, callback(1), table.size, last); From 947c9bcf3ff5062a9649d5dd224be20cf0b3a54d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 12:24:27 -0700 Subject: [PATCH 02/13] Contract types: explain throwing paths, bind only declared functions, docs and baselines - NoThrow diagnostics name compiler-resolved Node errors (class and message) and explain parameters the frontend compiles as T | undefined because a caller passes a possibly missing value (for example xs[i]!), naming that call. - Synthetic dynamic-class helpers (%dyn.*) carry the location of the code that first needed them; they are no longer bound to annotations there. - Int32 hints only parenthesize where needed; the NoThrow hint no longer suggests try/catch, which does not discharge the contract. - Diagnostics snapshots and TypeScript preflight/order baselines for the new fixtures. - Docs: /docs/contracts and a limitations entry. --- docs/content/docs/contracts.mdx | 242 ++++++++++ docs/content/docs/limitations.mdx | 4 + docs/content/docs/meta.json | 1 + docs/src/lib/page-titles.ts | 1 + docs/test/fixtures/migration-baseline.json | 30 ++ packages/compiler/src/backend/may-throw.ts | 32 +- packages/compiler/src/contracts/check.ts | 436 +++++++++++++++--- packages/compiler/src/frontend/type-mapper.ts | 3 +- packages/compiler/src/ir/int32-slots.ts | 1 - packages/compiler/src/ir/integer-ranges.ts | 3 +- .../test/ts7/baselines/order-parity.json | 48 ++ tests/diagnostics/contract-nothrow.ts | 9 + .../__snapshots__/contract-immutable.ts.txt | 35 ++ .../contract-int32-escapes.ts.txt | 26 ++ .../__snapshots__/contract-int32.ts.txt | 53 +++ .../__snapshots__/contract-nothrow.ts.txt | 35 ++ .../__snapshots__/contract-positions.ts.txt | 53 +++ 17 files changed, 936 insertions(+), 76 deletions(-) create mode 100644 docs/content/docs/contracts.mdx create mode 100644 tests/harness/__snapshots__/contract-immutable.ts.txt create mode 100644 tests/harness/__snapshots__/contract-int32-escapes.ts.txt create mode 100644 tests/harness/__snapshots__/contract-int32.ts.txt create mode 100644 tests/harness/__snapshots__/contract-nothrow.ts.txt create mode 100644 tests/harness/__snapshots__/contract-positions.ts.txt diff --git a/docs/content/docs/contracts.mdx b/docs/content/docs/contracts.mdx new file mode 100644 index 000000000..bb91e341e --- /dev/null +++ b/docs/content/docs/contracts.mdx @@ -0,0 +1,242 @@ +--- +title: "Contract Types" +--- + +Contract types are type aliases that mean nothing to TypeScript or Node.js but are promises scriptc checks. You import them from `@scriptc/types` with `import type`, which TypeScript erases, so Node.js never loads the package and runs the program exactly as without the annotations. scriptc proves each contract or rejects the program with a diagnostic that explains why the contract does not hold. A program that compiles behaves exactly as under Node.js; the contract only adds an optimization that scriptc can now rely on. + + + + + + + + + + + + + + + + + + + + + + + + + + +
ContractPromiseWhat scriptc does with it
NoThrow<T>The function never throws.Callers need no exception check after the call.
Int32Every value stored is a signed 32-bit integer by construction.The variable, field, parameter or result is stored as a 32-bit integer.
Immutable<T>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.
+ +Add `@scriptc/types` to your project's development dependencies. It contains only type declarations. + +## Example + +```ts:main.ts +import type { Immutable, Int32, NoThrow } from "@scriptc/types"; + +const KEYWORDS: Immutable> = new Map([ + ["const", 1], + ["let", 2], + ["function", 3], +]); + +class Token { + pos: Int32; + end: Int32; + kind: number; + constructor(pos: Int32, end: Int32, kind: number) { + this.pos = pos; + this.end = end; + this.kind = kind; + } +} + +function isLetter(ch: number): NoThrow { + return (ch >= 97 && ch <= 122) || (ch >= 65 && ch <= 90); +} + +function scan(text: string): Token[] { + const tokens: Token[] = []; + let pos: Int32 = 0; + while (pos < text.length) { + if (!isLetter(text.charCodeAt(pos))) { + pos = (pos + 1) | 0; + continue; + } + let end: Int32 = pos; + while (end < text.length && isLetter(text.charCodeAt(end))) end = (end + 1) | 0; + tokens.push(new Token(pos, end, KEYWORDS.get(text.slice(pos, end)) ?? 0)); + pos = end; + } + return tokens; +} + +for (const t of scan("const answer = 42; function f() {}")) console.log(t.pos, t.end, t.kind); +``` + +Node.js 24 runs the program directly: + +```console +$ node main.ts +0 5 1 +6 12 0 +19 27 3 +28 29 0 +``` + +The scriptc executable prints the same output. `KEYWORDS` is immortal, `Token.pos`, `Token.end` and the scanner's positions are 32-bit integers, and the calls to `isLetter` carry no exception checks: + +```console +$ scriptc build main.ts -o scan >/dev/null +$ ./scan +0 5 1 +6 12 0 +19 27 3 +28 29 0 +``` + +## Where contracts apply + +scriptc recognizes a contract where it is written as the annotation of a declaration, and only when the name is imported from `@scriptc/types` (a named import, possibly renamed, or a namespace import such as `sc.Int32`). TypeScript does not keep alias names in inferred types, so a contract never spreads by inference: write it where the value is declared. + +
+
NoThrow<T>
+
The written return type of a function, method or arrow function. NoThrow<Int32> and NoThrow<Immutable<T>> combine the result contracts.
+
Int32
+
Parameters, variables, class instance fields and function results whose type is a plain number.
+
Immutable<T>
+
Parameters, variables, class instance fields and function results.
+
+ +A contract anywhere else (a type alias, an interface member, an element or type argument such as `Int32[]`, a function type) would promise something scriptc does not check, so the build reports it with SC7006. + +## NoThrow + +A `NoThrow` function must not contain a path to a throwing operation: a `throw`, a call to a function that may throw, a call through a function value that may hold such a function, an operation that throws in Node.js (such as reading a property of `undefined`), or recursion, whose stack guard can throw `RangeError`. scriptc already computes this for every function to omit exception checks; the contract turns the result into a guarantee and reports the first throwing path when it does not hold. + +In this version a surrounding `try`/`catch` does not discharge the contract, and `async` functions and generators cannot be `NoThrow`. + +## Int32 + +Each value stored into an `Int32` slot (initializers, assignments, compound assignments, `++` and `--`, arguments and returns) must be an int32 in Node.js semantics, so storing it as a 32-bit integer cannot change it: + +- an integer literal in range; +- the result of `|`, `&`, `^`, `~`, `<<`, `>>` or `Math.imul`; +- another `Int32` value; +- arithmetic whose range scriptc can bound, such as `i + 1` under a loop condition `i < text.length`. + +`x >>> 0` is a uint32 and is not accepted. Anything else needs an explicit `| 0`, which makes Node.js compute the same 32-bit value. Reads keep JavaScript semantics: `a + b` of two `Int32` values is an ordinary number that may exceed the int32 range. + +scriptc refuses an `Int32` slot whose writes it cannot all see (SC7003): a parameter of a function used as a value, or a field of a class whose instances reach `any` or dynamic code. + +## Immutable + +`Immutable` is a deep read-only view, so TypeScript already rejects direct writes such as `KEYWORDS.set(...)`. scriptc also checks the writes TypeScript cannot see: + +- a method that writes `this`, called on the value; +- a function that writes a parameter, given the value; +- a cast such as `value as number[]`, an `any` alias, `Object.assign`, or index-signature writes; +- a value stored into an `Immutable` slot that another reference can still change. + +A value stored into an `Immutable` slot must be fresh: a literal, a `new` expression whose constructor keeps `this` to itself, or a function that returns what it builds. Values that reach dynamic code, or functions scriptc cannot see, are refused. + +## Diagnostics + +Each failed contract names the declaration and the reason: + +```ts:reject.ts +import type { Immutable, Int32, NoThrow } from "@scriptc/types"; + +const LIMITS: Immutable = [10, 20]; + +function widen(limits: number[]): void { + limits.push(30); +} + +function parseDigit(ch: number): number { + if (ch < 48 || ch > 57) throw new RangeError("not a digit"); + return ch - 48; +} + +function digitAt(text: string, i: number): NoThrow { + return parseDigit(text.charCodeAt(i)); +} + +function middle(start: Int32, end: Int32): Int32 { + return (start + end) / 2; +} + +widen(LIMITS as number[]); +console.log(digitAt("7", 0), middle(2, 5), LIMITS.length); +``` + +```console +$ scriptc build reject.ts +/tmp/app/reject.ts:6:3 - error SC7004: 'limits.push(30)' writes to a value that may be Immutable ('limits' may be an Immutable value passed to 'widen' at reject.ts:22:1) + + 5 | function widen(limits: number[]): void { + 6 | limits.push(30); + | ^~~~~~~~~~~~~~~ + 7 | } + + hint: Immutable values cannot be written through any reference; copy the value before changing it + +/tmp/app/reject.ts:14:44 - error SC7001: 'digitAt' is declared NoThrow but may throw: calls 'parseDigit' at reject.ts:15:10 → throw statement at reject.ts:10:27 + + 13 | + 14 | function digitAt(text: string, i: number): NoThrow { + | ^~~~~~~~~~~~~~~ + 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 + +/tmp/app/reject.ts:19:10 - error SC7002: the value returned from 'middle' (Int32) is not provably an int32: '(start + end) / 2' + + 18 | function middle(start: Int32, end: Int32): Int32 { + 19 | return (start + end) / 2; + | ^~~~~~~~~~~~~~~~~ + 20 | } + + hint: make the int32 conversion explicit, for example '((start + end) / 2) | 0', so Node computes the same value + +3 errors. +``` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeMeaning
SC7001A NoThrow function may throw; the message shows the path to the throwing operation.
SC7002A value stored into an Int32 slot is not provably an int32; the hint shows the | 0 form.
SC7003An Int32 slot has writes scriptc cannot see.
SC7004A write can reach an Immutable value, or a stored value is not fresh.
SC7005An Immutable value escapes to code scriptc cannot check.
SC7006A contract is written where scriptc does not check it.
diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index cf354b971..c2860ee27 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -128,6 +128,10 @@ scriptc checks programs against the `es2025` standard library and its own ambien A program that passes its project's TypeScript check may still use unsupported operations. A second preflight using the project's declarations distinguishes those sites from ordinary type errors and reports scriptc diagnostics with rewrite hints. Declared APIs without a compiler implementation, such as unsupported console members, produce `SC2020`. +### Contract types + +[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result; 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 With `--npm-static=three`, native and WASI builds support published package imports and tested CPU workflows for math, bounds, procedural geometry, curves, shapes, extrusion, text geometry, scene graphs, cameras, lights, materials, textures, and render-target data. Mesh workflows include instancing, batching, morph targets, skeletal deformation, LOD, and sprites. Raycasting includes mesh intersections, sorted results, recursive traversal, layers, distance limits, camera rays, lines, and points. diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index e756322c1..a92346155 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -10,6 +10,7 @@ "ffi", "native-objects", "threads", + "contracts", "wasm", "platforms", "---Reference---", diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts index 3f36f89b2..1b240d4d3 100644 --- a/docs/src/lib/page-titles.ts +++ b/docs/src/lib/page-titles.ts @@ -8,6 +8,7 @@ export const PAGE_TITLES: Record = { ffi: "Native FFI", "native-objects": "Native Program Objects", threads: "Sharing Data Between Workers", + contracts: "Contract Types", wasm: "WebAssembly Modules", platforms: "Platform Support", compatibility: "Node.js 24 Compatibility", diff --git a/docs/test/fixtures/migration-baseline.json b/docs/test/fixtures/migration-baseline.json index dfac93ebe..f938545d4 100644 --- a/docs/test/fixtures/migration-baseline.json +++ b/docs/test/fixtures/migration-baseline.json @@ -227,6 +227,36 @@ } ] }, + { + "href": "/docs/contracts", + "title": "Contract Types", + "headings": [ + { + "text": "Example", + "id": "example" + }, + { + "text": "Where contracts apply", + "id": "where-contracts-apply" + }, + { + "text": "NoThrow", + "id": "nothrow" + }, + { + "text": "Int32", + "id": "int32" + }, + { + "text": "Immutable", + "id": "immutable" + }, + { + "text": "Diagnostics", + "id": "diagnostics" + } + ] + }, { "legacy": "/wasm", "href": "/docs/wasm", diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 65da6777f..8223e597d 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -329,7 +329,8 @@ export function computeMayThrow( break; } if (explain) { - if (!threw && f.throws) f.seed = { kind: "seed", what: describeThrowingNode(rec), loc: rec.loc }; + if (!threw && f.throws) + f.seed = { kind: "seed", what: describeThrowingNode(rec), loc: rec.loc }; for (let i = callees; i < f.callees.length; i++) if (!f.calleeLocs!.has(f.callees[i]!)) f.calleeLocs!.set(f.callees[i]!, rec.loc); if (rec.kind === "newValue" && !f.calleeLocs!.has("\0classValue")) @@ -433,6 +434,18 @@ export function computeMayThrow( }; } +/** Error constructors by runtime error kind (SCR_ERR_* in scr_runtime.h). */ +const ERROR_KIND_NAMES: readonly string[] = [ + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "DOMException", + "ReferenceError", + "EvalError", + "URIError", +]; + /** A short description of a node that seeds the may-throw fixpoint. */ function describeThrowingNode(rec: IrExpr | IrStmt): string { const detail = rec as { method?: string; fn?: string; name?: string }; @@ -466,8 +479,23 @@ function describeThrowingNode(rec: IrExpr | IrStmt): string { case "yieldExpr": case "genResume": return "generator resumption"; - case "libCall": + case "libCall": { + // Compiler-resolved Node throws: name the error and its message. + if (rec.fn === "error.nodeThrow" || rec.fn === "error.new") { + const nodeThrow = rec.fn === "error.nodeThrow"; + const kind = rec.args[0]; + const message = nodeThrow ? rec.args[2] : rec.args[0]; + const name = nodeThrow + ? kind?.kind === "numLit" + ? (ERROR_KIND_NAMES[kind.value] ?? "Error") + : "Error" + : rec.type.kind === "object" + ? rec.type.className.replace(/^%/, "") + : "Error"; + return message?.kind === "strLit" ? `throws ${name}: ${message.value}` : `throws ${name}`; + } return `library call ${detail.fn ?? ""}`.trimEnd(); + } case "intrinsic": return `intrinsic ${detail.name ?? ""}`.trimEnd(); case "arraySetLength": diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts index f09324864..e63a885e5 100644 --- a/packages/compiler/src/contracts/check.ts +++ b/packages/compiler/src/contracts/check.ts @@ -102,9 +102,12 @@ class ContractChecker { this.positions = new SourceLocations(sources); for (const fn of mod.functions) { this.functions.set(fn.name, fn); - push(this.fnByLoc, contractLocKey(fn.loc), fn); + // Synthetic dynamic-class helpers (%dyn.*) carry the location of the + // code that first needed them, which is not their declaration. + if (!fn.name.startsWith("%dyn.")) push(this.fnByLoc, contractLocKey(fn.loc), fn); for (const local of fn.locals) - if (local.source) push(this.localsByLoc, contractLocKey(local.source.loc), { fn, localId: local.id }); + if (local.source) + push(this.localsByLoc, contractLocKey(local.source.loc), { fn, localId: local.id }); } for (const g of mod.globals ?? []) if (g.source) push(this.globalsByLoc, contractLocKey(g.source.loc), g.id); @@ -149,6 +152,63 @@ class ContractChecker { return fnName.replace(/^%/, ""); } + /** A short TypeScript-like spelling of an IR type, for messages. */ + typeText(t: IrType): string { + switch (t.kind) { + case "f64": + return "number"; + case "bool": + return "boolean"; + case "undefinedT": + return "undefined"; + case "nullT": + return "null"; + case "object": + return this.classes.get(t.className)?.jsName ?? t.className.replace(/^%(m\d+\.)?/, ""); + case "union": { + const def = this.mod.unions?.find((u) => u.id === t.unionId); + return def ? def.arms.map((a) => this.typeText(a)).join(" | ") : "a union"; + } + default: + return t.kind; + } + } + + /** Why a throwing parameter access exists although the source type looks + * safe: the frontend compiles a parameter as `T | undefined` (or null) + * when a caller can pass a missing value, typically a non-null asserted + * element read `xs[i]!`, so the TypeError Node raises inside the callee + * happens there too. Names the parameter and the first such call. */ + private widenedParams(fnName: string | undefined): string { + const fn = fnName === undefined ? undefined : this.functions.get(fnName); + if (fn === undefined) return ""; + const nullable = (t: IrType): boolean => + t.kind === "undefinedT" || + t.kind === "nullT" || + (t.kind === "union" && + this.mod.unions + ?.find((u) => u.id === t.unionId) + ?.arms.some((a) => a.kind === "undefinedT" || a.kind === "nullT") === true); + const params = fn.params.filter((p) => nullable(p.type)); + if (params.length === 0) return ""; + const p = params[0]!; + const index = fn.params.indexOf(p); + let site: SrcLoc | null = null; + for (const caller of this.mod.functions) { + if (site !== null) break; + walk(caller.body, { + expr: (e) => { + if (site !== null || e.kind !== "call" || e.callee !== fn.name) return; + const arg = e.args[index]; + if (arg !== undefined && nullable(arg.type)) site = arg.loc; + }, + }); + } + const name = p.name; + const because = site !== null ? `: the argument at ${this.where(site)} can be missing` : ""; + return ` (parameter '${name}' is compiled as ${this.typeText(p.type)}${because})`; + } + /* ── binding annotations to IR ── */ bindingsOf(key: string): { fn: IrFunction | null; id: string }[] { @@ -217,7 +277,7 @@ class ContractChecker { const reason: MayThrowReason | undefined = mt.why?.get(current); if (reason === undefined) break; if (reason.kind === "seed") { - steps.push(`${reason.what} at ${this.where(reason.loc)}`); + steps.push(`${reason.what} at ${this.where(reason.loc)}${this.widenedParams(current)}`); current = undefined; } else if (reason.kind === "call") { steps.push(`calls '${this.displayName(reason.callee)}' at ${this.where(reason.loc)}`); @@ -235,7 +295,7 @@ class ContractChecker { "SC7001", note.loc, `'${note.name}' is declared NoThrow but may throw: ${steps.join(" → ")}`, - "handle the error inside the function (try/catch around the throwing call), or remove NoThrow; a throw inside try/catch still counts in this version", + "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", ); } this.record("noThrow", proven); @@ -254,8 +314,14 @@ function int32Rewrite(text: string): string { const compound = /^([^=]+?)\s*(\*\*|>>>|<<|>>|[-+*/%&|^])=\s*(.+)$/.exec(text); if (compound) return `${compound[1]} = (${compound[1]} ${compound[2]} ${compound[3]}) | 0`; const assignment = /^([^=]+?)\s*=\s*([^=].*)$/.exec(text); - if (assignment) return `${assignment[1]} = (${assignment[2]}) | 0`; - return `(${text}) | 0`; + if (assignment) return `${assignment[1]} = ${orZero(assignment[2]!)}`; + return orZero(text); +} + +/** `text | 0`, parenthesized unless `text` binds tighter than `|` for sure + * (a name, member chain, literal or call without nested parentheses). */ +function orZero(text: string): string { + return /^[\w$.]+(\([^()]*\))?$/.test(text) ? `${text} | 0` : `(${text}) | 0`; } function push(map: Map, key: K, value: V): void { @@ -290,7 +356,12 @@ class Int32Contracts { } private refuse(note: ContractNote, why: string, hint: string): void { - this.c.report("SC7003", note.loc, `the Int32 contract on '${note.name}' cannot be checked: ${why}`, hint); + this.c.report( + "SC7003", + note.loc, + `the Int32 contract on '${note.name}' cannot be checked: ${why}`, + hint, + ); } private bind(): boolean { @@ -300,11 +371,19 @@ class Int32Contracts { if (note.marker !== "Int32") continue; for (const fn of c.functionsAt(key)) { if (fn.async === true || fn.generator !== undefined) { - this.refuse(note, "async functions and generators return promises or iterators", "annotate the awaited value where it is stored instead"); + this.refuse( + note, + "async functions and generators return promises or iterators", + "annotate the awaited value where it is stored instead", + ); continue; } if (fn.returnType.kind !== "f64") { - this.refuse(note, "the result is not a plain number", "write Int32 only on a number result"); + this.refuse( + note, + "the result is not a plain number", + "write Int32 only on a number result", + ); continue; } this.returns.set(fn.name, note); @@ -317,7 +396,11 @@ class Int32Contracts { ? b.fn.locals.find((l) => l.id === b.id)?.type : c.mod.globals?.find((g) => g.id === b.id)?.type; if (type?.kind !== "f64") { - this.refuse(note, "the binding is not a plain number (optional and union types are not int32 slots)", "declare it as a required Int32"); + this.refuse( + note, + "the binding is not a plain number (optional and union types are not int32 slots)", + "declare it as a required Int32", + ); continue; } if (b.fn === null) { @@ -325,7 +408,8 @@ class Int32Contracts { continue; } this.locals.set(`${b.fn.name}\0${b.id}`, note); - if (b.fn.params.some((p) => p.localId === b.id)) this.params.set(`${b.fn.name}\0${b.id}`, note); + if (b.fn.params.some((p) => p.localId === b.id)) + this.params.set(`${b.fn.name}\0${b.id}`, note); } } for (const [key, note] of c.notes.fields) { @@ -335,11 +419,19 @@ class Int32Contracts { for (const cls of c.classesAt(key.slice(0, cut))) { const def = cls.fields.find((f) => f.name === field); if (!def) { - this.refuse(note, `the compiled class has no field '${field}'`, "annotate an ordinary instance field"); + this.refuse( + note, + `the compiled class has no field '${field}'`, + "annotate an ordinary instance field", + ); continue; } if (def.type.kind !== "f64") { - this.refuse(note, "the field is not a plain number", "declare the field as a required Int32"); + this.refuse( + note, + "the field is not a plain number", + "declare the field as a required Int32", + ); continue; } const family = this.slots.family(cls.name, field); @@ -353,7 +445,7 @@ class Int32Contracts { private propagateCaptures(): void { const c = this.c; - for (let changed = true; changed; ) { + for (let changed = true; changed;) { changed = false; for (const fn of c.mod.functions) walk(fn.body, { @@ -389,12 +481,26 @@ class Int32Contracts { for (const [family, note] of this.fields) { const owner = family.slice(0, family.indexOf("\0")); let generic = false; - for (let cls = c.classes.get(owner); cls; cls = cls.base ? c.classes.get(cls.base) : undefined) - if (cls.runtime === true || cls.genericOf !== undefined || (cls.base && !c.classes.has(cls.base))) generic = true; + for ( + let cls = c.classes.get(owner); + cls; + cls = cls.base ? c.classes.get(cls.base) : undefined + ) + if ( + cls.runtime === true || + cls.genericOf !== undefined || + (cls.base && !c.classes.has(cls.base)) + ) + generic = true; for (const cls of c.classes.values()) - if (cls.genericOf !== undefined && (cls.genericOf === owner || cls.name === owner)) generic = true; + if (cls.genericOf !== undefined && (cls.genericOf === owner || cls.name === owner)) + generic = true; if (generic) - this.refuse(note, "the class is generic or extends a runtime-provided class", "move the field to a non-generic class"); + this.refuse( + note, + "the class is generic or extends a runtime-provided class", + "move the field to a non-generic class", + ); else if (dynamic.has(family)) this.refuse( note, @@ -405,8 +511,7 @@ class Int32Contracts { } if (this.params.size > 0) { const children = new Map(); - for (const cls of c.classes.values()) - if (cls.base) push(children, cls.base, cls.name); + for (const cls of c.classes.values()) if (cls.base) push(children, cls.base, cls.name); const subtree = (name: string): string[] => { const out: string[] = []; const stack = [name]; @@ -427,7 +532,11 @@ class Int32Contracts { "wrap it in a function that takes a number and passes `x | 0`", ); else if (calls.runtimeVirtual(fnName)) - this.refuse(note, "the runtime calls this method", "remove Int32 from runtime-dispatched methods"); + this.refuse( + note, + "the runtime calls this method", + "remove Int32 from runtime-dispatched methods", + ); } } } @@ -445,7 +554,9 @@ class Int32Contracts { call: (callee) => (this.returns.has(callee) ? INT32_RANGE : null), boxed: (localId) => (this.locals.has(`${fnName}\0${localId}`) ? INT32_RANGE : null), locals: (localId) => - this.locals.has(`${fnName}\0${localId}`) || this.locals.has(`\0${localId}`) ? INT32_RANGE : null, + this.locals.has(`${fnName}\0${localId}`) || this.locals.has(`\0${localId}`) + ? INT32_RANGE + : null, }; } @@ -495,18 +606,31 @@ class Int32Contracts { if (s.kind === "varDecl" || s.kind === "assign") { const note = localNote(s.localId); const value = s.kind === "varDecl" ? s.init : s.value; - if (note && value && !int32(value)) fail(note, value, value.loc, "the value stored into"); - } else if (s.kind === "forOf" || (s.kind === "tryCatch" && s.catchLocalId !== undefined)) { + if (note && value && !int32(value)) + fail(note, value, value.loc, "the value stored into"); + } else if ( + s.kind === "forOf" || + (s.kind === "tryCatch" && s.catchLocalId !== undefined) + ) { const id = s.kind === "forOf" ? s.localId : s.catchLocalId!; const note = localNote(id); - if (note) fail(note, null, s.loc, s.kind === "forOf" ? "a for-of loop element stored into" : "a caught value stored into"); + if (note) + fail( + note, + null, + s.loc, + s.kind === "forOf" + ? "a for-of loop element stored into" + : "a caught value stored into", + ); } else if (s.kind === "fieldSet") { const family = this.slots.family(s.className, s.field); const note = family !== null ? this.fields.get(family) : undefined; if (note && !int32(s.value)) fail(note, s.value, s.value.loc, "the value stored into"); } else if (s.kind === "return") { const note = this.returns.get(fn.name); - if (note && (!s.value || !int32(s.value))) fail(note, s.value, s.value?.loc ?? s.loc, "the value returned from"); + if (note && (!s.value || !int32(s.value))) + fail(note, s.value, s.value?.loc ?? s.loc, "the value returned from"); } }, expr: (e) => { @@ -549,19 +673,100 @@ class Int32Contracts { /** Read-only and searching array intrinsics: the receiver is not written, * and arguments are compared or copied out, never stored. */ -const ARR_READS = new Set(["length", "getNumber", "indexEq", "indexOf", "includes", "nextPresent", "join", "slice", "flatCopy", "flatOne", "toReversed", "toSpliced", "toSortedPrimitive", "concatSpread"]); -const MAP_READS = new Set(["get", "has", "size", "iterCount", "iterLive", "iterKey", "iterValue", "iterEnter", "iterExit", "clone", "keySet", "valueSet"]); -const SET_READS = new Set(["has", "size", "iterCount", "iterLive", "iterKey", "iterEnter", "iterExit", "clone"]); +const ARR_READS = new Set([ + "length", + "getNumber", + "indexEq", + "indexOf", + "includes", + "nextPresent", + "join", + "slice", + "flatCopy", + "flatOne", + "toReversed", + "toSpliced", + "toSortedPrimitive", + "concatSpread", +]); +const MAP_READS = new Set([ + "get", + "has", + "size", + "iterCount", + "iterLive", + "iterKey", + "iterValue", + "iterEnter", + "iterExit", + "clone", + "keySet", + "valueSet", +]); +const SET_READS = new Set([ + "has", + "size", + "iterCount", + "iterLive", + "iterKey", + "iterEnter", + "iterExit", + "clone", +]); /** Element reads whose result can be part of the receiver's graph. */ const MAP_ELEMENT_READS = new Set(["get", "iterKey", "iterValue"]); const SET_ELEMENT_READS = new Set(["iterKey"]); /** Copies: a fresh container holding the receiver's elements. */ -const ARR_COPIES = new Set(["slice", "flatCopy", "flatOne", "toReversed", "toSpliced", "concatSpread", "toSortedPrimitive"]); +const ARR_COPIES = new Set([ + "slice", + "flatCopy", + "flatOne", + "toReversed", + "toSpliced", + "concatSpread", + "toSortedPrimitive", +]); const COLLECTION_COPIES = new Set(["clone", "keySet", "valueSet"]); /** Expression kinds whose result is the same value as their child. */ -const PASS_THROUGH = new Set(["upcast", "downcast", "unionWrap", "unionNarrow", "ternary", "logical", "nullish", "orDefault", "seqExpr", "chainRecv", "optChain", "assignExpr"]); +const PASS_THROUGH = new Set([ + "upcast", + "downcast", + "unionWrap", + "unionNarrow", + "ternary", + "logical", + "nullish", + "orDefault", + "seqExpr", + "chainRecv", + "optChain", + "assignExpr", +]); /** Observations: the value is inspected, never stored or written. */ -const OBSERVE = new Set(["toBool", "strEq", "unionEq", "unionFuncEq", "unionIsTag", "unionDisc", "instanceOf", "instanceOfValue", "dynScalarEq", "fieldAbsent", "recordHas", "recordOvfHas", "arrayHas", "arrayState", "jsonStringify", "toString", "strConcat", "bin", "unary", "unionKeyGet", "recordKeyGet", "recordOvfKeys"]); +const OBSERVE = new Set([ + "toBool", + "strEq", + "unionEq", + "unionFuncEq", + "unionIsTag", + "unionDisc", + "instanceOf", + "instanceOfValue", + "dynScalarEq", + "fieldAbsent", + "recordHas", + "recordOvfHas", + "arrayHas", + "arrayState", + "jsonStringify", + "toString", + "strConcat", + "bin", + "unary", + "unionKeyGet", + "recordKeyGet", + "recordOvfKeys", +]); /** Library calls that only read their arguments (console output). */ const LIB_READS = /^(console\.|inspect\.|json\.stringify)/; @@ -587,7 +792,12 @@ class ImmutableContracts { } private refuse(note: ContractNote, why: string, hint: string): void { - this.c.report("SC7005", note.loc, `the Immutable contract on '${note.name}' cannot be checked: ${why}`, hint); + this.c.report( + "SC7005", + note.loc, + `the Immutable contract on '${note.name}' cannot be checked: ${why}`, + hint, + ); } run(): void { @@ -622,7 +832,12 @@ class ImmutableContracts { const field = key.slice(cut + 1); for (const cls of c.classesAt(key.slice(0, cut))) { const family = this.slots.family(cls.name, field); - if (family === null) this.refuse(note, `the compiled class has no field '${field}'`, "annotate an ordinary instance field"); + if (family === null) + this.refuse( + note, + `the compiled class has no field '${field}'`, + "annotate an ordinary instance field", + ); else this.annotated.set(`f:${family}`, note); } } @@ -640,7 +855,7 @@ class ImmutableContracts { * only it references. */ private computeFreshFunctions(): void { const c = this.c; - for (let changed = true; changed; ) { + for (let changed = true; changed;) { changed = false; for (const fn of c.mod.functions) { if (this.freshFns.has(fn.name) || fn.async || fn.generator || fn.captures?.length) continue; @@ -676,28 +891,41 @@ class ImmutableContracts { } private computeFreshLocals(fn: IrFunction): Set { - const candidates = new Set(fn.locals.filter((l) => !l.boxed && isRefCounted(l.type)).map((l) => l.id)); + const candidates = new Set( + fn.locals.filter((l) => !l.boxed && isRefCounted(l.type)).map((l) => l.id), + ); for (const p of fn.params) candidates.delete(p.localId); const allowed = new Set(); walk(fn.body, { stmt: (s) => { if (s.kind === "return" && s.value?.kind === "varRef") allowed.add(s.value); - if ((s.kind === "fieldSet" || s.kind === "recordSet") && s.obj.kind === "varRef") allowed.add(s.obj); - if ((s.kind === "arraySet" || s.kind === "bytesSet") && s.arr.kind === "varRef") allowed.add(s.arr); + if ((s.kind === "fieldSet" || s.kind === "recordSet") && s.obj.kind === "varRef") + allowed.add(s.obj); + if ((s.kind === "arraySet" || s.kind === "bytesSet") && s.arr.kind === "varRef") + allowed.add(s.arr); }, expr: (e) => { - if ((e.kind === "arrIntrinsic" || e.kind === "mapIntrinsic" || e.kind === "setIntrinsic") && e.receiver.kind === "varRef") + if ( + (e.kind === "arrIntrinsic" || e.kind === "mapIntrinsic" || e.kind === "setIntrinsic") && + e.receiver.kind === "varRef" + ) allowed.add(e.receiver); - if ((e.kind === "arrayGet" || e.kind === "arrayHas") && e.arr.kind === "varRef") allowed.add(e.arr); - if ((e.kind === "fieldGet" || e.kind === "recordGet") && e.obj.kind === "varRef") allowed.add(e.obj); + if ((e.kind === "arrayGet" || e.kind === "arrayHas") && e.arr.kind === "varRef") + allowed.add(e.arr); + if ((e.kind === "fieldGet" || e.kind === "recordGet") && e.obj.kind === "varRef") + allowed.add(e.obj); }, }); - for (let changed = true; changed; ) { + for (let changed = true; changed;) { changed = false; walk(fn.body, { stmt: (s) => { const value = s.kind === "varDecl" ? s.init : s.kind === "assign" ? s.value : undefined; - if (value && candidates.has((s as { localId: string }).localId) && !this.fresh(value, candidates)) { + if ( + value && + candidates.has((s as { localId: string }).localId) && + !this.fresh(value, candidates) + ) { candidates.delete((s as { localId: string }).localId); changed = true; } @@ -708,7 +936,8 @@ class ImmutableContracts { candidates.delete(e.localId); changed = true; } - if ((e.kind === "assignExpr" || e.kind === "incDec") && candidates.delete(e.localId)) changed = true; + if ((e.kind === "assignExpr" || e.kind === "incDec") && candidates.delete(e.localId)) + changed = true; }, }); } @@ -717,7 +946,11 @@ class ImmutableContracts { /** A value nothing else references: a literal, a fresh construction or * factory result, a primitive, or a value that is already immutable. */ - private fresh(e: IrExpr, freshLocals: ReadonlySet, derived?: (e: IrExpr) => boolean): boolean { + private fresh( + e: IrExpr, + freshLocals: ReadonlySet, + derived?: (e: IrExpr) => boolean, + ): boolean { if (!isRefCounted(e.type) || e.type.kind === "string") return true; if (derived?.(e)) return true; switch (e.kind) { @@ -731,20 +964,30 @@ class ImmutableContracts { case "recordLit": return e.fields.every((f) => this.fresh(f.value, freshLocals, derived)); case "mapNew": - return (e.seed ?? []).every((p) => this.fresh(p.key, freshLocals, derived) && this.fresh(p.value, freshLocals, derived)); + return (e.seed ?? []).every( + (p) => + this.fresh(p.key, freshLocals, derived) && this.fresh(p.value, freshLocals, derived), + ); case "setNew": return e.seed === undefined || this.fresh(e.seed, freshLocals, derived); case "new": - return this.constructorKeepsThis(e.className) && e.args.every((a) => this.fresh(a, freshLocals, derived)); + return ( + this.constructorKeepsThis(e.className) && + e.args.every((a) => this.fresh(a, freshLocals, derived)) + ); case "call": - return this.freshFns.has(e.callee) && e.args.every((a) => this.fresh(a, freshLocals, derived)); + return ( + this.freshFns.has(e.callee) && e.args.every((a) => this.fresh(a, freshLocals, derived)) + ); case "upcast": case "downcast": case "unionWrap": case "unionNarrow": return this.fresh(e.value, freshLocals, derived); case "ternary": - return this.fresh(e.then, freshLocals, derived) && this.fresh(e.else_, freshLocals, derived); + return ( + this.fresh(e.then, freshLocals, derived) && this.fresh(e.else_, freshLocals, derived) + ); default: return false; } @@ -754,7 +997,11 @@ class ImmutableContracts { * fields: the new object is referenced from nowhere else. */ private constructorKeepsThis(className: string): boolean { const c = this.c; - for (let cls = c.classes.get(className); cls; cls = cls.base ? c.classes.get(cls.base) : undefined) { + for ( + let cls = c.classes.get(className); + cls; + cls = cls.base ? c.classes.get(cls.base) : undefined + ) { if (cls.runtime === true) return false; const ctor = c.functions.get(`%${cls.name}.constructor`); if (!ctor) continue; @@ -767,7 +1014,12 @@ class ImmutableContracts { }, expr: (e) => { if (e.kind === "fieldGet" && e.obj.kind === "varRef") allowed.add(e.obj); - if (e.kind === "call" && e.callee === `%${cls.base}.constructor` && e.args[0]?.kind === "varRef") allowed.add(e.args[0]); + if ( + e.kind === "call" && + e.callee === `%${cls.base}.constructor` && + e.args[0]?.kind === "varRef" + ) + allowed.add(e.args[0]); }, }); let ok = true; @@ -822,7 +1074,8 @@ class ImmutableContracts { if (!report || !derived(target)) return; const shown = c.text(target.loc); const origin = this.originOf(fn, target); - const subject = origin === "" && shown !== "" && shown !== c.text(at) ? ` ('${shown}')` : ""; + const subject = + origin === "" && shown !== "" && shown !== c.text(at) ? ` ('${shown}')` : ""; c.report( "SC7004", at, @@ -843,7 +1096,8 @@ class ImmutableContracts { const target = c.functions.get(callee); list.forEach((arg, i) => { const param = target?.params[i + offset]; - if (!target || !param) return escape(arg, arg.loc, `a call to '${c.displayName(callee)}'`); + if (!target || !param) + return escape(arg, arg.loc, `a call to '${c.displayName(callee)}'`); store( `l:${callee}\0${param.localId}`, arg, @@ -861,7 +1115,12 @@ class ImmutableContracts { store(this.localSlot(fn, s.localId), s.value); break; case "return": - if (s.value) store(`r:${fn.name}`, s.value, `returned from '${c.displayName(fn.name)}' at ${c.where(s.loc)}`); + if (s.value) + store( + `r:${fn.name}`, + s.value, + `returned from '${c.displayName(fn.name)}' at ${c.where(s.loc)}`, + ); break; case "fieldSet": mutate(s.obj, s.loc, `'${c.text(s.loc)}'`); @@ -917,15 +1176,18 @@ class ImmutableContracts { case "fieldIncDec": return mutate(e.obj, e.loc, `'${c.text(e.loc)}'`); case "arrIntrinsic": - if (ARR_COPIES.has(e.method)) this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc)); + if (ARR_COPIES.has(e.method)) + this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc)); if (!ARR_READS.has(e.method)) mutate(e.receiver, e.loc, `'${c.text(e.loc)}'`); for (const a of e.args) { - if (e.method === "indexOf" || e.method === "includes" || e.method === "indexEq") continue; + if (e.method === "indexOf" || e.method === "includes" || e.method === "indexEq") + continue; store(`e:${typeKey(e.receiver.type)}`, a); } return; case "mapIntrinsic": - if (COLLECTION_COPIES.has(e.method)) this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc)); + if (COLLECTION_COPIES.has(e.method)) + this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc)); if (!MAP_READS.has(e.method)) mutate(e.receiver, e.loc, `'${c.text(e.loc)}'`); if (e.method === "set") { if (e.args[0]) store(`mk:${typeKey(e.receiver.type)}`, e.args[0]); @@ -933,9 +1195,11 @@ class ImmutableContracts { } return; case "setIntrinsic": - if (COLLECTION_COPIES.has(e.method)) this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc)); + if (COLLECTION_COPIES.has(e.method)) + this.copyElements(e.receiver, e.type, derived(e.receiver), c.where(e.loc)); if (!SET_READS.has(e.method)) mutate(e.receiver, e.loc, `'${c.text(e.loc)}'`); - if (e.method === "add" && e.args[0]) store(`se:${typeKey(e.receiver.type)}`, e.args[0]); + if (e.method === "add" && e.args[0]) + store(`se:${typeKey(e.receiver.type)}`, e.args[0]); return; case "arrayLit": for (const x of e.elems) store(`e:${typeKey(e.type)}`, x); @@ -965,7 +1229,8 @@ class ImmutableContracts { return; } case "libCall": - if (!LIB_READS.test(e.fn)) for (const a of e.args) escape(a, a.loc, `the library call ${e.fn}`); + if (!LIB_READS.test(e.fn)) + for (const a of e.args) escape(a, a.loc, `the library call ${e.fn}`); return; case "callValue": for (const a of e.args) escape(a, a.loc, "a call through a function value"); @@ -991,7 +1256,12 @@ class ImmutableContracts { /** A copy of a container whose elements may be immutable: the copy's * element slots may hold them too (the copy itself is fresh). */ - private copyElements(receiver: IrExpr, result: IrType, receiverDerived: boolean, where: string): void { + private copyElements( + receiver: IrExpr, + result: IrType, + receiverDerived: boolean, + where: string, + ): void { const at = `copied at ${where}`; const rk = typeKey(receiver.type); const sources = [`e:${rk}`, `mk:${rk}`, `mv:${rk}`, `se:${rk}`]; @@ -1009,7 +1279,8 @@ class ImmutableContracts { for (let steps = 0; steps < 16; steps++) { if (root.kind === "fieldGet" || root.kind === "recordGet") root = root.obj; else if (root.kind === "arrayGet") root = root.arr; - else if (PASS_THROUGH.has(root.kind) && "value" in root) root = (root as { value: IrExpr }).value; + else if (PASS_THROUGH.has(root.kind) && "value" in root) + root = (root as { value: IrExpr }).value; else if (root.kind === "varRef") { // Compiler temporaries (no source binding): follow their initializer. const id = root.localId; @@ -1048,16 +1319,24 @@ class ImmutableContracts { return (slot !== null && this.mayHold.has(slot)) || derived(e.obj); } case "recordGet": - return this.mayHold.has(`s:${(e.obj.type as { shapeId?: string }).shapeId ?? ""}\0${e.field}`) || derived(e.obj); + return ( + this.mayHold.has(`s:${(e.obj.type as { shapeId?: string }).shapeId ?? ""}\0${e.field}`) || + derived(e.obj) + ); case "arrayGet": return this.mayHold.has(`e:${typeKey(e.arr.type)}`) || derived(e.arr); case "mapIntrinsic": return ( MAP_ELEMENT_READS.has(e.method) && - (this.mayHold.has(`mv:${typeKey(e.receiver.type)}`) || this.mayHold.has(`mk:${typeKey(e.receiver.type)}`) || derived(e.receiver)) + (this.mayHold.has(`mv:${typeKey(e.receiver.type)}`) || + this.mayHold.has(`mk:${typeKey(e.receiver.type)}`) || + derived(e.receiver)) ); case "setIntrinsic": - return SET_ELEMENT_READS.has(e.method) && (this.mayHold.has(`se:${typeKey(e.receiver.type)}`) || derived(e.receiver)); + return ( + SET_ELEMENT_READS.has(e.method) && + (this.mayHold.has(`se:${typeKey(e.receiver.type)}`) || derived(e.receiver)) + ); case "call": return this.mayHold.has(`r:${e.callee}`); case "virtualCall": @@ -1099,7 +1378,13 @@ class ImmutableContracts { if (c.mod.lib !== undefined || c.diagnostics.length > 0) return; const out: string[] = []; for (const g of c.mod.globals ?? []) { - if (g.mutable || g.tdz === true || g.initFlag !== undefined || !this.annotated.has(`g:${g.id}`)) continue; + if ( + g.mutable || + g.tdz === true || + g.initFlag !== undefined || + !this.annotated.has(`g:${g.id}`) + ) + continue; if (!this.publishable(g.type, new Set())) continue; let writes = 0; let fresh = true; @@ -1143,7 +1428,9 @@ class ImmutableContracts { if (seen.has(key)) return true; seen.add(key); const shape = c.mod.records?.find((r) => r.id === t.shapeId); - return !!shape && !shape.indexValue && shape.fields.every((f) => this.publishable(f.type, seen)); + return ( + !!shape && !shape.indexValue && shape.fields.every((f) => this.publishable(f.type, seen)) + ); } case "union": { const key = `u:${t.unionId}`; @@ -1159,12 +1446,21 @@ class ImmutableContracts { // Subclasses can stand behind a base-typed reference. for (const cls of c.classes.values()) { let under = false; - for (let x: IrClassDef | undefined = cls; x; x = x.base ? c.classes.get(x.base) : undefined) + for ( + let x: IrClassDef | undefined = cls; + x; + x = x.base ? c.classes.get(x.base) : undefined + ) if (x.name === t.className) under = true; if (!under) continue; - for (let x: IrClassDef | undefined = cls; x; x = x.base ? c.classes.get(x.base) : undefined) + for ( + let x: IrClassDef | undefined = cls; + x; + x = x.base ? c.classes.get(x.base) : undefined + ) if (x.runtime === true || x.localCaptures !== undefined) return false; - if (!cls.fields.every((f) => f.name.startsWith("%") || this.publishable(f.type, seen))) return false; + if (!cls.fields.every((f) => f.name.startsWith("%") || this.publishable(f.type, seen))) + return false; } return true; } diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 4dfd8eb60..bbf2a8e07 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -4569,7 +4569,8 @@ function immutableSourceType(type: ts.Type, ctx: TypeMapperCtx): ts.Type | null const ownerSymbol = checker.getSymbolAtLocation(owner.name); if (ownerSymbol) source = checker.getDeclaredTypeOfSymbol(ownerSymbol); } - if (source !== null && checker.getPropertiesOfType(source).length === props.length) return source; + if (source !== null && checker.getPropertiesOfType(source).length === props.length) + return source; } return null; } diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 6cf59e00e..ffd6ae468 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -478,7 +478,6 @@ export function dynamicallyStoredFamilies(mod: IrModule, slots: Int32Slots): Set families.add(slots.family(cls.name, field.name)!); } return families; - } /** How functions can be reached besides direct calls: as values (closures, diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts index 0680069c4..43bec075d 100644 --- a/packages/compiler/src/ir/integer-ranges.ts +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -418,8 +418,7 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = { for (const arg of e.args) expr(arg, facts); if (e.type.kind === "f64") { if (e.method === "length") range = { min: 0, max: STRING_LENGTH_LIMIT }; - else if (e.method === "indexOf") - range = { min: -1, max: STRING_LENGTH_LIMIT }; + else if (e.method === "indexOf") range = { min: -1, max: STRING_LENGTH_LIMIT }; } break; } diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 35d3bc0b4..3c61db291 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15834,6 +15834,54 @@ "/tests/corpus/final-field-borrows.ts" ], "diags": [] + }, + "/tests/corpus/4630-contract-int32.ts": { + "order": [ + "/tests/corpus/4630-contract-int32.ts" + ], + "diags": [] + }, + "/tests/corpus/4631-contract-nothrow.ts": { + "order": [ + "/tests/corpus/4631-contract-nothrow.ts" + ], + "diags": [] + }, + "/tests/corpus/4632-contract-immutable.ts": { + "order": [ + "/tests/corpus/4632-contract-immutable.ts" + ], + "diags": [] + }, + "/tests/diagnostics/contract-immutable.ts": { + "order": [ + "/tests/diagnostics/contract-immutable.ts" + ], + "diags": [] + }, + "/tests/diagnostics/contract-int32-escapes.ts": { + "order": [ + "/tests/diagnostics/contract-int32-escapes.ts" + ], + "diags": [] + }, + "/tests/diagnostics/contract-int32.ts": { + "order": [ + "/tests/diagnostics/contract-int32.ts" + ], + "diags": [] + }, + "/tests/diagnostics/contract-nothrow.ts": { + "order": [ + "/tests/diagnostics/contract-nothrow.ts" + ], + "diags": [] + }, + "/tests/diagnostics/contract-positions.ts": { + "order": [ + "/tests/diagnostics/contract-positions.ts" + ], + "diags": [] } } } diff --git a/tests/diagnostics/contract-nothrow.ts b/tests/diagnostics/contract-nothrow.ts index 8a5081ff8..6d971dac5 100644 --- a/tests/diagnostics/contract-nothrow.ts +++ b/tests/diagnostics/contract-nothrow.ts @@ -22,4 +22,13 @@ function apply(f: (x: number) => number, x: number): NoThrow { return f(x); } +// The source type says `string`, but a caller passes a non-null asserted +// element that can be missing; Node then throws inside the callee, so the +// parameter is compiled as `string | undefined` and the read can throw. +function width(text: string): NoThrow { + return text.length; +} + +const words = "a b".split(" "); console.log(sumDigits("123"), depth(3), apply((x) => x + 1, 1), apply((x) => parseDigit(String(x)), 2)); +console.log(width(words[0]!), width(words[1]!)); diff --git a/tests/harness/__snapshots__/contract-immutable.ts.txt b/tests/harness/__snapshots__/contract-immutable.ts.txt new file mode 100644 index 000000000..67f4be09f --- /dev/null +++ b/tests/harness/__snapshots__/contract-immutable.ts.txt @@ -0,0 +1,35 @@ +contract-immutable.ts:9:5 - error SC7004: 'this.count++' writes to a value that may be Immutable ('this' may be an Immutable value passed to 'Counter.bump' at contract-immutable.ts:30:1) + + 8 | bump(): void { + 9 | this.count++; + | ^~~~~~~~~~~~ + 10 | } + + hint: Immutable values cannot be written through any reference; copy the value before changing it + +contract-immutable.ts:16:35 - error SC7004: the value stored into 'VIEW' (Immutable) may be referenced elsewhere: 'shared' is not a fresh literal, construction or factory result + + 15 | const shared = [1, 2, 3]; + 16 | const VIEW: Immutable = shared; + | ^~~~~~ + 17 | + + hint: build the value in place (a literal, `new`, or a function that returns what it builds), so no mutable reference to it remains + +contract-immutable.ts:23:3 - error SC7004: 'config.name = "y"' writes to a value that may be Immutable ('config' may be an Immutable value passed to 'rename' at contract-immutable.ts:31:1) + + 22 | function rename(config: { name: string }): void { + 23 | config.name = "y"; + | ^~~~~~~~~~~~~~~~~ + 24 | } + + hint: Immutable values cannot be written through any reference; copy the value before changing it + +contract-immutable.ts:27:3 - error SC7004: 'rows[0]!.push(4)' writes to a value that may be Immutable ('rows' may be an Immutable value passed to 'grow' at contract-immutable.ts:32:1) + + 26 | function grow(rows: number[][]): void { + 27 | rows[0]!.push(4); + | ^~~~~~~~~~~~~~~~ + 28 | } + + hint: Immutable values cannot be written through any reference; copy the value before changing it \ No newline at end of file diff --git a/tests/harness/__snapshots__/contract-int32-escapes.ts.txt b/tests/harness/__snapshots__/contract-int32-escapes.ts.txt new file mode 100644 index 000000000..5ba985a48 --- /dev/null +++ b/tests/harness/__snapshots__/contract-int32-escapes.ts.txt @@ -0,0 +1,26 @@ +contract-int32-escapes.ts:6:20 - error SC7003: the Int32 contract on 'x' cannot be checked: 'double' is used as a value (callback, class value or export), so its callers are not all visible + + 5 | + 6 | function double(x: Int32): Int32 { + | ^~~~~ + 7 | return (x * 2) | 0; + + hint: wrap it in a function that takes a number and passes `x | 0` + +contract-int32-escapes.ts:11:9 - error SC7003: the Int32 contract on 'Box.size' cannot be checked: instances of the class reach dynamic code (any, unknown casts, islands) where a store to the field is not checked + + 10 | class Box { + 11 | size: Int32 = 0; + | ^~~~~ + 12 | } + + hint: keep instances of this class out of any-typed values, or remove Int32 + +contract-int32-escapes.ts:16:1 - error SC7002: the value stored into 'Box.size' (Int32) is not provably an int32: 'loose.size = 0.5' + + 15 | const loose: any = box; + 16 | loose.size = 0.5; + | ^~~~~~~~~~~~~~~~ + 17 | console.log([1.5, 2].map(double).join(","), box.size); + + hint: make the int32 conversion explicit, for example 'loose.size = 0.5 | 0', so Node computes the same value \ No newline at end of file diff --git a/tests/harness/__snapshots__/contract-int32.ts.txt b/tests/harness/__snapshots__/contract-int32.ts.txt new file mode 100644 index 000000000..2f3a65c1d --- /dev/null +++ b/tests/harness/__snapshots__/contract-int32.ts.txt @@ -0,0 +1,53 @@ +contract-int32.ts:10:15 - error SC7002: the value stored into 'Span.id' (Int32) is not provably an int32: 'id' + + 9 | constructor(id: number) { + 10 | this.id = id; + | ^~ + 11 | } + + hint: make the int32 conversion explicit, for example 'id | 0', so Node computes the same value + +contract-int32.ts:13:5 - error SC7002: the value stored into 'Span.pos' (Int32) is not provably an int32: 'this.pos++' + + 12 | advance(): void { + 13 | this.pos++; + | ^~~~~~~~~~ + 14 | } + + hint: make the int32 conversion explicit, for example 'this.pos = (this.pos + 1) | 0', so Node computes the same value + +contract-int32.ts:18:10 - error SC7002: the value returned from 'width' (Int32) is not provably an int32: 'span.end - span.pos' + + 17 | function width(span: Span): Int32 { + 18 | return span.end - span.pos; + | ^~~~~~~~~~~~~~~~~~~ + 19 | } + + hint: make the int32 conversion explicit, for example '(span.end - span.pos) | 0', so Node computes the same value + +contract-int32.ts:22:10 - error SC7002: the value returned from 'half' (Int32) is not provably an int32: 'x / 2' + + 21 | function half(x: Int32): Int32 { + 22 | return x / 2; + | ^~~~~ + 23 | } + + hint: make the int32 conversion explicit, for example '(x / 2) | 0', so Node computes the same value + +contract-int32.ts:27:3 - error SC7002: the value stored into 'count' (Int32) is not provably an int32: 'count += 1' + + 26 | function bump(): void { + 27 | count += 1; + | ^~~~~~~~~~ + 28 | } + + hint: make the int32 conversion explicit, for example 'count = (count + 1) | 0', so Node computes the same value + +contract-int32.ts:31:20 - error SC7002: the value stored into 'len' (Int32) is not provably an int32: 'sizes.length' + + 30 | const sizes = [1, 2, 3]; + 31 | const len: Int32 = sizes.length; + | ^~~~~~~~~~~~ + 32 | + + hint: make the int32 conversion explicit, for example 'sizes.length | 0', so Node computes the same value \ No newline at end of file diff --git a/tests/harness/__snapshots__/contract-nothrow.ts.txt b/tests/harness/__snapshots__/contract-nothrow.ts.txt new file mode 100644 index 000000000..412ba2352 --- /dev/null +++ b/tests/harness/__snapshots__/contract-nothrow.ts.txt @@ -0,0 +1,35 @@ +contract-nothrow.ts:11:35 - error SC7001: 'sumDigits' is declared NoThrow but may throw: calls 'parseDigit' at contract-nothrow.ts:13:48 → throw statement at contract-nothrow.ts:7:29 + + 10 | + 11 | function sumDigits(text: string): NoThrow { + | ^~~~~~~~~~~~~~~ + 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 + +contract-nothrow.ts:17:28 - error SC7001: 'depth' is declared NoThrow but may throw: can recurse (directly or through callbacks), so the stack guard may throw RangeError at contract-nothrow.ts:17:1 + + 16 | + 17 | function depth(n: number): NoThrow { + | ^~~~~~~~~~~~~~~ + 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 + +contract-nothrow.ts:21:54 - error SC7001: 'apply' is declared NoThrow but may throw: calls a function value at contract-nothrow.ts:22:10, and the function value 'the function at contract-nothrow.ts:33:71' may throw → calls 'parseDigit' at contract-nothrow.ts:33:78 → throw statement at contract-nothrow.ts:7:29 + + 20 | + 21 | function apply(f: (x: number) => number, x: number): NoThrow { + | ^~~~~~~~~~~~~~~ + 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 + +contract-nothrow.ts:28:31 - error SC7001: 'width' is declared NoThrow but may throw: throws TypeError: Cannot read properties of undefined (reading 'length') at contract-nothrow.ts:29:10 (parameter 'text' is compiled as string | undefined: the argument at contract-nothrow.ts:34:19 can be missing) + + 27 | // parameter is compiled as `string | undefined` and the read can throw. + 28 | function width(text: string): NoThrow { + | ^~~~~~~~~~~~~~~ + 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 diff --git a/tests/harness/__snapshots__/contract-positions.ts.txt b/tests/harness/__snapshots__/contract-positions.ts.txt new file mode 100644 index 000000000..d43cd07e3 --- /dev/null +++ b/tests/harness/__snapshots__/contract-positions.ts.txt @@ -0,0 +1,53 @@ +contract-positions.ts:6:15 - error SC7006: the Int32 contract is not checked in this position (Int32 is checked on parameters, variables, class instance fields and function results) + + 5 | + 6 | type NodeId = Int32; + | ^~~~~ + 7 | + + hint: write Int32 directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed + +contract-positions.ts:9:6 - error SC7006: the Int32 contract is not checked in this position (Int32 is checked on parameters, variables, class instance fields and function results) + + 8 | interface Point { + 9 | x: Int32; + | ^~~~~ + 10 | } + + hint: write Int32 directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed + +contract-positions.ts:12:12 - error SC7006: the Int32 contract is not checked in this position (Int32 is checked on parameters, variables, class instance fields and function results) + + 11 | + 12 | const ids: Int32[] = [1, 2]; + | ^~~~~ + 13 | const point: Point = { x: 1 }; + + hint: write Int32 directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed + +contract-positions.ts:15:32 - error SC7006: the NoThrow contract is not checked in this position (NoThrow wraps a function's written return type) + + 14 | const id: NodeId = 3; + 15 | const callback: (x: number) => NoThrow = (x) => x; + | ^~~~~~~~~~~~~~~ + 16 | const table: Map> = new Map(); + + hint: write it as the function's return type annotation: function f(): NoThrow + +contract-positions.ts:16:26 - error SC7006: the Immutable contract is not checked in this position (Immutable is checked on parameters, variables, class instance fields and function results) + + 15 | const callback: (x: number) => NoThrow = (x) => x; + 16 | const table: Map> = new Map(); + | ^~~~~~~~~~~~~~~~~~~ + 17 | let last: NoThrow = 0; + + hint: write Immutable directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed + +contract-positions.ts:17:11 - error SC7006: the NoThrow contract is not checked in this position (NoThrow wraps a function's written return type) + + 16 | const table: Map> = new Map(); + 17 | let last: NoThrow = 0; + | ^~~~~~~~~~~~~~~ + 18 | + + hint: write it as the function's return type annotation: function f(): NoThrow \ No newline at end of file From d5c5262d0160a0274ca2681884938a16ccc52e3e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 12:25:11 -0700 Subject: [PATCH 03/13] Test may-throw explanations --- .../compiler/src/backend/may-throw.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/compiler/src/backend/may-throw.test.ts b/packages/compiler/src/backend/may-throw.test.ts index 4a07f8227..c60d754eb 100644 --- a/packages/compiler/src/backend/may-throw.test.ts +++ b/packages/compiler/src/backend/may-throw.test.ts @@ -494,3 +494,37 @@ test("throw-only, storage and primitive library operations stay bounded in worke // Only a library call that can reach a dynamic value's conversion polls. expect([...computeMayThrow(mod).workerEntryPolls!].sort()).toEqual(["dynamic"]); }); + +test("explain names the first throwing operation and each call edge", () => { + const message: IrExpr = { + kind: "strLit", + value: "Cannot read properties of undefined (reading 'length')", + type: STRING, + loc, + }; + const code: IrExpr = { kind: "strLit", value: "", type: STRING, loc }; + const typeError: IrExpr = { kind: "numLit", value: 1, type: F64, loc }; + const nodeThrow: IrExpr = { + kind: "libCall", + fn: "error.nodeThrow", + args: [typeError, code, message], + type: VOID, + loc, + }; + const callLoc = { file: "tdz.ts", start: 5, end: 9 }; + const mod = moduleWith( + fn("thrower", [exprStmt(nodeThrow)], []), + fn("caller", [exprStmt({ kind: "call", callee: "thrower", args: [], type: VOID, loc: callLoc })], []), + fn("quiet", [exprStmt(value)], []), + ); + const result = computeMayThrow(mod, { explain: true }); + expect([...result.fns].sort()).toEqual(["caller", "thrower"]); + expect(result.why?.get("thrower")).toEqual({ + kind: "seed", + what: "throws TypeError: Cannot read properties of undefined (reading 'length')", + loc, + }); + expect(result.why?.get("caller")).toEqual({ kind: "call", callee: "thrower", loc: callLoc }); + // Without the option the analysis records nothing extra. + expect(computeMayThrow(mod).why).toBeUndefined(); +}); From 0825756407969ed9b36be2d01bef6506606d35d7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 12:38:23 -0700 Subject: [PATCH 04/13] Contract checks compile in the self-hosted compiler Narrow IR nodes by kind instead of structural casts, and pass concrete arrays instead of Iterable, so the self-hosting build of the compiler accepts the contract checker and the may-throw explanations. --- .../compiler/src/backend/may-throw.test.ts | 6 +- packages/compiler/src/backend/may-throw.ts | 14 +++-- packages/compiler/src/contracts/check.ts | 61 +++++++++++-------- 3 files changed, 50 insertions(+), 31 deletions(-) diff --git a/packages/compiler/src/backend/may-throw.test.ts b/packages/compiler/src/backend/may-throw.test.ts index b08e77312..c593de309 100644 --- a/packages/compiler/src/backend/may-throw.test.ts +++ b/packages/compiler/src/backend/may-throw.test.ts @@ -546,7 +546,11 @@ test("explain names the first throwing operation and each call edge", () => { const callLoc = { file: "tdz.ts", start: 5, end: 9 }; const mod = moduleWith( fn("thrower", [exprStmt(nodeThrow)], []), - fn("caller", [exprStmt({ kind: "call", callee: "thrower", args: [], type: VOID, loc: callLoc })], []), + fn( + "caller", + [exprStmt({ kind: "call", callee: "thrower", args: [], type: VOID, loc: callLoc })], + [], + ), fn("quiet", [exprStmt(value)], []), ); const result = computeMayThrow(mod, { explain: true }); diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index abe72e187..8f0bab18e 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -448,7 +448,6 @@ const ERROR_KIND_NAMES: readonly string[] = [ /** A short description of a node that seeds the may-throw fixpoint. */ function describeThrowingNode(rec: IrExpr | IrStmt): string { - const detail = rec as { method?: string; fn?: string; name?: string }; switch (rec.kind) { case "throw": return "throw statement"; @@ -494,10 +493,17 @@ function describeThrowingNode(rec: IrExpr | IrStmt): string { : "Error"; return message?.kind === "strLit" ? `throws ${name}: ${message.value}` : `throws ${name}`; } - return `library call ${detail.fn ?? ""}`.trimEnd(); + return `library call ${rec.fn}`; } case "intrinsic": - return `intrinsic ${detail.name ?? ""}`.trimEnd(); + return `intrinsic ${rec.name}`; + case "strIntrinsic": + case "regexIntrinsic": + case "arrIntrinsic": + case "bytesIntrinsic": + case "mapIntrinsic": + case "setIntrinsic": + return `${rec.kind} ${rec.method}`; case "arraySetLength": return "array length assignment (RangeError)"; case "jsonStringify": @@ -508,7 +514,7 @@ function describeThrowingNode(rec: IrExpr | IrStmt): string { case "recordSet": return "write into a possibly published object (TypeError)"; default: - return detail.method !== undefined ? `${rec.kind} ${detail.method}` : rec.kind; + return rec.kind; } } diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts index e63a885e5..fc5dcc7ee 100644 --- a/packages/compiler/src/contracts/check.ts +++ b/packages/compiler/src/contracts/check.ts @@ -93,6 +93,8 @@ class ContractChecker { private readonly globalsByLoc = new Map(); private readonly classesByLoc = new Map(); private readonly contracts: IrContracts = {}; + /** Whether any verified fact was recorded. */ + private recorded = false; constructor( readonly mod: IrModule, @@ -233,13 +235,14 @@ class ContractChecker { immutable.run(); if (this.diagnostics.length > 0) return { diagnostics: this.diagnostics, contracts: null }; const c = this.contracts; - const empty = Object.values(c).every((v) => v === undefined || (v as unknown[]).length === 0); - return { diagnostics: [], contracts: empty ? null : c }; + return { diagnostics: [], contracts: this.recorded ? c : null }; } - record(field: keyof IrContracts, values: Iterable): void { + record(field: keyof IrContracts, values: readonly string[]): void { const list = [...new Set(values)].sort(); - if (list.length > 0) this.contracts[field] = list; + if (list.length === 0) return; + this.contracts[field] = list; + this.recorded = true; } /* ── NoThrow ── */ @@ -349,10 +352,10 @@ class Int32Contracts { if (this.fields.size + this.params.size + this.returns.size + this.locals.size === 0) return; this.structural(); this.writes(); - this.c.record("int32Fields", this.fields.keys()); - this.c.record("int32Params", this.params.keys()); - this.c.record("int32Returns", this.returns.keys()); - this.c.record("int32Locals", this.locals.keys()); + this.c.record("int32Fields", [...this.fields.keys()]); + this.c.record("int32Params", [...this.params.keys()]); + this.c.record("int32Returns", [...this.returns.keys()]); + this.c.record("int32Locals", [...this.locals.keys()]); } private refuse(note: ContractNote, why: string, hint: string): void { @@ -595,11 +598,11 @@ class Int32Contracts { else if (!int32(arg)) fail(note, arg, arg.loc, "the argument for"); }); }; - const incDecStored = (e: IrExpr & { prefix: boolean; op: "+" | "-" }): boolean => { + const incDecStored = (e: IrExpr, prefix: boolean, op: "+" | "-"): boolean => { const result = ranges.get(e); if (!result) return false; - if (e.prefix) return withinInt32(result); - return withinInt32(integerArithmeticRange(e.op, result, { min: 1, max: 1 })); + if (prefix) return withinInt32(result); + return withinInt32(integerArithmeticRange(op, result, { min: 1, max: 1 })); }; walk(fn.body, { stmt: (s) => { @@ -639,7 +642,7 @@ class Int32Contracts { if (note && !int32(e.value)) fail(note, e.value, e.value.loc, "the value stored into"); } else if (e.kind === "incDec") { const note = localNote(e.localId); - if (note && !incDecStored(e)) + if (note && !incDecStored(e, e.prefix, e.op)) c.report( "SC7002", e.loc, @@ -921,12 +924,9 @@ class ImmutableContracts { walk(fn.body, { stmt: (s) => { const value = s.kind === "varDecl" ? s.init : s.kind === "assign" ? s.value : undefined; - if ( - value && - candidates.has((s as { localId: string }).localId) && - !this.fresh(value, candidates) - ) { - candidates.delete((s as { localId: string }).localId); + const target = s.kind === "varDecl" || s.kind === "assign" ? s.localId : ""; + if (value && candidates.has(target) && !this.fresh(value, candidates)) { + candidates.delete(target); changed = true; } if (s.kind === "forOf" && candidates.delete(s.localId)) changed = true; @@ -1131,9 +1131,11 @@ class ImmutableContracts { store(`s:${s.shapeId}\0${s.field}`, s.value); break; case "recordKeySet": + mutate(s.obj, s.loc, `'${c.text(s.loc)}'`); + escape(s.value, s.loc, "a dynamically keyed property"); + break; case "recordKeyDelete": - mutate((s as { obj: IrExpr }).obj, s.loc, `'${c.text(s.loc)}'`); - if (s.kind === "recordKeySet") escape(s.value, s.loc, "a dynamically keyed property"); + mutate(s.obj, s.loc, `'${c.text(s.loc)}'`); break; case "arraySet": mutate(s.arr, s.loc, `'${c.text(s.loc)}'`); @@ -1143,7 +1145,7 @@ class ImmutableContracts { case "arraySetUndefined": case "arrayDelete": case "bytesSet": - mutate((s as { arr: IrExpr }).arr, s.loc, `'${c.text(s.loc)}'`); + mutate(s.arr, s.loc, `'${c.text(s.loc)}'`); break; case "forOf": { const slot = this.localSlot(fn, s.localId); @@ -1279,8 +1281,14 @@ class ImmutableContracts { for (let steps = 0; steps < 16; steps++) { if (root.kind === "fieldGet" || root.kind === "recordGet") root = root.obj; else if (root.kind === "arrayGet") root = root.arr; - else if (PASS_THROUGH.has(root.kind) && "value" in root) - root = (root as { value: IrExpr }).value; + else if ( + root.kind === "upcast" || + root.kind === "downcast" || + root.kind === "unionWrap" || + root.kind === "unionNarrow" || + root.kind === "assignExpr" + ) + root = root.value; else if (root.kind === "varRef") { // Compiler temporaries (no source binding): follow their initializer. const id = root.localId; @@ -1320,8 +1328,9 @@ class ImmutableContracts { } case "recordGet": return ( - this.mayHold.has(`s:${(e.obj.type as { shapeId?: string }).shapeId ?? ""}\0${e.field}`) || - derived(e.obj) + this.mayHold.has( + `s:${e.obj.type.kind === "record" ? e.obj.type.shapeId : ""}\0${e.field}`, + ) || derived(e.obj) ); case "arrayGet": return this.mayHold.has(`e:${typeKey(e.arr.type)}`) || derived(e.arr); @@ -1481,7 +1490,7 @@ function describeSink(e: IrExpr): string { case "jsExit": return "the JavaScript island"; case "intrinsic": - return `the runtime (${(e as { name: string }).name})`; + return `the runtime (${e.name})`; default: return `an operation scriptc does not follow (${e.kind})`; } From 61f3bac6d41aa4444e0955cb3f897b85918e1043 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 12:52:33 -0700 Subject: [PATCH 05/13] Add the @scriptc/types README --- packages/types/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 packages/types/README.md diff --git a/packages/types/README.md b/packages/types/README.md new file mode 100644 index 000000000..5436cee18 --- /dev/null +++ b/packages/types/README.md @@ -0,0 +1,25 @@ +# @scriptc/types + +Contract types for scriptc: type aliases with no meaning in TypeScript or Node.js that scriptc checks as extra promises. Import them with `import type`, which TypeScript erases, so Node.js never loads this package. + +```ts +import type { Immutable, Int32, NoThrow } from "@scriptc/types"; + +const KEYWORDS: Immutable> = new Map([["const", 1]]); + +class Token { + pos: Int32 = 0; +} + +function isLetter(ch: number): NoThrow { + return (ch >= 97 && ch <= 122) || (ch >= 65 && ch <= 90); +} +``` + +## Contracts + +- `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. + +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. From 86c4edab5410f50c55fbc477f90d0adb843d213b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 13:00:46 -0700 Subject: [PATCH 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 bc9a33c9c79436db816b3b0f5597904c5df68e8f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 12 Oct 2026 08:54:54 -0500 Subject: [PATCH 13/13] 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", () => {