From cc44afb4ee909b286b89641e4d785cb9c8911b4a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 22 Sep 2026 21:50:04 -0500 Subject: [PATCH] fix: accept readonly tuples as Set seeds (#384) - Accept supported readonly tuples in static Set constructors while preserving single evaluation and insertion order. - Add differential coverage and refresh constructor diagnostics. Fixes #373 Co-authored-by: Hagege Ruben <20857346+HagegeR@users.noreply.github.com> --- .../src/frontend/lowering/lower-classes.ts | 31 ++++++++++++++----- .../compiler/src/frontend/lowering/lowerer.ts | 5 +-- .../test/ts7/baselines/order-parity.json | 6 ++++ tests/corpus/2947-set-readonly-seeds.ts | 25 +++++++++++++++ tests/harness/__snapshots__/sets.ts.txt | 2 +- .../harness/__snapshots__/stdlib-fence.ts.txt | 2 +- 6 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 tests/corpus/2947-set-readonly-seeds.ts diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 2e424a1c..994004bb 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -5323,12 +5323,11 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { } lowerer.badType(expr, tsType); } - // `new Set()`: Map's sibling. The SEEDED form lowers for any - // T[]-typed argument — literal or variable, T already a legal - // element type — as construct + bulk add (duplicates collapse, - // insertion order preserved, exactly JS). Non-array seeds (another - // Set, general iterables) keep the fence. Unsupported element types - // are named specifically. + // `new Set()`: Map's sibling. The SEEDED form lowers for arrays + // and fixed tuples of legal elements — literal or variable — as + // construct + bulk add (duplicates collapse, insertion order + // preserved, exactly JS). Other iterables keep the fence. + // Unsupported element types are named specifically. // `new WeakMap()` / `new WeakSet()` in JAVASCRIPT sources: no weak // container exists in the value model, but harness code constructs // one unconditionally and touches it only on paths tests don't @@ -5379,6 +5378,24 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { // Any other lowered kind falls through to the named fence // below — never a mistyped seed into the validator. } + // Fixed tuples use record storage, not T[] storage. Snapshot + // their legal elements into an array before bulk insertion; + // the helper takes the tuple as one argument, so an effectful + // seed expression is evaluated exactly once. + if (argIr?.kind === "record") { + const shape = lowerer.shapes.get(argIr.shapeId); + if (shape?.tuple && shape.fields.every((field) => typeEquals(field.type, mapped.elem))) { + const seedType = { kind: "array" as const, elem: mapped.elem }; + const helper = lowerer.tupleArrayWidthHelper(argIr.shapeId, seedType, loc); + if (helper) { + const tuple = lowerer.lowerExpr(argNode); + if (typeEquals(tuple.type, argIr)) { + const seed: IrExpr = { kind: "call", callee: helper, args: [tuple], type: seedType, loc }; + return { kind: "setNew", seed, type: mapped, loc }; + } + } + } + } } } // JavaScript's identity-Set idiom: `new Set([setTimeout, atob, @@ -5410,7 +5427,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { lowerer.noLowering( "new Set(values)", expr, - "construct the Set empty and add() each value — only an array of " + + "construct the Set empty and add() each value — only an array or fixed tuple of " + "already-legal elements (string or number) seeds a Set", ); } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index e038919d..02f95a23 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -6002,8 +6002,9 @@ export class Lowerer { tupleArrayWidthHelper(fromId: string, toT: IrType & { kind: "array" }, loc: SrcLoc): string | null { const from = this.shapes.get(fromId); if (!from || !from.tuple) return null; + const fields = [...from.fields].sort((a, b) => Number(a.name) - Number(b.name)); const lifts: WidthLift[] = []; - for (const f of from.fields) { + for (const f of fields) { const lift = this.widthLiftPlan(f.type, toT.elem); if (!lift) return null; lifts.push(lift); @@ -6025,7 +6026,7 @@ export class Lowerer { kind: "return", value: { kind: "arrayLit", - elems: from.fields.map((f, i) => + elems: fields.map((f, i) => this.applyWidthLift( lifts[i]!, { kind: "recordGet", obj: t, shapeId: fromId, field: f.name, type: f.type, loc }, diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index d93a03f8..34aa60c7 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -6510,6 +6510,12 @@ ], "diags": [] }, + "/tests/corpus/2947-set-readonly-seeds.ts": { + "order": [ + "/tests/corpus/2947-set-readonly-seeds.ts" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" diff --git a/tests/corpus/2947-set-readonly-seeds.ts b/tests/corpus/2947-set-readonly-seeds.ts new file mode 100644 index 00000000..985be7b7 --- /dev/null +++ b/tests/corpus/2947-set-readonly-seeds.ts @@ -0,0 +1,25 @@ +const words = ["pear", "apple", "pear"] as const; +const named = new Set(words); +console.log(named.size, named.has("apple"), [...named].join(",")); + +const inferred = new Set(words); +console.log(inferred.size, inferred.has("pear"), [...inferred].join(",")); + +const readonlyWords: readonly string[] = ["north", "south", "north"]; +const fromReadonlyArray = new Set(readonlyWords); +console.log(fromReadonlyArray.size, [...fromReadonlyArray].join(",")); + +const numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 3] as const; +const fromTuple = new Set(numbers); +console.log(fromTuple.size, [...fromTuple].join(",")); + +let seedCalls = 0; +function makeSeed() { + seedCalls++; + return ["once", "twice", "once"] as const; +} +const fromCall = new Set(makeSeed()); +console.log(seedCalls, fromCall.size, [...fromCall].join(",")); + +const direct = new Set(["red", "blue", "red"] as const); +console.log(direct.size, [...direct].join(",")); diff --git a/tests/harness/__snapshots__/sets.ts.txt b/tests/harness/__snapshots__/sets.ts.txt index ce1b3ab9..015432bf 100644 --- a/tests/harness/__snapshots__/sets.ts.txt +++ b/tests/harness/__snapshots__/sets.ts.txt @@ -5,7 +5,7 @@ sets.ts:8:16 - error SC2020: 'new Set(values)' is part of the standard library t | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ 9 | - hint: construct the Set empty and add() each value — only an array of already-legal elements (string or number) seeds a Set + hint: construct the Set empty and add() each value — only an array or fixed tuple of already-legal elements (string or number) seeds a Set sets.ts:12:7 - error SC2009: values of type 'Set' cannot be compiled: the Set shape is supported, but elements are limited to numbers and strings — 'boolean' is outside that domain diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index 48a2e2ce..247beba7 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -134,7 +134,7 @@ stdlib-fence.ts:30:12 - error SC2020: 'new Set(values)' is part of the standard | ^~~~~~~~~~~~~~~~~~~~~~~~ 31 | // (new Error / TypeError / RangeError / SyntaxError now LOWER — the error - hint: construct the Set empty and add() each value — only an array of already-legal elements (string or number) seeds a Set + hint: construct the Set empty and add() each value — only an array or fixed tuple of already-legal elements (string or number) seeds a Set stdlib-fence.ts:34:7 - error SC2020: 'AggregateError' is part of the standard library types but has no scriptc lowering yet