Merge main

This commit is contained in:
Chris Tate
2026-10-29 22:54:58 -05:00
68 changed files with 2183 additions and 273 deletions
@@ -595,6 +595,19 @@ export function emitDynamicExpr(
B.br(join);
return;
}
// A declared-only arm without the literal key: the object has no
// such own property, so the read answers undefined.
if (literal !== null && !shape.indexValue) {
const tag = resultDef?.arms.findIndex((a) => a.kind === "undefinedT") ?? -1;
if (tag < 0 || e.type.kind !== "union") {
throw new InternalCompilerError(
"llvm emitter bug: unionKeyGet missing key without an undefined result arm",
);
}
B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, tag)}, ptr ${slot}`);
B.br(join);
return;
}
// Index-signature arm (or declared-only shape under a runtime
// key): the shared keyed-read chain — a literal key naming no
// declared field touches only the overflow map.
@@ -811,10 +811,19 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
const kind = host.emitExpr(e.args[0]!);
const code = host.emitExpr(e.args[1]!);
const msg = host.emitExpr(e.args[2]!);
host.declare(`declare void @scr_throw_node_coded(double, ptr, ptr)`);
B.line(
`call void @scr_throw_node_coded(double ${kind.name}, ptr ${code.name}, ptr ${msg.name})`,
);
if (e.args.length > 3) {
// The fourth argument: the error's own `url` property.
const url = host.emitExpr(e.args[3]!);
host.declare(`declare void @scr_throw_node_coded_url(double, ptr, ptr, ptr)`);
B.line(
`call void @scr_throw_node_coded_url(double ${kind.name}, ptr ${code.name}, ptr ${msg.name}, ptr ${url.name})`,
);
} else {
host.declare(`declare void @scr_throw_node_coded(double, ptr, ptr)`);
B.line(
`call void @scr_throw_node_coded(double ${kind.name}, ptr ${code.name}, ptr ${msg.name})`,
);
}
const ty = host.llType(e.type);
if (ty === "void") {
host.emitAlwaysPendingUnwind();
@@ -0,0 +1,51 @@
import * as ts from "../ts7/adapter.js";
/** A `Pick<C, K>` / `Omit<C, K>` view: the lib alias it instantiates and
* its source type argument `C`. */
export interface ClassView {
name: "Pick" | "Omit";
source: ts.Type;
}
/** The lib's Pick or Omit alias (declared only in declaration files). */
function libViewName(symbol: ts.Symbol): "Pick" | "Omit" | null {
const name = ts.symbolName(symbol);
if (name !== "Pick" && name !== "Omit") return null;
return symbol.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile)
? name
: null;
}
/** `type` as a Pick/Omit view, spelled inline (`Pick<C, K>`) or through
* non-generic user aliases (`type View = Pick<C, K>`, `type V2 = View`).
* An aliased instantiation carries the USER's alias symbol and no alias
* type arguments, so the alias declaration's type node names the view.
* Generic user aliases (`type V<T> = Pick<T, K>`) are not resolved. */
export function classViewOf(checker: ts.TypeChecker, type: ts.Type, depth = 0): ClassView | null {
const alias = type.getAliasSymbol();
if (alias === undefined) return null;
const direct = libViewName(alias);
if (direct !== null) {
const source = type.getAliasTypeArguments()[0];
return source === undefined ? null : { name: direct, source };
}
if (depth >= 8 || type.getAliasTypeArguments().length > 0) return null;
const decl = alias.declarations.find(ts.isTypeAliasDeclaration);
if (decl === undefined || (decl.typeParameters?.nodes.length ?? 0) > 0) return null;
let node: ts.TypeNode = decl.type;
while (ts.isParenthesizedTypeNode(node)) node = node.type;
if (!ts.isTypeReferenceNode(node)) return null;
let target = checker.getSymbolAtLocation(node.typeName);
if (target !== undefined && (target.flags & ts.SymbolFlags.Alias) !== 0)
target = checker.getAliasedSymbol(target);
if (target === undefined) return null;
const viewName = libViewName(target);
if (viewName !== null) {
const argument = node.typeArguments?.nodes[0];
return argument === undefined
? null
: { name: viewName, source: checker.getTypeFromTypeNode(argument) };
}
if ((target.flags & ts.SymbolFlags.TypeAlias) === 0) return null;
return classViewOf(checker, checker.getDeclaredTypeOfSymbol(target), depth + 1);
}
@@ -482,7 +482,7 @@ export function funcCoerceAdapter(
strandRet = false;
// A record result the slot's result is a structural width of.
if (strandRet && lowerer.widthLiftPlan(fromT.ret, toT.ret)) strandRet = false;
const key = `fnadapt:${typeKey(fromT)}:${typeKey(toT)}`;
const key = `fnadapt:${typeKey(fromT)}:${typeKey(toT)}${lowerer.coercions.checkedRetags > 0 ? ":checked" : ""}`;
const existing = lowerer.coercions.retags.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%fn.adapt", key);
@@ -9,6 +9,13 @@ export class CoercionState {
// plan checks its other fields. Remove every assumption on unwind;
// generated helpers must intern their names before building their bodies.
readonly planning = new Set<string>();
/** > 0 while a record width plan plans or builds its fields: method
* fields may take checked function adapters (WidthLift funcAdaptChecked). */
methodFieldDepth = 0;
/** > 0 while a checked function adapter plans or builds: contravariant
* parameters may width-lift, and union fields may retag with trapping
* arms (WidthLift checkedRetag). */
checkedRetags = 0;
// Registry revisions invalidate only fully validated copy-only routes.
readonly copyRetags = new Map<string, { name: string; shapes: number; unions: number }>();
// Provenance for checked extraction; consumers must not infer it from names.
@@ -1,4 +1,4 @@
import { planRecordUnionLift, planRecordUnionWrap } from "../union-retag.js";
import { planRecordUnionLift, planRecordUnionWrap, planUnionRetag } from "../union-retag.js";
import type { WidthLift } from "../width-lift.js";
import type { IrRecordShape, IrType } from "../../../ir/ir.js";
import { canDynCheckTo, isUnitType, typeEquals, UNDEFINED_T } from "../../../ir/ir.js";
@@ -57,6 +57,33 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
lowerer.armTag(dst.unionId, UNDEFINED_T) >= 0
)
return { how: "unitRetag" };
// Inside a checked method adapter (contravariant positions): source
// arms with no destination home trap instead of refusing the pair.
if (lowerer.coercions.checkedRetags > 0) {
const from = lowerer.unions.get(src.unionId);
const to = lowerer.unions.get(dst.unionId);
if (from && to) {
const trappable: number[] = [];
from.arms.forEach((arm, tag) => {
if (isUnitType(arm) || lowerer.armTag(dst.unionId, arm) >= 0) return;
if (to.arms.some((d) => !isUnitType(d) && lowerer.widthLiftPlan(arm, d) !== null))
return;
trappable.push(tag);
});
if (
trappable.length > 0 &&
trappable.length < from.arms.length &&
planUnionRetag(
from,
to,
(id) => lowerer.shapes.get(id),
(s, d) => lowerer.widthLiftPlan(s, d),
new Set(trappable),
) !== null
)
return { how: "checkedRetag", trappable };
}
}
return null;
}
// A unit-typed source can't wrap here (unionWrap requires the
@@ -71,7 +98,7 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
return { how: "discriminantWrap" };
return { how: "wrap", tag };
}
const candidates: { tag: number; arm: IrType }[] = [];
const candidates: { tag: number; arm: IrType; checked: boolean }[] = [];
def.arms.forEach((arm, i) => {
if (isUnitType(arm)) return;
const sameFamily =
@@ -87,9 +114,15 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
arm.kind === "array") ||
(src.kind === "object" && arm.kind === "record") ||
(src.kind === "record" && arm.kind === "object");
if (sameFamily && lowerer.widthLiftPlan(src, arm) !== null) candidates.push({ tag: i, arm });
const armLift = sameFamily ? lowerer.widthLiftPlan(src, arm) : null;
if (armLift !== null)
candidates.push({ tag: i, arm, checked: armLift.how === "funcAdaptChecked" });
});
let selected = candidates;
// A clean function candidate always wins over a checked one, so every
// selection that resolves today resolves the same way.
if (selected.some((c) => c.checked) && selected.some((c) => !c.checked))
selected = selected.filter((c) => !c.checked);
if (selected.length > 1 && src.kind === "record") {
const source = lowerer.shapes.get(src.shapeId);
// An inferred JS union may widen its tag while retaining variants
@@ -216,6 +249,22 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
if (dst.kind === "func" && src.kind === "func" && lowerer.cleanFuncAdaptable(src, dst)) {
return { how: "funcAdapt" };
}
// A method FIELD of a record width copy whose parameters convert only
// through a checked narrowing (method bivariance across instantiations):
// a checked adapter. Top-level function conversions never see it.
if (
dst.kind === "func" &&
src.kind === "func" &&
lowerer.coercions.methodFieldDepth > 0 &&
lowerer.coercions.checkedRetags === 0
) {
lowerer.coercions.checkedRetags++;
try {
if (lowerer.cleanFuncAdaptable(src, dst)) return { how: "funcAdaptChecked" };
} finally {
lowerer.coercions.checkedRetags--;
}
}
if (dst.kind === "record" && src.kind === "record") {
// An INDEX-SIGNATURE destination takes the overflow CAPTURE reshape:
// declared fields copy off the source, every other source field keys
@@ -370,6 +419,7 @@ export function recordWidthPlan(
// fields answers "assume coercible" — see CoercionState.planning.
if (lowerer.coercions.planning.has(key)) return new Map();
lowerer.coercions.planning.add(key);
lowerer.coercions.methodFieldDepth++;
try {
const plan = new Map<string, FieldLift>();
for (const tf of to.fields) {
@@ -405,6 +455,7 @@ export function recordWidthPlan(
}
return plan;
} finally {
lowerer.coercions.methodFieldDepth--;
lowerer.coercions.planning.delete(key);
}
}
@@ -131,6 +131,34 @@ export function applyWidthLift(
throw new InternalCompilerError("lowerer bug: planned retag lift failed to intern");
return { kind: "call", callee: retag, args: [value], type: dst, loc };
}
case "checkedRetag": {
if (dst.kind !== "union" || value.type.kind !== "union")
throw new InternalCompilerError("lowerer bug: checked retag lift shape");
const retag = lowerer.unionRetagHelper(
value.type.unionId,
dst.unionId,
loc,
new Set(lift.trappable),
);
if (!retag)
throw new InternalCompilerError("lowerer bug: planned checked retag lift failed to intern");
return { kind: "call", callee: retag, args: [value], type: dst, loc };
}
case "funcAdaptChecked": {
if (dst.kind !== "func" || value.type.kind !== "func")
throw new InternalCompilerError("lowerer bug: checked funcAdapt lift shape");
lowerer.coercions.checkedRetags++;
try {
const adapter = lowerer.funcCoerceAdapter(value.type, dst, loc);
if (!adapter)
throw new InternalCompilerError(
"lowerer bug: planned checked funcAdapt lift failed to intern",
);
return { kind: "call", callee: adapter, args: [value], type: dst, loc };
} finally {
lowerer.coercions.checkedRetags--;
}
}
case "unitRetag": {
if (dst.kind !== "union" || value.type.kind !== "union")
throw new InternalCompilerError("lowerer bug: unit retag lift shape");
@@ -527,11 +555,16 @@ export function recordWidthHelper(
lowerer.coercionOriginNode = null;
lowerer.coercionFieldOrigin = null;
try {
lowerer.liftedFns.push(
buildRecordProjection(lowerer, name, fromT, to, plan, loc, (field) =>
declared === null ? null : fieldOrigin(lowerer.checker, declared, field),
),
);
lowerer.coercions.methodFieldDepth++;
try {
lowerer.liftedFns.push(
buildRecordProjection(lowerer, name, fromT, to, plan, loc, (field) =>
declared === null ? null : fieldOrigin(lowerer.checker, declared, field),
),
);
} finally {
lowerer.coercions.methodFieldDepth--;
}
} finally {
lowerer.coercionOriginNode = outerNode;
lowerer.coercionFieldOrigin = outerField;
@@ -22,12 +22,14 @@ import {
type SrcLoc,
UNDEFINED_T,
arrayOf,
canDynCheckTo,
isRefCounted,
isSupportedIndexValue,
isUnitType,
typeEquals,
} from "../../../ir/ir.js";
import { tryLowerExpression } from "../expressions/try-lower-expression.js";
import { recordMayKeepSource, recordSourceTest } from "../record-sources.js";
import { locOf } from "../../program.js";
import { typeKey } from "../../type-mapper.js";
import { type WidthLift } from "../lowerer.js";
@@ -1221,8 +1223,47 @@ export function lowerRecordOvfCaptureHelper(
),
);
}
// A CLOSED source can still hold own keys its shape does not declare: a
// record built from the checked-dynamic tree keeps them beside it (the
// record-sources.ts view), and every JS copy — spread, Object.assign,
// JSON — carries them. The field-by-field rebuild above cannot see
// them, so when the source kept such a view, rebuild the target from
// that live view instead: the validated keyed conversion the checked
// record builder performs, which copies exactly the keys Node copies.
// The declared-field copies before this branch are pure writes into a
// fresh local, so the redone work is only wasted slot stores on the
// rare path where the view exists.
// The conversion is only honest when the checked-dynamic tree can check it,
// and the view can only exist if the program converts something into a
// closed record at all. Both facts are settled before the shape helpers
// finalize their bodies, so the tail is chosen there.
const sourcedFromView =
fIv === null &&
recordMayKeepSource(lowerer, fromT) &&
canDynCheckTo(
toT,
(id) => lowerer.shapes.get(id),
(id) => lowerer.unions.get(id),
);
body.push({ kind: "return", value: outRef, loc });
const suffix = body.slice(1 + fieldStmts.size);
const keptSourceTail = (): IrStmt[] => {
const fromView: IrExpr = {
kind: "dynCheck",
value: { kind: "dynFrom", value: sRef, type: DYN, loc },
type: toT,
loc,
};
const fromViewReturn: IrStmt = { kind: "return", value: fromView, loc };
const fromViewBranch: IrStmt = {
kind: "if",
cond: recordSourceTest(sRef, loc),
then: [fromViewReturn],
else_: null,
loc,
};
return [fromViewBranch, ...suffix];
};
const fn: IrFunction = {
name,
params: [{ localId: "s.0", name: "s", type: fromT }],
@@ -1244,13 +1285,15 @@ export function lowerRecordOvfCaptureHelper(
lowerer.shapeOrderHelperFinalizers.push(() => {
const current = lowerer.shapes.get(fromId) ?? from;
const currentOrder = current.declaredOrder ?? current.fields.map((f) => f.name);
let tail: IrStmt[] = suffix;
if (sourcedFromView && lowerer.recordSourcesPossible) tail = keptSourceTail();
fn.body = [
body[0]!,
...currentOrder.flatMap((field) => {
const stmt = fieldStmts.get(field);
return stmt ? [stmt] : [];
}),
...suffix,
...tail,
];
});
lowerer.liftedFns.push(fn);
@@ -84,7 +84,13 @@ import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js";
import { withInheritedMethods } from "../record-inherited.js";
import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js";
import { isClassCallback } from "../class-callbacks.js";
import { isKeyedFieldAccess } from "./keyed-field-targets.js";
import {
isKeyedFieldAccess,
keyedFieldRead,
keyedFieldWrite,
sameTypedFieldKeys,
type KeyedFieldTarget,
} from "./keyed-field-targets.js";
import { lowerFieldCompoundValue } from "./updates.js";
import { readsHeldEvolvingArray } from "../lower-stmts.js";
import {
@@ -1548,6 +1554,36 @@ export function lowerRecordKeyRead(
recordKeyResultOk(lowerer, shape, DYN)
)
declared = DYN;
// A key whose literal type names only declared fields of one type, on a
// record whose other fields have other types, reads the field it names.
// A called access (`r[key]()`) keeps the refusal.
const named =
!declared && litKey === null && !isCalledAccess(expr)
? sameTypedFieldKeys(lowerer, shape, keyNode)
: null;
if (named) {
const stmts: IrStmt[] = [];
const settle = (value: IrExpr, name: string, force = false): IrExpr => {
if (!force && isSafeToRepeat(value)) return value;
const temp = lowerer.declareHiddenLocal(name, value.type);
stmts.push({ kind: "varDecl", localId: temp.id, init: value, loc });
return varRef(temp.id, temp.type, loc);
};
const receiver = settle(obj, "%keyedReceiver", !isSafeToDiscard(key));
const target: KeyedFieldTarget = {
container: "recordKeyed",
obj: receiver,
shapeId,
field: named.keys[0]!,
keys: named.keys,
key: settle(key, "%keyedKey"),
fieldType: named.fieldType,
};
const get = keyedFieldRead(lowerer, target, loc, true);
const read: IrExpr =
stmts.length === 0 ? get : { kind: "seqExpr", stmts, result: get, type: get.type, loc };
return includeUndefined ? read : lowerer.maybeNarrow(read, expr);
}
if (!declared) {
lowerer.unsupported(
"SC1090",
@@ -2571,6 +2607,48 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression):
};
}
}
// A key whose literal type names only declared fields of one type
// writes the field it names. JavaScript evaluates the receiver, then
// the key, then the value; the dispatch reads the saved receiver and
// key once per named field. Locals are saved too, because the key or
// the value can reassign them.
const named =
litKey === null ? sameTypedFieldKeys(lowerer, shape, target.argumentExpression) : null;
if (named && !shape.fields.every((f) => typeEquals(f.type, named.fieldType))) {
const loc = locOf(expr);
const body: IrStmt[] = [];
const save = (value: IrExpr, name: string): IrExpr => {
const temp = lowerer.declareHiddenLocal(name, value.type);
body.push({ kind: "varDecl", localId: temp.id, init: value, loc });
return varRef(temp.id, temp.type, loc);
};
const obj = save(lowerer.lowerExpr(target.expression), "%keyedReceiver");
const rawKey = lowerRecordPropertyKey(
lowerer,
lowerer.lowerExpr(target.argumentExpression),
target.argumentExpression,
);
if (rawKey.type.kind !== "string") {
lowerer.unsupported(
"SC1090",
target.argumentExpression,
"indexing records with non-string or non-number keys",
);
}
const key = save(rawKey, "%keyedKey");
const value = lowerer.lowerExprExpecting(expr.right, named.fieldType);
const keyed: KeyedFieldTarget = {
container: "recordKeyed",
obj,
shapeId: receiverIr.shapeId,
field: named.keys[0]!,
keys: named.keys,
key,
fieldType: named.fieldType,
};
body.push(keyedFieldWrite(lowerer, keyed, value, loc));
return { kind: "block", body, loc };
}
if (!shape.indexValue) {
// A SIGNATURE-FREE shape whose declared fields share ONE type
// writes through the same per-shape dispatch (the mockable-clock
@@ -5,9 +5,12 @@ import {
DYN,
F64,
STRING,
shapeHasAccessorSlots,
typeEquals,
type IrExpr,
type IrRecordShape,
type IrStmt,
type IrType,
type SrcLoc,
} from "../../../ir/ir.js";
import { isJsSourceFile } from "../../program.js";
@@ -141,6 +144,32 @@ export function keyedFieldTarget(
};
}
/** The declared fields a computed key names when the key's literal type
* allows only names of declared fields stored with one type
* (`stats[counter]` with `counter: "hits" | "misses"` on a record whose
* other fields have other types). Null for any other key or shape:
* accessor-carrying shapes, index-signature shapes and JavaScript sources
* keep their own keyed access. */
export function sameTypedFieldKeys(
lowerer: Lowerer,
shape: IrRecordShape,
keyNode: ts.Expression,
): { keys: string[]; fieldType: IrType } | null {
if (
shape.tuple ||
shape.indexValue !== undefined ||
shapeHasAccessorSlots(shape) ||
isJsSourceFile(ts.getSourceFileOfNode(keyNode))
)
return null;
const keys = literalFieldKeys(lowerer, keyNode);
if (!keys) return null;
const fields = keys.map((k) => shape.fields.find((f) => f.name === k));
const first = fields[0];
if (!first || fields.some((f) => !f || !typeEquals(f.type, first.type))) return null;
return { keys, fieldType: first.type };
}
/** Does `key` name `field`? */
function keyIs(key: IrExpr, field: string, loc: SrcLoc): IrExpr {
return {
@@ -561,8 +561,7 @@ function fenceAccessorSpreadSource(
/** Only definitely present later records replace earlier defaults. A
* callback binding may store undefined despite its checker annotation. */
function laterSpreadType(lowerer: Lowerer, expr: ts.Expression): IrType | null {
let source = expr;
while (ts.isParenthesizedExpression(source)) source = source.expression;
const source = spreadSourceNode(lowerer, expr);
if (ts.isIdentifier(source)) return lowerer.lowerExpr(source).type;
return lowerer.mapTypeOf(lowerer.typeOf(source));
}
@@ -1331,7 +1330,21 @@ export function lowerObjectLiteral(
}
lowerer.badType(expr, tsType);
}
let type: IrType = mapped;
// An enclosing literal that built at its OWN type (its context had no
// layout) asks for the matching own layout of this nested literal. When
// this literal's contextual layout cannot reach that slot, build at it:
// the enclosing literal's coercion then decides, and the width lifts
// (the index-signature overflow capture included) carry the whole tree
// into the real destination. Otherwise the two layers disagree and the
// SC2002 names the source literal's own type as the expected one.
const layout: IrType & { kind: "record" } =
expected &&
!typeEquals(mapped, expected) &&
lowerer.widthLiftPlan(mapped, expected) === null &&
typeEquals(lowerer.mapTypeOf(lowerer.typeOf(expr)) ?? mapped, expected)
? expected
: mapped;
let type: IrType = layout;
// A fresh, plain literal can construct its fields directly for a known
// destination with the same field set. This matters for inferred
// conditional records: an empty array in one arm has no element layout
@@ -1907,16 +1920,19 @@ export function lowerObjectLiteral(
// earlier ones (JS last-write-wins). Evaluate the source exactly
// once, including empty sources and those whose fields are all
// overridden. Field snapshots above preserve the copy order.
let srcNode: ts.Expression = prop.expression;
while (ts.isParenthesizedExpression(srcNode)) srcNode = srcNode.expression;
const srcNode = spreadSourceNode(lowerer, prop.expression);
const condRecord = conditionalRecordSpreadOf(lowerer, prop.expression);
const srcLowered = capture(
let srcLowered = capture(
condRecord !== null
? lowerConditionalRecordSpread(lowerer, condRecord, locOf(prop))
: prop === expr.properties.nodes[0] && leadingSpreadLowered !== null
? leadingSpreadLowered
: lowerer.lowerExpr(srcNode),
);
{
const narrowed = checkedSpreadSource(lowerer, srcNode, srcLowered);
if (narrowed !== null) srcLowered = capture(narrowed);
}
// Array callbacks can receive a record-or-undefined ABI even when
// TypeScript describes a required record. Select the copy strategy
// from that stored representation so absent sources copy nothing.
@@ -1938,7 +1954,7 @@ export function lowerObjectLiteral(
const recArms = def?.arms.filter((a) => a.kind === "record") ?? [];
if (
!def ||
recArms.length !== 1 ||
recArms.length === 0 ||
!def.arms.every((a) => a.kind === "record" || isUnitType(a))
) {
lowerer.unsupported(
@@ -1947,8 +1963,6 @@ export function lowerObjectLiteral(
`object spread of '${lowerer.fmt(srcType)}' sources (only known record shapes spread — ${NARROW_FIRST})`,
);
}
const recArm = recArms[0]! as IrType & { kind: "record" };
const recTag = def.arms.indexOf(recArm);
// A checked-dynamic VALUE under the union-mapped checker type
// (a JS dyn-holding binding): the present/absent desugar tests
// union tags a dyn box does not carry — fence honestly instead
@@ -1961,242 +1975,218 @@ export function lowerObjectLiteral(
`object spread of a checked-dynamic '${lowerer.fmt(srcType)}' source (${NARROW_FIRST})`,
);
}
const srcShape = lowerer.shapes.get(recArm.shapeId);
if (!srcShape)
throw new InternalCompilerError(`lowerer bug: spread of unknown shape ${recArm.shapeId}`);
fenceAccessorSpreadSource(lowerer, prop, srcShape);
if (srcShape.indexValue || shape.indexValue) {
const fallback = dynSpreadLiteral(lowerer, expr, type);
if (fallback) return fallback;
lowerer.unsupported(
"SC1090",
prop,
"object spread involving index-signature shapes (overflow keys are runtime state — copy the fields you need explicitly)",
);
}
const laterNames = new Set<string>();
for (const later of expr.properties.nodes.slice(expr.properties.nodes.indexOf(prop) + 1)) {
if (ts.isSpreadAssignment(later)) {
if (conditionalSpreadOf(later.expression) || andSpreadOf(lowerer, later.expression))
continue;
const lt = laterSpreadType(lowerer, later.expression);
if (lt?.kind === "record") {
// A field the later source may lack defines nothing.
for (const lf of lowerer.shapes.get(lt.shapeId)?.fields ?? [])
if (!canLackField(lf.type)) laterNames.add(lf.name);
}
continue;
}
if (
later.name &&
(ts.isIdentifier(later.name) ||
ts.isStringLiteral(later.name) ||
ts.isNumericLiteral(later.name) ||
(ts.isComputedPropertyName(later.name) &&
literalComputedKey(lowerer, later.name) !== null))
) {
laterNames.add(propNameText(lowerer, later.name));
}
}
const srcRef = (): IrExpr => srcLowered ?? lowerer.lowerExpr(srcNode);
const srcNarrowed = (): IrExpr => ({
kind: "unionNarrow",
unionId: srcType.unionId,
tag: recTag,
value: srcRef(),
type: recArm,
loc: locOf(prop),
});
// The source PROVEN present: the checked extraction `x!` lowers to
// (narrowedArmHelper + checkedNarrowCall). A required target field
// with no earlier contributor has no absent representation, so
// `{ ...maybeRecord }` into it must find the record: the unit arm
// throws the catchable TypeError instead of leaving the slot unset
// (divergence 38's lying-assertion stance — the checker typed the
// spread's fields optional, so only a cast or an unchecked indexed
// read reaches this shape).
const srcPresent = (): IrExpr => {
const helper = lowerer.narrowedArmHelper(srcType.unionId, recArm, locOf(prop));
return helper
? lowerer.checkedNarrowCall(helper, srcRef(), recArm, locOf(prop))
: srcNarrowed();
};
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);
// No slot on the target shape: the copy DROPS the field
// (divergence 36's stance, same as the plain-record spread).
if (!targetType) continue;
if (conditionalNames.has(f.name)) {
const conditionalDyn = conditionalFallback();
if (conditionalDyn) return conditionalDyn;
// Several record arms: each arm contributes its own fields behind its
// tag test, wrapping the earlier contributor, so a key that arm lacks
// keeps the earlier value (JS copies only the arm's own keys).
for (const recArmCandidate of recArms) {
const recArm = recArmCandidate as IrType & { kind: "record" };
const recTag = def.arms.indexOf(recArm);
const srcShape = lowerer.shapes.get(recArm.shapeId);
if (!srcShape)
throw new InternalCompilerError(
`lowerer bug: spread of unknown shape ${recArm.shapeId}`,
);
fenceAccessorSpreadSource(lowerer, prop, srcShape);
if (srcShape.indexValue || shape.indexValue) {
const fallback = dynSpreadLiteral(lowerer, expr, type);
if (fallback) return fallback;
lowerer.unsupported(
"SC1090",
prop,
`spread of '${f.name}' over an earlier conditional spread (the desugar keeps one entry per name — restructure so each name has one contributor)`,
"object spread involving index-signature shapes (overflow keys are runtime state — copy the fields you need explicitly)",
);
}
const fRead = (present = false): IrExpr => ({
kind: "recordGet",
obj: present ? srcPresent() : srcNarrowed(),
shapeId: recArm.shapeId,
field: f.name,
type: f.type,
loc: locOf(prop),
});
let cond: IrExpr = {
kind: "unionIsTag",
const laterNames = new Set<string>();
for (const later of expr.properties.nodes.slice(
expr.properties.nodes.indexOf(prop) + 1,
)) {
if (ts.isSpreadAssignment(later)) {
if (conditionalSpreadOf(later.expression) || andSpreadOf(lowerer, later.expression))
continue;
const lt = laterSpreadType(lowerer, later.expression);
if (lt?.kind === "record") {
// A field the later source may lack defines nothing.
for (const lf of lowerer.shapes.get(lt.shapeId)?.fields ?? [])
if (!canLackField(lf.type)) laterNames.add(lf.name);
}
continue;
}
if (
later.name &&
(ts.isIdentifier(later.name) ||
ts.isStringLiteral(later.name) ||
ts.isNumericLiteral(later.name) ||
(ts.isComputedPropertyName(later.name) &&
literalComputedKey(lowerer, later.name) !== null))
) {
laterNames.add(propNameText(lowerer, later.name));
}
}
const srcRef = (): IrExpr => srcLowered ?? lowerer.lowerExpr(srcNode);
const srcNarrowed = (): IrExpr => ({
kind: "unionNarrow",
unionId: srcType.unionId,
tag: recTag,
negated: false,
value: srcRef(),
type: BOOL,
loc: locOf(prop),
};
let thenVal: IrExpr;
// A source field that can be absent copies only when present.
const fieldPresent = (): IrExpr => ({
kind: "logical",
op: "&&",
left: cond,
right: {
kind: "recordHas",
obj: {
kind: "unionNarrow",
unionId: srcType.unionId,
tag: recTag,
value: srcRef(),
type: recArm,
loc: locOf(prop),
},
shapeId: recArm.shapeId,
field: f.name,
type: BOOL,
loc: locOf(prop),
},
type: BOOL,
type: recArm,
loc: locOf(prop),
});
const canBeAbsent =
f.type.kind === "union" && lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0;
if (typeEquals(f.type, targetType)) {
thenVal = fRead();
if (canBeAbsent) cond = fieldPresent();
} else if (
f.type.kind === "union" &&
lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0 &&
typeEquals(lowerer.stripUndefinedArm(f.type), targetType)
) {
// Optional source field into a required target slot: present
// means the source holds the record AND the field its value
// arm — the spread-override completion, union-source form.
const ftUndef = lowerer.armTag(f.type.unionId, UNDEFINED_T);
cond = {
// The source PROVEN present: the checked extraction `x!` lowers to
// (narrowedArmHelper + checkedNarrowCall). A required target field
// with no earlier contributor has no absent representation, so
// `{ ...maybeRecord }` into it must find the record: the unit arm
// throws the catchable TypeError instead of leaving the slot unset
// (divergence 38's lying-assertion stance — the checker typed the
// spread's fields optional, so only a cast or an unchecked indexed
// read reaches this shape).
const srcPresent = (): IrExpr => {
const helper = lowerer.narrowedArmHelper(srcType.unionId, recArm, locOf(prop));
return helper
? lowerer.checkedNarrowCall(helper, srcRef(), recArm, locOf(prop))
: srcNarrowed();
};
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);
// No slot on the target shape: the copy DROPS the field
// (divergence 36's stance, same as the plain-record spread).
if (!targetType) continue;
if (conditionalNames.has(f.name)) {
const conditionalDyn = conditionalFallback();
if (conditionalDyn) return conditionalDyn;
lowerer.unsupported(
"SC1090",
prop,
`spread of '${f.name}' over an earlier conditional spread (the desugar keeps one entry per name — restructure so each name has one contributor)`,
);
}
const fRead = (present = false): IrExpr => ({
kind: "recordGet",
obj: present ? srcPresent() : srcNarrowed(),
shapeId: recArm.shapeId,
field: f.name,
type: f.type,
loc: locOf(prop),
});
let cond: IrExpr = {
kind: "unionIsTag",
unionId: srcType.unionId,
tag: recTag,
negated: false,
value: srcRef(),
type: BOOL,
loc: locOf(prop),
};
let thenVal: IrExpr;
// A source field that can be absent copies only when present.
const fieldPresent = (): IrExpr => ({
kind: "logical",
op: "&&",
left: cond,
right: {
kind: "unionIsTag",
unionId: f.type.unionId,
tag: ftUndef,
negated: true,
value: fRead(),
kind: "recordHas",
obj: {
kind: "unionNarrow",
unionId: srcType.unionId,
tag: recTag,
value: srcRef(),
type: recArm,
loc: locOf(prop),
},
shapeId: recArm.shapeId,
field: f.name,
type: BOOL,
loc: locOf(prop),
},
type: BOOL,
loc: locOf(prop),
};
const ftDef = lowerer.unions.get(f.type.unionId);
if (targetType.kind === "union") {
const retag = lowerer.unionRetagHelper(
f.type.unionId,
targetType.unionId,
locOf(prop),
);
if (!retag) {
});
const canBeAbsent =
f.type.kind === "union" && lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0;
if (typeEquals(f.type, targetType)) {
thenVal = fRead();
if (canBeAbsent) cond = fieldPresent();
} else if (
f.type.kind === "union" &&
lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0 &&
typeEquals(lowerer.stripUndefinedArm(f.type), targetType)
) {
// Optional source field into a required target slot: present
// means the source holds the record AND the field its value
// arm — the spread-override completion, union-source form.
const ftUndef = lowerer.armTag(f.type.unionId, UNDEFINED_T);
cond = {
kind: "logical",
op: "&&",
left: cond,
right: {
kind: "unionIsTag",
unionId: f.type.unionId,
tag: ftUndef,
negated: true,
value: fRead(),
type: BOOL,
loc: locOf(prop),
},
type: BOOL,
loc: locOf(prop),
};
const ftDef = lowerer.unions.get(f.type.unionId);
if (targetType.kind === "union") {
const retag = lowerer.unionRetagHelper(
f.type.unionId,
targetType.unionId,
locOf(prop),
);
if (!retag) {
lowerer.pushDiag(
recordShapeMismatchDiag(
lowerer.fmt(type),
lowerer.fmt(recArm),
locOf(prop),
`spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot re-tag into '${lowerer.fmt(targetType)}' behind the present-test`,
),
);
throw new PoisonError();
}
thenVal = {
kind: "call",
callee: retag,
args: [fRead()],
type: targetType,
loc: locOf(prop),
};
} else if (
ftDef &&
ftDef.arms.length === 2 &&
lowerer.armTag(f.type.unionId, targetType) >= 0
) {
thenVal = {
kind: "unionNarrow",
unionId: f.type.unionId,
tag: lowerer.armTag(f.type.unionId, targetType),
value: fRead(),
type: targetType,
loc: locOf(prop),
};
} else {
lowerer.pushDiag(
recordShapeMismatchDiag(
lowerer.fmt(type),
lowerer.fmt(recArm),
locOf(prop),
`spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot re-tag into '${lowerer.fmt(targetType)}' behind the present-test`,
`spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot narrow into '${lowerer.fmt(targetType)}' behind the present-test`,
),
);
throw new PoisonError();
}
thenVal = {
kind: "call",
callee: retag,
args: [fRead()],
type: targetType,
loc: locOf(prop),
};
} else if (
ftDef &&
ftDef.arms.length === 2 &&
lowerer.armTag(f.type.unionId, targetType) >= 0
) {
thenVal = {
kind: "unionNarrow",
unionId: f.type.unionId,
tag: lowerer.armTag(f.type.unionId, targetType),
value: fRead(),
type: targetType,
loc: locOf(prop),
};
} else {
lowerer.pushDiag(
recordShapeMismatchDiag(
lowerer.fmt(type),
lowerer.fmt(recArm),
locOf(prop),
`spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot narrow into '${lowerer.fmt(targetType)}' behind the present-test`,
),
);
throw new PoisonError();
}
} else {
// The width-lift fallback (arm wrap, re-tag, nested
// reshape) — the same per-field rule the slot coercion
// applies. Runs AFTER the optional-completion branch: a
// present-test has its own semantics a re-tag's stranded
// undefined trap must not shadow.
const lift = lowerer.widthLiftPlan(f.type, targetType);
if (!lift) {
lowerer.pushDiag(
recordShapeMismatchDiag(
lowerer.fmt(type),
lowerer.fmt(recArm),
locOf(prop),
`spread field '${f.name}': '${lowerer.fmt(f.type)}' does not lift into '${lowerer.fmt(targetType)}'`,
),
);
throw new PoisonError();
}
thenVal = lowerer.applyWidthLift(lift, fRead(), targetType, locOf(prop));
if (canBeAbsent) cond = fieldPresent();
}
const at = fields.findIndex((x) => x.name === f.name);
const elseVal =
at >= 0 ? fields[at]!.value : lowerer.absentFieldValue(targetType, locOf(prop));
if (!elseVal) {
if (canBeAbsent) {
// The FIELD itself can be missing from a present source:
// no extraction makes it exist.
lowerer.unsupported(
"SC1090",
prop,
`object spread of '${lowerer.fmt(srcType)}' sources where '${f.name}' has no earlier contributor (the absent arm leaves the required field unset — spread defaults first: { ...defaults, ...overrides })`,
);
}
// No earlier contributor and the target slot cannot be absent:
// the source must be here. Copy through the checked extraction
// (the absent unit arm throws) — the same read the width lift
// below wraps, so a lift still applies.
let strict = fRead(true);
if (!typeEquals(f.type, targetType)) {
// The width-lift fallback (arm wrap, re-tag, nested
// reshape) — the same per-field rule the slot coercion
// applies. Runs AFTER the optional-completion branch: a
// present-test has its own semantics a re-tag's stranded
// undefined trap must not shadow.
const lift = lowerer.widthLiftPlan(f.type, targetType);
if (!lift) {
lowerer.pushDiag(
@@ -2209,21 +2199,56 @@ export function lowerObjectLiteral(
);
throw new PoisonError();
}
strict = lowerer.applyWidthLift(lift, strict, targetType, locOf(prop));
thenVal = lowerer.applyWidthLift(lift, fRead(), targetType, locOf(prop));
if (canBeAbsent) cond = fieldPresent();
}
fields.push({ name: f.name, value: strict });
continue;
const at = fields.findIndex((x) => x.name === f.name);
const elseVal =
at >= 0 ? fields[at]!.value : lowerer.absentFieldValue(targetType, locOf(prop));
if (!elseVal) {
if (canBeAbsent) {
// The FIELD itself can be missing from a present source:
// no extraction makes it exist.
lowerer.unsupported(
"SC1090",
prop,
`object spread of '${lowerer.fmt(srcType)}' sources where '${f.name}' has no earlier contributor (the absent arm leaves the required field unset — spread defaults first: { ...defaults, ...overrides })`,
);
}
// No earlier contributor and the target slot cannot be absent:
// the source must be here. Copy through the checked extraction
// (the absent unit arm throws) — the same read the width lift
// below wraps, so a lift still applies.
let strict = fRead(true);
if (!typeEquals(f.type, targetType)) {
const lift = lowerer.widthLiftPlan(f.type, targetType);
if (!lift) {
lowerer.pushDiag(
recordShapeMismatchDiag(
lowerer.fmt(type),
lowerer.fmt(recArm),
locOf(prop),
`spread field '${f.name}': '${lowerer.fmt(f.type)}' does not lift into '${lowerer.fmt(targetType)}'`,
),
);
throw new PoisonError();
}
strict = lowerer.applyWidthLift(lift, strict, targetType, locOf(prop));
}
fields.push({ name: f.name, value: strict });
continue;
}
const merged: IrExpr = {
kind: "ternary",
cond,
then: thenVal,
else_: elseVal,
type: targetType,
loc: locOf(prop),
};
if (at >= 0) fields[at] = { name: f.name, value: merged };
else fields.push({ name: f.name, value: merged });
}
const merged: IrExpr = {
kind: "ternary",
cond,
then: thenVal,
else_: elseVal,
type: targetType,
loc: locOf(prop),
};
if (at >= 0) fields[at] = { name: f.name, value: merged };
else fields.push({ name: f.name, value: merged });
}
continue;
}
@@ -2649,6 +2674,32 @@ export function lowerObjectLiteral(
: { kind: "seqExpr", stmts: spreadStmts, result, type, loc };
}
/** A spread source whose stored union holds several record arms while the
* checker narrowed this site to fewer (`if (b?.type === "tool") ({ ...b })`
* on an element read, or a type predicate's arm): extract the narrowed
* value through the CHECKED narrowing, so every arm the checker excludes,
* including a hidden absence arm, throws the catchable TypeError instead of
* being copied. Null when the stored union has at most one record arm (the
* present-test desugar owns that case), when the site type is not a
* narrowing of the stored union, or in JavaScript files. */
function checkedSpreadSource(lowerer: Lowerer, node: ts.Expression, stored: IrExpr): IrExpr | null {
if (stored.type.kind !== "union") return null;
if (isJsSourceFile(ts.getSourceFileOfNode(node))) return null;
const def = lowerer.unions.get(stored.type.unionId);
if (!def || def.arms.filter((arm) => arm.kind === "record").length < 2) return null;
const site = lowerer.mapTypeOf(lowerer.typeOf(node));
if (!site || typeEquals(site, stored.type)) return null;
const loc = stored.loc;
if (site.kind === "record") {
if (lowerer.armTag(stored.type.unionId, site) < 0) return null;
const helper = lowerer.narrowedArmHelper(stored.type.unionId, site, loc);
return helper ? lowerer.checkedNarrowCall(helper, stored, site, loc) : null;
}
if (site.kind !== "union") return null;
const helper = lowerer.narrowedRetagHelper(node, stored.type.unionId, site.unionId, loc);
return helper ? { kind: "call", callee: helper, args: [stored], type: site, loc } : null;
}
/** The field type is an array, or a union with exactly one array arm. */
function singleArrayArm(lowerer: Lowerer, type: IrType): boolean {
if (type.kind === "array") return true;
@@ -2782,6 +2833,20 @@ function lowerUnionRecordClone(lowerer: Lowerer, expr: ts.ObjectLiteralExpressio
};
}
}
// Any other source (a capture, a runtime-optional binding, a member
// read) converts through the same checked re-tag: the arms the site's
// checker type excludes, a hidden absence arm included, throw the
// catchable TypeError instead of being cloned.
if (!typeEquals(source.type, sourceType) && source.type.kind === "union") {
const helper = lowerer.narrowedRetagHelper(
spread.expression,
source.type.unionId,
sourceType.unionId,
source.loc,
);
if (helper)
source = { kind: "call", callee: helper, args: [source], type: sourceType, loc: source.loc };
}
if (!typeEquals(source.type, sourceType)) {
lowerer.unsupported(
"SC1090",
@@ -2984,14 +3049,51 @@ function lowerConditionalRecordSpread(
};
}
/** A spread's source expression with parentheses stripped, seeing through
* an empty-object default: `...(x ?? {})` and `...(x || {})` spread exactly
* what `...x` spreads (a nullish or other falsy value is a primitive with
* no own keys, and x evaluates once either way). Only where `x` is a plain
* record or a record-or-unit union, which the field copy and the
* optional-spread desugar handle; index-signature and other sources keep
* their paths. */
function spreadSourceNode(lowerer: Lowerer, expr: ts.Expression): ts.Expression {
let e: ts.Expression = expr;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (
!ts.isBinaryExpression(e) ||
(e.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken &&
e.operatorToken.kind !== ts.SyntaxKind.BarBarToken)
)
return e;
let right: ts.Expression = e.right;
while (ts.isParenthesizedExpression(right)) right = right.expression;
if (!ts.isObjectLiteralExpression(right) || right.properties.nodes.length !== 0) return e;
let left: ts.Expression = e.left;
while (ts.isParenthesizedExpression(left)) left = left.expression;
const plainRecord = (t: IrType | undefined): boolean =>
t?.kind === "record" && lowerer.shapes.get(t.shapeId)?.indexValue === undefined;
const source = lowerer.mapTypeOf(lowerer.typeOf(left));
if (plainRecord(source ?? undefined)) return left;
if (source?.kind === "union") {
const arms = lowerer.unions.get(source.unionId)?.arms ?? [];
const records = arms.filter((arm) => arm.kind === "record");
if (
records.length === 1 &&
plainRecord(records[0]) &&
arms.every((arm) => arm.kind === "record" || isUnitType(arm))
)
return left;
}
return e;
}
/** A spread source the record desugar cannot enumerate: an untyped
* (`unknown`) value, or one whose shape carries an index signature
* (`Record<string, unknown>`, `{ [key: string]: T }`) so its keys are
* runtime state. Node copies own enumerable keys in insertion order, which
* the checked-dynamic object reproduces. */
function dynSpreadSource(lowerer: Lowerer, node: ts.Expression): boolean {
let e: ts.Expression = node;
while (ts.isParenthesizedExpression(e)) e = e.expression;
const e = spreadSourceNode(lowerer, node);
if ((lowerer.typeOf(e).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return true;
const indexed = (t: IrType | null): boolean => {
if (t?.kind === "record") return lowerer.shapes.get(t.shapeId)?.indexValue !== undefined;
@@ -5,6 +5,7 @@ import {
lowerArrayClassStaticCall,
} from "./builtins/array-classes.js";
import { structuralCallInstance } from "./structural-instances.js";
import { classViewOf } from "./class-views.js";
import { checkedPrivateMethodReceiver, privateNameClass } from "./private-brands.js";
import { lowerStaticPrivateCall } from "./private-statics.js";
import { assertFunctionAliasOf } from "./lower-assert.js";
@@ -11520,7 +11521,8 @@ function maybePromiseHandlerResult(
return stmt;
}
/** `view.m(args)` where `view` is a `Pick<C, K>` / `Omit<C, K>` view of a
/** `view.m(args)` where `view` is a `Pick<C, K>` / `Omit<C, K>` view (inline
* or through type aliases, classViewOf) of a
* program class C and `m` is one of C's GENERIC methods (its overloads
* included): generic methods have no dispatchable method value, so the
* view's live receiver is extracted as the C instance it holds (a checked
@@ -11533,11 +11535,9 @@ function pickViewGenericMethodCall(
access: ts.PropertyAccessExpression,
recvTs: ts.Type,
): IrExpr | null {
const alias = recvTs.getAliasSymbol();
if (alias === undefined || (ts.symbolName(alias) !== "Pick" && ts.symbolName(alias) !== "Omit"))
return null;
if (!alias.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile)) return null;
const source = recvTs.getAliasTypeArguments()[0];
const view = classViewOf(lowerer.checker, recvTs);
if (view === null) return null;
const source = view.source;
const symbol = source?.getSymbol();
const info = symbol ? lowerer.classBySymbol.get(symbol) : undefined;
if (!info || call.questionDotToken || access.questionDotToken) return null;
@@ -11557,7 +11557,7 @@ function pickViewGenericMethodCall(
lowerer.unsupported(
"SC1090",
access,
`calling the generic method '${access.name.text}' through a '${ts.symbolName(alias)}' view of '${info.def.name}' that holds a '${lowerer.fmt(lowered.type)}'`,
`calling the generic method '${access.name.text}' through a '${view.name}' view of '${info.def.name}' that holds a '${lowerer.fmt(lowered.type)}'`,
);
}
const receiver: IrExpr =
@@ -24,7 +24,12 @@ import {
sharedModuleRegistrations,
} from "./lower-shared-modules.js";
import { dirname as dirnamePath, resolve as resolvePath } from "node:path";
import { packageNameOfPath, probeNodeImportRefusal } from "../npm.js";
import {
packageNameOfPath,
probeNodeFileImportRefusal,
probeNodeImportRefusal,
type NodeImportRefusal,
} from "../npm.js";
import { isNpmStaticPackage } from "../npm-static.js";
import { isJsSourceFileName } from "../tsc-codes.js";
import { isRelativeSpecifier } from "../workspace-registry.js";
@@ -753,23 +758,38 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void {
* runtime-computed name). */
/** The refusal a literal import() rejects with, or null when it loads:
* Node's ERR_MODULE_NOT_FOUND for a package the project marks never loaded
* (package-shims.ts), or the first refusal in the static graph of a module
* only import() loads (LoadResult.dynamicImportRejections). */
* (package-shims.ts), the first refusal in the static graph of a module
* only import() loads (LoadResult.dynamicImportRejections), or Node's
* resolution refusal for a specifier nothing resolves (a package nothing
* installed provides, a relative path to a file that does not exist). */
function dynamicImportRejectionOf(
lowerer: Lowerer,
sf: ts.SourceFile,
spec: string,
call: ts.CallExpression,
): StartupCrash | null {
if (isUnloadedPackage(spec)) {
const refusal = probeNodeImportRefusal(sf.fileName, spec);
if (refusal === null) return null;
return { message: refusal.message, className: "%Error", code: refusal.code, loc: locOf(call) };
}
if (lowerer.dynamicImportRejections.size === 0) return null;
const rejection = (refusal: NodeImportRefusal | null): StartupCrash | null =>
refusal === null
? null
: {
message: refusal.message,
className: "%Error",
code: refusal.code,
...(refusal.url === undefined ? {} : { url: refusal.url }),
loc: locOf(call),
};
if (isUnloadedPackage(spec)) return rejection(probeNodeImportRefusal(sf.fileName, spec));
const root =
resolveImport(lowerer.program, sf, spec) ?? npmStaticDepSf7(lowerer.program, sf, spec);
return root === null ? null : (lowerer.dynamicImportRejections.get(root) ?? null);
if (root !== null) return lowerer.dynamicImportRejections.get(root) ?? null;
// Builtins, and URL specifiers (file:, data:), are not package names.
if (canonicalBuiltinModule(spec) !== null || /^[a-z][a-z0-9+.-]*:/i.test(spec)) return null;
return rejection(
probeNodeFileImportRefusal(sf.fileName, spec) ??
(spec.startsWith(".") || spec.startsWith("/")
? null
: probeNodeImportRefusal(sf.fileName, spec)),
);
}
function collectDynamicImports(lowerer: Lowerer, sf: ts.SourceFile): void {
@@ -2290,7 +2290,7 @@ function lowerRejectedImport(
{ kind: "exprStmt", expr: hop, loc },
{
kind: "exprStmt",
expr: nodeThrowExpr(errorKind, crash.code ?? "", crash.message, VOID, loc),
expr: nodeThrowExpr(errorKind, crash.code ?? "", crash.message, VOID, loc, crash.url),
loc,
},
{ kind: "return", value: dynUndefinedExpr(loc), loc },
@@ -40,6 +40,7 @@ import { lowerCollectionSpread } from "./containers/collection-methods.js";
import { lowerEventObjectPropertyWrite } from "./lower-event-target.js";
import { lowerDynObjectLiteral } from "./expressions/object-literals.js";
import { importMetaBindingSource } from "./import-meta.js";
import { nullishEmptyPatternSource } from "./nullish-empty-pattern.js";
import { anyResidueReplacedBy } from "./any-collection-residue.js";
import { iteratorCanStep, iteratorValue } from "./iterator-consumption.js";
import { notIterableSpelling } from "./iterable-spelling.js";
@@ -2507,6 +2508,7 @@ export function lowerDestructuringDecl(
}
let init =
importMetaBindingSource(lowerer, decl.name, decl.initializer) ??
nullishEmptyPatternSource(lowerer, decl.name, decl.initializer) ??
lowerer.lowerExpr(decl.initializer);
const parseArgsDynObject =
init.type.kind === "dyn" &&
@@ -97,6 +97,7 @@ import {
import { identityPreservingWidening } from "./coercions/identity.js";
import { scanDivergences } from "./divergences.js";
import { applyKeyOrderWitnesses, KeyOrderNotes } from "./key-order-witness.js";
import { functionKeepsRecordSources } from "./record-sources.js";
import { exprFunctionReference } from "../../ir/function-references.js";
import { everyStmtList, observeRecordLiteralRebuilds } from "../../ir/traverse.js";
import { checkedClassAssertion } from "./class-assertions.js";
@@ -3401,6 +3402,11 @@ export class Lowerer {
* Reachability lowers inits before the declarations they discover, so
* these rebuild after the worklist restores historical shape metadata. */
readonly shapeOrderHelperFinalizers: (() => void)[] = [];
/** Whether any lowered function converts a checked-dynamic value into a
* closed record, the only conversion that can leave a record holding
* undeclared source keys (record-sources.ts). Settled once lowering ends,
* before the shape helpers finalize their bodies. */
recordSourcesPossible = false;
/** Construction witnesses that choose each shape's enumeration order
* once lowering settles (key-order-witness.ts). */
readonly keyOrder = new KeyOrderNotes();
@@ -4560,6 +4566,9 @@ export class Lowerer {
// statement.
"sharedModules",
"sharedInitFlags",
// Settled from every lowered function once lowering ends, before the
// shape helpers finalize (record-sources.ts).
"recordSourcesPossible",
"slotOriginMemo",
"recordOriginMemo",
"boundaryCastOrder",
@@ -10720,6 +10729,22 @@ export class Lowerer {
...this.implicitFns,
]);
observeRecordLiteralRebuilds(null);
// A program whose functions never convert a checked-dynamic value into a
// closed record cannot keep a source view anywhere, so the captures that
// consult one keep their static copy (record-sources.ts).
const allFunctions = [
...orderedUnits,
...initFunctions,
...instanceFunctions,
...this.liftedFns,
...this.implicitFns,
];
const sourceScan = { memo: new Map<string, boolean>(), visiting: new Set<string>() };
for (const fn of allFunctions) {
if (!functionKeepsRecordSources(this, fn, sourceScan)) continue;
this.recordSourcesPossible = true;
break;
}
for (const finalize of this.shapeOrderHelperFinalizers) finalize();
this.settleSharedHelperLocations([
...orderedUnits,
@@ -0,0 +1,136 @@
import { BOOL, type IrExpr, type IrType, isUnitType, typeKey } from "../../ir/ir.js";
import { isJsSourceFile, locOf } from "../program.js";
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
function stripParens(node: ts.Expression): ts.Expression {
while (ts.isParenthesizedExpression(node)) node = node.expression;
return node;
}
/** `const { a, ...rest } = options ?? {}` (also `|| {}`) where the options
* record has a member that cannot be absent. The pattern destructures
* either the record or an empty object, which has no own keys, so the
* source lowers to a union of the record and the empty record: the union
* pattern lowering reads each key from the arm that holds it and answers
* undefined from the empty arm, and a rest element packs the record's
* remaining fields or nothing. The options operand is evaluated once.
*
* Null (the caller keeps its current lowering) for JavaScript files, other
* initializer forms, an operand the checker does not type as one record
* plus nullish arms, and a record whose every field may be absent: the
* existing lowering already builds the empty object as that record with
* every key absent. */
export function nullishEmptyPatternSource(
lowerer: Lowerer,
pattern: ts.BindingName,
initializer: ts.Expression,
): IrExpr | null {
if (!ts.isObjectBindingPattern(pattern)) return null;
if (isJsSourceFile(ts.getSourceFileOfNode(initializer))) return null;
const init = stripParens(initializer);
if (
!ts.isBinaryExpression(init) ||
(init.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken &&
init.operatorToken.kind !== ts.SyntaxKind.BarBarToken)
)
return null;
const right = stripParens(init.right);
if (!ts.isObjectLiteralExpression(right) || right.properties.nodes.length !== 0) return null;
const loc = locOf(init);
// Decide from the checker's view of the operand before lowering it once.
const declared = lowerer.mapTypeOf(lowerer.typeOf(init.left));
if (declared?.kind !== "union") return null;
const declaredArms = lowerer.unions.get(declared.unionId)?.arms ?? [];
const declaredRecords = declaredArms.filter((arm) => arm.kind === "record");
if (
declaredRecords.length !== 1 ||
!declaredArms.every((arm) => arm.kind === "record" || isUnitType(arm))
)
return null;
const declaredShape = lowerer.shapes.get(
(declaredRecords[0] as IrType & { kind: "record" }).shapeId,
);
if (
!declaredShape ||
declaredShape.tuple ||
declaredShape.indexValue ||
declaredShape.fields.length === 0 ||
declaredShape.fields.every(
(field) => lowerer.absentFieldValue(field.type, loc)?.kind === "fieldAbsent",
)
)
return null;
const left = lowerer.lowerExpr(init.left);
// The stored operand can differ from the checker's view. A stored record
// is never nullish (the fallback is dead); any other stored form must be
// the same one record plus nullish arms.
if (left.type.kind === "record") return left;
if (left.type.kind !== "union") {
lowerer.unsupported(
"SC1031",
init,
"destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)",
);
}
const sourceId = left.type.unionId;
const arms = lowerer.unions.get(sourceId)?.arms ?? [];
const records = arms.filter((arm) => arm.kind === "record");
if (records.length !== 1 || !arms.every((arm) => arm.kind === "record" || isUnitType(arm))) {
lowerer.unsupported(
"SC1031",
init,
"destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)",
);
}
const record = records[0] as IrType & { kind: "record" };
const empty: IrType = { kind: "record", shapeId: lowerer.shapes.intern([]) };
const resultArms = [record, empty].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
const resultType: IrType = { kind: "union", unionId: lowerer.unions.intern(resultArms) };
const sourceTag = arms.indexOf(record);
const tmp = lowerer.declareHiddenLocal("%nullishEmpty", left.type);
const ref: IrExpr = { kind: "varRef", localId: tmp.id, type: left.type, loc };
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: tmp.id, init: left, loc }],
result: {
kind: "ternary",
cond: {
kind: "unionIsTag",
unionId: sourceId,
tag: sourceTag,
negated: false,
value: ref,
type: BOOL,
loc,
},
then: {
kind: "unionWrap",
unionId: resultType.unionId,
tag: resultArms.indexOf(record),
value: {
kind: "unionNarrow",
unionId: sourceId,
tag: sourceTag,
value: ref,
type: record,
loc,
},
type: resultType,
loc,
},
else_: {
kind: "unionWrap",
unionId: resultType.unionId,
tag: resultArms.indexOf(empty),
value: { kind: "recordLit", fields: [], type: empty, loc },
type: resultType,
loc,
},
type: resultType,
loc,
},
type: resultType,
loc,
};
}
@@ -8,9 +8,12 @@
* serialization, untyped views, inspect, Object.keys/values/entries —
* answer through the record's live checked view instead of its static
* field list. This module names the record types that can carry such a
* source and the runtime test the surfaces branch on. */
import type { IrExpr, IrType, SrcLoc } from "../../ir/ir.js";
* source, the runtime test the surfaces branch on, and whether the program
* converts anything into one at all (a program without such a conversion
* answers the test false everywhere, so its captures keep static copies). */
import type { IrExpr, IrFunction, IrType, SrcLoc } from "../../ir/ir.js";
import { BOOL, DYN, recordTextCodecClass, shapeHasAccessorSlots } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import { varRef } from "../../ir/build.js";
import type { Lowerer } from "./lowerer.js";
import { isPerfEntryType } from "./builtins/performance.js";
@@ -37,6 +40,84 @@ export function recordSourceTest(value: IrExpr, loc: SrcLoc): IrExpr {
return { kind: "libCall", fn: "record.sourceHas", args: [value], type: BOOL, loc };
}
/** A union or record revisited while it is being visited answers as if its
* cycle member were empty (`keeps: false`), so the caller's answer is
* incomplete until the outer one settles. `cycled` marks that incompleteness: such an answer is
* never memoized, because another entry point into the same cycle could reach
* the missing arm. */
interface KeepsSourceAnswer {
keeps: boolean;
cycled: boolean;
}
/** Shared work list of the whole-program scan: settled union and record
* answers, and the unions and records on the current path, by id. */
export interface KeepsSourceScan {
memo: Map<string, boolean>;
visiting: Set<string>;
}
/** Whether a checked conversion into `target` can leave a record holding
* undeclared source keys. The conversion reads one record at a time, so the
* records nested in a union arm, a promise, an array, a record field or an
* index signature's values count too (`Record<string, { x: string }>` keeps
* the sources of its closed values). Unions and records are memoized by id;
* one revisited on the current path answers `cycled`. */
function conversionKeepsSource(
lowerer: Lowerer,
target: IrType,
scan: KeepsSourceScan,
): KeepsSourceAnswer {
if (target.kind === "promise") return conversionKeepsSource(lowerer, target.inner, scan);
if (target.kind === "array") return conversionKeepsSource(lowerer, target.elem, scan);
if (target.kind === "record" && recordMayKeepSource(lowerer, target))
return { keeps: true, cycled: false };
let id: string;
let members: IrType[];
if (target.kind === "union") {
id = target.unionId;
members = lowerer.unions.get(target.unionId)?.arms ?? [];
} else if (target.kind === "record") {
id = target.shapeId;
const shape = lowerer.shapes.get(target.shapeId);
members = [
...(shape?.fields.map((field) => field.type) ?? []),
...(shape?.indexValue !== undefined ? [shape.indexValue] : []),
];
} else return { keeps: false, cycled: false };
const known = scan.memo.get(id);
if (known !== undefined) return { keeps: known, cycled: false };
if (scan.visiting.has(id)) return { keeps: false, cycled: true };
scan.visiting.add(id);
let keeps = false;
let cycled = false;
for (const member of members) {
const answer = conversionKeepsSource(lowerer, member, scan);
keeps = keeps || answer.keeps;
cycled = cycled || answer.cycled;
if (keeps) break;
}
scan.visiting.delete(id);
if (!cycled || keeps) scan.memo.set(id, keeps);
return { keeps, cycled: cycled && !keeps };
}
/** Whether `fn` holds a validated conversion that can note a record source:
* a `dynCheck` whose target accepts `recordMayKeepSource`. Only such a
* conversion creates the kept view `recordSourceTest` answers, so a program
* without one never needs a capture to consult the view. */
export function functionKeepsRecordSources(
lowerer: Lowerer,
fn: IrFunction,
scan: KeepsSourceScan,
): boolean {
return !everyStmtList(fn.body, {
expr: (expr) =>
expr.kind !== "dynCheck" || !conversionKeepsSource(lowerer, expr.type, scan).keeps,
stmt: () => true,
});
}
/** A reflective answer over a record value that branches on its kept
* source: `staticAnswer` walks the declared fields, `viewAnswer` answers
* from the live checked view (typed `type`, the static answer's type). The
@@ -42,6 +42,13 @@ export type WidthLift =
| { how: "dynOut" }
| { how: "upcast" }
| { how: "funcAdapt" }
/** A method field whose parameter records differ per instantiation
* (`Provider<"x">` into `Provider<Api>`, admitted by method bivariance):
* funcCoerceAdapter built in checked mode. Record-field planning only. */
| { how: "funcAdaptChecked" }
/** A union into a union where the `trappable` source arms have no home:
* they throw the catchable TypeError. Checked mode only. */
| { how: "checkedRetag"; trappable: number[] }
/** A promise whose payload widens (`Promise<void>` into `Promise<unknown>`):
* the checked payload widening coerceToExpected applies at a promise slot,
* which keeps the promise's identity and settlement turn. */
@@ -0,0 +1,83 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { expect, test } from "vitest";
import { probeNodeFileImportRefusal } from "./npm.js";
// probeNodeFileImportRefusal against Node itself: for each specifier, Node's
// import() from the same importer either rejects with ERR_MODULE_NOT_FOUND,
// whose code, message and url the probe must reproduce, or does something
// else (loads, or refuses with another error), where the probe answers null.
// Node runs in an empty directory: it resolves a relative specifier's
// "Did you mean" hint against its working directory, so there it adds none.
test("relative and absolute import() refusals match Node's", () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-file-import-")));
writeFileSync(join(dir, "has space.mjs"), "export {};\n");
writeFileSync(join(dir, "plain.mjs"), "export {};\n");
writeFileSync(join(dir, "lit%20pct.mjs"), "export {};\n");
writeFileSync(join(dir, "helper.js"), "export {};\n");
mkdirSync(join(dir, "sub dir"));
mkdirSync(join(dir, "pkgdir"));
writeFileSync(join(dir, "pkgdir/package.json"), JSON.stringify({ main: "entry" }));
writeFileSync(join(dir, "pkgdir/entry.js"), "export {};\n");
mkdirSync(join(dir, "indexed"));
writeFileSync(join(dir, "indexed/index.json"), "{}\n");
mkdirSync(join(dir, "empty"));
const importer = join(dir, "main.mjs");
writeFileSync(
importer,
`const results = [];
for (const specifier of JSON.parse(process.argv[2])) {
try {
await import(specifier);
results.push(null);
} catch (error) {
results.push(error.code === "ERR_MODULE_NOT_FOUND" ? { code: error.code, message: error.message, url: error.url } : null);
}
}
process.stdout.write(JSON.stringify(results));
`,
);
const specifiers = [
"./no-such-file.mjs",
"./no%20such.mjs",
"./has%20space.mjs",
"./has space.mjs",
"./lit%20pct.mjs",
"./lit%2520pct.mjs",
"./plain.mjs?x=1",
"./plain.mjs#frag",
"./missing.mjs?x=1",
"./missing.mjs#frag",
"./sub%2Fx.mjs",
"./sub%5Cx.mjs",
"./bad%zz.mjs",
"./sub dir/",
"./no-such-dir/",
"./helper",
"./pkgdir",
"./pkgdir/",
"./indexed",
`../${basename(dir)}/missing.mjs`,
`../${basename(dir)}/helper`,
`${dir}/nope.mjs#h`,
`${dir}/has%20space.mjs`,
`${dir}/helper`,
`${dir}/helper?q`,
`${dir}/pkgdir/entry`,
`${dir}/lit%20pct.mjs`,
`${dir}/no-such`,
`${dir}/indexed/`,
];
const node = JSON.parse(
execFileSync(process.execPath, [importer, JSON.stringify(specifiers)], {
cwd: join(dir, "empty"),
encoding: "utf8",
}),
) as unknown[];
const probed = specifiers.map((specifier) => probeNodeFileImportRefusal(importer, specifier));
expect(Object.fromEntries(specifiers.map((s, i) => [s, probed[i]]))).toEqual(
Object.fromEntries(specifiers.map((s, i) => [s, node[i]])),
);
});
+84
View File
@@ -1,5 +1,6 @@
/** Node package resolution shared by preflight and native module introspection. */
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { packageNameOfSpecifier as packageNameOf } from "./workspace-registry.js";
import {
trackedDirectoryExists,
@@ -142,6 +143,89 @@ export function probeNodeImportRefusal(
}
}
/** Node's import() refusal for a relative or absolute specifier whose
* target does not exist: ERR_MODULE_NOT_FOUND naming the resolved path, with
* the resolved URL. ESM resolution reads the specifier as a URL relative to
* the importer's file URL and adds no extension: percent escapes are
* decoded in the path, and a query or fragment stays in the URL only. An
* existing file or directory answers null, as do other specifier forms and
* the specifiers Node refuses with another error (a path ending in "/", an
* encoded "/" or "\", a malformed escape). */
export function probeNodeFileImportRefusal(
fromFile: string,
specifier: string,
host: NpmGraphHost = realNpmGraphHost,
): NodeImportRefusal | null {
if (!isRelativeImportSpecifier(specifier) && !specifier.startsWith("/")) return null;
const importer = resolve(fromFile);
let url: URL;
let target: string;
try {
url = new URL(specifier, pathToFileURL(importer));
if (url.pathname.endsWith("/") || /%2f|%5c/i.test(url.pathname)) return null;
target = fileURLToPath(url);
} catch {
return null;
}
if (host.isFile(target) || host.isDirectory(target)) return null;
const hint = specifier.startsWith("/") ? commonJsResolveFile(specifier, host) : null;
return {
code: "ERR_MODULE_NOT_FOUND",
message:
`Cannot find module '${target}' imported from ${importer}` +
(hint === null || hint === specifier
? ""
: `\nDid you mean to import ${JSON.stringify(hint)}?`),
url: url.href,
};
}
function isRelativeImportSpecifier(specifier: string): boolean {
return (
specifier.startsWith("./") ||
specifier.startsWith("../") ||
specifier === "." ||
specifier === ".."
);
}
/* Node's ERR_MODULE_NOT_FOUND hint names the file CommonJS resolution of
* the literal specifier finds: the exact file, the .js/.json/.node
* extensions, or a directory's package.json main or index. Node resolves a
* relative specifier for the hint against the process's working directory,
* which the compiled program only knows when it runs, so only an absolute
* specifier gets the hint here; the found path is the hint itself. */
const COMMONJS_EXTENSIONS = [".js", ".json", ".node"];
function commonJsResolveFile(request: string, host: NpmGraphHost): string | null {
const file = (path: string): string | null => (host.isFile(path) ? host.realpath(path) : null);
const withExtensions = (path: string): string | null => {
for (const ext of COMMONJS_EXTENSIONS) {
const found = file(path + ext);
if (found !== null) return found;
}
return null;
};
const found = file(request) ?? withExtensions(request);
if (found !== null) return found;
if (!host.isDirectory(request)) return null;
const manifest = host.readFile(join(request, "package.json"));
if (manifest !== null) {
let main: unknown;
try {
main = (JSON.parse(manifest) as PkgJson).main;
} catch {
main = undefined;
}
if (typeof main === "string" && main !== "") {
const entry = resolve(request, main);
const found = file(entry) ?? withExtensions(entry) ?? withExtensions(join(entry, "index"));
if (found !== null) return found;
}
}
return withExtensions(join(request, "index"));
}
export function probeNodeRequireRefusal(
fromFile: string,
specifier: string,
@@ -557,6 +557,9 @@ export interface StartupCrash {
loc: { file: string; start: number; end: number };
/** Node's error code, for the refusals that carry one. */
code?: string;
/** The error's own `url` property: the missing file's URL, for Node's
* ERR_MODULE_NOT_FOUND of an import() whose target does not exist. */
url?: string;
}
/** True when a coverage mapping names one exact bare host module. TypeScript
+4 -1
View File
@@ -59,13 +59,15 @@ export function dynUndefinedExpr(loc: SrcLoc): IrExpr {
/** An always-throwing Node error with the replaced expression's type.
* Kinds: 0 Error, 1 TypeError, 2 RangeError, 5 ReferenceError, 6 EvalError. An empty
* code omits the error code property. */
* code omits the error code property; a `url` becomes the error's own `url`
* property (Node's ERR_MODULE_NOT_FOUND for a missing file). */
export function nodeThrowExpr(
kind: 0 | 1 | 2 | 5 | 6,
code: string,
message: string,
type: IrType,
loc: SrcLoc,
url?: string,
): IrExpr {
return {
kind: "libCall",
@@ -74,6 +76,7 @@ export function nodeThrowExpr(
{ kind: "numLit", value: kind, type: F64, loc },
{ kind: "strLit", value: code, type: STRING, loc },
{ kind: "strLit", value: message, type: STRING, loc },
...(url === undefined ? [] : [{ kind: "strLit", value: url, type: STRING, loc } as const]),
],
type,
loc,
@@ -3842,9 +3842,10 @@ function buildLibFnSignatures() {
* arms (ERR_INVALID_THIS receivers, ERR_MISSING_ARGS arity ladders,
* the symbol-to-string TypeError): args are [error-kind f64 (the
* SCR_ERR_* index: 0 Error, 1 TypeError, 2 RangeError), code (empty =
* no code slot), message]. ALWAYS THROWS catchably; the result type is
* the replaced expression's own (never materialized — the
* global.undefRead pattern). May-throw seed. */
* no code slot), message], and optionally a fourth string, the error's
* own `url` property (ir/validate.ts accepts it). ALWAYS THROWS
* catchably; the result type is the replaced expression's own (never
* materialized — the global.undefRead pattern). May-throw seed. */
"error.nodeThrow": { argTypes: [F64, STRING, STRING], result: VOID },
/** JS ToString over a dyn value WITH the object protocol (a user
* toString/valueOf member is CALLED and its throw propagates;
+51
View File
@@ -2183,3 +2183,54 @@ test("a caught-typed global records a module error only from a catch binding", (
'in main: assign to catch binding "%error" (frontend must reject)',
]);
});
test("a record arm without the literal key answers undefined in unionKeyGet", () => {
const uX: IrType = { kind: "union", unionId: "uX" };
const uS: IrType = { kind: "union", unionId: "uS" };
const rA: IrType = { kind: "record", shapeId: "rA" };
const module = (answer: IrType): IrModule => ({
irVersion: 17,
sourceFile: loc.file,
entry: "main",
records: [
{ id: "rA", fields: [{ name: "x", type: uX }] },
{ id: "rE", fields: [] },
],
unions: [
{ id: "uX", arms: [STRING, UNDEFINED_T] },
{ id: "uS", arms: [rA, { kind: "record", shapeId: "rE" }] },
],
functions: [
{
name: "main",
params: [{ localId: "p", name: "p", type: uS }],
locals: [{ id: "p", name: "p", type: uS, mutable: false }],
returnType: answer,
body: [
{
kind: "return",
value: {
kind: "unionKeyGet",
unionId: "uS",
key: { kind: "strLit", value: "x", type: STRING, loc },
value: { kind: "varRef", localId: "p", type: uS, loc },
type: answer,
loc,
},
loc,
},
],
loc,
},
],
});
// The empty arm lacks "x": it has no such own property, so it answers
// the result's undefined arm.
expect(validateModule(module(uX))).toEqual([]);
// Without an undefined arm in the result there is no answer to give.
expect(
validateModule(module(STRING)).some((d) =>
d.message.includes("unionKeyGet: arm 1 of uS has no answer for the key"),
),
).toBe(true);
});
+13
View File
@@ -3871,6 +3871,14 @@ function validateFunction(
}
return;
}
// A declared-only arm that lacks a literal key has no such own
// property: it answers undefined, like a unit arm.
if (
literal !== null &&
!shape.indexValue &&
resultUnion?.arms.some((a) => a.kind === "undefinedT")
)
return;
if (!shape.indexValue && !(literal === null && shape.fields.length > 0)) {
err(`unionKeyGet: arm ${i} of ${e.unionId} has no answer for the key`, e.loc);
}
@@ -4189,16 +4197,21 @@ function validateFunction(
e.fn === "readable.unpipe" ||
e.fn === "rl.create" ||
e.fn === "rl.createSignal";
// error.nodeThrow takes an optional fourth argument: the error's own
// `url` property (a string).
const optionalUrl = e.fn === "error.nodeThrow" && e.args.length === 4;
// fs.unwatchFile's listener is optional, so its last slot may be
// omitted (1 or 2 args) rather than extended.
const optionalTail = e.fn === "fs.unwatchFile" ? 1 : 0;
const arityOk = variadic
? e.args.length >= sig.argTypes.length
: e.args.length === sig.argTypes.length ||
optionalUrl ||
(optionalTail > 0 && e.args.length === sig.argTypes.length - optionalTail);
if (!arityOk) {
err(`libCall ${e.fn}: ${e.args.length} args, expected ${sig.argTypes.length}`, e.loc);
}
if (optionalUrl) expectType(e.args[3]!, STRING, `libCall ${e.fn} arg 3`);
e.args.forEach((a, i) => {
checkExpr(a);
const want = sig.argTypes[i];
@@ -0,0 +1,87 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile } from "../src/index.js";
const sanitize = process.env["SCRIPTC_SAN"] === "1";
// A Provider<"beta"> held as Provider<Api> receives an alpha model through
// method bivariance: Model<"beta">'s compat holds only undefined, so the
// alpha model's compat object cannot convert. Native throws the checked
// conversion's TypeError (types don't lie); Node prints `beta cancel a1 lie`
// and `passed through` instead, because its method call never checks the
// argument's shape. That divergence is the documented checked-conversion
// policy, so this case is a native package test rather than a differential
// corpus program.
const program = `// A Provider<"beta"> held as Provider<Api> receives an alpha model through
// method bivariance: Model<"beta">'s compat holds only undefined, so the
// alpha model's compat object cannot convert. Native throws the checked
// conversion's TypeError (types don't lie); Node passes the lie through.
type Api = "alpha" | "beta";
interface AlphaCompat { strict?: boolean }
interface Model<TApi extends Api> {
id: string;
api: TApi;
compat?: TApi extends "alpha" ? AlphaCompat : never;
}
interface Provider<TApi extends Api = Api> {
readonly id: string;
cancel?(model: Model<TApi>, handle: string): Promise<void>;
}
function betaProvider(): Provider<"beta"> {
return {
id: "b",
cancel: async (model, handle) => {
console.log("beta cancel", model.id, handle);
},
};
}
async function main(): Promise<void> {
const providers: Provider[] = [betaProvider()];
const beta = providers[0]!;
await beta.cancel?.({ id: "b1", api: "beta" }, "ok");
try {
await beta.cancel?.({ id: "a1", api: "alpha", compat: { strict: true } }, "lie");
console.log("passed through");
} catch (error) {
console.log(error instanceof TypeError, (error as Error).message);
}
}
main();
`;
test.each(["llvm"] as const)(
"checked method-field adapters throw on an instantiation mismatch (%s)",
async (backend) => {
const dir = mkdtempSync(join(tmpdir(), "scriptc-method-field-adapters-"));
try {
const entry = join(dir, "main.ts");
writeFileSync(entry, program);
const result = await compile(entry, {
backend,
sanitize,
outDir: dir,
outPath: join(dir, "program"),
});
expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true);
if (!result.ok) return;
const child = spawnSync(result.binaryPath, { encoding: "utf8" });
const stderr =
sanitize && process.platform === "linux"
? child.stderr.replace(
/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext functions and may produce false positives in some cases!\n/gm,
"",
)
: child.stderr;
expect(stderr).toBe("");
expect(child.status).toBe(0);
expect(child.stdout).toBe(
"beta cancel b1 ok\ntrue a '{ strict: boolean | undefined }' value is not representable in the target union (a value narrowed or asserted past it still held it)\n",
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/async-return-contextual-records.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/dynamic-import-not-found.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/method-field-instantiation-adapters.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/nested-literal-own-layout.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/pick-alias-generic-methods.ts"
],
"diags": []
}
@@ -0,0 +1,8 @@
{
"order": [
"<repo>/tests/corpus/pick-alias-view-modules/registry.ts",
"<repo>/tests/corpus/pick-alias-view-modules/globals.ts",
"<repo>/tests/corpus/pick-alias-view-modules/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/record-literal-key-unions.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/record-nullish-empty-destructure.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/record-source-keys-index-capture.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/record-source-keys-index-values.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/record-union-spreads.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/spread-empty-default.ts"
],
"diags": []
}
+4
View File
@@ -1759,6 +1759,10 @@ void scr_errdyn_put(ScrError *e, ScrDyn *d); /* retains both sides */
/* Sets an own property on an error: its checked-dynamic node, which casts
* and util.inspect read. Moves value. */
void scr_error_set_prop(ScrError *e, const char *key, size_t key_len, ScrDyn *value);
/* scr_throw_node_coded with an own `url` property (error.nodeThrow's
* fourth argument). Borrows the strings; always throws. */
SCR_COLD void scr_throw_node_coded_url(double kind, const ScrStr *code, const ScrStr *msg,
const ScrStr *url);
ScrDyn *scr_errdyn_view_of(const ScrError *e); /* borrowed or NULL */
/* ToBoolean over the dyn kind (JS-exact); borrowed, never throws. */
bool scr_dyn_truthy(const ScrDyn *d);
+14
View File
@@ -10798,6 +10798,20 @@ void scr_error_set_prop(ScrError *e, const char *key, size_t key_len, ScrDyn *va
scr_dyn_release(view);
}
/* scr_throw_node_coded with Node's `url` own property: the
* ERR_MODULE_NOT_FOUND of an import() whose relative target does not
* exist carries the target's file URL. Borrows the strings; always throws
* (catchably — call sites are compiler-emitted pending checks). */
void scr_throw_node_coded_url(double kind, const ScrStr *code, const ScrStr *msg, const ScrStr *url) {
ScrError *e = scr_error_new((int)kind, (ScrStr *)msg); /* borrowed in */
if (code->len > 0) {
scr_str_release(e->code); /* NULL-safe */
e->code = scr_str_retain((ScrStr *)code);
}
scr_error_set_prop(e, "url", 3, scr_dyn_new_str((ScrStr *)url));
scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg());
}
/* The error's checked-dynamic node when it has one (borrowed), else NULL:
* util.inspect reads its extra own properties. */
ScrDyn *scr_errdyn_view_of(const ScrError *e) {
@@ -0,0 +1,108 @@
// Object literals returned from async arrows typed by a method signature
// (`resolve(input): Promise<AuthResult | undefined>`), with destructured
// parameters. The return position's contextual type is
// `AuthResult | undefined | PromiseLike<AuthResult | undefined>`; the
// literal must build as AuthResult so its nested header record reaches the
// pure index record ProviderHeaders (null-valued keys included), exactly as
// in a sync function.
type ProviderHeaders = Record<string, string | null>;
interface ModelAuth {
apiKey?: string;
headers?: ProviderHeaders;
}
interface AuthResult {
auth: ModelAuth;
env?: Record<string, string>;
source?: string;
}
interface Ctx {
env(name: string): Promise<string | undefined>;
}
interface Credential {
type: "api_key";
key?: string;
}
interface Auth {
name: string;
resolve(input: { ctx: Ctx; credential?: Credential; signal: AbortSignal }): Promise<AuthResult | undefined>;
peek?(input: { ctx: Ctx }): AuthResult | undefined;
}
function tokenAuth(): Auth {
return {
name: "token",
resolve: async ({ ctx, credential, signal }) => {
signal.throwIfAborted();
if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored" };
const token = await ctx.env("TOKEN");
if (token) return { auth: { headers: { Authorization: `Bearer ${token}` } }, source: "TOKEN" };
return undefined;
},
};
}
function gatewayAuth(): Auth {
return {
name: "gateway",
resolve: async ({ ctx }) => {
const key = await ctx.env("KEY");
if (!key) return undefined;
return {
auth: {
headers: {
"cf-aig-authorization": `Bearer ${key}`,
Authorization: null,
"x-api-key": null,
},
},
env: { ACCOUNT: "a1" },
source: "KEY",
};
},
peek: ({ ctx }) => (ctx ? { auth: { headers: { "x-peek": "1" } } } : undefined),
};
}
function boundAuth(): Auth {
return {
name: "bound",
resolve: async ({ ctx }) => {
const token = await ctx.env("TOKEN");
const headers = { Authorization: `Bearer ${token ?? "none"}` };
return { auth: { headers }, source: "bound" };
},
};
}
async function plain(flag: boolean): Promise<{ id: string; tags?: Record<string, string | null> } | undefined> {
return flag ? { id: "p", tags: { a: "1", b: null } } : undefined;
}
function show(result: AuthResult | undefined): string {
if (result === undefined) return "undefined";
const headers = result.auth.headers ?? {};
const keys = Object.keys(headers).sort();
return `${result.source ?? "-"} key=${result.auth.apiKey ?? "-"} ${keys.map((k) => `${k}=${String(headers[k])}`).join(" ")} env=${JSON.stringify(result.env ?? null)}`;
}
async function main(): Promise<void> {
const env = new Map<string, string>([
["KEY", "k1"],
["TOKEN", "t1"],
]);
const ctx: Ctx = { env: async (name) => env.get(name) };
const signal = new AbortController().signal;
const token = tokenAuth();
console.log(show(await token.resolve({ ctx, credential: { type: "api_key", key: "s1" }, signal })));
console.log(show(await token.resolve({ ctx, signal })));
console.log(show(await gatewayAuth().resolve({ ctx, signal })));
console.log(show(gatewayAuth().peek?.({ ctx })));
console.log(show(await boundAuth().resolve({ ctx, signal })));
env.clear();
console.log(show(await token.resolve({ ctx, signal })));
console.log(show(await gatewayAuth().resolve({ ctx, signal })));
console.log(show(await boundAuth().resolve({ ctx, signal })));
const p = await plain(true);
console.log(p?.id, JSON.stringify(p?.tags), (await plain(false)) === undefined);
}
main();
+29
View File
@@ -0,0 +1,29 @@
// import() of a specifier nothing resolves rejects with Node's
// ERR_MODULE_NOT_FOUND when the call runs: a package nothing installed
// provides, and a relative path to a file that does not exist, whose error
// also carries the missing file's URL. The specifier is a URL: its escapes
// are decoded in the message's path, and its query and fragment stay in
// the url only. The program itself loads, and each call rejects again when
// repeated.
const dirs = (text) => text.replace(/\/[^\s']*\//g, "<dir>/");
async function probe(label, load) {
try {
await load();
console.log(label, "loaded");
} catch (e) {
console.log(label, e instanceof Error, e.name, e.code);
console.log(" ", dirs(e.message));
console.log(" url:", e.url === undefined ? "none" : dirs(e.url));
}
}
console.log("start");
await probe("bare", () => import("no-such-package-anywhere"));
await probe("bare subpath", () => import("no-such-package-anywhere/sub.js"));
await probe("scoped", () => import("@nobody/no-such-package"));
await probe("relative", () => import("./no-such-file.mjs"));
await probe("relative again", () => import("./no-such-file.mjs"));
await probe("parent", () => import("../no-such-dir/no-such-file.js"));
await probe("escaped", () => import("./no%20such%20file.mjs?v=1#top"));
console.log("end");
@@ -0,0 +1,51 @@
// Provider<"alpha"> and Provider<"beta"> into Provider<Api>: method fields
// whose parameter is the instantiation-dependent Model<TApi> (a
// conditional compat member that is `never` for beta) convert through a
// checked adapter. TypeScript admits the pair by method bivariance; every
// call here passes each provider its own models, so nothing traps.
type Api = "alpha" | "beta";
interface AlphaCompat {
strict?: boolean;
}
interface Model<TApi extends Api> {
id: string;
api: TApi;
compat?: TApi extends "alpha" ? AlphaCompat : never;
}
interface Provider<TApi extends Api = Api> {
readonly id: string;
models(): Model<TApi>[];
cancel?(model: Model<TApi>, handle: string): Promise<void>;
}
function alphaProvider(): Provider<"alpha"> {
return {
id: "a",
models: () => [{ id: "a1", api: "alpha", compat: { strict: true } }],
cancel: async (model, handle) => {
console.log("cancel", model.id, handle, String(model.compat?.strict));
},
};
}
function betaProvider(): Provider<"beta"> {
return {
id: "b",
models: () => [{ id: "b1", api: "beta" }],
cancel: async (model, handle) => {
console.log("beta cancel", model.id, handle, model.compat === undefined);
},
};
}
function plainProvider(): Provider<"beta"> {
return { id: "p", models: () => [] };
}
function all(): Provider[] {
return [alphaProvider(), betaProvider(), plainProvider()];
}
async function main(): Promise<void> {
for (const p of all()) {
const [m] = p.models();
console.log(p.id, m?.id, m?.api, p.cancel === undefined);
if (m) await p.cancel?.(m, "h");
}
}
main();
+20
View File
@@ -0,0 +1,20 @@
// The outer literal's context (a union with a WeakRef arm) has no layout,
// so it builds at its own type; its nested literals must follow that
// layout instead of their own contextual one, and the outer coercion then
// lifts the whole tree (headers into the index-signature record).
type ProviderHeaders = Record<string, string | null>;
interface ModelAuth {
apiKey?: string;
headers?: ProviderHeaders;
}
interface AuthResult {
auth: ModelAuth;
source?: string;
}
type Answer = AuthResult | WeakRef<object> | undefined;
function make(flag: boolean): Answer {
return flag ? { auth: { headers: { Authorization: "Bearer x" } }, source: "env" } : undefined;
}
const r = make(true) as AuthResult;
console.log(r.source, r.auth.headers?.Authorization);
console.log(make(false) === undefined);
@@ -0,0 +1,79 @@
// Generic class methods called through a Pick<>/Omit<> view of the class
// that is NAMED by a type alias (`type View = Pick<Registry, ...>`), also
// through an alias of that alias. Inline Pick<> views already compile; the
// alias hides the Pick from the view's type, which then mapped to a
// record of erased method fields that a Registry instance cannot convert to.
interface ChatModel {
kind: "chat";
id: string;
}
interface ImageModel {
kind: "image";
id: string;
size: number;
}
interface ModelMap {
chat: ChatModel;
image: ImageModel;
}
type ModelKind = keyof ModelMap;
class Registry {
private readonly all: (ChatModel | ImageModel)[] = [
{ kind: "chat", id: "c1" },
{ kind: "image", id: "i1", size: 512 },
{ kind: "chat", id: "c2" },
];
modelsOfKind<K extends ModelKind>(kind: K, provider?: string): readonly ModelMap[K][] {
return this.all.filter((m) => m.kind === kind && provider !== "none") as ModelMap[K][];
}
modelOfKind<K extends ModelKind>(kind: K, id: string): ModelMap[K] | undefined {
for (const m of this.modelsOfKind(kind)) if (m.id === id) return m;
return undefined;
}
async availableOfKind<K extends ModelKind>(kind: K, options?: { tag?: string }): Promise<readonly ModelMap[K][]> {
return this.modelsOfKind(kind).filter(() => options?.tag !== "none");
}
label(): string {
return "registry";
}
}
type RegistryView = Pick<Registry, "modelsOfKind" | "modelOfKind" | "availableOfKind" | "label">;
type ReadOnlyView = RegistryView;
type NoAsyncView = Omit<Registry, "availableOfKind">;
function toKind(value: unknown): ModelKind {
return value === "image" ? "image" : "chat";
}
function describe(models: RegistryView, args: unknown[]): string {
const [kind, id] = args;
const list = models.modelsOfKind(toKind(kind)).map((m) => m.id);
const one = models.modelOfKind(toKind(kind), String(id));
return `${models.label()} ${list.join(",")} ${one === undefined ? "none" : one.id}`;
}
function globals(models: ReadOnlyView): Record<string, (args: unknown[]) => unknown> {
return {
list: (args) => models.modelsOfKind(toKind(args[0]), args[1] === undefined ? undefined : String(args[1])).length,
size: (args) => models.modelOfKind("image", String(args[0]))?.size ?? -1,
};
}
function sizes(models: NoAsyncView): string {
return models.modelsOfKind("image").map((m) => `${m.id}:${m.size}`).join(",");
}
async function main(): Promise<void> {
const registry = new Registry();
console.log(describe(registry, ["chat", "c2"]));
console.log(describe(registry, ["image", "zz"]));
const g = globals(registry);
console.log(g.list!(["chat"]), g.list!(["chat", "none"]), g.size!(["i1"]), g.size!(["c1"]));
console.log(sizes(registry));
const view: RegistryView = registry;
const available = await view.availableOfKind("image", { tag: "x" });
console.log(available.map((m) => `${m.id}:${m.size}`).join(","), (await view.availableOfKind("chat", { tag: "none" })).length);
}
main();
@@ -0,0 +1,2 @@
import type { Registry } from "./registry.ts";
export interface Ctx { registry: Registry; label: string }
@@ -0,0 +1,15 @@
import type { Ctx } from "./ctx.ts";
import type { ModelKind, RegistryView } from "./registry.ts";
function toKind(value: unknown): ModelKind {
return value === "image" ? "image" : "chat";
}
function globals(models: RegistryView): (args: unknown[]) => string {
return (args) => {
const [kind] = args;
return models.modelsOfKind(toKind(kind)).map((m) => m.id).join(",");
};
}
export function run(ctx: Ctx | undefined): string {
const list = ctx ? globals(ctx.registry) : undefined;
return list ? `${ctx?.label} ${list(["chat"])} ${list(["image"])}` : "none";
}
@@ -0,0 +1,3 @@
import { Registry } from "./registry.ts";
import { run } from "./globals.ts";
console.log(run({ registry: new Registry(), label: "L" }), run(undefined));
@@ -0,0 +1,21 @@
export interface ChatModel { kind: "chat"; id: string }
export interface ImageModel { kind: "image"; id: string; size: number }
export interface ModelMap { chat: ChatModel; image: ImageModel }
export type ModelKind = keyof ModelMap;
export class Registry {
private readonly all: (ChatModel | ImageModel)[] = [
{ kind: "chat", id: "c1" },
{ kind: "image", id: "i1", size: 512 },
];
modelsOfKind<K extends ModelKind>(kind: K, provider?: string): readonly ModelMap[K][] {
return this.all.filter((m) => m.kind === kind && provider !== "none") as ModelMap[K][];
}
modelOfKind<K extends ModelKind>(kind: K, id: string): ModelMap[K] | undefined {
for (const m of this.modelsOfKind(kind)) if (m.id === id) return m;
return undefined;
}
label(): string {
return "registry";
}
}
export type RegistryView = Pick<Registry, "modelsOfKind" | "modelOfKind" | "label">;
+98
View File
@@ -0,0 +1,98 @@
// Keyed reads and writes whose key is a union of literal names of declared
// fields stored with one type, on records whose other fields have other
// types: plain reads, plain writes, compound updates, keys from parameters
// and loops, and JavaScript evaluation order with effectful receivers and
// keys.
type Stats = { hits: number; misses: number; bytes: number; name: string; ok: boolean };
type Counter = "hits" | "misses" | "bytes";
type Flags = { name: string; verbose: boolean; strict: boolean; level: number };
type Labels = { id: number; title: string; summary: string; tags: string[] };
function read(s: Stats, key: Counter): number {
return s[key];
}
function bump(s: Stats, key: Counter, by: number): void {
s[key] = s[key] + by;
}
function toggle(flags: Flags, key: "verbose" | "strict"): boolean {
flags[key] = !flags[key];
return flags[key];
}
function relabel(labels: Labels, key: "title" | "summary", text: string): string {
const before = labels[key];
labels[key] = text.toUpperCase();
return `${before} -> ${labels[key]}`;
}
const stats: Stats[] = [];
for (let i = 0; i < 4; i++) stats.push({ hits: 0, misses: 0, bytes: 0, name: "s" + i, ok: true });
let seed = 7;
for (let i = 0; i < 1000; i++) {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
const s = stats[seed & 3]!;
bump(s, seed % 5 === 0 ? "misses" : "hits", 1);
bump(s, "bytes", (seed >>> 8) & 255);
}
for (const s of stats) console.log(s.name, read(s, "hits"), read(s, "misses"), read(s, "bytes"), s.ok);
// Keys from a loop over a constant list of names.
const counters = ["hits", "misses", "bytes"] as const;
const totals: Stats = { hits: 0, misses: 0, bytes: 0, name: "total", ok: false };
for (const s of stats) for (const key of counters) totals[key] += s[key];
console.log(totals.name, totals.hits, totals.misses, totals.bytes);
// Booleans and strings.
const flags: Flags = { name: "build", verbose: false, strict: true, level: 2 };
console.log(toggle(flags, "verbose"), toggle(flags, "strict"), toggle(flags, "verbose"), flags.level);
const labels: Labels = { id: 1, title: "draft", summary: "none", tags: ["a"] };
console.log(relabel(labels, "title", "final"), relabel(labels, "summary", "short"), labels.tags.length);
// The receiver, then the key, then the value, each once.
const order: string[] = [];
function pick(i: number): Stats {
order.push("receiver " + i);
return stats[i]!;
}
function name(key: Counter): Counter {
order.push("key " + key);
return key;
}
function amount(n: number): number {
order.push("value " + n);
return n;
}
pick(1)[name("misses")] = amount(40);
console.log(order.join(", "), stats[1]!.misses);
order.length = 0;
console.log(pick(2)[name("bytes")], order.join(", "));
// A key that names one field.
function only(s: Stats, key: "bytes"): number {
s[key] = s[key] * 2;
return s[key];
}
console.log(only(stats[3]!, "bytes"));
// A key or value that reassigns the receiver or key local: the write goes
// to the receiver and key evaluated before them.
let target = stats[0]!;
let slot: Counter = "hits";
target[slot] = ((slot = "misses"), 500);
console.log(stats[0]!.hits, stats[0]!.misses, slot);
target[slot] = ((target = stats[2]!), 600);
console.log(stats[0]!.misses, stats[2]!.misses, target === stats[2]);
function retarget(): Counter {
target = stats[3]!;
return "bytes";
}
target = stats[1]!;
target[retarget()] = 700;
console.log(stats[1]!.bytes, stats[3]!.bytes === 700);
target = stats[0]!;
slot = "hits";
target[slot] += ((slot = "bytes"), (target = stats[1]!), 1);
console.log(stats[0]!.hits, stats[0]!.bytes, stats[1]!.hits);
@@ -0,0 +1,83 @@
// `const { a, ...rest } = options ?? {}` (and `|| {}`): the pattern
// destructures either the options record or an empty object. Each key reads
// undefined from the empty object, a default applies to it, and the rest
// element packs the record's remaining own keys or nothing. The options
// expression is evaluated once.
interface EditorOptions {
description: string;
paddingX: number;
label?: string;
}
function editor(options?: EditorOptions): string {
const { description, ...editorOptions } = options ?? {};
return `${description} ${JSON.stringify(editorOptions)} [${Object.keys(editorOptions).join(",")}]`;
}
console.log(editor());
console.log(editor({ description: "d", paddingX: 2 }));
console.log(editor({ description: "d", paddingX: 2, label: "l" }));
// Every member optional: the empty object is the same layout with every
// key absent.
interface StreamOptions {
apiKey?: string;
env?: string;
signal?: string;
transformHeaders?: (headers: string) => string;
}
function stream(options?: StreamOptions): string {
const { transformHeaders, apiKey, ...rest } = options ?? {};
const headers = transformHeaders ? transformHeaders("h") : "none";
return `${headers} ${apiKey} ${JSON.stringify(rest)} ${"signal" in rest}`;
}
console.log(stream());
console.log(stream({ apiKey: "k", signal: "s" }));
console.log(stream({ transformHeaders: (h) => h + "!", env: "e" }));
// A nested optional record, renamed keys and a default.
interface Entry {
options?: { deferred: boolean; signal?: string; step: number };
}
function submit(entry: Entry): string {
const { deferred: _deferred, signal: _signal, step = -1, ...submission } = entry.options ?? {};
return `${step} ${JSON.stringify(submission)}`;
}
console.log(submit({}));
console.log(submit({ options: { deferred: true, step: 4 } }));
console.log(submit({ options: { deferred: false, signal: "x", step: 0 } }));
// `||` and a null arm.
interface Retry {
headers: string;
retries: number;
}
function retry(value: Retry | null): string {
const { headers, retries = 3 } = value || {};
return `${headers} ${retries} ${typeof retries}`;
}
console.log(retry(null));
console.log(retry({ headers: "h", retries: 2 }));
// A generic options parameter.
function generic<TOptions extends { apiKey?: string; maxTokens: number }>(options?: TOptions): string {
const { apiKey, ...providerOptions } = options ?? {};
return `${apiKey} ${JSON.stringify(providerOptions)}`;
}
console.log(generic());
console.log(generic({ maxTokens: 3, apiKey: "k" }));
// The options expression runs once.
let calls = 0;
function current(): EditorOptions | undefined {
calls++;
return calls === 1 ? undefined : { description: "second", paddingX: 1 };
}
const { description: first } = current() ?? {};
const { description: second, paddingX } = current() ?? {};
console.log(first, second, paddingX, calls);
export {};
@@ -0,0 +1,130 @@
// A record built from the checked-dynamic tree keeps the own keys its shape
// does not declare, and every JS copy carries them. Converting such a record
// into an index-signature record must copy them too.
//
// The shape that lost them: an inline, UNANNOTATED arrow in a generic call
// whose member is declared as `(h: H) => H | Promise<H>`. No contextual type
// reaches the arrow through the type parameter, so its body is typed by tsc's
// own inference, which drops the spread's index signature: the closure is
// typed Promise<{ x }>, `{ ...h, x }` lowers through the checked-dynamic
// spread into that closed record, and the caller's keys survive only in the
// record's kept source view. The call site reads the member through the
// declared H and adapts the function value; converting `{ x }` into H must
// copy the kept keys. Node prints "accept,x"; before the fix this program
// printed "x" for the arrow, local and method lines, with no diagnostic.
type H = Record<string, string | null>;
interface T {
transformHeaders?: (h: H) => H | Promise<H>;
}
function keys(r: H): string {
return Object.keys(r).sort().join(",") || "(none)";
}
// The failing shape: block-bodied inline arrow, generic parameter.
async function viaArrow<O extends T>(o: O | undefined): Promise<H> {
let h: H = { accept: "a" };
if (o?.transformHeaders) h = await o.transformHeaders(h);
return h;
}
// The same call read into a local first (the pi repro's shape).
async function viaLocal<O extends T>(o: O | undefined): Promise<H> {
let h: H = { accept: "a" };
if (o?.transformHeaders) {
const r = await o.transformHeaders(h);
console.log("local-mid:", keys(r));
h = r;
}
return h;
}
interface Methods {
transform(h: H): Promise<H>;
}
// The method form drops the same keys through the same capture.
async function viaMethod<O extends Methods>(o: O): Promise<H> {
return await o.transform({ accept: "a" });
}
// A non-generic parameter: the arrow's contextual slot is the declared member,
// so these keep the index signature and never needed the capture.
async function viaArrowNonGeneric(o: T | undefined): Promise<H> {
let h: H = { accept: "a" };
if (o?.transformHeaders) h = await o.transformHeaders(h);
return h;
}
interface Hooks {
transform?: (h: H) => H | Promise<H>;
}
interface Nested {
hooks: Hooks;
}
async function viaNested<O extends Nested>(o: O | undefined): Promise<H> {
let h: H = { accept: "a" };
const t = o?.hooks.transform;
if (t) h = await t(h);
return h;
}
function syncArrow<O extends { transform?: (h: H) => H }>(o: O): H {
return o.transform ? o.transform({ accept: "a" }) : { accept: "a" };
}
async function main(): Promise<void> {
const arrow = await viaArrow({
transformHeaders: async (h) => {
return { ...h, x: "1" };
},
});
console.log("arrow:", keys(arrow), JSON.stringify(arrow));
const local = await viaLocal({
transformHeaders: async (h) => {
return { ...h, x: "1" };
},
});
console.log("local:", keys(local));
const method = await viaMethod({
async transform(h) {
return { ...h, x: "1" };
},
});
console.log("method:", keys(method));
const generic = await viaArrowNonGeneric({
transformHeaders: async (h) => {
return { ...h, x: "1" };
},
});
console.log("non-generic:", keys(generic));
const expression = await viaArrowNonGeneric({
transformHeaders: async (h) => ({ ...h, x: "1" }),
});
console.log("expression:", keys(expression));
const nested = await viaNested({
hooks: {
transform: async (h) => {
return { ...h, x: "1" };
},
},
});
console.log("nested:", keys(nested));
const sync = syncArrow({
transform: (h) => {
return { ...h, x: "1" };
},
});
console.log("sync:", keys(sync));
}
void main();
@@ -0,0 +1,19 @@
// A checked conversion into an index-signature record whose values are
// closed records keeps each value's undeclared source keys, so converting
// such a value into an index-signature record later copies them. This
// program has no checked conversion into a closed record itself, so the
// capture learns that kept sources are possible from the index signature's
// values. Node prints "extra,x".
type H = Record<string, string | null>;
interface T {
transformHeaders?: (h: H) => H | Promise<H>;
}
const parsed: Record<string, { x: string }> = JSON.parse('{"p":{"x":"1","extra":"e"}}');
async function viaArrow<O extends T>(o: O | undefined): Promise<H> {
let h: H = { accept: "a" };
if (o?.transformHeaders) h = await o.transformHeaders(h);
return h;
}
viaArrow({ transformHeaders: async (h) => parsed["p"]! }).then((r) =>
console.log(Object.keys(r).sort().join(",")),
);
+92
View File
@@ -0,0 +1,92 @@
// Object spreads of unions with several record arms.
// 1. The checker narrowed the source to fewer arms than its stored union
// holds (an optional-chain discriminant test on an element read, a type
// predicate): the spread copies the narrowed arm.
// 2. A union source spreads into one declared record: each arm copies only
// the keys it holds, so a key the running arm lacks keeps the earlier
// value or stays absent.
// Key order is printed sorted: per-arm creation order is a separate
// (pre-existing) key-order limitation.
type Block =
| { type: "text"; text: string; textSignature?: string }
| { type: "tool"; id: string; args: unknown; ns?: string }
| { type: "image"; data: string };
function keys(value: object): string {
return Object.keys(value).sort().join(",");
}
function update(content: Block[], index: number, args: unknown): string {
const block = content[index];
if (block?.type === "tool") {
content[index] = { ...block, args };
return "updated";
}
return "skipped";
}
function isTool(block: Block): block is Block & { type: "tool" } {
return block.type === "tool";
}
function relabel(content: readonly Block[]): Block[] {
return content.map((block) => (isTool(block) ? { ...block, args: "relabelled" } : block));
}
const blocks: Block[] = [
{ type: "tool", id: "1", args: 0 },
{ type: "text", text: "t" },
{ type: "tool", id: "2", args: 0, ns: "n" },
{ type: "image", data: "d" },
];
console.log(update(blocks, 0, 5), update(blocks, 1, 5), update(blocks, 2, [1]), update(blocks, 9, 5));
for (const block of blocks) {
if (block.type === "tool") console.log(block.id, JSON.stringify(block.args), block.ns, keys(block));
else console.log(block.type, keys(block));
}
for (const block of relabel(blocks)) {
if (block.type === "tool") console.log(block.id, JSON.stringify(block.args), keys(block));
}
interface RequestOptions {
apiKey?: string;
headers?: Record<string, string>;
env?: string;
maxTokens?: number;
label?: string;
}
function describe(options: RequestOptions): string {
return [
options.apiKey,
options.headers?.["x"],
options.env,
options.maxTokens,
options.label,
keys(options),
"apiKey" in options,
].join(" ");
}
// The `cond ? { a, b } : {}` credential idiom.
function route(sameProvider: boolean, apiKey: string | undefined, env: string | undefined): string {
const auth = sameProvider ? { apiKey, env } : {};
return describe({ label: "base", ...auth, maxTokens: 5 });
}
console.log(route(true, "k", "e"));
console.log(route(true, undefined, "e"));
console.log(route(false, "k", "e"));
type Source = { env?: string; apiKey: string } | { label: string; headers?: Record<string, string> } | undefined;
function merge(base: RequestOptions, source: Source): string {
return describe({ ...base, ...source, maxTokens: 1 });
}
console.log(merge({ headers: { x: "h0" } }, { apiKey: "k", env: "e" }));
console.log(merge({ headers: { x: "h0" }, apiKey: "k0" }, { apiKey: "k" }));
console.log(merge({ headers: { x: "h0" } }, { label: "l" }));
console.log(merge({ headers: { x: "h0" } }, { label: "l", headers: { x: "h1" } }));
console.log(merge({ env: "e0" }, undefined));
export {};
+45
View File
@@ -0,0 +1,45 @@
// `...(options ?? {})` and `...(options || {})` spread exactly what
// `...options` spreads: nullish and other falsy values contribute no keys.
// The optional source is an Omit<> view of an options interface whose
// fields include a function, so a checked-dynamic copy cannot hold it.
interface LoaderOptions {
cwd: string;
agentDir: string;
extraPaths?: string[];
onLoad?: (paths: string[]) => string[];
}
interface ServiceOptions {
cwd: string;
loaderOptions?: Omit<LoaderOptions, "cwd" | "agentDir">;
}
class Loader {
readonly options: LoaderOptions;
constructor(options: LoaderOptions) {
this.options = options;
}
describe(): string {
const paths = this.options.onLoad?.(this.options.extraPaths ?? []) ?? [];
return `${this.options.cwd} ${this.options.agentDir} [${paths.join(",")}]`;
}
}
function viaNullish(options: ServiceOptions): Loader {
return new Loader({ ...(options.loaderOptions ?? {}), cwd: options.cwd, agentDir: "/agent" });
}
function viaOr(options: ServiceOptions): Loader {
return new Loader({ cwd: options.cwd, agentDir: "/or", ...(options.loaderOptions || {}) });
}
let evaluated = 0;
function counted(options: ServiceOptions): ServiceOptions["loaderOptions"] {
evaluated++;
return options.loaderOptions;
}
function viaCall(options: ServiceOptions): Loader {
return new Loader({ ...(counted(options) ?? {}), cwd: options.cwd, agentDir: "/call" });
}
const bare: ServiceOptions = { cwd: "/w" };
const full: ServiceOptions = { cwd: "/w", loaderOptions: { extraPaths: ["x"], onLoad: (p) => [...p, "y"] } };
console.log(viaNullish(bare).describe());
console.log(viaNullish(full).describe());
console.log(viaOr(bare).describe());
console.log(viaOr(full).describe());
console.log(viaCall(bare).describe(), viaCall(full).describe(), evaluated);
+3 -3
View File
@@ -12,9 +12,9 @@ const loose: Loose = { a: 1 };
const picked: PickedA = loose;
console.log(picked);
// A method field whose parameter shapes relate only BIVARIANTLY: neither
// parameter type converts into the other mechanically, so the field does
// not lift.
// A method field whose parameter shapes relate only BIVARIANTLY: the field
// lifts as a checked adapter, so only the console.log below refuses (a
// function value has no inspect lowering).
type SrcM = { m(x: { a: number }): void; extra: number };
type DstM = { m(x: { a: number; b: number }): void };
const srcM: SrcM = {
@@ -0,0 +1 @@
{ "plain": 9301, "san": 135316 }
@@ -0,0 +1 @@
{ "plain": 4900, "san": 6100 }
@@ -0,0 +1 @@
{ "plain": 6706, "san": 112967 }
@@ -0,0 +1 @@
{ "plain": 5031, "san": 84920 }
@@ -0,0 +1 @@
{ "plain": 7191, "san": 12536 }
@@ -0,0 +1 @@
{ "plain": 5283, "san": 8250 }
@@ -0,0 +1 @@
{ "plain": 5800, "san": 62700 }
@@ -0,0 +1 @@
{ "plain": 6298, "san": 72106 }
@@ -0,0 +1 @@
{ "plain": 1450, "san": 2550 }
@@ -0,0 +1 @@
{ "plain": 4700, "san": 7052 }
@@ -0,0 +1 @@
{ "plain": 4589, "san": 7483 }