diff --git a/packages/compiler/src/frontend/lowering/assertion-members.ts b/packages/compiler/src/frontend/lowering/assertion-members.ts new file mode 100644 index 000000000..d61c379d6 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/assertion-members.ts @@ -0,0 +1,168 @@ +/* Members a type assertion attaches to record values. */ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; +import { type IrType, typeEquals } from "../../ir/ir.js"; +import { hostsAll } from "./expressions/hosted-narrowing.js"; + +/** `route as Route & { order: number; internal: boolean }` followed by + * `view.internal = true` adds a property to the very object `route` + * names: Node has no layouts, so every later read of `route` (its JSON, + * its keys, a spread) sees the member. Native records are closed structs, + * and the asserted intersection maps to a different struct, so neither a + * conversion (a copy that severs the write from `route`) nor an erasure + * (a struct without the slot) is faithful. + * + * This pass finds such assertions before any body lowers and gives each + * record arm of the operand's type the asserted members it does not + * declare, as optional fields. Values the program builds hold them absent, + * which is invisible to every key surface (`in`, Object.keys, JSON, + * inspect, spread). The asserted type then refines the stored arms + * instead of naming new ones (hosted-narrowing.ts), so the assertion keeps + * the value and its writes land in the stored object. + * + * Only the plain form qualifies: one intersection whose constituents are + * the operand's own type and type literals of required property + * signatures, over plain data types (object types, aliases and interfaces; + * not classes or the standard library's types). Arms that are tuples or + * carry an index signature, members whose type does not map, and members + * two assertions type differently are left alone: those assertions keep + * their exact-shape diagnostics. Assertions whose operand already declares + * every member are skipped before any type maps, so programs without the + * form intern exactly the types they did before. */ +export function extendAssertedRecordMembers( + lowerer: Lowerer, + files: readonly ts.SourceFile[], +): void { + const additions = new Map>(); + const conflicts = new Set(); + const consider = (node: ts.AsExpression | ts.TypeAssertion): void => { + let typeNode: ts.TypeNode = node.type; + while (ts.isParenthesizedTypeNode(typeNode)) typeNode = typeNode.type; + if (!ts.isIntersectionTypeNode(typeNode)) return; + const parts = typeNode.types.nodes; + const literals = parts.filter((p) => ts.isTypeLiteralNode(p)); + const bases = parts.filter((p) => !ts.isTypeLiteralNode(p)); + if (literals.length === 0 || bases.length !== 1) return; + const memberNodes: { name: string; type: ts.TypeNode }[] = []; + for (const literal of literals) { + if (!ts.isTypeLiteralNode(literal)) return; + for (const member of literal.members.nodes) { + // A required member: the form that writes through the view. An + // optional one only reads, and keeps its exact-shape fence. + if (!ts.isPropertySignatureDeclaration(member) || !member.type || member.questionToken) + return; + const name = ts.isIdentifier(member.name) + ? member.name.text + : ts.isStringLiteral(member.name) + ? member.name.text + : null; + if (name === null || name.startsWith("%")) return; + memberNodes.push({ name, type: member.type }); + } + } + // Checker-only gate first: an assertion every constituent of whose + // operand type already declares the members (`e as Expr & { kind: + // "mul" }`, the refinement idiom) attaches nothing, and mapping its + // types here would intern them out of the usual order. + const operandTs = lowerer.typeOf(node.expression); + const constituents = operandTs.isUnionType() ? ts.constituentTypes(operandTs) : [operandTs]; + const declares = (t: ts.Type, name: string): boolean => + lowerer.checker.getPropertyOfType(t, name) !== undefined; + if (constituents.every((t) => memberNodes.every((m) => declares(t, m.name)))) return; + // Plain data types only: object literal types, aliases and interfaces + // (not classes, and not the standard library's types). + const dataType = (t: ts.Type): boolean => { + const symbol = t.getAliasSymbol() ?? t.getSymbol(); + return ( + !!symbol && + (symbol.flags & ts.SymbolFlags.Class) === 0 && + symbol.declarations.length > 0 && + symbol.declarations.every( + (d) => + !lowerer.isStdlibFile(d.getSourceFile()) && + (ts.isTypeLiteralNode(d) || + ts.isInterfaceDeclaration(d) || + ts.isTypeAliasDeclaration(d)), + ) + ); + }; + if (!constituents.every(dataType)) return; + const operand = lowerer.mapTypeOf(operandTs); + const base = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(bases[0]!)); + if (!operand || !base || !typeEquals(operand, base)) return; + const arms = + operand.kind === "union" ? (lowerer.unions.get(operand.unionId)?.arms ?? []) : [operand]; + if (arms.length === 0) return; + const shapeIds: string[] = []; + for (const arm of arms) { + if (arm.kind !== "record") return; + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.tuple || shape.indexValue) return; + shapeIds.push(arm.shapeId); + } + const members: { name: string; type: IrType }[] = []; + for (const { name, type } of memberNodes) { + if (shapeIds.every((id) => lowerer.shapes.get(id)!.fields.some((f) => f.name === name))) + continue; + const mapped = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(type)); + if (!mapped || mapped.kind === "void") return; + const optional = lowerer.withUndefinedArmOf(mapped); + if (!optional) return; + members.push({ name, type: optional }); + } + for (const shapeId of shapeIds) { + const shape = lowerer.shapes.get(shapeId)!; + for (const member of members) { + if (shape.fields.some((f) => f.name === member.name)) continue; + let added = additions.get(shapeId); + if (!added) additions.set(shapeId, (added = new Map())); + const prior = added.get(member.name); + if (prior && !typeEquals(prior, member.type)) conflicts.add(`${shapeId}:${member.name}`); + else added.set(member.name, member.type); + } + } + }; + const visit = (node: ts.Node): void => { + if (ts.isAsExpression(node) || ts.isTypeAssertion(node)) consider(node); + ts.forEachChild(node, visit); + }; + for (const sf of files) { + if (sf.isDeclarationFile) continue; + // Cheap text gate: the form needs an intersection with a type literal. + if (!/&\s*\{/.test(sf.text)) continue; + visit(sf); + } + for (const [shapeId, added] of additions) { + const extra = [...added] + .filter(([name]) => !conflicts.has(`${shapeId}:${name}`)) + .map(([name, type]) => ({ name, type })); + lowerer.shapes.extendShape(shapeId, extra); + } + if (additions.size > 0) keepStoredTypeOfAssertedGlobals(lowerer, files); +} + +/** A module-level `const view = value as T & { member: X }` binding keeps + * the stored type of `value` once the members are attached, exactly as a + * local binding does (lowerVarDecl): the assertion keeps the object, and + * writes through the binding must reach it. Globals took their checker + * (or annotated) type during collection, before the members existed; an + * annotation the stored type hosts gives way to it the same way. */ +function keepStoredTypeOfAssertedGlobals(lowerer: Lowerer, files: readonly ts.SourceFile[]): void { + for (const sf of files) { + if (sf.isDeclarationFile) continue; + for (const statement of sf.statements.nodes) { + if (!ts.isVariableStatement(statement)) continue; + if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue; + for (const decl of statement.declarationList.declarations.nodes) { + if (!ts.isIdentifier(decl.name) || !decl.initializer) continue; + let init: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if (!ts.isAsExpression(init) && !ts.isTypeAssertion(init)) continue; + const global = lowerer.globalOf(decl.name); + const stored = lowerer.mapTypeOf(lowerer.typeOf(init.expression)); + if (!global || !stored || (stored.kind !== "union" && stored.kind !== "record")) continue; + if (hostsAll(lowerer, stored, global.type)) global.type = stored; + } + } + } +} diff --git a/packages/compiler/src/frontend/lowering/expressions/hosted-narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/hosted-narrowing.ts new file mode 100644 index 000000000..87a7ba38e --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/hosted-narrowing.ts @@ -0,0 +1,157 @@ +/* Narrowings that refine record arms without naming a stored arm. */ +import type { Lowerer } from "../lowerer.js"; +import { type IrExpr, type IrType, type SrcLoc, typeEquals } from "../../../ir/ir.js"; + +/** A checker narrowing can refine the arms of a union of records with an + * intersection: a predicate `r is Route & { path: string }`, or the + * `"path" in r` narrowing, which intersects arms that do not declare the + * name with `Record<"path", unknown>`. Each narrowed arm then maps to a + * new record shape that is not an arm of the stored union, although the + * value never changed layout. A stored record arm HOSTS a narrowed record + * when both name the same members, each narrowed member refined to a + * subset of the stored slot type (or to `unknown`); a member an assertion + * attached to the stored shape (assertion-members.ts) may be missing from + * the narrowed one. A required member the stored arm does not declare + * cannot exist on its values: native records are closed, so `in` already + * answers false there and the narrowed arm is uninhabited. + * + * Only unions of two or more record arms without a literal discriminant + * qualify: discriminated unions narrow by their discriminant, and a + * record with unit arms keeps its own predicate-strengthening reads. + * + * Returns the stored arms that can hold a value of the narrowed type, or + * null when the union does not qualify, when some narrowed arm is neither + * a stored arm nor a record, or when no stored arm is excluded (there is + * nothing to narrow). */ +export function hostedArmTags( + lowerer: Lowerer, + fromId: string, + narrowed: IrType, +): Set | null { + const from = lowerer.unions.get(fromId); + if (!from || !recordUnion(lowerer, fromId)) return null; + const narrowedArms = + narrowed.kind === "union" ? lowerer.unions.get(narrowed.unionId)?.arms : [narrowed]; + if (!narrowedArms || narrowedArms.length === 0) return null; + const allowed = new Set(); + let refined = false; + for (const arm of narrowedArms) { + const direct = lowerer.armTag(fromId, arm); + if (direct >= 0) { + allowed.add(direct); + continue; + } + if (arm.kind !== "record") return null; + refined = true; + from.arms.forEach((stored, tag) => { + if (stored.kind === "record" && recordHosts(lowerer, stored.shapeId, arm.shapeId)) { + allowed.add(tag); + } + }); + } + if (!refined || allowed.size === 0 || allowed.size === from.arms.length) return null; + return allowed; +} + +/** A union of at least two record arms without a literal discriminant. */ +function recordUnion(lowerer: Lowerer, unionId: string): boolean { + const def = lowerer.unions.get(unionId); + if (!def || def.discriminant) return false; + return def.arms.filter((arm) => arm.kind === "record").length >= 2; +} + +/** Whether every value of `narrowed` (a checker refinement of a value + * stored as `storage`) lives in the stored layout: each narrowed arm is a + * stored arm or a record a stored record arm hosts (see hostedArmTags), + * and at least one needs the hosting. A union storage must qualify as in + * hostedArmTags; a single record only when an assertion attached members + * to it. A binding initialized by such an assertion keeps the stored type, + * so its member writes reach the asserted object. */ +export function hostsAll(lowerer: Lowerer, storage: IrType, narrowed: IrType): boolean { + if (typeEquals(storage, narrowed)) return false; + let storedArms: readonly IrType[]; + if (storage.kind === "union") { + if (!recordUnion(lowerer, storage.unionId)) return false; + storedArms = lowerer.unions.get(storage.unionId)!.arms; + } else if (storage.kind === "record" && lowerer.shapes.attachedNames(storage.shapeId).length) { + storedArms = [storage]; + } else { + return false; + } + const narrowedArms = + narrowed.kind === "union" ? lowerer.unions.get(narrowed.unionId)?.arms : [narrowed]; + if (!narrowedArms || narrowedArms.length === 0) return false; + let refined = false; + for (const arm of narrowedArms) { + if (storedArms.some((stored) => typeEquals(stored, arm))) continue; + if (arm.kind !== "record") return false; + const hosted = storedArms.some( + (stored) => stored.kind === "record" && recordHosts(lowerer, stored.shapeId, arm.shapeId), + ); + if (!hosted) return false; + refined = true; + } + return refined; +} + +function recordHosts(lowerer: Lowerer, storedId: string, narrowedId: string): boolean { + const stored = lowerer.shapes.get(storedId); + const narrowed = lowerer.shapes.get(narrowedId); + if (!stored || !narrowed || stored.tuple || narrowed.tuple) return false; + if (stored.indexValue || narrowed.indexValue) return false; + const attached = lowerer.shapes.attachedNames(storedId); + for (const sf of stored.fields) { + if (!narrowed.fields.some((nf) => nf.name === sf.name) && !attached.includes(sf.name)) + return false; + } + for (const nf of narrowed.fields) { + const sf = stored.fields.find((f) => f.name === nf.name); + if (!sf || !slotRefines(lowerer, nf.type, sf.type)) return false; + } + return true; +} + +/** Whether a stored slot of type `stored` can be read as `narrowed`: the + * same type, `unknown`, or a subset of the stored union's arms (a read + * site narrows from the stored slot exactly as it does for a predicate + * strengthening one record). */ +function slotRefines(lowerer: Lowerer, narrowed: IrType, stored: IrType): boolean { + if (typeEquals(narrowed, stored) || narrowed.kind === "dyn") return true; + if (stored.kind !== "union") return false; + if (narrowed.kind !== "union") return lowerer.armTag(stored.unionId, narrowed) >= 0; + const arms = lowerer.unions.get(narrowed.unionId)?.arms; + return !!arms && arms.every((arm) => lowerer.armTag(stored.unionId, arm) >= 0); +} + +/** The stored view of a hosted narrowing (see hostedArmTags): one stored + * record arm extracted with the checked helper, or the sub-union of the + * hosting arms re-tagged with the excluded arms as traps. Both keep the + * value's stored layout, so member reads and writes reach the same + * object. Null when the narrowing is not a hosted one. */ +export function hostedStorageView( + lowerer: Lowerer, + value: IrExpr, + narrowed: IrType, + loc: SrcLoc, +): IrExpr | null { + if (value.type.kind !== "union") return null; + const fromId = value.type.unionId; + const allowed = hostedArmTags(lowerer, fromId, narrowed); + if (!allowed) return null; + const from = lowerer.unions.get(fromId)!; + if (allowed.size === 1) { + const arm = from.arms[[...allowed][0]!]!; + const helper = lowerer.narrowedArmHelper(fromId, arm, loc); + return helper ? lowerer.checkedNarrowCall(helper, value, arm, loc) : null; + } + const arms = from.arms.filter((_, tag) => allowed.has(tag)); + const subId = lowerer.unions.transform(from, arms); + const trappable = new Set(); + from.arms.forEach((_, tag) => { + if (!allowed.has(tag)) trappable.add(tag); + }); + const helper = lowerer.unionRetagHelper(fromId, subId, loc, trappable); + if (!helper) return null; + const type: IrType = { kind: "union", unionId: subId }; + return { kind: "call", callee: helper, args: [value], type, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index ca68d34da..b54da8180 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -29,6 +29,7 @@ import { isOptionalProcessStreamProperty } from "../builtins/process.js"; import { literalValues } from "../../literal-values.js"; import { literalUnionArm } from "../../union-discriminants.js"; import { checkedArmExtraction } from "../coercions/unions.js"; +import { hostedStorageView, hostsAll } from "./hosted-narrowing.js"; import { nodeThrowExpr, varRef } from "../../../ir/build.js"; import { InternalCompilerError } from "../../../errors.js"; import { @@ -659,6 +660,12 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx }; } } + // A narrowing that refines record arms with an intersection keeps the + // stored layout: view the value as the stored arms that can hold it. + if (narrowed?.kind === "union") { + const hosted = hostedStorageView(lowerer, expr, narrowed, expr.loc); + if (hosted) return hosted; + } // A checker type narrowed to a UNIT arm (the `=== undefined` branch) // also stays union-typed: a unit arm has no payload to extract, and // nothing useful reads such a value anyway. (Standalone undefined maps @@ -668,6 +675,14 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx return expr; } const tag = lowerer.armTag(expr.type.unionId, narrowed); + if ( + tag < 0 && + narrowed.kind === "record" && + !lowerer.unions.get(expr.type.unionId)?.discriminant + ) { + const hosted = hostedStorageView(lowerer, expr, narrowed, expr.loc); + if (hosted) return hosted; + } if (tag < 0) { // The checker may refine fields inside a discriminated record too. // That creates a different mapped shape, but does not change the @@ -926,6 +941,10 @@ export function runtimeOptionalReceiverRead( loc, ); if (!helper) { + // Intersection-refined record arms read through their stored + // layout (the predicate `r is Route & { path: string }`). + const hosted = hostedStorageView(lowerer, varRef(local.id, local.type, loc), narrowed, loc); + if (hosted) return hosted; lowerer.unsupported( "SC1090", expr, @@ -946,6 +965,10 @@ export function runtimeOptionalReceiverRead( // path below; mixed storage also needs to validate its concrete class. if (view && (lowerer.unions.get(local.type.unionId)?.arms.length ?? 0) > 2) return view; } + if (narrowed.kind === "record" && lowerer.armTag(local.type.unionId, narrowed) < 0) { + const hosted = hostedStorageView(lowerer, varRef(local.id, local.type, loc), narrowed, loc); + if (hosted) return hosted; + } const undefTag = lowerer.armTag(local.type.unionId, UNDEFINED_T); const def = lowerer.unions.get(local.type.unionId); if (!def || undefTag < 0) @@ -1300,6 +1323,12 @@ export function lowerAsExpression( ) { return lowerer.coerceInto(expr, inner, target); } + // `job as Job & { startedAt: number }` on a record: the asserted + // record refines the stored one, so the assertion keeps the object + // (a width copy would sever writes through the asserted view). + if (inner.type.kind === "record" && target && hostsAll(lowerer, inner.type, target)) { + return inner; + } // Static assertions normally erase, but record layouts are // monomorphic: a consumer selected from the asserted shape must see // that shape physically. Reuse the ordinary slot coercion so plain @@ -1326,6 +1355,12 @@ export function lowerAsExpression( return lowerer.checkedNarrowCall(helper, inner, target, locOf(expr)); } } + // An intersection refining the stored record arms keeps the value + // (hosted-narrowing.ts): the stored arms that can hold it. + if (target && (target.kind === "union" || target.kind === "record")) { + const hosted = hostedStorageView(lowerer, inner, target, locOf(expr)); + if (hosted) return hosted; + } } // `node.parent as Sub | undefined`: a union target whose class arm is // narrower than the stored class gets the same checked class view as diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 619c0fe37..4aa62534e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -38,7 +38,7 @@ import { lowerCryptoKeyInstanceof } from "../builtins/webcrypto.js"; import { lowerNetAddressInstanceof } from "../builtins/net-address.js"; import { fenceEarlyNsMemberRef, nsMemberIdentOf } from "../lower-namespaces.js"; import { maybePromiseInstanceOf } from "../maybe-promise.js"; -import { isSafeToDiscard } from "./evaluation-safety.js"; +import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js"; import { type ClassInfo, storedClassValueType } from "../lower-classes.js"; import { classInstanceOf } from "../class-dynamic-dispatch.js"; import { InternalCompilerError } from "../../../errors.js"; @@ -1429,13 +1429,14 @@ export function lowerInExpression( // arm answers membership STATICALLY (a declared non-optional field is // always present, an undeclared name never is), so the whole test // collapses to runtime TAG tests — tsc's own narrowing then types the - // branches (the discriminating-`in` idiom). Optional (undefined-armed) - // fields and tuple/index-signature arms answer per VALUE, not per arm - // — those unions keep the fence below. + // branches (the discriminating-`in` idiom). An arm declaring the key + // as an optional (undefined-armed) slot answers per VALUE instead: + // its tag test joins that payload's presence test (see + // perValueUnionIn). Tuple/index-signature arms keep the fence below. if (recv.type.kind === "union") { const unionId = recv.type.unionId; const arms = lowerer.unions.get(unionId)?.arms ?? []; - const answers: { tag: number; has: boolean }[] = []; + const answers: { tag: number; has: boolean | "slot"; arm: IrType }[] = []; let staticAnswers = arms.length > 0; for (const arm of arms) { const tag = lowerer.armTag(unionId, arm); @@ -1452,14 +1453,17 @@ export function lowerInExpression( f.type.kind === "union" && lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0 ) { - staticAnswers = false; // an optional slot: presence is per-value - break; + answers.push({ tag, has: "slot", arm }); // presence is per-value + continue; } // Accessor properties are own properties to `in` (Node answers // true without invoking the getter) — either slot present makes // the name a member. const acc = shape.fields.some((x) => x.name === `%get:${key}` || x.name === `%set:${key}`); - answers.push({ tag, has: f !== undefined || acc || inherited }); + answers.push({ tag, has: f !== undefined || acc || inherited, arm }); + } + if (staticAnswers && answers.some((a) => a.has === "slot")) { + return perValueUnionIn(lowerer, recv, unionId, key, answers, loc); } if (staticAnswers) { const pureRecv = @@ -1611,6 +1615,75 @@ export function lowerInExpression( ); } +/** `"k" in u` over a union of fixed record shapes where some arm declares + * `k` as an optional slot. Arms without the name answer false and arms + * with an always-present member answer true, both by tag; an optional + * slot answers its payload's own-property presence (an explicit + * undefined is present, an omitted or deleted property is not), read + * through the checked arm extraction the tag test has just proven. The + * receiver evaluates once: a non-repeatable one is stabilized in a + * hidden local first. */ +function perValueUnionIn( + lowerer: Lowerer, + recv: IrExpr, + unionId: string, + key: string, + answers: readonly { tag: number; has: boolean | "slot"; arm: IrType }[], + loc: SrcLoc, +): IrExpr { + const recvType = recv.type; + const stable = isSafeToRepeat(recv) ? null : lowerer.declareHiddenLocal("%inRecv", recvType); + const ref = (): IrExpr => (stable ? varRef(stable.id, recvType, loc) : recv); + const isTag = (tag: number): IrExpr => ({ + kind: "unionIsTag", + unionId, + tag, + negated: false, + value: ref(), + type: BOOL, + loc, + }); + let out: IrExpr | null = null; + for (const a of answers) { + if (a.has === false) continue; + let test = isTag(a.tag); + if (a.has === "slot" && a.arm.kind === "record") { + test = { + kind: "logical", + op: "&&", + left: test, + right: { + kind: "recordHas", + obj: { + kind: "unionNarrow", + unionId, + tag: a.tag, + value: ref(), + type: a.arm, + loc, + }, + shapeId: a.arm.shapeId, + field: key, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } + out = out ? { kind: "logical", op: "||", left: out, right: test, type: BOOL, loc } : test; + } + const result: IrExpr = out ?? { kind: "boolLit", value: false, type: BOOL, loc }; + if (!stable) return result; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: stable.id, init: recv, loc }], + result, + type: BOOL, + loc, + }; +} + /** The runtime-key `in` (see lowerInExpression): `k in r` where k is a * runtime string and r an index-signature record — an interned * `%rec.haskey.(k, r)` walks the declared names (a string-equality diff --git a/packages/compiler/src/frontend/lowering/key-order-witness.ts b/packages/compiler/src/frontend/lowering/key-order-witness.ts index ba8001792..437a29563 100644 --- a/packages/compiler/src/frontend/lowering/key-order-witness.ts +++ b/packages/compiler/src/frontend/lowering/key-order-witness.ts @@ -224,12 +224,30 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio const loops: number[] = []; let loopIds = 0; const loopKey = (): string => loops.join("/"); + /** Bindings initialized by another binding's reference (`const view = + * value`, the snapshot a union-receiver write takes), so writes through + * either count as writes through the one object. */ + const aliases = new Map(); + const bindingOf = (obj: IrExpr): string | null => { + let o = obj; + while (o.kind === "unionNarrow" || o.kind === "downcast") o = o.value; + if (o.kind !== "varRef") return null; + let id = o.localId; + const seen = new Set(); + for (;;) { + const alias = aliases.get(id); + if (!alias || alias.fn !== fnName || inits.get(id)?.count !== 1 || seen.has(id)) return id; + seen.add(id); + id = alias.target; + } + }; const noteInit = (id: string, init: IrExpr): void => { const previous = inits.get(id); if (previous !== undefined) { previous.count++; return; } + if (init.kind === "varRef") aliases.set(id, { target: init.localId, fn: fnName }); const fresh = init.kind === "recordLit" || init.kind === "recordClone" || @@ -315,7 +333,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio field: st.field, kind: st.value.kind === "fieldAbsent" ? "delete" : "write", fn: fnName, - binding: st.obj.kind === "varRef" ? st.obj.localId : null, + binding: bindingOf(st.obj), loop: loopKey(), pos, }); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index e530a57f7..0e4f3c24c 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1,3 +1,4 @@ +import { hostsAll } from "./expressions/hosted-narrowing.js"; import { matchAllRowsReadByElements, storedMatchAllRowsReadByElements, @@ -5481,6 +5482,23 @@ export function lowerVarDecl( settledType = init.type; } } + // `const view = route as Route & { order: number }`: the asserted type + // refines the record arms the value is stored in (the assertion keeps + // that value, see hosted-narrowing.ts). The binding keeps the stored + // type, so writes through it reach the asserted object. An annotation + // the stored type hosts (`const view: Route & { order: number } = ...`) + // keeps it too: coercing into the annotated layout would copy the + // value and read members the writes have not created yet. + if ( + !isLet && + decl.initializer && + isAssertionInitializer(decl.initializer) && + (settledType.kind === "union" || settledType.kind === "record") && + (init.type.kind === "union" || init.type.kind === "record") && + hostsAll(lowerer, init.type, settledType) + ) { + settledType = init.type; + } // An exact class behind a generic interface keeps its native // representation so generic calls can specialize against its body. if ( @@ -11212,3 +11230,9 @@ export function stdlibBufferVarAlias(lowerer: Lowerer, decl: ts.VariableDeclarat requireEarlyReference(lowerer.program, decl) === null ); } + +function isAssertionInitializer(node: ts.Expression): boolean { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + return ts.isAsExpression(e) || ts.isTypeAssertion(e); +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7144e574e..4a8bee1fa 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,3 +1,4 @@ +import { extendAssertedRecordMembers } from "./assertion-members.js"; import { jsDynamicDeclarations } from "./js-dynamic-objects.js"; import { lifetimeRegionType, @@ -6781,6 +6782,10 @@ export class Lowerer { } this.collectProgram(parts); this.speculativeTypeQueries(() => { + extendAssertedRecordMembers( + this, + parts.map((fp) => fp.sf), + ); this.analyzeRuntimeOptionalArrayReads(parts); this.analyzeDateJsonResults(parts); this.analyzeGuardedRecordReadGlobals(parts); @@ -7188,6 +7193,10 @@ export class Lowerer { }; this.collectProgram(parts); this.speculativeTypeQueries(() => { + extendAssertedRecordMembers( + this, + parts.map((fp) => fp.sf), + ); this.analyzeRuntimeOptionalArrayReads(parts); this.analyzeDateJsonResults(parts); this.analyzeGuardedRecordReadGlobals(parts); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index ea706992a..22b4c4e7a 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -325,7 +325,7 @@ export class ShapeRegistry { ) { return; } - shape.declaredOrder = declaredOrder; + shape.declaredOrder = this.withExtendedNames(shape.id, declaredOrder); this.declaredOrderPriority.set(shape.id, this.currentDeclaredOrderPriority); } @@ -341,7 +341,7 @@ export class ShapeRegistry { } const shape = this.byId.get(shapeId); if (!shape) continue; - shape.declaredOrder = best.order; + shape.declaredOrder = this.withExtendedNames(shapeId, best.order); this.declaredOrderPriority.set(shapeId, best.priority); } for (const finalize of this.derivedDeclaredOrderFinalizers) finalize(); @@ -374,7 +374,7 @@ export class ShapeRegistry { // still obeys first-writer-wins. if (shape?.declaredOrder !== originalOrder) return; const next = order(); - if (next !== undefined) shape.declaredOrder = next; + if (next !== undefined) shape.declaredOrder = this.withExtendedNames(shape.id, next); }; } @@ -482,6 +482,53 @@ export class ShapeRegistry { get(shapeId: string): IrRecordShape | undefined { return this.byId.get(shapeId); } + + /** Names extendShape added to a shape, which every key-order table the + * shape adopts lists after its declared names. */ + private readonly extendedNames = new Map(); + + /** The members extendShape attached to a shape (empty for most). */ + attachedNames(shapeId: string): readonly string[] { + return this.extendedNames.get(shapeId) ?? []; + } + + private withExtendedNames(shapeId: string, order: string[]): string[] { + const extra = this.extendedNames.get(shapeId); + if (!extra) return order; + const missing = extra.filter((name) => !order.includes(name)); + return missing.length === 0 ? order : [...order, ...missing]; + } + + /** Adds optional fields to an interned shape in place: members a type + * assertion attaches to the shape's values (`r as R & { tag: string }` + * followed by `view.tag = ...`; see assertion-members.ts). Every value + * of the shape holds them absent until a write through such a view, so + * the shape keeps its id, both structural keys resolve to it, and its + * key-order table lists the new names after the declared ones. Runs + * before any body lowers; existing layouts change, so the revision + * advances like a closing recursive placeholder. */ + extendShape(shapeId: string, extra: readonly { name: string; type: IrType }[]): void { + const shape = this.byId.get(shapeId); + if (!shape || shape.tuple || extra.length === 0) return; + const fresh = extra.filter((f) => !shape.fields.some((x) => x.name === f.name)); + if (fresh.length === 0) return; + shape.fields = [...shape.fields, ...fresh].sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ); + this.extendedNames.set(shapeId, [ + ...(this.extendedNames.get(shapeId) ?? []), + ...fresh.map((f) => f.name), + ]); + if (shape.declaredOrder) + shape.declaredOrder = this.withExtendedNames(shapeId, shape.declaredOrder); + const candidates = this.declaredOrderCandidates.get(shapeId); + if (candidates) { + for (const c of candidates) c.order = this.withExtendedNames(shapeId, c.order); + } + this.revision++; + const key = this.keyOf(shape.fields, false, shape.indexValue); + if (!this.byKey.has(key)) this.byKey.set(key, shapeId); + } } /** The frontend's union interner — mirrors ShapeRegistry. A union's diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6460-in-union-optional-slots.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6460-in-union-optional-slots.ts.json new file mode 100644 index 000000000..8491507eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6460-in-union-optional-slots.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6460-in-union-optional-slots.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6461-predicate-intersection-narrowing.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6461-predicate-intersection-narrowing.ts.json new file mode 100644 index 000000000..3ce078006 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6461-predicate-intersection-narrowing.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6461-predicate-intersection-narrowing.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6462-assertion-attached-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6462-assertion-attached-members.ts.json new file mode 100644 index 000000000..36e333373 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6462-assertion-attached-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6462-assertion-attached-members.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6460-in-union-optional-slots.ts b/tests/corpus/6460-in-union-optional-slots.ts new file mode 100644 index 000000000..f381217b9 --- /dev/null +++ b/tests/corpus/6460-in-union-optional-slots.ts @@ -0,0 +1,53 @@ +// `"k" in u` over a union of records where some arms declare `k` as an +// optional slot: those arms answer by the value's own property presence +// (an explicit undefined is present, an omitted or deleted key is not), +// the other arms by their tag. +type Cond = { type: string; value?: string }; +type HandleRoute = { handle: string; src?: string; dest?: string; status?: number }; +type SourceRoute = { + src: string; + dest?: string; + headers?: { [key: string]: string }; + middlewarePath?: string; + continue?: boolean; + status?: number; + has?: Cond[]; +}; +type Route = HandleRoute | SourceRoute; + +const routes: Route[] = [ + { handle: "filesystem" }, + { src: "/a", middlewarePath: "/mw" }, + { src: "/b", dest: "/c", status: undefined }, + { handle: "miss", src: "/x", dest: undefined }, + { src: "/d", has: [{ type: "header" }] }, +]; + +for (const r of routes) { + console.log("handle" in r, "middlewarePath" in r, "dest" in r, "status" in r, "has" in r, "toString" in r); +} + +// A deleted optional key leaves the value; a later write brings it back. +const second = routes[1]!; +if ("middlewarePath" in second) { + delete second.middlewarePath; +} +console.log("middlewarePath" in second, JSON.stringify(second)); +if (!("handle" in second)) { + second.middlewarePath = "/again"; +} +console.log("middlewarePath" in second, JSON.stringify(second)); + +// The receiver evaluates once, even when it is a call. +let calls = 0; +function pick(i: number): Route { + calls++; + return routes[i]!; +} +console.log("dest" in pick(2), "dest" in pick(0), "src" in pick(3), calls); + +// `in` as a narrowing test inside a predicate and a filter callback. +function hasMiddleware(route: Route): boolean { + return "middlewarePath" in route && typeof route.middlewarePath === "string"; +} +console.log(routes.filter(hasMiddleware).length, routes.filter((r) => "status" in r).length); diff --git a/tests/corpus/6461-predicate-intersection-narrowing.ts b/tests/corpus/6461-predicate-intersection-narrowing.ts new file mode 100644 index 000000000..7e8ff9d8a --- /dev/null +++ b/tests/corpus/6461-predicate-intersection-narrowing.ts @@ -0,0 +1,70 @@ +// A predicate `r is Route & { path: string }` refines the record arms of +// a union without naming a stored arm: arms that do not declare `path` +// cannot hold such a value, the others are read through their own +// layout. The narrowed value can be read in place, in templates, in +// object literals and from closures. +type HandleRoute = { handle: string; src?: string; dest?: string }; +type SourceRoute = { src: string; dest?: string; path?: string; status?: number }; +type Route = HandleRoute | SourceRoute; + +function isPathRoute(route: Route): route is Route & { path: string } { + return "path" in route && typeof route.path === "string"; +} + +type Output = { path: string; index: number }; + +function collect(result: { names: Set; routes: Route[] }): Map { + const out = new Map(); + for (const route of result.routes) { + if (!isPathRoute(route)) continue; + let name = route.path; + if (!result.names.has(name)) name = name.replace(/^\//, ""); + if (!result.names.has(name)) { + throw new Error(`Mapping ${route.path} not found`); + } + out.set(name, { path: route.path, index: out.get(name)?.index ?? out.size }); + } + return out; +} + +const routes: Route[] = [ + { handle: "filesystem" }, + { src: "/a", path: "/one" }, + { src: "/b" }, + { src: "/c", path: "two" }, + { src: "/d", path: "/one", status: 308 }, +]; +console.log([...collect({ names: new Set(["one", "two"]), routes })]); +try { + collect({ names: new Set(), routes: [{ src: "/z", path: "/missing" }] }); +} catch (e) { + console.log((e as Error).message); +} + +// Closures capture the narrowed binding. +const readers: (() => string)[] = []; +for (const route of routes) { + if (!isPathRoute(route)) continue; + readers.push(() => `${route.src} -> ${route.path}`); +} +console.log(readers.map((read) => read())); + +// Three arms, two of which can hold the refinement: the narrowed value is +// either of them, and their shared members still read. +type A = { kind: string; a: number; tag?: string }; +type B = { kind: string; b: boolean; tag?: string }; +type C = { kind: string; c: string }; +type Item = A | B | C; +function isTagged(item: Item): item is Item & { tag: string } { + return "tag" in item && item.tag !== undefined; +} +const items: Item[] = [ + { kind: "a", a: 1, tag: "x" }, + { kind: "b", b: true }, + { kind: "c", c: "plain" }, + { kind: "b", b: false, tag: "y" }, +]; +for (const item of items) { + if (isTagged(item)) console.log(item.kind, item.tag.toUpperCase()); + else console.log(item.kind, "untagged"); +} diff --git a/tests/corpus/6462-assertion-attached-members.ts b/tests/corpus/6462-assertion-attached-members.ts new file mode 100644 index 000000000..8a2be31b7 --- /dev/null +++ b/tests/corpus/6462-assertion-attached-members.ts @@ -0,0 +1,58 @@ +// `route as Route & { order: number; internal: boolean }` followed by +// writes through the asserted view adds the members to the object the +// original binding names: they appear wherever the route is observed +// later, in the order the writes created them. +type HandleRoute = { handle: string; src?: string; dest?: string; status?: number }; +type SourceRoute = { src: string; dest?: string; path?: string; status?: number }; +type Route = HandleRoute | SourceRoute; + +function markInternal(input: Route[], out: Route[], order: number): void { + for (const route of input) { + const marked = route as Route & { internal: boolean; order: number }; + marked.internal = true; + marked.order = order; + out.unshift(route); + } +} + +const out: Route[] = [{ handle: "filesystem" }, { src: "/plain", dest: "/x" }]; +markInternal([{ src: "/a" }, { handle: "miss" }, { src: "/b", status: 404 }], out, 3); +console.log(JSON.stringify(out)); +console.log(out); +console.log(out.map((r) => ("internal" in (r as Route & { internal?: boolean }) ? "marked" : "plain"))); +console.log(out.map((r) => ({ ...r }))); + +// Values the assertion never reached keep no trace of the members. +const fresh: Route = { src: "/new" }; +console.log(JSON.stringify(fresh), Object.keys(fresh)); + +// A single record shape works the same way, and the asserted view reads +// back what it wrote. +type Job = { id: string; retries?: number }; +const job: Job = { id: "j1" }; +const tracked = job as Job & { startedAt: number }; +tracked.startedAt = 42; +console.log(tracked.startedAt + 1, JSON.stringify(job), Object.keys(job)); + +// An annotated binding of the asserted type keeps the object as well: +// the members appear on it once written, not before. +function stamp(job: Job): Job & { finishedAt: number } { + const done: Job & { finishedAt: number } = job as Job & { finishedAt: number }; + done.finishedAt = 7; + return done; +} +const second: Job = { id: "j2", retries: 1 }; +console.log(stamp(second).finishedAt, JSON.stringify(second), Object.keys(second)); + +const third: Job = { id: "j3" }; +const queued: Job & { queuedAt: number } = third as Job & { queuedAt: number }; +queued.queuedAt = 9; +console.log(queued.queuedAt, JSON.stringify(third), Object.keys(third)); + +function markAnnotated(route: Route): void { + const marked: Route & { internal: boolean } = route as Route & { internal: boolean }; + marked.internal = false; +} +const annotatedRoute: Route = { handle: "hit" }; +markAnnotated(annotatedRoute); +console.log(JSON.stringify(annotatedRoute));