diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index b177b231d..39b3242d6 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -6415,7 +6415,7 @@ export function collectInheritedJsFieldWrites( * hierarchy identifies the original declarations through aliases as well. */ export function collectVirtualJsMethods(lowerer: Lowerer, files: readonly ts.SourceFile[]): void { const families: [ts.MethodDeclaration, ts.MethodDeclaration][] = []; - collectClassMethodMutations(lowerer, files); + lowerer.speculativeTypeQueries(() => collectClassMethodMutations(lowerer, files)); const visit = (node: ts.Node): void => { if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { const accessors = node.members.filter( diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index b1116dd27..d4d03591d 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -203,6 +203,7 @@ import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { fenceNodeModuleMutation, isNodeModuleValue, + lowerCjsTopLevelThis, lowerNodeModuleIdentifier, lowerNodeModuleProperty, lowerRequireCacheElement, @@ -690,6 +691,30 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (!sym) { return { kind: "strLit", value: "undefined", type: STRING, loc }; } + // `const { inspect } = require("util")` / `import { inspect } from + // "node:util"`: a binding of a builtin module's exported function, + // class or object. JavaScript sources represent such a value without + // its own lowering as an identity token (a string), so the operand's + // representation cannot answer. Node's answer follows the export's + // kind, and the binding read has no effects. + const imported = isJsSourceFile(tested.getSourceFile()) + ? lowerer.builtinImportOf(tested) + : null; + if ( + imported && + builtinModuleConstOf(lowerer, imported.module, imported.member) === undefined + ) { + const t = lowerer.typeOf(tested); + if ( + lowerer.checker.getCallSignatures(t).length > 0 || + lowerer.checker.getConstructSignatures(t).length > 0 + ) { + return { kind: "strLit", value: "function", type: STRING, loc }; + } + if ((t.flags & ts.TypeFlags.Object) !== 0) { + return { kind: "strLit", value: "object", type: STRING, loc }; + } + } if (lowerer.isStdlibSymbol(sym)) { const t = lowerer.typeOf(expr.expression); if ( @@ -1661,6 +1686,9 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // constant. TypeScript keeps the fence (noImplicitThis makes it a // compile-time story there). if (isJsSourceFile(expr.getSourceFile())) { + // Module scope of a CommonJS file: `this` is module.exports. + const exportsThis = lowerCjsTopLevelThis(lowerer, expr); + if (exportsThis) return exportsThis; return { kind: "libCall", fn: "dyn.this", args: [], type: DYN, loc }; } lowerer.unsupported("SC1080", expr); diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index c5802b07e..3baab9dd0 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -2664,6 +2664,17 @@ export function lowerFileInit( loc: loc0, }); } + // Top-level `this` of a CommonJS module: its object exists before the + // module's first statement (lowerCjsTopLevelThis). + const exportsThis = lowerer.cjsExportsThis.get(sf); + if (exportsThis) { + header.push({ + kind: "assign", + localId: exportsThis.id, + value: { kind: "dynObjLit", fields: [], type: DYN, loc: loc0 }, + loc: loc0, + }); + } for (const { stmt, dep } of orderedImportsOf(lowerer.program, sf)) { const npm = lowerer.npmInitActions.get(sf)?.get(stmt); if (npm !== undefined && npm.length > 0) { diff --git a/packages/compiler/src/frontend/lowering/lower-node-module.ts b/packages/compiler/src/frontend/lowering/lower-node-module.ts index ad3f86dee..c15749601 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-module.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-module.ts @@ -8,10 +8,11 @@ import * as win32 from "node:path/win32"; import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import type { FileParts } from "./lower-modules.js"; -import { isNodeEsmFile, locOf } from "../program.js"; +import { isCjsJsFile, isNodeEsmFile, locOf } from "../program.js"; import { runtimePathForTarget } from "../runtime-resolve.js"; import { BOOL, + DYN, F64, type IrExpr, type IrStmt, @@ -224,6 +225,135 @@ function nodeModuleType(lowerer: Lowerer, node: ts.Expression): boolean { return rendered === "ScriptcModule" || rendered === "NodeModule" || rendered === "NodeJS.Module"; } +/** `module.exports` used only for its truthiness (the UMD test `typeof + * module !== "undefined" && module.exports`): the exports object starts as + * a fresh object, so it stays truthy unless the module replaces it with a + * value that can be falsy. True when every `module.exports = value` in the + * file assigns an object or function. */ +function moduleExportsAlwaysTruthy(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { + if (!truthinessOnly(expr)) return false; + const sf = expr.getSourceFile(); + if (isNodeEsmFile(sf, lowerer.program)) return false; + let truthy = true; + ts.walkPreorder(sf, (node) => { + if ( + !ts.isBinaryExpression(node) || + node.operatorToken.kind < ts.SyntaxKind.FirstAssignment || + node.operatorToken.kind > ts.SyntaxKind.LastAssignment + ) + return undefined; + let target = node.left; + while (ts.isParenthesizedExpression(target)) target = target.expression; + if ( + !ts.isPropertyAccessExpression(target) || + target.name.text !== "exports" || + !isCjsModuleGlobal(lowerer, target.expression) + ) + return undefined; + const type = lowerer.typeOf(node.right); + const objectLike = (t: ts.Type): boolean => + t.isUnionType() + ? ts.constituentTypes(t).every(objectLike) + : (t.flags & ts.TypeFlags.Object) !== 0 || (t.flags & ts.TypeFlags.NonPrimitive) !== 0; + if (node.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !objectLike(type)) { + truthy = false; + return "stop"; + } + return undefined; + }); + return truthy; +} + +/** Whether only the truthiness of `node` can be observed: a condition, a + * `!` operand, or a `&&`/`||` operand of an expression that is itself used + * only for its truthiness. */ +function truthinessOnly(node: ts.Expression): boolean { + let child: ts.Node = node; + for (let parent = node.parent; ; child = parent, parent = parent.parent) { + if (ts.isParenthesizedExpression(parent)) continue; + if (ts.isPrefixUnaryExpression(parent)) + return parent.operator === ts.SyntaxKind.ExclamationToken; + if ( + ts.isBinaryExpression(parent) && + (parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + parent.operatorToken.kind === ts.SyntaxKind.BarBarToken) + ) + continue; + if (ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent)) + return parent.expression === child; + if (ts.isForStatement(parent)) return parent.condition === child; + if (ts.isConditionalExpression(parent)) return parent.condition === child; + return false; + } +} + +/** `this` at the top level of a CommonJS module (directly, or inside + * arrow functions there): Node calls the module wrapper with `this` bound + * to the module's original exports object. */ +export function isCjsTopLevelThis(lowerer: Lowerer, node: ts.Node): boolean { + if (node.kind !== ts.SyntaxKind.ThisKeyword) return false; + for (let p: ts.Node | undefined = node.parent; p && !ts.isSourceFile(p); p = p.parent) { + if (ts.isArrowFunction(p)) continue; + if ( + ts.isFunctionLike(p) || + ts.isClassDeclaration(p) || + ts.isClassExpression(p) || + ts.isClassStaticBlockDeclaration(p) + ) + return false; + } + return isCjsJsFile(node.getSourceFile(), lowerer.program); +} + +export function usesCjsTopLevelThis(lowerer: Lowerer, sf: ts.SourceFile): boolean { + if (!isCjsJsFile(sf, lowerer.program)) return false; + let found = false; + ts.walkPreorder(sf, (node) => { + if ( + node !== sf && + !ts.isArrowFunction(node) && + (ts.isFunctionLike(node) || + ts.isClassDeclaration(node) || + ts.isClassExpression(node) || + ts.isClassStaticBlockDeclaration(node)) + ) + return "skip"; + if (node.kind === ts.SyntaxKind.ThisKeyword) { + found = true; + return "stop"; + } + return undefined; + }); + return found; +} + +/** The value of top-level `this` in a CommonJS module: the module's own + * object standing for its original exports, created when the module starts + * (lowerFileInit). Exports themselves are the static export table, so a + * property access through `this` would read or write an object no requirer + * sees; it keeps a named refusal instead. Typeof, truthiness, identity and + * passing the value along (the UMD wrapper `(function (root) { ... })(this)`) + * behave as in Node. */ +export function lowerCjsTopLevelThis(lowerer: Lowerer, expr: ts.Expression): IrExpr | null { + if (!isCjsTopLevelThis(lowerer, expr)) return null; + const global = lowerer.cjsExportsThis.get(expr.getSourceFile()); + if (!global) return null; + let receiver: ts.Node = expr; + while (ts.isParenthesizedExpression(receiver.parent)) receiver = receiver.parent; + const access = receiver.parent; + if ( + (ts.isPropertyAccessExpression(access) || ts.isElementAccessExpression(access)) && + access.expression === receiver + ) { + lowerer.unsupported( + "SC1090", + access, + "property access through top-level 'this' in a CommonJS module (it is module.exports; spell exports.)", + ); + } + return varRef(global.id, DYN, locOf(expr)); +} + function optionalModule( lowerer: Lowerer, node: ts.Expression, @@ -309,6 +439,8 @@ export function lowerNodeModuleProperty( if (!direct && !nodeModuleType(lowerer, expr.expression)) return null; if (expr.questionDotToken) return null; const member = expr.name.text; + if (member === "exports" && direct && moduleExportsAlwaysTruthy(lowerer, expr)) + return boolLit(true, locOf(expr)); if (member === "exports" || member === "require") return null; let moduleRef = direct ? cjsModuleRef(lowerer, expr.expression) diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 9eb512332..0f1b606ef 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4512,16 +4512,21 @@ export function lowerVarDecl( if (wrapped) return { kind: "assign", localId: g.id, value: wrapped, loc: locOf(decl) }; return null; } - const raw = lowerVariableInitializer( - lowerer, - decl.initializer, - g.type.kind === "dyn" && - (isNativeBuiltinValueInitializer(lowerer, decl.initializer) || - ts.isArrayLiteralExpression(decl.initializer) || - ts.isObjectLiteralExpression(decl.initializer)) - ? DYN - : undefined, - ); + const guardedRead = isGuardedRecordRead(lowerer, decl) + ? lowerAbsenceProbe(lowerer, decl.initializer) + : null; + const raw = + guardedRead ?? + lowerVariableInitializer( + lowerer, + decl.initializer, + g.type.kind === "dyn" && + (isNativeBuiltinValueInitializer(lowerer, decl.initializer) || + ts.isArrayLiteralExpression(decl.initializer) || + ts.isObjectLiteralExpression(decl.initializer)) + ? DYN + : undefined, + ); if ( isJsSourceFile(decl.getSourceFile()) && !decl.type && @@ -5317,29 +5322,111 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar } function immediatelyGuardedAbsenceProbe(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { - if (decl.type || !ts.isIdentifier(decl.name) || !decl.initializer) return false; + const guard = immediateAbsenceGuard(lowerer, decl); + // `if (!x)`: the falsy test sees a missing value. + if (guard === "falsy") return true; + // `if (x === undefined)` and its spellings compare the binding with the + // missing value itself, so the read must be able to produce it. Only + // index-signature record reads take this form: array reads already + // carry their undefined arm, and their ordinary lowering keeps its + // bounds proofs. + return guard === "compare" && isIndexSignatureRecordRead(lowerer, decl); +} + +/** A file-scope or block-scope `let e = map[k]` over an index-signature + * record whose next statement guards `e` for absence (`if (e === + * undefined)`, `if (!e)`, ...): the get-or-create idiom. Its read keeps the + * missing key as undefined even without noUncheckedIndexedAccess. */ +export function isGuardedRecordRead(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { + return immediateAbsenceGuard(lowerer, decl) !== null && isIndexSignatureRecordRead(lowerer, decl); +} + +function isIndexSignatureRecordRead(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { + if (!decl.initializer) return false; + let init = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if (!ts.isElementAccessExpression(init)) return false; + // Under noUncheckedIndexedAccess the checker already spells the missing + // arm, and the ordinary read carries it. + const read = lowerer.typeOf(init); + if ( + (read.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 || + (read.isUnionType() && + ts.constituentTypes(read).some((t) => (t.flags & ts.TypeFlags.Undefined) !== 0)) + ) + return false; + const receiver = lowerer.mapTypeOf(lowerer.typeOf(init.expression)); + const shape = receiver?.kind === "record" ? lowerer.shapes.get(receiver.shapeId) : undefined; + return !!shape && !shape.tuple && !!shape.indexValue && shape.indexValue.kind !== "dyn"; +} + +/** How the statement right after an unannotated declaration tests the + * declared binding for absence: `if (!x)` ("falsy"), or a comparison with + * the missing value — `x === undefined`, `x == null`, `x !== void 0`, + * `typeof x === "undefined"` and their mirrored and negated spellings + * ("compare"). Null for anything else. */ +function immediateAbsenceGuard( + lowerer: Lowerer, + decl: ts.VariableDeclaration, +): "falsy" | "compare" | null { + if (decl.type || !ts.isIdentifier(decl.name) || !decl.initializer) return null; const statement = decl.parent?.parent; - if (!statement || !ts.isVariableStatement(statement)) return false; + if (!statement || !ts.isVariableStatement(statement)) return null; const container = statement.parent; const statements = ts.isBlock(container) || ts.isSourceFile(container) ? container.statements : null; - if (!statements) return false; + if (!statements) return null; const index = statements.indexOf(statement); const next = index >= 0 ? statements[index + 1] : undefined; - if (!next || !ts.isIfStatement(next)) return false; - let condition = next.expression; - while (ts.isParenthesizedExpression(condition)) condition = condition.expression; - if ( - !ts.isPrefixUnaryExpression(condition) || - condition.operator !== ts.SyntaxKind.ExclamationToken - ) - return false; - let operand = condition.operand; - while (ts.isParenthesizedExpression(operand)) operand = operand.expression; - return ( - ts.isIdentifier(operand) && - lowerer.resolveValueSymbol(operand) === lowerer.resolveValueSymbol(decl.name) - ); + if (!next || !ts.isIfStatement(next)) return null; + const binding = lowerer.resolveValueSymbol(decl.name); + const peel = (node: ts.Expression): ts.Expression => { + while (ts.isParenthesizedExpression(node)) node = node.expression; + return node; + }; + const isBinding = (node: ts.Expression): boolean => { + const operand = peel(node); + return ts.isIdentifier(operand) && lowerer.resolveValueSymbol(operand) === binding; + }; + const condition = peel(next.expression); + if (ts.isPrefixUnaryExpression(condition)) + return condition.operator === ts.SyntaxKind.ExclamationToken && isBinding(condition.operand) + ? "falsy" + : null; + if (!ts.isBinaryExpression(condition)) return null; + const op = condition.operatorToken.kind; + const strict = + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken; + const loose = + op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsToken; + if (!strict && !loose) return null; + const isUndefined = (node: ts.Expression): boolean => { + const operand = peel(node); + if (ts.isVoidExpression(operand)) return true; + return ( + ts.isIdentifier(operand) && + operand.text === "undefined" && + (lowerer.typeOf(operand).flags & ts.TypeFlags.Undefined) !== 0 + ); + }; + const isNull = (node: ts.Expression): boolean => peel(node).kind === ts.SyntaxKind.NullKeyword; + const missing = (node: ts.Expression): boolean => isUndefined(node) || (loose && isNull(node)); + const isTypeofBinding = (node: ts.Expression): boolean => { + const operand = peel(node); + return ts.isTypeOfExpression(operand) && isBinding(operand.expression); + }; + const isUndefinedTag = (node: ts.Expression): boolean => { + const operand = peel(node); + return ts.isStringLiteralLike(operand) && operand.text === "undefined"; + }; + const { left, right } = condition; + return (isBinding(left) && missing(right)) || + (missing(left) && isBinding(right)) || + (isTypeofBinding(left) && isUndefinedTag(right)) || + (isUndefinedTag(left) && isTypeofBinding(right)) + ? "compare" + : null; } function falsyExitRuntimeOptionalLocals(lowerer: Lowerer, stmt: ts.IfStatement): IrLocal[] { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index dc218b3aa..bf4ed5464 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -271,7 +271,7 @@ import { appendForkModules, appendWorkerModules, } from "./lower-modules.js"; -import { prepareCjsModuleGraph } from "./lower-node-module.js"; +import { prepareCjsModuleGraph, usesCjsTopLevelThis } from "./lower-node-module.js"; import { registerBuiltinEventTargetClasses } from "./lower-event-target.js"; import { type ClassInfo, @@ -533,6 +533,7 @@ import { lowerForOf, lowerForStatement, matchGroupsPatternSource, + isGuardedRecordRead, } from "./lower-stmts.js"; import { type FieldTarget, @@ -1859,6 +1860,9 @@ export class Lowerer { * carry undefined at runtime. Identifier reads keep the union tag so * typeof and later boundaries observe the real value. */ readonly runtimeOptionalGlobals = new Set(); + /** Per CommonJS file using top-level `this`: the global holding the + * object that stands for its original exports (lowerCjsTopLevelThis). */ + readonly cjsExportsThis = new Map(); /** Bindings whose unchecked string arithmetic can produce either a number * (NaN for an absent read) or a string. Preserve that result union at bare * reads instead of applying the checker-bare string narrowing adapter. */ @@ -2868,7 +2872,8 @@ export class Lowerer { return null; } const className = this.classNamer(decl); - if (!this.collectingExprClasses.has(decl)) this.lowerClassExpressionInfo(decl); + if (!this.collectingExprClasses.has(decl) && !this.collectLocalClassForType(decl)) + return null; return { kind: "object", className }; }, mixinIntersectionInstance: (widened) => mixinIntersectionInstanceType(this, widened), @@ -3701,6 +3706,16 @@ export class Lowerer { const id = `%g.${tag}%loaded`; this.moduleGuardOf.set(fp.sf, id); this.globalsList.push({ id, name: "%loaded", type: BOOL, mutable: true }); + if (usesCjsTopLevelThis(this, fp.sf)) { + const exportsThis: IrGlobal = { + id: `%g.${tag}%exportsThis`, + name: "%exportsThis", + type: DYN, + mutable: true, + }; + this.globalsList.push(exportsThis); + this.cjsExportsThis.set(fp.sf, exportsThis); + } } // A module is intrinsically async when an await/for-await occurs @@ -3955,6 +3970,33 @@ export class Lowerer { } } + /** A file-scope get-or-create read (`let e = map[k]; if (e === + * undefined) ...` over an index-signature record) keeps a missing key as + * undefined (isGuardedRecordRead). Its pre-registered global takes the + * undefined arm before any body reads it; the declaration lowers its + * initializer as the matching absence probe. */ + analyzeGuardedRecordReadGlobals(parts: FileParts[]): void { + for (const { sf } of parts) { + for (const statement of sf.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const decl of statement.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || !isGuardedRecordRead(this, decl)) continue; + const global = this.globalOf(decl.name); + const symbol = this.resolveValueSymbol(decl.name); + if (!global || !symbol || global.tdz || this.runtimeOptionalBindingTypes.has(symbol)) + continue; + const widened = + global.type.kind === "union" + ? this.withUndefinedArmOf(global.type) + : this.withUndefinedArm(global.type); + if (!widened || typeEquals(widened, global.type)) continue; + global.type = widened; + this.runtimeOptionalBindingTypes.set(symbol, widened); + } + } + } + } + analyzeRuntimeOptionalArrayReads(parts: FileParts[]): void { const provenance = RuntimeOptionalProvenance.fromEnvironment(); // The site whose value the current promotion follows (debug only). @@ -6197,8 +6239,11 @@ export class Lowerer { for (const fp of parts) this.checker.prefetchSourceFile(fp.sf); } this.collectProgram(parts); - this.analyzeRuntimeOptionalArrayReads(parts); - this.analyzeDateJsonResults(parts); + this.speculativeTypeQueries(() => { + this.analyzeRuntimeOptionalArrayReads(parts); + this.analyzeDateJsonResults(parts); + this.analyzeGuardedRecordReadGlobals(parts); + }); // Decorated classes analyze AFTER the whole collection pass: a // decorator's return type may name a subclass declared below the // class, and reference lowering needs each class's rebindability @@ -6583,8 +6628,11 @@ export class Lowerer { collectionEdges.add(name); }; this.collectProgram(parts); - this.analyzeRuntimeOptionalArrayReads(parts); - this.analyzeDateJsonResults(parts); + this.speculativeTypeQueries(() => { + this.analyzeRuntimeOptionalArrayReads(parts); + this.analyzeDateJsonResults(parts); + this.analyzeGuardedRecordReadGlobals(parts); + }); // Decorated classes analyze post-collection here too: the %init seeds // lower the decoration calls, whose edges (decorator bodies, construct // thunks) reachable emit must see. @@ -9736,6 +9784,49 @@ export class Lowerer { return lowerClassExpressionInfo(this, expr); } + /** Depth of the whole-program analyses that query types across every + * file before reachability is known (speculativeTypeQueries). */ + private speculativeTypeQueryDepth = 0; + + /** Runs a whole-program analysis whose type queries are speculative: they + * visit every file, reached or not, and no statement or declaration owns + * a refusal they trigger (collectLocalClassForType). */ + speculativeTypeQueries(analysis: () => void): void { + this.speculativeTypeQueryDepth++; + try { + analysis(); + } finally { + this.speculativeTypeQueryDepth--; + } + } + + /** Collects a function-local class because a type query named its + * instance type. Collection and body lowering own a refusal of that + * class: the poison reaches the declaration or statement that asked, as + * before. A speculative whole-program analysis owns none, so there the + * class is unmappable for the query and its diagnostics are dropped; + * lowering the class's own declaration collects it again and reports + * them if the program reaches it. */ + collectLocalClassForType(decl: ts.ClassLikeDeclaration): boolean { + if (this.speculativeTypeQueryDepth === 0 || this.fnStack.length > 0) { + this.lowerClassExpressionInfo(decl); + return true; + } + const sink: ScrDiagnostic[] = []; + const previousSink = this.diagSink; + this.diagSink = sink; + try { + this.lowerClassExpressionInfo(decl); + } catch (e) { + if (e instanceof PoisonError) return false; + throw e; + } finally { + this.diagSink = previousSink; + } + for (const d of sink) this.pushDiag(d); + return true; + } + lowerClassExpression(expr: ts.ClassLikeDeclaration): IrExpr { return lowerClassExpression(this, expr); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6100-record-get-or-create.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6100-record-get-or-create.ts.json new file mode 100644 index 000000000..b7c18a8a4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6100-record-get-or-create.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6100-record-get-or-create.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6101-record-get-or-create-unchecked/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6101-record-get-or-create-unchecked/main.ts.json new file mode 100644 index 000000000..dcb5b192f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6101-record-get-or-create-unchecked/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6101-record-get-or-create-unchecked/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6102-local-class-type-query.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6102-local-class-type-query.js.json new file mode 100644 index 000000000..86c6ba9e8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6102-local-class-type-query.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6102-local-class-type-query.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6103-typeof-builtin-bindings.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6103-typeof-builtin-bindings.cjs.json new file mode 100644 index 000000000..008914d85 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6103-typeof-builtin-bindings.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6103-typeof-builtin-bindings.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6104-typeof-builtin-bindings.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6104-typeof-builtin-bindings.mjs.json new file mode 100644 index 000000000..b6a8a48e2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6104-typeof-builtin-bindings.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6104-typeof-builtin-bindings.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6105-cjs-top-level-this/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6105-cjs-top-level-this/main.js.json new file mode 100644 index 000000000..ef768b4ce --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6105-cjs-top-level-this/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6105-cjs-top-level-this/lib.js", + "/tests/corpus/6105-cjs-top-level-this/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6106-esm-top-level-this.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6106-esm-top-level-this.mjs.json new file mode 100644 index 000000000..ad427bd7f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6106-esm-top-level-this.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6106-esm-top-level-this.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query-brand.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query-brand.ts.json new file mode 100644 index 000000000..e17ea3bf5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query-brand.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/local-class-type-query-brand.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query.ts.json new file mode 100644 index 000000000..3c89fba9f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/local-class-type-query.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6100-record-get-or-create.ts b/tests/corpus/6100-record-get-or-create.ts new file mode 100644 index 000000000..807cb303e --- /dev/null +++ b/tests/corpus/6100-record-get-or-create.ts @@ -0,0 +1,119 @@ +// Get-or-create over nested index-signature records without +// noUncheckedIndexedAccess: a missing key reads as undefined when the next +// statement tests the binding for absence, in every spelling of that test, +// at module scope and inside functions. A write with a computed key through +// a binding that may hold a missing element throws Node's TypeError after +// the key and the value evaluated. +type ExportMap = Record>; + +interface Target { + subpath: string; + condition: string; + target: string; +} + +function build(targets: Target[]): { exports: ExportMap; unmapped: string[] } { + const exports: ExportMap = {}; + const unmapped: string[] = []; + for (const t of targets) { + if (t.target === "") { + unmapped.push(t.subpath); + continue; + } + let entry = exports[t.subpath]; + if (entry === undefined) { + entry = {}; + exports[t.subpath] = entry; + } + entry[t.condition] = `./${t.target}`; + entry["types"] = `./${t.target}`; + } + return { exports: exports, unmapped: unmapped }; +} + +const result = build([ + { subpath: ".", condition: "import", target: "src/index.ts" }, + { subpath: ".", condition: "require", target: "src/index.ts" }, + { subpath: "./x", condition: "default", target: "" }, + { subpath: "./y", condition: "node", target: "src/y.ts" }, +]); +console.log(JSON.stringify(result.exports), result.unmapped.join(",")); + +function group(words: string[]): Record { + const byInitial: Record = {}; + for (const w of words) { + const k = w.slice(0, 1); + let list = byInitial[k]; + if (list == null) { + list = []; + byInitial[k] = list; + } + list.push(w); + } + return byInitial; +} +console.log(JSON.stringify(group(["apple", "avocado", "banana", "blueberry", "cherry"]))); + +function count(words: string[]): Record { + const counts: Record = {}; + for (const w of words) { + const n = counts[w]; + if (typeof n === "undefined") counts[w] = 1; + else counts[w] = n + 1; + } + return counts; +} +console.log(JSON.stringify(count(["a", "b", "a", "c", "a"]))); + +function lookup(table: Record, key: string): string { + const hit = table[key]; + if (hit !== undefined) return hit; + return `<${key} missing>`; +} +console.log(lookup({ a: "A" }, "a"), lookup({ a: "A" }, "b")); + +function rows(table: Record>, key: string, col: string): number { + const row = table[key]; + if (row != undefined) return row[col] ?? -1; + return -2; +} +console.log(rows({ r: { c: 3 } }, "r", "c"), rows({ r: { c: 3 } }, "r", "d"), rows({}, "x", "c")); + +// Module scope. +const nested: Record> = {}; +const key = process.argv.length > 99 ? "unused" : "k"; +let slot = nested[key]; +if (slot === undefined) { + slot = {}; + nested[key] = slot; +} +slot[key] = 1; +slot["fixed"] = 2; +console.log(JSON.stringify(nested)); + +const names: Record = { present: "yes" }; +const missing = names[key]; +if (!missing) console.log("falsy guard:", missing); +const present = names["present"]; +if (present === void 0) console.log("unreachable"); +else console.log("present:", present); + +// A write with a computed key through a binding that may hold a missing +// array element throws Node's TypeError naming the key, after evaluating +// the key and the value. +function probe(tables: Record[]): void { + const firstTable = tables[0]; + const lastTable = tables[5]; + const pick = (k: string): string => { + console.log("key", k); + return k; + }; + firstTable[pick("b")] = pick("second"); + console.log(JSON.stringify(firstTable)); + try { + lastTable[pick("c")] = pick("value"); + } catch (e) { + console.log((e as Error).name, (e as Error).message); + } +} +probe([{ a: "first" }]); diff --git a/tests/corpus/6101-record-get-or-create-unchecked/main.ts b/tests/corpus/6101-record-get-or-create-unchecked/main.ts new file mode 100644 index 000000000..a2b4ef705 --- /dev/null +++ b/tests/corpus/6101-record-get-or-create-unchecked/main.ts @@ -0,0 +1,70 @@ +// Get-or-create over nested index-signature records under +// noUncheckedIndexedAccess: after the presence guard the binding is still +// stored with its undefined arm, and reads, writes and absence probes with +// computed keys go through it (an internal compiler error before). +type ExportMap = Record>; + +function build(rows: { k: string; c: string; v: string }[]): ExportMap { + const map: ExportMap = {}; + for (const r of rows) { + let entry = map[r.k]; + if (entry === undefined) { + entry = {}; + map[r.k] = entry; + } + entry[r.c] = r.v; + } + return map; +} +console.log( + JSON.stringify( + build([ + { k: "a", c: "x", v: "1" }, + { k: "a", c: "y", v: "2" }, + { k: "b", c: "x", v: "3" }, + ]), + ), +); + +function touch(map: ExportMap, k: string): string { + let e = map[k]; + if (e === undefined) { + e = { [k]: "init" }; + map[k] = e; + } + const before = e[k] ?? "none"; + e[k] = e[k] + "!"; + return before + (k in e ? "+" : "-"); +} +const m: ExportMap = {}; +console.log(touch(m, "a"), touch(m, "a"), touch(m, "b"), JSON.stringify(m)); + +function tally(counts: Record>, group: string, item: string): void { + let row = counts[group]; + if (!row) { + row = {}; + counts[group] = row; + } + row[item] = (row[item] ?? 0) + 1; +} +const counts: Record> = {}; +for (const [g, i] of [ + ["fruit", "apple"], + ["fruit", "pear"], + ["veg", "kale"], + ["fruit", "apple"], +]) { + tally(counts, g!, i!); +} +console.log(JSON.stringify(counts)); + +// Module scope. +const top: ExportMap = {}; +const key = process.argv.length > 99 ? "unused" : "k"; +let slot = top[key]; +if (slot === undefined) { + slot = {}; + top[key] = slot; +} +slot[key] = "v"; +console.log(JSON.stringify(top), slot[key], slot["other"] ?? "missing"); diff --git a/tests/corpus/6101-record-get-or-create-unchecked/tsconfig.json b/tests/corpus/6101-record-get-or-create-unchecked/tsconfig.json new file mode 100644 index 000000000..f2e1c10cd --- /dev/null +++ b/tests/corpus/6101-record-get-or-create-unchecked/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true + } +} diff --git a/tests/corpus/6102-local-class-type-query.js b/tests/corpus/6102-local-class-type-query.js new file mode 100644 index 000000000..92fe35725 --- /dev/null +++ b/tests/corpus/6102-local-class-type-query.js @@ -0,0 +1,42 @@ +// A whole-program analysis that queries types before reachability is known +// (the computed-key write census here) can name a function-local class whose +// collection is refused. That query has no owner for the refusal: the class +// stays unmapped for it, and the refusal belongs to the class's own +// declaration, which this program never executes (it used to crash the +// compiler with an uncaught PoisonError). +function main(run) { + if (!run) return "skipped"; + const Project = class { + constructor() { + this.name = "p"; + } + close() { + if (this.missingFilesMap) { + this.missingFilesMap.clear(); + this.missingFilesMap = void 0; + } + } + add(k) { + (this.missingFilesMap || (this.missingFilesMap = new Map())).set(k, 1); + } + }; + const holder = { p: new Project() }; + const key = String(run); + holder.p[key] = "q"; + return holder.p.name; +} + +class Counter { + constructor() { + this.counts = {}; + } + bump(key) { + this.counts[key] = (this.counts[key] || 0) + 1; + return this.counts[key]; + } +} + +const counter = new Counter(); +const word = process.argv.length > 99 ? "unused" : "word"; +counter.bump(word); +console.log(main(0), counter.bump(word)); diff --git a/tests/corpus/6103-typeof-builtin-bindings.cjs b/tests/corpus/6103-typeof-builtin-bindings.cjs new file mode 100644 index 000000000..ecef94dd3 --- /dev/null +++ b/tests/corpus/6103-typeof-builtin-bindings.cjs @@ -0,0 +1,15 @@ +// typeof on bindings destructured from Node builtin modules: Node answers by +// the export's kind ("function" for functions and classes, "object" for +// namespace objects), never by how the binding is represented. +const { inspect, format, promisify, deprecate, isDeepStrictEqual } = require("util"); +const { join, resolve } = require("path"); +const { readFileSync, promises } = require("fs"); +const { EventEmitter } = require("events"); +const { createHash } = require("crypto"); +const util = require("node:util"); +const { inspect: aliased } = util; +console.log(typeof inspect, typeof format, typeof promisify, typeof deprecate, typeof isDeepStrictEqual); +console.log(typeof join, typeof resolve, typeof readFileSync, typeof promises); +console.log(typeof EventEmitter, typeof createHash, typeof aliased, typeof util.inspect); +console.log(typeof inspect === "function" ? "callable" : "not callable"); +console.log(join("a", "b"), format("%s=%d", "x", 1), inspect({ a: [1, 2] })); diff --git a/tests/corpus/6104-typeof-builtin-bindings.mjs b/tests/corpus/6104-typeof-builtin-bindings.mjs new file mode 100644 index 000000000..dd2976775 --- /dev/null +++ b/tests/corpus/6104-typeof-builtin-bindings.mjs @@ -0,0 +1,11 @@ +// typeof on named, default-destructured and namespace-destructured builtin +// bindings in an ES module: Node answers by the export's kind. +import util, { inspect as namedInspect, format } from "node:util"; +import * as path from "node:path"; +import { createHash } from "node:crypto"; +import { promises } from "node:fs"; +const { inspect, promisify } = util; +const { join } = path; +console.log(typeof inspect, typeof promisify, typeof namedInspect, typeof format); +console.log(typeof join, typeof createHash, typeof util.inspect, typeof promises); +console.log(namedInspect("s"), format("%d", 2), join("x", "y")); diff --git a/tests/corpus/6105-cjs-top-level-this/lib.js b/tests/corpus/6105-cjs-top-level-this/lib.js new file mode 100644 index 000000000..7addc1840 --- /dev/null +++ b/tests/corpus/6105-cjs-top-level-this/lib.js @@ -0,0 +1,11 @@ +// A required CommonJS module sees its own exports object as `this`, and the +// UMD test for a CommonJS host takes the module.exports branch. +const kind = typeof this; +let host = "global"; +if (typeof module !== "undefined" && module.exports) host = "commonjs"; +(function (root) { + console.log("lib wrapper root:", typeof root, root ? "truthy" : "falsy"); +})(this); +exports.describe = function () { + return kind + " " + host; +}; diff --git a/tests/corpus/6105-cjs-top-level-this/main.js b/tests/corpus/6105-cjs-top-level-this/main.js new file mode 100644 index 000000000..1da910171 --- /dev/null +++ b/tests/corpus/6105-cjs-top-level-this/main.js @@ -0,0 +1,12 @@ +// Top-level `this` in a CommonJS module is module.exports (an object), also +// inside arrow functions at module scope. +const lib = require("./lib.js"); +const self = this; +const arrow = () => this; +console.log(typeof this, self === this, arrow() === this, this ? "truthy" : "falsy"); +console.log(lib.describe()); +(function () { + console.log("call(this):", typeof this); +}).call(this); +if ((typeof module !== "undefined" && module !== null) && module.exports) console.log("commonjs host"); +else console.log("browser host"); diff --git a/tests/corpus/6105-cjs-top-level-this/package.json b/tests/corpus/6105-cjs-top-level-this/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/corpus/6105-cjs-top-level-this/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/corpus/6106-esm-top-level-this.mjs b/tests/corpus/6106-esm-top-level-this.mjs new file mode 100644 index 000000000..6079ecc1e --- /dev/null +++ b/tests/corpus/6106-esm-top-level-this.mjs @@ -0,0 +1,4 @@ +// Top-level `this` in an ES module stays undefined, also inside arrow +// functions at module scope. +const arrow = () => this; +console.log(typeof this, this === undefined, typeof arrow()); diff --git a/tests/diagnostics/local-class-type-query-brand.ts b/tests/diagnostics/local-class-type-query-brand.ts new file mode 100644 index 000000000..566304627 --- /dev/null +++ b/tests/diagnostics/local-class-type-query-brand.ts @@ -0,0 +1,14 @@ +// The same speculative element-type query, reaching a function-local class +// with a private brand through the function's returned array. +function make() { + class Box { + #v = 1; + read(): number { + return this.#v; + } + } + return [new Box()]; +} +const xs = make(); +const x = xs[0]; +console.log(x === undefined ? -1 : x.read()); diff --git a/tests/diagnostics/local-class-type-query.ts b/tests/diagnostics/local-class-type-query.ts new file mode 100644 index 000000000..217653ff2 --- /dev/null +++ b/tests/diagnostics/local-class-type-query.ts @@ -0,0 +1,14 @@ +// A whole-program analysis queries the element type of `items` before any +// body lowers. The function-local class it names is refused: the refusal is +// reported once, at the class (it used to crash the compiler with an uncaught +// PoisonError). +function make() { + class Box { + static count = 0; + v = 1; + } + return new Box(); +} +const items = [make()]; +const first = items[0]; +console.log(first ? first.v : 0); diff --git a/tests/harness/__snapshots__/local-class-type-query-brand.ts.txt b/tests/harness/__snapshots__/local-class-type-query-brand.ts.txt new file mode 100644 index 000000000..6db3418a8 --- /dev/null +++ b/tests/harness/__snapshots__/local-class-type-query-brand.ts.txt @@ -0,0 +1,6 @@ +local-class-type-query-brand.ts:4:3 - error SC1090: function-local classes with private brands are not supported yet + + 3 | function make() { + 4 | class Box { + | ^~~~~~~~~~~ + 5 | #v = 1; \ No newline at end of file diff --git a/tests/harness/__snapshots__/local-class-type-query.ts.txt b/tests/harness/__snapshots__/local-class-type-query.ts.txt new file mode 100644 index 000000000..87489fcd5 --- /dev/null +++ b/tests/harness/__snapshots__/local-class-type-query.ts.txt @@ -0,0 +1,6 @@ +local-class-type-query.ts:6:3 - error SC1090: function-local classes with statics, decorators, or generic members are not supported yet + + 5 | function make() { + 6 | class Box { + | ^~~~~~~~~~~ + 7 | static count = 0; \ No newline at end of file diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 7d8030e95..3fcd101e0 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2200,6 +2200,13 @@ "6042-regex-match-object-destructuring.ts": 3393, "6043-string-index-narrowed-union.ts": 2983, "605-closures-forward-capture-tdz.ts": 3277, + "6100-record-get-or-create.ts": 3771, + "6101-record-get-or-create-unchecked/main.ts": 2107, + "6102-local-class-type-query.js": 2600, + "6103-typeof-builtin-bindings.cjs": 2202, + "6104-typeof-builtin-bindings.mjs": 2031, + "6105-cjs-top-level-this/main.js": 1371, + "6106-esm-top-level-this.mjs": 1915, "6140-cjs-require-tdz-throws/main.js": 2731, "6141-cjs-require-tdz-checked/main.js": 2856, "6142-cjs-require-published-early/main.js": 3400, @@ -5222,6 +5229,13 @@ "6042-regex-match-object-destructuring.ts": 3091, "6043-string-index-narrowed-union.ts": 3627, "605-closures-forward-capture-tdz.ts": 4638, + "6100-record-get-or-create.ts": 3173, + "6101-record-get-or-create-unchecked/main.ts": 3228, + "6102-local-class-type-query.js": 1967, + "6103-typeof-builtin-bindings.cjs": 1796, + "6104-typeof-builtin-bindings.mjs": 1715, + "6105-cjs-top-level-this/main.js": 3213, + "6106-esm-top-level-this.mjs": 1672, "6140-cjs-require-tdz-throws/main.js": 2989, "6141-cjs-require-tdz-checked/main.js": 4062, "6142-cjs-require-published-early/main.js": 5156, diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index a6cdee2b5..c4e70294e 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -73,7 +73,7 @@ const KNOWN: Record = { "node24-env": { run: "SC1090" }, "node24-esm-metadata": { run: "SC1090" }, "node24-event-emitter": { run: "SC2020" }, - "node24-file-url-conversion": { build: "SC1090" }, + "node24-file-url-conversion": { run: "SC2020" }, "node24-fs-descriptors": { run: "SC1090" }, "node24-mime": { run: "SC2020" }, "node24-mime-errors": { run: "SC1090" }, @@ -104,7 +104,7 @@ const KNOWN: Record = { "package-imports": { build: "SC2013" }, "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, - "stream-shims": { build: "SC1090" }, + "stream-shims": { run: "SC1090" }, "util-shims": { run: "SC2020" }, "web-streams": { build: "SC2013" }, "workspace-copied": { build: "SC2013" },