diff --git a/packages/compiler/src/frontend/lowering/coercions/fresh-spread.ts b/packages/compiler/src/frontend/lowering/coercions/fresh-spread.ts new file mode 100644 index 000000000..656ac3ee1 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/coercions/fresh-spread.ts @@ -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; +} diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 53d46d00d..83a184400 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -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" || diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index 12078720e..e9a130a2d 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -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", diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index f8b8c7604..5b9d05414 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -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; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index efa2813f1..6a3d92c22 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -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 || diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6476-in-literal-key-index-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6476-in-literal-key-index-records.ts.json new file mode 100644 index 000000000..c030a0076 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6476-in-literal-key-index-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6476-in-literal-key-index-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6477-fresh-array-spread-insertion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6477-fresh-array-spread-insertion.ts.json new file mode 100644 index 000000000..3e3775faa --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6477-fresh-array-spread-insertion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6477-fresh-array-spread-insertion.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6478-any-annotated-dictionary.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6478-any-annotated-dictionary.ts.json new file mode 100644 index 000000000..8925564a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6478-any-annotated-dictionary.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6478-any-annotated-dictionary.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fresh-spread-exported/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fresh-spread-exported/main.ts.json new file mode 100644 index 000000000..2dd42cd27 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fresh-spread-exported/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/diagnostics/fresh-spread-exported/items.ts", + "/tests/diagnostics/fresh-spread-exported/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fresh-spread-repeated.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fresh-spread-repeated.ts.json new file mode 100644 index 000000000..cb4b2f25d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fresh-spread-repeated.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/fresh-spread-repeated.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6476-in-literal-key-index-records.ts b/tests/corpus/6476-in-literal-key-index-records.ts new file mode 100644 index 000000000..5866c3ed1 --- /dev/null +++ b/tests/corpus/6476-in-literal-key-index-records.ts @@ -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: (?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 = /(?\w+)=(?\w*)/.exec("mode=fast"); +if (both?.groups) { + console.log("key" in both.groups, "value" in both.groups, "other" in both.groups); +} +const dict: Record = { a: 1 }; +dict["late"] = 2; +delete dict["a"]; +console.log("a" in dict, "late" in dict, "missing" in dict, "toString" in dict); diff --git a/tests/corpus/6477-fresh-array-spread-insertion.ts b/tests/corpus/6477-fresh-array-spread-insertion.ts new file mode 100644 index 000000000..4ce4f87d9 --- /dev/null +++ b/tests/corpus/6477-fresh-array-spread-insertion.ts @@ -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]); diff --git a/tests/corpus/6478-any-annotated-dictionary.ts b/tests/corpus/6478-any-annotated-dictionary.ts new file mode 100644 index 000000000..6a66f0662 --- /dev/null +++ b/tests/corpus/6478-any-annotated-dictionary.ts @@ -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; +console.log(Object.keys(out), out["a.txt"] instanceof Ref, out["dir"] instanceof Blob, out["count"]); diff --git a/tests/diagnostics/fresh-spread-exported/items.ts b/tests/diagnostics/fresh-spread-exported/items.ts new file mode 100644 index 000000000..dd1574ab1 --- /dev/null +++ b/tests/diagnostics/fresh-spread-exported/items.ts @@ -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); diff --git a/tests/diagnostics/fresh-spread-exported/main.ts b/tests/diagnostics/fresh-spread-exported/main.ts new file mode 100644 index 000000000..dd2090acd --- /dev/null +++ b/tests/diagnostics/fresh-spread-exported/main.ts @@ -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(" ")); diff --git a/tests/diagnostics/fresh-spread-repeated.ts b/tests/diagnostics/fresh-spread-repeated.ts new file mode 100644 index 000000000..4b1a6d341 --- /dev/null +++ b/tests/diagnostics/fresh-spread-repeated.ts @@ -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]); diff --git a/tests/harness/__snapshots__/fresh-spread-exported/main.ts.txt b/tests/harness/__snapshots__/fresh-spread-exported/main.ts.txt new file mode 100644 index 000000000..935da2e96 --- /dev/null +++ b/tests/harness/__snapshots__/fresh-spread-exported/main.ts.txt @@ -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 | \ No newline at end of file diff --git a/tests/harness/__snapshots__/fresh-spread-repeated.ts.txt b/tests/harness/__snapshots__/fresh-spread-repeated.ts.txt new file mode 100644 index 000000000..c6fef3aa6 --- /dev/null +++ b/tests/harness/__snapshots__/fresh-spread-repeated.ts.txt @@ -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 | } \ No newline at end of file