From 6470c870ef19255c4c12c79efb98107e773efac1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 15:47:37 -0500 Subject: [PATCH 1/8] Store unknown and object class fields in checked-dynamic storage TypeScript class fields declared `unknown` or `object`, also with `null` or `undefined` or as arrays of those, were refused with SC1090. They now use the checked-dynamic storage that JavaScript class fields without a declared type already use. This covers instance fields, static fields and parameter properties. Fields declared `any` stay refused. Fixes #1147 Co-authored-by: Alan Leal <33952+alantriesagain@users.noreply.github.com> --- docs/content/limitations/untyped-values.mdx | 2 +- .../compiler/src/frontend/lowering/lowerer.ts | 29 +++++- .../5933-class-fields-unknown-object.ts.json | 6 ++ .../5933-class-fields-unknown-object.ts | 99 +++++++++++++++++++ .../deferred-class-binding/crate.ts | 2 +- tests/diagnostics/json-dyn.ts | 2 +- .../deferred-class-binding/main.ts.txt | 2 +- tests/harness/__snapshots__/json-dyn.ts.txt | 16 +-- 8 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5933-class-fields-unknown-object.ts.json create mode 100644 tests/corpus/5933-class-fields-unknown-object.ts 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/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index a3ac01cb4..e4e76607b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2821,10 +2821,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 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/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/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/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/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__/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 From 0210e54e49acb82facec5f43ab8019c4aadd423b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 15:50:17 -0500 Subject: [PATCH 2/8] Lower filter, find, findIndex, some and every on tuples Fixed tuples, readonly or mutable, lower these predicate methods like map and flatMap: each position is read when it is visited, find, findIndex, some and every stop at the first deciding position, and predicate results and narrowing follow the array methods' rules. Fixes #1171 Co-authored-by: BaoLei <50226652+blurname@users.noreply.github.com> --- .../lowering/containers/array-callbacks.ts | 380 ++++++++++++++---- .../lowering/containers/array-methods.ts | 13 +- .../5950-tuple-predicate-methods.ts.json | 6 + tests/corpus/5950-tuple-predicate-methods.ts | 112 ++++++ 4 files changed, 435 insertions(+), 76 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5950-tuple-predicate-methods.ts.json create mode 100644 tests/corpus/5950-tuple-predicate-methods.ts 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/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/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")); From 8a2d9356c4671508d32b4774e8277a90ffa8c4d2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 15:52:14 -0500 Subject: [PATCH 3/8] Print BlockList address rules in a platform-independent order Corpus 5659 printed blockList.rules and the inspected BlockList in the order Node lists them. Node lists the address rules first, in the iteration order of the std::unordered_map that holds them, and that order differs between platforms, so the expected output on macOS differed from Linux. The subnet and range rules that follow keep Node's fixed order (newest first). The program now sorts the address rules before printing the rules lists, and inspects a BlockList that holds a single address rule. --- .../5659-net-blocklist-socketaddress.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) 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); From 6f4b569cb6a56c40bef261a619dc4893ac1c5770 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 15:52:58 -0500 Subject: [PATCH 4/8] Compile switch statements over object values A switch whose discriminant is an object, array, record, function or other reference value was refused. It now lowers like the union and checked-dynamic switches: the discriminant is evaluated once and each case expression, in source order and only until one matches, is compared with it by the same strict-equality lowering as ===, which compares references by identity. The === and !== lowering is now a function the switch lowering shares. --- .../src/frontend/lowering/lower-exprs.ts | 477 +++++++++--------- .../src/frontend/lowering/lower-stmts.ts | 24 +- .../5927-switch-object-identity.ts.json | 6 + tests/corpus/5927-switch-object-identity.ts | 71 +++ 4 files changed, 345 insertions(+), 233 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5927-switch-object-identity.ts.json create mode 100644 tests/corpus/5927-switch-object-identity.ts 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/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/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; +} From cd977bf20038d1ca58f1d4ea8b35d8ba8825b02d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 15:55:37 -0500 Subject: [PATCH 5/8] Lower Promise.try with arguments for the callback Promise.try(f, ...args) evaluates the arguments at the call and passes them to f, which still runs synchronously; its result, a returned promise or a synchronous throw settle the returned promise as in the one-argument form. A callback with more parameters than arguments, or an arguments list with a spread, is refused. --- .../frontend/lowering/builtins/promises.ts | 55 ++++++++++++++----- .../corpus/5920-promise-try-args.ts.json | 6 ++ .../corpus/5921-promise-try-args-js.js.json | 6 ++ tests/corpus/5920-promise-try-args.ts | 22 ++++++++ tests/corpus/5921-promise-try-args-js.js | 7 +++ tests/diagnostics/es2025-fences.ts | 8 +-- .../__snapshots__/es2025-fences.ts.txt | 10 ++-- 7 files changed, 92 insertions(+), 22 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5920-promise-try-args.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5921-promise-try-args-js.js.json create mode 100644 tests/corpus/5920-promise-try-args.ts create mode 100644 tests/corpus/5921-promise-try-args-js.js 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/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/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/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/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 From d00ccbe89ea280cc05b1c5d4283b26b2bf8b64e3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 15:58:16 -0500 Subject: [PATCH 6/8] Widen Result and Result unions A union arm whose object type has a required property of type never has no values. When a conversion's source checker type proves an arm uninhabited, the re-tag traps on that arm instead of requiring a matching destination layout, so Result widens into Result. Fixes #1170 Co-authored-by: BaoLei <50226652+blurname@users.noreply.github.com> --- .../src/frontend/lowering/coercions/unions.ts | 42 ++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 5 +- .../corpus/5951-result-never-widening.ts.json | 6 ++ tests/corpus/5951-result-never-widening.ts | 83 +++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5951-result-never-widening.ts.json create mode 100644 tests/corpus/5951-result-never-widening.ts 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/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index a3ac01cb4..10fdf4062 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, @@ -8420,7 +8421,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/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/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))); From e161dc4446f16ba196039c973c1973d5eefe40c1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 16:00:54 -0500 Subject: [PATCH 7/8] Treat type-asserted values as present in the optional-value analysis `hot(xs[i] as number)` and `hot(stack.pop() as number)` counted as possibly undefined, so `hot(n: number)` took a boxed `number | undefined` parameter and callers boxed every number they passed. An assertion to a type without undefined already checks the value and throws a TypeError for undefined, so its result is now treated as present, like `x!`. Fixes #985 Co-authored-by: Tim Kendall <1427378+timkendall@users.noreply.github.com> --- .../compiler/src/frontend/lowering/lowerer.ts | 24 +++++++++ .../test/runtime-optional-precision.test.ts | 26 ++++++++++ ...erted-reads-keep-number-parameters.ts.json | 6 +++ ...1-asserted-reads-keep-number-parameters.ts | 52 +++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5931-asserted-reads-keep-number-parameters.ts.json create mode 100644 tests/corpus/5931-asserted-reads-keep-number-parameters.ts diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index a3ac01cb4..77a2fc842 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4124,11 +4124,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; 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/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/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); From 2f2f26d0d5505255bf140a1ebe2e67337325f785 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 16:06:08 -0500 Subject: [PATCH 8/8] Report program type errors instead of npm package refusals When the program still fails to typecheck against a refused package's own declarations, the type errors are the program's. Report them at their locations instead of an SC2013 refusal for every imported package. Fixes #1155 Co-authored-by: Quim T. <26749475+espetro@users.noreply.github.com> --- .gitignore | 1 + .../compiler/src/frontend/pipeline.test.ts | 23 +++++++++++++++++ packages/compiler/src/frontend/pipeline.ts | 15 +++++++++++ .../npm-program-type-error/main.ts.json | 25 +++++++++++++++++++ .../npm-program-type-error/main.ts | 7 ++++++ .../node_modules/routerish/index.d.ts | 1 + .../node_modules/routerish/index.js | 3 +++ .../node_modules/routerish/package.json | 7 ++++++ .../node_modules/sqlish/index.d.ts | 1 + .../node_modules/sqlish/index.js | 3 +++ .../node_modules/sqlish/package.json | 7 ++++++ .../npm-program-type-error/main.ts.txt | 13 ++++++++++ 12 files changed, 106 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/npm-program-type-error/main.ts.json create mode 100644 tests/diagnostics/npm-program-type-error/main.ts create mode 100644 tests/diagnostics/npm-program-type-error/node_modules/routerish/index.d.ts create mode 100644 tests/diagnostics/npm-program-type-error/node_modules/routerish/index.js create mode 100644 tests/diagnostics/npm-program-type-error/node_modules/routerish/package.json create mode 100644 tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.d.ts create mode 100644 tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.js create mode 100644 tests/diagnostics/npm-program-type-error/node_modules/sqlish/package.json create mode 100644 tests/harness/__snapshots__/npm-program-type-error/main.ts.txt 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/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/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/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__/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