diff --git a/packages/compiler/src/frontend/lowering/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts index fdd16da90..9a231bc77 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promises.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts @@ -441,6 +441,17 @@ export function lowerPromiseStaticCall( inner.kind === "void" ? { kind: "promise", inner: VOID } : lowerer.mapTypeOf(lowerer.typeOf(call)); + // An unannotated JavaScript parameter (`Promise.all(items)` in a + // package's .js file) types the call as Promise, which has no + // typed layout: the engine's Promise.all runs over the entries as + // checked-dynamic values, as it does for an array literal there. + if ( + resultT === null && + isJsSourceFile(ts.getSourceFileOfNode(call)) && + lowerer.dynConvertible(entries.type) + ) { + return checkedPromiseAll(lowerer, lowerer.coerceToExpected(entries, DYN), loc); + } // Promise[] combines into a `(T | undefined)[]`, which // stores T elements (primitiveElement): collect the values, then copy // them in with their present undefined values. diff --git a/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts b/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts index 33515bf2c..2a8711ffb 100644 --- a/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts @@ -190,6 +190,9 @@ function protocolSignature( ): ProtocolSignature | null { // A guarded optional step's receiver still carries its nullish arms. const recvTs = lowerer.checker.getNonNullableType(lowerer.typeOf(access.expression)); + // A record view of a Map or Set has no stored member to read: the call + // dispatches on the collection at runtime instead. + if (lowerer.classCollectionView(recvTs)) return null; const property = lowerer.checker.getPropertyOfType(recvTs, access.name.text); if (!property) return null; const declarations = property.declarations; diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 15fd83bdf..f29eaff3a 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -13,6 +13,11 @@ export class ClassProtocols { /** The value types of the string-keyed dictionaries that keep checked * identity (see markDictionaryViews). */ private readonly dictionaryViews = new Set(); + /** The structural types a lib Map or Set flows into while it answers one + * of their members (see markIdentityFlows). They keep checked identity + * without protocol dispatch: their members are invoked on the collection + * itself, not read from a property bag. */ + private readonly collectionViews = new Set(); constructor( private readonly checker: ts.TypeChecker, @@ -425,9 +430,16 @@ export class ClassProtocols { const added = [...marked].filter((symbol) => !before.has(symbol)); // A copy of an instance drops the methods its class defines, so a view // that exposes one of them stays live wherever such an instance may - // arrive. + // arrive. A record view of a collection needs no protocol: its checked + // identity is the collection itself, and its members are the + // collection's own methods, which protocol dispatch would read from a + // property bag instead of invoking. const kept = new Set( - added.filter((symbol) => methodViews.has(symbol) || this.needsView(symbol, marked, facts)), + added.filter( + (symbol) => + !this.collectionViews.has(symbol) && + (methodViews.has(symbol) || this.needsView(symbol, marked, facts)), + ), ); let grew = true; while (grew) { @@ -501,8 +513,9 @@ export class ClassProtocols { } // A Pick or Omit view's values are instances of the class it views, // or of any other program class that has the view's members. - if (this.pickOfProgramClass(type)) { - add(type.getAliasTypeArguments()[0]!); + const viewed = this.pickViewOf(type, 0); + if (viewed !== null) { + add(viewed); for (const other of this.classesAcceptedBy(type)) for (const related of this.classFamily(other, facts)) out.add(related); return; @@ -634,6 +647,14 @@ export class ClassProtocols { ); } + /** Whether one of `names` is a member the collection type itself + * declares (`has`, `get`, `size`, `keys`, ...). */ + private exposesCollectionMember(collection: ts.Type, names: ReadonlySet): boolean { + return this.checker + .getPropertiesOfType(collection) + .some((property) => names.has(ts.symbolName(property))); + } + /** Whether class `symbol` declares a get or set accessor for one of * `names`. */ private hasAccessorFor(symbol: ts.Symbol, names: ReadonlySet): boolean { @@ -698,15 +719,67 @@ export class ClassProtocols { /** `Pick` or `Omit` over a program class instance type: a * view of the class's own members, spelled inline instead of as an * interface. Like an interface the class matches, a view that exposes a - * method needs a live receiver (see protocol). */ + * method needs a live receiver (see protocol). A program type alias of + * such a view names the same view (`type V = Pick`), so its + * declaration is resolved to the Pick/Omit it spells. */ private pickOfProgramClass(type: ts.Type): boolean { + return this.pickViewOf(type, 0) !== null; + } + + /** The program class instance this view views, resolving program type + * aliases of views (`type A = B`, `type B = Pick`) up to a small + * depth; null for everything else. */ + private pickViewOf(type: ts.Type, depth: number): ts.Type | null { + if (depth > 8) return null; const alias = type.getAliasSymbol(); - if (alias === undefined || (ts.symbolName(alias) !== "Pick" && ts.symbolName(alias) !== "Omit")) - return false; - if (!alias.declarations.every((declaration) => declaration.getSourceFile().isDeclarationFile)) - return false; - const source = type.getAliasTypeArguments()[0]; - return source !== undefined && this.isProgramClassInstance(source); + if (alias === undefined) return null; + if (this.isPickOrOmitAlias(alias)) { + const source = type.getAliasTypeArguments()[0]; + return source !== undefined && this.isProgramClassInstance(source) ? source : null; + } + const declaration = this.programTypeAlias(alias); + return declaration === null ? null : this.pickViewNode(declaration.type, depth + 1); + } + + /** The class instance a view's type node views. The resolved type of a + * program alias is the alias itself, so the reference is read by name: + * `Pick` / `Omit` keep the class it views, and another alias + * is followed through its declaration. */ + private pickViewNode(node: ts.TypeNode, depth: number): ts.Type | null { + if (depth > 8) return null; + while (ts.isParenthesizedTypeNode(node)) node = node.type; + if (!ts.isTypeReferenceNode(node)) return null; + const symbol = attempt(() => this.checker.getSymbolAtLocation(node.typeName)); + if (symbol === undefined) return null; + if (this.isPickOrOmitAlias(symbol)) { + const sourceNode = node.typeArguments?.nodes[0]; + const source = + sourceNode === undefined + ? undefined + : attempt(() => this.checker.getTypeFromTypeNode(sourceNode)); + return source !== undefined && this.isProgramClassInstance(source) ? source : null; + } + const declaration = this.programTypeAlias(symbol); + return declaration === null ? null : this.pickViewNode(declaration.type, depth + 1); + } + + /** The lib's `Pick` or `Omit` utility type. */ + private isPickOrOmitAlias(symbol: ts.Symbol): boolean { + const name = ts.symbolName(symbol); + if (name !== "Pick" && name !== "Omit") return false; + return symbol.declarations.every( + (declaration) => declaration.getSourceFile().isDeclarationFile, + ); + } + + /** The program's own declaration of a type alias, if the symbol has one. */ + private programTypeAlias(symbol: ts.Symbol): ts.TypeAliasDeclaration | null { + const declaration = symbol.declarations.find( + (candidate) => + ts.isTypeAliasDeclaration(candidate) && + !ts.getSourceFileOfNode(candidate).isDeclarationFile, + ); + return declaration !== undefined && ts.isTypeAliasDeclaration(declaration) ? declaration : null; } /** String-keyed dictionaries (`Record`, `{ [key: string]: V }`) @@ -866,6 +939,58 @@ export class ClassProtocols { .find((index) => (index.keyType.flags & ts.TypeFlags.String) !== 0)?.type; } + /** A lib Map, Set, ReadonlyMap or ReadonlySet: a live collection. Handed + * to a structural type that spells one of its members, a copy would answer + * from its own fields and miss later writes to the collection, so the view + * stays the collection itself (checked-dynamic identity). */ + private isBuiltinCollection(type: ts.Type): boolean { + if ((type.flags & ts.TypeFlags.Object) === 0) return false; + const target = type.isTypeReference() ? type.getTarget() : type; + const symbol = target?.getSymbol() ?? type.getSymbol(); + if (symbol === undefined) return false; + const name = ts.symbolName(symbol); + if (name !== "Map" && name !== "Set" && name !== "ReadonlyMap" && name !== "ReadonlySet") + return false; + return ( + (symbol.flags & ts.SymbolFlags.Class) === 0 && + symbol.declarations.length > 0 && + symbol.declarations.every( + (declaration) => ts.getSourceFileOfNode(declaration).isDeclarationFile, + ) + ); + } + + /** A builtin collection with an `any` or `unknown` type argument is + * UNTYPED (`new Map()`). It cannot back a structural view narrower than + * the values it may yield — `get` answers `any`, which the view declares + * as its own member type — so it keeps the record-shape refusal rather + * than carrying identity into the view. */ + private isUntypedCollection(type: ts.Type): boolean { + if (!type.isTypeReference()) return true; + const args = this.checker.getTypeArguments(type); + return ( + args.length === 0 || + args.some((arg) => (arg.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) + ); + } + + /** A lib Error, TypeError, ... instance type (RUNTIME_ERROR_CLASSES' lib + * names). Its fields are writable and its checked-dynamic view is the + * error itself, so a record built from it would miss later writes. */ + private isBuiltinError(type: ts.Type): boolean { + if ((type.flags & ts.TypeFlags.Object) === 0) return false; + const symbol = type.getSymbol(); + if (symbol === undefined) return false; + if (!ERROR_LIB_NAMES.has(ts.symbolName(symbol))) return false; + return ( + (symbol.flags & ts.SymbolFlags.Class) === 0 && + symbol.declarations.length > 0 && + symbol.declarations.every( + (declaration) => ts.getSourceFileOfNode(declaration).isDeclarationFile, + ) + ); + } + private isProgramClassInstance(type: ts.Type): boolean { const symbol = type.getSymbol(); return ( @@ -888,6 +1013,14 @@ export class ClassProtocols { return element !== undefined && this.carriesIdentity(element, marked); } if (this.isProgramClassInstance(type)) return true; + // A Map or Set is a live collection: a record built from it answers the + // members it copies once and misses every later write. An untyped + // `new Map()` cannot back a narrower view (see isUntypedCollection), so + // only a fully typed collection carries identity into the view. + if (this.isBuiltinCollection(type)) return !this.isUntypedCollection(type); + // A builtin Error keeps its own identity too: extra fields the program + // puts on it (a status, a body) belong to the error, not to a copy. + if (this.isBuiltinError(type)) return true; // A live Pick or Omit view of a program class holds the instance itself. if (this.pickOfProgramClass(type) && this.usesCheckedIdentity(type)) return true; const symbol = type.getSymbol(); @@ -959,7 +1092,27 @@ export class ClassProtocols { const from = source.getSymbol(); if (from === symbol) return false; const facts = this.facts; + let collectionGrew = false; if (facts !== null) { + // A Map or Set that reaches the view, which answers one of its + // members: a record copy has no live backing behind `has`, `get` or + // `size`, and Node's answer changes with every later write. The view + // keeps checked identity, but not protocol dispatch: its members are + // the collection's own methods. A collection view's value handed on + // to another structural type is the same collection, so that view + // answers through it too. + const collection = this.isBuiltinCollection(source) + ? this.exposesCollectionMember( + source, + new Set( + this.checker.getPropertiesOfType(target).map((property) => ts.symbolName(property)), + ), + ) + : from !== undefined && this.collectionViews.has(from); + if (collection && !this.collectionViews.has(symbol)) { + this.collectionViews.add(symbol); + collectionGrew = true; + } // The flow: the source's classes and marked type reach `symbol`. const origins = facts.origins.get(symbol) ?? new Set(); for (const origin of this.originsOf(source, facts)) origins.add(origin); @@ -971,11 +1124,25 @@ export class ClassProtocols { facts.flows.set(from, flows); } } - if (marked.has(symbol)) return false; + // A view marked earlier that becomes a collection view still changes + // the fixpoint: the views its values flow into are asked again. + if (marked.has(symbol)) return collectionGrew; marked.add(symbol); return true; } + /** Whether `type` is a structural view that a Map or Set reaches and that + * answers through the collection's own methods (see markIdentityFlows). + * Such a view keeps checked identity but never protocol dispatch: there + * is no stored member to read, and the collection answers the call. */ + collectionView(type: ts.Type): boolean { + this.index(); + if (type.isUnionType() || type.isIntersectionType()) + return ts.constituentTypes(type).some((part) => this.collectionView(part)); + const symbol = type.getSymbol(); + return symbol !== undefined && this.collectionViews.has(symbol); + } + /** Class-backed protocols require live receiver dispatch. Other interfaces, * including factories backed by static methods and ordinary records of * closures, retain their fixed native layouts. */ @@ -995,9 +1162,12 @@ export class ClassProtocols { // it (a literal whose methods read `this`) or when it receives existing // objects (markDictionaryViews). const dictionaryValue = this.dictionaryValueType(type); + const view = type.getSymbol(); const answer = this.protocol(type) || - (dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue)); + (dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue)) || + // A record view of a Map or Set answers through the collection itself. + (view !== undefined && this.collectionViews.has(view)); this.answers.set(type, answer); return answer; } @@ -1062,6 +1232,22 @@ interface FlowFacts { /** The empty answer of the symbol maps above. */ const NO_SYMBOLS: ReadonlySet = new Set(); +/** The lib names RUNTIME_ERROR_CLASSES gives the runtime error encoding + * (`%Error`, `%TypeError`, ...; ir.ts). */ +const ERROR_LIB_NAMES = new Set([ + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "DOMException", + "ReferenceError", + "EvalError", + "URIError", + "AggregateError", + "AssertionError", + "SuppressedError", +]); + /** Calls of these namespaces and functions observe whole objects. */ const OBSERVER_NAMESPACES = new Set(["console", "JSON", "Object", "Reflect", "util", "assert"]); const OBSERVER_FUNCTIONS = new Set([ diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index eca6e5ed2..ed8c6ddce 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -114,10 +114,12 @@ export function lowerArrayHofCall( // leave an element its callback ignores unread. const unread = callback.unread && !present && elementMayBeForeign(lowerer, elem); const fnRet = fnArg.type.ret; - if (method === "map" && (fnRet.kind === "void" || fnRet.kind === "func")) { - // The result array U[] is unrepresentable here (no void elements; - // the map helper's closure-returning form has no fixture-backed - // story yet). + if (method === "map" && fnRet.kind === "void") { + // The result array `void[]` is unrepresentable: a void return value is + // erased before it could be stored. A CLOSURE-returning callback is an + // ordinary function element — the value is one ScrClosure for its whole + // life, stored by reference with the element kind's `_v` adapter and + // trace, exactly like a closure array literal (`[() => 1]`). lowerer.badType(call, lowerer.typeOf(call)); } if (method === "map") requireProducedArrayElement(lowerer, call, "'.map()'", fnRet); @@ -659,7 +661,8 @@ export function lowerArrayUnionHofCall( const argNode = call.arguments.nodes[0]!; const { fnArg, arity } = lowerArrayCallback(lowerer, argNode, [valueT], receiverT); const fnRet = fnArg.type.ret; - if (method === "map" && (fnRet.kind === "void" || fnRet.kind === "func")) { + if (method === "map" && fnRet.kind === "void") { + // `void[]` has no element (see the plain-receiver map path). lowerer.badType(call, lowerer.typeOf(call)); } if (method === "map") requireProducedArrayElement(lowerer, call, "'.map()'", fnRet); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ee991091f..28d7e7c22 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -10653,6 +10653,83 @@ function isUntypedIterable(lowerer: Lowerer, node: ts.Expression): boolean { return lowerer.mapTypeOf(type)?.kind === "dyn"; } +/** `for (const x of map.get(k) ?? [])`: the receiver's checker type is one + * non-array iterable beside `never[]` — the array literal's type, whose only + * value is the empty array (`never` is uninhabited), so that arm yields + * nothing at all. The loop walks the iterable arm under its tag test and the + * empty-array arm runs zero iterations. An ARRAY iterable never needs this: + * `T[] | never[]` reduces in the checker to `T[]`, never being assignable to + * T. A union of two inhabited iterables has no single element type and keeps + * its existing fence. */ +function lowerForOfEmptyArrayArm( + lowerer: Lowerer, + stmt: ts.ForOfStatement, + iterable: IrExpr, + iterSrc: ts.Expression, + labels?: string[], +): IrStmt | null { + if (iterable.type.kind !== "union" || stmt.awaitModifier !== undefined) return null; + const { checker } = lowerer; + const widened = lowerer.typeOf(iterSrc); + if (!widened.isUnionType()) return null; + const parts = ts.constituentTypes(widened); + if (parts.length !== 2) return null; + const neverArrays = parts.filter((part) => { + if (!checker.isArrayType(part)) return false; + const elem = checker.getTypeArguments(part as ts.TypeReference)[0]; + return elem !== undefined && (elem.flags & ts.TypeFlags.Never) !== 0; + }); + if (neverArrays.length !== 1) return null; + const other = parts.find((part) => part !== neverArrays[0]); + if (other === undefined) return null; + const armT = lowerer.mapTypeOf(other); + if (armT === null) return null; + if (armT.kind !== "set" && armT.kind !== "map" && armT.kind !== "string") return null; + const tag = lowerer.armTag(iterable.type.unionId, armT); + if (tag < 0) return null; + const loc = locOf(stmt); + // The tag test and the walk read the source once: a call or getter in the + // head runs one time, and the loop walks the value the test saw. + const source = lowerer.declareHiddenLocal("%forOfEmptyArm", iterable.type); + const sourceRef: IrExpr = { kind: "varRef", localId: source.id, type: iterable.type, loc }; + const narrowed: IrExpr = { + kind: "unionNarrow", + unionId: iterable.type.unionId, + tag, + value: sourceRef, + type: armT, + loc, + }; + const loop: IrStmt = + armT.kind === "set" + ? lowerForOfSet(lowerer, stmt, narrowed, armT) + : armT.kind === "map" + ? lowerForOfMap(lowerer, stmt, narrowed, armT) + : lowerForOfString(lowerer, stmt, narrowed, labels); + return { + kind: "block", + body: [ + { kind: "varDecl", localId: source.id, init: iterable, loc }, + { + kind: "if", + cond: { + kind: "unionIsTag", + unionId: iterable.type.unionId, + tag, + negated: false, + value: sourceRef, + type: BOOL, + loc, + }, + then: [loop], + else_: null, + loc, + }, + ], + loc, + }; +} + export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { // Labels consume HERE (nested statements must never see them). The // plain array/string paths carry them; the container/matchAll/stdin @@ -10936,6 +11013,10 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { const iterableUnion = iterableUnionOf(lowerer, iterable.type); if (iterableUnion) return lowerForOfIterable(lowerer, stmt, iterable, iterableUnion, labels); } + { + const emptyArrayArm = lowerForOfEmptyArrayArm(lowerer, stmt, iterable, iterSrc, labels); + if (emptyArrayArm) return emptyArrayArm; + } let tupleSource: { shapeId: string; fields: { name: string; type: IrType }[]; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 417f13c20..027e8a8f6 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -5127,6 +5127,13 @@ export class Lowerer { return this.classProtocols.usesCheckedIdentity(type); } + /** A record view a Map or Set answers: it keeps protocol identity but + * calls the collection's own methods, never a stored member. */ + classCollectionView(type: ts.Type): boolean { + this.classProtocols ??= new ClassProtocols(this.checker, this.moduleOrder); + return this.classProtocols.collectionView(type); + } + /** Declared class fields may store a DYN value only when its static type * is `unknown` or `object` (also with `null` or `undefined`, or an array * of those), names checked protocol identity, is an owned runtime diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 55a7631a4..36dae9b10 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -5419,25 +5419,46 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return DYN; } + // SEVERAL PROMISE ARMS are ONE: `Promise | Promise` is + // `Promise`. A promise arm's tag IS the `instanceof Promise` + // test, so JavaScript cannot tell the arms apart at run time (both + // answer true, both `await` to a value of their payload), and the + // merged payload union is exactly what `await` produces either way. + // The merge happens here, in the union the checker gave us, so every + // use site (a declaration slot, an argument, an `await`, `.then`) + // sees the same one-promise-arm layout; the conversion of a + // `Promise` value into it is the checked promise-payload widening + // (promisePayloadWidens / promiseWiden). A payload that cannot ride + // that union (a promise of a promise, a regex or generator payload) + // keeps the whole union fenced. + const mergedArms = mergePromiseArms(arms, ctx); + if (mergedArms === null) return null; + // Promise arms were the whole union (`Promise | Promise` + // merges to the plain `Promise`): a one-armed union has no + // representation and needs none — the merge left the arm alone. + if (mergedArms.length === 1) { + if (unions.recursivePending(widened)) return null; + return mergedArms[0]!; + } if ( - arms.some( + mergedArms.some( (a) => a.kind === "void" || a.kind === "union" || a.kind === "dyn" || // A generator arm only comes from an Iterable (an optional // one keeps its unit arms beside the iterable union's). - (a.kind === "generator" && iterableArmsElem(arms) === null), + (a.kind === "generator" && iterableArmsElem(mergedArms) === null), ) || - !maybePromiseUnionOk(parts, arms, ctx) + !maybePromiseUnionOk(parts, mergedArms, ctx) ) { return null; } - arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + mergedArms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); const discriminant = - unionDiscriminant(recordParts, arms, ctx) ?? + unionDiscriminant(recordParts, mergedArms, ctx) ?? (substitutedUnions.length === 1 - ? remapUnionDiscriminant(substitutedUnions[0]!, arms) + ? remapUnionDiscriminant(substitutedUnions[0]!, mergedArms) : undefined); if (unions.recursivePending(widened)) { // The knot closed through this union. A frame that resolved @@ -5446,9 +5467,12 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // same ts.Type answers differently per instantiation — so // recursive generic-open unions stay fenced. if (contextResolutions !== sensitivityAtEntry) return null; - return { kind: "union", unionId: unions.finalizeRecursive(widened, arms, discriminant) }; + return { + kind: "union", + unionId: unions.finalizeRecursive(widened, mergedArms, discriminant), + }; } - return { kind: "union", unionId: unions.intern(arms, discriminant) }; + return { kind: "union", unionId: unions.intern(mergedArms, discriminant) }; } finally { unions.inProgress.delete(widened); } @@ -8871,6 +8895,79 @@ function maybePromiseUnionOk( return maybePromiseThenableArm(parts, ctx) === null; } +/** Merge the promise arms of one union into a single `Promise` + * (mapTypeInner's union rule): JavaScript-wise the arms are + * indistinguishable — `instanceof Promise` answers true for both, and + * `await` settles to the payload union — so the merged arm is the same + * value in the same slot. Payloads join like union arms: a `void` payload + * becomes the undefined unit, a payload that is already a union flattens, + * and a checked-dynamic payload (`Promise`) absorbs the arms the + * checked-dynamic representation holds faithfully (dynSubsumableUnionArm). + * Null keeps the whole union fenced: a promise-of-a-promise payload, a + * payload with no union home (regex, generator), or a dyn payload beside an + * arm whose semantics would degrade. */ +function mergePromiseArms(arms: IrType[], ctx: TypeMapperCtx): IrType[] | null { + let promiseArms = 0; + for (const arm of arms) if (arm.kind === "promise") promiseArms++; + if (promiseArms < 2) return arms; + const payloads: IrType[] = []; + let dynamic = false; + for (const arm of arms) { + if (arm.kind !== "promise") continue; + const inner = arm.inner; + if (inner.kind === "void") { + payloads.push(UNDEFINED_T); + continue; + } + if (inner.kind === "dyn") { + dynamic = true; + continue; + } + if (inner.kind === "union") { + const substituted = ctx.unions.get(inner.unionId); + if (!substituted?.arms.length) return null; + payloads.push(...substituted.arms); + continue; + } + payloads.push(inner); + } + if (dynamic) { + if (!payloads.every((arm) => dynSubsumableUnionArm(arm, ctx))) return null; + payloads.length = 0; + payloads.push(DYN); + } + const byKey = new Map(); + for (const arm of payloads) { + // A promise payload has no arm home beside the others: `await` of the + // merged promise would adopt it as a thenable, which no union tag does. + if (arm.kind === "promise") return null; + byKey.set(typeKey(arm), arm); + } + const payload = [...byKey.values()]; + if (payload.length === 0) return null; + // Every arm is the undefined unit: the promise never settles a value, + // exactly `Promise`. + if (payload.every((arm) => arm.kind === "undefinedT")) payload.length = 0; + // One payload arm is that arm itself (a regex or a promise payload has a + // promise slot but no union arm); a JOINT payload is a union, so its arms + // need union homes. + const inner: IrType | null = + payload.length === 0 + ? VOID + : payload.length === 1 + ? payload[0]! + : payload.every((arm) => armHasUnionHome(arm)) + ? { + kind: "union", + unionId: ctx.unions.intern( + [...payload].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)), + ), + } + : null; + const merged: IrType | null = inner === null ? null : { kind: "promise", inner }; + return merged === null ? null : [...arms.filter((arm) => arm.kind !== "promise"), merged]; +} + /** The first non-promise constituent of a promise-armed union whose type * has a `then` member (a thenable data arm), or null. */ function maybePromiseThenableArm(parts: readonly ts.Type[], ctx: TypeMapperCtx): ts.Type | null { @@ -9025,7 +9122,7 @@ export function describeComponentBlocker(widened: ts.Type, ctx: TypeMapperCtx): } const promiseArms = mappedArms.filter(({ mapped }) => mapped?.kind === "promise"); if (promiseArms.length > 1) { - return `the union shape is supported, but it has several promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test)`; + return `the union shape is supported, but its promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}') do not merge into one promise (their payloads have no common union layout)`; } if (promiseArms.length === 1 && dataArms.length > 1) { const callable = mappedArms.find(({ mapped }) => mapped?.kind === "func"); diff --git a/packages/compiler/src/ir/ir.test.ts b/packages/compiler/src/ir/ir.test.ts index 30fe6f50e..244fa8ec5 100644 --- a/packages/compiler/src/ir/ir.test.ts +++ b/packages/compiler/src/ir/ir.test.ts @@ -128,7 +128,9 @@ describe("checked records with opaque payloads", () => { id: "unsafe", fields: [ { name: "child", type: { kind: "record", shapeId: "unsafe" } }, - { name: "map", type: { kind: "map", key: STRING, value: F64 } }, + // The back-edge answers coinductively; this sibling is what makes + // the shape uncheckable. + { name: "payload", type: { kind: "caught" } }, ], }, ], @@ -255,6 +257,150 @@ describe("native process stream checked storage", () => { }); }); +describe("native promise checked storage", () => { + test("preserves promise fields, elements and union arms without claiming JSON storage", () => { + const promise: IrType = { kind: "promise", inner: STRING }; + const openPromise: IrType = { kind: "promise", inner: DYN }; + const settled: IrType = { kind: "promise", inner: { kind: "void" } }; + const reload: IrType = { kind: "record", shapeId: "reload" }; + const optional: IrType = { kind: "union", unionId: "optionalReload" }; + const record = (id: string): IrRecordShape | undefined => + id === "reload" + ? { + id, + fields: [ + { name: "readers", type: F64 }, + { name: "promise", type: promise }, + ], + } + : undefined; + const union = (id: string): IrUnionDef | undefined => + id === "optionalReload" ? { id, arms: [reload, { kind: "undefinedT" }] } : undefined; + for (const type of [ + promise, + openPromise, + settled, + arrayOf(promise), + { kind: "map", key: STRING, value: promise } as IrType, + reload, + optional, + arrayOf(optional), + ]) { + expect(canConvertToDyn(type, record, union)).toBe(true); + expect(canDynCheckTo(type, record, union)).toBe(true); + expect(isJsonSafeType(type, record, union)).toBe(false); + expect(isJsonStringifySafeType(type, record, union)).toBe(false); + } + // A promise of a value with no dynamic representation stays outside the + // conversion domain, including when it sits in a record field. + const blocked: IrType = { kind: "promise", inner: arrayOf({ kind: "procStream" }) }; + expect(canConvertToDyn(blocked, record, union)).toBe(false); + expect(canConvertToDyn(arrayOf(blocked), record, union)).toBe(false); + expect( + canConvertToDyn( + { kind: "record", shapeId: "blockedReload" }, + (id) => + id === "blockedReload" ? { id, fields: [{ name: "promise", type: blocked }] } : undefined, + () => undefined, + ), + ).toBe(false); + }); +}); + +describe("recursive checked graphs", () => { + // A context whose method takes an options object whose callback receives + // the same context: the check domain re-enters the record through a + // checked slot, as a callback-options API does. + const records = new Map([ + [ + "context", + { + id: "context", + fields: [ + { + name: "fork", + type: { + kind: "func", + params: [STRING, { kind: "union", unionId: "options" }], + ret: { kind: "promise", inner: { kind: "record", shapeId: "result" } }, + }, + }, + ], + }, + ], + [ + "options", + { + id: "options", + fields: [ + { + name: "withSession", + type: { + kind: "func", + params: [{ kind: "record", shapeId: "context" }], + ret: { kind: "promise", inner: { kind: "void" } }, + }, + }, + ], + }, + ], + ["result", { id: "result", fields: [{ name: "cancelled", type: { kind: "bool" } }] }], + [ + "unsafeContext", + { + id: "unsafeContext", + fields: [ + { + name: "fork", + type: { + kind: "func", + params: [{ kind: "record", shapeId: "unsafeOptions" }], + ret: { kind: "void" }, + }, + }, + ], + }, + ], + [ + "unsafeOptions", + { + id: "unsafeOptions", + fields: [ + { + name: "withSession", + type: { + kind: "func", + params: [{ kind: "record", shapeId: "unsafeContext" }], + ret: { kind: "void" }, + }, + }, + { name: "body", type: { kind: "caught" } }, + ], + }, + ], + ]); + const unions = new Map([ + [ + "options", + { id: "options", arms: [{ kind: "record", shapeId: "options" }, { kind: "undefinedT" }] }, + ], + ]); + const record = (id: string): IrRecordShape | undefined => records.get(id); + const union = (id: string): IrUnionDef | undefined => unions.get(id); + + test("answers a recursion back-edge coinductively in both directions", () => { + const context: IrType = { kind: "record", shapeId: "context" }; + expect(canConvertToDyn(context, record, union)).toBe(true); + expect(canDynCheckTo(context, record, union)).toBe(true); + expect(canDynCheckTo({ kind: "union", unionId: "options" }, record, union)).toBe(true); + }); + + test("a failing field inside the cycle still refuses it", () => { + expect(canDynCheckTo({ kind: "record", shapeId: "unsafeContext" }, record, union)).toBe(false); + expect(canDynCheckTo({ kind: "record", shapeId: "unsafeOptions" }, record, union)).toBe(false); + }); +}); + describe("checked conversion graph traversal", () => { test.each([ { label: "boxing", convert: canConvertToDyn }, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 7200da6dd..6946ad1cf 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -4572,6 +4572,17 @@ function canBoxDynCompositeAt( return true; case "func": return canBoxFuncIntoDyn(t, getRecord, getUnion, visiting); + case "promise": + // Promises box by REFERENCE wherever the walker meets them, nested in + // records, arrays and unions exactly like a bare promise: promise + // carries its ScrPromise, a void payload is absent, and any other inner + // converts through the adapter promise toDynHelper emits + // (scr_dyn_new_promise_adapting). + return ( + t.inner.kind === "dyn" || + t.inner.kind === "void" || + canConvertToDyn(t.inner, getRecord, getUnion, visiting) + ); case "set": return ( canConvertToDyn(t.elem, getRecord, getUnion, visiting) && @@ -4651,6 +4662,13 @@ export function canDynCheckTo( return result; } +/** The in-progress key of a checked composite. Marking it for the duration of + * the frame answers a recursion back-edge coinductively, and the wrapper + * keeps it as the success memo once the frame returns true. */ +function checkedVisitKey(t: IrType): string { + return `checked:${typeKey(t)}`; +} + /** Reuse completed subgraphs within one conversion query. A failed branch * invalidates successes that may depend on a coinductive back-edge to it; * active recursion keys remain until their owning calls unwind. */ @@ -4690,8 +4708,8 @@ function canDynCheckToAt( if (isDynTypedRefType(t)) return true; if (t.kind === "array") { if (t.elem.kind === "procStream") return false; - const key = typeKey(t); - if (visiting.has(key)) return false; + const key = checkedVisitKey(t); + if (visiting.has(key)) return true; visiting.add(key); const result = canDynCheckTo(t.elem, getRecord, getUnion, visiting); visiting.delete(key); @@ -4700,8 +4718,14 @@ function canDynCheckToAt( // A codec handle unboxes to the record it holds. if (textCodecRecordClass(t, getRecord) !== null) return true; if (t.kind === "record") { - const key = typeKey(t); - if (visiting.has(key)) return false; + // Recursive shapes answer COINDUCTIVELY, like the composite dynFrom + // walk: assume the back-edge checks and verify every reachable field at + // its first visit. A recursion through a checked slot is ordinary in a + // callback-options graph — a method's options object takes a context + // whose methods take the same options — and refusing the back-edge + // would fence types whose every leaf is checkable. + const key = checkedVisitKey(t); + if (visiting.has(key)) return true; const shape = getRecord(t.shapeId); if (!shape || shapeHasAccessorSlots(shape)) return false; visiting.add(key); diff --git a/packages/compiler/test/class-structural-views.test.ts b/packages/compiler/test/class-structural-views.test.ts index 328a724f7..008d7d92f 100644 --- a/packages/compiler/test/class-structural-views.test.ts +++ b/packages/compiler/test/class-structural-views.test.ts @@ -85,3 +85,87 @@ handler.press?.(); `); expect(kinds["handler"]).toBe("record"); }); + +test("a named alias of Pick over a class stays a live view", async () => { + const kinds = await globalKinds(` +class Counter { + count = 0; + tick(): number { this.count += 1; return this.count; } + peek(): number { return this.count; } +} +type Tickable = Pick; +const counter = new Counter(); +const view: Tickable = counter; +console.log(view.tick(), counter.count); +`); + expect(kinds["view"]).toBe("dyn"); + expect(kinds["counter"]).toBe("object"); +}); + +test("a named alias of Omit over a class stays a live view", async () => { + const kinds = await globalKinds(` +class Counter { + count = 0; + tick(): number { this.count += 1; return this.count; } + peek(): number { return this.count; } +} +type Peekable = Omit; +const counter = new Counter(); +const view: Peekable = counter; +console.log(view.peek()); +`); + expect(kinds["view"]).toBe("dyn"); +}); + +test("an alias of an alias of Pick over a class stays a live view", async () => { + const kinds = await globalKinds(` +class Counter { + count = 0; + tick(): number { this.count += 1; return this.count; } +} +type Tickable = Pick; +type AlsoTickable = Tickable; +const counter = new Counter(); +const view: AlsoTickable = counter; +console.log(view.tick()); +`); + expect(kinds["view"]).toBe("dyn"); +}); + +test("a Map received as a record with one of its members stays the collection", async () => { + const kinds = await globalKinds(` +type Exists = { has(key: string): boolean }; +const byId = new Map(); +const view: Exists = byId; +console.log(view.has("a"), byId.has("a")); +`); + expect(kinds["view"]).toBe("dyn"); +}); + +test("a Set received as a record with one of its members stays the collection", async () => { + const kinds = await globalKinds(` +type Exists = { has(key: string): boolean }; +const seen = new Set(); +const view: Exists = seen; +console.log(view.has("a"), seen.has("a")); +`); + expect(kinds["view"]).toBe("dyn"); +}); + +test("an interface that extends Error received from an error stays the error", async () => { + const kinds = await globalKinds(` +interface HttpError extends Error { + status: number | undefined; + body: string; +} +function fail(): HttpError { + const error = new Error("x") as HttpError; + error.status = 500; + error.body = "b"; + return error; +} +const failure = fail(); +console.log(failure.status, failure instanceof Error); +`); + expect(kinds["failure"]).toBe("dyn"); +}); diff --git a/packages/compiler/test/iterable-union-forof.test.ts b/packages/compiler/test/iterable-union-forof.test.ts new file mode 100644 index 000000000..c4449992e --- /dev/null +++ b/packages/compiler/test/iterable-union-forof.test.ts @@ -0,0 +1,116 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; +import type { IrModule } from "../src/ir/ir.js"; + +/** Lower one program to IR; a fence answers its diagnostic codes instead. */ +async function lower( + source: string, +): Promise<{ ok: true; module: IrModule } | { ok: false; codes: string[] }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-iterable-union-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) return { ok: false, codes: result.diagnostics.map((d) => d.code) }; + return { ok: true, module: deserializeModule(await readFile(outPath, "utf8")) }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** Every IR node kind present in the module, as a set. */ +function nodeKinds(module: IrModule): Set { + const kinds = new Set(); + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (value === null || typeof value !== "object") return; + const node = value as { kind?: unknown }; + if (typeof node.kind === "string") kinds.add(node.kind); + for (const child of Object.values(value)) visit(child); + }; + visit(module.functions); + return kinds; +} + +test("a for-of over an iterable beside never[] walks the iterable on its tag", async () => { + const result = await lower(` +function setTotal(registry: Map>, key: string): number { + let total = 0; + for (const name of registry.get(key) ?? []) total += name.length; + return total; +} +function mapTotal(registry: Map>, key: string): number { + let total = 0; + for (const [name, value] of registry.get(key) ?? []) total += name.length + value; + return total; +} +function stringTotal(registry: Map, key: string): number { + let total = 0; + for (const ch of registry.get(key) ?? []) total += ch.length; + return total; +} +console.log(setTotal(new Map(), "k"), mapTotal(new Map(), "k"), stringTotal(new Map(), "k")); +export {}; +`); + if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`); + expect(validateModule(result.module)).toEqual([]); + const kinds = nodeKinds(result.module); + // The loop is guarded by the arm's tag; the empty-array arm yields nothing. + expect(kinds.has("unionIsTag")).toBe(true); + expect(kinds.has("unionNarrow")).toBe(true); +}); + +test("a for-of over never[] beside an effectful receiver evaluates it once", async () => { + const result = await lower(` +function registry(): Map> | undefined { + return new Map(); +} +function total(): number { + let sum = 0; + for (const name of registry() ?? []) sum += name.length; + return sum; +} +console.log(total()); +export {}; +`); + if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`); + expect(validateModule(result.module)).toEqual([]); + // The tag test and the walk share ONE evaluation: the receiver is a call, + // not a plain read, so it lands in a hidden local both reads use. + let calls = 0; + const count = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) count(item); + return; + } + if (value === null || typeof value !== "object") return; + const node = value as { kind?: unknown; callee?: unknown }; + if (node.kind === "call" && node.callee === "registry") calls++; + for (const child of Object.values(value)) count(child); + }; + count(result.module.functions); + expect(calls).toBe(1); +}); + +test("a union of two inhabited iterables still has no common iteration", async () => { + const result = await lower(` +function total(registry: Map | string[]>, key: string): number { + let sum = 0; + for (const item of registry.get(key) ?? []) sum += item.length; + return sum; +} +console.log(total(new Map(), "k")); +export {}; +`); + // Both arms can hold values, and the two walks yield at different element + // kinds through different protocols: the union keeps its fence. + expect(result.ok).toBe(false); + if (!result.ok) expect(result.codes).toEqual(["SC2001"]); +}); diff --git a/packages/compiler/test/map-closure-elements.test.ts b/packages/compiler/test/map-closure-elements.test.ts new file mode 100644 index 000000000..2043b2bce --- /dev/null +++ b/packages/compiler/test/map-closure-elements.test.ts @@ -0,0 +1,74 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; +import type { IrModule } from "../src/ir/ir.js"; + +/** Lower one program to IR; a fence answers its diagnostic codes instead. */ +async function lower( + source: string, +): Promise<{ ok: true; module: IrModule } | { ok: false; codes: string[] }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-map-closure-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) return { ok: false, codes: result.diagnostics.map((d) => d.code) }; + return { ok: true, module: deserializeModule(await readFile(outPath, "utf8")) }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("a map callback answering a closure produces an array of functions", async () => { + const result = await lower(` +interface Entry { source: string; scope: "user" | "project" } +function tasksFor(entries: Entry[]): Array<() => Promise> { + return entries.map((entry) => async () => \`\${entry.scope}:\${entry.source}\`); +} +function readers(entries: Entry[]): (() => string)[] { + return entries.map((entry) => () => entry.source); +} +console.log(tasksFor([]).length, readers([]).length); +export {}; +`); + if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`); + expect(validateModule(result.module)).toEqual([]); + // The element kind is the closure type itself: a function value is one + // ScrClosure stored by reference, like a closure array literal. + for (const name of ["tasksFor", "readers"]) { + const fn = result.module.functions.find((candidate) => candidate.name === name); + expect(fn, name).toBeDefined(); + expect(fn!.returnType.kind, name).toBe("array"); + if (fn!.returnType.kind === "array") expect(fn!.returnType.elem.kind, name).toBe("func"); + } +}); + +test("an inline map result of closures stays inline", async () => { + const result = await lower(` +interface Entry { source: string } +const entries: Entry[] = [{ source: "a" }]; +const first = entries.map((entry) => (() => entry.source.length)); +console.log(first[0]!()); +export {}; +`); + if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`); + expect(validateModule(result.module)).toEqual([]); +}); + +test("a void-returning map callback still has no element to store", async () => { + const result = await lower(` +const items = [1, 2]; +const out = items.map((n) => { + console.log(n); +}); +console.log(out.length); +export {}; +`); + // `void[]` stays unrepresentable: a void value is erased before it could + // be written. Only the CLOSURE element became an ordinary element. + expect(result.ok).toBe(false); + if (!result.ok) expect(result.codes).toEqual(["SC2009"]); +}); diff --git a/packages/compiler/test/promise-union-widening.test.ts b/packages/compiler/test/promise-union-widening.test.ts index 36813b41d..cc9e2adef 100644 --- a/packages/compiler/test/promise-union-widening.test.ts +++ b/packages/compiler/test/promise-union-widening.test.ts @@ -77,16 +77,62 @@ test("an equal promise arm stays a plain copy", async () => { expect(promisePayloadConversions(retag)).toBe(0); }); -test("two destination promise arms keep the conversion fence", async () => { +test("two destination promise arms merge into one widened payload", async () => { + // `Promise | Promise` is `Promise`: the + // checked-dynamic payload absorbs the faithful one, so the destination is + // the same one-promise-arm union the single-arm case has. const result = await compileSource(` function settle(flag: boolean): Promise | undefined { return flag ? Promise.resolve() : undefined; } - const ambiguous: Promise | Promise | undefined = settle(true); - console.log(ambiguous === undefined); + const merged: Promise | Promise | undefined = settle(true); + console.log(merged === undefined); export {}; `); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.diagnostics.map((d) => d.code)).toContain("SC2003"); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const retag = result.module.functions.filter((fn) => fn.name.startsWith("%union.retag.")); + expect(retag).toHaveLength(1); + expect(promisePayloadConversions(retag)).toBe(1); +}); + +test("several promise arms settle to one promise of the payload union", async () => { + const result = await compileSource(` + interface Decision { kind: "stop"; note: string } + interface Settled { reason: string } + function pick(n: number): void | Promise | Promise | Decision { + if (n === 0) return; + if (n === 1) return Promise.resolve({ reason: "one" }); + return { kind: "stop", note: "x" }; + } + async function main(): Promise { + const hit = pick(1); + if (hit instanceof Promise) console.log("then", await hit.then((v) => v === undefined)); + const decided = await pick(2); + console.log("decision", decided === undefined ? "?" : "note" in decided ? decided.note : "?"); + } + void main(); + export {}; + `); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const unions = result.module.unions ?? []; + // The IR invariant the merge establishes: no union holds two promise arms + // (the promise arm's tag IS the `instanceof Promise` test). + for (const union of unions) + expect(union.arms.filter((arm) => arm.kind === "promise").length).toBeLessThan(2); + // The declared union is the promise beside the undefined unit. + const armed = unions.filter((union) => union.arms.some((arm) => arm.kind === "promise")); + expect(armed.length).toBeGreaterThan(0); + const promiseArm = armed[0]!.arms.find((arm) => arm.kind === "promise"); + expect(promiseArm?.kind).toBe("promise"); + // `void | Promise | Promise` merges to a payload UNION: + // the settled record beside the undefined unit. + if (promiseArm?.kind === "promise") { + expect(promiseArm.inner.kind).toBe("union"); + if (promiseArm.inner.kind === "union") { + const payload = unions.find((union) => union.id === promiseArm.inner.unionId); + expect(payload?.arms.map((arm) => arm.kind).sort()).toEqual(["record", "undefinedT"]); + } + } }); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6955-pick-alias-class-views.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6955-pick-alias-class-views.ts.json new file mode 100644 index 000000000..340caf90a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6955-pick-alias-class-views.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6955-pick-alias-class-views.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6956-collection-record-views.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6956-collection-record-views.ts.json new file mode 100644 index 000000000..420a66523 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6956-collection-record-views.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6956-collection-record-views.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6957-error-interface-views.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6957-error-interface-views.ts.json new file mode 100644 index 000000000..0518102dc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6957-error-interface-views.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6957-error-interface-views.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8417-promise-union-arms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8417-promise-union-arms.ts.json new file mode 100644 index 000000000..77516bf48 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8417-promise-union-arms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8417-promise-union-arms.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8418-map-closure-elements.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8418-map-closure-elements.ts.json new file mode 100644 index 000000000..6e258b55d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8418-map-closure-elements.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8418-map-closure-elements.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8419-iterable-never-array-arm.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8419-iterable-never-array-arm.ts.json new file mode 100644 index 000000000..c4335540f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8419-iterable-never-array-arm.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8419-iterable-never-array-arm.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8430-dyn-record-promise-field.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8430-dyn-record-promise-field.ts.json new file mode 100644 index 000000000..e4e86badf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8430-dyn-record-promise-field.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8430-dyn-record-promise-field.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8431-dyn-recursive-checked-record.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8431-dyn-recursive-checked-record.ts.json new file mode 100644 index 000000000..9700350a3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8431-dyn-recursive-checked-record.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8431-dyn-recursive-checked-record.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6955-pick-alias-class-views.ts b/tests/corpus/6955-pick-alias-class-views.ts new file mode 100644 index 000000000..1d472d9c0 --- /dev/null +++ b/tests/corpus/6955-pick-alias-class-views.ts @@ -0,0 +1,61 @@ +// A named type alias of `Pick` / `Omit` over a program class is +// the same view as the inline form: calls through the alias run the +// instance's own methods, so its state is shared with every other reference. +class Session { + reads = 0; + id: string; + constructor(id: string) { + this.id = id; + } + read(): string { + this.reads += 1; + return this.id + ":" + this.reads; + } + reset(): void { + this.reads = 0; + } + count(): number { + return this.reads; + } +} + +type Readable = Pick; +type Resettable = Omit; + +function twice(view: Readable): string { + return view.read() + "/" + view.read(); +} + +const session = new Session("a"); +console.log(twice(session), session.reads); + +// The alias names a live view: writes to the instance show through it. +const readable: Readable = session; +session.reads = 10; +console.log(readable.read(), session.reads); +console.log((readable as unknown as Session) === session); + +// Omit over the same class, through a named alias. +function reset(view: Resettable): number { + view.reset(); + return view.count(); +} +console.log(reset(session)); + +// An alias of an alias resolves to the same view. +type AlsoReadable = Readable; +function once(view: AlsoReadable): string { + return view.read(); +} +console.log(once(session), session.reads); + +// Another class with the view's members flows through it the same way. +class Token { + reads = 0; + read(): string { + this.reads += 1; + return "t" + this.reads; + } +} +const token = new Token(); +console.log(twice(token), token.reads); diff --git a/tests/corpus/6956-collection-record-views.ts b/tests/corpus/6956-collection-record-views.ts new file mode 100644 index 000000000..4707957dd --- /dev/null +++ b/tests/corpus/6956-collection-record-views.ts @@ -0,0 +1,64 @@ +// A Map or Set handed to a record type that spells one of its members stays +// the collection itself: later writes are visible through the view, and the +// view casts back to the same collection. +type Exists = { has(key: string): boolean }; + +function freeId(existing: Exists): string { + for (let i = 0; i < 5; i++) { + const id = "id" + i; + if (!existing.has(id)) return id; + } + return "none"; +} + +const byId = new Map(); +byId.set("id0", 1); +console.log(freeId(byId)); +byId.set("id1", 2); +console.log(freeId(byId)); + +const seen = new Set(); +seen.add("id0"); +seen.add("id1"); +seen.add("id2"); +console.log(freeId(seen)); + +// The view is live: entries added after the conversion are visible. +const view: Exists = byId; +byId.set("id3", 3); +console.log(view.has("id3"), byId.has("id3")); +console.log((view as unknown as Map) === byId); +const setView: Exists = seen; +seen.add("id3"); +console.log(setView.has("id3"), (setView as unknown as Set) === seen); + +// A record literal of the same shape keeps working. +console.log(freeId({ has: (key: string) => key === "id0" })); + +// A member Map answers without a stored field. +type Sized = { readonly size: number }; +const sized: Sized = byId; +byId.set("id4", 4); +console.log(sized.size, byId.size); + +// A view handed on to a second structural type is still the collection: the +// second view's calls answer through it, and later writes stay visible. +type Lookup = { get(key: string): number | undefined }; +type Table = { get(key: string): number | undefined; has(key: string): boolean }; +function viaLookup(table: Lookup, key: string): number { + return table.get(key) ?? -1; +} +function viaTable(table: Table, key: string): number { + return table.has(key) ? viaLookup(table, key) : -2; +} +console.log(viaTable(byId, "id1"), viaTable(byId, "nope")); +byId.set("id5", 5); +console.log(viaTable(byId, "id5")); +type Member = { has(key: string): boolean }; +function viaMember(member: Member, key: string): boolean { + return member.has(key); +} +function viaExists(existing: Exists, key: string): boolean { + return viaMember(existing, key); +} +console.log(viaExists(seen, "id3"), viaExists(seen, "id9")); diff --git a/tests/corpus/6957-error-interface-views.ts b/tests/corpus/6957-error-interface-views.ts new file mode 100644 index 000000000..518d94153 --- /dev/null +++ b/tests/corpus/6957-error-interface-views.ts @@ -0,0 +1,42 @@ +// An Error asserted to an interface that extends Error keeps the error +// itself: the extra fields live on the error object, and identity survives the +// conversion in both directions. +interface HttpError extends Error { + status: number | undefined; + body: string; +} + +function httpError(label: string, status: number, body: string): HttpError { + const error = new Error(label + " failed") as HttpError; + error.status = status; + error.body = body; + return error; +} + +const failure = httpError("GET", 500, "nope"); +console.log(failure.message, failure.name, failure.status, failure.body); +console.log(failure instanceof Error); +console.log(typeof failure.stack); + +function timeout(): HttpError { + const error = new Error("timed out") as HttpError; + error.name = "TimeoutError"; + error.status = undefined; + error.body = ""; + return error; +} +const late = timeout(); +console.log(late.message, late.name, late.status, late.body === ""); + +// Passing the error through a function returns the same object. +function same(error: HttpError): HttpError { + return error; +} +const original = new Error("x") as HttpError; +original.status = 1; +original.body = "b"; +console.log(same(original) === original, original.status, original.body); + +// The error crosses out to unknown and back, still the same object. +const widened: unknown = failure; +console.log((widened as HttpError) === failure); diff --git a/tests/corpus/8417-promise-union-arms.ts b/tests/corpus/8417-promise-union-arms.ts new file mode 100644 index 000000000..7a8484089 --- /dev/null +++ b/tests/corpus/8417-promise-union-arms.ts @@ -0,0 +1,74 @@ +// Several promise arms in one union: `void | Promise | Promise | +// Decision` is one promise arm `Promise` — `instanceof +// Promise` is true for either arm and `await` settles to the payload union. +interface Decision { + kind: "stop" | "continue"; + note: string; +} + +interface Settled { + reason: string; +} + +type TurnResult = void | Promise | Promise | Decision; + +function pick(n: number): TurnResult { + if (n === 0) return; + if (n === 1) return Promise.resolve({ reason: "one" }); + if (n === 2) return Promise.resolve(); + return { kind: "continue", note: "x" }; +} + +function fail(): void | Promise { + return Promise.reject(new Error("boom")); +} + +class Agent { + finishTurn?: (turn: number) => TurnResult; + + async turn(n: number): Promise { + const hook = this.finishTurn; + if (!hook) return "none"; + const out = await hook(n); + if (out === undefined) return "void"; + if ("note" in out) return `decision:${out.note}`; + return `settled:${out.reason}`; + } +} + +async function main(): Promise { + // A promise stored in the union is the same object: the payload + // conversion never re-boxes it. + const original = Promise.resolve({ reason: "one" }); + const stored: TurnResult = original; + console.log("identity", stored === original); + + const hit = pick(1); + console.log("promise", hit instanceof Promise, pick(3) instanceof Promise); + if (hit instanceof Promise) { + const settled = await hit; + console.log("narrowed", settled === undefined ? "void" : "object"); + console.log("then", await hit.then((value) => (value === undefined ? "u" : "s"))); + } + + console.log("void", (await pick(0)) === undefined); + const settled = await pick(1); + console.log("awaited", typeof settled === "object" && "reason" in settled ? settled.reason : "?"); + const decided = await pick(3); + console.log("decision", typeof decided === "object" && "note" in decided ? decided.note : "?"); + + const empty = pick(2); + if (empty instanceof Promise) await empty.then(() => console.log("then void")); + + try { + await fail(); + } catch (error) { + console.log("rejected", (error as Error).message); + } + + const agent = new Agent(); + console.log("no hook", await agent.turn(1)); + agent.finishTurn = pick; + console.log("hook", await agent.turn(1), await agent.turn(3), await agent.turn(0)); +} +void main(); diff --git a/tests/corpus/8418-map-closure-elements.ts b/tests/corpus/8418-map-closure-elements.ts new file mode 100644 index 000000000..c2b874f35 --- /dev/null +++ b/tests/corpus/8418-map-closure-elements.ts @@ -0,0 +1,38 @@ +// `Array.prototype.map` whose callback answers a CLOSURE: the result is an +// array of functions (`Array<() => Promise>`, the runWithConcurrency +// task-list shape), each element one ScrClosure stored by reference. +interface Entry { + source: string; + scope: "user" | "project"; +} + +function tasksFor(entries: Entry[]): Array<() => Promise> { + return entries.map((entry) => async () => `${entry.scope}:${entry.source}`); +} + +function same(entries: Entry[]): Array<() => string> { + return entries.map((entry) => () => entry.source); +} + +async function main(): Promise { + const entries: Entry[] = [ + { source: "a", scope: "user" }, + { source: "b", scope: "project" }, + ]; + const tasks = tasksFor(entries); + for (const task of tasks) console.log("task", await task()); + + const readers = same(entries); + // A function value is one closure for its whole life: identity through + // indexOf/includes holds. + const first = readers[0]; + console.log("identity", readers.indexOf(first!), readers.includes(first!)); + if (first) console.log("called", first()); + + const inline = entries.map((entry) => () => entry.source.length); + console.log("inline", inline.map((read) => read()).join(",")); + + const results = await Promise.all(tasks.map((task) => task())); + console.log("all", results.join(",")); +} +void main(); diff --git a/tests/corpus/8419-iterable-never-array-arm.ts b/tests/corpus/8419-iterable-never-array-arm.ts new file mode 100644 index 000000000..71d98585c --- /dev/null +++ b/tests/corpus/8419-iterable-never-array-arm.ts @@ -0,0 +1,59 @@ +// `for (const x of registry.get(key) ?? [])`: the receiver's type is the +// iterable beside `never[]` — the array literal's type, whose only value is +// the empty array, so that arm runs zero iterations. +function setLength(registry: Map>, key: string): number { + let total = 0; + for (const name of registry.get(key) ?? []) total += name.length; + return total; +} + +function mapTotal(registry: Map>, key: string): number { + let total = 0; + for (const [name, value] of registry.get(key) ?? []) total += name.length + value; + return total; +} + +function stringLength(registry: Map, key: string): number { + let total = 0; + for (const ch of registry.get(key) ?? []) total += ch.length; + return total; +} + +function main(): void { + const sets = new Map>(); + sets.set("a", new Set(["xy", "z"])); + console.log("set", setLength(sets, "a"), setLength(sets, "missing")); + sets.get("a")?.add("q"); + console.log("set live", setLength(sets, "a")); + + const maps = new Map>(); + maps.set("a", new Map([["xy", 3]])); + console.log("map", mapTotal(maps, "a"), mapTotal(maps, "missing")); + + const strings = new Map(); + strings.set("a", "xyz"); + console.log("string", stringLength(strings, "a"), stringLength(strings, "missing")); + + // The same shape through an explicit union annotation. + const empty: Set | never[] = sets.get("missing") ?? []; + let seen = 0; + for (const item of empty) seen += item.length; + console.log("annotated", seen); + + // The head runs once: a lookup with a side effect is called one time, and + // the walk iterates the value the tag test saw. + let calls = 0; + const alternate = (): Set | undefined => (++calls % 2 === 1 ? sets.get("a") : undefined); + let walked = 0; + for (const item of alternate() ?? []) walked += item.length; + console.log("once", calls, walked); + let mapCalls = 0; + const mapLookup = (): Map | undefined => { + mapCalls++; + return maps.get("a"); + }; + let mapWalked = 0; + for (const [name, value] of mapLookup() ?? []) mapWalked += name.length + value; + console.log("map once", mapCalls, mapWalked); +} +main(); diff --git a/tests/corpus/8430-dyn-record-promise-field.ts b/tests/corpus/8430-dyn-record-promise-field.ts new file mode 100644 index 000000000..72a210c50 --- /dev/null +++ b/tests/corpus/8430-dyn-record-promise-field.ts @@ -0,0 +1,71 @@ +// A record whose field is a Promise converts into a checked-dynamic slot and +// back. The promise boxes by reference (SCR_DYN_PROMISE), so a round trip +// keeps the awaited payload and the promise's identity, exactly as in Node. +// A method answering the read state of a model store is the shape that raised +// SC1101: `reload?: { readers: number; promise: Promise }`. + +interface Reload { + readers: number; + promise: Promise; +} +interface ReadState { + data: number; + revision?: string; + reload?: Reload; +} +interface Waiter { + label: string; + done: Promise; +} + +function summary(u: unknown): string { + const s = u as { data: number; revision?: string; reload?: { readers: number } }; + return `${s.data}/${s.revision ?? "-"}/${s.reload ? s.reload.readers : "-"}`; +} + +function reopen(u: unknown): ReadState { + return u as ReadState; +} + +function labels(us: unknown[]): string { + return us + .map((u) => (u as Waiter).label) + .join(","); +} + +async function main(): Promise { + const payload = Promise.resolve("ready"); + const state: ReadState = { + data: 7, + revision: "r1", + reload: { readers: 3, promise: payload }, + }; + console.log(summary(state)); + + // The checked conversion back keeps the promise itself. + const back: ReadState = reopen(state); + console.log(`same promise: ${back.reload?.promise === payload}`); + console.log(`payload: ${await back.reload?.promise}`); + + // Reading the promise off the dynamic view awaits the same payload. + const live = (state as unknown) as { reload: { promise: unknown } }; + console.log(`live payload: ${await live.reload.promise}`); + + // An absent field stays absent, and a present-but-void promise settles. + const bare: ReadState = { data: 1 }; + console.log(summary(bare), `no reload: ${bare.reload === undefined}`); + + let settled = false; + const waiters: Waiter[] = [ + { label: "a", done: Promise.resolve() }, + { label: "b", done: Promise.resolve() }, + ]; + waiters[0]!.done.then(() => { + settled = true; + }); + console.log(`waiters: ${labels(waiters)}`); + await waiters[0]!.done; + console.log(`settled: ${settled}`); +} + +main(); diff --git a/tests/corpus/8431-dyn-recursive-checked-record.ts b/tests/corpus/8431-dyn-recursive-checked-record.ts new file mode 100644 index 000000000..60e349bdb --- /dev/null +++ b/tests/corpus/8431-dyn-recursive-checked-record.ts @@ -0,0 +1,50 @@ +// A dynamic value crossing into a native record type that is recursive +// through a checked slot: a node's method answers the next node, so the +// checked domain re-enters the same record. The static check answers that +// back-edge coinductively and validates every reachable field, so the +// extraction compiles and the value behaves as in Node — including a Map +// field beside the recursion. + +interface Entry { + label: string; + counts: Map; + next(): Entry | undefined; +} + +function build(label: string): Entry { + return { + label, + counts: new Map([["hits", label.length]]), + next() { + return label.length > 1 ? build(label.slice(1)) : undefined; + }, + }; +} + +function coerce(value: any): Entry { + return value; +} + +function walk(entry: Entry | undefined): string { + const parts: string[] = []; + let cursor = entry; + while (cursor !== undefined) { + parts.push(`${cursor.label}:${cursor.counts.get("hits")}`); + cursor = cursor.next(); + } + return parts.join(">"); +} + +const root = build("abcd"); +const dynamic: unknown = root; +const entry = coerce(dynamic); +console.log(entry.label, entry.counts.get("hits")); +console.log(walk(entry)); +console.log(walk(entry.next()?.next())); + +// The extracted value keeps the Map semantics: a mutation is visible through +// the extracted record and a new key inserts. +entry.counts.set("extra", 1); +entry.counts.set("hits", entry.counts.get("hits")! + 1); +console.log(entry.counts.size, entry.counts.get("hits"), entry.counts.has("extra")); +console.log([...entry.counts.keys()].join(",")); diff --git a/tests/diagnostics/maybe-promise-fences.ts b/tests/diagnostics/maybe-promise-fences.ts index e6fdf98fa..c2381908a 100644 --- a/tests/diagnostics/maybe-promise-fences.ts +++ b/tests/diagnostics/maybe-promise-fences.ts @@ -10,11 +10,13 @@ function thenable(): Thenish | Promise { } console.log(typeof thenable); -// Two promise arms: the union tag cannot tell them apart by instanceof. -function twoPromises(): Promise | Promise | boolean { +// Promise arms merge into one promise of their payload union (`await` settles +// to either payload). They do not merge when a payload has no union arm home: +// a RegExp payload is the promise slot's own, not an arm. +function splitPromises(): Promise | Promise | boolean { return true; } -console.log(typeof twoPromises); +console.log(typeof splitPromises); // Promises have no inspect lowering, so neither does a union holding one. function maybe(b: boolean): string | Promise { diff --git a/tests/fixtures/npm/cases/js-own-tostring/main.ts b/tests/fixtures/npm/cases/js-own-tostring/main.ts index 9793b2baa..b572a7bfc 100644 --- a/tests/fixtures/npm/cases/js-own-tostring/main.ts +++ b/tests/fixtures/npm/cases/js-own-tostring/main.ts @@ -1,7 +1,8 @@ // A JavaScript package (the TypeScript namespace downlevel zod uses) whose // namespace object assigns its own `toString` returning string | undefined; // Number.parseInt with one argument; and a `then` whose handler result the -// declared union types (zod's ParseStatus.mergeArray pattern). +// declared union types (zod's ParseStatus.mergeArray pattern), over mixed and +// uniform promise arrays. import { check, direct, mergeLater, note, show, showRadix, wholePart } from "nsutil"; console.log(String(direct("a")), String(direct({ message: "b" })), String(direct(undefined))); @@ -12,5 +13,7 @@ console.log(values.map((value) => show(value)).join(" "), showRadix(5, 2), note. async function main(): Promise { console.log(JSON.stringify(await mergeLater([Promise.resolve(1), Promise.resolve("two")], { ok: true }))); console.log(JSON.stringify(await mergeLater([Promise.resolve(3), Promise.resolve("four")], { ok: false }))); + const uniform: Promise[] = [Promise.resolve(5), Promise.resolve(6)]; + console.log(JSON.stringify(await mergeLater(uniform, { ok: true }))); } void main(); diff --git a/tests/harness/__snapshots__/maybe-promise-fences.ts.txt b/tests/harness/__snapshots__/maybe-promise-fences.ts.txt index ab325aaaa..57a98ad2a 100644 --- a/tests/harness/__snapshots__/maybe-promise-fences.ts.txt +++ b/tests/harness/__snapshots__/maybe-promise-fences.ts.txt @@ -16,36 +16,36 @@ maybe-promise-fences.ts:11:20 - error SC2004: uses of 'thenable' inherit the blo hint: the declaration of 'thenable' did not compile — fix the diagnostic reported there and these sites clear with it -maybe-promise-fences.ts:14:10 - error SC2009: values of type 'boolean | Promise | Promise' cannot be compiled: the union shape is supported, but it has several promise arms ('Promise' and 'Promise'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test) +maybe-promise-fences.ts:16:10 - error SC2009: values of type 'boolean | Promise | Promise' cannot be compiled: the union shape is supported, but its promise arms ('Promise' and 'Promise') do not merge into one promise (their payloads have no common union layout) - 13 | // Two promise arms: the union tag cannot tell them apart by instanceof. - 14 | function twoPromises(): Promise | Promise | boolean { - | ^~~~~~~~~~~ - 15 | return true; + 15 | // a RegExp payload is the promise slot's own, not an arm. + 16 | function splitPromises(): Promise | Promise | boolean { + | ^~~~~~~~~~~~~ + 17 | return true; hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') -maybe-promise-fences.ts:17:20 - error SC2004: uses of 'twoPromises' inherit the blocker on its declaration +maybe-promise-fences.ts:19:20 - error SC2004: uses of 'splitPromises' inherit the blocker on its declaration - 16 | } - 17 | console.log(typeof twoPromises); - | ^~~~~~~~~~~ - 18 | + 18 | } + 19 | console.log(typeof splitPromises); + | ^~~~~~~~~~~~~ + 20 | - hint: the declaration of 'twoPromises' did not compile — fix the diagnostic reported there and these sites clear with it + hint: the declaration of 'splitPromises' did not compile — fix the diagnostic reported there and these sites clear with it -maybe-promise-fences.ts:23:13 - error SC1090: console.log of 'Promise | string' values ('Promise' values have no inspect lowering) are not supported yet +maybe-promise-fences.ts:25:13 - error SC1090: console.log of 'Promise | string' values ('Promise' values have no inspect lowering) are not supported yet - 22 | } - 23 | console.log(maybe(true)); + 24 | } + 25 | console.log(maybe(true)); | ^~~~~~~~~~~ - 24 | + 26 | -maybe-promise-fences.ts:27:50 - error SC2020: 'Promise.resolve over 'Promise | string' at this result type' is part of the standard library types but has no scriptc lowering yet +maybe-promise-fences.ts:29:50 - error SC2020: 'Promise.resolve over 'Promise | string' at this result type' is part of the standard library types but has no scriptc lowering yet - 26 | // must be that arm's own promise type. - 27 | const widened = Promise.resolve(maybe(false)); + 28 | // must be that arm's own promise type. + 29 | const widened = Promise.resolve(maybe(false)); | ^~~~~~~~~~~~ - 28 | console.log(typeof widened); + 30 | console.log(typeof widened); hint: the promise arm passes through identically, so it must already be the result's promise type \ No newline at end of file diff --git a/tests/harness/corpus-costs/6955-pick-alias-class-views.ts.json b/tests/harness/corpus-costs/6955-pick-alias-class-views.ts.json new file mode 100644 index 000000000..60d7b373e --- /dev/null +++ b/tests/harness/corpus-costs/6955-pick-alias-class-views.ts.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 5500 } diff --git a/tests/harness/corpus-costs/6956-collection-record-views.ts.json b/tests/harness/corpus-costs/6956-collection-record-views.ts.json new file mode 100644 index 000000000..60d7b373e --- /dev/null +++ b/tests/harness/corpus-costs/6956-collection-record-views.ts.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 5500 } diff --git a/tests/harness/corpus-costs/6957-error-interface-views.ts.json b/tests/harness/corpus-costs/6957-error-interface-views.ts.json new file mode 100644 index 000000000..60d7b373e --- /dev/null +++ b/tests/harness/corpus-costs/6957-error-interface-views.ts.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 5500 } diff --git a/tests/harness/corpus-costs/8417-promise-union-arms.ts.json b/tests/harness/corpus-costs/8417-promise-union-arms.ts.json new file mode 100644 index 000000000..ec6332e7a --- /dev/null +++ b/tests/harness/corpus-costs/8417-promise-union-arms.ts.json @@ -0,0 +1 @@ +{ "plain": 4400, "san": 5400 } diff --git a/tests/harness/corpus-costs/8418-map-closure-elements.ts.json b/tests/harness/corpus-costs/8418-map-closure-elements.ts.json new file mode 100644 index 000000000..98a0304aa --- /dev/null +++ b/tests/harness/corpus-costs/8418-map-closure-elements.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 4600 } diff --git a/tests/harness/corpus-costs/8419-iterable-never-array-arm.ts.json b/tests/harness/corpus-costs/8419-iterable-never-array-arm.ts.json new file mode 100644 index 000000000..578be4ce6 --- /dev/null +++ b/tests/harness/corpus-costs/8419-iterable-never-array-arm.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 4100 } diff --git a/tests/harness/corpus-costs/8430-dyn-record-promise-field.ts.json b/tests/harness/corpus-costs/8430-dyn-record-promise-field.ts.json new file mode 100644 index 000000000..f75782fb5 --- /dev/null +++ b/tests/harness/corpus-costs/8430-dyn-record-promise-field.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 21000 } diff --git a/tests/harness/corpus-costs/8431-dyn-recursive-checked-record.ts.json b/tests/harness/corpus-costs/8431-dyn-recursive-checked-record.ts.json new file mode 100644 index 000000000..a62da83ba --- /dev/null +++ b/tests/harness/corpus-costs/8431-dyn-recursive-checked-record.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 15300 }