diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ae91a87d5..e737eca64 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -6309,7 +6309,110 @@ export class Lowerer { }); return keys; }; + // Element ABIs of the native arrays a value converted to a dynamic type + // can reach: the conversion keeps each such array as a live view, so + // stores through the dynamic value write the native storage. + const collectNativeViews = (): Set => { + const keys = new Set(); + const seen = new Set(); + const collect = (type: IrType | null): void => { + if (!type) return; + const id = typeKey(type); + if (seen.has(id)) return; + seen.add(id); + if (type.kind === "array") { + if (type.elem.kind !== "dyn") keys.add(typeKey(type.elem)); + collect(type.elem); + } else if (type.kind === "union") { + for (const arm of this.unions.get(type.unionId)?.arms ?? []) collect(arm); + } else if (type.kind === "record") { + for (const field of this.shapes.get(type.shapeId)?.fields ?? []) collect(field.type); + } else if (type.kind === "object") { + for (const field of this.classes.get(type.className)?.fields.values() ?? []) + collect(field); + } else if (type.kind === "map") { + collect(type.key); + collect(type.value); + } else if (type.kind === "set") collect(type.elem); + else if (type.kind === "promise") collect(type.inner); + }; + const holdsDynamic = (type: IrType | null, depth: number): boolean => { + if (!type || depth > 4) return false; + if (type.kind === "dyn") return true; + if (type.kind === "array" || type.kind === "set") return holdsDynamic(type.elem, depth + 1); + if (type.kind === "map") return holdsDynamic(type.value, depth + 1); + if (type.kind === "promise") return holdsDynamic(type.inner, depth + 1); + if (type.kind === "union") + return (this.unions.get(type.unionId)?.arms ?? []).some((arm) => + holdsDynamic(arm, depth + 1), + ); + if (type.kind === "record") + return (this.shapes.get(type.shapeId)?.fields ?? []).some((field) => + holdsDynamic(field.type, depth + 1), + ); + return false; + }; + const mapped = (type: ts.Type | null | undefined): IrType | null => + type ? panicSafe(() => this.mapTypeOf(type), null) : null; + const flow = (source: ts.Expression, target: ts.Type | null | undefined): void => { + if (target === undefined) return; + const into = target === null ? null : mapped(target); + // An unanswerable target may be dynamic. + if (target !== null && into !== null && !holdsDynamic(into, 0)) return; + const from = typeOrNull(source); + if (from === null) keys.add("*"); + else collect(mapped(from)); + }; + const visit = (node: ts.Node): void => { + if (ts.isAsExpression(node) || ts.isTypeAssertion(node)) + flow(node.expression, typeOrNull(node)); + else if (ts.isVariableDeclaration(node) && node.type && node.initializer) + flow(node.initializer, typeOrNull(node.name)); + else if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) + flow(node.right, typeOrNull(node.left)); + else if (ts.isReturnStatement(node) && node.expression) + flow(node.expression, contextualOrNull(node.expression)); + else if (ts.isCallExpression(node) || ts.isNewExpression(node)) { + for (const arg of node.arguments?.nodes ?? []) + flow(ts.isSpreadElement(arg) ? arg.expression : arg, contextualOrNull(arg)); + } else if (ts.isPropertyAssignment(node)) + flow(node.initializer, contextualOrNull(node.initializer)); + else if (ts.isShorthandPropertyAssignment(node)) { + const name = node.name as ts.Expression; + flow(name, contextualOrNull(name)); + } else if (ts.isArrayLiteralExpression(node)) { + for (const element of node.elements.nodes) + if (!ts.isSpreadElement(element) && !ts.isOmittedExpression(element)) + flow(element, contextualOrNull(element)); + } else if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) + flow(node.body as ts.Expression, contextualOrNull(node.body as ts.Expression)); + }; + for (const sf of sourceFiles) + ts.walkPreorder(sf, (node) => { + // A checker panic hides this conversion: any array. + if (!panicSafe(() => (visit(node), true), false)) keys.add("*"); + return undefined; + }); + return keys; + }; + const dynamicType = (type: ts.Type): boolean => { + if ((type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return true; + if (this.checker.isArrayType(type) && !this.checker.isTupleType(type)) { + const element = this.checker.getTypeArguments(type as ts.TypeReference)[0]; + if (element && (element.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) + return true; + } + return panicSafe(() => this.mapTypeOf(type)?.kind === "dyn", false); + }; const elementStates = new ArrayElementStates({ + isDynamicArray: (node) => { + const type = typeOrNull(node); + return type === null || dynamicType(type); + }, + nativeViewKeys: collectNativeViews, elementAdmitsUndefined: (key) => { siteDependencies?.read(key); return optionalElementKeys.has(key); diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts b/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts index 96d20d2c4..8fc0c4a95 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts @@ -37,6 +37,13 @@ export interface ElementStateHost { ownerOf(node: ts.Expression): ts.Symbol | null; /** The owning slot a newly built array is stored into. */ allocationOwner(node: ts.Expression): ts.Symbol | null; + /** `node` is a dynamic value (`any`, `unknown`, `unknown[]`): when it is + * a live view of a native array, a store through it writes that array's + * storage. */ + isDynamicArray(node: ts.Expression): boolean; + /** Element keys of the native arrays a value converted to a dynamic type + * can reach, as live views ("*" for any array). */ + nativeViewKeys(): ReadonlySet; /** Debug provenance for a new fact. */ note: ((fact: "undefined" | "holes", key: string, site: ts.Node | undefined) => void) | null; /** The scan read, or added to, the facts of an element key, of an owning @@ -48,6 +55,8 @@ export interface ElementStateHost { /** The fact name of the facts shared by every array. */ const ANY_ARRAY = "*"; +/** The fact name of holes stored through dynamic views. */ +const VIEW_HOLES = "%view"; /** What an array value can contain besides present values. */ interface Contents { @@ -81,6 +90,10 @@ export class ArrayElementStates { * whose element ABI is decided per generic instantiation. */ private anyUndefined = false; private anyHoley = false; + /** A store through a dynamic value can leave holes; they land in every + * native array such a value views. */ + private viewHoles = false; + private viewKeys: ReadonlySet | null = null; /** Facts of arrays owned by one storage slot. */ private readonly undefinedOwners = new Set(); private readonly holeyOwners = new Set(); @@ -330,14 +343,32 @@ export class ArrayElementStates { this.host.read?.(ANY_ARRAY); if (keys === null) return null; for (const key of keys) this.host.read?.(key); + this.host.read?.(VIEW_HOLES); return { undefined: this.anyUndefined || keys.some((key) => this.undefinedKeys.has(key) || this.host.elementAdmitsUndefined(key)), - holes: this.anyHoley || keys.some((key) => this.holeyKeys.has(key)), + holes: this.anyHoley || keys.some((key) => this.holeyKeys.has(key) || this.viewed(key)), }; } + /** Holes stored through a dynamic view can be in arrays of this key. */ + private viewed(key: string): boolean { + if (!this.viewHoles) return false; + this.viewKeys ??= this.host.nativeViewKeys(); + return this.viewKeys.has(key) || this.viewKeys.has(ANY_ARRAY); + } + + /** A store through `receiver`, which has no native element key, can + * leave holes. Only a dynamic value can be a view of a native array. */ + private addViewHoles(receiver: ts.Expression): boolean { + if (this.viewHoles || !this.host.isDynamicArray(receiver)) return false; + this.viewHoles = true; + this.host.changed?.(VIEW_HOLES); + this.host.note?.("holes", VIEW_HOLES, this.site); + return true; + } + /** Reading every index of `node` (a spread, an iteration, `Array.from`) * can produce `undefined`. Only native arrays can: other iterables reject * an undefined value when it is stored. */ @@ -427,7 +458,11 @@ export class ArrayElementStates { /** A store through `receiver` can leave holes. */ private addHolesTo(receiver: ts.Expression): boolean { const owner = this.host.ownerOf(receiver); - if (owner === null) return this.addHoles(this.host.arrayKeys(receiver)); + if (owner === null) { + const keys = this.host.arrayKeys(receiver); + if (keys !== null && keys.length === 0) return this.addViewHoles(receiver); + return this.addHoles(keys); + } if (this.holeyOwners.has(owner)) return false; this.holeyOwners.add(owner); this.host.changed?.(owner); @@ -490,7 +525,8 @@ export class ArrayElementStates { const undefinedValue = storesValue && this.host.mayBeUndefined(node.right); if (!holes && !undefinedValue) return false; const keys = this.host.arrayKeys(target.expression); - if (keys !== null && keys.length === 0) return false; + if (keys !== null && keys.length === 0) + return holes && !namesProperty(target) && this.addViewHoles(target.expression); let changed = false; if (holes && this.addHolesTo(target.expression)) changed = true; if (undefinedValue && this.addUndefinedTo(target.expression)) changed = true; @@ -501,8 +537,6 @@ export class ArrayElementStates { target.name.text === "length" && !isShrinkingLength(node, target) ) { - const keys = this.host.arrayKeys(target.expression); - if (keys !== null && keys.length === 0) return false; return this.addHolesTo(target.expression); } return false; @@ -574,10 +608,16 @@ export class ArrayElementStates { )); if (!reflective) return false; let changed = false; - for (const arg of args) { + // The written object: `Reflect.apply`'s receiver argument, otherwise + // the first argument. + const written = this.host.isGlobal(owner, "Reflect") && method === "apply" ? 1 : 0; + for (const [index, arg] of args.entries()) { const target = ts.isSpreadElement(arg) ? arg.expression : arg; const keys = this.host.arrayKeys(target); - if (keys !== null && keys.length === 0) continue; + if (keys !== null && keys.length === 0) { + if (index === written && this.addViewHoles(target)) changed = true; + continue; + } if (this.addUndefinedTo(target)) changed = true; if (this.addHolesTo(target)) changed = true; } @@ -761,6 +801,14 @@ function isAppendIndex(target: ts.ElementAccessExpression): boolean { ); } +/** `o["name"]`: a string literal key that is not an array index writes a + * named property, never an element. */ +function namesProperty(target: ts.ElementAccessExpression): boolean { + const key = peel(target.argumentExpression); + if (!ts.isStringLiteral(key) && !ts.isNoSubstitutionTemplateLiteral(key)) return false; + return !/^(0|[1-9][0-9]*)$/.test(key.text) || Number(key.text) >= 2 ** 32 - 1; +} + /** `xs.length = 0`, `xs.length = xs.length - k` and `xs.length -= k` * truncate; truncation never leaves holes. */ function isShrinkingLength( diff --git a/packages/compiler/test/runtime-optional-precision.test.ts b/packages/compiler/test/runtime-optional-precision.test.ts index 7840b0c62..3a3251b5a 100644 --- a/packages/compiler/test/runtime-optional-precision.test.ts +++ b/packages/compiler/test/runtime-optional-precision.test.ts @@ -102,6 +102,31 @@ console.log(copy.map((item) => weigh(item)).join(",")); expect(signature(spread, "weigh")).toBe("weigh(Item|undefined): f64"); }); +test("holes stored through a dynamic view widen callbacks over the arrays it views", async () => { + const viewed = await lower(`${ITEM} +const items: Item[] = [new Item(1)]; +const view: unknown[] = items; +view[3] = new Item(2); +console.log(items.findIndex((item) => weigh(item) > 1)); +`); + expect(signature(viewed, "weigh")).toBe("weigh(Item|undefined): f64"); + // A dynamic store cannot reach an element type no dynamic value views. + const other = await lower(`${ITEM} +const items: Item[] = [new Item(1)]; +const values: unknown[] = [1]; +values[3] = 2; +console.log(items.findIndex((item) => weigh(item) > 1)); +`); + expect(signature(other, "weigh")).toBe("weigh(Item): f64"); + // A view without stores that leave holes keeps the parameters. + const read = await lower(`${ITEM} +const items: Item[] = [new Item(1)]; +const view: unknown[] = items; +console.log(view.length, items.findIndex((item) => weigh(item) > 1)); +`); + expect(signature(read, "weigh")).toBe("weigh(Item): f64"); +}); + test("holes that only their owning slot can observe stay out of callbacks", async () => { const module = await lower(`${ITEM} class Cache { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6067-view-holes-in-typed-arrays.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6067-view-holes-in-typed-arrays.ts.json new file mode 100644 index 000000000..9d257fd49 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6067-view-holes-in-typed-arrays.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6067-view-holes-in-typed-arrays.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6067-view-holes-in-typed-arrays.ts b/tests/corpus/6067-view-holes-in-typed-arrays.ts new file mode 100644 index 000000000..b59437646 --- /dev/null +++ b/tests/corpus/6067-view-holes-in-typed-arrays.ts @@ -0,0 +1,57 @@ +// Holes written through an `unknown[]` or `any` view of a typed array are +// holes of the typed array: for...of visits them as undefined and the find +// family passes them to its callback, as in Node. The views reach the array +// through a binding, an `any` parameter, a generic instantiated with +// `unknown`, a record field, a class field and a nested array. + +const numbers: number[] = [1, 2]; +const numbersView: unknown[] = numbers; +numbersView[3] = 5; +for (const value of numbers) console.log("numbers", value, typeof value); +console.log(numbers.findIndex((value) => value === undefined)); +console.log(numbers.findLastIndex((value) => value === undefined)); +console.log(numbers.find((value) => value !== undefined && value > 1)); + +const grown: string[] = ["a"]; +const grownView: unknown[] = grown; +grownView.length = 3; +for (const value of grown) console.log("grown", value); + +function growAny(values: any): void { + values[2] = 9; +} +const passed: number[] = [7]; +growAny(passed); +for (const value of passed) console.log("passed", value); + +function keep(value: T): T { + return value; +} +const kept: number[] = [3]; +(keep(kept) as unknown[])[2] = 4; +for (const value of kept) console.log("kept", value); + +const boxed: number[] = [1]; +const box: unknown = { values: boxed }; +(box as { values: unknown[] }).values[2] = 3; +for (const value of boxed) console.log("boxed", value); + +class Holder { + values: number[] = [1, 2]; +} +const holder = new Holder(); +const holderView: unknown = holder; +(holderView as { values: unknown[] }).values[3] = 4; +for (const value of holder.values) console.log("holder", value); + +const nested: number[][] = [[1]]; +const nestedView: unknown[][] = nested; +nestedView[0]![2] = 3; +for (const value of nested[0]!) console.log("nested", value); + +// A named property written through a view is not an element. +const named: number[] = [1, 2]; +const bag = named as unknown as Record; +bag["label"] = "x"; +for (const value of named) console.log("named", value); +console.log(named.length, bag["label"]); diff --git a/tests/harness/corpus-costs/6067-view-holes-in-typed-arrays.ts.json b/tests/harness/corpus-costs/6067-view-holes-in-typed-arrays.ts.json new file mode 100644 index 000000000..b8b740bf8 --- /dev/null +++ b/tests/harness/corpus-costs/6067-view-holes-in-typed-arrays.ts.json @@ -0,0 +1 @@ +{ "plain": 3967, "san": 4623 }