diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 42cf40a392..788cbf5708 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -127,6 +127,11 @@ export const LIB_FN_SYMS: Record = { "str.encodeUriComponent": "scr_str_encode_uri_component", "error.cause": "scr_error_cause", "error.setCause": "scr_error_set_cause", + "error.setCode": "scr_error_set_code_value", + "error.setCodeDyn": "scr_error_set_code_dyn", + "error.assign": "scr_error_assign_dyn", + "error.hasKey": "scr_error_has_key", + "error.hasCode": "scr_error_has_code", "error.defineCause": "scr_error_define_cause", "error.deleteCause": "scr_error_delete_cause", "error.hasCause": "scr_error_has_cause", diff --git a/packages/compiler/src/frontend/lowering/builtins/errors.ts b/packages/compiler/src/frontend/lowering/builtins/errors.ts index a8b4dbcd63..439c9fca31 100644 --- a/packages/compiler/src/frontend/lowering/builtins/errors.ts +++ b/packages/compiler/src/frontend/lowering/builtins/errors.ts @@ -52,6 +52,20 @@ export function refuseExecExceptionCode(lowerer: Lowerer, node: ts.Node): never * declaration is not stdlib). Reads only: writes keep their fence (no * compiled program constructs an errno error). Null for non-error * receivers and non-stdlib members, so the chain keeps trying. */ +/** Error members with slots of their own: never read through the dynamic + * node, even when an intersection restates them. */ +const ERROR_SLOT_MEMBERS = new Set([ + "code", + "cause", + "stack", + "message", + "name", + "errno", + "syscall", + "path", + "dest", +]); + export function lowerErrorCodeProperty( lowerer: Lowerer, expr: ts.PropertyAccessExpression, @@ -135,6 +149,33 @@ export function lowerErrorCodeProperty( declared, ); } + // A member an `Error & { ... }` intersection adds beyond Error's own and + // the system-error metadata (`Object.assign(new Error(m), { status })`): + // an own property of the error's checked-dynamic node, read at the + // declared type (the intersection mapping admits only checkable types). + if ( + recvT.className === "%Error" && + !ERROR_SLOT_MEMBERS.has(expr.name.text) && + !expr.questionDotToken && + !lowerer.isStdlibMember(expr) && + lowerer.typeOf(expr.expression).isIntersectionType() + ) { + const declared = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (declared && declared.kind !== "void") { + const receiver = lowerer.lowerExpr(expr.expression); + const loc = locOf(expr); + return lowerer.coerceToExpected( + { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(receiver, DYN), + key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + type: DYN, + loc, + }, + declared, + ); + } + } // A program class under the receiver's class can carry its own `code` // (a declared field or a property added through an untyped reference), // which Node reads instead of the errno code: read through the error's diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 445014b885..3d8817ab18 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -4,6 +4,7 @@ import { InternalCompilerError } from "../../../errors.js"; import type { IrExpr, IrParam, IrStmt, IrType, SrcLoc } from "../../../ir/ir.js"; import { canAdaptDynFuncTo, + canDynCheckTo, DYN, F64, isUnitType, @@ -341,6 +342,19 @@ export function funcCoerceAdapter( voidRet = toT.ret.kind === "dyn" ? "dyn" : "strand"; } } + // An untyped result (a generic wrapper's `any` handed back `as F`) takes + // the checked-dynamic extraction to the slot's result: a record checks + // its shape, a Promise keeps its identity and checks its payload when it + // settles. + const checkedRet = + strandRet && + fromT.ret.kind === "dyn" && + canDynCheckTo( + toT.ret, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ); + if (checkedRet) strandRet = false; const key = `fnadapt:${typeKey(fromT)}:${typeKey(toT)}`; const existing = lowerer.coercions.retags.get(key); if (existing) return existing; @@ -440,6 +454,10 @@ export function funcCoerceAdapter( `a '${lowerer.fmt(fromT)}' function invoked through a '${lowerer.fmt(toT)}' slot (the result cannot convert to '${lowerer.fmt(toT.ret)}' — the checker's loose function compatibility admitted the assignment, but the call has no exact lowering)`, ), ]; + } else if (checkedRet) { + body = [ + { kind: "return", value: { kind: "dynCheck", value: call, type: toT.ret, loc }, loc }, + ]; } else { const result = lowerer.coerceToExpected(call, toT.ret); if (!typeEquals(result.type, toT.ret)) diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index f49d46fb05..9f98ac2db6 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -1288,12 +1288,14 @@ export function lowerAsExpression( // the requested shape; an assertion does not bypass runtime checks. const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); const bridged = - narrowed && - (narrowed.kind === "f64" || - narrowed.kind === "bool" || - narrowed.kind === "string" || - narrowed.kind === "object" || - narrowed.kind === "dyn"); + (narrowed && + (narrowed.kind === "f64" || + narrowed.kind === "bool" || + narrowed.kind === "string" || + narrowed.kind === "object" || + narrowed.kind === "dyn")) || + // `catch (e: any)`: the binding reads as its checked-dynamic value. + (narrowed === null && (lowerer.typeOf(expr.expression).flags & ts.TypeFlags.Any) !== 0); if (!bridged) lowerer.unsupported("SC1063", expr); } const inner = lowerer.lowerExpr(expr.expression); diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 72a44b4c9a..22bcb66099 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1142,6 +1142,23 @@ export function lowerPropertyAccessExpr( ); } } + // A stdlib-declared member (`message` of an interface extending Error) + // on a catch binding narrowed to a structural type: the binding reads + // as its checked-dynamic value, so the member does too. + if (!expr.questionDotToken && lowerer.caughtLocalOf(expr.expression)) { + const recv = lowerer.lowerExpr(expr.expression); + if (recv.kind === "caughtToDyn") { + const read: IrExpr = { + kind: "dynKeyGet", + key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, + value: recv, + type: DYN, + loc, + }; + const declared = lowerer.mapTypeOf(lowerer.typeOf(expr)); + return declared && declared.kind !== "void" ? lowerer.coerceToExpected(read, declared) : read; + } + } // The lib fence's PROPERTY chokepoint: a stdlib-declared member that // no lowering above claimed ([1,2].entries, Math.SQRT2, Promise.all, // re.exec as a value, ...) reports SC2020 here. diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index aaf881788d..91d1468c27 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -1405,9 +1405,10 @@ export function lowerInExpression( // predicate's `err instanceof Error && "code" in err` shape): `code` // answers from the runtime error's code slot (stamped by fs/system/ // event/spawn errors, absent on plain constructions — exactly Node's - // own-property answer), and the always-declared members fold true. - // Everything else fences: the runtime object carries no other dynamic - // properties to ask (`stack` would be a lie — Node has one, we don't). + // own-property answer), and the always-present members (message, name, + // stack, Object.prototype's keys, members the static class declares) + // fold true. Other keys test the declaring subclasses and the error's + // own runtime properties; keys of built-in errors' extra slots fence. if (recv.type.kind === "object") { // %DOMException first: `'cause' in e` answers the options form's // own-property record (the runtime slot), and code/name/message are @@ -1429,77 +1430,76 @@ export function lowerInExpression( if (key === "cause") { return { kind: "libCall", fn: "error.hasCause", args: [recv], type: BOOL, loc }; } + // The code slot, or an own `code` with another value + // (`err.code = undefined` is present). if (key === "code") { - const codeType = lowerer.envValueType(); - if (codeType.kind !== "union") - throw new InternalCompilerError("lowerer bug: error code type is not a union"); - const undefTag = lowerer.armTag(codeType.unionId, UNDEFINED_T); - const read: IrExpr = { - kind: "libCall", - fn: "error.code", - args: [recv], - type: codeType, - loc, - }; - return { - kind: "unionIsTag", - unionId: codeType.unionId, - tag: undefTag, - negated: true, - value: read, - type: BOOL, - loc, - }; - } - if ( - (key === "message" || key === "name") && - (recv.kind === "varRef" || recv.kind === "caughtNarrow") - ) { - return { kind: "boolLit", value: true, type: BOOL, loc }; + return { kind: "libCall", fn: "error.hasCode", args: [recv], type: BOOL, loc }; } + // The receiver evaluates for its effects when the answer is static. + const always = (): IrExpr => + recv.kind === "varRef" || recv.kind === "caughtNarrow" + ? { kind: "boolLit", value: true, type: BOOL, loc } + : { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: recv, loc }], + result: { kind: "boolLit", value: true, type: BOOL, loc }, + type: BOOL, + loc, + }; + if (key === "message" || key === "name") return always(); // Every Error has its own `stack`, and Error.prototype's `toString` // and `constructor` are among Object.prototype's keys. - if ( - (key === "stack" || OBJECT_PROTOTYPE_KEYS.has(key)) && - (recv.kind === "varRef" || recv.kind === "caughtNarrow") - ) { - return { kind: "boolLit", value: true, type: BOOL, loc }; - } - // Any other key: a member a program class declares. The receiver's - // runtime class is its static class or a descendant; the key is - // present exactly when that class (or an ancestor) declares it. Keys - // built-in errors keep outside their class fields depend on the - // runtime error and stay fenced, as do keys no program class declares. + if (key === "stack" || OBJECT_PROTOTYPE_KEYS.has(key)) return always(); + // A member the static class declares is always present. const declares = (c: ClassInfo): boolean => c.fields.has(key) || lowerer.findMethodOn(c, key) !== null || lowerer.findMethodOn(c, `get:${key}`) !== null || lowerer.findMethodOn(c, `set:${key}`) !== null; - if ( - (recv.kind === "varRef" || recv.kind === "caughtNarrow") && - !BUILTIN_ERROR_SLOT_KEYS.has(key) && - [...lowerer.classes.values()].some((c) => c.builtinError !== true && declares(c)) - ) { - const staticName = recv.type.className; - const staticInfo = lowerer.classes.get(staticName); - if (staticInfo && declares(staticInfo)) - return { kind: "boolLit", value: true, type: BOOL, loc }; + const staticInfo = lowerer.classes.get(recv.type.className); + if (staticInfo && declares(staticInfo)) return always(); + // Any other key: a descendant class that declares it (instanceof), or + // an own property set at runtime (a throw site's extra fields, + // Object.assign, writes through a dynamic view). Keys built-in errors + // keep outside their class fields depend on the runtime error and + // stay fenced. + if (!BUILTIN_ERROR_SLOT_KEYS.has(key)) { + const stable = recv.kind === "varRef" || recv.kind === "caughtNarrow"; + const slot = stable ? null : lowerer.declareHiddenLocal("%inRecv", recv.type); + const ref = (): IrExpr => (slot ? varRef(slot.id, recv.type, loc) : recv); const tests: IrExpr[] = []; + const staticName = recv.type.className; for (const c of lowerer.classes.values()) { const name = c.def.name; if (name === staticName || !lowerer.isSubclassOf(name, staticName) || !declares(c)) continue; // A declaring ancestor below the static class already covers it. if (c.base && c.base.def.name !== staticName && declares(c.base)) continue; - tests.push({ kind: "instanceOf", value: recv, className: name, type: BOOL, loc }); + tests.push({ kind: "instanceOf", value: ref(), className: name, type: BOOL, loc }); } - return tests.reduce( - (acc, test) => - acc.kind === "boolLit" - ? test - : { kind: "logical", op: "||", left: acc, right: test, type: BOOL, loc }, - { kind: "boolLit", value: false, type: BOOL, loc }, - ); + tests.push({ + kind: "libCall", + fn: "error.hasKey", + args: [ref(), { kind: "strLit", value: key, type: STRING, loc }], + type: BOOL, + loc, + }); + const answer = tests.reduce((acc, test) => ({ + kind: "logical", + op: "||", + left: acc, + right: test, + type: BOOL, + loc, + })); + if (!slot) return answer; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: slot.id, init: recv, loc }], + result: answer, + type: BOOL, + loc, + }; } lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/function-context.ts b/packages/compiler/src/frontend/lowering/function-context.ts index 55d8ed0b08..92042b7d72 100644 --- a/packages/compiler/src/frontend/lowering/function-context.ts +++ b/packages/compiler/src/frontend/lowering/function-context.ts @@ -48,6 +48,10 @@ export interface FnCtx { argumentsLocal?: IrLocal | null; /** Arrows inherit the nearest ordinary function's arguments binding. */ inheritsArguments?: boolean; + /** A function lowered on demand on top of an unrelated caller's frames + * (a generic instance): lexical `this` resolution stops here, so the + * body sees what it sees when lowered alone. */ + thisBoundary?: true; /** Tuple bindings of this function that are only forwarded * (typed-arguments.ts onlyForwarded): binding local id → the native * slots forwarding reads. Such a binding is never materialized. Local ids @@ -161,7 +165,16 @@ export function captureContextBinding( throw new InternalCompilerError("lowerer bug: no active function context"); const direct = bindingInContext(current, symbol); if (direct !== null) return { local: direct, origin: direct, error: null }; - for (let depth = stack.length - 2; depth >= 0; depth--) { + let floor = 0; + if (symbol === undefined) { + for (let depth = stack.length - 1; depth >= 0; depth--) { + if (stack[depth]!.thisBoundary) { + floor = depth; + break; + } + } + } + for (let depth = stack.length - 2; depth >= floor; depth--) { const origin = bindingInContext(stack[depth]!, symbol); if (origin === null) continue; diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 6572c1c882..b586884c5a 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -905,6 +905,8 @@ export function lowerGenericInstance( : lowerer.bodyReturnType(isAsync, inst.returnType); const fnCtx = newFnCtx(false, null, null, bodyReturn); fnCtx.isAsync = isAsync; + // Instances lower on demand, on top of whichever caller reached them. + fnCtx.thisBoundary = true; // Implicit-any instances whose declared return did not map lower in // return-INFERENCE mode: `return` statements record here bare, and the // post-pass (resolveInferredReturn) settles the type and wraps them. diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 1c7c2ac435..f3864aa41b 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4381,6 +4381,14 @@ function lowerCaughtPredicateCall( const paramSymbol = lowerer.checker.getSymbolAtLocation(param.name); if (!paramSymbol) return null; const ret = decl.body.statements.nodes.length === 1 ? decl.body.statements.nodes[0] : undefined; + // A longer guard whose parameter is `unknown` (or `any`) takes the caught + // value as an ordinary checked-dynamic argument: the call path converts + // the binding, and the narrowed binding reads through the same value. + if ( + (!ret || !ts.isReturnStatement(ret) || !ret.expression) && + lowerer.mapTypeOf(lowerer.checker.getTypeOfSymbol(paramSymbol))?.kind === "dyn" + ) + return null; if (!ret || !ts.isReturnStatement(ret) || !ret.expression) { lowerer.unsupported( "SC1090", @@ -6922,6 +6930,7 @@ function recordAssignHelper( const helper = `%obj.assign.${lowerer.arrHofHelpers.size}`; lowerer.arrHofHelpers.set(key, helper); const sShape = lowerer.shapes.get(srcShapeId)!; + const tShape = lowerer.shapes.get(targetShapeId)!; const tT: IrType = { kind: "record", shapeId: targetShapeId }; const sT: IrType = { kind: "record", shapeId: srcShapeId }; const tRef: IrExpr = { kind: "varRef", localId: "t.0", type: tT, loc }; @@ -6936,12 +6945,13 @@ function recordAssignHelper( type: f.type, loc, }; + const targetType = tShape.fields.find((x) => x.name === f.name)!.type; const set: IrStmt = { kind: "recordSet", obj: tRef, shapeId: targetShapeId, field: f.name, - value: get, + value: typeEquals(targetType, f.type) ? get : lowerer.coerceToExpected(get, targetType), loc, }; const present = lowerer.recordFieldPresent(sRef, srcShapeId, f.name, loc); @@ -7489,6 +7499,8 @@ function lowerObjectStaticCall( }; } } + const onError = lowerObjectAssignOntoError(lowerer, call); + if (onError) return onError; const hybrid = lowerObjectAssignHybrid(lowerer, call); if (hybrid) return hybrid; // `Object.assign({}, lit)` — an EMPTY fresh-literal target and one @@ -7581,7 +7593,12 @@ function lowerObjectStaticCall( !shapeHasAccessorSlots(sShape) && sShape.fields.every((sf) => { const tf = tShape.fields.find((x) => x.name === sf.name); - return tf !== undefined && typeEquals(tf.type, sf.type); + // A source value converts into a wider target field (`{ bail: + // true }` into `bail?: boolean`). + return ( + tf !== undefined && + (typeEquals(tf.type, sf.type) || lowerer.coercibleValue(sf.type, tf.type)) + ); }); if (ok) { const loc = locOf(call); @@ -7599,6 +7616,37 @@ function lowerObjectStaticCall( } } } + // `throw Object.assign(record, { extra })`: an error-shaped record given + // a member its type lacks, thrown at once. A fixed record cannot grow + // the member, so the throw carries a checked-dynamic copy of the target + // with the source's members added. The copy is unobservable: control + // leaves with the throw, so nothing on that path reads the target again. + { + let parent: ts.Node | undefined = call.parent; + while (parent && ts.isParenthesizedExpression(parent)) parent = parent.parent; + const argNodes = call.arguments.nodes; + if ( + parent && + ts.isThrowStatement(parent) && + argNodes.length === 2 && + !argNodes.some((a) => ts.isSpreadElement(a)) + ) { + const tProbe = tryLowerExpression(lowerer, argNodes[0]!); + const sProbe = tryLowerExpression(lowerer, argNodes[1]!); + if ( + tProbe?.type.kind === "record" && + lowerer.dynConvertible(tProbe.type) && + sProbe && + (sProbe.type.kind === "dyn" || lowerer.dynConvertible(sProbe.type)) + ) { + const loc = locOf(call); + const target = lowerer.coerceToExpected(lowerer.lowerExpr(argNodes[0]!), DYN); + const source = lowerer.coerceToExpected(lowerer.lowerExpr(argNodes[1]!), DYN); + if (target.type.kind === "dyn" && source.type.kind === "dyn") + return { kind: "libCall", fn: "dyn.assign", args: [target, source], type: DYN, loc }; + } + } + } // `Object.assign(target, ...sources)` over a CHECKED-DYNAMIC target // — the n-ary/spread form (`Object.assign({}, ...plugins.map(p => // p.options), coreOptions)`, support.js's option-table merge). The @@ -9678,6 +9726,30 @@ export function lowerObjectMethodCall( `calls of the abstract method '${method}' with no concrete implementation below the receiver's static class`, ); } + // The static receiver's nearest declaration is an override adapted to + // the inherited ABI, and nothing below overrides it: call its body + // directly under its declared signature (exact parameters and result). + // The slot function stays reachable; it lifts the body. + const adapted = found.declarer.returnAdapters?.get(method); + // A TypeScript override's own entry answers its declared result. A + // JavaScript body's result is inferred, so its callers keep the slot. + const declarerDecl = found.declarer.decl; + if ( + adapted && + !(declarerDecl && isJsSourceFile(declarerDecl.getSourceFile())) && + !lowerer.overrideBelow(info, method) + ) { + lowerer.noteEdge(`%${found.declarer.def.name}.${method}`); + const receiver = lowerReceiver(); + const args = lowerer.completeArgs(call.arguments.nodes, adapted.params, locOf(call), call); + return { + kind: "call", + callee: `%${found.declarer.def.name}.${method}$own`, + args: [lowerer.upcastTo(receiver, found.declarer.def.name), ...args], + type: adapted.ret, + loc: locOf(call), + }; + } if (lowerer.overrideBelow(info, method)) lowerer.noteVirtualEdge(info, method); else lowerer.noteEdge(`%${found.declarer.def.name}.${method}`); const receiver = lowerReceiver(); @@ -10288,3 +10360,60 @@ function maybePromiseHandlerResult( } return stmt; } + +/** `Object.assign(err, ...sources)` in TypeScript onto an error-hierarchy + * target (`Object.assign(new Error(message), details)`): the sources' + * own members land on the error — the runtime copy over its + * checked-dynamic node, whose name, message, stack, cause and code feed + * back into the typed slots — and the call answers the error itself. + * Null for every other target. */ +function lowerObjectAssignOntoError(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { + const argNodes = call.arguments.nodes; + if ( + isJsSourceFile(call.getSourceFile()) || + argNodes.length < 2 || + argNodes.some((a) => ts.isSpreadElement(a)) + ) + return null; + const probe = tryLowerExpression(lowerer, argNodes[0]!); + if (probe?.type.kind !== "object" || !lowerer.isErrorRootedClass(probe.type.className)) + return null; + const loc = locOf(call); + const target = lowerer.lowerExpr(argNodes[0]!); + const slot = lowerer.declareHiddenLocal("%assignErr", target.type); + const sources: IrExpr[] = []; + for (const node of argNodes.slice(1)) { + const raw = lowerer.lowerExpr(node); + fenceSymbolFieldCopy(lowerer, node, raw.type); + const source = lowerer.coerceToExpected(raw, DYN); + if (source.type.kind !== "dyn") { + lowerer.unsupported( + "SC1090", + node, + `Object.assign onto an Error from a '${lowerer.fmt(raw.type)}' source (sources enter the error's dynamic properties)`, + ); + } + sources.push(source); + } + const ref = (): IrExpr => ({ kind: "varRef", localId: slot.id, type: target.type, loc }); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: slot.id, init: target, loc }, + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "error.assign", + args: [ref(), { kind: "dynArrLit", elems: sources, type: DYN, loc }], + type: VOID, + loc, + }, + loc, + }, + ], + result: ref(), + type: target.type, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 17ffb99b49..182a73f14e 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -30,6 +30,7 @@ import type { FnCtx, Lowerer } from "./lowerer.js"; import { inlineNamespaceRequireOf, lowerWithInlineRequireInit } from "./cjs-inline-require.js"; import { BOOL, + canDynCheckTo, DATE_T, DYN, F64, @@ -499,6 +500,12 @@ export interface ClassInfo { /** Own JS overrides whose signature cannot use the inherited ABI. The slot * retains that ABI, but its implementation throws before executing. */ methodEntryFences?: Map; + /** Own TypeScript overrides whose declared result differs from the + * inherited ABI's but converts into it (`Promise` over `any`, a + * union narrowed to its inherited arm): the slot function `%C.m` keeps the + * inherited ABI and converts the result of the body, which lowers as + * `%C.m$own` with the declared result (async when declared so). */ + returnAdapters?: Map; /** OWN GENERIC instance methods (own type parameters — `m(x: T)`), * monomorphized per call site like top-level generic functions: instance * `n` is the module function `%C.m%n` taking `this` as param 0. They @@ -2249,6 +2256,7 @@ export function collectClassShapeInner( } >(); const methodEntryFences = new Map(); + const returnAdapters = new Map(); // Own accessor declarations ("get:x"/"set:x" → node), for the // partial-override analysis below (diagnostics need the node). const accessorNodes = new Map(); @@ -3434,7 +3442,67 @@ export function collectClassShapeInner( overridden.declarer.decl !== null && isJsSourceFile(overridden.declarer.decl.getSourceFile()) && overridden.sig.gen === undefined; - if (fencedJsOverride) { + // A TypeScript override that only changes the result, into a + // type that converts into the inherited one (`Promise` over + // an `any` base, `string | undefined` over `string`): the slot + // keeps the inherited ABI and converts the body's result at the + // boundary (lowerClassMethodMemberInner). + const ownAsync = + member.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) === true; + if ( + !fencedJsOverride && + !functionLocalClass(decl) && + overridden.sig.gen === undefined && + // An async base's slot answers its promise: a Promise type, or + // the checked-dynamic value of an untyped async method. + (overridden.sig.async !== true || + overridden.sig.ret.kind === "promise" || + overridden.sig.ret.kind === "dyn") && + !overridden.sig.abstract && + overridden.sig.params.length === shapes.length && + overridden.sig.params.every( + (p, i) => + p.mode === shapes[i]!.mode && + (p.mode === "required" || p.mode === "omittable") && + // Each inherited argument converts into the override's + // parameter: equal, a checked extraction from an `any` + // slot, or a widening (a contravariant parameter). + (typeEquals(p.type, shapes[i]!.type) || + (p.type.kind === "dyn" && + canDynCheckTo( + shapes[i]!.type, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + )) || + lowerer.coercibleValue(p.type, shapes[i]!.type)), + ) && + (ownAsync ? ft.ret.kind === "promise" : true) && + (typeEquals(ft.ret, overridden.sig.ret) || + (overridden.sig.ret.kind === "dyn" + ? ft.ret.kind !== "void" && lowerer.dynConvertible(ft.ret) + : lowerer.coercibleValue(ft.ret, overridden.sig.ret) || + // A promise whose payload widens into the inherited one + // (`Promise` over an untyped async `Promise`). + (ft.ret.kind === "promise" && + overridden.sig.ret.kind === "promise" && + lowerer.promisePayloadWidens(ft.ret, overridden.sig.ret)) || + // A JavaScript override's inferred `any` result checks + // into the inherited type (`toString() { return + // this.message }` over Error's). + (ft.ret.kind === "dyn" && + isJsSourceFile(member.getSourceFile()) && + overridden.sig.ret.kind !== "void" && + canDynCheckTo( + overridden.sig.ret, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + )))) + ) { + returnAdapters.set(mName, { params: [...shapes], ret: ft.ret, async: ownAsync }); + shapes.splice(0, shapes.length, ...overridden.sig.params); + ft.ret = overridden.sig.ret; + if (overridden.sig.async === true) thunkAsyncSlot(overridden, mName); + } else if (fencedJsOverride) { // A dormant JS method must not poison the whole class. Keep // the inherited call/dispatch ABI and refuse at method entry; // lowering this body with the base signature would change JS. @@ -3489,7 +3557,34 @@ export function collectClassShapeInner( // fiber spawn wrapper; vtable slots hold raw implementations) — // an override chain touching an async method on either end would // put a spawn wrapper behind a virtual slot, so it fences. - if (overridden && (asyncMember || overridden.sig.async === true)) { + // An override chain touching async methods dispatches through + // plain slot functions that return the body's promise: the async + // bodies lower as `%C.m$own` behind them (thunkAsyncSlot). + if ( + overridden && + !returnAdapters.has(mName) && + (asyncMember || overridden.sig.async === true) && + !isJsSourceFile(member.getSourceFile()) && + !functionLocalClass(decl) && + member.asteriskToken === undefined && + overridden.sig.gen === undefined && + !overridden.sig.abstract && + ft.ret.kind === "promise" && + typeEquals(ft.ret, overridden.sig.ret) && + overridden.sig.params.length === shapes.length && + overridden.sig.params.every( + (p, i) => typeEquals(p.type, shapes[i]!.type) && p.mode === shapes[i]!.mode, + ) + ) { + if (overridden.sig.async === true) thunkAsyncSlot(overridden, mName); + if (asyncMember) + returnAdapters.set(mName, { params: [...shapes], ret: ft.ret, async: true }); + } + if ( + overridden && + !returnAdapters.has(mName) && + (asyncMember || overridden.sig.async === true) + ) { const message = `overriding ${overridden.sig.async === true ? "the async method" : "a method with an async method"} '${mName}' (async methods dispatch statically — the vtable slot machinery has no fiber-spawn story)`; if (isJsSourceFile(member.getSourceFile())) methodEntryFences.set(mName, unsupportedDiag("SC1090", locOf(member.name), message)); @@ -3520,7 +3615,7 @@ export function collectClassShapeInner( } else { methods.set( mName, - asyncMember + asyncMember && !returnAdapters.has(mName) ? { params: shapes, ret: ft.ret, async: true as const } : { params: shapes, ret: ft.ret }, ); @@ -4238,6 +4333,7 @@ export function collectClassShapeInner( fieldOrder, methods, ...(methodEntryFences.size > 0 ? { methodEntryFences } : {}), + ...(returnAdapters.size > 0 ? { returnAdapters } : {}), decl, ...(emitOverride !== undefined ? { emitOverride } : {}), ctor, @@ -6616,6 +6712,28 @@ function abstractMemberSignature( /** A nominal return subtype uses the same owned pointer as the inherited * return slot. The current class is still being collected, so consult its * already collected base chain before the complete class graph is available. */ +/** An inherited ASYNC method that an override now dispatches against: its + * slot becomes a plain function returning the body's promise, and the + * async body lowers behind it as `%C.m$own` (the adapter path with an + * identity conversion), so a virtual call never enters an async body + * without its spawn wrapper. */ +function thunkAsyncSlot( + overridden: { declarer: ClassInfo; sig: ClassMethodSignature }, + mName: string, +): void { + if (overridden.sig.async !== true) return; + const declarer = overridden.declarer; + declarer.returnAdapters ??= new Map(); + if (!declarer.returnAdapters.has(mName)) { + declarer.returnAdapters.set(mName, { + params: [...overridden.sig.params], + ret: overridden.sig.ret, + async: true, + }); + } + delete overridden.sig.async; +} + function overrideReturnAbi( lowerer: Lowerer, declared: IrType, @@ -7838,6 +7956,82 @@ function lowerClassMethodMemberInner( allowDiagSink: true, }); } + // An override converting its declared result into the inherited ABI: + // the body lowers as `%C.m$own` under its declared result, and the slot + // function forwards to it and converts what it returns. + const adapter = info.returnAdapters?.get(mName); + if (adapter) { + const ownSig: ClassMethodSignature = { ...sig, params: adapter.params, ret: adapter.ret }; + if (adapter.async) ownSig.async = true; + const own = lowerClassMethodBody(lowerer, info, fnLike, `%${className}.${mName}$own`, ownSig); + if (!own) return null; + lowerer.liftedFns.push(own); + const loc = locOf(fnLike); + const params: IrParam[] = [ + { localId: "this.0", name: "this", type: thisType }, + ...sig.params.map((p, i) => ({ localId: `p.${i}`, name: `p${i}`, type: p.type })), + ]; + const fnCtx = newFnCtx(false, null, null, sig.ret); + lowerer.fnStack.push(fnCtx); + try { + const call: IrExpr = { + kind: "call", + callee: own.name, + args: params.map((p, i): IrExpr => { + const ref: IrExpr = { kind: "varRef", localId: p.localId, type: p.type, loc }; + // `this` passes through; each inherited argument converts into + // the override's declared parameter (checked where it narrows). + return i === 0 ? ref : lowerer.coerceInto(fnLike.name, ref, adapter.params[i - 1]!.type); + }), + type: adapter.ret, + loc, + }; + // A void slot discards the override's result after running it. A + // toString slot hands on what every ToString consumer of the result + // makes of it (`String(err)`, templates): undefined reads + // "undefined", as in Node. Other slots check the result into the + // inherited type. + const result = (): IrExpr => + mName === "toString" && sig.ret.kind === "string" + ? call.type.kind === "dyn" + ? { kind: "libCall", fn: "dyn.stringConstructor", args: [call], type: STRING, loc } + : lowerer.ensureString(call, fnLike.name) + : lowerer.coerceInto(fnLike.name, call, sig.ret); + const body: IrStmt[] = + sig.ret.kind === "void" + ? [ + { kind: "exprStmt", expr: call, loc }, + { kind: "return", value: null, loc }, + ] + : [{ kind: "return", value: result(), loc }]; + return { + name: `%${className}.${mName}`, + params, + returnType: sig.ret, + locals: [ + ...params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + ...lowerer.ctx.locals, + ], + body, + loc, + }; + } finally { + lowerer.fnStack.pop(); + } + } + return lowerClassMethodBody(lowerer, info, fnLike, `%${className}.${mName}`, sig); +} + +function lowerClassMethodBody( + lowerer: Lowerer, + info: ClassInfo, + fnLike: ts.MethodDeclaration | ts.AccessorDeclaration, + fnName: string, + sig: ClassMethodSignature, +): IrFunction | null { + const className = info.def.name; + const thisType: IrType = { kind: "object", className }; + if (!fnLike.body) return null; const prevClass = lowerer.currentClass; lowerer.currentClass = info; // ASYNC methods: the module function is an async IrFunction — its @@ -7879,7 +8073,7 @@ function lowerClassMethodMemberInner( const body = [...declared.prologue, ...lowerer.lowerStmts(fnLike.body.statements.nodes)]; appendImplicitUndefinedReturn(lowerer, body, bodyReturn, locOf(fnLike)); const fn: IrFunction = { - name: `%${className}.${mName}`, + name: fnName, params, returnType: bodyReturn, locals: lowerer.ctx.locals, diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 4f81db1f19..e770b8d581 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -484,7 +484,7 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // Only arrows can see an enclosing method's `this` (JS lexical-this); // `this` in plain nested functions is already a tsc error under // noImplicitThis, so the generic scope walk here is preflight-safe. - const local = lowerer.resolveThis(); + const local = lowerer.resolveThis(expr); if (local) { const value: IrExpr = { kind: "varRef", localId: local.id, type: local.type, loc }; // `this.#x` in a function-local class's prototype method, whose `this` @@ -1057,6 +1057,15 @@ export function nullishBranches( export function lowerCondition(lowerer: Lowerer, expr: ts.Expression): IrExpr { let e: ts.Expression = expr; while (ts.isParenthesizedExpression(e)) e = e.expression; + // `if (Object.setPrototypeOf)`: the feature test of transpiled Error + // subclasses' prototype reset. Node always has it. + if ( + ts.isPropertyAccessExpression(e) && + !e.questionDotToken && + e.name.text === "setPrototypeOf" && + lowerer.isStdlibGlobal(e.expression, "Object") + ) + return { kind: "boolLit", value: true, type: BOOL, loc: locOf(e) }; if (ts.isPropertyAccessExpression(e)) { const presence = lowerPromiseThenPresence(lowerer, e, { kind: "boolLit", @@ -2257,9 +2266,14 @@ export function caughtRead( // Unnarrowed reads cross into unknown. The caught-value dispatch pass // retains known native class payloads through live identity capsules; // other payload kinds use the runtime's ordinary conversion. - if (narrowed?.kind === "dyn") { + // `catch (e: any)` (or useUnknownInCatchVariables off) reads the same way. + if ( + narrowed?.kind === "dyn" || + (narrowed === null && (lowerer.typeOf(node).flags & ts.TypeFlags.Any) !== 0) + ) { return { kind: "caughtToDyn", value: ref, type: DYN, loc }; } + // A binding a user type predicate (or an assertion) narrowed to a // structural type (`isErrorLike(e)` → `{ message: string; … }`, an // intersection like `TypeError & { code: unknown }`): the thrown value is diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index c712541ee6..29cd9c4375 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1496,6 +1496,8 @@ const THROWN_AS_DYN: ReadonlySet = new Set([ ]); export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | null { + if (ownPrototypeResetStatement(lowerer, stmt)) + return { kind: "block", body: [], loc: locOf(stmt) }; if (ts.isClassDeclaration(stmt)) { if (!stmt.name) lowerer.unsupported("SC1090", stmt, "anonymous local class declarations"); const info = lowerer.lowerClassExpressionInfo(stmt); @@ -7478,6 +7480,34 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm loc, }; } + // `err.code = v` on an Error value (`err as Error & { code?: + // string }`): typed reads come from the runtime error's code + // slot, so the write lands there. A value that is not statically + // a string checks at runtime: strings take the slot, anything + // else clears it and stays a dynamic own property. The + // system-error members (errno, syscall, path, dest) read + // through the checked-dynamic node and keep the dynamic write, as + // do JavaScript files, whose reads use that node too. + if ( + !isJsSourceFile(expr.getSourceFile()) && + recv?.type.kind === "object" && + lowerer.isErrorRootedClass(recv.type.className) && + expr.left.name.text === "code" + ) { + const loc = locOf(expr); + const stringValue = isStringTyped(lowerer.typeOf(expr.right)); + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: stringValue ? "error.setCode" : "error.setCodeDyn", + args: [recv, lowerer.lowerExprExpecting(expr.right, stringValue ? STRING : DYN)], + type: VOID, + loc, + }, + loc, + }; + } if (recv?.type.kind === "object" && recv.type.className === "%Error") { const loc = locOf(expr); return { @@ -11298,6 +11328,141 @@ export function stdlibBufferVarAlias(lowerer: Lowerer, decl: ts.VariableDeclarat ); } +/** A checker type that is a string or string literal (or a union of them). */ +function isStringTyped(t: ts.Type): boolean { + if (t.isUnionType()) return ts.constituentTypes(t).every(isStringTyped); + return (t.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) !== 0; +} + +/** The Error-subclass prototype reset of transpiled code, inside a class + * constructor: + * + * const proto = new.target.prototype; + * if (Object.setPrototypeOf) Object.setPrototypeOf(this, proto); + * else this.__proto__ = proto; + * + * (also `Object.setPrototypeOf(this, new.target.prototype)` and + * `Object.setPrototypeOf(this, C.prototype)` in C's own constructor when no + * class extends C). A compiled instance already has exactly that prototype, + * so each statement is a no-op. True for those statements. */ +function ownPrototypeResetStatement(lowerer: Lowerer, stmt: ts.Statement): boolean { + if (ts.isVariableStatement(stmt)) { + const decls = stmt.declarationList.declarations.nodes; + if (decls.length !== 1 || (stmt.declarationList.flags & ts.NodeFlags.Const) === 0) return false; + const decl = decls[0]!; + if (!ts.isIdentifier(decl.name) || !decl.initializer) return false; + const ctor = enclosingConstructor(decl); + if (!ctor || !ownPrototypeRef(lowerer, decl.initializer, ctor)) return false; + const symbol = lowerer.checker.getSymbolAtLocation(decl.name); + if (!symbol) return false; + // Every other use of the binding must be one of the reset statements. + let onlyResets = true; + ts.walkPreorder(ctor, (n) => { + if (!onlyResets) return "stop"; + if (!ts.isIdentifier(n) || n === decl.name) return undefined; + if (lowerer.checker.getSymbolAtLocation(n) !== symbol) return undefined; + const statement = n.parent?.parent; + if ( + !statement || + !ts.isExpressionStatement(statement) || + resetTarget(lowerer, statement.expression) !== n + ) + onlyResets = false; + return undefined; + }); + return onlyResets; + } + if (!ts.isExpressionStatement(stmt)) return false; + const ctor = enclosingConstructor(stmt); + if (!ctor) return false; + const target = resetTarget(lowerer, stmt.expression); + return target !== null && ownPrototypeRef(lowerer, target, ctor); +} + +/** The prototype operand of `Object.setPrototypeOf(this, P)` or + * `this.__proto__ = P`, or null. */ +function resetTarget(lowerer: Lowerer, expr: ts.Expression): ts.Expression | null { + if ( + ts.isCallExpression(expr) && + expr.arguments.nodes.length === 2 && + expr.arguments.nodes[0]!.kind === ts.SyntaxKind.ThisKeyword && + ts.isPropertyAccessExpression(expr.expression) && + expr.expression.name.text === "setPrototypeOf" && + lowerer.isStdlibGlobal(expr.expression.expression, "Object") + ) + return expr.arguments.nodes[1]!; + if ( + ts.isBinaryExpression(expr) && + expr.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(expr.left) && + expr.left.expression.kind === ts.SyntaxKind.ThisKeyword && + expr.left.name.text === "__proto__" + ) + return expr.right; + return null; +} + +/** The class constructor whose body holds `node`, crossing only statements + * and blocks (any nested function rebinds `this`). */ +function enclosingConstructor(node: ts.Node): ts.ConstructorDeclaration | null { + for (let cur = node.parent; cur !== undefined; cur = cur.parent) { + if (ts.isConstructorDeclaration(cur)) return cur; + if ( + ts.isFunctionLike(cur) || + ts.isClassDeclaration(cur) || + ts.isClassExpression(cur) || + ts.isSourceFile(cur) + ) + return null; + } + return null; +} + +/** Whether `node` denotes the prototype of the object `ctor` constructs: + * `new.target.prototype`, a const holding it, or the class's own + * `C.prototype` when no class extends C. */ +function ownPrototypeRef( + lowerer: Lowerer, + node: ts.Expression, + ctor: ts.ConstructorDeclaration, +): boolean { + while (ts.isParenthesizedExpression(node)) node = node.expression; + if (ts.isPropertyAccessExpression(node) && node.name.text === "prototype") { + const base = node.expression; + if ( + ts.isMetaProperty(base) && + base.keywordToken === ts.SyntaxKind.NewKeyword && + base.name.text === "target" + ) + return true; + const cls = ctor.parent; + if (!ts.isIdentifier(base) || !(ts.isClassDeclaration(cls) || ts.isClassExpression(cls))) + return false; + const symbol = lowerer.resolveValueSymbol(base); + const info = symbol ? lowerer.classBySymbol.get(symbol) : undefined; + if (!info || symbol!.valueDeclaration !== cls) return false; + for (const other of lowerer.classes.values()) { + if (other !== info && lowerer.isSubclassOf(other.def.name, info.def.name)) return false; + } + return true; + } + if (ts.isIdentifier(node)) { + const symbol = lowerer.resolveValueSymbol(node); + const decl = symbol?.valueDeclaration; + return ( + decl !== undefined && + ts.isVariableDeclaration(decl) && + decl.initializer !== undefined && + ts.isVariableDeclarationList(decl.parent) && + (decl.parent.flags & ts.NodeFlags.Const) !== 0 && + enclosingConstructor(decl) === ctor && + !ts.isIdentifier(decl.initializer) && + ownPrototypeRef(lowerer, decl.initializer, ctor) + ); + } + return false; +} + function isAssertionInitializer(node: ts.Expression): boolean { let e = node; while (ts.isParenthesizedExpression(e)) e = e.expression; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 064756d333..26d331967f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -9820,6 +9820,8 @@ export class Lowerer { node = node.expression; if (expected) { if (ts.isConditionalExpression(node)) return lowerTernary(this, node, expected); + const escaped = this.neverAssertionInto(node, expected); + if (escaped) return escaped; const fresh = this.emptyCollectionFor(node, expected); if (fresh) return this.coerceInto(node, fresh, expected); } @@ -10733,6 +10735,14 @@ export class Lowerer { return findMethodOn(this, info, name); } + /** A class whose root is the runtime's %Error (builtin errors and their + * program subclasses). */ + isErrorRootedClass(className: string): boolean { + let info = this.classes.get(className) ?? null; + while (info && info.base) info = info.base; + return info?.def.name === "%Error"; + } + isSubclassOf(sub: string, sup: string): boolean { return isSubclassOf(this, sub, sup); } @@ -11031,24 +11041,30 @@ export class Lowerer { ): void { const loc = locOf(decl); const name = nameNode.text; - const tupleType = this.irTypeOf(nameNode); + const fn = decl.parent; + const symbol = this.checker.getSymbolAtLocation(nameNode); + const isRead = (id: ts.Identifier): boolean => + id !== nameNode && this.checker.getSymbolAtLocation(id) === symbol; + const pure = fn !== undefined && symbol !== undefined && onlyForwarded(this, fn, isRead); + // A forwarded-only tuple is never built: a tuple with optional + // positions (`Parameters<(n?: T) => R>`, whose length varies per call) + // forwards its slots, an omitted position as undefined — which a + // callee's default treats exactly as an omitted argument. Its length + // is the one read that would observe the difference. + const tupleType = + pure && + elemShapes.length > 0 && + this.mapTypeOf(this.typeOf(nameNode)) === null && + !readsLength(fn!, isRead) + ? this.forwardedSlotsTuple(elemShapes) + : this.irTypeOf(nameNode); const slots = elemShapes.map((shape, i) => { const slot = this.declareHiddenLocal(`%${name}.${i}`, shape.type); params.push({ localId: slot.id, name: `%${name}.${i}`, type: shape.type }); return slot; }); const body = this.declareLocal(nameNode, name, tupleType, true); - const fn = decl.parent; - const symbol = this.checker.getSymbolAtLocation(nameNode); - if ( - fn !== undefined && - symbol !== undefined && - onlyForwarded( - this, - fn, - (id) => id !== nameNode && this.checker.getSymbolAtLocation(id) === symbol, - ) - ) { + if (pure) { this.ctx.pureTuples.set(body.id, slots); return; } @@ -11056,6 +11072,38 @@ export class Lowerer { prologue.push({ kind: "varDecl", localId: body.id, init, loc }); } + /** `value as never` flowing into a typed slot: `never` is assignable to + * every type, so the assertion promises the slot's type. A dynamic value + * takes the checked extraction to it, as `value as T` would. Null for + * every other form, and for numeric, dynamic and union slots, which + * keep the existing lowering. */ + private neverAssertionInto(node: ts.Expression, expected: IrType): IrExpr | null { + if (!(ts.isAsExpression(node) || ts.isTypeAssertion(node))) return null; + if (expected.kind === "f64" || expected.kind === "dyn" || expected.kind === "union") + return null; + if (!(this.checker.getTypeFromTypeNode(node.type).flags & ts.TypeFlags.Never)) return null; + const innerTs = this.typeOf(node.expression); + if (!(innerTs.flags & ts.TypeFlags.Any) && this.mapTypeOf(innerTs)?.kind !== "dyn") return null; + if ( + !canDynCheckTo( + expected, + (id) => this.shapes.get(id), + (id) => this.unions.get(id), + ) + ) + return null; + const inner = this.lowerExpr(node.expression); + if (inner.type.kind !== "dyn") return null; + return { kind: "dynCheck", value: inner, type: expected, loc: locOf(node) }; + } + + /** The tuple record of a forwarded-only rest tuple's native slots. */ + private forwardedSlotsTuple(elemShapes: readonly ParamShape[]): IrType { + const fields = elemShapes.map((shape, i) => ({ name: String(i), type: shape.type })); + fields.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + return { kind: "record", shapeId: this.shapes.intern(fields, true) }; + } + /** A fresh tuple holding the given positional slots, typed `tupleType` * (a tuple record, or the empty-array form of `[]`). */ tupleOfSlots(slots: readonly IrLocal[], tupleType: IrType, blame: ts.Node, loc: SrcLoc): IrExpr { @@ -11497,8 +11545,8 @@ export class Lowerer { /** Lexical `this` — the enclosing method's this-param, possibly captured * through arrows (function expressions/declarations reset `this` in JS; * their bodies never see an enclosing method's binding). */ - resolveThis(): IrLocal | null { - return this.resolveKey(undefined); + resolveThis(blame?: ts.Node): IrLocal | null { + return this.resolveKey(undefined, blame); } peekLocal(ident: ts.Identifier): IrLocal | null { @@ -13337,6 +13385,31 @@ export class Lowerer { } } +/** Whether some read of a binding (identified by `isRead`) in `fn` takes + * its `.length`. */ +function readsLength(fn: ts.Node, isRead: (id: ts.Identifier) => boolean): boolean { + let found = false; + ts.walkPreorder(fn, (n) => { + if (found) return "stop"; + if (!ts.isIdentifier(n) || !isRead(n)) return undefined; + let top: ts.Node = n; + while ( + top.parent && + (ts.isParenthesizedExpression(top.parent) || + ts.isAsExpression(top.parent) || + ts.isTypeAssertion(top.parent) || + ts.isSatisfiesExpression(top.parent) || + ts.isNonNullExpression(top.parent)) + ) + top = top.parent; + const p = top.parent; + if (p && ts.isPropertyAccessExpression(p) && p.expression === top && p.name.text === "length") + found = true; + return undefined; + }); + return found; +} + /** A union of record layouts flowing into one record slot: a function * returning differently-shaped object literals (`{ ok: false }` and * `{ ok: true, size }`, whose union the checker normalizes to arms with diff --git a/packages/compiler/src/frontend/lowering/typed-arguments.ts b/packages/compiler/src/frontend/lowering/typed-arguments.ts index ebddcafb31..b52a3e059b 100644 --- a/packages/compiler/src/frontend/lowering/typed-arguments.ts +++ b/packages/compiler/src/frontend/lowering/typed-arguments.ts @@ -321,7 +321,9 @@ export function forwardedTupleType(lowerer: Lowerer, node: ts.Expression): IrTyp // Checker-only probe first: an ordinary array rest never maps here. restTupleElementTypes(lowerer.typeOf(operand), lowerer.typeCtx) !== null ) { - const t = lowerer.mapTypeOf(lowerer.typeOf(operand)); + // A tuple with optional positions has no value type of its own; its + // forwarded-only binding carries the slots' tuple (declareRestTupleParam). + const t = lowerer.mapTypeOf(lowerer.typeOf(operand)) ?? lowerer.peekLocal(operand)?.type; if (t && isTupleValueType(lowerer, t)) return t; } return null; diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 0bb31ad03c..a1292ad6a7 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -4670,7 +4670,9 @@ function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null // A parameter a TypeScript program types `any` takes whatever the // caller passes: the checked-dynamic slot, as an `any`-typed binding // already is (dynFallbackType). Declaration files keep their paths. - // Only function declarations and spelled function types: lambdas and + // Only function declarations, spelled function types and lambda + // parameters whose default is the only source of their type (`(n = + // lookup() ?? 5) => ...` with an `any` lookup): other lambdas and // methods in object literals keep their existing checked-dynamic story. if ( !pt && @@ -4678,7 +4680,12 @@ function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null decl !== undefined && ts.isParameter(decl) && decl.parent !== undefined && - (ts.isFunctionDeclaration(decl.parent) || ts.isFunctionTypeNode(decl.parent)) && + (ts.isFunctionDeclaration(decl.parent) || + ts.isFunctionTypeNode(decl.parent) || + ((ts.isArrowFunction(decl.parent) || ts.isFunctionExpression(decl.parent)) && + decl.initializer !== undefined && + decl.type === undefined && + !isJsSourceFile(decl.getSourceFile()))) && !decl.getSourceFile().isDeclarationFile && (checker.getTypeOfSymbol(p).flags & ts.TypeFlags.Any) !== 0 ) @@ -6147,6 +6154,7 @@ function mapErrorMetadataIntersection(type: ts.Type, ctx: TypeMapperCtx): IrType }); if (!hasError) return null; let error: IrType | null = null; + const extra: ts.Symbol[] = []; for (const part of parts) { const mapped = mapType(part, ctx); if (mapped?.kind === "object" && RUNTIME_ERROR_CLASSES.has(mapped.className)) { @@ -6167,13 +6175,44 @@ function mapErrorMetadataIntersection(type: ts.Type, ctx: TypeMapperCtx): IrType : ["code", "syscall", "path", "dest"].includes(property.name) ? "string" : null; - if (!expected || !(property.flags & ts.SymbolFlags.Optional)) return null; + if (!expected) { + extra.push(property); + continue; + } const field = mapType(ctx.checker.getTypeOfSymbol(property), ctx); const arms = field?.kind === "union" ? ctx.unions.get(field.unionId)?.arms : field ? [field] : []; if (!arms?.length || !arms.every((arm) => arm.kind === expected || arm.kind === "undefinedT")) return null; + // A required member must still admit the absent value the slot reads. + if ( + !(property.flags & ts.SymbolFlags.Optional) && + !arms.some((arm) => arm.kind === "undefinedT") + ) + return null; + } + } + if (!error) return null; + // Members beyond Error's own and the system-error metadata (`Error & { + // status: number }`, Object.assign's result) are own properties of the + // error's checked-dynamic node: each must map to a type a checked read + // can produce. Error's own string members keep their slots. + for (const property of extra) { + const field = mapType(ctx.checker.getTypeOfSymbol(property), ctx); + if (!field || field.kind === "void") return null; + if (property.name === "message" || property.name === "name") { + if (field.kind !== "string") return null; + continue; } + if (property.name === "stack" || property.name === "cause") return null; + if ( + !canDynCheckTo( + field, + (id) => ctx.shapes.get(id), + (id) => ctx.unions.get(id), + ) + ) + return null; } return error; } diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index ee65a32a52..f42885043f 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -422,6 +422,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "error.stack", "error.cause", "error.setCause", + "error.assign", "error.deleteCause", "dyn.objectTag", // Numeric coercion runs user valueOf/toString — throws propagate. diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 9a66b0d68b..714903788a 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3258,6 +3258,23 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "error.hasCause": { argTypes: [null], result: BOOL }, /** Assignment borrows both operands and retains the new cause. */ "error.setCause": { argTypes: [null, DYN], result: VOID }, + /** `err.code = s` on an error-hierarchy receiver: the code slot that + * error.code reads and coded throws stamp. Borrows both; never throws. */ + "error.setCode": { argTypes: [null, STRING], result: VOID }, + /** `err.code = v` with a value that is not statically a string: strings + * take the code slot, anything else clears it and sets the dynamic own + * property. Borrows both; never throws. */ + "error.setCodeDyn": { argTypes: [null, DYN], result: VOID }, + /** `Object.assign(err, ...sources)` onto an error-hierarchy receiver + * (args: the error, a dyn array of the sources): the own-member copy + * onto its checked-dynamic node, then the typed slots (name, message, + * stack, cause, code) re-read from it. Throws what the copy throws. */ + "error.assign": { argTypes: [null, DYN], result: VOID }, + /** `key in err` for a key no compiled class of the receiver declares + * (args: the error, the key): a runtime-set own property (Object.assign, + * throw-site fields, dynamic writes) or an Object.prototype member. */ + "error.hasKey": { argTypes: [null, STRING], result: BOOL }, + "error.hasCode": { argTypes: [null], result: BOOL }, "error.defineCause": { argTypes: [null, DYN], result: VOID }, "error.deleteCause": { argTypes: [null], result: VOID }, /** The compiler-resolved Node-parity throw for always-throwing lowered diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index d6a4a09b4b..835dfa8baf 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4113,6 +4113,11 @@ function validateFunction( e.fn === "error.cause" || e.fn === "error.hasCause" || e.fn === "error.setCause" || + e.fn === "error.setCode" || + e.fn === "error.setCodeDyn" || + e.fn === "error.assign" || + e.fn === "error.hasKey" || + e.fn === "error.hasCode" || e.fn === "error.defineCause" || e.fn === "error.deleteCause" ) { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6400-override-any-base.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6400-override-any-base.ts.json new file mode 100644 index 0000000000..a885a5175d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6400-override-any-base.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6400-override-any-base.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6401-override-param-variance.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6401-override-param-variance.ts.json new file mode 100644 index 0000000000..ee14b5af3b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6401-override-param-variance.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6401-override-param-variance.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6402-override-js-base/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6402-override-js-base/main.ts.json new file mode 100644 index 0000000000..3ac1bf1fed --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6402-override-js-base/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6402-override-js-base/pool.js", + "/tests/corpus/6402-override-js-base/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6403-error-code-write.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6403-error-code-write.ts.json new file mode 100644 index 0000000000..b673f79206 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6403-error-code-write.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6403-error-code-write.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6404-error-in-and-assign.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6404-error-in-and-assign.ts.json new file mode 100644 index 0000000000..c880bc57ba --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6404-error-in-and-assign.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6404-error-in-and-assign.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6405-wrapped-default-parameters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6405-wrapped-default-parameters.ts.json new file mode 100644 index 0000000000..8be83f858e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6405-wrapped-default-parameters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6405-wrapped-default-parameters.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6406-npm-instance-receiver/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6406-npm-instance-receiver/main.ts.json new file mode 100644 index 0000000000..e651808f7f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6406-npm-instance-receiver/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6406-npm-instance-receiver/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6409-catch-binding-shapes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6409-catch-binding-shapes.ts.json new file mode 100644 index 0000000000..57e3aeaf1d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6409-catch-binding-shapes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6409-catch-binding-shapes.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6410-wrapper-untyped-results.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6410-wrapper-untyped-results.ts.json new file mode 100644 index 0000000000..20c1be33a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6410-wrapper-untyped-results.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6410-wrapper-untyped-results.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6411-prototype-reset-constructors/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6411-prototype-reset-constructors/main.ts.json new file mode 100644 index 0000000000..adfb3989d7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6411-prototype-reset-constructors/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6411-prototype-reset-constructors/issues.js", + "/tests/corpus/6411-prototype-reset-constructors/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6412-js-error-tostring-override/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6412-js-error-tostring-override/main.ts.json new file mode 100644 index 0000000000..145c024282 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6412-js-error-tostring-override/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6412-js-error-tostring-override/errors.js", + "/tests/corpus/6412-js-error-tostring-override/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6413-override-js-async-base/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6413-override-js-async-base/main.ts.json new file mode 100644 index 0000000000..de5fe5afbd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6413-override-js-async-base/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6413-override-js-async-base/lock.js", + "/tests/corpus/6413-override-js-async-base/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6414-catch-values-roundtrip.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6414-catch-values-roundtrip.ts.json new file mode 100644 index 0000000000..797bd8ca20 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6414-catch-values-roundtrip.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6414-catch-values-roundtrip.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6415-error-code-dynamic-writes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6415-error-code-dynamic-writes.ts.json new file mode 100644 index 0000000000..6500a5b3ba --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6415-error-code-dynamic-writes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6415-error-code-dynamic-writes.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_error.h b/packages/runtime/src/scr_error.h index 453ebf3e14..a7c921f236 100644 --- a/packages/runtime/src/scr_error.h +++ b/packages/runtime/src/scr_error.h @@ -282,6 +282,21 @@ SCR_COLD void scr_undef_global_read(ScrStr *name); * +1 or NULL when absent (the compiler's undefined arm). The _msg_code * thrower is scr_throw_error_msg with the code stamped on the payload. */ void scr_error_set_code(ScrError *e, const char *code); +/* The program's `err.code = s` write into the same slot (and the error's + * checked-dynamic node, when it has one). Borrows both (scr_json.c). */ +void scr_error_set_code_value(ScrError *e, ScrStr *code); +/* `err.code = v` with a value that is not statically a string: a string + * takes the slot as above; anything else clears the slot and lands on the + * error's checked-dynamic node, as a dynamic property write would. + * Borrows both (scr_json.c). */ +void scr_error_set_code_dyn(ScrError *e, struct ScrDyn *value); +/* Object.assign(err, ...sources): the copy onto the error's checked-dynamic + * node, then the typed slots re-read from it. Borrows both (scr_json.c). */ +void scr_error_assign_dyn(ScrError *e, const struct ScrDyn *sources); +/* `key in err` for a key no compiled class declares: a runtime-set own + * property or an Object.prototype member. Borrows both (scr_json.c). */ +bool scr_error_has_key(ScrError *e, const ScrStr *key); +bool scr_error_has_code(ScrError *e); const char *scr_error_message_code(const ScrStr *msg); ScrStr *scr_error_code(ScrError *e); SCR_COLD void scr_throw_error_msg_code(int kind, const char *message, size_t len, const char *code); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index f2b4696676..9746e8eb11 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4428,6 +4428,7 @@ static void scr_dyn_descriptor_fields_drop(ScrDyn **fields) { } static bool scr_dyn_key_is_index(const char *key, size_t len, double *out); static void scr_error_sync_cause(ScrDyn *view, const ScrStr *key); +static void scr_error_sync_slots(const ScrDyn *view, ScrDyn *obj, const ScrStr *key); /* Property tables belong to closures, not temporary checked wrappers. Seed * the configurable, non-writable builtin members once so redefinitions and @@ -6719,6 +6720,10 @@ ScrStr *scr_dyn_format_j(const ScrDyn *d) { typedef struct { ScrError *err; /* retained (pins the address — no pointer reuse) */ ScrDyn *dyn; /* retained */ + /* An own `code` that is not a string (`err.code = undefined`), retained, + * or NULL. The code slot holds only strings, and a subclass's typed view + * rebuilds its properties from the slots on every read. */ + ScrDyn *code; } ScrErrDynEnt; static SCR_TL ScrErrDynEnt *scr_errdyn_cache = NULL; @@ -6822,6 +6827,7 @@ static void scr_errdyn_sweep(void) { for (size_t i = 0; i < n_dead; i++) { scr_error_release(dead[i].err); scr_errdyn_release_node(dead[i].dyn); + scr_dyn_release(dead[i].code); } free(dead); } @@ -6842,6 +6848,7 @@ static void scr_errdyn_append(ScrError *e, ScrDyn *d) { } scr_errdyn_cache[scr_errdyn_n].err = scr_error_retain(e); scr_errdyn_cache[scr_errdyn_n].dyn = scr_dyn_retain(d); + scr_errdyn_cache[scr_errdyn_n].code = NULL; scr_errdyn_n++; if (scr_errdyn_n * 2 > scr_errdyn_slots) { free(scr_errdyn_by_err); @@ -6897,6 +6904,7 @@ static void scr_errdyn_teardown(void) { for (size_t i = 0; i < n; i++) { scr_error_release(cache[i].err); scr_errdyn_release_node(cache[i].dyn); + scr_dyn_release(cache[i].code); } free(cache); } @@ -7048,7 +7056,13 @@ ScrDyn *scr_error_dyn_fields(const ScrError *e) { /* errno rides fs errors (with syscall) and zlib errors (without). */ if (e->system_call || e->system_errno != 0) scr_dyn_obj_set(d, "errno", 5, scr_dyn_new_num(e->system_errno)); - if (e->code) scr_dyn_obj_set(d, "code", 4, scr_dyn_new_str(e->code)); + if (e->code) { + scr_dyn_obj_set(d, "code", 4, scr_dyn_new_str(e->code)); + } else { + size_t hit = scr_errdyn_find(e, true); + if (hit != SIZE_MAX && scr_errdyn_cache[hit].code != NULL) + scr_dyn_obj_set(d, "code", 4, scr_dyn_retain(scr_errdyn_cache[hit].code)); + } if (e->system_call) scr_dyn_obj_set(d, "syscall", 7, scr_dyn_new_str(e->system_call)); } if (!e->errno_order && e->system_path) scr_dyn_obj_set(d, "path", 4, scr_dyn_new_str(e->system_path)); @@ -7250,18 +7264,35 @@ ScrError *scr_errdyn_err_of(const ScrDyn *d) { void scr_errdyn_put(ScrError *e, ScrDyn *d) { scr_errdyn_append(e, d); } -/* Error.cause has one live value even when the Error already crossed a - * checked-value boundary. Reflective data-property edits update its slot. */ -static void scr_error_sync_cause(ScrDyn *view, const ScrStr *key) { +/* An Error's slots (name, message, stack, cause, code) have one live value + * even when the Error already crossed a checked-value boundary: writes and + * deletes of those own properties through its checked-dynamic node update + * the slots typed reads use. `view` is the error's node; `obj` holds the + * properties (the node itself, or a typed view's materialized object). */ +static void scr_error_sync_slots(const ScrDyn *view, ScrDyn *obj, const ScrStr *key) { bool cause = key->len == 5 && memcmp(key->data, "cause", 5) == 0; bool stack = key->len == 5 && memcmp(key->data, "stack", 5) == 0; bool name = key->len == 4 && memcmp(key->data, "name", 4) == 0; bool message = key->len == 7 && memcmp(key->data, "message", 7) == 0; - if (!cause && !stack && !name && !message) return; + bool code = key->len == 4 && memcmp(key->data, "code", 4) == 0; + if (!cause && !stack && !name && !message && !code) return; + if (obj == NULL || obj->kind != SCR_DYN_OBJ) return; size_t hit = scr_errdyn_find(view, false); if (hit == SIZE_MAX) return; - ScrDynEntry *entry = scr_dyn_entry(view, key); + ScrDynEntry *entry = scr_dyn_entry(obj, key); ScrError *e = scr_errdyn_cache[hit].err; + if (code) { + /* A string takes the slot; any other value clears it and stays an own + * property, which the entry keeps for views rebuilt from the slots. */ + bool string = entry && !entry->accessor && entry->value->kind == SCR_DYN_STR; + ScrStr *next = string ? scr_str_retain(entry->value->v.str) : NULL; + scr_str_release(e->code); + e->code = next; + ScrDyn *other = entry && !string && !entry->accessor ? scr_dyn_retain(entry->value) : NULL; + scr_dyn_release(scr_errdyn_cache[hit].code); + scr_errdyn_cache[hit].code = other; + return; + } if (name || message) { /* The native error's toString and inspect read these fields. */ if (!entry || entry->accessor || entry->value->kind != SCR_DYN_STR) return; @@ -7292,6 +7323,10 @@ static void scr_error_sync_cause(ScrDyn *view, const ScrStr *key) { e->cause_enumerable = entry && entry->enumerable; } +static void scr_error_sync_cause(ScrDyn *view, const ScrStr *key) { + scr_error_sync_slots(view, view, key); +} + /* Error.prototype.toString over the checked-dynamic error encoding (an * object carrying the reserved "%error" marker): `name` defaults to "Error" * and `message` to "", both read through the prototype; either empty leaves @@ -8323,7 +8358,12 @@ void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value) { if (scr_event_ref_hooks && scr_event_ref_hooks->set(recv, key, value)) return; ScrDyn *materialized = scr_dyn_typed_ref_materialize(recv); scr_dyn_key_set(materialized, key, value); - if (!scr_exc_pending()) scr_dyn_typed_ref_commit(recv); + if (!scr_exc_pending()) { + scr_dyn_typed_ref_commit(recv); + /* An Error subclass's class commit writes its declared fields; the + * slots it inherits from Error take the write here. */ + scr_error_sync_slots(recv, materialized, key); + } scr_dyn_release(materialized); return; } @@ -10097,7 +10137,10 @@ void scr_dyn_key_delete(ScrDyn *recv, const ScrStr *key, bool strict) { scr_dyn_entry(materialized, key) != NULL; if (!scr_exc_pending()) scr_dyn_key_delete(materialized, key, strict); bool deleted = own && !scr_exc_pending(); - if (!scr_exc_pending()) scr_dyn_typed_ref_commit(recv); + if (!scr_exc_pending()) { + scr_dyn_typed_ref_commit(recv); + scr_error_sync_slots(recv, materialized, key); + } scr_dyn_release(materialized); if (deleted && scr_exc_pending()) { /* The native layout requires this field, so the commit rejected the @@ -12478,7 +12521,17 @@ ScrDyn *scr_dyn_assign(ScrDyn *target, const ScrDyn *src) { if (target->kind == SCR_DYN_TYPED_REF) { ScrDyn *materialized = scr_dyn_typed_ref_materialize(target); ScrDyn *assigned = scr_dyn_assign(materialized, src); - if (!scr_exc_pending()) scr_dyn_typed_ref_commit(target); + if (!scr_exc_pending()) { + scr_dyn_typed_ref_commit(target); + /* An Error subclass's inherited slots take the copied values. */ + static const char *const slots[] = { "name", "message", "stack", "cause", "code" }; + for (size_t i = 0; i < sizeof slots / sizeof slots[0]; i++) { + ScrStr *key = scr_str_new(slots[i], strlen(slots[i])); + if (materialized->kind == SCR_DYN_OBJ && scr_dyn_entry(materialized, key) != NULL) + scr_error_sync_slots(target, materialized, key); + scr_str_release(key); + } + } scr_dyn_release(assigned); scr_dyn_release(materialized); return scr_exc_pending() ? NULL : scr_dyn_retain(target); @@ -14603,3 +14656,80 @@ ScrDyn *scr_dyn_iter_take(const ScrDyn *src, size_t n, const char *spell) { bool scr_dyn_iterates_by_protocol(const ScrDyn *value) { return scr_dyn_sync_generator(value); } + +/* The program's writes onto compiled errors (scr_error.h). Kept at the end + * of the file, after every function they call, so the rest of the unit + * emits in its existing order. */ + +/* `err.code = s` from the program (an Error asserted to carry `code`): + * the same slot every coded throw stamps, so reads and `'code' in err` + * observe the write. Borrows both. */ +void scr_error_set_code_value(ScrError *e, ScrStr *code) { + scr_str_release(e->code); + e->code = scr_str_retain(code); + /* An error that already crossed into the checked-dynamic tree renders + * and reads through that node, so the write lands there too. */ + ScrDyn *view = scr_errdyn_view_of(e); + if (view != NULL && view->kind == SCR_DYN_OBJ) scr_dyn_obj_set(view, "code", 4, scr_dyn_new_str(code)); +} + +void scr_error_set_code_dyn(ScrError *e, ScrDyn *value) { + if (value != NULL && value->kind == SCR_DYN_STR) { + scr_error_set_code_value(e, value->v.str); + return; + } + scr_str_release(e->code); + e->code = NULL; + ScrDyn *view = scr_dyn_from_error(e); + ScrStr *key = scr_str_new("code", 4); + scr_dyn_key_set(view, key, value); + scr_str_release(key); + scr_dyn_release(view); +} + +/* Object.assign(err, ...sources) onto a compiled error: each source's own + * members land on the error's checked-dynamic node, then the slots that + * typed reads use (name, message, stack, cause, code) take the result. + * Borrows both; throws what the copy throws (a nullish source is skipped, + * as in JS). */ +void scr_error_assign_dyn(ScrError *e, const ScrDyn *sources) { + ScrDyn *view = scr_dyn_from_error(e); + ScrDyn *r = scr_dyn_assign_all(view, sources); + if (r != NULL) { + scr_dyn_release(r); + if (view->kind == SCR_DYN_OBJ) { + scr_error_commit_dyn(e, view); + if (!scr_exc_pending()) { + ScrDyn *code = scr_dyn_obj_get(view, "code", 4); + ScrStr *next = code != NULL && code->kind == SCR_DYN_STR ? scr_str_retain(code->v.str) : NULL; + scr_str_release(e->code); + e->code = next; + } + } + } + scr_dyn_release(view); +} + +/* `'code' in err`: the code slot holds a string, or the error has an own + * `code` property with another value (`err.code = undefined`), which lives + * on its checked-dynamic node. Borrows; never throws. */ +bool scr_error_has_code(ScrError *e) { + if (e->code != NULL) return true; + const ScrDyn *view = scr_errdyn_view_of(e); + if (view == NULL) return false; + ScrStr *key = scr_str_new("code", 4); + bool own = scr_dyn_has_own(view, key); + scr_str_release(key); + return own; +} + +/* `key in err` for a key no compiled class of the receiver declares: an own + * property set at runtime, which lives on the error's checked-dynamic node + * (that node exists once anything reflected or wrote through it), or a + * member of Object.prototype. Borrows both; never throws. */ +bool scr_error_has_key(ScrError *e, const ScrStr *key) { + const ScrDyn *view = scr_errdyn_view_of(e); + if (view != NULL && view->kind == SCR_DYN_OBJ && scr_dyn_find_entry(view, key->data, key->len) != NULL) + return true; + return scr_dyn_object_proto_name(key); +} diff --git a/tests/corpus/6400-override-any-base.ts b/tests/corpus/6400-override-any-base.ts new file mode 100644 index 0000000000..72b050425f --- /dev/null +++ b/tests/corpus/6400-override-any-base.ts @@ -0,0 +1,35 @@ +// Overrides that narrow an `any`-typed base result: a typed async override +// over an untyped base method, and a sync override returning a union. +class Pool { + name = "pool"; + acquire(): any { + return Promise.resolve("base"); + } + describe(): any { + return this.name; + } +} +class MeteredPool extends Pool { + waits = 0; + override async acquire(): Promise { + this.waits++; + await super.acquire(); + } + override describe(): string | undefined { + return this.waits > 1 ? `metered ${this.waits}` : undefined; + } +} +async function use(pool: Pool, label: string): Promise { + const got = await pool.acquire(); + console.log(label, got === undefined ? "undefined" : got, String(pool.describe())); +} +async function main(): Promise { + const metered = new MeteredPool(); + await use(new Pool(), "plain"); + await use(metered, "metered"); + await metered.acquire(); + console.log(metered.waits, metered.describe()); + const pools: Pool[] = [new Pool(), metered]; + for (const p of pools) console.log(typeof p.describe()); +} +main(); diff --git a/tests/corpus/6401-override-param-variance.ts b/tests/corpus/6401-override-param-variance.ts new file mode 100644 index 0000000000..997207af8d --- /dev/null +++ b/tests/corpus/6401-override-param-variance.ts @@ -0,0 +1,33 @@ +// Overrides whose parameters differ from the base: a typed parameter over +// an `any` slot, a wider (contravariant) parameter, and a narrowed result. +class Handler { + handle(input: any): any { + return `base:${input}`; + } + score(value: string): number | undefined { + return value.length; + } +} +class TypedHandler extends Handler { + override handle(input: string): string { + return `typed:${input.toUpperCase()}`; + } + override score(value: string | number): number { + return typeof value === "string" ? value.length * 10 : value; + } +} +function run(h: Handler): void { + console.log(h.handle("abc"), h.score("xy")); +} +run(new Handler()); +run(new TypedHandler()); +const typed = new TypedHandler(); +console.log(typed.handle("q"), typed.score(7)); +try { + run2(new TypedHandler()); +} catch (e) { + console.log("caught", (e as Error).name); +} +function run2(h: Handler): void { + console.log(h.handle(42)); +} diff --git a/tests/corpus/6402-override-js-base/main.ts b/tests/corpus/6402-override-js-base/main.ts new file mode 100644 index 0000000000..6821f09dd0 --- /dev/null +++ b/tests/corpus/6402-override-js-base/main.ts @@ -0,0 +1,26 @@ +// TypeScript overrides of an untyped JavaScript base: a typed async +// override over an untyped method, and a narrowed toString. +import { Pool } from "./pool.js"; + +class MeteredPool extends Pool { + waits = 0; + override async acquire(): Promise { + this.waits++; + const n: number = await super.acquire(); + return n * 10; + } + override toString(): string { + return `Metered(${this.waits})`; + } +} +async function main(): Promise { + const pools: Pool[] = [new Pool(2), new MeteredPool(3)]; + for (const pool of pools) { + const got = await pool.acquire(); + console.log(String(got), pool.toString(), `${pool}`); + pool.release(); + } + const metered = new MeteredPool(1); + console.log(await metered.acquire(), metered.waits); +} +main(); diff --git a/tests/corpus/6402-override-js-base/pool.js b/tests/corpus/6402-override-js-base/pool.js new file mode 100644 index 0000000000..d1a3378826 --- /dev/null +++ b/tests/corpus/6402-override-js-base/pool.js @@ -0,0 +1,17 @@ +// An untyped JavaScript base class, the shape of a small npm semaphore. +export class Pool { + constructor(size) { + this.size = size; + this.taken = 0; + } + async acquire() { + this.taken++; + return this.taken; + } + release() { + this.taken--; + } + toString() { + return `Pool(${this.taken}/${this.size})`; + } +} diff --git a/tests/corpus/6403-error-code-write.ts b/tests/corpus/6403-error-code-write.ts new file mode 100644 index 0000000000..3d6efc2a0e --- /dev/null +++ b/tests/corpus/6403-error-code-write.ts @@ -0,0 +1,39 @@ +// Own properties written on Error values typed with an intersection. +const coded = new Error("c") as Error & { code?: string }; +coded.code = "E1"; +console.log(coded.code, "code" in coded); +function has(err: Error): boolean { + return "code" in err; +} +console.log(has(coded), has(new Error("plain"))); +try { + throw coded; +} catch (e) { + console.log((e as Error & { code?: string }).code); +} + +// System-error members first, then the code: reads stay consistent. +type SysError = Error & { code?: string; errno?: number; syscall?: string; path?: string }; +function sysError(message: string, code: string | undefined): SysError { + const err = new Error(message) as SysError; + err.errno = -2; + err.syscall = "open"; + err.path = "/missing"; + err.code = code; + return err; +} +const enoent = sysError("no such file", "ENOENT"); +console.log(enoent.code, enoent.errno, enoent.syscall, enoent.path, "code" in enoent); +console.log(enoent.message, enoent instanceof Error); +const uncoded = sysError("no code", undefined); +console.log(uncoded.code, uncoded.syscall); + +// A code that is only known at runtime. +function withCode(err: Error & { code?: string }, code: unknown): void { + err.code = code as string; +} +const late = new Error("late") as Error & { code?: string }; +withCode(late, "ELATE"); +console.log(late.code); +late.code = "EAGAIN"; +console.log(late.code, late.message); diff --git a/tests/corpus/6404-error-in-and-assign.ts b/tests/corpus/6404-error-in-and-assign.ts new file mode 100644 index 0000000000..a9bb6d086b --- /dev/null +++ b/tests/corpus/6404-error-in-and-assign.ts @@ -0,0 +1,144 @@ +// `'key' in err` on Error values of every receiver form, and +// Object.assign onto a fresh Error. +class HttpError extends Error { + status = 404; + get retryable(): boolean { + return this.status >= 500; + } + describe(): string { + return `${this.status}`; + } +} + +// A receiver narrowed from unknown by instanceof. +function statusOf(value: unknown): unknown { + if (value instanceof Error) { + return "status" in value ? value.status : "none"; + } + return "not an error"; +} +console.log(statusOf(new HttpError("not found")), statusOf(new Error("plain")), statusOf("text")); + +// Parameters, call results and caught values; members a subclass declares +// as fields, accessors and methods; Error.prototype's own members. +function keys(err: Error): string { + return [ + "status" in err, + "retryable" in err, + "describe" in err, + "toString" in err, + "missing" in err, + "message" in err, + ].join(" "); +} +console.log(keys(new HttpError("h"))); +console.log(keys(new Error("e"))); +console.log(keys(new TypeError("t"))); +function latest(): Error { + return new HttpError("latest"); +} +console.log("status" in latest(), "message" in latest()); +function caught(fn: () => void): string { + try { + fn(); + return "ok"; + } catch (e) { + if (e instanceof Error && "status" in e) return `status ${String(e.status)}`; + return "plain"; + } +} +console.log( + caught(() => { + throw new HttpError("t"); + }), + caught(() => { + throw new Error("u"); + }), +); + +// Properties set at runtime are own properties too. +const expando = new Error("x"); +(expando as unknown as { status: number }).status = 1; +console.log(keys(expando)); + +// Object.assign copies the source's own properties onto the error and +// answers the error itself. +interface ErrorLike { + message: string; + status?: number; + code?: string; +} +function toError(error: ErrorLike | string): Error { + return typeof error === "string" + ? new Error(error) + : Object.assign(new Error(error.message), error); +} +const assigned = toError({ message: "boom", status: 500, code: "EBOOM" }); +console.log(assigned.message, (assigned as Error & { code?: string }).code, "status" in assigned); +console.log(statusOf(assigned), assigned instanceof Error, keys(assigned)); +const renamed = toError({ message: "renamed" }); +console.log(renamed.message, "status" in renamed, toError("text").message); +const fresh = new Error("before"); +console.log(Object.assign(fresh, { message: "after" }) === fresh, fresh.message); + +// An Error intersection carrying extra fields: Object.assign's result, +// read and written through its declared members. +interface ApiErrorFields { + status: number; + action: string | undefined; + code: string | undefined; + link: string | undefined; +} +function apiError(message: string, fields: ApiErrorFields) { + const error = Object.assign(new Error(message), fields); + return error; +} +const gateway = apiError("bad gateway", { status: 502, action: undefined, code: "E502", link: "https://example.com" }); +console.log(gateway.message, gateway.status, gateway.code, gateway.link, "action" in gateway, gateway.action); +gateway.status = 503; +console.log(gateway.status, gateway instanceof Error); +Object.assign(gateway, { status: 504, code: "E504" }); +console.log(gateway.status, gateway.code); +function describeApiError(error: Error & { status?: number }): string { + return `${error.message}:${error.status ?? "none"}`; +} +console.log(describeApiError(gateway), describeApiError(new Error("plain"))); + +// Object.assign into a record whose fields are wider than the source's. +interface BuildFailure { + message: string; + bail?: boolean; + code?: string; +} +function failure(message: string, bail: boolean): BuildFailure { + const err: BuildFailure = { message }; + if (bail) Object.assign(err, { bail: true }); + return Object.assign(err, { code: "E_BUILD" }); +} +for (const f of [failure("stop", true), failure("go", false)]) + console.log(f.message, f.bail, f.code); + +// A record given a member its type lacks and thrown at once. +interface FailureRecord { + from: string; + isGraceful: boolean; + errorValue: unknown; +} +function classifyFailure(err: unknown, from: string): FailureRecord { + return { from, isGraceful: from === "user", errorValue: err }; +} +function handleFailure(err: unknown, from: string): string { + const failure = classifyFailure(err, from); + if (failure.from === "build") { + throw Object.assign(failure, { bail: true }); + } + return `${failure.from} ${failure.isGraceful}`; +} +for (const from of ["build", "user"]) { + try { + console.log("handled", handleFailure(new Error("x"), from)); + } catch (e) { + const thrownRecord = e as { from: string; bail?: boolean; isGraceful: boolean }; + console.log("thrown", thrownRecord.from, thrownRecord.bail, thrownRecord.isGraceful); + } +} diff --git a/tests/corpus/6405-wrapped-default-parameters.ts b/tests/corpus/6405-wrapped-default-parameters.ts new file mode 100644 index 0000000000..f607eb885e --- /dev/null +++ b/tests/corpus/6405-wrapped-default-parameters.ts @@ -0,0 +1,67 @@ +// Lambdas whose parameters take their type from an `any` default, passed +// through generic wrappers that forward their arguments. +function trace any>(name: string, fn: F): F { + return ((...args: Parameters) => { + console.log("span", name); + return fn(...args); + }) as F; +} +function traceArgs(name: string, fn: (...args: A) => R): (...args: A) => R { + return (...args: A) => { + console.log("span", name); + return fn(...args); + }; +} +function lookup(key: string): any { + return key === "set" ? 7 : undefined; +} + +const doubled = trace("doubled", (n = lookup("unset") ?? 5) => n * 2); +console.log(doubled(), doubled(4)); +const sum = trace("sum", (n = lookup("set") ?? 5, m = 1) => n * 4 + m); +console.log(sum(), sum(2), sum(2, 3)); +const label = trace("label", (prefix = lookup("unset") ?? "item", count: number = 0) => `${prefix}#${count}`); +console.log(label(), label("row"), label(undefined, 3)); +const tripled = traceArgs("tripled", (n: number = lookup("unset") ?? 5) => n * 3); +console.log(tripled(), tripled(2)); + +// A wrapper that also reports tags computed from the wrapped function's +// result, handing its untyped result on through `as never`. +function traceTagged any>( + name: string, + fn: F, + resultTags?: ( + result: ReturnType extends PromiseLike ? U : ReturnType, + ) => Record, +): F { + return ((...args: Parameters) => { + const result: unknown = fn(...args); + if (resultTags) console.log(name, JSON.stringify(resultTags(result as never))); + return result; + }) as F; +} +const summarize = traceTagged( + "summarize", + (limit = lookup("unset") ?? 2) => ({ kept: Number(limit), dropped: 1 }), + (r) => ({ total: r.kept + r.dropped }), +); +console.log(summarize().kept, summarize(5).kept); + +// The same through a function expression whose result is untyped. +function traceTaggedAny any>( + name: string, + fn: F, + resultTags: (result: ReturnType) => Record, +): F { + return function (this: unknown, ...args: Parameters) { + const result = fn.apply(this, args); + console.log(name, JSON.stringify(resultTags(result as never))); + return result; + } as F; +} +const measure = traceTaggedAny( + "measure", + (width = lookup("set") ?? 1, height = 2) => ({ area: Number(width) * height }), + (r) => ({ area: r.area }), +); +console.log(measure().area, measure(3, 4).area); diff --git a/tests/corpus/6406-npm-instance-receiver/main.ts b/tests/corpus/6406-npm-instance-receiver/main.ts new file mode 100644 index 0000000000..3dc8935489 --- /dev/null +++ b/tests/corpus/6406-npm-instance-receiver/main.ts @@ -0,0 +1,7 @@ +// A package's constructor function, compiled per call site, reads its own +// receiver while the constructor that reached it has a receiver too. +import { create } from "corked-writer"; +const w = create({ highWaterMark: 4 }); +console.log(w.uncork()); +console.log(w.uncork()); +console.log(create().uncork()); diff --git a/tests/corpus/6406-npm-instance-receiver/node_modules/corked-writer/index.d.ts b/tests/corpus/6406-npm-instance-receiver/node_modules/corked-writer/index.d.ts new file mode 100644 index 0000000000..1de006a3da --- /dev/null +++ b/tests/corpus/6406-npm-instance-receiver/node_modules/corked-writer/index.d.ts @@ -0,0 +1,4 @@ +export interface Writable { + uncork(): string; +} +export function create(options?: { highWaterMark?: number }): Writable; diff --git a/tests/corpus/6406-npm-instance-receiver/node_modules/corked-writer/index.js b/tests/corpus/6406-npm-instance-receiver/node_modules/corked-writer/index.js new file mode 100644 index 0000000000..6a7e75541e --- /dev/null +++ b/tests/corpus/6406-npm-instance-receiver/node_modules/corked-writer/index.js @@ -0,0 +1,45 @@ +'use strict'; + +// Constructor functions in the shape of a stream implementation: a request +// object built inside another constructor keeps its receiver in a var for +// a callback, and the writer is also exported as a value. + +function CorkedRequest(state) { + var _this = this; + this.next = null; + this.entry = null; + this.finish = function () { + onCorkedFinish(_this, state); + }; +} +module.exports = { + create: function (options) { + return new Writable(options); + }, + Writable: Writable, +}; +Writable.WritableState = WritableState; +function WritableState(options, stream) { + this.highWaterMark = options.highWaterMark || 16; + this.pending = 2; + this.stream = stream; + this.corkedRequestsFree = new CorkedRequest(this); +} +WritableState.prototype.getBuffer = function getBuffer() { + return this.pending; +}; +function Writable(options) { + this._writableState = new WritableState(options || {}, this); + this.label = "w"; +} +Writable.prototype.uncork = function () { + var state = this._writableState; + var req = state.corkedRequestsFree; + req.finish(); + state.corkedRequestsFree = new CorkedRequest(state); + return this.label + " " + req.next + " " + state.highWaterMark; +}; +function onCorkedFinish(corkReq, state) { + state.pending--; + corkReq.next = "finished:" + state.pending; +} diff --git a/tests/corpus/6406-npm-instance-receiver/node_modules/corked-writer/package.json b/tests/corpus/6406-npm-instance-receiver/node_modules/corked-writer/package.json new file mode 100644 index 0000000000..94a00403d0 --- /dev/null +++ b/tests/corpus/6406-npm-instance-receiver/node_modules/corked-writer/package.json @@ -0,0 +1 @@ +{ "name": "corked-writer", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6409-catch-binding-shapes.ts b/tests/corpus/6409-catch-binding-shapes.ts new file mode 100644 index 0000000000..930c41f38b --- /dev/null +++ b/tests/corpus/6409-catch-binding-shapes.ts @@ -0,0 +1,69 @@ +// Catch bindings typed any, multi-statement guards over catch bindings, +// and writes into a caught error. +interface SafeFetchError extends Error { + kind: "safe-fetch"; + host: string; +} +function isSafeFetchError(e: unknown): e is SafeFetchError { + if (!(e instanceof Error)) return false; + return "kind" in e && e.kind === "safe-fetch"; +} +function blocked(host: string): Error { + return Object.assign(new Error(`blocked ${host}`), { kind: "safe-fetch", host }); +} +function run(fn: () => void): string { + try { + fn(); + return "ok"; + } catch (err) { + if (isSafeFetchError(err)) return `guarded ${err.host} ${err.message}`; + return `other ${err instanceof Error ? err.message : String(err)}`; + } +} +console.log(run(() => {})); +console.log(run(() => { + throw blocked("example.com"); +})); +console.log(run(() => { + throw new Error("plain"); +})); +console.log(run(() => { + throw "text"; +})); + +function annotate(fn: () => void): void { + try { + fn(); + } catch (e: any) { + e.message = `config: ${e.message}`; + if (e.code === "ENOPE") e.hint = "check the file"; + throw e; + } +} +for (const code of ["ENOPE", "EOTHER"]) { + try { + annotate(() => { + throw Object.assign(new Error("missing"), { code }); + }); + } catch (e) { + const err = e as Error & { code?: string; hint?: string }; + console.log(err.message, err.code, err.hint, err instanceof Error); + } +} + +// An assertion on a catch binding typed any. +function codeOf(fn: () => void): string { + try { + fn(); + return "none"; + } catch (e: any) { + const err = e as Error & { code?: string }; + return `${err.message}:${err.code ?? "-"}`; + } +} +console.log( + codeOf(() => { + throw Object.assign(new Error("coded"), { code: "E1" }); + }), + codeOf(() => {}), +); diff --git a/tests/corpus/6410-wrapper-untyped-results.ts b/tests/corpus/6410-wrapper-untyped-results.ts new file mode 100644 index 0000000000..f8df7683ab --- /dev/null +++ b/tests/corpus/6410-wrapper-untyped-results.ts @@ -0,0 +1,50 @@ +// Generic wrappers returning their untyped result `as F`: async functions, +// records with unknown fields, and rejections passing through. +function trace any>(name: string, fn: F): F { + return ((...args: Parameters) => { + console.log("span", name); + return fn(...args); + }) as F; +} +function traceThis any>(name: string, fn: F): F { + return function (this: unknown, ...args: Parameters) { + const result: unknown = fn.apply(this, args); + console.log("span", name, typeof result); + return result; + } as F; +} +function lookup(key: string): any { + return key === "set" ? 7 : undefined; +} +const fetchCount = trace("fetchCount", async (base: number): Promise => { + await null; + return base + 1; +}); +const fetchUser = traceThis("fetchUser", async (id: string): Promise<{ id: string; admin: boolean }> => ({ + id, + admin: id === "root", +})); +const fails = trace("fails", async (why: string): Promise => { + throw new Error(why); +}); +const summarize = trace("summarize", (limit = lookup("unset") ?? 2) => ({ kept: limit, dropped: 1 })); +const measure = traceThis("measure", (n: number) => ({ size: n, label: lookup("set") })); +async function main(): Promise { + console.log(await fetchCount(1)); + const user = await fetchUser("root"); + console.log(user.id, user.admin); + try { + await fails("nope"); + } catch (e) { + console.log("caught", e instanceof Error ? e.message : String(e)); + } + const s = summarize(); + console.log(s.kept, s.dropped, summarize(5).kept); + const m = measure(3); + console.log(m.size, m.label); + const pending = fetchCount(10); + console.log("before"); + console.log(await pending); +} +main(); +export {}; diff --git a/tests/corpus/6411-prototype-reset-constructors/issues.js b/tests/corpus/6411-prototype-reset-constructors/issues.js new file mode 100644 index 0000000000..1058d71884 --- /dev/null +++ b/tests/corpus/6411-prototype-reset-constructors/issues.js @@ -0,0 +1,27 @@ +// An Error subclass in the shape of a validation library's: the constructor +// resets the prototype to new.target's, as transpiled code does. +export class IssueError extends Error { + constructor(issues) { + super(); + this.issues = []; + const actualProto = new.target.prototype; + if (Object.setPrototypeOf) { + Object.setPrototypeOf(this, actualProto); + } else { + this.__proto__ = actualProto; + } + this.name = "IssueError"; + this.issues = issues; + } + get message() { + return JSON.stringify(this.issues); + } + get isEmpty() { + return this.issues.length === 0; + } +} +export class StrictIssueError extends IssueError { + get strict() { + return true; + } +} diff --git a/tests/corpus/6411-prototype-reset-constructors/main.ts b/tests/corpus/6411-prototype-reset-constructors/main.ts new file mode 100644 index 0000000000..65f155e8dd --- /dev/null +++ b/tests/corpus/6411-prototype-reset-constructors/main.ts @@ -0,0 +1,33 @@ +// Constructors that reset the instance prototype, the transpiled idiom for +// Error subclasses. +import { IssueError, StrictIssueError } from "./issues.js"; +class AppError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + this.name = "AppError"; + } + describe(): string { + return `${this.name}: ${this.message}`; + } +} +class NotFound extends AppError { + constructor(what: string) { + super(`${what} not found`); + Object.setPrototypeOf(this, NotFound.prototype); + this.name = "NotFound"; + } +} +const app = new AppError("boom"); +const missing = new NotFound("user"); +console.log(app.describe(), app instanceof AppError, app instanceof Error); +console.log(missing.describe(), missing instanceof NotFound, missing instanceof AppError); +const issues = new IssueError([{ path: "a" }]); +console.log(issues.message, issues.isEmpty, issues instanceof IssueError, issues.name); +const strict = new StrictIssueError([]); +console.log(strict.isEmpty, strict.strict, strict instanceof IssueError, String(strict instanceof StrictIssueError)); +try { + throw new NotFound("page"); +} catch (e) { + console.log(e instanceof NotFound ? e.describe() : "other"); +} diff --git a/tests/corpus/6412-js-error-tostring-override/errors.js b/tests/corpus/6412-js-error-tostring-override/errors.js new file mode 100644 index 0000000000..839b57a67f --- /dev/null +++ b/tests/corpus/6412-js-error-tostring-override/errors.js @@ -0,0 +1,22 @@ +// JavaScript Error subclasses whose toString overrides infer results that +// differ from Error.prototype.toString's string. +export class IssueError extends Error { + constructor(issues) { + super("invalid"); + this.issues = issues; + this.name = "IssueError"; + } + /** @returns {string | undefined} */ + toString() { + return this.issues.length > 0 ? this.issues.join("; ") : undefined; + } +} +export class LabelError extends Error { + constructor(label, detail) { + super(detail); + this.label = label; + } + toString() { + return this.label; + } +} diff --git a/tests/corpus/6412-js-error-tostring-override/main.ts b/tests/corpus/6412-js-error-tostring-override/main.ts new file mode 100644 index 0000000000..15a857efda --- /dev/null +++ b/tests/corpus/6412-js-error-tostring-override/main.ts @@ -0,0 +1,9 @@ +// TypeScript callers of JavaScript toString overrides whose inferred +// results differ from the inherited string. +import { IssueError, LabelError } from "./errors.js"; +const issues = new IssueError(["a is required", "b must be a number"]); +console.log(issues.toString()); +console.log(String(issues), `${new IssueError([])}`); +const errors: Error[] = [issues, new LabelError("db", "down"), new Error("plain")]; +for (const e of errors) console.log(e.toString(), e instanceof IssueError); +console.log(errors.map((e) => String(e)).join(" | ")); diff --git a/tests/corpus/6413-override-js-async-base/lock.js b/tests/corpus/6413-override-js-async-base/lock.js new file mode 100644 index 0000000000..1ea00239df --- /dev/null +++ b/tests/corpus/6413-override-js-async-base/lock.js @@ -0,0 +1,22 @@ +// An untyped JavaScript lock whose async acquire resolves to an untyped +// ticket. +export class Lock { + constructor() { + this.held = false; + this.log = []; + this.queue = makeQueue(this.log); + } + async acquire() { + const ticket = await this.queue.wait(); + this.held = true; + this.log.push("base acquire"); + return ticket; + } + release() { + this.held = false; + } +} + +function makeQueue(log) { + return { wait: () => new Promise((resolve) => setTimeout(() => resolve(log.length), 1)) }; +} diff --git a/tests/corpus/6413-override-js-async-base/main.ts b/tests/corpus/6413-override-js-async-base/main.ts new file mode 100644 index 0000000000..da0c0b1836 --- /dev/null +++ b/tests/corpus/6413-override-js-async-base/main.ts @@ -0,0 +1,30 @@ +// TypeScript overrides returning Promise over an untyped JavaScript +// async method: an async override and a promise-returning one. +import { Lock } from "./lock.js"; +class TimedLock extends Lock { + waits = 0; + async acquire(): Promise { + this.waits++; + await super.acquire(); + this.log.push("timed acquire"); + } +} +class EagerLock extends Lock { + acquire(): Promise { + this.log.push("eager"); + return Promise.resolve(); + } +} +async function main(): Promise { + const locks: Lock[] = [new TimedLock(), new EagerLock(), new Lock()]; + for (const lock of locks) { + await lock.acquire(); + console.log(lock.held, lock.log.join(",")); + } + const timed = new TimedLock(); + await timed.acquire(); + console.log(timed.waits, timed.held); + const viaBase: Lock = new TimedLock(); + console.log(await new Lock().acquire(), await viaBase.acquire()); +} +main(); diff --git a/tests/corpus/6414-catch-values-roundtrip.ts b/tests/corpus/6414-catch-values-roundtrip.ts new file mode 100644 index 0000000000..ebf62980e7 --- /dev/null +++ b/tests/corpus/6414-catch-values-roundtrip.ts @@ -0,0 +1,114 @@ +// Thrown values of every kind read back through catch bindings: primitives, +// arrays, plain objects, Errors and subclasses, undefined and null, through +// un-narrowed reads and through typeof, instanceof, Array.isArray and +// predicate narrowing. +class AppError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.name = "AppError"; + this.code = code; + } +} +interface Shaped { + kind: string; + size: number; +} +function isShaped(value: unknown): value is Shaped { + return typeof value === "object" && value !== null && "kind" in value && "size" in value; +} +// Each value is thrown at its own static type (values thrown from `unknown` +// storage are covered separately). +const letters = ["a", "b", "c"]; +const none: string[] = []; +const shapedValue: Shaped = { kind: "box", size: 3 }; +const emptyObject = {}; +const plainError = new Error("plain"); +const typeError = new TypeError("typed"); +const appError = new AppError("app", "E_APP"); +const thrown: unknown[] = [ + 5, + 0, + -1.5, + "text", + "", + true, + false, + letters, + none, + shapedValue, + emptyObject, + plainError, + typeError, + appError, + undefined, + null, +]; +function throwAt(index: number): never { + switch (index) { + case 0: + throw 5; + case 1: + throw 0; + case 2: + throw -1.5; + case 3: + throw "text"; + case 4: + throw ""; + case 5: + throw true; + case 6: + throw false; + case 7: + throw letters; + case 8: + throw none; + case 9: + throw shapedValue; + case 10: + throw emptyObject; + case 11: + throw plainError; + case 12: + throw typeError; + case 13: + throw appError; + case 14: + throw undefined; + default: + throw null; + } +} +function describe(index: number): string { + try { + throwAt(index); + } catch (e) { + const parts: string[] = [String(e === thrown[index])]; + if (typeof e === "number") parts.push(`number ${e + 1}`); + else if (typeof e === "string") parts.push(`string ${e.length}`); + else if (typeof e === "boolean") parts.push(`boolean ${!e}`); + else if (Array.isArray(e)) parts.push(`array ${e.length} ${e.join("+")}`); + else if (e instanceof AppError) parts.push(`app ${e.code} ${e.message}`); + else if (e instanceof Error) parts.push(`error ${e.name} ${e.message}`); + else if (isShaped(e)) parts.push(`shaped ${e.kind} ${e.size}`); + else if (e === undefined) parts.push("undefined"); + else if (e === null) parts.push("null"); + else parts.push(`other ${typeof e}`); + return parts.join(" "); + } +} +for (let i = 0; i < thrown.length; i++) console.log(i, describe(i)); +for (let i = 0; i < 10; i++) { + try { + if (i === 5) throw i; + } catch (e) { + console.log("loop", e === 5, e); + } +} +try { + throw ["x", "y"]; +} catch (e) { + const list = e as string[]; + console.log(list[0], list[1], list.length); +} diff --git a/tests/corpus/6415-error-code-dynamic-writes.ts b/tests/corpus/6415-error-code-dynamic-writes.ts new file mode 100644 index 0000000000..f4c4d56285 --- /dev/null +++ b/tests/corpus/6415-error-code-dynamic-writes.ts @@ -0,0 +1,64 @@ +// An Error's code written through a dynamic view (a `catch (e: any)` +// binding), copied onto an Error subclass with Object.assign, set to +// undefined, or deleted is what typed reads and `'code' in err` see, on +// Errors and on Error subclasses. +class CodedError extends Error {} + +const original = new Error("a") as NodeJS.ErrnoException; +try { + throw original; +} catch (e: any) { + e.code = "E_ANY"; +} +console.log("any write", original.code, "code" in original); + +try { + throw original; +} catch (e: any) { + delete e.code; +} +console.log("any delete", original.code, "code" in original); + +function codeOf(err: NodeJS.ErrnoException): string | undefined { + return err.code; +} +const sub = new CodedError("b"); +Object.assign(sub, { code: "E_ASSIGN" }); +console.log("subclass assign", codeOf(sub), "code" in sub); + +const plain = new Error("c") as NodeJS.ErrnoException; +Object.assign(plain, { code: "E_PLAIN" }); +console.log("assign", plain.code, "code" in plain); + +const cleared = new Error("d") as NodeJS.ErrnoException; +cleared.code = undefined; +console.log("undefined", cleared.code, "code" in cleared); +const fresh = new Error("e") as NodeJS.ErrnoException; +console.log("fresh", fresh.code, "code" in fresh); + +const subCleared = new CodedError("f") as NodeJS.ErrnoException; +subCleared.code = undefined; +console.log("subclass undefined", subCleared.code, "code" in subCleared); +try { + throw subCleared; +} catch (e: any) { + console.log("subclass undefined through any", e.code, "code" in e); +} +subCleared.code = "E_SUB"; +console.log("subclass string", subCleared.code, "code" in subCleared); +try { + throw subCleared; +} catch (e: any) { + e.code = undefined; +} +console.log("subclass any undefined", subCleared.code, "code" in subCleared); +try { + throw subCleared; +} catch (e: any) { + delete e.code; +} +console.log("subclass delete", subCleared.code, "code" in subCleared); + +const subAssigned = new CodedError("g"); +Object.assign(subAssigned, { code: undefined }); +console.log("subclass assign undefined", codeOf(subAssigned), "code" in subAssigned); diff --git a/tests/harness/corpus-costs/6400-override-any-base.ts.json b/tests/harness/corpus-costs/6400-override-any-base.ts.json new file mode 100644 index 0000000000..5ff4195cc9 --- /dev/null +++ b/tests/harness/corpus-costs/6400-override-any-base.ts.json @@ -0,0 +1 @@ +{"plain":4713} diff --git a/tests/harness/corpus-costs/6401-override-param-variance.ts.json b/tests/harness/corpus-costs/6401-override-param-variance.ts.json new file mode 100644 index 0000000000..0f324b5715 --- /dev/null +++ b/tests/harness/corpus-costs/6401-override-param-variance.ts.json @@ -0,0 +1 @@ +{"plain":3445} diff --git a/tests/harness/corpus-costs/6402-override-js-base/main.ts.json b/tests/harness/corpus-costs/6402-override-js-base/main.ts.json new file mode 100644 index 0000000000..dafa7b34ed --- /dev/null +++ b/tests/harness/corpus-costs/6402-override-js-base/main.ts.json @@ -0,0 +1 @@ +{"plain":5309} diff --git a/tests/harness/corpus-costs/6403-error-code-write.ts.json b/tests/harness/corpus-costs/6403-error-code-write.ts.json new file mode 100644 index 0000000000..e4d08c9bb2 --- /dev/null +++ b/tests/harness/corpus-costs/6403-error-code-write.ts.json @@ -0,0 +1 @@ +{"plain":3632} diff --git a/tests/harness/corpus-costs/6404-error-in-and-assign.ts.json b/tests/harness/corpus-costs/6404-error-in-and-assign.ts.json new file mode 100644 index 0000000000..12a27db7de --- /dev/null +++ b/tests/harness/corpus-costs/6404-error-in-and-assign.ts.json @@ -0,0 +1 @@ +{"plain":4787} diff --git a/tests/harness/corpus-costs/6405-wrapped-default-parameters.ts.json b/tests/harness/corpus-costs/6405-wrapped-default-parameters.ts.json new file mode 100644 index 0000000000..2fe355beca --- /dev/null +++ b/tests/harness/corpus-costs/6405-wrapped-default-parameters.ts.json @@ -0,0 +1 @@ +{"plain":4127} diff --git a/tests/harness/corpus-costs/6406-npm-instance-receiver/main.ts.json b/tests/harness/corpus-costs/6406-npm-instance-receiver/main.ts.json new file mode 100644 index 0000000000..0e304e2ed2 --- /dev/null +++ b/tests/harness/corpus-costs/6406-npm-instance-receiver/main.ts.json @@ -0,0 +1 @@ +{"plain":4693} diff --git a/tests/harness/corpus-costs/6409-catch-binding-shapes.ts.json b/tests/harness/corpus-costs/6409-catch-binding-shapes.ts.json new file mode 100644 index 0000000000..072cdc1795 --- /dev/null +++ b/tests/harness/corpus-costs/6409-catch-binding-shapes.ts.json @@ -0,0 +1 @@ +{"plain":3726} diff --git a/tests/harness/corpus-costs/6410-wrapper-untyped-results.ts.json b/tests/harness/corpus-costs/6410-wrapper-untyped-results.ts.json new file mode 100644 index 0000000000..970974165a --- /dev/null +++ b/tests/harness/corpus-costs/6410-wrapper-untyped-results.ts.json @@ -0,0 +1 @@ +{"plain":3837} diff --git a/tests/harness/corpus-costs/6411-prototype-reset-constructors/main.ts.json b/tests/harness/corpus-costs/6411-prototype-reset-constructors/main.ts.json new file mode 100644 index 0000000000..b2731b1c17 --- /dev/null +++ b/tests/harness/corpus-costs/6411-prototype-reset-constructors/main.ts.json @@ -0,0 +1 @@ +{"plain":4711} diff --git a/tests/harness/corpus-costs/6412-js-error-tostring-override/main.ts.json b/tests/harness/corpus-costs/6412-js-error-tostring-override/main.ts.json new file mode 100644 index 0000000000..64c9f2e6d2 --- /dev/null +++ b/tests/harness/corpus-costs/6412-js-error-tostring-override/main.ts.json @@ -0,0 +1 @@ +{"plain":3803} diff --git a/tests/harness/corpus-costs/6413-override-js-async-base/main.ts.json b/tests/harness/corpus-costs/6413-override-js-async-base/main.ts.json new file mode 100644 index 0000000000..eeb8f92e9f --- /dev/null +++ b/tests/harness/corpus-costs/6413-override-js-async-base/main.ts.json @@ -0,0 +1 @@ +{"plain":3820} diff --git a/tests/harness/corpus-costs/6414-catch-values-roundtrip.ts.json b/tests/harness/corpus-costs/6414-catch-values-roundtrip.ts.json new file mode 100644 index 0000000000..502f0220df --- /dev/null +++ b/tests/harness/corpus-costs/6414-catch-values-roundtrip.ts.json @@ -0,0 +1 @@ +{"plain":4942} diff --git a/tests/harness/corpus-costs/6415-error-code-dynamic-writes.ts.json b/tests/harness/corpus-costs/6415-error-code-dynamic-writes.ts.json new file mode 100644 index 0000000000..fb3867563e --- /dev/null +++ b/tests/harness/corpus-costs/6415-error-code-dynamic-writes.ts.json @@ -0,0 +1 @@ +{"plain":3425}