diff --git a/.gitignore b/.gitignore index f747b4f62..e5b54a5fa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ .pnpm-store/ # test-fixture packages are TEST DATA, committed on purpose !tests/diagnostics/npm-imports/node_modules/ +!tests/diagnostics/npm-program-type-error/node_modules/ !tests/diagnostics/retired-engine-import-refusal/node_modules/ !tests/fixtures/commander-calc/node_modules/ !tests/fixtures/npm/node_modules/ diff --git a/docs/content/limitations/untyped-values.mdx b/docs/content/limitations/untyped-values.mdx index f0ab9a526..c0811a51e 100644 --- a/docs/content/limitations/untyped-values.mdx +++ b/docs/content/limitations/untyped-values.mdx @@ -1,6 +1,6 @@ Supported uses of `any` use native checked values. Operations without native lowering produce diagnostics such as `SC2011`. Use `unknown` with a checked cast where an operation needs a concrete type. Map and Set type arguments may use `any`, with the same checked storage as `unknown`. -Locals, parameters, and returns can carry untyped values; record and tuple fields can hold `unknown`. JavaScript instance fields can also hold native checked values. Bare fields start as `undefined`, and redeclaring a field with the same checked-value type resets it in initialization order. Explicit TypeScript `any`/`unknown` class fields, static checked-value fields, statically typed array elements, and union members are unsupported. +Locals, parameters, and returns can carry untyped values; record and tuple fields can hold `unknown`. JavaScript instance fields and TypeScript class fields declared `unknown` or `object` (also with `null` or `undefined`, or as arrays of those) can also hold native checked values. Bare fields start as `undefined`, and redeclaring a field with the same checked-value type resets it in initialization order. Explicit TypeScript `any` class fields, other checked-value static fields, statically typed array elements, and union members are unsupported. Program class instances retain identity through `unknown` and casts back to their exact class or a base class, including classes with internal collections. Inspecting dynamic properties throws a catchable `TypeError` if the class's fields cannot pass through checked storage. Supported native handles also retain identity. JavaScript argument and return conversions preserve native array references; other JSON-shaped record and array conversions can copy values. diff --git a/packages/compiler/src/frontend/lowering/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts index 5fd189242..2dc212f81 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promises.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts @@ -279,25 +279,39 @@ export function lowerPromiseStaticCall( // tick-for-tick in Node for plain results (probed), with the // promise-returning form riding the async return-adoption machinery // (its one-tick residue is SEMANTICS.md 358) — so the lowering IS - // that wrapper: one interned async helper per callback type. The - // ...args form fences (close over the values instead). + // that wrapper: one interned async helper per callback and argument + // types. `Promise.try(f, ...args)` evaluates the arguments at the call + // and passes them to f, which still runs synchronously. if (member === "try") { const fNode = call.arguments[0]; - if (call.arguments.length !== 1 || fNode === undefined || ts.isSpreadElement(fNode)) { + const argNodes = call.arguments.slice(1); + if (fNode === undefined || call.arguments.some(ts.isSpreadElement)) { lowerer.noLowering( - `Promise.try with ${call.arguments.length} arguments`, + `Promise.try with ${call.arguments.length === 0 ? "no" : "spread"} arguments`, call, - "the ...args form has no lowering — close over the values: Promise.try(() => f(a, b))", + "pass the callback and its arguments directly: Promise.try(f, a, b)", ); } const f = lowerer.lowerExpr(fNode); - if (f.type.kind !== "func" || f.type.params.length !== 0) { + // Arguments past the callback's parameters are evaluated and dropped; + // only an arrow function, which has no `arguments`, can ignore them. + if ( + f.type.kind !== "func" || + f.type.params.length > argNodes.length || + (f.type.params.length < argNodes.length && !ts.isArrowFunction(fNode)) + ) { lowerer.noLowering( - `Promise.try over a callback of type '${lowerer.checker.typeToString(lowerer.typeOf(fNode))}'`, + `Promise.try over a callback of type '${lowerer.checker.typeToString(lowerer.typeOf(fNode))}' with ${argNodes.length} argument${argNodes.length === 1 ? "" : "s"}`, fNode, - "a zero-parameter function is the supported form", + "pass one argument per callback parameter: Promise.try((a, b) => a + b, 1, 2)", ); } + const fnT = f.type; + const args = argNodes.map((node, i) => + i < fnT.params.length + ? lowerer.lowerExprExpecting(node, fnT.params[i]!) + : lowerer.lowerExpr(node), + ); const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); if (resultT?.kind !== "promise") { lowerer.noLowering( @@ -323,13 +337,22 @@ export function lowerPromiseStaticCall( "annotate both to one T: Promise.try(f) with f returning T or Promise", ); } - const key = `promise.try:${typeKey(f.type)}`; + const key = `promise.try:${typeKey(f.type)}:${args.map((a) => typeKey(a.type)).join(",")}`; let helper = lowerer.arrHofHelpers.get(key); if (!helper) { helper = `%promise.try.${lowerer.arrHofHelpers.size}`; lowerer.arrHofHelpers.set(key, helper); const fRef: IrExpr = { kind: "varRef", localId: "f.0", type: f.type, loc }; - const callF: IrExpr = { kind: "callValue", callee: fRef, args: [], type: ret, loc }; + const argLocals = args.map((a, i) => ({ id: `a${i}.0`, name: `a${i}`, type: a.type })); + const callF: IrExpr = { + kind: "callValue", + callee: fRef, + args: argLocals + .slice(0, fnT.params.length) + .map((a) => ({ kind: "varRef" as const, localId: a.id, type: a.type, loc })), + type: ret, + loc, + }; // Adopt a promise result before either returning its value or completing void. const value: IrExpr = ret.kind === "promise" ? { kind: "awaitExpr", value: callF, type: ret.inner, loc } : callF; @@ -342,16 +365,22 @@ export function lowerPromiseStaticCall( : [{ kind: "return", value, loc }]; const lifted: IrFunction = { name: helper, - params: [{ localId: "f.0", name: "f", type: f.type }], + params: [ + { localId: "f.0", name: "f", type: f.type }, + ...argLocals.map((a) => ({ localId: a.id, name: a.name, type: a.type })), + ], returnType: inner, - locals: [{ id: "f.0", name: "f", type: f.type, mutable: true }], + locals: [ + { id: "f.0", name: "f", type: f.type, mutable: true }, + ...argLocals.map((a) => ({ ...a, mutable: true })), + ], body, loc, async: true, }; lowerer.liftedFns.push(lifted); } - return { kind: "call", callee: helper, args: [f], type: resultT, loc }; + return { kind: "call", callee: helper, args: [f, ...args], type: resultT, loc }; } // Promise.resolve — the already-settled promise. Zero arguments is // Promise; a PROMISE argument is returned as-is (the spec's diff --git a/packages/compiler/src/frontend/lowering/coercions/unions.ts b/packages/compiler/src/frontend/lowering/coercions/unions.ts index ca7495dd7..0356b714e 100644 --- a/packages/compiler/src/frontend/lowering/coercions/unions.ts +++ b/packages/compiler/src/frontend/lowering/coercions/unions.ts @@ -77,6 +77,48 @@ export function narrowedRetagHelper( return lowerer.unionRetagHelper(fromId, toId, loc, trappable); } +/** A union conversion whose source arms include an object type with a + * required property of type `never` (the error arm of `Result`). + * No value has that type, but its arm still has a storage layout (never + * maps to the f64 placeholder) that no destination arm matches. When the + * node's checker type proves a source arm uninhabited, the re-tag traps on + * it instead of needing a destination. Optional `never` properties, + * `never[]` and functions returning `never` have values and keep their + * arms. Null when no source arm is proven uninhabited. */ +export function uninhabitedArmRetagHelper( + lowerer: Lowerer, + node: ts.Node, + fromId: string, + toId: string, + loc: SrcLoc, +): string | null { + const type = lowerer.typeOf(node); + if (!type.isUnionType()) return null; + const uninhabited = new Set(); + const inhabited = new Set(); + for (const part of ts.constituentTypes(type)) { + const arm = lowerer.mapTypeOf(part); + const tag = arm ? lowerer.armTag(fromId, arm) : -1; + if (tag < 0) continue; + (hasRequiredNeverProperty(lowerer, part) ? uninhabited : inhabited).add(tag); + } + // An arm shared with an inhabited constituent holds real values. + for (const tag of inhabited) uninhabited.delete(tag); + if (uninhabited.size === 0) return null; + return lowerer.unionRetagHelper(fromId, toId, loc, uninhabited); +} + +function hasRequiredNeverProperty(lowerer: Lowerer, type: ts.Type): boolean { + if ((type.flags & ts.TypeFlags.Object) === 0) return false; + return lowerer.checker + .getPropertiesOfType(type) + .some( + (property) => + (property.flags & ts.SymbolFlags.Optional) === 0 && + (lowerer.checker.getTypeOfSymbol(property).flags & ts.TypeFlags.Never) !== 0, + ); +} + /** The stranded-UNIT trap for PLAIN (non-union) slots: a null/undefined * value flowing into a non-nullable typed slot the checker approved — * `null!` and `null as any as T` casts, and the non-strict world's diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index b1e6aa98f..a79864983 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -709,6 +709,30 @@ function arrayUnionHofHelper( return name; } +/** A kept filter element as the result's element type: the value itself, + * or its narrowed arm or class view when the predicate narrowed it. */ +function filterRetainedValue( + lowerer: Lowerer, + v: IrExpr, + elem: IrType, + outElem: IrType, + loc: SrcLoc, +): IrExpr { + if (typeEquals(outElem, elem)) return v; + const classView = + outElem.kind === "object" ? checkedClassAssertion(lowerer, v, outElem, loc) : null; + if (classView !== null) return classView; + if (v.type.kind !== "union") return v; + return { + kind: "unionNarrow", + unionId: v.type.unionId, + tag: lowerer.armTag(v.type.unionId, outElem), + value: v, + type: outElem, + loc, + }; +} + /** The loop body of one synthetic array HOF, from existing IR nodes: * * map: out = []; n = a.length; for (i = 0; i < n; i++) out.push(f(a[i])); return out; @@ -914,24 +938,7 @@ function buildArrayHofFn( { id: "v.0", name: "v", type: valueT, mutable: false }, ); returnType = outT; - const classView = - !typeEquals(outElem, elem) && outElem.kind === "object" - ? checkedClassAssertion(lowerer, varRef("v.0", valueT, loc), outElem, loc) - : null; - const retained = typeEquals(outElem, elem) - ? varRef("v.0", valueT, loc) - : classView !== null - ? classView - : valueT.kind === "union" - ? { - kind: "unionNarrow" as const, - unionId: valueT.unionId, - tag: lowerer.armTag(valueT.unionId, outElem), - value: varRef("v.0", valueT, loc), - type: outElem, - loc, - } - : varRef("v.0", valueT, loc); + const retained = filterRetainedValue(lowerer, varRef("v.0", valueT, loc), elem, outElem, loc); body = [ { kind: "varDecl", @@ -1039,16 +1046,7 @@ export function lowerArrayFindLikeCall( // `packages.some((p) => p.scripts[name])` idiom, whose callback // returns the index read's `string | undefined`). const fnRet = fnArg.type.ret; - const truthyRet = - fnRet.kind === "bool" || - fnRet.kind === "f64" || - fnRet.kind === "string" || - fnRet.kind === "dyn" || - (fnRet.kind === "union" && - (lowerer.unions.get(fnRet.unionId)?.arms ?? []).every( - (a) => a.kind !== "dyn" && a.kind !== "caught", - )); - if (!truthyRet) lowerer.badType(argNode, lowerer.typeOf(argNode)); + if (!findPredicateOk(lowerer, fnRet)) lowerer.badType(argNode, lowerer.typeOf(argNode)); const last = method === "findLast" || method === "findLastIndex"; if ( method === "some" || @@ -1116,42 +1114,7 @@ export function lowerArrayFindLikeCall( if (resultT.kind !== "union") lowerer.badType(call, lowerer.typeOf(call)); // defensive: T | undefined always maps to a union const undefTag = lowerer.armTag(resultT.unionId, UNDEFINED_T); if (undefTag < 0) lowerer.badType(call, lowerer.typeOf(call)); - let retag: string | null = null; - if (!lowerer.coercibleValue(valueT, resultT)) { - const inline = ts.isArrowFunction(argNode) || ts.isFunctionExpression(argNode); - if (!unchangedElement && (!inline || argNode.type !== undefined)) { - lowerer.unsupported( - "SC1090", - argNode, - `narrowing '.${method}' requires an inline callback with an inferred predicate (annotate the return ': boolean' to keep the receiver's element type)`, - ); - } - const signature = inline ? lowerer.checker.getSignatureFromDeclaration(argNode) : undefined; - const predicate = signature - ? lowerer.checker.getTypePredicateOfSignature(signature) - : undefined; - if ( - (!unchangedElement && (!predicate || predicate.parameterIndex !== 0)) || - valueT.kind !== "union" - ) { - lowerer.unsupported( - "SC1090", - call, - `'.${method}' result narrowing without a predicate over its element`, - ); - } - retag = lowerer.narrowedRetagHelper(call, valueT.unionId, resultT.unionId, loc); - const present = lowerer.stripUndefinedArm(resultT); - if ( - retag === null && - !(present.kind === "object" && canAssertClassValue(lowerer, valueT, present)) - ) - lowerer.unsupported( - "SC1090", - call, - `'.${method}' narrowing to an incompatible result layout`, - ); - } + const retag = findResultRetag(lowerer, call, argNode, method, valueT, resultT, unchangedElement); const helper = findHelper( lowerer, elem, @@ -1168,6 +1131,67 @@ export function lowerArrayFindLikeCall( return { kind: "call", callee: helper, args: [receiver, fnArg], type: resultT, loc }; } +/** The retag helper that converts a found element into find's result union, + * or null when the element converts directly. Only an inline callback with + * an inferred predicate over its element can narrow the result; a written + * predicate is an unchecked assertion and keeps the refusal. */ +function findResultRetag( + lowerer: Lowerer, + call: ts.CallExpression, + argNode: ts.Expression, + method: string, + valueT: IrType, + resultT: IrType & { kind: "union" }, + unchangedElement: boolean, +): string | null { + if (lowerer.coercibleValue(valueT, resultT)) return null; + const loc = locOf(call); + const inline = ts.isArrowFunction(argNode) || ts.isFunctionExpression(argNode); + if (!unchangedElement && (!inline || argNode.type !== undefined)) { + lowerer.unsupported( + "SC1090", + argNode, + `narrowing '.${method}' requires an inline callback with an inferred predicate (annotate the return ': boolean' to keep the receiver's element type)`, + ); + } + const signature = inline ? lowerer.checker.getSignatureFromDeclaration(argNode) : undefined; + const predicate = signature ? lowerer.checker.getTypePredicateOfSignature(signature) : undefined; + if ( + (!unchangedElement && (!predicate || predicate.parameterIndex !== 0)) || + valueT.kind !== "union" + ) { + lowerer.unsupported( + "SC1090", + call, + `'.${method}' result narrowing without a predicate over its element`, + ); + } + const retag = lowerer.narrowedRetagHelper(call, valueT.unionId, resultT.unionId, loc); + const present = lowerer.stripUndefinedArm(resultT); + if ( + retag === null && + !(present.kind === "object" && canAssertClassValue(lowerer, valueT, present)) + ) + lowerer.unsupported("SC1090", call, `'.${method}' narrowing to an incompatible result layout`); + return retag; +} + +/** The find-like predicate results whose ToBoolean has a static answer: + * bool passes through; f64/string by value; checked values and + * truthy-answerable unions by their runtime kind or arm. */ +function findPredicateOk(lowerer: Lowerer, fnRet: IrType): boolean { + return ( + fnRet.kind === "bool" || + fnRet.kind === "f64" || + fnRet.kind === "string" || + fnRet.kind === "dyn" || + (fnRet.kind === "union" && + (lowerer.unions.get(fnRet.unionId)?.arms ?? []).every( + (a) => a.kind !== "dyn" && a.kind !== "caught", + )) + ); +} + /** ToBoolean of a predicate result inside a synthesized HOF helper — the * ensureBool subset the find-like path admits (bool through; checked * values via dynTest; f64/string/truthy-answerable unions via toBool). */ @@ -1208,17 +1232,7 @@ function findHelper( const fnT = funcOf([valueT, F64, arrT].slice(0, arity), fnRet); const v = varRef("v.0", valueT, loc); - const presentArm = lowerer.stripUndefinedArm(resultT); - // A successful inferred class predicate may select a descendant of a - // stored base arm. Keep the value read before the callback mutated the array. - const classView = - retag === null && !lowerer.coercibleValue(valueT, resultT) && presentArm.kind === "object" - ? checkedClassAssertion(lowerer, v, presentArm, loc) - : null; - const found: IrExpr = - retag === null - ? lowerer.coerceToExpected(classView ?? v, resultT) - : { kind: "call", callee: retag, args: [v], type: resultT, loc }; + const found = findFoundValue(lowerer, v, resultT, retag, loc); const miss: IrExpr = { kind: "unionWrap", unionId: resultT.unionId, @@ -1272,6 +1286,25 @@ function findHelper( return name; } +/** A found element as find's result union. A successful inferred class + * predicate may select a descendant of a stored base arm. The value is the + * one read before the callback ran, even if the callback mutated its slot. */ +function findFoundValue( + lowerer: Lowerer, + v: IrExpr, + resultT: IrType & { kind: "union" }, + retag: string | null, + loc: SrcLoc, +): IrExpr { + if (retag !== null) return { kind: "call", callee: retag, args: [v], type: resultT, loc }; + const presentArm = lowerer.stripUndefinedArm(resultT); + const classView = + !lowerer.coercibleValue(v.type, resultT) && presentArm.kind === "object" + ? checkedClassAssertion(lowerer, v, presentArm, loc) + : null; + return lowerer.coerceToExpected(classView ?? v, resultT); +} + /** The findIndex/findLastIndex loop — find's index-returning sibling, no * union; findLastIndex is the identical loop walked backwards: * @@ -2580,3 +2613,200 @@ function buildDynFilterFn(name: string, elemT: IrType, arity: number, loc: SrcLo loc, }; } + +/** Tuple `filter` / `find` / `findIndex` / `some` / `every`. `reads` holds + * one element read per position, in index order, each over the helper's + * `a.0` receiver parameter. The helper visits positions in order and reads + * each one only when it is visited, so a callback's write to a later + * position is visible; filter and find keep the value read before the + * callback ran. find, findIndex, some and every return at the first + * deciding position. Predicate results and narrowing follow the array + * methods' rules. */ +export function lowerTuplePredicateCall( + lowerer: Lowerer, + call: ts.CallExpression, + method: "filter" | "find" | "findIndex" | "some" | "every", + receiver: IrExpr, + elem: IrType, + reads: IrExpr[], +): IrExpr { + const loc = locOf(call); + const argNode = call.arguments[0]!; + const booleanFilter = + method === "filter" && + ts.isIdentifier(argNode) && + argNode.text === "Boolean" && + lowerer.isStdlibSymbol(lowerer.resolveValueSymbol(argNode) ?? undefined); + if (booleanFilter) { + if (elem.kind === "union") lowerer.requireTruthyUnion(elem.unionId, argNode); + if (elem.kind === "dyn" || elem.kind === "void" || isUnitType(elem)) { + lowerer.badType(argNode, lowerer.typeOf(argNode)); + } + } + const { fnArg, arity } = booleanFilter + ? { fnArg: truthyFilterCallback(lowerer, elem, loc), arity: 1 } + : lowerArrayCallback(lowerer, argNode, [elem], receiver.type); + const fnRet = fnArg.type.ret; + if (method === "filter" ? !filterPredicateOk(lowerer, fnRet) : !findPredicateOk(lowerer, fnRet)) { + if (fnRet.kind === "void") { + lowerer.unsupported( + "SC1090", + argNode, + `'.${method}()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)`, + ); + } + lowerer.badType(argNode, lowerer.typeOf(argNode)); + } + let resultT: IrType; + let outElem: IrType = elem; + // A narrowing find retags from the element union widened with undefined, + // the value type an array find narrows from. + let foundT: IrType = elem; + let retag: string | null = null; + if (method === "filter") { + // The element union is built in position order, so the checker's + // result element can list the same arms in another order. + const checkerResult = lowerer.mapTypeOf(lowerer.typeOf(call)); + const arms = (type: IrType): IrType[] => + type.kind === "union" ? lowerer.unions.get(type.unionId)!.arms : [type]; + const unchanged = + checkerResult?.kind === "array" && + arms(checkerResult.elem).length === arms(elem).length && + arms(checkerResult.elem).every((arm) => arms(elem).some((other) => typeEquals(arm, other))); + outElem = unchanged + ? elem + : filterResultElem(lowerer, call, argNode, elem, elem, booleanFilter); + resultT = arrayOf(outElem); + } else if (method === "find") { + resultT = lowerer.irTypeOf(call); + if (resultT.kind !== "union" || lowerer.armTag(resultT.unionId, UNDEFINED_T) < 0) { + lowerer.badType(call, lowerer.typeOf(call)); + } + if (!lowerer.coercibleValue(elem, resultT)) foundT = arrayValueType(lowerer, elem); + retag = findResultRetag(lowerer, call, argNode, method, foundT, resultT, false); + } else { + resultT = method === "findIndex" ? F64 : BOOL; + } + const key = `tuple.${method}:${typeKey(receiver.type)}:${typeKey(fnArg.type)}:${typeKey(resultT)}:${arity}${callbackSite(fnArg)}`; + let helper = lowerer.arrHofHelpers.get(key); + if (!helper) { + helper = `%tuple.${method}.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, helper); + const v = varRef("v.0", elem, loc); + const out = varRef("out.0", resultT, loc); + const test = (index: number): IrExpr => { + const invocation: IrExpr = { + kind: "callValue", + callee: varRef("f.0", fnArg.type, loc), + args: [v, numLit(index, loc)].slice(0, arity), + type: fnRet, + loc, + }; + return method === "filter" + ? filterCond(invocation, fnRet, loc) + : predicateToBoolean(invocation); + }; + const decided = (index: number): IrStmt => { + switch (method) { + case "filter": + return arrayValueStore( + lowerer, + out, + { kind: "arrIntrinsic", method: "length", receiver: out, args: [], type: F64, loc }, + filterRetainedValue(lowerer, v, elem, outElem, loc), + outElem, + loc, + ); + case "find": + return { + kind: "return", + value: findFoundValue( + lowerer, + lowerer.coerceToExpected(v, foundT), + resultT as IrType & { kind: "union" }, + retag, + loc, + ), + loc, + }; + case "findIndex": + return { kind: "return", value: numLit(index, loc), loc }; + case "some": + return { kind: "return", value: boolLit(true, loc), loc }; + case "every": + return { kind: "return", value: boolLit(false, loc), loc }; + } + }; + const body: IrStmt[] = + method === "filter" + ? [ + { + kind: "varDecl", + localId: "out.0", + init: { kind: "arrayLit", elems: [], type: resultT, loc }, + loc, + }, + ] + : []; + reads.forEach((read, index) => { + body.push( + index === 0 + ? { kind: "varDecl", localId: "v.0", init: read, loc } + : { kind: "assign", localId: "v.0", value: read, loc }, + { + kind: "if", + cond: + method === "every" + ? { kind: "unary", op: "!", operand: test(index), type: BOOL, loc } + : test(index), + then: [decided(index)], + else_: null, + loc, + }, + ); + }); + let done: IrExpr; + switch (method) { + case "filter": + done = out; + break; + case "find": { + const unionId = (resultT as IrType & { kind: "union" }).unionId; + done = { + kind: "unionWrap", + unionId, + tag: lowerer.armTag(unionId, UNDEFINED_T), + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type: resultT, + loc, + }; + break; + } + case "findIndex": + done = numLit(-1, loc); + break; + default: + done = boolLit(method === "every", loc); + } + body.push({ kind: "return", value: done, loc }); + lowerer.liftedFns.push({ + name: helper, + params: [ + { localId: "a.0", name: "a", type: receiver.type }, + { localId: "f.0", name: "f", type: fnArg.type }, + ], + returnType: resultT, + locals: [ + { id: "a.0", name: "a", type: receiver.type, mutable: false }, + { id: "f.0", name: "f", type: fnArg.type, mutable: false }, + ...(reads.length > 0 ? [{ id: "v.0", name: "v", type: elem, mutable: true }] : []), + ...(method === "filter" + ? [{ id: "out.0", name: "out", type: resultT, mutable: false }] + : []), + ], + body, + loc, + }); + } + return { kind: "call", callee: helper, args: [receiver, fnArg], type: resultT, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index ea821f006..40c2e730d 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -46,6 +46,7 @@ import { lowerArrayFindLikeCall, lowerArrayFlatMapCall, lowerArrayReduceCall, + lowerTuplePredicateCall, } from "./array-callbacks.js"; import { lowerArraySpreadItems, @@ -1847,12 +1848,19 @@ export function lowerTupleReadMethodCall( if (lowerer.chainBlocked(access, call)) return null; const method = access.name.text; const search = method === "includes" || method === "indexOf" || method === "lastIndexOf"; + const predicate = + method === "filter" || + method === "find" || + method === "findIndex" || + method === "some" || + method === "every"; if ( method !== "slice" && method !== "map" && method !== "flatMap" && method !== "join" && - !search + !search && + !predicate ) return null; if (!lowerer.isStdlibMember(access)) return null; @@ -2048,6 +2056,9 @@ export function lowerTupleReadMethodCall( "capture the original tuple explicitly to preserve its identity", ); } + if (predicate) { + return lowerTuplePredicateCall(lowerer, call, method, receiver, elem, fields.map(read)); + } const { fnArg, arity } = lowerArrayCallback(lowerer, callback, [elem], receiver.type); const ret = fnArg.type.ret; if (ret.kind === "void" || ret.kind === "func") lowerer.badType(call, lowerer.typeOf(call)); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 371ea1946..eabd2e47b 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -10230,7 +10230,6 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr lowerOptionalNumber(lowerer, operand, loc, node); const arithmeticLeft = strictEquality ? left : arithmeticNumber(left, expr.left); const arithmeticRight = strictEquality ? right : arithmeticNumber(right, expr.right); - const plainBothNum = left.type.kind === "f64" && right.type.kind === "f64"; const bothNum = arithmeticLeft.type.kind === "f64" && arithmeticRight.type.kind === "f64"; const bothStr = left.type.kind === "string" && right.type.kind === "string"; @@ -10452,234 +10451,17 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr }; } case ts.SyntaxKind.EqualsEqualsEqualsToken: - case ts.SyntaxKind.ExclamationEqualsEqualsToken: { - const negated = op === ts.SyntaxKind.ExclamationEqualsEqualsToken; - const indexed = tryLowerIndexedComparison(lowerer, left, right, negated, loc); - if (indexed) return indexed; - if (plainBothNum) - return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; - if (bothStr) { - if (left.kind === "strLit" && right.kind === "strLit") - return { - kind: "boolLit", - value: (left.value === right.value) !== negated, - type: BOOL, - loc, - }; - return { kind: "strEq", negated, left, right, type: BOOL, loc }; - } - // bool === bool: a plain value compare (the config-drift checks' - // `desired.lanMode !== actual.lanMode` shape). - if (left.type.kind === "bool" && right.type.kind === "bool") { - return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; - } - const unitTest = lowerer.lowerUnitComparison(left, right, negated, loc); - if (unitTest) return unitTest; - if (left.type.kind === "union" || right.type.kind === "union") { - const compared = lowerUnionEquality(lowerer, left, right, negated, false, loc); - if (compared) return compared; - const aliasRefusal = (type: IrType): void => { - if (tagEqualityMayMissAlias(lowerer, type)) - lowerer.unsupported( - "SC1090", - expr, - `comparisons of related class values beside structural union members (${NARROW_FIRST})`, - ); - }; - - // JS strict equality of the ARM values, per union tag (a - // per-union helper): equal tags compare payloads (f64 ==, string - // bytes, bool ==, ref-arm POINTER identity), different tags are - // never equal. Two shapes lower: same union on both sides, and - // union vs plain arm value (`u === "text"` where the checker - // didn't narrow) — the plain side wraps into the union, which - // preserves payload identity. Different unions (tsc admits - // partially-overlapping ones) still need narrowing first. - const ut = - left.type.kind === "union" ? left.type : (right.type as IrType & { kind: "union" }); - const bothUnion = left.type.kind === "union" && right.type.kind === "union"; - const sameUnion = bothUnion && typeEquals(left.type, right.type); - const plainFunc = - left.type.kind === "func" ? left : right.type.kind === "func" ? right : null; - const unionArms = lowerer.unions.get(ut.unionId)?.arms ?? []; - const funcArm = unionArms.find((arm) => arm.kind === "func"); - // `listeners()` reports original prefix callbacks, but the - // checker views an indexed result as function | undefined. For - // identity comparison, retain the original closure pointer rather - // than adapting it to the tuple signature. - if ( - !bothUnion && - plainFunc !== null && - funcArm?.kind === "func" && - unionArms.filter((arm) => arm.kind === "func").length === 1 && - unionArms.some(isUnitType) - ) { - return { - kind: "unionFuncEq", - unionId: ut.unionId, - tag: lowerer.armTag(ut.unionId, funcArm), - union: left.type.kind === "union" ? left : right, - func: plainFunc, - negated, - type: BOOL, - loc, - }; - } - if ((sameUnion || !bothUnion) && lowerer.eqComparableUnion(ut.unionId)) { - aliasRefusal(ut); - return { - kind: "unionEq", - unionId: ut.unionId, - negated, - sameValue: false, - left: lowerer.coerceInto(expr.left, left, ut), - right: lowerer.coerceInto(expr.right, right, ut), - type: BOOL, - loc, - }; - } - if (left.type.kind === "union" && right.type.kind === "union") { - const leftDef = lowerer.unions.get(left.type.unionId); - const rightDef = lowerer.unions.get(right.type.unionId); - if (leftDef && rightDef) { - const leftValues = leftDef.arms.filter((arm) => !isUnitType(arm)); - const rightValues = rightDef.arms.filter((arm) => !isUnitType(arm)); - const sameValueArms = - leftValues.length === rightValues.length && - leftValues.every((arm) => - rightValues.some((candidate) => typeEquals(candidate, arm)), - ); - if (sameValueArms) { - const arms = [...leftDef.arms]; - for (const arm of rightDef.arms) { - if (!arms.some((candidate) => typeEquals(candidate, arm))) arms.push(arm); - } - arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); - const common: IrType & { kind: "union" } = { - kind: "union", - unionId: lowerer.unions.intern(arms), - }; - aliasRefusal(common); - return { - kind: "unionEq", - unionId: common.unionId, - negated, - sameValue: false, - left: lowerer.coerceInto(expr.left, left, common), - right: lowerer.coerceInto(expr.right, right, common), - type: BOOL, - loc, - }; - } - } - } - lowerer.unsupported("SC1090", expr, `comparisons of union-typed values (${NARROW_FIRST})`); - } - // Arrays, maps, functions, class instances and records compare by - // reference identity (pointer compare) — JS-exact object equality. - // Hierarchy-related classes compare after widening the derived side - // (same pointer either way — prefix layout). - let idLeft = left; - let idRight = right; - if (left.type.kind === "object" && right.type.kind === "object") { - if (lowerer.isSubclassOf(left.type.className, right.type.className)) { - idLeft = lowerer.upcastTo(left, right.type.className); - } else if (lowerer.isSubclassOf(right.type.className, left.type.className)) { - idRight = lowerer.upcastTo(right, left.type.className); - } - } - // Two function values compare by identity whatever their STATIC - // signatures (tsc admits the comparison when one side is - // assignable to the other — a tuple-typed listeners() element - // against a prefix-declared handler): the pointer answers it. - if (idLeft.type.kind === "func" && idRight.type.kind === "func") { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - // Two CLASS values compare by identity whatever their static - // classes — one immortal object per class, one pointer compare - // (the function-identity rule verbatim; `X === D` through a - // base-typed slot answers exactly JS). - if (idLeft.type.kind === "classval" && idRight.type.kind === "classval") { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - if (idLeft.type.kind === "moduleNs" && idRight.type.kind === "moduleNs") { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - if ( - (idLeft.type.kind === "array" || - idLeft.type.kind === "map" || - idLeft.type.kind === "set" || - idLeft.type.kind === "regex" || - idLeft.type.kind === "url" || - idLeft.type.kind === "object" || - idLeft.type.kind === "record" || - // Symbols ARE identity: `Symbol('a') === Symbol('a')` is false, - // a symbol equals exactly itself, and Symbol.for's interned - // values compare equal across call sites — all one pointer - // compare, JS's spec without approximation. - idLeft.type.kind === "symbol" || - // Typed arrays / Buffers ARE objects to ===: pointer identity - // (buf === buf.swap16() — the in-place mutators return this). - idLeft.type.kind === "bytes" || - idLeft.type.kind === "promise") && - typeEquals(idLeft.type, idRight.type) - ) { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - // Runtime HANDLES are objects to === too: one handle per socket/ - // request/response, so pointer identity IS JS's object equality - // (`c.pause() === c` — Node's chaining assertions). */ - if (DYN_HANDLE_KINDS.has(idLeft.type.kind) && typeEquals(idLeft.type, idRight.type)) { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - if (lowerer.dynConvertible(idLeft.type) && lowerer.dynConvertible(idRight.type)) { - return { - kind: "dynScalarEq", - left: lowerer.coerceToExpected(idLeft, DYN), - right: lowerer.coerceToExpected(idRight, DYN), - ...(negated ? { negated: true as const } : {}), - type: BOOL, - loc, - }; - } - lowerer.unsupported("SC1043", expr); - break; - } + case ts.SyntaxKind.ExclamationEqualsEqualsToken: + return lowerStrictEquality( + lowerer, + expr, + expr.left, + expr.right, + left, + right, + op === ts.SyntaxKind.ExclamationEqualsEqualsToken, + loc, + ); case ts.SyntaxKind.LessThanToken: case ts.SyntaxKind.LessThanEqualsToken: case ts.SyntaxKind.GreaterThanToken: @@ -10728,6 +10510,243 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr ); } +/** `left === right` (`!==` when negated) over lowered operands: JS strict + * equality for every pair of static kinds the program can compare. + * `node` anchors diagnostics, and `leftNode`/`rightNode` the coercions. */ +export function lowerStrictEquality( + lowerer: Lowerer, + node: ts.Node, + leftNode: ts.Node, + rightNode: ts.Node, + left: IrExpr, + right: IrExpr, + negated: boolean, + loc: SrcLoc, +): IrExpr { + const plainBothNum = left.type.kind === "f64" && right.type.kind === "f64"; + const bothStr = left.type.kind === "string" && right.type.kind === "string"; + const indexed = tryLowerIndexedComparison(lowerer, left, right, negated, loc); + if (indexed) return indexed; + if (plainBothNum) + return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; + if (bothStr) { + if (left.kind === "strLit" && right.kind === "strLit") + return { + kind: "boolLit", + value: (left.value === right.value) !== negated, + type: BOOL, + loc, + }; + return { kind: "strEq", negated, left, right, type: BOOL, loc }; + } + // bool === bool: a plain value compare (the config-drift checks' + // `desired.lanMode !== actual.lanMode` shape). + if (left.type.kind === "bool" && right.type.kind === "bool") { + return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; + } + const unitTest = lowerer.lowerUnitComparison(left, right, negated, loc); + if (unitTest) return unitTest; + if (left.type.kind === "union" || right.type.kind === "union") { + const compared = lowerUnionEquality(lowerer, left, right, negated, false, loc); + if (compared) return compared; + const aliasRefusal = (type: IrType): void => { + if (tagEqualityMayMissAlias(lowerer, type)) + lowerer.unsupported( + "SC1090", + node, + `comparisons of related class values beside structural union members (${NARROW_FIRST})`, + ); + }; + + // JS strict equality of the ARM values, per union tag (a + // per-union helper): equal tags compare payloads (f64 ==, string + // bytes, bool ==, ref-arm POINTER identity), different tags are + // never equal. Two shapes lower: same union on both sides, and + // union vs plain arm value (`u === "text"` where the checker + // didn't narrow) — the plain side wraps into the union, which + // preserves payload identity. Different unions (tsc admits + // partially-overlapping ones) still need narrowing first. + const ut = left.type.kind === "union" ? left.type : (right.type as IrType & { kind: "union" }); + const bothUnion = left.type.kind === "union" && right.type.kind === "union"; + const sameUnion = bothUnion && typeEquals(left.type, right.type); + const plainFunc = left.type.kind === "func" ? left : right.type.kind === "func" ? right : null; + const unionArms = lowerer.unions.get(ut.unionId)?.arms ?? []; + const funcArm = unionArms.find((arm) => arm.kind === "func"); + // `listeners()` reports original prefix callbacks, but the + // checker views an indexed result as function | undefined. For + // identity comparison, retain the original closure pointer rather + // than adapting it to the tuple signature. + if ( + !bothUnion && + plainFunc !== null && + funcArm?.kind === "func" && + unionArms.filter((arm) => arm.kind === "func").length === 1 && + unionArms.some(isUnitType) + ) { + return { + kind: "unionFuncEq", + unionId: ut.unionId, + tag: lowerer.armTag(ut.unionId, funcArm), + union: left.type.kind === "union" ? left : right, + func: plainFunc, + negated, + type: BOOL, + loc, + }; + } + if ((sameUnion || !bothUnion) && lowerer.eqComparableUnion(ut.unionId)) { + aliasRefusal(ut); + return { + kind: "unionEq", + unionId: ut.unionId, + negated, + sameValue: false, + left: lowerer.coerceInto(leftNode, left, ut), + right: lowerer.coerceInto(rightNode, right, ut), + type: BOOL, + loc, + }; + } + if (left.type.kind === "union" && right.type.kind === "union") { + const leftDef = lowerer.unions.get(left.type.unionId); + const rightDef = lowerer.unions.get(right.type.unionId); + if (leftDef && rightDef) { + const leftValues = leftDef.arms.filter((arm) => !isUnitType(arm)); + const rightValues = rightDef.arms.filter((arm) => !isUnitType(arm)); + const sameValueArms = + leftValues.length === rightValues.length && + leftValues.every((arm) => rightValues.some((candidate) => typeEquals(candidate, arm))); + if (sameValueArms) { + const arms = [...leftDef.arms]; + for (const arm of rightDef.arms) { + if (!arms.some((candidate) => typeEquals(candidate, arm))) arms.push(arm); + } + arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + const common: IrType & { kind: "union" } = { + kind: "union", + unionId: lowerer.unions.intern(arms), + }; + aliasRefusal(common); + return { + kind: "unionEq", + unionId: common.unionId, + negated, + sameValue: false, + left: lowerer.coerceInto(leftNode, left, common), + right: lowerer.coerceInto(rightNode, right, common), + type: BOOL, + loc, + }; + } + } + } + lowerer.unsupported("SC1090", node, `comparisons of union-typed values (${NARROW_FIRST})`); + } + // Arrays, maps, functions, class instances and records compare by + // reference identity (pointer compare) — JS-exact object equality. + // Hierarchy-related classes compare after widening the derived side + // (same pointer either way — prefix layout). + let idLeft = left; + let idRight = right; + if (left.type.kind === "object" && right.type.kind === "object") { + if (lowerer.isSubclassOf(left.type.className, right.type.className)) { + idLeft = lowerer.upcastTo(left, right.type.className); + } else if (lowerer.isSubclassOf(right.type.className, left.type.className)) { + idRight = lowerer.upcastTo(right, left.type.className); + } + } + // Two function values compare by identity whatever their STATIC + // signatures (tsc admits the comparison when one side is + // assignable to the other — a tuple-typed listeners() element + // against a prefix-declared handler): the pointer answers it. + if (idLeft.type.kind === "func" && idRight.type.kind === "func") { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + // Two CLASS values compare by identity whatever their static + // classes — one immortal object per class, one pointer compare + // (the function-identity rule verbatim; `X === D` through a + // base-typed slot answers exactly JS). + if (idLeft.type.kind === "classval" && idRight.type.kind === "classval") { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + if (idLeft.type.kind === "moduleNs" && idRight.type.kind === "moduleNs") { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + if ( + (idLeft.type.kind === "array" || + idLeft.type.kind === "map" || + idLeft.type.kind === "set" || + idLeft.type.kind === "regex" || + idLeft.type.kind === "url" || + idLeft.type.kind === "object" || + idLeft.type.kind === "record" || + // Symbols ARE identity: `Symbol('a') === Symbol('a')` is false, + // a symbol equals exactly itself, and Symbol.for's interned + // values compare equal across call sites — all one pointer + // compare, JS's spec without approximation. + idLeft.type.kind === "symbol" || + // Typed arrays / Buffers ARE objects to ===: pointer identity + // (buf === buf.swap16() — the in-place mutators return this). + idLeft.type.kind === "bytes" || + idLeft.type.kind === "promise") && + typeEquals(idLeft.type, idRight.type) + ) { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + // Runtime HANDLES are objects to === too: one handle per socket/ + // request/response, so pointer identity IS JS's object equality + // (`c.pause() === c` — Node's chaining assertions). */ + if (DYN_HANDLE_KINDS.has(idLeft.type.kind) && typeEquals(idLeft.type, idRight.type)) { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + if (lowerer.dynConvertible(idLeft.type) && lowerer.dynConvertible(idRight.type)) { + return { + kind: "dynScalarEq", + left: lowerer.coerceToExpected(idLeft, DYN), + right: lowerer.coerceToExpected(idRight, DYN), + ...(negated ? { negated: true as const } : {}), + type: BOOL, + loc, + }; + } + return lowerer.unsupported("SC1043", node); +} + function lowerLogicalChain(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr { // Logical chains have a left-nested AST. Keep the native stack bounded // while retaining each pair's checker type and lazy operand evaluation. diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 289ab8fce..367c147f8 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -164,7 +164,11 @@ import { } from "./lower-exprs.js"; import { globalSymbolKey } from "./expressions/global-symbols.js"; import { lowerShortCircuitAssignment } from "./expressions/nullish-assignment.js"; -import { lowerEnvironmentKey, lowerNativeFunctionAssignment } from "./lower-exprs.js"; +import { + lowerEnvironmentKey, + lowerNativeFunctionAssignment, + lowerStrictEquality, +} from "./lower-exprs.js"; import { isNativeProxyInitializer } from "./expressions/native-proxy.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { lowerUnionFieldWrite } from "./expressions/union-fields.js"; @@ -5156,9 +5160,21 @@ export function lowerSwitch(lowerer: Lowerer, stmt: ts.SwitchStatement): IrStmt const dk = disc.type.kind; if (dk === "dyn") return lowerDynSwitch(lowerer, stmt, disc, labels); if (dk === "union") return lowerUnionSwitch(lowerer, stmt, disc, labels); - if (dk !== "f64" && dk !== "string" && dk !== "bool") { - lowerer.unsupported("SC1090", stmt.expression, "switch on non-primitive values"); - } + // Objects, arrays, functions and other references match their cases + // with ===, which compares them by identity. + if (dk !== "f64" && dk !== "string" && dk !== "bool") + return lowerBranchSwitch(lowerer, stmt, disc, labels, (stableDisc, test, expression) => + lowerStrictEquality( + lowerer, + expression, + stmt.expression, + expression, + stableDisc, + test, + false, + locOf(expression), + ), + ); // The whole case-body sequence is ONE lexical scope in JS: a bare `let` // in one case is visible in later cases. lowerer.scopes.push(new Map()); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index a3ac01cb4..031d0459a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -58,6 +58,7 @@ import { import { unionRetagMappable, narrowedRetagHelper, + uninhabitedArmRetagHelper, strandedUnitTrap, strandedCoercionTrap, recordUnionWrapHelper, @@ -2821,10 +2822,33 @@ export class Lowerer { } /** Declared class fields may store a DYN value only when its static type - * names checked protocol identity or an owned runtime handle (a Worker - * or MessagePort); `unknown` and `any` fields stay refused. */ + * is `unknown` or `object` (also with `null` or `undefined`, or an array + * of those), names checked protocol identity, or is an owned runtime + * handle (a Worker or MessagePort); `any` fields stay refused. */ classFieldStoresDyn(type: ts.Type): boolean { - return this.classImplementedProtocol(type) || isWorkerHandleType(type, this.typeCtx); + return ( + this.opaqueValueType(type) || + this.classImplementedProtocol(type) || + isWorkerHandleType(type, this.typeCtx) + ); + } + + /** `unknown`, `object`, an array of those, or a union of those with + * `null` and `undefined`: types whose values use checked-dynamic storage + * as they do in JavaScript class fields. */ + private opaqueValueType(type: ts.Type): boolean { + const opaque = (part: ts.Type): boolean => + (part.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.NonPrimitive)) !== 0 || + (this.checker.isArrayType(part) && + this.checker.getTypeArguments(part as ts.TypeReference).every(opaque)); + if (!type.isUnionType()) return opaque(type); + const parts = ts.constituentTypes(type); + return ( + parts.some(opaque) && + parts.every( + (part) => (part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0 || opaque(part), + ) + ); } /** The nominal identity of a checker module-namespace type. Program @@ -4124,11 +4148,35 @@ export class Lowerer { // Such a binding reads `T | undefined`, so a typed parameter, local or // field it flows into widens like any other missing element read. const holeyIterationSymbols = new Set(); + // `xs[i] as number` and `stack.pop() as number`: an assertion to a type + // without undefined checks the value (lowerAsExpression narrows to the + // asserted type and throws a TypeError for undefined), so its result is + // never undefined. + const assertsDefined = (node: ts.Expression): boolean => { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isAsExpression(e) && !ts.isTypeAssertion(e)) return false; + const asserted = typeOrNull(e); + const admits = (type: ts.Type): boolean => + (type.flags & + (ts.TypeFlags.Any | + ts.TypeFlags.Unknown | + ts.TypeFlags.Undefined | + ts.TypeFlags.Void | + ts.TypeFlags.TypeParameter)) !== + 0; + return ( + asserted !== null && + !admits(asserted) && + !(asserted.isUnionType() && ts.constituentTypes(asserted).some(admits)) + ); + }; const mayBeOptional = (node: ts.Expression): boolean => { // `xs[i]!` is the explicit proven-present form. Its array read keeps // the established dense bounds trap and must not promote the enclosing // function/global ABI to `T | undefined`. if (explicitlyNonNull(node)) return false; + if (assertsDefined(node)) return false; const e = peel(node); if (isIndexedRead(e)) return true; if (ts.isPropertyAccessExpression(e) && isOptionalProcessStreamProperty(this, e)) return true; @@ -8420,7 +8468,9 @@ export class Lowerer { // node's CHECKER type proves those arms away, they trap instead — the // sub-union narrowing bridge (narrowedRetagHelper). if (e.type.kind === "union" && expected.kind === "union" && !typeEquals(e.type, expected)) { - const helper = this.narrowedRetagHelper(node, e.type.unionId, expected.unionId, e.loc); + const helper = + this.narrowedRetagHelper(node, e.type.unionId, expected.unionId, e.loc) ?? + uninhabitedArmRetagHelper(this, node, e.type.unionId, expected.unionId, e.loc); if (helper) { e = { kind: "call", callee: helper, args: [e], type: expected, loc: e.loc }; } diff --git a/packages/compiler/src/frontend/pipeline.test.ts b/packages/compiler/src/frontend/pipeline.test.ts index 2aa6aad89..902441c96 100644 --- a/packages/compiler/src/frontend/pipeline.test.ts +++ b/packages/compiler/src/frontend/pipeline.test.ts @@ -102,3 +102,26 @@ test("preserves the original failure and continues cleanup when a disposer throw expect(opened).toHaveLength(3); for (const load of opened) expect(load.dispose).toHaveBeenCalledTimes(1); }); + +test("reports program type errors instead of refusing the package", () => { + // The program fails to typecheck against the package's inferred surface + // and against its declarations alike: the errors are the program's. + preflight.mockReturnValueOnce([]).mockReturnValue([typeError]); + const frontend = runFrontend("/main.ts", loader, ["fixture"]); + expect(frontend.npmStatic).toEqual([]); + expect(frontend.preflight).toEqual([typeError]); + frontend.dispose(); +}); + +test("reports program type errors that name the package's module", () => { + const missingExport: ScrDiagnostic = { + code: "SC0001", + message: `Module '"fixture"' has no exported member 'open'.`, + loc: { file: "/main.ts", start: 9, end: 13 }, + }; + preflight.mockReturnValueOnce([]).mockReturnValue([missingExport]); + const frontend = runFrontend("/main.ts", loader, ["fixture"]); + expect(frontend.npmStatic).toEqual([]); + expect(frontend.preflight).toEqual([missingExport]); + frontend.dispose(); +}); diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index db2b57af8..139581c10 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -337,6 +337,9 @@ function loadFrontend( } } const effective = new Set(requested); + // Packages refused only because the program does not typecheck against + // their inferred surface (no offender, no package-anchored diagnostic). + const typecheckRefused = new Set(); while (effective.size > 0) { const reasons = new Map(npmStaticOffenders()); for (const d of preflight) { @@ -352,6 +355,7 @@ function loadFrontend( } } for (const [pkg, count] of named) { + typecheckRefused.add(pkg); reasons.set( pkg, `its inferred export surface breaks ${count} import site${count === 1 ? "" : "s"} in program files (bundler-emitted surfaces type only as far as inference reaches)`, @@ -384,6 +388,7 @@ function loadFrontend( if (effective.size > 0 && preflight.some((d) => d.code === "SC0001")) { const dropWithNote = (p: string): void => { effective.delete(p); + typecheckRefused.add(p); statuses.push({ package: p, status: "refused", @@ -418,6 +423,16 @@ function loadFrontend( for (const p of requested) { if (effective.has(p)) statuses.push({ package: p, status: "static" }); } + // The final program types the refused packages by their own + // declarations. If it still fails to typecheck, the type errors are the + // program's, not the packages': report them at their own locations + // instead of a package refusal that hides them. + if (typecheckRefused.size > 0 && preflight.some((d) => d.code === "SC0001")) { + for (let i = statuses.length - 1; i >= 0; i--) { + const s = statuses[i]!; + if (s.status === "refused" && typecheckRefused.has(s.package)) statuses.splice(i, 1); + } + } const finalLoad = load; return { diff --git a/packages/compiler/test/runtime-optional-precision.test.ts b/packages/compiler/test/runtime-optional-precision.test.ts index bf41309c2..7840b0c62 100644 --- a/packages/compiler/test/runtime-optional-precision.test.ts +++ b/packages/compiler/test/runtime-optional-precision.test.ts @@ -221,3 +221,29 @@ console.log(describe(items[4]!)); `); expect(signature(unproven, "describe")).toBe("describe(Item|undefined): string"); }); + +test("a type assertion keeps an unchecked read from widening its callee", async () => { + const module = await lower(` +function hot(n: number): number { + return (n * 2) | 0; +} +function cold(xs: number[]): number { + let s = 0; + for (let i = 0; i < xs.length; i++) s = (s + hot(xs[i] as number)) | 0; + return s; +} +function drain(stack: number[]): number { + let s = 0; + while (stack.length > 0) s = (s + hot(stack.pop() as number)) | 0; + return s; +} +function lookup(m: Map): number { + return hot(m.get("a")); +} +console.log(cold([1, 2, 3]), drain([4, 5]), lookup(new Map([["a", 6]]))); +`); + expect(signature(module, "hot")).toBe("hot(f64): f64"); + const llvm = emitLlvmModule(module); + for (const caller of ["cold", "drain", "lookup"]) + expect(body(llvm, caller)).not.toContain("@scr_union_new"); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5920-promise-try-args.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5920-promise-try-args.ts.json new file mode 100644 index 000000000..b74ce6d45 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5920-promise-try-args.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5920-promise-try-args.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5921-promise-try-args-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5921-promise-try-args-js.js.json new file mode 100644 index 000000000..085d6415c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5921-promise-try-args-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5921-promise-try-args-js.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5927-switch-object-identity.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5927-switch-object-identity.ts.json new file mode 100644 index 000000000..5ea05b587 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5927-switch-object-identity.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5927-switch-object-identity.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5931-asserted-reads-keep-number-parameters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5931-asserted-reads-keep-number-parameters.ts.json new file mode 100644 index 000000000..f2cfa7309 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5931-asserted-reads-keep-number-parameters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5931-asserted-reads-keep-number-parameters.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5933-class-fields-unknown-object.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5933-class-fields-unknown-object.ts.json new file mode 100644 index 000000000..5cb517618 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5933-class-fields-unknown-object.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5933-class-fields-unknown-object.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5950-tuple-predicate-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5950-tuple-predicate-methods.ts.json new file mode 100644 index 000000000..52c08dd09 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5950-tuple-predicate-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5950-tuple-predicate-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5951-result-never-widening.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5951-result-never-widening.ts.json new file mode 100644 index 000000000..2c81316cf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5951-result-never-widening.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5951-result-never-widening.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/npm-program-type-error/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/npm-program-type-error/main.ts.json new file mode 100644 index 000000000..52af990e9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/npm-program-type-error/main.ts.json @@ -0,0 +1,25 @@ +{ + "order": [ + "/tests/diagnostics/npm-program-type-error/main.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Module '\"sqlish\"' has no exported member 'open'.", + "loc": { + "file": "/tests/diagnostics/npm-program-type-error/main.ts", + "start": 138, + "end": 142 + } + }, + { + "code": "SC0001", + "message": "Expected 2 arguments, but got 1.", + "loc": { + "file": "/tests/diagnostics/npm-program-type-error/main.ts", + "start": 239, + "end": 244 + } + } + ] +} diff --git a/tests/corpus/5659-net-blocklist-socketaddress.ts b/tests/corpus/5659-net-blocklist-socketaddress.ts index f2c37c084..181f533d4 100644 --- a/tests/corpus/5659-net-blocklist-socketaddress.ts +++ b/tests/corpus/5659-net-blocklist-socketaddress.ts @@ -1,10 +1,18 @@ // net.SocketAddress (options, defaults, parse(), toJSON() and Node's // validation errors) and net.BlockList (address, range, subnet and CIDR // rules, removal, the rule listing order, check() across IPv4 and IPv6, -// toJSON()/fromJSON() and PRIVATE_RANGES). +// toJSON()/fromJSON() and PRIVATE_RANGES). Node lists the address rules +// first, in the iteration order of a C++ unordered_map (which differs between +// platforms), then the subnets and the ranges, each newest first; the program +// sorts the address rules before printing. import * as net from "node:net"; import { inspect } from "node:util"; +function ordered(rules: readonly string[]): string[] { + const addresses = rules.filter((rule) => rule.startsWith("Address: ")); + return [...addresses.sort(), ...rules.slice(addresses.length)]; +} + function show(label: string, f: () => unknown): void { try { const v = f(); @@ -43,9 +51,13 @@ list.addRange("10.0.0.1", "10.0.0.5"); list.addSubnet("192.168.0.0", 16); list.addCIDR("fe80::/10"); list.addAddresses(["7.7.7.7", sa]); -show("rules", () => list.rules); +show("rules", () => ordered(list.rules)); show("size", () => list.size); -show("inspect", () => list); +const single = new net.BlockList(); +single.addAddress("3.3.3.3"); +single.addRange("10.0.0.1", "10.0.0.5"); +single.addSubnet("192.168.0.0", 16); +show("inspect", () => single); show("check", () => [ list.check("1.2.3.4"), list.check("10.0.0.3"), @@ -66,7 +78,7 @@ show("bad cidr", () => list.addCIDR("10.0.0.0")); list.removeAddress("2.2.2.2"); list.removeRange("10.0.0.1", "10.0.0.5"); list.removeCIDR("fe80::/10"); -show("after removal", () => list.rules); +show("after removal", () => ordered(list.rules)); const mapped = new net.BlockList(); mapped.addSubnet("::", 64, "ipv6"); @@ -74,7 +86,7 @@ show("mapped", () => [mapped.check("9.9.9.9"), mapped.check("::1:0:0:0:1", "ipv6 const copy = new net.BlockList(); copy.fromJSON(list.toJSON()); -show("fromJSON", () => copy.rules); +show("fromJSON", () => ordered(copy.rules)); const parsed = new net.BlockList(); parsed.fromJSON(JSON.stringify(["Subnet: IPv6 ::/64", "x Address: IPv4 9.9.9.9 y", "Range: IPv4 1.1.1.1-1.1.1.2"])); show("fromJSON string", () => parsed.rules); diff --git a/tests/corpus/5920-promise-try-args.ts b/tests/corpus/5920-promise-try-args.ts new file mode 100644 index 000000000..a5d309397 --- /dev/null +++ b/tests/corpus/5920-promise-try-args.ts @@ -0,0 +1,22 @@ +// Promise.try(f, ...args): the arguments are evaluated at the call and passed +// to f, which runs synchronously; throws reject and returned promises are +// adopted. +Promise.try(() => 5).then((v) => console.log("value", v)); +Promise.try((): number => { throw new Error("sync"); }).catch((e) => console.log("caught", (e as Error).message)); +Promise.try(() => Promise.resolve("inner")).then((v) => console.log("adopted", v)); +Promise.try(async () => 7).then((v) => console.log("async", v)); +const order: string[] = []; +const p = Promise.try(() => { order.push("run"); return 1; }); +order.push("after"); +p.then(() => console.log(order.join(","))); +Promise.try((a: number, b: number) => a + b, 1, 2).then((v) => console.log("args", v)); +Promise.try(() => undefined).then((v) => console.log("void", v)); +const log: string[] = []; +const v = (x: number): number => { log.push("arg" + x); return x; }; +const q = Promise.try((a: number, b: number) => { log.push("call"); return a + b; }, v(1), v(2)); +log.push("after"); +q.then((r) => console.log(r, log.join(","))); +Promise.try((s: string) => { throw new Error(s); }, "bad").catch((e) => console.log("caught", (e as Error).message)); +Promise.try((n: number) => Promise.resolve(n * 2), 21).then((r) => console.log("adopted", r)); +function named(this: void, x: number): number { return x + 1; } +Promise.try(named, 4).then((r) => console.log("named", r)); diff --git a/tests/corpus/5921-promise-try-args-js.js b/tests/corpus/5921-promise-try-args-js.js new file mode 100644 index 000000000..10171c11d --- /dev/null +++ b/tests/corpus/5921-promise-try-args-js.js @@ -0,0 +1,7 @@ +// Promise.try(f, ...args) in JavaScript: unannotated parameters take the +// passed values. +Promise.try((a, b) => a * b, 6, 7).then((v) => console.log("product", v)); +Promise.try((s) => s.toUpperCase(), "js").then((v) => console.log(v)); +Promise.try((x) => { + throw new RangeError("r" + x); +}, 1).catch((e) => console.log(e.name, e.message)); diff --git a/tests/corpus/5927-switch-object-identity.ts b/tests/corpus/5927-switch-object-identity.ts new file mode 100644 index 000000000..04d2025a5 --- /dev/null +++ b/tests/corpus/5927-switch-object-identity.ts @@ -0,0 +1,71 @@ +// switch over objects, arrays, records and functions matches cases with ===: +// by identity, with case expressions evaluated lazily in source order. +class Mode { + readonly name: string; + constructor(name: string) { + this.name = name; + } +} +const fast = new Mode("fast"); +const slow = new Mode("slow"); +function label(mode: Mode | undefined): string { + switch (mode) { + case fast: + return "fast"; + case slow: + return "slow"; + case undefined: + return "none"; + default: + return "other"; + } +} +console.log(label(fast), label(slow), label(undefined), label(new Mode("fast"))); +type Point = { x: number }; +const origin: Point = { x: 0 }; +function where(p: Point): string { + switch (p) { + case origin: + return "origin"; + default: + return `at ${p.x}`; + } +} +console.log(where(origin), where({ x: 0 })); +const list = [1, 2]; +const other = [1, 2]; +for (const arr of [list, other]) { + switch (arr) { + case list: + console.log("list"); + break; + case other: + console.log("other"); + } +} +const order: string[] = []; +const pick = (m: Mode): Mode => { + order.push(m.name); + return m; +}; +switch (slow) { + case pick(fast): + console.log("no"); + break; + case pick(slow): + console.log("matched second"); + break; + case pick(fast): + console.log("never evaluated"); +} +console.log(order.join(",")); +const f = (): number => 1; +const g = (): number => 2; +switch (g as () => number) { + case f: + console.log("f"); + break; + case g: + console.log("g"); + break; +} diff --git a/tests/corpus/5931-asserted-reads-keep-number-parameters.ts b/tests/corpus/5931-asserted-reads-keep-number-parameters.ts new file mode 100644 index 000000000..fa4f96f29 --- /dev/null +++ b/tests/corpus/5931-asserted-reads-keep-number-parameters.ts @@ -0,0 +1,52 @@ +// Arguments asserted to a type without undefined (`xs[i] as number`, +// `stack.pop() as number`, `map.get(k) as Item`) pass present values. +// A callee that also receives an unchecked missing read still observes +// undefined there. +class Item { + weight: number; + constructor(weight: number) { + this.weight = weight; + } +} + +function hot(n: number): number { + return (n * 2) | 0; +} + +function describe(n: number): string { + return n === undefined ? "missing" : `value ${n}`; +} + +function weigh(item: Item): number { + return item.weight; +} + +function cold(xs: number[]): number { + let s = 0; + for (let i = 0; i < xs.length; i++) s = (s + hot(xs[i] as number)) | 0; + return s; +} + +function drain(stack: number[]): number { + let s = 0; + while (stack.length > 0) s = (s + hot(stack.pop() as number)) | 0; + return s; +} + +function total(items: Map, keys: string[]): number { + let sum = 0; + for (const key of keys) if (items.has(key)) sum += weigh(items.get(key) as Item); + return sum; +} + +const xs = [1, 2, 3]; +console.log(cold(xs), drain([4, 5, 6])); +console.log(describe(xs[1] as number), describe(xs[7])); +const items = new Map([ + ["a", new Item(10)], + ["b", new Item(20)], +]); +console.log(total(items, ["a", "b", "c"])); +let t = 0; +for (let i = 0; i < 1000; i++) t = (t + hot(i)) | 0; +console.log(t); diff --git a/tests/corpus/5933-class-fields-unknown-object.ts b/tests/corpus/5933-class-fields-unknown-object.ts new file mode 100644 index 000000000..aadcec590 --- /dev/null +++ b/tests/corpus/5933-class-fields-unknown-object.ts @@ -0,0 +1,99 @@ +// TypeScript class fields declared `unknown` or `object` (also with +// `null` or `undefined`, or as arrays) hold any value of those types. +// Reads narrow with typeof and instanceof, stored references keep their +// identity, and static fields and parameter properties work the same way. +class Item { + name: string; + constructor(name: string) { + this.name = name; + } + greet(): string { + return `item ${this.name}`; + } +} + +class Slot { + value: unknown = null; + data: object | null = null; + list: unknown[] = []; + extra?: unknown; + set(next: unknown): void { + this.value = next; + } + describe(): string { + if (typeof this.value === "string") return `string of ${this.value.length}`; + if (typeof this.value === "number") return `number ${this.value * 2}`; + if (this.value instanceof Item) return this.value.greet(); + if (Array.isArray(this.value)) return `array of ${this.value.length}`; + return `other ${String(this.value)}`; + } +} + +class Holder { + payload: unknown; + readonly meta: object; + constructor(payload: unknown, meta: object) { + this.payload = payload; + this.meta = meta; + } +} + +class Registry { + static current: unknown = null; + static entries: object[] = []; +} + +class Cell { + item: T; + note: unknown = undefined; + constructor(item: T) { + this.item = item; + } +} + +const slot = new Slot(); +console.log(slot.value, slot.data, slot.list, slot.extra, slot.describe()); +const item = new Item("a"); +for (const next of ["text", 21, item, [1, 2, 3], true, undefined] as unknown[]) { + slot.set(next); + console.log(slot.describe()); +} + +slot.value = item; +item.name = "b"; +console.log((slot.value as Item).name, slot.value === item); +const record = { count: 1 }; +slot.data = record; +record.count = 2; +console.log(slot.data, slot.data === record); +const numbers = [1, 2]; +slot.data = numbers; +numbers.push(3); +console.log(slot.data, slot.data === numbers); +const lookup = new Map([["k", 1]]); +slot.value = lookup; +lookup.set("j", 2); +console.log(slot.value); +const double = (x: number): number => x * 2; +slot.value = double; +if (typeof slot.value === "function") console.log(slot.value === double, (slot.value as (x: number) => number)(4)); +slot.list.push(item, 1, null, "s"); +console.log(slot.list.length, slot.list[0] === item, slot.list.slice(1)); +slot.extra = { nested: [1, { deep: true }] }; +console.log(JSON.stringify(slot.extra), slot.extra ?? "none"); +slot.value = undefined; +console.log(slot.value === undefined, slot.value ?? "fallback"); + +const holder = new Holder([1, 2, 3], { tag: "m" }); +console.log(Array.isArray(holder.payload), holder.payload, holder.meta); +const values: unknown[] = [holder.payload, holder.meta]; +console.log(values.length, values[0] === holder.payload); + +console.log(Registry.current, Registry.entries); +Registry.current = { a: 1 }; +Registry.entries.push(record, numbers); +console.log(Registry.current, Registry.entries.length, Registry.entries[0] === record); + +const cell = new Cell(1); +cell.note = "note"; +console.log(cell.item, cell.note, typeof cell.note); diff --git a/tests/corpus/5950-tuple-predicate-methods.ts b/tests/corpus/5950-tuple-predicate-methods.ts new file mode 100644 index 000000000..da2533a05 --- /dev/null +++ b/tests/corpus/5950-tuple-predicate-methods.ts @@ -0,0 +1,112 @@ +// filter, find, findIndex, some and every on fixed tuples, readonly and +// mutable: each position is read when it is visited, find/findIndex/some/ +// every stop at the first deciding position, and results match arrays. +export {}; + +const tools = ["read", "write", "search"] as const; +const allowed: string[] = ["read", "search"]; +console.log(JSON.stringify(tools.filter((name) => allowed.includes(name)))); +console.log(tools.find((name) => name.startsWith("w")), tools.find((name) => name === allowed[5])); +console.log(tools.findIndex((name) => name === "search"), tools.findIndex((name) => name.length > 9)); +console.log(tools.some((name) => name.length > 5), tools.some((name) => name.length > 9)); +console.log(tools.every((name) => name.length > 3), tools.every((name) => name.length > 4)); +console.log(tools.filter((_, index) => index !== 1).join(",")); + +// Mixed element types, with and without inferred narrowing. +const mixed: readonly [number, string, boolean, number] = [7, "eight", true, 0]; +console.log(JSON.stringify(mixed.filter((value): boolean => typeof value !== "boolean"))); +const numbers = mixed.filter((value) => typeof value === "number"); +console.log(numbers.reduce((sum, value) => sum + value, 0), numbers.length); +const text = mixed.find((value) => typeof value === "string"); +console.log(text === undefined ? "none" : text.toUpperCase()); +console.log(mixed.findIndex((value) => value === true), mixed.some((value) => value === "eight")); +console.log(mixed.every((value) => typeof value !== "object")); + +// Truthiness of non-boolean predicate results, and Boolean. +const counts: [number, number, number] = [0, 3, 0]; +console.log(counts.filter((value) => value).join(","), counts.findIndex((value) => value)); +console.log(counts.some((value) => value), counts.every((value) => value)); +const labels: [string, string, string] = ["", "b", "c"]; +console.log(labels.filter(Boolean).join(","), labels.find((value) => value)); +const maybe: [string | undefined, string | undefined] = [undefined, "x"]; +console.log(JSON.stringify(maybe.filter(Boolean)), maybe.findIndex((value) => value)); + +// A single-element tuple. +const single: [number] = [4]; +console.log(JSON.stringify(single.filter((value) => value > 4)), single.find((value) => value === 4)); + +// A callback's write to a later position is visible when it is visited; +// filter and find keep the value read before the callback ran. +const mutable: [number, number, number] = [1, 2, 3]; +console.log(mutable.filter((value, index) => { + if (index === 0) mutable[1] = 20; + if (index === 1) mutable[1] = 200; + return value > 1; +}).join(","), mutable.join(",")); +const slots: [number, number] = [5, 6]; +console.log(slots.find((value) => { + slots[0] = 50; + return value === 5; +}), slots[0]); +const later: [number, number, number] = [1, 2, 3]; +console.log(later.some((value, index) => { + if (index === 0) later[2] = 30; + return value === 30; +})); + +// Reference identity of kept and found elements. +const a = { id: "a" }; +const b = { id: "b" }; +const objects: [typeof a, typeof b] = [a, b]; +const kept = objects.filter((value) => value.id === "b"); +const found = objects.find((value) => value.id === "a"); +console.log(kept[0] === b, found === a); +b.id = "B"; +console.log(kept[0]!.id); + +// Short-circuiting and visit order. +let visited = ""; +const letters = ["p", "q", "r"] as const; +console.log(letters.find((value) => { visited += value; return value === "q"; }), visited); +visited = ""; +console.log(letters.findIndex((value) => { visited += value; return value === "p"; }), visited); +visited = ""; +console.log(letters.some((value) => { visited += value; return value === "q"; }), visited); +visited = ""; +console.log(letters.every((value) => { visited += value; return value === "p"; }), visited); +visited = ""; +console.log(letters.filter((value) => { visited += value; return true; }).length, visited); + +// The receiver evaluates before the callback expression. +let calls = ""; +function receiver(): [number, number] { calls += "R"; return [8, 9]; } +function predicate(): (value: number) => boolean { + calls += "C"; + return (value) => { calls += String(value); return value === 9; }; +} +console.log(receiver().filter(predicate()).join(","), calls); +calls = ""; +console.log(receiver().find(predicate()), calls); + +// A throwing callback stops the visit. +visited = ""; +try { + letters.filter((value, index) => { + visited += value; + if (index === 1) throw new Error("stop"); + return true; + }); +} catch (error) { + if (error instanceof Error) console.log(error.message, visited); +} + +// Generic consumers. +function firstMatch(value: T, other: T): T | undefined { + const pair: [T, T] = [value, other]; + return pair.find((item) => item === other); +} +function anyEqual(value: T, other: T): boolean { + const pair: readonly [T, T] = [value, other]; + return pair.some((item, index) => index > 0 && item === value); +} +console.log(firstMatch(1, 2), firstMatch("x", "y"), anyEqual(3, 3), anyEqual("u", "v")); diff --git a/tests/corpus/5951-result-never-widening.ts b/tests/corpus/5951-result-never-widening.ts new file mode 100644 index 000000000..6929f4605 --- /dev/null +++ b/tests/corpus/5951-result-never-widening.ts @@ -0,0 +1,83 @@ +// A union arm with a required `never` property has no values, so +// Result and Result widen into Result at returns, +// initializers, arguments, array elements and async returns. +export {}; + +type Result = { ok: true; value: T } | { ok: false; error: E }; + +function ok(value: T): Result { + return { ok: true, value }; +} + +function err(error: E): Result { + return { ok: false, error }; +} + +function parse(value: string): Result { + return value.length > 0 ? ok(value) : { ok: false, error: "empty" }; +} + +function check(value: number): Result { + if (value < 0) return err("negative"); + return ok(value); +} + +function show(result: Result): string { + return result.ok ? `ok ${result.value}` : `error ${result.error}`; +} + +function showNumber(result: Result): string { + return result.ok ? `ok ${result.value}` : `error ${result.error}`; +} + +console.log(JSON.stringify(parse("hello")), JSON.stringify(parse(""))); +console.log(showNumber(check(4)), showNumber(check(-1))); + +const fromOk: Result = ok("bound"); +const fromErr: Result = err("failed"); +console.log(show(fromOk), show(fromErr)); +console.log(show(ok("argument")), showNumber(err("argument"))); + +const all: Result[] = [ok(1), err("two"), ok(3)]; +console.log(all.map((result) => showNumber(result)).join(", ")); + +let current: Result = ok("first"); +console.log(show(current)); +current = err("reassigned"); +console.log(show(current)); +current = ok("third"); +console.log(show(current)); + +function wrap(value: T): Result { + return ok(value); +} +console.log(showNumber(wrap(5)), show(wrap("five"))); + +// The never arm's fields stay readable in branches that cannot run. +const only = ok(10); +if (only.ok) console.log("value", only.value); +else console.log("unreachable", only.error); + +// Optional never properties, never[] and never-returning callbacks have +// values, so their arms convert as before. +type Loose = + | { ok: true; value: string; error?: never } + | { ok: false; value?: never; error: string }; +function looseOk(value: string): Loose { + return { ok: true, value }; +} +const loose: Loose | null = looseOk("loose"); +console.log(JSON.stringify(loose)); + +type Batch = { ok: true; items: string[] } | { ok: false; errors: never[] }; +function emptyFailure(): Batch { + return { ok: false, errors: [] }; +} +const batch: Batch | undefined = emptyFailure(); +console.log(JSON.stringify(batch)); + +async function load(name: string): Promise> { + return name === "" ? err("missing") : ok(`loaded ${name}`); +} +void load("config").then((result) => console.log(show(result))); +void load("").then((result) => console.log(show(result))); diff --git a/tests/diagnostics/deferred-class-binding/crate.ts b/tests/diagnostics/deferred-class-binding/crate.ts index 7a24ac02e..7dd470efa 100644 --- a/tests/diagnostics/deferred-class-binding/crate.ts +++ b/tests/diagnostics/deferred-class-binding/crate.ts @@ -1,6 +1,6 @@ export class Crate { readonly label: string; - contents: unknown; + contents: any; constructor(label: string) { this.label = label; this.contents = undefined; diff --git a/tests/diagnostics/es2025-fences.ts b/tests/diagnostics/es2025-fences.ts index 128c9d9ed..2df12226f 100644 --- a/tests/diagnostics/es2025-fences.ts +++ b/tests/diagnostics/es2025-fences.ts @@ -2,8 +2,8 @@ // ArrayBuffers and transfer, SharedArrayBuffer.grow, Atomics.waitAsync, // Float16Array and its Math/DataView companions, first-class iterator // objects, and the unlowered forms of the implemented statics -// (Promise.try's ...args, groupBy's literal-union keys, a set-like -// non-Set argument). +// (Promise.try with fewer arguments than parameters, groupBy's +// literal-union keys, a set-like non-Set argument). // Resizable ArrayBuffer + transfer (ES2024). const ab = new ArrayBuffer(8, { maxByteLength: 16 }); @@ -31,8 +31,8 @@ dv.setFloat16(0, 1.5); // First-class iterator objects. const it = Iterator.from([1, 2, 3].values()); -// Promise.try's ...args form (close over the values instead). -const tried = Promise.try((a: number, b: number) => a + b, 1, 2); +// Promise.try with fewer arguments than the callback's parameters. +const tried = Promise.try((a: number, b?: number) => a + (b ?? 0), 1); // Object.groupBy at a literal-union key type (a fixed-field record). const grouped = Object.groupBy([1, 2, 3], (n) => (n % 2 === 0 ? "even" : "odd")); diff --git a/tests/diagnostics/json-dyn.ts b/tests/diagnostics/json-dyn.ts index 77cfd19b1..c5cbac8ea 100644 --- a/tests/diagnostics/json-dyn.ts +++ b/tests/diagnostics/json-dyn.ts @@ -39,7 +39,7 @@ function localCapture(): () => number { return () => local as number; } class Holder { - data: unknown = JSON.parse("{}"); + data: unknown = JSON.parse("{}"); // unknown class fields use checked-dynamic storage — no fence } const anything: any = 5; // checker-`any` bindings ride the checked-dynamic tree now — no fence const dynArray: unknown[] = []; // unknown[] IS the dyn array now — no fence (corpus 2585) diff --git a/tests/diagnostics/npm-program-type-error/main.ts b/tests/diagnostics/npm-program-type-error/main.ts new file mode 100644 index 000000000..40132d7ea --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/main.ts @@ -0,0 +1,7 @@ +// Type errors in program code report at their own locations, not as +// SC2013 refusals of the npm packages the program imports. +import { open } from "sqlish"; +import { route } from "routerish"; + +console.log(open(":memory:")); +console.log(route("/users")); diff --git a/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.d.ts b/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.d.ts new file mode 100644 index 000000000..8832808f8 --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.d.ts @@ -0,0 +1 @@ +export declare function route(method: string, path: string): string; diff --git a/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.js b/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.js new file mode 100644 index 000000000..8cffdfff3 --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.js @@ -0,0 +1,3 @@ +export function route(method, path) { + return `${method} ${path}`; +} diff --git a/tests/diagnostics/npm-program-type-error/node_modules/routerish/package.json b/tests/diagnostics/npm-program-type-error/node_modules/routerish/package.json new file mode 100644 index 000000000..db9fe562f --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/routerish/package.json @@ -0,0 +1,7 @@ +{ + "name": "routerish", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.d.ts b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.d.ts new file mode 100644 index 000000000..146b98c02 --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.d.ts @@ -0,0 +1 @@ +export declare function connect(path: string): { path: string }; diff --git a/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.js b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.js new file mode 100644 index 000000000..9cda88901 --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.js @@ -0,0 +1,3 @@ +export function connect(path) { + return { path }; +} diff --git a/tests/diagnostics/npm-program-type-error/node_modules/sqlish/package.json b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/package.json new file mode 100644 index 000000000..26f3b6a08 --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/package.json @@ -0,0 +1,7 @@ +{ + "name": "sqlish", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/tests/harness/__snapshots__/deferred-class-binding/main.ts.txt b/tests/harness/__snapshots__/deferred-class-binding/main.ts.txt index 4f144907c..c80ae94e9 100644 --- a/tests/harness/__snapshots__/deferred-class-binding/main.ts.txt +++ b/tests/harness/__snapshots__/deferred-class-binding/main.ts.txt @@ -1,7 +1,7 @@ deferred-class-binding/crate.ts:3:3 - error SC1090: 'unknown'-typed class fields are not supported yet 2 | readonly label: string; - 3 | contents: unknown; + 3 | contents: any; | ^~~~~~~~ 4 | constructor(label: string) { diff --git a/tests/harness/__snapshots__/es2025-fences.ts.txt b/tests/harness/__snapshots__/es2025-fences.ts.txt index 0a91648ea..e1761f73a 100644 --- a/tests/harness/__snapshots__/es2025-fences.ts.txt +++ b/tests/harness/__snapshots__/es2025-fences.ts.txt @@ -97,14 +97,14 @@ es2025-fences.ts:32:12 - error SC2020: 'Iterator.from' is part of the standard l hint: first-class iterator objects have no lowering — iterator helpers compile as one chain on an array iterator, consumed in place: arr.values().map(f).take(n).toArray() -es2025-fences.ts:35:15 - error SC2020: 'Promise.try with 3 arguments' is part of the standard library types but has no scriptc lowering yet +es2025-fences.ts:35:27 - error SC2020: 'Promise.try over a callback of type '(a: number, b?: number | undefined) => number' with 1 argument' is part of the standard library types but has no scriptc lowering yet - 34 | // Promise.try's ...args form (close over the values instead). - 35 | const tried = Promise.try((a: number, b: number) => a + b, 1, 2); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 34 | // Promise.try with fewer arguments than the callback's parameters. + 35 | const tried = Promise.try((a: number, b?: number) => a + (b ?? 0), 1); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 36 | - hint: the ...args form has no lowering — close over the values: Promise.try(() => f(a, b)) + hint: pass one argument per callback parameter: Promise.try((a, b) => a + b, 1, 2) es2025-fences.ts:38:17 - error SC2020: 'Object.groupBy at this result type' is part of the standard library types but has no scriptc lowering yet diff --git a/tests/harness/__snapshots__/json-dyn.ts.txt b/tests/harness/__snapshots__/json-dyn.ts.txt index 4b0f60784..4a09378fb 100644 --- a/tests/harness/__snapshots__/json-dyn.ts.txt +++ b/tests/harness/__snapshots__/json-dyn.ts.txt @@ -5,13 +5,6 @@ json-dyn.ts:25:41 - error SC1090: JSON.stringify of '(number) => number' values | ^~~~~~~~~~~~~~~~~~~~ 26 | class Point { -json-dyn.ts:42:3 - error SC1090: 'unknown'-typed class fields are not supported yet - - 41 | class Holder { - 42 | data: unknown = JSON.parse("{}"); - | ^~~~ - 43 | } - json-dyn.ts:46:7 - error SC2007: values of type '{ (text: string, reviver?: ((this: any, key: string, value: any) => any) | undefined): any; (text: string): unknown; (text: string, reviver: (this: unknown, key: string, value: unknown) => unknown): unknown; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature 45 | const dynArray: unknown[] = []; // unknown[] IS the dyn array now — no fence (corpus 2585) @@ -26,11 +19,4 @@ json-dyn.ts:46:18 - error SC1090: JSON methods as values (call 'parse' directly) 45 | const dynArray: unknown[] = []; // unknown[] IS the dyn array now — no fence (corpus 2585) 46 | const parseRef = JSON.parse; | ^~~~~~~~~~ - 47 | // The exact `string | undefined` root now lowers: strings serialize while - -json-dyn.ts:63:1 - error SC1090: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet - - 62 | // them relevant; these references are what makes them count. - 63 | new Holder(); - | ^~~~~~~~~~~~ - 64 | // End with a nonblank context line so diagnostic snapshots carry no trailing whitespace. \ No newline at end of file + 47 | // The exact `string | undefined` root now lowers: strings serialize while \ No newline at end of file diff --git a/tests/harness/__snapshots__/npm-program-type-error/main.ts.txt b/tests/harness/__snapshots__/npm-program-type-error/main.ts.txt new file mode 100644 index 000000000..885665fc9 --- /dev/null +++ b/tests/harness/__snapshots__/npm-program-type-error/main.ts.txt @@ -0,0 +1,13 @@ +npm-program-type-error/main.ts:3:10 - error SC0001: Module '"sqlish"' has no exported member 'open'. + + 2 | // SC2013 refusals of the npm packages the program imports. + 3 | import { open } from "sqlish"; + | ^~~~ + 4 | import { route } from "routerish"; + +npm-program-type-error/main.ts:7:13 - error SC0001: Expected 2 arguments, but got 1. + + 6 | console.log(open(":memory:")); + 7 | console.log(route("/users")); + | ^~~~~ + 8 | \ No newline at end of file