diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 4272d3a6e..d977818f1 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -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. diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index d8b94fac0..9bdd0f2f6 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -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(); diff --git a/packages/compiler/src/frontend/lowering/class-views.ts b/packages/compiler/src/frontend/lowering/class-views.ts new file mode 100644 index 000000000..bacde86d3 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-views.ts @@ -0,0 +1,51 @@ +import * as ts from "../ts7/adapter.js"; + +/** A `Pick` / `Omit` 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`) or through + * non-generic user aliases (`type View = Pick`, `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 = Pick`) 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); +} diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 322cc09fa..7d0e3658c 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -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); diff --git a/packages/compiler/src/frontend/lowering/coercions/state.ts b/packages/compiler/src/frontend/lowering/coercions/state.ts index 9fa815081..1760df174 100644 --- a/packages/compiler/src/frontend/lowering/coercions/state.ts +++ b/packages/compiler/src/frontend/lowering/coercions/state.ts @@ -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(); + /** > 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(); // Provenance for checked extraction; consumers must not infer it from names. diff --git a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts index 2a1d75331..721f027c2 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts @@ -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(); for (const tf of to.fields) { @@ -405,6 +455,7 @@ export function recordWidthPlan( } return plan; } finally { + lowerer.coercions.methodFieldDepth--; lowerer.coercions.planning.delete(key); } } diff --git a/packages/compiler/src/frontend/lowering/coercions/structural.ts b/packages/compiler/src/frontend/lowering/coercions/structural.ts index a1d3283b3..7cdcdd8aa 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural.ts @@ -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; diff --git a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts index ad8f019b9..77504ebd7 100644 --- a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts +++ b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts @@ -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); diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 1ce873853..c94bfa802 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -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 diff --git a/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts b/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts index 932e2e4b6..267628572 100644 --- a/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts +++ b/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts @@ -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 { diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 7947f2830..2e0bc53d1 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -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(); - 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(); + 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`, `{ [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; diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 4f420bdb3..e5f0f9f7a 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -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` / `Omit` view of a +/** `view.m(args)` where `view` is a `Pick` / `Omit` 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 = diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 00299ce58..36ea4aa93 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -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 { diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index f1db4bcf1..bf9251569 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -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 }, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ee0ff6d02..3aeda4db1 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -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" && diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7700b7406..57f9c92fe 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -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(), visiting: new Set() }; + 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, diff --git a/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts b/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts new file mode 100644 index 000000000..66c1f00e7 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts @@ -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, + }; +} diff --git a/packages/compiler/src/frontend/lowering/record-sources.ts b/packages/compiler/src/frontend/lowering/record-sources.ts index 57ac9a6b0..692b4e1f8 100644 --- a/packages/compiler/src/frontend/lowering/record-sources.ts +++ b/packages/compiler/src/frontend/lowering/record-sources.ts @@ -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; + visiting: Set; +} + +/** 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` 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 diff --git a/packages/compiler/src/frontend/lowering/width-lift.ts b/packages/compiler/src/frontend/lowering/width-lift.ts index 59209dc50..1e2c75c7e 100644 --- a/packages/compiler/src/frontend/lowering/width-lift.ts +++ b/packages/compiler/src/frontend/lowering/width-lift.ts @@ -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`, 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` into `Promise`): * the checked payload widening coerceToExpected applies at a promise slot, * which keeps the promise's identity and settlement turn. */ diff --git a/packages/compiler/src/frontend/npm-file-import.test.ts b/packages/compiler/src/frontend/npm-file-import.test.ts new file mode 100644 index 000000000..daa0270ac --- /dev/null +++ b/packages/compiler/src/frontend/npm-file-import.test.ts @@ -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]])), + ); +}); diff --git a/packages/compiler/src/frontend/npm.ts b/packages/compiler/src/frontend/npm.ts index 6cb1d3b5e..c525eb9f6 100644 --- a/packages/compiler/src/frontend/npm.ts +++ b/packages/compiler/src/frontend/npm.ts @@ -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, diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index ae600d483..702341e68 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -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 diff --git a/packages/compiler/src/ir/build.ts b/packages/compiler/src/ir/build.ts index 77259450c..8a384676f 100644 --- a/packages/compiler/src/ir/build.ts +++ b/packages/compiler/src/ir/build.ts @@ -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, diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 844e7ed83..807f78284 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -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; diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 83e0f9a31..727d350af 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -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); +}); diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 4111a46b2..b11153bb2 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -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]; diff --git a/packages/compiler/test/method-field-instantiation-adapters.test.ts b/packages/compiler/test/method-field-instantiation-adapters.test.ts new file mode 100644 index 000000000..189901675 --- /dev/null +++ b/packages/compiler/test/method-field-instantiation-adapters.test.ts @@ -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 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 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 { + id: string; + api: TApi; + compat?: TApi extends "alpha" ? AlphaCompat : never; +} +interface Provider { + readonly id: string; + cancel?(model: Model, handle: string): Promise; +} +function betaProvider(): Provider<"beta"> { + return { + id: "b", + cancel: async (model, handle) => { + console.log("beta cancel", model.id, handle); + }, + }; +} +async function main(): Promise { + 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 }); + } + }, +); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-return-contextual-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-return-contextual-records.ts.json new file mode 100644 index 000000000..64c1702a0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-return-contextual-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/async-return-contextual-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-import-not-found.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-import-not-found.mjs.json new file mode 100644 index 000000000..bda8f524b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-import-not-found.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-import-not-found.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/method-field-instantiation-adapters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/method-field-instantiation-adapters.ts.json new file mode 100644 index 000000000..095817660 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/method-field-instantiation-adapters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/method-field-instantiation-adapters.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nested-literal-own-layout.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nested-literal-own-layout.ts.json new file mode 100644 index 000000000..5899185f8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nested-literal-own-layout.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/nested-literal-own-layout.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-generic-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-generic-methods.ts.json new file mode 100644 index 000000000..8927822a9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-generic-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/pick-alias-generic-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-view-modules/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-view-modules/main.ts.json new file mode 100644 index 000000000..1fd543dbe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-view-modules/main.ts.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/pick-alias-view-modules/registry.ts", + "/tests/corpus/pick-alias-view-modules/globals.ts", + "/tests/corpus/pick-alias-view-modules/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-literal-key-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-literal-key-unions.ts.json new file mode 100644 index 000000000..b10d37dd3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-literal-key-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-literal-key-unions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json new file mode 100644 index 000000000..e7da7aa73 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-nullish-empty-destructure.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-capture.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-capture.ts.json new file mode 100644 index 000000000..17cabc69b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-capture.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-source-keys-index-capture.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json new file mode 100644 index 000000000..72d9a1314 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-source-keys-index-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-union-spreads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-union-spreads.ts.json new file mode 100644 index 000000000..d468d35ae --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-union-spreads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-union-spreads.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json new file mode 100644 index 000000000..9d75cf30c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/spread-empty-default.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 2bc1d9a31..d2ccb1c39 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -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); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 090bfbfc8..352a3abb2 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -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) { diff --git a/tests/corpus/async-return-contextual-records.ts b/tests/corpus/async-return-contextual-records.ts new file mode 100644 index 000000000..f19af35dc --- /dev/null +++ b/tests/corpus/async-return-contextual-records.ts @@ -0,0 +1,108 @@ +// Object literals returned from async arrows typed by a method signature +// (`resolve(input): Promise`), with destructured +// parameters. The return position's contextual type is +// `AuthResult | undefined | PromiseLike`; 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; +interface ModelAuth { + apiKey?: string; + headers?: ProviderHeaders; +} +interface AuthResult { + auth: ModelAuth; + env?: Record; + source?: string; +} +interface Ctx { + env(name: string): Promise; +} +interface Credential { + type: "api_key"; + key?: string; +} +interface Auth { + name: string; + resolve(input: { ctx: Ctx; credential?: Credential; signal: AbortSignal }): Promise; + 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 } | 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 { + const env = new Map([ + ["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(); diff --git a/tests/corpus/dynamic-import-not-found.mjs b/tests/corpus/dynamic-import-not-found.mjs new file mode 100644 index 000000000..757eae920 --- /dev/null +++ b/tests/corpus/dynamic-import-not-found.mjs @@ -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, "/"); + +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"); diff --git a/tests/corpus/method-field-instantiation-adapters.ts b/tests/corpus/method-field-instantiation-adapters.ts new file mode 100644 index 000000000..5fda08000 --- /dev/null +++ b/tests/corpus/method-field-instantiation-adapters.ts @@ -0,0 +1,51 @@ +// Provider<"alpha"> and Provider<"beta"> into Provider: method fields +// whose parameter is the instantiation-dependent Model (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 { + id: string; + api: TApi; + compat?: TApi extends "alpha" ? AlphaCompat : never; +} +interface Provider { + readonly id: string; + models(): Model[]; + cancel?(model: Model, handle: string): Promise; +} +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 { + 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(); diff --git a/tests/corpus/nested-literal-own-layout.ts b/tests/corpus/nested-literal-own-layout.ts new file mode 100644 index 000000000..7f313363d --- /dev/null +++ b/tests/corpus/nested-literal-own-layout.ts @@ -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; +interface ModelAuth { + apiKey?: string; + headers?: ProviderHeaders; +} +interface AuthResult { + auth: ModelAuth; + source?: string; +} +type Answer = AuthResult | WeakRef | 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); diff --git a/tests/corpus/pick-alias-generic-methods.ts b/tests/corpus/pick-alias-generic-methods.ts new file mode 100644 index 000000000..a15d25579 --- /dev/null +++ b/tests/corpus/pick-alias-generic-methods.ts @@ -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`), 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(kind: K, provider?: string): readonly ModelMap[K][] { + return this.all.filter((m) => m.kind === kind && provider !== "none") as ModelMap[K][]; + } + modelOfKind(kind: K, id: string): ModelMap[K] | undefined { + for (const m of this.modelsOfKind(kind)) if (m.id === id) return m; + return undefined; + } + async availableOfKind(kind: K, options?: { tag?: string }): Promise { + return this.modelsOfKind(kind).filter(() => options?.tag !== "none"); + } + label(): string { + return "registry"; + } +} + +type RegistryView = Pick; +type ReadOnlyView = RegistryView; +type NoAsyncView = Omit; + +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 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 { + 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(); diff --git a/tests/corpus/pick-alias-view-modules/ctx.ts b/tests/corpus/pick-alias-view-modules/ctx.ts new file mode 100644 index 000000000..57bff4a07 --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/ctx.ts @@ -0,0 +1,2 @@ +import type { Registry } from "./registry.ts"; +export interface Ctx { registry: Registry; label: string } diff --git a/tests/corpus/pick-alias-view-modules/globals.ts b/tests/corpus/pick-alias-view-modules/globals.ts new file mode 100644 index 000000000..7d0f7f07c --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/globals.ts @@ -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"; +} diff --git a/tests/corpus/pick-alias-view-modules/main.ts b/tests/corpus/pick-alias-view-modules/main.ts new file mode 100644 index 000000000..86c15c49c --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/main.ts @@ -0,0 +1,3 @@ +import { Registry } from "./registry.ts"; +import { run } from "./globals.ts"; +console.log(run({ registry: new Registry(), label: "L" }), run(undefined)); diff --git a/tests/corpus/pick-alias-view-modules/registry.ts b/tests/corpus/pick-alias-view-modules/registry.ts new file mode 100644 index 000000000..5cdf4bd4b --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/registry.ts @@ -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(kind: K, provider?: string): readonly ModelMap[K][] { + return this.all.filter((m) => m.kind === kind && provider !== "none") as ModelMap[K][]; + } + modelOfKind(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; diff --git a/tests/corpus/record-literal-key-unions.ts b/tests/corpus/record-literal-key-unions.ts new file mode 100644 index 000000000..cb9336455 --- /dev/null +++ b/tests/corpus/record-literal-key-unions.ts @@ -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); diff --git a/tests/corpus/record-nullish-empty-destructure.ts b/tests/corpus/record-nullish-empty-destructure.ts new file mode 100644 index 000000000..aff75af76 --- /dev/null +++ b/tests/corpus/record-nullish-empty-destructure.ts @@ -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(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 {}; diff --git a/tests/corpus/record-source-keys-index-capture.ts b/tests/corpus/record-source-keys-index-capture.ts new file mode 100644 index 000000000..6c45ce872 --- /dev/null +++ b/tests/corpus/record-source-keys-index-capture.ts @@ -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`. 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; + +interface T { + transformHeaders?: (h: H) => H | Promise; +} + +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: O | undefined): Promise { + 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: O | undefined): Promise { + 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; +} + +// The method form drops the same keys through the same capture. +async function viaMethod(o: O): Promise { + 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 { + let h: H = { accept: "a" }; + if (o?.transformHeaders) h = await o.transformHeaders(h); + return h; +} + +interface Hooks { + transform?: (h: H) => H | Promise; +} + +interface Nested { + hooks: Hooks; +} + +async function viaNested(o: O | undefined): Promise { + let h: H = { accept: "a" }; + const t = o?.hooks.transform; + if (t) h = await t(h); + return h; +} + +function syncArrow H }>(o: O): H { + return o.transform ? o.transform({ accept: "a" }) : { accept: "a" }; +} + +async function main(): Promise { + 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(); diff --git a/tests/corpus/record-source-keys-index-values.ts b/tests/corpus/record-source-keys-index-values.ts new file mode 100644 index 000000000..92d6077e2 --- /dev/null +++ b/tests/corpus/record-source-keys-index-values.ts @@ -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; +interface T { + transformHeaders?: (h: H) => H | Promise; +} +const parsed: Record = JSON.parse('{"p":{"x":"1","extra":"e"}}'); +async function viaArrow(o: O | undefined): Promise { + 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(",")), +); diff --git a/tests/corpus/record-union-spreads.ts b/tests/corpus/record-union-spreads.ts new file mode 100644 index 000000000..f3698826e --- /dev/null +++ b/tests/corpus/record-union-spreads.ts @@ -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; + 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 } | 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 {}; diff --git a/tests/corpus/spread-empty-default.ts b/tests/corpus/spread-empty-default.ts new file mode 100644 index 000000000..4905b9619 --- /dev/null +++ b/tests/corpus/spread-empty-default.ts @@ -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; +} +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); diff --git a/tests/diagnostics/record-width.ts b/tests/diagnostics/record-width.ts index c02b4e294..f42decd88 100644 --- a/tests/diagnostics/record-width.ts +++ b/tests/diagnostics/record-width.ts @@ -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 = { diff --git a/tests/harness/corpus-costs/async-return-contextual-records.ts.json b/tests/harness/corpus-costs/async-return-contextual-records.ts.json new file mode 100644 index 000000000..d1cbee83a --- /dev/null +++ b/tests/harness/corpus-costs/async-return-contextual-records.ts.json @@ -0,0 +1 @@ +{ "plain": 9301, "san": 135316 } diff --git a/tests/harness/corpus-costs/dynamic-import-not-found.mjs.json b/tests/harness/corpus-costs/dynamic-import-not-found.mjs.json new file mode 100644 index 000000000..cbb390ba4 --- /dev/null +++ b/tests/harness/corpus-costs/dynamic-import-not-found.mjs.json @@ -0,0 +1 @@ +{ "plain": 4900, "san": 6100 } diff --git a/tests/harness/corpus-costs/method-field-instantiation-adapters.ts.json b/tests/harness/corpus-costs/method-field-instantiation-adapters.ts.json new file mode 100644 index 000000000..ae4c04177 --- /dev/null +++ b/tests/harness/corpus-costs/method-field-instantiation-adapters.ts.json @@ -0,0 +1 @@ +{ "plain": 6706, "san": 112967 } diff --git a/tests/harness/corpus-costs/nested-literal-own-layout.ts.json b/tests/harness/corpus-costs/nested-literal-own-layout.ts.json new file mode 100644 index 000000000..0c791d2ac --- /dev/null +++ b/tests/harness/corpus-costs/nested-literal-own-layout.ts.json @@ -0,0 +1 @@ +{ "plain": 5031, "san": 84920 } diff --git a/tests/harness/corpus-costs/pick-alias-generic-methods.ts.json b/tests/harness/corpus-costs/pick-alias-generic-methods.ts.json new file mode 100644 index 000000000..3d92741c6 --- /dev/null +++ b/tests/harness/corpus-costs/pick-alias-generic-methods.ts.json @@ -0,0 +1 @@ +{ "plain": 7191, "san": 12536 } diff --git a/tests/harness/corpus-costs/pick-alias-view-modules/main.ts.json b/tests/harness/corpus-costs/pick-alias-view-modules/main.ts.json new file mode 100644 index 000000000..296b09a53 --- /dev/null +++ b/tests/harness/corpus-costs/pick-alias-view-modules/main.ts.json @@ -0,0 +1 @@ +{ "plain": 5283, "san": 8250 } diff --git a/tests/harness/corpus-costs/record-literal-key-unions.ts.json b/tests/harness/corpus-costs/record-literal-key-unions.ts.json new file mode 100644 index 000000000..719a1099c --- /dev/null +++ b/tests/harness/corpus-costs/record-literal-key-unions.ts.json @@ -0,0 +1 @@ +{ "plain": 5800, "san": 62700 } diff --git a/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json b/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json new file mode 100644 index 000000000..c56636be7 --- /dev/null +++ b/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json @@ -0,0 +1 @@ +{ "plain": 6298, "san": 72106 } diff --git a/tests/harness/corpus-costs/record-source-keys-index-capture.ts.json b/tests/harness/corpus-costs/record-source-keys-index-capture.ts.json new file mode 100644 index 000000000..81f013248 --- /dev/null +++ b/tests/harness/corpus-costs/record-source-keys-index-capture.ts.json @@ -0,0 +1 @@ +{ "plain": 1450, "san": 2550 } diff --git a/tests/harness/corpus-costs/record-union-spreads.ts.json b/tests/harness/corpus-costs/record-union-spreads.ts.json new file mode 100644 index 000000000..d2e79152f --- /dev/null +++ b/tests/harness/corpus-costs/record-union-spreads.ts.json @@ -0,0 +1 @@ +{ "plain": 4700, "san": 7052 } diff --git a/tests/harness/corpus-costs/spread-empty-default.ts.json b/tests/harness/corpus-costs/spread-empty-default.ts.json new file mode 100644 index 000000000..da02be042 --- /dev/null +++ b/tests/harness/corpus-costs/spread-empty-default.ts.json @@ -0,0 +1 @@ +{ "plain": 4589, "san": 7483 }