From b072f22c4e60b7e0b9097d5a301fbb75cf29c11b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 14 Oct 2026 21:07:40 -0500 Subject: [PATCH] Widen record parameters and returns by flow class A record built from unchecked element reads can hold undefined in a field the checker calls present. Such a record kept its widened layout only in an inferred binding: an annotated parameter or return, or an unannotated return, converted it to the declared layout with a checked narrow, which throws a TypeError for a missing element where Node passes undefined through. Record values that flow between bindings and the parameters and results of functions and methods whose every call is visible now form flow classes. A class takes the widened layout, with the union of its members' optional fields, when it is closed: each value entering it is a fresh object literal or comes from a member, each 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. Members then share one layout, so no conversion runs at the boundary and the record keeps its identity. A class that reaches other storage keeps the declared layout for every member, including the inferred bindings and the record parameters the analysis widened before. Converting there copied the object, so writes through a parameter or a pushed record did not reach the caller's object; such a literal now converts once where it is created. --- benchmarks/runtime/README.md | 1 + benchmarks/runtime/workloads.json | 7 + .../runtime/workloads/record-boundaries.ts | 78 ++ .../src/frontend/lowering/lower-stmts.ts | 2 + .../compiler/src/frontend/lowering/lowerer.ts | 227 +++++- .../lowering/runtime-optional-records.test.ts | 185 +++++ .../lowering/runtime-optional-records.ts | 727 ++++++++++++++++++ .../5325-record-boundary-returns.ts.json | 6 + .../5326-record-boundary-params.ts.json | 6 + .../5327-record-boundary-identity.ts.json | 6 + .../corpus/5328-record-boundary-open.ts.json | 6 + .../5329-record-boundary-recursion.ts.json | 6 + .../5330-record-boundary-fields.ts.json | 6 + .../5331-record-boundary-methods.ts.json | 6 + tests/corpus/5325-record-boundary-returns.ts | 37 + tests/corpus/5326-record-boundary-params.ts | 32 + tests/corpus/5327-record-boundary-identity.ts | 35 + tests/corpus/5328-record-boundary-open.ts | 28 + .../corpus/5329-record-boundary-recursion.ts | 25 + tests/corpus/5330-record-boundary-fields.ts | 24 + tests/corpus/5331-record-boundary-methods.ts | 36 + tests/harness/wasi-differential.test.ts | 2 + 22 files changed, 1452 insertions(+), 36 deletions(-) create mode 100644 benchmarks/runtime/workloads/record-boundaries.ts create mode 100644 packages/compiler/src/frontend/lowering/runtime-optional-records.test.ts create mode 100644 packages/compiler/src/frontend/lowering/runtime-optional-records.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5325-record-boundary-returns.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5326-record-boundary-params.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5327-record-boundary-identity.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5328-record-boundary-open.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5329-record-boundary-recursion.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5330-record-boundary-fields.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5331-record-boundary-methods.ts.json create mode 100644 tests/corpus/5325-record-boundary-returns.ts create mode 100644 tests/corpus/5326-record-boundary-params.ts create mode 100644 tests/corpus/5327-record-boundary-identity.ts create mode 100644 tests/corpus/5328-record-boundary-open.ts create mode 100644 tests/corpus/5329-record-boundary-recursion.ts create mode 100644 tests/corpus/5330-record-boundary-fields.ts create mode 100644 tests/corpus/5331-record-boundary-methods.ts 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 9f16280239..e2de9d81d7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -75,6 +75,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"; @@ -1797,6 +1798,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(); @@ -3803,6 +3808,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, @@ -4080,10 +4140,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; }; @@ -4178,9 +4236,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); @@ -5221,27 +5310,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; @@ -5323,7 +5405,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); @@ -5496,6 +5596,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) { @@ -5576,7 +5740,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; @@ -5593,16 +5757,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 87db3d5335..67feea73e1 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",