Count holes written through dynamic views of typed arrays

This commit is contained in:
Chris Tate
2026-10-22 16:45:21 -05:00
parent 5dacb5bed3
commit c271aa85aa
6 changed files with 247 additions and 7 deletions
@@ -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<string> => {
const keys = new Set<string>();
const seen = new Set<string>();
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);
@@ -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<string>;
/** 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<string> | null = null;
/** Facts of arrays owned by one storage slot. */
private readonly undefinedOwners = new Set<ts.Symbol>();
private readonly holeyOwners = new Set<ts.Symbol>();
@@ -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(
@@ -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 {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6067-view-holes-in-typed-arrays.ts"
],
"diags": []
}
@@ -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<T>(value: T): T {
return value;
}
const kept: number[] = [3];
(keep<unknown>(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<string, unknown>;
bag["label"] = "x";
for (const value of named) console.log("named", value);
console.log(named.length, bag["label"]);
@@ -0,0 +1 @@
{ "plain": 3967, "san": 4623 }