diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx index d919ee640..a58d41416 100644 --- a/docs/src/app/limitations/page.mdx +++ b/docs/src/app/limitations/page.mdx @@ -8,7 +8,7 @@ These are rejected at compile time with an `SC` code, a code frame, and usually **Language edges** -- Loose `==`/`!=` comparisons compile between two numbers, two strings, or two booleans, and for the `x == null`/`x != null` nullish idiom. Other coercing comparisons are rejected — use `===`/`!==`. +- Loose `==`/`!=` comparisons compile across statically represented primitive and primitive-union operands, including number/string/boolean/BigInt coercions and the `x == null`/`x != null` nullish idiom. Object-to-primitive comparisons remain fenced because custom `valueOf`/`toString` methods can execute arbitrary code; convert the object explicitly first. - Block- and function-scoped `using` and `await using` compile for statically represented classes with zero-parameter disposal methods and for the supported FileHandle, timer, immediate, child-process, and readline handles. Array `for (using ... of ...)` loops compile. Top-level and switch-clause declarations, disposal through an engine-held package value, and the `DisposableStack` constructors remain fenced; a caught `SuppressedError` exposes its Node-compatible `name` and `message`, while its `error` and `suppressed` payload properties remain outside the static catch surface. - Typed async generator functions and methods compile with lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also consumes Node `Readable` streams, Web `ReadableStream` values, and statically represented class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record; abrupt completion runs the iterator's cleanup, including literal `destroyOnReturn` and `preventCancel` options. Async `yield*` delegation, stored Web/Node iterator handles, and other structural async-iterator objects remain fenced. The `node:timers/promises` `setInterval(delay, value)` async iterator compiles, while its AbortSignal options remain fenced. - Generics monomorphize when the target resolves statically. The remaining edges include generic functions declared inside another function, generic class expressions, generic classes whose base depends on their own type parameters, and generic methods that would require dynamic dispatch. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index c187e713a..cc6cbbace 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", - "artifactVersion": "743056178b8be4cb1d5c", + "artifactVersion": "700e6429460b8481a546", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 311d8f76e..43b0d9786 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -74,8 +74,8 @@ "apiEntries": 29, "static": { "supported": 0, - "partial": 15, - "refused": 6, + "partial": 17, + "refused": 4, "not-implemented": 8, "by-design": 0, "unreviewed": 0, @@ -1827,9 +1827,9 @@ "inherited": true }, "static": { - "status": "refused", - "detail": "Explicitly rejected by a named compiler diagnostic.", - "verification": "explicit-refusal" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" }, "dynamic": { "status": "partial", @@ -2023,9 +2023,9 @@ "inherited": true }, "static": { - "status": "refused", - "detail": "Explicitly rejected by a named compiler diagnostic.", - "verification": "explicit-refusal" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" }, "dynamic": { "status": "partial", diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index bd129488b..b36bdc54a 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,9 +7,9 @@ "tierItemCount": 6663, "tiers": { "static": { - "replace-refusal": 48, + "replace-refusal": 46, "verify-gap": 2714, - "audit-partial": 450, + "audit-partial": 452, "classify": 2, "implement": 81 }, @@ -318,7 +318,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -391,7 +392,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -431,7 +433,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -471,7 +474,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -537,12 +541,18 @@ "priority": "high", "tiers": { "static": { - "status": "refused", - "action": "replace-refusal", - "verification": "explicit-refusal", + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", "confidence": "high", - "source": "surface-manifest:unsupported:node-builtin.assert.equal", - "tests": [] + "source": "compiler-dedicated:assert.equal", + "tests": [ + "tests/corpus/1600-assert-passing.ts", + "tests/corpus/1604-assert-deep-structures.ts", + "tests/corpus/1609-assert-async.ts", + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" + ] }, "dynamic": { "status": "partial", @@ -581,7 +591,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -621,7 +632,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -661,7 +673,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -701,7 +714,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -776,7 +790,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -807,12 +822,18 @@ "priority": "high", "tiers": { "static": { - "status": "refused", - "action": "replace-refusal", - "verification": "explicit-refusal", + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", "confidence": "high", - "source": "surface-manifest:unsupported:node-builtin.assert.notEqual", - "tests": [] + "source": "compiler-dedicated:assert.notEqual", + "tests": [ + "tests/corpus/1600-assert-passing.ts", + "tests/corpus/1604-assert-deep-structures.ts", + "tests/corpus/1609-assert-async.ts", + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" + ] }, "dynamic": { "status": "partial", @@ -851,7 +872,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -891,7 +913,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -931,7 +954,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -971,7 +995,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -1011,7 +1036,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -82287,7 +82313,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index ff1ce0097..846dc9f75 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -27,8 +27,8 @@ "apiEntries": 29, "static": { "supported": 0, - "partial": 15, - "refused": 6, + "partial": 17, + "refused": 4, "not-implemented": 8, "by-design": 0, "unreviewed": 0, @@ -1534,7 +1534,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -1843,7 +1844,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -1933,7 +1935,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -1996,7 +1999,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -2032,7 +2036,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -2092,8 +2097,15 @@ "inherited": true }, "static": { - "status": "refused", - "evidence": "surface-manifest:unsupported:node-builtin.assert.equal" + "status": "partial", + "evidence": "compiler-dedicated:assert.equal", + "tests": [ + "tests/corpus/1600-assert-passing.ts", + "tests/corpus/1604-assert-deep-structures.ts", + "tests/corpus/1609-assert-async.ts", + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" + ] }, "dynamic": { "status": "partial", @@ -2128,7 +2140,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -2164,7 +2177,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -2200,7 +2214,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -2236,7 +2251,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -2302,7 +2318,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -2332,8 +2349,15 @@ "inherited": true }, "static": { - "status": "refused", - "evidence": "surface-manifest:unsupported:node-builtin.assert.notEqual" + "status": "partial", + "evidence": "compiler-dedicated:assert.notEqual", + "tests": [ + "tests/corpus/1600-assert-passing.ts", + "tests/corpus/1604-assert-deep-structures.ts", + "tests/corpus/1609-assert-async.ts", + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" + ] }, "dynamic": { "status": "partial", @@ -2368,7 +2392,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -2404,7 +2429,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -2440,7 +2466,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -2476,7 +2503,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -2512,7 +2540,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -30275,7 +30304,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { @@ -99219,6 +99249,7 @@ "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts", "tests/harness/node-test.test.ts" ] }, @@ -99876,7 +99907,8 @@ "tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", - "tests/corpus/1721-assert-throws-regex-class.ts" + "tests/corpus/1721-assert-throws-regex-class.ts", + "tests/corpus/2936-static-loose-equality.ts" ] }, "dynamic": { diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 6f7963ea3..1421de056 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -107,7 +107,7 @@ ], "dedicated": [ { "symbols": ["console.log", "console.info", "console.debug", "console.error", "console.warn"], "status": "partial", "evidence": ["tests/corpus/1460-console-error-warn.ts", "tests/corpus/2440-console-inspect-args.ts"] }, - { "symbols": ["assert", "assert.ok", "assert.strictEqual", "assert.notStrictEqual", "assert.deepStrictEqual", "assert.notDeepStrictEqual", "assert.fail", "assert.match", "assert.doesNotMatch", "assert.throws", "assert.rejects", "assert.doesNotReject", "assert.ifError"], "status": "partial", "evidence": ["tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", "tests/corpus/1721-assert-throws-regex-class.ts"] }, + { "symbols": ["assert", "assert.ok", "assert.equal", "assert.notEqual", "assert.strictEqual", "assert.notStrictEqual", "assert.deepStrictEqual", "assert.notDeepStrictEqual", "assert.fail", "assert.match", "assert.doesNotMatch", "assert.throws", "assert.rejects", "assert.doesNotReject", "assert.ifError"], "status": "partial", "evidence": ["tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", "tests/corpus/1721-assert-throws-regex-class.ts", "tests/corpus/2936-static-loose-equality.ts"] }, { "symbols": ["crypto.createHash", "Hash", "hash.update", "hash.digest", "hash.copy", "crypto.createHmac", "Hmac", "hmac.update", "hmac.digest", "crypto.hash", "crypto.timingSafeEqual", "crypto.randomFillSync", "crypto.randomInt", "crypto.pbkdf2", "crypto.pbkdf2Sync"], "status": "partial", "evidence": ["tests/corpus/2890-crypto-static-utility.ts", "tests/harness/npm-static.test.ts"] }, { "symbols": ["EventEmitter", "emitter.on", "emitter.addListener", "emitter.once", "emitter.prependListener", "emitter.prependOnceListener", "emitter.off", "emitter.removeListener", "emitter.removeAllListeners", "emitter.emit", "emitter.listenerCount", "emitter.listeners", "emitter.rawListeners", "emitter.eventNames", "emitter.setMaxListeners", "emitter.getMaxListeners", "events.getEventListeners", "events.listenerCount", "events.once", "events.on", "events.setMaxListeners"], "status": "partial", "evidence": ["tests/corpus/1677-emitter-listeners.ts", "tests/corpus/2620-ee-override-once-order.ts"] }, { "symbols": ["AsyncLocalStorage", "asyncLocalStorage.run", "asyncLocalStorage.exit", "asyncLocalStorage.getStore", "asyncLocalStorage.enterWith", "asyncLocalStorage.disable"], "status": "partial", "evidence": ["tests/corpus/2213-async-local-storage.cjs"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 20149ee6e..e345c320a 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1799,9 +1799,10 @@ declare module "node:util/types" { * Object.is over scalars; deepStrictEqual compares composites * structurally per their static types. The messages here are plain * strings (Node accepts Errors — that form fences per site), and the - * loose-equality quartet (equal/notEqual/deepEqual/notDeepEqual) is - * declared so real code typechecks but fences at its use sites (== has - * no lowering; assert/strict's equal IS strictEqual and lowers). */ + * shallow legacy pair (equal/notEqual) follows Node's == coercions for + * primitive and primitive-union operands, including Node v24's NaN + * exception. deepEqual/notDeepEqual remain declared-but-fenced; + * assert/strict's loose names alias the strict forms and lower. */ /* node:test — the in-process test runner (prefix-only in Node too: * require("test") is MODULE_NOT_FOUND). test/it register tests (sync and * async bodies, skip/todo/only options and method twins), describe/suite diff --git a/packages/compiler/src/backend/c/exprs.ts b/packages/compiler/src/backend/c/exprs.ts index c24f45213..f3b0dbe30 100644 --- a/packages/compiler/src/backend/c/exprs.ts +++ b/packages/compiler/src/backend/c/exprs.ts @@ -5188,6 +5188,8 @@ function emitPrimitiveLibCall(state: LibCallState): Temp { return finish(`scr_bigint_shr(${arg(0)}, ${arg(1)})`); case "bigint.eq": return finish(`scr_bigint_eq(${arg(0)}, ${arg(1)})`); + case "bigint.eqString": + return finish(`scr_bigint_eq_string(${arg(0)}, ${arg(1)})`); case "bigint.cmp": return finish(`scr_bigint_cmp_f64(${arg(0)}, ${arg(1)})`); case "bigint.cmpNumber": @@ -8361,6 +8363,8 @@ function emitAssertInspectLibCall(state: LibCallState): Temp { // The quartet over checked-dynamic operands: SameValue / // dyn-walk deep equality, assertion_error.js messages. return finish(`scr_assert_eq_dyn(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)}, ${arg(4)}, ${arg(5)})`); + case "assert.looseResult": + return finish(`scr_assert_loose_result(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)}, ${arg(4)}, ${arg(5)})`); case "assert.deepResult": return finish(`scr_assert_deep_result(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)})`); case "assert.deqEnter": diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 95365b054..a1a232b64 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -188,6 +188,7 @@ 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.looseResult": "scr_assert_loose_result", "assert.deepResult": "scr_assert_deep_result", "assert.sameValue": "scr_assert_same_value_f64", "assert.deqEnter": "scr_assert_deq_enter", @@ -423,6 +424,7 @@ export const LIB_FN_SYMS: Record = { "bigint.shl": "scr_bigint_shl", "bigint.shr": "scr_bigint_shr", "bigint.eq": "scr_bigint_eq", + "bigint.eqString": "scr_bigint_eq_string", "bigint.cmp": "scr_bigint_cmp_f64", "bigint.cmpNumber": "scr_bigint_cmp_number", "bigint.truthy": "scr_bigint_truthy", diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index ad9289ea6..ee0982434 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -236,7 +236,7 @@ export const UNSUPPORTED: Record = { SC1040: { feature: "loose equality (== and !=)", milestone: "M4", - hint: "use === / !== ('x == null' / 'x != null' — the null-or-undefined test — is supported; other loose comparisons need dynamic coercion semantics)", + hint: "primitive and primitive-union coercions plus the x == null / x != null idiom compile statically; object-to-primitive comparisons can execute valueOf/toString — convert the object explicitly first", }, // SC1041 (bitwise operators, ToInt32 semantics) shipped — code retired, // do not reuse. diff --git a/packages/compiler/src/frontend/lowering/abstract-equality.ts b/packages/compiler/src/frontend/lowering/abstract-equality.ts new file mode 100644 index 000000000..b8e530005 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/abstract-equality.ts @@ -0,0 +1,281 @@ +import { InternalCompilerError } from "../../errors.js"; +import { BOOL, DYN_HANDLE_KINDS, F64, IrExpr, IrStmt, IrType, SrcLoc, isUnitType, typeEquals } from "../../ir/ir.js"; +import { boolLit, numLit, varRef } from "../../ir/build.js"; +import type { Lowerer } from "./lowerer.js"; + +/** + * The engine-free part of ECMAScript Abstract Equality Comparison. + * + * Every primitive pair is exact, including StringToNumber, + * StringToBigInt, Boolean-to-number recursion, and BigInt/Number's + * mathematical comparison. Object-to-primitive coercion stays outside + * this helper: user-defined valueOf/toString methods can execute arbitrary + * JavaScript, so a static build must keep that form fenced. Object pairs + * with one exact representation compare by identity, as == and === agree + * there. + */ + +const IDENTITY_KINDS: ReadonlySet = new Set([ + "array", + "bytes", + "map", + "set", + "object", + "record", + "promise", + "symbol", + "classval", + "moduleNs", +]); + +const PRIMITIVE_KINDS: ReadonlySet = new Set([ + "f64", + "string", + "bool", + "bigint", + "symbol", +]); + +function supportsPair(lowerer: Lowerer, left: IrType, right: IrType): boolean { + if (left.kind === "union") { + const def = lowerer.unions.get(left.unionId); + return def !== undefined && def.arms.every((arm) => supportsPair(lowerer, arm, right)); + } + if (right.kind === "union") { + const def = lowerer.unions.get(right.unionId); + return def !== undefined && def.arms.every((arm) => supportsPair(lowerer, left, arm)); + } + if (isUnitType(left) || isUnitType(right)) { + const other = isUnitType(left) ? right : left; + return other.kind !== "dyn" && other.kind !== "jsval" && other.kind !== "caught" && other.kind !== "void"; + } + + if (PRIMITIVE_KINDS.has(left.kind) && PRIMITIVE_KINDS.has(right.kind)) return true; + + if (left.kind === "func" && right.kind === "func") return true; + if (IDENTITY_KINDS.has(left.kind) && typeEquals(left, right)) return true; + if (DYN_HANDLE_KINDS.has(left.kind) && typeEquals(left, right)) return true; + + if (left.kind === "object" && right.kind === "object") { + return lowerer.isSubclassOf(left.className, right.className) || lowerer.isSubclassOf(right.className, left.className); + } + return false; +} + +function boolAsNumber(value: IrExpr, loc: SrcLoc): IrExpr { + return { + kind: "ternary", + cond: value, + then: numLit(1, loc), + else_: numLit(0, loc), + type: F64, + loc, + }; +} + +function numericEq(left: IrExpr, right: IrExpr, nanEqualsNan: boolean, loc: SrcLoc): IrExpr { + const ordinary: IrExpr = { kind: "bin", op: "===", left, right, type: BOOL, loc }; + if (!nanEqualsNan) return ordinary; + return { + kind: "logical", + op: "||", + left: ordinary, + right: { + kind: "logical", + op: "&&", + left: { kind: "libCall", fn: "num.isNaN", args: [left], type: BOOL, loc }, + right: { kind: "libCall", fn: "num.isNaN", args: [right], type: BOOL, loc }, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; +} + +function primitivePair( + lowerer: Lowerer, + left: IrExpr, + right: IrExpr, + nanEqualsNan: boolean, + loc: SrcLoc, +): IrExpr { + if (isUnitType(left.type) || isUnitType(right.type)) { + return boolLit(isUnitType(left.type) && isUnitType(right.type), loc); + } + + if (left.type.kind === "object" && right.type.kind === "object") { + if (typeEquals(left.type, right.type)) { + return { kind: "bin", op: "===", left, right, type: BOOL, loc }; + } + if (lowerer.isSubclassOf(left.type.className, right.type.className)) { + return { kind: "bin", op: "===", left: lowerer.upcastTo(left, right.type.className), right, type: BOOL, loc }; + } + if (lowerer.isSubclassOf(right.type.className, left.type.className)) { + return { kind: "bin", op: "===", left, right: lowerer.upcastTo(right, left.type.className), type: BOOL, loc }; + } + } + + if (left.type.kind === right.type.kind) { + switch (left.type.kind) { + case "f64": + return numericEq(left, right, nanEqualsNan, loc); + case "string": + return { kind: "strEq", negated: false, left, right, type: BOOL, loc }; + case "bool": + return { kind: "bin", op: "===", left, right, type: BOOL, loc }; + case "bigint": + return { kind: "libCall", fn: "bigint.eq", args: [left, right], type: BOOL, loc }; + case "symbol": + case "array": + case "bytes": + case "map": + case "set": + case "record": + case "promise": + case "classval": + case "moduleNs": + return { kind: "bin", op: "===", left, right, type: BOOL, loc }; + default: + break; + } + } + + if (left.type.kind === "func" && right.type.kind === "func") { + return { kind: "bin", op: "===", left, right, type: BOOL, loc }; + } + if (DYN_HANDLE_KINDS.has(left.type.kind) && typeEquals(left.type, right.type)) { + return { kind: "bin", op: "===", left, right, type: BOOL, loc }; + } + // Boolean first recurses as ToNumber(boolean), per ECMA-262 7.2.13. + if (left.type.kind === "bool") return primitivePair(lowerer, boolAsNumber(left, loc), right, nanEqualsNan, loc); + if (right.type.kind === "bool") return primitivePair(lowerer, left, boolAsNumber(right, loc), nanEqualsNan, loc); + + // Symbols never equal a different primitive type. Object/symbol would + // require ToPrimitive and was rejected by supportsPair. + if (left.type.kind === "symbol" || right.type.kind === "symbol") return boolLit(false, loc); + + if (left.type.kind === "f64" && right.type.kind === "string") { + const parsed: IrExpr = { kind: "libCall", fn: "num.fromString", args: [right], type: F64, loc }; + return numericEq(left, parsed, false, loc); + } + if (left.type.kind === "string" && right.type.kind === "f64") { + const parsed: IrExpr = { kind: "libCall", fn: "num.fromString", args: [left], type: F64, loc }; + return numericEq(parsed, right, false, loc); + } + if (left.type.kind === "bigint" && right.type.kind === "string") { + return { kind: "libCall", fn: "bigint.eqString", args: [left, right], type: BOOL, loc }; + } + if (left.type.kind === "string" && right.type.kind === "bigint") { + return { kind: "libCall", fn: "bigint.eqString", args: [right, left], type: BOOL, loc }; + } + if (left.type.kind === "bigint" && right.type.kind === "f64") { + const cmp: IrExpr = { kind: "libCall", fn: "bigint.cmpNumber", args: [left, right], type: F64, loc }; + return { kind: "bin", op: "===", left: cmp, right: numLit(0, loc), type: BOOL, loc }; + } + if (left.type.kind === "f64" && right.type.kind === "bigint") { + const cmp: IrExpr = { kind: "libCall", fn: "bigint.cmpNumber", args: [right, left], type: F64, loc }; + return { kind: "bin", op: "===", left: cmp, right: numLit(0, loc), type: BOOL, loc }; + } + + throw new InternalCompilerError( + `abstract equality reached an unplanned pair ${left.type.kind}/${right.type.kind}`, + ); +} + +function pairExpr( + lowerer: Lowerer, + left: IrExpr, + right: IrExpr, + nanEqualsNan: boolean, + loc: SrcLoc, +): IrExpr { + if (left.type.kind === "union") { + const unionId = left.type.unionId; + const def = lowerer.unions.get(unionId); + if (!def || def.arms.length === 0) throw new InternalCompilerError(`abstract equality over unknown union ${unionId}`); + const branch = (tag: number): IrExpr => { + const arm = def.arms[tag]; + if (!arm) throw new InternalCompilerError(`abstract equality union ${unionId} lacks tag ${tag}`); + return pairExpr(lowerer, { kind: "unionNarrow", unionId, tag, value: left, type: arm, loc }, right, nanEqualsNan, loc); + }; + let out = branch(def.arms.length - 1); + for (let tag = def.arms.length - 2; tag >= 0; tag--) { + out = { + kind: "ternary", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value: left, type: BOOL, loc }, + then: branch(tag), + else_: out, + type: BOOL, + loc, + }; + } + return out; + } + if (right.type.kind === "union") { + const unionId = right.type.unionId; + const def = lowerer.unions.get(unionId); + if (!def || def.arms.length === 0) throw new InternalCompilerError(`abstract equality over unknown union ${unionId}`); + const branch = (tag: number): IrExpr => { + const arm = def.arms[tag]; + if (!arm) throw new InternalCompilerError(`abstract equality union ${unionId} lacks tag ${tag}`); + return pairExpr(lowerer, left, { kind: "unionNarrow", unionId, tag, value: right, type: arm, loc }, nanEqualsNan, loc); + }; + let out = branch(def.arms.length - 1); + for (let tag = def.arms.length - 2; tag >= 0; tag--) { + out = { + kind: "ternary", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value: right, type: BOOL, loc }, + then: branch(tag), + else_: out, + type: BOOL, + loc, + }; + } + return out; + } + return primitivePair(lowerer, left, right, nanEqualsNan, loc); +} + +export function abstractEqualitySupported(lowerer: Lowerer, left: IrType, right: IrType): boolean { + return supportsPair(lowerer, left, right); +} + +export function abstractEqualityExpr( + lowerer: Lowerer, + left: IrExpr, + right: IrExpr, + loc: SrcLoc, + nanEqualsNan = false, +): IrExpr { + if (!supportsPair(lowerer, left.type, right.type)) { + throw new InternalCompilerError(`abstract equality expression built for unsupported ${left.type.kind}/${right.type.kind} pair`); + } + return pairExpr(lowerer, left, right, nanEqualsNan, loc); +} + +/** Evaluate both operands once, left-to-right, then compare their stable values. */ +export function lowerAbstractEquality( + lowerer: Lowerer, + left: IrExpr, + right: IrExpr, + negated: boolean, + loc: SrcLoc, +): IrExpr | null { + if (!supportsPair(lowerer, left.type, right.type)) return null; + const stmts: IrStmt[] = []; + const stable = (value: IrExpr, name: string): IrExpr => { + if (isUnitType(value.type)) { + if (value.kind !== "unitLit") stmts.push({ kind: "exprStmt", expr: value, loc }); + return { kind: "unitLit", unit: value.type.kind === "nullT" ? "null" : "undefined", type: value.type, loc }; + } + const local = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: local.id, init: value, loc }); + return varRef(local.id, value.type, loc); + }; + const a = stable(left, "%looseA"); + const b = stable(right, "%looseB"); + let result = pairExpr(lowerer, a, b, false, loc); + if (negated) result = { kind: "unary", op: "!", operand: result, type: BOOL, loc }; + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: BOOL, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index da92316f1..30543246f 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -1,7 +1,8 @@ import { InternalCompilerError } from "../../errors.js"; /* The node:assert lowering (a spoke module like lower-dgram.ts): the * callable module form (`assert(x)` through a default import or a CJS - * require binding), ok, the strict/deep equality quartet, fail, throws, + * require binding), ok, legacy equal/notEqual, the strict/deep equality + * quartet, fail, throws, * and match/doesNotMatch — for BOTH the "assert" and "assert/strict" * modules ("assert/strict" additionally binds the loose NAMES to the * strict comparisons: equal IS strictEqual, exactly Node's aliasing). @@ -27,7 +28,8 @@ import { resultIsDiscarded } from "./call-position.js"; import type { Lowerer } from "./lowerer.js"; import { jsFuncNameOf, own } from "./lowerer.js"; import { NARROW_FIRST } from "./surfaces.js"; -import { typeReachesItself } from "./lower-inspect.js"; +import { inspectExpr, typeReachesItself } from "./lower-inspect.js"; +import { abstractEqualityExpr, abstractEqualitySupported } from "./abstract-equality.js"; import { BOOL, CAUGHT, DYN, DYN_HANDLE_KINDS, F64, IrExpr, IrLibFn, IrStmt, IrType, REGEX, RUNTIME_ERROR_CLASSES, STRING, SrcLoc, VOID, isUnitType, typeEquals, typeKey } from "../../ir/ir.js"; import { boolLit, countedFor, numLit, strLit, varRef } from "../../ir/build.js"; @@ -96,6 +98,10 @@ export function lowerAssertModuleCall( switch (canonicalAssertMember(bi.module, bi.member)) { case "ok": return lowerAssertOk(lowerer, expr, loc); + case "equal": + return lowerAssertLooseEqual(lowerer, expr, loc, false); + case "notEqual": + return lowerAssertLooseEqual(lowerer, expr, loc, true); case "strictEqual": return lowerAssertEqual(lowerer, expr, loc, false, false); case "notStrictEqual": @@ -123,6 +129,82 @@ export function lowerAssertModuleCall( } } +function looseAssertTypeSupported(lowerer: Lowerer, type: IrType): boolean { + if ( + type.kind === "f64" || type.kind === "bigint" || type.kind === "string" || + type.kind === "bool" || type.kind === "symbol" || isUnitType(type) + ) { + return true; + } + if (type.kind !== "union") return false; + const def = lowerer.unions.get(type.unionId); + return def !== undefined && def.arms.every((arm) => looseAssertTypeSupported(lowerer, arm)); +} + +/** assert.equal / assert.notEqual over statically represented primitives. + * The shared Abstract Equality kernel owns coercion; this wrapper adds + * Node v24's legacy NaN-equals-NaN exception and the exact one-line + * generated message. Both values stabilize first so comparison and + * inspection never re-evaluate user expressions. */ +function lowerAssertLooseEqual( + lowerer: Lowerer, + expr: ts.CallExpression, + loc: SrcLoc, + negated: boolean, +): IrExpr { + const surface = negated ? "assert.notEqual" : "assert.equal"; + requireStatementPosition(lowerer, expr, surface); + if (expr.arguments.length < 2 || expr.arguments.length > 3) { + lowerer.noLowering(`${surface} with ${expr.arguments.length} arguments`, expr); + } + const aNode = expr.arguments[0]; + const bNode = expr.arguments[1]; + if (!aNode || !bNode) throw new InternalCompilerError(`${surface} arity fence returned unexpectedly`); + const left = lowerer.lowerExpr(aNode); + const right = lowerer.lowerExpr(bNode); + if ( + !looseAssertTypeSupported(lowerer, left.type) || + !looseAssertTypeSupported(lowerer, right.type) || + !abstractEqualitySupported(lowerer, left.type, right.type) + ) { + lowerer.noLowering( + `${surface} of '${lowerer.fmt(left.type)}' and '${lowerer.fmt(right.type)}' values`, + expr, + "legacy equality lowers for primitive and primitive-union operands; object-to-primitive coercion can execute user-defined valueOf/toString methods", + ); + } + + const stmts: IrStmt[] = []; + const stable = (value: IrExpr, name: string): IrExpr => { + if (isUnitType(value.type)) { + if (value.kind !== "unitLit") stmts.push({ kind: "exprStmt", expr: value, loc }); + return { + kind: "unitLit", + unit: value.type.kind === "nullT" ? "null" : "undefined", + type: value.type, + loc, + }; + } + const local = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: local.id, init: value, loc }); + return varRef(local.id, value.type, loc); + }; + const a = stable(left, "%assertLooseA"); + const b = stable(right, "%assertLooseB"); + const equal = abstractEqualityExpr(lowerer, a, b, loc, true); + const actual = inspectExpr(lowerer, a.type, a, numLit(0, loc), numLit(2, loc), loc); + const expected = inspectExpr(lowerer, b.type, b, numLit(0, loc), numLit(2, loc), loc); + const { msg, hasMsg } = lowerMessageArg(lowerer, expr.arguments[2], loc); + const result: IrExpr = { + kind: "libCall", + fn: "assert.looseResult", + args: [equal, boolLit(negated, loc), actual, expected, msg, hasMsg], + type: VOID, + loc, + }; + return { kind: "seqExpr", stmts, result, type: VOID, loc }; +} + /** The callable-module form: `assert(x)` where the callee identifier IS * the assert module binding — a default import (`import assert from * "node:assert"`) or the CJS `const assert = require("assert")`. Node's diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 4de0dc1d2..3b14bd405 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -35,6 +35,7 @@ import { hasOptionalChainGuard, isOptionalChainTail, isRequireMainFilename } fro import { conditionalSpreadOf, foldedStringKeyOf } from "./expressions/object-literals.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { fenceNodeModuleMutation, isNodeModuleValue, lowerNodeModuleIdentifier, lowerNodeModuleProperty, lowerRequireCacheElement, lowerRequireCacheHas, lowerRequireMainProperty } from "./lower-node-module.js"; +import { lowerAbstractEquality } from "./abstract-equality.js"; /** An assignable `obj.field` target — a class field, a record field, or a * class ACCESSOR property (reads become getter calls, writes setter calls; @@ -5966,25 +5967,20 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr if (op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsToken) { const nullTest = lowerLooseNullCompare(lowerer, expr, loc); if (nullTest) return nullTest; - // SAME-KIND loose equality IS strict equality (the spec's == - // dispatches to === when both operands share a type): `typeof v == - // 'object'`, `n != 0`, `flag == true` all lower exactly. Mixed - // kinds (where == coerces) keep the fence. - { - const negated = op === ts.SyntaxKind.ExclamationEqualsToken; - const left = lowerer.lowerExpr(expr.left); - const right = lowerer.lowerExpr(expr.right); - if (left.type.kind === "string" && right.type.kind === "string") { - return { kind: "strEq", negated, left, right, type: BOOL, loc }; - } - if ( - (left.type.kind === "f64" && right.type.kind === "f64") || - (left.type.kind === "bool" && right.type.kind === "bool") - ) { - return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; - } - } - lowerer.unsupported("SC1040", expr); + const loose = lowerAbstractEquality( + lowerer, + lowerer.lowerExpr(expr.left), + lowerer.lowerExpr(expr.right), + op === ts.SyntaxKind.ExclamationEqualsToken, + loc, + ); + if (loose) return loose; + lowerer.unsupported( + "SC1040", + expr, + undefined, + "object-to-primitive loose equality can call user-defined valueOf/toString methods — compare explicit primitive conversions instead", + ); } if (op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken) { if (op === ts.SyntaxKind.BarBarToken) { diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index ba7424970..9fbac0b62 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -246,7 +246,7 @@ function isNumberFlag(lowerer: Lowerer, t: IrType, v: () => IrExpr, loc: SrcLoc) /** The rendering of one value of type `t` at runtime depth `recurse` * with the depth budget `depth` — a direct scalar libCall or a call of * the interned per-type helper. */ -function inspectExpr( +export function inspectExpr( lowerer: Lowerer, t: IrType, value: IrExpr, diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index a85602cad..396d47dd9 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1301,18 +1301,15 @@ export function builtinModulesArrayLit(loc: { file: string; start: number; end: const ZLIB_HINT = "the default-options deflate/inflate, raw, gzip/gunzip, and unzip sync/callback forms plus crc32 are the lowered zlib surface"; -/** The loose-equality quartet's shared hint: == coercion has no lowering - * anywhere in this compiler, and Node itself points at the strict forms. */ -const ASSERT_LOOSE_HINT = - "loose == equality has no lowering — the strict forms compare with " + - "Object.is/structural equality like Node's assert/strict module, where " + - "equal IS strictEqual"; +/** The legacy deep pair remains fenced. The shallow equal/notEqual pair + * lowers through the static Abstract Equality kernel in lower-assert. */ +const ASSERT_LOOSE_DEEP_HINT = + "legacy deep equality has no lowering yet — deepStrictEqual and " + + "notDeepStrictEqual compare structures with Node's modern semantics"; const ASSERT_MODULE_HINTS: Record = { - equal: ASSERT_LOOSE_HINT, - notEqual: ASSERT_LOOSE_HINT, - deepEqual: ASSERT_LOOSE_HINT, - notDeepEqual: ASSERT_LOOSE_HINT, + deepEqual: ASSERT_LOOSE_DEEP_HINT, + notDeepEqual: ASSERT_LOOSE_DEEP_HINT, ifError: "test explicitly instead: assert.strictEqual(err, null) / " + "assert.strictEqual(err, undefined)", diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index b3a99d76f..2bdb7a238 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2369,6 +2369,7 @@ export type IrLibFn = | "bigint.shl" | "bigint.shr" | "bigint.eq" + | "bigint.eqString" | "bigint.cmp" | "bigint.cmpNumber" | "bigint.truthy" @@ -3957,6 +3958,7 @@ export type IrLibFn = | "assert.eqBool" | "assert.eqSym" | "assert.eqDyn" + | "assert.looseResult" | "assert.deepResult" | "assert.sameValue" /* deepStrictEqual's pair memo over cycle-capable types: enter answers @@ -7601,6 +7603,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "assert.eqBool", "assert.eqSym", "assert.eqDyn", + "assert.looseResult", "assert.deepResult", "assert.match", "assert.refEqBytes", diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index f5f69d45c..7f8a47cd9 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -275,6 +275,7 @@ export const LIB_FN_SIGS: Record/tests/corpus/2936-static-loose-equality.ts": { + "order": [ + "/tests/corpus/2936-static-loose-equality.ts" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" diff --git a/packages/runtime/src/scr_assert.c b/packages/runtime/src/scr_assert.c index 133052a57..d51c12c2e 100644 --- a/packages/runtime/src/scr_assert.c +++ b/packages/runtime/src/scr_assert.c @@ -317,6 +317,25 @@ void scr_assert_eq_bool(bool a, bool b, bool negated, bool deep, has_msg); } +/* assert.equal / assert.notEqual: the frontend supplies the completed + * Abstract Equality verdict (including Node v24's NaN-equals-NaN legacy + * exception) and util.inspect renderings of the original operands. Node's + * generated legacy message is one line: "actual == expected" / "actual != + * expected". A supplied message, including the empty string, replaces it. */ +void scr_assert_loose_result(bool equal, bool negated, ScrStr *actual, + ScrStr *expected, ScrStr *msg, bool has_msg) { + if ((negated && !equal) || (!negated && equal)) return; + if (has_msg) { + scr_assert_fail_msg(scr_str_retain(msg)); + return; + } + ScrAssertBuf b = {0}; + ab_str(&b, actual); + ab_cstr(&b, negated ? " != " : " == "); + ab_str(&b, expected); + scr_assert_fail_msg(ab_take(&b)); +} + /* deepStrictEqual / notDeepStrictEqual over COMPOSITE values: the frontend * synthesized the honest structural comparison; this only turns its verdict * into Node's throw. The generated message is the header line alone — diff --git a/packages/runtime/src/scr_bigint.c b/packages/runtime/src/scr_bigint.c index 50f4dd9b0..7e499f232 100644 --- a/packages/runtime/src/scr_bigint.c +++ b/packages/runtime/src/scr_bigint.c @@ -510,7 +510,11 @@ static ScrBigInt *bi_parse_fail(const ScrStr *s) { return NULL; } -ScrBigInt *scr_bigint_parse(ScrStr *s) { +static ScrBigInt *bi_parse_invalid(const ScrStr *s, bool report_error) { + return report_error ? bi_parse_fail(s) : NULL; +} + +static ScrBigInt *bi_parse_string(ScrStr *s, bool report_error) { size_t lo = 0, hi = s->len; size_t width; while (lo < hi && (width = bi_space_prefix(s->data + lo, hi - lo)) != 0) lo += width; @@ -521,7 +525,7 @@ ScrBigInt *scr_bigint_parse(ScrStr *s) { if (s->data[lo] == '+' || s->data[lo] == '-') { signed_input = true; if (s->data[lo++] == '-') sign = -1; - if (lo == hi) return bi_parse_fail(s); + if (lo == hi) return bi_parse_invalid(s, report_error); } unsigned base = 10; if (hi - lo >= 2 && s->data[lo] == '0') { @@ -530,9 +534,9 @@ ScrBigInt *scr_bigint_parse(ScrStr *s) { else if (p == 'o' || p == 'O') base = 8; else if (p == 'b' || p == 'B') base = 2; if (base != 10) { - if (signed_input) return bi_parse_fail(s); + if (signed_input) return bi_parse_invalid(s, report_error); lo += 2; - if (lo == hi) return bi_parse_fail(s); + if (lo == hi) return bi_parse_invalid(s, report_error); } } ScrBigInt *v = bi_zero(); @@ -541,16 +545,29 @@ ScrBigInt *scr_bigint_parse(ScrStr *s) { int d = bi_digit((unsigned char)s->data[lo]); if (d < 0 || (unsigned)d >= base) { scr_bigint_release(v); - return bi_parse_fail(s); + return bi_parse_invalid(s, report_error); } bi_mul_small(&v, base, (uint32_t)d); any = true; } - if (!any) { scr_bigint_release(v); return bi_parse_fail(s); } + if (!any) { scr_bigint_release(v); return bi_parse_invalid(s, report_error); } if (v->sign) v->sign = sign; return v; } +ScrBigInt *scr_bigint_parse(ScrStr *s) { return bi_parse_string(s, true); } + +/* Abstract Equality's String/BigInt arm uses StringToBigInt without + * surfacing its parse failure: an invalid string compares false instead + * of throwing. BigInt(string) keeps the public throwing parser above. */ +bool scr_bigint_eq_string(ScrBigInt *a, ScrStr *b) { + ScrBigInt *parsed = bi_parse_string(b, false); + if (!parsed) return false; + bool equal = scr_bigint_eq(a, parsed); + scr_bigint_release(parsed); + return equal; +} + ScrBigInt *scr_bigint_from_f64(double value) { if (!isfinite(value) || trunc(value) != value) { char num[64], msg[192]; diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 9e58dee71..3da212dfd 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -3018,6 +3018,7 @@ ScrBigInt *scr_bigint_xor(ScrBigInt *a, ScrBigInt *b); ScrBigInt *scr_bigint_shl(ScrBigInt *a, ScrBigInt *count); ScrBigInt *scr_bigint_shr(ScrBigInt *a, ScrBigInt *count); bool scr_bigint_eq(ScrBigInt *a, ScrBigInt *b); +bool scr_bigint_eq_string(ScrBigInt *a, ScrStr *b); /* StringToBigInt failure is false */ double scr_bigint_cmp_f64(ScrBigInt *a, ScrBigInt *b); /* -1, 0, 1 */ double scr_bigint_cmp_number(ScrBigInt *a, double b); /* -1, 0, 1; 2 = NaN */ bool scr_bigint_truthy(ScrBigInt *value); @@ -6594,6 +6595,10 @@ void scr_assert_neq_fail(const char *insp, size_t ilen, bool deep, * the divergences). Borrows everything; throws on the failing verdict. */ void scr_assert_eq_dyn(ScrDyn *a, ScrDyn *b, bool negated, bool deep, ScrStr *msg, bool has_msg); +/* assert.equal/notEqual over the frontend's Abstract Equality verdict; + * actual/expected are borrowed util.inspect renderings. */ +void scr_assert_loose_result(bool equal, bool negated, ScrStr *actual, + ScrStr *expected, ScrStr *msg, bool has_msg); /* assert.throws / assert.rejects whose callback returned (or whose * promise fulfilled): "Missing expected exception|rejection" with Node's * details — ` (${expected.name})` when the expected class/shape carries a diff --git a/tests/corpus/2936-static-loose-equality.ts b/tests/corpus/2936-static-loose-equality.ts new file mode 100644 index 000000000..1f4a4a523 --- /dev/null +++ b/tests/corpus/2936-static-loose-equality.ts @@ -0,0 +1,85 @@ +// Static Abstract Equality Comparison: primitive coercions, union-arm +// dispatch, evaluate-once ordering, and node:assert's legacy shallow pair. +import assert, { equal, notEqual } from "node:assert"; + +type Primitive = string | number | boolean | bigint | null | undefined; + +function eq(a: Primitive, b: Primitive): boolean { + return a == b; +} + +function neq(a: Primitive, b: Primitive): boolean { + return a != b; +} + +console.log(eq("1", 1), eq("", 0), eq(" \t", 0), eq("0x10", 16), eq("no", 0)); +console.log(eq(false, 0), eq(true, 1), eq(true, 2), eq(false, ""), eq(true, "1")); +console.log(eq(1n, 1), eq(1n, "01"), eq(1n, "1.0"), eq(0n, ""), eq(1n, true)); +console.log(eq(9007199254740992n, 9007199254740992), eq(9007199254740993n, 9007199254740992)); +console.log(eq(null, undefined), eq(null, 0), eq(undefined, false)); +console.log(eq(0 / 0, 0 / 0), eq(0, -0), neq("2", 2), neq("2", 3)); + +type SymbolOrString = symbol | string; +function symbolEq(a: SymbolOrString, b: SymbolOrString): boolean { + return a == b; +} +const shared = Symbol.for("shared"); +console.log(symbolEq(shared, shared), symbolEq(Symbol("x"), Symbol("x")), symbolEq(shared, "shared")); + +const sameArray = [1, 2]; +function arrayEq(a: number[], b: number[]): boolean { + return a == b; +} +console.log(arrayEq(sameArray, sameArray), arrayEq(sameArray, [1, 2])); + +let order = ""; +let leftCalls = 0; +let rightCalls = 0; +function left(): Primitive { + order += "L"; + leftCalls++; + return "1"; +} +function right(): Primitive { + order += "R"; + rightCalls++; + return 1; +} +console.log(left() == right(), order, leftCalls, rightCalls); + +assert.equal("1", 1); +assert.equal("", 0); +assert.equal(false, 0); +assert.equal(1n, "01"); +assert.equal(0n, ""); +assert.equal(null, undefined); +assert.equal(0 / 0, 0 / 0); // Node's legacy NaN exception. +assert.equal(0, -0); +assert.notEqual("2", 3); +equal(true, 1); +notEqual(2n, "3"); + +function unionAssert(a: Primitive, b: Primitive): void { + assert.equal(a, b); +} +unionAssert(1n, "01"); +unionAssert(false, ""); + +function messageOf(fn: () => void): string { + try { + fn(); + return "DID NOT THROW"; + } catch (error) { + return error instanceof Error ? `${error.name}:${error.message}` : "not an Error"; + } +} + +console.log(JSON.stringify(messageOf(() => assert.equal("1", 2)))); +console.log(JSON.stringify(messageOf(() => assert.notEqual("1", 1)))); +console.log(JSON.stringify(messageOf(() => assert.notEqual(0 / 0, 0 / 0)))); +console.log(JSON.stringify(messageOf(() => assert.equal(1n, "2")))); +console.log(JSON.stringify(messageOf(() => assert.equal(null, 0)))); +console.log(JSON.stringify(messageOf(() => assert.equal(Symbol("x"), Symbol("y"))))); +console.log(JSON.stringify(messageOf(() => assert.equal(1, 2, "custom")))); +console.log(JSON.stringify(messageOf(() => assert.equal(1, 2, "")))); +console.log("done"); diff --git a/tests/coverage-fixtures/dynamic-mix.ts b/tests/coverage-fixtures/dynamic-mix.ts index 940a289cc..169b7142d 100644 --- a/tests/coverage-fixtures/dynamic-mix.ts +++ b/tests/coverage-fixtures/dynamic-mix.ts @@ -10,5 +10,5 @@ const up = (19.99).toPrecision(3); const parsed = Number.parseFloat("1.5"); // the global's string form is static now; the Number static keeps the island const raw = __island_eval("6 * 7"); const unknownScore: unknown = 81; -const flags = unknownScore == 81; // mixed-kind loose equality keeps the fence (same-kind == lowers) +const flags = unknownScore == 81; // unknown/object coercion keeps the fence; static primitive pairs lower console.log("done"); diff --git a/tests/coverage-fixtures/mixed.ts b/tests/coverage-fixtures/mixed.ts index ab5488ab3..73a36ce47 100644 --- a/tests/coverage-fixtures/mixed.ts +++ b/tests/coverage-fixtures/mixed.ts @@ -9,7 +9,7 @@ const items = [1, 2, 3]; const handler = (x: number) => x * 2; const anyone: unknown = score(1); -const flags = anyone == score(2); // mixed-kind loose equality keeps the fence (same-kind == lowers) +const flags = anyone == score(2); // unknown/object coercion keeps the fence; static primitive pairs lower const { length: firstItem } = items; // object patterns over arrays keep the fence (patterns and defaults compile) let label = "score: "; label += score(10); diff --git a/tests/diagnostics/assert-fences.ts b/tests/diagnostics/assert-fences.ts index dfa5cb3ad..3ca054193 100644 --- a/tests/diagnostics/assert-fences.ts +++ b/tests/diagnostics/assert-fences.ts @@ -1,6 +1,6 @@ -// The assert surface's honest fences: the loose == quartet (assert's own -// equal/deepEqual — assert/strict's same-named members ARE the strict -// comparisons and lower), reference-equality strictEqual on objects, +// The assert surface's honest fences: legacy deepEqual/notDeepEqual +// (shallow equal/notEqual and assert/strict's same-named aliases lower), +// reference-equality strictEqual on objects, // union operands (narrow first), mismatched or uncomparable deep types // (typed arrays, class instances), namespace-object calls, unsupported // throws/rejects expectations (properties outside the static error @@ -11,7 +11,7 @@ import assert from "node:assert"; import * as assertNs from "node:assert"; -assert.equal(1, 1); // loose == — points at the strict forms +assert.equal(1, 1); // shallow legacy equality lowers for primitives assert.notEqual(1, 2); assert.deepEqual([1], [1]); assert.notDeepEqual([1], [2]); diff --git a/tests/harness/__snapshots__/assert-fences.ts.txt b/tests/harness/__snapshots__/assert-fences.ts.txt index b1a799c56..9a87c7fa1 100644 --- a/tests/harness/__snapshots__/assert-fences.ts.txt +++ b/tests/harness/__snapshots__/assert-fences.ts.txt @@ -1,21 +1,3 @@ -assert-fences.ts:14:1 - error SC2020: 'assert.equal' is part of the standard library types but has no scriptc lowering yet - - 13 | - 14 | assert.equal(1, 1); // loose == — points at the strict forms - | ^~~~~~~~~~~~~~~~~~ - 15 | assert.notEqual(1, 2); - - hint: loose == equality has no lowering — the strict forms compare with Object.is/structural equality like Node's assert/strict module, where equal IS strictEqual - -assert-fences.ts:15:1 - error SC2020: 'assert.notEqual' is part of the standard library types but has no scriptc lowering yet - - 14 | assert.equal(1, 1); // loose == — points at the strict forms - 15 | assert.notEqual(1, 2); - | ^~~~~~~~~~~~~~~~~~~~~ - 16 | assert.deepEqual([1], [1]); - - hint: loose == equality has no lowering — the strict forms compare with Object.is/structural equality like Node's assert/strict module, where equal IS strictEqual - assert-fences.ts:16:1 - error SC2020: 'assert.deepEqual' is part of the standard library types but has no scriptc lowering yet 15 | assert.notEqual(1, 2); @@ -23,7 +5,7 @@ assert-fences.ts:16:1 - error SC2020: 'assert.deepEqual' is part of the standard | ^~~~~~~~~~~~~~~~~~~~~~~~~~ 17 | assert.notDeepEqual([1], [2]); - hint: loose == equality has no lowering — the strict forms compare with Object.is/structural equality like Node's assert/strict module, where equal IS strictEqual + hint: legacy deep equality has no lowering yet — deepStrictEqual and notDeepStrictEqual compare structures with Node's modern semantics assert-fences.ts:17:1 - error SC2020: 'assert.notDeepEqual' is part of the standard library types but has no scriptc lowering yet @@ -32,7 +14,7 @@ assert-fences.ts:17:1 - error SC2020: 'assert.notDeepEqual' is part of the stand | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 18 | - hint: loose == equality has no lowering — the strict forms compare with Object.is/structural equality like Node's assert/strict module, where equal IS strictEqual + hint: legacy deep equality has no lowering yet — deepStrictEqual and notDeepStrictEqual compare structures with Node's modern semantics assert-fences.ts:20:1 - error SC2020: 'assert.strictEqual of '{ n: number }' and '{ n: number }' values' is part of the standard library types but has no scriptc lowering yet diff --git a/tests/harness/__snapshots__/multiple-errors.ts.txt b/tests/harness/__snapshots__/multiple-errors.ts.txt index 63eb91872..90d3b057f 100644 --- a/tests/harness/__snapshots__/multiple-errors.ts.txt +++ b/tests/harness/__snapshots__/multiple-errors.ts.txt @@ -14,4 +14,4 @@ multiple-errors.ts:13:13 - error SC1040: loose equality (== and !=) is not suppo | ^~~~~~~~~~~ 14 | - hint: use === / !== ('x == null' / 'x != null' — the null-or-undefined test — is supported; other loose comparisons need dynamic coercion semantics) \ No newline at end of file + hint: object-to-primitive loose equality can call user-defined valueOf/toString methods — compare explicit primitive conversions instead \ No newline at end of file