Compile literal-key 'in' over index-signature records, fresh array spread insertions, and any-annotated dictionaries (#1514)

This commit is contained in:
Chris Tate
2026-10-20 18:46:29 -05:00
committed by GitHub
18 changed files with 303 additions and 0 deletions
@@ -0,0 +1,97 @@
/* Array spreads whose elements nothing else can reach. */
import * as ts from "../../ts7/adapter.js";
import type { IrExpr, IrType, SrcLoc } from "../../../ir/ir.js";
import type { Lowerer } from "../lowerer.js";
/** Whether the elements a spread inserts are reachable only through it: an
* array literal (`...(cond ? [{ ... }] : [])`), or an unexported const
* initialized by one that this spread is the only reference to, and that
* the spread reads at most once per evaluation of the initializer. Converting such elements
* into the destination's element layout copies them, which nothing can
* observe. */
export function freshSpreadSource(lowerer: Lowerer, node: ts.Expression): boolean {
let e = node;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (ts.isArrayLiteralExpression(e))
return e.elements.nodes.every(
(x) => !ts.isSpreadElement(x) && (ts.isObjectLiteralExpression(x) || !isReference(x)),
);
if (ts.isConditionalExpression(e))
return freshSpreadSource(lowerer, e.whenTrue) && freshSpreadSource(lowerer, e.whenFalse);
if (!ts.isIdentifier(e)) return false;
const symbol = lowerer.resolveValueSymbol(e);
const decl = symbol?.valueDeclaration;
if (!symbol || !decl || !ts.isVariableDeclaration(decl) || !decl.initializer) return false;
const list = decl.parent;
if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) return false;
// Other modules can import an exported binding and reach its elements.
const statement = list.parent;
if (
ts.isVariableStatement(statement) &&
statement.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
)
return false;
if (!freshSpreadSource(lowerer, decl.initializer)) return false;
// The binding's only use is this spread; an export specifier naming it
// (`export { name }`) is another.
let scope: ts.Node = list;
while (scope.parent && !ts.isBlock(scope) && !ts.isSourceFile(scope)) scope = scope.parent;
let uses = 0;
const visit = (n: ts.Node): void => {
if (uses > 1) return;
if (ts.isExportSpecifier(n) && (n.propertyName ?? n.name).text === e.text) {
uses = 2;
return;
}
if (ts.isIdentifier(n) && n !== decl.name && n.text === e.text) {
if (lowerer.resolveValueSymbol(n) === symbol) uses++;
}
ts.forEachChild(n, visit);
};
visit(scope);
return uses === 1 && runsOnceInScope(node, scope);
}
/** Whether `node` runs at most once each time `scope` runs: no loop body,
* loop test or update, function, or class field initializer lies between
* them. A spread that runs again inserts the same elements again, and
* copies would no longer be the same objects. */
function runsOnceInScope(node: ts.Node, scope: ts.Node): boolean {
let child = node;
for (let n = node.parent; n && n !== scope; child = n, n = n.parent) {
if (ts.isFunctionLike(n) || ts.isPropertyDeclaration(n)) return false;
if (ts.isForOfStatement(n) || ts.isForInStatement(n)) {
if (child !== n.expression) return false;
} else if (ts.isForStatement(n)) {
if (child !== n.initializer) return false;
} else if (ts.isWhileStatement(n) || ts.isDoStatement(n)) {
return false;
}
}
return true;
}
function isReference(e: ts.Expression): boolean {
return !(
ts.isStringLiteral(e) ||
ts.isNumericLiteral(e) ||
e.kind === ts.SyntaxKind.TrueKeyword ||
e.kind === ts.SyntaxKind.FalseKeyword ||
e.kind === ts.SyntaxKind.NullKeyword
);
}
/** A fresh array spread converted into the destination's array layout by
* the structural width lift, or null when the layouts do not lift. */
export function liftedFreshSpread(
lowerer: Lowerer,
node: ts.Expression,
source: IrExpr,
arrType: IrType,
loc: SrcLoc,
): IrExpr | null {
if (source.type.kind !== "array" || arrType.kind !== "array") return null;
if (!freshSpreadSource(lowerer, node)) return null;
const lift = lowerer.widthLiftPlan(source.type, arrType);
return lift ? lowerer.applyWidthLift(lift, source, arrType, loc) : null;
}
@@ -1,5 +1,6 @@
import { generatorDrain } from "../iterator-adapters.js";
import { identityPreservingWidening } from "../coercions/identity.js";
import { liftedFreshSpread } from "../coercions/fresh-spread.js";
import { InternalCompilerError } from "../../../errors.js";
import {
dynUndefinedExpr,
@@ -142,6 +143,16 @@ export function lowerArraySpreadItems(
if (source.type.kind === "record" && lowerer.shapes.get(source.type.shapeId)?.tuple) {
source = lowerer.widthCoerce(source, arrType) ?? source;
}
if (
!typeEquals(source.type, arrType) &&
!(
source.type.kind === "array" &&
identityPreservingWidening(lowerer, source.type.elem, elem)
)
) {
// Fresh elements (an array literal) convert into the element layout.
source = liftedFreshSpread(lowerer, node.expression, source, arrType, loc) ?? source;
}
if (!typeEquals(source.type, arrType)) {
if (
source.type.kind !== "array" ||
@@ -1,3 +1,4 @@
import { liftedFreshSpread } from "../coercions/fresh-spread.js";
import { lowerArrayIteratorValue } from "../iterator-adapters.js";
import { identityPreservingWidening } from "../coercions/identity.js";
import { arrayConcatHelper } from "./array-concat.js";
@@ -793,6 +794,10 @@ export function lowerArrayMethodCall(
loc,
};
}
if (!typeEquals(src.type, receiverIr)) {
// Fresh elements (an array literal) convert into the element layout.
src = liftedFreshSpread(lowerer, spreadArg.expression, src, receiverIr, loc) ?? src;
}
if (!typeEquals(src.type, receiverIr)) {
lowerer.unsupported(
"SC1090",
@@ -1380,6 +1380,24 @@ export function lowerInExpression(
if (key === "code" && isExecExceptionType(lowerer, lowerer.typeOf(expr.right))) {
refuseExecExceptionCode(lowerer, expr);
}
// A literal key that names no declared member of an index-signature
// record (`"id" in match.groups`): the same keyed presence probe a
// runtime key takes — declared fields by name, then the overflow keys.
{
const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.right));
const shape = recvT?.kind === "record" ? lowerer.shapes.get(recvT.shapeId) : undefined;
if (
shape?.indexValue &&
!shape.tuple &&
!OBJECT_PROTOTYPE_KEYS.has(key) &&
!shape.fields.some(
(f) => f.name === key || f.name === `%get:${key}` || f.name === `%set:${key}`,
)
) {
const probed = lowerRuntimeKeyIn(lowerer, expr, loc);
if (probed) return probed;
}
}
let recv = lowerer.lowerExpr(expr.right);
const classInfo =
recv.type.kind === "classval" ? lowerer.classes.get(recv.type.className) : undefined;
@@ -5501,7 +5501,29 @@ export function lowerVarDecl(
// `let` may be reassigned a different shape later; genuine `any`
// only — every other unmappable keeps its own diagnostic.
const checkerAny = (lowerer.typeOf(decl.name).flags & ts.TypeFlags.Any) !== 0;
// An annotation the checker could not resolve (a package type alias that
// reads as `any`) over an empty object literal: the binding is a
// dictionary the code fills by key (`files[name] = entry`), which the
// empty struct the literal would adopt cannot hold. It holds the
// checked-dynamic object instead.
let emptyAnyDictionary = false;
if (checkerAny && decl.type && !isLet && init.type.kind === "record") {
let lit: ts.Expression = decl.initializer;
while (ts.isParenthesizedExpression(lit)) lit = lit.expression;
const shape = lowerer.shapes.get(init.type.shapeId);
if (
ts.isObjectLiteralExpression(lit) &&
lit.properties.nodes.length === 0 &&
shape &&
shape.fields.length === 0 &&
!shape.indexValue
) {
type = DYN;
emptyAnyDictionary = true;
}
}
if (
!emptyAnyDictionary &&
(type === null || (type.kind === "dyn" && checkerAny)) &&
!isLet &&
(checkerAny ||
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6476-in-literal-key-index-records.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6477-fresh-array-spread-insertion.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6478-any-annotated-dictionary.ts"
],
"diags": []
}
@@ -0,0 +1,7 @@
{
"order": [
"<repo>/tests/diagnostics/fresh-spread-exported/items.ts",
"<repo>/tests/diagnostics/fresh-spread-exported/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/fresh-spread-repeated.ts"
],
"diags": []
}
@@ -0,0 +1,18 @@
// `"k" in record` with a literal key over an index-signature record (a
// match's named groups, a dictionary) answers the record's own keys.
function source(header: string): string | undefined {
const result = /^x-meta-source: (?<id>dpl_.*)/m.exec(header);
if (result?.groups && "id" in result.groups) {
return result.groups.id;
}
return undefined;
}
console.log(source("x-meta-source: dpl_123\nother: 1"), source("nothing"));
const both = /(?<key>\w+)=(?<value>\w*)/.exec("mode=fast");
if (both?.groups) {
console.log("key" in both.groups, "value" in both.groups, "other" in both.groups);
}
const dict: Record<string, number> = { a: 1 };
dict["late"] = 2;
delete dict["a"];
console.log("a" in dict, "late" in dict, "missing" in dict, "toString" in dict);
@@ -0,0 +1,19 @@
// Spreading a fresh array literal whose elements are typed by their own
// literal (an omitted member reads `undefined`) into an array of the
// declared element type converts the elements; nothing else can reach
// them.
type Result = { message: string; file: { path: string } | undefined; noisy: boolean };
const results: Result[] = [{ message: "first", file: { path: "/a" }, noisy: false }];
const spam = results.length > 0 ? [{ message: "spam", file: undefined, noisy: true }] : [];
const more: Result[] = [{ message: "m", file: undefined, noisy: false }];
results.unshift(...spam, ...more);
const tail = results.length > 5 ? [] : [{ message: "tail", file: undefined, noisy: false }];
results.push(...tail);
console.log(results.map((r) => `${r.message}:${r.file?.path ?? "-"}:${r.noisy}`).join(" "));
console.log(JSON.stringify(results[1]));
// A const declared in the loop body is a new array on every iteration.
for (let i = 0; i < 2; i++) {
const step = [{ message: `step${i}`, file: undefined, noisy: false }];
results.push(...step);
}
console.log(results.map((r) => r.message).join(" "), results[results.length - 1] === results[results.length - 2]);
@@ -0,0 +1,24 @@
// A binding annotated with a type the checker reads as `any`, initialized
// with an empty object literal, is a dictionary the code fills by key.
class Ref {
digest: string;
constructor(digest: string) {
this.digest = digest;
}
}
class Blob {
data: string;
constructor(data: string) {
this.data = data;
}
}
function collect(entries: { name: string; sha?: string }[]): unknown {
const files: any = {};
for (const entry of entries) {
files[entry.name] = entry.sha ? new Ref(`sha:${entry.sha}`) : new Blob("");
}
files["count"] = entries.length;
return files;
}
const out = collect([{ name: "a.txt", sha: "abc" }, { name: "dir" }]) as Record<string, unknown>;
console.log(Object.keys(out), out["a.txt"] instanceof Ref, out["dir"] instanceof Blob, out["count"]);
@@ -0,0 +1,10 @@
// Exported consts: the importing module reaches their elements, so the
// spreads here keep the element-layout refusal. Copying the elements
// would hide main.ts's write from `results`.
export type Result = { message: string; file: { path: string } | undefined; noisy: boolean };
export const results: Result[] = [];
export const shared = [{ message: "shared", file: undefined, noisy: false }];
results.push(...shared);
const listed = [{ message: "listed", file: undefined, noisy: true }];
export { listed };
results.unshift(...listed);
@@ -0,0 +1,6 @@
// An exported const array spread once in its own module: in Node, writes
// through the importer's binding show in the array the spread filled.
import { listed, results, shared } from "./items.js";
shared[0].message = "changed";
listed[0].noisy = false;
console.log(results.map((r) => `${r.message}:${r.noisy}`).join(" "));
@@ -0,0 +1,16 @@
// A const array spread that runs more than once per evaluation of its
// initializer inserts the same elements each time: in a loop body or in a
// function, `results[1] === results[2]` holds in Node. Converting the
// elements into the destination's layout would copy them on each run, so
// these spreads keep the element-layout refusal.
type Result = { message: string; file: { path: string } | undefined; noisy: boolean };
const results: Result[] = [];
const looped = [{ message: "loop", file: undefined, noisy: false }];
for (let i = 0; i < 2; i++) results.push(...looped);
const called = [{ message: "call", file: undefined, noisy: true }];
function addCalled(): void {
results.unshift(...called);
}
addCalled();
addCalled();
console.log(results.length, results[0] === results[1]);
@@ -0,0 +1,13 @@
fresh-spread-exported/items.ts:7:14 - error SC1090: pushing a spread of '{ file: null | undefined; message: string; noisy: boolean }[]' onto a '{ file: { path: string } | undefined; message: string; noisy: boolean }[]' array (only a same-element-type array spreads) is not supported yet
6 | export const shared = [{ message: "shared", file: undefined, noisy: false }];
7 | results.push(...shared);
| ^~~~~~~~~
8 | const listed = [{ message: "listed", file: undefined, noisy: true }];
fresh-spread-exported/items.ts:10:17 - error SC1090: unshifting a spread of '{ file: null | undefined; message: string; noisy: boolean }[]' onto a '{ file: { path: string } | undefined; message: string; noisy: boolean }[]' array (only a same-element-type array spreads) is not supported yet
9 | export { listed };
10 | results.unshift(...listed);
| ^~~~~~~~~
11 |
@@ -0,0 +1,13 @@
fresh-spread-repeated.ts:9:42 - error SC1090: pushing a spread of '{ file: null | undefined; message: string; noisy: boolean }[]' onto a '{ file: { path: string } | undefined; message: string; noisy: boolean }[]' array (only a same-element-type array spreads) is not supported yet
8 | const looped = [{ message: "loop", file: undefined, noisy: false }];
9 | for (let i = 0; i < 2; i++) results.push(...looped);
| ^~~~~~~~~
10 | const called = [{ message: "call", file: undefined, noisy: true }];
fresh-spread-repeated.ts:12:19 - error SC1090: unshifting a spread of '{ file: null | undefined; message: string; noisy: boolean }[]' onto a '{ file: { path: string } | undefined; message: string; noisy: boolean }[]' array (only a same-element-type array spreads) is not supported yet
11 | function addCalled(): void {
12 | results.unshift(...called);
| ^~~~~~~~~
13 | }