mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
pi conversions and layouts: class/collection/error views, merged promise arms, recursive checked records (#2486)
This commit is contained in:
@@ -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<any[]>, 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<T | undefined>[] combines into a `(T | undefined)[]`, which
|
||||
// stores T elements (primitiveElement): collect the values, then copy
|
||||
// them in with their present undefined values.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ts.Type>();
|
||||
/** 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<ts.Symbol>();
|
||||
|
||||
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<string>): 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<string>): boolean {
|
||||
@@ -698,15 +719,67 @@ export class ClassProtocols {
|
||||
/** `Pick<C, K>` or `Omit<C, K>` 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<C, K>`), 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<C, K>`) 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<C, K>` / `Omit<C, K>` 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<string, V>`, `{ [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<ts.Symbol>();
|
||||
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<ts.Symbol> = 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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5419,25 +5419,46 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
) {
|
||||
return DYN;
|
||||
}
|
||||
// SEVERAL PROMISE ARMS are ONE: `Promise<A> | Promise<B>` is
|
||||
// `Promise<A | B>`. 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<A>` 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<void> | Promise<undefined>`
|
||||
// merges to the plain `Promise<void>`): 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<T> (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<A | B>`
|
||||
* (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<unknown>`) 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<string, IrType>();
|
||||
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<void>`.
|
||||
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");
|
||||
|
||||
@@ -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<string, IrRecordShape>([
|
||||
[
|
||||
"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<string, IrUnionDef>([
|
||||
[
|
||||
"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 },
|
||||
|
||||
@@ -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<dyn>
|
||||
// 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);
|
||||
|
||||
@@ -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<Counter, "tick">;
|
||||
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<Counter, "tick">;
|
||||
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<Counter, "tick">;
|
||||
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<string, number>();
|
||||
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<string>();
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
const kinds = new Set<string>();
|
||||
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<string, Set<string>>, key: string): number {
|
||||
let total = 0;
|
||||
for (const name of registry.get(key) ?? []) total += name.length;
|
||||
return total;
|
||||
}
|
||||
function mapTotal(registry: Map<string, Map<string, number>>, key: string): number {
|
||||
let total = 0;
|
||||
for (const [name, value] of registry.get(key) ?? []) total += name.length + value;
|
||||
return total;
|
||||
}
|
||||
function stringTotal(registry: Map<string, string>, 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<string, Set<string>> | 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, Set<string> | 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"]);
|
||||
});
|
||||
@@ -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<string>> {
|
||||
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"]);
|
||||
});
|
||||
@@ -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<unknown> | Promise<string>` is `Promise<unknown>`: 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<void> | undefined {
|
||||
return flag ? Promise.resolve() : undefined;
|
||||
}
|
||||
const ambiguous: Promise<unknown> | Promise<string> | undefined = settle(true);
|
||||
console.log(ambiguous === undefined);
|
||||
const merged: Promise<unknown> | Promise<string> | 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<Settled> | Promise<void> | Decision {
|
||||
if (n === 0) return;
|
||||
if (n === 1) return Promise.resolve({ reason: "one" });
|
||||
return { kind: "stop", note: "x" };
|
||||
}
|
||||
async function main(): Promise<void> {
|
||||
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<Settled> | Promise<void>` 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"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6955-pick-alias-class-views.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6956-collection-record-views.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6957-error-interface-views.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8417-promise-union-arms.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8418-map-closure-elements.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8419-iterable-never-array-arm.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8430-dyn-record-promise-field.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8431-dyn-recursive-checked-record.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// A named type alias of `Pick<C, K>` / `Omit<C, K>` 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<Session, "read">;
|
||||
type Resettable = Omit<Session, "read">;
|
||||
|
||||
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);
|
||||
@@ -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<string, number>();
|
||||
byId.set("id0", 1);
|
||||
console.log(freeId(byId));
|
||||
byId.set("id1", 2);
|
||||
console.log(freeId(byId));
|
||||
|
||||
const seen = new Set<string>();
|
||||
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<string, number>) === byId);
|
||||
const setView: Exists = seen;
|
||||
seen.add("id3");
|
||||
console.log(setView.has("id3"), (setView as unknown as Set<string>) === 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"));
|
||||
@@ -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);
|
||||
@@ -0,0 +1,74 @@
|
||||
// Several promise arms in one union: `void | Promise<A> | Promise<void> |
|
||||
// Decision` is one promise arm `Promise<A | undefined>` — `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<Settled> | Promise<void> | 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<Settled> {
|
||||
return Promise.reject(new Error("boom"));
|
||||
}
|
||||
|
||||
class Agent {
|
||||
finishTurn?: (turn: number) => TurnResult;
|
||||
|
||||
async turn(n: number): Promise<string> {
|
||||
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<void> {
|
||||
// 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();
|
||||
@@ -0,0 +1,38 @@
|
||||
// `Array.prototype.map` whose callback answers a CLOSURE: the result is an
|
||||
// array of functions (`Array<() => Promise<T>>`, 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<string>> {
|
||||
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<void> {
|
||||
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();
|
||||
@@ -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<string, Set<string>>, key: string): number {
|
||||
let total = 0;
|
||||
for (const name of registry.get(key) ?? []) total += name.length;
|
||||
return total;
|
||||
}
|
||||
|
||||
function mapTotal(registry: Map<string, Map<string, number>>, key: string): number {
|
||||
let total = 0;
|
||||
for (const [name, value] of registry.get(key) ?? []) total += name.length + value;
|
||||
return total;
|
||||
}
|
||||
|
||||
function stringLength(registry: Map<string, string>, 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<string, Set<string>>();
|
||||
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<string, Map<string, number>>();
|
||||
maps.set("a", new Map([["xy", 3]]));
|
||||
console.log("map", mapTotal(maps, "a"), mapTotal(maps, "missing"));
|
||||
|
||||
const strings = new Map<string, string>();
|
||||
strings.set("a", "xyz");
|
||||
console.log("string", stringLength(strings, "a"), stringLength(strings, "missing"));
|
||||
|
||||
// The same shape through an explicit union annotation.
|
||||
const empty: Set<string> | 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<string> | 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<string, number> | 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();
|
||||
@@ -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<StoredModels> }`.
|
||||
|
||||
interface Reload {
|
||||
readers: number;
|
||||
promise: Promise<string>;
|
||||
}
|
||||
interface ReadState {
|
||||
data: number;
|
||||
revision?: string;
|
||||
reload?: Reload;
|
||||
}
|
||||
interface Waiter {
|
||||
label: string;
|
||||
done: Promise<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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();
|
||||
@@ -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<string, number>;
|
||||
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(","));
|
||||
@@ -10,11 +10,13 @@ function thenable(): Thenish | Promise<number> {
|
||||
}
|
||||
console.log(typeof thenable);
|
||||
|
||||
// Two promise arms: the union tag cannot tell them apart by instanceof.
|
||||
function twoPromises(): Promise<number> | Promise<string> | 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<RegExp> | Promise<string> | 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<string> {
|
||||
|
||||
+4
-1
@@ -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<void> {
|
||||
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<number>[] = [Promise.resolve(5), Promise.resolve(6)];
|
||||
console.log(JSON.stringify(await mergeLater(uniform, { ok: true })));
|
||||
}
|
||||
void main();
|
||||
|
||||
@@ -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<string> | Promise<number>' cannot be compiled: the union shape is supported, but it has several promise arms ('Promise<string>' and 'Promise<number>'), 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<string> | Promise<RegExp>' cannot be compiled: the union shape is supported, but its promise arms ('Promise<string>' and 'Promise<RegExp>') 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<number> | Promise<string> | boolean {
|
||||
| ^~~~~~~~~~~
|
||||
15 | return true;
|
||||
15 | // a RegExp payload is the promise slot's own, not an arm.
|
||||
16 | function splitPromises(): Promise<RegExp> | Promise<string> | 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> | string' values ('Promise<string>' values have no inspect lowering) are not supported yet
|
||||
maybe-promise-fences.ts:25:13 - error SC1090: console.log of 'Promise<string> | string' values ('Promise<string>' 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> | 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> | 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<string | number>(maybe(false));
|
||||
28 | // must be that arm's own promise type.
|
||||
29 | const widened = Promise.resolve<string | number>(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
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 2600, "san": 5500 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 2600, "san": 5500 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 2600, "san": 5500 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 4400, "san": 5400 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 3100, "san": 4600 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 3000, "san": 4100 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 3400, "san": 21000 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 2900, "san": 15300 }
|
||||
Reference in New Issue
Block a user