diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ad4fd5312..7eb86d6d9 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4334,16 +4334,21 @@ export function lowerVarDecl( if (wrapped) return { kind: "assign", localId: g.id, value: wrapped, loc: locOf(decl) }; return null; } - const raw = lowerVariableInitializer( - lowerer, - decl.initializer, - g.type.kind === "dyn" && - (isNativeBuiltinValueInitializer(lowerer, decl.initializer) || - ts.isArrayLiteralExpression(decl.initializer) || - ts.isObjectLiteralExpression(decl.initializer)) - ? DYN - : undefined, - ); + const guardedRead = isGuardedRecordRead(lowerer, decl) + ? lowerAbsenceProbe(lowerer, decl.initializer) + : null; + const raw = + guardedRead ?? + lowerVariableInitializer( + lowerer, + decl.initializer, + g.type.kind === "dyn" && + (isNativeBuiltinValueInitializer(lowerer, decl.initializer) || + ts.isArrayLiteralExpression(decl.initializer) || + ts.isObjectLiteralExpression(decl.initializer)) + ? DYN + : undefined, + ); if ( isJsSourceFile(decl.getSourceFile()) && !decl.type && @@ -5139,29 +5144,111 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar } function immediatelyGuardedAbsenceProbe(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { - if (decl.type || !ts.isIdentifier(decl.name) || !decl.initializer) return false; + const guard = immediateAbsenceGuard(lowerer, decl); + // `if (!x)`: the falsy test sees a missing value. + if (guard === "falsy") return true; + // `if (x === undefined)` and its spellings compare the binding with the + // missing value itself, so the read must be able to produce it. Only + // index-signature record reads take this form: array reads already + // carry their undefined arm, and their ordinary lowering keeps its + // bounds proofs. + return guard === "compare" && isIndexSignatureRecordRead(lowerer, decl); +} + +/** A file-scope or block-scope `let e = map[k]` over an index-signature + * record whose next statement guards `e` for absence (`if (e === + * undefined)`, `if (!e)`, ...): the get-or-create idiom. Its read keeps the + * missing key as undefined even without noUncheckedIndexedAccess. */ +export function isGuardedRecordRead(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { + return immediateAbsenceGuard(lowerer, decl) !== null && isIndexSignatureRecordRead(lowerer, decl); +} + +function isIndexSignatureRecordRead(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { + if (!decl.initializer) return false; + let init = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if (!ts.isElementAccessExpression(init)) return false; + // Under noUncheckedIndexedAccess the checker already spells the missing + // arm, and the ordinary read carries it. + const read = lowerer.typeOf(init); + if ( + (read.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 || + (read.isUnionType() && + ts.constituentTypes(read).some((t) => (t.flags & ts.TypeFlags.Undefined) !== 0)) + ) + return false; + const receiver = lowerer.mapTypeOf(lowerer.typeOf(init.expression)); + const shape = receiver?.kind === "record" ? lowerer.shapes.get(receiver.shapeId) : undefined; + return !!shape && !shape.tuple && !!shape.indexValue && shape.indexValue.kind !== "dyn"; +} + +/** How the statement right after an unannotated declaration tests the + * declared binding for absence: `if (!x)` ("falsy"), or a comparison with + * the missing value — `x === undefined`, `x == null`, `x !== void 0`, + * `typeof x === "undefined"` and their mirrored and negated spellings + * ("compare"). Null for anything else. */ +function immediateAbsenceGuard( + lowerer: Lowerer, + decl: ts.VariableDeclaration, +): "falsy" | "compare" | null { + if (decl.type || !ts.isIdentifier(decl.name) || !decl.initializer) return null; const statement = decl.parent?.parent; - if (!statement || !ts.isVariableStatement(statement)) return false; + if (!statement || !ts.isVariableStatement(statement)) return null; const container = statement.parent; const statements = ts.isBlock(container) || ts.isSourceFile(container) ? container.statements : null; - if (!statements) return false; + if (!statements) return null; const index = statements.indexOf(statement); const next = index >= 0 ? statements[index + 1] : undefined; - if (!next || !ts.isIfStatement(next)) return false; - let condition = next.expression; - while (ts.isParenthesizedExpression(condition)) condition = condition.expression; - if ( - !ts.isPrefixUnaryExpression(condition) || - condition.operator !== ts.SyntaxKind.ExclamationToken - ) - return false; - let operand = condition.operand; - while (ts.isParenthesizedExpression(operand)) operand = operand.expression; - return ( - ts.isIdentifier(operand) && - lowerer.resolveValueSymbol(operand) === lowerer.resolveValueSymbol(decl.name) - ); + if (!next || !ts.isIfStatement(next)) return null; + const binding = lowerer.resolveValueSymbol(decl.name); + const peel = (node: ts.Expression): ts.Expression => { + while (ts.isParenthesizedExpression(node)) node = node.expression; + return node; + }; + const isBinding = (node: ts.Expression): boolean => { + const operand = peel(node); + return ts.isIdentifier(operand) && lowerer.resolveValueSymbol(operand) === binding; + }; + const condition = peel(next.expression); + if (ts.isPrefixUnaryExpression(condition)) + return condition.operator === ts.SyntaxKind.ExclamationToken && isBinding(condition.operand) + ? "falsy" + : null; + if (!ts.isBinaryExpression(condition)) return null; + const op = condition.operatorToken.kind; + const strict = + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken; + const loose = + op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsToken; + if (!strict && !loose) return null; + const isUndefined = (node: ts.Expression): boolean => { + const operand = peel(node); + if (ts.isVoidExpression(operand)) return true; + return ( + ts.isIdentifier(operand) && + operand.text === "undefined" && + (lowerer.typeOf(operand).flags & ts.TypeFlags.Undefined) !== 0 + ); + }; + const isNull = (node: ts.Expression): boolean => peel(node).kind === ts.SyntaxKind.NullKeyword; + const missing = (node: ts.Expression): boolean => isUndefined(node) || (loose && isNull(node)); + const isTypeofBinding = (node: ts.Expression): boolean => { + const operand = peel(node); + return ts.isTypeOfExpression(operand) && isBinding(operand.expression); + }; + const isUndefinedTag = (node: ts.Expression): boolean => { + const operand = peel(node); + return ts.isStringLiteralLike(operand) && operand.text === "undefined"; + }; + const { left, right } = condition; + return (isBinding(left) && missing(right)) || + (missing(left) && isBinding(right)) || + (isTypeofBinding(left) && isUndefinedTag(right)) || + (isUndefinedTag(left) && isTypeofBinding(right)) + ? "compare" + : null; } function falsyExitRuntimeOptionalLocals(lowerer: Lowerer, stmt: ts.IfStatement): IrLocal[] { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 12deec7ad..aca306900 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -525,6 +525,7 @@ import { lowerForOf, lowerForStatement, matchGroupsPatternSource, + isGuardedRecordRead, } from "./lower-stmts.js"; import { type FieldTarget, @@ -3908,6 +3909,33 @@ export class Lowerer { } } + /** A file-scope get-or-create read (`let e = map[k]; if (e === + * undefined) ...` over an index-signature record) keeps a missing key as + * undefined (isGuardedRecordRead). Its pre-registered global takes the + * undefined arm before any body reads it; the declaration lowers its + * initializer as the matching absence probe. */ + analyzeGuardedRecordReadGlobals(parts: FileParts[]): void { + for (const { sf } of parts) { + for (const statement of sf.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const decl of statement.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || !isGuardedRecordRead(this, decl)) continue; + const global = this.globalOf(decl.name); + const symbol = this.resolveValueSymbol(decl.name); + if (!global || !symbol || global.tdz || this.runtimeOptionalBindingTypes.has(symbol)) + continue; + const widened = + global.type.kind === "union" + ? this.withUndefinedArmOf(global.type) + : this.withUndefinedArm(global.type); + if (!widened || typeEquals(widened, global.type)) continue; + global.type = widened; + this.runtimeOptionalBindingTypes.set(symbol, widened); + } + } + } + } + analyzeRuntimeOptionalArrayReads(parts: FileParts[]): void { const provenance = RuntimeOptionalProvenance.fromEnvironment(); // The site whose value the current promotion follows (debug only). @@ -6058,6 +6086,7 @@ export class Lowerer { this.collectProgram(parts); this.analyzeRuntimeOptionalArrayReads(parts); this.analyzeDateJsonResults(parts); + this.analyzeGuardedRecordReadGlobals(parts); // Decorated classes analyze AFTER the whole collection pass: a // decorator's return type may name a subclass declared below the // class, and reference lowering needs each class's rebindability @@ -6443,6 +6472,7 @@ export class Lowerer { this.collectProgram(parts); this.analyzeRuntimeOptionalArrayReads(parts); this.analyzeDateJsonResults(parts); + this.analyzeGuardedRecordReadGlobals(parts); // Decorated classes analyze post-collection here too: the %init seeds // lower the decoration calls, whose edges (decorator bodies, construct // thunks) reachable emit must see. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6100-record-get-or-create.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6100-record-get-or-create.ts.json new file mode 100644 index 000000000..b7c18a8a4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6100-record-get-or-create.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6100-record-get-or-create.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6100-record-get-or-create.ts b/tests/corpus/6100-record-get-or-create.ts new file mode 100644 index 000000000..807cb303e --- /dev/null +++ b/tests/corpus/6100-record-get-or-create.ts @@ -0,0 +1,119 @@ +// Get-or-create over nested index-signature records without +// noUncheckedIndexedAccess: a missing key reads as undefined when the next +// statement tests the binding for absence, in every spelling of that test, +// at module scope and inside functions. A write with a computed key through +// a binding that may hold a missing element throws Node's TypeError after +// the key and the value evaluated. +type ExportMap = Record>; + +interface Target { + subpath: string; + condition: string; + target: string; +} + +function build(targets: Target[]): { exports: ExportMap; unmapped: string[] } { + const exports: ExportMap = {}; + const unmapped: string[] = []; + for (const t of targets) { + if (t.target === "") { + unmapped.push(t.subpath); + continue; + } + let entry = exports[t.subpath]; + if (entry === undefined) { + entry = {}; + exports[t.subpath] = entry; + } + entry[t.condition] = `./${t.target}`; + entry["types"] = `./${t.target}`; + } + return { exports: exports, unmapped: unmapped }; +} + +const result = build([ + { subpath: ".", condition: "import", target: "src/index.ts" }, + { subpath: ".", condition: "require", target: "src/index.ts" }, + { subpath: "./x", condition: "default", target: "" }, + { subpath: "./y", condition: "node", target: "src/y.ts" }, +]); +console.log(JSON.stringify(result.exports), result.unmapped.join(",")); + +function group(words: string[]): Record { + const byInitial: Record = {}; + for (const w of words) { + const k = w.slice(0, 1); + let list = byInitial[k]; + if (list == null) { + list = []; + byInitial[k] = list; + } + list.push(w); + } + return byInitial; +} +console.log(JSON.stringify(group(["apple", "avocado", "banana", "blueberry", "cherry"]))); + +function count(words: string[]): Record { + const counts: Record = {}; + for (const w of words) { + const n = counts[w]; + if (typeof n === "undefined") counts[w] = 1; + else counts[w] = n + 1; + } + return counts; +} +console.log(JSON.stringify(count(["a", "b", "a", "c", "a"]))); + +function lookup(table: Record, key: string): string { + const hit = table[key]; + if (hit !== undefined) return hit; + return `<${key} missing>`; +} +console.log(lookup({ a: "A" }, "a"), lookup({ a: "A" }, "b")); + +function rows(table: Record>, key: string, col: string): number { + const row = table[key]; + if (row != undefined) return row[col] ?? -1; + return -2; +} +console.log(rows({ r: { c: 3 } }, "r", "c"), rows({ r: { c: 3 } }, "r", "d"), rows({}, "x", "c")); + +// Module scope. +const nested: Record> = {}; +const key = process.argv.length > 99 ? "unused" : "k"; +let slot = nested[key]; +if (slot === undefined) { + slot = {}; + nested[key] = slot; +} +slot[key] = 1; +slot["fixed"] = 2; +console.log(JSON.stringify(nested)); + +const names: Record = { present: "yes" }; +const missing = names[key]; +if (!missing) console.log("falsy guard:", missing); +const present = names["present"]; +if (present === void 0) console.log("unreachable"); +else console.log("present:", present); + +// A write with a computed key through a binding that may hold a missing +// array element throws Node's TypeError naming the key, after evaluating +// the key and the value. +function probe(tables: Record[]): void { + const firstTable = tables[0]; + const lastTable = tables[5]; + const pick = (k: string): string => { + console.log("key", k); + return k; + }; + firstTable[pick("b")] = pick("second"); + console.log(JSON.stringify(firstTable)); + try { + lastTable[pick("c")] = pick("value"); + } catch (e) { + console.log((e as Error).name, (e as Error).message); + } +} +probe([{ a: "first" }]);