diff --git a/docs/content/limitations/builtin-apis/assertions.mdx b/docs/content/limitations/builtin-apis/assertions.mdx index f7f5c9354..381fc3802 100644 --- a/docs/content/limitations/builtin-apis/assertions.mdx +++ b/docs/content/limitations/builtin-apis/assertions.mdx @@ -3,6 +3,8 @@ When `strictEqual` compares function values, `actual` and `expected` read as `undefined`; when `assert.throws` or `assert.rejects` reports an error that is not an instance of the expected class, or that does not match an expected object, `expected` reads as `undefined`. Values whose type can be cyclic, such as recursive records, also read as `undefined`. `assert.AssertionError` used as a value outside `instanceof` throws `SC2020`. + The comparison methods, `ok`, `fail` and `ifError` are function values that assert as the methods do; called through a stored value, `ok` without a message reports `value == true`, where Node quotes the call, and `fail` takes at most one argument. + `throws`, `rejects`, `doesNotThrow`, `doesNotReject`, `match` and `doesNotMatch` as values throw `SC2020`. `assert.deepEqual` and `assert.notDeepEqual` follow Node's loose comparison, including Maps, Sets, Dates, regular expressions, typed arrays, and errors; operands whose type can be cyclic or holds functions inside a structure are refused. `assert.partialDeepStrictEqual` follows Node's partial comparison over the same values, with the same refusals. Checked-dynamic values compared deeply have no cycle detection. diff --git a/docs/content/limitations/builtin-apis/prototypes.mdx b/docs/content/limitations/builtin-apis/prototypes.mdx index 7a7e01635..858de3c7e 100644 --- a/docs/content/limitations/builtin-apis/prototypes.mdx +++ b/docs/content/limitations/builtin-apis/prototypes.mdx @@ -11,3 +11,4 @@ The constructors `Object`, `Function`, `Date`, `RegExp`, `Map`, `Set`, `WeakMap`, `WeakSet`, and `Promise` read as function values; through a stored value only `Object` can be called. `new` through a stored `Object`, `Date`, `Map`, `Set`, `WeakMap`, or `WeakSet` builds the same value as the `new` expression. `new` through a stored `RegExp` or `Promise`, `new` of a stored `Object` with a primitive argument, and `instanceof` against these constructors are unsupported. + `instanceof Object` and `instanceof Function` on untyped values follow the prototype chain as in Node, so an object built with `Object.create(null)` is not an `Object`. diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 1b93f345e..de3a56c3a 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -37,6 +37,7 @@ export const ASSERT_VERDICT_LIB_FNS: ReadonlyMap = new Ma export const LIB_FN_SYMS: Record = { "assert.expectsErrDyn": "scr_assert_expects_err_dyn", + "assert.throwsValidate": "scr_assert_throws_validate", "insp.jsonDyn": "scr_util_format_j", "http.reqH2StreamOrThrow": "scr_http_req_h2_stream_or_throw", "intl.segmenterNew": "scr_intl_segmenter_new", @@ -402,6 +403,9 @@ export const LIB_FN_SYMS: Record = { "assert.eqBool": "scr_assert_eq_bool", "assert.eqSym": "scr_assert_eq_sym", "assert.eqDyn": "scr_assert_eq_dyn", + "assert.messageValue": "scr_assert_message_value", + "assert.methodValue": "scr_assert_method_value", + "assert.messageClear": "scr_assert_message_clear", "assert.looseResult": "scr_assert_loose_result", "assert.deepResult": "scr_assert_deep_result", "assert.looseDeepResult": "scr_assert_loose_deep_result", diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index e2ab95ee4..389abb521 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -2228,23 +2228,10 @@ function lowerRuntimeKeyIn( return { kind: "call", callee: helper, args: [keyIr, recv], type: BOOL, loc }; } -/** Standard-library constructors the untyped instanceof helper has no - * instance test for: an untyped `x instanceof Object` always refuses. */ -const DYN_INSTANCEOF_REFUSED: ReadonlySet = new Set(["Object", "Function"]); - /** The SC1090 message `x instanceof C` throws, with the site's position, - * when `C` is a runtime constructor with no instance test. A right-hand - * side that names one of DYN_INSTANCEOF_REFUSED always reaches that - * refusal, so the site is listed with the runtime fences. */ + * when `C` is a runtime constructor with no instance test. */ function dynInstanceOfRefusal(lowerer: Lowerer, expr: ts.BinaryExpression): string { const diag = unsupportedDiag("SC1090", locOf(expr), "instanceof against this native constructor"); - const symbol = lowerer.checker.getSymbolAtLocation(expr.right); - if ( - symbol !== undefined && - lowerer.isStdlibSymbol(symbol) && - DYN_INSTANCEOF_REFUSED.has(ts.symbolName(symbol)) - ) - lowerer.runtimeFences.push(diag); const sf = ts.getSourceFileOfNode(expr); const line = ts.getLineAndCharacterOfPosition(sf, diag.loc.start).line + 1; return `${diag.message} [${diag.code} at ${diag.loc.file}:${line}]`; diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index b996abed7..02faa7ade 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -51,11 +51,15 @@ import { STRING, type SrcLoc, VOID, + isDynTypedRefType, isUnitType, typeEquals, typeKey, } from "../../ir/ir.js"; import { boolLit, countedFor, dynUndefinedExpr, numLit, strLit, varRef } from "../../ir/build.js"; +import { everyExpr } from "../../ir/traverse.js"; +import { checkedClassInstanceOf } from "./class-construction.js"; +import { isJsSourceFile } from "../program.js"; /** node:assert/strict binds the loose NAMES to the strict comparisons — * Node's own aliasing (`strict.equal === assert.strictEqual`). */ @@ -73,6 +77,63 @@ function canonicalAssertMember(module: string, member: string): string { return member; } +/** The assert methods that are function values (scr_assert_method_value). */ +const ASSERT_FUNCTION_VALUES: ReadonlySet = new Set([ + "ok", + "fail", + "equal", + "notEqual", + "strictEqual", + "notStrictEqual", + "deepEqual", + "notDeepEqual", + "deepStrictEqual", + "notDeepStrictEqual", + "partialDeepStrictEqual", + "ifError", +]); + +/** The assert method a `const` binding holds (`const ok = assert.ok`, + * `const eq = strictEqual`), or null. */ +export function assertFunctionAliasOf( + lowerer: Lowerer, + ident: ts.Identifier, +): { module: string; member: string } | null { + const decl = lowerer.checker.getSymbolAtLocation(ident)?.valueDeclaration; + if ( + !decl || + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + !ts.isVariableDeclarationList(decl.parent) || + (decl.parent.flags & ts.NodeFlags.Const) === 0 || + !decl.initializer + ) + return null; + let init = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + const bi = ts.isPropertyAccessExpression(init) + ? lowerer.builtinMemberOf(init) + : ts.isIdentifier(init) + ? lowerer.builtinImportOf(init) + : null; + if (!bi || (bi.module !== "assert" && bi.module !== "assert/strict")) return null; + return ASSERT_FUNCTION_VALUES.has(canonicalAssertMember(bi.module, bi.member)) ? bi : null; +} + +/** An assert method read as a value (`emitter.on("x", assert.fail)`, + * `const eq = assert.strictEqual`): an untyped function that asserts as + * the method does, the same value for every read. Null for other + * members. */ +export function assertFunctionValue( + bi: { module: string; member: string }, + loc: SrcLoc, +): IrExpr | null { + if (bi.module !== "assert" && bi.module !== "assert/strict") return null; + const member = canonicalAssertMember(bi.module, bi.member); + if (!ASSERT_FUNCTION_VALUES.has(member)) return null; + return { kind: "libCall", fn: "assert.methodValue", args: [strLit(member, loc)], type: DYN, loc }; +} + /* Node's AssertionError carries actual, expected and operator. Where the * verdict is computed in compiled code, the failure branch records them * (assert.record) before the throwing call, so a passing assertion boxes @@ -166,28 +227,156 @@ function requireStatementPosition(lowerer: Lowerer, call: ts.CallExpression, wha ); } +/** The message the runtime reads from a registered value + * (SCR_ASSERT_VALUE_MESSAGE in scr_assert.h). */ +const VALUE_MESSAGE = "\u0001scriptc:assert-message-value"; + +/** A message argument that is not a string, met while one assertion + * lowers (withValueMessage). `fail` marks assert.fail's message, which + * Node uses for every value but null and undefined. */ +let valueMessage: { value: IrExpr; has: string; text: string; fail: boolean } | null = null; + /** The optional trailing message argument: Node accepts a string or an * Error (throwing the Error itself) — the STRING form lowers; a literal * `undefined` counts as omitted (Node's `message == null` test). Returns * the libCall's (msg, hasMsg) pair — a "" dummy when omitted, so the - * runtime can distinguish an omitted message from an empty one. */ + * runtime can distinguish an omitted message from an empty one. Any other + * value that crosses into the checked-dynamic tree (an untyped + * `failures.join('')`, an Error) is registered with the runtime at the call + * instead (withValueMessage). */ function lowerMessageArg( lowerer: Lowerer, node: ts.Expression | undefined, loc: SrcLoc, + fail = false, ): { msg: IrExpr; hasMsg: IrExpr } { if (!node || (ts.isIdentifier(node) && node.text === "undefined")) { return { msg: { kind: "strLit", value: "", type: STRING, loc }, hasMsg: boolLit(false, loc) }; } const msg = lowerer.lowerExpr(node); - if (msg.type.kind !== "string") { + if (msg.type.kind === "string") return { msg, hasMsg: boolLit(true, loc) }; + const value = + valueMessage === null && (msg.type.kind === "dyn" || lowerer.dynConvertible(msg.type)) + ? lowerer.coerceToExpected(msg, DYN) + : null; + if (value === null || value.type.kind !== "dyn") { lowerer.noLowering( `assert messages of type '${lowerer.fmt(msg.type)}'`, node, "only string messages lower (Node also accepts an Error to throw — construct and throw it directly)", ); } - return { msg, hasMsg: boolLit(true, loc) }; + const has = lowerer.declareHiddenLocal("assert.hasMessage", BOOL); + const text = lowerer.declareHiddenLocal("assert.messageText", STRING); + valueMessage = { value, has: has.id, text: text.id, fail }; + return { msg: varRef(text.id, STRING, loc), hasMsg: varRef(has.id, BOOL, loc) }; +} + +/** Lowers one assertion; a message value met on the way is evaluated after + * the assertion's other arguments, as Node evaluates them. A null or + * undefined message is omitted (Node's `message == null` test); a truthy + * one is registered with the runtime, which the failure reads; any other + * value is the empty message, which Node's comparison diffs treat as no + * message too (createErrDiff tests the message's truthiness). */ +function withValueMessage( + lowerer: Lowerer, + expr: ts.CallExpression, + loc: SrcLoc, + lower: () => IrExpr | null, +): IrExpr | null { + const outer = valueMessage; + valueMessage = null; + try { + const lowered = lower(); + const message = valueMessage as { + value: IrExpr; + has: string; + text: string; + fail: boolean; + } | null; + if (lowered === null || message === null) return lowered; + // assert.ok's statements evaluate the value first; its call is last. + const before = lowered.kind === "seqExpr" ? lowered.stmts : []; + const call = lowered.kind === "seqExpr" ? lowered.result : lowered; + if (call.kind !== "libCall") { + lowerer.noLowering( + "assert messages that are not strings in this assertion form", + expr, + "pass a string message", + ); + } + const stmts: IrStmt[] = [...before]; + // An argument computed from the message (assert.fail's generated flag) + // reads it after the declarations below; the others are evaluated first. + const readsMessage = (arg: IrExpr): boolean => + !everyExpr(arg, { + stmt: () => true, + expr: (e) => + !(e.kind === "varRef" && (e.localId === message.has || e.localId === message.text)), + }); + const args = call.args.map((arg, index) => + readsMessage(arg) ? arg : stableValue(lowerer, stmts, arg, `assert.arg${index}`, loc), + ); + const local = lowerer.declareHiddenLocal("assert.message", DYN); + const value = varRef(local.id, DYN, loc); + // The runtime reports a registered value: a truthy one for the other + // methods, any value but null and undefined for assert.fail (Node's + // `actual == null` test), whose other values read "Failed". + const registered: IrExpr = message.fail + ? varRef(message.has, BOOL, loc) + : { kind: "dynTest", test: "truthy", value, type: BOOL, loc }; + stmts.push( + { kind: "varDecl", localId: local.id, init: message.value, loc }, + { + kind: "varDecl", + localId: message.has, + init: { kind: "dynTest", test: "nullish", negated: true, value, type: BOOL, loc }, + loc, + }, + { + kind: "varDecl", + localId: message.text, + init: { + kind: "ternary", + cond: registered, + then: strLit(VALUE_MESSAGE, loc), + else_: strLit(message.fail ? "Failed" : "", loc), + type: STRING, + loc, + }, + loc, + }, + { + kind: "if", + cond: registered, + then: [ + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "assert.messageValue", + args: [value, { kind: "dynTest", test: "error", value, type: BOOL, loc }], + type: VOID, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }, + { kind: "exprStmt", expr: { ...call, args }, loc }, + ); + return { + kind: "seqExpr", + stmts, + result: { kind: "libCall", fn: "assert.messageClear", args: [], type: VOID, loc }, + type: VOID, + loc, + }; + } finally { + valueMessage = outer; + } } /** The module-function dispatch — the spoke's entry, called from both the @@ -201,6 +390,15 @@ export function lowerAssertModuleCall( loc: SrcLoc, ): IrExpr | null { if (bi.module !== "assert" && bi.module !== "assert/strict") return null; + return withValueMessage(lowerer, expr, loc, () => lowerAssertMember(lowerer, expr, bi, loc)); +} + +function lowerAssertMember( + lowerer: Lowerer, + expr: ts.CallExpression, + bi: { module: string; member: string }, + loc: SrcLoc, +): IrExpr | null { switch (canonicalAssertMember(bi.module, bi.member)) { case "ok": return lowerAssertOk(lowerer, expr, loc); @@ -880,7 +1078,7 @@ export function lowerAssertDirectCall( 'use the default import: import assert from "node:assert")', ); } - return lowerAssertOk(lowerer, expr, loc); + return withValueMessage(lowerer, expr, loc, () => lowerAssertOk(lowerer, expr, loc)); } /** assert(value[, message]) / assert.ok(value[, message]): the truthiness @@ -889,7 +1087,24 @@ export function lowerAssertDirectCall( * the same bytes at compile time). */ function lowerAssertOk(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr { requireStatementPosition(lowerer, expr, ts.getTextOfNode(expr.expression)); - if (expr.arguments.nodes.length < 1 || expr.arguments.nodes.length > 2) { + if (expr.arguments.nodes.length === 0) { + // Node's message for a call without arguments. + const text = "No value argument passed to `assert.ok()`"; + return { + kind: "seqExpr", + stmts: [recordOnFailure(lowerer, boolLit(true, loc), null, boolLit(true, loc), "==", loc)], + result: { + kind: "libCall", + fn: "assert.ok", + args: [boolLit(false, loc), strLit(text, loc), boolLit(true, loc)], + type: VOID, + loc, + }, + type: VOID, + loc, + }; + } + if (expr.arguments.nodes.length > 2) { lowerer.noLowering( `${ts.getTextOfNode(expr.expression)} with ${expr.arguments.nodes.length} arguments`, expr, @@ -953,22 +1168,26 @@ function lowerAssertFail(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc) generated = boolLit(true, loc); } else { // Node's AssertionError sets generatedMessage to !message, so an - // empty custom message counts as generated. - msg = stableValue( - lowerer, - stmts, - lowerMessageArg(lowerer, msgNode, loc).msg, - "%assertFailMessage", - loc, - ); - generated = { - kind: "strEq", - negated: false, - left: msg, - right: strLit("", loc), - type: BOOL, - loc, - }; + // empty custom message counts as generated. A message that is not a + // string is read from the hidden local that withValueMessage declares + // after these statements, so it is used as is. + const { msg: lowered, hasMsg } = lowerMessageArg(lowerer, msgNode, loc, true); + if (lowered.kind === "varRef" && hasMsg.kind === "varRef") { + // A value message: generated when Node reads "Failed" for null or + // undefined; the runtime sets the flag for a registered value. + msg = lowered; + generated = { kind: "unary", op: "!", operand: hasMsg, type: BOOL, loc }; + } else { + msg = stableValue(lowerer, stmts, lowered, "%assertFailMessage", loc); + generated = { + kind: "strEq", + negated: false, + left: msg, + right: strLit("", loc), + type: BOOL, + loc, + }; + } } const falseLit: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; stmts.push(recordOnFailure(lowerer, boolLit(true, loc), null, null, "fail", loc)); @@ -1056,9 +1275,22 @@ function lowerAssertEqual( // would answer false where Node can answer true (the value's own // earlier crossing) — a miscompare, never lowered. Bytes fence both // ways (the dyn copy cannot carry the Buffer/Uint8Array brand the - // checker type knows); class instances and everything non-convertible - // keep the fence too. - if (a.type.kind === "dyn" || b.type.kind === "dyn") { + // checker type knows); everything non-convertible keeps the fence too. + // A class instance crosses as a reference to the instance itself, so + // strict compares are honest reference equality, between two instances + // too (assert.strictEqual(e, e.setMaxListeners(1))). + const instanceType = (t: IrType): boolean => + isDynTypedRefType(t) && !lowerer.isSubclassOf(t.className, "%Error"); + const instance = (e: IrExpr): boolean => + !deep && + (instanceType(e.type) || + (e.type.kind === "union" && + (lowerer.unions + .get(e.type.unionId) + ?.arms.every((arm) => instanceType(arm) || isUnitType(arm)) ?? + false))) && + lowerer.dynConvertible(e.type); + if (a.type.kind === "dyn" || b.type.kind === "dyn" || (instance(a) && instance(b))) { const box = (e: IrExpr, node: ts.Expression): IrExpr => { if (e.type.kind === "dyn") return e; const k = e.type.kind; @@ -1067,6 +1299,18 @@ function lowerAssertEqual( k === "f64" || k === "string" || k === "bool" || + // A union of scalars and units crosses by value, like its arms. + (e.type.kind === "union" && + (lowerer.unions + .get(e.type.unionId) + ?.arms.every( + (arm) => + arm.kind === "f64" || + arm.kind === "string" || + arm.kind === "bool" || + isUnitType(arm), + ) ?? + false)) || (k === "func" && lowerer.dynConvertible(e.type)) || // Runtime handles cross by REFERENCE (identity preserved), so the // strict compare is honest: assert.strictEqual(this, server) — @@ -1082,6 +1326,9 @@ function lowerAssertEqual( // same). The deep walk compares the retained error data without // re-encoding and losing identity (SEMANTICS.md). (k === "object" && e.type.kind === "object" && e.type.className === "%Error") || + instance(e) || + // A symbol crosses as itself, so strict compares are identity. + (!deep && k === "symbol") || (deep && k !== "bytes" && k !== "object" && lowerer.dynConvertible(e.type)) || // Under skipPrototype the prototype a class instance or Buffer // brand would carry is not compared. @@ -1470,17 +1717,29 @@ function classifyThrowsExpected( displayName: rec ? rec.lib : info.def.name.replace(/^%/, ""), }; } - if ( - (surface === "assert.doesNotThrow" || surface === "assert.doesNotReject") && - expectedT?.kind === "func" - ) { + if (expectedT?.kind === "func") { const value = lowerer.lowerExpr(node); + // An untyped value there (a dynamic-tier function's result) is checked + // at run time like any untyped expected value. + if (value.type.kind === "jsval" || value.type.kind === "dyn") { + const boxed = lowerer.coerceToExpected(value, DYN); + if (boxed.type.kind === "dyn") return { form: "errValue", value: boxed }; + } + // A JavaScript function accepts any argument; a typed one must take + // unknown values (the caught value need not be an Error). if ( value.type.kind === "func" && - value.type.params.every((param) => param.kind === "dyn") && + (value.type.params.every((param) => param.kind === "dyn") || + isJsSourceFile(ts.getSourceFileOfNode(node))) && lowerer.dynConvertible(value.type) ) { - return { form: "predicate", value: lowerer.coerceToExpected(value, DYN) }; + // The boxed function carries its source name (Node's message names + // a named validation function). + const fnName = jsFuncNameOf(node); + return { + form: "predicate", + value: { kind: "dynFrom", value, ...(fnName !== null ? { fnName } : {}), type: DYN, loc }, + }; } lowerer.noLowering( `${surface} with this validation callback`, @@ -1546,7 +1805,9 @@ function classifyThrowsExpected( // tracingChannel suite's shape): the value crosses as a dyn error and // the runtime compares per Node's expectsError key walk. A // checked-dynamic expected rides directly; a static %Error boxes. - if (expectedT?.kind === "dyn") { + // An untyped JavaScript value (a parameter) too: a function there is + // checked at run time (assertThrowsHelper). + if (expectedT?.kind === "dyn" || expectedT === null) { const value = lowerer.lowerExpr(node); if (value.type.kind === "dyn") return { form: "errValue", value }; } @@ -1943,6 +2204,46 @@ function assertThrowsHelper( return { e: strLit("", loc), has: false }; })(); + // expectedException's function branch: a function with a prototype that + // the caught value is an instance of passes; anything else is called as + // a validation function, whose result must be true. + const validateStmts = (fn: IrExpr): IrStmt[] => { + const actual = (): IrExpr => ({ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc }); + const prototype: IrExpr = { + kind: "dynKeyGet", + value: fn, + key: strLit("prototype", loc), + optional: true, + type: DYN, + loc, + }; + return [ + ifStmt( + { kind: "dynTest", test: "undefined", negated: true, value: prototype, type: BOOL, loc }, + [ + ifStmt( + checkedClassInstanceOf( + lowerer, + actual(), + fn, + "assert.throws against this native constructor is not supported yet", + loc, + ), + [doReturn], + ), + ], + ), + lib("assert.throwsValidate", [ + actual(), + fn, + strLit(mode === "rejects" ? "rejects" : "throws", loc), + m(), + hm(), + ]), + doReturn, + ]; + }; + let catchBody: IrStmt[]; if (mode === "dnr" || mode === "dnt") { const actual = (): IrExpr => ({ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc }); @@ -2116,7 +2417,13 @@ function assertThrowsHelper( // caught value crosses as a dyn value (identity-preserving) and // the runtime walks the expected's keys — a mismatch throws the // deep-equal AssertionError. + // A function there is a validation function (or an error class + // value) as in the predicate form. catchBody = [ + ifStmt( + { kind: "dynTest", test: "function", value: varRef("ev.0", DYN, loc), type: BOOL, loc }, + validateStmts(varRef("ev.0", DYN, loc)), + ), lib("assert.expectsErrDyn", [ { kind: "caughtToDyn", value: caughtRef(), type: DYN, loc }, varRef("ev.0", DYN, loc), @@ -2127,7 +2434,8 @@ function assertThrowsHelper( ]; break; case "predicate": - throw new InternalCompilerError("assert validation callback only enters a no-error helper"); + catchBody = validateStmts(varRef("predicate.0", DYN, loc)); + break; } } diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 06c37736a..2ccb4213f 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,3 +1,4 @@ +import { assertFunctionAliasOf } from "./lower-assert.js"; import { keyedHelperName } from "./stable-names.js"; import { perKeyOrder } from "./key-order-witness.js"; import { lowerDateToPrimitiveCall } from "./builtins/dates.js"; @@ -1053,6 +1054,13 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { } const bi = lowerer.builtinImportOf(expr.expression); if (bi) return lowerBuiltinCall(lowerer, expr, bi, loc); + // A constant bound to an assert method (`const ok = assert.ok`) called + // directly is that assertion, with the message Node quotes the call for. + { + const alias = assertFunctionAliasOf(lowerer, expr.expression); + const served = alias ? lowerer.lowerAssertModuleCall(expr, alias, loc) : null; + if (served) return served; + } // The assert module binding called DIRECTLY (`assert(x)` — a default // import or the CJS `const assert = require("assert")`): Node's // module object IS assert.ok; namespace-import bindings fence inside @@ -8709,7 +8717,12 @@ function lowerObjectStaticCall( (probed && isDynTypedRefType(probed.type)) || (probed?.type.kind === "object" && lowerer.errorHierarchyClassOf(probed.type.className)) || (probed && constructor && (constructor.callableBase || hasRuntimeStatics(constructor))) || - (probed?.type.kind === "func" && lowerer.dynConvertible(probed.type)) + (probed?.type.kind === "func" && lowerer.dynConvertible(probed.type)) || + // null, undefined and unions of untyped JavaScript values: the same + // probe, whose ToObject throws Node's TypeError on nullish. + (probed && + (isUnitType(probed.type) || probed.type.kind === "union") && + lowerer.dynConvertible(probed.type)) ) { const loc = locOf(call); const receiver = lowerer.coerceToExpected(probed, DYN); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 9e9e75794..295a56459 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1627,6 +1627,44 @@ const THROWN_AS_DYN: ReadonlySet = new Set([ "date", ]); +/** A `return` at the top level of a CommonJS module (preflight admits it + * there only): it ends the module's evaluation, and its value is + * evaluated and discarded, as Node's module wrapper does. Top-level + * bindings and export assignments after it would be left uninitialized + * where Node leaves them in their temporal dead zone or absent, so a + * module with any keeps a refusal. Null for a return inside a function. */ +function lowerModuleLevelReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrStmt | null { + let top: ts.Node = stmt; + while (top.parent !== undefined && !ts.isSourceFile(top.parent)) { + if (ts.isFunctionLike(top.parent) || ts.isClassStaticBlockDeclaration(top.parent)) return null; + top = top.parent; + } + const sf = top.parent; + if (sf === undefined || !ts.isSourceFile(sf)) return null; + const stmts = sf.statements.nodes; + const later = stmts.slice(stmts.indexOf(top as ts.Statement) + 1); + if ( + later.some( + (s) => + ts.isVariableStatement(s) || ts.isClassDeclaration(s) || cjsExportAssignmentOf(s) !== null, + ) + ) { + lowerer.unsupported( + "SC1090", + stmt, + "a top-level return in a CommonJS module that declares variables or classes, or assigns exports, after it", + ); + } + const loc = locOf(stmt); + const ret: IrStmt = { kind: "return", value: null, loc }; + if (!stmt.expression) return ret; + return { + kind: "block", + body: [{ kind: "exprStmt", expr: lowerer.lowerExpr(stmt.expression), loc }, ret], + loc, + }; +} + export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | null { if (ownPrototypeResetStatement(lowerer, stmt)) return { kind: "block", body: [], loc: locOf(stmt) }; @@ -1736,6 +1774,8 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt if (ts.isForOfStatement(stmt)) return lowerer.lowerForOf(stmt); if (ts.isLabeledStatement(stmt)) return lowerLabeled(lowerer, stmt); if (ts.isReturnStatement(stmt)) { + const moduleReturn = lowerModuleLevelReturn(lowerer, stmt); + if (moduleReturn) return moduleReturn; // Implicit-any instance RETURN INFERENCE (function-returns.ts // resolveInferredReturn): the value lowers BARE and the statement // records itself — the post-pass settles the instance's return type diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 786a26eea..6fd6d863a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -593,7 +593,11 @@ import { lowerTestMethodCall, lowerTestCtxProperty, } from "./lower-test.js"; -import { lowerAssertModuleCall, lowerAssertDirectCall } from "./lower-assert.js"; +import { + assertFunctionValue, + lowerAssertModuleCall, + lowerAssertDirectCall, +} from "./lower-assert.js"; import type { AssertInstanceOptions } from "./assert-instances.js"; import { lowerUtilModuleCall } from "./lower-inspect.js"; import { lowerSystemErrorMapValue } from "./lower-system-errors.js"; @@ -13989,6 +13993,8 @@ export class Lowerer { * deliberately narrower than table membership because many table rows * are only dispatch sentinels for call-site-specific lowering. */ lowerBuiltinCallableValue(bi: { module: string; member: string }, loc: SrcLoc): IrExpr | null { + const assertion = assertFunctionValue(bi, loc); + if (assertion) return assertion; if (bi.module === "util" && bi.member === "types") return utilTypesModuleValue(this, loc); if (bi.module === "util/types" && builtinModuleFnOf(this, bi.module, bi.member)) return lowerUtilTypeValue(this, bi.member, loc); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 17013b6e3..1b4c7efbe 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -873,6 +873,12 @@ export const OBJECT_CALLABLE_VALUES: Record result: DYN, valueParams: exactValueParams(DYN, DYN), }, + hasOwn: { + fn: "dyn.hasOwnComputed", + params: [DYN, DYN], + result: BOOL, + valueParams: exactValueParams(DYN, DYN), + }, keys: { fn: "dyn.objKeys", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) }, values: { fn: "dyn.objValues", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) }, entries: { fn: "dyn.objEntries", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) }, diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 8e582af89..25caecb64 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -5363,6 +5363,16 @@ function preflight7(load: LoadResult): { return new RegExp(`\\btypeof\\s*\\(?\\s*${name.replace(/\$/g, "\\$")}(?![\\w$])`).test(sf.text); }; + // A `return` at the top level of a CommonJS module (TS1108): Node runs + // the module as a function body, so the return ends the module's + // evaluation. ES modules and TypeScript keep the error. + const cjsTopLevelReturnSuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => { + if (d.code !== 1108 || d.fileName === undefined || !isJsSourceFileName(d.fileName)) + return false; + const sf = p.getSourceFile(d.fileName); + return sf !== undefined && isCjsJsFile7(sf, p); + }; + const workspaceImplicitAnySuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => { if (d.code !== 7016 || d.fileName === undefined || d.pos === undefined || d.end === undefined) return false; @@ -5409,6 +5419,7 @@ function preflight7(load: LoadResult): { !isNpmStaticSubclassArgument(p, d) && !workspaceImplicitAnySuppressed(p, d) && !typeofTestedNameSuppressed(p, d) && + !cjsTopLevelReturnSuppressed(p, d) && !jsdocTypeSuppressed(p, d, commentDup), ); }; diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index e6c0be7cd..e8ffc29fb 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -132,6 +132,10 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ // binding types as any and reached uses fence instead of passing unchecked 2307, 2792, + // `delete` of a property the inference typed required (2790): JS deletes + // any configurable property, and the lowering checks each delete's + // receiver itself + 2790, // useUnknownInCatchVariables' annotation demand ("'e' is of type // 'unknown'", "Object is of type 'unknown'"): a JS catch clause CANNOT // annotate its variable, so the demand is unsatisfiable in source — diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 38512fdef..7317116db 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -977,6 +977,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "assert.noErrorPredicate", "assert.regexDynTest", "assert.expectsErrDyn", + "assert.throwsValidate", "assert.ifErrorErr", "assert.ifErrorF64", "assert.ifErrorStr", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 0e16d96d1..1109f7f95 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4412,6 +4412,12 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ // The equality quartet over checked-dynamic operands (the frontend // boxes a static side into the checked-dynamic tree first). "assert.eqDyn": { argTypes: [DYN, DYN, BOOL, BOOL, STRING, BOOL], result: VOID }, + /** A message argument that is not a string, registered for the next + * failing assertion (scr_assert_message_value). */ + "assert.messageValue": { argTypes: [DYN, BOOL], result: VOID }, + /** An assert method as a function value, by canonical name. */ + "assert.methodValue": { argTypes: [STRING], result: DYN }, + "assert.messageClear": { argTypes: [], result: VOID }, "assert.looseResult": { argTypes: [BOOL, BOOL, STRING, STRING, STRING, BOOL], result: VOID }, // The throws(fn, {shape}) accumulator: begin/slot calls never throw; // shapeEnd throws the Comparison diff. The error slot is the @@ -4437,6 +4443,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * against the caught value's; a mismatch throws the deep-equal * AssertionError (scr_assert.c). MAY THROW by design. */ "assert.expectsErrDyn": { argTypes: [DYN, DYN, STRING, BOOL], result: VOID }, + /** A validation function against the caught value (Node's expectedException). */ + "assert.throwsValidate": { argTypes: [DYN, DYN, STRING, STRING, BOOL], result: VOID }, // assert.ifError's typed entries (always throw; unit args never // lower). The error slot is program-dependent — any %Error-hierarchy // class, root or subclass (the insp.error precedent, null slot). diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8062-assert-message-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8062-assert-message-values.cjs.json new file mode 100644 index 000000000..78ba99298 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8062-assert-message-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8062-assert-message-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8065-cjs-top-level-return/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8065-cjs-top-level-return/main.js.json new file mode 100644 index 000000000..22edecf3f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8065-cjs-top-level-return/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8065-cjs-top-level-return/early.js", + "/tests/corpus/8065-cjs-top-level-return/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8066-object-hasown-delete.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8066-object-hasown-delete.cjs.json new file mode 100644 index 000000000..bf862b8f2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8066-object-hasown-delete.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8066-object-hasown-delete.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8067-assert-throws-validators.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8067-assert-throws-validators.cjs.json new file mode 100644 index 000000000..89f59e97c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8067-assert-throws-validators.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8067-assert-throws-validators.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8076-assert-instance-identity.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8076-assert-instance-identity.cjs.json new file mode 100644 index 000000000..b4212e234 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8076-assert-instance-identity.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8076-assert-instance-identity.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8078-instanceof-object-function.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8078-instanceof-object-function.cjs.json new file mode 100644 index 000000000..90af753d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8078-instanceof-object-function.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8078-instanceof-object-function.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8190-assert-function-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8190-assert-function-values.cjs.json new file mode 100644 index 000000000..730dd6360 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8190-assert-function-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8190-assert-function-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8191-assert-function-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8191-assert-function-values.ts.json new file mode 100644 index 000000000..0837dc2ee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8191-assert-function-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8191-assert-function-values.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_assert.c b/packages/runtime/src/scr_assert.c index 4917b1291..bf5d1c875 100644 --- a/packages/runtime/src/scr_assert.c +++ b/packages/runtime/src/scr_assert.c @@ -212,7 +212,56 @@ bool scr_assert_skip_prototype(void) { return (g_instance_options & SCR_ASSERT_SKIP_PROTOTYPE) != 0; } +/* A message argument that is not a string (an untyped value, or an Error + * to throw): the compiled call registers it before the assertion and passes + * SCR_ASSERT_VALUE_MESSAGE as its message, which the failure paths compose + * like any custom message. A failure whose message starts with it throws + * the Error itself (innerFail's `message instanceof Error`) or puts + * String(value) in its place with generatedMessage `!value`, as Node's + * AssertionError does. Matching the sentinel keeps a registration that no + * failure consumed from reaching another assertion. */ +static SCR_TL ScrDyn *g_message_value; +static SCR_TL bool g_message_error; + +void scr_assert_message_value(ScrDyn *value, bool error) { + if (g_message_value) scr_dyn_release(g_message_value); + g_message_value = scr_dyn_retain(value); + g_message_error = error; +} + +void scr_assert_message_clear(void) { + if (g_message_value) scr_dyn_release(g_message_value); + g_message_value = NULL; +} + void scr_assert_fail(ScrStr *message, bool generated) { + static const char sentinel[] = SCR_ASSERT_VALUE_MESSAGE; + const size_t n = sizeof sentinel - 1; + if (g_message_value && message->len >= n && memcmp(message->data, sentinel, n) == 0) { + ScrDyn *value = g_message_value; + g_message_value = NULL; + if (g_message_error) { + scr_str_release(message); + scr_assert_record(NULL, NULL, NULL); + g_instance_options = 0; + scr_dyn_throw(value); + return; + } + ScrStr *text = scr_dyn_string_coerce_js(value); + generated = !scr_dyn_truthy(value); + scr_dyn_release(value); + if (!text) { + scr_str_release(message); + return; + } + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_write(&b, text->data, text->len); + scr_jb_write(&b, message->data + n, message->len - n); + scr_str_release(text); + scr_str_release(message); + message = scr_jb_finish(&b); + } ScrError *e = scr_assert_error(message, generated, scr_assert_diff_full() ? "full" : "simple"); g_instance_options = 0; scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); @@ -2703,6 +2752,60 @@ bool scr_assert_no_error_predicate(ScrDyn *predicate, ScrDyn *actual) { return matched; } +/* assert.throws/rejects with a validation function (expectedException's + * function branch, after its instanceof check): the function runs with a + * fresh object as `this` and the caught value, and any result but true + * fails, with Node's generated message unless the call passed one. The + * caught value and the function are the AssertionError's actual and + * expected. Borrows all. */ +void scr_assert_throws_validate(ScrDyn *actual, ScrDyn *validate, ScrStr *op, ScrStr *msg, + bool has_msg) { + ScrDyn *receiver = scr_dyn_new_obj(); + scr_dyn_this_push_dyn(receiver); + ScrDyn *result = scr_dyn_call(validate, &actual, 1, "expected"); + scr_dyn_this_pop(); + scr_dyn_release(receiver); + if (!result) return; + if (result->kind == SCR_DYN_BOOL && result->v.b) { + scr_dyn_release(result); + return; + } + ScrStr *message; + bool generated = !(has_msg && msg->len > 0); + if (!generated) { + message = scr_str_retain(msg); + } else { + ScrAssertBuf b = {0}; + ab_cstr(&b, "The "); + ScrDyn *name = scr_dyn_fn_get(validate, "name", 4); + if (name && name->kind == SCR_DYN_STR && name->v.str->len > 0) { + ab_cstr(&b, "\""); + ab_bytes(&b, name->v.str->data, name->v.str->len); + ab_cstr(&b, "\" "); + } + if (name) scr_dyn_release(name); + ab_cstr(&b, "validation function is expected to return \"true\". Received "); + ScrStr *shown = scr_assert_cf_inspect(result); + ab_bytes(&b, shown->data, shown->len); + scr_str_release(shown); + if (scr_dyn_ref_is_error(actual)) { + ScrStr *text = scr_dyn_string_coerce_js(actual); + if (!text) { + scr_dyn_release(result); + scr_str_release(ab_take(&b)); + return; + } + ab_cstr(&b, "\n\nCaught error:\n\n"); + ab_bytes(&b, text->data, text->len); + scr_str_release(text); + } + message = ab_take(&b); + } + scr_dyn_release(result); + scr_assert_record_dyn(actual, validate, op); + scr_assert_fail(message, generated); +} + /* ── assert.ifError ─────────────────────────────────────────────────── * Node throws for ANY value but null/undefined (falsy included); the * frontend routes null/undefined to a no-op and everything else here. @@ -2879,3 +2982,225 @@ ScrError *scr_assert_error_new(ScrDyn *options) { scr_assert_attach_values(e, generated, actual, expected, op, diff); return e; } + +/* ── assert functions as values ────────────────────────────────────────── + * `assert.fail` stored or passed as a function (`emitter.on("x", + * assert.fail)`): one function per method, which takes its arguments as + * untyped values and asserts as the method does. */ + +/* Node's message argument: absent or null/undefined (a generated message), + * a string, an Error (thrown when the assertion fails), or another value + * (String(value)). The value is registered as scr_assert_fail reads it; the + * caller clears it after the assertion. +1 into *msg. */ +static void scr_av_message(ScrDyn *const *args, size_t argc, size_t i, ScrStr **msg, bool *has) { + const ScrDyn *m = i < argc ? args[i] : NULL; + if (!m || m->kind == SCR_DYN_UNDEF || m->kind == SCR_DYN_NULL) { + *msg = scr_str_new("", 0); + *has = false; + return; + } + *has = true; + if (m->kind == SCR_DYN_STR) { + *msg = scr_str_retain(m->v.str); + return; + } + bool error = scr_dyn_ref_is_error(m) || + (m->kind == SCR_DYN_OBJ && scr_dyn_obj_get(m, "%error", 6) != NULL); + scr_assert_message_value((ScrDyn *)m, error); + *msg = scr_str_new(SCR_ASSERT_VALUE_MESSAGE, sizeof SCR_ASSERT_VALUE_MESSAGE - 1); +} + +static const ScrDyn *scr_av_arg(ScrDyn *const *args, size_t argc, size_t i) { + return i < argc ? args[i] : scr_dyn_undefined(); +} + +static ScrDyn *scr_av_done(ScrStr *msg) { + scr_str_release(msg); + scr_assert_message_clear(); + return scr_dyn_retain(scr_dyn_undefined()); +} + +/* assert(value, message) and assert.ok: Node's generated message quotes + * the call's source, which a function value has no fixed site for; the + * message here is AssertionError's own `value == true`. */ +static ScrDyn *scr_av_ok(ScrClosure *c, ScrDyn *const *args, size_t argc) { + (void)c; + const ScrDyn *value = scr_av_arg(args, argc, 0); + if (scr_dyn_truthy(value)) return scr_dyn_retain(scr_dyn_undefined()); + scr_assert_record(scr_dyn_retain((ScrDyn *)value), scr_dyn_new_bool(true), "=="); + if (argc == 0) { + static const char text[] = "No value argument passed to `assert.ok()`"; + scr_assert_fail(scr_str_new(text, sizeof text - 1), true); + return NULL; + } + ScrStr *msg; + bool has; + scr_av_message(args, argc, 1, &msg, &has); + if (has) { + scr_assert_fail(scr_str_retain(msg), false); + return scr_av_done(msg); + } + ScrStr *shown = scr_assert_cf_inspect(value); + ScrAssertBuf b = {0}; + ab_str(&b, shown); + ab_cstr(&b, " == true"); + scr_str_release(shown); + scr_assert_fail(ab_take(&b), true); + return scr_av_done(msg); +} + +/* assert.fail(message): "Failed" without a message. */ +static ScrDyn *scr_av_fail(ScrClosure *c, ScrDyn *const *args, size_t argc) { + (void)c; + if (argc > 1) { + static const char text[] = "assert.fail with more than one argument through a function value is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, text, sizeof text - 1, "SC2020"); + return NULL; + } + ScrStr *msg; + bool has; + scr_av_message(args, argc, 0, &msg, &has); + scr_assert_record(scr_dyn_retain(scr_dyn_undefined()), scr_dyn_retain(scr_dyn_undefined()), "fail"); + if (has) scr_assert_fail(scr_str_retain(msg), false); + else scr_assert_fail(scr_str_new("Failed", 6), true); + return scr_av_done(msg); +} + +/* The comparison methods take both operands: Node's ERR_MISSING_ARGS + * TypeError otherwise. False with it pending. */ +static bool scr_av_operands(size_t argc) { + if (argc >= 2) return true; + static const char text[] = "The \"actual\" and \"expected\" arguments must be specified"; + scr_throw_error_msg_code(SCR_ERR_TYPE, text, sizeof text - 1, "ERR_MISSING_ARGS"); + return false; +} + +/* equal / notEqual: Node's loose comparison, NaN equal to NaN. */ +static ScrDyn *scr_av_loose(ScrDyn *const *args, size_t argc, bool negated) { + if (!scr_av_operands(argc)) return NULL; + const ScrDyn *a = scr_av_arg(args, argc, 0), *b = scr_av_arg(args, argc, 1); + bool nan = a->kind == SCR_DYN_NUM && b->kind == SCR_DYN_NUM && isnan(a->v.num) && isnan(b->v.num); + bool equal = nan || scr_dyn_abstract_eq(a, b); + if (scr_exc_pending()) return NULL; + if (negated ? !equal : equal) return scr_dyn_retain(scr_dyn_undefined()); + ScrStr *msg; + bool has; + scr_av_message(args, argc, 2, &msg, &has); + scr_assert_record(scr_dyn_retain((ScrDyn *)a), scr_dyn_retain((ScrDyn *)b), negated ? "!=" : "=="); + ScrStr *ia = scr_assert_cf_inspect(a), *ib = scr_assert_cf_inspect(b); + scr_assert_loose_result(equal, negated, ia, ib, msg, has); + scr_str_release(ia); + scr_str_release(ib); + return scr_av_done(msg); +} + +static ScrDyn *scr_av_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_loose(a, n, false); +} +static ScrDyn *scr_av_not_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_loose(a, n, true); +} + +/* The strict and deep comparisons, through the untyped assert paths. */ +enum { SCR_AV_STRICT, SCR_AV_DEEP_STRICT, SCR_AV_DEEP, SCR_AV_PARTIAL }; + +static ScrDyn *scr_av_compare(ScrDyn *const *args, size_t argc, int form, bool negated) { + if (!scr_av_operands(argc)) return NULL; + ScrDyn *a = (ScrDyn *)scr_av_arg(args, argc, 0), *b = (ScrDyn *)scr_av_arg(args, argc, 1); + ScrStr *msg; + bool has; + scr_av_message(args, argc, 2, &msg, &has); + if (form == SCR_AV_DEEP) scr_assert_loose_deep_dyn(a, b, negated, msg, has); + else if (form == SCR_AV_PARTIAL) scr_assert_partial_deep_dyn(a, b, msg, has); + else scr_assert_eq_dyn(a, b, negated, form == SCR_AV_DEEP_STRICT, msg, has); + return scr_av_done(msg); +} + +static ScrDyn *scr_av_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_STRICT, false); +} +static ScrDyn *scr_av_not_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_STRICT, true); +} +static ScrDyn *scr_av_deep_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP_STRICT, false); +} +static ScrDyn *scr_av_not_deep_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP_STRICT, true); +} +static ScrDyn *scr_av_deep_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP, false); +} +static ScrDyn *scr_av_not_deep_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP, true); +} +static ScrDyn *scr_av_partial_deep_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_PARTIAL, false); +} + +static ScrDyn *scr_av_if_error(ScrClosure *c, ScrDyn *const *args, size_t argc) { + (void)c; + scr_assert_iferror_dyn(scr_av_arg(args, argc, 0)); + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); +} + +static const struct { + const char *name; + ScrDynThunk thunk; + uint32_t arity; +} scr_av_methods[] = { + {"ok", &scr_av_ok, 0}, + {"fail", &scr_av_fail, 1}, + {"equal", &scr_av_equal, 3}, + {"notEqual", &scr_av_not_equal, 3}, + {"strictEqual", &scr_av_strict_equal, 3}, + {"notStrictEqual", &scr_av_not_strict_equal, 3}, + {"deepEqual", &scr_av_deep_equal, 3}, + {"notDeepEqual", &scr_av_not_deep_equal, 3}, + {"deepStrictEqual", &scr_av_deep_strict_equal, 3}, + {"notDeepStrictEqual", &scr_av_not_deep_strict_equal, 3}, + {"partialDeepStrictEqual", &scr_av_partial_deep_strict_equal, 3}, + {"ifError", &scr_av_if_error, 1}, +}; +#define SCR_AV_COUNT (sizeof scr_av_methods / sizeof scr_av_methods[0]) +static SCR_TL ScrDyn *scr_av_values[SCR_AV_COUNT]; +static SCR_TL bool scr_av_registered; + +static void scr_av_cleanup(void) { + for (size_t i = 0; i < SCR_AV_COUNT; i++) { + scr_dyn_release(scr_av_values[i]); + scr_av_values[i] = NULL; + } + scr_av_registered = false; +} + +/* The function value of an assert method by its canonical name (the frontend + * maps assert/strict's aliases), +1; one value per method, so stored copies + * compare equal. */ +ScrDyn *scr_assert_method_value(ScrStr *name) { + for (size_t i = 0; i < SCR_AV_COUNT; i++) { + if (strlen(scr_av_methods[i].name) != name->len || + memcmp(scr_av_methods[i].name, name->data, name->len) != 0) + continue; + if (!scr_av_values[i]) { + if (!scr_av_registered) { + scr_av_registered = true; + scr_atexit(scr_av_cleanup); + } + scr_av_values[i] = scr_dyn_new_func(scr_closure_new(NULL, 0), scr_av_methods[i].thunk, + scr_av_methods[i].arity, "native:assert", + scr_av_methods[i].name); + } + return scr_dyn_retain(scr_av_values[i]); + } + return scr_dyn_retain(scr_dyn_undefined()); +} diff --git a/packages/runtime/src/scr_assert.h b/packages/runtime/src/scr_assert.h index 8037f08ec..57158a69a 100644 --- a/packages/runtime/src/scr_assert.h +++ b/packages/runtime/src/scr_assert.h @@ -24,6 +24,19 @@ void scr_assert_fail(ScrStr *message, bool generated); /* takes ownership; alway * consumes them; a passing call clears them. */ void scr_assert_set_options(int options); void scr_assert_clear_options(void); +/* A message argument that is not a string, for the next assertion that + * fails with SCR_ASSERT_VALUE_MESSAGE as its message (scr_assert.c): an + * Error (`error`) is thrown as it is, anything else reports String(value). + * Borrows value; clear drops a registration the call did not consume. */ +#define SCR_ASSERT_VALUE_MESSAGE "\x01scriptc:assert-message-value" +void scr_assert_message_value(struct ScrDyn *value, bool error); +void scr_assert_message_clear(void); +/* An assert method as a function value, by canonical name (+1). */ +struct ScrDyn *scr_assert_method_value(ScrStr *name); +/* assert.throws/rejects with a validation function: its result must be + * true (Node's expectedException), else the AssertionError. Borrows all. */ +void scr_assert_throws_validate(struct ScrDyn *actual, struct ScrDyn *validate, ScrStr *op, + ScrStr *msg, bool has_msg); bool scr_assert_diff_full(void); bool scr_assert_skip_prototype(void); void scr_assert_record(ScrDyn *actual, ScrDyn *expected, const char *op); /* values move in */ diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 40c795f81..82667308d 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4719,6 +4719,115 @@ int scr_dyn_has_instance_hook(const ScrDyn *value, const ScrDyn *callee) { return truthy ? 1 : 0; } +/* `value instanceof Object`: whether Object.prototype is on the value's + * prototype chain. Primitives answer false, and so does an object whose + * chain ends in null first (Object.create(null), a null-prototype record). + * Objects without a chain the runtime walks (class instances, native + * handles, errors, promises) inherit Object.prototype. False with the + * exception pending when a prototype read throws. */ +static bool scr_dyn_instanceof_object(const ScrDyn *value) { + switch (value->kind) { + case SCR_DYN_UNDEF: + case SCR_DYN_NULL: + case SCR_DYN_BOOL: + case SCR_DYN_NUM: + case SCR_DYN_STR: + case SCR_DYN_BIGINT: + case SCR_DYN_SYMBOL: + return false; + default: + break; + } + /* Object.prototype is on the chain from the value's own prototype on: + * Object.prototype itself has a null prototype, so it is not an Object. */ + ScrDyn *current = scr_dyn_retain((ScrDyn *)value); + for (bool first = true;; first = false) { + if (!first && current == scr_builtin_object_prototype) { + scr_dyn_release(current); + return true; + } + if (current == scr_builtin_object_prototype) { + scr_dyn_release(current); + return false; + } + ScrDyn *next = NULL; + if (current->kind == SCR_DYN_FUNC) { + /* A function's prototype is Function.prototype, whose own is + * Object.prototype, unless the program set it. */ + if (scr_closure_identity(current->v.fn.clo)->props) { + ScrDyn *table = scr_dyn_fn_properties(current); + next = table->prototype ? scr_dyn_retain(table->prototype) + : table->null_proto ? scr_dyn_new_null() : NULL; + scr_dyn_release(table); + } + } else if (current->kind == SCR_DYN_OBJ || current->kind == SCR_DYN_ARR || + (current->kind == SCR_DYN_TYPED_REF && + strncmp(current->v.typed_ref.type_key, "object:", 7) != 0)) { + next = scr_dyn_get_prototype(current); + if (!next) { + scr_dyn_release(current); + return false; /* the read threw: pending */ + } + } + scr_dyn_release(current); + if (!next) return true; + if (next->kind == SCR_DYN_NULL) { + scr_dyn_release(next); + return false; + } + current = next; + } +} + +/* `value instanceof Function`: whether a function is on the value's + * prototype chain. A function's own prototype is Function.prototype unless + * the program set it, so the walk ends true at the first function that + * kept its prototype; it follows set prototypes through functions and + * objects alike (`Object.create(function () {})` is a Function instance), + * and ends false at null or at a value without a chain the runtime walks. + * False with the exception pending when a prototype read throws. */ +static bool scr_dyn_instanceof_function(const ScrDyn *value) { + ScrDyn *current = NULL; + if (value->kind == SCR_DYN_FUNC) { + if (!scr_closure_identity(value->v.fn.clo)->props) return true; + ScrDyn *table = scr_dyn_fn_properties((ScrDyn *)value); + current = table->prototype ? scr_dyn_retain(table->prototype) : NULL; + bool unset = !table->prototype && !table->null_proto; + scr_dyn_release(table); + if (unset) return true; + } else if (value->kind == SCR_DYN_OBJ || value->kind == SCR_DYN_ARR) { + current = scr_dyn_get_prototype((ScrDyn *)value); + if (!current) return false; /* the read threw: pending */ + } + while (current && current->kind != SCR_DYN_NULL) { + ScrDyn *next = NULL; + if (current->kind == SCR_DYN_FUNC) { + if (!scr_closure_identity(current->v.fn.clo)->props) { + scr_dyn_release(current); + return true; + } + ScrDyn *table = scr_dyn_fn_properties(current); + bool unset = !table->prototype && !table->null_proto; + next = table->prototype ? scr_dyn_retain(table->prototype) : NULL; + scr_dyn_release(table); + if (unset) { + scr_dyn_release(current); + return true; + } + } else if (current->kind == SCR_DYN_OBJ || current->kind == SCR_DYN_ARR) { + next = scr_dyn_get_prototype(current); + if (!next) { + scr_dyn_release(current); + return false; /* the read threw: pending */ + } + } + scr_dyn_release(current); + current = next; + } + if (current) scr_dyn_release(current); + return false; +} + bool scr_bytes_instanceof_at(const ScrDyn *value, const ScrDyn *callee, const ScrStr *refusal) { if (!callee || callee->kind != SCR_DYN_FUNC) { static const char message[] = "Right-hand side of 'instanceof' is not callable"; @@ -4727,6 +4836,13 @@ bool scr_bytes_instanceof_at(const ScrDyn *value, const ScrDyn *callee, const Sc } int hooked = scr_dyn_has_instance_hook(value, callee); if (hooked >= 0) return hooked == 1; + if (callee->v.fn.sig && callee->v.fn.name) { + if (strcmp(callee->v.fn.sig, "native:constructor") == 0 && strcmp(callee->v.fn.name, "Object") == 0) + return scr_dyn_instanceof_object(value); + if (strcmp(callee->v.fn.sig, "native:function-constructor") == 0 && + strcmp(callee->v.fn.name, "Function") == 0) + return scr_dyn_instanceof_function(value); + } /* An error constructor held as a value (`inst instanceof params.Parent` * with Parent === Error): the error kind's interval, and for Error itself * objects inheriting Error.prototype. */ diff --git a/tests/corpus/8062-assert-message-values.cjs b/tests/corpus/8062-assert-message-values.cjs new file mode 100644 index 000000000..fc37a71e4 --- /dev/null +++ b/tests/corpus/8062-assert-message-values.cjs @@ -0,0 +1,48 @@ +'use strict'; +// node:assert messages that are not string literals: an untyped string, an +// Error to throw, null/undefined (omitted) and falsy values, and +// scalar-union operands against untyped values. +const assert = require('assert'); + +function attempt(label, fn) { + try { + fn(); + console.log(label, 'passed'); + } catch (e) { + console.log(label, e.name, JSON.stringify(e.message), e.generatedMessage, e.code); + } +} + +const failures = []; +for (const x of ['a', 'b']) if (x === 'c') failures.push(`\n${x}`); +attempt('joined passes', () => assert.strictEqual(failures.length, 0, failures.join(''))); +const untyped = JSON.parse('"untyped message"'); +attempt('untyped', () => assert.strictEqual(1, 2, untyped)); +attempt('untyped deep', () => assert.deepStrictEqual([1], [2], untyped)); +attempt('untyped notStrict', () => assert.notStrictEqual(1, 1, untyped)); +attempt('error message', () => assert.strictEqual(1, 2, new TypeError('own error'))); +attempt('error deep', () => assert.deepStrictEqual({ a: 1 }, { a: 2 }, new RangeError('own range'))); +const none = JSON.parse('null'); +attempt('null message', () => assert.strictEqual(1, 2, none)); +const zero = JSON.parse('0'); +attempt('zero message deep', () => assert.deepStrictEqual([1], [2], zero)); +attempt('passing error message', () => assert.strictEqual(1, 1, new Error('unused'))); +attempt('later string message', () => assert.strictEqual(1, 2, 'plain')); + +// Operand order: both operands evaluate before the message. +const order = []; +const mark = (label, value) => (order.push(label), value); +attempt('order', () => assert.strictEqual(mark('a', 1), mark('b', 2), mark('m', JSON.parse('"m"')))); +console.log(order.join(',')); + +const row = [['a', 'b', true], ['c', 'd', 'x']]; +for (const [, , expected] of row) { + attempt('union operand', () => assert.strictEqual(JSON.parse(JSON.stringify(expected)), expected)); +} + +// assert.fail with a message that is not a string: an Error is thrown as +// is; other values become the message, generated when falsy. +attempt('fail error', () => assert.fail(new RangeError('re'))); +attempt('fail value', () => assert.fail(JSON.parse('{"a":1}'))); +attempt('fail zero', () => assert.fail(zero)); +attempt('fail null', () => assert.fail(JSON.parse('null'))); diff --git a/tests/corpus/8065-cjs-top-level-return/early.js b/tests/corpus/8065-cjs-top-level-return/early.js new file mode 100644 index 000000000..8d7de4ba3 --- /dev/null +++ b/tests/corpus/8065-cjs-top-level-return/early.js @@ -0,0 +1,6 @@ +'use strict'; +exports.ready = true; +console.log('early module start', typeof helper); +if (exports.ready) return; +console.log('early module end'); +function helper() {} diff --git a/tests/corpus/8065-cjs-top-level-return/main.js b/tests/corpus/8065-cjs-top-level-return/main.js new file mode 100644 index 000000000..7743eaac5 --- /dev/null +++ b/tests/corpus/8065-cjs-top-level-return/main.js @@ -0,0 +1,16 @@ +'use strict'; +// A CommonJS module may end its evaluation with a top-level return: the +// rest of the module does not run, while timers, exit handlers and the +// modules it already required keep their effects. +const early = require('./early.js'); +console.log('early exports', early.ready); +setTimeout(() => console.log('timer ran'), 1); +process.on('exit', () => console.log('exit handler')); +for (const value of [1, 2, 3]) { + if (value === 2) return 'ignored value'; + console.log('value', value); +} +console.log('not reached'); +function hoisted() { + return 'hoisted'; +} diff --git a/tests/corpus/8065-cjs-top-level-return/package.json b/tests/corpus/8065-cjs-top-level-return/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/corpus/8065-cjs-top-level-return/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/tests/corpus/8066-object-hasown-delete.cjs b/tests/corpus/8066-object-hasown-delete.cjs new file mode 100644 index 000000000..11668b521 --- /dev/null +++ b/tests/corpus/8066-object-hasown-delete.cjs @@ -0,0 +1,23 @@ +'use strict'; +// Object.hasOwn over nullish receivers and as a value, and `delete` of +// properties JavaScript inference types as required. +const o = { a: 1, b: undefined }; +console.log(Object.hasOwn(o, 'a'), Object.hasOwn(o, 'b'), Object.hasOwn(o, 'c')); +console.log(Object.hasOwn([1, 2], 1), Object.hasOwn([1, 2], 2), Object.hasOwn(Object.create({ x: 1 }), 'x')); +for (const value of [null, undefined]) { + try { + Object.hasOwn(value, 'a'); + } catch (e) { + console.log(e.name, e.message); + } +} +const pick = Math.random() < 2 ? { k: 1 } : null; +console.log(Object.hasOwn(pick, 'k')); +const hasOwn = Object.hasOwn; +console.log(hasOwn({ k: 1 }, 'k'), hasOwn({}, 'k'), [{ a: 1 }, {}].map((x) => hasOwn(x, 'a')).join()); +const record = { a: 1, b: 2 }; +delete record.a; +console.log(Object.keys(record).join(), Object.hasOwn(record, 'a'), record.a); +const table = { one: 1, two: 2 }; +for (const key of Object.keys(table)) if (key === 'one') delete table[key]; +console.log(JSON.stringify(table)); diff --git a/tests/corpus/8067-assert-throws-validators.cjs b/tests/corpus/8067-assert-throws-validators.cjs new file mode 100644 index 000000000..189f4221a --- /dev/null +++ b/tests/corpus/8067-assert-throws-validators.cjs @@ -0,0 +1,22 @@ +'use strict'; +// assert.throws and assert.rejects with validation functions, constructor +// functions, and untyped expected values (Node's expectedException). +const assert = require('assert'); +function show(label, fn) { + try { fn(); console.log(label, 'passed'); } catch (e) { console.log(label, e.name, JSON.stringify(e.message), e.generatedMessage, e.operator, e.actual && e.actual.message); } +} +function check(err) { return err.message === 'x'; } +show('named false', () => assert.throws(() => { throw new Error('y'); }, check)); +show('named true', () => assert.throws(() => { throw new Error('x'); }, check)); +show('custom message', () => assert.throws(() => { throw new Error('y'); }, check, 'custom')); +show('returns 1', () => assert.throws(() => { throw new Error('y'); }, () => 1)); +show('non-error thrown', () => assert.throws(() => { throw 'str'; }, (v) => v === 'str')); +show('non-error fails', () => assert.throws(() => { throw 42; }, () => undefined)); +function MyErr(m) { this.message = m; } +show('constructor', () => assert.throws(() => { throw new MyErr('q'); }, MyErr)); +function untyped(expected) { assert.throws(() => { throw new Error('u'); }, expected); } +show('untyped validator', () => untyped((e) => e.message === 'u')); +show('untyped validator fails', () => untyped(function v() { return false; })); +show('untyped object', () => untyped({ message: 'u' })); +assert.rejects(Promise.reject(new Error('r')), (e) => e.message === 'r').then(() => console.log('rejects passed')); +assert.rejects(Promise.reject(new Error('r')), () => false).catch((e) => console.log('rejects', JSON.stringify(e.message), e.operator)); diff --git a/tests/corpus/8076-assert-instance-identity.cjs b/tests/corpus/8076-assert-instance-identity.cjs new file mode 100644 index 000000000..e8cedf3d6 --- /dev/null +++ b/tests/corpus/8076-assert-instance-identity.cjs @@ -0,0 +1,50 @@ +'use strict'; +// assert.strictEqual and notStrictEqual compare class instances by +// reference: two instances, an instance and the same instance read back +// through an untyped value, and an instance a method returns. Symbols +// compare by identity too. +const assert = require('assert'); +const EventEmitter = require('events'); + +class Box { + constructor(v) { + this.v = v; + } + self() { + return this; + } +} + +const b = new Box(1); +const c = new Box(1); +assert.strictEqual(b, b.self()); +assert.notStrictEqual(b, c); +const e = new EventEmitter(); +assert.strictEqual(e, e.setMaxListeners(1)); +const untyped = [e, b][0]; +assert.strictEqual(untyped, e); +assert.strictEqual(e, untyped); +assert.notStrictEqual(untyped, b); + +try { + assert.strictEqual(b, c); +} catch (error) { + console.log(error.code); + console.log(error.message); +} +try { + assert.notStrictEqual(b, b.self(), 'same box'); +} catch (error) { + console.log(error.code, error.message); +} +// A symbol read back through an untyped value is the same symbol. +const symbol = Symbol('s'); +const read = Reflect.get({ k: symbol }, 'k'); +assert.strictEqual(read, symbol); +assert.notStrictEqual(read, Symbol('s')); +try { + assert.strictEqual(read, Symbol('other')); +} catch (error) { + console.log(error.code, error.message.split('\n')[0]); +} +console.log('done'); diff --git a/tests/corpus/8078-instanceof-object-function.cjs b/tests/corpus/8078-instanceof-object-function.cjs new file mode 100644 index 000000000..22f212269 --- /dev/null +++ b/tests/corpus/8078-instanceof-object-function.cjs @@ -0,0 +1,77 @@ +'use strict'; +// `x instanceof Object` and `x instanceof Function` on untyped values follow +// Node: objects are instances of Object unless their prototype chain ends +// in null first, and functions (classes and arrows included) are instances +// of Function. +class Shape {} +class Square extends Shape {} + +const nullProto = Object.create(null); +const underNull = Object.create(nullProto); +const regrown = Object.create(null); +Object.setPrototypeOf(regrown, {}); +const orphan = function orphan() {}; +Object.setPrototypeOf(orphan, null); +function declared() {} + +const values = { + 'plain object': {}, + array: [1, 2], + 'Object.create(null)': nullProto, + 'object under a null-prototype object': underNull, + 'Object.create(null) given a prototype': regrown, + 'Object.create(plain object)': Object.create({ a: 1 }), + 'parsed JSON': JSON.parse('{"a":[1]}'), + 'function declaration': declared, + 'bound function': declared.bind(null), + 'arrow function': () => 1, + 'async function': async () => {}, + class: Square, + 'class instance': new Square(), + 'function with a null prototype': orphan, + map: new Map(), + date: new Date(0), + 'regular expression': /x/, + error: new TypeError('t'), + 'typed array': new Uint8Array(2), + promise: Promise.resolve(1), + number: 1, + string: 's', + boolean: false, + null: null, + undefined: undefined, + symbol: Symbol('s'), + bigint: 10n, +}; + +for (const [label, value] of Object.entries(values)) { + console.log(`${label}: Object ${value instanceof Object}, Function ${value instanceof Function}`); +} + +function describe(value) { + if (!(value instanceof Object)) return 'primitive or null-prototype'; + return value instanceof Function ? 'function' : 'object'; +} +console.log([nullProto, {}, describe, 0].map(describe).join(', ')); + +// Prototype chains through functions and objects, and Object.prototype +// itself, whose own prototype is null. +const base = function base() {}; +const fromFunction = {}; +Object.setPrototypeOf(fromFunction, base); +const throughObject = {}; +Object.setPrototypeOf(throughObject, fromFunction); +const nullRooted = function nullRooted() {}; +Object.setPrototypeOf(nullRooted, null); +const reparented = function reparented() {}; +Object.setPrototypeOf(reparented, fromFunction); +const chains = { + objectPrototype: Object.getPrototypeOf({}), + fromFunction, + throughObject, + nullRooted, + reparented, +}; +for (const [label, value] of Object.entries(chains)) { + console.log(`${label}: Object ${value instanceof Object}, Function ${value instanceof Function}`); +} diff --git a/tests/corpus/8190-assert-function-values.cjs b/tests/corpus/8190-assert-function-values.cjs new file mode 100644 index 000000000..f27bf22dc --- /dev/null +++ b/tests/corpus/8190-assert-function-values.cjs @@ -0,0 +1,45 @@ +'use strict'; +// assert methods are function values: stored, passed and called through +// aliases, they assert as the methods do, and every read is the same +// function. A constant bound to a method and called directly reports +// the call as Node quotes it. +const assert = require('assert'); +const { strictEqual, deepStrictEqual } = require('assert'); +const strict = require('assert/strict'); +const EventEmitter = require('events'); +console.log(typeof assert.fail, assert.fail === assert.fail, strict.equal === assert.strictEqual, strictEqual === assert.strictEqual); +const ok = assert.ok; +ok(1); +const tryIt = (label, fn) => { try { fn(); console.log(label, 'passed'); } catch (e) { console.log(label, e.name, e.code, JSON.stringify(e.message), e.generatedMessage); } }; +tryIt('ok', () => ok(0)); +tryIt('ok none', () => ok()); +tryIt('ok msg', () => ok(false, 'nope')); +tryIt('ok err', () => ok(false, new TypeError('own'))); +tryIt('fail', () => assert.fail.call(null)); +tryIt('fail msg', () => (0, assert.fail)('boom')); +tryIt('equal', () => assert.equal.call(null, 1, '1')); +tryIt('equal fail', () => [assert.equal][0](1, 2)); +tryIt('notEqual fail', () => [assert.notEqual][0](1, '1')); +tryIt('strict', () => strictEqual(1, 2)); +tryIt('deep', () => deepStrictEqual({ a: 1 }, { a: 1 })); +tryIt('deep fail', () => [deepStrictEqual][0]({ a: 1 }, { a: 2 }, 'differs')); +tryIt('ifError', () => [assert.ifError][0](null)); +const checks = [assert.ok, assert.fail, assert.strictEqual]; +checks.forEach((check, i) => console.log(i, typeof check, check === checks[i], check.name)); +[1, 'x', true].forEach((value) => assert.ok(value)); +console.log('done'); + +// The comparison methods need both operands, as Node's ERR_MISSING_ARGS +// TypeError says, also through function values. +const comparisons = [assert.equal, assert.notEqual, assert.strictEqual, assert.notStrictEqual, + assert.deepEqual, assert.notDeepEqual, assert.deepStrictEqual, assert.notDeepStrictEqual]; +for (const compare of comparisons) { + for (const args of [[], [1]]) { + try { + compare(...args); + console.log(compare.name, args.length, 'passed'); + } catch (e) { + console.log(compare.name, args.length, e.name, e.code, e.message); + } + } +} diff --git a/tests/corpus/8191-assert-function-values.ts b/tests/corpus/8191-assert-function-values.ts new file mode 100644 index 000000000..8ef24153b --- /dev/null +++ b/tests/corpus/8191-assert-function-values.ts @@ -0,0 +1,16 @@ +// assert methods read as values in TypeScript: a constant alias called +// directly, a stored value, and a method passed as a callback. +import assert from "node:assert"; +const eq = assert.strictEqual; +eq(1, 1); +const fail = assert.fail; +const checks: unknown[] = [assert.ok, assert.fail]; +console.log(typeof checks[0], checks[1] === fail); +try { + eq(1, 2, "one is not two"); +} catch (error) { + console.log((error as Error).message); +} +const run = (fn: (v: unknown) => void) => fn(true); +run(assert.ok); +console.log("done"); diff --git a/tests/coverage-fixtures/startup-fences.js b/tests/coverage-fixtures/startup-fences.js index 22aad4e0a..5e1d00b62 100644 --- a/tests/coverage-fixtures/startup-fences.js +++ b/tests/coverage-fixtures/startup-fences.js @@ -2,8 +2,8 @@ // a fence inside a function runs when the function is called. "use strict"; var value = JSON.parse("{}"); -console.log(value instanceof Object); +console.log(value instanceof WeakRef); function check(other) { - return other instanceof Object; + return other instanceof WeakRef; } console.log(check([])); diff --git a/tests/diagnostics/assert-fences.ts b/tests/diagnostics/assert-fences.ts index 823c64c40..7cf96bc5b 100644 --- a/tests/diagnostics/assert-fences.ts +++ b/tests/diagnostics/assert-fences.ts @@ -5,8 +5,8 @@ // (typed arrays, class instances), namespace-object calls, unsupported // throws/rejects expectations (properties outside the static error // surface, empty shapes, non-literal objects, doesNotReject shapes — -// Node's own TypeError), ifError over uncomparable types, and assert -// functions as values. Every reached site terminates in a named +// Node's own TypeError), ifError over uncomparable types, and assert.throws +// as a value. Every reached site terminates in a named // diagnostic — never a miscompare, never silence. import assert from "node:assert"; import * as assertNs from "node:assert"; @@ -47,8 +47,8 @@ assert.throws(() => { throw new Error("x"); }, {}); // Node rejects the empty sh void assert.doesNotReject(async () => {}, { name: "TypeError" }); // Node's own TypeError (function or RegExp only) assert.ifError(new Map()); // no honest ifError rendering for this type -const eq = assert.strictEqual; // assert functions as values -eq(1, 1); +const throwsFn = assert.throws; // assert.throws as a value: it takes the function to run +throwsFn(() => {}); interface Tree { label: string; kids: Tree[] } const tree: Tree = { label: "t", kids: [] }; diff --git a/tests/harness/__snapshots__/assert-fences.ts.txt b/tests/harness/__snapshots__/assert-fences.ts.txt index 2ab78f117..041c20e9b 100644 --- a/tests/harness/__snapshots__/assert-fences.ts.txt +++ b/tests/harness/__snapshots__/assert-fences.ts.txt @@ -104,12 +104,12 @@ assert-fences.ts:48:16 - error SC2020: 'assert.ifError over 'Map hint: numbers, strings, booleans, Errors, null/undefined, and unions of those are the lowered forms -assert-fences.ts:50:12 - error SC2020: 'assert.strictEqual' is part of the standard library types but has no scriptc lowering yet +assert-fences.ts:50:18 - error SC2020: 'assert.throws' is part of the standard library types but has no scriptc lowering yet 49 | - 50 | const eq = assert.strictEqual; // assert functions as values - | ^~~~~~~~~~~~~~~~~~ - 51 | eq(1, 1); + 50 | const throwsFn = assert.throws; // assert.throws as a value: it takes the function to run + | ^~~~~~~~~~~~~ + 51 | throwsFn(() => {}); hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index e1831c609..1046ca6af 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -221,10 +221,10 @@ test("deferred fences in a module's top-level code report as running at startup" ]); const out = report(fixture("startup-fences.js")); expect(out).toMatch( - /runs at startup {3}1 site .*\n.*instanceof against this native constructor\n\s+at tests\/coverage-fixtures\/startup-fences\.js:5:13/, + /runs at startup {3}1 site .*\n.*'instanceof' right-hand sides other than classes declared in the program\n\s+at tests\/coverage-fixtures\/startup-fences\.js:5:30/, ); expect(out).toMatch( - /deferred to runtime {3}1 site .*\n.*instanceof against this native constructor\n\s+at tests\/coverage-fixtures\/startup-fences\.js:7:10/, + /deferred to runtime {3}1 site .*\n.*'instanceof' right-hand sides other than classes declared in the program\n\s+at tests\/coverage-fixtures\/startup-fences\.js:7:27/, ); const envelope = coverageEnvelope(coverage, { compilerVersion: "test", sourceTexts }); expect(envelope.diagnostics.map((d) => [d.scope, d.line, d.startup])).toEqual([