From 60e0b246e74cbe1d1849bba30701f24b2d041acd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 19:57:45 -0700 Subject: [PATCH] Choose record key order from the orders values are built in Each record shape's enumeration table now follows the key order its object literals, spreads, rest bindings and record copies create, and appends optional keys assigned after construction. Shapes whose values are built in more than one order keep their declaration order and SC6002. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/coverage.mdx | 2 +- .../record-identity-and-property-order.mdx | 2 +- .../frontend/lowering/coercions/structural.ts | 8 +- .../lowering/containers/indexed-objects.ts | 2 + .../src/frontend/lowering/divergences.ts | 17 +- .../lowering/expressions/object-literals.ts | 17 +- .../lowering/key-order-witness.test.ts | 329 +++++++++ .../frontend/lowering/key-order-witness.ts | 675 ++++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 2 + .../src/frontend/lowering/lower-inspect.ts | 7 +- .../src/frontend/lowering/lower-stmts.ts | 37 +- .../compiler/src/frontend/lowering/lowerer.ts | 11 + .../corpus/key-order-late-optional.ts.json | 6 + .../corpus/key-order-literal-flows.ts.json | 6 + .../corpus/key-order-spread-copies.ts.json | 6 + tests/corpus/key-order-late-optional.ts | 84 +++ tests/corpus/key-order-literal-flows.ts | 150 ++++ tests/corpus/key-order-spread-copies.ts | 107 +++ tests/coverage-fixtures/divergences.ts | 6 +- tests/harness/native-frontend.ts | 2 + 20 files changed, 1448 insertions(+), 28 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/key-order-witness.test.ts create mode 100644 packages/compiler/src/frontend/lowering/key-order-witness.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-late-optional.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-literal-flows.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-spread-copies.ts.json create mode 100644 tests/corpus/key-order-late-optional.ts create mode 100644 tests/corpus/key-order-literal-flows.ts create mode 100644 tests/corpus/key-order-spread-copies.ts diff --git a/docs/content/docs/coverage.mdx b/docs/content/docs/coverage.mdx index 805d7f6128..b27327b915 100644 --- a/docs/content/docs/coverage.mdx +++ b/docs/content/docs/coverage.mdx @@ -76,7 +76,7 @@ Coverage and `scriptc build` report the same diagnostic codes. Common ones inclu Some code compiles but can behave differently than under Node.js. Coverage reports the cases it can detect as `SC6xxx` warnings, and `scriptc build` prints the same warnings after a successful build. They never fail a build, but `--fail-on=divergences` can fail a CI job on them. - `SC6001`: a record passed to, stored as, asserted as, or stored as an array element of a record type with different properties is copied, and the program then writes through the copy or compares it by identity. -- `SC6002`: an object literal's key order differs from its type's declaration order, and the program observes the order through `JSON.stringify`, `Object.keys`, `Object.values`, `Object.entries`, `for...in`, or console output. +- `SC6002`: values of one record type are built in different key orders, so the type keeps its declaration order, an object literal's key order differs from it, and the program observes the order through `JSON.stringify`, `Object.keys`, `Object.values`, `Object.entries`, `for...in`, or console output. - `SC6003`: `localeCompare` compares a string literal outside the built-in Latin-script collation. - `SC6004`: a call passes a class instance to a function that casts it to an unrelated class. - `SC6005`: a `Date` string literal uses a form the native parser does not accept. diff --git a/docs/content/limitations/record-identity-and-property-order.mdx b/docs/content/limitations/record-identity-and-property-order.mdx index 76ff0ae74d..a1faa1c342 100644 --- a/docs/content/limitations/record-identity-and-property-order.mdx +++ b/docs/content/limitations/record-identity-and-property-order.mdx @@ -2,7 +2,7 @@ A record converted to a record type with a different set of properties, such as Typed records with declared fields, indexed records, and arrays retain identity and mutations through native checked storage. Class instances retain identity at their exact type or a base class, and at any interface or object type that a class instance is passed, stored, returned, or asserted as when the program could tell a copy from the instance: values of such a type are checked views of the original object, so writes, identity comparisons, Set and Map keys, `util.inspect`, and `JSON.stringify` through them reach the instance, as in Node.js. These types use checked dynamic storage, which is slower than native record fields, for all of their values, including plain objects. A type whose values are only read field by field, from fields the class does not write after construction and does not implement as accessors, stays a native record and holds a copy. Object spread of a class instance is refused. Dense array literals created directly in checked storage preserve identity, and supported native handles pass by reference. -`Object.keys`, `Object.values`, `Object.entries`, and `JSON.stringify` use a record's declaration order rather than per-object insertion order. Their order matches Node.js when objects are built in declaration order. `SC6002` reports object literals whose order differs where the program observes it. +`Object.keys`, `Object.values`, `Object.entries`, `for...in`, `JSON.stringify`, and `util.inspect` enumerate a record's keys in one order per record type rather than per object. scriptc chooses that order from the order in which the program's object literals create the keys, follows object spread, rest destructuring, and record conversions back to the values they copy, and places an optional key that the program assigns after construction after the keys the object was built with. The order matches Node.js when every value of the type is created in the same order. When values of one record type are created in different orders, including values of two types with the same properties and property types, or when a key is deleted and assigned again, the type keeps the declaration order of its first type instead, and `SC6002` reports object literals whose order differs where the program observes it. A record converted from a checked value, such as `const rows: { score: number }[] = JSON.parse(text)`, keeps the properties its type does not declare. `JSON.stringify`, `Object.keys`, `Object.values`, `Object.entries`, `util.inspect`, and untyped views of the record see them, together with the declared fields, in the source object's key order. `Object.values` and `Object.entries` check those values against their static result type and throw a `TypeError` if one does not fit. Copies of such a record made by object spread, `Object.assign`, rest destructuring, or conversion to another record type contain only the declared fields. Index-signature records list declared fields before the other keys. diff --git a/packages/compiler/src/frontend/lowering/coercions/structural.ts b/packages/compiler/src/frontend/lowering/coercions/structural.ts index 925f59d9b7..f0489415f3 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural.ts @@ -603,6 +603,10 @@ function buildRecordProjection( const localId = parameter + ".0"; const receiver = varRef(localId, source, loc); const result: IrType = { kind: "record", shapeId: target.id }; + // A record source's copy keeps the source's key order; a class + // instance's fields keep today's table. + const copyOf = (node: IrExpr): IrExpr => + source.kind === "record" ? lowerer.keyOrder.copy(node, source.shapeId) : node; return { name, params: [{ localId, name: parameter, type: source }], @@ -611,7 +615,7 @@ function buildRecordProjection( body: [ { kind: "return", - value: { + value: copyOf({ kind: "recordLit", fields: target.fields.map((field) => { const conversion = plan.get(field.name)!; @@ -661,7 +665,7 @@ function buildRecordProjection( }), type: result, loc, - }, + }), loc, }, ], diff --git a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts index cca4bd51b4..e638e984f2 100644 --- a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts +++ b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts @@ -804,6 +804,7 @@ export function lowerRecordOvfCaptureHelper( // Every source declared field must flow into the keyed-write slot — // in DECLARATION order (insertion order below; declaredOrder omits // internal '%'-fields, which never enter the copy). + lowerer.keyOrder.observed.add(from.id); const orderedFields = from.declaredOrder ? from.declaredOrder.flatMap((n) => { const f = from.fields.find((x) => x.name === n); @@ -1090,6 +1091,7 @@ export function lowerObjectAssignIndexShape( const from = lowerer.shapes.get(srcIr.shapeId); if (!from || from.tuple) return null; if (from.fields.some((f) => f.name.startsWith("%"))) return null; + lowerer.keyOrder.observed.add(from.id); const orderedFields = from.declaredOrder ? from.declaredOrder.flatMap((n) => { const f = from.fields.find((x) => x.name === n); diff --git a/packages/compiler/src/frontend/lowering/divergences.ts b/packages/compiler/src/frontend/lowering/divergences.ts index 2a9009aed8..cf437aea03 100644 --- a/packages/compiler/src/frontend/lowering/divergences.ts +++ b/packages/compiler/src/frontend/lowering/divergences.ts @@ -779,7 +779,11 @@ function keyOrderFinding( written: readonly string[], assignedNames: ReadonlySet, ): { native: string; node: string; detail: "reordered" | "added-later" } | null { - const present = declared.filter((name) => written.includes(name)); + // A shape whose table the witness pass re-chose (key-order-witness.ts) + // enumerates that table, not the checker's declaration order. + const table = witnessTable(lowerer, contextual); + const native = table ?? declared; + const present = native.filter((name) => written.includes(name) && declared.includes(name)); const writtenKnown = written.filter((name) => declared.includes(name)); if (present.join("\0") !== writtenKnown.join("\0") && writtenKnown.length >= 2) { return { native: present.join(", "), node: writtenKnown.join(", "), detail: "reordered" }; @@ -795,12 +799,21 @@ function keyOrderFinding( const prop = checker.getPropertyOfType(contextual, name); if (prop === undefined || (prop.flags & ts.SymbolFlags.Optional) === 0) continue; const nodeOrder = [...writtenKnown, name]; - const nativeOrder = declared.filter((d) => nodeOrder.includes(d)); + const nativeOrder = native.filter((d) => nodeOrder.includes(d)); + if (table !== null && nativeOrder.join("\0") === nodeOrder.join("\0")) continue; return { native: nativeOrder.join(", "), node: nodeOrder.join(", "), detail: "added-later" }; } return null; } +/** The re-chosen enumeration table of the record shape a contextual type + * lowered to, or null when the witness pass kept today's table. */ +function witnessTable(lowerer: Lowerer, contextual: ts.Type): readonly string[] | null { + const mapped = safely(() => lowerer.mapTypeOf(contextual)); + if (mapped?.kind !== "record" || !lowerer.keyOrder.changed.has(mapped.shapeId)) return null; + return lowerer.shapes.get(mapped.shapeId)?.declaredOrder ?? null; +} + function classDeclarationOfTypeNode( lowerer: Lowerer, node: ts.TypeNode, diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index c95731fc78..af8c547aeb 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -24,6 +24,7 @@ import { PoisonError, neverTaintedJsType } from "../lowerer.js"; import type { Lowerer } from "../lowerer.js"; import { lowerIndexMergeHelper } from "../containers/indexed-objects.js"; import type { IndexMergeContributor } from "../containers/indexed-objects.js"; +import type { KeyOrderSegment } from "../key-order-witness.js"; import { PERF_ENTRY_MARKER, isGenericCallableMemberType, @@ -1588,6 +1589,8 @@ export function lowerObjectLiteral( // Member names DROPPED from the shape (JS deferral — unlowerable pure // member reads narrow away; see the catch in the property loop). const droppedNames = new Set(); + // The order this literal creates its keys in (key-order-witness.ts). + const orderSegments: KeyOrderSegment[] = []; for (const prop of expr.properties) { // A later contributor can mutate an earlier source or overwrite a // value with effects. Snapshot pending fields at their source position, @@ -1643,6 +1646,7 @@ export function lowerObjectLiteral( v = lowerer.coerceInto(valueNode, v, fieldType); if (!typeEquals(v.type, fieldType)) lowerer.badType(valueNode, lowerer.typeOf(valueNode)); conditionalNames.add(name); + orderSegments.push({ kind: "key", name, definite: false }); fields.push({ name, value: { @@ -1749,6 +1753,7 @@ export function lowerObjectLiteral( } } const srcRef = (): IrExpr => srcLowered ?? lowerer.lowerExpr(srcNode); + orderSegments.push({ kind: "spread", shapeId: recArm.shapeId, maybeAbsent: true }); for (const f of srcShape.fields) { if (laterNames.has(f.name)) continue; const targetType = fieldTypes.get(f.name); @@ -1983,6 +1988,12 @@ export function lowerObjectLiteral( laterNames.add(propNameText(lowerer, later.name)); } } + // A checked-dynamic source's keys are a run-time fact. + orderSegments.push( + srcLowered.type.kind === "dyn" + ? { kind: "opaque" } + : { kind: "spread", shapeId: srcType.shapeId, maybeAbsent: false }, + ); for (const f of srcShape.fields) { if (laterNames.has(f.name)) continue; let targetType = fieldTypes.get(f.name); @@ -2297,6 +2308,7 @@ export function lowerObjectLiteral( const at = fields.findIndex((x) => x.name === name); if (at >= 0) fields.splice(at, 1); fields.push({ name, value }); + orderSegments.push({ kind: "key", name, definite: true }); } // Optional fields may be omitted: the field holds its absent state. // tsc rejects omission of required fields before lowering. @@ -2325,7 +2337,10 @@ export function lowerObjectLiteral( fields.push({ name: f.name, value: absent }); } } - const result: IrExpr = { kind: "recordLit", fields, type, loc }; + const result: IrExpr = lowerer.keyOrder.literal( + { kind: "recordLit", fields, type, loc }, + orderSegments, + ); return spreadStmts.length === 0 ? result : { kind: "seqExpr", stmts: spreadStmts, result, type, loc }; diff --git a/packages/compiler/src/frontend/lowering/key-order-witness.test.ts b/packages/compiler/src/frontend/lowering/key-order-witness.test.ts new file mode 100644 index 0000000000..01f632cf30 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/key-order-witness.test.ts @@ -0,0 +1,329 @@ +import { expect, test } from "vitest"; +import { F64, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js"; +import { ShapeRegistry } from "../type-mapper.js"; +import { + applyKeyOrderWitnesses, + KeyOrderNotes, + type KeyOrderSegment, +} from "./key-order-witness.js"; +import type { Lowerer } from "./lowerer.js"; + +const loc = { file: "order.ts", start: 0, end: 1 }; +/** `number | undefined`: the optional-field slot type. */ +const OPT: IrType = { kind: "union", unionId: "u0" }; +const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); +const absent: IrExpr = { kind: "fieldAbsent", unionId: "u0", type: OPT, loc }; + +interface Fixture { + lowerer: Lowerer; + shapes: ShapeRegistry; + notes: KeyOrderNotes; + record(fields: [string, IrType][], order: string[]): IrType & { kind: "record" }; + literal(type: IrType, keys: string[], segments?: KeyOrderSegment[]): IrExpr; + run(body: IrStmt[]): void; + order(type: IrType & { kind: "record" }): string[] | undefined; +} + +function fixture(): Fixture { + const shapes = new ShapeRegistry(); + const notes = new KeyOrderNotes(); + const lowerer = { + shapes, + keyOrder: notes, + unions: new Map([["u0", { id: "u0", arms: [F64, { kind: "undefinedT" }] }]]), + classes: new Map(), + armTag: (unionId: string, arm: IrType): number => + unionId === "u0" && arm.kind === "undefinedT" ? 1 : -1, + } as unknown as Lowerer; + const record = (fields: [string, IrType][], order: string[]): IrType & { kind: "record" } => ({ + kind: "record", + shapeId: shapes.intern( + [...fields].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([name, type]) => ({ name, type })), + false, + undefined, + order, + ), + }); + const literal = (type: IrType, keys: string[], segments?: KeyOrderSegment[]): IrExpr => { + const shape = shapes.get((type as IrType & { kind: "record" }).shapeId)!; + const fields = keys.map((name) => ({ name, value: num(1) })); + for (const f of shape.fields) + if (!keys.includes(f.name)) fields.push({ name: f.name, value: absent }); + return notes.literal( + { kind: "recordLit", fields, type, loc }, + segments ?? keys.map((name) => ({ kind: "key", name, definite: true })), + ); + }; + const run = (body: IrStmt[]): void => { + const fn: IrFunction = { name: "main", params: [], returnType: F64, locals: [], body, loc }; + applyKeyOrderWitnesses(lowerer, [fn]); + }; + const order = (type: IrType & { kind: "record" }): string[] | undefined => + shapes.get(type.shapeId)?.declaredOrder; + return { lowerer, shapes, notes, record, literal, run, order }; +} + +const observe = (value: IrExpr): IrStmt => ({ + kind: "exprStmt", + expr: { kind: "jsonStringify", value, type: { kind: "string" }, loc }, + loc, +}); +const decl = (localId: string, init: IrExpr): IrStmt => ({ kind: "varDecl", localId, init, loc }); +const ref = (localId: string, type: IrType): IrExpr => ({ kind: "varRef", localId, type, loc }); +const set = (obj: IrExpr, field: string): IrStmt => ({ + kind: "recordSet", + obj, + shapeId: (obj.type as IrType & { kind: "record" }).shapeId, + field, + value: num(2), + loc, +}); + +test("a shape takes the one order its literals write", () => { + const f = fixture(); + const ab = f.record( + [ + ["a", F64], + ["b", F64], + ], + ["a", "b"], + ); + f.run([observe(f.literal(ab, ["b", "a"])), observe(f.literal(ab, ["b", "a"]))]); + expect(f.order(ab)).toEqual(["b", "a"]); + expect(f.notes.changed.has(ab.shapeId)).toBe(true); +}); + +test("literals that agree with the table leave the same array in place", () => { + const f = fixture(); + const ab = f.record( + [ + ["a", F64], + ["b", F64], + ], + ["a", "b"], + ); + const before = f.order(ab); + f.run([observe(f.literal(ab, ["a", "b"]))]); + expect(f.order(ab)).toBe(before); + expect(f.notes.changed.size).toBe(0); +}); + +test("values built in two orders keep today's table", () => { + const f = fixture(); + const ab = f.record( + [ + ["a", F64], + ["b", F64], + ], + ["a", "b"], + ); + f.run([observe(f.literal(ab, ["b", "a"])), observe(f.literal(ab, ["a", "b"]))]); + expect(f.order(ab)).toEqual(["a", "b"]); +}); + +test("an unobserved shape keeps today's table", () => { + const f = fixture(); + const ab = f.record( + [ + ["a", F64], + ["b", F64], + ], + ["a", "b"], + ); + f.run([decl("x", f.literal(ab, ["b", "a"]))]); + expect(f.order(ab)).toEqual(["a", "b"]); +}); + +test("a synthesized construction pins today's table", () => { + const f = fixture(); + const ab = f.record( + [ + ["a", F64], + ["b", F64], + ], + ["a", "b"], + ); + const synthesized: IrExpr = { + kind: "recordLit", + fields: [ + { name: "a", value: num(1) }, + { name: "b", value: num(2) }, + ], + type: ab, + loc, + }; + f.run([observe(f.literal(ab, ["b", "a"])), observe(synthesized)]); + expect(f.order(ab)).toEqual(["a", "b"]); +}); + +test("a checked conversion pins today's table", () => { + const f = fixture(); + const ab = f.record( + [ + ["a", F64], + ["b", F64], + ], + ["a", "b"], + ); + const parsed: IrExpr = { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: ab, loc }; + f.run([observe(f.literal(ab, ["b", "a"])), observe(parsed)]); + expect(f.order(ab)).toEqual(["a", "b"]); +}); + +test("a spread contributes its source's order, and an override keeps its position", () => { + const f = fixture(); + const src = f.record( + [ + ["a", F64], + ["b", F64], + ], + ["a", "b"], + ); + const out = f.record( + [ + ["a", F64], + ["b", F64], + ["c", F64], + ], + ["a", "b", "c"], + ); + const spread: KeyOrderSegment = { kind: "spread", shapeId: src.shapeId, maybeAbsent: false }; + f.run([ + decl("s", f.literal(src, ["b", "a"])), + // { ...s, a: 1, c: 2 } — `a` stays where the spread put it. + observe( + f.literal( + out, + ["a", "b", "c"], + [ + spread, + { kind: "key", name: "a", definite: true }, + { kind: "key", name: "c", definite: true }, + ], + ), + ), + ]); + expect(f.order(out)).toEqual(["b", "a", "c"]); +}); + +test("a spread of a checked-conversion source cannot be ordered statically", () => { + const f = fixture(); + const src = f.record( + [ + ["a", F64], + ["b", F64], + ], + ["a", "b"], + ); + const out = f.record( + [ + ["a", F64], + ["b", F64], + ["c", F64], + ], + ["c", "a", "b"], + ); + const parsed: IrExpr = { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: src, loc }; + const spread: KeyOrderSegment = { kind: "spread", shapeId: src.shapeId, maybeAbsent: false }; + f.run([ + decl("s", parsed), + observe(f.literal(out, ["a", "b", "c"], [spread, { kind: "key", name: "c", definite: true }])), + ]); + expect(f.order(out)).toEqual(["c", "a", "b"]); +}); + +test("a late-only optional key follows the keys values are built with", () => { + const f = fixture(); + const o = f.record( + [ + ["a", OPT], + ["b", F64], + ], + ["a", "b"], + ); + f.run([decl("o", f.literal(o, ["b"])), set(ref("o", o), "a"), observe(ref("o", o))]); + expect(f.order(o)).toEqual(["b", "a"]); +}); + +test("straight-line writes of late-only keys keep program order", () => { + const f = fixture(); + const o = f.record( + [ + ["id", F64], + ["x", OPT], + ["y", OPT], + ], + ["id", "x", "y"], + ); + f.run([ + decl("o", f.literal(o, ["id"])), + set(ref("o", o), "y"), + set(ref("o", o), "x"), + observe(ref("o", o)), + ]); + expect(f.order(o)).toEqual(["id", "y", "x"]); +}); + +test("late-only keys written through different bindings keep today's table", () => { + const f = fixture(); + const o = f.record( + [ + ["id", F64], + ["x", OPT], + ["y", OPT], + ], + ["id", "x", "y"], + ); + f.run([ + decl("o", f.literal(o, ["id"])), + set(ref("o", o), "y"), + set(ref("p", o), "x"), + observe(ref("o", o)), + ]); + expect(f.order(o)).toEqual(["id", "x", "y"]); +}); + +test("a late-only key written in a loop over an outer binding keeps today's table", () => { + const f = fixture(); + const o = f.record( + [ + ["id", F64], + ["x", OPT], + ["y", OPT], + ], + ["id", "x", "y"], + ); + f.run([ + decl("o", f.literal(o, ["id"])), + set(ref("o", o), "y"), + { + kind: "while", + cond: { kind: "boolLit", value: false, type: { kind: "bool" }, loc }, + body: [set(ref("o", o), "x")], + loc, + }, + observe(ref("o", o)), + ]); + expect(f.order(o)).toEqual(["id", "x", "y"]); +}); + +test("re-adding a key a literal creates earlier keeps today's table", () => { + const f = fixture(); + const o = f.record( + [ + ["a", OPT], + ["b", F64], + ], + ["a", "b"], + ); + const del: IrStmt = { + kind: "recordSet", + obj: ref("o", o), + shapeId: o.shapeId, + field: "a", + value: absent, + loc, + }; + f.run([decl("o", f.literal(o, ["a", "b"])), del, set(ref("o", o), "a"), observe(ref("o", o))]); + expect(f.order(o)).toEqual(["a", "b"]); +}); diff --git a/packages/compiler/src/frontend/lowering/key-order-witness.ts b/packages/compiler/src/frontend/lowering/key-order-witness.ts new file mode 100644 index 0000000000..ba80017924 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/key-order-witness.ts @@ -0,0 +1,675 @@ +/* Record enumeration order chosen from the orders values are built in. + * + * Node enumerates an object's string keys as canonical array indices + * ascending, then every other key in creation order. A record stores its + * fields name-sorted and enumerates them through one per-shape table, + * `IrRecordShape.declaredOrder`, which the type mapper fills with the + * checker's property order of the first type interned to the shape. That + * table is exact only while every value of the shape is created in that + * order. This pass replaces it, after lowering, with the order the + * program's construction sites actually create, when that order is one + * order. It changes the table's contents only: storage layout, shape + * identity and every helper's structure stay as they are, so the generated + * code differs only in the constant order the enumeration surfaces walk. + * + * Witnesses, per shape: + * - an object literal contributes the order it writes its keys in. Spread + * segments contribute their source shape's order (an inheritance edge, + * never the desugar's field list), and a key keeps the position of its + * first occurrence, so an override inside a spread stays where the + * spread put it. A key whose earlier occurrence may be absent at run + * time contributes every position it can take; + * - a width copy or a rest binding contributes its source shape's order; + * - any other construction (synthesized builtin results, opaque runtime + * producers) pins today's table; + * - checked conversions from dynamic values (typed JSON.parse, values + * from JavaScript) pin today's table: the record-source table keeps + * their own key order relative to it, and another table would make + * values parsed in today's order keep a source view. Their order is + * unknown, so a copy out of such a shape cannot be ordered statically; + * - a write to an optional key that may be absent appends it in Node. A + * key no construction creates ("late-only") goes after every key a + * construction can create; any other moving write goes after every key. + * Two late-only keys are ordered only by a straight-line sequence of + * writes through one binding. + * + * The orders merge into a precedence graph per shape, topologically sorted + * with ties broken by today's table, so a shape whose witnesses agree with + * the table keeps it untouched. A cycle means the order depends on the + * value: that shape, and every shape copied from it, keeps today's table + * (and today's SC6002 warnings). Shapes that no order surface observes + * keep today's table too. */ +import type { IrExpr, IrFunction, IrRecordShape, IrStmt, IrType } from "../../ir/ir.js"; +import { shapeHasAccessorSlots, typeEquals, UNDEFINED_T } from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild, everyTypeChild } from "../../ir/traverse.js"; +import type { Lowerer } from "./lowerer.js"; + +/** One property of an object literal, in source order. */ +export type KeyOrderSegment = + /** An explicit key (`definite`), or a conditional spread's key, which + * exists only when its condition holds. */ + | { kind: "key"; name: string; definite: boolean } + /** `...source` of a record shape; `maybeAbsent` when the whole source + * can be undefined (`...options` of `T | undefined`). */ + | { kind: "spread"; shapeId: string; maybeAbsent: boolean } + /** A spread whose keys are only known at run time. */ + | { kind: "opaque" }; + +/** Witness notes the lowering attaches to the IR nodes it builds. Keyed by + * node identity, so nodes a speculative lowering discards are never walked, + * and a node some later pass rebuilds falls back to pinning. */ +export class KeyOrderNotes { + private readonly literals = new Map(); + private readonly copies = new Map(); + /** Shapes a frontend order surface (keys, values/entries, inspect, + * index-signature merges) walks. */ + readonly observed = new Set(); + /** Shapes whose table this pass replaced. */ + readonly changed = new Set(); + + literal(node: IrExpr, segments: KeyOrderSegment[]): IrExpr { + this.literals.set(node, segments); + return node; + } + + copy(node: IrExpr, sourceShapeId: string): IrExpr { + this.copies.set(node, sourceShapeId); + return node; + } + + observe(type: IrType): void { + if (type.kind === "record") this.observed.add(type.shapeId); + } + + literalOf(node: IrExpr): KeyOrderSegment[] | undefined { + return this.literals.get(node); + } + + copyOf(node: IrExpr): string | undefined { + return this.copies.get(node); + } +} + +const isArrayIndex = (name: string): boolean => { + const n = Number(name); + return Number.isInteger(n) && n >= 0 && n < 4294967295 && String(n) === name; +}; + +interface WriteSite { + field: string; + kind: "write" | "delete" | "clone"; + fn: string; + binding: string | null; + loop: string; + pos: number; +} + +interface BindingInit { + count: number; + fn: string; + loop: string; + pos: number; + fresh: boolean; +} + +interface ShapeFacts { + literals: KeyOrderSegment[][]; + copies: { source: string; keys: Set }[]; + /** A construction in today's order (synthesized literal). */ + pinned: boolean; + /** An opaque runtime producer: today's order, any key may be absent. */ + opaque: boolean; + /** A checked conversion from a dynamic value. */ + runtime: boolean; + clones: boolean; + present: Set; + absent: Set; + deleted: Set; + writes: WriteSite[]; +} + +type Resolved = { order: string[] } | "unknown"; + +/** `structuredClone(v)` checked back into v's own type: the copy is built + * from v's dyn view, so it creates exactly v's keys in v's order. */ +function orderPreservingClone(node: IrExpr & { kind: "dynCheck" }): boolean { + const value = node.value; + if (value.kind !== "libCall" || value.fn !== "dyn.structuredClone") return false; + const source = value.args[0]; + return ( + source !== undefined && source.kind === "dynFrom" && typeEquals(source.value.type, node.type) + ); +} + +/** Chooses each in-scope record shape's enumeration table from its + * witnesses. Runs once retained reachability has settled the historical + * tables and derived metadata, before the order-snapshotting helper bodies + * rebuild. */ +export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunction[]): void { + const notes = lowerer.keyOrder; + const shapes = lowerer.shapes; + const facts = new Map(); + const factsOf = (shapeId: string): ShapeFacts => { + let f = facts.get(shapeId); + if (f === undefined) { + f = { + literals: [], + copies: [], + pinned: false, + opaque: false, + runtime: false, + clones: false, + present: new Set(), + absent: new Set(), + deleted: new Set(), + writes: [], + }; + facts.set(shapeId, f); + } + return f; + }; + const observedRoots: IrType[] = []; + const inits = new Map(); + const undefinedArmed = (t: IrType): boolean => + t.kind === "union" && lowerer.armTag(t.unionId, UNDEFINED_T) >= 0; + const containsAbsent = (value: IrExpr): boolean => + value.kind === "fieldAbsent" || + (value.kind === "ternary" && (containsAbsent(value.then) || containsAbsent(value.else_))); + + /** Every record shape a type can hold, through fields, arms, elements, + * callables, promises and class fields. */ + /** Each walk shares one `seen` set per result set, so every type graph + * is walked once however many roots reach it. */ + const shapesIn = (type: IrType, out: Set, seen: Set): void => { + const visit = (t: IrType): boolean => { + if (t.kind === "record") { + if (seen.has("r:" + t.shapeId)) return true; + seen.add("r:" + t.shapeId); + out.add(t.shapeId); + const shape = shapes.get(t.shapeId); + if (shape) { + for (const f of shape.fields) visit(f.type); + if (shape.indexValue) visit(shape.indexValue); + } + return true; + } + if (t.kind === "union") { + if (seen.has("u:" + t.unionId)) return true; + seen.add("u:" + t.unionId); + for (const arm of lowerer.unions.get(t.unionId)?.arms ?? []) visit(arm); + return true; + } + if (t.kind === "object") { + if (seen.has("c:" + t.className)) return true; + seen.add("c:" + t.className); + for (const f of lowerer.classes.get(t.className)?.def.fields ?? []) visit(f.type); + return true; + } + return everyTypeChild(t, visit); + }; + visit(type); + }; + const opaqueShapes = new Set(); + const opaqueSeen = new Set(); + const runtimeShapes = new Set(); + const runtimeSeen = new Set(); + const producedBy = (type: IrType, opaque: boolean): void => { + if (opaque) shapesIn(type, opaqueShapes, opaqueSeen); + else shapesIn(type, runtimeShapes, runtimeSeen); + }; + + // ── collection ────────────────────────────────────────────────────── + let pos = 0; + let fnName = ""; + const loops: number[] = []; + let loopIds = 0; + const loopKey = (): string => loops.join("/"); + const noteInit = (id: string, init: IrExpr): void => { + const previous = inits.get(id); + if (previous !== undefined) { + previous.count++; + return; + } + const fresh = + init.kind === "recordLit" || + init.kind === "recordClone" || + (init.kind === "seqExpr" && init.result.kind === "recordLit"); + inits.set(id, { count: 1, fn: fnName, loop: loopKey(), pos, fresh }); + }; + const visitExpr = (e: IrExpr): void => { + switch (e.kind) { + case "recordLit": { + if (e.type.kind !== "record") return; + const f = factsOf(e.type.shapeId); + const presentKeys = new Set(); + for (const field of e.fields) { + if (field.overflow) continue; + if (field.value.kind === "fieldAbsent") { + f.absent.add(field.name); + continue; + } + presentKeys.add(field.name); + f.present.add(field.name); + if (containsAbsent(field.value)) f.absent.add(field.name); + } + const segments = notes.literalOf(e); + const copy = notes.copyOf(e); + if (segments !== undefined) f.literals.push(segments); + else if (copy !== undefined) f.copies.push({ source: copy, keys: presentKeys }); + else f.pinned = true; + return; + } + case "recordClone": { + if (e.type.kind !== "record") return; + const f = factsOf(e.type.shapeId); + f.clones = true; + for (const o of e.overrides) + f.writes.push({ field: o.name, kind: "clone", fn: fnName, binding: null, loop: "", pos }); + return; + } + case "assignExpr": + noteInit(e.localId, e.value); + return; + case "jsonStringify": + case "dynFrom": + observedRoots.push(e.value.type); + return; + case "recordKeyGet": + observedRoots.push(e.obj.type); + return; + case "libCall": + // Runtime surfaces (console, util.format, workers) that take a + // record may walk its keys; runtime builders make new records. + for (const arg of e.args) observedRoots.push(arg.type); + producedBy(e.type, true); + return; + case "intrinsic": + // Intrinsics hand back values built elsewhere, except allSettled's + // result records; console output renders through the inspect + // helpers, which note their shapes. + if (e.name === "promise.allSettled") producedBy(e.type, true); + return; + case "ffiCall": + case "strIntrinsic": + case "regexIntrinsic": + case "bytesIntrinsic": + producedBy(e.type, true); + return; + case "dynCheck": + if (!orderPreservingClone(e)) producedBy(e.type, false); + return; + case "dynDestrCheck": + case "caughtCheck": + case "caughtNarrow": + producedBy(e.type, false); + return; + default: + return; + } + }; + const visitStmt = (st: IrStmt): void => { + switch (st.kind) { + case "recordSet": + if (st.value.kind === "fieldAbsent") factsOf(st.shapeId).deleted.add(st.field); + factsOf(st.shapeId).writes.push({ + field: st.field, + kind: st.value.kind === "fieldAbsent" ? "delete" : "write", + fn: fnName, + binding: st.obj.kind === "varRef" ? st.obj.localId : null, + loop: loopKey(), + pos, + }); + return; + case "varDecl": + if (st.init !== null) noteInit(st.localId, st.init); + return; + case "assign": + noteInit(st.localId, st.value); + return; + default: + return; + } + }; + const expr = (e: IrExpr): boolean => { + pos++; + visitExpr(e); + return everyExprChild(e, expr, stmt); + }; + const stmt = (st: IrStmt): boolean => { + pos++; + visitStmt(st); + if (st.kind === "while" || st.kind === "doWhile" || st.kind === "for" || st.kind === "forOf") { + loops.push(++loopIds); + everyStmtChild(st, expr, stmt); + loops.pop(); + return true; + } + return everyStmtChild(st, expr, stmt); + }; + for (const fn of fns) { + fnName = fn.name; + for (const st of fn.body) stmt(st); + } + for (const id of opaqueShapes) { + const f = factsOf(id); + f.pinned = true; + f.opaque = true; + } + for (const id of runtimeShapes) factsOf(id).runtime = true; + + // ── scope ─────────────────────────────────────────────────────────── + // Only plain declared-field shapes with a table take part. + const inScope = (shape: IrRecordShape | undefined): shape is IrRecordShape => + shape !== undefined && + !shape.tuple && + shape.indexValue === undefined && + shape.declaredOrder !== undefined && + !shapeHasAccessorSlots(shape); + const tableOf = (shape: IrRecordShape): string[] => + shape.declaredOrder!.filter((k) => !k.startsWith("%") && !isArrayIndex(k)); + // A checked conversion builds values in their source's own order, which + // the record-source table reproduces relative to the shape's table. A new + // table would make every value parsed in today's order keep a source + // view, so such shapes keep today's table. + for (const f of facts.values()) if (f.runtime) f.pinned = true; + + // Nothing to choose: every witness agrees with today's table and no + // optional key is written late. + const interesting = [...facts].some(([id, f]) => { + const shape = shapes.get(id); + if (!inScope(shape)) return false; + if (f.writes.some((w) => w.kind !== "delete") || f.copies.length > 0) return true; + const table = tableOf(shape).join("\0"); + return f.literals.some((segments) => { + const keys: string[] = []; + for (const segment of segments) { + if (segment.kind !== "key") return true; + if (!isArrayIndex(segment.name)) keys.push(segment.name); + } + return keys.join("\0") !== table; + }); + }); + if (!interesting) return; + + // ── presence classes and late-write constraints ───────────────────── + const maybeAbsent = (id: string, key: string): boolean => { + const shape = shapes.get(id); + const field = shape?.fields.find((x) => x.name === key); + if (!field || !undefinedArmed(field.type)) return false; + const f = facts.get(id); + if (f === undefined) return true; + return f.absent.has(key) || f.runtime || f.opaque || f.deleted.has(key); + }; + /** Edges every value of the shape satisfies whatever its sources' + * orders are, or "unknown" when late writes make the order depend on + * the value. */ + const lateEdges = (id: string, keys: readonly string[]): [string, string][] | "unknown" => { + const f = facts.get(id); + if (f === undefined) return []; + const edges: [string, string][] = []; + const keySet = new Set(keys); + const createdKeys = (k: string): boolean => f.present.has(k) || f.runtime || f.opaque; + const required = keys.filter((k) => !maybeAbsent(id, k)); + const created = keys.filter((k) => maybeAbsent(id, k) && createdKeys(k)); + const lateOnly = new Set(keys.filter((k) => maybeAbsent(id, k) && !createdKeys(k))); + // The constraint depends on the written key only, not on the site. + const moving = new Set(); + for (const w of f.writes) + if (w.kind !== "delete" && keySet.has(w.field) && maybeAbsent(id, w.field)) + moving.add(w.field); + for (const field of moving) { + const before = lateOnly.has(field) ? [...required, ...created] : keys; + for (const k of before) if (k !== field) edges.push([k, field]); + } + const lateSites = f.writes.filter((w) => lateOnly.has(w.field)); + const lateWritten = new Set(lateSites.filter((w) => w.kind !== "delete").map((w) => w.field)); + if (lateWritten.size < 2) return edges; + // Two or more late-only keys: each value gains them in the order its + // writes run, so every write must belong to one straight-line + // sequence through one binding initialized once, in the same function + // and loop body as its writes. + const groups = new Map(); + for (const w of lateSites) { + if (w.kind === "clone" || w.binding === null) return "unknown"; + const init = inits.get(w.binding); + if ( + init === undefined || + init.count !== 1 || + init.fn !== w.fn || + init.loop !== w.loop || + init.pos > w.pos + ) + return "unknown"; + const group = groups.get(w.binding) ?? []; + group.push(w); + groups.set(w.binding, group); + } + if (groups.size > 1 && [...groups.keys()].some((b) => !inits.get(b)!.fresh)) return "unknown"; + for (const group of groups.values()) { + const seen = new Set(); + for (const w of group) { + if (seen.has(w.field)) return "unknown"; + seen.add(w.field); + } + const writes = group.filter((w) => w.kind === "write").sort((a, b) => a.pos - b.pos); + for (let i = 0; i + 1 < writes.length; i++) + edges.push([writes[i]!.field, writes[i + 1]!.field]); + } + return edges; + }; + + // ── witness sequences ─────────────────────────────────────────────── + /** The key sequences one literal can create, or null when a spread + * source's order is unknown. A key sits at its first occurrence that + * certainly defines it; each earlier occurrence that may define it is + * another possible position. */ + const literalSequences = ( + segments: readonly KeyOrderSegment[], + keySet: ReadonlySet, + resolved: (id: string) => Resolved, + ): string[][] | null => { + const occurrences: { key: string; definite: boolean }[] = []; + for (const s of segments) { + if (s.kind === "opaque") return null; + if (s.kind === "key") { + if (keySet.has(s.name)) occurrences.push({ key: s.name, definite: s.definite }); + continue; + } + const source = resolved(s.shapeId); + if (source === "unknown") return null; + for (const k of source.order) { + if (keySet.has(k)) + occurrences.push({ key: k, definite: !s.maybeAbsent && !maybeAbsent(s.shapeId, k) }); + } + } + // Per key, the occurrences that can create it: every one up to and + // including the first definite occurrence. The first such position is + // the base; each other one is an alternative. + const positions = new Map(); + const settled = new Set(); + occurrences.forEach((o, i) => { + if (settled.has(o.key)) return; + const list = positions.get(o.key) ?? []; + list.push(i); + positions.set(o.key, list); + if (o.definite) settled.add(o.key); + }); + const base = new Map(); + for (const [key, list] of positions) base.set(key, list[0]!); + const sequenceWith = (at: Map): string[] => + [...at].sort((a, b) => a[1] - b[1]).map(([k]) => k); + const out = [sequenceWith(base)]; + for (const [key, list] of positions) { + for (const alternative of list.slice(1)) { + const moved = new Map(base); + moved.set(key, alternative); + out.push(sequenceWith(moved)); + } + } + return out; + }; + + /** Today's table with every witness edge applied, or "unknown". */ + const merge = (table: readonly string[], edges: readonly [string, string][]): Resolved => { + const rank = new Map(table.map((k, i) => [k, i])); + const succ = new Map>(table.map((k) => [k, new Set()])); + const indeg = new Map(table.map((k) => [k, 0])); + for (const [a, b] of edges) { + if (a === b || !rank.has(a) || !rank.has(b)) continue; + const s = succ.get(a)!; + if (s.has(b)) continue; + s.add(b); + indeg.set(b, indeg.get(b)! + 1); + } + const order: string[] = []; + const ready = table.filter((k) => indeg.get(k) === 0); + while (ready.length > 0) { + ready.sort((a, b) => rank.get(a)! - rank.get(b)!); + const k = ready.shift()!; + order.push(k); + for (const next of succ.get(k)!) { + indeg.set(next, indeg.get(next)! - 1); + if (indeg.get(next) === 0) ready.push(next); + } + } + return order.length === table.length ? { order } : "unknown"; + }; + + const chain = (seq: readonly string[], out: [string, string][]): void => { + for (let i = 0; i + 1 < seq.length; i++) out.push([seq[i]!, seq[i + 1]!]); + }; + + // ── resolution over inheritance edges ─────────────────────────────── + const result = new Map(); + const late = new Map(); + const dependencies = (id: string): string[] => { + const f = facts.get(id); + if (f === undefined) return []; + const deps = new Set(); + for (const segments of f.literals) + for (const s of segments) if (s.kind === "spread") deps.add(s.shapeId); + for (const c of f.copies) deps.add(c.source); + return [...deps]; + }; + /** One shape's order given its sources' current orders. */ + const resolveOne = (id: string, current: (source: string) => Resolved): Resolved => { + const shape = shapes.get(id); + if (!inScope(shape)) return "unknown"; + const table = tableOf(shape); + const f = facts.get(id); + // A shape the program never constructs natively gets its values from + // somewhere this pass cannot see. + if (f === undefined || !(f.literals.length > 0 || f.copies.length > 0 || f.pinned || f.runtime)) + return "unknown"; + // Clones copy their source's own key order, which a checked + // conversion leaves unknown. + if (f.runtime && f.clones) return "unknown"; + const keySet = new Set(table); + const edges: [string, string][] = []; + if (f.pinned) chain(table, edges); + for (const segments of f.literals) { + const sequences = literalSequences(segments, keySet, current); + if (sequences === null) return "unknown"; + for (const seq of sequences) chain(seq, edges); + } + for (const c of f.copies) { + const source = current(c.source); + if (source === "unknown") return "unknown"; + chain( + source.order.filter((k) => keySet.has(k) && c.keys.has(k)), + edges, + ); + } + const cached = late.get(id); + const lateFor = cached !== undefined ? cached : lateEdges(id, table); + if (cached === undefined) late.set(id, lateFor); + if (lateFor === "unknown") return "unknown"; + for (const edge of lateFor) edges.push(edge); + return merge(table, edges); + }; + /** A source's order as its copies see it: values of a shape built by a + * checked conversion keep their source's own order. */ + const asSource = (id: string, resolved: Resolved): Resolved => + facts.get(id)?.runtime === true ? "unknown" : resolved; + + // Tarjan's SCCs over "depends on" edges: sources finish first. + const index = new Map(); + const low = new Map(); + const stack: string[] = []; + const onStack = new Set(); + let counter = 0; + const sccs: string[][] = []; + const strong = (v: string): void => { + index.set(v, counter); + low.set(v, counter); + counter++; + stack.push(v); + onStack.add(v); + for (const w of dependencies(v)) { + if (!index.has(w)) { + strong(w); + low.set(v, Math.min(low.get(v)!, low.get(w)!)); + } else if (onStack.has(w)) low.set(v, Math.min(low.get(v)!, index.get(w)!)); + } + if (low.get(v) === index.get(v)) { + const scc: string[] = []; + let w: string; + do { + w = stack.pop()!; + onStack.delete(w); + scc.push(w); + } while (w !== v); + sccs.push(scc); + } + }; + for (const id of facts.keys()) if (!index.has(id)) strong(id); + const external = (id: string): Resolved => { + const known = result.get(id); + return known === undefined ? "unknown" : asSource(id, known); + }; + for (const scc of sccs) { + const members = new Set(scc); + const guess = new Map(); + for (const id of scc) { + const shape = shapes.get(id); + guess.set(id, inScope(shape) ? { order: tableOf(shape) } : "unknown"); + } + const lookup = (id: string): Resolved => + members.has(id) ? asSource(id, guess.get(id)!) : external(id); + let stable = false; + for (let round = 0; round < scc.length + 3 && !stable; round++) { + stable = true; + for (const id of scc) { + const next = resolveOne(id, lookup); + const prev = guess.get(id)!; + const same = + next === "unknown" + ? prev === "unknown" + : prev !== "unknown" && prev.order.join("\0") === next.order.join("\0"); + if (!same) { + stable = false; + guess.set(id, next); + } + } + } + for (const id of scc) result.set(id, stable ? guess.get(id)! : "unknown"); + } + + // ── observation ───────────────────────────────────────────────────── + const observed = new Set(); + const observedSeen = new Set(); + for (const id of notes.observed) + shapesIn({ kind: "record", shapeId: id }, observed, observedSeen); + for (const root of observedRoots) shapesIn(root, observed, observedSeen); + + // ── apply ─────────────────────────────────────────────────────────── + for (const [id, resolved] of result) { + if (resolved === "unknown" || !observed.has(id)) continue; + const shape = shapes.get(id); + if (!inScope(shape)) continue; + const table = tableOf(shape); + if (resolved.order.join("\0") === table.join("\0")) continue; + shape.declaredOrder = [...shape.declaredOrder!.filter(isArrayIndex), ...resolved.order]; + notes.changed.add(id); + } +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 303bdf5176..1e1e4b37c0 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -6447,6 +6447,7 @@ export function recordKeysArrayCall( loc: SrcLoc, ): IrExpr { const resultT = arrayOf(STRING); + lowerer.keyOrder.observe(argIr); const key = `obj.keys:${argIr.shapeId}:${typeKey(resultT)}`; let helper = lowerer.arrHofHelpers.get(key); if (!helper) { @@ -7909,6 +7910,7 @@ function lowerObjectStaticCall( valueT = tupleShape.fields.find((f) => f.name === "1")!.type; } + lowerer.keyOrder.observe(argIr); const key = `obj.${member}:${argIr.shapeId}:${typeKey(resultT)}`; let helper = lowerer.arrHofHelpers.get(key); if (!helper) { diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 1d67cef928..484eec71f3 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -10,6 +10,7 @@ import { DYN, F64, type IrExpr, + type IrFunction, type IrStmt, type IrType, RUNTIME_ERROR_CLASSES, @@ -942,6 +943,7 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string { // Object.keys' declared order (SEMANTICS.md 36's stance). Retained // reachability can settle this shape after the helper is first // interned, so rebuild the field walk once metadata is final. + lowerer.keyOrder.observe(t); rebuildShapeOrderBody = (): void => { const current = lowerer.shapes.get(t.shapeId) ?? shape; const order = current.declaredOrder ?? current.fields.map((f) => f.name); @@ -1292,7 +1294,10 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string { prependCycleGuard(); } - const fn = { + // Typed as the IR function itself: the finalizer below replaces the + // body of the very object liftedFns holds (natively, a differently typed + // literal would be copied into the list). + const fn: IrFunction = { name, params: [ { localId: "v.0", name: "v", type: t }, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index e4b2591d5b..b679fb35d0 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -3979,7 +3979,7 @@ function objectRestValue( ), }; }); - return { kind: "recordLit", fields, type: restT, loc }; + return lowerer.keyOrder.copy({ kind: "recordLit", fields, type: restT, loc }, srcType.shapeId); } /** The fresh record for `{ a, ...rest }` over a CLASS-INSTANCE source: @@ -7867,22 +7867,25 @@ function destructuringAssignInto( ), ); } - const packed: IrExpr = { - kind: "recordLit", - fields: remaining.map((f): Extract["fields"][number] => ({ - name: f.name, - value: { - kind: "recordGet", - obj: { kind: "varRef", localId: tmp.id, type: srcType, loc: locOf(prop) }, - shapeId: srcType.shapeId, - field: f.name, - type: f.type, - loc: locOf(prop), - }, - })), - type: { kind: "record", shapeId: restShapeId }, - loc: locOf(prop), - }; + const packed: IrExpr = lowerer.keyOrder.copy( + { + kind: "recordLit", + fields: remaining.map((f): Extract["fields"][number] => ({ + name: f.name, + value: { + kind: "recordGet", + obj: { kind: "varRef", localId: tmp.id, type: srcType, loc: locOf(prop) }, + shapeId: srcType.shapeId, + field: f.name, + type: f.type, + loc: locOf(prop), + }, + })), + type: { kind: "record", shapeId: restShapeId }, + loc: locOf(prop), + }, + srcType.shapeId, + ); if (!ts.isIdentifier(restTo)) { // `({ a, ...box.rest } = src)` — the rest packs identically and // lands through the property/element write machinery. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 5729a6c805..665acbbb5b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -20,6 +20,7 @@ import { } from "./require-initialization.js"; import { identityPreservingWidening } from "./coercions/identity.js"; import { scanDivergences } from "./divergences.js"; +import { applyKeyOrderWitnesses, KeyOrderNotes } from "./key-order-witness.js"; import { checkedClassAssertion } from "./class-assertions.js"; import { narrowStoredClassValue, narrowGenericClassValue } from "./class-unions.js"; import { isOptionalProcessStreamProperty } from "./builtins/process.js"; @@ -2432,6 +2433,9 @@ export class Lowerer { * Reachability lowers inits before the declarations they discover, so * these rebuild after the worklist restores historical shape metadata. */ readonly shapeOrderHelperFinalizers: (() => void)[] = []; + /** Construction witnesses that choose each shape's enumeration order + * once lowering settles (key-order-witness.ts). */ + readonly keyOrder = new KeyOrderNotes(); /** Emit-override specializations (`%C.emit:` — lower-event-emitter.ts's * emit-overrides block): interned names, the drive-loop queue, and the * currently-lowering specialization's context (the super-forward @@ -7380,6 +7384,13 @@ export class Lowerer { this.settleGenericDemandPriorities(); this.shapes.settleDeclaredOrderPriorities(); for (const finalize of this.shapeOrderMetadataFinalizers) finalize(); + applyKeyOrderWitnesses(this, [ + ...orderedUnits, + ...initFunctions, + ...instanceFunctions, + ...this.liftedFns, + ...this.implicitFns, + ]); for (const finalize of this.shapeOrderHelperFinalizers) finalize(); const functions = [ ...orderedUnits, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-late-optional.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-late-optional.ts.json new file mode 100644 index 0000000000..5975cbb402 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-late-optional.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/key-order-late-optional.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-literal-flows.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-literal-flows.ts.json new file mode 100644 index 0000000000..7b99fa5f60 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-literal-flows.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/key-order-literal-flows.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-spread-copies.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-spread-copies.ts.json new file mode 100644 index 0000000000..43b336a1bd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/key-order-spread-copies.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/key-order-spread-copies.ts" + ], + "diags": [] +} diff --git a/tests/corpus/key-order-late-optional.ts b/tests/corpus/key-order-late-optional.ts new file mode 100644 index 0000000000..f4742acb03 --- /dev/null +++ b/tests/corpus/key-order-late-optional.ts @@ -0,0 +1,84 @@ +// An optional key that no literal creates is appended when it is first +// assigned, after every key the value was built with, and straight-line +// assignments of several such keys keep their program order, including +// assignments behind conditions. Assigning a key that already exists never +// moves it. +interface Item { + label?: string; + id: number; + done: boolean; +} +function makeItem(id: number): Item { + return { id, done: false }; +} +const item = makeItem(1); +item.label = "first"; +console.log(Object.keys(item).join(","), JSON.stringify(item)); +console.log(item); +const direct: Item = { id: 2, done: true }; +direct.label = "second"; +console.log(Object.keys(direct).join(","), JSON.stringify(direct)); +const untouched = makeItem(3); +console.log(JSON.stringify(untouched), Object.keys(untouched).join(",")); + +// Two late keys assigned in reverse declaration order. +interface Pos { + id: number; + x?: number; + y?: number; +} +function makePos(): Pos { + return { id: 0 }; +} +const pos = makePos(); +pos.y = 1; +pos.x = 2; +console.log(Object.keys(pos).join(","), JSON.stringify(pos)); +console.log(pos); +pos.id = 3; +console.log(JSON.stringify(pos)); + +// An options object filled key by key, some keys conditionally. +interface Opts { + verbose?: boolean; + depth?: number; + name?: string; +} +function build(deep: boolean, loud: boolean): Opts { + const o: Opts = {}; + o.name = "n"; + if (deep) o.depth = 2; + if (loud) o.verbose = true; + return o; +} +for (const deep of [true, false]) + for (const loud of [true, false]) console.log(JSON.stringify(build(deep, loud))); +console.log(build(true, true)); +const forIn: string[] = []; +for (const k in build(true, true)) forIn.push(k); +console.log(forIn.join(",")); + +// Late keys on records stored in arrays and Maps. +interface Task { + owner?: string; + title: string; + priority: number; +} +const tasks: Task[] = []; +for (let i = 0; i < 3; i++) tasks.push({ title: "t" + i, priority: i }); +for (const t of tasks) if (t.priority > 0) t.owner = "o" + t.priority; +console.log(JSON.stringify(tasks)); +const byTitle = new Map(); +for (const t of tasks) byTitle.set(t.title, t); +console.log(byTitle); + +// An explicit undefined is a present key at its literal position. +interface Maybe { + a?: number; + b: number; +} +function makeMaybe(): Maybe { + return { b: 1, a: undefined }; +} +console.log(Object.keys(makeMaybe()).join(","), JSON.stringify(makeMaybe())); +console.log(makeMaybe()); diff --git a/tests/corpus/key-order-literal-flows.ts b/tests/corpus/key-order-literal-flows.ts new file mode 100644 index 0000000000..350e78ffaf --- /dev/null +++ b/tests/corpus/key-order-literal-flows.ts @@ -0,0 +1,150 @@ +// Records enumerate their keys in the order their object literals create +// them, even when every literal writes the keys in another order than the +// type declares them and the value is observed far from the literal: +// factory returns, arrays, Maps, nested records, generic factories, union +// arms, records widened to unknown/any, structuredClone, and every +// order-observing surface (keys, values, entries, for...in, JSON.stringify +// with replacers and indentation, inspect, console.log, util.format). +import { format, inspect } from "node:util"; + +interface Point { + x: number; + y: number; + label: string; +} +function makePoint(i: number): Point { + return { label: "p" + i, y: i * 2, x: i }; +} + +const p = makePoint(1); +console.log(Object.keys(p).join(",")); +console.log(Object.values(p).join(",")); +console.log(JSON.stringify(Object.entries(p))); +const forIn: string[] = []; +for (const k in p) forIn.push(k); +console.log(forIn.join(",")); +console.log(JSON.stringify(p)); +console.log(JSON.stringify(p, null, 2)); +console.log(JSON.stringify(p, ["y", "x"])); +const visited: string[] = []; +JSON.stringify(p, (key: string, value: unknown) => { + visited.push(key); + return value; +}); +console.log(visited.join(",")); +console.log(p); +console.log(inspect(p)); +console.log(format("%o|%j|%O", p, p, p)); + +// Arrays of records: literals pushed in a loop and produced by map. +interface Row { + id: number; + name: string; + score: number; +} +const rows: Row[] = []; +for (let i = 0; i < 3; i++) rows.push({ name: "n" + i, score: i * 10, id: i }); +for (const r of rows) console.log(Object.values(r).join("|")); +console.log(JSON.stringify(rows)); +const mapped: Row[] = [4, 5].map((i) => ({ name: "m" + i, score: i, id: i * 100 })); +console.log(JSON.stringify(mapped)); +console.log(mapped); + +// Records stored in a Map, read back, serialized and inspected. +const byName = new Map(); +for (const r of rows) byName.set(r.name, r); +byName.set("extra", { name: "extra", score: 9, id: 99 }); +console.log(byName); +console.log(JSON.stringify([...byName.values()])); +console.log(Object.keys(byName.get("extra")!).join(",")); + +// Nested records: every level keeps its own literal's order. +interface Inner { + b: number; + a: number; +} +interface Outer { + inner: Inner; + id: number; + tags: string[]; +} +function makeOuter(id: number): Outer { + return { id, tags: ["t" + id], inner: { a: id, b: id + 1 } }; +} +console.log(JSON.stringify(makeOuter(1))); +console.log(makeOuter(2)); +console.log(JSON.stringify([makeOuter(3), makeOuter(4)], null, 1)); + +// Generic factories instantiate per type argument. +function boxed(value: T): { value: T; tag: string; count: number } { + return { tag: "box", count: 1, value }; +} +console.log(JSON.stringify(boxed(7))); +console.log(JSON.stringify(boxed("s"))); +console.log(Object.keys(boxed(true)).join(",")); + +// Union arms keep their own literal orders. +type Shape = { kind: "circle"; r: number } | { kind: "rect"; w: number; h: number }; +function shapeAt(i: number): Shape { + return i % 2 === 0 ? { r: i, kind: "circle" } : { h: i, kind: "rect", w: i + 1 }; +} +const shapes: Shape[] = [shapeAt(0), shapeAt(1), shapeAt(2)]; +for (const s of shapes) console.log(JSON.stringify(s)); +console.log(shapes); + +// Widened to unknown and any, and cloned. +const u: unknown = makePoint(3); +console.log(JSON.stringify(u)); +const loose: any = makePoint(4); +console.log(Object.keys(loose).join(",")); +const cloned = structuredClone(makePoint(5)); +console.log(JSON.stringify(cloned), Object.keys(cloned).join(",")); + +// Integer-like keys still come first, ascending, ahead of the literal order. +interface Indexed { + name: string; + "10": number; + "2": number; + "01": number; +} +function makeIndexed(): Indexed { + return { "01": 1, name: "n", "10": 10, "2": 2 }; +} +console.log(JSON.stringify(makeIndexed())); +console.log(Object.keys(makeIndexed()).join(",")); + +// A literal whose fields are flipped by later assignments: assigning an +// existing key never moves it (the pattern of scriptc's own +// RuntimeFeatures scan). +interface Features { + regex: boolean; + eventTarget: boolean; + warningReplace: boolean; + stdioExtended: boolean; + processEvents: boolean; + emitter: boolean; +} +function scanFeatures(classes: string[], workers: boolean): Features { + const features: Features = { + regex: false, + eventTarget: classes.some((c) => c === "EventTarget"), + processEvents: false, + warningReplace: false, + stdioExtended: false, + emitter: workers, + }; + if (classes.length > 1) features.regex = true; + features.stdioExtended = workers; + return features; +} +console.log(JSON.stringify(scanFeatures(["EventTarget", "X"], true))); +console.log(JSON.stringify(scanFeatures([], false))); + +// Literals written in declaration order keep it. +interface Ordered { + first: string; + second: number; + third: boolean; +} +const ordered: Ordered = { first: "f", second: 2, third: true }; +console.log(JSON.stringify(ordered), Object.keys(ordered).join(",")); diff --git a/tests/corpus/key-order-spread-copies.ts b/tests/corpus/key-order-spread-copies.ts new file mode 100644 index 0000000000..b677eb0a43 --- /dev/null +++ b/tests/corpus/key-order-spread-copies.ts @@ -0,0 +1,107 @@ +// Copies keep the key order of the values they copy: a spread places the +// source's keys at the spread's position in the source's own order, an +// override keeps the position the spread gave it, and new keys follow. +// Rest bindings, width copies, Object.assign and structuredClone keep the +// source order too, including when the source literal was written in +// another order than its type declares. +interface Source { + a: number; + b: number; + c: number; +} +function makeSource(i: number): Source { + return { c: i, b: i + 1, a: i + 2 }; +} + +// Spread followed by a new key, and by an override. +interface Extended { + a: number; + b: number; + c: number; + d: number; +} +const extended: Extended = { ...makeSource(1), d: 4 }; +console.log(JSON.stringify(extended), Object.keys(extended).join(",")); +const overridden: Source = { ...makeSource(2), b: 0 }; +console.log(JSON.stringify(overridden)); +interface Led { + a: number; + b: number; + c: number; + lead: number; +} +const leading: Led = { lead: 0, ...makeSource(3) }; +console.log(JSON.stringify(leading)); + +// A spread whose overrides change field types builds a new shape through +// the field-by-field desugar (scriptc's own frontend-cache writer). +interface Entry { + ok: true; + llvm: string; + ir: string[] | null; + sidecarJson: string | null; + features: number; + sources: [string, string][]; + warnings?: string[]; +} +function makeEntry(): Entry { + return { ok: true, llvm: "L", ir: ["x"], sidecarJson: null, features: 1, sources: [] }; +} +const entry = makeEntry(); +console.log(JSON.stringify({ schema: "v3", input: { ...entry, llvm: "", ir: null } })); + +// Same-shape updates keep every key in place. +interface State { + name: string; + count: number; + last: string | undefined; +} +let state: State = { count: 0, last: undefined, name: "s" }; +for (let i = 0; i < 3; i++) state = { ...state, count: state.count + 1, last: "i" + i }; +console.log(JSON.stringify(state), Object.keys(state).join(",")); +console.log({ ...state, name: "shown" }); + +// Rest bindings copy the remaining keys in the source's order. +const { b, ...rest } = makeSource(5); +console.log(b, JSON.stringify(rest), Object.keys(rest).join(",")); +let picked: number; +let restAgain: { a: number; c: number }; +({ b: picked, ...restAgain } = makeSource(6)); +console.log(picked, JSON.stringify(restAgain), Object.keys(restAgain).join(",")); + +// Width copies into an optional-widened type and assignment into a fresh +// target. +interface Wider { + a: number; + b: number; + c: number; + note?: string; +} +function widen(s: Source): Wider { + return s; +} +console.log(JSON.stringify(widen(makeSource(7)))); +const assigned = Object.assign({ z: 0 }, makeSource(8)); +console.log(JSON.stringify(assigned)); +console.log(JSON.stringify(structuredClone(makeSource(9)))); + +// Nested spreads of nested records. +interface Holder { + inner: Source; + id: number; +} +const holder: Holder = { id: 1, inner: makeSource(10) }; +const copied: Holder = { ...holder, id: 2 }; +console.log(JSON.stringify(copied)); +console.log(copied); + +// Conditional spreads place their key at the spread's position. +interface Options { + mode: string; + depth?: number; + verbose: boolean; +} +function options(deep: boolean): Options { + return { mode: "m", ...(deep ? { depth: 3 } : {}), verbose: deep }; +} +console.log(JSON.stringify(options(true)), JSON.stringify(options(false))); diff --git a/tests/coverage-fixtures/divergences.ts b/tests/coverage-fixtures/divergences.ts index e13701a93d..10b0a12e7f 100644 --- a/tests/coverage-fixtures/divergences.ts +++ b/tests/coverage-fixtures/divergences.ts @@ -20,14 +20,14 @@ console.log(tagged.label); const view: Labeled = tagged; console.log(view === tagged); -// Key order: the literal's order differs from the declaration and the -// program observes it. +// Key order: values of one type are built in two orders and the program +// observes them, so the type keeps its declaration order. interface Point { x: number; y: number; } const point: Point = { y: 1, x: 2 }; -console.log(JSON.stringify(point)); +console.log(JSON.stringify(point), JSON.stringify({ x: 3, y: 4 })); // Text outside the built-in collation compares by code point natively. console.log(["б", "а"].sort((p, q) => p.localeCompare(q)).join(","), "яблоко".localeCompare("груша")); diff --git a/tests/harness/native-frontend.ts b/tests/harness/native-frontend.ts index 475db54aa6..bb76631a88 100644 --- a/tests/harness/native-frontend.ts +++ b/tests/harness/native-frontend.ts @@ -66,6 +66,8 @@ export const programs = [ "class-array-optional-return.ts", "contextual-array-union-write.ts", "6020-class-extends-function-constructor.js", + // Re-chosen key orders rebuild the inspect helpers after lowering. + "key-order-late-optional.ts", ]; interface LowerRequest {