diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 63d7f0066f..56d1d34498 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -16,6 +16,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `numeric-kernels` | cpu | sieve, hashing, `number[][]` matmul, `Float64Array` | | `value-objects` | cpu | small immutable records and class instances: vector helpers, method chains, particle updates | | `optional-fields` | cpu | records and class instances with `number \| undefined` and other scalar-union fields | +| `record-boundaries` | cpu | records built from unchecked array reads passed to and returned from annotated functions | | `validate-errors` | cpu | throw/catch on a fraction of inputs, small call chains | | `csv-numbers` | cpu | `split`, `Number`, `parseInt`, `toFixed` | | `log-lines` | cpu | 200k immediate `console.log`/`process.stdout.write` lines into the runner's pipe | diff --git a/benchmarks/runtime/workloads.json b/benchmarks/runtime/workloads.json index 58151719bb..2d697833d4 100644 --- a/benchmarks/runtime/workloads.json +++ b/benchmarks/runtime/workloads.json @@ -84,6 +84,13 @@ "10" ] }, + { + "name": "record-boundaries", + "entry": "workloads/record-boundaries.ts", + "args": [ + "8" + ] + }, { "name": "validate-errors", "entry": "workloads/validate-errors.ts", diff --git a/benchmarks/runtime/workloads/record-boundaries.ts b/benchmarks/runtime/workloads/record-boundaries.ts new file mode 100644 index 0000000000..5cdb0ee8de --- /dev/null +++ b/benchmarks/runtime/workloads/record-boundaries.ts @@ -0,0 +1,78 @@ +// Records built from unchecked array reads that cross annotated function +// boundaries: points returned from a lookup and passed to a metric, +// samples defaulted through `??` inside the callee, and a span grown by +// a recursive helper. Every read here finds its element. +interface Point { + x: number; + y: number; +} +function pointAt(xs: number[], ys: number[], i: number): Point { + return { x: xs[i], y: ys[i] }; +} +function distance(p: Point, q: Point): number { + const dx = p.x - q.x; + const dy = p.y - q.y; + return Math.sqrt(dx * dx + dy * dy); +} +function pathLength(xs: number[], ys: number[], rounds: number): number { + let total = 0; + for (let r = 0; r < rounds; r++) { + for (let i = 0; i + 1 < xs.length; i++) total += distance(pointAt(xs, ys, i), pointAt(xs, ys, i + 1)); + } + return total; +} + +interface Sample { + value: number; + weight: number; +} +function weighted(s: Sample): number { + return (s.value ?? 0) * (s.weight ?? 1); +} +function blend(a: Sample, b: Sample): Sample { + return { value: (a.value + b.value) / 2, weight: a.weight + b.weight }; +} +function mix(values: number[], weights: number[], rounds: number): number { + let total = 0; + for (let r = 0; r < rounds; r++) { + for (let i = 0; i + 1 < values.length; i++) { + const s = blend({ value: values[i], weight: weights[i] }, { value: values[i + 1], weight: weights[i + 1] }); + total += weighted(s); + } + } + return total; +} + +interface Span { + lo: number; + hi: number; +} +function widen(span: Span, values: number[], from: number, to: number): Span { + if (from >= to) return span; + const v = values[from]; + return widen({ lo: Math.min(span.lo, v), hi: Math.max(span.hi, v) }, values, from + 1, to); +} +function spans(values: number[], rounds: number): number { + let total = 0; + for (let r = 0; r < rounds; r++) { + for (let i = 0; i + 16 <= values.length; i += 16) { + const s = widen({ lo: values[i], hi: values[i] }, values, i + 1, i + 16); + total += s.hi - s.lo; + } + } + return total; +} + +const scale = Number(process.argv[2] ?? "1"); +const xs: number[] = []; +const ys: number[] = []; +const weights: number[] = []; +for (let i = 0; i < 4096; i++) { + xs.push((i * 37) % 101); + ys.push((i * 53) % 97); + weights.push(((i * 7) % 11) / 4); +} +const path = pathLength(xs, ys, Math.floor(300 * scale)); +const mixed = mix(xs, weights, Math.floor(300 * scale)); +const spanned = spans(ys, Math.floor(300 * scale)); +console.log("path", path.toFixed(3), "mix", mixed.toFixed(3), "spans", spanned.toFixed(3)); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 182793d0c6..742fdfc3a9 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4828,6 +4828,8 @@ function inferredOptionalRecordType( actual: IrType, ): IrType { if (decl.type || hasJsTypeAnnotation(decl)) return declared; + const symbol = ts.isIdentifier(decl.name) ? lowerer.resolveValueSymbol(decl.name) : undefined; + if (symbol && lowerer.runtimeOptionalRecordsKeptDeclared.has(symbol)) return declared; if (lowerer.isRuntimeOptionalRecordVariant(actual, declared)) return actual; if (declared.kind !== "record" || actual.kind !== "record") return declared; const target = lowerer.shapes.get(declared.shapeId); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 0d9654caa1..b08419c959 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -76,6 +76,7 @@ import { NarrowingStability } from "./narrowing-stability.js"; import { ArrayElementStates } from "./runtime-optional-elements.js"; import { indexReadInBounds } from "./runtime-optional-bounds.js"; import { ArrayOwnership } from "./runtime-optional-ownership.js"; +import { RecordFlowClasses } from "./runtime-optional-records.js"; import { StringIndexBounds } from "./string-index-bounds.js"; import { NanCodedReads } from "./nan-coded-reads.js"; import { RuntimeOptionalLocals } from "./runtime-optional-locals.js"; @@ -1798,6 +1799,10 @@ export class Lowerer { /** Record fields promoted to an undefined-armed union by an indexed-read * value. The key is the concrete emitted shape and field name. */ readonly runtimeOptionalFields = new Set(); + /** Record bindings whose flow class reaches storage outside the class: + * they keep the declared layout, so an initializer from unchecked reads + * converts once instead of the binding adopting the widened variant. */ + readonly runtimeOptionalRecordsKeptDeclared = new Set(); /** Callback pattern parameters carry an optional source without making * every name destructured from a present source optional. */ readonly runtimeOptionalPatternTypes = new Map(); @@ -3804,6 +3809,61 @@ export class Lowerer { } const mappedOrNull = (node: ts.Node): IrType | null => panicSafe(() => this.mapTypeOf(this.typeOf(node)), null); + const admitsUndefined = (type: IrType): boolean => typeEquals(addUndefined(type), type); + const plainRecordShape = (type: IrType | null | undefined): string | null => { + if (type?.kind !== "record") return null; + const shape = this.shapes.get(type.shapeId); + return shape && !shape.tuple && !shape.indexValue && shape.fields.length > 0 + ? type.shapeId + : null; + }; + // Parameters and results read the signatures collected above; bindings + // ask the checker, so the scan builds no layout of its own. + const parameterType = (node: ts.Node): IrType | null => { + const parameter = node.parent; + if (!parameter || !ts.isParameter(parameter)) return null; + const fn = parameter.parent; + const symbol = + (ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn)) && fn.name + ? symbolOf(fn.name) + : null; + const index = (fn as ts.FunctionLikeDeclaration).parameters.indexOf(parameter); + return (symbol && signatureBySymbol.get(symbol)?.params[index]?.type) ?? null; + }; + const returnTypeOf = (fn: ts.FunctionDeclaration | ts.MethodDeclaration): IrType | null => { + const symbol = fn.name ? symbolOf(fn.name) : null; + return (symbol && signatureBySymbol.get(symbol)?.returnType) ?? null; + }; + const recordFlows = new RecordFlowClasses(sourceFiles, { + symbolOf, + declarationOf: (symbol) => this.checker.valueDeclarationOf(symbol), + mayBeRecord: (node) => { + if (node.parent && ts.isParameter(node.parent)) + return plainRecordShape(parameterType(node)) !== null; + const t = panicSafe(() => this.typeOf(node), null); + return ( + t !== null && + (t.flags & ts.TypeFlags.Object) !== 0 && + !this.checker.isArrayType(t) && + this.checker.getCallSignatures(t).length === 0 && + this.checker.getConstructSignatures(t).length === 0 + ); + }, + prefetchTypes: (nodes) => this.checker.prefetchCollectionTypes(nodes), + mayReturnRecord: (fn) => plainRecordShape(returnTypeOf(fn)) !== null, + familyOf: (method) => familyBySymbol.get(method), + recordShape: (node) => + plainRecordShape( + node.parent && ts.isParameter(node.parent) ? parameterType(node) : mappedOrNull(node), + ), + returnShape: (fn) => plainRecordShape(returnTypeOf(fn)), + isGlobal: (node, name) => this.isStdlibGlobal(node, name), + }); + /** Optional fields of the records a visible function returns. */ + const returnFields = new Map>(); + /** Record parameters that receive a value with optional fields, by the + * parameter's own symbol. */ + const recordParameterSlots = new Map(); const nanCoded = new NanCodedReads( { symbolOf, @@ -4081,10 +4141,8 @@ export class Lowerer { if (op === ts.SyntaxKind.AmpersandAmpersandToken) return mayBeOptional(e.left) || mayBeOptional(e.right); } - if (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression)) { - const symbol = symbolOf(e.expression); - const fields = symbol === null ? undefined : optionalFields.get(symbol); - return fields?.has(fieldName(e)) ?? false; + if (ts.isPropertyAccessExpression(e)) { + return fieldsOfValue(e.expression)?.has(fieldName(e)) ?? false; } return false; }; @@ -4179,9 +4237,40 @@ export class Lowerer { // a spread or a parameter receives the source's optional fields. const fieldsOfValue = (node: ts.Expression): ReadonlySet | undefined => { const e = peel(node); - if (!ts.isIdentifier(e)) return undefined; - const symbol = symbolOf(e); - return symbol === null ? undefined : optionalFields.get(symbol); + if (ts.isIdentifier(e)) { + const symbol = symbolOf(e); + // A binding whose class reaches other storage keeps the declared + // layout, so its fields read as present. + return symbol === null || recordFlows.opened(symbol) + ? undefined + : optionalFields.get(symbol); + } + // A fresh literal carries the fields its values can leave undefined. + if (ts.isObjectLiteralExpression(e)) { + const out = spreadFieldsOf(e); + for (const prop of e.properties) { + if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue; + const name = prop.name; + if (!(ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name))) + continue; + const value = ts.isPropertyAssignment(prop) ? prop.initializer : (name as ts.Expression); + if (mayBeOptional(value)) out.add(name.text); + } + return out.size > 0 ? out : undefined; + } + if ( + ts.isCallExpression(e) && + (ts.isIdentifier(e.expression) || ts.isPropertyAccessExpression(e.expression)) + ) { + const symbol = symbolOf(ts.isIdentifier(e.expression) ? e.expression : e.expression.name); + return symbol === null ? undefined : returnFields.get(symbol); + } + if (ts.isConditionalExpression(e)) { + const a = fieldsOfValue(e.whenTrue); + const b = fieldsOfValue(e.whenFalse); + return a && b ? new Set([...a, ...b]) : (a ?? b); + } + return undefined; }; const noteFieldsFrom = (symbol: ts.Symbol, source: ts.Expression): boolean => { const fields = fieldsOfValue(source); @@ -5222,27 +5311,20 @@ export class Lowerer { if (!parameter || !ts.isIdentifier(parameter.name)) continue; const bound = symbolOf(parameter.name); if (!bound) continue; - let promoted: IrType = parameterShape.type; + const declared = this.shapes.get(parameterShape.type.shapeId); + // The parameter's layout settles with its flow class once + // the fixed point ends. A literal or call argument only adds + // fields the declaration calls present. + const fresh = !ts.isIdentifier(peel(arg)); for (const field of fields) { - const source = - promoted.kind === "record" - ? this.shapes.get(promoted.shapeId)?.fields.find((f) => f.name === field) - : undefined; - if (source) { - promoted = this.runtimeOptionalRecordField( - promoted, - field, - addUndefined(source.type), - ); + const slot = declared?.fields.find((f) => f.name === field); + if (!slot || (fresh && admitsUndefined(slot.type))) continue; + if (noteFieldSymbol(bound, field)) { + provenance?.note("record-parameter", parameter, arg); + changed = true; } - if (noteFieldSymbol(bound, field)) changed = true; - } - if (!typeEquals(promoted, parameterShape.type)) { - parameterShape.type = promoted; - this.runtimeOptionalBindingTypes.set(bound, promoted); - provenance?.note("record-parameter", parameter, arg); - changed = true; } + recordParameterSlots.set(bound, { target, index: i }); } } if (ts.isSpreadElement(arg) || !shape) return; @@ -5324,7 +5406,25 @@ export class Lowerer { }; const scanReturns = (symbol: ts.Symbol, decl: ts.FunctionLikeDeclaration): boolean => { let changed = false; + const recordReturn = recordFlows.returnClass(symbol) !== null; + const returned = signatureBySymbol.get(symbol)?.returnType; + const returnedShape = returned?.kind === "record" ? this.shapes.get(returned.shapeId) : null; for (const expression of returnsOf(decl)) { + const fields = recordReturn ? fieldsOfValue(expression) : undefined; + if (fields) { + for (const field of fields) { + // A result field that already admits undefined keeps the + // checker's narrowing. + const slot = returnedShape?.fields.find((f) => f.name === field); + if (slot && admitsUndefined(slot.type)) continue; + const set = returnFields.get(symbol) ?? new Set(); + if (set.has(field)) continue; + set.add(field); + returnFields.set(symbol, set); + provenance?.note("record-return", decl.name ?? decl, expression); + changed = true; + } + } if (mayBeOptional(expression) && !optionalReturns.has(symbol)) { optionalReturns.add(symbol); provenance?.note("return", decl.name ?? decl, expression); @@ -5497,6 +5597,70 @@ export class Lowerer { this.runtimeOptionalGlobals.add(global); } } + // Record layouts settle per flow class: a closed class widens every + // member to the union of its members' optional fields, and a class that + // reaches other storage keeps the declared layout for every member. + const classFields = new Map>(); + const addClassFields = (id: number | null, fields: ReadonlySet): void => { + if (id === null) return; + const set = classFields.get(id) ?? new Set(); + for (const field of fields) set.add(field); + classFields.set(id, set); + }; + for (const [symbol, fields] of optionalFields) + addClassFields(recordFlows.bindingClass(symbol), fields); + for (const [symbol, fields] of returnFields) + addClassFields(recordFlows.returnClass(symbol), fields); + const settledFields = ( + id: number | null, + own: ReadonlySet | undefined, + ): ReadonlySet | undefined => + id === null + ? own + : classFields.has(id) && recordFlows.closed(id) + ? classFields.get(id) + : undefined; + const widenRecordFields = (type: IrType, fields: ReadonlySet): IrType => { + let promoted = type; + for (const field of fields) { + const source = + promoted.kind === "record" + ? this.shapes.get(promoted.shapeId)?.fields.find((f) => f.name === field) + : undefined; + if (source) + promoted = this.runtimeOptionalRecordField(promoted, field, addUndefined(source.type)); + } + return promoted; + }; + for (const [symbol, id] of recordFlows.bindings()) + if (classFields.has(id) && !recordFlows.closed(id)) + this.runtimeOptionalRecordsKeptDeclared.add(symbol); + // Every tracked parameter settles with its class, including one that + // only receives plain records. An untracked one (a function used as a + // value, a method another member name or view can reach) widens to the + // fields its own arguments carry. + for (const { symbol, fn, index } of recordFlows.parameters()) + recordParameterSlots.set(symbol, { target: fn, index }); + for (const [bound, { target, index }] of recordParameterSlots) { + const shape = signatureBySymbol.get(target)?.params[index]; + if (!shape || shape.type.kind !== "record") continue; + const fields = settledFields(recordFlows.bindingClass(bound), optionalFields.get(bound)); + if (!fields) continue; + const promoted = widenRecordFields(shape.type, fields); + if (typeEquals(promoted, shape.type)) continue; + shape.type = promoted; + this.runtimeOptionalBindingTypes.set(bound, promoted); + } + for (const [symbol, sig] of signatureBySymbol) { + const id = recordFlows.returnClass(symbol); + const fields = id === null ? undefined : settledFields(id, undefined); + if (fields && sig.returnType.kind === "record") { + const next = widenRecordFields(sig.returnType, fields); + sig.returnType = next; + if (sig.top) sig.top.returnType = next; + if (sig.method) sig.method.ret = next; + } + } for (const [symbol, sig] of signatureBySymbol) { const params = optionalParams.get(symbol); if (params) { @@ -5577,7 +5741,7 @@ export class Lowerer { if (global) global.type = promoted; } } - const fields = optionalFields.get(symbol); + const fields = settledFields(recordFlows.bindingClass(symbol), optionalFields.get(symbol)); if (!dynamicObjectEntryRows.has(symbol) && !optionalSymbols.has(symbol) && !fields) return; const current = this.mapTypeOf(this.typeOf(node.name)); if (!current) return; @@ -5594,16 +5758,7 @@ export class Lowerer { }; } } - if (fields && promoted.kind === "record") { - for (const field of fields) { - const source = - promoted.kind === "record" - ? this.shapes.get(promoted.shapeId)?.fields.find((f) => f.name === field) - : undefined; - if (!source) continue; - promoted = this.runtimeOptionalRecordField(promoted, field, addUndefined(source.type)); - } - } + if (fields && promoted.kind === "record") promoted = widenRecordFields(promoted, fields); if (!typeEquals(promoted, current)) { this.runtimeOptionalBindingTypes.set(symbol, promoted); const global = ts.isIdentifier(node.name) diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-records.test.ts b/packages/compiler/src/frontend/lowering/runtime-optional-records.test.ts new file mode 100644 index 0000000000..2ca59739dd --- /dev/null +++ b/packages/compiler/src/frontend/lowering/runtime-optional-records.test.ts @@ -0,0 +1,185 @@ +import { afterAll, expect, test } from "vitest"; +import * as ts from "../ts7/adapter.js"; +import { closeSourceParser, parseSourceFile } from "../ts7/source-parser-node.js"; +import { RecordFlowClasses, type RecordFlowHost } from "./runtime-optional-records.js"; + +afterAll(closeSourceParser); + +/** Flow classes over one parsed file. Names are unique per test, so a + * binding's symbol is its name; a slot holds a record when its annotation + * names a type starting with `R` (the shape) or, unannotated, when it is + * initialized from an object literal or a call. */ +function classes(source: string): { + classOf(name: string): number | null; + closed(name: string): boolean; + opened(name: string): boolean; + returns(name: string): number | null; +} { + const file = parseSourceFile("records.ts", source, "ts"); + const symbols = new Map(); + const declarations = new Map(); + const symbolFor = (name: string): ts.Symbol => { + let symbol = symbols.get(name); + if (!symbol) { + symbol = { name, declarations: [] } as unknown as ts.Symbol; + symbols.set(name, symbol); + } + return symbol; + }; + ts.walkPreorder(file, (node) => { + if ( + (ts.isVariableDeclaration(node) || + ts.isParameter(node) || + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isMethodDeclaration(node)) && + node.name && + ts.isIdentifier(node.name) + ) { + const symbol = symbolFor(node.name.text); + declarations.set(symbol, node); + (symbol.declarations as unknown as ts.Node[]).push(node); + } + return undefined; + }); + const annotated = (type: ts.TypeNode | undefined): string | null => { + if (!type || !ts.isTypeReferenceNode(type) || !ts.isIdentifier(type.typeName)) return null; + return type.typeName.text.startsWith("R") ? type.typeName.text : null; + }; + const host: RecordFlowHost = { + symbolOf: (node) => + ts.isIdentifier(node) && node.text !== "console" && node.text !== "undefined" + ? symbolFor(node.text) + : null, + declarationOf: (symbol) => declarations.get(symbol), + recordShape: (node) => { + const declaration = node.parent; + if (!declaration) return null; + if (ts.isParameter(declaration)) return annotated(declaration.type); + if (!ts.isVariableDeclaration(declaration)) return null; + if (declaration.type) return annotated(declaration.type); + const init = declaration.initializer; + return init && (ts.isObjectLiteralExpression(init) || ts.isCallExpression(init)) ? "R" : null; + }, + returnShape: (fn) => annotated(fn.type), + mayBeRecord: (node) => host.recordShape(node) !== null, + prefetchTypes: () => {}, + mayReturnRecord: (fn) => annotated(fn.type) !== null, + familyOf: () => undefined, + isGlobal: (node, name) => ts.isIdentifier(node) && node.text === name, + }; + const flows = new RecordFlowClasses([file], host); + const classOf = (name: string): number | null => flows.bindingClass(symbolFor(name)); + return { + classOf, + closed: (name) => { + const id = classOf(name); + return id !== null && flows.closed(id); + }, + opened: (name) => flows.opened(symbolFor(name)), + returns: (name) => flows.returnClass(symbolFor(name)), + }; +} + +test("literals, parameters, returns and bindings form one closed class", () => { + const flows = classes(` + function make(a: number[], i: number): R { return { v: a[i] }; } + function show(p: R): string { return String(p.v); } + function pass(q: R): R { return q; } + const made = make([], 0); + const passed = pass(made); + console.log(show(passed), show({ v: 1 }), passed === made, made.v); + `); + expect(flows.closed("made")).toBe(true); + for (const name of ["passed", "p", "q"]) expect(flows.classOf(name)).toBe(flows.classOf("made")); + expect(flows.returns("make")).toBe(flows.classOf("made")); + expect(flows.returns("pass")).toBe(flows.classOf("made")); +}); + +test("a member stored elsewhere opens its whole class", () => { + const flows = classes(` + function keep(p: R, out: unknown[]): void { out.push(p); } + const o = { v: 1 }; + keep(o, []); + const other = { v: 2 }; + console.log(other.v); + `); + expect(flows.classOf("p")).toBe(flows.classOf("o")); + expect(flows.closed("o")).toBe(false); + expect(flows.opened("p")).toBe(true); + expect(flows.closed("other")).toBe(true); + expect(flows.opened("other")).toBe(false); +}); + +test("methods the runtime can call without a call site are not tracked", () => { + const flows = classes(` + class Plain { use(p: R): number { return p.v; } } + class Listener { handleEvent(e: R): void { console.log(e.v); } } + class Mine extends EventTarget { mine(q: R): number { return q.v; } } + class Base { base(): number { return 0; } } + class Derived extends Base { derived(s: R): number { return s.v; } } + new Plain().use({ v: 1 }); + new Listener().handleEvent({ v: 2 }); + new Mine().mine({ v: 3 }); + new Derived().derived({ v: 4 }); + `); + expect(flows.classOf("p")).not.toBeNull(); + expect(flows.classOf("s")).not.toBeNull(); + expect(flows.classOf("e")).toBeNull(); + expect(flows.classOf("q")).toBeNull(); +}); + +test("a function used as a value has no tracked parameters", () => { + const flows = classes(` + function show(p: R): string { return String(p.v); } + const shown = [].map(show); + const r = { v: 1 }; + show(r); + `); + expect(flows.classOf("p")).toBeNull(); + expect(flows.closed("r")).toBe(false); +}); + +test("shorthand properties, element writes and foreign sources open a class", () => { + const flows = classes(` + const nested = { v: 1 }; + const holder = { nested }; + const fromField = holder.inner; + const copied = { v: 2 }; + const box: unknown[] = [copied]; + const read = { v: 3 }; + console.log(read.v, { ...read }, read === undefined, \`\${read}\`); + `); + expect(flows.closed("nested")).toBe(false); + expect(flows.closed("fromField")).toBe(false); + expect(flows.closed("copied")).toBe(false); + expect(flows.closed("read")).toBe(true); +}); + +test("members of different declared shapes do not share a widened layout", () => { + const flows = classes(` + function wide(p: RWide): RWide { return p; } + const narrow: RNarrow = { v: 1 }; + wide(narrow); + `); + expect(flows.classOf("narrow")).toBe(flows.classOf("p")); + expect(flows.closed("narrow")).toBe(false); + // Not opened: the class reaches no other storage. + expect(flows.opened("narrow")).toBe(false); +}); + +test("assignments join their target and a foreign value opens it", () => { + const flows = classes(` + let target = { v: 1 }; + const joined = { v: 2 }; + target = joined; + let other = { v: 3 }; + other ??= target; + let foreign = { v: 4 }; + foreign = holder.inner; + `); + expect(flows.classOf("target")).toBe(flows.classOf("joined")); + expect(flows.classOf("other")).toBe(flows.classOf("target")); + expect(flows.closed("target")).toBe(true); + expect(flows.closed("foreign")).toBe(false); +}); diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-records.ts b/packages/compiler/src/frontend/lowering/runtime-optional-records.ts new file mode 100644 index 0000000000..0ecdd62b34 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/runtime-optional-records.ts @@ -0,0 +1,727 @@ +import * as ts from "../ts7/adapter.js"; + +/** Program queries for the record-flow proof. */ +export interface RecordFlowHost { + symbolOf(node: ts.Node): ts.Symbol | null; + declarationOf(symbol: ts.Symbol): ts.Node | undefined; + /** The node's checker type can be a record: an object type that is not + * an array, a function or a class instance. Answers without building a + * layout, so the scan leaves shape interning order alone. */ + mayBeRecord(node: ts.Node): boolean; + /** Batches the checker type queries `mayBeRecord` makes for `nodes`. */ + prefetchTypes(nodes: readonly ts.Node[]): void; + /** The same question for a function's result. */ + mayReturnRecord(fn: Callable): boolean; + /** The methods sharing a dispatch slot with `method` (itself included), + * or undefined when it shares none. */ + familyOf(method: ts.Symbol): readonly ts.Symbol[] | undefined; + /** The shape id of the node's plain record layout (named fields, no + * tuple and no index signature), or null for any other value. */ + recordShape(node: ts.Node): string | null; + /** The shape id of a function's plain record result, or null. */ + returnShape(fn: Callable): string | null; + isGlobal(node: ts.Expression, name: string): boolean; +} + +/** A function declaration or a class method. */ +export type Callable = ts.FunctionDeclaration | ts.MethodDeclaration; + +/** Calls that read a record without keeping or converting it. */ +const READING_CALLS = new Map([ + ["console", new Set(["log", "info", "warn", "error", "debug", "dir"])], + ["JSON", new Set(["stringify"])], + ["Object", new Set(["keys", "values", "entries", "hasOwn", "getOwnPropertyNames"])], + ["Reflect", new Set(["has", "ownKeys"])], + ["Array", new Set(["isArray"])], +]); + +/** Method names the runtime calls without a visible call site. */ +const PROTOCOL_METHODS = new Set([ + "handleEvent", + "toJSON", + "toString", + "toLocaleString", + "valueOf", + "then", + "next", + "return", + "throw", +]); + +/** Global functions that read a record without keeping it. */ +const READING_FUNCTIONS = new Set(["String", "structuredClone"]); + +/** Record values that flow between local bindings, the parameters and the + * returns of functions whose every call site is visible, grouped into + * classes that must share one layout. Widening a record field to hold + * undefined changes the layout, so a class can take the widened layout only + * when it is closed: every value entering it is a fresh object literal or + * comes from another member, every use of a member reads or writes its + * fields, passes it to another member, or only inspects it, and every + * member declares the same shape. A record that reaches any other storage + * keeps the declared layout, since converting it there would copy the + * object. */ +export class RecordFlowClasses { + private readonly parent: number[] = []; + private readonly open: boolean[] = []; + /** The declaration each slot reads its shape from: a binding or + * parameter name, or a function for its result. */ + private readonly slotNodes: ts.Node[] = []; + private readonly slotReturns: boolean[] = []; + private readonly shapeChecked = new Map(); + private readonly bindingSlots = new Map(); + private readonly returnSlots = new Map(); + /** Visible functions by symbol: plain declarations called only by name, + * and methods whose dispatch slot only direct calls reach. */ + private readonly functions = new Map(); + /** Members sharing each visible method's dispatch slot. */ + private readonly families = new Map(); + /** String keys of `x["name"]` accesses. */ + private readonly literalKeys = new Set(); + /** Identifiers by name; symbols resolve only for the names of slots and + * functions, so the scan asks the checker about few nodes. */ + private readonly names = new Map(); + private readonly references = new Map(); + /** Names written as shorthand properties (`{ p }`): the checker resolves + * those to the property, so a binding of that name stays open. */ + private readonly shorthandNames = new Set(); + + constructor( + private readonly files: readonly ts.SourceFile[], + private readonly host: RecordFlowHost, + ) { + this.index(); + this.collectFunctions(); + this.collectSlots(); + this.connect(); + } + + /** The class id of a binding or parameter slot, or null when the symbol + * is not a tracked record slot. */ + bindingClass(symbol: ts.Symbol): number | null { + const slot = this.bindingSlots.get(symbol); + return slot === undefined ? null : this.find(slot); + } + + /** The binding or parameter is in a class that reaches other storage. */ + opened(symbol: ts.Symbol): boolean { + const slot = this.bindingSlots.get(symbol); + return slot !== undefined && this.open[this.find(slot)]!; + } + + /** Every tracked binding and parameter with its class id. */ + bindings(): [ts.Symbol, number][] { + const out: [ts.Symbol, number][] = []; + for (const [symbol, slot] of this.bindingSlots) out.push([symbol, this.find(slot)]); + return out; + } + + /** Every tracked parameter: its symbol, function and position. */ + parameters(): { symbol: ts.Symbol; fn: ts.Symbol; index: number }[] { + const out: { symbol: ts.Symbol; fn: ts.Symbol; index: number }[] = []; + for (const [fn, decl] of this.functions) { + for (let index = 0; index < decl.parameters.length; index++) { + const symbol = this.host.symbolOf(decl.parameters[index]!.name); + if (symbol && this.bindingSlots.has(symbol)) out.push({ symbol, fn, index }); + } + } + return out; + } + + /** The class id of a visible function's record return, or null. */ + returnClass(symbol: ts.Symbol): number | null { + const slot = this.returnSlots.get(symbol); + return slot === undefined ? null : this.find(slot); + } + + /** The declaration of a visible function or method, whose parameters + * and return are tracked slots. */ + visibleFunction(symbol: ts.Symbol): Callable | undefined { + return this.functions.get(symbol); + } + + /** A class can take a widened layout: nothing outside it reaches its + * members and they all declare one record shape. */ + closed(classId: number): boolean { + const root = this.find(classId); + if (this.open[root]) return false; + const cached = this.shapeChecked.get(root); + if (cached !== undefined) return cached; + let shape: string | null | undefined; + let uniform = true; + for (let slot = 0; slot < this.parent.length && uniform; slot++) { + if (this.find(slot) !== root) continue; + const node = this.slotNodes[slot]!; + const own = this.slotReturns[slot] + ? this.host.returnShape(node as ts.FunctionDeclaration) + : this.host.recordShape(node); + // Members of different shapes convert between each other. + if (own === null || (shape !== undefined && own !== shape)) uniform = false; + shape = own; + } + this.shapeChecked.set(root, uniform); + return uniform; + } + + private newSlot(node: ts.Node, isReturn = false): number { + const id = this.parent.length; + this.parent.push(id); + this.open.push(false); + this.slotNodes.push(node); + this.slotReturns.push(isReturn); + return id; + } + + private find(slot: number): number { + let root = slot; + while (this.parent[root] !== root) root = this.parent[root]!; + while (this.parent[slot] !== root) { + const next = this.parent[slot]!; + this.parent[slot] = root; + slot = next; + } + return root; + } + + private union(a: number, b: number): void { + const ra = this.find(a); + const rb = this.find(b); + if (ra === rb) return; + this.parent[rb] = ra; + this.open[ra] = this.open[ra]! || this.open[rb]!; + } + + private markOpen(slot: number): void { + this.open[this.find(slot)] = true; + } + + private index(): void { + for (const file of this.files) { + ts.walkPreorder(file, (node) => { + if (ts.isElementAccessExpression(node)) { + const key = peel(node.argumentExpression); + if (ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)) + this.literalKeys.add(key.text); + return undefined; + } + if (!ts.isIdentifier(node)) return undefined; + if (node.parent && ts.isShorthandPropertyAssignment(node.parent)) + this.shorthandNames.add(node.text); + const list = this.names.get(node.text); + if (list) list.push(node); + else this.names.set(node.text, [node]); + return undefined; + }); + } + } + + /** The identifiers that name `symbol`, its declaration included. */ + private referencesOf(symbol: ts.Symbol): readonly ts.Identifier[] { + let list = this.references.get(symbol); + if (!list) { + list = (this.names.get(symbol.name) ?? []).filter( + (node) => this.host.symbolOf(node) === symbol, + ); + this.references.set(symbol, list); + } + return list; + } + + private collectFunctions(): void { + for (const file of this.files) { + if (file.isDeclarationFile || isJsFile(file)) continue; + ts.walkPreorder(file, (node) => { + if (!ts.isFunctionDeclaration(node) || !node.name || !node.body) return undefined; + if (node.asteriskToken || node.typeParameters?.length) return undefined; + const flags = ts.getCombinedModifierFlags(node); + if (flags & (ts.ModifierFlags.Export | ts.ModifierFlags.Async | ts.ModifierFlags.Ambient)) + return undefined; + if (node.parameters.some((p) => p.dotDotDotToken || !ts.isIdentifier(p.name))) + return undefined; + if (usesArguments(node.body)) return undefined; + const symbol = this.host.symbolOf(node.name); + if (!symbol || (symbol.declarations?.length ?? 0) !== 1) return undefined; + const calledByName = this.referencesOf(symbol).every((reference) => { + if (reference === node.name) return true; + const call = reference.parent; + return ( + !!call && + ts.isCallExpression(call) && + call.expression === reference && + !call.arguments.some(ts.isSpreadElement) + ); + }); + if (calledByName) { + this.functions.set(symbol, node); + this.families.set(symbol, [symbol]); + } + return undefined; + }); + } + this.collectMethods(); + } + + /** Methods whose every member is a plain body and whose name no other + * declaration, dynamic access or literal key shares: then every call that + * can dispatch to the slot names a member directly. */ + /** The class extends, directly or through its bases, a class not + * declared in the program's own sources. */ + private extendsForeignClass(decl: ts.ClassDeclaration): boolean { + const seen = new Set(); + let current: ts.ClassDeclaration = decl; + for (;;) { + if (seen.has(current)) return true; + seen.add(current); + const heritage = current.heritageClauses?.find( + (clause) => clause.token === ts.SyntaxKind.ExtendsKeyword, + ); + const base = heritage?.types[0]?.expression; + if (!base) return false; + const symbol = ts.isIdentifier(base) ? this.host.symbolOf(base) : null; + const target = symbol ? this.host.declarationOf(symbol) : undefined; + if (!target || !ts.isClassDeclaration(target)) return true; + const file = target.getSourceFile(); + if (file.isDeclarationFile || isJsFile(file)) return true; + current = target; + } + } + + private collectMethods(): void { + const candidates = new Map(); + for (const file of this.files) { + if (file.isDeclarationFile || isJsFile(file)) continue; + ts.walkPreorder(file, (node) => { + if (!ts.isMethodDeclaration(node) || !ts.isIdentifier(node.name) || !node.body) + return undefined; + const owner = node.parent; + if (!owner || !ts.isClassDeclaration(owner)) return undefined; + if ( + ts.getCombinedModifierFlags(owner) & + (ts.ModifierFlags.Export | ts.ModifierFlags.Ambient) + ) + return undefined; + // The runtime calls protocol methods and the overrides of its own + // classes (`_read`, `handleEvent`) with no call site to see. + if (PROTOCOL_METHODS.has(node.name.text) || this.extendsForeignClass(owner)) + return undefined; + if (node.asteriskToken || node.typeParameters?.length) return undefined; + if ( + ts.getCombinedModifierFlags(node) & + (ts.ModifierFlags.Async | ts.ModifierFlags.Abstract) + ) + return undefined; + if (node.parameters.some((p) => p.dotDotDotToken || !ts.isIdentifier(p.name))) + return undefined; + if (usesArguments(node.body)) return undefined; + const symbol = this.host.symbolOf(node.name); + if (symbol && (symbol.declarations?.length ?? 0) === 1) candidates.set(symbol, node); + return undefined; + }); + } + const decided = new Set(); + for (const [symbol, decl] of candidates) { + if (decided.has(symbol)) continue; + const family = this.host.familyOf(symbol) ?? [symbol]; + for (const member of family) decided.add(member); + if (!family.every((member) => candidates.has(member))) continue; + const name = (decl.name as ts.Identifier).text; + if (this.literalKeys.has(name)) continue; + const members = new Set(family); + const direct = (this.names.get(name) ?? []).every((id) => { + const parent = id.parent; + const owner = this.host.symbolOf(id); + if (owner && members.has(owner)) { + if (parent && ts.isMethodDeclaration(parent) && parent.name === id) return true; + const call = parent?.parent; + return ( + !!parent && + ts.isPropertyAccessExpression(parent) && + parent.name === id && + !!call && + ts.isCallExpression(call) && + call.expression === parent && + !call.arguments.some(ts.isSpreadElement) + ); + } + // Any other member, property or key of this name could reach the + // slot through a structural or dynamic view of an instance. + return !parent || !isMemberName(id, parent); + }); + if (!direct) continue; + for (const member of family) { + this.functions.set(member, candidates.get(member)!); + this.families.set(member, family); + } + } + } + + private collectSlots(): void { + const names: ts.Identifier[] = []; + for (const file of this.files) { + if (file.isDeclarationFile || isJsFile(file)) continue; + ts.walkPreorder(file, (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) names.push(node.name); + return undefined; + }); + } + // The scan runs before reachability is known, so it asks about bodies + // lowering may skip; one batch answers them. + this.host.prefetchTypes(names); + for (const name of names) { + if (!this.host.mayBeRecord(name)) continue; + const symbol = this.host.symbolOf(name); + if (symbol) this.bindingSlots.set(symbol, this.newSlot(name)); + } + for (const [symbol, decl] of this.functions) { + for (const parameter of decl.parameters) { + if (!this.host.mayBeRecord(parameter.name)) continue; + const bound = this.host.symbolOf(parameter.name); + if (bound) this.bindingSlots.set(bound, this.newSlot(parameter.name)); + } + if (this.host.mayReturnRecord(decl)) this.returnSlots.set(symbol, this.newSlot(decl, true)); + } + // A dispatch slot has one ABI: members share each parameter's and the + // result's layout. + for (const [symbol, family] of this.families) { + if (family[0] !== symbol || family.length < 2) continue; + const decls = family.map((member) => this.functions.get(member)!); + const first = decls[0]!; + for (let i = 0; i < first.parameters.length; i++) { + const head = this.slotOf(first.parameters[i]!.name); + for (const decl of decls.slice(1)) { + const other = decl.parameters[i] ? this.slotOf(decl.parameters[i]!.name) : undefined; + if (head !== undefined && other !== undefined) this.union(head, other); + else if (head !== undefined) this.markOpen(head); + else if (other !== undefined) this.markOpen(other); + } + } + const results = family.map((member) => this.returnSlots.get(member)); + for (const result of results.slice(1)) { + if (results[0] !== undefined && result !== undefined) this.union(results[0], result); + else if (results[0] !== undefined) this.markOpen(results[0]); + else if (result !== undefined) this.markOpen(result); + } + } + } + + private slotOf(name: ts.Node): number | undefined { + const symbol = this.host.symbolOf(name); + return symbol ? this.bindingSlots.get(symbol) : undefined; + } + + /** The calls that can reach a visible function or method, with the + * callee expression each one names. */ + private callsOf(fn: ts.Symbol): ts.CallExpression[] { + const out: ts.CallExpression[] = []; + for (const member of this.families.get(fn) ?? [fn]) { + for (const reference of this.referencesOf(member)) { + const parent = reference.parent; + if (!parent) continue; + const callee = + ts.isPropertyAccessExpression(parent) && parent.name === reference ? parent : reference; + const call = callee.parent; + if (call && ts.isCallExpression(call) && call.expression === callee) out.push(call); + } + } + return out; + } + + private connect(): void { + for (const [symbol, slot] of this.bindingSlots) { + const declaration = this.host.declarationOf(symbol); + if (declaration && ts.isVariableDeclaration(declaration)) { + const list = declaration.parent; + const statement = list?.parent; + if ( + !list || + !ts.isVariableDeclarationList(list) || + !statement || + !ts.isVariableStatement(statement) || + ts.getCombinedModifierFlags(declaration) & ts.ModifierFlags.Export + ) { + this.markOpen(slot); + } else if (declaration.initializer) { + this.source(declaration.initializer, slot); + } + } else if (declaration && ts.isParameter(declaration)) { + if (declaration.questionToken) this.markOpen(slot); + if (declaration.initializer) this.source(declaration.initializer, slot); + const fn = declaration.parent as Callable; + const index = fn.parameters.indexOf(declaration); + const fnSymbol = fn.name ? this.host.symbolOf(fn.name) : null; + for (const call of fnSymbol ? this.callsOf(fnSymbol) : []) { + const arg = call.arguments[index]; + if (arg) this.source(arg, slot); + else if (!declaration.initializer) this.markOpen(slot); + } + } else { + this.markOpen(slot); + } + if (this.shorthandNames.has(symbol.name)) this.markOpen(slot); + for (const reference of this.referencesOf(symbol)) { + if (declaration && (declaration as { name?: ts.Node }).name === reference) continue; + if (!this.use(reference, slot)) this.markOpen(slot); + } + } + for (const [symbol, slot] of this.returnSlots) { + const decl = this.functions.get(symbol)!; + for (const expression of returnExpressions(decl)) this.source(expression, slot); + } + } + + /** Joins the slot with the class a value comes from, or opens the slot + * when the value can be an object some other storage already holds. */ + private source(node: ts.Expression, slot: number): void { + const e = peel(node); + if (ts.isObjectLiteralExpression(e)) return; + if (ts.isIdentifier(e)) { + const symbol = this.host.symbolOf(e); + const other = symbol ? this.bindingSlots.get(symbol) : undefined; + if (other !== undefined) this.union(slot, other); + else this.markOpen(slot); + return; + } + if ( + ts.isCallExpression(e) && + (ts.isIdentifier(e.expression) || ts.isPropertyAccessExpression(e.expression)) + ) { + const callee = ts.isIdentifier(e.expression) ? e.expression : e.expression.name; + const symbol = this.host.symbolOf(callee); + const other = symbol ? this.returnSlots.get(symbol) : undefined; + if (other !== undefined) this.union(slot, other); + else this.markOpen(slot); + return; + } + if (ts.isConditionalExpression(e)) { + this.source(e.whenTrue, slot); + this.source(e.whenFalse, slot); + return; + } + if (ts.isBinaryExpression(e)) { + const op = e.operatorToken.kind; + if ( + op === ts.SyntaxKind.QuestionQuestionToken || + op === ts.SyntaxKind.BarBarToken || + op === ts.SyntaxKind.AmpersandAmpersandToken + ) { + this.source(e.left, slot); + this.source(e.right, slot); + return; + } + if (op === ts.SyntaxKind.CommaToken) { + this.source(e.right, slot); + return; + } + } + this.markOpen(slot); + } + + /** True when this reference to a member only reads or writes its fields, + * passes the record to another member (a flow `source` joins), or + * inspects it without keeping or converting it. */ + private use(reference: ts.Identifier, slot: number): boolean { + let value: ts.Node = reference; + for (;;) { + const user = value.parent; + if (!user) return false; + if ( + ts.isParenthesizedExpression(user) || + ts.isNonNullExpression(user) || + ts.isAsExpression(user) || + ts.isSatisfiesExpression(user) || + ts.isTypeAssertion(user) + ) { + value = user; + continue; + } + if (ts.isPropertyAccessExpression(user) && user.expression === value) return true; + if (ts.isElementAccessExpression(user) && user.expression === value) return true; + if (ts.isSpreadAssignment(user)) return true; + if (ts.isTemplateSpan(user) || ts.isTypeOfExpression(user)) return true; + if (ts.isExpressionStatement(user)) return true; + if (ts.isPrefixUnaryExpression(user) && user.operator === ts.SyntaxKind.ExclamationToken) + return true; + if ( + (ts.isIfStatement(user) || ts.isWhileStatement(user) || ts.isDoStatement(user)) && + user.expression === value + ) + return true; + if (ts.isForInStatement(user) && user.expression === value) return true; + if (ts.isConditionalExpression(user)) { + if (user.condition === value) return true; + value = user; + continue; + } + if (ts.isBinaryExpression(user)) { + const op = user.operatorToken.kind; + if ( + op === ts.SyntaxKind.QuestionQuestionToken || + op === ts.SyntaxKind.BarBarToken || + op === ts.SyntaxKind.AmpersandAmpersandToken + ) { + // The record can become the result: follow the result's use. + value = user; + continue; + } + if (op === ts.SyntaxKind.CommaToken) { + if (user.left === value) return true; + value = user; + continue; + } + if ( + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken || + op === ts.SyntaxKind.EqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsToken + ) { + const other = user.left === value ? user.right : user.left; + const e = peel(other); + if (e.kind === ts.SyntaxKind.NullKeyword || isUndefined(e)) return true; + // Comparing identity needs both records in one layout. + this.source(other, slot); + return true; + } + const assigns = + op === ts.SyntaxKind.EqualsToken || + op === ts.SyntaxKind.QuestionQuestionEqualsToken || + op === ts.SyntaxKind.BarBarEqualsToken || + op === ts.SyntaxKind.AmpersandAmpersandEqualsToken; + // A record assigned to a member joins it when the member's own + // reference is visited, below. + if (assigns && user.right === value) return this.isMember(user.left); + if (assigns && user.left === value && value === reference) { + this.source(user.right, slot); + return true; + } + if (op === ts.SyntaxKind.InKeyword || op === ts.SyntaxKind.InstanceOfKeyword) return true; + return false; + } + if (ts.isVariableDeclaration(user) && user.initializer === value) { + if (ts.isObjectBindingPattern(user.name)) return true; + return this.isMember(user.name); + } + if (ts.isReturnStatement(user)) { + const fn = enclosingFunction(user); + const symbol = + fn && (ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn)) && fn.name + ? this.host.symbolOf(fn.name) + : null; + return !!symbol && this.returnSlots.has(symbol); + } + if (ts.isCallExpression(user) && user.expression !== value) { + const callee = peel(user.expression); + if (ts.isIdentifier(callee)) { + if (READING_FUNCTIONS.has(callee.text) && this.host.isGlobal(callee, callee.text)) + return true; + const symbol = this.host.symbolOf(callee); + const fn = symbol ? this.functions.get(symbol) : undefined; + if (!fn) return false; + const parameter = fn.parameters[user.arguments.indexOf(value as ts.Expression)]; + return !!parameter && this.isMember(parameter.name); + } + if (ts.isPropertyAccessExpression(callee)) { + const symbol = this.host.symbolOf(callee.name); + const method = symbol ? this.functions.get(symbol) : undefined; + if (method) { + const parameter = method.parameters[user.arguments.indexOf(value as ts.Expression)]; + return !!parameter && this.isMember(parameter.name); + } + } + if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) { + const methods = READING_CALLS.get(callee.expression.text); + return ( + !!methods && + methods.has(callee.name.text) && + this.host.isGlobal(callee.expression, callee.expression.text) + ); + } + return false; + } + return false; + } + } + + private isMember(node: ts.Node): boolean { + if (!ts.isIdentifier(node)) return false; + const symbol = this.host.symbolOf(node); + return !!symbol && this.bindingSlots.has(symbol); + } +} + +/** The return expressions of a function body, excluding nested functions. */ +export function returnExpressions(fn: ts.FunctionLikeDeclaration): ts.Expression[] { + const out: ts.Expression[] = []; + const visit = (node: ts.Node): void => { + if (ts.isReturnStatement(node)) { + if (node.expression) out.push(node.expression); + return; + } + if (ts.isFunctionLike(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node)) + return; + node.forEachChild(visit); + }; + fn.body?.forEachChild(visit); + return out; +} + +/** The identifier names a member: a property, method or accessor in a + * class, interface, type or object literal, a property read, or an object + * pattern key. */ +function isMemberName(id: ts.Identifier, parent: ts.Node): boolean { + if (ts.isPropertyAccessExpression(parent)) return parent.name === id; + if (ts.isBindingElement(parent)) + return ( + parent.propertyName === id || + (!parent.propertyName && parent.name === id && ts.isObjectBindingPattern(parent.parent)) + ); + return ( + (ts.isMethodDeclaration(parent) || + ts.isPropertyDeclaration(parent) || + ts.isMethodSignatureDeclaration(parent) || + ts.isPropertySignatureDeclaration(parent) || + ts.isGetAccessorDeclaration(parent) || + ts.isSetAccessorDeclaration(parent) || + ts.isPropertyAssignment(parent) || + ts.isShorthandPropertyAssignment(parent)) && + (parent as { name?: ts.Node }).name === id + ); +} + +function enclosingFunction(node: ts.Node): ts.FunctionLikeDeclaration | null { + for (let n = node.parent; n; n = n.parent) + if (ts.isFunctionLike(n)) return n as ts.FunctionLikeDeclaration; + return null; +} + +function usesArguments(body: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (found) return; + if (ts.isIdentifier(node) && node.text === "arguments") found = true; + else if (!ts.isFunctionDeclaration(node) && !ts.isFunctionExpression(node)) + node.forEachChild(visit); + }; + body.forEachChild(visit); + return found; +} + +function isUndefined(node: ts.Expression): boolean { + return ts.isIdentifier(node) && node.text === "undefined"; +} + +function isJsFile(file: ts.SourceFile): boolean { + return /\.(c|m)?jsx?$/.test(file.fileName); +} + +function peel(node: ts.Expression): ts.Expression { + let e = node; + while ( + ts.isParenthesizedExpression(e) || + ts.isAsExpression(e) || + ts.isTypeAssertion(e) || + ts.isSatisfiesExpression(e) || + ts.isNonNullExpression(e) + ) + e = e.expression; + return e; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5325-record-boundary-returns.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5325-record-boundary-returns.ts.json new file mode 100644 index 0000000000..c8330f66b4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5325-record-boundary-returns.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5325-record-boundary-returns.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5326-record-boundary-params.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5326-record-boundary-params.ts.json new file mode 100644 index 0000000000..c65c2f12f0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5326-record-boundary-params.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5326-record-boundary-params.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5327-record-boundary-identity.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5327-record-boundary-identity.ts.json new file mode 100644 index 0000000000..62218096c2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5327-record-boundary-identity.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5327-record-boundary-identity.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5328-record-boundary-open.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5328-record-boundary-open.ts.json new file mode 100644 index 0000000000..32388b1db2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5328-record-boundary-open.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5328-record-boundary-open.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5329-record-boundary-recursion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5329-record-boundary-recursion.ts.json new file mode 100644 index 0000000000..54c3d67fd7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5329-record-boundary-recursion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5329-record-boundary-recursion.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5330-record-boundary-fields.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5330-record-boundary-fields.ts.json new file mode 100644 index 0000000000..fcba082fec --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5330-record-boundary-fields.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5330-record-boundary-fields.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5331-record-boundary-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5331-record-boundary-methods.ts.json new file mode 100644 index 0000000000..b6fe601add --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5331-record-boundary-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5331-record-boundary-methods.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5325-record-boundary-returns.ts b/tests/corpus/5325-record-boundary-returns.ts new file mode 100644 index 0000000000..3b12f39d70 --- /dev/null +++ b/tests/corpus/5325-record-boundary-returns.ts @@ -0,0 +1,37 @@ +// Functions whose annotated or inferred return is a record built from +// unchecked element reads return `undefined` in each field a missing +// element leaves empty, as Node does. +interface Pair { + left: number; + right: number; +} +function pairAt(values: number[], i: number): Pair { + return { left: values[i], right: values[i + 1] }; +} +function choose(values: number[], i: number, flip: boolean): Pair { + return flip ? { left: values[i + 1], right: values[i] } : pairAt(values, i); +} +function inferred(values: number[], i: number) { + return { left: values[i - 1], right: values[i] }; +} +function forward(values: number[], i: number): Pair { + const pair = pairAt(values, i); + return pair; +} + +const values = [3, 1, 4]; +for (let i = 0; i < 4; i++) { + const p = pairAt(values, i); + const c = choose(values, i, i % 2 === 1); + const q = inferred(values, i); + const f = forward(values, i); + console.log(p.left, p.right, c.left ?? "none", c.right ?? "none", q.left, f.right); + console.log(JSON.stringify(p), JSON.stringify(q), String(p.right + 1), p.right === undefined); +} + +let total = 0; +for (let i = 0; i < 5; i++) { + const p = pairAt(values, i); + total += (p.left ?? 0) * 10 + (p.right ?? 0); +} +console.log(total); diff --git a/tests/corpus/5326-record-boundary-params.ts b/tests/corpus/5326-record-boundary-params.ts new file mode 100644 index 0000000000..d4effe5838 --- /dev/null +++ b/tests/corpus/5326-record-boundary-params.ts @@ -0,0 +1,32 @@ +// Record parameters receive literals built from unchecked reads, call +// results and bindings; each missing element reads as undefined inside +// the callee. +interface Sample { + value: number; + weight: number; + label: string; +} +function describe(s: Sample): string { + const scaled = s.value * (s.weight ?? 1); + return `${s.label}:${s.value ?? "?"}:${s.weight}:${Number.isNaN(scaled) ? "nan" : scaled}`; +} +function heavier(a: Sample, b: Sample): Sample { + return (a.weight ?? 0) >= (b.weight ?? 0) ? a : b; +} +function sampleAt(values: number[], weights: number[], i: number): Sample { + return { value: values[i], weight: weights[i], label: `s${i}` }; +} + +const values = [5, 6, 7]; +const weights = [0.5, 2]; +for (let i = 0; i < 4; i++) { + console.log(describe({ value: values[i], weight: weights[i], label: `lit${i}` })); + const made = sampleAt(values, weights, i); + console.log(describe(made), describe(heavier(made, { value: 1, weight: 1, label: "one" }))); +} +const fixed: Sample = { value: 9, weight: 3, label: "fixed" }; +console.log(describe(fixed), describe(heavier(fixed, sampleAt(values, weights, 3))).length); +console.log( + [0, 1, 2, 3].map((i) => describe(sampleAt(values, weights, i))).join(" | "), + JSON.stringify(sampleAt(values, weights, 2)), +); diff --git a/tests/corpus/5327-record-boundary-identity.ts b/tests/corpus/5327-record-boundary-identity.ts new file mode 100644 index 0000000000..5bc04fba36 --- /dev/null +++ b/tests/corpus/5327-record-boundary-identity.ts @@ -0,0 +1,35 @@ +// A record keeps its identity across parameters and returns that some +// caller fills from unchecked reads: writes through a parameter reach the +// caller's object, and a returned argument is the same object. +interface Counter { + count: number; + name: string; +} +function bump(c: Counter): number { + c.count = (c.count ?? 0) + 1; + return c.count; +} +function same(c: Counter): Counter { + return c; +} +function fresh(counts: number[], i: number): Counter { + return { count: counts[i], name: `c${i}` }; +} + +const counts = [10]; +for (let i = 0; i < 2; i++) { + const c = fresh(counts, i); + console.log(bump(c), c.count, same(c) === c); +} +const plain: Counter = { count: 5, name: "plain" }; +console.log(bump(plain), plain.count); +const alias = same(plain); +alias.count = 40; +console.log(plain.count, alias === plain, bump(alias), plain.count); +let pick = fresh(counts, 0); +if (pick.count === 10) pick = plain; +console.log(bump(pick), plain.count, pick === plain); +const literal = { count: counts[3], name: "lit" }; +bump(literal); +bump(literal); +console.log(literal.count, literal.name); diff --git a/tests/corpus/5328-record-boundary-open.ts b/tests/corpus/5328-record-boundary-open.ts new file mode 100644 index 0000000000..1aa6d39404 --- /dev/null +++ b/tests/corpus/5328-record-boundary-open.ts @@ -0,0 +1,28 @@ +// Records that reach storage outside their parameters and bindings (an +// array, an object field, a callback) keep their declared layout and their +// identity. These inputs hold every element they read. +interface Cell { + row: number; + col: number; +} +function store(cell: Cell, out: Cell[]): Cell { + out.push(cell); + return cell; +} +function key(cell: Cell): string { + return `${cell.row},${cell.col}`; +} +const rows = [1, 2, 3]; +const cols = [4, 5, 6]; +const out: Cell[] = []; +for (let i = 0; i < 3; i++) { + const cell = { row: rows[i], col: cols[i] }; + const kept = store(cell, out); + cell.col += 10; + console.log(kept === cell, out[i] === cell, key(out[i]!)); +} +const holder: { cell: Cell } = { cell: { row: rows[0], col: cols[0] } }; +const view = holder.cell; +view.row = 99; +console.log(holder.cell.row, view === holder.cell); +console.log(out.map(key).join(";"), key({ row: rows[1], col: cols[2] })); diff --git a/tests/corpus/5329-record-boundary-recursion.ts b/tests/corpus/5329-record-boundary-recursion.ts new file mode 100644 index 0000000000..94f6250844 --- /dev/null +++ b/tests/corpus/5329-record-boundary-recursion.ts @@ -0,0 +1,25 @@ +// Widened records through recursive and nested calls: each level builds +// its argument from unchecked reads that run past the end of the input. +interface Span { + lo: number; + hi: number; +} +function extend(span: Span, values: number[], i: number): Span { + if (i > values.length) return span; + const next: Span = { lo: Math.min(span.lo ?? Infinity, values[i] ?? Infinity), hi: values[i + 1] }; + return extend(next, values, i + 1); +} +function size(span: Span): number { + return (span.hi ?? span.lo ?? 0) - (span.lo ?? 0); +} +function even(span: Span, n: number): boolean { + return n === 0 ? span.hi === undefined : odd({ lo: span.lo, hi: span.hi }, n - 1); +} +function odd(span: Span, n: number): boolean { + return n === 0 ? span.hi !== undefined : even(span, n - 1); +} +const values = [7, 3, 9, 1]; +const result = extend({ lo: values[0], hi: values[1] }, values, 0); +console.log(result.lo, result.hi, size(result), JSON.stringify(result)); +console.log(size(extend({ lo: 2, hi: 5 }, values, 2)), even(result, 3), odd(result, 2)); +for (let i = 0; i < 6; i++) console.log(size({ lo: values[i], hi: values[i + 2] })); diff --git a/tests/corpus/5330-record-boundary-fields.ts b/tests/corpus/5330-record-boundary-fields.ts new file mode 100644 index 0000000000..ea22876398 --- /dev/null +++ b/tests/corpus/5330-record-boundary-fields.ts @@ -0,0 +1,24 @@ +// Boolean and string fields from unchecked reads cross the same +// boundaries; spreads, destructuring and keyed reads of the widened +// records see undefined where an element is missing. +interface Entry { + name: string; + active: boolean; + rank: number; +} +function entryAt(names: string[], flags: boolean[], ranks: number[], i: number): Entry { + return { name: names[i], active: flags[i], rank: ranks[i] }; +} +function render(e: Entry): string { + const { name, active } = e; + const copy = { ...e, seen: true }; + const keys = Object.keys(copy).join("/"); + return `${name ?? "anon"}|${active === undefined ? "?" : active}|${e["rank"]}|${keys}|${JSON.stringify(copy)}`; +} +const names = ["ada", "bo"]; +const flags = [true]; +const ranks = [1, 2, 3]; +for (let i = 0; i < 4; i++) { + console.log(render(entryAt(names, flags, ranks, i))); + console.log(render({ name: names[i], active: flags[i] ?? false, rank: ranks[i] })); +} diff --git a/tests/corpus/5331-record-boundary-methods.ts b/tests/corpus/5331-record-boundary-methods.ts new file mode 100644 index 0000000000..c2c7cebb37 --- /dev/null +++ b/tests/corpus/5331-record-boundary-methods.ts @@ -0,0 +1,36 @@ +// Method parameters and results share one layout across their dispatch +// slot. A record built from unchecked reads reaches a method with its +// missing fields undefined, and writes through the parameter reach the +// caller's object. +interface Reading { + value: number; + scale: number; +} +class Meter { + total = 0; + record(r: Reading): number { + this.total += (r.value ?? 0) * (r.scale ?? 1); + r.value = (r.value ?? 0) + 1; + return r.value; + } + readingAt(values: number[], scales: number[], i: number): Reading { + return { value: values[i], scale: scales[i] }; + } +} +class DoubleMeter extends Meter { + record(r: Reading): number { + return super.record(r) * 2; + } +} +const values = [3, 4]; +const scales = [10]; +const meters: Meter[] = [new Meter(), new DoubleMeter()]; +for (let i = 0; i < 3; i++) { + for (const meter of meters) { + const literal = { value: values[i], scale: scales[i] }; + const made = meter.readingAt(values, scales, i); + console.log(meter.record(literal), literal.value, meter.record(made), made.value, made.scale); + } +} +const plain: Reading = { value: 1, scale: 2 }; +console.log(meters[1]!.record(plain), plain.value, meters.map((m) => m.total).join(",")); diff --git a/tests/harness/wasi-differential.test.ts b/tests/harness/wasi-differential.test.ts index 8b44bcfa53..68fdced841 100644 --- a/tests/harness/wasi-differential.test.ts +++ b/tests/harness/wasi-differential.test.ts @@ -185,6 +185,8 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => { "5232-inline-union-fields-dynamic.ts", "5234-inline-union-class-fields.ts", "5237-inline-union-frame-objects.ts", + "5327-record-boundary-identity.ts", + "5331-record-boundary-methods.ts", "1020-async-basics.ts", "1021-async-ordering.ts", "1022-async-exceptions.ts",