mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-03 07:54:40 +08:00
Narrow unions of records by intersection refinements, and keep members an assertion attaches (#1484)
This commit is contained in:
@@ -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<string, Map<string, IrType>>();
|
||||
const conflicts = new Set<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<number> | 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<number>();
|
||||
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<number>();
|
||||
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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.<n>(k, r)` walks the declared names (a string-equality
|
||||
|
||||
@@ -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<string, { target: string; fn: string }>();
|
||||
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<string>();
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string[]>();
|
||||
|
||||
/** 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
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6460-in-union-optional-slots.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6461-predicate-intersection-narrowing.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6462-assertion-attached-members.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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<string>; routes: Route[] }): Map<string, Output> {
|
||||
const out = new Map<string, Output>();
|
||||
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");
|
||||
}
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user