From c48e5bea09ae6b02cea4be9a42609582e41f2557 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 7 Oct 2026 21:54:58 -0500 Subject: [PATCH 01/14] Improve native callbacks, value narrowing, and runtime metadata - Keep typed callbacks on native paths while preserving receivers, evaluation order, and method replacement. - Preserve caught class identity, collection narrowing, and missing indexed values across typed code. - Add standard terminal access and filesystem error metadata with differential coverage and versioned runtime compatibility. --- docs/content/docs/cli.mdx | 2 +- docs/content/docs/how-it-works.mdx | 2 +- docs/content/docs/native-objects.mdx | 2 +- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 62 +-- .../generated/node-v24-backlog.json | 160 ++++--- .../generated/node-v24-internal.json | 108 ++++- internal/compatibility/static-support.json | 5 +- packages/cli/README.md | 2 +- packages/cli/test/native-link-info.test.ts | 4 +- .../ambient/scriptc-node-fallback.d.ts | 23 +- .../src/backend/cycle-analysis.test.ts | 2 +- .../compiler/src/backend/llvm/classes.test.ts | 6 +- .../backend/llvm/constant-callbacks.test.ts | 2 +- .../compiler/src/backend/llvm/emitter.test.ts | 29 +- packages/compiler/src/backend/llvm/emitter.ts | 12 +- .../src/backend/llvm/expr-primitives.test.ts | 2 +- .../backend/llvm/expr-stream-bridges.test.ts | 2 +- .../backend/llvm/local-array-reads.test.ts | 2 +- .../backend/llvm/local-union-storage.test.ts | 2 +- .../backend/llvm/map-read-lifetimes.test.ts | 2 +- .../src/backend/llvm/stack-unions.test.ts | 2 +- .../backend/llvm/string-construction.test.ts | 2 +- .../src/backend/llvm/string-lifetimes.test.ts | 2 +- .../src/backend/llvm/switch-dispatch.test.ts | 2 +- .../compiler/src/backend/may-throw.test.ts | 2 +- packages/compiler/src/backend/runtime-abi.ts | 4 +- .../src/backend/runtime-pack-targets.test.ts | 2 +- .../compiler/src/backend/runtime-pack.test.ts | 2 +- .../src/backend/target-diagnostics.test.ts | 2 +- .../src/frontend/lowering/array-values.ts | 84 ++++ .../src/frontend/lowering/builtins/errors.ts | 27 +- .../src/frontend/lowering/builtins/process.ts | 107 +++-- .../lowering/caught-value-dispatch.ts | 84 ++++ .../src/frontend/lowering/class-callbacks.ts | 11 +- .../lowering/class-dynamic-dispatch.ts | 5 +- .../lowering/class-method-mutations.ts | 431 ++++++++++++++++++ .../frontend/lowering/collection-narrowing.ts | 36 ++ .../containers/array-callback-call.ts | 63 +++ .../lowering/containers/array-methods.ts | 86 ---- .../lowering/containers/collection-methods.ts | 12 +- .../frontend/lowering/lower-builtin-values.ts | 1 + .../src/frontend/lowering/lower-calls.ts | 37 +- .../src/frontend/lowering/lower-classes.ts | 36 +- .../src/frontend/lowering/lower-exprs.ts | 131 ++++-- .../src/frontend/lowering/lower-functions.ts | 56 ++- .../src/frontend/lowering/lower-stmts.ts | 23 +- .../compiler/src/frontend/lowering/lowerer.ts | 64 ++- .../lowering/sanitize-class-types.test.ts | 4 +- .../src/frontend/lowering/surfaces.ts | 3 + .../compiler/src/frontend/type-mapper.test.ts | 28 +- packages/compiler/src/frontend/type-mapper.ts | 77 +++- .../compiler/src/ir/builtin-signatures.ts | 5 +- .../compiler/src/ir/constant-tables.test.ts | 2 +- .../src/ir/initializer-bindings.test.ts | 2 +- packages/compiler/src/ir/ir.ts | 13 +- .../compiler/src/ir/scalar-records.test.ts | 2 +- packages/compiler/src/ir/serialize.ts | 6 +- packages/compiler/src/ir/validate.test.ts | 17 +- packages/compiler/src/ir/validate.ts | 17 +- .../compiler/src/library/fence-eval.test.ts | 2 +- .../compiler/src/library/int-infer.test.ts | 8 +- .../src/library/library-cache.test.ts | 10 +- packages/compiler/src/native/compiler.test.ts | 4 +- packages/compiler/surface-manifest.json | 6 + .../test/bytes-element-emission.test.ts | 8 +- packages/compiler/test/emit-llvm.test.ts | 6 +- packages/compiler/test/fixtures/fib-ir.ts | 2 +- .../test/fromcharcode-emission.test.ts | 2 +- packages/compiler/test/ir.test.ts | 4 +- .../compiler/test/llvm-lib-dispatch.test.ts | 2 +- .../test/native-callback-emission.test.ts | 83 ++++ .../test/native-codegen-integration.test.ts | 4 +- .../test/string-accumulation-emission.test.ts | 2 +- .../test/ts7/baselines/order-parity.json | 42 ++ .../runtime-darwin-arm64/scripts/build.mjs | 2 +- .../runtime-pack-common/scripts/build.mjs | 2 +- packages/runtime/src/scr_console.c | 2 +- packages/runtime/src/scr_context.c | 2 +- packages/runtime/src/scr_error.c | 6 + packages/runtime/src/scr_events.c | 13 +- packages/runtime/src/scr_json.c | 4 + packages/runtime/src/scr_lib.c | 42 +- packages/runtime/src/scr_runtime.h | 12 +- tests/corpus/1408-string-indexing.ts | 6 +- tests/corpus/caught-class-identity.ts | 43 ++ tests/corpus/caught-system-error-fields.ts | 42 ++ tests/corpus/collection-data-unions.ts | 45 ++ tests/corpus/native-callback-dispatch.ts | 77 ++++ tests/corpus/terminal-stream-properties.ts | 25 + tests/corpus/typed-index-arguments.ts | 20 + .../node-types/terminal-error-fields.ts | 20 + tests/fixtures/self-hosting/class-types.ts | 2 +- tests/fixtures/terminal-geometry/main.ts | 15 +- tests/harness/project-config.test.ts | 18 + .../self-hosting-native-toolchain.test.ts | 2 +- tests/harness/self-hosting-validator-cases.ts | 2 +- 97 files changed, 1980 insertions(+), 530 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/caught-value-dispatch.ts create mode 100644 packages/compiler/src/frontend/lowering/class-method-mutations.ts create mode 100644 packages/compiler/src/frontend/lowering/collection-narrowing.ts create mode 100644 packages/compiler/src/frontend/lowering/containers/array-callback-call.ts create mode 100644 packages/compiler/test/native-callback-emission.test.ts create mode 100644 tests/corpus/caught-class-identity.ts create mode 100644 tests/corpus/caught-system-error-fields.ts create mode 100644 tests/corpus/collection-data-unions.ts create mode 100644 tests/corpus/native-callback-dispatch.ts create mode 100644 tests/corpus/terminal-stream-properties.ts create mode 100644 tests/corpus/typed-index-arguments.ts create mode 100644 tests/fixtures/node-types/terminal-error-fields.ts diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index d1600346c..bd2130571 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -54,7 +54,7 @@ fib.s Different output kinds accumulate in `.scriptc/`; rebuilding a kind updates its file. -The IR and LLVM source outputs require only Node. On supported macOS, Linux, and Windows hosts, assembly and object outputs use the matching native helper installed with scriptc and do not invoke an external compiler, archiver, linker, or SDK. The object is a relocatable program object with undefined scr_* runtime symbols and a required scr_runtime_abi_v7 marker, not a standalone library. External consumption is experimental and requires the exact runtime version reported by --print=native-link-info. That option still writes the object, performs no link, and prints a versioned JSON recipe with the target, main entry, installed precompiled runtime pack, FFI inputs, and system libraries. It never reports private scriptc cache paths. See Native Program Objects for complete C-driver and direct-linker examples. --emit=exe is the default. LLVM builds emit the program object through the helper and link precompiled runtime objects; runtime development with AddressSanitizer compiles an instrumented C runtime. +The IR and LLVM source outputs require only Node. On supported macOS, Linux, and Windows hosts, assembly and object outputs use the matching native helper installed with scriptc and do not invoke an external compiler, archiver, linker, or SDK. The object is a relocatable program object with undefined scr_* runtime symbols and a required scr_runtime_abi_v8 marker, not a standalone library. External consumption is experimental and requires the exact runtime version reported by --print=native-link-info. That option still writes the object, performs no link, and prints a versioned JSON recipe with the target, main entry, installed precompiled runtime pack, FFI inputs, and system libraries. It never reports private scriptc cache paths. See Native Program Objects for complete C-driver and direct-linker examples. --emit=exe is the default. LLVM builds emit the program object through the helper and link precompiled runtime objects; runtime development with AddressSanitizer compiles an instrumented C runtime. ## scriptc run diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index c0a4587dd..0d59767de 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -68,7 +68,7 @@ Without `-o`, artifacts are written to `.scriptc/` next to the input. Different ### External linking -Program objects define `main` and leave selected `scr_*` runtime functions undefined. A strong reference to `scr_runtime_abi_v7` checks runtime compatibility at link time. +Program objects define `main` and leave selected `scr_*` runtime functions undefined. A strong reference to `scr_runtime_abi_v8` checks runtime compatibility at link time. `--print=native-link-info` emits the program object and a JSON recipe containing the runtime pack and link order for external builds. This object ABI is experimental and requires an exact runtime version. See [Native Program Objects](/docs/native-objects) for examples. diff --git a/docs/content/docs/native-objects.mdx b/docs/content/docs/native-objects.mdx index 54f1efacb..7315493c5 100644 --- a/docs/content/docs/native-objects.mdx +++ b/docs/content/docs/native-objects.mdx @@ -8,7 +8,7 @@ title: "Native Program Objects" The external object ABI is **experimental**. Its `scr_*` function and data surface may change before 1.0, so consumers must use the exact runtime-pack version reported by the same compiler installation. This is stricter than semver compatibility. -The object intentionally leaves its selected runtime symbols undefined. It also holds a strong reference to `scr_runtime_abi_v7`, which the matching runtime defines. Linking an object against a runtime with another ABI marker fails at link time with the missing versioned symbol; it cannot become a latent runtime incompatibility. +The object intentionally leaves its selected runtime symbols undefined. It also holds a strong reference to `scr_runtime_abi_v8`, which the matching runtime defines. Linking an object against a runtime with another ABI marker fails at link time with the missing versioned symbol; it cannot become a latent runtime incompatibility. ## Machine-readable link information diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 4b0f2a929..cbbe881f9 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": "32b04265083c89d00ea3", + "artifactVersion": "ae934a592466ef1e67c1", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 643394fd5..1391cf23b 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -410,9 +410,9 @@ "apiEntries": 461, "static": { "supported": 0, - "partial": 3, + "partial": 8, "refused": 0, - "not-implemented": 458, + "not-implemented": 453, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -1130,9 +1130,9 @@ "apiEntries": 18, "static": { "supported": 0, - "partial": 3, + "partial": 7, "refused": 0, - "not-implemented": 15, + "not-implemented": 11, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -22256,9 +22256,9 @@ "anchor": "class-systemerror", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -22279,9 +22279,9 @@ "anchor": "class-systemerror", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -22302,9 +22302,9 @@ "anchor": "class-systemerror", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -22371,9 +22371,9 @@ "anchor": "class-systemerror", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -22417,9 +22417,9 @@ "anchor": "class-systemerror", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -80894,9 +80894,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -81062,9 +81062,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -81174,9 +81174,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -81258,9 +81258,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "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 8bd3a5d37..e73d033de 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -8,8 +8,8 @@ "tiers": { "static": { "replace-refusal": 42, - "verify-gap": 2427, - "audit-partial": 737, + "verify-gap": 2418, + "audit-partial": 746, "classify": 2, "implement": 80 }, @@ -18651,12 +18651,16 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:errors", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:errors.code", + "tests": [ + "tests/corpus/caught-system-error-fields.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/project-config.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -18679,12 +18683,16 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:errors", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:errors.dest", + "tests": [ + "tests/corpus/caught-system-error-fields.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/project-config.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -18707,12 +18715,16 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:errors", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:errors.errno", + "tests": [ + "tests/corpus/caught-system-error-fields.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/project-config.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -18791,12 +18803,16 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:errors", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:errors.path", + "tests": [ + "tests/corpus/caught-system-error-fields.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/project-config.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -18847,12 +18863,16 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:errors", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:errors.syscall", + "tests": [ + "tests/corpus/caught-system-error-fields.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/project-config.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -90173,12 +90193,18 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:tty", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-dedicated:readStream.isTTY", + "tests": [ + "tests/corpus/1448-stream-columns.ts", + "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/terminal-geometry.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -90373,12 +90399,18 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:tty", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-dedicated:writeStream.getWindowSize", + "tests": [ + "tests/corpus/1448-stream-columns.ts", + "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/terminal-geometry.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -90480,6 +90512,8 @@ "tests": [ "tests/corpus/1448-stream-columns.ts", "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", "tests/harness/terminal-geometry.test.ts" ] }, @@ -90509,12 +90543,18 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:tty", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-dedicated:writeStream.isTTY", + "tests": [ + "tests/corpus/1448-stream-columns.ts", + "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/terminal-geometry.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -90550,6 +90590,8 @@ "tests": [ "tests/corpus/1448-stream-columns.ts", "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", "tests/harness/terminal-geometry.test.ts" ] }, @@ -90612,12 +90654,18 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:tty", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-dedicated:tty.isatty", + "tests": [ + "tests/corpus/1448-stream-columns.ts", + "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/terminal-geometry.test.ts" + ] }, "dynamic": { "status": "partial", diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 62073fb98..faa5855d9 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -459,9 +459,9 @@ "apiEntries": 461, "static": { "supported": 0, - "partial": 3, + "partial": 8, "refused": 0, - "not-implemented": 458, + "not-implemented": 453, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -1251,9 +1251,9 @@ "apiEntries": 18, "static": { "supported": 0, - "partial": 3, + "partial": 7, "refused": 0, - "not-implemented": 15, + "not-implemented": 11, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -37699,8 +37699,11 @@ "status": "partial", "evidence": "derived:descendants", "tests": [ + "tests/corpus/caught-system-error-fields.ts", "tests/corpus/error-stack-frames.ts", - "tests/corpus/error-stack-limit.ts" + "tests/corpus/error-stack-limit.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/project-config.test.ts" ] }, "dynamic": { @@ -38037,8 +38040,13 @@ "anchor": "class-systemerror", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:errors" + "status": "partial", + "evidence": "compiler-feature:errors.code", + "tests": [ + "tests/corpus/caught-system-error-fields.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/project-config.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -38059,8 +38067,13 @@ "anchor": "class-systemerror", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:errors" + "status": "partial", + "evidence": "compiler-feature:errors.dest", + "tests": [ + "tests/corpus/caught-system-error-fields.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/project-config.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -38081,8 +38094,13 @@ "anchor": "class-systemerror", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:errors" + "status": "partial", + "evidence": "compiler-feature:errors.errno", + "tests": [ + "tests/corpus/caught-system-error-fields.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/project-config.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -38147,8 +38165,13 @@ "anchor": "class-systemerror", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:errors" + "status": "partial", + "evidence": "compiler-feature:errors.path", + "tests": [ + "tests/corpus/caught-system-error-fields.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/project-config.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -38191,8 +38214,13 @@ "anchor": "class-systemerror", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:errors" + "status": "partial", + "evidence": "compiler-feature:errors.syscall", + "tests": [ + "tests/corpus/caught-system-error-fields.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/project-config.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -107797,6 +107825,8 @@ "tests": [ "tests/corpus/1448-stream-columns.ts", "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", "tests/harness/terminal-geometry.test.ts" ] }, @@ -107911,8 +107941,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tty" + "status": "partial", + "evidence": "compiler-dedicated:readStream.isTTY", + "tests": [ + "tests/corpus/1448-stream-columns.ts", + "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/terminal-geometry.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -108076,8 +108113,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tty" + "status": "partial", + "evidence": "compiler-dedicated:writeStream.getWindowSize", + "tests": [ + "tests/corpus/1448-stream-columns.ts", + "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/terminal-geometry.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -108162,6 +108206,8 @@ "tests": [ "tests/corpus/1448-stream-columns.ts", "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", "tests/harness/terminal-geometry.test.ts" ] }, @@ -108189,8 +108235,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tty" + "status": "partial", + "evidence": "compiler-dedicated:writeStream.isTTY", + "tests": [ + "tests/corpus/1448-stream-columns.ts", + "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/terminal-geometry.test.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -108221,6 +108274,8 @@ "tests": [ "tests/corpus/1448-stream-columns.ts", "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", "tests/harness/terminal-geometry.test.ts" ] }, @@ -108275,8 +108330,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tty" + "status": "partial", + "evidence": "compiler-dedicated:tty.isatty", + "tests": [ + "tests/corpus/1448-stream-columns.ts", + "tests/corpus/2992-stream-rows.ts", + "tests/corpus/terminal-stream-properties.ts", + "tests/fixtures/node-types/terminal-error-fields.ts", + "tests/harness/terminal-geometry.test.ts" + ] }, "dynamic": { "status": "partial", diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index da1c5128e..80cdeb2f9 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -96,7 +96,7 @@ "node-builtin.test": ["tests/harness/node-test.test.ts"], "node-builtin.timers": ["tests/corpus/1804-timers-module.ts", "tests/corpus/2093-timers-promises.ts"], "node-builtin.tls": ["tests/corpus/2331-tls-server-options-typed.ts", "tests/corpus/2557-tls-ca-store.ts"], - "node-builtin.tty": ["tests/corpus/1448-stream-columns.ts"], + "node-builtin.tty": ["tests/corpus/1448-stream-columns.ts", "tests/corpus/terminal-stream-properties.ts", "tests/harness/terminal-geometry.test.ts"], "node-builtin.url": ["tests/corpus/1794-searchparams-url-live.ts"], "node-builtin.url.pathToFileURL": ["tests/corpus/path-to-file-url-options.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js"], "node-builtin.url.fileURLToPath": ["tests/corpus/file-url-percent-decoding.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js"], @@ -118,6 +118,7 @@ "stdlib.abort": ["tests/harness/fetch-conformance.test.ts"] }, "features": [ + { "chapter": "errors", "parent": "Class: `SystemError`", "symbols": ["code", "errno", "syscall", "path", "dest"], "status": "partial", "evidence": ["tests/corpus/caught-system-error-fields.ts", "tests/fixtures/node-types/terminal-error-fields.ts", "tests/harness/project-config.test.ts"] }, {"chapter": "worker_threads", "symbols": ["Worker"], "status": "partial", "evidence": ["tests/corpus/native-worker-messages.ts", "tests/corpus/native-worker-commonjs.cjs", "tests/corpus/native-worker-environment.ts", "tests/corpus/native-worker-roots/main.ts"]}, {"chapter": "worker_threads", "symbols": ["MessagePort", "parentPort", "worker_threads.workerData", "isMainThread", "threadId"], "status": "partial", "evidence": ["tests/corpus/native-worker-commonjs.cjs", "tests/corpus/native-worker-port-lifetime.ts", "tests/corpus/native-worker-references.ts"]}, {"chapter": "worker_threads", "symbols": ["worker.postMessage", "port.postMessage"], "status": "partial", "evidence": ["tests/corpus/native-worker-message-graphs.ts", "tests/corpus/native-worker-shared-views.ts", "tests/corpus/native-worker-clone-errors.ts", "tests/corpus/native-worker-delayed-messages.ts"]}, @@ -233,7 +234,7 @@ ], "dedicated": [ { "symbols": ["assert.doesNotThrow", "assert.doesNotReject"], "status": "partial", "evidence": ["tests/corpus/node24-no-error-assertions.ts", "tests/corpus/node24-no-error-assertions.cjs", "tests/corpus/node24-no-error-assertions-coercion.js", "tests/fixtures/node-types/no-error-assertions.ts", "tests/harness/project-config.test.ts"] }, - { "symbols": ["writeStream.rows", "writeStream.columns"], "status": "partial", "evidence": ["tests/corpus/1448-stream-columns.ts", "tests/corpus/2992-stream-rows.ts", "tests/harness/terminal-geometry.test.ts"] }, + { "symbols": ["writeStream.rows", "writeStream.columns", "writeStream.isTTY", "writeStream.getWindowSize", "readStream.isTTY", "tty.isatty"], "status": "partial", "evidence": ["tests/corpus/1448-stream-columns.ts", "tests/corpus/2992-stream-rows.ts", "tests/corpus/terminal-stream-properties.ts", "tests/fixtures/node-types/terminal-error-fields.ts", "tests/harness/terminal-geometry.test.ts"] }, { "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", "tests/corpus/2631-create-require/main.ts", "tests/corpus/console-native-value.js", "tests/harness/npm-static.test.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.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/corpus/node24-crypto-sha2.ts", "tests/harness/npm-static.test.ts"] }, diff --git a/packages/cli/README.md b/packages/cli/README.md index 44fd5917d..d15bdcb24 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -34,7 +34,7 @@ Builds use a bounded persistent cache. Unchanged source can reuse the validated - `scriptc run ` — compile and run - `scriptc coverage ` — what compiles statically, and why the rest doesn't -`scriptc build app.ts --emit=ir|llvm|asm|obj` selects serialized typed IR, textual LLVM IR, target assembly, or a relocatable program object as the one primary artifact. `--emit=exe` is the default. Assembly/object emission uses the matching helper on supported macOS, Linux, and Windows hosts (and produces WASI artifacts when that target is selected). Objects retain undefined `scr_*` runtime references plus the `scr_runtime_abi_v7` compatibility marker; they are not library archives. External consumption is experimental and requires the exact matching runtime. `scriptc build app.ts --print=native-link-info -o app.o` prints the versioned JSON target/runtime/link recipe without performing a link. `--emit=asm|obj --sanitize` is rejected until ASan pipeline parity is available. +`scriptc build app.ts --emit=ir|llvm|asm|obj` selects serialized typed IR, textual LLVM IR, target assembly, or a relocatable program object as the one primary artifact. `--emit=exe` is the default. Assembly/object emission uses the matching helper on supported macOS, Linux, and Windows hosts (and produces WASI artifacts when that target is selected). Objects retain undefined `scr_*` runtime references plus the `scr_runtime_abi_v8` compatibility marker; they are not library archives. External consumption is experimental and requires the exact matching runtime. `scriptc build app.ts --print=native-link-info -o app.o` prints the versioned JSON target/runtime/link recipe without performing a link. `--emit=asm|obj --sanitize` is rejected until ASan pipeline parity is available. For embedder-hosted modules that are not installed npm packages, coverage can map an exact bare specifier to a local declaration with repeatable `--external-types ` options. This is analysis-only: the types unblock application measurement, while runtime module uses remain reported as blockers. diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts index 73e429be8..19b9c4a84 100644 --- a/packages/cli/test/native-link-info.test.ts +++ b/packages/cli/test/native-link-info.test.ts @@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => { minimum_os: "14.0", }, program: { object, entry_symbol: "main" }, - runtime_abi: { version: 7, marker: "scr_runtime_abi_v7" }, + runtime_abi: { version: 8, marker: "scr_runtime_abi_v8" }, runtime_pack: { kind: "precompiled", package: "@scriptc/runtime-darwin-arm64", @@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => { () => null, (failure: { stderr?: string }) => failure, ); - expect(error?.stderr).toContain("scr_runtime_abi_v7"); + expect(error?.stderr).toContain("scr_runtime_abi_v8"); }); }); diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 41378ff9b..8177c5b90 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -250,24 +250,8 @@ declare var process: { * preserved. The boolean is Node's backpressure signal — these synchronous * writes always return true. Static BufferEncoding arguments and the * completion callback overloads match Node's WritableStream surface. */ - stdout: { - write(data: string | Uint8Array, callback?: (error?: Error | null) => void): boolean; - write( - data: string | Uint8Array, - encoding: BufferEncoding, - callback?: (error?: Error | null) => void, - ): boolean; - readonly isTTY: boolean; - }; - stderr: { - write(data: string | Uint8Array, callback?: (error?: Error | null) => void): boolean; - write( - data: string | Uint8Array, - encoding: BufferEncoding, - callback?: (error?: Error | null) => void, - ): boolean; - readonly isTTY: boolean; - }; + stdout: import("tty").WriteStream; + stderr: import("tty").WriteStream; /* The stdin stream, the piped-input slice: the TTY probe, the * data/end/error events (on and once — a 'data' listener keeps the * event loop alive until EOF, like Node's flowing stdin), destroy() @@ -4203,6 +4187,9 @@ declare module "tty" { callback?: (error?: Error | null) => void, ): boolean; readonly isTTY: boolean; + readonly columns: number; + readonly rows: number; + getWindowSize(): [number, number]; } export function isatty(fd: number): boolean; } diff --git a/packages/compiler/src/backend/cycle-analysis.test.ts b/packages/compiler/src/backend/cycle-analysis.test.ts index 0fe62630c..113b0eb47 100644 --- a/packages/compiler/src/backend/cycle-analysis.test.ts +++ b/packages/compiler/src/backend/cycle-analysis.test.ts @@ -19,7 +19,7 @@ import { computeTraced as llvmTraced } from "./llvm/shapes.js"; const loc = { file: "cycles.ts", start: 0, end: 1 }; function module(): IrModule { return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [{ name: "main", locals: [], params: [], returnType: VOID, body: [], loc }], diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index d9029a644..8710759e2 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -9,7 +9,7 @@ const loc = { file: "class-membership.ts", start: 0, end: 1 }; test("leaf layouts keep direct teardown while recursive layouts share the depth budget", () => { const linked = { kind: "object" as const, className: "Linked" }; const mod: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], @@ -61,7 +61,7 @@ function graph() { { name: "FlippedString", base: "PairString", genericOf: "Flipped" }, ]; const mod: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [], @@ -95,7 +95,7 @@ test.each(["i32", "i64"])( expect(helper).toContain("label %ordinary"); expect(helper).toContain("or i1"); const ordinary: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [], diff --git a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts index a49a4e089..f81b592fd 100644 --- a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts +++ b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts @@ -66,7 +66,7 @@ function fixture(): IrModule { loc, }; return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [predicate, consumer, entry], diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index 1ef04762e..f60eaeabf 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -1,7 +1,9 @@ import { expect, test } from "vitest"; import { BOOL, + DATE_T, F64, + PROCSTREAM_T, STRING, VOID, type IrExpr, @@ -59,7 +61,7 @@ function moduleFor( loc, }; return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [ @@ -147,6 +149,27 @@ test("shared exceptional exits preserve the function's scalar return ABI", () => expect(blocks[0]).toContain("ret double 0x0000000000000000"); }); +test("exceptional exits use each scalar kind's LLVM return type", () => { + for (const type of [F64, DATE_T, BOOL, PROCSTREAM_T]) { + for (const ownedLocals of [false, true]) { + const module = moduleFor([call(), call(), { kind: "throw", value: literal("failed"), loc }]); + const work = module.functions[1]!; + work.returnType = type; + if (!ownedLocals) { + work.params = []; + work.locals = []; + } + const llvm = emitLlvmModule(module); + const body = /^define internal [^\n]*@sc_f_work\([^]*?^}/m.exec(llvm)?.[0]; + expect(body).toBeDefined(); + expect(body).toContain( + type.kind === "bool" ? "ret i1 false" : "ret double 0x0000000000000000", + ); + expect(body).not.toContain("ret ptr null"); + } + } +}); + const receiverLoc = { file: "read-receiver.ts", start: 0, end: 0 }; const leaf: IrType = { kind: "record", shapeId: "leaf" }; const root: IrType = { kind: "record", shapeId: "root" }; @@ -184,7 +207,7 @@ const text = (obj: IrExpr): IrExpr => ({ function work(expr: IrExpr, parameter: IrType, boxed = false, tdz = false): string { const module: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: receiverLoc.file, entry: "main", records: [ @@ -355,7 +378,7 @@ function sharedFieldModule(prefixes: IrType[][], fieldType: IrType): IrModule { ], })); return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", records, diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 476808e93..329467856 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1600,7 +1600,7 @@ export class LlEmitter { `@scr_error_vts = external ${tl}global [${RUNTIME_ERROR_CLASSES.size} x %ScrVt]`, `declare void @scr_init()`, `declare void @scr_lib_init(i32, ptr)`, - ...(this.mod.workers ? [`declare void @scr_runtime_workers_v7()`] : []), + ...(this.mod.workers ? [`declare void @scr_runtime_workers_v8()`] : []), ...(this.runtimeAbiMarker && this.mod.lib === undefined ? [`declare void @${RUNTIME_ABI_MARKER}()`] : []), @@ -1841,7 +1841,7 @@ export class LlEmitter { `define i32 @${this.mod.workers ? "sc_context_entry" : this.wasi ? "__main_argc_argv" : "main"}(i32 %argc, ptr %argv) ${FN_ATTRS} {`, `entry:`, ...(this.runtimeAbiMarker ? [` call void @${RUNTIME_ABI_MARKER}()`] : []), - ...(this.mod.workers ? [` call void @scr_runtime_workers_v7()`] : []), + ...(this.mod.workers ? [` call void @scr_runtime_workers_v8()`] : []), ` call void @scr_init()`, ...stamps, // Event-surface programs (signal/exit listeners) fill the loop's @@ -3014,13 +3014,13 @@ export class LlEmitter { } else if (this.currentWasiCoro !== null) { terminator = `br label %${this.currentWasiCoro.finalLabel}`; } else { - const t = this.currentReturnType; + const t = this.llType(this.currentReturnType); terminator = - t.kind === "void" + t === "void" ? "ret void" - : t.kind === "f64" || t.kind === "date" + : t === "double" ? `ret double ${f64Lit(0)}` - : t.kind === "bool" + : t === "i1" ? "ret i1 false" : "ret ptr null"; } diff --git a/packages/compiler/src/backend/llvm/expr-primitives.test.ts b/packages/compiler/src/backend/llvm/expr-primitives.test.ts index 878fae3d8..bd570a052 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.test.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.test.ts @@ -17,7 +17,7 @@ function fixture(): IrModule { loc, }); return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "__main", functions: [{ name: "__main", params: [], returnType: VOID, locals: [], body, loc }], diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts index 9f46ac3f3..9741cf216 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts @@ -20,7 +20,7 @@ function sharedGraph(roots: number): IrModule { unionId: `input${index}`, })); return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", records: [ diff --git a/packages/compiler/src/backend/llvm/local-array-reads.test.ts b/packages/compiler/src/backend/llvm/local-array-reads.test.ts index ef6778281..c85ef11e1 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.test.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.test.ts @@ -124,7 +124,7 @@ function fixture(): IrModule { ], }; return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", records: [{ id: "cell", fields: [{ name: "x", type: F64 }] }], diff --git a/packages/compiler/src/backend/llvm/local-union-storage.test.ts b/packages/compiler/src/backend/llvm/local-union-storage.test.ts index 63f88eefa..6118f4653 100644 --- a/packages/compiler/src/backend/llvm/local-union-storage.test.ts +++ b/packages/compiler/src/backend/llvm/local-union-storage.test.ts @@ -81,7 +81,7 @@ function fixture(): IrModule { ], }; return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", unions: [{ id: "optional", arms: [STRING, UNDEFINED_T] }], diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts index dc8d91ff5..927c1247c 100644 --- a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts @@ -66,7 +66,7 @@ function fixture(value: IrType = record, key: IrType = F64): IrModule { ], }; return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", records: [{ id: "cell", fields: [{ name: "x", type: F64 }] }], diff --git a/packages/compiler/src/backend/llvm/stack-unions.test.ts b/packages/compiler/src/backend/llvm/stack-unions.test.ts index 1f61b655b..03f8ba157 100644 --- a/packages/compiler/src/backend/llvm/stack-unions.test.ts +++ b/packages/compiler/src/backend/llvm/stack-unions.test.ts @@ -69,7 +69,7 @@ function fixture(arm: IrType = record): IrModule { body: [ret(narrow(ref("value"), arm))], }; return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", records: [{ id: "cell", fields: [{ name: "x", type: F64 }] }], diff --git a/packages/compiler/src/backend/llvm/string-construction.test.ts b/packages/compiler/src/backend/llvm/string-construction.test.ts index 160dd768f..b22b06b21 100644 --- a/packages/compiler/src/backend/llvm/string-construction.test.ts +++ b/packages/compiler/src/backend/llvm/string-construction.test.ts @@ -17,7 +17,7 @@ const concat = (left: IrExpr, right: IrExpr): IrExpr => ({ function module(value: IrExpr): IrModule { return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index b1d667659..acbf20747 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -68,7 +68,7 @@ function fn(name: string, names: string[], value: IrExpr): IrFunction { } function mod(...functions: IrFunction[]): IrModule { return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/backend/llvm/switch-dispatch.test.ts b/packages/compiler/src/backend/llvm/switch-dispatch.test.ts index f40a02d5a..9e4517b7d 100644 --- a/packages/compiler/src/backend/llvm/switch-dispatch.test.ts +++ b/packages/compiler/src/backend/llvm/switch-dispatch.test.ts @@ -8,7 +8,7 @@ const string = (value: string): IrExpr => ({ kind: "strLit", value, type: STRING function emit(type: IrType, tests: (IrExpr | null)[], pointerBits: 32 | 64 = 64): string { const mod: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/backend/may-throw.test.ts b/packages/compiler/src/backend/may-throw.test.ts index 899ed300e..1aecf25c9 100644 --- a/packages/compiler/src/backend/may-throw.test.ts +++ b/packages/compiler/src/backend/may-throw.test.ts @@ -26,7 +26,7 @@ function fn(name: string, body: IrStmt[], locals: IrLocal[] = [local]): IrFuncti return { name, body, locals, params: [], returnType: VOID, loc }; } function moduleWith(...functions: IrFunction[]): IrModule { - return { irVersion: 14, sourceFile: loc.file, entry: "caller", functions }; + return { irVersion: 15, sourceFile: loc.file, entry: "caller", functions }; } const assignment: IrStmt = { kind: "assign", localId: local.id, value, loc }; const expression: IrExpr = { kind: "assignExpr", localId: local.id, value, type: F64, loc }; diff --git a/packages/compiler/src/backend/runtime-abi.ts b/packages/compiler/src/backend/runtime-abi.ts index 739a86cb1..a4efac02e 100644 --- a/packages/compiler/src/backend/runtime-abi.ts +++ b/packages/compiler/src/backend/runtime-abi.ts @@ -2,8 +2,8 @@ * of the link contract: a program object keeps this symbol undefined and the * matching runtime defines it, so incompatible manual links fail before the * program can start. */ -export const RUNTIME_ABI_VERSION = 7 as const; -export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v7" as const; +export const RUNTIME_ABI_VERSION = 8 as const; +export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v8" as const; /** Object consumption is useful today, but the complete scr_* surface may * still change before 1.0. The versioned marker prevents accidental mixing; diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts index 8e8598cad..d071822c2 100644 --- a/packages/compiler/src/backend/runtime-pack-targets.test.ts +++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts @@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 7, marker: "scr_runtime_abi_v7" }, + runtime_abi: { version: 8, marker: "scr_runtime_abi_v8" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" }, flavors: { diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index 986bb0655..e46b52786 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -112,7 +112,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 7, marker: "scr_runtime_abi_v7" }, + runtime_abi: { version: 8, marker: "scr_runtime_abi_v8" }, compiler: { command: "clang", identity: "fixture clang", diff --git a/packages/compiler/src/backend/target-diagnostics.test.ts b/packages/compiler/src/backend/target-diagnostics.test.ts index c4c25e270..8e6e53ca6 100644 --- a/packages/compiler/src/backend/target-diagnostics.test.ts +++ b/packages/compiler/src/backend/target-diagnostics.test.ts @@ -35,7 +35,7 @@ const fetch: IrExpr = { }; function module(direct: IrExpr[] = []): IrModule { return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "%main", records: [], diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index a35e11b4b..066ea7140 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -1,3 +1,4 @@ +import { InternalCompilerError } from "../../errors.js"; import { dynUndefinedExpr, varRef } from "../../ir/build.js"; import { BOOL, @@ -175,3 +176,86 @@ export function arrayValueStore( } return { kind: "arraySet", arr, index, value: lowerer.coerceToExpected(value, elem), loc }; } + +/** An ordinary indexed read: values retain their element type, while + * holes, missing properties, and present undefined yield undefined. */ +export function lowerSafeIndexRead( + lowerer: Lowerer, + arr: IrExpr, + index: IrExpr, + loc: SrcLoc, +): IrExpr | null { + if (arr.type.kind !== "array") throw new InternalCompilerError("indexed read requires an array"); + const elem = arr.type.elem; + if (elem.kind === "void") return null; + const resultT = arrayValueType(lowerer, elem); + const key = `idxOr:${typeKey(elem)}`; + let name = lowerer.arrHofHelpers.get(key); + if (!name) { + name = `%arr.idxOr.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, name); + const arrT = arr.type; + lowerer.liftedFns.push({ + name, + params: [ + { localId: "a.0", name: "a", type: arrT }, + { localId: "i.0", name: "i", type: F64 }, + ], + returnType: resultT, + locals: [ + { id: "a.0", name: "a", type: arrT, mutable: false }, + { id: "i.0", name: "i", type: F64, mutable: false }, + ], + body: [ + { + kind: "return", + value: arrayValueRead( + lowerer, + varRef("a.0", arrT, loc), + varRef("i.0", F64, loc), + elem, + loc, + ), + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: name, args: [arr, index], type: resultT, loc }; +} + +/** Fuse only our own f64-array read helper with immediate ToNumber. + * Reuse its arguments verbatim so the receiver and index still evaluate + * exactly once, in order, before the read. Do not specialize user calls, + * union-element arrays, or optional values stored in locals. */ +export function tryLowerNumericIndexRead( + lowerer: Lowerer, + operand: IrExpr, + loc: SrcLoc, +): IrExpr | null { + if ( + operand.kind !== "call" || + operand.callee !== lowerer.arrHofHelpers.get(`idxOr:${typeKey(F64)}`) + ) + return null; + const [arr, index] = operand.args; + if ( + operand.args.length !== 2 || + arr?.type.kind !== "array" || + arr.type.elem.kind !== "f64" || + index?.type.kind !== "f64" + ) + return null; + // The intrinsic owns the evaluated receiver through index evaluation, + // then borrows it for one slot lookup. No extra helper parameter or + // separate state/getter expressions need to retain the array again. + return { + kind: "arrIntrinsic", + method: "getNumber", + receiver: arr, + args: [index], + type: F64, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/builtins/errors.ts b/packages/compiler/src/frontend/lowering/builtins/errors.ts index 6281980bc..e612aa4f4 100644 --- a/packages/compiler/src/frontend/lowering/builtins/errors.ts +++ b/packages/compiler/src/frontend/lowering/builtins/errors.ts @@ -51,19 +51,42 @@ export function lowerErrorCodeProperty( }; } } - if (expr.name.text !== "code" && expr.name.text !== "cause" && expr.name.text !== "stack") + const systemField = ["errno", "syscall", "path", "dest"].includes(expr.name.text); + if ( + !systemField && + expr.name.text !== "code" && + expr.name.text !== "cause" && + expr.name.text !== "stack" + ) return null; // Error-rooted classes only — builtin or user subclass (both embed the // code and cause slots in their layout prefix). let info = lowerer.classes.get(recvT.className) ?? null; while (info && info.base) info = info.base; if (!info || info.def.name !== "%Error") return null; - if (!lowerer.isStdlibMember(expr)) return null; + if ( + !lowerer.isStdlibMember(expr) && + !(recvT.className === "%Error" && lowerer.typeOf(expr.expression).isIntersectionType()) + ) + return null; const rawReceiver = lowerer.lowerExpr(expr.expression); const receiver = rawReceiver.type.kind === "dyn" ? lowerer.coerceInto(expr.expression, rawReceiver, recvT) : rawReceiver; + if (systemField) { + const loc = locOf(expr); + return lowerer.coerceToExpected( + { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(receiver, DYN), + key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + type: DYN, + loc, + }, + lowerer.withUndefinedArm(expr.name.text === "errno" ? F64 : STRING), + ); + } return { kind: "libCall", fn: diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 5b97ba0c0..6bbaa54da 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -22,7 +22,6 @@ import { arrayOf, canBoxFuncIntoDyn, funcOf, - typeKey, } from "../../../ir/ir.js"; import { lowerBuiltinLoaderValue } from "../lower-builtin-values.js"; import { lowerProcessIpcSend, ipcMessageListener, ipcDisconnectListener } from "./ipc.js"; @@ -30,9 +29,9 @@ import { lowerDiagnosticsSubscriber, lowerTracingArguments } from "./async-conte /** Method calls on first-class process-stream receivers (procStream — * a WritableStream-typed value like prefixStream's `output` param): - * write(data) with one string, dispatched at runtime onto the exact - * stdout/stderr write paths (the fd IS the value). Everything else - * @types/node declares on WritableStream fences member-qualified. + * write(data) with one string and terminal geometry, dispatched onto + * the original stdout/stderr descriptor. Other declared stream members + * retain member-qualified diagnostics. * Null for non-procStream receivers. */ export function lowerProcStreamMethodCall( lowerer: Lowerer, @@ -58,6 +57,22 @@ export function lowerProcStreamMethodCall( }; } } + if (name === "getWindowSize" && call.arguments.length === 0) { + const receiver = lowerer.coerceToExpected(lowerer.lowerExpr(access.expression), DYN); + return lowerer.coerceInto( + call, + { + kind: "dynInvoke", + recv: receiver, + method: name, + calleeName: call.expression.getText(), + args: [], + type: DYN, + loc, + }, + lowerer.irTypeOf(call), + ); + } if (name === "write" && call.arguments.length === 1) { const receiver = lowerer.lowerExpr(access.expression); const data = lowerer.lowerExpr(call.arguments[0]!); @@ -73,25 +88,33 @@ export function lowerProcStreamMethodCall( lowerer.noLowering( `WritableStream.${name}`, call, - "write(data) with one string is the supported stream-value member", + "write(data) with one string and getWindowSize() are the supported stream-value methods", lowerer.checker.getSymbolAtLocation(access.name), ); } -/** `process.stdin/stdout/stderr.isTTY` → isatty(3) on the stream's fd - * (a REAL boolean: Node's non-TTY streams expose `undefined` here — the - * documented divergence; truthiness tests, the actual usage, agree), and - * `process.stdout/stderr.columns/rows` → ioctl(TIOCGWINSZ) on the fd, with - * Node's non-TTY answer intact: the read is `number | undefined` and a - * non-TTY (or ioctl-refusing) stream yields the undefined arm. The - * receiver match sees through parens and as-casts to the SYMBOL — - * `(process.stderr as typeof process.stderr & { columns?: number }) - * .columns` is the wild widening pattern (@types/node declares a plain - * `number`, so honest code casts the undefined possibility back in), and - * the cast changes the expression's TYPE, never the value. columns sites - * whose checker type does NOT admit undefined are fenced with that exact - * fix instead of lowering to a lie. Null for anything else, so the - * property chain keeps trying. */ +/** Standard streams expose optional terminal properties: pipes have no + * isTTY, columns, or rows. The declarations do not prove their presence. */ +export function isOptionalProcessStreamProperty( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): boolean { + if (!["isTTY", "columns", "rows"].includes(expr.name.text)) return false; + let recv = expr.expression; + while (ts.isParenthesizedExpression(recv) || ts.isAsExpression(recv) || ts.isTypeAssertion(recv)) + recv = recv.expression; + if (ts.isPropertyAccessExpression(recv)) { + const stream = lowerer.stdlibGlobalMember(recv, "process"); + if ( + stream === "stdout" || + stream === "stderr" || + (stream === "stdin" && expr.name.text === "isTTY") + ) + return true; + } + return lowerer.mapTypeOf(lowerer.typeOf(expr.expression))?.kind === "procStream"; +} + export function lowerProcessStreamProperty( lowerer: Lowerer, expr: ts.PropertyAccessExpression, @@ -137,6 +160,25 @@ export function lowerProcessStreamProperty( left = left.expression; if (ts.isPropertyAccessExpression(left)) recv = left; } + if ( + lowerer.mapTypeOf(lowerer.typeOf(recv))?.kind === "procStream" && + !( + ts.isPropertyAccessExpression(recv) && + ["stdout", "stderr"].includes(lowerer.stdlibGlobalMember(recv, "process") ?? "") + ) + ) { + const receiver = lowerer.coerceToExpected(lowerer.lowerExpr(expr.expression), DYN); + return lowerer.coerceToExpected( + { + kind: "dynKeyGet", + value: receiver, + key: strLit(member, locOf(expr)), + type: DYN, + loc: locOf(expr), + }, + lowerer.withUndefinedArm(member === "isTTY" ? BOOL : F64), + ); + } if (!ts.isPropertyAccessExpression(recv)) return null; const stream = lowerer.stdlibGlobalMember(recv, "process"); if (stream !== "stdin" && stream !== "stdout" && stream !== "stderr") return null; @@ -148,22 +190,21 @@ export function lowerProcessStreamProperty( loc, }; if (member === "isTTY") { - return { kind: "libCall", fn: "process.isTTY", args: [fd], type: BOOL, loc }; + const type = lowerer.withUndefinedArm(BOOL); + return { + kind: "ternary", + cond: { kind: "libCall", fn: "process.isTTY", args: [fd], type: BOOL, loc }, + then: lowerer.coerceToExpected(boolLit(true, loc), type), + else_: lowerer.coerceToExpected( + { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type, + ), + type, + loc, + }; } - if (stream === "stdin") return null; // no geometry on a ReadStream — generic fences apply - const declared = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (stream === "stdin") return null; const want = lowerer.withUndefinedArm(F64); - // JS files skip the annotation fence: there is no annotation to fix — - // the read IS Node's `number | undefined` and lowers to exactly that - // (commander's `isTTY ? columns : undefined` help-width probes). - if ((!declared || typeKey(declared) !== typeKey(want)) && !isJsSourceFile(expr.getSourceFile())) { - lowerer.noLowering( - `process.${stream}.${member} as a plain number`, - expr, - `on a non-TTY stream Node's .${member} is undefined — type the read to admit it: ` + - `(process.${stream} as typeof process.${stream} & { ${member}?: number }).${member}`, - ); - } return { kind: "libCall", fn: member === "rows" ? "process.rows" : "process.columns", diff --git a/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts new file mode 100644 index 000000000..b88ba93af --- /dev/null +++ b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts @@ -0,0 +1,84 @@ +import { varRef } from "../../ir/build.js"; +import { BOOL, CAUGHT, DYN, type IrFunction, type IrType } from "../../ir/ir.js"; +import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; +import type { Lowerer } from "./lowerer.js"; + +/** Exception snapshots retain native objects. Recover their known layouts + * before crossing into unknown, using the same live capsules as ordinary + * class values. The reachable throw census grows with the lowering worklist. */ +export class CaughtValueDispatch { + private readonly thrownClasses = new Set(); + private readonly convertedClasses = new Set(); + private helper: IrFunction | null = null; + + process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean { + const consumers: IrFunction[] = []; + for (const fn of functions) { + if (fn === this.helper) continue; + let consumes = false; + everyStmtList(fn.body, { + stmt: (stmt) => { + if (stmt.kind === "throw" && stmt.value.type.kind === "object") { + const info = lowerer.classes.get(stmt.value.type.className); + if (info && lowerer.inHierarchy(info)) this.thrownClasses.add(info.def.name); + } + return true; + }, + expr: (expr) => { + if (expr.kind === "caughtToDyn") consumes = true; + return true; + }, + }); + if (consumes) consumers.push(fn); + } + if (!this.helper && consumers.length === 0) return false; + let changed = false; + if (!this.helper) { + const loc = consumers[0]!.loc; + const value = varRef("caught", CAUGHT, loc); + this.helper = { + name: "%caught.dynamicValue", + params: [{ localId: "caught", name: "caught", type: CAUGHT }], + locals: [{ id: "caught", name: "caught", type: CAUGHT, mutable: false }], + returnType: DYN, + body: [{ kind: "return", value: { kind: "caughtToDyn", value, type: DYN, loc }, loc }], + loc, + }; + lowerer.liftedFns.push(this.helper); + changed = true; + } + const helper = this.helper; + for (const className of this.thrownClasses) { + if (this.convertedClasses.has(className)) continue; + this.convertedClasses.add(className); + const loc = helper.loc; + const value = varRef("caught", CAUGHT, loc); + const type: IrType = { kind: "object", className }; + helper.body.unshift({ + kind: "if", + cond: { kind: "caughtTest", value, test: "instanceof", className, type: BOOL, loc }, + then: [ + { + kind: "return", + value: lowerer.coerceToExpected({ kind: "caughtNarrow", value, type, loc }, DYN), + loc, + }, + ], + else_: null, + loc, + }); + changed = true; + } + for (const fn of consumers) { + fn.body = transformStmtList(fn.body, { + stmt: (stmt) => stmt, + expr: (expr) => + expr.kind === "caughtToDyn" + ? { kind: "call", callee: helper.name, args: [expr.value], type: DYN, loc: expr.loc } + : expr, + }); + changed = true; + } + return changed; + } +} diff --git a/packages/compiler/src/frontend/lowering/class-callbacks.ts b/packages/compiler/src/frontend/lowering/class-callbacks.ts index 3308371a9..400988f0d 100644 --- a/packages/compiler/src/frontend/lowering/class-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/class-callbacks.ts @@ -10,15 +10,15 @@ import { type IrStmt, type SrcLoc, } from "../../ir/ir.js"; -import { locOf } from "../program.js"; +import { isJsSourceFile, locOf } from "../program.js"; import type { Lowerer } from "./lowerer.js"; import { findMethodOn, type ClassInfo } from "./lower-classes.js"; import { classPropertiesHelper } from "./class-dynamic-dispatch.js"; import { classPrototypeData } from "./class-prototypes.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; -/** Observed method slots and zero-argument notifications can be replaced - * through instance properties or their shared prototype chain. */ +/** Unmodified typed methods retain direct or virtual native dispatch. + * JavaScript notification methods keep their checked property fallback. */ export function isClassCallback(lowerer: Lowerer, info: ClassInfo, name: string): boolean { if (!isClassOwnEnumerableFieldName(name) || name.startsWith("get:") || name.startsWith("set:")) return false; @@ -35,7 +35,10 @@ export function isClassCallback(lowerer: Lowerer, info: ClassInfo, name: string) !!found && !found.sig.abstract && (lowerer.prototypeMethodAccesses.has(name) || - (found.sig.params.length === 0 && found.sig.ret.kind === "void")) + (info.decl !== null && + isJsSourceFile(info.decl.getSourceFile()) && + found.sig.params.length === 0 && + found.sig.ret.kind === "void")) ); } diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index ddd1f9193..5b927e8a7 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -59,6 +59,7 @@ import { isJsSourceFile } from "../program.js"; import * as ts from "../ts7/adapter.js"; import { SYMBOL_T } from "../../ir/ir.js"; import { isClassCallback } from "./class-callbacks.js"; +import { CaughtValueDispatch } from "./caught-value-dispatch.js"; import { refreshDescriptorGuards } from "./class-descriptors.js"; import { ClassConstructionDispatch } from "./class-construction.js"; import { emitterRooted } from "./lower-event-emitter.js"; @@ -499,6 +500,7 @@ export class ClassDynamicDispatch { constructor(private readonly asyncFree = false) {} private readonly generatorDispatch = new GeneratorDynamicDispatch(); + private readonly caughtDispatch = new CaughtValueDispatch(); private readonly constructionDispatch = new ClassConstructionDispatch(); private readonly boxed = new Set(); private readonly boxedConstructors = new Set(); @@ -557,7 +559,8 @@ export class ClassDynamicDispatch { ) fn.body.shift(); } - let changed = refreshErrorMethodDispatch(lowerer, functions); + let changed = this.caughtDispatch.process(lowerer, functions); + changed = refreshErrorMethodDispatch(lowerer, functions) || changed; changed = refreshClassMethodValueSelections(lowerer) || changed; changed = refreshErrorPropertyDispatch(lowerer, functions) || changed; changed = this.generatorDispatch.process(lowerer, functions, this.generated) || changed; diff --git a/packages/compiler/src/frontend/lowering/class-method-mutations.ts b/packages/compiler/src/frontend/lowering/class-method-mutations.ts new file mode 100644 index 000000000..0ddf65bc3 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-method-mutations.ts @@ -0,0 +1,431 @@ +import * as ts from "../ts7/adapter.js"; +import { literalValues } from "../literal-values.js"; +import { isJsSourceFile } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; + +/** Census observable slots before choosing native calls. Names are shared + * conservatively across classes: aliases cannot hide a named replacement. */ +export function collectClassMethodMutations( + lowerer: Lowerer, + files: readonly ts.SourceFile[], +): void { + const methods = new Set(); + const arrowFields = new Set(); + const receiverFields = new Set(); + const computedWrites: { + key: ts.Expression | undefined; + receiver: ts.Expression | undefined; + site: ts.Node; + }[] = []; + const reflection: ts.PropertyAccessExpression[] = []; + const globals: ts.Identifier[] = []; + const jsAssignments: (ts.PropertyAccessExpression | ts.ElementAccessExpression)[] = []; + const computedMethods: ts.Expression[] = []; + let unknownKey: ts.Node | undefined; + let numericKey: ts.Node | undefined; + let unknownOwnField = false; + // JavaScript method inference and dynamic class factories keep their + // existing dispatch contract. The native-call proof applies to typed + // declarations, while writes in either source language can invalidate it. + for (const file of files) { + if (isJsSourceFile(file)) continue; + ts.walkPreorder(file, (node) => { + if ( + !ts.isMethodDeclaration(node) || + (!ts.isClassDeclaration(node.parent) && !ts.isClassExpression(node.parent)) + ) + return; + if ( + ts.isIdentifier(node.name) || + ts.isStringLiteralLike(node.name) || + ts.isNumericLiteral(node.name) + ) + methods.add(node.name.text); + else if (ts.isComputedPropertyName(node.name)) computedMethods.push(node.name.expression); + }); + } + lowerer.checker.prefetchClassCollection(computedMethods, []); + for (const key of computedMethods) + for (const name of literalValues(lowerer.typeOf(key)) ?? []) methods.add(String(name)); + const markName = (name: string, site: ts.Node, arrow = false): void => { + if (name === "prototype" || name === "__proto__") unknownKey = site; + if (methods.has(name)) lowerer.prototypeMethodAccesses.set(name, site); + if (!arrow) receiverFields.add(name); + }; + const unwrap = (value: ts.Expression): ts.Expression => { + while ( + ts.isParenthesizedExpression(value) || + ts.isAsExpression(value) || + ts.isTypeAssertion(value) || + ts.isNonNullExpression(value) + ) + value = value.expression; + return value; + }; + // Structural types can be live views of class instances. A fresh + // allocation in a constant binding proves that a dictionary write cannot + // replace a class method without relying on its declared shape. + const freshReceiver = (value: ts.Expression): boolean => { + value = unwrap(value); + if (ts.isIdentifier(value)) { + const symbol = lowerer.checker.getSymbolAtLocation(value); + const decl = symbol && lowerer.checker.declarationsOf(symbol)[0]; + if ( + !decl || + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + !ts.isVariableDeclarationList(decl.parent) || + !(decl.parent.flags & ts.NodeFlags.Const) || + !decl.initializer + ) + return false; + value = unwrap(decl.initializer); + } + if (ts.isObjectLiteralExpression(value) || ts.isArrayLiteralExpression(value)) return true; + return ( + ts.isCallExpression(value) && + ts.isPropertyAccessExpression(value.expression) && + lowerer.stdlibGlobalMember(value.expression, "Object") === "create" + ); + }; + const primitiveDictionary = (value: ts.Expression): boolean => { + const primitive = (type: ts.Type): boolean => + type.isUnionType() + ? ts.constituentTypes(type).every(primitive) + : (type.flags & + (ts.TypeFlags.StringLike | + ts.TypeFlags.NumberLike | + ts.TypeFlags.BooleanLike | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.Null | + ts.TypeFlags.Undefined | + ts.TypeFlags.ESSymbolLike)) !== + 0; + return lowerer.checker + .getIndexInfosOfType(lowerer.typeOf(value)) + .some( + (index) => + (index.keyType.flags & ts.TypeFlags.StringLike) !== 0 && primitive(index.valueType), + ); + }; + const literalLoopKeys = (key: ts.Expression): (string | number)[] | null => { + if (!ts.isIdentifier(key)) return null; + const symbol = lowerer.checker.getSymbolAtLocation(key); + const decl = symbol && lowerer.checker.declarationsOf(symbol)[0]; + if (!decl || !ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name)) return null; + const list = decl.parent; + if (!ts.isVariableDeclarationList(list) || !(list.flags & ts.NodeFlags.Const)) return null; + const loop = list.parent; + if (!ts.isForOfStatement(loop) || loop.initializer !== list) return null; + const values = unwrap(loop.expression); + if (!ts.isArrayLiteralExpression(values)) return null; + const names: (string | number)[] = []; + for (const value of values.elements) { + if (ts.isStringLiteralLike(value)) names.push(value.text); + else if (ts.isNumericLiteral(value)) names.push(Number(value.text)); + else return null; + } + return names; + }; + const sameBinding = (left: ts.Expression, right: ts.Expression): boolean => { + left = unwrap(left); + right = unwrap(right); + if (!ts.isIdentifier(left) || !ts.isIdentifier(right)) return false; + const symbol = lowerer.checker.getSymbolAtLocation(left); + return symbol !== undefined && symbol === lowerer.checker.getSymbolAtLocation(right); + }; + const preservesExistingMethod = ( + key: ts.Expression, + receiver: ts.Expression, + site: ts.Node, + ): boolean => { + // An immediately guarded insertion cannot replace a method already + // present on the receiver or its prototype. Restrict the body to this + // one write so no intervening statement can change either binding. + const statement = site.parent; + if (!ts.isExpressionStatement(statement)) return false; + const branch = statement.parent; + if (!ts.isIfStatement(branch) || branch.thenStatement !== statement) return false; + const test = unwrap(branch.expression); + if (!ts.isPrefixUnaryExpression(test) || test.operator !== ts.SyntaxKind.ExclamationToken) + return false; + const present = unwrap(test.operand); + return ( + ts.isBinaryExpression(present) && + present.operatorToken.kind === ts.SyntaxKind.InKeyword && + sameBinding(key, present.left) && + sameBinding(receiver, present.right) + ); + }; + const deletesOwnEnumerableKey = ( + key: ts.Expression, + receiver: ts.Expression, + site: ts.Node, + ): boolean => { + if (!ts.isDeleteExpression(site) || !ts.isIdentifier(key)) return false; + const statement = site.parent; + if (!ts.isExpressionStatement(statement)) return false; + const loop = statement.parent; + if (!ts.isForOfStatement(loop) || loop.statement !== statement) return false; + if (!ts.isVariableDeclarationList(loop.initializer)) return false; + const decl = loop.initializer.declarations[0]; + if (!decl || !ts.isIdentifier(decl.name) || !sameBinding(key, decl.name)) return false; + const keys = unwrap(loop.expression); + return ( + ts.isCallExpression(keys) && + ts.isPropertyAccessExpression(keys.expression) && + lowerer.stdlibGlobalMember(keys.expression, "Object") === "keys" && + keys.arguments.length === 1 && + sameBinding(receiver, keys.arguments[0]!) + ); + }; + const markKey = (key: ts.Expression, site: ts.Node, receiver?: ts.Expression): void => { + key = unwrap(key); + if (ts.isStringLiteralLike(key)) markName(key.text, site); + else if (ts.isNumericLiteral(key)) markName(String(Number(key.text)), site); + else computedWrites.push({ key, receiver, site }); + }; + const markFields = (value: ts.Expression, site: ts.Node, receiver?: ts.Expression): void => { + value = unwrap(value); + if (!ts.isObjectLiteralExpression(value)) { + computedWrites.push({ key: undefined, receiver, site }); + return; + } + for (const field of value.properties) { + if (ts.isSpreadAssignment(field)) markFields(field.expression, site, receiver); + else if (field.name && (ts.isIdentifier(field.name) || ts.isStringLiteralLike(field.name))) + markName(field.name.text, site); + else if (field.name && ts.isComputedPropertyName(field.name)) + markKey(field.name.expression, site, receiver); + } + }; + const markTarget = (target: ts.Expression, site: ts.Node, arrow = false): void => { + target = unwrap(target); + if (ts.isPropertyAccessExpression(target)) markName(target.name.text, site, arrow); + else if (ts.isElementAccessExpression(target)) + markKey(target.argumentExpression, site, target.expression); + else if (ts.isArrayLiteralExpression(target)) { + for (const element of target.elements) { + if (ts.isSpreadElement(element)) markTarget(element.expression, site); + else if (!ts.isOmittedExpression(element)) markTarget(element, site); + } + } else if (ts.isObjectLiteralExpression(target)) { + for (const field of target.properties) { + if (ts.isPropertyAssignment(field)) markTarget(field.initializer, site); + else if (ts.isSpreadAssignment(field)) markTarget(field.expression, site); + } + } else if ( + ts.isBinaryExpression(target) && + target.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) + markTarget(target.left, site); + }; + for (const file of files) + ts.walkPreorder(file, (node) => { + if (lowerer.dynamic) unknownKey = file; + if ( + (ts.isPropertyDeclaration(node) || + ts.isPropertyAssignment(node) || + ts.isMethodDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node)) && + ts.isComputedPropertyName(node.name) + ) { + markKey(node.name.expression, node); + } + + if ( + (ts.isPropertyDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node)) && + (ts.isIdentifier(node.name) || + ts.isStringLiteralLike(node.name) || + ts.isNumericLiteral(node.name)) + ) { + if ( + ts.isPropertyDeclaration(node) && + node.initializer && + ts.isArrowFunction(unwrap(node.initializer)) + ) + arrowFields.add(node.name.text); + else receiverFields.add(node.name.text); + } + if ( + (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) && + (ts.isIdentifier(node.name) || + ts.isStringLiteralLike(node.name) || + ts.isNumericLiteral(node.name)) + ) { + if (!ts.isPropertyAssignment(node) || !ts.isArrowFunction(unwrap(node.initializer))) + receiverFields.add(node.name.text); + } + if (ts.isParameter(node) && ts.isIdentifier(node.name) && node.modifiers?.length) + receiverFields.add(node.name.text); + if ( + ts.isMethodDeclaration(node) && + (ts.isIdentifier(node.name) || + ts.isStringLiteralLike(node.name) || + ts.isNumericLiteral(node.name)) + ) { + receiverFields.add(node.name.text); + } + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) { + markTarget( + node.left, + node, + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isArrowFunction(unwrap(node.right)), + ); + if ( + isJsSourceFile(file) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + (ts.isPropertyAccessExpression(node.left) || + (ts.isElementAccessExpression(node.left) && + ts.isStringLiteralLike(node.left.argumentExpression))) + ) + jsAssignments.push(node.left); + } + if (ts.isDeleteExpression(node)) markTarget(node.expression, node); + if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) + ) + markTarget(node.operand, node); + if ( + (ts.isForOfStatement(node) || ts.isForInStatement(node)) && + !ts.isVariableDeclarationList(node.initializer) && + node.initializer.kind !== ts.SyntaxKind.MissingDeclaration + ) + markTarget(node.initializer, node); + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + const base = unwrap(node.expression); + const prototype = + (ts.isPropertyAccessExpression(base) && base.name.text === "prototype") || + (ts.isElementAccessExpression(base) && + ts.isStringLiteralLike(base.argumentExpression) && + base.argumentExpression.text === "prototype"); + if (prototype) { + if (ts.isPropertyAccessExpression(node)) + lowerer.prototypeMethodAccesses.set(node.name.text, node); + else if (ts.isStringLiteralLike(node.argumentExpression)) + lowerer.prototypeMethodAccesses.set(node.argumentExpression.text, node); + else markKey(node.argumentExpression, node); + } + } + // Extracted reflection functions and aliases of Object/Reflect can + // mutate arbitrary names. Direct calls below retain precise keys. + if (ts.isIdentifier(node) && (node.text === "Object" || node.text === "Reflect")) + globals.push(node); + if ( + ts.isPropertyAccessExpression(node) && + [ + "setPrototypeOf", + "defineProperty", + "set", + "deleteProperty", + "defineProperties", + "assign", + ].includes(node.name.text) + ) + reflection.push(node); + }); + // The census crosses deferred bodies. Batch only the binding and key + // queries it actually needs instead of eagerly querying every identifier. + lowerer.checker.prefetchClassCollection( + jsAssignments.flatMap((access) => + ts.isElementAccessExpression(access) ? [access.expression] : [], + ), + jsAssignments.flatMap((access) => (ts.isPropertyAccessExpression(access) ? [access.name] : [])), + ); + for (const access of jsAssignments) { + const name = ts.isPropertyAccessExpression(access) + ? access.name.text + : (access.argumentExpression as ts.StringLiteral).text; + const symbol = ts.isPropertyAccessExpression(access) + ? lowerer.checker.getSymbolAtLocation(access.name) + : lowerer.checker.getPropertyOfType(lowerer.typeOf(access.expression), name); + if (symbol && lowerer.checker.declarationsOf(symbol).some(ts.isMethodDeclaration)) + lowerer.prototypeMethodAccesses.set(name, access); + } + lowerer.checker.prefetchSymbolRoots([...globals, ...reflection]); + for (const node of globals) + if ( + (lowerer.isStdlibGlobal(node, "Object") || lowerer.isStdlibGlobal(node, "Reflect")) && + (!ts.isPropertyAccessExpression(node.parent) || node.parent.expression !== node) + ) + unknownKey = node; + for (const node of reflection) { + const objectMethod = lowerer.stdlibGlobalMember(node, "Object"); + const reflectMethod = lowerer.stdlibGlobalMember(node, "Reflect"); + if (objectMethod === "setPrototypeOf" || reflectMethod === "setPrototypeOf") unknownKey = node; + const single = + objectMethod === "defineProperty" || + reflectMethod === "defineProperty" || + reflectMethod === "set" || + reflectMethod === "deleteProperty"; + const multiple = objectMethod === "defineProperties" || objectMethod === "assign"; + if (!single && !multiple) continue; + const call = node.parent; + if (!ts.isCallExpression(call) || call.expression !== node) { + unknownKey = node; + continue; + } + const receiver = call.arguments[0]; + if (single && call.arguments[1]) markKey(call.arguments[1], call, receiver); + else if (objectMethod === "defineProperties" && call.arguments[1]) + markFields(call.arguments[1], call, receiver); + else if (objectMethod === "assign") + for (const source of call.arguments.slice(1)) markFields(source, call, receiver); + } + lowerer.checker.prefetchClassCollection( + computedWrites.flatMap((write) => [ + ...(write.key ? [write.key] : []), + ...(write.receiver ? [write.receiver] : []), + ]), + computedWrites.flatMap((write) => [ + ...(write.key ? [write.key] : []), + ...(write.receiver ? [write.receiver] : []), + ]), + ); + for (const { key, receiver, site } of computedWrites) { + const type = key ? lowerer.typeOf(key) : undefined; + const names = type && (literalValues(type) ?? (key ? literalLoopKeys(key) : null)); + if (names) { + for (const name of names) markName(String(name), site); + continue; + } + if (key && receiver && preservesExistingMethod(key, receiver, site)) continue; + if (key && receiver && deletesOwnEnumerableKey(key, receiver, site)) { + // Class methods are non-enumerable prototype properties. Own callback + // fields remain observable, so this proof does not certify those. + unknownOwnField = true; + continue; + } + // A primitive-only string index excludes callable method slots. Unlike + // structural interfaces, it cannot be a live class-method view. + if (receiver && (freshReceiver(receiver) || primitiveDictionary(receiver))) continue; + const flags = type?.flags ?? ts.TypeFlags.Unknown; + if (flags & ts.TypeFlags.NumberLike) { + // Numeric writes can replace numeric names but not ordinary names. + numericKey = site; + } else if (!(flags & ts.TypeFlags.ESSymbolLike)) { + unknownKey = site; + } + } + if (numericKey) { + for (const name of [...methods, ...arrowFields]) { + if (String(Number(name)) === name) markName(name, numericKey); + } + } + if (unknownKey) + for (const method of methods) lowerer.prototypeMethodAccesses.set(method, unknownKey); + if (!unknownKey && !unknownOwnField) + for (const field of arrowFields) { + if (!receiverFields.has(field)) lowerer.receiverFreeCallbackFields.add(field); + } +} diff --git a/packages/compiler/src/frontend/lowering/collection-narrowing.ts b/packages/compiler/src/frontend/lowering/collection-narrowing.ts new file mode 100644 index 000000000..57b75daf5 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/collection-narrowing.ts @@ -0,0 +1,36 @@ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; + +/** instanceof uses Map/Set in the standard declarations. + * Recover an unambiguous native collection's stored arguments when that + * test erased a readonly view's arguments. Never choose between layouts. */ +export function concreteCollectionNarrow( + lowerer: Lowerer, + node: ts.Node, + narrowed: ts.Type, +): ts.Type | null { + if (!ts.isIdentifier(node) && !ts.isPropertyAccessExpression(node)) return null; + const symbol = narrowed.getSymbol(); + const name = symbol?.name; + if (name !== "Map" && name !== "Set") return null; + if ( + !symbol || + !lowerer.checker + .declarationsOf(symbol) + .some((decl) => lowerer.isStdlibFile(decl.getSourceFile())) + ) + return null; + const args = lowerer.checker.getTypeArguments(narrowed as ts.TypeReference); + if (args.length === 0 || !args.every((arg) => (arg.flags & ts.TypeFlags.Any) !== 0)) return null; + const binding = lowerer.checker.getSymbolAtLocation( + ts.isPropertyAccessExpression(node) ? node.name : node, + ); + if (!binding) return null; + const stored = lowerer.checker.getTypeOfSymbol(binding); + const arms = stored.isUnionType() ? ts.constituentTypes(stored) : [stored]; + const candidates = arms.filter((arm) => { + const mapped = lowerer.mapTypeOf(arm); + return mapped?.kind === (name === "Map" ? "map" : "set"); + }); + return candidates.length === 1 ? candidates[0]! : null; +} diff --git a/packages/compiler/src/frontend/lowering/containers/array-callback-call.ts b/packages/compiler/src/frontend/lowering/containers/array-callback-call.ts new file mode 100644 index 000000000..b88de3795 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/containers/array-callback-call.ts @@ -0,0 +1,63 @@ +import * as ts from "../../ts7/adapter.js"; +import { locOf } from "../../program.js"; +import { varRef } from "../../../ir/build.js"; +import { DYN, type IrExpr, type IrStmt, isUnitType } from "../../../ir/ir.js"; +import type { Lowerer } from "../lowerer.js"; +import { completeFuncValueArgs } from "../call-arguments.js"; +import { lowerOptionalNumber } from "../lower-exprs.js"; +import { lowerSafeIndexRead } from "../array-values.js"; + +/** Retain native function slots during indexed calls. The array remains + * the receiver, and the selected callback is captured before arguments + * can replace it. Missing slots throw only after argument evaluation. */ +export function lowerArrayCallbackCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.ElementAccessExpression, + array: IrExpr, +): IrExpr | null { + if (array.type.kind !== "array" || call.arguments.some(ts.isSpreadElement)) return null; + const elem = array.type.elem; + const arms = elem.kind === "union" ? (lowerer.unions.get(elem.unionId)?.arms ?? []) : [elem]; + const functions = arms.filter((arm) => arm.kind === "func"); + const fn = functions[0]; + if ( + functions.length !== 1 || + fn?.kind !== "func" || + !arms.every((arm) => arm.kind === "func" || isUnitType(arm)) || + fn.rest + ) + return null; + if (!(lowerer.typeOf(access.argumentExpression).flags & ts.TypeFlags.NumberLike)) return null; + const loc = locOf(call); + const statements: IrStmt[] = []; + const store = (value: IrExpr, prefix: string): IrExpr => { + const local = lowerer.declareHiddenLocal(prefix, value.type); + statements.push({ kind: "varDecl", localId: local.id, init: value, loc }); + return varRef(local.id, local.type, loc); + }; + const receiver = store(array, "%callbackArray"); + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(access.argumentExpression), + locOf(access.argumentExpression), + access.argumentExpression, + ); + const read = lowerSafeIndexRead(lowerer, receiver, index, loc); + if (!read || read.type.kind !== "union") return null; + const callee = store(read, "%arrayCallback"); + const args = completeFuncValueArgs(lowerer, call, fn, loc).map((arg) => + store(arg, "%callbackArg"), + ); + const helper = lowerer.narrowedArmHelper(read.type.unionId, fn, loc); + if (!helper) return null; + const result: IrExpr = { + kind: "callValue", + callee: { kind: "call", callee: helper, args: [callee], type: fn, loc }, + receiver: lowerer.coerceToExpected(receiver, DYN), + args, + type: fn.ret, + loc, + }; + return { kind: "seqExpr", stmts: statements, result, type: result.type, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index 96c53721e..ef06b67b7 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -1904,92 +1904,6 @@ export function lowerTupleReadMethodCall( return { kind: "call", callee: helper, args: [receiver, fnArg], type: outType, loc }; } -/** An ordinary indexed read: values retain their element type, while - * holes, missing properties, and present undefined yield undefined. */ -export function lowerSafeIndexRead( - lowerer: Lowerer, - arr: IrExpr, - index: IrExpr, - loc: SrcLoc, -): IrExpr | null { - if (arr.type.kind !== "array") throw new InternalCompilerError("indexed read requires an array"); - const elem = arr.type.elem; - if (elem.kind === "void") return null; - const resultT = arrayValueType(lowerer, elem); - const key = `idxOr:${typeKey(elem)}`; - let name = lowerer.arrHofHelpers.get(key); - if (!name) { - name = `%arr.idxOr.${lowerer.arrHofHelpers.size}`; - lowerer.arrHofHelpers.set(key, name); - const arrT = arr.type; - lowerer.liftedFns.push({ - name, - params: [ - { localId: "a.0", name: "a", type: arrT }, - { localId: "i.0", name: "i", type: F64 }, - ], - returnType: resultT, - locals: [ - { id: "a.0", name: "a", type: arrT, mutable: false }, - { id: "i.0", name: "i", type: F64, mutable: false }, - ], - body: [ - { - kind: "return", - value: arrayValueRead( - lowerer, - varRef("a.0", arrT, loc), - varRef("i.0", F64, loc), - elem, - loc, - ), - loc, - }, - ], - loc, - }); - } - return { kind: "call", callee: name, args: [arr, index], type: resultT, loc }; -} - -/** Fuse only our own f64-array read helper with immediate ToNumber. - * Reuse its arguments verbatim so the receiver and index still evaluate - * exactly once, in order, before the read. Do not specialize user calls, - * union-element arrays, or optional values stored in locals. */ -export function tryLowerNumericIndexRead( - lowerer: Lowerer, - operand: IrExpr, - loc: SrcLoc, -): IrExpr | null { - if ( - operand.kind !== "call" || - operand.callee !== lowerer.arrHofHelpers.get(`idxOr:${typeKey(F64)}`) - ) - return null; - const [arr, index] = operand.args; - if ( - operand.args.length !== 2 || - arr?.type.kind !== "array" || - arr.type.elem.kind !== "f64" || - index?.type.kind !== "f64" - ) - return null; - // The intrinsic owns the evaluated receiver through index evaluation, - // then borrows it for one slot lookup. No extra helper parameter or - // separate state/getter expressions need to retain the array again. - return { - kind: "arrIntrinsic", - method: "getNumber", - receiver: arr, - args: [index], - type: F64, - loc, - }; -} - -/** Backward-compatible name for the npm-static probe path. */ -export const lowerNpmStaticSafeIndexRead = lowerSafeIndexRead; - /** Interned synthetic function for one (elem, argument-shape) concat — * `%arr.concat.(a, x0, x1, ...)`: a fresh array takes a's elements * (pushSpread), then each argument pushes (element) or spreads (array) diff --git a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts index 3dbfc2477..06890193a 100644 --- a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts @@ -92,7 +92,17 @@ export function lowerMapMethodCall( // result union. `undefined` sorts LAST among all possible arm // typeKeys, so when V is itself a union its arms keep their tags in // the result union — the backend leans on that (docs/ir.md). - const type = receiverIr.value.kind === "dyn" ? DYN : lowerer.irTypeOf(call); + // instanceof on a readonly view can erase the checker's value type + // to any. Recover that case from native storage; otherwise preserve + // the contextual return mapping for recursive and generic values. + const erased = (lowerer.typeOf(call).flags & ts.TypeFlags.Any) !== 0; + const type = + receiverIr.value.kind === "dyn" + ? DYN + : erased + ? (lowerer.withUndefinedArmOf(receiverIr.value) ?? + lowerer.withUndefinedArm(receiverIr.value)) + : lowerer.irTypeOf(call); if (type.kind !== "union" && type.kind !== "dyn") lowerer.badType(call, lowerer.typeOf(call)); return { kind: "mapIntrinsic", method: "get", receiver, args: [k], type, loc }; } diff --git a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts index 3829c846c..a9df9e662 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts @@ -102,6 +102,7 @@ export function isNativeBuiltinValueInitializer( lowerer.stdlibGlobalMember(expr, "globalThis") === "console" || lowerer.stdlibGlobalMember(expr, "process") === "getBuiltinModule" || lowerer.stdlibGlobalMember(expr, "process") === "hrtime" || + ["stdin", "stdout", "stderr"].includes(lowerer.stdlibGlobalMember(expr, "process") ?? "") || (expr.name.text === "bigint" && ts.isPropertyAccessExpression(expr.expression) && lowerer.stdlibGlobalMember(expr.expression, "process") === "hrtime") || diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 225e13aec..1298bddaa 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,3 +1,4 @@ +import { lowerArrayCallbackCall } from "./containers/array-callback-call.js"; /** Dispatch source calls, builtin members and receiver-specific operations. * Signature collection, argument completion and function bodies have their * own owners; this module selects the applicable call path. */ @@ -1422,6 +1423,25 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { if (sig && !(jsSpreadArgs && spreadNeedsRuntimeArity(lowerer, sig.params, expr.arguments))) { lowerer.noteEdge(sig.name); const args = lowerer.completeArgs(expr.arguments, sig.params, loc, expr); + const symbol = lowerer.resolveValueSymbol(expr.expression); + const declaration = symbol ? lowerer.checker.valueDeclarationOf(symbol) : undefined; + // A bare call supplies undefined as this, even inside a callback + // currently carrying a receiver. The value-call ABI saves and + // restores that ambient receiver around the function invocation. + if ( + declaration && + ts.isFunctionDeclaration(declaration) && + declaration.parameters.some(isThisParameter) + ) { + const callee = lowerer.lowerExpr(expr.expression); + return reconcileOverloadReturn(lowerer, expr, { + kind: "callValue", + callee, + args, + type: sig.returnType, + loc, + }); + } return reconcileOverloadReturn(lowerer, expr, { kind: "call", callee: sig.name, @@ -1871,6 +1891,11 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { ) { const access = expr.expression; const value = tryLowerExpression(lowerer, access.expression); + if (value?.type.kind === "array" && !isJsSourceFile(expr.getSourceFile())) { + const callback = lowerArrayCallbackCall(lowerer, expr, access, value); + if (callback) return callback; + } + if ( value?.type.kind === "dyn" || value?.type.kind === "generator" || @@ -8991,7 +9016,9 @@ export function lowerObjectMethodCall( const receiverLocal = lowerer.declareHiddenLocal("%callReceiver", target.obj.type); const init: IrStmt = { kind: "varDecl", localId: receiverLocal.id, init: target.obj, loc }; target.obj = { kind: "varRef", localId: receiverLocal.id, type: receiverLocal.type, loc }; - const receiver = lowerer.coerceToExpected(target.obj, DYN); + const receiver: { receiver?: IrExpr } = {}; + if (!lowerer.receiverFreeCallbackFields.has(access.name.text)) + receiver.receiver = lowerer.coerceToExpected(target.obj, DYN); const finish = (result: IrExpr): IrExpr => ({ kind: "seqExpr", stmts: [init], @@ -9021,12 +9048,12 @@ export function lowerObjectMethodCall( ) { const boxed: IrExpr = { kind: "dynFrom", value: callee, type: DYN, loc }; const spread = lowerSpreadArgsCall(lowerer, call, boxed, loc); - if (spread?.kind === "dynCall") return finish({ ...spread, receiver }); + if (spread?.kind === "dynCall") return finish({ ...spread, ...receiver }); const args = call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)); return finish({ kind: "dynCall", callee: boxed, - receiver, + ...receiver, calleeName: access.getText(), args, type: DYN, @@ -9034,7 +9061,7 @@ export function lowerObjectMethodCall( }); } const args = completeFuncValueArgs(lowerer, call, callee.type, locOf(call)); - return finish({ kind: "callValue", callee, receiver, args, type: callee.type.ret, loc }); + return finish({ kind: "callValue", callee, ...receiver, args, type: callee.type.ret, loc }); } if (callee?.type.kind === "dyn") { if (call.arguments.some((a) => ts.isSpreadElement(a))) { @@ -9044,7 +9071,7 @@ export function lowerObjectMethodCall( return finish({ kind: "dynCall", callee, - receiver, + ...receiver, calleeName: access.getText(), args, type: DYN, diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 452099ff1..0c5fd0f26 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1,4 +1,5 @@ import { dynUndefinedExpr, nodeThrowExpr } from "../../ir/build.js"; +import { collectClassMethodMutations } from "./class-method-mutations.js"; import { InternalCompilerError } from "../../errors.js"; /* Class lowering: shape collection over the single-inheritance graph * (fields, methods, accessors, overrides), constructor/member lowering with @@ -758,6 +759,10 @@ export function registerBuiltinErrorClasses(lowerer: Lowerer): void { { name: "%nameEnumerable", type: BOOL }, { name: "%stackFrames", type: STRING }, { name: "%stack", type: STRING }, + { name: "%systemErrno", type: F64 }, + { name: "%systemCall", type: STRING }, + { name: "%systemPath", type: STRING }, + { name: "%systemDest", type: STRING }, ], loc, }, @@ -5984,36 +5989,7 @@ export function collectInheritedJsFieldWrites( * hierarchy identifies the original declarations through aliases as well. */ export function collectVirtualJsMethods(lowerer: Lowerer, files: readonly ts.SourceFile[]): void { const families: [ts.MethodDeclaration, ts.MethodDeclaration][] = []; - // Record named prototype accesses before collecting method signatures. - // Those slots need a callable ABI even when JavaScript would otherwise - // specialize each call independently. - for (const file of files) - ts.walkPreorder(file, (node) => { - if (!ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) return; - const name = ts.isPropertyAccessExpression(node) - ? node.name.text - : ts.isStringLiteralLike(node.argumentExpression) - ? node.argumentExpression.text - : null; - if (name === null) return; - const prototype = - ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "prototype"; - const assigned = - ts.isBinaryExpression(node.parent) && - node.parent.left === node && - node.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; - const symbol = !assigned - ? undefined - : ts.isPropertyAccessExpression(node) - ? lowerer.checker.getSymbolAtLocation(node.name) - : lowerer.checker.getPropertyOfType(lowerer.typeOf(node.expression), name); - if ( - prototype || - (symbol && lowerer.checker.declarationsOf(symbol).some(ts.isMethodDeclaration)) - ) { - lowerer.prototypeMethodAccesses.set(name, node); - } - }); + collectClassMethodMutations(lowerer, files); const visit = (node: ts.Node): void => { if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { const accessors = node.members.filter( diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 6afd99152..1392a24e4 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1,3 +1,4 @@ +import { isOptionalProcessStreamProperty } from "./builtins/process.js"; import { lowerWorkerMetadata } from "./builtins/workers.js"; import { dynUndefinedExpr, @@ -105,12 +106,13 @@ import { import { PoisonError, dynFallbackType, jsFuncNameOf, neverTaintedJsType, own } from "./lowerer.js"; import { lowerCollectionSpread } from "./containers/collection-methods.js"; import { - lowerNpmStaticSafeIndexRead, + arrayValueRead, + arrayValueStore, + arrayValueType, lowerSafeIndexRead, tryLowerNumericIndexRead, -} from "./containers/array-methods.js"; +} from "./array-values.js"; import { strCharsCall } from "./containers/array-construction.js"; -import { arrayValueRead, arrayValueStore, arrayValueType } from "./array-values.js"; import { lowerOptionalStringIndex } from "./string-index.js"; import { tryLowerIndexedComparison } from "./indexed-comparison.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; @@ -3583,6 +3585,9 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx return expr; } if (expr.type.kind !== "union") return expr; + if (ts.isPropertyAccessExpression(node) && isOptionalProcessStreamProperty(lowerer, node)) + return expr; + // Represented fields and index values can include undefined even when // checker inference or lib.d.ts declares strings (RegExp captures). // Only a declaration that represents the optional arm can provide a @@ -4316,7 +4321,26 @@ function lowerNullishPair( // arm that has no first-class union representation. const fresh = rest.length === 1 ? lowerer.emptyCollectionFor(expr.right, rest[0]!) : null; if (fresh) return { kind: "nullish", left, right: fresh, type: fresh.type, loc }; - const type = lowerer.irTypeOf(expr); + let type = lowerer.irTypeOf(expr); + // A runtime-optional read can reach the default even when declarations + // call the left side present. Preserve a differently typed primitive + // fallback rather than checking it against that incomplete result type. + const rightType = lowerer.mapTypeOf(lowerer.typeOf(expr.right)); + const primitive = (t: IrType): boolean => + t.kind === "f64" || t.kind === "string" || t.kind === "bool"; + if ( + rightType && + primitive(type) && + primitive(rightType) && + !typeEquals(type, rightType) && + rest.every(primitive) + ) { + const arms = [...rest]; + if (!arms.some((arm) => typeEquals(arm, rightType))) arms.push(rightType); + arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + type = { kind: "union", unionId: lowerer.unions.intern(arms) }; + lowerer.runtimeOptionalArithmeticTypes.set(expr, type); + } if (type.kind === "dyn" && lowerer.dynConvertible(left.type)) { return { kind: "nullish", @@ -6397,7 +6421,12 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre // exit primitives eagerly): the engine element read, exiting at the // declared number type. if (recv.type.kind === "jsval") return islandElementRead(lowerer, expr, recv); - const index = lowerer.lowerExpr(expr.argumentExpression); + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(expr.argumentExpression), + locOf(expr.argumentExpression), + expr.argumentExpression, + ); if (isJsSourceFile(expr.getSourceFile()) && recv.type.kind === "bytes") { return { kind: "dynKeyGet", @@ -6698,38 +6727,23 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre } } if (receiverIr?.kind === "string") { - // `s[i]` with a number index reads a UTF-16 code unit — charAt's - // exact job (the UTF-16-exact runtime). Without - // noUncheckedIndexedAccess the checker types the read `string`, - // and charAt's "" is the only string-typed answer for an - // out-of-range or fractional index where JS reads `undefined` — - // SEMANTICS.md documents the divergence; in-range integer reads - // (the loop pattern) are JS-exact. Optional results instead retain - // undefined for missing string properties. + // An indexed string property is absent for non-integer and missing + // indexes. charAt has different coercion and out-of-range semantics. const recv = lowerer.lowerExpr(expr.expression); - const index = lowerer.lowerExpr(expr.argumentExpression); - const resultType = lowerer.mapTypeOf(lowerer.typeOf(expr)); + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(expr.argumentExpression), + locOf(expr.argumentExpression), + expr.argumentExpression, + ); if (index.type.kind === "f64" && recv.type.kind === "string") { - if (resultType?.kind === "string") { - return { - kind: "strIntrinsic", - method: "charAt", - receiver: recv, - args: [index], - type: STRING, - loc: locOf(expr), - }; - } - if (resultType?.kind === "union") { - const arms = lowerer.unions.get(resultType.unionId)?.arms; - if ( - arms?.length === 2 && - lowerer.armTag(resultType.unionId, STRING) >= 0 && - lowerer.armTag(resultType.unionId, UNDEFINED_T) >= 0 - ) { - return lowerOptionalStringIndex(lowerer, recv, index, resultType, locOf(expr)); - } - } + return lowerOptionalStringIndex( + lowerer, + recv, + index, + lowerer.withUndefinedArm(STRING), + locOf(expr), + ); } lowerer.unsupported( "SC1090", @@ -6902,7 +6916,7 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre arr.method === "slice" && npmStaticPackageOfPath(expr.getSourceFile().fileName) !== null ) { - const safe = lowerNpmStaticSafeIndexRead(lowerer, arr, index, locOf(expr)); + const safe = lowerSafeIndexRead(lowerer, arr, index, locOf(expr)); if (safe) return safe; } // A union-element read narrows like an identifier when the checker has @@ -8525,13 +8539,9 @@ export function lowerAsExpression( }; } } - // Narrowed reads (`err instanceof Error` proven) still pass below; - // other targets keep the narrowness fence with the checked-cast fix. - // In a JS FILE the AsExpression is tsgo's spelling of a JSDoc cast - // (`/** @type {T} */ (e)` — JS has no `as`); 5.9.3 parsed that as a - // plain parenthesized read, which lowered caught→dyn and met the - // target through the ordinary checked-cast coercion — so the dyn - // bridge stays open here exactly for that shape. + // Structural assertions use the ordinary checked dynamic bridge. It + // retains primitive values and native class identity before validating + // the requested shape; an assertion does not bypass runtime checks. const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); const bridged = narrowed && @@ -8539,7 +8549,7 @@ export function lowerAsExpression( narrowed.kind === "bool" || narrowed.kind === "string" || narrowed.kind === "object" || - (narrowed.kind === "dyn" && isJsSourceFile(expr.getSourceFile()))); + narrowed.kind === "dyn"); if (!bridged) lowerer.unsupported("SC1063", expr); } const inner = lowerer.lowerExpr(expr.expression); @@ -10074,7 +10084,7 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr } const optionalStringPlus = lowerOptionalStringPlus(); if (optionalStringPlus) return optionalStringPlus; - if (isJsSourceFile(expr.getSourceFile())) { + { const primitive = (type: IrType): boolean => type.kind === "f64" || type.kind === "string" || @@ -11542,6 +11552,37 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc ) { const collection = lowerer.isStdlibGlobal(expr.right, "Map") ? "map" : "set"; const value = lowerer.lowerExpr(expr.left); + if (value.type.kind === "union") { + const union = lowerer.unions.get(value.type.unionId); + if (union) { + const local = lowerer.declareHiddenLocal("%collectionTest", value.type); + const reference = varRef(local.id, value.type, loc); + let result: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; + union.arms.forEach((arm, tag) => { + if (arm.kind !== collection) return; + const test: IrExpr = { + kind: "unionIsTag", + unionId: union.id, + value: reference, + tag, + negated: false, + type: BOOL, + loc, + }; + result = + result.kind === "boolLit" + ? test + : { kind: "logical", op: "||", left: result, right: test, type: BOOL, loc }; + }); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }], + result, + type: BOOL, + loc, + }; + } + } if (value.type.kind === "dyn") return { kind: "libCall", diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index e106483a0..2c004133b 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -25,7 +25,30 @@ import { appendImplicitUndefinedReturn, blockBodyOf, } from "./function-returns.js"; -import { generatorMeta, type ParamShape } from "./call-signatures.js"; +import { generatorMeta, isThisParameter, type ParamShape } from "./call-signatures.js"; + +/** Save the call-site receiver before defaults or body effects. Escaping + * arrows capture this binding instead of reading a later call's receiver. */ +function declareCallReceiver( + lowerer: Lowerer, + node: ts.FunctionLikeDeclaration, + generator: boolean, +): IrStmt { + const receiver = lowerer.declareThis(DYN); + const loc = locOf(node); + return { + kind: "varDecl", + localId: receiver.id, + init: { + kind: "libCall", + fn: generator ? "dyn.generatorThis" : "dyn.this", + args: [], + type: DYN, + loc, + }, + loc, + }; +} /** True when the identifier resolves (through import aliases) to a * top-level function declaration of ANY program file (not merely a @@ -253,25 +276,15 @@ export function lowerLambda( const diagsBefore = lowerer.diags.length; lowerer.fnStack.push(fnCtx); try { + const receiver = + (checkedReceiver || + isJsSourceFile(node.getSourceFile()) || + node.parameters.some(isThisParameter)) && + !ts.isArrowFunction(node) + ? declareCallReceiver(lowerer, node, isGenerator) + : null; const { params, prologue } = lowerer.declareParams(node.parameters, shapes); - // Checked JavaScript functions take their receiver from the call site. - // Store it as this function's binding so escaping arrows capture it, - // and a method nested inside another method never captures its owner. - if ((checkedReceiver || isJsSourceFile(node.getSourceFile())) && !ts.isArrowFunction(node)) { - const receiver = lowerer.declareThis(DYN); - prologue.unshift({ - kind: "varDecl", - localId: receiver.id, - init: { - kind: "libCall", - fn: isGenerator ? "dyn.generatorThis" : "dyn.this", - args: [], - type: DYN, - loc, - }, - loc, - }); - } + if (receiver) prologue.unshift(receiver); declareFunctionArguments( lowerer, node, @@ -447,7 +460,12 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I const diagsBefore = lowerer.diags.length; lowerer.fnStack.push(ctx); try { + const receiver = + decl.parameters.some(isThisParameter) || isJsSourceFile(decl.getSourceFile()) + ? declareCallReceiver(lowerer, decl, sig.generator !== undefined) + : null; const { params, prologue } = lowerer.declareParams(decl.parameters, sig.params); + if (receiver) prologue.unshift(receiver); declareFunctionArguments( lowerer, decl, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ac3f2671a..55a31c85d 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -16,6 +16,7 @@ import { arrayValueStore, arrayValueType, unionArrayValueRead, + lowerSafeIndexRead, } from "./array-values.js"; import { lowerForAwaitGenerator, @@ -96,7 +97,6 @@ import { lowerForOfSearchParams, lowerForOfSet, } from "./containers/for-of.js"; -import { lowerSafeIndexRead } from "./containers/array-methods.js"; import { objectIterOverIndexShape } from "./containers/indexed-objects.js"; import { strCharsCall } from "./containers/array-construction.js"; import { @@ -4691,10 +4691,13 @@ export function lowerVarDecl( const arithmetic = raw.type.kind === "union" ? lowerer.unions.get(raw.type.unionId)?.arms : undefined; if ( - g.type.kind === "string" && arithmetic?.length === 2 && - arithmetic.some((a) => a.kind === "f64") && - arithmetic.some((a) => a.kind === "string") + arithmetic.every((a) => a.kind === "f64" || a.kind === "string" || a.kind === "bool") && + (lowerer.runtimeOptionalArithmeticTypes.has(decl.initializer) || + lowerer.runtimeOptionalBindingType(decl.name)?.kind === "union" || + (g.type.kind === "string" && + arithmetic.some((a) => a.kind === "f64") && + arithmetic.some((a) => a.kind === "string"))) ) { g.type = raw.type; lowerer.runtimeOptionalArithmeticGlobals.add(g); @@ -5107,9 +5110,13 @@ export function lowerVarDecl( ); }; const runtimeStringArithmetic = - settledType.kind === "string" && - ((arithmeticType && typeEquals(arithmeticType, init.type)) || - isStringArithmeticUnion(init.type)); + (arithmeticType !== undefined && typeEquals(arithmeticType, init.type)) || + (settledType.kind === "string" && isStringArithmeticUnion(init.type)) || + (init.type.kind === "union" && + typeEquals(lowerer.runtimeOptionalBindingType(decl.name, settledType), init.type) && + lowerer.unions + .get(init.type.unionId) + ?.arms.every((a) => a.kind === "f64" || a.kind === "string" || a.kind === "bool") === true); if (runtimeStringArithmetic) { settledType = init.type; } @@ -5255,7 +5262,7 @@ export function lowerVarDecl( // subtyping (`const p: {a: number} = wider;`) is rejected, not coerced. init = lowerer.coerceInto(decl.initializer, init, settledType); const local = lowerer.declareLocal(decl.name, decl.name.text, settledType, isLet); - if (runtimeStringArithmetic && isStringArithmeticUnion(init.type)) { + if (runtimeStringArithmetic && init.type.kind === "union") { lowerer.runtimeOptionalArithmeticLocals.add(lowerer.runtimeOptionalRootOf(local)); } if (runtimeOptional) { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index c71007209..d73d83370 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,3 +1,5 @@ +import { isOptionalProcessStreamProperty } from "./builtins/process.js"; +import { concreteCollectionNarrow } from "./collection-narrowing.js"; import { lowerWorkerMetadata } from "./builtins/workers.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../ir/build.js"; import type { FieldLift } from "./coercions/structural-plans.js"; @@ -2249,6 +2251,8 @@ export class Lowerer { /** Named method slots observed before class collection need a callable * ABI and shared prototype descriptors instead of call-site specialization. */ readonly prototypeMethodAccesses = new Map(); + /** Field names whose complete source census stores only lexical arrows. */ + readonly receiverFreeCallbackFields = new Set(); /** Inferred JS methods participating in an override chain keep a vtable * ABI instead of call-site specialization. Filled before class collection. */ readonly virtualJsMethods = new Set(); @@ -3628,6 +3632,7 @@ export class Lowerer { const optionalSymbols = new Set(); const optionalReturns = new Set(); const arithmeticReturns = new Map(); + const primitiveBindings = new Map(); const optionalParams = new Map>(); const optionalFields = new Map>(); const dynamicObjectEntryRows = new Set(); @@ -3758,10 +3763,11 @@ export class Lowerer { } return false; }; - const isArrayRead = (node: ts.Expression): boolean => { + const isIndexedRead = (node: ts.Expression): boolean => { const e = peel(node); if (!ts.isElementAccessExpression(e)) return false; - return this.mapTypeOf(this.typeOf(e.expression))?.kind === "array"; + const kind = this.mapTypeOf(this.typeOf(e.expression))?.kind; + return kind === "array" || kind === "string"; }; const isDynamicObjectEntryRead = (node: ts.Expression): boolean => { const read = peel(node); @@ -3796,7 +3802,8 @@ export class Lowerer { // function/global ABI to `T | undefined`. if (explicitlyNonNull(node)) return false; const e = peel(node); - if (isArrayRead(e)) return true; + if (isIndexedRead(e)) return true; + if (ts.isPropertyAccessExpression(e) && isOptionalProcessStreamProperty(this, e)) return true; if (ts.isCallExpression(e) && this.runtimeOptionalReduceTypes.has(e)) return true; if ( ts.isCallExpression(e) && @@ -3848,28 +3855,43 @@ export class Lowerer { } return false; }; - const optionalStringArithmeticType = (node: ts.Expression): IrType | null => { + const optionalPrimitiveResultType = (node: ts.Expression): IrType | null => { const e = peel(node); - if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.PlusToken) + if (ts.isIdentifier(e)) { + const symbol = symbolOf(e); + return symbol ? (primitiveBindings.get(symbol) ?? null) : null; + } + if (ts.isCallExpression(e)) { + const symbol = callableSymbolOf(e.expression); + return symbol ? (arithmeticReturns.get(symbol) ?? null) : null; + } + if (!ts.isBinaryExpression(e)) return null; + if (e.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && mayBeOptional(e.left)) { + const left = this.mapTypeOf(this.typeOf(e.left)); + const right = this.mapTypeOf(this.typeOf(e.right)); + const primitive = (t: IrType | null): t is IrType => + t?.kind === "f64" || t?.kind === "string" || t?.kind === "bool"; + if (primitive(left) && primitive(right) && !typeEquals(left, right)) { + const arms = [left, right].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + return { kind: "union", unionId: this.unions.intern(arms) }; + } return null; - const stringArrayRead = (part: ts.Expression): boolean => { + } + if (e.operatorToken.kind !== ts.SyntaxKind.PlusToken) return null; + const stringRead = (part: ts.Expression): boolean => { const p = peel(part); if (!ts.isElementAccessExpression(p)) return false; const t = this.mapTypeOf(this.typeOf(part)); if (t?.kind !== "string") return false; const recv = this.mapTypeOf(this.typeOf(p.expression)); - return recv?.kind === "array" && recv.elem.kind === "string"; + return recv?.kind === "string" || (recv?.kind === "array" && recv.elem.kind === "string"); }; const primitive = (part: ts.Expression): boolean => { const t = this.mapTypeOf(this.typeOf(part)); return t?.kind === "f64" || t?.kind === "string"; }; - if (!stringArrayRead(e.left) && !stringArrayRead(e.right)) return null; - if ( - !(primitive(e.left) || stringArrayRead(e.left)) || - !(primitive(e.right) || stringArrayRead(e.right)) - ) - return null; + if (!stringRead(e.left) && !stringRead(e.right)) return null; + if (!primitive(e.left) || !primitive(e.right)) return null; return { kind: "union", unionId: this.unions.intern([F64, STRING]) }; }; const noteField = (decl: ts.VariableDeclaration, name: string): boolean => { @@ -4181,6 +4203,16 @@ export class Lowerer { const scan = (node: ts.Node): void => { if (ts.isVariableDeclaration(node)) { if (node.initializer) { + const result = optionalPrimitiveResultType(node.initializer); + const symbol = ts.isIdentifier(node.name) ? symbolOf(node.name) : null; + if (result?.kind === "union" && symbol) { + const before = primitiveBindings.get(symbol); + if (!before || !typeEquals(before, result)) { + primitiveBindings.set(symbol, result); + this.runtimeOptionalBindingTypes.set(symbol, result); + changed = true; + } + } if (ts.isIdentifier(node.name) && isDynamicObjectEntryRead(node.initializer)) { const symbol = symbolOf(node.name); if (symbol && !dynamicObjectEntryRows.has(symbol)) { @@ -4472,7 +4504,7 @@ export class Lowerer { optionalReturns.add(symbol); changed = true; } - const arithmetic = optionalStringArithmeticType(expression); + const arithmetic = optionalPrimitiveResultType(expression); if (arithmetic && !arithmeticReturns.has(symbol)) { arithmeticReturns.set(symbol, arithmetic); changed = true; @@ -4887,7 +4919,7 @@ export class Lowerer { this.diags.length > 0 ? null : { - irVersion: 14, + irVersion: 15, sourceFile: this.entry.fileName, functions, classes: artifacts.classes, @@ -5974,6 +6006,8 @@ export class Lowerer { const arm = sym !== undefined ? this.aliasNarrowTypes.get(sym) : undefined; if (arm !== undefined) return arm; } + const collection = concreteCollectionNarrow(this, node, t); + if (collection) return collection; // IMPLICIT-ANY instance bodies: an identifier reference to a BOUND // param answers the call site's concrete type wherever the checker // still says `any` (there is no `T` for mapType to substitute — the diff --git a/packages/compiler/src/frontend/lowering/sanitize-class-types.test.ts b/packages/compiler/src/frontend/lowering/sanitize-class-types.test.ts index 31d51d7d7..d80499ef3 100644 --- a/packages/compiler/src/frontend/lowering/sanitize-class-types.test.ts +++ b/packages/compiler/src/frontend/lowering/sanitize-class-types.test.ts @@ -21,7 +21,7 @@ test("fenced instance slots are erased throughout signatures, storage and nested }; const ref = (type: IrType): IrExpr => ({ kind: "varRef", localId: "x", type, loc }); const module: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", globals: [{ id: "g", name: "g", type: composite, mutable: false }], @@ -110,7 +110,7 @@ test("modules without optional tables and registered class types retain their st const loc = { file: "registered.ts", start: 0, end: 1 }; const type: IrType = { kind: "object", className: "Registered" }; const module: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 55453eddb..8080b0fed 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1514,6 +1514,9 @@ export const BUILTIN_MODULE_FNS: Record< isUtf8: { fn: "buffer.isUtf8", params: [DYN], result: BOOL }, transcode: { fn: "buffer.transcode", params: [DYN, DYN, DYN], result: BYTES_U8 }, }, + tty: { + isatty: { fn: "process.isTTY", params: [F64], result: BOOL }, + }, process: { loadEnvFile: { fn: "process.loadEnvFile", params: [DYN], result: VOID }, }, diff --git a/packages/compiler/src/frontend/type-mapper.test.ts b/packages/compiler/src/frontend/type-mapper.test.ts index dbfa8a5ce..cf78554b4 100644 --- a/packages/compiler/src/frontend/type-mapper.test.ts +++ b/packages/compiler/src/frontend/type-mapper.test.ts @@ -73,12 +73,30 @@ describe("nullable collection union builders", () => { }, ); - test("collection generators retain the data-sibling refusal", () => { + test.each([ + [mapOf(STRING, F64), STRING], + [setOf(STRING), F64], + ])("collection generator results retain each data arm", (yieldT, retT) => { + const shapes = new ShapeRegistry(); + const unions = new UnionRegistry(); + const result = genResultRecord(yieldT, retT, shapes, unions); + const value = + result && shapes.get(result.shapeId)?.fields.find((field) => field.name === "value")?.type; + expect(value?.kind).toBe("union"); + if (value?.kind !== "union") throw new Error("missing value union"); + expect(unions.get(value.unionId)?.arms).toEqual( + expect.arrayContaining([yieldT, retT, UNDEFINED_T]), + ); + }); + + test("promise generators retain the data-sibling refusal", () => { expect( - genResultRecord(mapOf(STRING, F64), STRING, new ShapeRegistry(), new UnionRegistry()), - ).toBeNull(); - expect( - genResultRecord(setOf(STRING), F64, new ShapeRegistry(), new UnionRegistry()), + genResultRecord( + { kind: "promise", inner: STRING }, + F64, + new ShapeRegistry(), + new UnionRegistry(), + ), ).toBeNull(); }); }); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 7168390b0..72933b90e 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1550,6 +1550,8 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // runtime-bearing class, callable, indexed, and non-literal data shapes // remain intersections with no representation. if (widened.isIntersectionType()) { + const errorMetadata = mapErrorMetadataIntersection(widened, ctx); + if (errorMetadata) return errorMetadata; const brandedPrimitive = mapBrandedPrimitiveIntersection(widened, ctx); if (brandedPrimitive) return brandedPrimitive; } @@ -2000,7 +2002,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // (fs/exec throw sites stamp `code`), so the type maps to the %Error // root — `err as NodeJS.ErrnoException` from an Error-typed value is a // no-op cast, error-typed listener params accept it, and the `.code` - // read has its own lowering (errno/syscall/path stay per-member fences). + // reads have dedicated lowerings that preserve absent metadata. if (isStdlibInterface("ErrnoException")) return { kind: "object", className: "%Error" }; // spawn consumes a presence-sensitive option bag, including optional // stdio and env values. Keep it checked-dynamic for native normalization. @@ -2445,7 +2447,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { (d) => (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && ctx.isStdlibFile(d.getSourceFile()) && - isDeclaredInAmbientModule(d, "tty"), + (isDeclaredInAmbientModule(d, "tty") || + (isDeclaredInAmbientModule(d, "process") && + isDeclaredInAmbientNamespace(d, "NodeJS"))), )) || (psym?.name === "WritableStream" && checker @@ -2568,8 +2572,8 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // NodeJS.ErrnoException: @types/node's `interface ErrnoException extends // Error` (dns/fs callback error types). It IS an Error at runtime — map // it to the runtime %Error hierarchy like the lib Error interfaces - // above; the optional errno/code/path/syscall extras fence per member - // (the .stack stance). Provenance-checked like the rest. + // above; optional metadata uses the same checked property view. + // Provenance-checked like the rest. if ( psym?.name === "ErrnoException" && checker @@ -4162,7 +4166,7 @@ export function unitOnlyUnion(unions: UnionRegistry): IrType { * answers undefined). ONE shape per channel pair — `g.next()`'s lowering, * `.return()`, `.throw()`, the for-of desugar, and mapType's * IteratorResult alias mapping all intern through here, so reads agree. - * Null when the combined union would be illegal (a container arm beside + * Null when the combined union would be illegal (a promise arm beside * data arms, a regex/Date arm — kinds with no narrowing test here): such * generators stay unmapped. */ export function genResultRecord( @@ -4191,7 +4195,7 @@ export function genResultRecord( if (!add(yieldT) || !add(retT)) return null; byKey.set(typeKey(UNDEFINED_T), UNDEFINED_T); const arms = [...byKey.values()]; - // Containers keep the shared nullable-only rule. Generator function + // Promises keep the shared nullable-only rule. Generator function // payloads retain their existing unit-only boundary too. if ( !unionContainerArmsOk(arms) || @@ -4329,6 +4333,62 @@ function mapHybridCallableIntersection(widened: ts.Type, ctx: TypeMapperCtx): Ir return { kind: "record", shapeId: shapes.intern(fields, false, undefined, declaredOrder) }; } +/** Optional standard error metadata refines an existing Error identity; + * it does not allocate a structural copy or extend its native layout. */ +function mapErrorMetadataIntersection(type: ts.Type, ctx: TypeMapperCtx): IrType | null { + const parts = ts.constituentTypes(type); + // Do not map unrelated intersections speculatively: mapping recursive + // structural types can intern their representation before it is complete. + const hasError = parts.some((part) => { + const symbol = part.getSymbol(); + return ( + symbol !== undefined && + [ + "Error", + "EvalError", + "RangeError", + "ReferenceError", + "SyntaxError", + "TypeError", + "URIError", + "AggregateError", + ].includes(symbol.name) && + ctx.checker.declarationsOf(symbol).some((decl) => ctx.isStdlibFile(decl.getSourceFile())) + ); + }); + if (!hasError) return null; + let error: IrType | null = null; + for (const part of parts) { + const mapped = mapType(part, ctx); + if (mapped?.kind === "object" && RUNTIME_ERROR_CLASSES.has(mapped.className)) { + if (error && !typeEquals(error, mapped)) return null; + error = mapped; + continue; + } + if ( + ctx.checker.getCallSignatures(part).length || + ctx.checker.getConstructSignatures(part).length || + ctx.checker.getIndexInfosOfType(part).length + ) + return null; + for (const property of ctx.checker.getPropertiesOfType(part)) { + const expected = + property.name === "errno" + ? "f64" + : ["code", "syscall", "path", "dest"].includes(property.name) + ? "string" + : null; + if (!expected || !(property.flags & ts.SymbolFlags.Optional)) return null; + const field = mapType(ctx.checker.getTypeOfSymbol(property), ctx); + const arms = + field?.kind === "union" ? ctx.unions.get(field.unionId)?.arms : field ? [field] : []; + if (!arms?.length || !arms.every((arm) => arm.kind === expected || arm.kind === "undefinedT")) + return null; + } + } + return error; +} + /** A conventional primitive brand has one runtime-carrying primitive part * and one or more phantom object parts whose fields are literal marker * values. The marker can use a string or unique-symbol key, can be imported @@ -4971,10 +5031,7 @@ function armHasUnionHome(arm: IrType, siblingCount: number): boolean { case "generator": case "dyn": return false; - // Containers map only beside unit siblings; data siblings have no - // supported runtime narrowing test against them. - case "map": - case "set": + // Promise data siblings still lack a supported narrowing contract. case "promise": return siblingCount === 0; default: diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 03d4a3a91..d8ee6df86 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3395,9 +3395,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "fs.accessSync": { argTypes: [STRING, F64], result: VOID }, "fs.readFdSync": { argTypes: [F64, STRING], result: STRING }, "fs.readFdSyncBytes": { argTypes: [F64], result: BYTES_U8 }, - /** isatty(3) over an fd literal (0/1/2 — process.stdin/stdout/stderr - * .isTTY reads). A real boolean: false where Node's non-TTY streams - * expose undefined (documented divergence). Never throws. */ + /** Validated isatty(3) probe. Stream property lowering maps false to + * undefined; tty.isatty exposes the boolean result directly. */ "process.isTTY": { argTypes: [F64], result: BOOL }, // Like process.envGet: the result is the module's interned // `number | undefined` union — checked by arms in the libCall case. diff --git a/packages/compiler/src/ir/constant-tables.test.ts b/packages/compiler/src/ir/constant-tables.test.ts index c1bc9a094..454880274 100644 --- a/packages/compiler/src/ir/constant-tables.test.ts +++ b/packages/compiler/src/ir/constant-tables.test.ts @@ -20,7 +20,7 @@ const expr = (value: IrExpr): IrStmt => ({ kind: "exprStmt", expr: value, loc }) function fixture(): IrModule { return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", globals: [{ id, name: "table", type, mutable: false }], diff --git a/packages/compiler/src/ir/initializer-bindings.test.ts b/packages/compiler/src/ir/initializer-bindings.test.ts index 6891935c5..d810236b4 100644 --- a/packages/compiler/src/ir/initializer-bindings.test.ts +++ b/packages/compiler/src/ir/initializer-bindings.test.ts @@ -36,7 +36,7 @@ function fixture(): IrModule { ], }; return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: fn.name, functions: [fn], diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index cb5515f73..5a845f2b5 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -771,15 +771,12 @@ export function funcOf(params: IrType[], ret: IrType): IrType { return { kind: "func", params, ret }; } -/** Maps, sets and promises may share a union with null/undefined only. - * Unit tag tests can narrow nullable containers without losing identity; - * arbitrary data siblings require a separate runtime narrowing operation. - * Share this rule across checker mapping, synthesized unions and validation. */ +/** Maps and sets use tagged union storage and retain their native identity. + * Promises still require nullable-only unions until their data-arm narrowing + * and await boundary have a matching contract. */ export function unionContainerArmsOk(arms: IrType[]): boolean { return arms.every( - (a, i) => - (a.kind !== "map" && a.kind !== "set" && a.kind !== "promise") || - arms.every((b, j) => j === i || isUnitType(b)), + (a, i) => a.kind !== "promise" || arms.every((b, j) => j === i || isUnitType(b)), ); } @@ -903,7 +900,7 @@ export function isRefCounted(t: IrType): boolean { export interface IrModule { /** Bumped on any breaking IR change; serialize.ts refuses mismatches. */ - irVersion: 14; + irVersion: 15; sourceFile: string; functions: IrFunction[]; /** Class shapes. Constructors and methods are ordinary module functions diff --git a/packages/compiler/src/ir/scalar-records.test.ts b/packages/compiler/src/ir/scalar-records.test.ts index 5b632639b..0694c12eb 100644 --- a/packages/compiler/src/ir/scalar-records.test.ts +++ b/packages/compiler/src/ir/scalar-records.test.ts @@ -40,7 +40,7 @@ const read = (localId: string, field = "a"): IrExpr => ({ function fixture(): IrModule { return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", records: [ diff --git a/packages/compiler/src/ir/serialize.ts b/packages/compiler/src/ir/serialize.ts index cb5738135..b156300ea 100644 --- a/packages/compiler/src/ir/serialize.ts +++ b/packages/compiler/src/ir/serialize.ts @@ -2,9 +2,9 @@ * JSON's null/zero spellings must not erase NaN, infinities or negative zero. */ import type { IrModule } from "./ir.js"; -// Version 14 records worker execution, which requires thread-local program -// state and matching runtime objects. Older readers must not erase that mode. -export const IR_VERSION = 14 as const; +// Version 15 expands the native Error prefix with filesystem metadata. +// Older modules embed incompatible subclass offsets and must be rejected. +export const IR_VERSION = 15 as const; /** IR artifacts are plain data. Scan each function once before deciding * whether its JSON encoding needs the complete-number-domain replacer. */ diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index e6f240f87..956e08cd8 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -283,7 +283,7 @@ test.each([mapOf(STRING, F64), setOf(STRING), { kind: "promise", inner: F64 } as }, ); -test.each([mapOf(STRING, F64), setOf(STRING), { kind: "promise", inner: F64 } as IrType])( +test.each([{ kind: "promise", inner: F64 } as IrType])( "%j payloads still refuse unrelated data siblings", (type) => { const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [ @@ -295,13 +295,12 @@ test.each([mapOf(STRING, F64), setOf(STRING), { kind: "promise", inner: F64 } as }, ); -test("two differently typed Map payloads cannot silently share one tag test", () => { +test("differently typed collection payloads preserve separate union tags", () => { const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, [ { id: "maps", arms: [mapOf(STRING, F64), mapOf(STRING, STRING), UNDEFINED_T] }, ]); - expect( - validateModule(mod).filter((error) => error.message.includes("beside non-unit arms")), - ).toHaveLength(2); + expect(validateModule(mod)).toEqual([]); + expect(deserializeModule(serializeModule(mod))).toEqual(mod); }); function numericReadModule(overrides: Partial = {}): IrModule { @@ -315,7 +314,7 @@ function numericReadModule(overrides: Partial ...overrides, }; return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [ @@ -399,7 +398,7 @@ test.each(["sortPrimitive", "toSortedPrimitive"] as const)( function expressionModule(expr: IrExpr, unions: IrUnionDef[]): IrModule { return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", unions, @@ -1286,7 +1285,7 @@ test.each([ function tdzModule(mutable = true): IrModule { const value: IrExpr = { kind: "numLit", value: 0, type: F64, loc }; return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [ @@ -1454,7 +1453,7 @@ test("TDZ locals require a shared box", () => { function discriminatedModule(): IrModule { return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [{ name: "main", params: [], locals: [], returnType: VOID, body: [], loc }], diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index b1814019a..856f452e4 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -804,9 +804,8 @@ export function validateModule(mod: IrModule): IrValidationError[] { } u.arms.forEach((arm, i) => { // The unit kinds (undefinedT/nullT) are valid arms — union membership - // is the ONLY place they may appear. Containers are valid beside - // unit arms: tag tests distinguish absence while the reference - // payload preserves identity. The shared rule refuses data siblings. + // is the ONLY place they may appear. Container payloads preserve + // identity; promise data siblings retain their separate fence. if ( arm.kind === "void" || arm.kind === "union" || @@ -817,10 +816,7 @@ export function validateModule(mod: IrModule): IrValidationError[] { ) { errors.push({ message: `union ${u.id}: arm ${i} is ${arm.kind}`, loc: noLoc }); } - if ( - (arm.kind === "map" || arm.kind === "set" || arm.kind === "promise") && - !unionContainerArmsOk(u.arms) - ) { + if (arm.kind === "promise" && !unionContainerArmsOk(u.arms)) { errors.push({ message: `union ${u.id}: ${arm.kind} arm ${i} beside non-unit arms`, loc: noLoc, @@ -1117,14 +1113,15 @@ function validateFunction( if (isUnitType(fn.returnType)) { err(`return type is bare unit type ${fn.returnType.kind}`, fn.loc); } - // Exception snapshots may be captured, but are never public parameters - // or return values. Their payload crosses those boundaries explicitly. + // Exception snapshots may be captured or passed to internal conversion + // helpers. They are not source-level types and never return as values. if (fn.returnType.kind === "caught") err("return type is caught", fn.loc); for (const p of fn.params) { + if (p.type.kind === "caught" && fn.name !== "%caught.dynamicValue") + err(`param "${p.name}" is caught-typed`, fn.loc); if (!locals.has(p.localId)) { err(`param "${p.name}" has no local entry "${p.localId}"`, fn.loc); } - if (p.type.kind === "caught") err(`param "${p.name}" is caught-typed`, fn.loc); } for (const c of [...(fn.captures ?? []), ...(fn.classCaptures ?? [])]) { const local = locals.get(c.localId); diff --git a/packages/compiler/src/library/fence-eval.test.ts b/packages/compiler/src/library/fence-eval.test.ts index cbffca9c7..80511e970 100644 --- a/packages/compiler/src/library/fence-eval.test.ts +++ b/packages/compiler/src/library/fence-eval.test.ts @@ -15,7 +15,7 @@ function program(member: string): IrModule { loc, }; return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "probe", functions: [ diff --git a/packages/compiler/src/library/int-infer.test.ts b/packages/compiler/src/library/int-infer.test.ts index d06dd32cd..2822ca00b 100644 --- a/packages/compiler/src/library/int-infer.test.ts +++ b/packages/compiler/src/library/int-infer.test.ts @@ -97,7 +97,7 @@ const sink = (name: string): IrFunction => ({ /** A module holding the case function plus the two declared sinks. */ function caseModule(params: string[], locals: string[], body: IrStmt[]): IrModule { return { - irVersion: 14, + irVersion: 15, sourceFile: "corpus.ts", functions: [ sink("send"), @@ -196,7 +196,7 @@ const RECORD_CFG: IntSlotConfig = { function recordCase(body: IrStmt[], names = ["m"], extraFns: IrFunction[] = []): IrModule { return { - irVersion: 14, + irVersion: 15, sourceFile: "fields.ts", functions: [ ...extraFns, @@ -237,7 +237,7 @@ const classCountRead = (): IrExpr => ({ function onlyOrdinaryClass(body: IrStmt[]): IntVerdict { const mod: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: "class-fields.ts", functions: [ sink("send"), @@ -581,7 +581,7 @@ describe("the domain's edges beyond the corpus", () => { loc, }; const mod: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: "optional.ts", functions: [ { diff --git a/packages/compiler/src/library/library-cache.test.ts b/packages/compiler/src/library/library-cache.test.ts index 4e98e6d94..d93db0c5b 100644 --- a/packages/compiler/src/library/library-cache.test.ts +++ b/packages/compiler/src/library/library-cache.test.ts @@ -261,7 +261,7 @@ test("semantic library cache restores and rebases IR after a comment-only edit", const sourceBefore = await readFile(f.source, "utf8"); const returnStart = sourceBefore.indexOf("return"); const semanticMod = { - irVersion: 14, + irVersion: 15, sourceFile: f.source, functions: [ { @@ -324,7 +324,7 @@ test("semantic library cache refuses token and directive edits", async () => { const f = await fixture(); const sourceBefore = await readFile(f.source, "utf8"); const semanticMod = { - irVersion: 14, + irVersion: 15, sourceFile: f.source, functions: [ { @@ -372,7 +372,7 @@ test("semantic LLVM cache accepts comment edits that shift source lines", async const f = await fixture(); const sourceBefore = await readFile(f.source, "utf8"); const semanticMod = { - irVersion: 14, + irVersion: 15, sourceFile: f.source, functions: [ { @@ -447,7 +447,7 @@ test("semantic LLVM cache accepts line separator normalization", async () => { frontend: tracker.snapshot(), semantic: { mod: { - irVersion: 14, + irVersion: 15, sourceFile: f.source, functions: [], entry: "__main", @@ -468,7 +468,7 @@ test("semantic LLVM cache rebases comment edits in multi-source graphs", async ( const importedSource = "export function helper(): number { return 1; }\n"; await writeFile(imported, importedSource); const semanticMod = { - irVersion: 14, + irVersion: 15, sourceFile: f.source, functions: [ { diff --git a/packages/compiler/src/native/compiler.test.ts b/packages/compiler/src/native/compiler.test.ts index 7adde68a2..2c2afcc10 100644 --- a/packages/compiler/src/native/compiler.test.ts +++ b/packages/compiler/src/native/compiler.test.ts @@ -60,7 +60,7 @@ test.each([ writeFileSync(output, "previous executable"); writeFileSync(llvmPath, "previous LLVM"); const module: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: entry, entry: "%main", functions: [ @@ -197,7 +197,7 @@ test.each([false, true])( writeFileSync(output, "previous object"); writeFileSync(llvmPath, "previous LLVM"); const module: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: entry, entry: "%main", functions: [ diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 565e627ce..75645d769 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1975,6 +1975,12 @@ "status": "static", "note": "recognized module (bare and node:-prefixed specifiers)" }, + { + "id": "node-builtin.tty.isatty", + "kind": "node-builtin", + "name": "tty.isatty", + "status": "static" + }, { "id": "node-builtin.url", "kind": "node-builtin", diff --git a/packages/compiler/test/bytes-element-emission.test.ts b/packages/compiler/test/bytes-element-emission.test.ts index 9c40b0aeb..953687993 100644 --- a/packages/compiler/test/bytes-element-emission.test.ts +++ b/packages/compiler/test/bytes-element-emission.test.ts @@ -75,7 +75,7 @@ function fixture(): IrModule { ); return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "__main", functions: [{ name: "__main", params: [], returnType: VOID, locals, body, loc }], @@ -145,7 +145,7 @@ function receiverReassignmentFixture(): IrModule { ]; return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "__main", functions: [{ name: "__main", params: [], returnType: VOID, locals, body, loc }], @@ -212,7 +212,7 @@ function integerLoopFixture(mutatesIndex = false): IrModule { { kind: "bytesSet", arr: bytesRef(), index: indexRef(), value: ref("sum"), loc }, ); return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "__main", functions: [ @@ -333,7 +333,7 @@ test("large record clones stay outlined while small clones remain inlineable", ( const fields = Array.from({ length: count }, (_, i) => ({ name: `f${i}`, type: F64 })); const type = { kind: "record", shapeId: id } as const; return { - irVersion: 14, + irVersion: 15, sourceFile: "record-clone.ts", entry: "__main", records: [{ id, fields }], diff --git a/packages/compiler/test/emit-llvm.test.ts b/packages/compiler/test/emit-llvm.test.ts index 67f6ef798..0f8d85c34 100644 --- a/packages/compiler/test/emit-llvm.test.ts +++ b/packages/compiler/test/emit-llvm.test.ts @@ -35,7 +35,7 @@ test("strings: literals, concat in a loop, toString, RC-clean under audit", asyn // while (i < 3) { acc = acc + ("-" + i); i = i + 1; } // console.log(acc, acc === "x-0-1-2", "α∂" < "β"); const mod: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: "s.ts", entry: "__main", functions: [ @@ -137,7 +137,7 @@ test("short-circuit: right operand of && only evaluates when left is true", asyn // if (false && sideEffect()) {} ; if (true || sideEffect()) {} // console.log("done") const mod: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: "l.ts", entry: "__main", functions: [ @@ -227,7 +227,7 @@ test("string params: callee owns and releases; returns transfer ownership", asyn // function greet(who: string): string { return "hi " + who; } // console.log(greet("world")); const mod: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: "p.ts", entry: "__main", functions: [ diff --git a/packages/compiler/test/fixtures/fib-ir.ts b/packages/compiler/test/fixtures/fib-ir.ts index f1a4a4218..5d68f8d78 100644 --- a/packages/compiler/test/fixtures/fib-ir.ts +++ b/packages/compiler/test/fixtures/fib-ir.ts @@ -17,7 +17,7 @@ const n = (localId: string): IrExpr => ({ kind: "varRef", localId, type: F64, lo const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); export const fibModule: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: "fib.ts", entry: "__main", functions: [ diff --git a/packages/compiler/test/fromcharcode-emission.test.ts b/packages/compiler/test/fromcharcode-emission.test.ts index 4ddccbe67..2b4fb75d5 100644 --- a/packages/compiler/test/fromcharcode-emission.test.ts +++ b/packages/compiler/test/fromcharcode-emission.test.ts @@ -7,7 +7,7 @@ test("scalar fromCharCode avoids a heap argument pack in LLVM", () => { const loc = { file: "scalar-char.ts", start: 0, end: 0 }; const value: IrExpr = { kind: "numLit", value: 65, type: F64, loc }; const mod: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "__main", globals: [], diff --git a/packages/compiler/test/ir.test.ts b/packages/compiler/test/ir.test.ts index e8e49128a..2fd3c43f5 100644 --- a/packages/compiler/test/ir.test.ts +++ b/packages/compiler/test/ir.test.ts @@ -22,7 +22,7 @@ test("fib module JSON round-trips", () => { test("validator rejects type mismatches and bad references", () => { const loc = { file: "t.ts", start: 0, end: 0 }; const bad: IrModule = { - irVersion: 14, + irVersion: 15, sourceFile: "t.ts", entry: "__main", functions: [ @@ -108,7 +108,7 @@ test("serializer round-trips ±Infinity and NaN", () => { }); test("deserializer rejects the previous IR version", () => { - const json = serializeModule(fibModule).replace('"irVersion": 14', '"irVersion": 13'); + const json = serializeModule(fibModule).replace('"irVersion": 15', '"irVersion": 13'); expect(() => deserializeModule(json)).toThrow(/version mismatch/); }); diff --git a/packages/compiler/test/llvm-lib-dispatch.test.ts b/packages/compiler/test/llvm-lib-dispatch.test.ts index a92e93948..31204299c 100644 --- a/packages/compiler/test/llvm-lib-dispatch.test.ts +++ b/packages/compiler/test/llvm-lib-dispatch.test.ts @@ -19,7 +19,7 @@ const dyn = (): IrExpr => ({ kind: "dynFrom", value: str("value"), type: DYN, lo function libCallModule(fn: IrLibFn, args: IrExpr[], type: IrType): IrModule { return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "__main", functions: [ diff --git a/packages/compiler/test/native-callback-emission.test.ts b/packages/compiler/test/native-callback-emission.test.ts new file mode 100644 index 000000000..33d02d313 --- /dev/null +++ b/packages/compiler/test/native-callback-emission.test.ts @@ -0,0 +1,83 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; + +async function lower(source: string) { + const dir = mkdtempSync(join(tmpdir(), "scriptc-callback-emission-")); + try { + const entry = join(dir, "main.ts"); + const output = join(dir, "main.ir.json"); + writeFileSync(entry, source); + const result = await compile(entry, { outputKind: "ir", outDir: dir, outPath: output }); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const module = deserializeModule(readFileSync(output, "utf8")); + expect(validateModule(module)).toEqual([]); + return module; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test("native callback fields and unchanged methods do not demand class reflection", async () => { + const source = Array.from( + { length: 12 }, + (_, i) => ` +class Counter${i} { + value = 0; + notify(): void { this.value++; } + callback = (n: number): number => this.value + n; +} +const counter${i} = new Counter${i}(); +counter${i}.notify(); +console.log(counter${i}.callback(${i})); +`, + ).join("\n"); + const module = await lower( + source + + ` +const values: Record = {}; +function key(): string { return "notify"; } +values[key()] = 1; +function update(values: Record, name: string): void { values[name] = 2; } +update(values, key()); +console.log(values[key()]); +`, + ); + expect(module.functions.some((fn) => fn.name.startsWith("%dyn.class."))).toBe(false); + const json = JSON.stringify(module.functions); + expect(json).not.toContain('"kind":"dynCall"'); + expect(json).not.toContain('"kind":"dynFrom"'); + expect(json).toContain('"kind":"callValue"'); +}); + +test("own-key rebuilding and absent-key insertion preserve inherited methods", async () => { + const module = await lower(` +class Counter { value = 0; notify(): void { this.value++; } } +const counter = new Counter(); +function rebuild(value: Record): void { + const entries = Object.entries(value); + for (const key of Object.keys(value)) delete value[key]; + for (const [key, entry] of entries) if (!(key in value)) value[key] = entry; +} +const metadata: Record = { label: "ready" }; +rebuild(metadata); +counter.notify(); +console.log(metadata.label, counter.value); +`); + const initializers = module.functions.filter((fn) => fn.name.startsWith("%init.")); + expect(JSON.stringify(initializers)).toContain('"callee":"%Counter.notify"'); + expect(JSON.stringify(initializers)).not.toContain('"kind":"dynCall"'); +}); + +test("observable method replacement keeps class property dispatch", async () => { + const module = await lower(` +class Counter { value = 0; notify(): void { this.value++; } } +const counter = new Counter(); +Object.defineProperty(counter, "notify", { value: (): void => { counter.value += 10; } }); +counter.notify(); +console.log(counter.value); +`); + expect(module.functions.some((fn) => fn.name.startsWith("%dyn.class."))).toBe(true); +}); diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts index 860d9d9a3..eec354147 100644 --- a/packages/compiler/test/native-codegen-integration.test.ts +++ b/packages/compiler/test/native-codegen-integration.test.ts @@ -312,7 +312,7 @@ ${[0, 1, 2, 3, 4, 5].map((index) => ` call void @add${index}(i64 ${index + 2})` expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"])); expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"])); expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]); - expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v7"); + expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v8"); const clangExe = join(dir, "clang-program"); const linkDriver = join(dir, "clang-driver.c"); @@ -646,7 +646,7 @@ ${[0, 1, 2, 3, 4, 5].map((index) => ` call void @add${index}(i64 ${index + 2})` .split("\n"); expect(undefinedSymbols).toContain("_sf_scale"); expect(undefinedSymbols).toContain("_sf_callback_mix"); - expect(undefinedSymbols).toContain("_scr_runtime_abi_v7"); + expect(undefinedSymbols).toContain("_scr_runtime_abi_v8"); }); test("helper and clang object paths retain the same outbound FFI C ABI", async () => { diff --git a/packages/compiler/test/string-accumulation-emission.test.ts b/packages/compiler/test/string-accumulation-emission.test.ts index 5e1f40099..b323221ba 100644 --- a/packages/compiler/test/string-accumulation-emission.test.ts +++ b/packages/compiler/test/string-accumulation-emission.test.ts @@ -86,7 +86,7 @@ function fixture(): IrModule { .find((fn) => fn.name === "negative")! .body.splice(1, 0, { kind: "varDecl", localId: "other", init: str("other"), loc }); return { - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "__main", globals: [{ id: "%g.e.acc", name: "globalAccumulator", type: STRING, mutable: true }], diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1c25a7273..3581d2c9f 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -14637,6 +14637,48 @@ "/tests/corpus/native-worker-terminate-wait.ts" ], "diags": [] + }, + "/tests/corpus/caught-class-identity.ts": { + "order": [ + "/tests/corpus/caught-class-identity.ts" + ], + "diags": [] + }, + "/tests/corpus/caught-system-error-fields.ts": { + "order": [ + "/tests/corpus/caught-system-error-fields.ts" + ], + "diags": [] + }, + "/tests/corpus/collection-data-unions.ts": { + "order": [ + "/tests/corpus/collection-data-unions.ts" + ], + "diags": [] + }, + "/tests/corpus/native-callback-dispatch.ts": { + "order": [ + "/tests/corpus/native-callback-dispatch.ts" + ], + "diags": [] + }, + "/tests/corpus/terminal-stream-properties.ts": { + "order": [ + "/tests/corpus/terminal-stream-properties.ts" + ], + "diags": [] + }, + "/tests/corpus/typed-index-arguments.ts": { + "order": [ + "/tests/corpus/typed-index-arguments.ts" + ], + "diags": [] + }, + "/tests/fixtures/node-types/terminal-error-fields.ts": { + "order": [ + "/tests/fixtures/node-types/terminal-error-fields.ts" + ], + "diags": [] } } } diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index 0812c1345..bf079ec6b 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -239,7 +239,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: RUNTIME_PACK_MATRIX.target, - runtime_abi: { version: 7, marker: "scr_runtime_abi_v7" }, + runtime_abi: { version: 8, marker: "scr_runtime_abi_v8" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index c783c1a20..204ef2ff0 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -293,7 +293,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: matrix.target, - runtime_abi: { version: 7, marker: "scr_runtime_abi_v7" }, + runtime_abi: { version: 8, marker: "scr_runtime_abi_v8" }, compiler: { command: compiler, identity: compilerVersion, target: matrix.target.llvm_triple }, macros: { executable: ["SCR_DYNAMIC", "SCR_TEXT_DECODER_LEGACY"], diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index abe9ef0b1..a52bf5367 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -107,7 +107,7 @@ static void scr_enable_utf8_console_output(void) { } #endif -void scr_runtime_abi_v7(void) {} +void scr_runtime_abi_v8(void) {} void scr_init(void) { #ifdef SCR_WORKERS diff --git a/packages/runtime/src/scr_context.c b/packages/runtime/src/scr_context.c index bb735bc1b..759c96ff0 100644 --- a/packages/runtime/src/scr_context.c +++ b/packages/runtime/src/scr_context.c @@ -4,7 +4,7 @@ #include #include -void scr_runtime_workers_v7(void) {} +void scr_runtime_workers_v8(void) {} typedef struct ScrContextExit { void (*fn)(void); diff --git a/packages/runtime/src/scr_error.c b/packages/runtime/src/scr_error.c index ccc4040ec..882669555 100644 --- a/packages/runtime/src/scr_error.c +++ b/packages/runtime/src/scr_error.c @@ -49,6 +49,9 @@ static void scr_error_gcfree(void *obj) { scr_str_release(e->code); /* NULL-safe: absent on most errors */ scr_str_release(e->stack_frames); scr_str_release(e->stack); + scr_str_release(e->system_call); + scr_str_release(e->system_path); + scr_str_release(e->system_dest); if (e->error_cause && scr_error_cause_drop) scr_error_cause_drop(obj); scr_obj_free_note(); scr_cyc_free(e); @@ -80,6 +83,9 @@ static void scr_error_destroy(void *obj) { scr_str_release(e->code); scr_str_release(e->stack_frames); scr_str_release(e->stack); + scr_str_release(e->system_call); + scr_str_release(e->system_path); + scr_str_release(e->system_dest); if (e->error_cause && scr_error_cause_drop) scr_error_cause_drop(obj); scr_obj_free_note(); if (scr_error_traced) scr_cyc_free(e); diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index f43adf552..7af30c5e8 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -686,9 +686,9 @@ typedef struct { static SCR_TL ScrStdio scr_stdio_streams[3] = {{.fd = 0}, {.fd = 1}, {.fd = 2}}; enum { STDIO_WRITE, STDIO_ON, STDIO_ONCE, STDIO_OFF, STDIO_PAUSE, STDIO_RESUME, - STDIO_IS_PAUSED, STDIO_READ, STDIO_RAW, STDIO_DESTROY, STDIO_METHOD_COUNT }; + STDIO_IS_PAUSED, STDIO_READ, STDIO_RAW, STDIO_DESTROY, STDIO_WINDOW_SIZE, STDIO_METHOD_COUNT }; static const char *const scr_stdio_names[] = { - "write", "on", "once", "removeListener", "pause", "resume", "isPaused", "read", "setRawMode", "destroy" + "write", "on", "once", "removeListener", "pause", "resume", "isPaused", "read", "setRawMode", "destroy", "getWindowSize" }; static SCR_TL ScrDyn *scr_stdio_methods[STDIO_METHOD_COUNT]; static SCR_TL bool scr_stdio_initialized; @@ -852,7 +852,13 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc ScrStdio *stream = self->v.handle.ptr; ScrDyn *arg = argc ? args[0] : scr_dyn_undefined(); ScrDyn *result = NULL; - if (method == STDIO_WRITE && stream->fd != 0) { + if (method == STDIO_WINDOW_SIZE && stream->fd != 0) { + result = scr_dyn_new_arr(); + ScrDyn *columns = scr_dyn_new_num(scr_process_columns(stream->fd)); + ScrDyn *rows = scr_dyn_new_num(scr_process_rows(stream->fd)); + scr_dyn_arr_push(result, columns); + scr_dyn_arr_push(result, rows); + } else if (method == STDIO_WRITE && stream->fd != 0) { ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); ScrDyn *callback = argc > 2 ? args[2] : scr_dyn_undefined(); if (encoding->kind == SCR_DYN_FUNC) { callback = encoding; encoding = scr_dyn_undefined(); } @@ -974,6 +980,7 @@ static ScrDyn *scr_stdio_get(void *ptr, const char *key, size_t length) { if (length == 11 && memcmp(key, "addListener", 11) == 0) return scr_dyn_retain(scr_stdio_methods[STDIO_ON]); for (int i = 0; i < STDIO_METHOD_COUNT; i++) { if (strlen(scr_stdio_names[i]) != length || memcmp(key, scr_stdio_names[i], length) != 0) continue; + if (i == STDIO_WINDOW_SIZE) return stream->fd != 0 && scr_process_is_tty(stream->fd) ? scr_dyn_retain(scr_stdio_methods[i]) : NULL; if (i == STDIO_WRITE) return stream->fd == 0 ? NULL : scr_dyn_retain(stream->write ? stream->write : scr_stdio_methods[i]); if (i == STDIO_RAW && !scr_process_is_tty(stream->fd)) return NULL; if (i >= STDIO_PAUSE && stream->fd != 0) return scr_stdio_refusal(scr_stdio_names[i]); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 563b5d822..00893e8f5 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4580,7 +4580,11 @@ ScrDyn *scr_error_dyn_fields(const ScrError *e) { d->v.obj.entries[d->v.obj.len - 1].enumerable = e->message_enumerable; } if (e->stack) scr_dyn_obj_set(d, "stack", 5, scr_dyn_new_str(e->stack)); + if (e->system_call) scr_dyn_obj_set(d, "errno", 5, scr_dyn_new_num(e->system_errno)); if (e->code) scr_dyn_obj_set(d, "code", 4, scr_dyn_new_str(e->code)); + if (e->system_call) scr_dyn_obj_set(d, "syscall", 7, scr_dyn_new_str(e->system_call)); + if (e->system_path) scr_dyn_obj_set(d, "path", 4, scr_dyn_new_str(e->system_path)); + if (e->system_dest) scr_dyn_obj_set(d, "dest", 4, scr_dyn_new_str(e->system_dest)); if (e->error_cause) { scr_dyn_obj_set(d, "cause", 5, scr_dyn_retain(e->error_cause)); d->v.obj.entries[d->v.obj.len - 1].enumerable = e->cause_enumerable; diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 66176d6b8..b6d2273cc 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -15,6 +15,7 @@ * scr_fs_exists never throws, like Node's existsSync. */ #include "scr_runtime.h" +#include "scr_system_errors.h" #include #ifdef SCR_WORKERS @@ -2316,6 +2317,25 @@ static const char *scr_fs_err_path(const ScrStr *path, char buf[PATH_MAX]) { #endif /* Exported (scr_runtime.h): scr_bytes.c's fs Buffer forms share it. */ +/* Keep metadata on the error itself until it crosses a checked-value + * boundary. Failed operations do not populate the process identity cache. */ +static void scr_fs_throw_details(int e, const char *name, const char *message, size_t length, + const char *op, const ScrStr *path, const ScrStr *dest) { + double number = e > 0 ? -e : e; +#define SCR_FS_ERRNO_VALUE(code, text) if (strcmp(name, #code) == 0) number = SCR_NODE_UV__##code; + SCR_NODE_UV_ERRNO_MAP(SCR_FS_ERRNO_VALUE) +#undef SCR_FS_ERRNO_VALUE + ScrStr *text = scr_str_new(message, length); + ScrError *error = scr_error_new(SCR_ERR_ERROR, text); + scr_str_release(text); + scr_error_set_code(error, name); + error->system_errno = number; + error->system_call = scr_str_new(op, strlen(op)); + error->system_path = path ? scr_str_retain((ScrStr *)path) : NULL; + error->system_dest = dest ? scr_str_retain((ScrStr *)dest) : NULL; + scr_throw_obj(error, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); +} + void scr_fs_throw(int e, const char *op, const ScrStr *path) { #ifdef _WIN32 /* The CRT lands ERROR_ACCESS_DENIED in errno as EACCES; libuv's @@ -2338,12 +2358,7 @@ void scr_fs_throw(int e, const char *op, const ScrStr *path) { int len = path ? snprintf(msg, cap, "%s: %s, %s '%s'", name, text, op, shown) : snprintf(msg, cap, "%s: %s, %s", name, text, op); - /* A real Error instance (name "Error", message = Node's text) — what a - * typed catch's `e instanceof Error` + `e.message` observes in Node — - * with `code` stamped to the errno name (the exotic-errno fallback - * stamps its "E" spelling; Node would carry the uv name there). - * errno/syscall/path stay unrepresented (SEMANTICS.md divergence 13). */ - scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)len, name); + scr_fs_throw_details(e, name, msg, (size_t)len, op, path, NULL); free(msg); } @@ -3198,7 +3213,7 @@ static double scr_fs_write_bytes(double fd, const void *data, size_t length, const char *text = scr_errno_text(e); char msg[160]; int len = snprintf(msg, sizeof msg, "%s: %s, write", name, text); - scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)len, name); + scr_fs_throw_details(e, name, msg, (size_t)len, "write", NULL, NULL); return 0; } return (double)n; @@ -3359,7 +3374,7 @@ double scr_fs_read_sync(double fd, ScrBytes *buf, double offset, double length, const char *text = scr_errno_text(e); char msg[160]; int len = snprintf(msg, sizeof msg, "%s: %s, read", name, text); - scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)len, name); + scr_fs_throw_details(e, name, msg, (size_t)len, "read", NULL, NULL); return 0; } return (double)n; @@ -3440,7 +3455,7 @@ void scr_fs_close(double fd) { const char *text = scr_errno_text(e); char msg[160]; int len = snprintf(msg, sizeof msg, "%s: %s, close", name, text); - scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)len, name); + scr_fs_throw_details(e, name, msg, (size_t)len, "close", NULL, NULL); } } @@ -3459,7 +3474,7 @@ static void scr_fs_throw2(int e, const char *op, const ScrStr *src, const ScrStr scr_trap("scriptc: out of memory\n"); } int len = snprintf(msg, cap, "%s: %s, %s '%s' -> '%s'", name, text, op, shown_src, shown_dest); - scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)len, name); + scr_fs_throw_details(e, name, msg, (size_t)len, op, src, dest); free(msg); } @@ -3972,7 +3987,7 @@ static void scr_fs_throw_nopath(int e, const char *op) { const char *text = scr_errno_text(e); char msg[256]; int len = snprintf(msg, sizeof msg, "%s: %s, %s", name, text, op); - scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)len, name); + scr_fs_throw_details(e, name, msg, (size_t)len, op, NULL, NULL); } ScrStr *scr_fs_read_fd(double fd) { @@ -3991,7 +4006,10 @@ ScrBytes *scr_fs_read_fd_bytes(double fd) { /* ── the tty probes ──────────────────────────────────────────────────── */ -bool scr_process_is_tty(double fd) { return isatty((int)fd) != 0; } +bool scr_process_is_tty(double fd) { + if (!isfinite(fd) || fd < 0 || fd > INT_MAX || trunc(fd) != fd) return false; + return isatty((int)fd) != 0; +} /* Terminal geometry for process.stdout/stderr.columns/rows: ioctl(TIOCGWINSZ) on * the stream's fd, exactly Node's tty.WriteStream source of truth. A diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index c78e34560..4d3e53616 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -62,8 +62,8 @@ void scr_init(void); /* Program objects emitted by the bundled LLVM helper reference this symbol. * Its versioned spelling makes a mismatched manual runtime link fail before * the program can start. */ -void scr_runtime_abi_v7(void); -void scr_runtime_workers_v7(void); +void scr_runtime_abi_v8(void); +void scr_runtime_workers_v8(void); /* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ────── * Every unrecoverable runtime trap — OOM, semantic range traps, internal- @@ -617,6 +617,10 @@ typedef struct ScrError { bool name_enumerable; ScrStr *stack_frames; /* captured native source frames */ ScrStr *stack; /* lazily formatted, cached stack */ + double system_errno; /* present exactly when system_call is non-NULL */ + ScrStr *system_call; + ScrStr *system_path; + ScrStr *system_dest; } ScrError; enum { @@ -654,6 +658,10 @@ typedef struct ScrDomException { bool name_enumerable; ScrStr *stack_frames; ScrStr *stack; + double system_errno; + ScrStr *system_call; + ScrStr *system_path; + ScrStr *system_dest; double dom_code; /* the WebIDL legacy code (0 when the name is off-table) */ bool has_cause; /* the options form carried a `cause` member */ struct ScrDyn *cause; /* owned; NULL when has_cause is false */ diff --git a/tests/corpus/1408-string-indexing.ts b/tests/corpus/1408-string-indexing.ts index 3648e0c5f..2c9525927 100644 --- a/tests/corpus/1408-string-indexing.ts +++ b/tests/corpus/1408-string-indexing.ts @@ -1,8 +1,4 @@ -// String indexing `s[i]` reads a UTF-16 code unit — charAt's exact job, -// which is how it lowers. In-bounds integer indices (the loop pattern) are -// JS-exact, astral pairs split into lone surrogates included; the -// out-of-range "" - vs - undefined difference is a documented divergence -// and stays out of the corpus. Node is the oracle. +// Indexed string reads preserve missing values while in-range reads match charAt. const s = "héllo \u{1f600}!"; console.log("len", s.length); diff --git a/tests/corpus/caught-class-identity.ts b/tests/corpus/caught-class-identity.ts new file mode 100644 index 000000000..de5dfd717 --- /dev/null +++ b/tests/corpus/caught-class-identity.ts @@ -0,0 +1,43 @@ +class EventBase { + label: string; + constructor(label: string) { this.label = label; } +} +class RetryEvent extends EventBase { + attempts = 0; +} +class StopEvent extends EventBase {} + +function inspect(value: unknown): string { + if (value instanceof RetryEvent) { + value.attempts++; + return value.label; + } + return value instanceof StopEvent ? 'stop' : 'other'; +} +function raise(value: EventBase): void { throw value; } +const event = new RetryEvent('retry'); +try { raise(event); } catch (error) { + const saved: unknown = error; + console.log(error instanceof RetryEvent, inspect(saved), inspect(error)); + console.log(saved === event, (saved as RetryEvent).attempts); + try { throw error; } catch (again) { console.log(inspect(again)); } +} +console.log(event.attempts); +try { throw new StopEvent('done'); } catch (error) { console.log(inspect(error)); } +try { throw 42; } catch (error) { console.log(inspect(error), typeof error); } +try { throw 'reason'; } catch (error) { console.log(inspect(error), typeof error); } + +type FailureCode = { code?: string }; +function readCode(value: unknown): string { return (value as FailureCode).code ?? 'none'; } +try { throw { code: 'RETRY', detail: 7 }; } catch (error) { + console.log((error as FailureCode).code, readCode(error)); +} +try { throw new Error('plain'); } catch (error) { + console.log((error as FailureCode).code ?? 'none'); +} + +function delayed(): () => boolean { + try { throw event; } catch (error) { return () => error === event && inspect(error) === 'retry'; } +} +const checkLater = delayed(); +console.log(checkLater(), event.attempts); diff --git a/tests/corpus/caught-system-error-fields.ts b/tests/corpus/caught-system-error-fields.ts new file mode 100644 index 000000000..c52a664d6 --- /dev/null +++ b/tests/corpus/caught-system-error-fields.ts @@ -0,0 +1,42 @@ +import { readFileSync, mkdirSync, copyFileSync, closeSync, mkdtempSync, rmSync } from 'node:fs'; +import { getSystemErrorName } from 'node:util'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +interface Failure { code?: string; errno?: number; syscall?: string; path?: string; dest?: string; } +function code(error: unknown): string { return (error as Failure).code ?? 'none'; } +const root = mkdtempSync(join(tmpdir(), 'scriptc-error-fields-')); +const missing = join(root, 'missing.txt'); +const destination = join(root, 'output.txt'); +try { + try { readFileSync(missing, 'utf8'); } catch (error: unknown) { + const details = error as Failure; + console.log(Object.keys(error as object).join(',')); + const typed = error as NodeJS.ErrnoException; + console.log(code(error), details.syscall, details.path === missing, details.dest === undefined); + console.log(typed.code, typed.syscall, typed.path === missing, getSystemErrorName(typed.errno!) === typed.code); + console.log((error as Error & { code?: string }).code, error instanceof Error); + if (error instanceof Error) console.log('code' in error); + try { throw error; } catch (again) { console.log(again === error, code(again)); } + } + try { mkdirSync(root); } catch (error) { + const details = error as Failure; + console.log(details.code, details.syscall, details.path === root, getSystemErrorName(details.errno!) === details.code); + } + try { copyFileSync(missing, destination); } catch (error) { + const details = error as Failure; + console.log(details.code, details.syscall, details.path === missing, details.dest === destination); + } + try { closeSync(1000000); } catch (error) { + const details = error as Failure; + console.log(details.code, details.syscall, details.path === undefined, getSystemErrorName(details.errno!) === details.code); + } + try { throw new Error('plain'); } catch (error) { + const details = error as Failure; + console.log(code(error), details.errno, details.syscall, details.path, details.dest); + } + class LocalError extends Error { code = 'LOCAL'; errno = 3; syscall = 'custom'; } + try { throw new LocalError('local'); } catch (error) { + const details = error as Failure; + console.log(code(error), details.errno, details.syscall); + } +} finally { rmSync(root, { recursive: true }); } diff --git a/tests/corpus/collection-data-unions.ts b/tests/corpus/collection-data-unions.ts new file mode 100644 index 000000000..2b87e9878 --- /dev/null +++ b/tests/corpus/collection-data-unions.ts @@ -0,0 +1,45 @@ +type Value = ReadonlyMap | ReadonlySet | string | number | null | undefined; +let evaluations = 0; +function once(value: Value): Value { evaluations++; return value; } +function inspect(value: Value): string { + if (once(value) instanceof Map) console.log('map-test'); + if (value instanceof Map) { + let sum = 0; + for (const [key, entry] of value) { sum += entry; console.log(key); } + console.log(value.has('first'), value.get('first'), value.get('missing')); + return 'map:' + value.size + ':' + sum; + } + if (value instanceof Set) { + let text = ''; + for (const entry of value) text += entry; + return 'set:' + text; + } + if (typeof value === 'string') return 'text:' + value; + if (typeof value === 'number') return String(value); + return value === null ? 'null' : 'undefined'; +} +const map = new Map([['first', 1], ['second', 2]]); +const set = new Set(['a', 'b']); +console.log(inspect(map), inspect(set), inspect('hello'), inspect(7), inspect(null), inspect(undefined)); +console.log(evaluations); +function update(value: Map | string): void { + if (value instanceof Map) value.set('third', 3); +} +function same(value: Value): boolean { return value === map; } +update(map); +console.log(map.get('third'), same(map), same(set)); + +function label(value: Map | { text: string }): string { + return value instanceof Map ? String(value.get('first')) : value.text; +} +console.log(label(map), label({ text: 'record' })); + +function* collectionSteps(): Generator, string, unknown> { + yield map; + return 'done'; +} +const steps = collectionSteps(); +const first = steps.next(); +console.log(first.done, first.value instanceof Map, first.value === map); +const last = steps.next(); +console.log(last.done, last.value); diff --git a/tests/corpus/native-callback-dispatch.ts b/tests/corpus/native-callback-dispatch.ts new file mode 100644 index 000000000..2acba0284 --- /dev/null +++ b/tests/corpus/native-callback-dispatch.ts @@ -0,0 +1,77 @@ +class Counter { + count = 0; + notify(): void { this.count++; } + callback = (n: number): number => { this.count += n; return this.count; }; +} +const counter = new Counter(); +counter.notify(); +console.log(counter.callback(2), counter.count); +counter.callback = (n: number): number => n * 3; +console.log(counter.callback(4), counter.count); + +const calls: ((n: number) => number)[] = [(n: number): number => n + 1]; +let receivers = 0; +let indexes = 0; +function receiver(): ((n: number) => number)[] { receivers++; return calls; } +function index(): number { indexes++; return 0; } +function replace(): number { calls[0] = (n: number): number => n + 100; return 5; } +console.log(receiver()[index()](replace()), calls[0](5), receivers, indexes); +let effects = 0; +function effect(): number { effects++; return 1; } +try { calls[3](effect()); } catch (error) { console.log(error instanceof TypeError, effects); } +function withReceiver(this: unknown, n: number): number { + console.log(this === calls); + return n + 9; +} +calls[0] = withReceiver; +console.log(calls[0](2)); +function capture(this: unknown): () => boolean { return () => this === calls; } +const captures: (() => () => boolean)[] = [capture]; +const saved = captures[0](); +console.log(saved()); +const captureCalls: ((same?: unknown) => () => boolean)[] = []; +function captured(this: unknown, same: unknown = this): () => boolean { return () => this === same; } +captureCalls.push(captured); +const check = captureCalls[0](); +console.log(check()); +function plainReceiver(this: unknown): boolean { return this === undefined; } +function nestedReceiver(this: unknown): boolean { return plainReceiver(); } +const nestedCalls: (() => boolean)[] = [nestedReceiver]; +console.log(nestedCalls[0]()); + +interface Callable { notify(): void; } +function replaceNamed(value: Callable): void { + value.notify = (): void => { counter.count += 10; }; +} +replaceNamed(counter); +counter.notify(); +console.log(counter.count); +function replaceLiteral(value: Callable): void { + value["notify"] = (): void => { counter.count += 20; }; +} +replaceLiteral(counter); +counter.notify(); +console.log(counter.count); +function replaceComputed(value: Callable, key: keyof Callable): void { + value[key] = (): void => { counter.count += 30; }; +} +replaceComputed(counter, "notify"); +counter.notify(); +console.log(counter.count); +function replaceReflected(value: Callable): void { + Object.defineProperty(value, "notify", { value: (): void => { counter.count += 40; } }); +} +replaceReflected(counter); +counter.notify(); +console.log(counter.count); + +class CallbackOwner { + value = 7; + callback = (): number => this.value; +} +const owner = new CallbackOwner(); +function replaceField(value: CallbackOwner): void { + value.callback = function (this: CallbackOwner): number { return this.value + 5; }; +} +replaceField(owner); +console.log(owner.callback()); diff --git a/tests/corpus/terminal-stream-properties.ts b/tests/corpus/terminal-stream-properties.ts new file mode 100644 index 000000000..7e8465e52 --- /dev/null +++ b/tests/corpus/terminal-stream-properties.ts @@ -0,0 +1,25 @@ +import type { WriteStream } from 'node:tty'; +import { isatty } from 'node:tty'; +function dimensions(stream: WriteStream): string { + const columns = stream.columns; + const rows = stream.rows; + return `${columns ?? 'none'}:${rows ?? 'none'}:${stream.isTTY ?? 'none'}`; +} +const out = process.stdout; +const err = process.stderr; +console.log(dimensions(out), dimensions(err)); +console.log(process.stdin.isTTY, out.isTTY, err.isTTY); +console.log(out.columns, err.rows, isatty(1), isatty(2)); +let reads = 0; +function output(): WriteStream { reads++; return out; } +console.log(output().columns ?? 80, reads); +try { out.getWindowSize(); } catch (error) { console.log(error instanceof TypeError); } + +console.log(isatty(-1), isatty(0.5), isatty(NaN), isatty(Infinity), isatty(2147483648)); + +function selectOutput(fail: boolean): WriteStream { + if (fail) throw new Error('unavailable'); + return process.stdout; +} +try { selectOutput(true).write('unreachable'); } catch (error) { console.log(error instanceof Error); } +console.log(selectOutput(false) === process.stdout); diff --git a/tests/corpus/typed-index-arguments.ts b/tests/corpus/typed-index-arguments.ts new file mode 100644 index 000000000..e821e325b --- /dev/null +++ b/tests/corpus/typed-index-arguments.ts @@ -0,0 +1,20 @@ +class Samples { + values = new Int32Array([11, 22, 33, 44]); + read(position: number): number { return this.values[position]; } +} +const samples = new Samples(); +const positions: number[] = [0, 2, 3]; +console.log(samples.read(1), samples.read(positions[0]), samples.read(positions[1])); +let calls = 0; +function position(): number { calls++; return positions[2]; } +console.log(samples.read(position()), calls); +const text = 'abcd'; +function letter(position: number): string { return text[position]; } +console.log(letter(1), letter(positions[0]), letter(position()), calls); +function optionalLetter(position: number): string | undefined { return text[position]; } +console.log(optionalLetter(positions[8]), optionalLetter(-1), optionalLetter(0.5)); + +function first(text: string): string { return text[0]; } +function combine(a: string, b: string): string { return a[0] + b[0]; } +console.log(first('abc'), first('')); +console.log(combine('a', 'b'), combine('', 'b'), combine('', '')); diff --git a/tests/fixtures/node-types/terminal-error-fields.ts b/tests/fixtures/node-types/terminal-error-fields.ts new file mode 100644 index 000000000..25bb4b5e7 --- /dev/null +++ b/tests/fixtures/node-types/terminal-error-fields.ts @@ -0,0 +1,20 @@ +import type { WriteStream } from 'node:tty'; +import { readFileSync } from 'node:fs'; +import { getSystemErrorName } from 'node:util'; +function dimensions(stream: WriteStream): string { + return `${stream.columns ?? 'none'}:${stream.rows ?? 'none'}:${stream.isTTY ?? 'none'}`; +} +console.log(dimensions(process.stdout), dimensions(process.stderr)); +console.log(process.stdout.columns ?? 'none', process.stderr.rows ?? 'none'); +try { process.stdout.getWindowSize(); } catch (error) { console.log(error instanceof TypeError); } +try { readFileSync('missing-terminal-error-fixture', 'utf8'); } catch (error) { + const details = error as NodeJS.ErrnoException; + console.log(details.code, details.syscall, details.path, getSystemErrorName(details.errno!) === details.code); + console.log((error as Error & { code?: string }).code, error instanceof Error); +} +const output = process.stdout; +console.log(output.columns ?? 80, output.isTTY ?? false); +const width = output.columns ?? 'auto'; +console.log(width); +function selectedWidth() { return output.columns ?? 'auto'; } +console.log(selectedWidth()); diff --git a/tests/fixtures/self-hosting/class-types.ts b/tests/fixtures/self-hosting/class-types.ts index 0d3528eb9..4d51d7c25 100644 --- a/tests/fixtures/self-hosting/class-types.ts +++ b/tests/fixtures/self-hosting/class-types.ts @@ -8,7 +8,7 @@ const kept: IrType = { kind: "object", className: "Registered" }; const expr: IrExpr = { kind: "varRef", localId: "x", type: missing, loc }; const nested: IrType = { kind: "func", params: [{ kind: "array", elem: missing }], ret: { kind: "generator", yieldT: missing, retT: kept, nextT: missing } }; -const module: IrModule = { irVersion: 14, sourceFile: loc.file, entry: "main", +const module: IrModule = { irVersion: 15, sourceFile: loc.file, entry: "main", functions: [{ name: "main", params: [{ localId: "x", name: "x", type: missing }], returnType: nested, locals: [], loc, body: [{ kind: "exprStmt", expr, loc }] }], records: [{ id: "r", fields: [{ name: "callback", type: nested }], indexValue: missing }], diff --git a/tests/fixtures/terminal-geometry/main.ts b/tests/fixtures/terminal-geometry/main.ts index 29c349e4f..1624c8d0f 100644 --- a/tests/fixtures/terminal-geometry/main.ts +++ b/tests/fixtures/terminal-geometry/main.ts @@ -1,10 +1,13 @@ +import type { WriteStream } from 'node:tty'; +const out = process.stdout; +function size(stream: WriteStream): [number, number] { return stream.getWindowSize(); } function geometry(): number[] { - return [ - (process.stdout as typeof process.stdout & { columns?: number }).columns ?? -1, - (process.stdout as typeof process.stdout & { rows?: number }).rows ?? -1, - (process.stderr as typeof process.stderr & { columns?: number }).columns ?? -1, - (process.stderr as typeof process.stderr & { rows?: number }).rows ?? -1, - ]; + const output = out.getWindowSize(); + const errors = size(process.stderr); + if (!out.isTTY || !process.stderr.isTTY || process.stdin.isTTY !== undefined) throw new Error(`tty flags: ${out.isTTY},${process.stderr.isTTY},${process.stdin.isTTY}`); + if (output[0] !== out.columns || output[1] !== process.stdout.rows) throw new Error('stdout geometry'); + if (errors[0] !== process.stderr.columns || errors[1] !== process.stderr.rows) throw new Error('stderr geometry'); + return [output[0], output[1], errors[0], errors[1]]; } console.log(JSON.stringify(geometry())); let ticks = 0; diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 801ec9f14..0be93c850 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -611,3 +611,21 @@ test("dot-parent: bare '.' and '..' imports build and run (the TS project dialec const { stdout } = await execFileAsync(result.binaryPath); expect(stdout).toBe("parent-banner:lib-index!\n"); }); + +test("node-types: terminal properties and filesystem error metadata match Node", async () => { + const outDir = outDirFor("terminal-error-fields"); + const entry = join(nodeTypesDir, "terminal-error-fields.ts"); + const result = await compile(entry, { + outPath: join(outDir, "terminal-error-fields"), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index 5298c592f..6e75fe7f5 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -100,7 +100,7 @@ for (const backend of ["llvm"] as const) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 7, marker: "scr_runtime_abi_v7" }, + runtime_abi: { version: 8, marker: "scr_runtime_abi_v8" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: [], sanitizer: "external-toolchain-required" }, flavors: { diff --git a/tests/harness/self-hosting-validator-cases.ts b/tests/harness/self-hosting-validator-cases.ts index fed9f4781..1aeeb4273 100644 --- a/tests/harness/self-hosting-validator-cases.ts +++ b/tests/harness/self-hosting-validator-cases.ts @@ -16,7 +16,7 @@ import { boolLit, numLit, strLit, varRef } from "../../packages/compiler/src/ir/ const loc = { file: "validator-input.ts", start: 12, end: 34 }; const expression = (expr: IrExpr): IrStmt => ({ kind: "exprStmt", expr, loc }); const base = (): IrModule => ({ - irVersion: 14, + irVersion: 15, sourceFile: loc.file, entry: "main", functions: [{ name: "main", params: [], locals: [], returnType: VOID, body: [], loc }], From fe2a9037575569644da4ad402715ddedfd8ec1d4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 7 Oct 2026 23:57:49 -0500 Subject: [PATCH 02/14] Preserve native class views and numeric updates - Keep narrowed class fields and shared union writes native. - Support covariant returns and method extraction through abstract class views. - Preserve missing numeric values through local, static, and namespace updates. --- .../compiler/src/backend/llvm/classes.test.ts | 34 +++++ packages/compiler/src/backend/llvm/classes.ts | 8 +- .../frontend/lowering/class-method-values.ts | 37 ++++- .../src/frontend/lowering/class-unions.ts | 83 ++++++++++++ .../lowering/coercions/function-adapters.ts | 2 + .../lowering/expressions/union-field-write.ts | 126 ++++++++++-------- .../src/frontend/lowering/lower-calls.ts | 8 +- .../src/frontend/lowering/lower-classes.ts | 70 +++++++--- .../src/frontend/lowering/lower-exprs.ts | 95 ++++++++++++- .../src/frontend/lowering/lower-stmts.ts | 44 ++---- .../compiler/src/frontend/lowering/lowerer.ts | 68 +++++++++- packages/compiler/src/ir/ir.ts | 4 +- packages/compiler/src/ir/validate.test.ts | 92 +++++++++++++ packages/compiler/src/ir/validate.ts | 29 +++- .../compiler/test/class-field-updates.test.ts | 28 ++++ .../test/class-narrowing-emission.test.ts | 37 +++++ .../test/ts7/baselines/order-parity.json | 18 +++ tests/corpus/class-covariant-returns.ts | 67 ++++++++++ tests/corpus/class-narrowed-views.ts | 77 +++++++++++ tests/corpus/derived-numeric-updates.ts | 57 ++++++++ .../harness/__snapshots__/inheritance.ts.txt | 2 +- 21 files changed, 845 insertions(+), 141 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/class-unions.ts create mode 100644 packages/compiler/test/class-narrowing-emission.test.ts create mode 100644 tests/corpus/class-covariant-returns.ts create mode 100644 tests/corpus/class-narrowed-views.ts create mode 100644 tests/corpus/derived-numeric-updates.ts diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index 8710759e2..a3c7d4664 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -107,3 +107,37 @@ test.each(["i32", "i64"])( expect(emitClassMembershipHelper(buildClassGraph(ordinary, new Map()), size)).toEqual([]); }, ); + +test("unconstructed narrowing layouts may omit abstract slots while live classes require implementations", () => { + const mod: IrModule = { + irVersion: 15, + sourceFile: loc.file, + entry: "main", + functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], + classes: [ + { name: "Base", fields: [], methods: ["run"], abstractMethods: ["run"], abstract: true, loc }, + { name: "Dormant", base: "Base", fields: [], methods: [], loc }, + ], + }; + const active = { kind: "object" as const, className: "Active" }; + mod.classes!.push({ name: "Active", base: "Base", fields: [], methods: ["run"], loc }); + mod.functions.push({ + name: "%Active.run", + params: [{ localId: "self", name: "self", type: active }], + returnType: VOID, + locals: [{ id: "self", name: "self", type: active, mutable: false }], + body: [], + loc, + }); + expect(emitLlvmModule(mod)).toMatch(/ptr null[^\n]*; class Dormant/); + const self = { kind: "object" as const, className: "Dormant" }; + mod.functions.push({ + name: "%Dormant.constructor", + params: [{ localId: "self", name: "self", type: self }], + returnType: VOID, + locals: [{ id: "self", name: "self", type: self, mutable: false }], + body: [], + loc, + }); + expect(() => emitLlvmModule(mod)).toThrow("no implementation of run for Dormant"); +}); diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index b4b6a72bd..374289789 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -58,6 +58,8 @@ export class LlClassMeta { /** Root classes: the hierarchy's slots in DFS-declaration order. */ slots: LlVtSlot[] = []; membership: { pre: number; post: number }[] = []; + /** Type-only references retain layouts without retaining construction. */ + constructorRetained = false; constructor(readonly def: IrClassDef) { this.root = this; @@ -76,7 +78,9 @@ export function buildClassGraph( ): Map { const metaMap = new Map(); for (const cls of mod.classes ?? []) { - metaMap.set(cls.name, new LlClassMeta(cls)); + const meta = new LlClassMeta(cls); + meta.constructorRetained = fnByName.has(`%${cls.name}.constructor`); + metaMap.set(cls.name, meta); } for (const meta of metaMap.values()) { if (meta.def.base === undefined) continue; @@ -231,7 +235,7 @@ function vtEntriesFor(meta: LlClassMeta): { slot: LlVtSlot; impl: LlClassMeta | return { slot, impl: c }; } } - if (meta.def.abstract === true) return { slot, impl: null }; + if (meta.def.abstract === true || !meta.constructorRetained) return { slot, impl: null }; throw new InternalCompilerError( `llvm emitter bug: no implementation of ${slot.method} for ${meta.def.name}`, ); diff --git a/packages/compiler/src/frontend/lowering/class-method-values.ts b/packages/compiler/src/frontend/lowering/class-method-values.ts index c72a7d364..4267a186a 100644 --- a/packages/compiler/src/frontend/lowering/class-method-values.ts +++ b/packages/compiler/src/frontend/lowering/class-method-values.ts @@ -12,7 +12,7 @@ import { type IrType, type SrcLoc, } from "../../ir/ir.js"; -import { varRef } from "../../ir/build.js"; +import { nodeThrowExpr, varRef } from "../../ir/build.js"; import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; import { isJsSourceFile, locOf } from "../program.js"; import { newFnCtx, type Lowerer } from "./lowerer.js"; @@ -62,7 +62,18 @@ export function lowerClassMethodValue( type: DYN, loc, }; - let value = methodValue(lowerer, expr, info); + const declared = findMethodOn(lowerer, info, method); + // Abstract declarations have no prototype function. Concrete descendants + // provide the extracted value through the same reachability-driven selector. + let value = declared?.sig.abstract + ? nodeThrowExpr( + 1, + "", + `Missing implementation of method '${method}'`, + funcTypeFromParamShapes(declared.sig.params, declared.sig.ret), + loc, + ) + : methodValue(lowerer, expr, info); if (!value) return null; const callback = isClassCallback(lowerer, info, method); const local = callback ? lowerer.declareHiddenLocal("%callbackReceiver", receiver.type) : null; @@ -123,11 +134,18 @@ export function refreshClassMethodValueSelections(lowerer: Lowerer): boolean { selection.values.has(candidate.def.name) || !lowerer.classCanBeConstructed(candidate) || !lowerer.isSubclassOf(candidate.def.name, selection.info.def.name) || - !candidate.methods.has(selection.method) + !candidate.methods.has(selection.method) || + candidate.methods.get(selection.method)?.abstract ) continue; let value = methodValue(lowerer, selection.expression, candidate); if (value && selection.checked) value = lowerer.coerceToExpected(value, DYN); + if (value && !typeEquals(value.type, selection.fallback.type)) { + const widened = lowerer.coerceCovariantFunction(value, selection.fallback.type); + // Only a representation-preserving view can keep extraction's + // identity contract across differently typed base/derived views. + if (widened) value = widened; + } if (!value || !typeEquals(value.type, selection.fallback.type)) lowerer.unsupported( "SC1090", @@ -140,10 +158,15 @@ export function refreshClassMethodValueSelections(lowerer: Lowerer): boolean { if (!added) continue; const loc = selection.fn.loc; const receiver = varRef("this.0", selection.fn.params[0]!.type, loc); - const values = [...selection.values].sort(([a], [b]) => - lowerer.isSubclassOf(a, b) ? -1 : lowerer.isSubclassOf(b, a) ? 1 : 0, - ); - selection.fn.body = values.map(([className, value]): IrStmt => ({ + const names = [...selection.values.keys()]; + const values = [...selection.values] + .map(([className, value]) => ({ + className, + value, + ancestors: names.filter((name) => lowerer.isSubclassOf(className, name)).length, + })) + .sort((a, b) => b.ancestors - a.ancestors); + selection.fn.body = values.map(({ className, value }): IrStmt => ({ kind: "if", cond: { kind: "instanceOf", value: receiver, className, type: BOOL, loc }, then: [{ kind: "return", value, loc }], diff --git a/packages/compiler/src/frontend/lowering/class-unions.ts b/packages/compiler/src/frontend/lowering/class-unions.ts new file mode 100644 index 000000000..2822c44db --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-unions.ts @@ -0,0 +1,83 @@ +import { BOOL, type IrExpr, type IrStmt, type IrType } from "../../ir/ir.js"; +import { nodeThrowExpr, varRef } from "../../ir/build.js"; +import type { Lowerer } from "./lowerer.js"; + +/** A base-class slot stays one pointer after a guard selects several + * subclasses. Recover the tagged view from runtime membership without + * copying the object or guessing which of the remaining layouts it has. */ +export function narrowClassUnion( + lowerer: Lowerer, + value: IrExpr, + target: IrType & { kind: "union" }, +): IrExpr | null { + if (value.type.kind !== "object") return null; + const source = value.type; + const arms = lowerer.unions.get(target.unionId)?.arms; + if ( + !arms?.length || + !arms.every( + (arm) => arm.kind === "object" && lowerer.isSubclassOf(arm.className, source.className), + ) + ) + return null; + const loc = value.loc; + const key = `classUnion:${source.className}:${target.unionId}`; + let helper = lowerer.valueHelpers.get(key); + if (!helper) { + helper = `%class.union.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, helper); + const input = varRef("value.0", source, loc); + // Rank by ancestry before sorting: returning zero for unrelated pairs + // alone would not reliably put a descendant before a nonadjacent ancestor. + const ordered = arms + .map((arm, tag) => ({ + arm, + tag, + ancestors: arms.filter( + (other) => + arm.kind === "object" && + other.kind === "object" && + lowerer.isSubclassOf(arm.className, other.className), + ).length, + })) + .sort((a, b) => b.ancestors - a.ancestors); + const body: IrStmt[] = []; + for (const { arm, tag } of ordered) { + if (arm.kind !== "object") continue; + body.push({ + kind: "if", + cond: { kind: "instanceOf", value: input, className: arm.className, type: BOOL, loc }, + then: [ + { + kind: "return", + value: { + kind: "unionWrap", + unionId: target.unionId, + tag, + value: { kind: "downcast", value: input, type: arm, loc }, + type: target, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }); + } + body.push({ + kind: "return", + value: nodeThrowExpr(1, "", "Class value does not match the narrowed union", target, loc), + loc, + }); + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "value.0", name: "value", type: source }], + locals: [{ id: "value.0", name: "value", type: source, mutable: false }], + returnType: target, + body, + loc, + }); + } + return { kind: "call", callee: helper, args: [value], type: target, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index c3dcfdad0..3a59d0895 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -76,6 +76,8 @@ export function funcReturnWidthAdapter( * promises to accept the destination signature. */ export function coercibleValue(lowerer: Lowerer, src: IrType, dst: IrType): boolean { if (typeEquals(src, dst)) return true; + if (src.kind === "object" && dst.kind === "object") + return lowerer.isSubclassOf(src.className, dst.className); // The island boundary joins the mechanical set: values that MARSHAL // in (units, the checked-dynamic deep copy, JSON-safe data, liftable // composites, marshalable closures — coerceToExpected's jsval-IN diff --git a/packages/compiler/src/frontend/lowering/expressions/union-field-write.ts b/packages/compiler/src/frontend/lowering/expressions/union-field-write.ts index 0db5285dc..1bad5728f 100644 --- a/packages/compiler/src/frontend/lowering/expressions/union-field-write.ts +++ b/packages/compiler/src/frontend/lowering/expressions/union-field-write.ts @@ -1,18 +1,18 @@ import { nodeThrowExpr, varRef } from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; import type { Lowerer } from "../lowerer.js"; -import { BOOL, VOID, type IrExpr, type IrStmt, type IrType, typeEquals } from "../../../ir/ir.js"; +import { BOOL, type IrExpr, type IrStmt, type IrType, typeEquals } from "../../../ir/ir.js"; import { locOf } from "../../program.js"; -/** Assign a common data field without projecting the union's records into - * a new structural shape. Each tag writes the original record payload, so +/** Assign a common data field without projecting the union's objects into + * a new structural shape. Each tag writes the original payload, so * aliases and identity-keyed collections observe the mutation. Accessors, * missing fields, and differently typed storage retain their own fences. */ export function lowerUnionFieldWrite( lowerer: Lowerer, access: ts.PropertyAccessExpression, rhs: ts.Expression, -): IrStmt | null { +): IrExpr | null { const mapped = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); if (access.questionDotToken || mapped?.kind !== "union") return null; const receiver = lowerer.maybeNarrow(lowerer.lowerExpr(access.expression), access.expression); @@ -20,18 +20,21 @@ export function lowerUnionFieldWrite( const unionId = receiver.type.unionId; const arms = lowerer.unions.get(unionId)?.arms; if (!arms?.length) return null; - const fields: ({ shapeId: string; type: IrType } | null)[] = []; + const fields: ({ receiver: IrType & { kind: "record" | "object" }; type: IrType } | null)[] = []; let fieldType: IrType | null = null; for (const arm of arms) { if (arm.kind === "undefinedT" || arm.kind === "nullT") { fields.push(null); continue; } - if (arm.kind !== "record") return null; - const field = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === access.name.text); - if (!field || (fieldType !== null && !typeEquals(fieldType, field.type))) return null; - fieldType = field.type; - fields.push({ shapeId: arm.shapeId, type: field.type }); + if (arm.kind !== "record" && arm.kind !== "object") return null; + const type = + arm.kind === "record" + ? lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === access.name.text)?.type + : lowerer.classes.get(arm.className)?.fields.get(access.name.text); + if (!type || (fieldType !== null && !typeEquals(fieldType, type))) return null; + fieldType = type; + fields.push({ receiver: arm, type }); } if (fieldType === null) return null; const loc = locOf(access); @@ -45,60 +48,67 @@ export function lowerUnionFieldWrite( { kind: "varDecl", localId: object.id, init: receiver, loc }, { kind: "varDecl", localId: stored.id, init: value, loc }, ]; - let dispatch: IrStmt[] = []; + const result = varRef(stored.id, stored.type, loc); + let dispatch: IrExpr = result; for (let tag = fields.length - 1; tag >= 0; tag--) { const field = fields[tag]; - const obj: IrExpr | null = field - ? { - kind: "unionNarrow", - unionId, - tag, - value: objectRef, - type: { kind: "record", shapeId: field.shapeId }, - loc, - } - : null; - const write: IrStmt = - field && obj - ? { - kind: "recordSet", - obj, - shapeId: field.shapeId, - field: access.name.text, - value: varRef(stored.id, stored.type, loc), - loc, - } - : { - kind: "exprStmt", - expr: nodeThrowExpr( - 1, - "", - `Cannot set properties of ${arms[tag]!.kind === "nullT" ? "null" : "undefined"} (setting '${access.name.text}')`, - VOID, + let branch: IrExpr; + if (field) { + const obj: IrExpr = { + kind: "unionNarrow", + unionId, + tag, + value: objectRef, + type: field.receiver, + loc, + }; + const write: IrStmt = + field.receiver.kind === "record" + ? { + kind: "recordSet", + obj, + shapeId: field.receiver.shapeId, + field: access.name.text, + value: result, loc, - ), - loc, - }; + } + : { + kind: "fieldSet", + obj, + className: field.receiver.className, + field: access.name.text, + value: result, + loc, + }; + branch = { kind: "seqExpr", stmts: [write], result, type: result.type, loc }; + } else { + branch = nodeThrowExpr( + 1, + "", + `Cannot set properties of ${arms[tag]!.kind === "nullT" ? "null" : "undefined"} (setting '${access.name.text}')`, + result.type, + loc, + ); + } dispatch = tag === fields.length - 1 - ? [write] - : [ - { - kind: "if", - cond: { - kind: "unionIsTag", - unionId, - tag, - value: objectRef, - negated: false, - type: BOOL, - loc, - }, - then: [write], - else_: dispatch, + ? branch + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag, + value: objectRef, + negated: false, + type: BOOL, loc, }, - ]; + then: branch, + else_: dispatch, + type: result.type, + loc, + }; } - return { kind: "block", body: [...body, ...dispatch], loc }; + return { kind: "seqExpr", stmts: body, result: dispatch, type: result.type, loc }; } diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 1298bddaa..0ecec7f3c 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4865,14 +4865,14 @@ function reconcileOverloadReturn( ): IrExpr { const rsig = lowerer.checker.getResolvedSignature(expr); const rdecl = rsig ? lowerer.checker.signatureDeclaration(rsig) : undefined; - // Fluent JS overrides share the base's return ABI, but their result - // still has the receiver's subclass layout at the call site. + // Nominal covariant overrides share the base's return ABI, but retain + // their declared subclass result at the call site. Unannotated fluent + // JS overrides use the same bridge for their proven receiver result. if ( call.type.kind === "object" && rdecl && ts.isMethodDeclaration(rdecl) && - isJsSourceFile(rdecl.getSourceFile()) && - returnsOnlyThis(rdecl) + (!isJsSourceFile(rdecl.getSourceFile()) || returnsOnlyThis(rdecl)) ) { call = lowerer.maybeNarrow(call, expr); } diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 0c5fd0f26..517ace086 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -510,6 +510,8 @@ export interface ClassInfo { staticFields: { name: string; type: IrType; + /** Indexed sources can be absent despite the declared static type. */ + runtimeOptional?: true; initializer: ts.Expression; globalId: string; readonly: boolean; @@ -2918,20 +2920,20 @@ export function collectClassShapeInner( `overriding the inherited generic method '${mName}' with a non-generic method (generic methods dispatch statically and would never reach this override)`, ); } - // Abstract re-declarations keep the overridden ABI exactly, - // like any override (a concrete implementation below must - // agree with BOTH, which exactness makes one constraint). const overridden = lowerer.findMethodOn(base, mName); if ( overridden && (overridden.sig.params.length !== shapes.length || !overridden.sig.params.every((p, i) => typeEquals(p.type, shapes[i]!.type)) || - !typeEquals(overridden.sig.ret, ret)) + !typeEquals( + overridden.sig.ret, + overrideReturnAbi(lowerer, ret, overridden.sig.ret, className, base), + )) ) { lowerer.unsupported( "SC1090", member.name, - "overriding a method with a different signature (parameter and return types must match the base declaration exactly)", + "overriding a method with a different signature (parameter types must match the base declaration exactly; returns may narrow to a subclass)", ); } methods.set(mName, { params: shapes, ret, abstract: true }); @@ -3065,11 +3067,12 @@ export function collectClassShapeInner( const refined = symbolSlotReturnType(lowerer, member, symbolFields, fields); if (refined) ft.ret = refined; } - // Overrides keep the EXACT overridden ABI signature. tsc's method + // Overrides keep the inherited ABI signature. tsc's method // bivariance would let a narrowed parameter type through, and a // vtable-dispatched call could then hand the override a base // instance it reads out-of-bounds fields from — exactness keeps - // every slot sound (covariant returns can come later). Comparing + // every parameter slot sound. Nominal covariant returns share the + // base pointer ABI while retaining their declared result type. Comparing // ABI types only (not modes) is deliberate: call sites complete // against the STATIC receiver's shape, so `m(x?: number)` and // `m(x: number | undefined)` interchange soundly in overrides. @@ -3147,7 +3150,10 @@ export function collectClassShapeInner( member.asteriskToken === undefined && (overridden.sig.params.length !== shapes.length || !overridden.sig.params.every((p, i) => typeEquals(p.type, shapes[i]!.type)) || - !typeEquals(overridden.sig.ret, ft.ret)) + !typeEquals( + overridden.sig.ret, + overrideReturnAbi(lowerer, ft.ret, overridden.sig.ret, className, base), + )) ) { const fencedJsOverride = isJsSourceFile(member.getSourceFile()) && @@ -3184,7 +3190,7 @@ export function collectClassShapeInner( locOf(member.name), returnOnly ? `overriding method '${mName}' with a different return type (the native return type must match the base declaration exactly)` - : `overriding method '${mName}' with a different signature (the native parameter and return types must match the base declaration exactly)`, + : `overriding method '${mName}' with a different signature (the native parameter types must match the base declaration exactly; returns may narrow to a subclass)`, ), ); shapes.splice(0, shapes.length, ...overridden.sig.params); @@ -3193,7 +3199,7 @@ export function collectClassShapeInner( lowerer.unsupported( "SC1090", member.name, - "overriding a method with a different signature (parameter and return types must match the base declaration exactly)", + "overriding a method with a different signature (parameter types must match the base declaration exactly; returns may narrow to a subclass)", ); } } @@ -4836,10 +4842,13 @@ export function lowerStaticFieldRead( ); } if (found?.field !== undefined) { - return lowerer.maybeNarrow( - { kind: "varRef", localId: found.field.globalId, type: found.field.type, loc }, - expr, - ); + const value: IrExpr = { + kind: "varRef", + localId: found.field.globalId, + type: found.field.type, + loc, + }; + return found.field.runtimeOptional ? value : lowerer.maybeNarrow(value, expr); } if (found) { return staticMethodValue(lowerer, found.declarer, expr.name.text, found.method, expr, loc); @@ -5778,10 +5787,13 @@ export function lowerClassValueProperty( ); } if (found.field !== undefined) { - return lowerer.maybeNarrow( - { kind: "varRef", localId: found.field.globalId, type: found.field.type, loc }, - expr, - ); + const value: IrExpr = { + kind: "varRef", + localId: found.field.globalId, + type: found.field.type, + loc, + }; + return found.field.runtimeOptional ? value : lowerer.maybeNarrow(value, expr); } return staticMethodValue(lowerer, found.declarer, member, found.method, expr, loc); } @@ -5803,6 +5815,28 @@ function abstractMemberSignature( }; } +/** A nominal return subtype uses the same owned pointer as the inherited + * return slot. The current class is still being collected, so consult its + * already collected base chain before the complete class graph is available. */ +function overrideReturnAbi( + lowerer: Lowerer, + declared: IrType, + inherited: IrType, + className: string, + base: ClassInfo | null, +): IrType { + if (declared.kind !== "object" || inherited.kind !== "object") return declared; + if ( + lowerer.isSubclassOf(declared.className, inherited.className) || + (declared.className === className && + base !== null && + (base.def.name === inherited.className || + lowerer.isSubclassOf(base.def.name, inherited.className))) + ) + return inherited; + return declared; +} + /** The nearest declaration of `name` at or above `info` — the method a * receiver of that static class runs when nothing below overrides it. */ export function findMethodOn( diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 1392a24e4..52af59ab2 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -23,6 +23,8 @@ import { dirname } from "node:path"; import * as posix from "node:path/posix"; import type { Lowerer } from "./lowerer.js"; import { checkedClassAssertion } from "./class-assertions.js"; +import { narrowClassUnion } from "./class-unions.js"; +import { lowerUnionFieldWrite } from "./expressions/union-field-write.js"; import { captureContextArguments } from "./function-context.js"; import { OBJECT_CALLABLE_VALUES } from "./surfaces.js"; import { wasiGuestPath } from "../../wasi-paths.js"; @@ -3575,6 +3577,10 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx // same trust-the-checker contract as the union bridge below. if (expr.type.kind === "object") { const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); + if (narrowed?.kind === "union") { + const union = narrowClassUnion(lowerer, expr, narrowed); + if (union) return union; + } if ( narrowed?.kind === "object" && narrowed.className !== expr.type.className && @@ -7201,6 +7207,9 @@ export function lowerAbsenceProbe(lowerer: Lowerer, node: ts.Expression): IrExpr expr = expr.expression; } if (ts.isPropertyAccessExpression(expr)) { + // Namespace members are bindings, not fields of a runtime namespace + // object. Their ordinary read already preserves optional storage. + if (!expr.questionDotToken && nsMemberIdentOf(lowerer, expr)) return null; const target = lowerer.fieldTarget(expr); if ( target?.container === "class" && @@ -8833,10 +8842,12 @@ export function lowerIncDec( } fenceNodeModuleMutation(lowerer, access, "assignment"); if (ts.isPropertyAccessExpression(access)) { - const target = staticFieldWriteTarget(lowerer, access); + const target = + staticFieldWriteTarget(lowerer, access) ?? + expandoWritableTarget(lowerer, access) ?? + nsWritableTarget(lowerer, access); if (target) { - if (target.type.kind !== "f64") lowerer.unsupported("SC1043", expr); - return { kind: "incDec", op, prefix, localId: target.id, type: F64, loc }; + return lowerIncDecTarget(lowerer, expr, target, prefix); } } const stmts: IrStmt[] = []; @@ -8993,10 +9004,24 @@ export function lowerIncDec( `increment/decrement of '${expr.operand.text}' (not a writable local or module global)`, ); } + return lowerIncDecTarget(lowerer, expr, target, prefix); +} + +/** Numeric updates read the represented slot once and write the computed + * value back in that same representation. In particular, an unchecked + * indexed initializer can leave undefined in an otherwise numeric binding. */ +export function lowerIncDecTarget( + lowerer: Lowerer, + expr: ts.PrefixUnaryExpression | ts.PostfixUnaryExpression, + target: { id: string; type: IrType }, + prefix: boolean, +): IrExpr { + const loc = locOf(expr); + const op = expr.operator === ts.SyntaxKind.PlusPlusToken ? "+" : "-"; if (target.type.kind === "dyn") { const old = lowerer.declareHiddenLocal("%numericOld", DYN); const next = lowerer.declareHiddenLocal("%numericNext", DYN); - const read = lowerer.lowerExpr(expr.operand); + const read = varRef(target.id, target.type, loc); return { kind: "seqExpr", stmts: [ @@ -9028,6 +9053,47 @@ export function lowerIncDec( loc, }; } + if (target.type.kind === "union") { + const read = lowerOptionalNumber( + lowerer, + varRef(target.id, target.type, loc), + loc, + expr.operand, + ); + if (read.type.kind === "f64") { + const old = lowerer.declareHiddenLocal("%numericOld", F64); + const next = lowerer.declareHiddenLocal("%numericNext", F64); + const result = varRef(prefix ? next.id : old.id, F64, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: old.id, init: read, loc }, + { + kind: "varDecl", + localId: next.id, + init: { + kind: "bin", + op, + left: varRef(old.id, F64, loc), + right: numLit(1, loc), + type: F64, + loc, + }, + loc, + }, + { + kind: "assign", + localId: target.id, + value: lowerer.coerceInto(expr.operand, varRef(next.id, F64, loc), target.type), + loc, + }, + ], + result, + type: F64, + loc, + }; + } + } if (target.type.kind !== "f64") lowerer.unsupported("SC1043", expr); return { kind: "incDec", op, prefix, localId: target.id, type: F64, loc }; } @@ -9444,6 +9510,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr if (ts.isPropertyAccessExpression(expr.left) && !expr.left.questionDotToken) { const recv = tryLowerExpression(lowerer, expr.left.expression); if (recv && recv.type.kind === "dyn") return lowerDynMemberAssignment(lowerer, expr, recv); + const unionWrite = lowerUnionFieldWrite(lowerer, expr.left, expr.right); + if (unionWrite) return unionWrite; const field = lowerer.fieldTarget(expr.left); if (field) { const recvTmp = lowerer.declareHiddenLocal("%setReceiver", field.obj.type); @@ -13492,7 +13560,11 @@ export function lowerUnionProperty( return null; } if (value.type.kind !== "union") { - throw new InternalCompilerError("lowerer bug: union-typed receiver lowered to a non-union"); + lowerer.unsupported( + "SC1090", + expr, + "shared property access without a represented union receiver", + ); } const def = lowerer.unions.get(value.type.unionId); if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${value.type.unionId}`); @@ -13918,10 +13990,19 @@ export function fieldTarget( const stored = ts.isIdentifier(access.expression) ? (lowerer.peekLocal(access.expression)?.type ?? lowerer.globalOf(access.expression)?.type) : undefined; + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + // Structural record views retain their storage layout, while a nominal + // subclass guard proves a more specific layout of the same object. const receiverIr = - stored?.kind === "record" || stored?.kind === "object" + stored?.kind === "record" ? stored - : lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + : stored?.kind === "object" && + !( + narrowed?.kind === "object" && + lowerer.isSubclassOf(narrowed.className, stored.className) + ) + ? stored + : narrowed; if (receiverIr?.kind === "object") { return classFieldTarget(lowerer, access.expression, receiverIr, access.name.text); } diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 55a31c85d..de032cab1 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -124,7 +124,7 @@ import { classStaticDataFor } from "./class-static-data.js"; import { objectFactorySignature } from "./object-factory-new.js"; import { isClassCallback, lowerClassCallbackAssign } from "./class-callbacks.js"; import { classForInHasKey, classPropertiesHelper } from "./class-dynamic-dispatch.js"; -import { genericIfaceBindingKeepsClass, staticFieldWriteTarget } from "./lower-classes.js"; +import { genericIfaceBindingKeepsClass } from "./lower-classes.js"; import { lowerStreamUnderscoreAssign, streamClassAliasDecl } from "./lower-stream.js"; import { lowerHttpResPropertyAssignment, @@ -153,6 +153,7 @@ import { lowerCompoundValueToTarget, lowerElementCompound, lowerIncDec, + lowerIncDecTarget, lowerGroupsProjection, matchResultNamedGroupsOf, runtimeOptionalGuardIds, @@ -6871,7 +6872,7 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm }; } const unionWrite = lowerUnionFieldWrite(lowerer, expr.left, expr.right); - if (unionWrite) return unionWrite; + if (unionWrite) return { kind: "exprStmt", expr: unionWrite, loc: locOf(expr) }; const target = lowerer.fieldTarget(expr.left); if (target) { const value = lowerer.lowerExprExpecting(expr.right, target.fieldType); @@ -7092,31 +7093,7 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm }; } // Statement position only: pre/post distinction is unobservable, so - // both desugar to `x = x ± 1`. - if ( - ts.isPropertyAccessExpression(expr.operand) || - (ts.isElementAccessExpression(expr.operand) && symbolFieldInfo(lowerer, expr.operand)) - ) { - // `N.x++` — the namespace-qualified spelling of a module-global - // increment (the member's global IS the variable); expando - // function members (`foo.count++`) are module globals too. - if (ts.isPropertyAccessExpression(expr.operand) && !expr.operand.questionDotToken) { - const staticTarget = staticFieldWriteTarget(lowerer, expr.operand); - if (staticTarget) return lowerIncDecToTarget(lowerer, expr, staticTarget); - const exT = expandoWritableTarget(lowerer, expr.operand); - if (exT) return lowerIncDecToTarget(lowerer, expr, exT); - const nsT = nsWritableTarget(lowerer, expr.operand); - if (nsT) return lowerIncDecToTarget(lowerer, expr, nsT); - } - // `obj.f++` desugars through the compound-field path (`obj.f += 1`); - // `this[kLimit]++` is the same desugar over the symbol-keyed slot. - return lowerer.lowerFieldCompound( - expr.operand as ts.PropertyAccessExpression | ts.ElementAccessExpression, - expr.operator === ts.SyntaxKind.PlusPlusToken ? "+" : "-", - null, - locOf(expr), - ); - } + // numeric bindings retain the simple `x = x ± 1` loop form. if (!ts.isIdentifier(expr.operand)) { lowerer.unsupported("SC1090", expr.operand, "increment/decrement of non-variables"); } @@ -7204,22 +7181,17 @@ function lowerIncDecToTarget( const loc = locOf(expr); const op = expr.operator === ts.SyntaxKind.PlusPlusToken ? "+" : "-"; const readTarget: IrExpr = { kind: "varRef", localId: target.id, type: target.type, loc }; - // JS any-origin targets: check the read to number, convert the - // result back into the dyn slot (the compound-assign stance). - const dynTarget = target.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile()); - if (target.type.kind !== "f64" && !dynTarget) { - lowerer.unsupported("SC1090", expr.operand, "increment/decrement of non-number targets"); - } + if (target.type.kind !== "f64") + return { kind: "exprStmt", expr: lowerIncDecTarget(lowerer, expr, target, false), loc }; const computed: IrExpr = { kind: "bin", op, - left: dynTarget ? { kind: "dynCheck", value: readTarget, type: F64, loc } : readTarget, + left: readTarget, right: { kind: "numLit", value: 1, type: F64, loc }, type: F64, loc, }; - const value: IrExpr = dynTarget ? { kind: "dynFrom", value: computed, type: DYN, loc } : computed; - return { kind: "assign", localId: target.id, value, loc }; + return { kind: "assign", localId: target.id, value: computed, loc }; } /** The shared tail of `x op= e` over a resolved variable/global target — diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index d73d83370..86f08d1d9 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3659,6 +3659,15 @@ export class Lowerer { ); }; const functionDeclBySymbol = new Map(); + const staticFieldsBySymbol = new Map(); + for (const info of this.classes.values()) { + for (const field of info.staticFields) { + const declaration = field.initializer.parent; + if (!ts.isPropertyDeclaration(declaration)) continue; + const symbol = symbolOf(declaration.name); + if (symbol) staticFieldsBySymbol.set(symbol, field); + } + } type RuntimeSig = { params: ParamShape[]; returnType: IrType; @@ -3828,6 +3837,10 @@ export class Lowerer { const symbol = symbolOf(e); return symbol !== null && optionalSymbols.has(symbol); } + if (ts.isPropertyAccessExpression(e)) { + const symbol = symbolOf(e.name); + if (symbol && optionalSymbols.has(symbol)) return true; + } if (ts.isCallExpression(e) && ts.isIdentifier(e.expression)) { const symbol = callableSymbolOf(e.expression); return symbol !== null && optionalReturns.has(symbol); @@ -4300,12 +4313,15 @@ export class Lowerer { optionalSymbols.add(symbol); changed = true; } - } else if ( - ts.isPropertyAccessExpression(node.left) && - ts.isIdentifier(node.left.expression) - ) { - const symbol = symbolOf(node.left.expression); - if (symbol && noteFieldSymbol(symbol, node.left.name.text)) changed = true; + } else if (ts.isPropertyAccessExpression(node.left)) { + const field = symbolOf(node.left.name); + if (field && staticFieldsBySymbol.has(field) && !optionalSymbols.has(field)) { + optionalSymbols.add(field); + changed = true; + } else if (ts.isIdentifier(node.left.expression)) { + const symbol = symbolOf(node.left.expression); + if (symbol && noteFieldSymbol(symbol, node.left.name.text)) changed = true; + } } } if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { @@ -4515,6 +4531,12 @@ export class Lowerer { let changed = true; while (changed) { changed = false; + for (const [symbol, field] of staticFieldsBySymbol) { + if (!optionalSymbols.has(symbol) && mayBeOptional(field.initializer)) { + optionalSymbols.add(symbol); + changed = true; + } + } for (const sf of sourceFiles) if (scanFile(sf)) changed = true; for (const [symbol, decl] of functionDeclBySymbol) if (scanReturns(symbol, decl)) changed = true; @@ -4545,6 +4567,20 @@ export class Lowerer { } } } + const globalsById = new Map(this.globalsList.map((global) => [global.id, global])); + for (const [symbol, field] of staticFieldsBySymbol) { + if (!optionalSymbols.has(symbol)) continue; + const promoted = addUndefined(field.type); + // An explicit optional annotation already has the right layout, but + // an indexed assignment still invalidates the checker's presence fact. + field.type = promoted; + field.runtimeOptional = true; + const global = globalsById.get(field.globalId); + if (global) { + global.type = promoted; + this.runtimeOptionalGlobals.add(global); + } + } for (const [symbol, sig] of signatureBySymbol) { const params = optionalParams.get(symbol); if (params) { @@ -6449,6 +6485,24 @@ export class Lowerer { return literalUnionArm(def, values, (id) => this.shapes.get(id), cached.owners); } + /** Widen only the result view; identical parameter ABIs preserve closure identity. */ + coerceCovariantFunction(expr: IrExpr, expected: IrType): IrExpr | null { + if ( + expr.type.kind === "func" && + expected.kind === "func" && + expr.type.rest === expected.rest && + expr.type.restAbi === expected.restAbi && + expr.type.argumentsAll === expected.argumentsAll && + expr.type.params.length === expected.params.length && + expr.type.params.every((type, i) => typeEquals(type, expected.params[i]!)) && + expr.type.ret.kind === "object" && + expected.ret.kind === "object" && + this.isSubclassOf(expr.type.ret.className, expected.ret.className) + ) + return { kind: "upcast", value: expr, type: expected, loc: expr.loc }; + return null; + } + /** Implicit union construction. Wherever a value flows into a typed slot * (initializer, assignment, call argument, return, field write, record * literal field, ternary arm) whose expected type is a union and the @@ -6758,6 +6812,8 @@ export class Lowerer { return { kind: "call", callee: adapter, args: [expr], type: expected, loc: expr.loc }; } } + const covariant = this.coerceCovariantFunction(expr, expected); + if (covariant) return covariant; // The GENERAL function-value adapter (funcCoerceAdapter): a function // whose signature differs from the slot's only by coercible pieces — // fewer parameters (JS ignores extras: `load(function () {})` into an diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 5a845f2b5..1306d6d67 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2673,7 +2673,9 @@ export type IrExpr = * pointer, type-only — legal exactly when D strictly descends from C * AND the two constructors' completed ABIs are equal (param-wise * typeEquals; validator-enforced), the invariant `newValue` completion - * rests on. */ + * rests on. Function values may also widen a nominal class return with + * identical parameter/rest types: the closure identity and pointer-return + * ABI remain unchanged. */ | { kind: "upcast"; value: IrExpr; type: IrType; loc: SrcLoc } /** Implicit widening of a promise value into a VOID-promise slot (an * inferred Promise/Promise return whose body built a diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 956e08cd8..51e826385 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -1615,3 +1615,95 @@ test("typed-array brand tests serialize their element kind and reject misplaced "in main: dynTest bytesElem requires a valid bytes test", ); }); + +test("function result views preserve the parameter ABI and reject reversed covariance", () => { + const source: IrType = { + kind: "func", + params: [F64], + ret: { kind: "object", className: "Child" }, + }; + const target: IrType = { + kind: "func", + params: [F64], + ret: { kind: "object", className: "Base" }, + }; + const mod = expressionModule( + { + kind: "upcast", + value: { kind: "varRef", localId: "callback", type: source, loc }, + type: target, + loc, + }, + [], + ); + mod.classes = [ + { name: "Base", fields: [], loc }, + { name: "Child", base: "Base", fields: [], loc }, + { name: "Other", fields: [], loc }, + ]; + mod.functions[0]!.params = [{ localId: "callback", name: "callback", type: source }]; + mod.functions[0]!.locals = [{ id: "callback", name: "callback", type: source, mutable: false }]; + expect(validateModule(deserializeModule(serializeModule(mod)))).toEqual([]); + for (const variant of [ + "parameter", + "arity", + "rest", + "rest-abi", + "arguments", + "return", + "unrelated", + "reverse", + ] as const) { + const bad = structuredClone(mod); + const stmt = bad.functions[0]!.body[0]!; + if (stmt.kind !== "exprStmt" || stmt.expr.kind !== "upcast" || stmt.expr.type.kind !== "func") + throw new Error("fixture"); + const type = stmt.expr.type; + if (variant === "parameter") type.params[0] = STRING; + if (variant === "arity") type.params.push(F64); + if (variant === "rest") type.rest = true; + if (variant === "rest-abi") type.restAbi = "jsval"; + if (variant === "arguments") type.argumentsAll = true; + if (variant === "return") type.ret = F64; + if (variant === "unrelated") type.ret = { kind: "object", className: "Other" }; + if (variant === "reverse") { + if (stmt.expr.value.type.kind !== "func") throw new Error("fixture"); + stmt.expr.value.type.ret = { kind: "object", className: "Base" }; + type.ret = { kind: "object", className: "Child" }; + } + expect( + validateModule(bad).map((error) => error.message), + variant, + ).toContain( + "in main: function upcast requires identical parameters and a covariant class return", + ); + } +}); + +test("abstract virtual slots admit subclass returns without admitting unrelated return layouts", () => { + const mod = virtualCallModule(); + const base: IrType = { kind: "object", className: "Base" }; + const child: IrType = { kind: "object", className: "First" }; + for (const fn of mod.functions.filter((fn) => fn.name.startsWith("%"))) { + fn.params.push({ localId: "result", name: "result", type: child }); + fn.locals.push({ id: "result", name: "result", type: child, mutable: false }); + fn.returnType = child; + fn.body = [ + { kind: "return", value: { kind: "varRef", localId: "result", type: child, loc }, loc }, + ]; + } + for (const fn of mod.functions.filter((fn) => fn.name.startsWith("caller"))) { + fn.params.push({ localId: "result", name: "result", type: child }); + fn.locals.push({ id: "result", name: "result", type: child, mutable: false }); + const stmt = fn.body[0]!; + if (stmt.kind !== "exprStmt" || stmt.expr.kind !== "virtualCall") throw new Error("fixture"); + stmt.expr.type = base; + stmt.expr.args.push({ kind: "varRef", localId: "result", type: child, loc }); + } + expect(validateModule(deserializeModule(serializeModule(mod)))).toEqual([]); + mod.classes!.find((cls) => cls.name === "First")!.base = "Unrelated"; + expect(validateModule(mod).map((error) => error.message)).toEqual([ + "in caller0: virtualCall Base.run result type mismatch", + "in caller1: virtualCall Base.run result type mismatch", + ]); +}); diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 856f452e4..160c4bbfb 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -2818,6 +2818,25 @@ function validateFunction( // exactly when D strictly descends from C AND the two completed // constructor ABIs agree (what newValue completion rests on). checkExpr(e.value); + if (e.kind === "upcast" && e.type.kind === "func" && e.value.type.kind === "func") { + const source = e.value.type; + const target = e.type; + if ( + source.rest !== target.rest || + source.restAbi !== target.restAbi || + source.argumentsAll !== target.argumentsAll || + source.params.length !== target.params.length || + !source.params.every((param, i) => typeEquals(param, target.params[i]!)) || + source.ret.kind !== "object" || + target.ret.kind !== "object" || + !isStrictSubclass(source.ret.className, target.ret.className) + ) + err( + "function upcast requires identical parameters and a covariant class return", + e.loc, + ); + break; + } if (e.kind === "upcast" && e.type.kind === "classval" && e.value.type.kind === "classval") { const [sub, sup] = [e.value.type.className, e.type.className]; if (!isPhysicalSubclass(sub, sup)) { @@ -2994,7 +3013,15 @@ function validateFunction( const p = impl.params[i + 1]; if (p) expectType(a, p.type, `virtualCall ${e.className}.${e.method} arg ${i}`); }); - if (!typeEquals(e.type, callSiteReturnType(impl))) { + const returned = callSiteReturnType(impl); + if ( + !typeEquals(e.type, returned) && + !( + e.type.kind === "object" && + returned.kind === "object" && + isStrictSubclass(returned.className, e.type.className) + ) + ) { err(`virtualCall ${e.className}.${e.method} result type mismatch`, e.loc); } break; diff --git a/packages/compiler/test/class-field-updates.test.ts b/packages/compiler/test/class-field-updates.test.ts index f9e6c660b..986adef2a 100644 --- a/packages/compiler/test/class-field-updates.test.ts +++ b/packages/compiler/test/class-field-updates.test.ts @@ -46,3 +46,31 @@ test("refuses updates to readonly static fields", () => { rmSync(dir, { recursive: true, force: true }); } }); + +test("covariant returns do not allow narrower method parameter storage", () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-override-parameter-")); + try { + const entry = join(dir, "main.ts"); + writeFileSync( + entry, + ` +class Item { value = 1; } +class DetailedItem extends Item { extra = 2; } +class Base { copy(value: Item): Item { return value; } } +class Child extends Base { + copy(value: DetailedItem): DetailedItem { console.log(value.extra); return value; } +} +const receiver: Base = new Child(); +console.log(receiver.copy(new Item()).value); +`, + ); + const { coverage } = analyze(entry); + expect( + coverage.diagnostics.some( + (d) => d.code === "SC1090" && d.message.includes("different signature"), + ), + ).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/class-narrowing-emission.test.ts b/packages/compiler/test/class-narrowing-emission.test.ts new file mode 100644 index 000000000..834072701 --- /dev/null +++ b/packages/compiler/test/class-narrowing-emission.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +test("subclass guards retain native field access through base-class slots", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-class-narrowing-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile( + entry, + ` +class Base { id = 1; } +class Child extends Base { values = [2, 4]; } +function count(value: Base): number { + if (value instanceof Child) return value.values.length + value["id"]; + return 0; +} +console.log(count(new Child()), count(new Base())); +`, + ); + const result = await compile(entry, { + outDir: dir, + outPath, + outputKind: "llvm", + dynamic: false, + }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + const llvm = await readFile(outPath, "utf8"); + expect(llvm).toMatch(/getelementptr inbounds %sc_o_Child/); + expect(llvm).not.toMatch(/call[^\n]*@scr_dyn_(?:obj_get|new_obj|typed_ref|key_get)/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 3581d2c9f..923f585e7 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -14679,6 +14679,24 @@ "/tests/fixtures/node-types/terminal-error-fields.ts" ], "diags": [] + }, + "/tests/corpus/class-narrowed-views.ts": { + "order": [ + "/tests/corpus/class-narrowed-views.ts" + ], + "diags": [] + }, + "/tests/corpus/class-covariant-returns.ts": { + "order": [ + "/tests/corpus/class-covariant-returns.ts" + ], + "diags": [] + }, + "/tests/corpus/derived-numeric-updates.ts": { + "order": [ + "/tests/corpus/derived-numeric-updates.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/class-covariant-returns.ts b/tests/corpus/class-covariant-returns.ts new file mode 100644 index 000000000..00f8f3ccb --- /dev/null +++ b/tests/corpus/class-covariant-returns.ts @@ -0,0 +1,67 @@ +abstract class Document { + title = "draft"; + abstract copy(): Document; +} +abstract class NamedDocument extends Document { + abstract copy(): NamedDocument; + abstract name(): string; +} +class Note extends NamedDocument { + text = "note"; + copy(): Note { + const result = new Note(); + result.text = this.text; + return result; + } + name(): string { return this.text; } +} +class Checklist extends NamedDocument { + copy(): Checklist { return new Checklist(); } + name(): string { return "checklist"; } +} +class RichNote extends Note { + count = 7; + copy(): RichNote { + const result = new RichNote(); + result.text = this.text; + result.count = this.count; + return result; + } +} +function duplicate(value: Document): Document { return value.copy(); } +function named(value: NamedDocument): string { return value.copy().name(); } +function note(value: Note): string { return value.copy().text; } +const plain = new Note(); +const rich = new RichNote(); +console.log(duplicate(plain) instanceof Note, duplicate(rich) instanceof RichNote); +console.log(named(plain), named(rich), note(rich), rich.copy().count); +const extracted = rich.copy; +console.log(extracted.call(rich).count, extracted === rich.copy); +const receiver: Note = rich; +console.log(receiver.copy === rich.copy, receiver.copy().text); + +class Builder { + value = 1; + copy(): Builder { return new Builder(); } + self(): this { return this; } +} +class DetailedBuilder extends Builder { + label = "ready"; + copy(): DetailedBuilder { return new DetailedBuilder(); } + self(): this { return this; } +} +const builder = new DetailedBuilder(); +console.log(builder.copy().label, builder.self().label, builder.self() === builder); +function copyBuilder(value: Builder): Builder { return value.copy(); } +console.log(copyBuilder(builder) instanceof DetailedBuilder); + +const createNote = (): Note => new Note(); +const createDocument: () => Document = createNote; +console.log(createDocument === createNote, createDocument() instanceof Note); + +function copiedViaMethod(value: Document): Document { + const copy = value.copy; + return copy.call(value); +} +console.log(copiedViaMethod(new Checklist()) instanceof Checklist); +console.log(copiedViaMethod(rich) instanceof RichNote); diff --git a/tests/corpus/class-narrowed-views.ts b/tests/corpus/class-narrowed-views.ts new file mode 100644 index 000000000..c67b0bcc0 --- /dev/null +++ b/tests/corpus/class-narrowed-views.ts @@ -0,0 +1,77 @@ +abstract class Entry { + revision = 0; + abstract measure(): number; +} +class Folder extends Entry { + name = "folder"; + items = [2, 4]; + measure(): number { return this.items.length; } +} +class Page extends Entry { + padding = 17; + items = [6]; + measure(): number { return this.padding; } +} +class SpecialFolder extends Folder { + extra = 9; + measure(): number { return this.extra; } +} +class UnusedWrapper extends Entry { + inner: Entry; + constructor(inner: Entry) { super(); this.inner = inner; } + measure(): number { return this.inner.measure(); } +} +function inspect(entry: Entry): number { + if (entry instanceof UnusedWrapper) return inspect(entry.inner); + if (entry instanceof Folder) return entry.items.length + entry["name"].length; + if (entry instanceof Page) return entry.padding; + return -1; +} +function append(entry: Entry): number { + if (entry instanceof Folder || entry instanceof Page) { + entry.items.push(8); + entry.revision = entry.items.length; + return entry.items.length + entry.revision; + } + return -1; +} +function select(flag: boolean): Folder | Page { + return flag ? new Folder() : new Page(); +} +function replace(entry: Folder | Page, items: number[]): void { + entry.items = items; +} +const folder = new Folder(); +const page = new Page(); +const special: Entry = new SpecialFolder(); +console.log(inspect(folder), inspect(page), inspect(special), special.measure()); +console.log(append(folder), append(page), append(special)); +console.log(folder.items.join(","), page.items.join(",")); +const selected = select(false); +replace(selected, [10, 20, 30]); +console.log(selected.items.length, selected.revision); +let current: Folder | Page = select(true); +const original = current; +let calls = 0; +function replacement(): number[] { + calls++; + current = new Page(); + return [40, 50]; +} +current.items = replacement(); +console.log(calls, original.items.join(","), current.items.join(","), original === current); +console.log(new UnusedWrapper(folder).measure()); + +// This class contributes a layout to narrowing but no live constructor. +class Dormant extends Entry { + value = 100; + measure(): number { return this.value; } +} +function dormant(entry: Entry): number { + return entry instanceof Dormant ? entry.value : entry.measure(); +} +console.log(dormant(folder), dormant(page)); + +const expressionTarget = select(false); +const assigned = (expressionTarget.items = [60, 70]); +console.log(assigned === expressionTarget.items, assigned.join(",")); diff --git a/tests/corpus/derived-numeric-updates.ts b/tests/corpus/derived-numeric-updates.ts new file mode 100644 index 000000000..a7119168a --- /dev/null +++ b/tests/corpus/derived-numeric-updates.ts @@ -0,0 +1,57 @@ +// @transform-types +function update(values: number[], index: number): void { + let value = values[index]; + const post = value++; + const pre = ++value; + const down = value--; + const last = --value; + console.log(post, pre, down, last, value, Object.is(post, -0)); + value = values[index + 1]; + value++; + --value; + console.log(value, Number.isNaN(value)); +} +update([3, 8], 0); +update([-0], 0); +update([], 4); + +const positions = [1, 3, 5]; +let visited = ""; +for (let i = 0; i < positions.length; i++) { + for (let j = positions[i]; j < 7; j++) visited += String(j); +} +console.log(visited); +let captured = positions[0]; +function next(): number { return captured++; } +console.log(next(), next(), captured); + +class Counter { + static value = positions[0]; +} +console.log(Counter.value++, ++Counter.value, Counter.value); +Counter.value = positions[99]; +Counter.value++; +console.log(Counter.value, Number.isNaN(Counter.value)); + +namespace State { + export let value = positions[1]; +} +console.log(State.value++, --State.value); +State.value = positions[99]; +State.value++; +console.log(Number.isNaN(State.value)); + +class Deferred { + static value = 3; + static copy = Counter.value; +} +Deferred.value = positions[99]; +console.log(Deferred.value++, ++Deferred.copy); +if (Counter.value !== undefined) console.log(Counter.value + 1); + +class OptionalCounter { + static value: number | undefined = positions[0]; +} +OptionalCounter.value = positions[99]; +console.log(OptionalCounter.value === undefined, Number.isNaN(OptionalCounter.value)); +console.log(OptionalCounter.value++, Number.isNaN(OptionalCounter.value)); diff --git a/tests/harness/__snapshots__/inheritance.ts.txt b/tests/harness/__snapshots__/inheritance.ts.txt index 2264a4600..3ef56a173 100644 --- a/tests/harness/__snapshots__/inheritance.ts.txt +++ b/tests/harness/__snapshots__/inheritance.ts.txt @@ -26,7 +26,7 @@ inheritance.ts:49:3 - error SC1090: redeclaring inherited fields at a different | ^~~~ 50 | } -inheritance.ts:55:3 - error SC1090: overriding a method with a different signature (parameter and return types must match the base declaration exactly) is not supported yet +inheritance.ts:55:3 - error SC1090: overriding a method with a different signature (parameter types must match the base declaration exactly; returns may narrow to a subclass) is not supported yet 54 | class Bivariant extends Animal { 55 | feed(other: Bivariant): string { From 25703ddbbf2ed1d2027404f3bd71f69e048496e3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 00:24:20 -0500 Subject: [PATCH 03/14] Preserve spread calls and explicit receiver signatures - Preserve argument order and receiver binding for replaceable method spreads. - Accept typed callback signatures with an explicit receiver parameter. --- .../src/frontend/lowering/class-callbacks.ts | 11 +++- .../src/frontend/lowering/lower-calls.ts | 2 +- packages/compiler/src/frontend/type-mapper.ts | 18 +++++-- .../test/ts7/baselines/order-parity.json | 6 +++ tests/corpus/class-method-spreads.ts | 51 +++++++++++++++++++ 5 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 tests/corpus/class-method-spreads.ts diff --git a/packages/compiler/src/frontend/lowering/class-callbacks.ts b/packages/compiler/src/frontend/lowering/class-callbacks.ts index 400988f0d..da40f361b 100644 --- a/packages/compiler/src/frontend/lowering/class-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/class-callbacks.ts @@ -15,6 +15,7 @@ import type { Lowerer } from "./lowerer.js"; import { findMethodOn, type ClassInfo } from "./lower-classes.js"; import { classPropertiesHelper } from "./class-dynamic-dispatch.js"; import { classPrototypeData } from "./class-prototypes.js"; +import { checkedIterableSpread } from "./checked-iterable-spread.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; /** Unmodified typed methods retain direct or virtual native dispatch. @@ -145,7 +146,7 @@ export function classCallbackCall( lowerer: Lowerer, receiver: IrExpr, name: string, - args: IrExpr[], + argumentNodes: readonly ts.Expression[], fallback: IrExpr, loc: SrcLoc, calleeName = name, @@ -155,12 +156,20 @@ export function classCallbackCall( const callback = lowerer.declareHiddenLocal("%callback", DYN); const value = varRef(bag.id, DYN, loc); const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; + const spreads: { arg: number; what: string }[] = []; + const args = argumentNodes.map((argument, index) => { + if (!ts.isSpreadElement(argument)) return lowerer.lowerExprExpecting(argument, DYN); + const spelling = argument.expression.getText(); + spreads.push({ arg: index, what: spelling }); + return checkedIterableSpread(lowerer, lowerer.lowerExpr(argument.expression), spelling, loc); + }); const call: IrExpr = { kind: "dynCall", callee: varRef(callback.id, DYN, loc), receiver: lowerer.coerceToExpected(receiver, DYN), calleeName, args, + ...(spreads.length > 0 ? { spreads } : {}), type: DYN, loc, }; diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 0ecec7f3c..50937c3e9 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -9157,7 +9157,7 @@ export function lowerObjectMethodCall( lowerer, value, method, - call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), + call.arguments, fallback, loc, access.getText(), diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 72933b90e..aef7f2b5d 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -49,6 +49,14 @@ import { accessorSlotProp, recordTextCodecClass } from "../ir/ir.js"; // import path. export { typeKey }; +/** The checker omits the erased receiver parameter from callable arity. */ +function runtimeParameterCount(parameters: readonly ts.ParameterDeclaration[]): number { + const first = parameters[0]; + return ( + parameters.length - (first && ts.isIdentifier(first.name) && first.name.text === "this" ? 1 : 0) + ); +} + /** Empty JS object inference describes no fixed layout. Keep these values * in checked storage across fields, parameters and function signatures. */ export function jsOpenObjectType( @@ -3200,12 +3208,15 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // has no declaration): the dotDotDot check below can't see it, and a // fixed-arity func mapping would LIE about the value's calling // convention. Detected by the param-count mismatch against the - // signature's own declaration; unmappable like spelled rest. + // signature's own declaration, excluding an erased `this` parameter. { const sigDecl = checker.signatureDeclaration(sig); const declParams = sigDecl !== undefined && ts.isFunctionLike(sigDecl) ? sigDecl.parameters : undefined; - if (declParams !== undefined && declParams.length !== sig.getParameters().length) { + if ( + declParams !== undefined && + runtimeParameterCount(declParams) !== sig.getParameters().length + ) { return null; } // tsgo never SYNTHESIZES that rest param into the inferred signature @@ -5178,7 +5189,8 @@ export function describeComponentBlocker(widened: ts.Type, ctx: TypeMapperCtx): if ( sigDecl !== undefined && ts.isFunctionLike(sigDecl) && - (sigDecl.parameters.length !== sig.getParameters().length || bodyReadsArguments(sigDecl)) + (runtimeParameterCount(sigDecl.parameters) !== sig.getParameters().length || + bodyReadsArguments(sigDecl)) ) { return `the function shape is supported, but its signature is variadic ('arguments'-reading), and a compiled signature is fixed-arity`; } diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 923f585e7..2611df194 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -9236,6 +9236,12 @@ ], "diags": [] }, + "/tests/corpus/class-method-spreads.ts": { + "order": [ + "/tests/corpus/class-method-spreads.ts" + ], + "diags": [] + }, "/tests/corpus/class-property-call-values.mjs": { "order": [ "/tests/corpus/class-property-call-values.mjs" diff --git a/tests/corpus/class-method-spreads.ts b/tests/corpus/class-method-spreads.ts new file mode 100644 index 000000000..7667b5332 --- /dev/null +++ b/tests/corpus/class-method-spreads.ts @@ -0,0 +1,51 @@ +const events: string[] = []; +class Collector { + value = 10; + collect(...values: (number | undefined)[]): number { + events.push("original"); + return this.value + values.reduce((total, value) => total + (value ?? 0), 0); + } +} +class DerivedCollector extends Collector { + override collect(...values: (number | undefined)[]): number { + events.push("derived"); + return this.value + values.length; + } +} +const collector = new Collector(); +const derived: Collector = new DerivedCollector(); +function receiver(): Collector { events.push("receiver"); return collector; } +function values(label: string, value: number): number[] { + events.push(label); + return [value, value + 1]; +} +function replacement(this: Collector, ...values: (number | undefined)[]): number { + events.push("replacement"); + return this.value * 10 + values.length; +} +console.log(collector.collect(...[1, 2]), derived.collect(...[3, 4])); +Object.defineProperty(collector, "collect", { configurable: true, value: replacement }); +console.log(receiver().collect(...values("first", 1), 7, ...values("last", 3))); +console.log(events.join(",")); +events.length = 0; +Object.defineProperty(collector, "collect", { + configurable: true, + get() { events.push("get"); return replacement; }, +}); +function replaceDuringSpread(): number[] { + events.push("spread"); + Object.defineProperty(collector, "collect", { configurable: true, value: () => -1 }); + return [4, 5]; +} +console.log(receiver().collect(...replaceDuringSpread())); +const empty: number[] = []; +console.log(events.join(","), collector.collect(...empty)); +Object.defineProperty(collector, "collect", { value: replacement }); +const sparse: number[] = []; +sparse.length = 3; +sparse[1] = 9; +console.log(collector.collect(...sparse)); +function fail(): number[] { events.push("throw"); throw new Error("stop"); } +events.length = 0; +try { receiver().collect(...fail(), ...values("unreachable", 8)); } +catch (error) { console.log(error instanceof Error, events.join(",")); } From 08772d01c8e4fe26c6c2ea603d23dc9e56f848af Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 01:21:43 -0500 Subject: [PATCH 04/14] Preserve narrowed class values and collection composition - Preserve narrowed class and generic values, abstract properties, and standalone caught-class identity. - Support identity-preserving array concatenation and typed-array collection values, with focused differential and sanitizer coverage. - Share revision-matched bootstrap seeds across CI jobs while retaining command, rebuild, packaging, and sanitizer checks. --- .github/workflows/ci.yml | 57 +++++- .../compiler/src/backend/llvm/expr-dynamic.ts | 32 ++++ .../lowering/caught-value-dispatch.ts | 2 +- .../src/frontend/lowering/class-unions.ts | 73 ++++++++ .../lowering/containers/array-concat.ts | 176 ++++++++++++++++++ .../lowering/containers/array-methods.ts | 78 +------- .../src/frontend/lowering/lower-classes.ts | 6 +- .../src/frontend/lowering/lower-exprs.ts | 124 +++++++++--- .../compiler/src/frontend/lowering/lowerer.ts | 14 ++ packages/compiler/src/ir/ir.ts | 9 +- packages/compiler/src/ir/validate.ts | 8 +- .../test/generic-class-recovery.test.ts | 45 +++++ .../test/ts7/baselines/order-parity.json | 42 +++++ scripts/bootstrap-seed.mjs | 70 +++++++ tests/corpus/abstract-property-dispatch.ts | 54 ++++++ tests/corpus/array-concat-widening.ts | 42 +++++ tests/corpus/caught-standalone-class.ts | 38 ++++ tests/corpus/generic-class-recovery.ts | 34 ++++ tests/corpus/map-typed-array-values.ts | 26 +++ tests/corpus/optional-class-flows.ts | 57 ++++++ tests/corpus/primitive-union-string.ts | 14 ++ tests/diagnostics/catch-bindings.ts | 4 +- tests/diagnostics/inheritance.ts | 6 +- .../__snapshots__/catch-bindings.ts.txt | 7 - .../harness/__snapshots__/inheritance.ts.txt | 7 - tests/harness/bootstrap-seed.test.ts | 89 +++++++++ .../self-hosting-native-driver.test.ts | 58 +++--- 27 files changed, 1015 insertions(+), 157 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/containers/array-concat.ts create mode 100644 packages/compiler/test/generic-class-recovery.test.ts create mode 100644 scripts/bootstrap-seed.mjs create mode 100644 tests/corpus/abstract-property-dispatch.ts create mode 100644 tests/corpus/array-concat-widening.ts create mode 100644 tests/corpus/caught-standalone-class.ts create mode 100644 tests/corpus/generic-class-recovery.ts create mode 100644 tests/corpus/map-typed-array-values.ts create mode 100644 tests/corpus/optional-class-flows.ts create mode 100644 tests/corpus/primitive-union-string.ts create mode 100644 tests/harness/bootstrap-seed.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 389903a9c..c6c95397e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,10 +176,52 @@ jobs: - run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz" - run: pnpm test tests/harness/effect.test.ts tests/harness/effect4.test.ts - # Give command probes and full compiler rebuilds independent runners so - # their compiler, checker, and linker processes do not compete for memory. - # Each job builds its own seed; local invocations still cover both phases. + # Build each instrumented seed once. Downstream contracts retain the same + # compiler and source artifacts without repeating seed construction. + bootstrap_seed: + name: build (Linux, ${{ matrix.flavor }}, bootstrap seed) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + flavor: [plain, san] + env: + SCRIPTC_SAN: ${{ matrix.flavor == 'san' && '1' || '' }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.1.3 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - uses: ./.github/actions/llvm-linux + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/zig + - uses: ./.github/actions/native-packages + with: + target: linux-x64-gnu + - uses: ./.github/actions/native-packages + with: + target: wasm32-wasi + helper: "false" + - run: pnpm build + - name: Prepare bootstrap seed + run: node scripts/bootstrap-seed.mjs + env: + SCRIPTC_BOOTSTRAP_SEED_DIRECTORY: ${{ runner.temp }}/native-bootstrap + - name: Archive seed and compiler artifacts + run: tar -C "$RUNNER_TEMP" -cf "$RUNNER_TEMP/bootstrap-seed.tar" native-bootstrap + - uses: actions/upload-artifact@v4 + with: + name: bootstrap-seed-${{ matrix.flavor }}-${{ github.sha }} + path: ${{ runner.temp }}/bootstrap-seed.tar + retention-days: 1 + if-no-files-found: error + bootstrap: + needs: bootstrap_seed name: test (Linux, ${{ matrix.flavor }}, native bootstrap, ${{ matrix.phase }}) # These contracts are portable. Standard Linux runners have enough memory # for LLVM optimization alongside the compiler that produces its input. @@ -205,7 +247,9 @@ jobs: - uses: ./.github/actions/llvm-linux - run: pnpm install --frozen-lockfile - uses: ./.github/actions/zig + # Rebuilds use the relocated seed's packaged helper and runtimes. - uses: ./.github/actions/native-packages + if: matrix.phase == 'commands' with: target: linux-x64-gnu - uses: ./.github/actions/native-packages @@ -214,9 +258,16 @@ jobs: target: wasm32-wasi helper: "false" - run: pnpm build + - uses: actions/download-artifact@v4 + with: + name: bootstrap-seed-${{ matrix.flavor }}-${{ github.sha }} + path: ${{ runner.temp }} + - name: Restore bootstrap seed + run: tar -C "$RUNNER_TEMP" -xf "$RUNNER_TEMP/bootstrap-seed.tar" - name: Native bootstrap and package installation checks run: node scripts/ci-native-bootstrap.mjs env: + SCRIPTC_BOOTSTRAP_SEED_DIRECTORY: ${{ runner.temp }}/native-bootstrap SCRIPTC_BOOTSTRAP_PACKAGE_DIR: ${{ runner.temp }}/native-cli-package # A real host-clang/glibc build, not the zig cross-target lane: catches diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index f7b125e44..7c32272a0 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -846,6 +846,38 @@ export function emitDynamicExpr( const c = host.emitReadReceiver(e.value); if (e.test === "instanceof") { const target = host.classMetaOf(e.className!); + if (!target.hierarchy) { + // Standalone class payloads have no vtable word. Their retain + // adapter identifies the exact native layout in the snapshot. + const adapterSlot = B.tmp(); + const adapter = B.tmp(); + B.line(`${adapterSlot} = getelementptr inbounds %ScrCaught, ptr ${c.name}, i64 0, i32 5`); + B.line(`${adapter} = load ptr, ptr ${adapterSlot}`); + let result: string | undefined; + const intervals = classMembershipIntervals(host.classMeta, target.def.name); + for (const candidate of host.classMeta.values()) { + if ( + candidate.hierarchy || + !intervals.some((range) => range.pre <= candidate.pre && candidate.pre <= range.post) + ) + continue; + const rc = vAdapters(host.shapeHost, { kind: "object", className: candidate.def.name }); + const matches = B.tmp(); + B.line(`${matches} = icmp eq ptr ${adapter}, ${rc.retain}`); + if (result === undefined) result = matches; + else { + const joined = B.tmp(); + B.line(`${joined} = or i1 ${result}, ${matches}`); + result = joined; + } + } + if (result === undefined) + throw new InternalCompilerError("empty standalone class membership"); + if (e.negated !== true) return { name: result, type: e.type }; + const negated = B.tmp(); + B.line(`${negated} = xor i1 ${result}, true`); + return { name: negated, type: e.type }; + } host.declare( `declare zeroext i1 @scr_caught_instanceof(ptr, ${host.sizeType}, ${host.sizeType})`, ); diff --git a/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts index b88ba93af..a07741461 100644 --- a/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts @@ -20,7 +20,7 @@ export class CaughtValueDispatch { stmt: (stmt) => { if (stmt.kind === "throw" && stmt.value.type.kind === "object") { const info = lowerer.classes.get(stmt.value.type.className); - if (info && lowerer.inHierarchy(info)) this.thrownClasses.add(info.def.name); + if (info) this.thrownClasses.add(info.def.name); } return true; }, diff --git a/packages/compiler/src/frontend/lowering/class-unions.ts b/packages/compiler/src/frontend/lowering/class-unions.ts index 2822c44db..c172b40cf 100644 --- a/packages/compiler/src/frontend/lowering/class-unions.ts +++ b/packages/compiler/src/frontend/lowering/class-unions.ts @@ -1,5 +1,6 @@ import { BOOL, type IrExpr, type IrStmt, type IrType } from "../../ir/ir.js"; import { nodeThrowExpr, varRef } from "../../ir/build.js"; +import { checkedClassAssertion } from "./class-assertions.js"; import type { Lowerer } from "./lowerer.js"; /** A base-class slot stays one pointer after a guard selects several @@ -81,3 +82,75 @@ export function narrowClassUnion( } return { kind: "call", callee: helper, args: [value], type: target, loc }; } + +/** Recover a class refinement from optional storage without treating the + * checker's non-optional array element type as proof of presence. Only a + * stricter class view qualifies; the stored tag and class are validated. */ +export function narrowStoredClassValue( + lowerer: Lowerer, + value: IrExpr, + target: IrType, +): IrExpr | null { + if (value.type.kind !== "union") return null; + const arms = lowerer.unions.get(value.type.unionId)?.arms; + if (!arms) return null; + if (target.kind === "object") { + if ( + !arms.some( + (arm) => + arm.kind === "object" && + arm.className !== target.className && + lowerer.isSubclassOf(target.className, arm.className), + ) + ) + return null; + return checkedClassAssertion(lowerer, value, target, value.loc); + } + if (target.kind !== "union") return null; + const present = lowerer.stripUndefinedArm(value.type); + if (present.kind !== "object") return null; + const targetArms = lowerer.unions.get(target.unionId)?.arms; + if ( + !targetArms?.length || + !targetArms.every( + (arm) => arm.kind === "object" && lowerer.isSubclassOf(arm.className, present.className), + ) + ) + return null; + const extract = lowerer.narrowedArmHelper(value.type.unionId, present, value.loc); + if (!extract) return null; + return narrowClassUnion( + lowerer, + { + kind: "call", + callee: extract, + args: [value], + type: present, + loc: value.loc, + }, + target, + ); +} + +/** An erased generic instanceof test identifies a family, not a concrete + * field layout. Recover a demanded instantiation only after checking its + * runtime membership; incompatible specializations cannot share payloads. */ +export function narrowGenericClassValue( + lowerer: Lowerer, + value: IrExpr, + expected: IrType, +): IrExpr | null { + if (value.type.kind !== "object") return null; + const family = lowerer.classes.get(value.type.className); + if (!family?.generic || !lowerer.inHierarchy(family)) return null; + const arms = expected.kind === "union" ? lowerer.unions.get(expected.unionId)?.arms : [expected]; + const candidates = arms?.filter( + (arm) => + arm.kind === "object" && + lowerer.classes.get(arm.className)?.genericInstance?.family === family, + ); + const target = candidates?.length === 1 ? candidates[0] : undefined; + return target?.kind === "object" + ? checkedClassAssertion(lowerer, value, target, value.loc) + : null; +} diff --git a/packages/compiler/src/frontend/lowering/containers/array-concat.ts b/packages/compiler/src/frontend/lowering/containers/array-concat.ts new file mode 100644 index 000000000..25f02f579 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/containers/array-concat.ts @@ -0,0 +1,176 @@ +import { countedFor, numLit, varRef } from "../../../ir/build.js"; +import { + BOOL, + F64, + arrayOf, + typeEquals, + typeKey, + type IrExpr, + type IrLocal, + type IrStmt, + type IrType, + type SrcLoc, +} from "../../../ir/ir.js"; +import type * as ts from "../../ts7/adapter.js"; +import type { Lowerer } from "../lowerer.js"; + +/** Copy into fresh storage after every argument has evaluated. Array + * arguments retain their own element ABI; only copied values are widened. + * Missing slots stay holes and present undefined stays present. */ +export function arrayConcatHelper( + lowerer: Lowerer, + elem: IrType, + shape: (IrType | null)[], + node: ts.CallExpression, + loc: SrcLoc, +): string { + const key = `concat:${typeKey(elem)}:${shape.map((type) => (type ? `a:${typeKey(type)}` : "e")).join(";")}`; + const existing = lowerer.arrHofHelpers.get(key); + if (existing) return existing; + const name = `%arr.concat.${lowerer.arrHofHelpers.size}`; + const arrT = arrayOf(elem); + const params = [ + { localId: "a.0", name: "a", type: arrT }, + ...shape.map((type, i) => ({ + localId: `x.${i}`, + name: `x${i}`, + type: type ? arrayOf(type) : elem, + })), + ]; + const locals: IrLocal[] = params.map((p) => ({ + id: p.localId, + name: p.name, + type: p.type, + mutable: false, + })); + locals.push({ id: "out.0", name: "out", type: arrT, mutable: false }); + const out = varRef("out.0", arrT, loc); + const length = (value: IrExpr): IrExpr => ({ + kind: "arrIntrinsic", + method: "length", + receiver: value, + args: [], + type: F64, + loc, + }); + const append = (source: IrExpr, sourceElem: IrType | null, site: ts.Node): IrStmt[] => { + if (sourceElem === null || typeEquals(sourceElem, elem)) + return [ + { + kind: "exprStmt", + expr: { + kind: "arrIntrinsic", + method: sourceElem ? "concatSpread" : "push", + receiver: out, + args: [source], + type: F64, + loc, + }, + loc, + }, + ]; + if (!identityPreservingWidening(lowerer, sourceElem, elem)) { + lowerer.unsupported( + "SC1090", + site, + "concat array elements requiring a structural copy (use a shared element type to preserve element identity)", + ); + } + const index = varRef("i.0", F64, loc); + const offset = varRef("offset.0", F64, loc); + const count = varRef("count.0", F64, loc); + const destination: IrExpr = { + kind: "bin", + op: "+", + left: offset, + right: index, + type: F64, + loc, + }; + const value = lowerer.coerceInto( + site, + { kind: "arrayGet", arr: source, index, type: sourceElem, loc }, + elem, + ); + return [ + { kind: "varDecl", localId: "offset.0", init: length(out), loc }, + { kind: "varDecl", localId: "count.0", init: length(source), loc }, + { + kind: "arraySetLength", + arr: out, + length: { kind: "bin", op: "+", left: offset, right: count, type: F64, loc }, + loc, + }, + countedFor(loc, count, () => [ + { + kind: "if", + cond: { + kind: "bin", + op: "===", + left: { kind: "arrayState", arr: source, index, type: F64, loc }, + right: numLit(1, loc), + type: BOOL, + loc, + }, + then: [{ kind: "arraySet", arr: out, index: destination, value, loc }], + else_: [ + { + kind: "if", + cond: { + kind: "bin", + op: "===", + left: { kind: "arrayState", arr: source, index, type: F64, loc }, + right: numLit(2, loc), + type: BOOL, + loc, + }, + then: [{ kind: "arraySetUndefined", arr: out, index: destination, loc }], + else_: null, + loc, + }, + ], + loc, + }, + ]), + ]; + }; + const body: IrStmt[] = [ + { + kind: "varDecl", + localId: "out.0", + init: { kind: "arrayLit", elems: [], type: arrT, loc }, + loc, + }, + ...append(varRef("a.0", arrT, loc), elem, node.expression), + ...shape.flatMap((type, i) => + append(varRef(`x.${i}`, type ? arrayOf(type) : elem, loc), type, node.arguments[i]!), + ), + { kind: "return", value: out, loc }, + ]; + if (shape.some((type) => type && !typeEquals(type, elem))) { + for (const id of ["i.0", "offset.0", "count.0"]) + locals.push({ id, name: id, type: F64, mutable: true }); + } + lowerer.arrHofHelpers.set(key, name); + lowerer.liftedFns.push({ name, params, returnType: arrT, locals, body, loc }); + return name; +} + +function identityPreservingWidening(lowerer: Lowerer, source: IrType, target: IrType): boolean { + if (typeEquals(source, target)) return true; + if (source.kind === "object" && target.kind === "object") + return lowerer.isSubclassOf(source.className, target.className); + if (source.kind === "union") + return ( + lowerer.unions + .get(source.unionId) + ?.arms.every((arm) => identityPreservingWidening(lowerer, arm, target)) ?? false + ); + if (target.kind === "union") + return ( + lowerer.unions + .get(target.unionId) + ?.arms.some((arm) => identityPreservingWidening(lowerer, source, arm)) ?? false + ); + return false; +} diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index ef06b67b7..de0ea79cb 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -1,3 +1,4 @@ +import { arrayConcatHelper } from "./array-concat.js"; import { boolLit, countedFor, numLit, strLit, varRef } from "../../../ir/build.js"; import { InternalCompilerError } from "../../../errors.js"; import * as ts from "../../ts7/adapter.js"; @@ -7,8 +8,6 @@ import { DYN, F64, type IrExpr, - type IrLocal, - type IrParam, type IrStmt, type IrType, JSVAL, @@ -693,7 +692,7 @@ export function lowerArrayMethodCall( // an array-of-arrays receiver given a bare inner array, where TS // types it as an element but JS would SPREAD it — is fenced. const receiver = lowerer.lowerExpr(access.expression); - const shape: ("e" | "a")[] = []; + const shape: (IrType | null)[] = []; const args: IrExpr[] = []; for (const argNode of call.arguments) { if (ts.isSpreadElement(argNode)) { @@ -707,7 +706,7 @@ export function lowerArrayMethodCall( const probedArg = probedUntyped ? tryLowerExpression(lowerer, argNode) : null; const argArrayType = probedArg?.type.kind === "array" ? probedArg.type : argIr?.kind === "array" ? argIr : null; - if (argArrayType !== null && typeEquals(argArrayType.elem, elem)) { + if (argArrayType !== null) { if (elem.kind === "array" && typeEquals(argArrayType, elem)) { // number[][].concat(inner: number[]) — TS says element, JS's // IsArray says spread; no honest static answer exists. @@ -723,7 +722,7 @@ export function lowerArrayMethodCall( let arg = lowerer.lowerExpr(argNode); if (arg.type.kind === "dyn") arg = lowerer.coerceInto(argNode, arg, argArrayType); if (arg.type.kind !== "array") lowerer.badType(argNode, lowerer.typeOf(argNode)); - shape.push("a"); + shape.push(arg.type.elem); args.push(arg); continue; } @@ -736,16 +735,16 @@ export function lowerArrayMethodCall( if (elem.kind === "jsval") { const arg = lowerer.lowerExpr(argNode); if (arg.type.kind === "array" && arg.type.elem.kind === "jsval") { - shape.push("a"); + shape.push(arg.type.elem); args.push(arg); continue; } } // An element value — union elements wrap exactly like a push. - shape.push("e"); + shape.push(null); args.push(lowerer.lowerExprExpecting(argNode, elem)); } - const helper = arrayConcatHelper(lowerer, elem, shape, loc); + const helper = arrayConcatHelper(lowerer, elem, shape, call, loc); return { kind: "call", callee: helper, args: [receiver, ...args], type: receiverIr, loc }; } if (name === "slice") { @@ -1904,69 +1903,6 @@ export function lowerTupleReadMethodCall( return { kind: "call", callee: helper, args: [receiver, fnArg], type: outType, loc }; } -/** Interned synthetic function for one (elem, argument-shape) concat — - * `%arr.concat.(a, x0, x1, ...)`: a fresh array takes a's elements - * (pushSpread), then each argument pushes (element) or spreads (array) - * in order; the receiver and array arguments are only READ. Rides - * liftedFns like the HOF helpers (a plain function — no captures). */ -function arrayConcatHelper( - lowerer: Lowerer, - elem: IrType, - shape: ("e" | "a")[], - loc: SrcLoc, -): string { - const key = `concat:${typeKey(elem)}:${shape.join("")}`; - const existing = lowerer.arrHofHelpers.get(key); - if (existing) return existing; - const name = `%arr.concat.${lowerer.arrHofHelpers.size}`; - lowerer.arrHofHelpers.set(key, name); - const arrT = arrayOf(elem); - - const locals: IrLocal[] = [ - { id: "a.0", name: "a", type: arrT, mutable: false }, - ...shape.map((s, i): IrLocal => ({ - id: `x.${i}`, - name: `x${i}`, - type: s === "a" ? arrT : elem, - mutable: false, - })), - { id: "out.0", name: "out", type: arrT, mutable: false }, - ]; - const params: IrParam[] = [ - { localId: "a.0", name: "a", type: arrT }, - ...shape.map((s, i): IrParam => ({ - localId: `x.${i}`, - name: `x${i}`, - type: s === "a" ? arrT : elem, - })), - ]; - const append = (s: "e" | "a", src: IrExpr): IrStmt => ({ - kind: "exprStmt", - expr: { - kind: "arrIntrinsic", - method: s === "a" ? "concatSpread" : "push", - receiver: varRef("out.0", arrT, loc), - args: [src], - type: F64, - loc, - }, - loc, - }); - const body: IrStmt[] = [ - { - kind: "varDecl", - localId: "out.0", - init: { kind: "arrayLit", elems: [], type: arrT, loc }, - loc, - }, - append("a", varRef("a.0", arrT, loc)), - ...shape.map((s, i) => append(s, varRef(`x.${i}`, s === "a" ? arrT : elem, loc))), - { kind: "return", value: varRef("out.0", arrT, loc), loc }, - ]; - lowerer.liftedFns.push({ name, params, returnType: arrT, locals, body, loc }); - return name; -} - /** `a.at(i)` — the es2022 relative-index read. Desugars to an interned * helper over existing IR nodes, ToIntegerOrInfinity-exact: the index * truncates toward zero (floor for non-negatives, mirrored floor for diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 517ace086..3e9486b51 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2515,8 +2515,8 @@ export function collectClassShapeInner( // declaration is an ordinary OWN field (tsc guarantees every // instantiable subclass declares it and, under // strictPropertyInitialization, initializes it). Reads through - // ABSTRACT-typed receivers have no slot to read and keep a - // per-site fence. + // ABSTRACT-typed receivers resolve the concrete instance's + // property through checked class dispatch. if (modifiers?.some((m) => m.kind === ts.SyntaxKind.AbstractKeyword)) continue; if (modifiers?.some((m) => m.kind === ts.SyntaxKind.AccessorKeyword)) { // `accessor x = 1` desugars (in JS) to a private slot plus a @@ -8599,7 +8599,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { expr, `Map values of type '${lowerer.checker.typeToString(targs[1])}' ` + `(Map values must be number, string, boolean, records, class instances, ` + - `arrays, promises, Maps, Sets, or unions of those — not functions, 'unknown', or 'any')`, + `typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values)`, ); } lowerer.badType(expr, tsType); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 52af59ab2..31926dd68 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -23,7 +23,7 @@ import { dirname } from "node:path"; import * as posix from "node:path/posix"; import type { Lowerer } from "./lowerer.js"; import { checkedClassAssertion } from "./class-assertions.js"; -import { narrowClassUnion } from "./class-unions.js"; +import { narrowClassUnion, narrowStoredClassValue } from "./class-unions.js"; import { lowerUnionFieldWrite } from "./expressions/union-field-write.js"; import { captureContextArguments } from "./function-context.js"; import { OBJECT_CALLABLE_VALUES } from "./surfaces.js"; @@ -282,8 +282,8 @@ const LOWER_EXPR_MAX_DEPTH = 200; let lowerExprDepth = 0; /** True when a property access names an ABSTRACT property declaration - * (`abstract p: number` — a PropertyDeclaration, not an accessor): the - * named-fence test for reads/writes through abstract-typed receivers. */ + * (`abstract p: number` — a PropertyDeclaration, not an accessor). + * These uses need concrete property dispatch because the base has no slot. */ export function abstractPropertyDeclOf( lowerer: Lowerer, expr: ts.PropertyAccessExpression, @@ -3768,6 +3768,8 @@ function runtimeOptionalReceiverRead( ); } if (narrowed.kind === "union") { + const classView = narrowStoredClassValue(lowerer, varRef(local.id, local.type, loc), narrowed); + if (classView) return classView; // The checker may keep the receiver at the storage union unchanged // (`string | undefined` on a JS `.match()` call). There is no // sub-union to re-tag: preserve the value for the downstream @@ -8355,6 +8357,28 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr ); } } + if ( + narrowed?.kind === "union" && + !typeEquals(narrowed, e.type) && + lowerer.unions + .get(narrowed.unionId) + ?.arms.every( + (arm) => + isUnitType(arm) || + arm.kind === "string" || + arm.kind === "f64" || + arm.kind === "bool" || + arm.kind === "bigint", + ) + ) { + const helper = lowerer.narrowedRetagHelper(node, e.type.unionId, narrowed.unionId, e.loc); + if (helper) + return ensureString( + lowerer, + { kind: "call", callee: helper, args: [e], type: narrowed, loc: e.loc }, + node, + ); + } lowerer.unsupported( "SC1090", node, @@ -11542,17 +11566,33 @@ export function caughtRead( } if (narrowed?.kind === "object") { const info = lowerer.classes.get(narrowed.className); - if (info && lowerer.inHierarchy(info)) { - return { kind: "caughtNarrow", value: ref, type: narrowed, loc }; + if (info) { + const value: IrExpr = { kind: "caughtNarrow", value: ref, type: narrowed, loc }; + if (lowerer.inHierarchy(info)) return value; + return { + kind: "ternary", + cond: { + kind: "caughtTest", + value: ref, + test: "instanceof", + className: info.def.name, + type: BOOL, + loc, + }, + then: value, + else_: lowerer.coerceInto( + node, + { kind: "caughtToDyn", value: ref, type: DYN, loc }, + narrowed, + ), + type: narrowed, + loc, + }; } } - // An UN-narrowed use — the occurrence still types `unknown` — converts - // to a dyn value (`options.onError?.(error)` — the caught snapshot - // flowing into an unknown slot): the typed→unknown deep-copy stance - // extended to exception payloads. Error payloads keep their - // observability (instanceof Error / .message / String() — the checked-dynamic tree's - // error encoding); other object payloads are type-erased at runtime and - // convert to the "[object Object]" approximation — SEMANTICS.md 67. + // Unnarrowed reads cross into unknown. The caught-value dispatch pass + // retains known native class payloads through live identity capsules; + // other payload kinds use the runtime's ordinary conversion. if (narrowed?.kind === "dyn") { return { kind: "caughtToDyn", value: ref, type: DYN, loc }; } @@ -11957,12 +11997,27 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc const caughtLocal = lowerer.caughtLocalOf(expr.left); if (caughtLocal) { if (!lowerer.inHierarchy(target)) { - lowerer.unsupported( - "SC1090", - expr, - `'instanceof' on a catch binding against the standalone class '${target.def.name}' ` + - `(only classes in extends hierarchies carry the vtable the payload test needs)`, - ); + const value = varRef(caughtLocal.id, CAUGHT, loc); + return { + kind: "ternary", + cond: { + kind: "caughtTest", + value, + test: "instanceof", + className: target.def.name, + type: BOOL, + loc, + }, + then: { kind: "boolLit", value: true, type: BOOL, loc }, + else_: classInstanceOf( + lowerer, + { kind: "caughtToDyn", value, type: DYN, loc }, + target, + loc, + ), + type: BOOL, + loc, + }; } return { kind: "caughtTest", @@ -14004,7 +14059,20 @@ export function fieldTarget( ? stored : narrowed; if (receiverIr?.kind === "object") { - return classFieldTarget(lowerer, access.expression, receiverIr, access.name.text); + const target = classFieldTarget(lowerer, access.expression, receiverIr, access.name.text); + if (target) return target; + // Abstract properties declare no base storage. Resolve the concrete + // instance's live field or accessor through the existing class bridge; + // inventing a shared field offset would misread subclass layouts. + if (abstractPropertyDeclOf(lowerer, access)) { + return { + container: "dynamic", + obj: lowerer.coerceInto(access.expression, lowerer.lowerExpr(access.expression), DYN), + field: access.name.text, + fieldType: lowerer.irTypeOf(access), + }; + } + return null; } if (receiverIr?.kind === "union" && hasClassPayload(lowerer, receiverIr)) { return representedClassFieldTarget( @@ -14252,13 +14320,17 @@ export function fieldGetExpr( blame: ts.Node, ): IrExpr { if (target.container === "dynamic") - return { - kind: "dynKeyGet", - value: target.obj, - key: { kind: "strLit", value: target.field, type: STRING, loc }, - type: DYN, - loc, - }; + return lowerer.coerceInto( + blame, + { + kind: "dynKeyGet", + value: target.obj, + key: { kind: "strLit", value: target.field, type: STRING, loc }, + type: DYN, + loc, + }, + target.fieldType, + ); if ( target.container === "class" && (target.field === "message" || target.field === "name") && diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 86f08d1d9..f40427c90 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,3 +1,4 @@ +import { narrowStoredClassValue, narrowGenericClassValue } from "./class-unions.js"; import { isOptionalProcessStreamProperty } from "./builtins/process.js"; import { concreteCollectionNarrow } from "./collection-narrowing.js"; import { lowerWorkerMetadata } from "./builtins/workers.js"; @@ -7311,6 +7312,19 @@ export class Lowerer { loc: expr.loc, } : this.coerceToExpected(expr, expected); + // Keep storage intact when ordinary widening already accepts it. Only + // a destination needing a more specific class layout uses the site's + // refinement and checked extraction. + if ( + !typeEquals(e.type, expected) && + (expected.kind === "object" || expected.kind === "union") + ) { + const site = e.type.kind === "union" ? this.mapTypeOf(this.typeOf(node)) : null; + const narrowed = + (site && narrowStoredClassValue(this, e, site)) ?? + narrowGenericClassValue(this, e, expected); + if (narrowed) e = this.coerceToExpected(narrowed, expected); + } // Existing JavaScript arrays retain mutations and identity across checked // slots. Fresh literals have no prior identity and use checked storage // directly, so later writes can change their inferred element type. diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 1306d6d67..32066b852 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -702,6 +702,7 @@ export function isSupportedSetElem(t: IrType, unionArms?: IrType[]): boolean { * cycle analysis and docs/memory.md). Shared frontend/validator. */ export function isSupportedMapValue(t: IrType): boolean { switch (t.kind) { + case "bytes": case "dyn": case "f64": case "bigint": @@ -3073,9 +3074,9 @@ export type IrExpr = * borrowed). The primitive tests ("string"/"number"/"boolean") compare * the snapshot's kind tag. "object" also checks reference payloads, * excluding callable and primitive references; - * "instanceof" requires `className` (a hierarchy class) and tests an OBJ - * payload's vtable preorder against its interval (false for every other - * payload kind). `negated` flips the result (the `!==` spelling). */ + * "instanceof" requires `className`: hierarchy payloads use the vtable + * interval, while standalone classes use their exact retain adapter. + * Other payloads answer false. `negated` flips the result (the `!==` spelling). */ | { kind: "caughtTest"; value: IrExpr; @@ -3090,7 +3091,7 @@ export type IrExpr = * tsc's control-flow narrowing has already proven the matching test * (`e instanceof C` / `typeof e === "string"`), so the read is * kind-UNCHECKED at runtime. `type` is f64, bool, string, or a - * hierarchy-class object; refcounted results come out retained (+1). */ + * class object; refcounted results come out retained (+1). */ | { kind: "caughtNarrow"; value: IrExpr; type: IrType; loc: SrcLoc } /** CHECKED extraction of a catch binding's payload as a hierarchy-class * instance — the caught analog of dynCheck, emitted for `e as C` casts diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 160c4bbfb..abec3eb19 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -3443,8 +3443,8 @@ function validateFunction( if (e.type.kind !== "bool") err("caughtTest must be bool", e.loc); if (e.test === "instanceof") { if (!e.className) err("caughtTest instanceof without a class", e.loc); - else if (!hierarchy.has(e.className)) { - err(`caughtTest instanceof against non-hierarchy class "${e.className}"`, e.loc); + else if (!classes.has(e.className)) { + err(`caughtTest instanceof against undeclared class "${e.className}"`, e.loc); } } else if (e.className !== undefined) { err(`caughtTest ${e.test} with a class name`, e.loc); @@ -3479,10 +3479,10 @@ function validateFunction( t.kind === "f64" || t.kind === "bool" || t.kind === "string" || - (t.kind === "object" && hierarchy.has(t.className)); + (t.kind === "object" && classes.has(t.className)); if (!ok) { err( - `caughtNarrow to ${t.kind === "object" ? `non-hierarchy class "${t.className}"` : t.kind}`, + `caughtNarrow to ${t.kind === "object" ? `undeclared class "${t.className}"` : t.kind}`, e.loc, ); } diff --git a/packages/compiler/test/generic-class-recovery.test.ts b/packages/compiler/test/generic-class-recovery.test.ts new file mode 100644 index 000000000..0ca44524b --- /dev/null +++ b/packages/compiler/test/generic-class-recovery.test.ts @@ -0,0 +1,45 @@ +import { spawnSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +test("generic family narrowing validates the requested payload layout", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-generic-recovery-")); + try { + const entry = join(dir, "main.ts"); + await writeFile( + entry, + ` +class Base { id = 0; } +class Cell extends Base { value: T; constructor(value: T) { super(); this.value = value; } } +function recover(base: Base): Cell | undefined { + return base instanceof Cell ? base : undefined; +} +const text = new Cell("intact"); +try { + const wrong = recover(text); + if (wrong) wrong.value = 42; + console.log("unexpected"); +} catch (error) { + console.log(error instanceof TypeError, text.value); +} +console.log(recover(text) === text, recover(new Cell(7))?.value); +`, + ); + const result = await compile(entry, { + outDir: dir, + outPath: join(dir, "program"), + backend: "llvm", + sanitize: process.env["SCRIPTC_SAN"] === "1", + }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + if (!result.ok) return; + const child = spawnSync(result.binaryPath, [], { encoding: "utf8" }); + expect(child.status, child.stderr).toBe(0); + expect(child.stdout).toBe("true intact\ntrue 7\n"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 2611df194..0def2ef69 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -8802,6 +8802,12 @@ ], "diags": [] }, + "/tests/corpus/abstract-property-dispatch.ts": { + "order": [ + "/tests/corpus/abstract-property-dispatch.ts" + ], + "diags": [] + }, "/tests/corpus/arguments-arrow-capture.mjs": { "order": [ "/tests/corpus/arguments-arrow-capture.mjs" @@ -8840,6 +8846,12 @@ ], "diags": [] }, + "/tests/corpus/array-concat-widening.ts": { + "order": [ + "/tests/corpus/array-concat-widening.ts" + ], + "diags": [] + }, "/tests/corpus/array-fill-unit-values.ts": { "order": [ "/tests/corpus/array-fill-unit-values.ts" @@ -8968,6 +8980,12 @@ ], "diags": [] }, + "/tests/corpus/caught-standalone-class.ts": { + "order": [ + "/tests/corpus/caught-standalone-class.ts" + ], + "diags": [] + }, "/tests/corpus/checked-addition.js": { "order": [ "/tests/corpus/checked-addition.js" @@ -9661,6 +9679,12 @@ ], "diags": [] }, + "/tests/corpus/generic-class-recovery.ts": { + "order": [ + "/tests/corpus/generic-class-recovery.ts" + ], + "diags": [] + }, "/tests/corpus/generic-pattern-bindings.ts": { "order": [ "/tests/corpus/generic-pattern-bindings.ts" @@ -10439,6 +10463,12 @@ ], "diags": [] }, + "/tests/corpus/map-typed-array-values.ts": { + "order": [ + "/tests/corpus/map-typed-array-values.ts" + ], + "diags": [] + }, "/tests/corpus/math-function-values.js": { "order": [ "/tests/corpus/math-function-values.js" @@ -10863,6 +10893,12 @@ ], "diags": [] }, + "/tests/corpus/optional-class-flows.ts": { + "order": [ + "/tests/corpus/optional-class-flows.ts" + ], + "diags": [] + }, "/tests/corpus/optional-native-callables.js": { "order": [ "/tests/corpus/optional-native-callables.js" @@ -10899,6 +10935,12 @@ ], "diags": [] }, + "/tests/corpus/primitive-union-string.ts": { + "order": [ + "/tests/corpus/primitive-union-string.ts" + ], + "diags": [] + }, "/tests/corpus/process-builtin-values.js": { "order": [ "/tests/corpus/process-builtin-values.js" diff --git a/scripts/bootstrap-seed.mjs b/scripts/bootstrap-seed.mjs new file mode 100644 index 000000000..f0993f126 --- /dev/null +++ b/scripts/bootstrap-seed.mjs @@ -0,0 +1,70 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const metadataName = "bootstrap-seed.json"; + +/** Artifacts are scoped to one workflow revision and instrumentation lane. + * The directory must stay fixed: FFI manifests and IR include absolute paths. */ +export function verifyBootstrapSeed(directory, revision, sanitize) { + const expected = { schema: 1, revision, sanitize, directory: resolve(directory) }; + const metadata = JSON.parse(readFileSync(join(directory, metadataName), "utf8")); + if (!revision || Object.entries(expected).some(([key, value]) => metadata[key] !== value)) { + throw new Error("bootstrap seed identity does not match this checkout, lane, or directory"); + } + for (const path of [ + "distribution/bin/scriptc", + "distribution/bin/scriptc.json", + ".scriptc/distribution-seed/cli.ir.json", + ".scriptc/distribution-seed/cli.ll", + ".scriptc/distribution-seed/compiler.ffi.json", + ]) { + if (!existsSync(join(directory, path))) throw new Error(`bootstrap seed is missing ${path}`); + } +} + +async function main() { + const directory = process.env.SCRIPTC_BOOTSTRAP_SEED_DIRECTORY; + const revision = process.env.GITHUB_SHA; + if (!directory || !revision) + throw new Error("bootstrap seed directory and revision are required"); + if (process.platform !== "linux" || process.arch !== "x64") { + throw new Error("shared bootstrap seeds require the Linux x64 CI host"); + } + const sanitize = process.env.SCRIPTC_SAN === "1"; + mkdirSync(directory, { recursive: true }); + execFileSync( + process.execPath, + [ + "--max-old-space-size=8192", + "--import", + "tsx", + join(root, "scripts/build-native-cli.mts"), + join(directory, "distribution"), + ], + { + cwd: root, + stdio: "inherit", + env: { + ...process.env, + SCRIPTC_NATIVE_EMIT_IR: "1", + SCRIPTC_NATIVE_OPTIMIZATION: sanitize ? "dev" : "release", + }, + }, + ); + writeFileSync( + join(directory, metadataName), + JSON.stringify({ + schema: 1, + revision, + sanitize, + directory: resolve(directory), + }) + "\n", + ); + verifyBootstrapSeed(directory, revision, sanitize); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) + await main(); diff --git a/tests/corpus/abstract-property-dispatch.ts b/tests/corpus/abstract-property-dispatch.ts new file mode 100644 index 000000000..9d14ec759 --- /dev/null +++ b/tests/corpus/abstract-property-dispatch.ts @@ -0,0 +1,54 @@ +class Payload { + value = 1; +} +abstract class Store { + abstract name: string; + abstract readonly payload: Payload; + describe(): string { return this.name; } + rename(value: string): void { this.name = value; } +} +class Fields extends Store { + prefix = 7; + name = "field"; + payload = new Payload(); +} +class Accessors extends Store { + current = "getter"; + data = new Payload(); + get name(): string { + return this.current; + } + set name(value: string) { + this.current = value; + } + get payload(): Payload { + return this.data; + } +} +class Inherited extends Fields { + extra = true; +} +let calls = 0; +function receiver(value: Store): Store { + calls++; + return value; +} +function update(value: Store): string { + receiver(value).name += "!"; + value.payload.value++; + return value.name; +} +const field = new Fields(); +const accessor = new Accessors(); +const inherited = new Inherited(); +console.log(update(field), update(accessor), update(inherited), calls); +console.log(field.payload.value, accessor.data.value, inherited.payload.value); +function read(value: Store): Payload { + return value.payload; +} +console.log(read(field) === field.payload, read(accessor) === accessor.data); +console.log(Object.keys(field).join(","), Object.keys(accessor).join(",")); +console.log(Object.getOwnPropertyDescriptor(field, "name")?.enumerable); +field.rename("updated field"); +accessor.rename("updated accessor"); +console.log(field.describe(), accessor.describe(), inherited.describe()); diff --git a/tests/corpus/array-concat-widening.ts b/tests/corpus/array-concat-widening.ts new file mode 100644 index 000000000..820770596 --- /dev/null +++ b/tests/corpus/array-concat-widening.ts @@ -0,0 +1,42 @@ +class Entry { + value = 1; +} +class Detailed extends Entry { + extra = 9; +} +function combine(first: Entry[], rest: Detailed[]): Entry[] { + return first.concat(rest, new Detailed(), [new Detailed()]); +} +const detail = new Detailed(); +const combined = combine([new Entry()], [detail]); +combined[1].value = 4; +console.log(combined.length, detail.value, combined[1] === detail); +const values: (number | string)[] = ["start"]; +const numbers = [1, , 3] as number[]; +const absent: number[] = []; +numbers.length = 5; +numbers[3] = absent[0]; +const result = values.concat(numbers, "end", [6]); +console.log(result.length, result.join("|")); +for (let i = 0; i < result.length; i++) console.log(i, i in result, result[i] === undefined); +console.log(numbers.length, 1 in numbers, 3 in numbers, 4 in numbers); +const later: number[] = [2]; +const log: string[] = []; +function first(): (number | string)[] { + log.push("receiver"); + return values; +} +function mutate(): string { + log.push("argument"); + later.push(3); + values.push("changed"); + return "last"; +} +console.log(first().concat(later, mutate()).join("|"), log.join("|")); +const narrow: (number | boolean)[] = [false, 4]; +const wide: (number | boolean | string)[] = ["wide"]; +console.log(wide.concat(narrow).join("|")); +const nested: Entry[][] = [[new Entry()]]; +const inner: Entry[] = [detail]; +const tail: Entry[][] = [inner]; +console.log(nested.concat(tail).length, nested.concat(tail)[1] === inner); diff --git a/tests/corpus/caught-standalone-class.ts b/tests/corpus/caught-standalone-class.ts new file mode 100644 index 000000000..92340850c --- /dev/null +++ b/tests/corpus/caught-standalone-class.ts @@ -0,0 +1,38 @@ +class Marker { + value = 7; +} +class Other { + value = 12; +} +class Unused { value = 0; } +const original = new Marker(); +function classify(value: unknown): void { + try { + throw value; + } catch (error) { + console.log(error instanceof Marker, error instanceof Other, error instanceof Unused); + if (error instanceof Marker) console.log(error.value, error === original); + } +} +try { + throw original; +} catch (error) { + console.log(error instanceof Marker, error instanceof Other, error instanceof Unused); + if (error instanceof Marker) { + error.value++; + console.log(error === original, original.value); + } + const alias: unknown = error; + console.log(alias === original); + try { + throw error; + } catch (again) { + console.log(again instanceof Marker); + if (again instanceof Marker) console.log(again === original, again.value); + } +} +classify(original); +classify(new Other()); +classify(7); +classify("marker"); +classify({ value: 7 }); diff --git a/tests/corpus/generic-class-recovery.ts b/tests/corpus/generic-class-recovery.ts new file mode 100644 index 000000000..9ffef68b4 --- /dev/null +++ b/tests/corpus/generic-class-recovery.ts @@ -0,0 +1,34 @@ +class Entry { + label = "entry"; +} +class Cell extends Entry { + value: T; + constructor(value: T) { + super(); + this.value = value; + } +} +function recover(entry: Entry): Cell | undefined { + if (entry instanceof Cell) return entry; + return undefined; +} +function assign(entry: Entry, value: T): boolean { + if (!(entry instanceof Cell)) return false; + const cell: Cell = entry; + cell.value = value; + return true; +} +const numeric = new Cell(7); +const text = new Cell("first"); +const entries: Entry[] = [numeric, text, new Entry()]; +const base: Entry = numeric; +const cast = base as unknown as Cell; +console.log(cast === numeric, cast.value); +console.log(recover(entries[0]) === numeric, recover(entries[1]) === text); +console.log(recover(entries[2]) === undefined); +console.log(assign(base, 9), assign(text, "second")); +console.log(numeric.value, text.value); +function erase(entry: Entry): unknown { return entry; } +const erased = erase(numeric); +const restored = erased as Cell; +console.log(restored === numeric, restored.value); diff --git a/tests/corpus/map-typed-array-values.ts b/tests/corpus/map-typed-array-values.ts new file mode 100644 index 000000000..68aa99314 --- /dev/null +++ b/tests/corpus/map-typed-array-values.ts @@ -0,0 +1,26 @@ +const bytes = new Uint8Array([1, 2, 3]); +const cache = new Map([["first", bytes]]); +const found = cache.get("first"); +if (found) found[1] = 8; +console.log(found === bytes, bytes[1], cache.has("first"), cache.get("absent") === undefined); +const view = bytes.subarray(1); +cache.set("view", view); +console.log(cache.get("view") === view); +for (const [key, value] of cache) console.log(key, value.length, value[0]); +const snapshot = Array.from(cache.values()); +cache.delete("first"); +cache.clear(); +console.log(snapshot[0] === bytes, snapshot[1] === view, snapshot[0][1]); +const integers = new Int32Array([123456, -7]); +const integerMap = new Map([[1, integers]]); +integerMap.set(2, new Int32Array([9])); +let total = 0; +integerMap.forEach((value) => { total += value[0]; }); +console.log(total, integerMap.get(1) === integers); +const wide = new Float64Array([1.25, -0, Infinity]); +const readonlyMap: ReadonlyMap = new Map([["wide", wide]]); +const read = readonlyMap.get("wide"); +console.log(read === wide, read?.length); +const records: Record = { bytes }; +records.other = view; +console.log(records.bytes === bytes, records.other === view); diff --git a/tests/corpus/optional-class-flows.ts b/tests/corpus/optional-class-flows.ts new file mode 100644 index 000000000..157ad65b3 --- /dev/null +++ b/tests/corpus/optional-class-flows.ts @@ -0,0 +1,57 @@ +class Item { + label = "item"; +} +class Left extends Item { + value = 3; +} +class Right extends Item { + value = 7; +} +function select(items: Item[]): Left | undefined { + let result: Left | undefined; + for (const item of items) { + if (item instanceof Left) result = item; + } + return result; +} +function lookup(items: Map, key: string): Left | undefined { + const item = items.get(key); + return item instanceof Left ? item : undefined; +} +function consume(item: Left): number { + item.value++; + return item.value; +} +function visit(item: Item): number { + return item instanceof Left ? consume(item) : 0; +} +function score(items: Item[]): number { + const item = items[0]; + const selected = item instanceof Left || item instanceof Right; + return selected ? item.value : item.label.length; +} +function captured(items: Item[]): () => Left | undefined { + let item = items[0]; + return () => { + if (item instanceof Left) { + const result: Left = item; + item = items[1]; + return result; + } + return undefined; + }; +} +const left = new Left(); +const items: Item[] = [left, new Right(), new Item()]; +const table = new Map([["left", left], ["right", items[1]]]); +console.log(select(items) === left, lookup(table, "left") === left); +console.log(lookup(table, "right"), lookup(table, "absent"), select([])); +console.log(visit(items[0]), visit(items[1]), visit(items[10])); +console.log(left.value, score(items), score([new Right()]), score([new Item()])); +const next = captured(items); +console.log(next() === left, next()); +try { + score([]); +} catch (error) { + console.log(error instanceof TypeError); +} diff --git a/tests/corpus/primitive-union-string.ts b/tests/corpus/primitive-union-string.ts new file mode 100644 index 000000000..01e89a543 --- /dev/null +++ b/tests/corpus/primitive-union-string.ts @@ -0,0 +1,14 @@ +class Label { + text = "label"; +} +function render(value: boolean | number | bigint | Label): string { + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "bigint") return `${value}`; + return value.text; +} +function template(value: string | number | Label): string { + if (typeof value === "number" || typeof value === "string") return `value=${value}`; + return value.text; +} +console.log(render(true), render(false), render(-0), render(NaN), render(Infinity)); +console.log(render(12n), render(new Label()), template(2.5), template("text")); diff --git a/tests/diagnostics/catch-bindings.ts b/tests/diagnostics/catch-bindings.ts index f76061322..28ab6ab0e 100644 --- a/tests/diagnostics/catch-bindings.ts +++ b/tests/diagnostics/catch-bindings.ts @@ -1,6 +1,6 @@ // Catch bindings are deliberately NARROW: the binding is typed by what the // exception cell can actually hold, so the supported uses are the narrowing -// tests (instanceof over hierarchy classes, typeof over selected types), reads +// tests (instanceof over native classes, typeof over selected types), reads // under a proven narrow, rethrow, and the unknown-slot CONVERSION (an // un-narrowed use typed `unknown` converts to a dynamic value — corpus // 1554; `const copy = e` compiles now and is NOT in this fence corpus). @@ -19,7 +19,7 @@ try { throw 3; } catch (e) { if (e instanceof Standalone) { - // standalone classes carry no vtable for the payload test + // Standalone classes are recognized without a vtable. console.log("no"); } } diff --git a/tests/diagnostics/inheritance.ts b/tests/diagnostics/inheritance.ts index 6b397c052..a2a09e732 100644 --- a/tests/diagnostics/inheritance.ts +++ b/tests/diagnostics/inheritance.ts @@ -9,9 +9,9 @@ class Animal { } } -// abstract classes compile; abstract PROPERTY declarations are erased at -// runtime (no shared slot exists), so reads through abstract-typed -// receivers keep a named fence +// Abstract properties resolve through the concrete instance. Keep this +// accepted method beside the rejected inheritance forms to ensure their +// diagnostics do not reintroduce a refusal for abstract property reads. abstract class Base { abstract kind(): string; abstract limit: number; diff --git a/tests/harness/__snapshots__/catch-bindings.ts.txt b/tests/harness/__snapshots__/catch-bindings.ts.txt index 21e0b17a5..78f82c4b3 100644 --- a/tests/harness/__snapshots__/catch-bindings.ts.txt +++ b/tests/harness/__snapshots__/catch-bindings.ts.txt @@ -1,10 +1,3 @@ -catch-bindings.ts:21:7 - error SC1090: 'instanceof' on a catch binding against the standalone class 'Standalone' (only classes in extends hierarchies carry the vtable the payload test needs) is not supported yet - - 20 | } catch (e) { - 21 | if (e instanceof Standalone) { - | ^~~~~~~~~~~~~~~~~~~~~~~ - 22 | // standalone classes carry no vtable for the payload test - catch-bindings.ts:28:10 - error SC1062: destructuring catch clause bindings are not supported yet 27 | throw 4; diff --git a/tests/harness/__snapshots__/inheritance.ts.txt b/tests/harness/__snapshots__/inheritance.ts.txt index 3ef56a173..1fc3312d7 100644 --- a/tests/harness/__snapshots__/inheritance.ts.txt +++ b/tests/harness/__snapshots__/inheritance.ts.txt @@ -1,10 +1,3 @@ -inheritance.ts:19:12 - error SC1090: reading the abstract property 'limit' through a 'this'-typed receiver (abstract property declarations are erased at runtime, so no shared slot exists — type the receiver as the concrete class, or declare an abstract getter instead) is not supported yet - - 18 | usesLimit(): number { - 19 | return this.limit; - | ^~~~~~~~~~ - 20 | } - inheritance.ts:31:3 - error SC1090: abstract generic methods (generic methods monomorphize from the nearest declaration's body, and an abstract declaration has none) are not supported yet 30 | abstract class GenericAbstract { diff --git a/tests/harness/bootstrap-seed.test.ts b/tests/harness/bootstrap-seed.test.ts new file mode 100644 index 000000000..fca0b5f48 --- /dev/null +++ b/tests/harness/bootstrap-seed.test.ts @@ -0,0 +1,89 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { load } from "js-yaml"; +import { expect, test } from "vitest"; +import { verifyBootstrapSeed } from "../../scripts/bootstrap-seed.mjs"; + +test("bootstrap artifacts require a complete seed from the same revision, lane, and path", () => { + const directory = mkdtempSync(join(tmpdir(), "scriptc-seed-contract-")); + try { + const metadata = { schema: 1, revision: "revision", sanitize: true, directory }; + writeFileSync(join(directory, "bootstrap-seed.json"), JSON.stringify(metadata)); + expect(() => verifyBootstrapSeed(directory, "revision", true)).toThrow("missing"); + for (const path of [ + "distribution/bin/scriptc", + "distribution/bin/scriptc.json", + ".scriptc/distribution-seed/cli.ir.json", + ".scriptc/distribution-seed/cli.ll", + ".scriptc/distribution-seed/compiler.ffi.json", + ]) { + const file = join(directory, path); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, "fixture"); + } + expect(() => verifyBootstrapSeed(directory, "revision", true)).not.toThrow(); + expect(() => verifyBootstrapSeed(directory, "other", true)).toThrow("identity"); + expect(() => verifyBootstrapSeed(directory, "revision", false)).toThrow("identity"); + expect(() => verifyBootstrapSeed(directory, undefined, true)).toThrow("identity"); + writeFileSync( + join(directory, "bootstrap-seed.json"), + JSON.stringify({ ...metadata, directory: "/different" }), + ); + expect(() => verifyBootstrapSeed(directory, "revision", true)).toThrow("identity"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("CI restores each seed only into its matching instrumented bootstrap lane", () => { + const workflow = load( + readFileSync(new URL("../../.github/workflows/ci.yml", import.meta.url), "utf8"), + ) as { + jobs: Record< + string, + { + needs?: string; + strategy: { matrix: { flavor: string[] } }; + env: Record; + steps: { + uses?: string; + if?: string; + with?: Record; + env?: Record; + run?: string; + }[]; + } + >; + }; + const seed = workflow.jobs.bootstrap_seed!; + const checks = workflow.jobs.bootstrap!; + expect(checks.needs).toBe("bootstrap_seed"); + expect( + seed.steps + .filter((step) => step.uses === "./.github/actions/native-packages") + .map((step) => step.with?.target), + ).toEqual(["linux-x64-gnu", "wasm32-wasi"]); + for (const step of checks.steps.filter( + (step) => step.uses === "./.github/actions/native-packages", + )) { + expect(step.if).toBe("matrix.phase == 'commands'"); + } + expect(seed.strategy.matrix.flavor).toEqual(checks.strategy.matrix.flavor); + expect(seed.env.SCRIPTC_SAN).toBe(checks.env.SCRIPTC_SAN); + const prepare = seed.steps.find((step) => step.run === "node scripts/bootstrap-seed.mjs")!; + const execute = checks.steps.find((step) => step.run === "node scripts/ci-native-bootstrap.mjs")!; + expect(prepare.env?.SCRIPTC_BOOTSTRAP_SEED_DIRECTORY).toBe("${{ runner.temp }}/native-bootstrap"); + expect(execute.env?.SCRIPTC_BOOTSTRAP_SEED_DIRECTORY).toBe( + prepare.env?.SCRIPTC_BOOTSTRAP_SEED_DIRECTORY, + ); + // Runner paths are available in step contexts, not job-level env. + for (const job of [seed, checks]) { + expect(Object.values(job.env).some((value) => value.includes("runner."))).toBe(false); + } + const upload = seed.steps.find((step) => step.uses === "actions/upload-artifact@v4")!; + const download = checks.steps.find((step) => step.uses === "actions/download-artifact@v4")!; + expect(upload.with?.name).toBe("bootstrap-seed-${{ matrix.flavor }}-${{ github.sha }}"); + expect(download.with?.name).toBe(upload.with?.name); + expect(upload.with?.["if-no-files-found"]).toBe("error"); +}); diff --git a/tests/harness/self-hosting-native-driver.test.ts b/tests/harness/self-hosting-native-driver.test.ts index 277c95923..2dbff17ad 100644 --- a/tests/harness/self-hosting-native-driver.test.ts +++ b/tests/harness/self-hosting-native-driver.test.ts @@ -1,3 +1,4 @@ +import { verifyBootstrapSeed } from "../../scripts/bootstrap-seed.mjs"; import { execFile, spawnSync } from "node:child_process"; import { chmodSync, @@ -55,9 +56,13 @@ function absoluteCommand(command: string): string { test( `the production CLI passes ${phase} bootstrap contracts with Node unavailable`, async () => { - const directory = mkdtempSync( - join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-native-bootstrap-"), - ); + const sharedSeed = process.env["SCRIPTC_BOOTSTRAP_SEED_DIRECTORY"]; + const directory = + sharedSeed ?? + mkdtempSync( + join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-native-bootstrap-"), + ); + if (sharedSeed) verifyBootstrapSeed(sharedSeed, process.env["GITHUB_SHA"], sanitize); const executable = (name: string) => join(directory, name + (process.platform === "win32" ? ".exe" : "")); const options = { @@ -67,30 +72,31 @@ test( }; try { const distribution = join(directory, "distribution"); - await bootstrapStep("build production CLI seed", () => - exec( - process.execPath, - [ - "--max-old-space-size=8192", - "--import", - "tsx", - join(root, "scripts/build-native-cli.mts"), - distribution, - ], - { - // The plain lane retains release optimization. Instrumenting a dev - // seed avoids optimizing the huge compiler module before ASan can - // exercise its complete traversal, serialization and LLVM emission. - ...options, - env: { - ...process.env, - SCRIPTC_SAN: sanitize ? "1" : "", - SCRIPTC_NATIVE_EMIT_IR: "1", - SCRIPTC_NATIVE_OPTIMIZATION: sanitize ? "dev" : "release", + if (!sharedSeed) + await bootstrapStep("build production CLI seed", () => + exec( + process.execPath, + [ + "--max-old-space-size=8192", + "--import", + "tsx", + join(root, "scripts/build-native-cli.mts"), + distribution, + ], + { + // The plain lane retains release optimization. Instrumenting a dev + // seed avoids optimizing the huge compiler module before ASan can + // exercise its complete traversal, serialization and LLVM emission. + ...options, + env: { + ...process.env, + SCRIPTC_SAN: sanitize ? "1" : "", + SCRIPTC_NATIVE_EMIT_IR: "1", + SCRIPTC_NATIVE_OPTIMIZATION: sanitize ? "dev" : "release", + }, }, - }, - ), - ); + ), + ); // CI reuses this already-built seed for npm and older-libc installation // smoke tests, avoiding another full compiler build on the critical path. const packageDirectory = process.env["SCRIPTC_BOOTSTRAP_PACKAGE_DIR"]; From 06b03f06a9df03159ee713c68f3b5130d8e17998 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 07:35:21 -0500 Subject: [PATCH 05/14] Compose class references across unions and collections (#734) * Compose class references across unions and collections - Compare class, function and native references held in differently represented unions with `===`, `!==` and `Object.is` while preserving subclass identity, and refuse comparisons that a structural union arm leaves undecidable - Keep subclass values intact through base-typed array and Set insertion spreads, `concat`, and inferred class-predicate `filter`/`find`/`findLast`, and allow checked class refinement of captured optional values with several class arms - Split the sanitized compiler self-rebuild CI job at the IR boundary, after giving sort helpers distinct IR nodes so serialized and in-memory IR emit the same LLVM * Keep compact comparisons for plain union arms - Compare a plain operand with a union that already holds its type through the existing inline union comparison, preserving established parameter representations - Update the coverage expectation for the accepted class refinement --- .github/workflows/ci.yml | 11 +- .../llvm/serialized-ir-emission.test.ts | 45 ++++ .../frontend/lowering/abstract-equality.ts | 71 +----- .../src/frontend/lowering/class-assertions.ts | 25 +- .../frontend/lowering/coercions/identity.ts | 35 +++ .../lowering/containers/array-callbacks.ts | 51 ++-- .../lowering/containers/array-concat.ts | 22 +- .../lowering/containers/array-construction.ts | 107 +++++++- .../lowering/containers/array-methods.ts | 26 +- .../lowering/lower-array-sort.test.ts | 31 +++ .../src/frontend/lowering/lower-array-sort.ts | 27 +- .../src/frontend/lowering/lower-calls.ts | 10 + .../src/frontend/lowering/lower-exprs.ts | 20 ++ .../compiler/src/frontend/lowering/lowerer.ts | 41 +++- .../src/frontend/lowering/strict-equality.ts | 161 ++++++++++++ .../src/frontend/lowering/union-comparison.ts | 57 +++++ packages/compiler/src/native/prepare.ts | 37 ++- .../test/ts7/baselines/order-parity.json | 18 ++ scripts/ci-native-bootstrap.mjs | 20 +- tests/corpus/captured-class-refinement.ts | 43 ++++ tests/corpus/class-collection-insertion.ts | 77 ++++++ tests/corpus/union-reference-comparison.ts | 75 ++++++ tests/diagnostics/index-signature-values.ts | 2 +- .../runtime-optional-refined-union.ts | 4 +- tests/diagnostics/stdlib-fence.ts | 19 +- .../__snapshots__/component-fences.ts.txt | 2 +- .../index-signature-values.ts.txt | 13 +- tests/harness/__snapshots__/maps.ts.txt | 2 +- .../runtime-optional-refined-union.ts.txt | 9 +- .../harness/__snapshots__/stdlib-fence.ts.txt | 27 +- tests/harness/bootstrap-seed.test.ts | 6 +- tests/harness/ci-native-bootstrap.test.ts | 94 ++++--- tests/harness/coverage.test.ts | 4 +- .../self-hosting-native-driver.test.ts | 230 +++++++++++------- 34 files changed, 1138 insertions(+), 284 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts create mode 100644 packages/compiler/src/frontend/lowering/coercions/identity.ts create mode 100644 packages/compiler/src/frontend/lowering/lower-array-sort.test.ts create mode 100644 packages/compiler/src/frontend/lowering/strict-equality.ts create mode 100644 packages/compiler/src/frontend/lowering/union-comparison.ts create mode 100644 tests/corpus/captured-class-refinement.ts create mode 100644 tests/corpus/class-collection-insertion.ts create mode 100644 tests/corpus/union-reference-comparison.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6c95397e..2719a6548 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,8 +229,15 @@ jobs: strategy: fail-fast: false matrix: - flavor: [plain, san] - phase: [commands, rebuild] + # The sanitized self-rebuild is split at the IR boundary: one job + # lowers the compiler and compares its IR with the seed's; the other + # emits, links and probes the next generation from the seed's IR. + include: + - { flavor: plain, phase: commands } + - { flavor: plain, phase: rebuild } + - { flavor: san, phase: commands } + - { flavor: san, phase: rebuild-frontend } + - { flavor: san, phase: rebuild-emit } env: SCRIPTC_TEST_WORKERS: "1" SCRIPTC_SAN: ${{ matrix.flavor == 'san' && '1' || '' }} diff --git a/packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts b/packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts new file mode 100644 index 000000000..51497d86a --- /dev/null +++ b/packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../../index.js"; +import { emitLlvmModuleSource } from "./emitter.js"; + +const root = join(import.meta.dirname, "../../../../.."); + +// Per-node emitter analyses must reach the same conclusions for an in-memory +// module and its serialized IR artifact. The sanitized bootstrap emits the +// next compiler generation from that artifact. +test("sort-heavy programs emit equal LLVM from memory and from serialized IR", async () => { + const directory = mkdtempSync(join(tmpdir(), "scriptc-sort-ir-")); + try { + for (const name of [ + "array-sort-runs.ts", + "uint8array-sort-domains.ts", + "2703-array-sparse-sort.ts", + ]) { + const entry = join(root, "tests/corpus", name); + const options = { + outDir: directory, + backend: "llvm" as const, + optimization: "release" as const, + }; + const llvmPath = join(directory, name + ".ll"); + const irPath = join(directory, name + ".ir.json"); + const llvm = await compile(entry, { ...options, outPath: llvmPath, outputKind: "llvm" }); + expect(llvm.ok, name).toBe(true); + const ir = await compile(entry, { ...options, outPath: irPath, outputKind: "ir" }); + expect(ir.ok, name).toBe(true); + const emitted = emitLlvmModuleSource(deserializeModule(readFileSync(irPath, "utf8")), { + targetTriple: process.env["SCRIPTC_TARGET"] ?? "", + pointerBits: 64, + wasi: false, + runtimeAbiMarker: false, + }); + const text = typeof emitted === "string" ? emitted : emitted.join(""); + expect(text === readFileSync(llvmPath, "utf8"), name).toBe(true); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}, 120_000); diff --git a/packages/compiler/src/frontend/lowering/abstract-equality.ts b/packages/compiler/src/frontend/lowering/abstract-equality.ts index d2a64ea2c..46fd7079b 100644 --- a/packages/compiler/src/frontend/lowering/abstract-equality.ts +++ b/packages/compiler/src/frontend/lowering/abstract-equality.ts @@ -1,3 +1,4 @@ +import { unionComparison } from "./union-comparison.js"; import { InternalCompilerError } from "../../errors.js"; import { BOOL, @@ -237,67 +238,15 @@ function pairExpr( 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); + const result = unionComparison( + lowerer, + left, + right, + (a, b) => primitivePair(lowerer, a, b, nanEqualsNan, loc), + loc, + ); + if (!result) throw new InternalCompilerError("abstract equality over an unknown union"); + return result; } export function abstractEqualitySupported(lowerer: Lowerer, left: IrType, right: IrType): boolean { diff --git a/packages/compiler/src/frontend/lowering/class-assertions.ts b/packages/compiler/src/frontend/lowering/class-assertions.ts index 7993117ea..92f1632b2 100644 --- a/packages/compiler/src/frontend/lowering/class-assertions.ts +++ b/packages/compiler/src/frontend/lowering/class-assertions.ts @@ -10,6 +10,26 @@ import { } from "../../ir/ir.js"; import type { Lowerer } from "./lowerer.js"; +/** Whether any stored payload can supply a checked view of this class. */ +export function canAssertClassValue( + lowerer: Lowerer, + source: IrType, + target: IrType & { kind: "object" }, +): boolean { + if (source.kind === "union") + return ( + lowerer.unions + .get(source.unionId) + ?.arms.some((arm) => canAssertClassValue(lowerer, arm, target)) ?? false + ); + return ( + source.kind === "object" && + (typeEquals(source, target) || + lowerer.isSubclassOf(source.className, target.className) || + lowerer.isSubclassOf(target.className, source.className)) + ); +} + /** Assertions cannot prove a subclass layout. Check the stored class before * exposing descendant fields, preserving identity and one operand evaluation. */ export function checkedClassAssertion( @@ -20,10 +40,7 @@ export function checkedClassAssertion( ): IrExpr | null { const source = value; const compatible = (type: IrType): type is IrType & { kind: "object" } => - type.kind === "object" && - (typeEquals(type, target) || - lowerer.isSubclassOf(type.className, target.className) || - lowerer.isSubclassOf(target.className, type.className)); + type.kind === "object" && canAssertClassValue(lowerer, type, target); const failure = (): IrExpr => nodeThrowExpr(1, "", `Value is not an instance of '${lowerer.fmt(target)}'`, target, loc); const convert = (input: IrExpr): IrExpr => { diff --git a/packages/compiler/src/frontend/lowering/coercions/identity.ts b/packages/compiler/src/frontend/lowering/coercions/identity.ts new file mode 100644 index 000000000..db19cf2fd --- /dev/null +++ b/packages/compiler/src/frontend/lowering/coercions/identity.ts @@ -0,0 +1,35 @@ +import { typeEquals, type IrType } from "../../../ir/ir.js"; +import type { Lowerer } from "../lowerer.js"; + +/** A value may enter wider storage only through tag changes or a class + * prefix view. Structural copies and callback adapters change identity. + * Retagging requires one target arm unless the caller's own conversion + * selects among several ancestor class views of the same object. */ +export function identityPreservingWidening( + lowerer: Lowerer, + source: IrType, + target: IrType, + ancestorChoice = false, +): boolean { + if (typeEquals(source, target)) return true; + if (source.kind === "object" && target.kind === "object") + return lowerer.isSubclassOf(source.className, target.className); + if (source.kind === "union") + return ( + lowerer.unions + .get(source.unionId) + ?.arms.every((arm) => identityPreservingWidening(lowerer, arm, target, ancestorChoice)) ?? + false + ); + if (target.kind === "union") { + const arms = lowerer.unions.get(target.unionId)?.arms; + if (arms?.some((arm) => typeEquals(source, arm))) return true; + const candidates = + arms?.filter((arm) => identityPreservingWidening(lowerer, source, arm, ancestorChoice)) ?? []; + return ( + candidates.length === 1 || + (ancestorChoice && candidates.length > 1 && candidates.every((arm) => arm.kind === "object")) + ); + } + return false; +} diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 809ea882e..8d1893ed3 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -1,5 +1,6 @@ import { boolLit, countedFor, numLit, varRef } from "../../../ir/build.js"; import { InternalCompilerError } from "../../../errors.js"; +import { canAssertClassValue, checkedClassAssertion } from "../class-assertions.js"; import * as ts from "../../ts7/adapter.js"; import type { Lowerer } from "../lowerer.js"; import { @@ -177,7 +178,12 @@ function filterResultElem( ); } } - if (valueT.kind !== "union" || lowerer.armTag(valueT.unionId, outElem) < 0) { + const classRefinement = + !booleanFilter && outElem.kind === "object" && canAssertClassValue(lowerer, valueT, outElem); + if ( + !classRefinement && + (valueT.kind !== "union" || lowerer.armTag(valueT.unionId, outElem) < 0) + ) { const multiArm = outElem.kind === "union"; lowerer.unsupported( "SC1090", @@ -187,7 +193,7 @@ function filterResultElem( `(${multiArm ? "only a SINGLE arm re-tags" : "only a single runtime arm re-tags"} — ${annotateEscape})`, ); } - if (booleanFilter) { + if (booleanFilter && valueT.kind === "union") { const arms = lowerer.unions.get(valueT.unionId)?.arms ?? []; if (arms.some((arm) => !typeEquals(arm, outElem) && !isUnitType(arm))) { lowerer.unsupported( @@ -570,18 +576,24 @@ function buildArrayHofFn( { id: "v.0", name: "v", type: valueT, mutable: false }, ); returnType = outT; + const classView = + !typeEquals(outElem, elem) && outElem.kind === "object" + ? checkedClassAssertion(lowerer, varRef("v.0", valueT, loc), outElem, loc) + : null; const retained = typeEquals(outElem, elem) ? varRef("v.0", valueT, loc) - : valueT.kind === "union" - ? { - kind: "unionNarrow" as const, - unionId: valueT.unionId, - tag: lowerer.armTag(valueT.unionId, outElem), - value: varRef("v.0", valueT, loc), - type: outElem, - loc, - } - : varRef("v.0", valueT, loc); + : classView !== null + ? classView + : valueT.kind === "union" + ? { + kind: "unionNarrow" as const, + unionId: valueT.unionId, + tag: lowerer.armTag(valueT.unionId, outElem), + value: varRef("v.0", valueT, loc), + type: outElem, + loc, + } + : varRef("v.0", valueT, loc); body = [ { kind: "varDecl", @@ -752,7 +764,11 @@ export function lowerArrayFindLikeCall( ); } retag = lowerer.narrowedRetagHelper(call, valueT.unionId, resultT.unionId, loc); - if (retag === null) + const present = lowerer.stripUndefinedArm(resultT); + if ( + retag === null && + !(present.kind === "object" && canAssertClassValue(lowerer, valueT, present)) + ) lowerer.unsupported( "SC1090", call, @@ -801,9 +817,16 @@ function findHelper( const valueT = arrayValueType(lowerer, elem); const v = varRef("v.0", valueT, loc); + const present = lowerer.stripUndefinedArm(resultT); + // A successful inferred class predicate may select a descendant of a + // stored base arm. Keep the value read before the callback mutated the array. + const classView = + retag === null && !lowerer.coercibleValue(valueT, resultT) && present.kind === "object" + ? checkedClassAssertion(lowerer, v, present, loc) + : null; const found: IrExpr = retag === null - ? lowerer.coerceToExpected(v, resultT) + ? lowerer.coerceToExpected(classView ?? v, resultT) : { kind: "call", callee: retag, args: [v], type: resultT, loc }; const miss: IrExpr = { kind: "unionWrap", diff --git a/packages/compiler/src/frontend/lowering/containers/array-concat.ts b/packages/compiler/src/frontend/lowering/containers/array-concat.ts index 25f02f579..57b8caf14 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-concat.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-concat.ts @@ -1,3 +1,4 @@ +import { identityPreservingWidening } from "../coercions/identity.js"; import { countedFor, numLit, varRef } from "../../../ir/build.js"; import { BOOL, @@ -69,7 +70,7 @@ export function arrayConcatHelper( loc, }, ]; - if (!identityPreservingWidening(lowerer, sourceElem, elem)) { + if (!identityPreservingWidening(lowerer, sourceElem, elem, true)) { lowerer.unsupported( "SC1090", site, @@ -155,22 +156,3 @@ export function arrayConcatHelper( lowerer.liftedFns.push({ name, params, returnType: arrT, locals, body, loc }); return name; } - -function identityPreservingWidening(lowerer: Lowerer, source: IrType, target: IrType): boolean { - if (typeEquals(source, target)) return true; - if (source.kind === "object" && target.kind === "object") - return lowerer.isSubclassOf(source.className, target.className); - if (source.kind === "union") - return ( - lowerer.unions - .get(source.unionId) - ?.arms.every((arm) => identityPreservingWidening(lowerer, arm, target)) ?? false - ); - if (target.kind === "union") - return ( - lowerer.unions - .get(target.unionId) - ?.arms.some((arm) => identityPreservingWidening(lowerer, source, arm)) ?? false - ); - return false; -} diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 2e0dbf948..833c68acf 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -1,3 +1,5 @@ +import { identityPreservingWidening } from "../coercions/identity.js"; +import { InternalCompilerError } from "../../../errors.js"; import { dynUndefinedExpr, nodeThrowExpr, @@ -109,13 +111,16 @@ export function lowerArraySpreadItems( for (const node of nodes) { if (ts.isSpreadElement(node)) { let source = lowerer.lowerExpr(node.expression); - if (source.type.kind === "set" && typeEquals(source.type.elem, elem)) { + if ( + source.type.kind === "set" && + identityPreservingWidening(lowerer, source.type.elem, elem) + ) { source = { kind: "setIntrinsic", method: "toArray", receiver: source, args: [], - type: arrType, + type: arrayOf(source.type.elem), loc, }; } @@ -123,7 +128,18 @@ export function lowerArraySpreadItems( source = lowerer.widthCoerce(source, arrType) ?? source; } if (!typeEquals(source.type, arrType)) { - lowerer.noLowering(`Array insertion spread from '${lowerer.fmt(source.type)}'`, node); + if ( + source.type.kind !== "array" || + !identityPreservingWidening(lowerer, source.type.elem, elem) + ) { + lowerer.noLowering(`Array insertion spread from '${lowerer.fmt(source.type)}'`, node); + } + body.push({ + kind: "exprStmt", + expr: appendWidenedArrayValues(lowerer, outRef, source, elem, loc), + loc, + }); + continue; } body.push({ kind: "exprStmt", @@ -149,6 +165,91 @@ export function lowerArraySpreadItems( return { kind: "seqExpr", stmts: body, result: outRef, type: arrType, loc }; } +/** Collect an already-lowered single spread without lowering its source twice. */ +export function widenArraySpread( + lowerer: Lowerer, + source: IrExpr, + elem: IrType, + loc: SrcLoc, +): IrExpr { + const type = arrayOf(elem); + const out = lowerer.declareHiddenLocal("%arrayItems", type); + const result = varRef(out.id, type, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: out.id, init: { kind: "arrayLit", elems: [], type, loc }, loc }, + { kind: "exprStmt", expr: appendWidenedArrayValues(lowerer, result, source, elem, loc), loc }, + ], + result, + type, + loc, + }; +} + +/** Spreading observes holes as undefined and snapshots values before later + * arguments can mutate the source. Only the copied elements are widened. */ +function appendWidenedArrayValues( + lowerer: Lowerer, + target: IrExpr, + source: IrExpr, + elem: IrType, + loc: SrcLoc, +): IrExpr { + if (source.type.kind !== "array") + throw new InternalCompilerError("array spread source is not an array"); + const sourceType = source.type; + const key = `insertValues:${typeKey(sourceType.elem)}:${typeKey(elem)}`; + let name = lowerer.arrHofHelpers.get(key); + if (!name) { + name = `%arr.insertValues.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, name); + const outType = arrayOf(elem); + const valueType = arrayValueType(lowerer, source.type.elem); + const out = varRef("out.0", outType, loc); + const input = varRef("source.0", source.type, loc); + const value = varRef("value.0", valueType, loc); + const length = (array: IrExpr): IrExpr => ({ + kind: "arrIntrinsic", + method: "length", + receiver: array, + args: [], + type: F64, + loc, + }); + lowerer.liftedFns.push({ + name, + params: [ + { localId: "out.0", name: "out", type: outType }, + { localId: "source.0", name: "source", type: source.type }, + ], + locals: [ + { id: "out.0", name: "out", type: outType, mutable: false }, + { id: "source.0", name: "source", type: source.type, mutable: false }, + { id: "value.0", name: "value", type: valueType, mutable: false }, + { id: "n.0", name: "count", type: F64, mutable: false }, + { id: "i.0", name: "index", type: F64, mutable: true }, + ], + returnType: F64, + body: [ + { kind: "varDecl", localId: "n.0", init: length(input), loc }, + countedFor(loc, varRef("n.0", F64, loc), (index) => [ + { + kind: "varDecl", + localId: "value.0", + init: arrayValueRead(lowerer, input, index, sourceType.elem, loc), + loc, + }, + arrayValueStore(lowerer, out, length(out), value, elem, loc), + ]), + { kind: "return", value: length(out), loc }, + ], + loc, + }); + } + return { kind: "call", callee: name, args: [target, source], type: F64, loc }; +} + /** The Array constructor's element and count forms, shared by calls and new. */ export function lowerArrayConstructor( lowerer: Lowerer, diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index de0ea79cb..94c83e4e1 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -1,3 +1,4 @@ +import { identityPreservingWidening } from "../coercions/identity.js"; import { arrayConcatHelper } from "./array-concat.js"; import { boolLit, countedFor, numLit, strLit, varRef } from "../../../ir/build.js"; import { InternalCompilerError } from "../../../errors.js"; @@ -45,7 +46,11 @@ import { lowerArrayFlatMapCall, lowerArrayReduceCall, } from "./array-callbacks.js"; -import { lowerArraySpreadItems, lowerArrayValueItems } from "./array-construction.js"; +import { + lowerArraySpreadItems, + lowerArrayValueItems, + widenArraySpread, +} from "./array-construction.js"; import { arrayLengthDeclaration } from "./array-iteration.js"; import { lowerArrayCallback, @@ -581,9 +586,9 @@ export function lowerArrayMethodCall( : null; if ((name === "push" || name === "unshift") && spreadArg && ts.isSpreadElement(spreadArg)) { let src = lowerer.lowerExpr(spreadArg.expression); - // `a.push(...someSet)` / `a.unshift(...someSet)`: a same-element Set drains first + // `a.push(...someSet)` / `a.unshift(...someSet)`: a compatible Set drains first // (setIntrinsic toArray — insertion order), then appends. - if (src.type.kind === "set" && typeEquals(src.type.elem, elem)) { + if (src.type.kind === "set" && identityPreservingWidening(lowerer, src.type.elem, elem)) { src = { kind: "setIntrinsic", method: "toArray", @@ -593,6 +598,21 @@ export function lowerArrayMethodCall( loc, }; } + if ( + src.type.kind === "array" && + !typeEquals(src.type, receiverIr) && + identityPreservingWidening(lowerer, src.type.elem, elem) + ) { + const items = widenArraySpread(lowerer, src, elem, loc); + return { + kind: "arrIntrinsic", + method: name === "push" ? "pushSpread" : "unshiftSpread", + receiver, + args: [items], + type: F64, + loc, + }; + } if (!typeEquals(src.type, receiverIr)) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/lower-array-sort.test.ts b/packages/compiler/src/frontend/lowering/lower-array-sort.test.ts new file mode 100644 index 000000000..9c1fd0b3b --- /dev/null +++ b/packages/compiler/src/frontend/lowering/lower-array-sort.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "vitest"; +import { F64, JSVAL, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; +import { buildArraySortFn, buildBytesSortFn } from "./lower-array-sort.js"; + +const loc = { file: "sort.ts", start: 0, end: 0 }; + +function repeatedNodes(fn: IrFunction): number { + const seen = new Set(); + let repeated = 0; + const visit = (node: IrExpr | IrStmt): boolean => { + if (seen.has(node)) repeated++; + seen.add(node); + return true; + }; + everyStmtList(fn.body, { expr: visit, stmt: visit }); + return repeated; +} + +test("sort helpers give every IR site its own node", () => { + const union = { kind: "union" as const, unionId: "u0" }; + const helpers = [ + buildArraySortFn("%arr.sort.0", F64, 2, false, null, loc), + buildArraySortFn("%arr.toSorted.1", F64, 1, true, null, loc), + buildArraySortFn("%arr.sort.2", JSVAL, 2, false, null, loc), + buildArraySortFn("%arr.sort.3", union, 2, true, 1, loc), + buildBytesSortFn("%bytes.sort.4", 2, true, loc), + buildBytesSortFn("%bytes.sort.5", 0, false, loc), + ]; + for (const fn of helpers) expect(repeatedNodes(fn), fn.name).toBe(0); +}); diff --git a/packages/compiler/src/frontend/lowering/lower-array-sort.ts b/packages/compiler/src/frontend/lowering/lower-array-sort.ts index 858de6be5..4522f9740 100644 --- a/packages/compiler/src/frontend/lowering/lower-array-sort.ts +++ b/packages/compiler/src/frontend/lowering/lower-array-sort.ts @@ -13,6 +13,7 @@ import { funcOf, } from "../../ir/ir.js"; import { numLit } from "../../ir/build.js"; +import { transformStmtList } from "../../ir/traverse.js"; type SortLocal = IrExpr & { kind: "varRef" }; type Greater = (left: IrExpr, right: IrExpr) => IrExpr; @@ -112,6 +113,28 @@ class SortIr { } } +/** The builders reuse local references, constants and comparator operands at + * several sites. Give every site its own node: per-node emitter analyses then + * see the same tree as a serialized IR artifact, and facts proved at one site + * cannot be merged with another. */ +function distinctNodes(body: IrStmt[]): IrStmt[] { + return transformStmtList(body, { + expr: (node) => { + switch (node.kind) { + case "varRef": + return { ...node }; + case "numLit": + return { ...node }; + case "boolLit": + return { ...node }; + default: + return node; + } + }, + stmt: (node) => (node.kind === "break" ? { ...node } : node), + }); +} + /** Stable natural merge sort over an owned snapshot. Strict descending runs * can be reversed without reversing ties. Short runs use bounded binary * insertion; merge passes then consume pairs of complete runs. The boundary @@ -380,7 +403,7 @@ export function buildArraySortFn( ], returnType: arrT, locals: b.locals, - body, + body: distinctNodes(body), loc, }; } @@ -456,7 +479,7 @@ export function buildBytesSortFn( ], returnType: BYTES_U8, locals: b.locals, - body, + body: distinctNodes(body), loc, }; } diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 50937c3e9..69c35a0b0 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,3 +1,4 @@ +import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; import { lowerArrayCallbackCall } from "./containers/array-callback-call.js"; /** Dispatch source calls, builtin members and receiver-specific operations. * Signature collection, argument completion and function bodies have their @@ -7196,6 +7197,9 @@ function lowerObjectStaticCall( }; } if (left.type.kind === "union" || right.type.kind === "union") { + const compared = lowerUnionEquality(lowerer, left, right, false, true, loc); + if (compared) return compared; + const ut = left.type.kind === "union" ? left.type : (right.type as IrType & { kind: "union" }); const bothUnion = left.type.kind === "union" && right.type.kind === "union"; @@ -7207,6 +7211,12 @@ function lowerObjectStaticCall( // union holds its type; a plain PRIMITIVE the union has no arm // for is the disjoint constant false (coercing it would strand). if (bothUnion || arms.some((a) => typeEquals(a, plain.type))) { + if (tagEqualityMayMissAlias(lowerer, ut)) + lowerer.noLowering( + "Object.is over related class values beside structural union members", + call, + NARROW_FIRST, + ); const sameValue = arms.some((a) => a.kind === "f64"); return { kind: "unionEq", diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 31926dd68..a71d8beba 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1,3 +1,4 @@ +import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; import { isOptionalProcessStreamProperty } from "./builtins/process.js"; import { lowerWorkerMetadata } from "./builtins/workers.js"; import { @@ -3805,6 +3806,12 @@ function runtimeOptionalReceiverRead( loc, }; } + if (narrowed.kind === "object") { + const view = checkedClassAssertion(lowerer, varRef(local.id, local.type, loc), narrowed, loc); + // Preserve the member-specific missing-value error on the two-arm + // path below; mixed storage also needs to validate its concrete class. + if (view && (lowerer.unions.get(local.type.unionId)?.arms.length ?? 0) > 2) return view; + } const undefTag = lowerer.armTag(local.type.unionId, UNDEFINED_T); const def = lowerer.unions.get(local.type.unionId); if (!def || undefTag < 0) @@ -10283,6 +10290,17 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr const unitTest = lowerer.lowerUnitComparison(left, right, negated, loc); if (unitTest) return unitTest; if (left.type.kind === "union" || right.type.kind === "union") { + const compared = lowerUnionEquality(lowerer, left, right, negated, false, loc); + if (compared) return compared; + const aliasRefusal = (type: IrType): void => { + if (tagEqualityMayMissAlias(lowerer, type)) + lowerer.unsupported( + "SC1090", + expr, + `comparisons of related class values beside structural union members (${NARROW_FIRST})`, + ); + }; + // JS strict equality of the ARM values, per union tag (a // per-union helper): equal tags compare payloads (f64 ==, string // bytes, bool ==, ref-arm POINTER identity), different tags are @@ -10322,6 +10340,7 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr }; } if ((sameUnion || !bothUnion) && lowerer.eqComparableUnion(ut.unionId)) { + aliasRefusal(ut); return { kind: "unionEq", unionId: ut.unionId, @@ -10354,6 +10373,7 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr kind: "union", unionId: lowerer.unions.intern(arms), }; + aliasRefusal(common); return { kind: "unionEq", unionId: common.unionId, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index f40427c90..5da099b99 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,3 +1,5 @@ +import { identityPreservingWidening } from "./coercions/identity.js"; +import { checkedClassAssertion } from "./class-assertions.js"; import { narrowStoredClassValue, narrowGenericClassValue } from "./class-unions.js"; import { isOptionalProcessStreamProperty } from "./builtins/process.js"; import { concreteCollectionNarrow } from "./collection-narrowing.js"; @@ -2039,7 +2041,14 @@ export class Lowerer { * whose checker type is the corresponding bare arm. */ runtimeOptionalWidening(actual: IrType, expected: IrType): IrType | null { if (actual.kind !== "union" || this.armTag(actual.unionId, UNDEFINED_T) < 0) return null; - return typeEquals(this.stripUndefinedArm(actual), expected) ? actual : null; + const present = this.stripUndefinedArm(actual); + if (typeEquals(present, expected)) return actual; + // Only a bare expected arm gains runtime-optional storage. A slot that + // already admits undefined keeps its own checked representation. + if (expected.kind === "union" && this.armTag(expected.unionId, UNDEFINED_T) >= 0) return null; + return identityPreservingWidening(this, present, expected) + ? this.runtimeOptionalType(expected) + : null; } runtimeOptionalType(t: IrType): IrType { @@ -2111,6 +2120,8 @@ export class Lowerer { /** True while re-lowering a base function's 2nd+ instance: the same source * statements were already counted for the first instance. */ suppressStats = false; + /** Comparisons interned by operand representations and equality semantics. */ + readonly equalityHelpers = new Map(); /** Synthetic array-HOF loop functions (map/filter/forEach desugar), * interned per method + element/callback-result type: key → fn name. */ readonly arrHofHelpers = new Map(); @@ -6840,6 +6851,34 @@ export class Lowerer { ) { return { kind: "upcast", value: expr, type: expected, loc: expr.loc }; } + // Optional subclass storage still has the same object payload. Extract + // it with a checked tag test, then widen the class view without copying. + if (expected.kind === "object" && expr.type.kind === "union") { + const arms = this.unions.get(expr.type.unionId)?.arms; + const present = arms?.filter((arm) => !isUnitType(arm)) ?? []; + if ( + present.length === 1 && + present[0]!.kind === "object" && + present[0]!.className !== expected.className && + this.isSubclassOf(present[0]!.className, expected.className) + ) { + // One class arm: the ordinary checked narrow reports a missing value + // exactly like same-class storage before the prefix view widens it. + return { + kind: "upcast", + value: this.coerceToExpected(expr, present[0]!), + type: expected, + loc: expr.loc, + }; + } + if ( + present.length > 1 && + present.every((arm) => identityPreservingWidening(this, arm, expected)) + ) { + const view = checkedClassAssertion(this, expr, expected, expr.loc); + if (view) return view; + } + } // CLASS-VALUE widening (classval:D into a classval:C slot): the same // pointer with only the static type changing — legal exactly when D // strictly descends from C AND the two completed constructor ABIs diff --git a/packages/compiler/src/frontend/lowering/strict-equality.ts b/packages/compiler/src/frontend/lowering/strict-equality.ts new file mode 100644 index 000000000..471526735 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/strict-equality.ts @@ -0,0 +1,161 @@ +import { boolLit, varRef } from "../../ir/build.js"; +import { + BOOL, + DYN_HANDLE_KINDS, + type IrExpr, + type IrType, + type SrcLoc, + isUnitType, + typeEquals, + typeKey, +} from "../../ir/ir.js"; +import type { Lowerer } from "./lowerer.js"; +import { unionComparison } from "./union-comparison.js"; + +const IDENTITY_KINDS: ReadonlySet = new Set([ + "array", + "map", + "set", + "regex", + "url", + "object", + "record", + "symbol", + "bytes", + "promise", + "func", + "classval", + "moduleNs", +]); + +function atomicEquality( + lowerer: Lowerer, + left: IrExpr, + right: IrExpr, + sameValue: boolean, + loc: SrcLoc, +): IrExpr | null { + const a = left.type, + b = right.type; + if ( + [a.kind, b.kind].some( + (kind) => kind === "dyn" || kind === "jsval" || kind === "caught" || kind === "void", + ) + ) + return null; + if (isUnitType(a) || isUnitType(b)) return boolLit(a.kind === b.kind, loc); + if (a.kind === "object" && b.kind === "object" && !typeEquals(a, b)) { + if (lowerer.isSubclassOf(a.className, b.className)) left = lowerer.upcastTo(left, b.className); + else if (lowerer.isSubclassOf(b.className, a.className)) + right = lowerer.upcastTo(right, a.className); + else return boolLit(false, loc); + } + if (a.kind !== b.kind) { + // A structural record can represent a class view. A different layout + // cannot prove that the original JavaScript identities are disjoint. + if ( + (a.kind === "record" && b.kind === "object") || + (a.kind === "object" && b.kind === "record") + ) + return null; + return boolLit(false, loc); + } + if (a.kind === "string") return { kind: "strEq", left, right, negated: false, type: BOOL, loc }; + if (a.kind === "bigint") + return { kind: "libCall", fn: "bigint.eq", args: [left, right], type: BOOL, loc }; + if (a.kind === "f64" && sameValue) + return { kind: "libCall", fn: "num.sameValue", args: [left, right], type: BOOL, loc }; + if ( + a.kind === "f64" || + a.kind === "bool" || + a.kind === "func" || + a.kind === "classval" || + a.kind === "moduleNs" || + ((IDENTITY_KINDS.has(a.kind) || DYN_HANDLE_KINDS.has(a.kind)) && + typeEquals(left.type, right.type)) + ) + return { kind: "bin", op: "===", left, right, type: BOOL, loc }; + return null; +} + +/** Related class arms can store the same object under different tags, so + * comparing a union's tags and payloads alone can miss an identity. */ +export function tagEqualityMayMissAlias(lowerer: Lowerer, type: IrType): boolean { + const arms = type.kind === "union" ? lowerer.unions.get(type.unionId)?.arms : null; + return ( + arms?.some( + (arm, index) => + arm.kind === "object" && + arms + .slice(index + 1) + .some( + (other) => + other.kind === "object" && + (lowerer.isSubclassOf(arm.className, other.className) || + lowerer.isSubclassOf(other.className, arm.className)), + ), + ) ?? false + ); +} + +/** Strict/SameValue comparison across independently represented unions. + * Parameters preserve left-to-right evaluation and reuse native payloads; + * equality must never allocate a width copy or adapt a callback signature. */ +export function lowerUnionEquality( + lowerer: Lowerer, + left: IrExpr, + right: IrExpr, + negated: boolean, + sameValue: boolean, + loc: SrcLoc, +): IrExpr | null { + if (left.type.kind !== "union" && right.type.kind !== "union") return null; + // Unit comparisons are handled before this path, including their effects. + if (isUnitType(left.type) || isUnitType(right.type)) return null; + // Ordinary same-layout unions keep the compact tag comparison. + if (typeEquals(left.type, right.type) && !tagEqualityMayMissAlias(lowerer, left.type)) + return null; + // So does a plain operand that is already one of a comparable union's arms. + const union = left.type.kind === "union" ? left.type : right.type; + const plain = left.type.kind === "union" ? right.type : left.type; + if ( + union.kind === "union" && + plain.kind !== "union" && + !tagEqualityMayMissAlias(lowerer, union) && + lowerer.eqComparableUnion(union.unionId) && + lowerer.unions.get(union.unionId)?.arms.some((arm) => typeEquals(arm, plain)) + ) + return null; + const key = `${lowerer.unions.revision}:${typeKey(left.type)}:${typeKey(right.type)}:${sameValue}`; + let name = lowerer.equalityHelpers.get(key); + if (!name) { + const a = varRef("left.0", left.type, loc), + b = varRef("right.0", right.type, loc); + const result = unionComparison( + lowerer, + a, + b, + (x, y) => atomicEquality(lowerer, x, y, sameValue, loc), + loc, + ); + if (!result) return null; + name = `%value.eq.${lowerer.equalityHelpers.size}`; + lowerer.equalityHelpers.set(key, name); + lowerer.liftedFns.push({ + name, + params: [ + { localId: "left.0", name: "left", type: left.type }, + { localId: "right.0", name: "right", type: right.type }, + ], + locals: [ + { id: "left.0", name: "left", type: left.type, mutable: false }, + { id: "right.0", name: "right", type: right.type, mutable: false }, + ], + returnType: BOOL, + body: [{ kind: "return", value: result, loc }], + loc, + }); + } + const result: IrExpr = { kind: "call", callee: name, args: [left, right], type: BOOL, loc }; + return negated ? { kind: "unary", op: "!", operand: result, type: BOOL, loc } : result; +} diff --git a/packages/compiler/src/frontend/lowering/union-comparison.ts b/packages/compiler/src/frontend/lowering/union-comparison.ts new file mode 100644 index 000000000..dcacde373 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/union-comparison.ts @@ -0,0 +1,57 @@ +import { BOOL, type IrExpr, type SrcLoc } from "../../ir/ir.js"; +import type { Lowerer } from "./lowerer.js"; + +/** Compare stable operands using their own union tags and payload layouts. + * No arm is converted merely to make its tag agree with the other operand. */ +export function unionComparison( + lowerer: Lowerer, + left: IrExpr, + right: IrExpr, + compare: (left: IrExpr, right: IrExpr) => IrExpr | null, + loc: SrcLoc, +): IrExpr | null { + const input = left.type.kind === "union" ? left : right.type.kind === "union" ? right : null; + if (!input || input.type.kind !== "union") return compare(left, right); + const unionId = input.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms; + if (!arms?.length) return null; + let result: IrExpr | null = null; + for (let tag = arms.length - 1; tag >= 0; tag--) { + const payload: IrExpr = { + kind: "unionNarrow", + unionId, + tag, + value: input, + type: arms[tag]!, + loc, + }; + const branch = unionComparison( + lowerer, + input === left ? payload : left, + input === left ? right : payload, + compare, + loc, + ); + if (!branch) return null; + result = + result === null + ? branch + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag, + negated: false, + value: input, + type: BOOL, + loc, + }, + then: branch, + else_: result, + type: BOOL, + loc, + }; + } + return result; +} diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 9e832d58d..703861097 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -1,4 +1,5 @@ import type { CompilationTiming } from "../timing.js"; +import { readFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import type { CompileFailure, @@ -22,7 +23,8 @@ import { import { provenanceSources } from "../frontend/provenance-registry.js"; import { prepareLibrary } from "../library/prepare.js"; import type { LibraryProfile } from "../library/library-profile.js"; -import { serializeModule } from "../ir/serialize.js"; +import type { IrModule } from "../ir/ir.js"; +import { deserializeModule, serializeModule } from "../ir/serialize.js"; import { contentDigest, type NativeCache } from "./cache.js"; import type { NativeToolchain } from "./toolchain.js"; import { nativeFileIdentity } from "./file-identity.js"; @@ -124,6 +126,26 @@ function validCachedInput(value: unknown): value is FrontendCacheEntry { ); } +/** Bootstrap-only input for the split sanitized self-rebuild: emit an + * executable from a serialized IR artifact of the same entry instead of + * running the frontend. A separate run compares that artifact with the IR the + * native frontend lowers itself. Debug metadata needs source text, so only + * stripped executables are accepted. */ +const BOOTSTRAP_IR_INPUT = "SCRIPTC_BOOTSTRAP_IR_INPUT"; + +function preparedFromIr( + path: string, + entry: string, + options: CompileRequestOptions, +): { ok: true; mod: IrModule; sourceTexts: Map } { + if ((options.outputKind ?? "exe") !== "exe" || options.strip !== true) + throw new Error(`${BOOTSTRAP_IR_INPUT} requires a stripped executable build`); + const mod = deserializeModule(readFileSync(path, "utf8")); + if (resolve(mod.sourceFile) !== resolve(entry)) + throw new Error(`${BOOTSTRAP_IR_INPUT} was lowered from a different entry`); + return { ok: true, mod, sourceTexts: new Map() }; +} + /** Keep the checker and typed IR out of the native optimizer's live heap. */ export function prepareNativeExecutable( entry: string, @@ -150,8 +172,9 @@ export function prepareNativeExecutable( } } const exclusions = { outputPaths, outputDirectories: [...directories] }; + const irInput = process.env[BOOTSTRAP_IR_INPUT] ?? ""; let key: string | null = null; - if (cache !== null && provenanceSources() === null) { + if (cache !== null && irInput === "" && provenanceSources() === null) { try { key = contentDigest( JSON.stringify({ @@ -182,10 +205,14 @@ export function prepareNativeExecutable( } timing("frontend-cache-miss"); const tracker = new FrontendInputTracker(); - const prepared = tracker.runSynchronous(() => - prepareExecutableModule(entry, options, ffi, toolchain.target.platform, frontend, timing), - ); + const prepared = + irInput !== "" + ? preparedFromIr(irInput, entry, options) + : tracker.runSynchronous(() => + prepareExecutableModule(entry, options, ffi, toolchain.target.platform, frontend, timing), + ); if (!prepared.ok) return prepared; + if (irInput !== "") timing("ir-input"); const ir = outputKind === "ir" || options.emitIr ? serializeModule(prepared.mod, true) : null; timing("ir-serialize"); const llvm = diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 0def2ef69..803444345 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -14745,6 +14745,24 @@ "/tests/corpus/derived-numeric-updates.ts" ], "diags": [] + }, + "/tests/corpus/captured-class-refinement.ts": { + "order": [ + "/tests/corpus/captured-class-refinement.ts" + ], + "diags": [] + }, + "/tests/corpus/class-collection-insertion.ts": { + "order": [ + "/tests/corpus/class-collection-insertion.ts" + ], + "diags": [] + }, + "/tests/corpus/union-reference-comparison.ts": { + "order": [ + "/tests/corpus/union-reference-comparison.ts" + ], + "diags": [] } } } diff --git a/scripts/ci-native-bootstrap.mjs b/scripts/ci-native-bootstrap.mjs index 42aaa32ad..d6919e854 100644 --- a/scripts/ci-native-bootstrap.mjs +++ b/scripts/ci-native-bootstrap.mjs @@ -4,11 +4,25 @@ import { dirname, resolve } from "node:path"; import { setTimeout } from "node:timers/promises"; import { pathToFileURL } from "node:url"; +const PHASES = ["all", "commands", "rebuild", "rebuild-frontend", "rebuild-emit"]; + +/** The sanitized self-rebuild runs as two halves split at the IR boundary: + * rebuild-frontend lowers the compiler and compares its IR with the seed's; + * rebuild-emit emits, links and probes the next generation from the seed's + * IR. "rebuild" and "all" select both halves in the sanitizer lane. The plain + * lane keeps its single chained rebuild and fixed-point check. */ export function nativeBootstrapPlan({ phase = "all", sanitize = false } = {}) { - if (!["all", "commands", "rebuild"].includes(phase)) { - throw new Error("SCRIPTC_BOOTSTRAP_PHASE must be all, commands, or rebuild"); + if (!PHASES.includes(phase)) { + throw new Error( + "SCRIPTC_BOOTSTRAP_PHASE must be all, commands, rebuild, rebuild-frontend, or rebuild-emit", + ); } - const phases = phase === "all" ? ["commands", "rebuild"] : [phase]; + if (!sanitize && phase.startsWith("rebuild-")) { + throw new Error(`${phase} is a sanitizer bootstrap phase; the plain lane uses rebuild`); + } + const rebuild = sanitize ? ["rebuild-frontend", "rebuild-emit"] : ["rebuild"]; + const phases = + phase === "all" ? ["commands", ...rebuild] : phase === "rebuild" ? rebuild : [phase]; return { phases, packageChecks: !sanitize && phases.includes("commands") }; } diff --git a/tests/corpus/captured-class-refinement.ts b/tests/corpus/captured-class-refinement.ts new file mode 100644 index 000000000..0f2123766 --- /dev/null +++ b/tests/corpus/captured-class-refinement.ts @@ -0,0 +1,43 @@ +class Base { value = 1; } +class Child extends Base { + extra = 2; + read(): number { return this.value + this.extra; } +} +class Named { name = "named"; } +type Value = Base | Named | string; +function isChild(value: Value): value is Child { return value instanceof Child; } + +function inspect(value: Value): number { + const keep = (): Value => value; + keep(); + if (isChild(value)) { + value.extra += 3; + return value.read(); + } + return -1; +} +function asserted(value: Value): number { + const keep = (): Value => value; + keep(); + return (value as Child).read(); +} +const child = new Child(); +const values: Value[] = [child, new Named(), "text", new Base()]; +for (const value of values) console.log(inspect(value)); +console.log(asserted(values[0])); +for (const value of values) { + if (isChild(value)) console.log(value.extra, value.read(), value === child); +} +const selected = values.filter((value) => value instanceof Child); +console.log(selected[0] === child, selected[0]!.read()); +console.log(values.find((value) => value instanceof Child) === child); +console.log(values.findLast((value) => value instanceof Child) === child); +const empty: Value[] = []; +console.log(empty.find((value) => value instanceof Child) === undefined); +const changed: Value[] = [child, new Child(), "text"]; +const kept = changed.filter((value, index, array) => { + array[index] = "replaced"; + return value instanceof Child; +}); +console.log(kept.length, kept[0] === child, changed[0] === "replaced"); +try { asserted(values[20]); } catch (error) { console.log(error instanceof TypeError); } diff --git a/tests/corpus/class-collection-insertion.ts b/tests/corpus/class-collection-insertion.ts new file mode 100644 index 000000000..7b4bd115b --- /dev/null +++ b/tests/corpus/class-collection-insertion.ts @@ -0,0 +1,77 @@ +class Base { value = 1; } +class Child extends Base { extra = 2; } +const child = new Child(); +const source: Child[] = [child]; +const target: Base[] = []; + +function insert(value: Child): void { + console.log(target.push(value), target.unshift(value)); + console.log(target.splice(1, 0, value).length); + const copied = target.toSpliced(1, 1, value); + const replaced = target.with(0, value); + const literal: Base[] = [value]; + console.log(copied.length, replaced.length, literal.length); + console.log(copied[1] === value, replaced[0] === value, literal[0] === value); + console.log(0 in literal, literal[0] === undefined); +} +insert(source[0]); +insert(source[4]); +console.log(target.length, target.filter((value): boolean => value === undefined).length); +console.log(0 in target, 1 in target, 2 in target); +const mapped: Base[] = source.map((value): Base => value); +console.log(mapped[0] === child); +const records = source.map((value) => ({ value: value as Base })); +console.log(records[0]!.value === child); + +const sparse = new Array(3); +sparse[1] = child; +const widened: Base[] = []; +console.log(widened.push(...sparse)); +console.log(widened[0] === undefined, widened[1] === child, widened[2] === undefined, 0 in widened, 2 in widened); +console.log(widened.unshift(...new Set([child, child]))); +console.log(widened[0] === child); +const changed = widened.toSpliced(1, 1, ...sparse); +console.log(changed.length, changed[2] === child, 1 in changed, 3 in changed); +console.log(widened.splice(1, 2, ...sparse).length, widened.length); + +const events: string[] = []; +const first = new Child(); +const second = new Child(); +first.value = 10; +second.value = 20; +const moving: Child[] = [first]; +const out: Base[] = []; +function getTarget(): Base[] { events.push("receiver"); return out; } +function getSource(): Child[] { events.push("source"); return moving; } +function mutate(): Base { events.push("mutate"); moving[0] = second; return child; } +console.log(getTarget().push(...getSource(), mutate(), ...getSource())); +console.log(events.join(","), out.map((value) => value.value).join(",")); +events.length = 0; +console.log(getTarget().unshift(...getSource()), events.join(",")); +console.log(out[0] === second, out[1] === first); +first.value = 30; +console.log(out[1]!.value); + +function fail(): Base { throw new Error("stop"); } +try { out.push(...source, fail()); } catch (error) { + if (error instanceof Error) console.log(error.message); +} +console.log(out.length); + +class Middle extends Base { middle = 3; } +class Leaf extends Middle { leaf = 4; } +const leaf = new Leaf(); +const ancestors: (Base | Middle)[] = [new Base()]; +const joined = ancestors.concat([leaf], [leaf]); +console.log(joined.length, joined[1] === leaf, joined.indexOf(leaf), joined[2] instanceof Leaf); + +interface Span { min: number; max: number } +interface Entry { name: string; span: Span } +interface Slot { name: string; entry?: Entry } +const entries: Entry[] = [{ name: "first", span: { min: 0, max: 9 } }]; +const slots: Slot[] = [{ name: "a", entry: entries[0] }, { name: "b", entry: entries[3] }]; +function narrowed(slot: Slot): Entry | null { + if (slot.entry) return { ...slot.entry, span: { min: 1, max: slot.entry.span.max } }; + return null; +} +console.log(JSON.stringify(slots.map(narrowed)), slots[0]!.entry === entries[0]); diff --git a/tests/corpus/union-reference-comparison.ts b/tests/corpus/union-reference-comparison.ts new file mode 100644 index 000000000..6229cdbdf --- /dev/null +++ b/tests/corpus/union-reference-comparison.ts @@ -0,0 +1,75 @@ +class Base { value = 1; } +class Child extends Base { extra = 2; } +class Other { label = "other"; } + +function compare(left: Child | undefined, right: Base | undefined): void { + console.log(left === right, left !== right, Object.is(left, right)); + console.log(right === left, right !== left, Object.is(right, left)); +} +const child = new Child(); +compare(child, child); +compare(child, new Child()); +compare(child, new Base()); +compare(undefined, undefined); +compare(child, undefined); +compare(undefined, child); + +function base(value: Base): Base { return value; } +function sameShape(left: Base | Child | undefined, right: Base | Child | undefined): void { + console.log(left === right, left !== right, Object.is(left, right)); +} +sameShape(child, base(child)); +sameShape(base(child), child); +sameShape(child, new Base()); + +function structural(left: Base | { label: string }, right: Child): void { + console.log(left === right, right !== left); +} +structural(child, child); +structural(new Child(), child); +structural({ label: "child" }, child); + +function mixed(left: Child | string | undefined, right: Base | Other | null): void { + console.log(left === right, left !== right, Object.is(left, right)); +} +mixed(child, child); +mixed(child, new Other()); +mixed("label", null); +mixed(undefined, null); + +function primitives(left: number | string | undefined, right: number | boolean | null): void { + console.log(left === right, left !== right, Object.is(left, right)); +} +primitives(NaN, NaN); +primitives(-0, 0); +primitives(0, -0); +primitives(7, 7); +primitives("7", 7); +primitives(undefined, null); +primitives("false", false); + +function big(left: bigint | undefined, right: bigint | string | null): void { + console.log(left === right, left !== right, Object.is(left, right)); +} +big(12345678901234567890n, BigInt("12345678901234567890")); +big(1n, 2n); +big(1n, "1"); +big(undefined, null); + +function callback(value: number): number { return value + 1; } +function callbacks(left: ((value: number) => number) | undefined, + right: ((value: number) => number) | null): void { + console.log(left === right, left !== right, Object.is(left, right)); +} +callbacks(callback, callback); +callbacks(callback, (value: number): number => value + 2); +callbacks(undefined, null); + +const events: string[] = []; +function left(): Child | undefined { events.push("left"); return child; } +function right(): Base | null { events.push("right"); return child; } +console.log(left() === right(), events.join(",")); +events.length = 0; +console.log(left() !== right(), events.join(",")); +events.length = 0; +console.log(Object.is(left(), right()), events.join(",")); diff --git a/tests/diagnostics/index-signature-values.ts b/tests/diagnostics/index-signature-values.ts index f0d92a339..6c53b141c 100644 --- a/tests/diagnostics/index-signature-values.ts +++ b/tests/diagnostics/index-signature-values.ts @@ -8,7 +8,7 @@ const bySymbol: { [s: symbol]: number } = {}; console.log(bySymbol); -// A value type with no representation in the overflow store. +// Storage is supported, but composite typed-array inspection is not. const buffers: Record = {}; console.log(buffers); diff --git a/tests/diagnostics/runtime-optional-refined-union.ts b/tests/diagnostics/runtime-optional-refined-union.ts index 39ea532a3..1fbef1148 100644 --- a/tests/diagnostics/runtime-optional-refined-union.ts +++ b/tests/diagnostics/runtime-optional-refined-union.ts @@ -1,5 +1,5 @@ -// Refining a stored arm within a multi-value union is not yet representable. -// Report a named fence instead of confusing the refined type with a stored tag. +// Structural refinements still need a named fence for multi-value storage. +// The class refinement below is accepted through a checked identity-preserving view. type Item = { rect?: { width: number } }; function hasRect(item: Item | string): item is Item & { rect: { width: number } } { diff --git a/tests/diagnostics/stdlib-fence.ts b/tests/diagnostics/stdlib-fence.ts index 37e0fb5d9..7293b777e 100644 --- a/tests/diagnostics/stdlib-fence.ts +++ b/tests/diagnostics/stdlib-fence.ts @@ -72,7 +72,7 @@ const resolved = Promise.resolve(1); // unions needing narrowing. const dynIs = Object.is(JSON.parse("1") as unknown, 1); function pick(a: string | number, b: number | boolean): boolean { - return Object.is(a, b); // partially-overlapping unions: narrow first + return Object.is(a, b); // partially-overlapping primitive unions are supported } const picked = pick(1, 2); @@ -91,4 +91,21 @@ function* dateGenerator(): Generator { yield 1; } dateGenerator().throw(new Date(0)); +// Related classes may store one object under different union tags. A +// structural arm beside them leaves no identity-preserving comparison. +class FenceBase { + id = 1; +} +class FenceSub extends FenceBase { + extra = 2; +} +type FenceValue = FenceBase | FenceSub | { label: string }; +function relatedStrict(a: FenceValue, b: FenceValue): boolean { + return a === b; +} +function relatedSameValue(a: FenceValue, b: FenceSub): boolean { + return Object.is(a, b); +} +relatedStrict(new FenceSub(), { label: "x" }); +relatedSameValue(new FenceSub(), new FenceSub()); // End of the diagnostic fixture. diff --git a/tests/harness/__snapshots__/component-fences.ts.txt b/tests/harness/__snapshots__/component-fences.ts.txt index 1046d3abb..498846004 100644 --- a/tests/harness/__snapshots__/component-fences.ts.txt +++ b/tests/harness/__snapshots__/component-fences.ts.txt @@ -7,7 +7,7 @@ component-fences.ts:9:7 - error SC2009: values of type 'Map void>' hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') -component-fences.ts:9:19 - error SC1090: Map values of type '() => void' (Map values must be number, string, boolean, records, class instances, arrays, promises, Maps, Sets, or unions of those — not functions, 'unknown', or 'any') is not supported yet +component-fences.ts:9:19 - error SC1090: Map values of type '() => void' (Map values must be number, string, boolean, records, class instances, typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values) is not supported yet 8 | // Map values have no slot for functions. 9 | const listeners = new Map void>(); diff --git a/tests/harness/__snapshots__/index-signature-values.ts.txt b/tests/harness/__snapshots__/index-signature-values.ts.txt index f98e516e0..9774ab9ae 100644 --- a/tests/harness/__snapshots__/index-signature-values.ts.txt +++ b/tests/harness/__snapshots__/index-signature-values.ts.txt @@ -16,24 +16,13 @@ index-signature-values.ts:9:13 - error SC2004: uses of 'bySymbol' inherit the bl hint: the declaration of 'bySymbol' did not compile — fix the diagnostic reported there and these sites clear with it -index-signature-values.ts:12:7 - error SC2006: values of type 'Record>' cannot be compiled: this index signature is outside the supported shape (string or number keys; values limited to numbers, strings, booleans, records, classes, arrays, unions, functions, Maps, Sets, RegExps, Promises, or 'unknown') - - 11 | // A value type with no representation in the overflow store. - 12 | const buffers: Record = {}; - | ^~~~~~~ - 13 | console.log(buffers); - - hint: string- and number-keyed index signatures over the supported value types compile directly; symbol keys and dual signatures with unequal value types have no lowering - -index-signature-values.ts:13:13 - error SC2004: uses of 'buffers' inherit the blocker on its declaration +index-signature-values.ts:13:13 - error SC1090: console.log of '{ [key: string]: Uint8Array }' values (typed arrays inside composites have no inspect lowering yet (Buffer's form vs Uint8Array's is not recorded in the static type here)) are not supported yet 12 | const buffers: Record = {}; 13 | console.log(buffers); | ^~~~~~~ 14 | - hint: the declaration of 'buffers' did not compile — fix the diagnostic reported there and these sites clear with it - index-signature-values.ts:18:7 - error SC2006: values of type '{ [k: string]: string | number; [n: number]: number; }' cannot be compiled: this index signature is outside the supported shape (string or number keys; values limited to numbers, strings, booleans, records, classes, arrays, unions, functions, Maps, Sets, RegExps, Promises, or 'unknown') 17 | // answer per key). diff --git a/tests/harness/__snapshots__/maps.ts.txt b/tests/harness/__snapshots__/maps.ts.txt index 79c3c2312..fe64f3787 100644 --- a/tests/harness/__snapshots__/maps.ts.txt +++ b/tests/harness/__snapshots__/maps.ts.txt @@ -7,7 +7,7 @@ maps.ts:8:7 - error SC2009: values of type 'Map void>' cannot be c hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') -maps.ts:8:18 - error SC1090: Map values of type '() => void' (Map values must be number, string, boolean, records, class instances, arrays, promises, Maps, Sets, or unions of those — not functions, 'unknown', or 'any') is not supported yet +maps.ts:8:18 - error SC1090: Map values of type '() => void' (Map values must be number, string, boolean, records, class instances, typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values) is not supported yet 7 | // Values exclude functions (no closure story in the uniform value slot yet). 8 | const handlers = new Map void>(); diff --git a/tests/harness/__snapshots__/runtime-optional-refined-union.ts.txt b/tests/harness/__snapshots__/runtime-optional-refined-union.ts.txt index a2e694c5b..c8404d5f0 100644 --- a/tests/harness/__snapshots__/runtime-optional-refined-union.ts.txt +++ b/tests/harness/__snapshots__/runtime-optional-refined-union.ts.txt @@ -3,11 +3,4 @@ runtime-optional-refined-union.ts:11:36 - error SC1090: a direct read from a run 10 | for (const item of items) { 11 | if (hasRect(item)) console.log(item.rect.width); | ^~~~ - 12 | } - -runtime-optional-refined-union.ts:29:36 - error SC1090: a direct read from a runtime-optional capture with multiple value arms are not supported yet - - 28 | for (const animal of animals) { - 29 | if (isDog(animal)) console.log(animal.bark()); - | ^~~~~~ - 30 | } \ No newline at end of file + 12 | } \ No newline at end of file diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index d5408a8ed..07d983a61 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -163,15 +163,6 @@ stdlib-fence.ts:52:1 - error SC2020: '.forEach with 2 arguments' is part of the hint: the thisArg parameter has no lowering — use an arrow function -stdlib-fence.ts:75:10 - error SC2020: 'Object.is over these union operands' is part of the standard library types but has no scriptc lowering yet - - 74 | function pick(a: string | number, b: number | boolean): boolean { - 75 | return Object.is(a, b); // partially-overlapping unions: narrow first - | ^~~~~~~~~~~~~~~ - 76 | } - - hint: union-typed comparisons need one comparable shape (narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) - stdlib-fence.ts:82:23 - error SC2009: values of type 'Date | undefined' cannot be compiled: the union shape is supported, but 'Date' arms have no home in a compiled union yet (no runtime narrowing test exists against sibling arms) 81 | // scalar/ref mismatch. @@ -202,4 +193,20 @@ stdlib-fence.ts:93:23 - error SC1090: .throw() with a Date value (the exception 92 | } 93 | dateGenerator().throw(new Date(0)); | ^~~~~~~~~~~ - 94 | // End of the diagnostic fixture. \ No newline at end of file + 94 | // Related classes may store one object under different union tags. A + +stdlib-fence.ts:104:10 - error SC1090: comparisons of related class values beside structural union members (narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) are not supported yet + + 103 | function relatedStrict(a: FenceValue, b: FenceValue): boolean { + 104 | return a === b; + | ^~~~~~~ + 105 | } + +stdlib-fence.ts:107:10 - error SC2020: 'Object.is over related class values beside structural union members' is part of the standard library types but has no scriptc lowering yet + + 106 | function relatedSameValue(a: FenceValue, b: FenceSub): boolean { + 107 | return Object.is(a, b); + | ^~~~~~~~~~~~~~~ + 108 | } + + hint: narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms \ No newline at end of file diff --git a/tests/harness/bootstrap-seed.test.ts b/tests/harness/bootstrap-seed.test.ts index fca0b5f48..101119c3c 100644 --- a/tests/harness/bootstrap-seed.test.ts +++ b/tests/harness/bootstrap-seed.test.ts @@ -44,7 +44,7 @@ test("CI restores each seed only into its matching instrumented bootstrap lane", string, { needs?: string; - strategy: { matrix: { flavor: string[] } }; + strategy: { matrix: { flavor?: string[]; include?: { flavor: string }[] } }; env: Record; steps: { uses?: string; @@ -69,7 +69,9 @@ test("CI restores each seed only into its matching instrumented bootstrap lane", )) { expect(step.if).toBe("matrix.phase == 'commands'"); } - expect(seed.strategy.matrix.flavor).toEqual(checks.strategy.matrix.flavor); + // Each bootstrap job restores the seed of a flavor the seed job builds. + const checkFlavors = new Set(checks.strategy.matrix.include?.map((job) => job.flavor)); + expect([...checkFlavors].toSorted()).toEqual(seed.strategy.matrix.flavor); expect(seed.env.SCRIPTC_SAN).toBe(checks.env.SCRIPTC_SAN); const prepare = seed.steps.find((step) => step.run === "node scripts/bootstrap-seed.mjs")!; const execute = checks.steps.find((step) => step.run === "node scripts/ci-native-bootstrap.mjs")!; diff --git a/tests/harness/ci-native-bootstrap.test.ts b/tests/harness/ci-native-bootstrap.test.ts index d431d23ee..37b6d403b 100644 --- a/tests/harness/ci-native-bootstrap.test.ts +++ b/tests/harness/ci-native-bootstrap.test.ts @@ -15,13 +15,23 @@ function latch() { return { promise, release }; } +const recordingChecks = (completed: string[]) => + Object.fromEntries( + ["commands", "rebuild", "rebuild-frontend", "rebuild-emit"].map((phase) => [ + phase, + async () => { + completed.push(phase); + }, + ]), + ); + test("CI partitions both bootstrap lanes without dropping or duplicating phase contracts", async () => { const workflow = load( readFileSync(new URL("../../.github/workflows/ci.yml", import.meta.url), "utf8"), ) as { jobs: { bootstrap: { - strategy: { matrix: { flavor: string[]; phase: string[] } }; + strategy: { matrix: { include: { flavor: string; phase: string }[] } }; env: Record; }; test: { needs: string[]; steps: { run: string }[] }; @@ -30,26 +40,22 @@ test("CI partitions both bootstrap lanes without dropping or duplicating phase c const bootstrap = workflow.jobs.bootstrap; expect(bootstrap.env.SCRIPTC_BOOTSTRAP_PHASE).toBe("${{ matrix.phase }}"); expect(bootstrap.env.SCRIPTC_SAN).toBe("${{ matrix.flavor == 'san' && '1' || '' }}"); - expect(bootstrap.strategy.matrix.flavor.toSorted()).toEqual(["plain", "san"]); - for (const flavor of bootstrap.strategy.matrix.flavor) { + const jobs = bootstrap.strategy.matrix.include; + expect([...new Set(jobs.map((job) => job.flavor))].toSorted()).toEqual(["plain", "san"]); + for (const flavor of ["plain", "san"]) { + const sanitize = flavor === "san"; const completed: string[] = []; let packageOwners = 0; - for (const phase of bootstrap.strategy.matrix.phase) { - const plan = nativeBootstrapPlan({ phase, sanitize: flavor === "san" }); + for (const { phase } of jobs.filter((job) => job.flavor === flavor)) { + const plan = nativeBootstrapPlan({ phase, sanitize }); // Each CI job must own one expensive phase on its own runner. expect(plan.phases).toHaveLength(1); if (plan.packageChecks) packageOwners++; - await runNativeBootstrapChecks(plan, { - commands: async () => { - completed.push("commands"); - }, - rebuild: async () => { - completed.push("rebuild"); - }, - }); + await runNativeBootstrapChecks(plan, recordingChecks(completed)); } - expect(completed.toSorted()).toEqual(["commands", "rebuild"]); - expect(packageOwners).toBe(flavor === "plain" ? 1 : 0); + // Together the jobs run exactly the phases of a direct invocation. + expect(completed.toSorted()).toEqual(nativeBootstrapPlan({ sanitize }).phases.toSorted()); + expect(packageOwners).toBe(sanitize ? 0 : 1); } expect(workflow.jobs.test.needs).toContain("bootstrap"); expect( @@ -59,25 +65,49 @@ test("CI partitions both bootstrap lanes without dropping or duplicating phase c ).toBe(true); }); -test("direct bootstrap invocations retain both phases and the plain package checks", async () => { +test("the sanitizer lane splits its self-rebuild at the IR boundary", () => { + expect(nativeBootstrapPlan({ sanitize: true }).phases).toEqual([ + "commands", + "rebuild-frontend", + "rebuild-emit", + ]); + expect(nativeBootstrapPlan({ phase: "rebuild", sanitize: true }).phases).toEqual([ + "rebuild-frontend", + "rebuild-emit", + ]); + expect(nativeBootstrapPlan({ phase: "rebuild" }).phases).toEqual(["rebuild"]); + for (const phase of ["rebuild-frontend", "rebuild-emit"]) { + expect(nativeBootstrapPlan({ phase, sanitize: true }).phases).toEqual([phase]); + expect(() => nativeBootstrapPlan({ phase })).toThrow("is a sanitizer bootstrap phase"); + } +}); + +test("direct bootstrap invocations retain every phase and the plain package checks", async () => { for (const sanitize of [false, true]) { const plan = nativeBootstrapPlan({ sanitize }); - const commands = latch(); - const rebuild = latch(); + // Every phase must start before any finishes: they run concurrently. + const started = plan.phases.map(() => latch()); + const all = Promise.all(started.map((item) => item.promise)); const completed: string[] = []; - await runNativeBootstrapChecks(plan, { - commands: async () => { - commands.release(); - await rebuild.promise; - completed.push("commands"); - }, - rebuild: async () => { - rebuild.release(); - await commands.promise; - completed.push("rebuild"); - }, - }); - expect(completed.toSorted()).toEqual(["commands", "rebuild"]); + await runNativeBootstrapChecks( + plan, + Object.fromEntries( + plan.phases.map((phase, index) => [ + phase, + async () => { + started[index]!.release(); + await all; + completed.push(phase); + }, + ]), + ), + ); + expect(completed.toSorted()).toEqual( + (sanitize + ? ["commands", "rebuild-frontend", "rebuild-emit"] + : ["commands", "rebuild"] + ).toSorted(), + ); expect(plan.packageChecks).toBe(!sanitize); } }); @@ -105,7 +135,7 @@ test("bootstrap phase failures drain their siblings and preserve both errors", a test("invalid bootstrap phase selection fails before any contracts can be skipped", () => { for (const phase of ["", "command", "seed", "commands,rebuild", " all "]) { expect(() => nativeBootstrapPlan({ phase })).toThrow( - "SCRIPTC_BOOTSTRAP_PHASE must be all, commands, or rebuild", + "SCRIPTC_BOOTSTRAP_PHASE must be all, commands, rebuild, rebuild-frontend, or rebuild-emit", ); } }); diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 73cf75db4..7a4c6148a 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -92,8 +92,8 @@ test("runtime-optional refined unions report blockers without crashing", () => { { dynamic }, ); expect(coverage.preflightFailed).toBe(false); - expect(coverage.diagnostics.map((d) => d.code)).toEqual(["SC1090", "SC1090"]); - expect(coverage.stats.statementsFailed).toBe(2); + expect(coverage.diagnostics.map((d) => d.code)).toEqual(["SC1090"]); + expect(coverage.stats.statementsFailed).toBe(1); } }); diff --git a/tests/harness/self-hosting-native-driver.test.ts b/tests/harness/self-hosting-native-driver.test.ts index 2dbff17ad..d2234ce41 100644 --- a/tests/harness/self-hosting-native-driver.test.ts +++ b/tests/harness/self-hosting-native-driver.test.ts @@ -159,8 +159,16 @@ test( ...(sanitizerCompiler === undefined ? {} : { SCRIPTC_CC: sanitizerCompiler }), }, }; - const invoke = async (compiler: string, args: string[], expectedStderr = "") => { - const result = await exec(compiler, args, nativeOptions).catch((error: unknown) => { + const invoke = async ( + compiler: string, + args: string[], + expectedStderr = "", + env: Record = {}, + ) => { + const result = await exec(compiler, args, { + ...nativeOptions, + env: { ...nativeOptions.env, ...env }, + }).catch((error: unknown) => { const failure = error as Error & { code?: string | number; signal?: string; @@ -401,12 +409,127 @@ test( expect(failed.stderr.toString()).toContain("not assignable"); expect(readFileSync(retained, "utf8")).toBe("existing output"); }; + // The production chain owns the complete frontend and emitter proof: + // compare the Node seed's IR and LLVM with the native self-rebuild, then + // execute the next compiler generation. Large structural checks run in a + // roomy child so Vitest's worker remains responsive. + const seedDirectory = join(directory, ".scriptc/distribution-seed"); + const seedIr = join(seedDirectory, "cli.ir.json"); + const compareArtifacts = async ({ ir, llvm }: { ir: boolean; llvm: boolean }) => { + const comparison = await bootstrapStep( + `compare Node and native compiler ${[ir ? "IR" : "", llvm ? "LLVM" : ""].filter(Boolean).join(" and ")}`, + () => + exec( + process.execPath, + [ + "--max-old-space-size=8192", + "--import", + "tsx", + "--input-type=module", + "--eval", + ` + import assert from 'node:assert/strict'; + import { readFileSync } from 'node:fs'; + import { isDeepStrictEqual } from 'node:util'; + import { deserializeModule, validateModule } from ${JSON.stringify(pathToFileURL(join(root, "packages/compiler/src/index.ts")).href)}; + if (${ir}) { + const expected = deserializeModule(readFileSync(process.argv[1], 'utf8')); + const actual = deserializeModule(readFileSync(process.argv[2], 'utf8')); + assert.ok(actual.functions.length > 1000); + assert.deepEqual(validateModule(actual), []); + assert.ok(isDeepStrictEqual(actual, expected), 'native self-lowering must match the Node seed'); + } + if (${llvm}) { + const seedLlvm = readFileSync(process.argv[3], 'utf8'); + let nativeLlvm = readFileSync(process.argv[4], 'utf8'); + if (${sanitize}) { + // The Node sanitizer builds runtime sources directly; the native + // executable path also emits the runtime ABI check. Pin that + // difference before comparing the rest of the complete module. + const declaration = 'declare void @' + ${JSON.stringify(RUNTIME_ABI_MARKER)} + '()\\n'; + const call = ' call void @' + ${JSON.stringify(RUNTIME_ABI_MARKER)} + '()\\n'; + for (const line of [declaration, call]) { + assert.equal(seedLlvm.split(line).length, 1); + assert.equal(nativeLlvm.split(line).length, 2); + nativeLlvm = nativeLlvm.replace(line, ''); + } + } + assert.ok(seedLlvm === nativeLlvm, 'native LLVM emission must match the Node seed'); + } + `, + seedIr, + join(directory, "cli.ir.json"), + join(seedDirectory, "cli.ll"), + join(directory, "cli.ll"), + ], + options, + ), + ); + expect(comparison.stdout).toBe(""); + expect(comparison.stderr).toBe(""); + }; + const probeRebuilt = async () => { + await checkProgram(rebuilt, join(root, "tests/corpus/nullish-long-chain.ts")); + await checkProgram(rebuilt, sample); + await checkProgram(rebuilt, unionSample); + await checkProgram(rebuilt, receiverSample); + await checkProgram(rebuilt, join(root, "tests/corpus/1010-json-stringify-space.ts")); + }; + const settle = async (checks: Promise[]) => { + for (const check of await Promise.allSettled(checks)) + if (check.status === "rejected") throw check.reason; + }; + // --strip keeps debug metadata out of the IR/LLVM equality comparison. const rebuildChecks = async () => { - // The plain lane optimizes the next generation for its fixed-point - // check. The sanitizer lane has already instrumented the seed; it - // rebuilds an instrumented development compiler and executes it below. - // --strip keeps debug metadata out of the IR/LLVM equality comparison. + // The plain lane optimizes the next generation for its fixed-point check. const self = await bootstrapStep("production CLI rebuilds itself", () => + invoke( + seed, + ["build", entry, "-o", rebuilt, "--strip", "--keep-llvm", "--emit-ir", "--ffi", ffi], + "scriptc: warning: --emit-ir is deprecated; use --emit=ir for IR as the primary output\n", + ), + ); + expect(self.trim()).toBe(rebuilt); + const fixedPoint = async () => { + // The plain lane owns the third-generation fixed point. The sanitizer + // lane runs the complete frontend and emitter under ASan in its two + // rebuild halves, compares both artifacts, and executes the new + // compiler. + const seedLlvm = join(directory, "cli.ll"); + const rebuiltLlvm = join(directory, "rebuilt.ll"); + await bootstrapStep("rebuilt compiler emits itself", () => + invoke(rebuilt, ["build", entry, "--emit=llvm", "-o", rebuiltLlvm, "--ffi", ffi]), + ); + // Executable output adds a runtime ABI check; textual LLVM emission + // omits it. Compare all other output without changing either mode. + const withoutAbiCheck = (path: string): string => + readFileSync(path, "utf8") + .replace(/^declare void @scr_runtime_abi_v\d+\(\)\n/m, "") + .replace(/^ call void @scr_runtime_abi_v\d+\(\)\n/m, ""); + expect( + withoutAbiCheck(seedLlvm) === withoutAbiCheck(rebuiltLlvm), + "native compiler generations must emit identical LLVM", + ).toBe(true); + }; + await settle([compareArtifacts({ ir: true, llvm: true }), probeRebuilt(), fixedPoint()]); + }; + // The sanitizer lane splits the instrumented self-rebuild at the IR + // boundary. Its frontend half lowers the compiler inside the instrumented + // seed and compares that IR with the seed's; lowering does not depend on + // the output's sanitizer or optimization options. Its emitter half runs + // the instrumented LLVM emitter on the seed's IR (equal to the native IR + // by the frontend half's comparison), compares the LLVM, then links and + // executes an instrumented development compiler. + const rebuildFrontend = async () => { + const lowered = join(directory, "cli.ir.json"); + const output = await bootstrapStep("sanitized native frontend lowers the compiler", () => + invoke(seed, ["build", entry, "--emit=ir", "-o", lowered, "--ffi", ffi]), + ); + expect(output.trim()).toBe(lowered); + await compareArtifacts({ ir: true, llvm: false }); + }; + const rebuildEmit = async () => { + const self = await bootstrapStep("sanitized native emitter rebuilds the compiler", () => invoke( seed, [ @@ -416,104 +539,23 @@ test( rebuilt, "--strip", "--keep-llvm", - "--emit-ir", "--ffi", ffi, - ...(sanitize ? ["--sanitize", "--optimization=dev"] : []), + "--sanitize", + "--optimization=dev", ], - "scriptc: warning: --emit-ir is deprecated; use --emit=ir for IR as the primary output\n", + "", + { SCRIPTC_BOOTSTRAP_IR_INPUT: seedIr }, ), ); expect(self.trim()).toBe(rebuilt); - // The production chain owns the complete frontend and emitter proof: - // compare the Node seed's IR and LLVM with the native self-rebuild, then - // execute the next compiler generation below. Large structural checks - // run in a roomy child so Vitest's worker remains responsive. - const seedDirectory = join(directory, ".scriptc/distribution-seed"); - const compareArtifacts = async () => { - const comparison = await bootstrapStep( - "compare Node and native compiler IR and LLVM", - () => - exec( - process.execPath, - [ - "--max-old-space-size=8192", - "--import", - "tsx", - "--input-type=module", - "--eval", - ` - import assert from 'node:assert/strict'; - import { readFileSync } from 'node:fs'; - import { isDeepStrictEqual } from 'node:util'; - import { deserializeModule, validateModule } from ${JSON.stringify(pathToFileURL(join(root, "packages/compiler/src/index.ts")).href)}; - const expected = deserializeModule(readFileSync(process.argv[1], 'utf8')); - const actual = deserializeModule(readFileSync(process.argv[2], 'utf8')); - assert.ok(actual.functions.length > 1000); - assert.deepEqual(validateModule(actual), []); - assert.ok(isDeepStrictEqual(actual, expected), 'native self-lowering must match the Node seed'); - const seedLlvm = readFileSync(process.argv[3], 'utf8'); - let nativeLlvm = readFileSync(process.argv[4], 'utf8'); - if (${sanitize}) { - // The Node sanitizer builds runtime sources directly; the native - // executable path also emits the runtime ABI check. Pin that - // difference before comparing the rest of the complete module. - const declaration = 'declare void @' + ${JSON.stringify(RUNTIME_ABI_MARKER)} + '()\\n'; - const call = ' call void @' + ${JSON.stringify(RUNTIME_ABI_MARKER)} + '()\\n'; - for (const line of [declaration, call]) { - assert.equal(seedLlvm.split(line).length, 1); - assert.equal(nativeLlvm.split(line).length, 2); - nativeLlvm = nativeLlvm.replace(line, ''); - } - } - assert.ok(seedLlvm === nativeLlvm, 'native LLVM emission must match the Node seed'); - `, - join(seedDirectory, "cli.ir.json"), - join(directory, "cli.ir.json"), - join(seedDirectory, "cli.ll"), - join(directory, "cli.ll"), - ], - options, - ), - ); - expect(comparison.stdout).toBe(""); - expect(comparison.stderr).toBe(""); - }; - const probeRebuilt = async () => { - await checkProgram(rebuilt, join(root, "tests/corpus/nullish-long-chain.ts")); - await checkProgram(rebuilt, sample); - await checkProgram(rebuilt, unionSample); - await checkProgram(rebuilt, receiverSample); - await checkProgram(rebuilt, join(root, "tests/corpus/1010-json-stringify-space.ts")); - }; - const fixedPoint = async () => { - // The plain lane owns the third-generation fixed point. The sanitizer - // lane already runs the full frontend and emitter under ASan in the - // self-rebuild, compares both artifacts, and executes the new compiler. - if (!sanitize) { - const seedLlvm = join(directory, "cli.ll"); - const rebuiltLlvm = join(directory, "rebuilt.ll"); - await bootstrapStep("rebuilt compiler emits itself", () => - invoke(rebuilt, ["build", entry, "--emit=llvm", "-o", rebuiltLlvm, "--ffi", ffi]), - ); - // Executable output adds a runtime ABI check; textual LLVM emission - // omits it. Compare all other output without changing either mode. - const withoutAbiCheck = (path: string): string => - readFileSync(path, "utf8") - .replace(/^declare void @scr_runtime_abi_v\d+\(\)\n/m, "") - .replace(/^ call void @scr_runtime_abi_v\d+\(\)\n/m, ""); - expect( - withoutAbiCheck(seedLlvm) === withoutAbiCheck(rebuiltLlvm), - "native compiler generations must emit identical LLVM", - ).toBe(true); - } - }; - const checks = await Promise.allSettled([compareArtifacts(), probeRebuilt(), fixedPoint()]); - for (const check of checks) if (check.status === "rejected") throw check.reason; + await settle([compareArtifacts({ ir: false, llvm: true }), probeRebuilt()]); }; await runNativeBootstrapChecks(plan, { commands: () => bootstrapStep("seed command and library probes", seedChecks), rebuild: rebuildChecks, + "rebuild-frontend": rebuildFrontend, + "rebuild-emit": rebuildEmit, }); } finally { rmSync(directory, { recursive: true, force: true }); From 8a9497cfa22994f77320149cba0ca0164c0e7922 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 09:47:45 -0500 Subject: [PATCH 06/14] Track record field presence separately from undefined values (#736) --- packages/compiler/src/backend/llvm/common.ts | 26 +++ packages/compiler/src/backend/llvm/dyn.ts | 84 ++++++- packages/compiler/src/backend/llvm/emitter.ts | 77 ++++++- .../src/backend/llvm/expr-dispatch.ts | 2 + .../compiler/src/backend/llvm/expr-dynamic.ts | 86 ++++++- .../src/backend/llvm/expr-primitives.ts | 19 +- .../compiler/src/backend/llvm/expr-records.ts | 2 +- .../src/backend/llvm/expr-stream-bridges.ts | 18 +- .../src/backend/llvm/lib-filesystem.ts | 7 +- .../frontend/lowering/coercions/structural.ts | 16 +- .../lowering/containers/indexed-objects.ts | 154 ++++++------- .../lowering/expressions/evaluation-safety.ts | 4 + .../lowering/expressions/object-literals.ts | 171 +++++++++++--- .../src/frontend/lowering/lower-assert.ts | 9 + .../src/frontend/lowering/lower-calls.ts | 211 ++++++++---------- .../src/frontend/lowering/lower-exprs.ts | 58 +---- .../src/frontend/lowering/lower-inspect.ts | 37 ++- .../src/frontend/lowering/lower-modules.ts | 2 + .../src/frontend/lowering/lower-stmts.ts | 53 +++-- .../compiler/src/frontend/lowering/lowerer.ts | 43 ++++ packages/compiler/src/ir/ir.ts | 22 +- packages/compiler/src/ir/traverse.ts | 8 + packages/compiler/src/ir/validate.test.ts | 85 +++++++ packages/compiler/src/ir/validate.ts | 50 ++++- .../test/ts7/baselines/order-parity.json | 30 +++ tests/corpus/1531-delete-optional-fields.ts | 8 +- tests/corpus/2364-in-runtime-key.ts | 4 +- .../2448-entries-null-arms-regex-arrays.ts | 4 +- tests/corpus/535-object-statics.ts | 4 +- tests/corpus/optional-flag-comparisons.ts | 60 +++++ tests/corpus/record-field-presence.ts | 73 ++++++ tests/corpus/record-presence-copies.ts | 73 ++++++ tests/corpus/record-presence-delete-json.ts | 50 +++++ .../corpus/record-unchecked-read-presence.ts | 15 ++ 34 files changed, 1231 insertions(+), 334 deletions(-) create mode 100644 tests/corpus/optional-flag-comparisons.ts create mode 100644 tests/corpus/record-field-presence.ts create mode 100644 tests/corpus/record-presence-copies.ts create mode 100644 tests/corpus/record-presence-delete-json.ts create mode 100644 tests/corpus/record-unchecked-read-presence.ts diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts index 65bbc1c32..f57576d6f 100644 --- a/packages/compiler/src/backend/llvm/common.ts +++ b/packages/compiler/src/backend/llvm/common.ts @@ -1,5 +1,6 @@ import { commentText, unsignedHex } from "../literals.js"; import { InternalCompilerError } from "../../errors.js"; +import type { BlockBuilder } from "./blocks.js"; import type { IrBytesElem, IrFfiCallbackParamClass, @@ -95,3 +96,28 @@ export const BYTES_ELEM_NUM: Record = { i16: 7, u8c: 8, }; + +/** The payload marker of an ABSENT record field slot (IR fieldAbsent): the + * union's undefined-arm tag with payload 1, where ordinary unit instances + * carry 0. Tests compare tag and payload, never the address, so separately + * compiled units agree. */ +export const ABSENT_FIELD_PAYLOAD = 1; + +/** Emits the i1 "this field-slot union value is ABSENT" test. */ +export function emitFieldAbsentTest(B: BlockBuilder, value: string, undefinedTag: number): string { + const tp = B.tmp(); + const tag = B.tmp(); + const isUndef = B.tmp(); + const sp = B.tmp(); + const payload = B.tmp(); + const marked = B.tmp(); + const absent = B.tmp(); + B.line(`${tp} = getelementptr inbounds %ScrUnion, ptr ${value}, i64 0, i32 1`); + B.line(`${tag} = load i32, ptr ${tp}`); + B.line(`${isUndef} = icmp eq i32 ${tag}, ${undefinedTag}`); + B.line(`${sp} = getelementptr inbounds %ScrUnion, ptr ${value}, i64 0, i32 5`); + B.line(`${payload} = load i64, ptr ${sp}`); + B.line(`${marked} = icmp eq i64 ${payload}, ${ABSENT_FIELD_PAYLOAD}`); + B.line(`${absent} = and i1 ${isUndef}, ${marked}`); + return absent; +} diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index cd0d7939f..d52d0bf5c 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1,5 +1,5 @@ import { typedRefConstructor } from "./shapes.js"; -import { BYTES_ELEM_NUM, f64Lit } from "./common.js"; +import { BYTES_ELEM_NUM, emitFieldAbsentTest, f64Lit } from "./common.js"; import { InternalCompilerError } from "../../errors.js"; /* ScrDyn helpers for the LLVM backend: per-type match predicates * (dynMatchHelper), checked builders (dynCheckHelper), static→dyn @@ -78,6 +78,8 @@ export const DYN_KIND = { * unit instances (undefined-armed dynCheck targets build them). */ export interface DynHost extends WalkerHost { unitInstanceRef(unionId: string, tag: number): string; + /** The immortal ABSENT field-slot state of an undefined-armed union. */ + absentInstanceRef(unionId: string): string; liveDynRefAdapter(t: IrType): { snapshot: string; commit: string }; liveDynUnionRefAdapter(t: IrType & { kind: "union" }): string; dynPromiseAdapter(t: IrType): string; @@ -355,6 +357,76 @@ export class LlDyn { return t; } + /** Branches past the caller's next emission when an undefined-armed + * field value is the ABSENT state. Returns the label the caller must branch + * to and start after its emission, or null when the type has no + * undefined arm. */ + private skipIfAbsent( + B: BlockBuilder, + value: string, + t: IrType & { kind: "union" }, + ): string | null { + const def = this.host.unionsById.get(t.unionId); + const utag = def ? def.arms.findIndex((arm) => arm.kind === "undefinedT") : -1; + if (utag < 0) return null; + const absent = emitFieldAbsentTest(B, value, utag); + const lSkip = B.newLabel("tdr.absent"); + const lSet = B.newLabel("tdr.set"); + B.condBr(absent, lSkip, lSet); + B.startBlock(lSet); + return lSkip; + } + + /** A checked undefined-armed field value, or the field's ABSENT state + * when the value is undefined because the source object has no own + * property of that name (a missing JSON key stays missing; an explicit + * undefined stays present). Proxy sources answer through their reads, so + * they count as present. The checked value is a +1 union; replacing it + * with the immortal absent instance releases nothing (unit instances are + * immortal too). */ + private presenceOf( + B: BlockBuilder, + d: string, + key: string, + t: IrType & { kind: "union" }, + value: string, + ): string { + const def = this.host.unionsById.get(t.unionId); + const utag = def ? def.arms.findIndex((arm) => arm.kind === "undefinedT") : -1; + if (utag < 0) return value; + const tp = B.tmp(); + const tag = B.tmp(); + const isUndef = B.tmp(); + B.line(`${tp} = getelementptr inbounds %ScrUnion, ptr ${value}, i64 0, i32 1`); + B.line(`${tag} = load i32, ptr ${tp}`); + B.line(`${isUndef} = icmp eq i32 ${tag}, ${utag}`); + const kind = this.kindOf(B, d); + const isObj = B.tmp(); + B.line(`${isObj} = icmp eq i32 ${kind}, ${DYN_KIND.OBJ}`); + const probe = B.tmp(); + B.line(`${probe} = and i1 ${isUndef}, ${isObj}`); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ptr`); + B.line(`store ptr ${value}, ptr ${slot}`); + const lProbe = B.newLabel("dcr.has"); + const lJoin = B.newLabel("dcr.hasj"); + B.condBr(probe, lProbe, lJoin); + B.startBlock(lProbe); + const own = this.objGetLit(B, d, key); + const missing = B.tmp(); + const chosen = B.tmp(); + B.line(`${missing} = icmp eq ptr ${own}, null`); + B.line( + `${chosen} = select i1 ${missing}, ptr ${this.host.absentInstanceRef(t.unionId)}, ptr ${value}`, + ); + B.line(`store ptr ${chosen}, ptr ${slot}`); + B.br(lJoin); + B.startBlock(lJoin); + const out = B.tmp(); + B.line(`${out} = load ptr, ptr ${slot}`); + return out; + } + /** Ordinary property lookup, including inherited data and accessors (+1). */ private objReadLit(B: BlockBuilder, d: string, key: string): string { this.host.declare(`declare ptr @scr_dyn_obj_read(ptr, ptr, ${this.S})`); @@ -1343,6 +1415,10 @@ export class LlDyn { B.line(`call void @scr_dyn_release(ptr ${m})`); storeInto(f.name, f.type, value); this.pendingBail(B, "dcr", releaseR, "ptr null"); + if (f.type.kind === "union") { + const stored = this.presenceOf(B, "%d", f.name, f.type, value); + if (stored !== value) storeInto(f.name, f.type, stored); + } } // Index-signature shapes CAPTURE undeclared keys into the // overflow map. @@ -1952,11 +2028,17 @@ export class LlDyn { for (const f of dynFields) { const klen = Buffer.byteLength(f.name, "utf8"); const fv = loadFieldOf(f.name, f.type); + // An ABSENT optional field contributes no key. + const lSkip = f.type.kind === "union" ? this.skipIfAbsent(B, fv, f.type) : null; const conv = B.tmp(); B.line(`${conv} = call ptr @${this.toDynHelper(f.type)}(${this.valTy(f.type)} ${fv})`); B.line( `call void @scr_dyn_obj_set(ptr ${d}, ptr ${host.cstr(f.name)}, ${host.sizeType} ${klen}, ptr ${conv}) ; ${llvmCommentText(f.name)}`, ); + if (lSkip !== null) { + B.br(lSkip); + B.startBlock(lSkip); + } } if (shape.indexValue) { const iv = shape.indexValue; diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 329467856..56b0d491a 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -19,7 +19,12 @@ import { InternalCompilerError } from "../../errors.js"; * compressed source tables that the runtime inflates on demand. */ import { deflateRawSync } from "node:zlib"; -import { endsWithJump, matchStringSelfConcat, streamTypedRefEligible } from "../../ir/analysis.js"; +import { + endsWithJump, + matchStringSelfConcat, + streamTypedRefEligible, + undefinedArmTag, +} from "../../ir/analysis.js"; import { emitLibraryIdentityLines } from "../library-identity-markers.js"; import type { IrBytesElem, @@ -156,6 +161,8 @@ import { } from "./string-slices.js"; import { StackCaptures } from "./stack-captures.js"; import { + ABSENT_FIELD_PAYLOAD, + emitFieldAbsentTest, f64Lit, ffiNativeTypeLl, ffiNativeParamLl, @@ -442,6 +449,8 @@ export class LlEmitter { * immortal (rc == SIZE_MAX) static per (union, unit tag). * RC entry points and the collector skip immortals. */ private readonly unitInstances = new Map(); + /** unionId → symbol of the union's immortal ABSENT field-slot instance. */ + private readonly absentInstances = new Map(); private readonly immortalValues = new Set(); /** Regex literal templates: "/" → { symbol, interned * source/flags literal refs } — one immortal ScrRegex template per distinct @@ -713,6 +722,7 @@ export class LlEmitter { internLiteral: (text) => this.internLiteral(text), cstr: (text) => this.cstr(text), unitInstanceRef: (unionId, tag) => this.unitInstanceRef(unionId, tag), + absentInstanceRef: (unionId) => this.absentInstanceRef(unionId), liveDynRefAdapter: (type) => this.liveDynRefAdapter(type), liveDynUnionRefAdapter: (type) => this.liveDynUnionRefAdapter(type), dynPromiseAdapter: (type) => this.dynPromiseAdapter(type), @@ -1656,6 +1666,18 @@ export class LlEmitter { ); } if (this.unitInstances.size > 0) out.push(``); + for (const [unionId, sym] of this.absentInstances) { + // One immortal ABSENT field-slot instance per undefined-armed union: + // the undefined arm's tag with payload 1 (ordinary unit instances + // carry 0). Every tag reader sees undefined; recordHas and the + // field reads compare tag and payload, never the address, so the + // per-object copies of separately compiled units agree. + const tag = undefinedArmTag({ kind: "union", unionId }, this.unionsById); + out.push( + `@${sym} = internal global %ScrUnion { ${this.sizeType} -1, i32 ${tag}, ptr null, ptr null, ptr null, i64 ${ABSENT_FIELD_PAYLOAD} } ; ${unionId} absent field`, + ); + } + if (this.absentInstances.size > 0) out.push(``); for (const [key, re] of this.regexInstances) { // One immortal ScrRegex per (pattern, flags) literal, pointing at // the interned source/flags strings. The bc slot starts null (lazy @@ -2858,6 +2880,48 @@ export class LlEmitter { return name; } + /** The immortal ABSENT state of an undefined-armed record field slot + * (IR fieldAbsent). */ + absentInstanceRef(unionId: string): string { + if (undefinedArmTag({ kind: "union", unionId }, this.unionsById) < 0) { + throw new InternalCompilerError( + `llvm emitter bug: absent state of ${unionId} without undefined`, + ); + } + let sym = this.absentInstances.get(unionId); + if (!sym) { + sym = `sc_absent_${this.absentInstances.size}`; + this.absentInstances.set(unionId, sym); + } + const name = `@${sym}`; + this.immortalValues.add(name); + return name; + } + + /** i1: a union value loaded from a field slot is the ABSENT state — the + * undefined arm's tag with the absent payload marker. */ + fieldAbsentTest(value: string, unionId: string): string { + return emitFieldAbsentTest( + this.B, + value, + undefinedArmTag({ kind: "union", unionId }, this.unionsById), + ); + } + + /** Loads a record field slot as a VALUE: an undefined-armed union slot + * holding the ABSENT state surfaces as the ordinary undefined arm, so + * absence never travels beyond its slot (a later `{ f: r.f }` is an + * explicit, present property). */ + loadRecordField(ptr: string, t: IrType): string { + const v = this.loadField(ptr, t); + if (t.kind !== "union" || undefinedArmTag(t, this.unionsById) < 0) return v; + const absent = this.fieldAbsentTest(v, t.unionId); + const unit = this.unitInstanceRef(t.unionId, undefinedArmTag(t, this.unionsById)); + const out = this.B.tmp(); + this.B.line(`${out} = select i1 ${absent}, ptr ${unit}, ptr ${v}`); + return out; + } + declare(decl: string): void { this.decls.add(decl); } @@ -5628,6 +5692,8 @@ export class LlEmitter { | "recordKeyGet" | "recordOvfKeys" | "recordOvfHas" + | "recordHas" + | "fieldAbsent" >, ): LlValue { return emitRecordExpr(this, e); @@ -5726,10 +5792,11 @@ export class LlEmitter { } if (e.kind === "recordGet" || e.kind === "fieldGet") { const receiver = this.emitReadReceiver(e.obj); - const { ptr, type } = - e.kind === "recordGet" - ? this.recordFieldPtr(receiver.name, e.shapeId, e.field) - : this.classFieldPtr(receiver.name, e.className, e.field); + if (e.kind === "recordGet") { + const { ptr, type } = this.recordFieldPtr(receiver.name, e.shapeId, e.field); + return { name: this.loadRecordField(ptr, type), type: e.type }; + } + const { ptr, type } = this.classFieldPtr(receiver.name, e.className, e.field); return { name: this.loadField(ptr, type), type: e.type }; } if (e.kind === "ternary" && this.canBorrowReceiver(e.then) && this.canBorrowReceiver(e.else_)) { diff --git a/packages/compiler/src/backend/llvm/expr-dispatch.ts b/packages/compiler/src/backend/llvm/expr-dispatch.ts index 468de6cdd..f0e13dbd0 100644 --- a/packages/compiler/src/backend/llvm/expr-dispatch.ts +++ b/packages/compiler/src/backend/llvm/expr-dispatch.ts @@ -113,6 +113,8 @@ export function emitExpr(host: LlvmEmitterContext, e: IrExpr): LlValue { case "recordKeyGet": case "recordOvfKeys": case "recordOvfHas": + case "recordHas": + case "fieldAbsent": return host.emitRecordExpr(e); case "dynFrom": case "dynFromJsval": diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 7c32272a0..37ad1776d 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -7,6 +7,7 @@ import { InternalCompilerError } from "../../errors.js"; import { streamTypedRefEligible } from "../../ir/analysis.js"; import { DYN, + type IrExpr, isDynTypedRefType, isRefCounted, isUnitType, @@ -337,7 +338,8 @@ export function emitDynamicExpr( arm.kind === "object" ? host.classFieldPtr(payload, arm.className, e.field) : host.recordFieldPtr(payload, arm.shapeId, e.field); - const v = host.loadField(ptr, type); + const v = + arm.kind === "object" ? host.loadField(ptr, type) : host.loadRecordField(ptr, type); const value = isRefCounted(e.type) ? host.retainValue(v, e.type) : v; B.line(`store ${ty} ${value}, ptr ${slot}`); B.br(join); @@ -437,7 +439,7 @@ export function emitDynamicExpr( literal !== null ? shape.fields.find((f) => f.name === literal) : undefined; if (declared) { const { ptr, type: ft } = host.recordFieldPtr(payload, arm.shapeId, declared.name); - const v = host.loadField(ptr, ft); + const v = host.loadRecordField(ptr, ft); if (typeEquals(ft, e.type)) { B.line(`store ${ty} ${isRefCounted(ft) ? host.retainValue(v, ft) : v}, ptr ${slot}`); B.br(join); @@ -729,6 +731,8 @@ export function emitDynamicExpr( return { name: neg, type: e.type }; } case "unionEq": { + const direct = emitUnionEqWrappedScalar(host, e); + if (direct) return direct; // Strict equality of the ARM values (tag compare + per-arm payload // compare — the C per-union helper, inlined). Both boxes borrowed. const inputs = emitBorrowedInputs(host, [e.left, e.right]); @@ -1025,3 +1029,81 @@ export function emitDynamicExpr( } } } + +/** `u === v` where one side wraps a unit, bool, or number arm value + * (`o.flag === true`, `o.n === undefined`): compare the other union's tag + * and payload directly instead of boxing the plain side. Operands keep + * their left-to-right evaluation; the union stays borrowed. Null when + * neither side is such a wrap. */ +function emitUnionEqWrappedScalar( + host: LlvmEmitterContext, + e: IrExpr & { kind: "unionEq" }, +): LlValue | null { + const B = host.B; + const def = host.unionsById.get(e.unionId); + if (!def) return null; + const scalarWrap = (x: IrExpr): (IrExpr & { kind: "unionWrap" }) | null => { + if (x.kind !== "unionWrap" || x.unionId !== e.unionId) return null; + const arm = def.arms[x.tag]; + if (!arm) return null; + if (isUnitType(arm)) return x; + // The plain value must not run code that could release the borrowed + // union: literals and local reads only. + const v = x.value; + const pure = v.kind === "boolLit" || v.kind === "numLit" || v.kind === "varRef"; + return pure && (arm.kind === "f64" || arm.kind === "bool") ? x : null; + }; + const rightWrap = scalarWrap(e.right); + const leftWrap = rightWrap ? null : scalarWrap(e.left); + const wrap = rightWrap ?? leftWrap; + if (!wrap) return null; + const arm = def.arms[wrap.tag]!; + let union: LlValue; + let plain: LlValue | null = null; + if (rightWrap) { + union = emitBorrowedInput(host, e.left); + if (!isUnitType(arm)) plain = host.emitExpr(wrap.value); + } else { + if (!isUnitType(arm)) plain = host.emitExpr(wrap.value); + union = emitBorrowedInput(host, e.right); + } + const tag = host.unionTag(union.name); + const tagMatch = B.tmp(); + B.line(`${tagMatch} = icmp eq i32 ${tag}, ${wrap.tag}`); + let result = tagMatch; + if (plain) { + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca i1`); + B.line(`store i1 false, ptr ${slot}`); + const same = B.newLabel("ueq.s"); + const join = B.newLabel("ueq.j"); + B.condBr(tagMatch, same, join); + B.startBlock(same); + const eq = B.tmp(); + if (arm.kind === "f64") { + host.declare(`declare double @scr_union_get_f64(ptr)`); + const v = B.tmp(); + B.line(`${v} = call double @scr_union_get_f64(ptr ${union.name})`); + if (e.sameValue) { + host.declare(`declare zeroext i1 @scr_num_same_value(double, double)`); + B.line(`${eq} = call zeroext i1 @scr_num_same_value(double ${v}, double ${plain.name})`); + } else { + B.line(`${eq} = fcmp oeq double ${v}, ${plain.name}`); + } + } else { + host.declare(`declare zeroext i1 @scr_union_get_bool(ptr)`); + const v = B.tmp(); + B.line(`${v} = call zeroext i1 @scr_union_get_bool(ptr ${union.name})`); + B.line(`${eq} = icmp eq i1 ${v}, ${plain.name}`); + } + B.line(`store i1 ${eq}, ptr ${slot}`); + B.br(join); + B.startBlock(join); + result = B.tmp(); + B.line(`${result} = load i1, ptr ${slot}`); + } + if (!e.negated) return { name: result, type: e.type }; + const negated = B.tmp(); + B.line(`${negated} = xor i1 ${result}, true`); + return { name: negated, type: e.type }; +} diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 2e8af2354..2db2d5bff 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -779,6 +779,8 @@ export function emitRecordExpr( | "recordKeyGet" | "recordOvfKeys" | "recordOvfHas" + | "recordHas" + | "fieldAbsent" >, ): LlValue { const B = host.B; @@ -794,7 +796,7 @@ export function emitRecordExpr( case "recordGet": { const obj = host.emitReadReceiver(e.obj); const { ptr, type } = host.recordFieldPtr(obj.name, e.shapeId, e.field); - const v = host.loadField(ptr, type); + const v = host.loadRecordField(ptr, type); if (isRefCounted(e.type)) return host.own({ name: host.retainValue(v, e.type), type: e.type }); return { name: v, type: e.type }; @@ -863,6 +865,21 @@ export function emitRecordExpr( } case "recordKeyGet": return host.emitRecordKeyGet(e); + case "recordHas": { + // Own-property presence: only an undefined-armed union slot can + // lack its property, by holding the ABSENT state. + const obj = host.emitReadReceiver(e.obj); + const { ptr, type } = host.recordFieldPtr(obj.name, e.shapeId, e.field); + if (type.kind !== "union" || undefinedArmTag(type, host.unionsById) < 0) + return { name: "true", type: e.type }; + const v = host.loadField(ptr, type); + const absent = host.fieldAbsentTest(v, type.unionId); + const t = B.tmp(); + B.line(`${t} = xor i1 ${absent}, true`); + return { name: t, type: e.type }; + } + case "fieldAbsent": + return { name: host.absentInstanceRef(e.unionId), type: e.type }; case "recordOvfHas": { const obj = host.emitExpr(e.obj); const key = host.emitExpr(e.key); diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index 68b874d1f..32a7e3b61 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -348,7 +348,7 @@ export function keyedRecordReadInto( B.condBr(hit, lh, ln); B.startBlock(lh); const { ptr, type } = host.recordFieldPtr(objName, shapeId, f.name); - B.line(`store ${ty} ${surface(type, host.loadField(ptr, type), false)}, ptr ${slot}`); + B.line(`store ${ty} ${surface(type, host.loadRecordField(ptr, type), false)}, ptr ${slot}`); B.br(join); B.startBlock(ln); } diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 8b2f0912d..848c806f9 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -1,7 +1,7 @@ import { typedRefConstructor } from "./shapes.js"; /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; -import { streamTypedRefEligible } from "../../ir/analysis.js"; +import { streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js"; import { type IrType, DYN_CLASS_PROPERTIES, @@ -16,7 +16,7 @@ import { import { mangleFunction, mangleGlobal, mangleRecordStruct } from "../mangle.js"; import { BlockBuilder } from "./blocks.js"; import { classFieldIndex, classStructSym } from "./classes.js"; -import { llvmCommentText } from "./common.js"; +import { emitFieldAbsentTest, llvmCommentText } from "./common.js"; import { FN_ATTRS, llFieldType, releaseSym, traceArg, vAdapters } from "./shapes.js"; import type { LlvmEmitterContext, LlStreamTypedRefAdapter } from "./expr-context.js"; @@ -855,10 +855,24 @@ export function streamTypedRefMaterializeAdapter( B.line(`${boolValue} = trunc i8 ${fieldValue} to i1`); fieldValue = boolValue; } + // An ABSENT optional field contributes no key. + const undefinedTag = undefinedArmTag(field.type, host.unionsById); + let skip: string | null = null; + if (undefinedTag >= 0) { + const absent = emitFieldAbsentTest(B, fieldValue, undefinedTag); + skip = B.newLabel("live.record.absent"); + const set = B.newLabel("live.record.set"); + B.condBr(absent, skip, set); + B.startBlock(set); + } const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue); B.line( `call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`, ); + if (skip !== null) { + B.br(skip); + B.startBlock(skip); + } } B.terminate(`ret ptr ${out}`); } diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 059635ace..47b9b1cb4 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -601,12 +601,11 @@ export function emitPathUrlLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll const st = shape.fields.find((f) => f.name === "scopeid")?.type; if (st?.kind !== "union") throw new InternalCompilerError("llvm emitter bug: networkInterfaces IPv4 scopeid type"); - const undefTag = undefinedArmTag(st, host.unionsById); - const su = B.tmp(); + // IPv4 entries have no scopeid property at all (immortal: no + // retain owed). B.line( - `${su} = call ptr @scr_union_retain_v(ptr ${host.unitInstanceRef(st.unionId, undefTag)})`, + `store ptr ${host.absentInstanceRef(st.unionId)}, ptr ${host.recordFieldPtr(r, t.shapeId, "scopeid").ptr}`, ); - B.line(`store ptr ${su}, ptr ${host.recordFieldPtr(r, t.shapeId, "scopeid").ptr}`); } const rc = vAdapters(host.shapeHost, t); const rowU = B.tmp(); diff --git a/packages/compiler/src/frontend/lowering/coercions/structural.ts b/packages/compiler/src/frontend/lowering/coercions/structural.ts index 6276bda19..c7c8aa008 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural.ts @@ -4,7 +4,7 @@ import { buildArrayConversion } from "./builders.js"; import type { WidthLift } from "../width-lift.js"; import { InternalCompilerError } from "../../../errors.js"; import type { IrExpr, IrFunction, IrRecordShape, IrStmt, IrType, SrcLoc } from "../../../ir/ir.js"; -import { BOOL, DYN, F64, STRING, UNDEFINED_T } from "../../../ir/ir.js"; +import { BOOL, DYN, F64, STRING } from "../../../ir/ir.js"; import { typeKey } from "../../type-mapper.js"; import { findStaticOn, findGenericStaticOn } from "../lower-classes.js"; import { lowerRecordOvfCaptureHelper } from "../containers/indexed-objects.js"; @@ -566,7 +566,7 @@ export function classStaticsProjection( const found = findStaticOn(lowerer, info, tf.name); if (!found) { if (tf.type.kind !== "union") return null; - const u = lowerer.wrappedUndefined(tf.type, loc); + const u = lowerer.absentFieldValue(tf.type, loc); if (!u) return null; fields.push({ name: tf.name, value: u }); continue; @@ -632,11 +632,11 @@ function buildRecordProjection( throw new InternalCompilerError( "lowerer bug: absent lift against a non-union field", ); + // The source has no such property: the projected field is + // absent too. const value: IrExpr = { - kind: "unionWrap", + kind: "fieldAbsent", unionId: field.type.unionId, - tag: conversion.utag, - value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, type: field.type, loc, }; @@ -660,9 +660,13 @@ function buildRecordProjection( type: conversion.src, loc, }; + const lifted = lowerer.applyWidthLift(conversion.lift, read, field.type, loc); return { name: field.name, - value: lowerer.applyWidthLift(conversion.lift, read, field.type, loc), + value: + source.kind === "record" + ? lowerer.presenceKeepingCopy(receiver, source.shapeId, field.name, lifted, loc) + : lifted, }; }), type: result, diff --git a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts index d29da859c..fc2d50614 100644 --- a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts +++ b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts @@ -185,9 +185,9 @@ export function objectIterOverIndexShape( ]; const fieldStmts = new Map(); - // Declared fields, in declaration order. Undefined-valued fields - // skip at runtime (the unset-optional convention); values surface - // into the element type or the site fences with the field named. + // Declared fields, in declaration order. Absent optional fields skip + // at runtime; values surface into the element type or the site + // fences with the field named. const order = shape.declaredOrder ?? shape.fields.map((f) => f.name); for (const name of order) { const f = shape.fields.find((x) => x.name === name)!; @@ -200,6 +200,8 @@ export function objectIterOverIndexShape( loc, }; const utag = f.type.kind === "union" ? lowerer.armTag(f.type.unionId, UNDEFINED_T) : -1; + // Set when the surfaced value narrows away the undefined arm. + let narrowedPush = false; // The pushed value per member; null when the field cannot surface. const surfaced = (): IrExpr | null => { if (!valueT) return null; @@ -246,6 +248,7 @@ export function objectIterOverIndexShape( type: other, loc, }; + narrowedPush = true; return { kind: "unionWrap", unionId: valueT.unionId, @@ -260,6 +263,18 @@ export function objectIterOverIndexShape( } return null; }; + const pushedOf = (element: IrExpr): IrExpr => + member === "values" + ? element + : { + kind: "recordLit", + fields: [ + { name: "0", value: { kind: "strLit", value: f.name, type: STRING, loc } }, + { name: "1", value: element }, + ], + type: tupleT!, + loc, + }; let pushed: IrExpr; if (member === "keys") { pushed = { kind: "strLit", value: f.name, type: STRING, loc }; @@ -272,37 +287,40 @@ export function objectIterOverIndexShape( `Object.${member} over '${lowerer.fmt(argIr)}' (field '${f.name}' of type '${lowerer.fmt(f.type)}' cannot flow into the '${lowerer.fmt(valueT!)}' result element — read the fields directly)`, ); } - pushed = - member === "values" - ? s - : { - kind: "recordLit", - fields: [ - { name: "0", value: { kind: "strLit", value: f.name, type: STRING, loc } }, - { name: "1", value: s }, - ], - type: tupleT!, - loc, - }; + pushed = pushedOf(s); } - const fieldStmt: IrStmt = - utag >= 0 && f.type.kind === "union" - ? { - kind: "if", - cond: { - kind: "unionIsTag", - unionId: f.type.unionId, - tag: utag, - negated: true, - value: raw, - type: BOOL, - loc, - }, - then: [push(pushed)], - else_: null, + // Fields that can be absent contribute only when present; a present + // undefined behind a narrowed push surfaces the element's own + // undefined (the element union carries one whenever the checker + // typed the optional field into it). + let fieldStmt: IrStmt = push(pushed); + if (utag >= 0 && f.type.kind === "union") { + if (narrowedPush) { + const undefinedElement = valueT ? lowerer.wrappedUndefined(valueT, loc) : null; + fieldStmt = { + kind: "if", + cond: { + kind: "unionIsTag", + unionId: f.type.unionId, + tag: utag, + negated: true, + value: raw, + type: BOOL, loc, - } - : push(pushed); + }, + then: [fieldStmt], + else_: undefinedElement ? [push(pushedOf(undefinedElement))] : null, + loc, + }; + } + fieldStmt = { + kind: "if", + cond: lowerer.recordFieldPresent(rRef, argIr.shapeId, f.name, loc), + then: [fieldStmt], + else_: null, + loc, + }; + } fieldStmts.set(f.name, fieldStmt); body.push(fieldStmt); } @@ -844,24 +862,30 @@ export function lowerRecordOvfCaptureHelper( name: d.name, value: d.kind === "direct" - ? lowerer.applyWidthLift( - d.lift, - { - kind: "recordGet", - obj: sRef, - shapeId: fromId, - field: d.name, - type: d.src, + ? lowerer.presenceKeepingCopy( + sRef, + fromId, + d.name, + lowerer.applyWidthLift( + d.lift, + { + kind: "recordGet", + obj: sRef, + shapeId: fromId, + field: d.name, + type: d.src, + loc, + }, + to.fields.find((f) => f.name === d.name)!.type, loc, - }, - to.fields.find((f) => f.name === d.name)!.type, + ), loc, ) : ({ - kind: "unionWrap", + // No source property of this name: the field starts + // absent (an overflow write below can supply it). + kind: "fieldAbsent", unionId: d.unionId, - tag: d.utag, - value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, type: to.fields.find((f) => f.name === d.name)!.type, loc, } satisfies IrExpr), @@ -901,15 +925,7 @@ export function lowerRecordOvfCaptureHelper( utag >= 0 && ff.type.kind === "union" ? { kind: "if", - cond: { - kind: "unionIsTag", - unionId: ff.type.unionId, - tag: utag, - negated: true, - value: raw, - type: BOOL, - loc, - }, + cond: lowerer.recordFieldPresent(sRef, fromId, ff.name, loc), then: [write], else_: null, loc, @@ -1124,15 +1140,7 @@ export function lowerObjectAssignIndexShape( utag >= 0 && ff.type.kind === "union" ? { kind: "if", - cond: { - kind: "unionIsTag", - unionId: ff.type.unionId, - tag: utag, - negated: true, - value: raw, - type: BOOL, - loc, - }, + cond: lowerer.recordFieldPresent(sRef, plan.fromId, ff.name, loc), then: [write], else_: null, loc, @@ -1488,15 +1496,7 @@ export function lowerIndexMergeHelper( utag >= 0 && ff.type.kind === "union" ? { kind: "if", - cond: { - kind: "unionIsTag", - unionId: ff.type.unionId, - tag: utag, - negated: true, - value: raw, - type: BOOL, - loc, - }, + cond: lowerer.recordFieldPresent(sRef, plan.shapeId, ff.name, loc), then: [write], else_: null, loc, @@ -1544,15 +1544,7 @@ export function lowerIndexMergeHelper( utag >= 0 && ff.type.kind === "union" ? { kind: "if", - cond: { - kind: "unionIsTag", - unionId: ff.type.unionId, - tag: utag, - negated: true, - value: raw, - type: BOOL, - loc, - }, + cond: lowerer.recordFieldPresent(sRef, plan.shapeId, ff.name, loc), then: [write], else_: null, loc, diff --git a/packages/compiler/src/frontend/lowering/expressions/evaluation-safety.ts b/packages/compiler/src/frontend/lowering/expressions/evaluation-safety.ts index 16673ae9a..d24f75b57 100644 --- a/packages/compiler/src/frontend/lowering/expressions/evaluation-safety.ts +++ b/packages/compiler/src/frontend/lowering/expressions/evaluation-safety.ts @@ -40,6 +40,10 @@ export function isSafeToDiscard(expr: IrExpr): boolean { case "unionIsTag": case "unionWrap": return isSafeToDiscard(expr.value); + case "recordHas": + return isSafeToDiscard(expr.obj); + case "fieldAbsent": + return true; // Fresh allocations are unobservable too — only their pieces can // carry effects (a spread re-reads its source: pure; element and // field initializers recurse). diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 371a09fea..7e475ae11 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1512,6 +1512,68 @@ export function lowerObjectLiteral( captured.add(ref); return ref; }; + // A pending field value may carry the ABSENT state (an omitted + // conditional-spread arm, a spread source lacking the key). That state + // only lives in a field slot, so a snapshot splits the value into a + // captured ordinary value and a pure presence test over captured + // conditions, then rebuilds `present ? value : absent` (arms stay lazy: + // the captured value keeps the original ternary structure). + const canLackField = (t: IrType): boolean => + t.kind === "union" && lowerer.armTag(t.unionId, UNDEFINED_T) >= 0; + const containsAbsent = (value: IrExpr): boolean => + value.kind === "fieldAbsent" || + (value.kind === "ternary" && (containsAbsent(value.then) || containsAbsent(value.else_))); + const captureField = (value: IrExpr): IrExpr => { + if (captured.has(value)) return value; + if (!containsAbsent(value)) return capture(value); + if (value.kind === "fieldAbsent") return value; + const split = (v: IrExpr): { value: IrExpr; present: IrExpr } => { + if (v.kind === "fieldAbsent") { + return { + value: lowerer.wrappedUndefined(v.type, v.loc)!, + present: { kind: "boolLit", value: false, type: BOOL, loc: v.loc }, + }; + } + if (v.kind === "ternary" && containsAbsent(v)) { + // Conditions snapshot here too. Nested conditions come from the + // spread desugar's own presence tests over captured sources, so + // evaluating them eagerly is unobservable. + const cond = + v.cond.kind === "varRef" || v.cond.kind === "boolLit" ? v.cond : capture(v.cond); + const t = split(v.then); + const e = split(v.else_); + return { + value: { ...v, cond, then: t.value, else_: e.value }, + present: { + kind: "ternary", + cond, + then: t.present, + else_: e.present, + type: BOOL, + loc: v.loc, + }, + }; + } + return { value: v, present: { kind: "boolLit", value: true, type: BOOL, loc: v.loc } }; + }; + const parts = split(value); + const ref = capture(parts.value); + const out: IrExpr = { + kind: "ternary", + cond: parts.present, + then: ref, + else_: { + kind: "fieldAbsent", + unionId: (value.type as IrType & { kind: "union" }).unionId, + type: value.type, + loc: value.loc, + }, + type: value.type, + loc: value.loc, + }; + captured.add(out); + return out; + }; // Field names introduced by conditional spreads: their ternary carries // the spread's whole evaluation (cond once, value lazily), so a LATER // contributor overriding one would silently drop that evaluation — @@ -1532,7 +1594,7 @@ export function lowerObjectLiteral( fields.splice(i--, 1); continue; } - if (!captured.has(field.value)) field.value = capture(field.value); + field.value = captureField(field.value); } } if (ts.isSpreadAssignment(prop)) { @@ -1557,7 +1619,7 @@ export function lowerObjectLiteral( } shapeMismatch(prop); } - const absent = lowerer.wrappedUndefined(fieldType, locOf(prop)); + const absent = lowerer.absentFieldValue(fieldType, locOf(prop)); if (!absent) { lowerer.unsupported( "SC1090", @@ -1668,8 +1730,9 @@ export function lowerObjectLiteral( if (conditionalSpreadOf(later.expression)) continue; const lt = laterSpreadType(lowerer, later.expression); if (lt?.kind === "record") { + // A field the later source may lack defines nothing. for (const lf of lowerer.shapes.get(lt.shapeId)?.fields ?? []) - laterNames.add(lf.name); + if (!canLackField(lf.type)) laterNames.add(lf.name); } continue; } @@ -1723,8 +1786,34 @@ export function lowerObjectLiteral( loc: locOf(prop), }; let thenVal: IrExpr; + // A source field that can be absent copies only when present. + const fieldPresent = (): IrExpr => ({ + kind: "logical", + op: "&&", + left: cond, + right: { + kind: "recordHas", + obj: { + kind: "unionNarrow", + unionId: srcType.unionId, + tag: recTag, + value: srcRef(), + type: recArm, + loc: locOf(prop), + }, + shapeId: recArm.shapeId, + field: f.name, + type: BOOL, + loc: locOf(prop), + }, + type: BOOL, + loc: locOf(prop), + }); + const canBeAbsent = + f.type.kind === "union" && lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0; if (typeEquals(f.type, targetType)) { thenVal = fRead(); + if (canBeAbsent) cond = fieldPresent(); } else if ( f.type.kind === "union" && lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0 && @@ -1818,10 +1907,11 @@ export function lowerObjectLiteral( throw new PoisonError(); } thenVal = lowerer.applyWidthLift(lift, fRead(), targetType, locOf(prop)); + if (canBeAbsent) cond = fieldPresent(); } const at = fields.findIndex((x) => x.name === f.name && !x.drop); const elseVal = - at >= 0 ? fields[at]!.value : lowerer.wrappedUndefined(targetType, locOf(prop)); + at >= 0 ? fields[at]!.value : lowerer.absentFieldValue(targetType, locOf(prop)); if (!elseVal) { lowerer.unsupported( "SC1090", @@ -1875,7 +1965,9 @@ export function lowerObjectLiteral( if (conditionalSpreadOf(later.expression)) continue; const lt = laterSpreadType(lowerer, later.expression); if (lt?.kind === "record") { - for (const lf of lowerer.shapes.get(lt.shapeId)?.fields ?? []) laterNames.add(lf.name); + // A field the later source may lack defines nothing. + for (const lf of lowerer.shapes.get(lt.shapeId)?.fields ?? []) + if (!canLackField(lf.type)) laterNames.add(lf.name); } continue; } @@ -1977,6 +2069,35 @@ export function lowerObjectLiteral( ); } const at = fields.findIndex((x) => x.name === f.name); + // A source field that can be absent copies only when present: an + // absent one keeps the earlier contributor's value, or leaves the + // target field absent. + const canBeAbsent = + f.type.kind === "union" && lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0; + if (canBeAbsent) { + const present: IrExpr = + obj.type.kind === "dyn" + ? { + kind: "dynHasKey", + key: f.name, + value: obj, + type: BOOL, + loc: locOf(prop), + } + : lowerer.recordFieldPresent(obj, srcType.shapeId, f.name, locOf(prop)); + const otherwise = + at >= 0 ? fields[at]!.value : lowerer.absentFieldValue(targetType, locOf(prop)); + if (otherwise) { + value = { + kind: "ternary", + cond: present, + then: value, + else_: otherwise, + type: targetType, + loc: locOf(prop), + }; + } + } if (at >= 0) fields[at] = { name: f.name, value }; else fields.push({ name: f.name, value }); } @@ -2174,10 +2295,10 @@ export function lowerObjectLiteral( if (at >= 0) fields.splice(at, 1); fields.push({ name, value }); } - // Optional fields may be omitted: the absent field holds an undefined - // union arm or, for an island handle slot, the engine's undefined cell. - // This matches writing `a: undefined` without exactOptionalPropertyTypes; - // tsc rejects omission of required fields before lowering. + // Optional fields may be omitted: the field holds its ABSENT state (it + // reads as undefined but is not an own property) or, for an island + // handle slot, the engine's undefined cell. tsc rejects omission of + // required fields before lowering. // A REQUIRED missing field keeps the shape-mismatch rejection (possible // through `as`: the cast smuggles a narrower literal past freshness). if (droppedNames.size > 0) { @@ -2207,7 +2328,7 @@ export function lowerObjectLiteral( ? { kind: "jsOp", op: "undefLit", args: [], type: JSVAL, loc } : null; const absent = - lowerer.wrappedUndefined(f.type, loc) ?? + lowerer.absentFieldValue(f.type, loc) ?? (f.type.kind === "dyn" ? dynUndefinedExpr(loc) : null) ?? absentHandle; if (!absent) shapeMismatch(expr); // only optional and 'unknown' fields may be omitted @@ -2502,8 +2623,8 @@ function lowerDeclaredSpreadMerge( fenceAccessorSpreadSource(lowerer, prop, srcShape); srcs.push({ value, shapeId: value.type.shapeId, shape: srcShape }); } - // Every target field must be optional: absent keys leave the undefined - // arm, exactly the unset-optional representation. + // Every target field must be optional: keys no source supplies stay + // absent. for (const f of shape.fields) { if (f.type.kind !== "union" || lowerer.armTag(f.type.unionId, UNDEFINED_T) < 0) { lowerer.unsupported( @@ -2631,7 +2752,7 @@ function lowerDeclaredSpreadMerge( kind: "recordLit", fields: shape.fields.map((f) => ({ name: f.name, - value: lowerer.wrappedUndefined(f.type, loc)!, + value: lowerer.absentFieldValue(f.type, loc)!, })), type, loc, @@ -2690,27 +2811,13 @@ function lowerDeclaredSpreadMerge( value: lowerer.applyWidthLift(lift, raw, target.type, loc), loc, }; - // Optional declared fields use the established unset convention. + // Optional declared fields copy only when present (an explicit + // undefined is a real property and overwrites an earlier value). // A dyn field, including a present undefined, is a real property; // its checked conversion runs and can overwrite an earlier value. - const absent = - field.type.kind === "union" ? lowerer.armTag(field.type.unionId, UNDEFINED_T) : -1; - if (absent >= 0 && field.type.kind === "union") { - body.push({ - kind: "if", - cond: { - kind: "unionIsTag", - unionId: field.type.unionId, - tag: absent, - negated: true, - value: raw, - type: BOOL, - loc, - }, - then: [write], - else_: null, - loc, - }); + const present = lowerer.recordFieldPresent(receiver, source.shapeId, field.name, loc); + if (present.kind === "recordHas") { + body.push({ kind: "if", cond: present, then: [write], else_: null, loc }); } else body.push(write); } const iv = source.shape ? source.shape.indexValue : DYN; diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index b5c024eb5..ecf26ff74 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -1760,6 +1760,15 @@ function deepEqHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string { }); body = []; for (const f of shape.fields) { + // Own keys must agree: an absent optional field never equals a + // present one, even one holding undefined. + const hasA = lowerer.recordFieldPresent(a(), t.shapeId, f.name, loc); + if (hasA.kind === "recordHas") { + const hasB = lowerer.recordFieldPresent(b(), t.shapeId, f.name, loc); + body.push( + bailIf({ kind: "ternary", cond: hasA, then: not(hasB), else_: hasB, type: BOOL, loc }), + ); + } if (isUnitType(f.type)) continue; // a unit field is equal by type body.push(bailIf(not(deq(f.type, get(a(), f.name, f.type), get(b(), f.name, f.type))))); } diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 69c35a0b0..f818c8257 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -6508,11 +6508,10 @@ function filterNarrowHelper( * no runtime walk. ORDER is the shape's first-seen DECLARATION order * (threaded through the shape registry), which matches Node whenever * objects are constructed in declaration order — the divergence for - * reordered construction is SEMANTICS.md 36. Fields holding the - * undefined arm of their union are SKIPPED at runtime (Node's missing - * key: an unset optional never made it into the object), which also - * means an EXPLICIT `{ a: undefined }` key is dropped where Node lists - * it — same rule as jsonStringify, same SEMANTICS entry. Values wrap + * reordered construction is SEMANTICS.md 36. Optional fields that are + * absent (omitted or deleted) are SKIPPED at runtime, Node's missing key, + * while an explicit `{ a: undefined }` is listed with its undefined + * value. Values wrap * into the checker's result-element type per field; a multi-arm field * union that differs from the result union would need a re-tag — fenced. * Null when this isn't an Object static over a fixed record (index @@ -6645,9 +6644,9 @@ function lowerSymbolMethodCall( /** The interned keys-array helper over a FIXED record shape: a call of a * lifted helper whose body pushes each declared field name in first-seen - * DECLARATION order, skipping fields currently holding the undefined arm - * of their union at runtime (Node's missing key — an unset optional - * never made it into the object; SEMANTICS.md 37's rules). ONE + * DECLARATION order, skipping optional fields that are currently absent + * (omitted or deleted — Node's missing key; a field explicitly holding + * undefined is present and listed). ONE * construction, interned per shape, shared by Object.keys and for-in — * for-in iterates exactly the keys Object.keys answers. */ export function recordKeysArrayCall( @@ -6702,33 +6701,12 @@ export function recordKeysArrayCall( }, loc, }; - // Undefined-armed fields: the push is guarded by a tag test (the + // Fields that can be absent push behind their presence test (the // key exists exactly when Object.keys would list it). - const utag = f.type.kind === "union" ? lowerer.armTag(f.type.unionId, UNDEFINED_T) : -1; + const present = lowerer.recordFieldPresent(ref, argIr.shapeId, f.name, loc); body.push( - utag >= 0 && f.type.kind === "union" - ? { - kind: "if", - cond: { - kind: "unionIsTag", - unionId: f.type.unionId, - tag: utag, - negated: true, - value: { - kind: "recordGet", - obj: ref, - shapeId: argIr.shapeId, - field: f.name, - type: f.type, - loc, - }, - type: BOOL, - loc, - }, - then: [pushStmt], - else_: null, - loc, - } + present.kind === "recordHas" + ? { kind: "if", cond: present, then: [pushStmt], else_: null, loc } : pushStmt, ); } @@ -6744,9 +6722,9 @@ export function recordKeysArrayCall( /** Interned `%obj.hasOwn.(r, k)` — Object.hasOwn's membership walk * over a record shape: the key compares against each - * declared field name, undefined-armed fields answering by their tag + * declared field name, optional fields answering by their presence * (a key is own exactly when Object.keys would list it — the two share - * the guard), everything else true. Unmatched keys probe the overflow + * the test), everything else true. Unmatched keys probe the overflow * map when present, so explicit undefined values remain own keys. */ function recordHasOwnHelper(lowerer: Lowerer, shapeId: string, loc: SrcLoc): string { const key = `obj.hasOwn:${shapeId}`; @@ -6760,19 +6738,7 @@ function recordHasOwnHelper(lowerer: Lowerer, shapeId: string, loc: SrcLoc): str const kRef: IrExpr = { kind: "varRef", localId: "k.0", type: STRING, loc }; const body: IrStmt[] = []; for (const f of shape.fields) { - const utag = f.type.kind === "union" ? lowerer.armTag(f.type.unionId, UNDEFINED_T) : -1; - const answer: IrExpr = - utag >= 0 && f.type.kind === "union" - ? { - kind: "unionIsTag", - unionId: f.type.unionId, - tag: utag, - negated: true, - value: { kind: "recordGet", obj: rRef, shapeId, field: f.name, type: f.type, loc }, - type: BOOL, - loc, - } - : { kind: "boolLit", value: true, type: BOOL, loc }; + const answer = lowerer.recordFieldPresent(rRef, shapeId, f.name, loc); body.push({ kind: "if", cond: { @@ -6811,8 +6777,9 @@ function recordHasOwnHelper(lowerer: Lowerer, shapeId: string, loc: SrcLoc): str /** Interned `%obj.assign.(t, s)` — Object.assign's per-field copy * over signature-free records (every source field lands on a same-named, - * same-typed target field — the caller's gate): undefined-armed source - * fields copy behind the not-undefined guard, everything else straight, + * same-typed target field — the caller's gate): optional source fields + * copy only when present (an explicit undefined copies), everything else + * straight, * and the TARGET returns (JS's aliasing). */ function recordAssignHelper( lowerer: Lowerer, @@ -6848,24 +6815,10 @@ function recordAssignHelper( value: get, loc, }; - const utag = f.type.kind === "union" ? lowerer.armTag(f.type.unionId, UNDEFINED_T) : -1; + const present = lowerer.recordFieldPresent(sRef, srcShapeId, f.name, loc); body.push( - utag >= 0 && f.type.kind === "union" - ? { - kind: "if", - cond: { - kind: "unionIsTag", - unionId: f.type.unionId, - tag: utag, - negated: true, - value: get, - type: BOOL, - loc, - }, - then: [set], - else_: null, - loc, - } + present.kind === "recordHas" + ? { kind: "if", cond: present, then: [set], else_: null, loc } : set, ); } @@ -7447,12 +7400,10 @@ function lowerObjectStaticCall( // (the mockable-clock restore: `Object.assign(mocked, // implementations)` over one shape): the per-field copy helper, // returning the TARGET — JS's aliasing, the target mutates in - // place. Undefined-armed source fields copy behind the - // not-undefined guard (an omitted optional field holds the - // undefined arm and must not erase the target's value — Node - // copies own keys only; an EXPLICIT `k: undefined` source diverges, - // the explicit-undefined-is-absent stance). Everything else keeps - // the spread hint. + // place. Optional source fields copy only when present (an omitted + // optional field must not erase the target's value — Node copies own + // keys only — while an explicit `k: undefined` does). Everything + // else keeps the spread hint. if (call.arguments.length === 2 && !call.arguments.some((a) => ts.isSpreadElement(a))) { const tProbe = tryLowerExpression(lowerer, call.arguments[0]!); const sProbe = tryLowerExpression(lowerer, call.arguments[1]!); @@ -7857,11 +7808,9 @@ function lowerObjectStaticCall( } // `Object.hasOwn(r, k)` over a RECORD receiver: a record's own-key set // is its declared field list, so membership is a compare chain against - // the field names (interned per shape). Undefined-armed (optional) - // fields answer by their runtime tag — the explicit-undefined-is-absent - // stance: an omitted optional field holds the undefined arm and reads - // as NOT own, exactly Node's absent key (an EXPLICIT `k: undefined` - // diverges — documented next to the child-env/JSON rule). Tuple + // the field names (interned per shape). Optional fields answer by + // their runtime presence: an omitted or deleted optional field is NOT + // own, while an explicit `k: undefined` is, exactly Node's keys. Tuple // and accessor-carrying shapes keep the SC2020 fence. Index-signature // records additionally probe the overflow map; non-record receivers // do too. @@ -8150,8 +8099,8 @@ function lowerObjectStaticCall( } return null; }; - // Undefined-armed fields: the push is guarded by a tag test, and - // the pushed value is the narrowed non-undefined arm. + // Undefined-armed fields: the push is guarded by the field's + // presence; a narrowed push re-tags the non-undefined arm. let guardUndefTag: number | null = null; let value: IrExpr = raw; let vt: IrType = f.type; @@ -8211,49 +8160,83 @@ function lowerObjectStaticCall( `cannot flow into the '${lowerer.fmt(valueT!)}' result element — read the fields directly)`, ); } - const pushed: IrExpr = - member === "values" - ? coerced - : { - kind: "recordLit", - fields: [ - { name: "0", value: { kind: "strLit", value: f.name, type: STRING, loc } }, - { name: "1", value: coerced }, - ], - type: tupleT!, - loc, - }; - const pushStmt: IrStmt = { + const pushOf = (element: IrExpr): IrStmt => ({ kind: "exprStmt", expr: { kind: "arrIntrinsic", method: "push", receiver: outRef, - args: [pushed], + args: [ + member === "values" + ? element + : { + kind: "recordLit", + fields: [ + { name: "0", value: { kind: "strLit", value: f.name, type: STRING, loc } }, + { name: "1", value: element }, + ], + type: tupleT!, + loc, + }, + ], type: F64, loc, }, loc, - }; - body.push( - guardUndefTag !== null && f.type.kind === "union" - ? { - kind: "if", - cond: { - kind: "unionIsTag", - unionId: f.type.unionId, - tag: guardUndefTag, - negated: true, - value: raw, - type: BOOL, - loc, - }, - then: [pushStmt], - else_: null, + }); + let stmt = pushOf(coerced); + if (guardUndefTag !== null && f.type.kind === "union") { + if (value !== raw) { + // A narrowed push: a present field holding undefined pushes + // the result element's own undefined when it has one; a values + // array without one stores a present undefined element (a + // runtime-optional field the checker typed as required). An + // entries tuple slot has no spelling for it. + const undefinedElement = valueT ? lowerer.wrappedUndefined(valueT, loc) : null; + const undefinedPush: IrStmt | null = undefinedElement + ? pushOf(undefinedElement) + : member === "values" + ? { + kind: "arraySetUndefined", + arr: outRef, + index: { + kind: "arrIntrinsic", + method: "length", + receiver: outRef, + args: [], + type: F64, + loc, + }, + loc, + } + : null; + stmt = { + kind: "if", + cond: { + kind: "unionIsTag", + unionId: f.type.unionId, + tag: guardUndefTag, + negated: true, + value: raw, + type: BOOL, loc, - } - : pushStmt, - ); + }, + then: [stmt], + else_: undefinedPush ? [undefinedPush] : null, + loc, + }; + } + // Absent fields contribute nothing; present ones push their + // value, undefined included. + stmt = { + kind: "if", + cond: lowerer.recordFieldPresent(ref, argIr.shapeId, f.name, loc), + then: [stmt], + else_: null, + loc, + }; + } + body.push(stmt); } body.push({ kind: "return", value: outRef, loc }); fn.body = body; @@ -8337,7 +8320,7 @@ function lowerObjectAssignHybrid(lowerer: Lowerer, call: ts.CallExpression): IrE for (const f of shape.fields) { const v = values.get(f.name); if (!v) { - const absent = lowerer.wrappedUndefined(f.type, loc); + const absent = lowerer.absentFieldValue(f.type, loc); if (!absent) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index a71d8beba..544e47b89 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -12380,11 +12380,10 @@ function lowerPrivateIn( * type-directed: a declared non-optional field is a compile-time `true`, * a missing field (no index signature) a compile-time `false` — both * folds limited to side-effect-free receivers, the lowerInstanceOf rule — - * and an OPTIONAL field (undefined-armed union) is a runtime tag test on - * the slot: present iff the arm is not undefined. That last case is the - * representation's honest answer — a field explicitly assigned - * `undefined` reads as absent (`"a" in {a: undefined}` is true in JS, - * false here; SEMANTICS.md 55). Union receivers (the `in`-narrowing + * and an OPTIONAL field (undefined-armed union) is a runtime presence + * test on the slot: an omitted or deleted property is absent, while a + * property explicitly holding `undefined` is present, as in JS + * (`"a" in {a: undefined}` is true). Union receivers (the `in`-narrowing * idiom over multiple shapes), index-signature keys, class instances, * and dyn/unknown stay fenced. Keys are literal strings — a computed key * over a shape would need the runtime key table. */ @@ -12436,8 +12435,8 @@ function lowerInExpression(lowerer: Lowerer, expr: ts.BinaryExpression, loc: Src // A RUNTIME string key over an INDEX-SIGNATURE record receiver (the // `names.filter((k) => k in config)` idiom): the interned key- // presence helper — declared fields answer statically per name - // (optional slots per value: the undefined arm reads absent, stance - // 55), then the overflow map's live keys. Other receivers keep the + // (optional slots by their runtime presence), then the overflow + // map's live keys. Other receivers keep the // fence: fixed shapes want the literal folds above, and there is no // runtime key table to ask. const rIn = lowerRuntimeKeyIn(lowerer, expr, loc); @@ -12704,24 +12703,9 @@ function lowerInExpression(lowerer: Lowerer, expr: ts.BinaryExpression, loc: Src const field = shape.fields.find((f) => f.name === key); if (field) { if (field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0) { - // Optional slot: the key is present iff the arm is not undefined. - const read: IrExpr = { - kind: "recordGet", - obj: recv, - shapeId: recv.type.shapeId, - field: key, - type: field.type, - loc, - }; - return { - kind: "unionIsTag", - unionId: field.type.unionId, - tag: lowerer.armTag(field.type.unionId, UNDEFINED_T), - negated: true, - value: read, - type: BOOL, - loc, - }; + // Optional slot: the key is present unless the field is absent + // (omitted or deleted); an explicit undefined is present. + return lowerer.recordFieldPresent(recv, recv.type.shapeId, key, loc); } // A declared non-optional field always exists on every value of the // shape — statically true, but folding may only drop a @@ -12822,27 +12806,9 @@ function lowerRuntimeKeyIn( type: BOOL, loc, }; - const utag = - !accessor && f.type.kind === "union" ? lowerer.armTag(f.type.unionId, UNDEFINED_T) : -1; - const answer: IrExpr = - utag >= 0 && f.type.kind === "union" - ? { - kind: "unionIsTag", - unionId: f.type.unionId, - tag: utag, - negated: true, - value: { - kind: "recordGet", - obj: r, - shapeId: recvT.shapeId, - field: f.name, - type: f.type, - loc, - }, - type: BOOL, - loc, - } - : { kind: "boolLit", value: true, type: BOOL, loc }; + const answer: IrExpr = accessor + ? { kind: "boolLit", value: true, type: BOOL, loc } + : lowerer.recordFieldPresent(r, recvT.shapeId, f.name, loc); body.push({ kind: "if", cond: eq, then: [ret(answer)], else_: null, loc }); } const ksT = arrayOf(STRING); diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index cdc1f941d..f4a8d5def 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -803,15 +803,42 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string { const current = lowerer.shapes.get(t.shapeId) ?? shape; const order = current.declaredOrder ?? current.fields.map((f) => f.name); const byName = new Map(current.fields.map((f) => [f.name, f.type] as const)); - body = [depthGate("[Object]"), ...begin()]; + // Optional fields render only while present (an omitted or + // deleted property is not shown; an explicit undefined is). A + // value whose fields are all absent is Node's empty `{}`, which + // answers before the depth gate. + const presence = order + .filter((fname) => byName.has(fname)) + .map((fname) => lowerer.recordFieldPresent(v(), t.shapeId, fname, loc)); + body = []; + if (presence.length > 0 && presence.every((p) => p.kind === "recordHas")) { + const any = presence + .slice(1) + .reduce( + (left, right) => ({ kind: "logical", op: "||", left, right, type: BOOL, loc }), + presence[0]!, + ); + body.push({ + kind: "if", + cond: { kind: "unary", op: "!", operand: any, type: BOOL, loc }, + then: [ret(strLit("{}", loc))], + else_: null, + loc, + }); + } + body.push(depthGate("[Object]"), ...begin()); for (const fname of order) { const ft = byName.get(fname); if (!ft) continue; + const show = entry( + concatAll([strLit(`${inspectKey(fname)}: `, loc), child(ft, get(fname, ft))], loc), + boolLit(false, loc), + ); + const present = lowerer.recordFieldPresent(v(), t.shapeId, fname, loc); body.push( - entry( - concatAll([strLit(`${inspectKey(fname)}: `, loc), child(ft, get(fname, ft))], loc), - boolLit(false, loc), - ), + present.kind === "recordHas" + ? { kind: "if", cond: present, then: [show], else_: null, loc } + : show, ); } body.push( diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 3efbbdae7..25ab5aaa1 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1006,6 +1006,7 @@ export function moduleArtifacts( case "recordKeyGet": case "recordOvfKeys": case "recordOvfHas": + case "recordHas": visitShape(node.shapeId); break; case "unionWrap": @@ -1015,6 +1016,7 @@ export function moduleArtifacts( case "unionKeyGet": case "unionIsTag": case "unionEq": + case "fieldAbsent": visitUnion(node.unionId); break; case "yieldExpr": diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index de032cab1..829163b07 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -3584,14 +3584,20 @@ function objectRestValue( kind: "recordLit", fields: rem.map((f) => ({ name: f.name, - value: { - kind: "recordGet", - obj: srcRef(), - shapeId: srcType.shapeId, - field: f.name, - type: f.type, + value: lowerer.presenceKeepingCopy( + srcRef(), + srcType.shapeId, + f.name, + { + kind: "recordGet", + obj: srcRef(), + shapeId: srcType.shapeId, + field: f.name, + type: f.type, + loc, + }, loc, - } as IrExpr, + ), })), type: { kind: "record", @@ -3639,7 +3645,16 @@ function objectRestValue( type: srcField.type, loc, }; - return { name: f.name, value: lowerer.coerceInto(el, read, f.type) }; + return { + name: f.name, + value: lowerer.presenceKeepingCopy( + srcRef(), + srcType.shapeId, + srcField.name, + lowerer.coerceInto(el, read, f.type), + loc, + ), + }; }); return { kind: "recordLit", fields, type: restT, loc }; } @@ -5819,8 +5834,9 @@ function isStrictDelete(node: ts.DeleteExpression): boolean { /** Statement-position `delete`: process.env keys → process.envUnset * (unsetenv), pure `Record` keys → recordKeyDelete (the - * overflow Map delete), declared OPTIONAL fields → the undefined-arm - * write (absence IS the undefined arm; divergence 60). Everything else + * overflow Map delete), declared OPTIONAL fields → the field's ABSENT + * state (the slot reads undefined and no longer counts as an own + * property). Everything else * fences with the honest reason — a required field is a struct slot no * runtime can remove. */ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrStmt { @@ -5941,14 +5957,13 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt if (shape?.indexValue && shape.fields.length === 0 && !shape.tuple) { return { kind: "recordKeyDelete", obj, shapeId: obj.type.shapeId, key: lowerKey(), loc }; } - // `delete r.f` of a declared OPTIONAL field (undefined-armed slot) is - // the undefined-arm write: a monomorphic shape cannot remove its - // slot, and absence IS the undefined arm (divergences 37/56), so the - // observable results — `in` answers false, Object.keys skips it, - // JSON.stringify drops it — match Node's post-delete answers exactly - // (divergence 60 documents the delete/`= undefined` collapse). - // Constant keys only (dot access or a literal bracket); required - // fields keep the honest fence below. + // `delete r.f` of a declared OPTIONAL field (undefined-armed slot) + // writes the field's ABSENT state: a monomorphic shape cannot remove + // its slot, but the slot remembers that no property exists, so `in` + // answers false, Object.keys skips it, and JSON.stringify drops it, + // while a later write makes it present again. Constant keys only + // (dot access or a literal bracket); required fields keep the honest + // fence below. const fieldName = ts.isPropertyAccessExpression(target) ? target.name.text : ts.isStringLiteral(target.argumentExpression) @@ -5959,7 +5974,7 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt ? shape?.fields.find((f) => f.name === fieldName) : undefined; if (field) { - const absent = lowerer.wrappedUndefined(field.type, loc); + const absent = lowerer.absentFieldValue(field.type, loc); if (absent) { return { kind: "recordSet", diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 5da099b99..eb29e8771 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -8046,6 +8046,49 @@ export class Lowerer { return wrappedUndefined(this, type, loc); } + /** The value an OMITTED or deleted property leaves in a record field + * slot of `type`: the ABSENT state for undefined-armed unions (the slot + * reads as undefined but the property does not exist), the dyn + * undefined for 'unknown' slots, and null when the slot cannot be + * missing. Only legal as a direct field-slot write (IR fieldAbsent). */ + absentFieldValue(type: IrType, loc: SrcLoc): IrExpr | null { + if (type.kind === "union" && this.armTag(type.unionId, UNDEFINED_T) >= 0) { + return { kind: "fieldAbsent", unionId: type.unionId, type, loc }; + } + return this.wrappedUndefined(type, loc); + } + + /** A field copy that keeps absence: `value` (the converted read of + * `field` from record `obj`, which must be repeatable) when the source + * property exists, else the target slot's ABSENT state. Fields that are + * always present, and targets that cannot be absent, copy `value` + * as-is. Only legal as a direct field-slot write. */ + presenceKeepingCopy( + obj: IrExpr, + shapeId: string, + field: string, + value: IrExpr, + loc: SrcLoc, + ): IrExpr { + const present = this.recordFieldPresent(obj, shapeId, field, loc); + if (present.kind !== "recordHas") return value; + const absent = this.absentFieldValue(value.type, loc); + if (absent?.kind !== "fieldAbsent") return value; + return { kind: "ternary", cond: present, then: value, else_: absent, type: value.type, loc }; + } + + /** Own-property presence of declared field `field` on record `obj` + * (borrowed — callers pass a repeatable receiver): a runtime test for + * undefined-armed union fields, which can be absent, and `true` for + * every other declared field. */ + recordFieldPresent(obj: IrExpr, shapeId: string, field: string, loc: SrcLoc): IrExpr { + const f = this.shapes.get(shapeId)?.fields.find((x) => x.name === field); + if (f && f.type.kind === "union" && this.armTag(f.type.unionId, UNDEFINED_T) >= 0) { + return { kind: "recordHas", obj, shapeId, field, type: BOOL, loc }; + } + return { kind: "boolLit", value: true, type: BOOL, loc }; + } + /** The entry value of a binding JS initializes to `undefined` (an * initializer-less declaration, a hoisted `var` before its statement): * undefined-armed unions hold the interned undefined arm, and 'any' diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 32066b852..ca41f3095 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2756,7 +2756,8 @@ export type IrExpr = loc: SrcLoc; } /** Record field read `r.f` — mirrors `fieldGet`: refcounted fields come - * out retained (+1). */ + * out retained (+1). A field in its ABSENT state (fieldAbsent) reads as + * the ordinary undefined arm. */ | { kind: "recordGet"; obj: IrExpr; shapeId: string; field: string; type: IrType; loc: SrcLoc } /** Dynamic-keyed record read `r[k]` (string key, evaluated at runtime). * Declared fields are tried FIRST (an emitted string-switch — field @@ -3049,6 +3050,25 @@ export type IrExpr = * value. Declared fields are handled separately by the lowering. Both * operands are borrowed, the key is string, the result is bool. */ | { kind: "recordOvfHas"; obj: IrExpr; shapeId: string; key: IrExpr; type: IrType; loc: SrcLoc } + /** Own-property presence of a DECLARED record field (`"f" in r`, + * Object.keys/hasOwn and every other key surface). A field whose type + * has no undefined arm is always present; an undefined-armed union field + * is present unless its slot holds the field's ABSENT state (see + * fieldAbsent), so `{ f: undefined }` keeps `f` while an omitted or + * deleted optional field lacks it. The receiver is borrowed; the result + * is bool. */ + | { kind: "recordHas"; obj: IrExpr; shapeId: string; field: string; type: IrType; loc: SrcLoc } + /** The ABSENT state of an undefined-armed record field slot: the value + * stored for an omitted optional property, a `delete`d property, or a + * checked-dynamic key that does not exist. It is the union's undefined + * arm in every respect (tag tests, narrowing, equality, JSON), but the + * slot remembers that no property exists, which recordHas observes. + * Record field reads surface the ordinary undefined arm, so the state + * never escapes its slot. Legal only where a field slot is written + * directly: a recordLit field value, a recordSet value, a recordClone + * override, or a ternary arm in one of those positions. `type` is the + * union named by `unionId`, which has an undefined arm. Immortal. */ + | { kind: "fieldAbsent"; unionId: string; type: IrType; loc: SrcLoc } /** Union construction: wrap an arm value into a fresh tagged box (the * frontend inserts these wherever a `B` flows into an `A | B` slot). * `tag` is the arm's index in the union's canonical arm list; `value` has diff --git a/packages/compiler/src/ir/traverse.ts b/packages/compiler/src/ir/traverse.ts index 6eb7160a2..6f2eb6bf9 100644 --- a/packages/compiler/src/ir/traverse.ts +++ b/packages/compiler/src/ir/traverse.ts @@ -302,6 +302,10 @@ export function everyExprChild( return expr(node.obj); case "recordOvfHas": return expr(node.obj) && expr(node.key); + case "recordHas": + return expr(node.obj); + case "fieldAbsent": + return true; case "unionWrap": return expr(node.value); case "unionFuncEq": @@ -658,6 +662,10 @@ export function mapExprChildren( return { ...node, obj: expr(node.obj) }; case "recordOvfHas": return { ...node, obj: expr(node.obj), key: expr(node.key) }; + case "recordHas": + return { ...node, obj: expr(node.obj) }; + case "fieldAbsent": + return node; case "unionWrap": return { ...node, value: expr(node.value) }; case "unionFuncEq": diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 51e826385..b0c1e898c 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -1443,6 +1443,91 @@ test.each(["receiver", "key", "result"] as const)( }, ); +function fieldPresenceModule(absentInLiteral: boolean): IrModule { + const optional: IrType = { kind: "union", unionId: "maybeCount" }; + const record: IrType = { kind: "record", shapeId: "item" }; + const absent: IrExpr = { kind: "fieldAbsent", unionId: "maybeCount", type: optional, loc }; + const literal: IrExpr = { + kind: "recordLit", + fields: [ + { + name: "count", + value: absentInLiteral + ? { + kind: "ternary", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + then: { + kind: "unionWrap", + unionId: "maybeCount", + tag: 0, + value: { kind: "numLit", value: 1, type: F64, loc }, + type: optional, + loc, + }, + else_: absent, + type: optional, + loc, + } + : { + kind: "unionWrap", + unionId: "maybeCount", + tag: 1, + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type: optional, + loc, + }, + }, + ], + type: record, + loc, + }; + const check: IrExpr = { + kind: "recordHas", + obj: literal, + shapeId: "item", + field: "count", + type: BOOL, + loc, + }; + const mod = expressionModule(check, [{ id: "maybeCount", arms: [F64, UNDEFINED_T] }]); + mod.records = [{ id: "item", fields: [{ name: "count", type: optional }] }]; + if (!absentInLiteral) mod.functions[0]!.body.push({ kind: "exprStmt", expr: absent, loc }); + return mod; +} + +test("field presence checks and absent field states validate and serialize", () => { + const mod = fieldPresenceModule(true); + expect(validateModule(mod)).toEqual([]); + expect(deserializeModule(serializeModule(mod))).toEqual(mod); +}); + +test("the absent field state is confined to field writes", () => { + expect( + validateModule(fieldPresenceModule(false)).some((error) => + error.message.includes("fieldAbsent outside a record field write"), + ), + ).toBe(true); +}); + +test("the absent field state requires an undefined arm", () => { + const mod = fieldPresenceModule(true); + mod.unions = [{ id: "maybeCount", arms: [F64, STRING] }]; + expect( + validateModule(mod).some((error) => error.message.includes("the union has no undefined arm")), + ).toBe(true); +}); + +test("field presence checks name a declared field", () => { + const mod = fieldPresenceModule(true); + const statement = mod.functions[0]!.body[0]!; + if (statement.kind !== "exprStmt" || statement.expr.kind !== "recordHas") + throw new Error("fixture"); + statement.expr.field = "missing"; + expect( + validateModule(mod).some((error) => error.message.includes('has no field "missing"')), + ).toBe(true); +}); + test("TDZ locals require a shared box", () => { const mod = tdzModule(); delete mod.functions[0]!.locals[0]!.boxed; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index abec3eb19..e4d6840e1 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -1282,6 +1282,23 @@ function validateFunction( // Keep expression families in separate functions so native bootstrap builds // do not feed LLVM a single control-flow graph containing every IR check. + /** fieldAbsent nodes that sit directly in a field-slot write (a + * recordLit field, recordSet value, recordClone override, or a ternary + * arm in one of those positions) — the only places the absent state may + * appear. */ + const fieldSlotValues = new Set(); + function checkSlotValue(e: IrExpr): void { + const mark = (v: IrExpr): void => { + if (v.kind === "fieldAbsent") fieldSlotValues.add(v); + else if (v.kind === "ternary") { + mark(v.then); + mark(v.else_); + } + }; + mark(e); + checkExpr(e); + } + function checkExpr(e: IrExpr): void { switch (e.kind) { case "numLit": @@ -1354,6 +1371,8 @@ function validateFunction( case "recordKeyGet": case "recordOvfHas": case "recordOvfKeys": + case "recordHas": + case "fieldAbsent": return checkRecordExpr(e); case "dynFrom": case "dynFromJsval": @@ -3041,7 +3060,9 @@ function validateFunction( | "recordGet" | "recordKeyGet" | "recordOvfHas" - | "recordOvfKeys"; + | "recordOvfKeys" + | "recordHas" + | "fieldAbsent"; }, ): void { switch (e.kind) { @@ -3058,7 +3079,8 @@ function validateFunction( const want = recordValidation.get(shape.id)!.initializationFields; const seen = new Set(); for (const f of e.fields) { - checkExpr(f.value); + if (f.drop || f.overflow) checkExpr(f.value); + else checkSlotValue(f.value); if (seen.has(f.name)) err(`recordLit initializes field "${f.name}" twice`, e.loc); seen.add(f.name); if (f.drop) { @@ -3118,7 +3140,7 @@ function validateFunction( const want = recordValidation.get(shape.id)!.initializationFields; const seen = new Set(); for (const f of e.overrides) { - checkExpr(f.value); + checkSlotValue(f.value); if (seen.has(f.name)) err(`recordClone overrides field "${f.name}" twice`, e.loc); seen.add(f.name); const ft = want.get(f.name); @@ -3127,6 +3149,26 @@ function validateFunction( } break; } + case "recordHas": { + checkExpr(e.obj); + const shape = records.get(e.shapeId); + if (!shape) err(`recordHas on undeclared shape "${e.shapeId}"`, e.loc); + else if (!recordValidation.get(e.shapeId)?.fields.has(e.field)) + err(`shape ${e.shapeId} has no field "${e.field}"`, e.loc); + expectType(e.obj, { kind: "record", shapeId: e.shapeId }, "recordHas receiver"); + if (e.type.kind !== "bool") err("recordHas must be bool", e.loc); + break; + } + case "fieldAbsent": { + const def = unions.get(e.unionId); + if (!def) err(`fieldAbsent of undeclared union "${e.unionId}"`, e.loc); + else if (!def.arms.some((arm) => arm.kind === "undefinedT")) + err(`fieldAbsent of ${e.unionId}: the union has no undefined arm`, e.loc); + if (e.type.kind !== "union" || e.type.unionId !== e.unionId) + err("fieldAbsent must have its union type", e.loc); + if (!fieldSlotValues.has(e)) err("fieldAbsent outside a record field write", e.loc); + break; + } case "recordGet": { checkExpr(e.obj); const shape = records.get(e.shapeId); @@ -5925,7 +5967,7 @@ function validateFunction( } case "recordSet": { checkExpr(s.obj); - checkExpr(s.value); + checkSlotValue(s.value); const shape = records.get(s.shapeId); const field = recordValidation.get(s.shapeId)?.fields.get(s.field); if (!shape) err(`recordSet on undeclared shape "${s.shapeId}"`, s.loc); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 803444345..b3c594995 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -14763,6 +14763,36 @@ "/tests/corpus/union-reference-comparison.ts" ], "diags": [] + }, + "/tests/corpus/optional-flag-comparisons.ts": { + "order": [ + "/tests/corpus/optional-flag-comparisons.ts" + ], + "diags": [] + }, + "/tests/corpus/record-field-presence.ts": { + "order": [ + "/tests/corpus/record-field-presence.ts" + ], + "diags": [] + }, + "/tests/corpus/record-presence-copies.ts": { + "order": [ + "/tests/corpus/record-presence-copies.ts" + ], + "diags": [] + }, + "/tests/corpus/record-presence-delete-json.ts": { + "order": [ + "/tests/corpus/record-presence-delete-json.ts" + ], + "diags": [] + }, + "/tests/corpus/record-unchecked-read-presence.ts": { + "order": [ + "/tests/corpus/record-unchecked-read-presence.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/1531-delete-optional-fields.ts b/tests/corpus/1531-delete-optional-fields.ts index cc76b6e1a..7132fbb2f 100644 --- a/tests/corpus/1531-delete-optional-fields.ts +++ b/tests/corpus/1531-delete-optional-fields.ts @@ -1,7 +1,7 @@ -// `delete r.f` on a declared OPTIONAL field: the slot takes the undefined -// arm — and because absence IS the undefined arm, every observable answer -// after the delete (reads, `in`, Object.keys, JSON.stringify) matches -// Node's post-delete state exactly (the route-metadata patch idiom: +// `delete r.f` on a declared OPTIONAL field: the property becomes absent, +// so every observable answer after the delete (reads, `in`, Object.keys, +// JSON.stringify) matches Node's post-delete state +// (the route-metadata patch idiom: // `if (patch.url === null) delete route.url; else route.url = patch.url`). // Field names are alphabetical (divergence 16's corpus convention), and // post-call reads go through a helper so tsc's stale property narrowing diff --git a/tests/corpus/2364-in-runtime-key.ts b/tests/corpus/2364-in-runtime-key.ts index d81d0b73b..d0ea3fe0b 100644 --- a/tests/corpus/2364-in-runtime-key.ts +++ b/tests/corpus/2364-in-runtime-key.ts @@ -1,8 +1,8 @@ // `in` with a RUNTIME string key over an INDEX-SIGNATURE record — the // service-config idiom (`names.filter((k) => k in config)`): the interned // %rec.haskey helper answers declared names statically per name (optional -// slots per VALUE — the undefined arm reads absent, stance 55) and then -// walks the overflow map's live keys. +// slots by their runtime presence) and then walks the overflow map's live +// keys. interface Seed { tokens?: Record; [service: string]: unknown; diff --git a/tests/corpus/2448-entries-null-arms-regex-arrays.ts b/tests/corpus/2448-entries-null-arms-regex-arrays.ts index c03260006..25e4e4cd5 100644 --- a/tests/corpus/2448-entries-null-arms-regex-arrays.ts +++ b/tests/corpus/2448-entries-null-arms-regex-arrays.ts @@ -7,7 +7,7 @@ // the mixed-defaults spread idiom (`{ ...defaults, ...overrides }` where a // default is null and the merged field types optional): a unit-armed field // with null as its ONE value arm pushes the null literal, guarded by the -// undefined skip (an unset optional never made it into the object). +// field's presence (an unset optional never made it into the object). interface MergedDefaults { rangeStart: number; parser: string; @@ -33,7 +33,7 @@ for (const [k, v] of Object.entries(bothSet)) { console.log(`${k}=${String(v)}`); } -// Neither present: the undefined guard skips both keys, exactly Node's +// Neither present: the presence guard skips both keys, exactly Node's // missing-key answer for unset optionals. const noneSet: MergedDefaults = { rangeStart: 7, parser: "meriyah" }; console.log(Object.keys(noneSet).join(",")); diff --git a/tests/corpus/535-object-statics.ts b/tests/corpus/535-object-statics.ts index b2d5dea91..87490a443 100644 --- a/tests/corpus/535-object-statics.ts +++ b/tests/corpus/535-object-statics.ts @@ -2,8 +2,8 @@ // is compile-time-known; the ORDER is the shape's declaration order, which // matches Node whenever objects are constructed in declaration order (the // literals here are — the divergence for reordered construction is -// SEMANTICS.md 36). Fields holding the undefined arm (unset optionals) are -// skipped at runtime, Node's missing-key behavior. +// SEMANTICS.md 36). Omitted optional fields are skipped at runtime, Node's +// missing-key behavior. interface Point { y: number; x: number; diff --git a/tests/corpus/optional-flag-comparisons.ts b/tests/corpus/optional-flag-comparisons.ts new file mode 100644 index 000000000..2f3365372 --- /dev/null +++ b/tests/corpus/optional-flag-comparisons.ts @@ -0,0 +1,60 @@ +// Comparing optional boolean and number fields against literals: absent, +// explicit undefined, true and false each answer like JS, through aliases +// and getters, with left-to-right evaluation. +interface Toggle { + enabled?: boolean; + level?: number; +} + +function classify(t: Toggle): string { + return [ + t.enabled === true, + t.enabled === false, + t.enabled !== true, + t.enabled === undefined, + true === t.enabled, + t.level === 0, + t.level !== 2, + "enabled" in t, + ].join(","); +} + +const absent: Toggle = {}; +const cleared: Toggle = { enabled: undefined, level: undefined }; +const on: Toggle = { enabled: true, level: 2 }; +const off: Toggle = { enabled: false, level: 0 }; +console.log(classify(absent)); +console.log(classify(cleared)); +console.log(classify(on)); +console.log(classify(off)); + +// Mutation through an alias is visible to the next comparison. +const alias: Toggle = on; +alias.enabled = false; +console.log(on.enabled === true, on.enabled === false); +delete alias.enabled; +console.log(on.enabled === false, on.enabled === undefined, "enabled" in on); +alias.enabled = true; +console.log(on.enabled === true); + +// Counting in a loop over mixed values. +const toggles: Toggle[] = [absent, cleared, on, off, { enabled: true }]; +let count = 0; +for (let round = 0; round < 1000; round++) { + for (const t of toggles) if (t.enabled === true) count++; +} +console.log(count); + +// Getter reads evaluate once, before the literal side. +const order: string[] = []; +const dynamic = { + get enabled(): boolean | undefined { + order.push("get"); + return order.length > 1; + }, +}; +function literal(): boolean { + order.push("literal"); + return true; +} +console.log(dynamic.enabled === true, dynamic.enabled === literal(), order.join(",")); diff --git a/tests/corpus/record-field-presence.ts b/tests/corpus/record-field-presence.ts new file mode 100644 index 000000000..91383df4a --- /dev/null +++ b/tests/corpus/record-field-presence.ts @@ -0,0 +1,73 @@ +// Own-property presence of optional record fields: an omitted field is +// absent, while a field written with an explicit undefined is present. +// Every key surface agrees: `in`, Object.keys/values/entries, hasOwn, +// for-in, inspection, deep equality, and JSON. +import * as assert from "node:assert"; +import * as util from "node:util"; + +interface Item { + sku: string; + count?: number; + note?: string; +} + +function keysOf(item: Item): string { + const out: string[] = []; + for (const key in item) out.push(key); + return out.join("|"); +} + +function report(label: string, item: Item): void { + console.log(label, "count" in item, "note" in item, Object.hasOwn(item, "note")); + console.log(" keys", Object.keys(item), keysOf(item)); + console.log(" values", Object.values(item)); + console.log(" entries", Object.entries(item)); + console.log(" log", item); + console.log(" inspect", util.inspect(item), JSON.stringify(item)); +} + +const omitted: Item = { sku: "a-1" }; +const explicitNote: Item = { sku: "a-2", note: undefined }; +const full: Item = { sku: "a-3", count: 4, note: "boxed" }; +const explicitBoth: Item = { sku: "a-4", count: undefined, note: undefined }; +report("omitted", omitted); +report("explicit", explicitNote); +report("full", full); +report("both", explicitBoth); + +// A required field whose type admits undefined is always present. +interface Slot { + value: number | undefined; +} +const slot: Slot = { value: undefined }; +console.log("value" in slot, Object.keys(slot).join(","), slot); + +// Optional parameters forwarded into an explicit property stay present. +function make(sku: string, count?: number): Item { + return { sku, count }; +} +console.log(make("b-1"), "count" in make("b-1"), Object.keys(make("b-2", 2))); + +// Deep equality compares own keys: absent and present-undefined differ. +console.log(util.isDeepStrictEqual(omitted, { sku: "a-1" } as Item)); +console.log(util.isDeepStrictEqual(omitted, { sku: "a-1", note: undefined } as Item)); +try { + assert.deepStrictEqual(explicitNote, { sku: "a-2" } as Item); + console.log("deep equal"); +} catch { + console.log("deep differ"); +} + +// Nested records keep presence per object. +const nested = { first: omitted, rest: [explicitNote, full] }; +console.log(nested); +console.log(JSON.stringify(nested)); + +// A record whose optional fields are all absent inspects as {}. +interface Filters { + tag?: string; + min?: number; +} +const none: Filters = {}; +const some: Filters = { tag: undefined }; +console.log(none, some, util.inspect({ inner: none }), Object.keys(none).length); diff --git a/tests/corpus/record-presence-copies.ts b/tests/corpus/record-presence-copies.ts new file mode 100644 index 000000000..8a4b0d789 --- /dev/null +++ b/tests/corpus/record-presence-copies.ts @@ -0,0 +1,73 @@ +// Copies keep each optional field's presence: object spread, rest +// bindings, Object.assign, conversion to a narrower declared type, +// structuredClone, and conversion to an untyped value. +interface Shape { + kind: string; + color?: string; + size?: number; +} + +function describe(label: string, shape: Shape): void { + console.log(label, Object.keys(shape).join(","), "color" in shape, JSON.stringify(shape), shape); +} + +const plain: Shape = { kind: "square" }; +const cleared: Shape = { kind: "circle", color: undefined }; +const styled: Shape = { kind: "star", color: "red", size: 3 }; + +// Same-shape spreads, with and without overrides. +describe("copy-plain", { ...plain }); +describe("copy-cleared", { ...cleared }); +describe("override", { ...plain, color: undefined }); +describe("override-size", { ...cleared, size: 9 }); + +// Merging over defaults: an absent field keeps the earlier value, a +// present undefined replaces it. +const defaults: Shape = { kind: "base", color: "gray", size: 1 }; +describe("merge-plain", { ...defaults, ...plain }); +describe("merge-cleared", { ...defaults, ...cleared }); +describe("merge-styled", { ...defaults, ...styled }); +describe("merge-reverse", { ...cleared, ...defaults }); + +// Optional spread sources. +function withOverrides(overrides?: Shape): Shape { + return { ...defaults, ...overrides }; +} +describe("optional-none", withOverrides()); +describe("optional-cleared", withOverrides(cleared)); +describe("optional-plain", withOverrides(plain)); + +// Rest bindings copy only the remaining own properties. +const { kind: k1, ...rest1 } = plain; +const { kind: k2, ...rest2 } = cleared; +const { kind: k3, ...rest3 } = styled; +console.log(k1, rest1, Object.keys(rest1)); +console.log(k2, rest2, Object.keys(rest2)); +console.log(k3, rest3, Object.keys(rest3)); + +// Object.assign copies own properties only. +const target1: Shape = { kind: "t1", color: "blue" }; +Object.assign(target1, plain); +describe("assign-plain", target1); +const target2: Shape = { kind: "t2", color: "blue" }; +Object.assign(target2, cleared); +describe("assign-cleared", target2); + +// A narrower declared type keeps presence. +interface Tinted { + color?: string; +} +function tintOf(t: Tinted): string { + return `${"color" in t} ${Object.hasOwn(t, "color")} ${t.color}`; +} +console.log(tintOf(plain), "/", tintOf(cleared), "/", tintOf(styled)); + +// structuredClone and untyped views. +const clonedPlain = structuredClone(plain); +const clonedCleared = structuredClone(cleared); +describe("clone-plain", clonedPlain); +describe("clone-cleared", clonedCleared); +const untypedPlain: unknown = plain; +const untypedCleared: unknown = cleared; +console.log(untypedPlain, untypedCleared); +console.log(Object.keys(untypedPlain as object), Object.keys(untypedCleared as object)); diff --git a/tests/corpus/record-presence-delete-json.ts b/tests/corpus/record-presence-delete-json.ts new file mode 100644 index 000000000..e7041b85e --- /dev/null +++ b/tests/corpus/record-presence-delete-json.ts @@ -0,0 +1,50 @@ +// delete makes an optional field absent; a later write (undefined +// included) makes it present again. JSON round trips decide presence +// from the actual keys. (`at` is declared last, so re-adding it keeps +// the same key order as JS insertion order.) +interface Event { + name: string; + tags?: string[]; + at?: number; +} + +function show(label: string, e: Event): void { + console.log(label, "at" in e, Object.hasOwn(e, "at"), Object.keys(e).join(","), JSON.stringify(e), e); +} + +const e: Event = { name: "launch", tags: ["x"], at: 10 }; +show("start", e); +delete e.at; +show("deleted", e); +e.at = undefined; +show("undefined", e); +e.at = 12; +show("set", e); +delete e.tags; +delete e.at; +show("both-deleted", e); +delete e.at; +show("delete-again", e); +e.tags = []; +show("tags", e); + +// Presence survives aliases: a delete through one reference is seen by +// the other. +const alias: Event = e; +delete alias.tags; +show("alias", e); + +// JSON.parse into a declared shape: missing keys stay missing. +const parsed = JSON.parse('{"name":"stop","tags":["a","b"]}') as Event; +show("parsed", parsed); +const parsedAt = JSON.parse('{"name":"tick","at":3}') as Event; +show("parsed-at", parsedAt); +const parsedList = JSON.parse('[{"name":"a"},{"name":"b","at":1}]') as Event[]; +console.log(parsedList.map((item: Event) => Object.keys(item).join(",")).join(" ")); + +// Stringify drops both absent and undefined-valued fields, so the round +// trip of a present undefined comes back absent, as in JS. +const roundTrip = JSON.parse(JSON.stringify({ name: "r", at: undefined } as Event)) as Event; +show("round-trip", roundTrip); +const kept = JSON.parse(JSON.stringify({ name: "k", tags: [], at: 0 } as Event)) as Event; +show("kept", kept); diff --git a/tests/corpus/record-unchecked-read-presence.ts b/tests/corpus/record-unchecked-read-presence.ts new file mode 100644 index 000000000..716333064 --- /dev/null +++ b/tests/corpus/record-unchecked-read-presence.ts @@ -0,0 +1,15 @@ +// Fields initialized from unchecked array reads are explicit properties +// even when the read lands outside the array: the key exists and holds +// undefined. +const source: number[] = [5, 6]; +const pair = { first: source[0], missing: source[7] }; +console.log("missing" in pair, Object.hasOwn(pair, "missing"), Object.keys(pair).join(",")); +console.log(Object.values(pair), pair); +console.log(JSON.stringify(pair)); +const seen: string[] = []; +for (const key in pair) seen.push(key); +console.log(seen.join("|")); + +const words: string[] = ["alpha"]; +const label = { head: words[0], tail: words[3] }; +console.log("tail" in label, Object.keys(label), label, label.tail === undefined); From fcee23f39867c34089c731b53b99f15227f12166 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 10:15:30 -0500 Subject: [PATCH 07/14] Preserve function identity through adapted callbacks (#737) --- docs/content/docs/limitations.mdx | 2 +- packages/compiler/src/backend/llvm/common.ts | 13 ++++ packages/compiler/src/backend/llvm/dyn.ts | 4 + packages/compiler/src/backend/llvm/emitter.ts | 4 +- .../compiler/src/backend/llvm/expr-calls.ts | 16 ++++ .../compiler/src/backend/llvm/expr-dynamic.ts | 24 ++++-- .../src/backend/llvm/expr-primitives.ts | 12 ++- .../src/backend/llvm/stack-callbacks.ts | 2 +- .../frontend/lowering/coercions/builders.ts | 9 ++- .../src/frontend/lowering/lower-calls.ts | 14 +++- .../src/frontend/lowering/lower-exprs.ts | 67 ++++++++++++++++- .../src/frontend/lowering/native-ffi.ts | 4 +- packages/compiler/src/ir/ir.ts | 14 +++- packages/compiler/src/ir/validate.test.ts | 48 ++++++++++++ packages/compiler/src/ir/validate.ts | 3 + .../test/ts7/baselines/order-parity.json | 31 ++++++++ packages/runtime/src/scr_array.c | 2 + packages/runtime/src/scr_closure.c | 12 +++ packages/runtime/src/scr_dc.c | 3 +- packages/runtime/src/scr_events.c | 2 +- packages/runtime/src/scr_events_emitter.c | 4 +- packages/runtime/src/scr_json.c | 63 +++++++++------- packages/runtime/src/scr_map.c | 2 +- packages/runtime/src/scr_runtime.h | 27 +++++++ packages/runtime/test/test_closure.c | 18 +++++ .../corpus/4040-adapted-function-identity.ts | 75 +++++++++++++++++++ .../4041-adapted-function-properties.ts | 26 +++++++ tests/corpus/4042-object-is-union-subclass.ts | 16 ++++ tests/corpus/4043-nonnull-missing-element.ts | 30 ++++++++ .../4044-adapted-callback-modules/main.ts | 24 ++++++ .../4044-adapted-callback-modules/registry.ts | 36 +++++++++ 31 files changed, 554 insertions(+), 53 deletions(-) create mode 100644 tests/corpus/4040-adapted-function-identity.ts create mode 100644 tests/corpus/4041-adapted-function-properties.ts create mode 100644 tests/corpus/4042-object-is-union-subclass.ts create mode 100644 tests/corpus/4043-nonnull-missing-element.ts create mode 100644 tests/corpus/4044-adapted-callback-modules/main.ts create mode 100644 tests/corpus/4044-adapted-callback-modules/registry.ts diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 6cdc2464d..32114272e 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -27,7 +27,7 @@ The following sections describe supported forms and their remaining restrictions - **Resource disposal:** Top-level and switch-clause `using` declarations, disposal through an engine-held package value, and `DisposableStack` constructors are unsupported. Block- and function-scoped `using` and `await using` support compiled classes with zero-parameter disposal methods and supported FileHandle, timer, immediate, child-process, and readline handles. Array `for (using ... of ...)` loops also compile. Caught `SuppressedError` values expose `name` and `message`, but not `error` or `suppressed` payloads. - **Async iteration:** Async `yield*` delegation, stored Web or Node iterator handles, and general structural async-iterator objects are unsupported. Typed async generators support lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also accepts Node `Readable` streams, Web `ReadableStream` values, and compiled class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record. Abrupt completion runs cleanup, including literal `destroyOnReturn` and `preventCancel` options. The `node:timers/promises` `setInterval(delay, value)` iterator compiles without AbortSignal options. - **Generics:** Generic functions declared inside other functions, generic class expressions, and generic methods requiring dynamic dispatch are unsupported. Generic classes can extend generic bases when each specialization has a concrete layout. Independent concrete uses have no specialization-count limit; recursively expanding demands and type layouts produce diagnostics. Other generic calls compile when the target resolves statically. Generic function values require a concrete signature at use and a binding that is never reassigned. -- **Function values:** Immutable aliases of overloaded program functions retain per-call overload resolution and identity. Typed rest-parameter functions can be stored and called indirectly. Builtin function values have API-specific restrictions; see [Builtin function values](#builtin-function-values). +- **Function values:** Immutable aliases of overloaded program functions retain per-call overload resolution and identity. A function stored in a slot with a different signature remains the same function for equality, array searches, `length`, and own properties. Typed rest-parameter functions can be stored and called indirectly. Builtin function values have API-specific restrictions; see [Builtin function values](#builtin-function-values). - **Argument spreads:** Non-empty fixed tuples can spread into fixed signatures; arrays, Sets, and compiled class iterables can spread into typed rest parameters. Runtime-length spreads into fixed or rest parameters require types that support checked `unknown` conversion. Other JavaScript calls use the checked-value or embedded-engine path; unsupported static forms produce a diagnostic. Calls with no native parameter slot are unsupported for runtime-length spreads. Arguments evaluate once, in source order, before defaults run, including surplus arguments. - **Dynamic imports:** Computed specifiers, import attributes, CommonJS namespace synthesis, and imports of shipped JavaScript packages are unsupported in static code or require `--dynamic`. Literal `import()` of compiled ESM/TypeScript modules and supported Node builtins compiles without the engine. Evaluation is lazy and microtask-delayed, top-level `await` is honored, repeated imports share namespace identity, exported mutable bindings stay live, and namespace keys use Node's sorted order. diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts index f57576d6f..913502b03 100644 --- a/packages/compiler/src/backend/llvm/common.ts +++ b/packages/compiler/src/backend/llvm/common.ts @@ -7,6 +7,7 @@ import type { IrFfiReturnClass, IrFfiValueParamClass, } from "../../ir/ir.js"; +import type { LlvmEmitterContext } from "./expr-context.js"; /** User-controlled text embedded after an LLVM `;` comment marker. Preserve * ordinary output byte-for-byte, but encode control and line-separator code @@ -121,3 +122,15 @@ export function emitFieldAbsentTest(B: BlockBuilder, value: string, undefinedTag B.line(`${absent} = and i1 ${isUndef}, ${marked}`); return absent; } + +/** The right-hand side of a JS function-identity comparison: two closures + * are one function when their identity roots match (a signature adapter + * stands for the function it adapts). Both operands must be closures. */ +export function closureIdentityEqual( + host: LlvmEmitterContext, + left: string, + right: string, +): string { + host.declare(`declare zeroext i1 @scr_closure_identity_equal(ptr, ptr)`); + return `call zeroext i1 @scr_closure_identity_equal(ptr ${left}, ptr ${right})`; +} diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index d52d0bf5c..024baebcd 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1764,6 +1764,10 @@ export class LlDyn { B.line(`store ptr ${box}, ptr ${capp}`); const rd = this.retainDyn(B, "%d"); B.line(`call void @scr_box_set_ref(ptr ${box}, ptr ${rd})`); + // The shim is the same JS function under another signature: it + // takes the boxed closure's identity (kept alive through caps[0]). + host.declare(`declare void @scr_dyn_adopt_identity(ptr, ptr)`); + B.line(`call void @scr_dyn_adopt_identity(ptr ${a}, ptr %d)`); B.terminate(`ret ptr ${a}`); break; } diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 56b0d491a..cac477795 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1554,7 +1554,7 @@ export class LlEmitter { `%ScrLogArg = type { i32, i64 }`, `%ScrVt = type { ${this.sizeType}, ${this.sizeType}, ptr }`, `%ScrUnion = type { ${this.sizeType}, i32, ptr, ptr, ptr, i64 }`, - `%ScrClosure = type { ${this.sizeType}, ptr, ${this.sizeType}, ptr, i32 }`, + `%ScrClosure = type { ${this.sizeType}, ptr, ${this.sizeType}, ptr, i32, i32, ptr }`, `%ScrFfiTable = type { ptr, ${this.sizeType}, ${this.sizeType}, ptr, i8, ptr, ptr, ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ptr }`, `%ScrRegex = type { ${this.sizeType}, ptr, ptr, ptr, double, ptr }`, // ScrArr mirrors scr_runtime.h field-for-field. Live dynamic stream @@ -2449,7 +2449,7 @@ export class LlEmitter { ret === "void" ? ` ${call}` : ` %r = ${call}`, ret === "void" ? ` ret void` : ` ret ${ret} %r`, `}`, - `@${mangleFnClosure(name)} = internal ${this.mod.workers === true || this.mod.lib?.threadInstances === true ? "thread_local " : ""}global %ScrClosure { ${this.sizeType} -1, ptr @${mangleWrapper(name)}, ${this.sizeType} 0, ptr null, i32 ${(fn.generator ? 1 : 0) + (fn.async ? 2 : 0) + (fn.ownsPrototype ? 4 : 0)} }`, + `@${mangleFnClosure(name)} = internal ${this.mod.workers === true || this.mod.lib?.threadInstances === true ? "thread_local " : ""}global %ScrClosure { ${this.sizeType} -1, ptr @${mangleWrapper(name)}, ${this.sizeType} 0, ptr null, i32 ${(fn.generator ? 1 : 0) + (fn.async ? 2 : 0) + (fn.ownsPrototype ? 4 : 0)}, i32 0, ptr null }`, ``, ); } diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 976d0309b..811f5e8e9 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -595,6 +595,22 @@ export function emitCallExpr( `${capp} = getelementptr inbounds ptr, ptr ${caps}, ${host.sizeType} ${i} ; caps[${i}]`, ); B.line(`store ptr ${retained}, ptr ${capp}`); + if (i === 0 && e.adapts === true) { + // A signature adapter stands for the function it forwards to. + const slot = B.tmp(); + const original = B.tmp(); + B.line(`${slot} = getelementptr inbounds %ScrBox, ptr ${box}, i64 0, i32 5`); + B.line(`${original} = load ptr, ptr ${slot}`); + // Boxed adapters report the original's length (its static + // arity, as the original's own boxes do). + const source = target.captures?.[0]?.type; + const length = + source?.kind === "func" + ? source.params.length - (source.restAbi === "typed" ? 1 : 0) + : 0; + host.declare(`declare void @scr_closure_adopt_identity(ptr, ptr, i32)`); + B.line(`call void @scr_closure_adopt_identity(ptr ${c}, ptr ${original}, i32 ${length})`); + } }); return out; } diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 37ad1776d..adb517326 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -14,7 +14,7 @@ import { typeEquals, typeKey, } from "../../ir/ir.js"; -import { BYTES_ELEM_NUM } from "./common.js"; +import { BYTES_ELEM_NUM, closureIdentityEqual } from "./common.js"; import { DYN_KIND } from "./dyn.js"; import { elemAccess, vAdapters } from "./shapes.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -807,10 +807,12 @@ export function emitDynamicExpr( } default: { // Ref arms: pointer identity, exactly JS object equality. + // Function arms compare identity roots (adapters included). const a = host.unionPeek(l.name); const b = host.unionPeek(r.name); const t = B.tmp(); - B.line(`${t} = icmp eq ptr ${a}, ${b} ; ${arm.kind}`); + if (arm.kind === "func") B.line(`${t} = ${closureIdentityEqual(host, a, b)}`); + else B.line(`${t} = icmp eq ptr ${a}, ${b} ; ${arm.kind}`); B.line(`store i1 ${t}, ptr ${slot}`); break; } @@ -832,11 +834,23 @@ export function emitDynamicExpr( const tag = host.unionTag(u.name); const tagMatch = B.tmp(); B.line(`${tagMatch} = icmp eq i32 ${tag}, ${e.tag}`); + // The payload is a closure only under the function tag; another + // arm's payload must never be read as one. + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca i1`); + B.line(`store i1 false, ptr ${slot}`); + const check = B.newLabel("ufe.c"); + const join = B.newLabel("ufe.j"); + B.condBr(tagMatch, check, join); + B.startBlock(check); const payload = host.unionPeek(u.name); - const ptrMatch = B.tmp(); - B.line(`${ptrMatch} = icmp eq ptr ${payload}, ${f.name}`); + const identical = B.tmp(); + B.line(`${identical} = ${closureIdentityEqual(host, payload, f.name)}`); + B.line(`store i1 ${identical}, ptr ${slot}`); + B.br(join); + B.startBlock(join); const result = B.tmp(); - B.line(`${result} = and i1 ${tagMatch}, ${ptrMatch}`); + B.line(`${result} = load i1, ptr ${slot}`); if (!e.negated) return host.own({ name: result, type: e.type }); const negated = B.tmp(); B.line(`${negated} = xor i1 ${result}, true`); diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 2db2d5bff..2d12c1370 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -7,7 +7,7 @@ import { arrNewCall, elemAccess } from "./shapes.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js"; -import { BYTES_ELEM_NUM, f64Lit } from "./common.js"; +import { BYTES_ELEM_NUM, closureIdentityEqual, f64Lit } from "./common.js"; import { emitStringInputs } from "./string-lifetimes.js"; import { emitStringParts, stringParts } from "./string-construction.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; @@ -188,6 +188,16 @@ export function emitOperatorExpr( `${equal} = call zeroext i1 @scr_promise_identity_equal(ptr ${l.name}, ptr ${r.name})`, ); if (e.op === "!==") B.line(`${t} = xor i1 ${equal}, true`); + } else if ( + (e.op === "===" || e.op === "!==") && + e.left.type.kind === "func" && + e.right.type.kind === "func" + ) { + // A signature adapter stands for the function it adapts, so + // function identity compares the closures' identity roots. + const equal = e.op === "===" ? t : B.tmp(); + B.line(`${equal} = ${closureIdentityEqual(host, l.name, r.name)}`); + if (e.op === "!==") B.line(`${t} = xor i1 ${equal}, true`); } else if ((e.op === "===" || e.op === "!==") && host.llType(e.left.type) === "ptr") { // Reference identity (JS object equality) — closures, arrays, // records compared as pointers, exactly the C `==`. diff --git a/packages/compiler/src/backend/llvm/stack-callbacks.ts b/packages/compiler/src/backend/llvm/stack-callbacks.ts index 2c58b326a..221e06276 100644 --- a/packages/compiler/src/backend/llvm/stack-callbacks.ts +++ b/packages/compiler/src/backend/llvm/stack-callbacks.ts @@ -19,7 +19,7 @@ export class StackCallbacks { emit(host: LlvmEmitterContext, value: IrExpr): LlValue | null { if (value.kind !== "closure" || value.captures.length > 32) return null; - const bytes = (5 + value.captures.length) * (host.sizeType === "i64" ? 8 : 4); + const bytes = (6 + value.captures.length) * (host.sizeType === "i64" ? 8 : 4); if (bytes > this.remaining || !this.eligible(value.fnName)) return null; this.remaining -= bytes; const B = host.B; diff --git a/packages/compiler/src/frontend/lowering/coercions/builders.ts b/packages/compiler/src/frontend/lowering/coercions/builders.ts index 20d9f90e4..71b7f102e 100644 --- a/packages/compiler/src/frontend/lowering/coercions/builders.ts +++ b/packages/compiler/src/frontend/lowering/coercions/builders.ts @@ -95,7 +95,14 @@ export function buildFunctionAdapter( body: [ { kind: "return", - value: { kind: "closure", fnName: implementation, captures: ["f.0"], type: target, loc }, + value: { + kind: "closure", + fnName: implementation, + captures: ["f.0"], + adapts: true, + type: target, + loc, + }, loc, }, ], diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index f818c8257..6e3a27fd6 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -7161,9 +7161,17 @@ function lowerObjectStaticCall( const plain = left.type.kind === "union" ? right : left; const arms = lowerer.unions.get(ut.unionId)?.arms ?? []; // The plain side wraps into the union exactly like === when the - // union holds its type; a plain PRIMITIVE the union has no arm - // for is the disjoint constant false (coercing it would strand). - if (bothUnion || arms.some((a) => typeEquals(a, plain.type))) { + // union holds its type, or when a plain class instance upcasts + // into exactly one class arm (the same pointer under a base tag); + // a plain PRIMITIVE the union has no arm for is the disjoint + // constant false (coercing it would strand). + const plainType = plain.type; + const upcastArm = + plainType.kind === "object" && + arms.filter( + (a) => a.kind === "object" && lowerer.isSubclassOf(plainType.className, a.className), + ).length === 1; + if (bothUnion || upcastArm || arms.some((a) => typeEquals(a, plain.type))) { if (tagEqualityMayMissAlias(lowerer, ut)) lowerer.noLowering( "Object.is over related class values beside structural union members", diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 544e47b89..4af4e69ab 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -20,6 +20,7 @@ import { literalUnionArm } from "../union-discriminants.js"; * ToBoolean/ToString coercion helpers, and field/element reads and writes * (FieldTarget). */ import * as ts from "../ts7/adapter.js"; +import { constituentTypes } from "../ts7/checker.js"; import { dirname } from "node:path"; import * as posix from "node:path/posix"; import type { Lowerer } from "./lowerer.js"; @@ -492,6 +493,13 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (ts.isNonNullExpression(expr)) { const inner = lowerer.lowerExpr(expr.expression); if (inner.type.kind === "union") { + // `!` performs no runtime check: a missing array element stays + // undefined where its destination can hold it. + if ( + ts.isElementAccessExpression(expr.expression) && + assertedElementKeepsUndefined(lowerer, expr) + ) + return inner; const use = runtimeOptionalUseOf(expr); if (runtimeOptionalAssertionErases(lowerer, expr, inner, use)) return inner; const target = lowerer.mapTypeOf(lowerer.typeOf(expr)); @@ -4015,6 +4023,53 @@ function runtimeOptionalElementKey(expr: ts.Expression): string | null { return null; } +/** Whether `xs[i]!` lands where an absent element's undefined is + * representable: array storage (a literal element, a push/unshift + * argument, an element assignment — arrays record the undefined state), + * an equality operand, or a slot whose contextual type admits undefined. + * TypeScript's `!` has no runtime effect, so such reads stay optional. */ +function assertedElementKeepsUndefined(lowerer: Lowerer, node: ts.NonNullExpression): boolean { + let outer: ts.Expression = node; + while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; + const parent = outer.parent; + if (ts.isArrayLiteralExpression(parent)) return true; + // An equality test observes the missing element itself. + if ( + ts.isBinaryExpression(parent) && + (parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken) + ) + return true; + const isArray = (receiver: ts.Expression): boolean => + lowerer.checker.isArrayType(lowerer.typeOf(receiver)); + if ( + ts.isCallExpression(parent) && + parent.arguments.includes(outer) && + ts.isPropertyAccessExpression(parent.expression) && + (parent.expression.name.text === "push" || parent.expression.name.text === "unshift") && + isArray(parent.expression.expression) + ) + return true; + if ( + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && + parent.right === outer && + ts.isElementAccessExpression(parent.left) && + isArray(parent.left.expression) + ) + return true; + const contextual = lowerer.checker.getContextualType(outer); + if (!contextual) return false; + const admits = (type: ts.Type): boolean => + (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Undefined)) !== 0; + return ( + admits(contextual) || + ((contextual.flags & ts.TypeFlags.Union) !== 0 && constituentTypes(contextual).some(admits)) + ); +} + function runtimeOptionalAssertionErases( lowerer: Lowerer, expr: ts.Expression, @@ -6910,10 +6965,14 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre // change the storage representation or the low-level getter itself. if (arr.type.kind === "array") { // A non-null assertion is the explicit proven-present form. Preserve - // the dense getter's established bounds trap here; turning `xs[i]!` - // into an optional union would change library ABI checks and the - // existing catchable RangeError contract for an out-of-bounds assert. - const provenPresent = ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr; + // the dense getter's established bounds trap where the asserted value + // must be the element type (library ABI checks and the catchable + // RangeError contract for an out-of-bounds assert). Where undefined is + // representable, the assertion keeps JavaScript's unchecked read. + const provenPresent = + ts.isNonNullExpression(expr.parent) && + expr.parent.expression === expr && + !assertedElementKeepsUndefined(lowerer, expr.parent); if (provenPresent) { return lowerer.maybeNarrow( { kind: "arrayGet", arr, index, type: elemT, loc: locOf(expr) }, diff --git a/packages/compiler/src/frontend/lowering/native-ffi.ts b/packages/compiler/src/frontend/lowering/native-ffi.ts index f88f1f669..601af3ae5 100644 --- a/packages/compiler/src/frontend/lowering/native-ffi.ts +++ b/packages/compiler/src/frontend/lowering/native-ffi.ts @@ -492,7 +492,9 @@ export function lowerFfiCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr // Retained identity is the runtime closure pointer. A coercion adapter // would be freshly allocated at registration and release sites, so an // assignable-but-different function shape (notably `() => number` into - // `() => void`) cannot honestly participate in explicit release. The + // `() => void`) cannot honestly participate in explicit release. Script + // identity roots do not help here: native code receives and matches the + // registered context pointer itself, never the script function. The // adapter set comes from the mint sites themselves (Lowerer's // freshClosureAdapters), not name-prefix matching, so a new coercion // helper cannot silently slip past this guard. diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index ca41f3095..cf4cd0604 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2549,8 +2549,18 @@ export type IrExpr = * in the callee's captures[] order). The result is owned (+1); the closure * itself retains each captured box. A reference to a top-level declared * function lowers to a zero-capture closure — backends must intern that - * case so `f === f` is true (JS function identity). */ - | { kind: "closure"; fnName: string; captures: string[]; type: IrType; loc: SrcLoc } + * case so `f === f` is true (JS function identity). `adapts` marks a + * signature adapter: captures[0] holds the function it forwards to, and + * the new closure takes that function's identity (JS sees one function, + * so `===`, SameValue and searches compare identity roots). */ + | { + kind: "closure"; + fnName: string; + captures: string[]; + adapts?: true; + type: IrType; + loc: SrcLoc; + } /** Indirect call of a func-typed value. Args follow `call`'s convention * (callee owns its params, callers pass +1). The callee expression is an * ordinary owned temp, released at statement end. receiver supplies the diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index b0c1e898c..6b5447d7a 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -1792,3 +1792,51 @@ test("abstract virtual slots admit subclass returns without admitting unrelated "in caller1: virtualCall Base.run result type mismatch", ]); }); + +test("signature adapters capture the function whose identity they share", () => { + const source: IrType = { kind: "func", params: [F64], ret: VOID }; + const target: IrType = { kind: "func", params: [F64, F64], ret: VOID }; + const adapter: IrExpr & { kind: "closure" } = { + kind: "closure", + fnName: "view", + captures: ["f.0"], + adapts: true, + type: target, + loc, + }; + const mod = expressionModule(adapter, []); + mod.functions[0]!.locals.push({ + id: "f.0", + name: "f", + type: source, + mutable: false, + boxed: true, + }); + mod.functions.push({ + name: "view", + params: [ + { localId: "a.0", name: "a", type: F64 }, + { localId: "b.0", name: "b", type: F64 }, + ], + captures: [{ localId: "f.0", name: "f", type: source }], + locals: [ + { id: "f.0", name: "f", type: source, mutable: false, boxed: true }, + { id: "a.0", name: "a", type: F64, mutable: false }, + { id: "b.0", name: "b", type: F64, mutable: false }, + ], + returnType: VOID, + body: [], + loc, + }); + expect(validateModule(mod)).toEqual([]); + expect(deserializeModule(serializeModule(mod))).toEqual(mod); + const numeric = structuredClone(mod); + for (const fn of numeric.functions) + for (const local of fn.locals) if (local.id === "f.0") local.type = F64; + numeric.functions[1]!.captures = [{ localId: "f.0", name: "f", type: F64 }]; + expect( + validateModule(numeric).some((error) => + error.message.includes("an adapter's first capture must be a function"), + ), + ).toBe(true); +}); diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index e4d6840e1..62d56c792 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -2672,6 +2672,9 @@ function validateFunction( break; } const wantCaps = target.captures ?? []; + if (e.adapts === true && locals.get(e.captures[0] ?? "")?.type.kind !== "func") { + err(`closure ${e.fnName}: an adapter's first capture must be a function`, e.loc); + } if (target.captures === undefined && e.captures.length > 0) { err(`closure over plain function "${e.fnName}" cannot capture`, e.loc); } diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index b3c594995..a2cb2f6af 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -14793,6 +14793,37 @@ "/tests/corpus/record-unchecked-read-presence.ts" ], "diags": [] + }, + "/tests/corpus/4040-adapted-function-identity.ts": { + "order": [ + "/tests/corpus/4040-adapted-function-identity.ts" + ], + "diags": [] + }, + "/tests/corpus/4041-adapted-function-properties.ts": { + "order": [ + "/tests/corpus/4041-adapted-function-properties.ts" + ], + "diags": [] + }, + "/tests/corpus/4042-object-is-union-subclass.ts": { + "order": [ + "/tests/corpus/4042-object-is-union-subclass.ts" + ], + "diags": [] + }, + "/tests/corpus/4043-nonnull-missing-element.ts": { + "order": [ + "/tests/corpus/4043-nonnull-missing-element.ts" + ], + "diags": [] + }, + "/tests/corpus/4044-adapted-callback-modules/main.ts": { + "order": [ + "/tests/corpus/4044-adapted-callback-modules/registry.ts", + "/tests/corpus/4044-adapted-callback-modules/main.ts" + ], + "diags": [] } } } diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index 86a040ef6..d6317e676 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -1528,6 +1528,8 @@ static bool scr_arr_ref_eq(const ScrArr *a, uint64_t slot, void *v) { if (a->elem == SCR_ELEM_REF && a->elem_retain == scr_dyn_retain_v) { return scr_dyn_strict_eq((ScrDyn *)p, (ScrDyn *)v); } + if (a->elem == SCR_ELEM_REF && a->elem_retain == scr_closure_retain_v) + return scr_closure_identity_equal((ScrClosure *)p, (ScrClosure *)v); return p == v; } diff --git a/packages/runtime/src/scr_closure.c b/packages/runtime/src/scr_closure.c index ce16b463d..affd802e0 100644 --- a/packages/runtime/src/scr_closure.c +++ b/packages/runtime/src/scr_closure.c @@ -192,12 +192,24 @@ ScrClosure *scr_closure_new(void *fn, size_t ncaps) { c->ncaps = ncaps; c->props = NULL; /* lazily allocated by Object.defineProperties */ c->function_kind = 0; + c->identity_length = 0; + c->identity = NULL; #ifdef SCR_RC_AUDIT scr_live_closures++; #endif return c; } +bool scr_closure_identity_equal(ScrClosure *a, ScrClosure *b) { + return scr_closure_identity(a) == scr_closure_identity(b); +} + +void scr_closure_adopt_identity(ScrClosure *adapter, ScrClosure *original, uint32_t length) { + if (adapter == NULL || original == NULL || adapter == original) return; + adapter->identity = scr_closure_identity(original); + adapter->identity_length = original->identity != NULL ? original->identity_length : length; +} + static void scr_closure_destroy(void *object) { ScrClosure *c = object; for (size_t i = 0; i < c->ncaps; i++) scr_box_release(c->caps[i]); diff --git a/packages/runtime/src/scr_dc.c b/packages/runtime/src/scr_dc.c index 148d4f3da..688796872 100644 --- a/packages/runtime/src/scr_dc.c +++ b/packages/runtime/src/scr_dc.c @@ -158,7 +158,8 @@ static void dc_throw_bad_subscriber(const ScrDyn *cb) { * identity otherwise. */ static bool dc_sub_matches(const ScrDyn *a, const ScrDyn *b) { if (a == b) return true; - return a->kind == SCR_DYN_FUNC && b->kind == SCR_DYN_FUNC && a->v.fn.clo == b->v.fn.clo; + return a->kind == SCR_DYN_FUNC && b->kind == SCR_DYN_FUNC && + scr_closure_identity_equal(a->v.fn.clo, b->v.fn.clo); } static void dc_sub_add(ScrDcChannel *ch, ScrDyn *cb) { diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 7af30c5e8..fcd426f18 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1155,7 +1155,7 @@ void scr_process_on_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, doub void scr_process_off_exit(ScrClosure *cb /*borrowed*/) { for (size_t i = 0; i < scr_exit_n; i++) { - if (scr_exit_ls[i].cb == cb) { + if (scr_closure_identity_equal(scr_exit_ls[i].cb, cb)) { scr_closure_release(scr_exit_ls[i].cb); memmove(scr_exit_ls + i, scr_exit_ls + i + 1, (scr_exit_n - i - 1) * sizeof *scr_exit_ls); scr_exit_n--; diff --git a/packages/runtime/src/scr_events_emitter.c b/packages/runtime/src/scr_events_emitter.c index 87d1259a4..f09a4b128 100644 --- a/packages/runtime/src/scr_events_emitter.c +++ b/packages/runtime/src/scr_events_emitter.c @@ -492,7 +492,7 @@ ScrEmitter *scr_emitter_off(ScrEmitter *em, ScrStr *name, ScrClosure *cb) { ScrEeBucket *b = em->reg ? scr_ee_bucket_find(em->reg, name->data, name->len) : NULL; if (b) { for (size_t i = b->n; i-- > 0;) { - if (scr_ee_entry_fn(b->ls[i]) == cb) { + if (scr_closure_identity_equal(scr_ee_entry_fn(b->ls[i]), cb)) { scr_ee_remove_at(em, b, i); break; } @@ -758,7 +758,7 @@ double scr_emitter_listener_count_fn(ScrEmitter *em, ScrStr *name, ScrClosure *f if (!b) return 0; size_t count = 0; for (size_t i = 0; i < b->n; i++) { - if (scr_ee_entry_fn(b->ls[i]) == fn) count++; + if (scr_closure_identity_equal(scr_ee_entry_fn(b->ls[i]), fn)) count++; } return (double)count; } diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 00893e8f5..911f60cae 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1521,7 +1521,7 @@ ScrDyn *scr_dyn_get_prototype(ScrDyn *object) { scr_dyn_release(view); return result; } - if (object->kind == SCR_DYN_FUNC && object->v.fn.clo->props) { + if (object->kind == SCR_DYN_FUNC && scr_closure_identity(object->v.fn.clo)->props) { ScrDyn *table = scr_dyn_fn_properties(object); ScrDyn *result = table->prototype ? scr_dyn_retain(table->prototype) : table->null_proto ? scr_dyn_new_null() : NULL; @@ -1560,7 +1560,7 @@ ScrDyn *scr_dyn_set_prototype(ScrDyn *object, ScrDyn *prototype) { return scr_dyn_retain(object); } if (object->kind == SCR_DYN_FUNC) { - if (prototype->kind == SCR_DYN_FUNC && object->v.fn.clo == prototype->v.fn.clo) { + if (prototype->kind == SCR_DYN_FUNC && scr_closure_identity_equal(object->v.fn.clo, prototype->v.fn.clo)) { static const char message[] = "Cyclic __proto__ value"; scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); return NULL; @@ -1826,8 +1826,8 @@ ScrDyn *scr_dyn_own_descriptor(const ScrDyn *value, const ScrStr *key) { return result; } if (value->kind == SCR_DYN_FUNC) { - if (value->v.fn.clo->props) { - ScrDyn *table = (ScrDyn *)scr_box_get_ref(value->v.fn.clo->props); + if (scr_closure_identity(value->v.fn.clo)->props) { + ScrDyn *table = (ScrDyn *)scr_box_get_ref(scr_closure_identity(value->v.fn.clo)->props); ScrDyn *desc = table ? scr_dyn_own_descriptor(table, key) : NULL; scr_dyn_release(table); return desc; @@ -2290,8 +2290,8 @@ bool scr_dyn_util_type_is(const ScrDyn *value, const ScrStr *probe) { if (PROBE("isRegExp")) return value->kind == SCR_DYN_HANDLE && value->v.handle.tag == SCR_DYNH_REGEXP; if (PROBE("isPromise")) return value->kind == SCR_DYN_PROMISE; if (PROBE("isProxy")) return value->kind == SCR_DYN_PROXY; - if (PROBE("isAsyncFunction")) return value->kind == SCR_DYN_FUNC && (value->v.fn.clo->function_kind & 2) != 0; - if (PROBE("isGeneratorFunction")) return value->kind == SCR_DYN_FUNC && (value->v.fn.clo->function_kind & 1) != 0; + if (PROBE("isAsyncFunction")) return value->kind == SCR_DYN_FUNC && (scr_closure_identity(value->v.fn.clo)->function_kind & 2) != 0; + if (PROBE("isGeneratorFunction")) return value->kind == SCR_DYN_FUNC && (scr_closure_identity(value->v.fn.clo)->function_kind & 1) != 0; if (PROBE("isGeneratorObject")) return scr_dyn_generator(value); if (PROBE("isNativeError")) { ScrError *error = scr_errdyn_err_of(value); @@ -2529,7 +2529,8 @@ ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, con d->v.fn.thunk = thunk; d->v.fn.sig = sig; d->v.fn.name = name; - d->v.fn.arity = arity; + /* A signature adapter reports the length of the function it adapts. */ + d->v.fn.arity = clo->identity != NULL ? clo->identity_length : arity; d->v.fn.class_obj = NULL; return d; } @@ -2668,7 +2669,7 @@ ScrDyn *scr_dyn_bind(ScrDyn *target, ScrDyn *const *args, size_t argc) { scr_str_release(prefix); scr_str_release(suffix); ScrClosure *closure = scr_closure_new(NULL, 4); - closure->function_kind = target->v.fn.clo->function_kind & 3; + closure->function_kind = scr_closure_identity(target->v.fn.clo)->function_kind & 3; for (size_t i = 0; i < 3; i++) closure->caps[i] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); closure->caps[3] = scr_box_new(SCR_BOX_STR); scr_box_set_ref(closure->caps[0], scr_dyn_retain(target)); @@ -3218,9 +3219,11 @@ static void scr_error_sync_cause(ScrDyn *view, const ScrStr *key); * the configurable, non-writable builtin members once so redefinitions and * deletions retain the same behavior through every alias. Returns +1. */ static ScrDyn *scr_dyn_fn_properties(const ScrDyn *function) { - if (!function->v.fn.clo->props && function->v.fn.class_obj && function->v.fn.class_obj->static_data) - function->v.fn.clo->props = scr_box_retain(function->v.fn.class_obj->static_data); - if (!function->v.fn.clo->props) { + /* A signature adapter shares the table of the function it adapts. */ + ScrClosure *owner = scr_closure_identity(function->v.fn.clo); + if (!owner->props && function->v.fn.class_obj && function->v.fn.class_obj->static_data) + owner->props = scr_box_retain(function->v.fn.class_obj->static_data); + if (!owner->props) { ScrDyn *table = scr_dyn_new_obj(); const char *name = function->v.fn.name ? function->v.fn.name : ""; ScrStr *name_string = scr_str_new(name, strlen(name)); @@ -3233,9 +3236,9 @@ static ScrDyn *scr_dyn_fn_properties(const ScrDyn *function) { } ScrBox *box = scr_box_new_obj(&scr_dyn_retain_v, &scr_dyn_release_v, &scr_dyn_trace_v); scr_box_set_ref(box, table); - function->v.fn.clo->props = box; + owner->props = box; if (function->v.fn.class_obj) function->v.fn.class_obj->static_data = scr_box_retain(box); - if (function->v.fn.clo->function_kind & 4) { + if (owner->function_kind & 4) { ScrDyn *prototype = scr_dyn_new_obj(); scr_dyn_obj_set(prototype, "constructor", 11, scr_dyn_retain((ScrDyn *)function)); prototype->v.obj.entries[0].enumerable = false; @@ -3245,7 +3248,7 @@ static ScrDyn *scr_dyn_fn_properties(const ScrDyn *function) { entry->configurable = false; } } - return (ScrDyn *)scr_box_get_ref(function->v.fn.clo->props); + return (ScrDyn *)scr_box_get_ref(owner->props); } ScrDyn *scr_dyn_class_inherit(ScrDyn *constructor, ScrDyn *base) { @@ -3257,7 +3260,7 @@ ScrDyn *scr_dyn_class_inherit(ScrDyn *constructor, ScrDyn *base) { ScrDyn *table = scr_dyn_fn_properties(constructor); if (base->kind == SCR_DYN_FUNC) scr_dyn_release(scr_dyn_fn_properties(base)); ScrClassObj *cls = constructor->v.fn.class_obj; - if (!cls->static_data) cls->static_data = scr_box_retain(constructor->v.fn.clo->props); + if (!cls->static_data) cls->static_data = scr_box_retain(scr_closure_identity(constructor->v.fn.clo)->props); ScrDyn *result = base->kind == SCR_DYN_UNDEF ? NULL : scr_dyn_set_prototype(table, base); scr_dyn_release(table); scr_dyn_release(result); @@ -3266,7 +3269,7 @@ ScrDyn *scr_dyn_class_inherit(ScrDyn *constructor, ScrDyn *base) { ScrDyn *scr_dyn_class_base_prototype(ScrDyn *constructor) { if (constructor->kind != SCR_DYN_FUNC || constructor->v.fn.class_obj || - constructor->v.fn.clo->function_kind != 4) { + scr_closure_identity(constructor->v.fn.clo)->function_kind != 4) { static const char message[] = "Class extends value is not a native ordinary constructor"; scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); return NULL; @@ -3299,7 +3302,7 @@ bool scr_dyn_data_view_is(const ScrDyn *value) { /* Ordinary native function constructors retain their live prototype table. * A constructor may replace the fresh receiver with another object. */ ScrDyn *scr_dyn_construct(const ScrDyn *callee, const ScrDyn *args, const ScrStr *what) { - if (callee->kind != SCR_DYN_FUNC || callee->v.fn.class_obj || callee->v.fn.clo->function_kind != 4) + if (callee->kind != SCR_DYN_FUNC || callee->v.fn.class_obj || scr_closure_identity(callee->v.fn.clo)->function_kind != 4) return scr_bytes_construct(callee, args, what); ScrDyn *prototype = scr_dyn_fn_get(callee, "prototype", 9); if (!prototype) return NULL; @@ -4273,7 +4276,12 @@ bool scr_dyn_is_callable(const ScrDyn *value) { int scr_dyn_function_kind(const ScrDyn *value) { while (value->kind == SCR_DYN_PROXY) value = value->v.proxy.target; - return value->kind == SCR_DYN_FUNC ? value->v.fn.clo->function_kind : 0; + return value->kind == SCR_DYN_FUNC ? scr_closure_identity(value->v.fn.clo)->function_kind : 0; +} + +void scr_dyn_adopt_identity(ScrClosure *adapter, const ScrDyn *value) { + if (value->kind == SCR_DYN_FUNC && value->v.fn.class_obj == NULL) + scr_closure_adopt_identity(adapter, value->v.fn.clo, value->v.fn.arity); } bool scr_dyn_is_object(const ScrDyn *d) { @@ -6916,7 +6924,7 @@ bool scr_dyn_strict_eq(const ScrDyn *a, const ScrDyn *b) { * dyn boundary twice is still ONE JS function value, so identity * lives in the boxed closure, not the box. */ if (a->v.fn.class_obj || b->v.fn.class_obj) return a->v.fn.class_obj == b->v.fn.class_obj; - return a == b || a->v.fn.clo == b->v.fn.clo; + return a == b || scr_closure_identity_equal(a->v.fn.clo, b->v.fn.clo); case SCR_DYN_BYTES: return a->v.bytes == b->v.bytes; case SCR_DYN_HANDLE: @@ -7005,10 +7013,11 @@ static ScrDyn *scr_function_constructor(uint32_t kind) { /* Keyed read on a FUNC node (see scr_runtime.h): own props first, then * the function-instance built-ins name/length. +1 or NULL. */ ScrDyn *scr_dyn_fn_get(const ScrDyn *d, const char *key, size_t key_len) { - if (d->v.fn.class_obj && d->v.fn.class_obj->static_data && !d->v.fn.clo->props) - d->v.fn.clo->props = scr_box_retain(d->v.fn.class_obj->static_data); - if (!d->v.fn.class_obj && key_len == 9 && !memcmp(key, "prototype", 9) && !d->v.fn.clo->props) { - if (d->v.fn.clo->function_kind & 1) { + ScrClosure *owner = scr_closure_identity(d->v.fn.clo); + if (d->v.fn.class_obj && d->v.fn.class_obj->static_data && !owner->props) + owner->props = scr_box_retain(d->v.fn.class_obj->static_data); + if (!d->v.fn.class_obj && key_len == 9 && !memcmp(key, "prototype", 9) && !owner->props) { + if (owner->function_kind & 1) { static const char message[] = "Native generator function prototypes have no lowering"; scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); return NULL; @@ -7016,8 +7025,8 @@ ScrDyn *scr_dyn_fn_get(const ScrDyn *d, const char *key, size_t key_len) { ScrDyn *table = scr_dyn_fn_properties(d); scr_dyn_release(table); } - if (d->v.fn.clo->props) { - ScrDyn *table = (ScrDyn *)scr_box_get_ref(d->v.fn.clo->props); /* +1 */ + if (owner->props) { + ScrDyn *table = (ScrDyn *)scr_box_get_ref(owner->props); /* +1 */ ScrDyn *m = table ? scr_dyn_obj_get(table, key, key_len) : NULL; ScrDyn *r = m ? scr_dyn_obj_read_receiver(table, key, key_len, d) : NULL; bool custom_prototype = table && (table->prototype || table->null_proto); @@ -7036,7 +7045,7 @@ ScrDyn *scr_dyn_fn_get(const ScrDyn *d, const char *key, size_t key_len) { if (key_len == 6 && memcmp(key, "length", 6) == 0) return scr_dyn_new_num(0); } if (!d->v.fn.class_obj && key_len == 11 && memcmp(key, "constructor", 11) == 0) - return scr_function_constructor(d->v.fn.clo->function_kind); + return scr_function_constructor(owner->function_kind); if (key_len == 4 && memcmp(key, "name", 4) == 0) { const char *n = d->v.fn.name ? d->v.fn.name : ""; ScrStr *s = scr_str_new(n, strlen(n)); @@ -9276,7 +9285,7 @@ static int scr_weak_key(const ScrDyn *key, void **ptr, unsigned *kind) { case SCR_DYN_BYTES: *ptr = key->v.bytes; *kind = 1; return 1; case SCR_DYN_FUNC: - *ptr = key->v.fn.class_obj ? (void *)key->v.fn.class_obj : (void *)key->v.fn.clo; + *ptr = key->v.fn.class_obj ? (void *)key->v.fn.class_obj : (void *)scr_closure_identity(key->v.fn.clo); *kind = key->v.fn.class_obj ? 3 : 2; return 1; case SCR_DYN_HANDLE: if (key->v.handle.tag != SCR_DYNH_ARRAY_BUFFER && key->v.handle.tag != SCR_DYNH_SHARED_ARRAY_BUFFER && key->v.handle.tag != SCR_DYNH_WEAK_MAP && diff --git a/packages/runtime/src/scr_map.c b/packages/runtime/src/scr_map.c index 0c6c2b206..9ddacea0b 100644 --- a/packages/runtime/src/scr_map.c +++ b/packages/runtime/src/scr_map.c @@ -99,7 +99,7 @@ static uint64_t scr_map_hash_dyn(const ScrDyn *d) { case SCR_DYN_BIGINT: return scr_bigint_hash(d->v.bigint); case SCR_DYN_STR: return scr_map_hash_str(d->v.str); case SCR_DYN_SYMBOL: value = scr_map_slot_from_ptr(d->v.symbol.value); break; - case SCR_DYN_FUNC: value = scr_map_slot_from_ptr(d->v.fn.class_obj ? (void *)d->v.fn.class_obj : (void *)d->v.fn.clo); break; + case SCR_DYN_FUNC: value = scr_map_slot_from_ptr(d->v.fn.class_obj ? (void *)d->v.fn.class_obj : (void *)scr_closure_identity(d->v.fn.clo)); break; case SCR_DYN_BYTES: value = scr_map_slot_from_ptr(d->v.bytes); break; case SCR_DYN_HANDLE: value = scr_map_slot_from_ptr(d->v.handle.ptr); break; case SCR_DYN_PROMISE: value = scr_map_slot_from_ptr(d->v.promise); break; diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 4d3e53616..8b02a12c6 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -1706,6 +1706,16 @@ typedef struct ScrClosure { * collected. */ ScrBox *props; uint32_t function_kind; /* low bits: 0 ordinary, 1 generator, 2 async, 3 async generator; bit 2: ordinary own prototype; bit 3: checked callable adapter in caps[0] */ + /* The root's `length` as boxed values report it; meaningful only when + * identity is set (the root's own boxes carry their arity). */ + uint32_t identity_length; + /* The JS function this closure stands for. NULL means the closure is + * its own identity. A signature adapter (the same function viewed + * through a different native ABI) points at the root of the function + * it adapts, so chains of adapters collapse to one root and `===`, + * SameValue, searches and own properties all observe one function. + * Borrowed: the adapter keeps its original alive through its captures. */ + struct ScrClosure *identity; ScrBox *caps[]; } ScrClosure; @@ -1721,6 +1731,23 @@ static inline ScrClosure *scr_closure_retain(ScrClosure *c) { void scr_closure_release(ScrClosure *c); /* releases the boxes; NULL-tolerant */ +/* The identity root of a function value (NULL-tolerant). */ +static inline ScrClosure *scr_closure_identity(ScrClosure *c) { + return c != NULL && c->identity != NULL ? c->identity : c; +} + +/* JS function identity: two closures are the same function when their + * identity roots match. */ +bool scr_closure_identity_equal(ScrClosure *a, ScrClosure *b); + +/* Mark `adapter` as a signature view of `original` (both borrowed); + * `length` is the original's arity when it is a root. */ +void scr_closure_adopt_identity(ScrClosure *adapter, ScrClosure *original, uint32_t length); + +/* Mark `adapter` as a typed view of a dynamic function value (both + * borrowed). Other callables keep the adapter as its own identity. */ +void scr_dyn_adopt_identity(ScrClosure *adapter, const struct ScrDyn *value); + /* ── outbound FFI retained callbacks (scr_ffi.c) ───────────────────── * One compiler-emitted table per retained callback descriptor. For * context-bearing descriptors, entries are counted rather than diff --git a/packages/runtime/test/test_closure.c b/packages/runtime/test/test_closure.c index b8c123cb5..8beb2ff36 100644 --- a/packages/runtime/test/test_closure.c +++ b/packages/runtime/test/test_closure.c @@ -62,6 +62,24 @@ int main(void) { scr_box_release(fb); CHECK(scr_closure_live_count() == 0); + /* identity roots: an adapter of an adapter answers the original */ + ScrClosure *root = scr_closure_new((void *)&dummy_fn, 0); + ScrClosure *view = scr_closure_new((void *)&dummy_fn, 0); + ScrClosure *view2 = scr_closure_new((void *)&dummy_fn, 0); + ScrClosure *other = scr_closure_new((void *)&dummy_fn, 0); + CHECK(scr_closure_identity(root) == root && scr_closure_identity(NULL) == NULL); + scr_closure_adopt_identity(view, root, 1); + scr_closure_adopt_identity(view2, view, 3); + CHECK(scr_closure_identity(view) == root && scr_closure_identity(view2) == root); + CHECK(view->identity_length == 1 && view2->identity_length == 1); + CHECK(scr_closure_identity_equal(view2, root) && scr_closure_identity_equal(view, view2)); + CHECK(!scr_closure_identity_equal(view, other) && !scr_closure_identity_equal(root, other)); + scr_closure_release(view2); + scr_closure_release(view); + scr_closure_release(other); + scr_closure_release(root); + CHECK(scr_closure_live_count() == 0); + /* immortal closure: retain/release are no-ops */ static struct { size_t rc; void *fn; size_t ncaps; } immortal = {SIZE_MAX, NULL, 0}; immortal.fn = (void *)&dummy_fn; diff --git a/tests/corpus/4040-adapted-function-identity.ts b/tests/corpus/4040-adapted-function-identity.ts new file mode 100644 index 000000000..3d114da87 --- /dev/null +++ b/tests/corpus/4040-adapted-function-identity.ts @@ -0,0 +1,75 @@ +// A function keeps one identity when a slot with a different signature +// views it: strict equality, Object.is, searches, returns, optional slots +// and Map/Set keys all observe the original function, while bound +// copies, wrappers and other functions stay distinct. +function log(x: number): void { + console.log("log", x); +} +const quiet = (x: number): void => {}; + +const wide: (x: number, i: number) => void = log; +console.log("wide", wide === log, log === wide, wide !== log, Object.is(wide, log)); +console.log("other", wide === quiet, Object.is(wide, quiet)); + +function same(f: (x: number, i: number) => void, g: (x: number) => void): boolean { + return f === g; +} +console.log("params", same(log, log), same(log, quiet), same(quiet, quiet)); + +function viaZero(f: (x: number) => void, g: () => void): boolean { + return Object.is(f, g); +} +const noop = (): void => {}; +console.log("zero", viaZero(noop, noop), viaZero(log, noop)); + +function widen(f: (x: number) => void): (x: number, i: number) => void { + return f; +} +console.log("return", widen(log) === log, widen(log) === widen(log), widen(quiet) === widen(log)); + +const first: (x: number, i: number) => void = log; +const second: (x: number, i: number) => void = log; +console.log("twice", first === second, first === wide); + +const handlers: ((x: number, i: number) => void)[] = []; +handlers.push(log); +handlers.push(quiet); +handlers.push(log); +console.log("array", handlers[0] === log, handlers[1] === quiet, handlers[1] === log); +console.log("search", handlers.includes(log), handlers.indexOf(quiet), handlers.lastIndexOf(log)); +console.log("miss", handlers.includes((x: number) => {}), handlers.indexOf(noop)); +const exact: ((x: number) => void)[] = [log, quiet]; +console.log("exact", exact.indexOf(log), exact.lastIndexOf(quiet), exact.includes(quiet)); + +let slot: ((x: number, i: number) => void) | undefined = undefined; +console.log("unset", slot === log); +slot = log; +console.log("slot", slot === log, slot === quiet); +function optional(f?: (x: number, i: number) => void): string { + return f === log ? "log" : f === quiet ? "quiet" : "none"; +} +console.log("optional", optional(log), optional(quiet), optional()); + +const one = (): number => 1; +const either: () => number | string = one; +const loose: () => void = one; +console.log("result", either === one, loose === one, either === (() => 1)); + +const b1 = quiet.bind(null); +const b2 = quiet.bind(null); +console.log("bind", b1 === quiet, b1 === b2, b1 === b1); +const wrapper = (x: number): void => quiet(x); +const wideWrapper: (x: number, i: number) => void = wrapper; +const wideQuiet: (x: number, i: number) => void = quiet; +console.log("wrapper", wrapper === quiet, wideWrapper === wideQuiet, wideWrapper === wrapper); + +const names = new Map(); +names.set(log, "log"); +names.set(wideQuiet, "quiet"); +console.log("map", names.get(wide), names.get(quiet), names.has(wrapper), names.size); +const seen = new Set([wide]); +seen.add(log); +seen.add(wideWrapper); +console.log("set", seen.has(log), seen.has(wrapper), seen.has(quiet), seen.size); + +[3, 4].forEach(log); diff --git a/tests/corpus/4041-adapted-function-properties.ts b/tests/corpus/4041-adapted-function-properties.ts new file mode 100644 index 000000000..823173bbf --- /dev/null +++ b/tests/corpus/4041-adapted-function-properties.ts @@ -0,0 +1,26 @@ +// A function seen through a wider signature is the same function object: +// its name, length and own properties are shared with the original, and +// a dynamic value converted back to a typed slot keeps its identity. +function scale(x: number): number { + return x * 2; +} +const wide: (x: number, i: number) => number = scale; +const viaWide: any = wide; +const viaOriginal: any = scale; +console.log(viaWide === viaOriginal, viaWide.name, viaWide.length, viaOriginal.length); +Object.defineProperty(wide, "tag", { value: "shared" }); +console.log(viaOriginal.tag, viaWide.tag); + +const boxed: unknown = scale; +const back = boxed as (x: number, i: number) => number; +console.log(back === scale, back(4, 0), boxed === wide); +const loose: any = scale; +const typed: (x: string) => number = loose; +console.log(typed === loose, (typed as unknown) === boxed); + +function other(x: number): number { + return x; +} +const otherWide: (x: number, i: number) => number = other; +const viaOther: any = otherWide; +console.log(viaOther === viaWide, viaOther.tag, viaOther.name, viaOther.length); diff --git a/tests/corpus/4042-object-is-union-subclass.ts b/tests/corpus/4042-object-is-union-subclass.ts new file mode 100644 index 000000000..36ac01bd4 --- /dev/null +++ b/tests/corpus/4042-object-is-union-subclass.ts @@ -0,0 +1,16 @@ +// Object.is accepts a subclass instance against a union with its base +// class arm, comparing the same object identity as strict equality. +class Base { + n = 1; +} +class Child extends Base { + m = 2; +} +function check(u: Base | { label: string }, s: Child): void { + console.log(u === s, Object.is(u, s), Object.is(s, u), u !== s); +} +const shared = new Child(); +check(shared, shared); +check(new Child(), shared); +check(new Base(), shared); +check({ label: "plain" }, shared); diff --git a/tests/corpus/4043-nonnull-missing-element.ts b/tests/corpus/4043-nonnull-missing-element.ts new file mode 100644 index 000000000..8f765ef65 --- /dev/null +++ b/tests/corpus/4043-nonnull-missing-element.ts @@ -0,0 +1,30 @@ +// A non-null assertion has no runtime effect: an out-of-range read stays +// undefined wherever array storage, an equality test, or an optional slot +// receives it. +class Child { + v = 1; +} +const kids: Child[] = [new Child()]; +const a = [kids[5]!]; +console.log(a.length, a[0] === undefined); +const typed: Child[] = [kids[7]!, kids[0]!]; +console.log(typed.length, typed[0] === undefined, typed[1] === kids[0]); +const later: Child[] = []; +later.push(kids[3]!); +later.unshift(kids[4]!); +later[2] = kids[0]!; +later[3] = kids[8]!; +console.log(later.length, later[0] === undefined, later[1] === undefined, later[2] === kids[0], later[3] === undefined); +function describe(c: Child | undefined): string { + return c === undefined ? "missing" : "present " + c.v; +} +console.log(describe(kids[9]!), describe(kids[0]!)); +const nums = [1, 2]; +const b = [nums[4]!, nums[0]!]; +console.log(b.length, b[0] === undefined, b[1]); +const words = ["x"]; +const c = [(words[2]!), words[0]!]; +console.log(c.length, c[0] === undefined, c[1], c.join("-")); +const xs = [1, 2, 3]; +console.log(xs[3]! === undefined, xs[0]! === undefined, xs[5]! !== undefined, xs[1]! === 2); +console.log(kids[4]! === undefined, undefined === kids[4]!, kids[0]! === kids[0], kids[2]! == null); diff --git a/tests/corpus/4044-adapted-callback-modules/main.ts b/tests/corpus/4044-adapted-callback-modules/main.ts new file mode 100644 index 000000000..70686640e --- /dev/null +++ b/tests/corpus/4044-adapted-callback-modules/main.ts @@ -0,0 +1,24 @@ +import { count, emit, has, off, on, widen, without } from "./registry.ts"; + +function first(value: number): void { + console.log("first", value); +} +const second = (value: number): void => { + console.log("second", value); +}; +const wrapper = (value: number): void => first(value); + +on(first); +on(second); +on(first); +console.log("registered", count(), has(first), has(second), has(wrapper)); +console.log("filtered", without(first), without(second), without(wrapper)); +console.log("widened", widen(first) === first, widen(first) === widen(first), widen(wrapper) === first); + +console.log("off wrapper", off(wrapper), count()); +console.log("off first", off(first), count()); +emit(1); +console.log("off widened", off(widen(first)), count()); +console.log("off again", off(first), count()); +emit(2); +console.log("off second", off(second), count(), has(second)); diff --git a/tests/corpus/4044-adapted-callback-modules/registry.ts b/tests/corpus/4044-adapted-callback-modules/registry.ts new file mode 100644 index 000000000..0d5465783 --- /dev/null +++ b/tests/corpus/4044-adapted-callback-modules/registry.ts @@ -0,0 +1,36 @@ +// A listener registry whose callbacks take a wider signature than the +// handlers callers pass in. Removal and lookup compare function identity. +export type Listener = (value: number, index: number) => void; + +const listeners: Listener[] = []; + +export function on(listener: Listener): void { + listeners.push(listener); +} + +export function off(listener: Listener): boolean { + const at = listeners.indexOf(listener); + if (at < 0) return false; + listeners.splice(at, 1); + return true; +} + +export function has(listener: Listener): boolean { + return listeners.includes(listener); +} + +export function without(listener: Listener): number { + return listeners.filter((entry) => entry !== listener).length; +} + +export function emit(value: number): void { + listeners.forEach((listener, index) => listener(value, index)); +} + +export function count(): number { + return listeners.length; +} + +export function widen(handler: (value: number) => void): Listener { + return handler; +} From f6390d878baf9c6d89c8d26d9d096070007e405e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 11:04:59 -0500 Subject: [PATCH 08/14] Carry optional values, never-returning calls and native iterators through typed returns (#739) --- .../lowering/containers/array-construction.ts | 7 + .../lowering/containers/array-methods.ts | 23 +- .../frontend/lowering/iterator-adapters.ts | 651 ++++++++++++++++++ .../src/frontend/lowering/lower-exprs.ts | 66 +- .../src/frontend/lowering/lower-stmts.ts | 6 +- .../compiler/src/frontend/lowering/lowerer.ts | 49 ++ packages/compiler/src/frontend/type-mapper.ts | 14 +- packages/compiler/src/ir/ir.ts | 5 + packages/compiler/src/ir/validate.ts | 6 + .../test/ts7/baselines/order-parity.json | 52 +- tests/corpus/iterator-typed-returns.ts | 148 ++++ tests/corpus/never-returning-calls.ts | 102 +++ tests/corpus/optional-element-defaults.ts | 84 +++ tests/diagnostics/iterator-slot-fences.ts | 21 + tests/diagnostics/stdlib-fence.ts | 2 +- .../__snapshots__/iterator-slot-fences.ts.txt | 24 + .../harness/__snapshots__/stdlib-fence.ts.txt | 20 +- 17 files changed, 1242 insertions(+), 38 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/iterator-adapters.ts create mode 100644 tests/corpus/iterator-typed-returns.ts create mode 100644 tests/corpus/never-returning-calls.ts create mode 100644 tests/corpus/optional-element-defaults.ts create mode 100644 tests/diagnostics/iterator-slot-fences.ts create mode 100644 tests/harness/__snapshots__/iterator-slot-fences.ts.txt diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 833c68acf..921e05fe1 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -1,3 +1,4 @@ +import { generatorDrain } from "../iterator-adapters.js"; import { identityPreservingWidening } from "../coercions/identity.js"; import { InternalCompilerError } from "../../../errors.js"; import { @@ -987,6 +988,12 @@ export function lowerArrayFromCall( if (src.type.kind === "dyn" && args.length === 1) { return lowerCheckedArrayFrom(lowerer, src, loc); } + // A generator (or an iterator adapted into one) drains in order. + if (src.type.kind === "generator" && args.length === 1) { + const arrT = lowerer.mapTypeOf(lowerer.typeOf(call)); + const drained = arrT?.kind === "array" ? generatorDrain(lowerer, src, arrT.elem, loc) : null; + if (drained) return drained; + } lowerer.noLowering( "Array.from with this argument shape", call, diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index 94c83e4e1..d8716e1db 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -1,3 +1,4 @@ +import { lowerArrayIteratorValue } from "../iterator-adapters.js"; import { identityPreservingWidening } from "../coercions/identity.js"; import { arrayConcatHelper } from "./array-concat.js"; import { boolLit, countedFor, numLit, strLit, varRef } from "../../../ir/build.js"; @@ -158,7 +159,15 @@ export function lowerArrayMethodCall( ): IrExpr | null { if (lowerer.chainBlocked(access, call)) return null; const name = access.name.text; - if (!ARRAY_METHODS.has(name) && name !== "sort" && name !== "shift" && name !== "splice") + const iteratorMethod = + (name === "values" || name === "keys" || name === "entries") && call.arguments.length === 0; + if ( + !ARRAY_METHODS.has(name) && + name !== "sort" && + name !== "shift" && + name !== "splice" && + !iteratorMethod + ) return null; let receiverIr = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); const checkerReceiver = lowerer.checker.getTypeAtLocation(access.expression); @@ -201,6 +210,18 @@ export function lowerArrayMethodCall( if (!probedUntyped && !lowerer.isStdlibMember(access)) return null; let elem = receiverIr.elem; const loc = locOf(call); + if (iteratorMethod) { + const type = lowerer.mapTypeOf(lowerer.typeOf(call)); + const receiver = lowerer.lowerExpr(access.expression); + // Only a static array resolves to a generator here; other lowered + // receivers (island arrays, narrowed unions, checked dynamic values) + // continue to their dedicated handling below. + if (receiver.type.kind === "array") { + const value = type ? lowerArrayIteratorValue(lowerer, receiver, name, type, loc) : null; + if (value) return value; + lowerer.unsupported("SC1090", call, `'${name}()' iterators over this array element type`); + } + } // An island handle behind an array-typed .d.ts surface // (`parts().join("-")` — arrays never exit eagerly, so the value // stays jsval): the ENGINE's own Array.prototype method runs on the diff --git a/packages/compiler/src/frontend/lowering/iterator-adapters.ts b/packages/compiler/src/frontend/lowering/iterator-adapters.ts new file mode 100644 index 000000000..50272efd0 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/iterator-adapters.ts @@ -0,0 +1,651 @@ +import { dynUndefinedExpr, varRef } from "../../ir/build.js"; +import { + BOOL, + DYN, + F64, + STRING, + type IrExpr, + type IrLocal, + type IrStmt, + type IrType, + type SrcLoc, + typeEquals, + UNDEFINED_T, + VOID, +} from "../../ir/ir.js"; +import { streamTypedRefEligible } from "../../ir/analysis.js"; +import { typeKey, genResultRecord } from "../type-mapper.js"; +import type { Lowerer } from "./lowerer.js"; +import { extractIteratorValue } from "./lower-generators.js"; +import { lowerSafeIndexRead } from "./array-values.js"; + +/** The conversion into an iterator-typed slot (a generator type): a + * checked-dynamic iterator or a generator with other channel types steps + * through an interned adapter generator. Null when neither applies. */ +export function iteratorSlotAdapter( + lowerer: Lowerer, + expr: IrExpr, + expected: IrType & { kind: "generator" }, +): IrExpr | null { + if (expr.type.kind === "dyn") return dynIteratorAdapter(lowerer, expr, expected); + if (expr.type.kind === "generator") return generatorRetypeAdapter(lowerer, expr, expected); + return null; +} + +/** `resumed.0 = value` — the adapters' early-exit marker around a yield. */ +function setResumed(value: boolean, loc: SrcLoc): IrStmt { + return { + kind: "assign", + localId: "resumed.0", + value: { kind: "boolLit", value, type: BOOL, loc }, + loc, + }; +} + +/** A yield expression's type: a valueless next channel resumes with void. */ +function resumeType(nextT: IrType): IrType { + return nextT.kind === "undefinedT" ? VOID : nextT; +} + +/** Convert a stepped value into a channel type. Records and arrays box into + * checked values by reference, so consumers observe the same objects. */ +function intoChannel(lowerer: Lowerer, value: IrExpr, target: IrType): IrExpr | null { + const coerced = lowerer.coerceToExpected(value, target); + if (!typeEquals(coerced.type, target)) return null; + if ( + coerced.kind === "dynFrom" && + streamTypedRefEligible(coerced.value.type) && + coerced.value.type.kind !== "bytes" + ) + coerced.liveRef = true; + return coerced; +} + +/** A checked-dynamic iterator (a Map, Set or array iterator, or any other + * object following the iterator protocol) flowing into a generator-typed + * slot (`IterableIterator`, `Iterator`, `Generator`). The interned + * adapter is a generator over the source: each `next()` steps the source + * once and forwards its value, so iteration stays lazy and observes the + * collection live, exactly like the source iterator. Values convert into + * the slot's channel types with the usual checked extraction. Null when + * the slot is not a synchronous generator with a yielding channel. */ +function dynIteratorAdapter( + lowerer: Lowerer, + expr: IrExpr, + expected: IrType & { kind: "generator" }, +): IrExpr | null { + if (expected.async || expected.yieldT.kind === "void") return null; + const resultType = genResultRecord( + expected.yieldT, + expected.retT, + lowerer.shapes, + lowerer.unions, + ); + if (!resultType) return null; + const loc = expr.loc; + const key = `iterAdapt:${typeKey(expected)}`; + let name = lowerer.valueHelpers.get(key); + if (!name) { + name = `%iter.adapt.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, name); + const locals: IrLocal[] = []; + const local = (id: string, type: IrType, mutable = false): IrExpr => { + locals.push({ id, name: id.slice(0, id.indexOf(".")), type, mutable }); + return varRef(id, type, loc); + }; + const source = local("source.0", DYN); + const iterator = local("iterator.0", DYN); + const next = local("next.0", DYN); + const step = local("step.0", DYN, true); + const forwards = expected.nextT.kind === "dyn"; + const sent = forwards ? local("sent.0", DYN, true) : null; + const get = (value: IrExpr, field: string): IrExpr => ({ + kind: "dynKeyGet", + value, + key: { kind: "strLit", value: field, type: STRING, loc }, + type: DYN, + loc, + }); + const advance = (args: IrExpr[]): IrExpr => ({ + kind: "libCall", + fn: "dyn.iteratorResult", + args: [ + { + kind: "dynCall", + callee: next, + receiver: iterator, + calleeName: "iterator.next", + args, + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }); + const returned = + expected.retT.kind === "void" + ? null + : intoChannel(lowerer, get(step, "value"), expected.retT); + const yielded = (): IrExpr | null => intoChannel(lowerer, get(step, "value"), expected.yieldT); + if ((expected.retT.kind !== "void" && !returned) || !yielded()) { + lowerer.valueHelpers.delete(key); + return null; + } + const finish: IrStmt = { kind: "return", value: returned, loc }; + // One suspension: yield the current value and take the resume value. + const suspend = (): IrStmt => { + const resumed: IrExpr = { + kind: "yieldExpr", + value: yielded()!, + type: resumeType(expected.nextT), + loc, + }; + return sent + ? { kind: "assign", localId: "sent.0", value: resumed, loc } + : { kind: "exprStmt", expr: resumed, loc }; + }; + const resumedFlag = local("resumed.0", BOOL, true); + const closer = local("closer.0", DYN); + const stepOnce = (): IrStmt => ({ + kind: "assign", + localId: "step.0", + value: advance(sent ? [sent] : []), + loc, + }); + // An early exit from a consumer's loop closes the adapter. JS calls the + // source's own return() then; a source without one (the native Map, Set + // and array iterators) stays open, so the adapter suspends again on the + // same value instead of completing, and later steps resume the source. + const closeOrPark: IrStmt = { + kind: "if", + cond: { kind: "unary", op: "!", operand: resumedFlag, type: BOOL, loc }, + then: [ + { kind: "varDecl", localId: "closer.0", init: get(iterator, "return"), loc }, + { + kind: "if", + cond: { kind: "dynTest", test: "nullish", value: closer, type: BOOL, loc }, + then: [suspend(), stepOnce(), { kind: "continue", loc }], + else_: [ + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.iteratorResult", + args: [ + { + kind: "dynCall", + callee: closer, + receiver: iterator, + calleeName: "iterator.return", + args: [], + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }, + loc, + }, + ], + loc, + }, + ], + else_: null, + loc, + }; + const body: IrStmt[] = [ + { + kind: "varDecl", + localId: "iterator.0", + init: { + kind: "libCall", + fn: "dyn.iteratorResult", + args: [ + { + kind: "libCall", + fn: "dyn.iterator", + args: [source, { kind: "strLit", value: "value is not iterable", type: STRING, loc }], + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }, + loc, + }, + { kind: "varDecl", localId: "next.0", init: get(iterator, "next"), loc }, + { kind: "varDecl", localId: "step.0", init: advance([]), loc }, + ...(sent + ? [{ kind: "varDecl" as const, localId: "sent.0", init: dynUndefinedExpr(loc), loc }] + : []), + { + kind: "varDecl", + localId: "resumed.0", + init: { kind: "boolLit", value: false, type: BOOL, loc }, + loc, + }, + { + kind: "while", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + body: [ + { + kind: "if", + cond: { kind: "dynTest", test: "truthy", value: get(step, "done"), type: BOOL, loc }, + then: [finish], + else_: null, + loc, + }, + setResumed(false, loc), + { + kind: "tryCatch", + tryBody: [suspend(), setResumed(true, loc)], + catchBody: null, + catchLocalId: null, + finallyBody: [closeOrPark], + loc, + }, + stepOnce(), + ], + loc, + }, + ]; + lowerer.liftedFns.push({ + name, + params: [{ localId: "source.0", name: "source", type: DYN }], + returnType: expected.retT, + locals, + body, + generator: { yieldT: expected.yieldT, nextT: expected.nextT, resultType }, + loc, + }); + } + return { kind: "call", callee: name, args: [expr], type: expected, loc }; +} + +/** A generator flowing into a slot with other channel types (a + * `Generator` returned through an + * `IterableIterator` annotation). The interned adapter generator + * resumes the source once per step and converts each value between the + * channel types, so iteration stays lazy. Null when a channel has no + * conversion. */ +function generatorRetypeAdapter( + lowerer: Lowerer, + expr: IrExpr, + expected: IrType & { kind: "generator" }, +): IrExpr | null { + const from = expr.type; + if (from.kind !== "generator" || from.async || expected.async) return null; + if (from.yieldT.kind === "void" || expected.yieldT.kind === "void") return null; + const fromResult = genResultRecord(from.yieldT, from.retT, lowerer.shapes, lowerer.unions); + const toResult = genResultRecord(expected.yieldT, expected.retT, lowerer.shapes, lowerer.unions); + const fromShape = fromResult ? lowerer.shapes.get(fromResult.shapeId) : undefined; + const valueT = fromShape?.fields.find((field) => field.name === "value")?.type; + if (!fromResult || !toResult || !valueT) return null; + const loc = expr.loc; + const key = `genRetype:${typeKey(from)}:${typeKey(expected)}`; + let name = lowerer.valueHelpers.get(key); + if (!name) { + const source = varRef("source.0", from, loc); + const step = varRef("step.0", fromResult, loc); + const field = (f: "value" | "done", type: IrType): IrExpr => ({ + kind: "recordGet", + obj: step, + shapeId: fromResult.shapeId, + field: f, + type, + loc, + }); + const extracted = extractIteratorValue( + lowerer, + from.yieldT, + valueT, + field("value", valueT), + loc, + ); + const yielded = extracted ? intoChannel(lowerer, extracted, expected.yieldT) : null; + if (!yielded) return null; + // The completion value: the source's return value, or undefined. + let returned: IrExpr | null = null; + if (expected.retT.kind !== "void") { + returned = + from.retT.kind === "void" + ? intoChannel( + lowerer, + { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + expected.retT, + ) + : intoChannel(lowerer, field("value", valueT), expected.retT); + if (!returned) return null; + } + // The resume value: forwarded when both sides carry one. + const forwards = expected.nextT.kind !== "undefinedT" && from.nextT.kind !== "undefinedT"; + const sentRef = varRef("sent.0", expected.nextT, loc); + const sent = forwards ? intoChannel(lowerer, sentRef, from.nextT) : null; + if (forwards && !sent) return null; + if (!forwards && from.nextT.kind !== "undefinedT" && from.nextT.kind !== "dyn") return null; + const resume = (arg: IrExpr | null): IrExpr => ({ + kind: "genResume", + mode: "next", + gen: source, + arg, + type: fromResult, + loc, + }); + const resumed: IrExpr = { + kind: "yieldExpr", + value: yielded, + type: resumeType(expected.nextT), + loc, + }; + name = `%gen.retype.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, name); + lowerer.liftedFns.push({ + name, + params: [{ localId: "source.0", name: "source", type: from }], + returnType: expected.retT, + locals: [ + { id: "source.0", name: "source", type: from, mutable: false }, + { id: "step.0", name: "step", type: fromResult, mutable: true }, + ...(forwards ? [{ id: "sent.0", name: "sent", type: expected.nextT, mutable: true }] : []), + { id: "resumed.0", name: "resumed", type: BOOL, mutable: true }, + ], + body: [ + { kind: "varDecl", localId: "step.0", init: resume(null), loc }, + ...(forwards ? [{ kind: "varDecl" as const, localId: "sent.0", init: null, loc }] : []), + { + kind: "varDecl", + localId: "resumed.0", + init: { kind: "boolLit", value: false, type: BOOL, loc }, + loc, + }, + { + kind: "while", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + body: [ + { + kind: "if", + cond: field("done", BOOL), + then: [{ kind: "return", value: returned, loc }], + else_: null, + loc, + }, + setResumed(false, loc), + { + kind: "tryCatch", + tryBody: [ + forwards + ? { kind: "assign", localId: "sent.0", value: resumed, loc } + : { kind: "exprStmt", expr: resumed, loc }, + setResumed(true, loc), + ], + catchBody: null, + catchLocalId: null, + // Closing the adapter early closes the source generator too. + finallyBody: [ + { + kind: "if", + cond: { + kind: "unary", + op: "!", + operand: varRef("resumed.0", BOOL, loc), + type: BOOL, + loc, + }, + then: [ + { + kind: "exprStmt", + expr: { + kind: "genResume", + mode: "return", + gen: source, + arg: null, + type: fromResult, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }, + ], + loc, + }, + { kind: "assign", localId: "step.0", value: resume(sent), loc }, + ], + loc, + }, + ], + generator: { yieldT: expected.yieldT, nextT: expected.nextT, resultType: toResult }, + loc, + }); + } + return { kind: "call", callee: name, args: [expr], type: expected, loc }; +} + +/** Drain a synchronous generator into a fresh `elem[]` — the eager + * consumers of an iterator (`[...it]`, `Array.from(it)`). Each step resumes + * the generator once and appends its value; completion stops the drain + * without appending the return value, as in JS. Null when the yielded + * values cannot be extracted into the element type. */ +export function generatorDrain( + lowerer: Lowerer, + source: IrExpr, + elem: IrType, + loc: SrcLoc, +): IrExpr | null { + const gen = source.type; + if (gen.kind !== "generator" || gen.async || gen.yieldT.kind === "void") return null; + const resultType = genResultRecord(gen.yieldT, gen.retT, lowerer.shapes, lowerer.unions); + const shape = resultType ? lowerer.shapes.get(resultType.shapeId) : undefined; + const valueT = shape?.fields.find((field) => field.name === "value")?.type; + if (!resultType || !valueT) return null; + const arrT: IrType = { kind: "array", elem }; + const key = `genDrain:${typeKey(gen)}:${typeKey(elem)}`; + let name = lowerer.valueHelpers.get(key); + if (!name) { + const genRef = varRef("gen.0", gen, loc); + const outRef = varRef("out.0", arrT, loc); + const stepRef = varRef("step.0", resultType, loc); + const raw = extractIteratorValue( + lowerer, + gen.yieldT, + valueT, + { + kind: "recordGet", + obj: stepRef, + shapeId: resultType.shapeId, + field: "value", + type: valueT, + loc, + }, + loc, + ); + if (!raw) return null; + const value = lowerer.coerceToExpected(raw, elem); + if (!typeEquals(value.type, elem)) return null; + name = `%gen.drain.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, name); + lowerer.liftedFns.push({ + name, + params: [{ localId: "gen.0", name: "gen", type: gen }], + returnType: arrT, + locals: [ + { id: "gen.0", name: "gen", type: gen, mutable: false }, + { id: "out.0", name: "out", type: arrT, mutable: false }, + { id: "step.0", name: "step", type: resultType, mutable: false }, + ], + body: [ + { + kind: "varDecl", + localId: "out.0", + init: { kind: "arrayLit", elems: [], type: arrT, loc }, + loc, + }, + { + kind: "while", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + body: [ + { + kind: "varDecl", + localId: "step.0", + init: { + kind: "genResume", + mode: "next", + gen: genRef, + arg: null, + type: resultType, + loc, + }, + loc, + }, + { + kind: "if", + cond: { + kind: "recordGet", + obj: stepRef, + shapeId: resultType.shapeId, + field: "done", + type: BOOL, + loc, + }, + then: [{ kind: "break", loc }], + else_: null, + loc, + }, + { + kind: "exprStmt", + expr: { + kind: "arrIntrinsic", + method: "push", + receiver: outRef, + args: [value], + type: F64, + loc, + }, + loc, + }, + ], + loc, + }, + { kind: "return", value: outRef, loc }, + ], + loc, + }); + } + return { kind: "call", callee: name, args: [source], type: arrT, loc }; +} + +/** `list.values()`, `list.keys()` and `list.entries()` as values: a + * generator over the live array. Each step compares the index with the + * current length and reads the current element, so elements appended + * during iteration are visited and completion is final, as with the + * native array iterator. `type` is the call's generator type. */ +export function lowerArrayIteratorValue( + lowerer: Lowerer, + receiver: IrExpr, + method: "values" | "keys" | "entries", + type: IrType, + loc: SrcLoc, +): IrExpr | null { + const arrT = receiver.type; + if (arrT.kind !== "array" || type.kind !== "generator") return null; + const resultType = genResultRecord(type.yieldT, type.retT, lowerer.shapes, lowerer.unions); + if (!resultType || type.retT.kind !== "void" || type.nextT.kind !== "undefinedT") return null; + const tuple = type.yieldT.kind === "record" ? lowerer.shapes.get(type.yieldT.shapeId) : undefined; + if (method === "entries" && (!tuple?.tuple || tuple.fields.length !== 2)) return null; + const key = `arrIter:${method}:${typeKey(arrT)}:${typeKey(type)}`; + let name = lowerer.valueHelpers.get(key); + if (!name) { + const list = varRef("list.0", arrT, loc); + const index = varRef("index.0", F64, loc); + const element = (): IrExpr | null => { + const read = lowerSafeIndexRead(lowerer, list, index, loc); + if (!read) return null; + const target = + method === "values" ? type.yieldT : tuple!.fields.find((f) => f.name === "1")!.type; + const value = lowerer.coerceToExpected(read, target); + return typeEquals(value.type, target) ? value : null; + }; + let yielded: IrExpr | null; + if (method === "keys") yielded = index; + else if (method === "values") yielded = element(); + else { + const value = element(); + yielded = + value === null + ? null + : { + kind: "recordLit", + fields: [ + { name: "0", value: index }, + { name: "1", value }, + ], + type: type.yieldT, + loc, + }; + } + if (!yielded || !typeEquals(yielded.type, type.yieldT)) return null; + name = `%arr.iter.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, name); + lowerer.liftedFns.push({ + name, + params: [{ localId: "list.0", name: "list", type: arrT }], + returnType: VOID, + locals: [ + { id: "list.0", name: "list", type: arrT, mutable: false }, + { id: "index.0", name: "index", type: F64, mutable: true }, + ], + body: [ + { + kind: "varDecl", + localId: "index.0", + init: { kind: "numLit", value: 0, type: F64, loc }, + loc, + }, + { + kind: "while", + cond: { + kind: "bin", + op: "<", + left: index, + right: { + kind: "arrIntrinsic", + method: "length", + receiver: list, + args: [], + type: F64, + loc, + }, + type: BOOL, + loc, + }, + body: [ + { kind: "exprStmt", expr: { kind: "yieldExpr", value: yielded, type: VOID, loc }, loc }, + { + kind: "assign", + localId: "index.0", + value: { + kind: "bin", + op: "+", + left: index, + right: { kind: "numLit", value: 1, type: F64, loc }, + type: F64, + loc, + }, + loc, + }, + ], + loc, + }, + { kind: "return", value: null, loc }, + ], + generator: { yieldT: type.yieldT, nextT: UNDEFINED_T, resultType }, + loc, + }); + } + return { kind: "call", callee: name, args: [receiver], type, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 4af4e69ab..907cd4462 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1,3 +1,4 @@ +import { generatorDrain } from "./iterator-adapters.js"; import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; import { isOptionalProcessStreamProperty } from "./builtins/process.js"; import { lowerWorkerMetadata } from "./builtins/workers.js"; @@ -365,7 +366,10 @@ export function lowerExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr { } lowerExprDepth++; try { - return lowerExprInner(lowerer, expr); + const lowered = lowerExprInner(lowerer, expr); + if (lowered.type.kind === "void" && (lowerer.typeOf(expr).flags & ts.TypeFlags.Never) !== 0) + lowerer.neverValued.add(lowered); + return lowered; } finally { lowerExprDepth--; } @@ -4350,6 +4354,37 @@ export function lowerNullishCoalesce( return result; } +/** A plain value read that may be runtime-optional: an unchecked element + * read, or a binding, member or call that carries one. Lowering these + * without a destination type is the same as lowering them into one. */ +function isPlainValueRead(node: ts.Expression): boolean { + while (ts.isParenthesizedExpression(node)) node = node.expression; + return ( + ts.isElementAccessExpression(node) || + ts.isIdentifier(node) || + ts.isPropertyAccessExpression(node) || + ts.isCallExpression(node) + ); +} + +/** A member access or call receiver needs the present value itself (JS + * throws a TypeError reading a member of undefined). */ +function isMemberReceiver(node: ts.Expression): boolean { + let child: ts.Node = node; + let parent = node.parent; + while (ts.isParenthesizedExpression(parent)) { + child = parent; + parent = parent.parent; + } + return ( + ((ts.isPropertyAccessExpression(parent) || + ts.isElementAccessExpression(parent) || + ts.isCallExpression(parent)) && + parent.expression === child) || + (ts.isTaggedTemplateExpression(parent) && parent.tag === child) + ); +} + function lowerNullishPair( lowerer: Lowerer, expr: ts.BinaryExpression, @@ -4420,6 +4455,29 @@ function lowerNullishPair( loc, }; } + // A runtime-optional default (`name ?? names[0]`): the checker types an + // unchecked element read as the bare element type, but an out-of-range + // read is undefined at runtime. When the default shares the left's + // optional union, that union is the result, so the undefined state + // reaches the consumer (an optional return or parameter takes it as its + // undefined arm; a plain slot still extracts the element with its + // checked narrow). + if ( + rest.length === 1 && + typeEquals(type, rest[0]!) && + isPlainValueRead(expr.right) && + !isMemberReceiver(expr) + ) { + const raw = lowerer.lowerExpr(expr.right); + if (typeEquals(raw.type, left.type)) + return { kind: "nullish", left, right: raw, type: left.type, loc }; + // `names[0] ?? undefined`: an explicit unit default stays the left's arm. + if (isUnitType(raw.type) && lowerer.armTag(left.type.unionId, raw.type) >= 0) { + const right = lowerer.coerceInto(expr.right, raw, left.type); + return { kind: "nullish", left, right, type: left.type, loc }; + } + return { kind: "nullish", left, right: lowerer.coerceInto(expr.right, raw, type), type, loc }; + } if (typeEquals(type, left.type) || (rest.length === 1 && typeEquals(type, rest[0]!))) { const right = lowerer.lowerExprExpecting(expr.right, type); return { kind: "nullish", left, right, type, loc }; @@ -5930,6 +5988,12 @@ export function lowerArrayLiteral( loc: locOf(el), }; } + // `[...it]` over a generator (or an iterator adapted into one): + // drain it once, in order, into a fresh element array. + if (src.type.kind === "generator") { + const drained = generatorDrain(lowerer, src, type.elem, locOf(el)); + if (drained) src = drained; + } // A native checked iterable can supply scalar elements even when // the checker inferred a typed array from its producer. Drain once // and validate the elements before the ordinary spread copy. diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 829163b07..dba8ccdd6 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4242,11 +4242,15 @@ export function bindPatternTarget( // through later locals, templates, operators, and narrowing instead // of being extracted with a checked arm read. let runtimeOptional = false; + // The optional-read pre-pass may already have widened the binding to + // that union; its reads still need the runtime-optional treatment. if ( !dynStays && value.type.kind === "union" && lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0 && - typeEquals(lowerer.stripUndefinedArm(value.type), type) + (typeEquals(lowerer.stripUndefinedArm(value.type), type) || + (typeEquals(value.type, type) && + typeEquals(lowerer.stripUndefinedArm(value.type), lowerer.irTypeOf(name)))) ) { type = value.type; runtimeOptional = true; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index eb29e8771..e84f6c71c 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -5,6 +5,7 @@ import { isOptionalProcessStreamProperty } from "./builtins/process.js"; import { concreteCollectionNarrow } from "./collection-narrowing.js"; import { lowerWorkerMetadata } from "./builtins/workers.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../ir/build.js"; +import { iteratorSlotAdapter } from "./iterator-adapters.js"; import type { FieldLift } from "./coercions/structural-plans.js"; import { lowerBuiltinCall } from "./builtin-calls.js"; import { CoercionState } from "./coercions/state.js"; @@ -2220,6 +2221,10 @@ export class Lowerer { * node carrying the ?. token is marked handled so the receiver-typed * lowerings stop declining it (chainBlocked). */ readonly chainRecvByNode = new Map(); + /** Lowered void calls whose checked type is `never` (they throw or exit). + * TypeScript lets such a call stand where any value is expected; + * coerceToExpected turns them into diverging sequences. */ + readonly neverValued = new Set(); readonly chainNarrowedType = new Map(); readonly chainHandled = new Set(); /** for-of-over-matchAll bindings whose `.index` reads the companion-index @@ -6432,6 +6437,15 @@ export class Lowerer { ); throw new PoisonError(); } + // Iterator-typed slots hold generators and built-in collection + // iterators; any other iterator object (a user class) stays fenced. + if (expected.kind === "generator" && actual.kind !== "generator") { + this.unsupported( + "SC1090", + node, + `'${this.fmt(actual)}' values in an iterator-typed slot (Iterator and IterableIterator slots hold generators and built-in collection iterators)`, + ); + } // Everything else — a plain-kind mismatch like a string flowing into a // class-instance slot — is tsc-rejected in the lowering world and only // reaches here through preflight's project-world second chance (e.g. a @@ -6523,6 +6537,14 @@ export class Lowerer { * else (including a DIFFERENT union) is left for requireExactShape, which * rejects union mismatches with SC2003. */ coerceToExpected(expr: IrExpr, expected: IrType): IrExpr { + if (expr.type.kind === "void" && expected.kind !== "void" && this.neverValued.has(expr)) + return this.divergentValue(expr, expected); + // Iterator-typed slots: native iterators and differently typed + // generators step through an adapter generator. + if (expected.kind === "generator" && !typeEquals(expr.type, expected)) { + const adapted = iteratorSlotAdapter(this, expr, expected); + if (adapted) return adapted; + } // Island boundary, both directions. IN: any static value flowing into // an any-typed slot marshals implicitly (tsc allows the assignment; // the marshal is where its semantics live). OUT: an 'any' value @@ -8598,6 +8620,33 @@ export class Lowerer { ); } + /** A never-returning call in a value position of type `expected`: the + * call runs for its effects (it throws or exits), and the expression ends + * in an unreachable trap instead of producing a value. The result reads an + * uninitialized hidden local that control never reaches. */ + divergentValue(call: IrExpr, expected: IrType): IrExpr { + const loc = call.loc; + const slot = this.declareHiddenLocal("%never", expected); + slot.mutable = true; + return { + kind: "seqExpr", + stmts: [ + { kind: "exprStmt", expr: call, loc }, + { kind: "varDecl", localId: slot.id, init: null, loc }, + { + kind: "runtimeFence", + code: "SC9002", + message: "unreachable: a 'never' call returned — please report this", + loc, + }, + ], + result: varRef(slot.id, expected, loc), + diverges: true, + type: expected, + loc, + }; + } + /** A function-scope local bound to NO ts.Symbol — the hidden ABI slot of a * defaulted parameter (the parameter's symbol binds to the separately- * declared body local; nothing in the source can name this one). */ diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index aef7f2b5d..028f0f2be 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -2735,6 +2735,15 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { } // Native collection iterators use checked runtime iterator objects. if (isStdlibInterface("MapIterator") || isStdlibInterface("SetIterator")) return DYN; + // Array iterators (`list.values()` and friends) are generators over the + // live array: each step reads the current length and element. + if (isStdlibInterface("ArrayIterator")) { + const arg = checker.getTypeArguments(widened as ts.TypeReference)[0]; + const yieldT = arg ? mapType(arg, ctx) : null; + if (!yieldT || yieldT.kind === "void" || isUnitType(yieldT)) return null; + if (!genResultRecord(yieldT, VOID, ctx.shapes, unions)) return null; + return { kind: "generator", yieldT, retT: VOID, nextT: UNDEFINED_T }; + } // Generator, AsyncGenerator (and // the lib's IterableIterator, the older sync annotation spelling): // the generator kind. Channel normalization keeps the runtime honest: @@ -2756,7 +2765,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if ( isStdlibInterface("Generator") || isStdlibInterface("AsyncGenerator") || - isStdlibInterface("IterableIterator") + isStdlibInterface("IterableIterator") || + // An Iterator annotation holds a single-pass iterator object: a + // generator, or a native iterator adapted into one. + isStdlibInterface("Iterator") ) { const args = checker.getTypeArguments(widened as ts.TypeReference); const channels = genChannels(args[0], args[1], args[2], ctx); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index cf4cd0604..ae1ac945e 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3008,6 +3008,11 @@ export type IrExpr = stmts: IrStmt[]; result: IrExpr; generatorDelegate?: true; + /** The statements end in a `runtimeFence` that control never reaches: + * the value of a call whose checked type is `never` (it throws or exits) + * flowing into a typed slot. `result` is a placeholder read of an + * uninitialized hidden local and is never evaluated. */ + diverges?: true; type: IrType; loc: SrcLoc; } diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 62d56c792..da343f16f 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -1638,6 +1638,12 @@ function validateFunction( allowed.add("throw"); allowed.add("runtimeFence"); } + if (e.diverges) { + const last = e.stmts[e.stmts.length - 1]; + if (last?.kind !== "runtimeFence") + err("diverging seqExpr must end in a runtimeFence", e.loc); + allowed.add("runtimeFence"); + } const flat = (ss: IrStmt[]): void => { for (const s of ss) { if (!allowed.has(s.kind)) { diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index a2cb2f6af..95c20906a 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11670,7 +11670,7 @@ "diags": [ { "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cjs-require-cycle/a.js → /tests/diagnostics/cjs-require-cycle/b.js → /tests/diagnostics/cjs-require-cycle/a.js; the cycle closes through a require() edge) are not supported yet", + "message": "circular imports (/tests/diagnostics/cjs-require-cycle/a.js \u2192 /tests/diagnostics/cjs-require-cycle/b.js \u2192 /tests/diagnostics/cjs-require-cycle/a.js; the cycle closes through a require() edge) are not supported yet", "loc": { "file": "/tests/diagnostics/cjs-require-cycle/a.js", "start": 0, @@ -11707,7 +11707,7 @@ "diags": [ { "code": "SC1013", - "message": "require() below code that can reach its binding 'greet' (Node initializes the binding AT the require and throws ReferenceError on any earlier access, which is not modeled — move the require above the code that can run first) is not supported yet", + "message": "require() below code that can reach its binding 'greet' (Node initializes the binding AT the require and throws ReferenceError on any earlier access, which is not modeled \u2014 move the require above the code that can run first) is not supported yet", "loc": { "file": "/tests/diagnostics/cjs-require-tdz/main.js", "start": 336, @@ -11780,7 +11780,7 @@ "diags": [ { "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cycle-tdz-read/a.ts → /tests/diagnostics/cycle-tdz-read/b.ts → /tests/diagnostics/cycle-tdz-read/a.ts; the cycle-crossing binding 'a' is read at /tests/diagnostics/cycle-tdz-read/b.ts:2, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet", + "message": "circular imports (/tests/diagnostics/cycle-tdz-read/a.ts \u2192 /tests/diagnostics/cycle-tdz-read/b.ts \u2192 /tests/diagnostics/cycle-tdz-read/a.ts; the cycle-crossing binding 'a' is read at /tests/diagnostics/cycle-tdz-read/b.ts:2, outside any function body \u2014 a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet", "loc": { "file": "/tests/diagnostics/cycle-tdz-read/a.ts", "start": 0, @@ -11800,7 +11800,7 @@ "diags": [ { "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cycle-window-call/a.ts → /tests/diagnostics/cycle-window-call/b.ts → /tests/diagnostics/cycle-window-call/a.ts; top-level code at /tests/diagnostics/cycle-window-call/a.ts:5 can run user code during the cycle's init window — only declaration-only module bodies are admitted) are not supported yet", + "message": "circular imports (/tests/diagnostics/cycle-window-call/a.ts \u2192 /tests/diagnostics/cycle-window-call/b.ts \u2192 /tests/diagnostics/cycle-window-call/a.ts; top-level code at /tests/diagnostics/cycle-window-call/a.ts:5 can run user code during the cycle's init window \u2014 only declaration-only module bodies are admitted) are not supported yet", "loc": { "file": "/tests/diagnostics/cycle-window-call/a.ts", "start": 0, @@ -11820,7 +11820,7 @@ "diags": [ { "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cycle/a.ts → /tests/diagnostics/cycle/b.ts → /tests/diagnostics/cycle/a.ts; the cycle-crossing binding 'a' is read at /tests/diagnostics/cycle/b.ts:2, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet", + "message": "circular imports (/tests/diagnostics/cycle/a.ts \u2192 /tests/diagnostics/cycle/b.ts \u2192 /tests/diagnostics/cycle/a.ts; the cycle-crossing binding 'a' is read at /tests/diagnostics/cycle/b.ts:2, outside any function body \u2014 a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet", "loc": { "file": "/tests/diagnostics/cycle/a.ts", "start": 0, @@ -12422,7 +12422,7 @@ "diags": [ { "code": "SC1013", - "message": "calls through a source binding named 'require' (this spelling is reserved for CommonJS/createRequire module edges — rename the binding) is not supported yet", + "message": "calls through a source binding named 'require' (this spelling is reserved for CommonJS/createRequire module edges \u2014 rename the binding) is not supported yet", "loc": { "file": "/tests/diagnostics/local-require-binding-esm.js", "start": 80, @@ -12542,7 +12542,7 @@ }, { "code": "SC1010", - "message": "the 'node:v8' module (it observes V8 engine internals — heap statistics and snapshots, GC and CPU profiles, serialize/deserialize — and a compiled binary embeds no V8 engine to observe) is not supported yet", + "message": "the 'node:v8' module (it observes V8 engine internals \u2014 heap statistics and snapshots, GC and CPU profiles, serialize/deserialize \u2014 and a compiled binary embeds no V8 engine to observe) is not supported yet", "loc": { "file": "/tests/diagnostics/module-out-of-scope.ts", "start": 347, @@ -12586,7 +12586,7 @@ }, { "code": "SC1010", - "message": "the 'node:inspector' module (it drives the V8 inspector protocol — debugger, profiler, heap access — and a compiled binary embeds no V8 engine to inspect) is not supported yet", + "message": "the 'node:inspector' module (it drives the V8 inspector protocol \u2014 debugger, profiler, heap access \u2014 and a compiled binary embeds no V8 engine to inspect) is not supported yet", "loc": { "file": "/tests/diagnostics/module-out-of-scope.ts", "start": 512, @@ -12713,7 +12713,7 @@ "diags": [ { "code": "SC1010", - "message": "the '#types' import (it resolves only to type declarations — there is no runtime implementation to compile) is not supported yet", + "message": "the '#types' import (it resolves only to type declarations \u2014 there is no runtime implementation to compile) is not supported yet", "loc": { "file": "/tests/diagnostics/pkg-import-fences/main.ts", "start": 449, @@ -12724,7 +12724,7 @@ }, { "code": "SC1010", - "message": "the 'ambientonly' module (it exists only as an ambient 'declare module' type surface — there is no runtime module to import) is not supported yet", + "message": "the 'ambientonly' module (it exists only as an ambient 'declare module' type surface \u2014 there is no runtime module to import) is not supported yet", "loc": { "file": "/tests/diagnostics/pkg-import-fences/main.ts", "start": 383, @@ -12735,7 +12735,7 @@ }, { "code": "SC1010", - "message": "the 'patternXmod' module (it exists only as an ambient 'declare module' type surface — there is no runtime module to import) is not supported yet", + "message": "the 'patternXmod' module (it exists only as an ambient 'declare module' type surface \u2014 there is no runtime module to import) is not supported yet", "loc": { "file": "/tests/diagnostics/pkg-import-fences/main.ts", "start": 416, @@ -12879,7 +12879,7 @@ "diags": [ { "code": "SC1013", - "message": "require() in an ES module (Node throws ReferenceError — use import) is not supported yet", + "message": "require() in an ES module (Node throws ReferenceError \u2014 use import) is not supported yet", "loc": { "file": "/tests/diagnostics/top-level-await-implicit-module-nested-require.js", "start": 114, @@ -13500,7 +13500,7 @@ "diags": [ { "code": "SC1010", - "message": "the 'v8' module (it observes V8 engine internals — heap statistics and snapshots, GC and CPU profiles, serialize/deserialize — and a compiled binary embeds no V8 engine to observe) is not supported yet", + "message": "the 'v8' module (it observes V8 engine internals \u2014 heap statistics and snapshots, GC and CPU profiles, serialize/deserialize \u2014 and a compiled binary embeds no V8 engine to observe) is not supported yet", "loc": { "file": "/tests/fixtures/node-types/import-fences.ts", "start": 422, @@ -13933,7 +13933,7 @@ }, "/tests/corpus/node24-esm-metadata/main.mjs": { "order": [ - "/tests/corpus/node24-esm-metadata/child with ü.mjs", + "/tests/corpus/node24-esm-metadata/child with \u00fc.mjs", "/tests/corpus/node24-esm-metadata/main.mjs" ], "diags": [] @@ -14824,6 +14824,30 @@ "/tests/corpus/4044-adapted-callback-modules/main.ts" ], "diags": [] + }, + "/tests/corpus/iterator-typed-returns.ts": { + "order": [ + "/tests/corpus/iterator-typed-returns.ts" + ], + "diags": [] + }, + "/tests/corpus/never-returning-calls.ts": { + "order": [ + "/tests/corpus/never-returning-calls.ts" + ], + "diags": [] + }, + "/tests/corpus/optional-element-defaults.ts": { + "order": [ + "/tests/corpus/optional-element-defaults.ts" + ], + "diags": [] + }, + "/tests/diagnostics/iterator-slot-fences.ts": { + "order": [ + "/tests/diagnostics/iterator-slot-fences.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/iterator-typed-returns.ts b/tests/corpus/iterator-typed-returns.ts new file mode 100644 index 000000000..8ccdd85e5 --- /dev/null +++ b/tests/corpus/iterator-typed-returns.ts @@ -0,0 +1,148 @@ +// Map, Set and array iterators returned through methods annotated +// IterableIterator or Iterator, forwarded through further methods +// and functions, and stored in variables. Iteration stays lazy and live: +// entries added while a loop runs are visited, and an exhausted iterator +// stays exhausted. + +interface Slot { + n: number; +} + +class Inventory { + private readonly counts = new Map(); + private readonly tags = new Set(); + readonly slots = new Map(); + readonly history: string[] = []; + add(name: string, n: number): void { + this.counts.set(name, n); + this.tags.add(name.toUpperCase()); + this.history.push(name); + } + entries(): IterableIterator<[string, number]> { + return this.counts.entries(); + } + names(): IterableIterator { + return this.counts.keys(); + } + amounts(): Iterator { + return this.counts.values(); + } + tagList(): IterableIterator { + return this.tags.values(); + } + recent(): IterableIterator { + return this.history.values(); + } + positions(): IterableIterator<[number, string]> { + return this.history.entries(); + } + slotEntries(): IterableIterator<[string, Slot]> { + return this.slots.entries(); + } +} + +class Store { + readonly stock = new Inventory(); + entries(): IterableIterator<[string, number]> { + return this.stock.entries(); + } +} + +function forward(store: Store): IterableIterator<[string, number]> { + return store.entries(); +} + +function* steps(): Generator { + yield 1; + yield 2; +} +function stepsAsIterator(): IterableIterator { + return steps(); +} + +const store = new Store(); +store.stock.add("bolt", 3); +store.stock.add("nut", 5); + +for (const [name, n] of forward(store)) console.log("entry", name, n); + +let grew = false; +for (const name of store.stock.names()) { + console.log("name", name); + if (!grew) { + store.stock.add("washer", 9); + grew = true; + } +} + +const amounts = store.stock.amounts(); +let step = amounts.next(); +while (!step.done) { + console.log("amount", step.value); + step = amounts.next(); +} +console.log("after", amounts.next().done); + +const stored: IterableIterator<[string, number]> = store.entries(); +for (const [name] of stored) { + console.log("first only", name); + break; +} +for (const [name] of stored) console.log("rest", name); + +console.log("spread", [...store.stock.tagList()]); +console.log("from", Array.from(store.stock.names())); +console.log("positions", [...store.stock.positions()]); + +let total = 0; +for (const v of store.stock.recent()) { + total += v.length; + if (v === "bolt") store.stock.history.push("pin"); +} +console.log("total", total, store.stock.history.length); + +store.stock.slots.set("a", { n: 1 }); +for (const [, slot] of store.stock.slotEntries()) slot.n += 10; +console.log("slot", store.stock.slots.get("a")!.n); + +const counted = stepsAsIterator(); +console.log("generator", counted.next().value, [...counted]); + +const empty = new Inventory(); +console.log("empty", [...empty.entries()], empty.amounts().next().done); + +// Leaving a loop early closes a generator (its finally block runs) but +// leaves a native iterator open, so a later loop resumes where it stopped. +function* guarded(): Generator { + try { + yield 1; + yield 2; + yield 3; + } finally { + console.log("guarded closed"); + } +} +function guardedSteps(): IterableIterator { + return guarded(); +} +const closing = guardedSteps(); +for (const n of closing) { + console.log("guarded", n); + if (n === 2) break; +} +console.log("guarded after", closing.next().done); + +const resumable = store.entries(); +for (const [name] of resumable) { + console.log("before throw", name); + break; +} +try { + for (const [name] of resumable) { + console.log("throwing at", name); + throw new Error("stop"); + } +} catch (e) { + if (e instanceof Error) console.log("caught", e.message); +} +for (const [name] of resumable) console.log("resumed", name); diff --git a/tests/corpus/never-returning-calls.ts b/tests/corpus/never-returning-calls.ts new file mode 100644 index 000000000..2bf90cf92 --- /dev/null +++ b/tests/corpus/never-returning-calls.ts @@ -0,0 +1,102 @@ +// A call to a function returning `never` can stand wherever a value is +// expected: it still runs (and throws), and the surrounding expression +// never needs its value. Each case runs both its normal path and its +// throwing path. + +class Point { + x: number; + constructor(x: number) { + this.x = x; + } +} + +function fail(message: string): never { + throw new Error(message); +} +function rangeFail(): never { + throw new RangeError("out of range"); +} + +function lengthOf(text: string): number { + if (text.length > 0) return text.length; + return fail("empty input"); +} +function checked(text: string): number { + const n: number = text.length > 0 ? text.length : fail("no length"); + return n; +} +function labelOf(value: string | undefined): string { + return value ?? fail("missing label"); +} +function pointOf(value: Point | undefined): Point { + return value ?? fail("missing point"); +} +const makePoint = (ok: boolean): Point => (ok ? new Point(3) : rangeFail()); +const count: () => number = () => fail("no count"); +function listOf(ok: boolean): string[] { + if (ok) return ["a"]; + const items: string[] = fail("no list"); + return items; +} +function maybe(flag: boolean): number | undefined { + if (flag) return 1; + return fail("no maybe"); +} +function twice(n: number): number { + return n * 2; +} +function same(value: T): T { + return value; +} + +class Reader { + pos = 0; + error(message: string): never { + throw new SyntaxError(message + " at " + this.pos); + } + digit(c: string): number { + return c >= "0" && c <= "9" ? c.charCodeAt(0) - 48 : this.error("bad digit " + c); + } +} + +function attempt(label: string, run: () => unknown): void { + try { + console.log(label, run()); + } catch (e) { + if (e instanceof Error) console.log(label, "caught", e.name, e.message); + } +} + +attempt("length", () => lengthOf("abc")); +attempt("length empty", () => lengthOf("")); +attempt("checked", () => checked("ab")); +attempt("checked empty", () => checked("")); +attempt("label", () => labelOf("tag")); +attempt("label missing", () => labelOf(undefined)); +attempt("point", () => pointOf(new Point(5)).x); +attempt("point missing", () => pointOf(undefined).x); +attempt("arrow", () => makePoint(true).x); +attempt("arrow fails", () => makePoint(false).x); +attempt("count", () => count()); +attempt("list", () => listOf(true).length); +attempt("list fails", () => listOf(false).length); +attempt("maybe", () => maybe(true)); +attempt("maybe fails", () => maybe(false)); +attempt("argument", () => twice(fail("as argument"))); +let total = 0; +attempt("generic", () => same(total > 0 ? total : fail("generic"))); +attempt("assigned", () => { + total = fail("assigned"); + return total; +}); +attempt("digit", () => new Reader().digit("7")); +attempt("digit fails", () => new Reader().digit("q")); + +async function load(ok: boolean): Promise { + if (ok) return 7; + return fail("async failure"); +} +load(true).then((v) => console.log("async", v)); +load(false).catch((e) => { + if (e instanceof Error) console.log("async caught", e.message); +}); diff --git a/tests/corpus/optional-element-defaults.ts b/tests/corpus/optional-element-defaults.ts new file mode 100644 index 000000000..1b7b45c39 --- /dev/null +++ b/tests/corpus/optional-element-defaults.ts @@ -0,0 +1,84 @@ +// Unchecked element reads are undefined past the end of an array even +// though the element type omits undefined. Slots whose types admit +// undefined receive that value as their undefined state; present +// elements keep their ordinary values. + +function attempt(label: string, run: () => unknown): void { + try { + console.log(label, run()); + } catch (e) { + if (e instanceof Error) console.log(label, "threw", e.name); + } +} + +const noNames: string[] = []; +const names = ["ada", "bo"]; +const scores = [7]; + +function firstName(preferred: string | undefined, list: string[]): string | undefined { + return preferred ?? list[0]; +} +function thirdScore(list: number[]): number | undefined { + return list[2]; +} +function describe(value: string | undefined): string { + return value === undefined ? "none" : value; +} +function pickOr(flag: boolean, list: string[]): string | undefined { + return flag ? list[0] : "fixed"; +} +function eitherName(list: string[]): string | undefined { + return list[1] || list[0]; +} +function stored(preferred: string | undefined, list: string[]): string | undefined { + const chosen = preferred ?? list[0]; + return chosen; +} +function fromPattern(preferred: string | undefined, list: string[]): string | undefined { + const [head] = list; + return preferred ?? head; +} +function laterRead(preferred: string | undefined, list: string[]): () => string | undefined { + return () => preferred ?? list[0]; +} +function orFallback(value: T | undefined, fallback: T): T { + return value === undefined ? fallback : value; +} + +class Shelf { + items: string[] = []; + label: string | undefined; + top(): string | undefined { + return this.label ?? this.items[this.items.length - 1]; + } +} + +attempt("first empty", () => firstName(undefined, noNames)); +attempt("first named", () => firstName(undefined, names)); +attempt("first preferred", () => firstName("cy", noNames)); +attempt("third", () => thirdScore(scores)); +attempt("describe", () => describe(noNames[0])); +attempt("describe present", () => describe(names[1])); +attempt("ternary", () => pickOr(true, noNames)); +attempt("or", () => eitherName(noNames)); +attempt("or present", () => eitherName(names)); +attempt("stored", () => stored(undefined, noNames)); +attempt("pattern", () => fromPattern(undefined, noNames)); +attempt("pattern present", () => fromPattern(undefined, names)); +attempt("capture", () => laterRead(undefined, noNames)()); +attempt("generic", () => orFallback(noNames[0] ?? undefined, "fallback")); +attempt("explicit generic", () => orFallback(noNames[3], "unused")); +attempt("length", () => (firstName(undefined, names) ?? "").length); +let missing: string | undefined; +attempt("concat", () => (missing ?? noNames[0]) + "!"); +attempt("template", () => `${firstName(undefined, noNames)}`); + +const shelf = new Shelf(); +attempt("shelf empty", () => shelf.top()); +shelf.items.push("box", "jar"); +attempt("shelf top", () => shelf.top()); +shelf.label = "label"; +attempt("shelf label", () => shelf.top()); + +// A member read needs the present value: undefined still throws there. +attempt("member", () => (missing ?? noNames[0]).length); diff --git a/tests/diagnostics/iterator-slot-fences.ts b/tests/diagnostics/iterator-slot-fences.ts new file mode 100644 index 000000000..759929615 --- /dev/null +++ b/tests/diagnostics/iterator-slot-fences.ts @@ -0,0 +1,21 @@ +// Iterator-typed slots hold generators and built-in collection iterators. +// A class implementing the iterator protocol stays a pointed fence there, +// and Iterable annotations have no lowering yet. + +class Countdown implements Iterator { + n = 2; + next(): IteratorResult { + return this.n > 0 ? { value: this.n--, done: false } : { value: undefined, done: true }; + } +} +function firstOf(it: Iterator): void { + console.log(it.next().value); +} +firstOf(new Countdown()); + +function total(values: Iterable): number { + let sum = 0; + for (const v of values) sum += v; + return sum; +} +console.log(total([1, 2])); diff --git a/tests/diagnostics/stdlib-fence.ts b/tests/diagnostics/stdlib-fence.ts index 7293b777e..213a2388d 100644 --- a/tests/diagnostics/stdlib-fence.ts +++ b/tests/diagnostics/stdlib-fence.ts @@ -38,7 +38,7 @@ const sinhMethod = Math.sinh; // fixed-arity Math function values now lower // (n-ary Math.min/max LOWER now — the variadic battery lives in the // corpus; the mixed spread/positional list is the form that stays fenced.) const clamped = Math.min(1, ...[2, 3]); -const entries = [1, 2].entries(); +const entries = [1, 2].entries(); // array iterators lower now const arrAt = [1, 2].at(0); const norm = "abc".normalize(); const limited = "a,b,c".split(Math.random() ? "," : /,/, 2); // union separators stay fenced; limits lower diff --git a/tests/harness/__snapshots__/iterator-slot-fences.ts.txt b/tests/harness/__snapshots__/iterator-slot-fences.ts.txt new file mode 100644 index 000000000..fd244e8fd --- /dev/null +++ b/tests/harness/__snapshots__/iterator-slot-fences.ts.txt @@ -0,0 +1,24 @@ +iterator-slot-fences.ts:14:9 - error SC1090: 'Countdown' values in an iterator-typed slot (Iterator and IterableIterator slots hold generators and built-in collection iterators) is not supported yet + + 13 | } + 14 | firstOf(new Countdown()); + | ^~~~~~~~~~~~~~~ + 15 | + +iterator-slot-fences.ts:16:16 - error SC2020: 'Iterable' is part of the standard library types but has no scriptc lowering yet + + 15 | + 16 | function total(values: Iterable): number { + | ^~~~~~ + 17 | let sum = 0; + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + +iterator-slot-fences.ts:21:13 - error SC2004: uses of 'total' inherit the blocker on its declaration + + 20 | } + 21 | console.log(total([1, 2])); + | ^~~~~ + 22 | + + hint: the declaration of 'total' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index 07d983a61..464c10a5b 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -116,28 +116,10 @@ stdlib-fence.ts:40:17 - error SC2020: 'Math.min' is part of the standard library 39 | // corpus; the mixed spread/positional list is the form that stays fenced.) 40 | const clamped = Math.min(1, ...[2, 3]); | ^~~~~~~~ - 41 | const entries = [1, 2].entries(); + 41 | const entries = [1, 2].entries(); // array iterators lower now hint: Math.min lowers with any number of plain arguments or ONE number[] spread — mixed spread/positional lists don't: spread a single array (Math.min(...xs)) -stdlib-fence.ts:41:7 - error SC2020: 'ArrayIterator<[number, number]>' is part of the standard library types but has no scriptc lowering yet - - 40 | const clamped = Math.min(1, ...[2, 3]); - 41 | const entries = [1, 2].entries(); - | ^~~~~~~ - 42 | const arrAt = [1, 2].at(0); - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) - -stdlib-fence.ts:41:17 - error SC2020: 'number[].entries' is part of the standard library types but has no scriptc lowering yet - - 40 | const clamped = Math.min(1, ...[2, 3]); - 41 | const entries = [1, 2].entries(); - | ^~~~~~~~~~~~~~ - 42 | const arrAt = [1, 2].at(0); - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) - stdlib-fence.ts:44:31 - error SC1090: '.split()' on a 'RegExp | string' separator (pass a string, or a regex literal) is not supported yet 43 | const norm = "abc".normalize(); From 4795ddd1dd3be339d69f0a2589220a615db8b48a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 11:29:07 -0500 Subject: [PATCH 09/14] Initialize import cycles in module evaluation order (#740) --- packages/compiler/src/backend/llvm/emitter.ts | 7 + .../compiler/src/backend/llvm/expr-async.ts | 10 + packages/compiler/src/backend/may-throw.ts | 2 + .../compiler/src/diagnostics/diagnostic.ts | 18 +- .../frontend/lowering/cycle-initialization.ts | 316 ++++++++++ .../src/frontend/lowering/lower-modules.ts | 14 +- .../compiler/src/frontend/lowering/lowerer.ts | 11 + .../compiler/src/frontend/program.test.ts | 67 ++- packages/compiler/src/frontend/program.ts | 552 ++++++++++++++++-- packages/compiler/src/ir/ir.ts | 14 +- packages/compiler/src/ir/validate.ts | 13 +- .../test/ts7/baselines/order-parity.json | 187 +++--- .../4060-cycle-shared-instance/inventory.ts | 17 + .../corpus/4060-cycle-shared-instance/main.ts | 9 + .../4060-cycle-shared-instance/report.ts | 7 + .../4061-cycle-evaluation-order/east.ts | 6 + .../4061-cycle-evaluation-order/main.ts | 9 + .../4061-cycle-evaluation-order/north.ts | 12 + .../4061-cycle-evaluation-order/south.ts | 6 + tests/corpus/4062-cycle-tdz-read/limits.ts | 16 + tests/corpus/4062-cycle-tdz-read/main.ts | 6 + tests/corpus/4062-cycle-tdz-read/probe.ts | 28 + tests/corpus/4063-cycle-hoisted-calls/even.ts | 11 + tests/corpus/4063-cycle-hoisted-calls/main.ts | 8 + tests/corpus/4063-cycle-hoisted-calls/odd.ts | 6 + .../4064-cycle-classes-after-init/item.ts | 15 + .../4064-cycle-classes-after-init/main.ts | 12 + .../4064-cycle-classes-after-init/shelf.ts | 23 + tests/corpus/4065-cycle-init-throws/ledger.ts | 5 + tests/corpus/4065-cycle-init-throws/main.ts | 7 + tests/corpus/4065-cycle-init-throws/rules.ts | 11 + .../corpus/4066-cycle-top-level-tdz/frame.ts | 4 + tests/corpus/4066-cycle-top-level-tdz/main.ts | 7 + .../corpus/4066-cycle-top-level-tdz/margin.ts | 4 + .../4067-dynamic-import-cycle-tdz}/a.ts | 4 +- .../4067-dynamic-import-cycle-tdz}/b.ts | 1 + .../4067-dynamic-import-cycle-tdz/main.ts | 14 + tests/diagnostics/cycle-class-early/dogs.ts | 5 + tests/diagnostics/cycle-class-early/main.ts | 2 + tests/diagnostics/cycle-class-early/pets.ts | 11 + tests/diagnostics/cycle-default-export/a.ts | 2 + tests/diagnostics/cycle-default-export/b.ts | 7 + .../diagnostics/cycle-default-export/main.ts | 2 + tests/diagnostics/cycle-namespace-import/a.ts | 3 + tests/diagnostics/cycle-namespace-import/b.ts | 4 + .../cycle-namespace-import/main.ts | 2 + tests/diagnostics/cycle-tdz-read/a.ts | 5 - tests/diagnostics/cycle-tdz-read/b.ts | 2 - tests/diagnostics/cycle-tdz-read/main.ts | 2 - tests/diagnostics/cycle-var-early/a.ts | 4 + tests/diagnostics/cycle-var-early/b.ts | 4 + tests/diagnostics/cycle-var-early/main.ts | 2 + tests/diagnostics/cycle-window-call/a.ts | 5 - tests/diagnostics/cycle-window-call/b.ts | 4 - tests/diagnostics/cycle-window-call/main.ts | 2 - tests/diagnostics/cycle/a.ts | 2 - tests/diagnostics/cycle/b.ts | 2 - tests/diagnostics/cycle/main.ts | 2 - .../diagnostics/dyn-import-cycle-tdz/main.ts | 12 - .../cjs-require-cycle/main.js.txt | 2 +- .../cycle-class-early/main.ts.txt | 8 + .../cycle-default-export/main.ts.txt | 7 + .../cycle-namespace-import/main.ts.txt | 7 + .../__snapshots__/cycle-tdz-read/main.ts.txt | 7 - .../__snapshots__/cycle-var-early/main.ts.txt | 8 + .../cycle-window-call/main.ts.txt | 7 - tests/harness/__snapshots__/cycle/main.ts.txt | 7 - .../dyn-import-cycle-tdz/main.ts.txt | 7 - .../self-hosting-frontend-program.test.ts | 3 +- 69 files changed, 1395 insertions(+), 213 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/cycle-initialization.ts create mode 100644 tests/corpus/4060-cycle-shared-instance/inventory.ts create mode 100644 tests/corpus/4060-cycle-shared-instance/main.ts create mode 100644 tests/corpus/4060-cycle-shared-instance/report.ts create mode 100644 tests/corpus/4061-cycle-evaluation-order/east.ts create mode 100644 tests/corpus/4061-cycle-evaluation-order/main.ts create mode 100644 tests/corpus/4061-cycle-evaluation-order/north.ts create mode 100644 tests/corpus/4061-cycle-evaluation-order/south.ts create mode 100644 tests/corpus/4062-cycle-tdz-read/limits.ts create mode 100644 tests/corpus/4062-cycle-tdz-read/main.ts create mode 100644 tests/corpus/4062-cycle-tdz-read/probe.ts create mode 100644 tests/corpus/4063-cycle-hoisted-calls/even.ts create mode 100644 tests/corpus/4063-cycle-hoisted-calls/main.ts create mode 100644 tests/corpus/4063-cycle-hoisted-calls/odd.ts create mode 100644 tests/corpus/4064-cycle-classes-after-init/item.ts create mode 100644 tests/corpus/4064-cycle-classes-after-init/main.ts create mode 100644 tests/corpus/4064-cycle-classes-after-init/shelf.ts create mode 100644 tests/corpus/4065-cycle-init-throws/ledger.ts create mode 100644 tests/corpus/4065-cycle-init-throws/main.ts create mode 100644 tests/corpus/4065-cycle-init-throws/rules.ts create mode 100644 tests/corpus/4066-cycle-top-level-tdz/frame.ts create mode 100644 tests/corpus/4066-cycle-top-level-tdz/main.ts create mode 100644 tests/corpus/4066-cycle-top-level-tdz/margin.ts rename tests/{diagnostics/dyn-import-cycle-tdz => corpus/4067-dynamic-import-cycle-tdz}/a.ts (55%) rename tests/{diagnostics/dyn-import-cycle-tdz => corpus/4067-dynamic-import-cycle-tdz}/b.ts (73%) create mode 100644 tests/corpus/4067-dynamic-import-cycle-tdz/main.ts create mode 100644 tests/diagnostics/cycle-class-early/dogs.ts create mode 100644 tests/diagnostics/cycle-class-early/main.ts create mode 100644 tests/diagnostics/cycle-class-early/pets.ts create mode 100644 tests/diagnostics/cycle-default-export/a.ts create mode 100644 tests/diagnostics/cycle-default-export/b.ts create mode 100644 tests/diagnostics/cycle-default-export/main.ts create mode 100644 tests/diagnostics/cycle-namespace-import/a.ts create mode 100644 tests/diagnostics/cycle-namespace-import/b.ts create mode 100644 tests/diagnostics/cycle-namespace-import/main.ts delete mode 100644 tests/diagnostics/cycle-tdz-read/a.ts delete mode 100644 tests/diagnostics/cycle-tdz-read/b.ts delete mode 100644 tests/diagnostics/cycle-tdz-read/main.ts create mode 100644 tests/diagnostics/cycle-var-early/a.ts create mode 100644 tests/diagnostics/cycle-var-early/b.ts create mode 100644 tests/diagnostics/cycle-var-early/main.ts delete mode 100644 tests/diagnostics/cycle-window-call/a.ts delete mode 100644 tests/diagnostics/cycle-window-call/b.ts delete mode 100644 tests/diagnostics/cycle-window-call/main.ts delete mode 100644 tests/diagnostics/cycle/a.ts delete mode 100644 tests/diagnostics/cycle/b.ts delete mode 100644 tests/diagnostics/cycle/main.ts delete mode 100644 tests/diagnostics/dyn-import-cycle-tdz/main.ts create mode 100644 tests/harness/__snapshots__/cycle-class-early/main.ts.txt create mode 100644 tests/harness/__snapshots__/cycle-default-export/main.ts.txt create mode 100644 tests/harness/__snapshots__/cycle-namespace-import/main.ts.txt delete mode 100644 tests/harness/__snapshots__/cycle-tdz-read/main.ts.txt create mode 100644 tests/harness/__snapshots__/cycle-var-early/main.ts.txt delete mode 100644 tests/harness/__snapshots__/cycle-window-call/main.ts.txt delete mode 100644 tests/harness/__snapshots__/cycle/main.ts.txt delete mode 100644 tests/harness/__snapshots__/dyn-import-cycle-tdz/main.ts.txt diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index cac477795..de763aa06 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -3735,6 +3735,13 @@ export class LlEmitter { this.throwIfUninitialized(empty, name); } + /** Throws the TDZ ReferenceError for `name` unless the i1 `flag` is set. */ + checkInitializedFlag(flag: string, name: string): void { + const empty = this.B.tmp(); + this.B.line(`${empty} = xor i1 ${flag}, true`); + this.throwIfUninitialized(empty, name); + } + private throwIfUninitialized(empty: string, name: string): void { const B = this.B; const lt = B.newLabel("tdz.t"); diff --git a/packages/compiler/src/backend/llvm/expr-async.ts b/packages/compiler/src/backend/llvm/expr-async.ts index d69cdc347..fbaaa24a5 100644 --- a/packages/compiler/src/backend/llvm/expr-async.ts +++ b/packages/compiler/src/backend/llvm/expr-async.ts @@ -11,6 +11,16 @@ export function emitIntrinsicExpr(host: LlvmEmitterContext, e: ExprOf<"intrinsic const B = host.B; switch (e.kind) { case "intrinsic": { + if (e.name === "module.tdzCheck") { + // An import-cycle binding's initialization flag: false is the + // temporal dead zone (Node's catchable ReferenceError). + const flag = host.emitExpr(e.args[0]!); + const name = e.args[1]!; + if (name.kind !== "strLit") + throw new InternalCompilerError("llvm emitter bug: module.tdzCheck name"); + host.checkInitializedFlag(flag.name, name.value); + return { name: "", type: e.type }; + } if (e.name === "module.await") { // Internal ESM dependency wait: pending promises park the module // fiber, while settled ones continue synchronously. diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 6a419b78b..317fe05f0 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -142,6 +142,8 @@ export function computeMayThrow(mod: IrModule): { fns: Set; indirect: bo // Module dependency evaluation has await's rejection behavior, // while deliberately avoiding await's extra settled-promise turn. if (rec.name === "module.await") f.throws = true; + // A cycle binding read before its declaration ran (TDZ). + if (rec.name === "module.tdzCheck") f.throws = true; break; case "yieldExpr": // A consumer .throw() surfaces at the yield (and .return()'s diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 6597ae471..13b38f954 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -197,17 +197,19 @@ export const UNSUPPORTED: Record = { hint: "export declarations directly: export function f() {}", }, SC1015: { feature: "dynamic import()", milestone: "M4" }, - // Benign cycles are ADMITTED (Node runs them as cache hits): cycles of - // ES modules whose top levels are declaration-only and whose - // cycle-crossing bindings are used only inside function bodies (mutual - // recursion, cross-module class references), plus closing edges that - // bind nothing readable. SC1016 remains for exactly the cycles where - // Node's partial initialization is observable — the message names the - // offending binding or top-level statement. + // ES-module cycles are ADMITTED and evaluate in Node's order: module + // bodies run depth-first, function declarations are callable from the + // start, and let/const bindings that can be read before their + // declaration carry an initialization check (Node's TDZ ReferenceError). + // SC1016 remains for the shapes without an exact representation — + // CommonJS members, top-level await, default-export expressions, + // namespace imports or re-exports of cycle members, and var or class + // bindings reachable before their declaration ran. The message names + // the offending construct. SC1016: { feature: "circular imports", milestone: "later", - hint: "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window", + hint: "ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge", }, // Class DECLARATIONS shipped; SC1020's remaining use is class expressions. SC1020: { feature: "class expressions", milestone: "later" }, diff --git a/packages/compiler/src/frontend/lowering/cycle-initialization.ts b/packages/compiler/src/frontend/lowering/cycle-initialization.ts new file mode 100644 index 000000000..834ee50d2 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/cycle-initialization.ts @@ -0,0 +1,316 @@ +/* Import-cycle initialization state: module bindings of a cycle that some + * read or write can reach before their declarations ran + * (cycleEarlyBindings) get a BOOL initialization flag. The declaring + * module's %init sets the flag right after the declaration's store, and + * every access from code that can run earlier checks it first — Node's + * temporal-dead-zone ReferenceError, with the same message. */ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; +import { cycleEarlyBindings, locOf } from "../program.js"; +import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; +import { + BOOL, + STRING, + VOID, + type IrExpr, + type IrFunction, + type IrGlobal, + type IrModule, + type IrStmt, + type IrType, + type SrcLoc, +} from "../../ir/ir.js"; +import { everyStmt, transformStmtList } from "../../ir/traverse.js"; + +/** One flagged binding: its storage, its flag, and the module bodies whose + * top-level code only runs after the declaration (no check needed). */ +export interface CycleInitFlag { + global: IrGlobal; + flagId: string; + name: string; + /** The declaring module. */ + module: ts.SourceFile; + settled: ReadonlySet; +} + +/** Registers the initialization flags after global collection. Bindings + * whose early access has no exact representation are refused: a `var` + * (Node reads `undefined` there, but module storage only starts when the + * declaring module's body starts), a class (its references are static), a + * binding without module storage, or one with a use the lowering resolves + * from the binding's type (a property key, a `typeof` operand). */ +export function markCycleEarlyBindings(lowerer: Lowerer): void { + const early = cycleEarlyBindings( + lowerer.program, + lowerer.entry, + lowerer.moduleOrder, + lowerer.forkTargets.length === 0 && lowerer.workerTargets.length === 0, + ); + const refuse = (node: ts.Node, reason: string): void => { + if (lowerer.remainder) return; + lowerer.pushDiag(unsupportedDiag("SC1016", locOf(node), `circular imports (${reason})`)); + }; + for (const binding of early) { + const where = `'${binding.name.text}' declared in ${binding.module.fileName} can be accessed before its declaration runs`; + if (binding.kind === "var") { + refuse(binding.reference, `the var binding ${where}, where Node reads undefined`); + continue; + } + if (binding.kind === "class") { + refuse(binding.reference, `the class ${where}`); + continue; + } + const global = lowerer.globalsBySymbol.get(binding.symbol); + if (global === undefined) { + refuse(binding.reference, `the binding ${where}, and it has no module storage to check`); + continue; + } + if (binding.folded !== undefined) { + refuse( + binding.folded, + `the binding ${where}, and this use is resolved from its type rather than its storage`, + ); + continue; + } + if (lowerer.cycleInitFlags.has(global.id)) continue; + const flagId = `${global.id}%initialized`; + lowerer.globalsList.push({ id: flagId, name: "%initialized", type: BOOL, mutable: true }); + global.initFlag = flagId; + const flag: CycleInitFlag = { + global, + flagId, + name: binding.name.text, + module: binding.module, + settled: binding.settled, + }; + lowerer.cycleInitFlags.set(global.id, flag); + const stmt = declaringStatement(binding.name); + if (stmt !== null) { + const list = lowerer.cycleInitDeclarations.get(stmt) ?? []; + list.push(flag); + lowerer.cycleInitDeclarations.set(stmt, list); + } + } +} + +function declaringStatement(name: ts.Node): ts.Statement | null { + for (let p: ts.Node | undefined = name.parent; p !== undefined; p = p.parent) { + if (ts.isVariableStatement(p)) return p; + if (ts.isSourceFile(p)) return null; + } + return null; +} + +function setFlag(flag: CycleInitFlag, loc: SrcLoc): IrStmt { + return { + kind: "assign", + localId: flag.flagId, + value: { kind: "boolLit", value: true, type: BOOL, loc }, + loc, + }; +} + +/** The declaring statement's IR with each flagged binding's flag set right + * after the binding's first store (or after the whole statement when the + * declaration stores nothing, as `let x;` can). */ +export function withCycleInitFlags( + lowerer: Lowerer, + stmt: ts.Statement, + lowered: IrStmt[], +): IrStmt[] { + const flags = lowerer.cycleInitDeclarations.get(stmt); + if (flags === undefined) return lowered; + const pending = new Map(flags.map((f) => [f.global.id, f] as const)); + const place = (list: IrStmt[]): IrStmt[] => { + const out: IrStmt[] = []; + for (const s of list) { + if (pending.size === 0) { + out.push(s); + continue; + } + if (s.kind === "assign" && pending.has(s.localId)) { + out.push({ ...s, initializes: true }, setFlag(pending.get(s.localId)!, s.loc)); + pending.delete(s.localId); + continue; + } + if (s.kind === "block") { + out.push({ ...s, body: place(s.body) }); + continue; + } + out.push(s); + // A store nested in another construct: the flag follows the construct. + for (const [id, flag] of [...pending]) { + let stores = false; + let nested = false; + everyStmt(s, { + expr: (e) => { + if (e.kind === "assignExpr" && e.localId === id) nested = true; + return true; + }, + stmt: (inner) => { + if (inner.kind === "assign" && inner.localId === id) { + (inner as { initializes?: true }).initializes = true; + stores = true; + } + return true; + }, + }); + if (nested && !lowerer.remainder) { + lowerer.pushDiag( + unsupportedDiag( + "SC1016", + locOf(stmt), + `circular imports (the declaration of '${flag.name}' stores it inside an expression, where its initialization cannot be marked)`, + ), + ); + } + if (stores || nested) { + out.push(setFlag(flag, s.loc)); + pending.delete(id); + } + } + } + return out; + }; + const out = place(lowered); + const loc = locOf(stmt); + for (const flag of pending.values()) out.push(setFlag(flag, loc)); + return out; +} + +/** Inserts the initialization checks into every function that can access a + * flagged binding before its declaration ran: every function except the + * %init bodies of settled modules, and in the declaring module's own %init + * only the statements up to the one that sets the flag (earlier reads + * there come from inlined callback bodies; tsc rejects direct ones). Reads + * check before reading; writes check after evaluating the assigned value, + * as PutValue does. The declaration's own store is never checked. */ +export function checkCycleBindingAccesses(lowerer: Lowerer, mod: IrModule): void { + if (lowerer.cycleInitFlags.size === 0) return; + const fileOfInit = new Map(); + for (const [sf, name] of lowerer.initNameOf) fileOfInit.set(name, sf); + for (const fn of mod.functions) rewriteFunction(lowerer, fn, fileOfInit.get(fn.name)); +} + +function rewriteFunction( + lowerer: Lowerer, + fn: IrFunction, + initOf: ts.SourceFile | undefined, +): void { + const active = new Map(); + for (const [id, flag] of lowerer.cycleInitFlags) { + if (initOf === undefined || initOf === flag.module || !flag.settled.has(initOf)) { + active.set(id, flag); + } + } + if (active.size === 0) return; + const produced = new WeakSet(); + let temps = 0; + const check = (flag: CycleInitFlag, loc: SrcLoc): IrStmt => { + const expr: IrExpr = { + kind: "intrinsic", + name: "module.tdzCheck", + args: [ + { kind: "varRef", localId: flag.flagId, type: BOOL, loc }, + { kind: "strLit", value: flag.name, type: STRING, loc }, + ], + type: VOID, + loc, + }; + return { kind: "exprStmt", expr, loc }; + }; + const temp = (type: IrType): string => { + const id = `%cycleInit.${temps++}`; + fn.locals.push({ id, name: "%cycleInit", type, mutable: false }); + return id; + }; + const transform = { + expr: (e: IrExpr): IrExpr => { + if (produced.has(e)) return e; + if (e.kind !== "varRef" && e.kind !== "incDec" && e.kind !== "assignExpr") return e; + const flag = active.get(e.localId); + if (flag === undefined) return e; + if (e.kind === "assignExpr") { + const t = temp(e.value.type); + const value: IrExpr = { kind: "varRef", localId: t, type: e.value.type, loc: e.loc }; + const write: IrExpr = { ...e, value }; + produced.add(write); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: t, init: e.value, loc: e.loc }, check(flag, e.loc)], + result: write, + type: e.type, + loc: e.loc, + }; + } + produced.add(e); + return { kind: "seqExpr", stmts: [check(flag, e.loc)], result: e, type: e.type, loc: e.loc }; + }, + stmt: (s: IrStmt): IrStmt => { + if (produced.has(s) || s.kind !== "assign" || s.initializes) return s; + const flag = active.get(s.localId); + if (flag === undefined) return s; + const t = temp(s.value.type); + const write: IrStmt = { + kind: "assign", + localId: s.localId, + value: { kind: "varRef", localId: t, type: s.value.type, loc: s.loc }, + loc: s.loc, + }; + produced.add(write); + return { + kind: "block", + body: [ + { kind: "varDecl", localId: t, init: s.value, loc: s.loc }, + check(flag, s.loc), + write, + ], + loc: s.loc, + }; + }, + }; + if (initOf === undefined) { + fn.body = transformStmtList(fn.body, transform); + return; + } + // A module %init: its own bindings stop needing checks once the + // top-level statement setting their flag completed. + fn.body = fn.body.map((s) => { + if (active.size === 0) return s; + const out = transformStmtList([s], transform)[0]!; + for (const [id, flag] of [...active]) { + if (flag.module !== initOf) continue; + const sets = !everyStmt(out, { + expr: () => true, + stmt: (inner) => !(inner.kind === "assign" && inner.localId === flag.flagId), + }); + if (sets) active.delete(id); + } + return out; + }); +} + +/** Class layouts that read module storage directly (an evaluated heritage + * value, a symbol-keyed field) bypass the checks: refuse when that storage + * is a flagged binding. */ +export function refuseUncheckedCycleReads( + lowerer: Lowerer, + classes: readonly { baseValueGlobal?: string; symbolFields?: { globalId: string }[] }[], +): void { + if (lowerer.cycleInitFlags.size === 0 || lowerer.remainder) return; + for (const cls of classes) { + const ids = [cls.baseValueGlobal, ...(cls.symbolFields ?? []).map((f) => f.globalId)]; + for (const id of ids) { + const flag = id === undefined ? undefined : lowerer.cycleInitFlags.get(id); + if (flag === undefined) continue; + const loc = flag.global.source?.loc ?? { file: lowerer.entry.fileName, start: 0, end: 0 }; + lowerer.pushDiag( + unsupportedDiag( + "SC1016", + loc, + `circular imports (the class layout reads '${flag.name}' directly, before an initialization check can run)`, + ), + ); + } + } +} diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 25ab5aaa1..946cb00b0 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -33,6 +33,7 @@ import { resolveNpmImport, } from "../program.js"; import type { CycleEdge } from "../program.js"; +import { markCycleEarlyBindings, withCycleInitFlags } from "./cycle-initialization.js"; import { invalidJsonModuleDiag, npmEmbedFailedDiag, @@ -507,6 +508,7 @@ export function collectProgram(lowerer: Lowerer, parts: FileParts[]): void { } lowerer.collecting = wasCollecting; } + markCycleEarlyBindings(lowerer); } /** The npm-import chokepoint, run once per pass during collection (so @@ -3088,8 +3090,16 @@ export function lowerFileInit( body.push(...lowerer.lowerStaticFieldInits(statics[at]!.info)); at++; } - const deferred = lowerer.deferredModuleInitializers?.defer(lowerer, stmt); - const stmtIr = deferred ? [deferred] : lowerer.lowerStmts([stmt]); + // A cycle binding's declaration stays at its position: its + // initialization flag must be set where the declaration runs. + const deferred = lowerer.cycleInitDeclarations.has(stmt) + ? null + : lowerer.deferredModuleInitializers?.defer(lowerer, stmt); + const stmtIr = withCycleInitFlags( + lowerer, + stmt, + deferred ? [deferred] : lowerer.lowerStmts([stmt]), + ); // Class EXPRESSIONS inside this statement queued their static // inits while it lowered: they land immediately before it — JS's // order for the supported whole-initializer positions. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index e84f6c71c..3f5ca9eab 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,3 +1,8 @@ +import { + checkCycleBindingAccesses, + refuseUncheckedCycleReads, + type CycleInitFlag, +} from "./cycle-initialization.js"; import { identityPreservingWidening } from "./coercions/identity.js"; import { checkedClassAssertion } from "./class-assertions.js"; import { narrowStoredClassValue, narrowGenericClassValue } from "./class-unions.js"; @@ -2468,6 +2473,10 @@ export class Lowerer { * function called above the declaration statement reads that, never a * NULL slot. Filled by collectGlobals. */ readonly varGlobalEntryInits = new Map(); + /** Import-cycle bindings with an initialization flag, by global id, and + * the top-level statements declaring them (cycle-initialization.ts). */ + readonly cycleInitFlags = new Map(); + readonly cycleInitDeclarations = new Map(); get ctx(): FnCtx { const top = this.fnStack[this.fnStack.length - 1]; @@ -4957,6 +4966,7 @@ export class Lowerer { // the emitted program references, flushing deferred class diagnostics // that a reached type makes relevant. const artifacts = this.moduleArtifacts(functions); + refuseUncheckedCycleReads(this, artifacts.classes ?? []); // Types still naming a class that never REGISTERED after retention's // flush (JS graphs whose class fences deferred to runtime — the // sentence-walker's path params, printer tables whose func-typed @@ -4985,6 +4995,7 @@ export class Lowerer { ...(this.ffiImports.length > 0 ? { ffiImports: [...this.ffiImports] } : {}), }; if (module) sanitizeUnregisteredClassTypes(module, (name) => this.classes.has(name)); + if (module) checkCycleBindingAccesses(this, module); return { module, diagnostics: this.diags, diff --git a/packages/compiler/src/frontend/program.test.ts b/packages/compiler/src/frontend/program.test.ts index 09eb245da..5d689ab3a 100644 --- a/packages/compiler/src/frontend/program.test.ts +++ b/packages/compiler/src/frontend/program.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { expect, test, vi } from "vitest"; import { checkPreflight, + cycleEarlyBindings, isNodeEsmFile, loadProgram, makeCycleAdmission, @@ -198,9 +199,17 @@ test.for([ ["export { first, alias }; export function read() { return 1; }", null], ["type Value = typeof alias; export function read(first: number = 1) { return first; }", null], ["class Box { value = alias; } export function read(value = first) { return value; }", null], - ["const value = alias; export function read() { return value; }", "alias"], - ["class Box { static value = first; } export function read() { return 1; }", "first"], - ["class Box { [alias] = 1; } export function read() { return 1; }", "alias"], + // Top-level reads during the cycle are admitted by the exact + // initialization rule (the lowering checks them); shapes without an + // initialization state keep the fence. + ["const value = alias; export function read() { return value; }", null], + ["class Box { static value = first; } export function read() { return 1; }", null], + ["class Box { [alias] = 1; } export function read() { return 1; }", null], + [ + 'import * as whole from "./main.ts"; const value = whole.first; export function read() { return value; }', + "the namespace import 'whole'", + ], + ["export default first + 1; export function read() { return 1; }", "default export expression"], ])("cycle binding indexes preserve initialization safety: %s", ([body, refused]) => { const directory = mkdtempSync( join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-cycle-bindings-"), @@ -219,8 +228,8 @@ test.for([ const cycles = checkPreflight(load).filter((diagnostic) => diagnostic.code === "SC1016"); if (refused === null) expect(cycles).toEqual([]); else { - expect(cycles).toHaveLength(1); - expect(cycles[0]!.message).toContain(`binding '${refused}' is read`); + expect(cycles.length).toBeGreaterThan(0); + for (const cycle of cycles) expect(cycle.message).toContain(refused); } } finally { load.dispose(); @@ -269,3 +278,51 @@ test("repeated cycle edges reuse a file's binding index", () => { rmSync(directory, { recursive: true, force: true }); } }); + +test.for([ + [ + // A shared instance built by a user constructor: the reads of it in + // the partner's function can run early; the class cannot. + { + "main.ts": 'import { shared } from "./a.ts"; console.log(shared.n);', + "a.ts": + 'import { f } from "./b.ts"; export class C { n = 1; } export const shared = new C(); console.log(f());', + "b.ts": 'import { shared } from "./a.ts"; export function f(): number { return shared.n; }', + }, + ["shared"], + ], + [ + // The partner's top level reads the importer's const before it ran. + { + "main.ts": 'import { a } from "./a.ts"; console.log(a);', + "a.ts": 'import { b } from "./b.ts"; export const a: number = b + 1;', + "b.ts": 'import { a } from "./a.ts"; export const b: number = a + 1;', + }, + ["a"], + ], + [ + // Declarations complete before any user code runs: nothing to check. + { + "main.ts": 'import { start } from "./a.ts"; console.log(start());', + "a.ts": + 'import { hop } from "./b.ts"; export const limit = 3; export function start(): number { return hop(limit); } console.log(start());', + "b.ts": + 'import { limit } from "./a.ts"; export function hop(n: number): number { return n + limit; }', + }, + [], + ], +] as const)("cycle bindings read before initialization: %j", ([files, early]) => { + const directory = mkdtempSync( + join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-cycle-early-"), + ); + for (const [name, text] of Object.entries(files)) writeFileSync(join(directory, name), text); + const load = loadProgram(join(directory, "main.ts")); + try { + expect(checkPreflight(load)).toEqual([]); + const found = cycleEarlyBindings(load.program, load.entry, load.moduleOrder, true); + expect(found.map((binding) => binding.name.text).sort()).toEqual([...early]); + } finally { + load.dispose(); + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index f2ce119a9..fe32d67a6 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -1812,10 +1812,11 @@ function nsBindingUsesAreBareStatements7( * Node runs legal ESM cycles all over the ecosystem: the revisited module * is a cache hit, and the only hazard is a READ of a binding before its * declaration executed (TDZ ReferenceError for let/const/class, a stale - * `undefined` for var) — runtime evaluation-order semantics the static - * story does not model, so cycles where such a read is REACHABLE keep the - * SC1016 fence. A cycle is admitted when partial initialization is - * provably unobservable — the DECLARATION-ONLY INIT WINDOW rule: + * `undefined` for var). Cycles where such a read is REACHABLE are admitted + * by the exact initialization rule (exactCycleRefusal7 below), which + * models the TDZ with initialization checks. A cycle needs no checks when + * partial initialization is provably unobservable — the DECLARATION-ONLY + * INIT WINDOW rule: * * 1. Every module of the cycle cluster (the strongly-connected component * — any member may be mid-initialization while another's top level @@ -1906,6 +1907,18 @@ function inTypePosition7(node: ts.Node): boolean { * callback-invoking builtin, a getter, an iterator, or an implicit * coercion of a user object) disqualifies the module's cycles. */ function nonInertTopLevel7(program: ts.Program, sf: ts.SourceFile): ts.Node | null { + const nonInert = topLevelInertness7(program); + for (const stmt of sf.statements) { + const offence = nonInert(stmt); + if (offence !== null) return offence; + } + return null; +} + +/** The per-statement form of nonInertTopLevel7: answers, for one + * top-level statement, the first construct that could execute user code + * while the statement runs, or null when the statement is inert. */ +function topLevelInertness7(program: ts.Program): (stmt: ts.Statement) => ts.Node | null { const checker = program.getTypeChecker(); const PRIM = ts.TypeFlags.StringLike | @@ -2070,7 +2083,7 @@ function nonInertTopLevel7(program: ts.Program, sf: ts.SourceFile): ts.Node | nu } return false; }; - for (const stmt of sf.statements) { + return (stmt: ts.Statement): ts.Node | null => { if ( ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt) || @@ -2083,36 +2096,25 @@ function nonInertTopLevel7(program: ts.Program, sf: ts.SourceFile): ts.Node | nu ts.ModifierFlags.Ambient) !== 0 ) { - continue; + return null; } if (ts.isEnumDeclaration(stmt)) { - const bad = stmt.members.find((m) => m.initializer !== undefined && !inert(m.initializer)); - if (bad !== undefined) return bad; - continue; - } - if (ts.isClassDeclaration(stmt)) { - const bad = inertClass(stmt); - if (bad !== null) return bad; - continue; + return stmt.members.find((m) => m.initializer !== undefined && !inert(m.initializer)) ?? null; } + if (ts.isClassDeclaration(stmt)) return inertClass(stmt); if (ts.isVariableStatement(stmt)) { for (const d of stmt.declarationList.declarations) { if (!ts.isIdentifier(d.name)) return d.name; // patterns can invoke getters/iterators if (d.initializer !== undefined && !inert(d.initializer)) return d.initializer; } - continue; - } - if (ts.isExpressionStatement(stmt)) { - if (!inert(stmt.expression)) return stmt; - continue; + return null; } + if (ts.isExpressionStatement(stmt)) return inert(stmt.expression) ? null : stmt; if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) { - if (!inert(stmt.expression)) return stmt; - continue; + return inert(stmt.expression) ? null : stmt; } return stmt; - } - return null; + }; } /** The head of a property/element-access chain. */ @@ -2182,13 +2184,16 @@ function backEdgeUseOffence7( * e.dep already mid-initialization in a depth-first walk), answers null * to ADMIT the cycle — the guarded %init calls reproduce Node's cache-hit * order and nothing can observe the partial initialization — or the - * human-readable reason it keeps the SC1016 fence. Two admission chances: + * human-readable reason it keeps the SC1016 fence. Three admission chances: * - the CHEAP PER-EDGE rule: the closing statement binds nothing (a * side-effect import) or binds only namespace objects whose every use * is a bare expression statement — no read crosses the edge at all; * - the DECLARATION-ONLY INIT WINDOW rule (the admission block above): * every cluster member is an ES module with an inert top level, and - * the closing edge's bindings are used only in deferred positions. + * the closing edge's bindings are used only in deferred positions; + * - the EXACT INITIALIZATION rule (exactCycleRefusal7): partial + * initialization is observable, but every observation is modeled — + * the lowering checks each binding cycleEarlyBindings reports. * Tarjan SCCs (lazy, rooted at each queried importer) and cluster * verdicts are memoized across calls, so `edgesOf` must answer the same * edges for the same file every time. */ @@ -2238,10 +2243,11 @@ export function makeCycleAdmission( const nsf = node.getSourceFile(); return `${nsf.fileName}:${ts.getLineAndCharacterOfPosition(nsf, node.getStart(nsf)).line + 1}`; }; - // Cluster verdict memo (keyed by the component array identity): the - // reason the cluster's cycles stay fenced, or null when its every - // member passes the inert-top-level bar. - const sccVerdict = new Map(); + // Cluster verdict memo (keyed by the component array identity): true + // when every member is an ES module passing the inert-top-level bar. + const sccVerdict = new Map(); + // The exact-initialization verdict, same keying (exactCycleRefusal7). + const exactVerdict = new Map(); // A module can close many cycle edges, each importing many bindings. // Index its identifiers once for this admission pass. ASTs are immutable; // the pass owns the index so it cannot retain a disposed program. @@ -2283,29 +2289,455 @@ export function makeCycleAdmission( return "the cycle's module cluster could not be analyzed"; } if (!sccVerdict.has(comp)) { - let reason: string | null = null; - for (const m of comp) { - if (isCjsJsFile7(m, program)) { - reason = `${m.fileName} is a CommonJS module — admission covers ES-module cycles only`; - break; + sccVerdict.set( + comp, + comp.every((m) => !isCjsJsFile7(m, program) && nonInertTopLevel7(program, m) === null), + ); + } + if ( + sccVerdict.get(comp) === true && + backEdgeUseOffence7(program, e.stmt, usesOf(importer)) === null + ) { + return null; + } + // The EXACT INITIALIZATION rule: the cycle runs in Node's evaluation + // order and every binding that can be observed before its declaration + // runs carries an initialization check (cycleEarlyBindings). + if (!exactVerdict.has(comp)) { + const reason = exactCycleRefusal7(program, comp, lineOf); + exactVerdict.set(comp, reason); + if (reason === null) { + let members = exactCycleMembers7.get(program); + if (members === undefined) { + members = new Set(); + exactCycleMembers7.set(program, members); } - const off = nonInertTopLevel7(program, m); - if (off !== null) { - reason = `top-level code at ${lineOf(off)} can run user code during the cycle's init window — only declaration-only module bodies are admitted`; - break; + for (const m of comp) members.add(m); + } + } + return exactVerdict.get(comp) ?? null; + }; +} + +/* ── exact cyclic initialization ───────────────────────────────────────── + * Cycles that fail the declaration-only rule still evaluate exactly like + * Node when their shape is representable: the guarded %init calls already + * run every module body in ES evaluation order (depth-first postorder, + * a module mid-evaluation answered from the cache), function declarations + * are callable from the start, and live bindings are shared globals. What + * remains is the temporal dead zone: a let/const binding read before its + * declaration ran throws ReferenceError. cycleEarlyBindings finds every + * such binding whose read CAN run early; the lowering gives each one an + * initialization flag and checks it at every read or write that can run + * before the declaration. The shapes below have no such representation + * and keep the SC1016 fence. */ + +/** Program → the modules of every cycle the exact rule admitted. */ +const exactCycleMembers7 = new WeakMap>(); + +/** The modules of `program` whose import cycles were admitted by the + * exact initialization rule (not the declaration-only rule). */ +export function exactCycleMembers(program: ts.Program): ReadonlySet { + return exactCycleMembers7.get(program) ?? new Set(); +} + +/** True when the module's top level (outside every function-like body) + * contains `await` or `for await`. */ +function hasTopLevelAwait7(sf: ts.SourceFile): boolean { + let found = false; + ts.walkPreorder(sf, (node) => { + if (found) return "stop"; + if (node !== sf && ts.isFunctionLike(node)) return "skip"; + if ( + ts.isAwaitExpression(node) || + (ts.isForOfStatement(node) && node.awaitModifier !== undefined) + ) { + found = true; + return "stop"; + } + return undefined; + }); + return found; +} + +/** The reason a strongly-connected module cluster cannot be initialized + * exactly, or null when it can. */ +function exactCycleRefusal7( + program: ts.Program, + comp: readonly ts.SourceFile[], + lineOf: (node: ts.Node) => string, +): string | null { + const members = new Set(comp); + const targetOf = (stmt: ts.ImportDeclaration | ts.ExportDeclaration): ts.SourceFile | null => { + const spec = stmt.moduleSpecifier; + if (spec === undefined || !ts.isStringLiteral(spec)) return null; + const sf = stmt.getSourceFile(); + return resolveImport7(program, sf, spec.text) ?? npmStaticDepSf7(program, sf, spec.text); + }; + for (const m of comp) { + if (isCjsJsFile7(m, program) || /\.c[jt]s$/.test(m.fileName)) { + return `${m.fileName} is a CommonJS module — cyclic initialization covers ES modules only`; + } + if (hasTopLevelAwait7(m)) { + return `${m.fileName} uses top-level await inside the cycle`; + } + for (const stmt of m.statements) { + if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) { + return `the default export expression at ${lineOf(stmt)} has no initialization state inside the cycle`; + } + if ( + ts.isExportDeclaration(stmt) && + !stmt.isTypeOnly && + (stmt.exportClause === undefined || ts.isNamespaceExport(stmt.exportClause)) + ) { + const dep = targetOf(stmt); + if (dep !== null && members.has(dep)) { + return `the namespace re-export at ${lineOf(stmt)} names a module of the cycle`; } } - sccVerdict.set(comp, reason); + if ( + ts.isImportDeclaration(stmt) && + stmt.importClause !== undefined && + !stmt.importClause.isTypeOnly && + stmt.importClause.namedBindings !== undefined && + ts.isNamespaceImport(stmt.importClause.namedBindings) + ) { + const dep = targetOf(stmt); + const nsName = stmt.importClause.namedBindings.name; + if ( + dep !== null && + members.has(dep) && + !nsBindingUsesAreBareStatements7(program, m, nsName) + ) { + return `the namespace import '${nsName.text}' at ${lineOf(stmt)} names a module of the cycle`; + } + } + if (ts.isClassDeclaration(stmt) && stmt.name === undefined) { + return `the anonymous default class at ${lineOf(stmt)} has no initialization state inside the cycle`; + } + const ambient = + (ts.getCombinedModifierFlags(stmt as unknown as ts.Declaration) & + ts.ModifierFlags.Ambient) !== + 0; + if (ambient) continue; + if ( + ts.isEnumDeclaration(stmt) && + (ts.getCombinedModifierFlags(stmt) & ts.ModifierFlags.Const) === 0 + ) { + return `the enum '${stmt.name.text}' at ${lineOf(stmt)} has no initialization state inside the cycle`; + } + if (ts.isModuleDeclaration(stmt) && !typeOnlyNamespace7(stmt)) { + return `the namespace at ${lineOf(stmt)} has no initialization state inside the cycle`; + } } - const clusterReason = sccVerdict.get(comp); - if (clusterReason === undefined) throw new Error("missing module-cycle verdict"); - if (clusterReason !== null) return clusterReason; - const use = backEdgeUseOffence7(program, e.stmt, usesOf(importer)); - if (use !== null) { - return `the cycle-crossing binding '${use.name}' is read at ${lineOf(use.node)}, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled`; + } + return null; +} + +/** A module-scope binding of an exactly-initialized cycle that some read + * or write can reach before its declaration ran. */ +export interface CycleEarlyBinding { + symbol: ts.Symbol; + /** The declaration's name. */ + name: ts.Identifier; + kind: "lexical" | "var" | "class"; + module: ts.SourceFile; + /** One reference that can run before the declaration. */ + reference: ts.Identifier; + /** A reference in a position the lowering may resolve at compile time + * from the binding's type (a property key, a `typeof` operand). */ + folded?: ts.Identifier; + /** Modules whose TOP-LEVEL code runs only after the declaration ran: + * modules outside the cycle and cycle members evaluated later. */ + settled: ReadonlySet; +} + +/** Every binding of the program's exactly-initialized cycles that can be + * observed before its declaration executes. `order` is the compiled module + * order; `singleRoot` is false when more than one module graph root runs + * (fork or worker entries), so a cycle can be entered at different + * members. With one root and a purely ES-module graph the evaluation order + * is static and the analysis is precise up to "user code may run": a + * binding needs its check when its declaration completes after some + * top-level code that can call user functions and a deferred position + * (function body, parameter default, instance field) references it, or + * when the top level of a cycle member evaluated before it reads it. + * Otherwise every deferred reference and every top-level reference from + * another member counts. */ +export function cycleEarlyBindings( + program: ts.Program, + entry: ts.SourceFile, + order: readonly ts.SourceFile[], + singleRoot: boolean, +): CycleEarlyBinding[] { + const exact = exactCycleMembers7.get(program); + if (exact === undefined || exact.size === 0) return []; + // Every lowering pass of one compile asks with the same inputs. + const key = `${entry.fileName}\0${order.map((sf) => sf.fileName).join("\0")}\0${singleRoot}`; + const memo = earlyBindingsMemo7.get(program); + if (memo !== undefined && memo.key === key) return memo.bindings; + const bindings = computeCycleEarlyBindings7(program, entry, order, singleRoot, exact); + earlyBindingsMemo7.set(program, { key, bindings }); + return bindings; +} + +const earlyBindingsMemo7 = new WeakMap< + ts.Program, + { key: string; bindings: CycleEarlyBinding[] } +>(); + +function computeCycleEarlyBindings7( + program: ts.Program, + entry: ts.SourceFile, + order: readonly ts.SourceFile[], + singleRoot: boolean, + exact: ReadonlySet, +): CycleEarlyBinding[] { + const inOrder = new Set(order); + const members = [...exact].filter((m) => inOrder.has(m)); + if (members.length === 0) return []; + const memberSet = new Set(members); + const depsOf = (sf: ts.SourceFile): ts.SourceFile[] => + orderedImportsOf(program, sf).flatMap(({ dep }) => + dep !== null && dep !== sf && inOrder.has(dep) ? [dep] : [], + ); + // Strongly-connected components among the exactly-initialized members. + const comps: ts.SourceFile[][] = []; + { + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: ts.SourceFile[] = []; + let next = 0; + const connect = (v: ts.SourceFile): void => { + index.set(v, next); + low.set(v, next); + next++; + stack.push(v); + onStack.add(v); + for (const w of depsOf(v)) { + if (!memberSet.has(w)) continue; + if (!index.has(w)) { + connect(w); + low.set(v, Math.min(low.get(v)!, low.get(w)!)); + } else if (onStack.has(w)) { + low.set(v, Math.min(low.get(v)!, index.get(w)!)); + } + } + if (low.get(v) === index.get(v)) { + const comp: ts.SourceFile[] = []; + for (;;) { + const w = stack.pop()!; + onStack.delete(w); + comp.push(w); + if (w === v) break; + } + if (comp.length > 1) comps.push(comp); + } + }; + for (const m of members) if (!index.has(m)) connect(m); + } + if (comps.length === 0) return []; + // The static evaluation order: Node's depth-first postorder from the + // entry over ES import edges. Known only for one root and a graph with + // no CommonJS module (a require() evaluates its target mid-body). + const position = new Map(); + if ( + singleRoot && + !order.some((sf) => isCjsJsFile7(sf, program) || /\.c[jt]s$/.test(sf.fileName)) + ) { + const seen = new Set(); + const visit = (sf: ts.SourceFile): void => { + seen.add(sf); + for (const dep of depsOf(sf)) if (!seen.has(dep)) visit(dep); + position.set(sf, position.size); + }; + visit(entry); + } + const checker = program.getTypeChecker(); + const nonInert = topLevelInertness7(program); + const out: CycleEarlyBinding[] = []; + for (const comp of comps) { + const known = comp.every((m) => position.has(m)); + const evaluation = known + ? comp.slice().sort((a, b) => position.get(a)! - position.get(b)!) + : comp.slice(); + interface Candidate { + symbol: ts.Symbol; + name: ts.Identifier; + kind: CycleEarlyBinding["kind"]; + module: ts.SourceFile; + /** User code can run between the cycle's start and the end of the + * declaration (always true when the order is unknown). */ + exposed: boolean; + rank: number; + start: number; + end: number; + reference: ts.Identifier | null; + folded: ts.Identifier | null; } - return null; - }; + const candidates = new Map(); + const names = new Set(); + let dirty = !known; + evaluation.forEach((m, rank) => { + for (const stmt of m.statements) { + const offence = nonInert(stmt); + const exposed = dirty || offence !== null; + const declared: { name: ts.Identifier; kind: CycleEarlyBinding["kind"] }[] = []; + if ( + ts.isVariableStatement(stmt) && + (ts.getCombinedModifierFlags(stmt as unknown as ts.Declaration) & + ts.ModifierFlags.Ambient) === + 0 + ) { + const lexical = + (stmt.declarationList.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0; + for (const d of stmt.declarationList.declarations) { + for (const name of boundNames7(d.name)) { + declared.push({ name, kind: lexical ? "lexical" : "var" }); + } + } + } else if (ts.isClassDeclaration(stmt) && stmt.name !== undefined) { + declared.push({ name: stmt.name, kind: "class" }); + } + for (const { name, kind } of declared) { + const symbol = checker.getSymbolAtLocation(name); + if (symbol === undefined) continue; + candidates.set(symbol, { + symbol, + name, + kind, + module: m, + exposed, + rank, + start: stmt.getStart(m), + end: stmt.getEnd(), + reference: null, + folded: null, + }); + names.add(name.text); + } + if (offence !== null) dirty = true; + } + }); + if (candidates.size === 0) continue; + const rankOf = new Map(evaluation.map((m, i) => [m, i] as const)); + // References: every identifier spelled like a candidate, resolved + // through import aliases to the declaration's symbol. + for (const sf of order) { + const refs: ts.Identifier[] = []; + ts.walkPreorder(sf, (node) => { + if (ts.isImportDeclaration(node)) return "skip"; + if (ts.isIdentifier(node) && names.has(node.text)) refs.push(node); + return undefined; + }); + if (refs.length === 0) continue; + checker.prefetchSymbolNodesExact(refs); + for (const ref of refs) { + const parent = ref.parent; + if (parent !== undefined && ts.isExportSpecifier(parent)) continue; + if (inTypePosition7(ref)) continue; + let symbol = checker.getSymbolAtLocation(ref); + if ( + parent !== undefined && + ts.isShorthandPropertyAssignment(parent) && + parent.name === ref + ) { + symbol = checker.getShorthandAssignmentValueSymbol(parent) ?? symbol; + } + if (symbol !== undefined && symbol.flags & ts.SymbolFlags.Alias) { + symbol = checker.getAliasedSymbol(symbol); + } + const c = symbol === undefined ? undefined : candidates.get(symbol); + if (c === undefined || ref === c.name) continue; + if (c.folded === null && foldablePosition7(ref)) c.folded = ref; + if (c.reference !== null) continue; + // A class body names its own inner binding, initialized throughout. + if ( + c.kind === "class" && + sf === c.module && + ref.getStart(sf) >= c.start && + ref.getEnd() <= c.end + ) + continue; + let early: boolean; + if (inDeferredPosition7(ref)) early = c.exposed; + else if (sf === c.module) early = ref.getStart(sf) < c.start; + else if (!memberSet.has(sf) || !rankOf.has(sf)) early = false; + else early = !known || rankOf.get(sf)! < c.rank; + if (early) c.reference = ref; + } + } + for (const c of candidates.values()) { + if (c.reference === null) continue; + const settled = new Set(order.filter((sf) => !rankOf.has(sf))); + if (known) for (const m of evaluation.slice(c.rank + 1)) settled.add(m); + out.push({ + symbol: c.symbol, + name: c.name, + kind: c.kind, + module: c.module, + reference: c.reference, + ...(c.folded !== null ? { folded: c.folded } : {}), + settled, + }); + } + } + return out; +} + +/** True for a reference whose value the lowering can take from the + * binding's checker type instead of its storage: a property key (element + * access argument, computed name, `in` operand, through parentheses, + * casts and template spans) or a `typeof` operand. */ +function foldablePosition7(ref: ts.Identifier): boolean { + let child: ts.Node = ref; + for (let p: ts.Node | undefined = ref.parent; p !== undefined; p = p.parent) { + if ( + ts.isParenthesizedExpression(p) || + ts.isAsExpression(p) || + ts.isSatisfiesExpression(p) || + ts.isNonNullExpression(p) || + ts.isTemplateSpan(p) || + ts.isTemplateExpression(p) + ) { + child = p; + continue; + } + if (ts.isTypeOfExpression(p)) return true; + if (ts.isElementAccessExpression(p)) return p.argumentExpression === child; + if (ts.isComputedPropertyName(p)) return true; + if (ts.isBinaryExpression(p)) + return p.operatorToken.kind === ts.SyntaxKind.InKeyword && p.left === child; + return false; + } + return false; +} + +/** The identifiers a binding name (identifier or destructuring pattern) + * declares. */ +function boundNames7(name: ts.BindingName): ts.Identifier[] { + if (ts.isIdentifier(name)) return [name]; + const out: ts.Identifier[] = []; + for (const el of name.elements) { + if (ts.isOmittedExpression(el) || el.name === undefined) continue; + out.push(...boundNames7(el.name)); + } + return out; +} + +/** A namespace declaration whose body holds only types (interfaces, type + * aliases, nested type-only namespaces) — no runtime object exists. */ +function typeOnlyNamespace7(decl: ts.ModuleDeclaration): boolean { + let body = decl.body; + while (body !== undefined && ts.isModuleDeclaration(body)) body = body.body; + if (body === undefined || !ts.isModuleBlock(body)) return true; + return body.statements.every( + (s) => + ts.isInterfaceDeclaration(s) || + ts.isTypeAliasDeclaration(s) || + (ts.isModuleDeclaration(s) && typeOnlyNamespace7(s)), + ); } /** Resolves an import specifier from `from` to a source file of the @@ -2774,10 +3206,9 @@ function preflight7(load: LoadResult): { // benign-cycle admission block above). Node evaluates such cycles // benignly (the revisited module is a cache hit, and no binding read // can hit TDZ or a stale slot), so the guarded %init calls reproduce - // its order exactly. Cycles that fail both keep the SC1016 fence, - // because a read through them mid-cycle observes TDZ (let/const) or - // undefined (var) in Node — runtime evaluation-order semantics the - // static story does not model. + // its order exactly. Cycles that fail both are judged by the exact + // initialization rule: admitted when every early read is modeled by an + // initialization check, fenced with SC1016 otherwise. const edges = new Map(); // Import edges Node's RESOLUTION refuses before any module evaluates — // recorded in source order per file (interleaved with the resolved deps @@ -3334,9 +3765,10 @@ function preflight7(load: LoadResult): { // Node's evaluation order: depth-first postorder from the entry, each // module once, back-edges answered from the cache (a module already // evaluating is skipped) — which the guarded %init calls reproduce - // exactly. Cycles are admitted only where Node's partially-initialized - // semantics are UNOBSERVABLE (JS's partial-initialization is otherwise a - // silent-misbehavior trap, so SC1016 stays for the rest): + // exactly. Cycles are admitted where Node's partially-initialized + // semantics are UNOBSERVABLE or exactly modeled (JS's partial + // initialization is otherwise a silent-misbehavior trap, so SC1016 + // stays for the rest): // - a DIRECT SELF-IMPORT (the package self-name reference resolving to // the importing module itself — Node's self-reference rule): the // imported bindings alias the module's OWN top-level bindings with @@ -3348,7 +3780,11 @@ function preflight7(load: LoadResult): { // benign-cycle admission block: every module of the cycle's // strongly-connected component has an inert top level and the // closing edge's bindings are used only in deferred positions, so no - // read can execute before every member initialized). + // read can execute before every member initialized); + // - the EXACT INITIALIZATION rule: an ES-module cluster whose early + // reads are all let/const bindings with module storage — the + // lowering checks each one (cycleEarlyBindings) and throws Node's + // ReferenceError on a read before the declaration ran. const cycleAdmissionReason = makeCycleAdmission(program, (sf) => edges.get(sf) ?? []); const order: ts.SourceFile[] = []; const state = new Map(); @@ -3362,9 +3798,9 @@ function preflight7(load: LoadResult): { if (s === "done") continue; if (s === "visiting") { // A back-edge: Node answers it from the cache (the module is - // already evaluating) — benign exactly when nothing can observe - // the partial initialization through this edge's bindings, by the - // cheap per-edge rule or the declaration-only init window rule. + // already evaluating) — admitted by the cheap per-edge rule, the + // declaration-only init window rule, or the exact initialization + // rule. const reason = cycleAdmissionReason(sf, e); if (reason !== null) { const cycleStart = stack.indexOf(e.dep.fileName); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index ae1ac945e..94e37b3bd 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1551,6 +1551,11 @@ export interface IrGlobal { /** Lexical record/function/checked-value bindings use their initially-null * pointer as a TDZ sentinel. Reads and later writes throw until initializing assign. */ tdz?: true; + /** An import-cycle binding whose initialization state lives in this BOOL + * global instead: the declaration's store is the `assign` marked + * `initializes`, followed by the flag's set; accesses that can run + * earlier test it with the module.tdzCheck intrinsic. */ + initFlag?: string; /** Original declaration and lexical scope, when this is a source binding. */ source?: IrBindingSource; } @@ -3246,7 +3251,11 @@ export type IrExpr = * result's inner type (promise arguments never reach here — the * frontend returns them as-is, the spec's native-promise identity; * thenables and promise-armed unions fence); the backend mints a fresh - * promise and fulfills it immediately per the inner kind. */ + * promise and fulfills it immediately per the inner kind. + * module.tdzCheck: two args, a BOOL initialization flag and the binding's + * source name as a strLit; void. Throws the catchable ReferenceError + * "Cannot access 'name' before initialization" while the flag is false + * (a module binding of an import cycle read before its declaration). */ | { kind: "intrinsic"; name: @@ -3256,7 +3265,8 @@ export type IrExpr = | "promise.all" | "promise.reject" | "promise.resolve" - | "module.await"; + | "module.await" + | "module.tdzCheck"; args: IrExpr[]; type: IrType; loc: SrcLoc; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index da343f16f..6eb6d573b 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -3706,6 +3706,17 @@ function validateFunction( } function checkIntrinsicExpr(e: IrExpr & { kind: "intrinsic" }): void { + if (e.name === "module.tdzCheck") { + const [flag, name] = e.args; + if (e.args.length !== 2 || flag === undefined || name?.kind !== "strLit") { + err("module.tdzCheck takes a flag and a binding-name literal", e.loc); + return; + } + checkExpr(flag); + if (flag.type.kind !== "bool") err("module.tdzCheck flag must be bool", e.loc); + if (e.type.kind !== "void") err("module.tdzCheck must be void", e.loc); + return; + } if (e.name === "module.await") { if (e.args.length !== 1) err("module.await takes exactly one argument", e.loc); for (const a of e.args) { @@ -5849,7 +5860,7 @@ function validateFunction( } case "assign": { const binding = locals.get(s.localId) ?? globals.get(s.localId); - if (s.initializes && !binding?.tdz) { + if (s.initializes && !binding?.tdz && globals.get(s.localId)?.initFlag === undefined) { err(`initializing assign requires a TDZ binding "${s.localId}"`, s.loc); } if (!binding) err(`assign to undeclared local/global "${s.localId}"`, s.loc); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 95c20906a..c4ef6c01f 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11677,7 +11677,7 @@ "end": 0 }, "milestone": "later", - "hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window" + "hint": "ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge" } ] }, @@ -11771,66 +11771,6 @@ ], "diags": [] }, - "/tests/diagnostics/cycle-tdz-read/main.ts": { - "order": [ - "/tests/diagnostics/cycle-tdz-read/b.ts", - "/tests/diagnostics/cycle-tdz-read/a.ts", - "/tests/diagnostics/cycle-tdz-read/main.ts" - ], - "diags": [ - { - "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cycle-tdz-read/a.ts \u2192 /tests/diagnostics/cycle-tdz-read/b.ts \u2192 /tests/diagnostics/cycle-tdz-read/a.ts; the cycle-crossing binding 'a' is read at /tests/diagnostics/cycle-tdz-read/b.ts:2, outside any function body \u2014 a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet", - "loc": { - "file": "/tests/diagnostics/cycle-tdz-read/a.ts", - "start": 0, - "end": 0 - }, - "milestone": "later", - "hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window" - } - ] - }, - "/tests/diagnostics/cycle-window-call/main.ts": { - "order": [ - "/tests/diagnostics/cycle-window-call/b.ts", - "/tests/diagnostics/cycle-window-call/a.ts", - "/tests/diagnostics/cycle-window-call/main.ts" - ], - "diags": [ - { - "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cycle-window-call/a.ts \u2192 /tests/diagnostics/cycle-window-call/b.ts \u2192 /tests/diagnostics/cycle-window-call/a.ts; top-level code at /tests/diagnostics/cycle-window-call/a.ts:5 can run user code during the cycle's init window \u2014 only declaration-only module bodies are admitted) are not supported yet", - "loc": { - "file": "/tests/diagnostics/cycle-window-call/a.ts", - "start": 0, - "end": 0 - }, - "milestone": "later", - "hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window" - } - ] - }, - "/tests/diagnostics/cycle/main.ts": { - "order": [ - "/tests/diagnostics/cycle/b.ts", - "/tests/diagnostics/cycle/a.ts", - "/tests/diagnostics/cycle/main.ts" - ], - "diags": [ - { - "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cycle/a.ts \u2192 /tests/diagnostics/cycle/b.ts \u2192 /tests/diagnostics/cycle/a.ts; the cycle-crossing binding 'a' is read at /tests/diagnostics/cycle/b.ts:2, outside any function body \u2014 a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet", - "loc": { - "file": "/tests/diagnostics/cycle/a.ts", - "start": 0, - "end": 0 - }, - "milestone": "later", - "hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window" - } - ] - }, "/tests/diagnostics/dataview.ts": { "order": [ "/tests/diagnostics/dataview.ts" @@ -11900,12 +11840,6 @@ ], "diags": [] }, - "/tests/diagnostics/dyn-import-cycle-tdz/main.ts": { - "order": [ - "/tests/diagnostics/dyn-import-cycle-tdz/main.ts" - ], - "diags": [] - }, "/tests/diagnostics/dynamic-import-static.ts": { "order": [ "/tests/diagnostics/dynamic-import-static.ts" @@ -14848,6 +14782,125 @@ "/tests/diagnostics/iterator-slot-fences.ts" ], "diags": [] + }, + "/tests/corpus/4060-cycle-shared-instance/main.ts": { + "order": [ + "/tests/corpus/4060-cycle-shared-instance/report.ts", + "/tests/corpus/4060-cycle-shared-instance/inventory.ts", + "/tests/corpus/4060-cycle-shared-instance/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4061-cycle-evaluation-order/main.ts": { + "order": [ + "/tests/corpus/4061-cycle-evaluation-order/south.ts", + "/tests/corpus/4061-cycle-evaluation-order/east.ts", + "/tests/corpus/4061-cycle-evaluation-order/north.ts", + "/tests/corpus/4061-cycle-evaluation-order/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4062-cycle-tdz-read/main.ts": { + "order": [ + "/tests/corpus/4062-cycle-tdz-read/probe.ts", + "/tests/corpus/4062-cycle-tdz-read/limits.ts", + "/tests/corpus/4062-cycle-tdz-read/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4063-cycle-hoisted-calls/main.ts": { + "order": [ + "/tests/corpus/4063-cycle-hoisted-calls/odd.ts", + "/tests/corpus/4063-cycle-hoisted-calls/even.ts", + "/tests/corpus/4063-cycle-hoisted-calls/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4064-cycle-classes-after-init/main.ts": { + "order": [ + "/tests/corpus/4064-cycle-classes-after-init/item.ts", + "/tests/corpus/4064-cycle-classes-after-init/shelf.ts", + "/tests/corpus/4064-cycle-classes-after-init/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4065-cycle-init-throws/main.ts": { + "order": [ + "/tests/corpus/4065-cycle-init-throws/rules.ts", + "/tests/corpus/4065-cycle-init-throws/ledger.ts", + "/tests/corpus/4065-cycle-init-throws/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4066-cycle-top-level-tdz/main.ts": { + "order": [ + "/tests/corpus/4066-cycle-top-level-tdz/margin.ts", + "/tests/corpus/4066-cycle-top-level-tdz/frame.ts", + "/tests/corpus/4066-cycle-top-level-tdz/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts": { + "order": [ + "/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts" + ], + "diags": [] + }, + "/tests/diagnostics/cycle-class-early/main.ts": { + "order": [ + "/tests/diagnostics/cycle-class-early/dogs.ts", + "/tests/diagnostics/cycle-class-early/pets.ts", + "/tests/diagnostics/cycle-class-early/main.ts" + ], + "diags": [] + }, + "/tests/diagnostics/cycle-default-export/main.ts": { + "order": [ + "/tests/diagnostics/cycle-default-export/b.ts", + "/tests/diagnostics/cycle-default-export/a.ts", + "/tests/diagnostics/cycle-default-export/main.ts" + ], + "diags": [ + { + "code": "SC1016", + "message": "circular imports (/tests/diagnostics/cycle-default-export/a.ts \u2192 /tests/diagnostics/cycle-default-export/b.ts \u2192 /tests/diagnostics/cycle-default-export/a.ts; the default export expression at /tests/diagnostics/cycle-default-export/a.ts:2 has no initialization state inside the cycle) are not supported yet", + "loc": { + "file": "/tests/diagnostics/cycle-default-export/a.ts", + "start": 0, + "end": 0 + }, + "milestone": "later", + "hint": "ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge" + } + ] + }, + "/tests/diagnostics/cycle-namespace-import/main.ts": { + "order": [ + "/tests/diagnostics/cycle-namespace-import/b.ts", + "/tests/diagnostics/cycle-namespace-import/a.ts", + "/tests/diagnostics/cycle-namespace-import/main.ts" + ], + "diags": [ + { + "code": "SC1016", + "message": "circular imports (/tests/diagnostics/cycle-namespace-import/a.ts \u2192 /tests/diagnostics/cycle-namespace-import/b.ts \u2192 /tests/diagnostics/cycle-namespace-import/a.ts; the namespace import 'helpers' at /tests/diagnostics/cycle-namespace-import/a.ts:1 names a module of the cycle) are not supported yet", + "loc": { + "file": "/tests/diagnostics/cycle-namespace-import/a.ts", + "start": 0, + "end": 0 + }, + "milestone": "later", + "hint": "ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge" + } + ] + }, + "/tests/diagnostics/cycle-var-early/main.ts": { + "order": [ + "/tests/diagnostics/cycle-var-early/b.ts", + "/tests/diagnostics/cycle-var-early/a.ts", + "/tests/diagnostics/cycle-var-early/main.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/4060-cycle-shared-instance/inventory.ts b/tests/corpus/4060-cycle-shared-instance/inventory.ts new file mode 100644 index 000000000..e7f881293 --- /dev/null +++ b/tests/corpus/4060-cycle-shared-instance/inventory.ts @@ -0,0 +1,17 @@ +import { summary } from "./report.ts"; + +export class Inventory { + items: Map = new Map(); + add(name: string, qty: number): void { + this.items.set(name, (this.items.get(name) ?? 0) + qty); + } + count(): number { + let total = 0; + for (const qty of this.items.values()) total += qty; + return total; + } +} + +export const stock = new Inventory(); +stock.add("nut", 10); +console.log("inventory ready:", summary()); diff --git a/tests/corpus/4060-cycle-shared-instance/main.ts b/tests/corpus/4060-cycle-shared-instance/main.ts new file mode 100644 index 000000000..6bfcbde7a --- /dev/null +++ b/tests/corpus/4060-cycle-shared-instance/main.ts @@ -0,0 +1,9 @@ +// A shared instance exported from one member of an import cycle and read +// through a function of the other member. inventory.ts evaluates after +// report.ts, so report's function only runs once `stock` initialized. +import { stock } from "./inventory.ts"; +import { summary } from "./report.ts"; + +stock.add("bolt", 4); +console.log(summary()); +console.log(stock.count()); diff --git a/tests/corpus/4060-cycle-shared-instance/report.ts b/tests/corpus/4060-cycle-shared-instance/report.ts new file mode 100644 index 000000000..2645ddaaa --- /dev/null +++ b/tests/corpus/4060-cycle-shared-instance/report.ts @@ -0,0 +1,7 @@ +import { stock } from "./inventory.ts"; + +export function summary(): string { + const parts: string[] = []; + for (const [name, qty] of stock.items) parts.push(`${name}=${qty}`); + return parts.join(", ") + ` (total ${stock.count()})`; +} diff --git a/tests/corpus/4061-cycle-evaluation-order/east.ts b/tests/corpus/4061-cycle-evaluation-order/east.ts new file mode 100644 index 000000000..6dcb43b5c --- /dev/null +++ b/tests/corpus/4061-cycle-evaluation-order/east.ts @@ -0,0 +1,6 @@ +import { south } from "./south.ts"; + +export function east(n: number): string { + return n <= 0 ? "E" : "e" + south(n - 1); +} +console.log("east body", east(1)); diff --git a/tests/corpus/4061-cycle-evaluation-order/main.ts b/tests/corpus/4061-cycle-evaluation-order/main.ts new file mode 100644 index 000000000..f23a87135 --- /dev/null +++ b/tests/corpus/4061-cycle-evaluation-order/main.ts @@ -0,0 +1,9 @@ +// Side effects across a three-module cycle run in ES evaluation order: +// the depth-first walk from main reaches north → east → south, and south's +// import of north is answered from the module cache, so the bodies run +// south, east, north, then main. Each body calls into the others. +import { north, log } from "./north.ts"; + +log("main body"); +console.log(north(3)); +console.log(log("done")); diff --git a/tests/corpus/4061-cycle-evaluation-order/north.ts b/tests/corpus/4061-cycle-evaluation-order/north.ts new file mode 100644 index 000000000..70d5e64e7 --- /dev/null +++ b/tests/corpus/4061-cycle-evaluation-order/north.ts @@ -0,0 +1,12 @@ +import { east } from "./east.ts"; + +const lines: string[] = []; +export function log(line: string): number { + lines.push(line); + console.log(`[${lines.length}] ${line}`); + return lines.length; +} +export function north(n: number): string { + return n <= 0 ? "N" : "n" + east(n - 1); +} +log("north body " + east(2)); diff --git a/tests/corpus/4061-cycle-evaluation-order/south.ts b/tests/corpus/4061-cycle-evaluation-order/south.ts new file mode 100644 index 000000000..3cc4bed0b --- /dev/null +++ b/tests/corpus/4061-cycle-evaluation-order/south.ts @@ -0,0 +1,6 @@ +import { north } from "./north.ts"; + +export function south(n: number): string { + return n <= 0 ? "S" : "s" + north(n - 1); +} +console.log("south body", south(0), typeof north); diff --git a/tests/corpus/4062-cycle-tdz-read/limits.ts b/tests/corpus/4062-cycle-tdz-read/limits.ts new file mode 100644 index 000000000..058006e64 --- /dev/null +++ b/tests/corpus/4062-cycle-tdz-read/limits.ts @@ -0,0 +1,16 @@ +import { probe, spend, clear } from "./probe.ts"; + +console.log("before:", probe()); +console.log("spend before:", spend(1)); +console.log("clear before:", clear()); +export const limit: number = 8; +export let used = 0; +export function record(n: number): void { + used += n; +} +export function reset(): void { + used = 0; +} +console.log("after:", probe()); +console.log("spend after:", spend(3)); +export { probe }; diff --git a/tests/corpus/4062-cycle-tdz-read/main.ts b/tests/corpus/4062-cycle-tdz-read/main.ts new file mode 100644 index 000000000..d41057700 --- /dev/null +++ b/tests/corpus/4062-cycle-tdz-read/main.ts @@ -0,0 +1,6 @@ +// Reading or writing a let/const binding of an import cycle before its +// declaration ran throws ReferenceError, exactly as Node reports it; the +// same reads succeed once the declaration ran. +import { limit, used, probe } from "./limits.ts"; + +console.log("main", limit, used, probe()); diff --git a/tests/corpus/4062-cycle-tdz-read/probe.ts b/tests/corpus/4062-cycle-tdz-read/probe.ts new file mode 100644 index 000000000..58bf50b13 --- /dev/null +++ b/tests/corpus/4062-cycle-tdz-read/probe.ts @@ -0,0 +1,28 @@ +import { limit, used, record, reset } from "./limits.ts"; + +export function probe(): string { + try { + return `${used}/${limit}`; + } catch (e) { + const err = e as Error; + return `${err.name}: ${err.message} (${e instanceof ReferenceError})`; + } +} + +export function spend(n: number): string { + try { + record(n); + return "ok " + used; + } catch (e) { + return (e as Error).message; + } +} + +export function clear(): string { + try { + reset(); + return "cleared"; + } catch (e) { + return (e as Error).name; + } +} diff --git a/tests/corpus/4063-cycle-hoisted-calls/even.ts b/tests/corpus/4063-cycle-hoisted-calls/even.ts new file mode 100644 index 000000000..2c360f631 --- /dev/null +++ b/tests/corpus/4063-cycle-hoisted-calls/even.ts @@ -0,0 +1,11 @@ +import { isOdd } from "./odd.ts"; + +export function isEven(n: number): boolean { + return n === 0 ? true : isOdd(n - 1); +} +export function parity(n: number): string { + return `${n} is ${labels[isEven(n) ? 0 : 1]}`; +} +console.log("even body", isEven(4), isOdd(4)); +const labels = ["even", "odd"]; +console.log("even ready", parity(2)); diff --git a/tests/corpus/4063-cycle-hoisted-calls/main.ts b/tests/corpus/4063-cycle-hoisted-calls/main.ts new file mode 100644 index 000000000..0665d9ad6 --- /dev/null +++ b/tests/corpus/4063-cycle-hoisted-calls/main.ts @@ -0,0 +1,8 @@ +// Function declarations of an import cycle are initialized before any +// module body runs: each member's top level calls functions of the other +// member while the cycle is still evaluating, including mutual recursion. +import { isEven, parity } from "./even.ts"; +import { isOdd } from "./odd.ts"; + +console.log(isEven(10), isOdd(7), isEven(3)); +console.log(parity(5), parity(8)); diff --git a/tests/corpus/4063-cycle-hoisted-calls/odd.ts b/tests/corpus/4063-cycle-hoisted-calls/odd.ts new file mode 100644 index 000000000..cc6b04f09 --- /dev/null +++ b/tests/corpus/4063-cycle-hoisted-calls/odd.ts @@ -0,0 +1,6 @@ +import { isEven } from "./even.ts"; + +export function isOdd(n: number): boolean { + return n === 0 ? false : isEven(n - 1); +} +console.log("odd body", typeof isEven, isOdd(1)); diff --git a/tests/corpus/4064-cycle-classes-after-init/item.ts b/tests/corpus/4064-cycle-classes-after-init/item.ts new file mode 100644 index 000000000..85d513195 --- /dev/null +++ b/tests/corpus/4064-cycle-classes-after-init/item.ts @@ -0,0 +1,15 @@ +import { Shelf } from "./shelf.ts"; + +export class Item { + static created = 0; + readonly name: string; + readonly shelf: Shelf | null; + constructor(name: string, shelf: Shelf | null) { + this.name = name; + this.shelf = shelf; + Item.created++; + } + placed(): string { + return this.shelf instanceof Shelf ? `${this.name}@${this.shelf.label}` : `${this.name}@none`; + } +} diff --git a/tests/corpus/4064-cycle-classes-after-init/main.ts b/tests/corpus/4064-cycle-classes-after-init/main.ts new file mode 100644 index 000000000..53ea37c77 --- /dev/null +++ b/tests/corpus/4064-cycle-classes-after-init/main.ts @@ -0,0 +1,12 @@ +// Classes of two cycle members reference each other (construction, +// instanceof, statics) from method bodies that run after both class +// declarations evaluated; a shared default instance is built at load. +import { Shelf, defaultShelf } from "./shelf.ts"; +import { Item } from "./item.ts"; + +const shelf = new Shelf("garage"); +shelf.add("rake"); +shelf.add("hose"); +console.log(shelf.describe()); +console.log(defaultShelf.describe()); +console.log(new Item("loose", null).placed(), Item.created, Shelf.count); diff --git a/tests/corpus/4064-cycle-classes-after-init/shelf.ts b/tests/corpus/4064-cycle-classes-after-init/shelf.ts new file mode 100644 index 000000000..9dceeefad --- /dev/null +++ b/tests/corpus/4064-cycle-classes-after-init/shelf.ts @@ -0,0 +1,23 @@ +import { Item } from "./item.ts"; + +export class Shelf { + static count = 0; + items: Item[] = []; + readonly label: string; + constructor(label: string) { + this.label = label; + Shelf.count++; + } + add(name: string): Item { + const item = new Item(name, this); + this.items.push(item); + return item; + } + describe(): string { + return `${this.label}: ${this.items.map((i) => i.placed()).join(", ")}`; + } +} + +export const defaultShelf = new Shelf("default"); +defaultShelf.add("box"); +console.log("shelf ready", Item.created); diff --git a/tests/corpus/4065-cycle-init-throws/ledger.ts b/tests/corpus/4065-cycle-init-throws/ledger.ts new file mode 100644 index 000000000..353991f16 --- /dev/null +++ b/tests/corpus/4065-cycle-init-throws/ledger.ts @@ -0,0 +1,5 @@ +import { validate } from "./rules.ts"; + +console.log("ledger body"); +export const total: number = validate([3, 4, -1]); +console.log("ledger never finishes", total); diff --git a/tests/corpus/4065-cycle-init-throws/main.ts b/tests/corpus/4065-cycle-init-throws/main.ts new file mode 100644 index 000000000..8a018d78d --- /dev/null +++ b/tests/corpus/4065-cycle-init-throws/main.ts @@ -0,0 +1,7 @@ +// @exit: 1 +// A module body of an import cycle throws during initialization: the +// error propagates out of the whole graph, so the rest of the throwing +// body, its importers and main never run (stdout and exit code match). +import { total } from "./ledger.ts"; + +console.log("main never runs", total); diff --git a/tests/corpus/4065-cycle-init-throws/rules.ts b/tests/corpus/4065-cycle-init-throws/rules.ts new file mode 100644 index 000000000..35dc3ffdc --- /dev/null +++ b/tests/corpus/4065-cycle-init-throws/rules.ts @@ -0,0 +1,11 @@ +import { total } from "./ledger.ts"; + +console.log("rules body"); +export function validate(entries: number[]): number { + let sum = 0; + for (const e of entries) { + if (e < 0) throw new RangeError(`negative entry ${e}`); + sum += e; + } + return sum + total; +} diff --git a/tests/corpus/4066-cycle-top-level-tdz/frame.ts b/tests/corpus/4066-cycle-top-level-tdz/frame.ts new file mode 100644 index 000000000..9851dc49d --- /dev/null +++ b/tests/corpus/4066-cycle-top-level-tdz/frame.ts @@ -0,0 +1,4 @@ +import { margin } from "./margin.ts"; + +console.log("frame body"); +export const width: number = margin * 2; diff --git a/tests/corpus/4066-cycle-top-level-tdz/main.ts b/tests/corpus/4066-cycle-top-level-tdz/main.ts new file mode 100644 index 000000000..10524674c --- /dev/null +++ b/tests/corpus/4066-cycle-top-level-tdz/main.ts @@ -0,0 +1,7 @@ +// @exit: 1 +// A cycle member's top level reads a const of the module that imported +// it before that module's body ran: Node throws an uncaught +// ReferenceError there, after the earlier output. +import { width } from "./frame.ts"; + +console.log("main never runs", width); diff --git a/tests/corpus/4066-cycle-top-level-tdz/margin.ts b/tests/corpus/4066-cycle-top-level-tdz/margin.ts new file mode 100644 index 000000000..284302865 --- /dev/null +++ b/tests/corpus/4066-cycle-top-level-tdz/margin.ts @@ -0,0 +1,4 @@ +import { width } from "./frame.ts"; + +console.log("margin body"); +export const margin: number = width / 10; diff --git a/tests/diagnostics/dyn-import-cycle-tdz/a.ts b/tests/corpus/4067-dynamic-import-cycle-tdz/a.ts similarity index 55% rename from tests/diagnostics/dyn-import-cycle-tdz/a.ts rename to tests/corpus/4067-dynamic-import-cycle-tdz/a.ts index 93bc98ee1..6e666f7bb 100644 --- a/tests/diagnostics/dyn-import-cycle-tdz/a.ts +++ b/tests/corpus/4067-dynamic-import-cycle-tdz/a.ts @@ -2,6 +2,6 @@ import { b } from "./b.ts"; export const a: string = "A"; -export function useB(): string { - return b; +export function label(): string { + return a + b; } diff --git a/tests/diagnostics/dyn-import-cycle-tdz/b.ts b/tests/corpus/4067-dynamic-import-cycle-tdz/b.ts similarity index 73% rename from tests/diagnostics/dyn-import-cycle-tdz/b.ts rename to tests/corpus/4067-dynamic-import-cycle-tdz/b.ts index 0aac23620..55ff41170 100644 --- a/tests/diagnostics/dyn-import-cycle-tdz/b.ts +++ b/tests/corpus/4067-dynamic-import-cycle-tdz/b.ts @@ -1,3 +1,4 @@ import { a } from "./a.ts"; +console.log("b body"); export const b: string = a + "B"; diff --git a/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts b/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts new file mode 100644 index 000000000..9ba01e112 --- /dev/null +++ b/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts @@ -0,0 +1,14 @@ +// @dynamic +// import() of a module whose cycle partner reads a const of it at top +// level: the partner evaluates first, its read throws ReferenceError, and +// the import() promise rejects with that error. +async function main(): Promise { + try { + const ns = await import("./a.ts"); + console.log("loaded", ns.label()); + } catch (e) { + const err = e as Error; + console.log("rejected:", err.name, err.message); + } +} +main(); diff --git a/tests/diagnostics/cycle-class-early/dogs.ts b/tests/diagnostics/cycle-class-early/dogs.ts new file mode 100644 index 000000000..7ca9bf785 --- /dev/null +++ b/tests/diagnostics/cycle-class-early/dogs.ts @@ -0,0 +1,5 @@ +import { Pet } from "./pets.ts"; +export class Dog extends Pet {} +export function makeDog(name: string): Pet { + return new Dog(name); +} diff --git a/tests/diagnostics/cycle-class-early/main.ts b/tests/diagnostics/cycle-class-early/main.ts new file mode 100644 index 000000000..29ba03389 --- /dev/null +++ b/tests/diagnostics/cycle-class-early/main.ts @@ -0,0 +1,2 @@ +import { first } from "./pets.ts"; +console.log(first.speak()); diff --git a/tests/diagnostics/cycle-class-early/pets.ts b/tests/diagnostics/cycle-class-early/pets.ts new file mode 100644 index 000000000..61402d620 --- /dev/null +++ b/tests/diagnostics/cycle-class-early/pets.ts @@ -0,0 +1,11 @@ +import { makeDog } from "./dogs.ts"; +export class Pet { + name: string; + constructor(name: string) { + this.name = name; + } + speak(): string { + return this.name; + } +} +export const first = makeDog("rex"); diff --git a/tests/diagnostics/cycle-default-export/a.ts b/tests/diagnostics/cycle-default-export/a.ts new file mode 100644 index 000000000..58036e73f --- /dev/null +++ b/tests/diagnostics/cycle-default-export/a.ts @@ -0,0 +1,2 @@ +import { scale } from "./b.ts"; +export default scale(4); diff --git a/tests/diagnostics/cycle-default-export/b.ts b/tests/diagnostics/cycle-default-export/b.ts new file mode 100644 index 000000000..e0f1fcd5a --- /dev/null +++ b/tests/diagnostics/cycle-default-export/b.ts @@ -0,0 +1,7 @@ +import size from "./a.ts"; +export function scale(n: number): number { + return n * 2; +} +export function current(): number { + return size; +} diff --git a/tests/diagnostics/cycle-default-export/main.ts b/tests/diagnostics/cycle-default-export/main.ts new file mode 100644 index 000000000..c8e0426b3 --- /dev/null +++ b/tests/diagnostics/cycle-default-export/main.ts @@ -0,0 +1,2 @@ +import size from "./a.ts"; +console.log(size); diff --git a/tests/diagnostics/cycle-namespace-import/a.ts b/tests/diagnostics/cycle-namespace-import/a.ts new file mode 100644 index 000000000..0009745ed --- /dev/null +++ b/tests/diagnostics/cycle-namespace-import/a.ts @@ -0,0 +1,3 @@ +import * as helpers from "./b.ts"; +export const base = 2; +export const total: number = helpers.double(base); diff --git a/tests/diagnostics/cycle-namespace-import/b.ts b/tests/diagnostics/cycle-namespace-import/b.ts new file mode 100644 index 000000000..a96d085cc --- /dev/null +++ b/tests/diagnostics/cycle-namespace-import/b.ts @@ -0,0 +1,4 @@ +import * as a from "./a.ts"; +export function double(n: number): number { + return n * a.base; +} diff --git a/tests/diagnostics/cycle-namespace-import/main.ts b/tests/diagnostics/cycle-namespace-import/main.ts new file mode 100644 index 000000000..351e3cd26 --- /dev/null +++ b/tests/diagnostics/cycle-namespace-import/main.ts @@ -0,0 +1,2 @@ +import { total } from "./a.ts"; +console.log(total); diff --git a/tests/diagnostics/cycle-tdz-read/a.ts b/tests/diagnostics/cycle-tdz-read/a.ts deleted file mode 100644 index b4a07b847..000000000 --- a/tests/diagnostics/cycle-tdz-read/a.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { b } from "./b.ts"; -export const a: string = "A"; -export function useB(): string { - return b; -} diff --git a/tests/diagnostics/cycle-tdz-read/b.ts b/tests/diagnostics/cycle-tdz-read/b.ts deleted file mode 100644 index 38431a367..000000000 --- a/tests/diagnostics/cycle-tdz-read/b.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { a } from "./a.ts"; -export const b: string = a + "B"; diff --git a/tests/diagnostics/cycle-tdz-read/main.ts b/tests/diagnostics/cycle-tdz-read/main.ts deleted file mode 100644 index 3411f476a..000000000 --- a/tests/diagnostics/cycle-tdz-read/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { useB } from "./a.ts"; -console.log(useB()); diff --git a/tests/diagnostics/cycle-var-early/a.ts b/tests/diagnostics/cycle-var-early/a.ts new file mode 100644 index 000000000..b58f743fd --- /dev/null +++ b/tests/diagnostics/cycle-var-early/a.ts @@ -0,0 +1,4 @@ +import { read } from "./b.ts"; +console.log(read()); +export var level = 3; +export { read }; diff --git a/tests/diagnostics/cycle-var-early/b.ts b/tests/diagnostics/cycle-var-early/b.ts new file mode 100644 index 000000000..cbce31a91 --- /dev/null +++ b/tests/diagnostics/cycle-var-early/b.ts @@ -0,0 +1,4 @@ +import { level } from "./a.ts"; +export function read(): number { + return level; +} diff --git a/tests/diagnostics/cycle-var-early/main.ts b/tests/diagnostics/cycle-var-early/main.ts new file mode 100644 index 000000000..da02ae7b6 --- /dev/null +++ b/tests/diagnostics/cycle-var-early/main.ts @@ -0,0 +1,2 @@ +import { read } from "./a.ts"; +console.log(read()); diff --git a/tests/diagnostics/cycle-window-call/a.ts b/tests/diagnostics/cycle-window-call/a.ts deleted file mode 100644 index 65ceac143..000000000 --- a/tests/diagnostics/cycle-window-call/a.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { bee } from "./b.ts"; -export function aye(n: number): number { - return n <= 0 ? 0 : bee(n - 1) + 1; -} -console.log("a-init", aye(2)); diff --git a/tests/diagnostics/cycle-window-call/b.ts b/tests/diagnostics/cycle-window-call/b.ts deleted file mode 100644 index ba263cf43..000000000 --- a/tests/diagnostics/cycle-window-call/b.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { aye } from "./a.ts"; -export function bee(n: number): number { - return n <= 0 ? 0 : aye(n - 1) + 1; -} diff --git a/tests/diagnostics/cycle-window-call/main.ts b/tests/diagnostics/cycle-window-call/main.ts deleted file mode 100644 index d88f6d195..000000000 --- a/tests/diagnostics/cycle-window-call/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { aye } from "./a.ts"; -console.log(aye(5)); diff --git a/tests/diagnostics/cycle/a.ts b/tests/diagnostics/cycle/a.ts deleted file mode 100644 index 2464c3596..000000000 --- a/tests/diagnostics/cycle/a.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { b } from "./b.ts"; -export const a: number = b + 1; diff --git a/tests/diagnostics/cycle/b.ts b/tests/diagnostics/cycle/b.ts deleted file mode 100644 index 29732b2f2..000000000 --- a/tests/diagnostics/cycle/b.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { a } from "./a.ts"; -export const b: number = a + 1; diff --git a/tests/diagnostics/cycle/main.ts b/tests/diagnostics/cycle/main.ts deleted file mode 100644 index 631eeb063..000000000 --- a/tests/diagnostics/cycle/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { a } from "./a.ts"; -console.log(a); diff --git a/tests/diagnostics/dyn-import-cycle-tdz/main.ts b/tests/diagnostics/dyn-import-cycle-tdz/main.ts deleted file mode 100644 index 0d6f2d33e..000000000 --- a/tests/diagnostics/dyn-import-cycle-tdz/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -// @dynamic -// The REFUSED shape through the --dynamic subgraph: the cycle behind this -// import() reads a cycle-crossing binding at a module's top level, which -// in Node observes the partially-initialized exporter (TDZ -// ReferenceError). The dynamic-subgraph walk fences it with the same -// narrowed SC1016 preflight's static walk mints, naming the binding and -// the offending line. -async function main(): Promise { - const ns = await import("./a.ts"); - console.log(ns.useB()); -} -main(); diff --git a/tests/harness/__snapshots__/cjs-require-cycle/main.js.txt b/tests/harness/__snapshots__/cjs-require-cycle/main.js.txt index d987d603c..ad55be524 100644 --- a/tests/harness/__snapshots__/cjs-require-cycle/main.js.txt +++ b/tests/harness/__snapshots__/cjs-require-cycle/main.js.txt @@ -4,4 +4,4 @@ cjs-require-cycle/a.js:1:1 - error SC1016: circular imports (cjs-require-cycle/a | ^ 2 | - hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window \ No newline at end of file + hint: ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-class-early/main.ts.txt b/tests/harness/__snapshots__/cycle-class-early/main.ts.txt new file mode 100644 index 000000000..3b6b20927 --- /dev/null +++ b/tests/harness/__snapshots__/cycle-class-early/main.ts.txt @@ -0,0 +1,8 @@ +cycle-class-early/dogs.ts:2:26 - error SC1016: circular imports (the class 'Pet' declared in cycle-class-early/pets.ts can be accessed before its declaration runs) are not supported yet + + 1 | import { Pet } from "./pets.ts"; + 2 | export class Dog extends Pet {} + | ^~~ + 3 | export function makeDog(name: string): Pet { + + hint: ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-default-export/main.ts.txt b/tests/harness/__snapshots__/cycle-default-export/main.ts.txt new file mode 100644 index 000000000..6cf9d4baf --- /dev/null +++ b/tests/harness/__snapshots__/cycle-default-export/main.ts.txt @@ -0,0 +1,7 @@ +cycle-default-export/a.ts:1:1 - error SC1016: circular imports (cycle-default-export/a.ts → cycle-default-export/b.ts → cycle-default-export/a.ts; the default export expression at cycle-default-export/a.ts:2 has no initialization state inside the cycle) are not supported yet + + 1 | import { scale } from "./b.ts"; + | ^ + 2 | export default scale(4); + + hint: ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-namespace-import/main.ts.txt b/tests/harness/__snapshots__/cycle-namespace-import/main.ts.txt new file mode 100644 index 000000000..9f81cddf0 --- /dev/null +++ b/tests/harness/__snapshots__/cycle-namespace-import/main.ts.txt @@ -0,0 +1,7 @@ +cycle-namespace-import/a.ts:1:1 - error SC1016: circular imports (cycle-namespace-import/a.ts → cycle-namespace-import/b.ts → cycle-namespace-import/a.ts; the namespace import 'helpers' at cycle-namespace-import/a.ts:1 names a module of the cycle) are not supported yet + + 1 | import * as helpers from "./b.ts"; + | ^ + 2 | export const base = 2; + + hint: ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-tdz-read/main.ts.txt b/tests/harness/__snapshots__/cycle-tdz-read/main.ts.txt deleted file mode 100644 index eef3a0584..000000000 --- a/tests/harness/__snapshots__/cycle-tdz-read/main.ts.txt +++ /dev/null @@ -1,7 +0,0 @@ -cycle-tdz-read/a.ts:1:1 - error SC1016: circular imports (cycle-tdz-read/a.ts → cycle-tdz-read/b.ts → cycle-tdz-read/a.ts; the cycle-crossing binding 'a' is read at cycle-tdz-read/b.ts:2, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet - - 1 | import { b } from "./b.ts"; - | ^ - 2 | export const a: string = "A"; - - hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-var-early/main.ts.txt b/tests/harness/__snapshots__/cycle-var-early/main.ts.txt new file mode 100644 index 000000000..a683dc59f --- /dev/null +++ b/tests/harness/__snapshots__/cycle-var-early/main.ts.txt @@ -0,0 +1,8 @@ +cycle-var-early/b.ts:3:10 - error SC1016: circular imports (the var binding 'level' declared in cycle-var-early/a.ts can be accessed before its declaration runs, where Node reads undefined) are not supported yet + + 2 | export function read(): number { + 3 | return level; + | ^~~~~ + 4 | } + + hint: ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-window-call/main.ts.txt b/tests/harness/__snapshots__/cycle-window-call/main.ts.txt deleted file mode 100644 index 1e3ab980b..000000000 --- a/tests/harness/__snapshots__/cycle-window-call/main.ts.txt +++ /dev/null @@ -1,7 +0,0 @@ -cycle-window-call/a.ts:1:1 - error SC1016: circular imports (cycle-window-call/a.ts → cycle-window-call/b.ts → cycle-window-call/a.ts; top-level code at cycle-window-call/a.ts:5 can run user code during the cycle's init window — only declaration-only module bodies are admitted) are not supported yet - - 1 | import { bee } from "./b.ts"; - | ^ - 2 | export function aye(n: number): number { - - hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle/main.ts.txt b/tests/harness/__snapshots__/cycle/main.ts.txt deleted file mode 100644 index 5917a95e3..000000000 --- a/tests/harness/__snapshots__/cycle/main.ts.txt +++ /dev/null @@ -1,7 +0,0 @@ -cycle/a.ts:1:1 - error SC1016: circular imports (cycle/a.ts → cycle/b.ts → cycle/a.ts; the cycle-crossing binding 'a' is read at cycle/b.ts:2, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet - - 1 | import { b } from "./b.ts"; - | ^ - 2 | export const a: number = b + 1; - - hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window \ No newline at end of file diff --git a/tests/harness/__snapshots__/dyn-import-cycle-tdz/main.ts.txt b/tests/harness/__snapshots__/dyn-import-cycle-tdz/main.ts.txt deleted file mode 100644 index cac6621a5..000000000 --- a/tests/harness/__snapshots__/dyn-import-cycle-tdz/main.ts.txt +++ /dev/null @@ -1,7 +0,0 @@ -dyn-import-cycle-tdz/main.ts:1:1 - error SC1016: circular imports (dyn-import-cycle-tdz/a.ts → dyn-import-cycle-tdz/b.ts → dyn-import-cycle-tdz/a.ts; the cycle-crossing binding 'a' is read at dyn-import-cycle-tdz/b.ts:3, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet - - 1 | // @dynamic - | ^ - 2 | // The REFUSED shape through the --dynamic subgraph: the cycle behind this - - hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window \ No newline at end of file diff --git a/tests/harness/self-hosting-frontend-program.test.ts b/tests/harness/self-hosting-frontend-program.test.ts index ba4e613f0..b3f0a6ce5 100644 --- a/tests/harness/self-hosting-frontend-program.test.ts +++ b/tests/harness/self-hosting-frontend-program.test.ts @@ -68,7 +68,8 @@ function inputs(directory: string): FrontendProgramRequest { }); add("unsafe-cycle", { "main.ts": 'import { first } from "./first.js"; console.log(first);', - "first.ts": 'import { second } from "./second.js"; export const first: number = second + 1;', + "first.ts": + 'import * as peer from "./second.js"; export const first: number = peer.second + 1;', "second.ts": 'import { first } from "./first.js"; export const second: number = first + 1;', }); add( From c903daefcf2babb9d4e9aff53be909ce10b63d84 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 11:54:45 -0500 Subject: [PATCH 10/14] Back off cycle scans for long-lived object graphs (#741) --- .../test/ts7/baselines/order-parity.json | 6 + packages/runtime/src/scr_cycle.c | 288 +++++++++++------- packages/runtime/src/scr_json.test.c | 6 +- packages/runtime/src/scr_runtime.h | 20 +- packages/runtime/test/test_cycle.c | 104 +++++++ tests/corpus/cycle-generations-long-lived.ts | 115 +++++++ 6 files changed, 425 insertions(+), 114 deletions(-) create mode 100644 tests/corpus/cycle-generations-long-lived.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index c4ef6c01f..832626afc 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -14901,6 +14901,12 @@ "/tests/diagnostics/cycle-var-early/main.ts" ], "diags": [] + }, + "/tests/corpus/cycle-generations-long-lived.ts": { + "order": [ + "/tests/corpus/cycle-generations-long-lived.ts" + ], + "diags": [] } } } diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 3faaed5c5..7bf5d1a71 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -39,12 +39,17 @@ * from it drags in every node below, and none of it is ever garbage. * * So the collector is generational, in the shape CPython uses for this - * same algorithm. Each header carries a `gen`; a pass names the oldest - * generation it will walk and SKIPS every object above it, exactly as it - * skips NULL and immortals. Objects that survive a pass are promoted, so - * the next nursery pass never walks them again: the cost of a nursery pass - * is proportional to what has been allocated since the last one, not to - * the live heap. + * same algorithm: nursery, mature and old. Each header carries a `gen`; a + * pass names the oldest generation it will walk and SKIPS every object above + * it, exactly as it skips NULL and immortals. Objects that survive a pass are + * promoted one generation, so the next pass at a lower level never walks + * them again: the cost of a nursery pass is proportional to what has been + * allocated since the last one, and the cost of a mature pass to what has + * survived a nursery pass since the last mature one — not to the live heap. + * The third generation is what keeps a long-lived structure out of the walk + * that reclaims medium-lived garbage: objects that die after surviving one + * pass (scratch structures built for a phase of work, say) are reclaimed by + * mature passes that never touch the data that has been live for a while. * * Restricting the walk stays sound because trial deletion is already * conservative in the right direction. An edge from a skipped (older) @@ -53,41 +58,57 @@ * answer the collector gives for a reference held by the stack. Edges the * other way are simply not followed; the older object was not a candidate * for freeing in this pass. What this gives up is cycles that SPAN - * generations: those are invisible to a nursery pass and are found by the - * full pass, which walks every generation and so is exactly the old - * algorithm. + * generations: those are invisible to a restricted pass and are found by + * the full pass, which walks every generation and so is exactly the + * single-generation algorithm. * * Scheduling. One counter drives the release path: candidates buffered - * since the last pass. When it trips, the pass runs at the nursery level - * unless either full-pass condition holds: - * heap growth the live cycle-headered heap is a fraction past its size - * after the last full pass — the standard mature-generation - * rule, which catches garbage made from fresh allocation; - * mature backlog the mature candidate buffer has reached a fraction of the + * since the last pass. When it trips, the pass runs at the highest level + * whose condition holds: + * full, growth the live cycle-headered heap is a fraction past its size + * after the last full pass. The fraction starts at 1/4 and + * ADAPTS to what full passes find: a growth-triggered full + * pass that reclaims few OLD objects relative to the growth + * that triggered it, twice in a row, doubles the fraction + * (up to 1/2), and one that does reclaim them restores 1/4. + * Garbage younger than the old generation does not count: + * the mature level reclaims it without walking the old heap. + * This is what stops a large retained structure from being + * re-walked at every quarter of growth while it is building + * or while the garbage is all young. + * full, backlog the old candidate buffer has reached a fraction of the * live heap. This one is not redundant: live data that is * unlinked INTO a dead cycle grows no counter at all (it * was tallied when it was allocated) and is invisible to a - * nursery pass, so without it a program that churns its + * restricted pass, so without it a program that churns its * long-lived structures would never collect anything. But - * a mature object is also buffered whenever a temporary + * an old object is also buffered whenever a temporary * reference to it is dropped, so merely READING a live * structure (walking a tree, say) refills the buffer with - * objects that are not garbage. A full pass that frees - * almost nothing therefore doubles this fraction, and a - * productive one restores it. - * scheduled age a mature candidate has waited through a nursery's worth - * of event-loop checkpoints. Candidate COUNT cannot bound - * the garbage behind one root, and an idle heap does not - * trip growth, so this keeps a sparse mature backlog from + * objects that are not garbage. A pass that frees almost + * nothing therefore doubles this fraction, and a productive + * one restores it. + * mature the same two rules one level down, with a fixed 1/4 + * growth fraction measured from the last mature-or-full + * pass and a backlog rule over the mature buffer. + * scheduled age a mature or old candidate has waited through a nursery's + * worth of event-loop checkpoints. Candidate COUNT cannot + * bound the garbage behind one root, and an idle heap does + * not trip growth, so this keeps a sparse backlog from * floating forever without putting a full walk on every * turn. - * The first two bound collection work to a constant factor of mutator work — - * one heap-sized walk per live/N candidates — while the scheduled age is the - * liveness backstop, limiting its forced full walks to one per nursery's worth - * of checkpoints while mature roots wait. That is the trade: a fixed root - * threshold bounds floating garbage tightly and pays unbounded time for it; - * this bounds ordinary mutator-triggered work and lets garbage float in - * proportion, but not forever. + * The growth and backlog rules bound collection work to a constant factor of + * mutator work — one heap-sized walk per live/N candidates or per growth of + * live/N — while the scheduled age is the liveness backstop, limiting its + * forced full walks to one per nursery's worth of checkpoints while older + * roots wait. That is the trade: a fixed root threshold bounds floating + * garbage tightly and pays unbounded time for it; this bounds ordinary + * mutator-triggered work and lets garbage float in proportion, but not + * forever. Peak memory stays bounded the same way: young garbage floats by + * at most a quarter of the heap before a mature pass, and old garbage by at + * most half the heap's size after the last full pass, and by more than a + * quarter only once two full passes in a row have shown that little of the + * growth was old garbage. */ #include "scr_runtime.h" @@ -154,8 +175,11 @@ void scr_rc_destroy(void *obj, void (*destroy)(void *)) { } } -/* Live cycle-headered objects — what the full-pass trigger watches. */ +/* Live cycle-headered objects — what the growth triggers watch — and the + * running count of OLD objects freed, which is how a full pass measures what + * only it could have reclaimed. */ static SCR_TL size_t scr_cyc_live = 0; +static SCR_TL size_t scr_cyc_old_freed = 0; void *scr_cyc_alloc(size_t size, ScrTraceFn trace, ScrCycFreeFn free_fn) { if (size > SIZE_MAX - sizeof(ScrCycHdr)) scr_cyc_oom(); @@ -172,6 +196,7 @@ void *scr_cyc_alloc(size_t size, ScrTraceFn trace, ScrCycFreeFn free_fn) { void scr_cyc_free(void *obj) { scr_weak_dispose(obj); scr_cyc_live--; + if (scr_cyc_hdr(obj)->gen == SCR_CYC_OLD) scr_cyc_old_freed++; free(scr_cyc_hdr(obj)); } @@ -223,23 +248,36 @@ static SCR_TL ScrVec scr_white; static SCR_TL ScrVec scr_xgen; /* Objects above this generation are invisible to the pass in flight. */ -static SCR_TL unsigned scr_gen_limit = SCR_CYC_MATURE; +static SCR_TL unsigned scr_gen_limit = SCR_CYC_OLD; static size_t scr_cyc_pass(unsigned gen_limit); /* ── when to collect ──────────────────────────────────────────────────── */ #define SCR_CYC_NURSERY_CANDIDATES 256 /* nursery trigger */ -#define SCR_CYC_FULL_GROWTH_DIV 4 /* full pass per +1/4 of live heap */ -#define SCR_CYC_FULL_FLOOR 4096 /* ...but not below this many objects */ +#define SCR_CYC_GROWTH_DIV 4 /* growth pass per +1/4 of live heap */ +#define SCR_CYC_GROWTH_FLOOR 4096 /* ...but not below this many objects */ -/* Live count as of the end of the last full pass. */ +/* Live count as of the end of the last full pass, and of the last pass that + * walked the mature generation (mature or full). */ static SCR_TL size_t scr_cyc_live_after_full = 0; +static SCR_TL size_t scr_cyc_live_after_mature = 0; -/* Doublings of the mature-backlog threshold earned by consecutive full passes - * that found almost nothing to free (see scr_cyc_mature_threshold). */ +/* Doublings of the full-pass growth fraction earned by consecutive + * growth-triggered full passes that reclaimed little of the old generation + * (see scr_cyc_full_pass). Capped at a fraction of 1/2: the heap may grow + * at most by half past its size after the last full pass before the next. + * The streak counts those unproductive passes since the last productive one; + * the fraction only grows once two have run in a row. */ +#define SCR_CYC_GROWTH_MAX_SHIFT 1 +static SCR_TL unsigned scr_cyc_growth_shift = 0; +static SCR_TL unsigned scr_cyc_growth_streak = 0; + +/* Doublings of each generation's backlog threshold earned by consecutive + * passes that found almost nothing to free (see scr_cyc_backlog_threshold). + * Only the mature and old entries are used. */ #define SCR_CYC_BACKLOG_MAX_SHIFT 8 -static SCR_TL unsigned scr_cyc_backlog_shift = 0; +static SCR_TL unsigned scr_cyc_backlog_shift[SCR_CYC_NGENS]; static size_t scr_cyc_nursery_threshold(void) { static SCR_TL size_t cached = 0; @@ -251,79 +289,122 @@ static size_t scr_cyc_nursery_threshold(void) { return cached; } -/* Buffered candidates across both generations, and the count at which the +/* Buffered candidates across every generation, and the count at which the * release path next collects. Keeping a running total (rather than summing * the buffers) is what holds the hot path to one load and one compare, as * it was when there was a single buffer. The trigger is re-armed at the end * of every pass to "whatever survived, plus a nursery's worth": without that - * hysteresis a large mature buffer — which a nursery pass cannot drain — + * hysteresis a large older buffer — which a nursery pass cannot drain — * would re-arm on every single release. Both start at zero, so the first * release runs one trivial pass that arms them properly. */ static SCR_TL size_t scr_cyc_nbuffered = 0; static SCR_TL size_t scr_cyc_trigger = 0; -/* Scheduled nursery passes for which at least one mature root was waiting. - * A full pass resets the age; with no mature backlog there is nothing to age. */ +/* Scheduled nursery passes for which at least one mature or old root was + * waiting. A full pass resets the age; with no such backlog there is nothing + * to age. */ static SCR_TL size_t scr_cyc_scheduled_mature_age = 0; static size_t scr_cyc_buffered(void) { - return scr_roots[SCR_CYC_NURSERY].n + scr_roots[SCR_CYC_MATURE].n; + return scr_roots[SCR_CYC_NURSERY].n + scr_roots[SCR_CYC_MATURE].n + + scr_roots[SCR_CYC_OLD].n; } -/* A full pass is due once the live heap has grown a fraction past what it - * was when the last one finished. Since a pass resets the baseline to the - * live count it leaves behind, an unproductive full pass cannot re-trigger - * itself — the next one waits for another fraction of growth. */ -static bool scr_cyc_full_due(void) { - size_t base = scr_cyc_live_after_full; - if (base < SCR_CYC_FULL_FLOOR) base = SCR_CYC_FULL_FLOOR; - return scr_cyc_live > base + base / SCR_CYC_FULL_GROWTH_DIV; +static bool scr_cyc_older_backlog(void) { + return scr_roots[SCR_CYC_MATURE].n != 0 || scr_roots[SCR_CYC_OLD].n != 0; } -/* Cold half of the release path: pick the generation and collect. +static size_t scr_cyc_growth_base(size_t live_after) { + return live_after < SCR_CYC_GROWTH_FLOOR ? SCR_CYC_GROWTH_FLOOR : live_after; +} + +/* A pass is due once the live heap has grown a fraction past what it was + * when the last pass at that level finished. Since a pass resets its + * baseline to the live count it leaves behind, an unproductive pass cannot + * re-trigger itself — the next one waits for another fraction of growth. */ +static bool scr_cyc_grown(size_t live_after, unsigned shift) { + size_t base = scr_cyc_growth_base(live_after); + return scr_cyc_live > base + ((base / SCR_CYC_GROWTH_DIV) << shift); +} + +/* The backlog rule for one generation's candidate buffer. * - * Heap growth is not the only thing that owes a full pass. Live data that - * TURNS INTO garbage — a mature structure unlinked into a dead cycle — - * grows no counter at all: those objects were already tallied in - * scr_cyc_live when they were allocated, so scr_cyc_full_due stays false - * forever, and a nursery pass cannot see them because they are mature. Left - * at that, a program that churns its long-lived structures without - * allocating would never collect anything. So the mature buffer's own size - * is the second full-pass trigger, at a fraction of the live heap: that - * bounds uncollected mature candidates proportionally and keeps the - * amortized cost linear (one heap-sized walk per live/N candidates). + * Heap growth is not the only thing that owes a pass. Live data that TURNS + * INTO garbage — an older structure unlinked into a dead cycle — grows no + * counter at all: those objects were already tallied in scr_cyc_live when + * they were allocated, so the growth rules stay false forever, and a + * restricted pass cannot see them because they sit above its level. Left at + * that, a program that churns its long-lived structures without allocating + * would never collect anything. So a buffer's own size is the second trigger + * for its level, at a fraction of the live heap: that bounds uncollected + * candidates proportionally and keeps the amortized cost linear (one walk + * per live/N candidates). * * Re-buffered live objects cannot be told apart from new garbage until a * pass walks them, and a program that keeps traversing one live structure - * buffers it again after every pass. Each full pass that frees almost none - * of its candidates doubles the threshold, so repeated unproductive walks - * thin out geometrically; once it exceeds the mature population this - * trigger rests, leaving heap growth, the scheduled age and exit to collect. - * That only delays garbage made from existing data: replacing it requires - * allocation, which grows the heap and trips the growth trigger. The first - * productive full pass restores the original fraction. */ -static size_t scr_cyc_mature_threshold(void) { - size_t t = scr_cyc_live / SCR_CYC_FULL_GROWTH_DIV; + * buffers it again after every pass. Each backlog-level pass that frees + * almost none of its candidates doubles the threshold, so repeated + * unproductive walks thin out geometrically; once it exceeds the population + * this trigger rests, leaving heap growth, the scheduled age and exit to + * collect. That only delays garbage made from existing data: replacing it + * requires allocation, which grows the heap and trips a growth trigger. The + * first productive pass at that level restores the original fraction. */ +static size_t scr_cyc_backlog_threshold(unsigned gen) { + size_t t = scr_cyc_live / SCR_CYC_GROWTH_DIV; if (t < SCR_CYC_NURSERY_CANDIDATES) t = SCR_CYC_NURSERY_CANDIDATES; - return t > (SIZE_MAX >> scr_cyc_backlog_shift) ? SIZE_MAX : t << scr_cyc_backlog_shift; + unsigned shift = scr_cyc_backlog_shift[gen]; + return t > (SIZE_MAX >> shift) ? SIZE_MAX : t << shift; } -/* A scheduled full pass, adapting the backlog threshold to its yield. The - * explicit sweep (scr_collect_cycles) is not a scheduling decision and - * leaves the threshold alone. Productive means at least one object freed - * per FULL_GROWTH_DIV candidates; a pass with no candidates says nothing. */ -static void scr_cyc_full_pass(void) { - size_t freed = scr_cyc_pass(SCR_CYC_MATURE); +/* A scheduled pass at the mature or old level, adapting that level's backlog + * threshold to its yield. The explicit sweep (scr_collect_cycles) is not a + * scheduling decision and leaves the thresholds alone. Productive means at + * least one object freed per GROWTH_DIV candidates; a pass with no + * candidates says nothing. */ +static void scr_cyc_level_pass(unsigned gen) { + size_t freed = scr_cyc_pass(gen); if (scr_cands.n == 0) return; - if (freed * SCR_CYC_FULL_GROWTH_DIV >= scr_cands.n) - scr_cyc_backlog_shift = 0; - else if (scr_cyc_backlog_shift < SCR_CYC_BACKLOG_MAX_SHIFT) - scr_cyc_backlog_shift++; + if (freed * SCR_CYC_GROWTH_DIV >= scr_cands.n) + scr_cyc_backlog_shift[gen] = 0; + else if (scr_cyc_backlog_shift[gen] < SCR_CYC_BACKLOG_MAX_SHIFT) + scr_cyc_backlog_shift[gen]++; +} + +/* A scheduled full pass. When heap growth triggered it, also adapt the + * growth fraction to what it reclaimed from the OLD generation — the one + * part of its work no restricted pass could have done. Weighing that against + * the growth since the last full pass keeps full passes frequent while old + * structures are being discarded (their garbage is what floats until the + * next full pass) and spaces them out while the heap is growing with live or + * young data, where each walk of the retained heap buys almost nothing. + * Backing off only after two unproductive passes in a row keeps a program + * that discards old structures at a steady rate on the base schedule: its + * passes alternate between finding the garbage just aged into the old + * generation and finding it still younger, and a single miss says little. */ +static void scr_cyc_full_pass(bool by_growth) { + size_t base = scr_cyc_growth_base(scr_cyc_live_after_full); + size_t growth = scr_cyc_live > base ? scr_cyc_live - base : 0; + size_t old_freed_before = scr_cyc_old_freed; + scr_cyc_level_pass(SCR_CYC_OLD); + if (!by_growth) return; + size_t reclaimed = scr_cyc_old_freed - old_freed_before; + if (reclaimed * SCR_CYC_GROWTH_DIV >= growth) { + scr_cyc_growth_shift = 0; + scr_cyc_growth_streak = 0; + } else if (++scr_cyc_growth_streak >= 2 + && scr_cyc_growth_shift < SCR_CYC_GROWTH_MAX_SHIFT) { + scr_cyc_growth_shift++; + } } static void scr_cyc_collect_due(void) { - if (scr_cyc_full_due() || scr_roots[SCR_CYC_MATURE].n >= scr_cyc_mature_threshold()) - scr_cyc_full_pass(); + if (scr_cyc_grown(scr_cyc_live_after_full, scr_cyc_growth_shift)) + scr_cyc_full_pass(true); + else if (scr_roots[SCR_CYC_OLD].n >= scr_cyc_backlog_threshold(SCR_CYC_OLD)) + scr_cyc_full_pass(false); + else if (scr_cyc_grown(scr_cyc_live_after_mature, 0) + || scr_roots[SCR_CYC_MATURE].n >= scr_cyc_backlog_threshold(SCR_CYC_MATURE)) + scr_cyc_level_pass(SCR_CYC_MATURE); else scr_cyc_pass(SCR_CYC_NURSERY); } @@ -338,11 +419,11 @@ void scr_cyc_collect_scheduled(void) { scr_cyc_scheduled_mature_age = 0; return; } - if (scr_roots[SCR_CYC_MATURE].n == 0) { + if (!scr_cyc_older_backlog()) { scr_cyc_scheduled_mature_age = 0; } else if (++scr_cyc_scheduled_mature_age >= scr_cyc_nursery_threshold()) { - scr_cyc_full_pass(); + scr_cyc_full_pass(false); return; } scr_cyc_collect_due(); @@ -363,7 +444,7 @@ void scr_cyc_on_dead(void *obj) { scr_cyc_nbuffered--; /* A death must not consume room reserved for a future candidate. */ scr_cyc_trigger--; - if (h->gen == SCR_CYC_MATURE && b->n == 0) + if (h->gen != SCR_CYC_NURSERY && !scr_cyc_older_backlog()) scr_cyc_scheduled_mature_age = 0; } @@ -427,7 +508,7 @@ static void scr_sb_visit(void *child, void *ctx) { static void scr_scan_black(void *obj, uintptr_t depth) { ScrCycHdr *h = scr_cyc_hdr(obj); h->color = SCR_CYC_BLACK; - if (h->gen < SCR_CYC_MATURE) scr_cyc_push(&scr_promote, obj); + if (h->gen < SCR_CYC_OLD) scr_cyc_push(&scr_promote, obj); if (depth == SCR_CYC_WALK_DEPTH) { scr_cyc_push(&scr_restore_pending, obj); return; @@ -541,7 +622,7 @@ static size_t scr_cyc_pass(unsigned gen_limit) { * object re-retained since buffering is black. Drain the buffers before * walking: tracing only adjusts counts and worklists, so nothing can * re-buffer underneath us. Candidates ABOVE the limit keep their slots - * and wait for a full pass. */ + * and wait for a pass at their own level. */ scr_cands.n = 0; for (unsigned g = 0; g <= gen_limit; g++) { for (size_t i = 0; i < scr_roots[g].n; i++) { @@ -583,7 +664,7 @@ static size_t scr_cyc_pass(unsigned gen_limit) { * `gen` still holds the values the walk filtered on (promotion below is * what changes them). A full pass skips no edge, so it pins nothing. */ scr_xgen.n = 0; - if (gen_limit < SCR_CYC_MATURE) { + if (gen_limit < SCR_CYC_OLD) { for (size_t i = 0; i < scr_white.n; i++) { void *obj = scr_white.v[i]; scr_cyc_hdr(obj)->trace(obj, scr_xg_pin_visit, NULL); @@ -591,10 +672,12 @@ static size_t scr_cyc_pass(unsigned gen_limit) { } /* Every phase that consults SCR_CYC_SKIP has run, so the recorded - * survivors can graduate now. They are externally referenced, so none of - * them is in the white set about to be freed. */ - for (size_t i = 0; i < scr_promote.n; i++) - scr_cyc_hdr(scr_promote.v[i])->gen = SCR_CYC_MATURE; + * survivors can graduate now, one generation each. They are externally + * referenced, so none of them is in the white set about to be freed. */ + for (size_t i = 0; i < scr_promote.n; i++) { + ScrCycHdr *h = scr_cyc_hdr(scr_promote.v[i]); + if (h->gen < SCR_CYC_OLD) h->gen++; + } scr_promote.n = 0; /* A restricted pass may have spared a candidate only because the edge @@ -605,13 +688,14 @@ static size_t scr_cyc_pass(unsigned gen_limit) { * root, and a dead cycle whose members all got spared that way would * never be walked again by any pass, full ones included. So a restricted * pass hands its survivors back as candidates in the generation they were - * just promoted into, where a full pass — which skips nothing, and so - * judges them on real reference counts — will settle them. A full pass + * just promoted into, where a later pass at that level — and in the end a + * full pass, which skips nothing and so judges them on real reference + * counts — will settle them. A full pass * needs none of this: it walked every edge, so rc > 0 there means a * genuine outside reference and Bacon-Rajan's own reasoning retires the * candidate. Re-buffering costs a walk, never correctness: an object that * is truly live gets re-blackened by the next retain and dropped then. */ - if (gen_limit < SCR_CYC_MATURE) { + if (gen_limit < SCR_CYC_OLD) { for (size_t i = 0; i < scr_cands.n; i++) { void *obj = scr_cands.v[i]; ScrCycHdr *h = scr_cyc_hdr(obj); @@ -650,7 +734,8 @@ static size_t scr_cyc_pass(unsigned gen_limit) { scr_xgen.n = 0; freed += scr_xg_freed; - if (gen_limit >= SCR_CYC_MATURE) { + if (gen_limit >= SCR_CYC_MATURE) scr_cyc_live_after_mature = scr_cyc_live; + if (gen_limit >= SCR_CYC_OLD) { scr_cyc_live_after_full = scr_cyc_live; scr_cyc_scheduled_mature_age = 0; } @@ -671,8 +756,7 @@ void scr_collect_cycles(void) { * can have buffered anything new, so a pass that comes up empty ends * this; and since each repeat needs a strictly smaller live heap to * continue, it terminates. */ - while (scr_cyc_pass(SCR_CYC_MATURE) - && (scr_roots[SCR_CYC_NURSERY].n || scr_roots[SCR_CYC_MATURE].n)) { + while (scr_cyc_pass(SCR_CYC_OLD) && scr_cyc_buffered()) { } } @@ -681,7 +765,7 @@ void scr_collect_cycles(void) { void scr_cyc_context_cleanup(void) { scr_collect_cycles(); ScrVec *vectors[] = {&scr_roots[SCR_CYC_NURSERY], &scr_roots[SCR_CYC_MATURE], - &scr_cands, &scr_promote, &scr_pending, &scr_restore_pending, &scr_white, &scr_xgen}; + &scr_roots[SCR_CYC_OLD], &scr_cands, &scr_promote, &scr_pending, &scr_restore_pending, &scr_white, &scr_xgen}; for (size_t i = 0; i < sizeof vectors / sizeof vectors[0]; i++) { free(vectors[i]->v); *vectors[i] = (ScrVec){0}; diff --git a/packages/runtime/src/scr_json.test.c b/packages/runtime/src/scr_json.test.c index 397eaa0fe..977506dd2 100644 --- a/packages/runtime/src/scr_json.test.c +++ b/packages/runtime/src/scr_json.test.c @@ -247,8 +247,8 @@ static void shared_property_keys(void) { static void checked_leaf_cycles(void) { /* Object properties omit primitives from trial deletion, while arrays and * captured unknown values still trace them. Exercise shared leaves through - * both edge kinds, with and without an external owner, in either generation. */ - for (int old = 0; old < 2; old++) { + * both edge kinds, with and without an external owner, in every generation. */ + for (uint16_t gen = SCR_CYC_NURSERY; gen < SCR_CYC_NGENS; gen++) { for (int external = 0; external < 2; external++) { #ifdef SCR_RC_AUDIT long before_dyns = scr_dyn_live_count(); @@ -257,7 +257,7 @@ static void checked_leaf_cycles(void) { ScrStr *text = scr_str_new("survives either edge", 20); ScrDyn *leaf = scr_dyn_new_str(text); scr_str_release(text); - if (old) scr_cyc_hdr(leaf)->gen = SCR_CYC_MATURE; + scr_cyc_hdr(leaf)->gen = gen; ScrDyn *first = scr_dyn_new_obj(), *second = scr_dyn_new_obj(); ScrDyn *array = scr_dyn_new_arr(); ScrClosure *closure = scr_closure_new(NULL, 1); diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 8b02a12c6..4d65dd2f8 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -336,12 +336,13 @@ char **scr_worker_argv(void); * scan: restore externally-referenced subgraphs; gather: free the * dead cycle members, releasing only edges that LEAVE the white set). * It is GENERATIONAL: each header carries a generation, a pass names the - * oldest one it will walk, and objects that survive a pass are promoted out - * of the nursery so later nursery passes never re-walk them. That is what - * keeps a pass proportional to recent allocation rather than to the whole - * live heap — see the generation note in scr_cycle.c for the soundness - * argument and the schedule. Collection points: program exit (before the RC - * audit), event-loop quiescence, and the per-generation triggers. + * oldest one it will walk, and objects that survive a pass are promoted one + * generation (nursery, mature, old) so later restricted passes never re-walk + * them. That is what keeps a pass proportional to recent allocation rather + * than to the whole live heap — see the generation note in scr_cycle.c for + * the soundness argument and the schedule. Collection points: program + * exit (before the RC audit), event-loop quiescence, and the per-generation + * triggers. * SCR_CYCLE_THRESHOLD pins the nursery trigger to a fixed candidate count. * There is no concurrent or incremental collection. * @@ -367,8 +368,9 @@ enum { }; /* Generations. A candidate sits in the buffer named by its own `gen`, and a - * pass walks only objects at or below the generation it collects. */ -enum { SCR_CYC_NURSERY = 0, SCR_CYC_MATURE = 1, SCR_CYC_NGENS = 2 }; + * pass walks only objects at or below the generation it collects; a pass at + * SCR_CYC_OLD is a full pass. */ +enum { SCR_CYC_NURSERY = 0, SCR_CYC_MATURE = 1, SCR_CYC_OLD = 2, SCR_CYC_NGENS = 3 }; typedef struct ScrCycHdr { ScrTraceFn trace; @@ -380,7 +382,7 @@ typedef struct ScrCycHdr { #endif uint32_t color; /* SCR_CYC_* */ uint16_t buffered; /* 1 = sitting in its generation's candidate buffer */ - uint16_t gen; /* SCR_CYC_NURSERY..SCR_CYC_MATURE (the walk filter) */ + uint16_t gen; /* SCR_CYC_NURSERY..SCR_CYC_OLD (the walk filter) */ size_t buf_index; /* position there (O(1) removal when rc hits 0) */ } ScrCycHdr; diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index aaa116924..190d79e11 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -244,6 +244,107 @@ static void check_unproductive_backlog_backs_off(void) { } } +/* Survive two passes: nursery to mature, then mature to old. */ +static void age_to_old(Node *node) { + for (int i = 0; i < 2; i++) { + read_live(node); + scr_collect_cycles(); + } + check(scr_cyc_hdr(node)->gen == SCR_CYC_OLD, "survivor did not reach the old generation"); +} + +/* Fresh live leaves, each released once so the release path keeps running + * its passes, until `target` objects have been freed or `limit` leaves have + * been made. Returns how many it made. */ +static size_t grow_until(Node **keep, size_t limit, size_t target) { + size_t n = 0; + while (freed < target && n < limit) { + keep[n] = make_leaf(); + read_live(keep[n]); + n++; + } + return n; +} + +static void drop_leaves(Node **keep, size_t n) { + for (size_t i = 0; i < n; i++) { + keep[i]->rc--; + scr_cyc_on_dead(keep[i]); + node_free(keep[i]); + } +} + +/* A dead cycle that has aged into the old generation is reachable only by a + * full pass. While the heap grows with live data, full passes find nothing + * and back off, but the dead cycle must still be reclaimed before the heap + * has grown by half past its size after the last full pass. Runs first, so + * the live heap is below the growth floor and that bound is known. */ +static void check_old_garbage_reclaimed_while_growing(void) { + enum { RING = 64, FLOOR = 4096, LIMIT = 4 * FLOOR }; + static Node *keep[LIMIT]; + size_t before = freed; + Node *ring = make_ring(RING); + age_to_old(ring); + release_live(ring); /* last outside owner gone: a dead old cycle */ + size_t grown = grow_until(keep, LIMIT, before + RING); + check(freed == before + RING, "dead old cycle was never reclaimed while the heap grew"); + check(grown <= FLOOR + FLOOR / 2, "dead old cycle outlived the growth bound"); + drop_leaves(keep, grown); + scr_collect_cycles(); +} + +/* Dead rings that survived at least one pass, each holding a traced edge + * into an old ring: whichever level reclaims them must pay those edges off, + * and must do so without an explicit sweep. */ +static void check_older_rings_into_old(void) { + enum { OLD_RING = 2048, RING = 8, RINGS = 1024, LIMIT = 4 * (OLD_RING + RING * RINGS) }; + static Node *keep[LIMIT]; + Node *old = make_ring(OLD_RING); + age_to_old(old); + size_t before = freed; + Node *rings[RINGS]; + for (size_t i = 0; i < RINGS; i++) { + rings[i] = make_ring(RING); + rings[i]->other = old; + old->rc++; + read_live(rings[i]); + } + scr_cyc_collect_scheduled(); /* every ring survives at least one pass */ + for (size_t i = 0; i < RINGS; i++) { + check(scr_cyc_hdr(rings[i])->gen != SCR_CYC_NURSERY, "ring was not promoted"); + release_live(rings[i]); + } + size_t grown = grow_until(keep, LIMIT, before + RINGS * RING); + check(freed == before + RINGS * RING, "dead older rings were never reclaimed"); + check(old->rc == 2, "edges into the old generation were not paid off"); + drop_leaves(keep, grown); + release_live(old); + scr_collect_cycles(); +} + +/* A dead cycle spanning the mature and old generations is spared by mature + * passes; promotion and re-buffering must still hand it to a full pass. */ +static void check_cycle_spanning_mature_and_old(void) { + enum { LIMIT = 4 * 4096 }; + static Node *keep[LIMIT]; + Node *old = make_ring(4); + age_to_old(old); + Node *young = make_ring(4); + young->other = old; + old->rc++; + old->other = young; + young->rc++; + read_live(young); + scr_cyc_collect_scheduled(); /* young survives a pass and is promoted */ + size_t before = freed; + release_live(old); + release_live(young); /* only the cycle's own edges remain */ + size_t grown = grow_until(keep, LIMIT, before + 8); + check(freed == before + 8, "cycle spanning generations was never reclaimed"); + drop_leaves(keep, grown); + scr_collect_cycles(); +} + static void check_deep_ring(void) { enum { DEPTH = 100000 }; size_t before = freed; @@ -415,6 +516,9 @@ static void check_deferred_white_restoration(void) { } int main(void) { + check_old_garbage_reclaimed_while_growing(); + check_older_rings_into_old(); + check_cycle_spanning_mature_and_old(); check_unproductive_backlog_backs_off(); check_sparse_mature_backlog(1); check_sparse_mature_backlog(2); diff --git a/tests/corpus/cycle-generations-long-lived.ts b/tests/corpus/cycle-generations-long-lived.ts new file mode 100644 index 000000000..08afe7af7 --- /dev/null +++ b/tests/corpus/cycle-generations-long-lived.ts @@ -0,0 +1,115 @@ +// Long-lived cyclic structures next to garbage of every age. +// +// Warehouses keep bins with parent back-pointers and a name index for the +// whole run. Each restock builds scratch pick lists (doubly linked, pointing +// into the bins) that live through many collector passes before they are +// dropped, and every few rounds a whole warehouse is retired after it has +// been live for a long time. Every dead structure must be reclaimed without +// touching the live ones: the sanitized lane's RC audit checks that nothing +// is left over at exit, and the checksums check that nothing live was freed. +class Bin { + label: string; + parent: Bin | null = null; + children: Bin[] = []; + stock: number; + constructor(label: string, stock: number) { + this.label = label; + this.stock = stock; + } +} + +class Warehouse { + name: string; + root: Bin; + index: Map = new Map(); + constructor(name: string) { + this.name = name; + this.root = new Bin(`${name}/root`, 0); + this.index.set(this.root.label, this.root); + } + add(parent: Bin, label: string, stock: number): Bin { + const bin = new Bin(`${this.name}/${label}`, stock); + bin.parent = parent; + parent.children.push(bin); + this.index.set(bin.label, bin); + return bin; + } +} + +class Pick { + bin: Bin; + qty: number; + prev: Pick | null = null; + next: Pick | null = null; + constructor(bin: Bin, qty: number) { + this.bin = bin; + this.qty = qty; + } +} + +function stockWarehouse(name: string, bins: number): Warehouse { + const w = new Warehouse(name); + const all: Bin[] = [w.root]; + for (let i = 1; i < bins; i++) all.push(w.add(all[(i - 1) >> 2], `b${i}`, (i * 7) % 13)); + return w; +} + +function pickList(w: Warehouse, round: number, length: number): Pick | null { + let head: Pick | null = null; + let tail: Pick | null = null; + for (let i = 0; i < length; i++) { + const bin = w.index.get(`${w.name}/b${1 + ((i * 31 + round) % (w.index.size - 1))}`); + if (bin === undefined) continue; + const p = new Pick(bin, 1 + (i % 3)); + if (tail === null) head = p; + else { + tail.next = p; + p.prev = tail; + } + tail = p; + } + return head; +} + +function depth(bin: Bin): number { + let d = 0; + for (let b: Bin | null = bin.parent; b !== null; b = b.parent) d++; + return d; +} + +const warehouses: Warehouse[] = []; +for (let i = 0; i < 4; i++) warehouses.push(stockWarehouse(`w${i}`, 1500)); + +let pending: (Pick | null)[] = []; +let picked = 0; +let retired = 0; +for (let round = 0; round < 24; round++) { + const w = warehouses[round % warehouses.length]; + pending.push(pickList(w, round, 400)); + // Lists wait a few rounds before they are fulfilled and dropped. + if (pending.length > 3) { + for (const head of pending) { + for (let p = head; p !== null; p = p.next) picked += p.qty * (1 + depth(p.bin)); + } + pending = []; + } + // Retire the oldest warehouse every eight rounds and open a fresh one. + if (round % 8 === 7) { + const gone = warehouses.shift(); + if (gone !== undefined) retired += gone.index.size; + warehouses.push(stockWarehouse(`w${4 + round}`, 1500)); + } +} + +let bins = 0; +let stock = 0; +for (const w of warehouses) { + bins += w.index.size; + for (const bin of w.index.values()) { + stock += bin.stock; + if (bin.parent !== null && !bin.parent.children.includes(bin)) console.log("broken link", bin.label); + } +} +console.log(`warehouses ${warehouses.map((w) => w.name).join(",")}`); +console.log(`bins ${bins} stock ${stock} retired ${retired}`); +console.log(`picked ${picked} pending ${pending.length}`); From 66b773fd7fb23866a67e5c88c3e0ffee7ae98288 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 12:52:53 -0500 Subject: [PATCH 11/14] Read optional elements and captured parameters without boxing (#767) --- .../src/backend/llvm/call-lifetimes.test.ts | 44 ++++++++ .../src/backend/llvm/call-lifetimes.ts | 31 +++--- .../compiler/src/backend/llvm/emitter.test.ts | 29 ++++- packages/compiler/src/backend/llvm/emitter.ts | 74 ++++++++++++- .../compiler/src/backend/llvm/expr-calls.ts | 5 +- .../compiler/src/backend/llvm/expr-control.ts | 68 +++++++++++- .../compiler/src/backend/llvm/expr-dynamic.ts | 26 ++++- .../src/backend/llvm/lazy-captures.test.ts | 89 ++++++++++++++++ .../src/backend/llvm/lazy-captures.ts | 87 +++++++++++++++ .../src/backend/llvm/local-array-reads.ts | 13 +++ .../src/backend/llvm/stack-callbacks.ts | 3 +- .../src/backend/llvm/stack-captures.ts | 9 +- .../compiler/src/frontend/lowering/lowerer.ts | 41 ++++++- .../lowering/runtime-optional-provenance.ts | 41 +++++++ packages/compiler/test/call-lifetimes.test.ts | 34 ++++++ .../test/string-and-array-lifetimes.test.ts | 48 ++++++++- .../test/ts7/baselines/order-parity.json | 44 +++++--- .../4045-optional-element-projections.ts | 100 ++++++++++++++++++ .../corpus/4046-captured-parameter-values.ts | 97 +++++++++++++++++ .../4047-captured-parameter-rebinding.ts | 81 ++++++++++++++ 20 files changed, 915 insertions(+), 49 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/lazy-captures.test.ts create mode 100644 packages/compiler/src/backend/llvm/lazy-captures.ts create mode 100644 packages/compiler/src/frontend/lowering/runtime-optional-provenance.ts create mode 100644 tests/corpus/4045-optional-element-projections.ts create mode 100644 tests/corpus/4046-captured-parameter-values.ts create mode 100644 tests/corpus/4047-captured-parameter-rebinding.ts diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts index 1b72c4491..583b9f4fb 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts @@ -430,3 +430,47 @@ test("unchanged let bindings can own call inputs but subsequent writes and captu }); expect(analyze(fn).bindings.get(fn.name)?.size).toBe(0); }); + +test("strict union equality projects both operands without escaping either box", () => { + const equal = (left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "unionEq", + unionId: "optional", + negated: false, + sameValue: false, + left, + right, + type: BOOL, + loc, + }); + const fn = helper("same", ["value", "other"]); + fn.body = [ret(equal(ref("value"), ref("other")))]; + expect(analyze(fn).parameters.get("same")).toEqual(new Set([0, 1])); + // A nested consumer inside an operand is still visited. + fn.body = [ret(equal(ref("value"), call("escape", [ref("other")])))]; + expect(analyze(fn).parameters.get("same")).toEqual(new Set([0])); +}); + +test("environment bodies prove their own locals but never their parameters", () => { + const leaf = helper("leaf"); + const fn = helper("lifted"); + fn.captures = [{ localId: "box", name: "box", type: optional }]; + fn.locals.push({ ...local("box"), boxed: true }, local("item")); + fn.body = [ + { + kind: "varDecl", + localId: "item", + init: { kind: "unionWrap", value: num(), unionId: "optional", tag: 0, type: optional, loc }, + loc, + }, + ret(call("leaf", [ref("item")])), + ret(call("leaf", [ref("box")])), + ret(call("leaf", [ref("value")])), + ]; + const facts = analyze(fn, leaf); + expect(facts.locals.get("lifted")).toEqual(new Set(["item"])); + expect(facts.bindings.get("lifted")).toEqual(new Set(["item"])); + expect(facts.parameters.has("lifted")).toBe(false); + expect(facts.borrowed.has("lifted")).toBe(false); + fn.async = true; + expect(analyze(fn, leaf).locals.has("lifted")).toBe(false); +}); diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.ts b/packages/compiler/src/backend/llvm/call-lifetimes.ts index 8ec356ab2..30d1d73a8 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.ts @@ -101,6 +101,9 @@ function collectUses(fn: IrFunction): Uses { case "unionIsTag": if (node.value.kind === "varRef") return true; break; + case "unionEq": + // Equality reads each operand's tag and payload; neither box escapes. + return [node.left, node.right].every((side) => side.kind === "varRef" || expr(side)); case "fieldGet": case "recordGet": if (node.obj.kind === "varRef") return true; @@ -182,8 +185,9 @@ function collectUses(fn: IrFunction): Uses { * do not recurse on the compiler stack or repeatedly rescan function bodies. * * The result is private to one emission of the finalized IR. No fact is - * serialized or reused after a compiler transformation. Synchronous bodies - * without environments are the only supported calling convention. */ + * serialized or reused after a compiler transformation. Only synchronous + * bodies are analyzed; parameter facts additionally require a body without + * an environment, which is the only directly called convention. */ export function analyzeCallLifetimes(functions: ReadonlyMap): CallLifetimes { const usesByFunction = new Map(); const nodes = new Map(); @@ -196,20 +200,11 @@ export function analyzeCallLifetimes(functions: ReadonlyMap) projectedLocals: new Map(), }; for (const fn of functions.values()) { - if (fn.async || fn.generator || fn.captures !== undefined || fn.classCaptures !== undefined) - continue; + if (fn.async || fn.generator) continue; const uses = collectUses(fn); usesByFunction.set(fn.name, uses); - const locals = new Map(fn.locals.map((local) => [local.id, local])); - const borrowed = new Set(); const stable = (local: IrLocal): boolean => !local.boxed && !local.tdz && !uses.written.has(local.id); - fn.params.forEach((param, index) => { - const local = locals.get(param.localId); - if (local && isRefCounted(param.type) && stable(local) && !uses.declarations.has(local.id)) - borrowed.add(index); - }); - if (borrowed.size > 0) result.borrowed.set(fn.name, borrowed); result.bindings.set( fn.name, new Set( @@ -218,6 +213,18 @@ export function analyzeCallLifetimes(functions: ReadonlyMap) .map((local) => local.id), ), ); + // Environment-taking bodies are reached through closure or class + // dispatch, never a direct call. Their own locals follow the same + // proof (captures are already writes), but parameters keep ownership. + if (fn.captures !== undefined || fn.classCaptures !== undefined) continue; + const locals = new Map(fn.locals.map((local) => [local.id, local])); + const borrowed = new Set(); + fn.params.forEach((param, index) => { + const local = locals.get(param.localId); + if (local && isRefCounted(param.type) && stable(local) && !uses.declarations.has(local.id)) + borrowed.add(index); + }); + if (borrowed.size > 0) result.borrowed.set(fn.name, borrowed); nodes.set( fn.name, fn.params.map((param) => { diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index f60eaeabf..153791b8c 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -109,7 +109,13 @@ test("throwing-call count does not multiply identical scope cleanup", () => { test("cleanup snapshots keep locals declared after an earlier throw separate", () => { const module = moduleFor([ call(), - { kind: "varDecl", localId: "later", init: literal("created"), loc }, + // An owned initializer: a stable literal binding needs no cleanup. + { + kind: "varDecl", + localId: "later", + init: { kind: "strConcat", left: literal("cre"), right: literal("ated"), type: STRING, loc }, + loc, + }, call(), ]); const blocks = cleanupBlocks(module); @@ -231,7 +237,26 @@ function work(expr: IrExpr, parameter: IrType, boxed = false, tdz = false): stri ...(tdz ? { tdz: true } : {}), }, ], - body: [{ kind: "return", value: expr, loc: receiverLoc }], + body: [ + { kind: "return", value: expr, loc: receiverLoc }, + // An unreachable rebinding keeps a boxed parameter in its shared + // box; a captured parameter that is never rebound uses a plain slot. + ...(boxed + ? [ + { + kind: "assign" as const, + localId: "value", + value: { + kind: "varRef" as const, + localId: "value", + type: parameter, + loc: receiverLoc, + }, + loc: receiverLoc, + }, + ] + : []), + ], loc: receiverLoc, }, ], diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index de763aa06..23edd3c23 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -135,6 +135,7 @@ import { import { BlockBuilder } from "./blocks.js"; import { emitLocalArrayRead, + emitProjectedArrayRead, emitBorrowedArrayRead, findLocalArrayReads, findCallArrayReads, @@ -159,6 +160,7 @@ import { emitStringSliceSnapshot, type StringSliceSnapshot, } from "./string-slices.js"; +import { LazyCaptures } from "./lazy-captures.js"; import { StackCaptures } from "./stack-captures.js"; import { ABSENT_FIELD_PAYLOAD, @@ -487,7 +489,7 @@ export class LlEmitter { readonly fnByName = new Map(); readonly referenceEffects: ReferenceEffects; - private readonly optionalArrayReads: OptionalArrayReads; + readonly optionalArrayReads: OptionalArrayReads; callArrayReads = new Map(); mapReadLifetimes: MapReadLifetimes = { locals: new Map(), arguments: new Map() }; readonly callLifetimes: CallLifetimes; @@ -495,6 +497,11 @@ export class LlEmitter { currentConstantCallbacks: ReadonlyMap = new Map(); readonly stackCallbacks: StackCallbacks; private readonly stackCaptures: StackCaptures; + private readonly lazyCaptures: LazyCaptures; + /** Unchanged captured parameters of the current function: each maps to + * the slot caching its box, NULL until the first closure needs it. */ + private readonly lazyCaptureBoxes = new Map(); + private readonly lazyStackBoxes = new Map(); private borrowedParameters = new Set(); private stableCallBindings: ReadonlySet = new Set(); /** Manifest-bound native imports, used by ffiCall emission. */ @@ -704,6 +711,7 @@ export class LlEmitter { this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes); this.stackCallbacks = new StackCallbacks(this.fnByName); this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks); + this.lazyCaptures = new LazyCaptures(this.fnByName); for (const r of mod.records ?? []) this.recordsById.set(r.id, r); const traced = computeTraced(mod); this.tracedShapes = traced.shapes; @@ -3688,6 +3696,33 @@ export class LlEmitter { } } + /** The box a new environment captures for `localId` (borrowed). An + * unchanged parameter gets one box on first capture, holding its own + * reference to the value; the function scope releases the cached box. */ + captureBox(localId: string): string { + const cache = this.lazyCaptureBoxes.get(localId); + if (cache === undefined) return this.loadBox(`%${mangleLocal(localId)}`); + const B = this.B; + const type = this.currentLocals.get(localId)!.type; + const current = this.loadBox(cache); + const missing = B.tmp(); + B.line(`${missing} = icmp eq ptr ${current}, null`); + const create = B.newLabel("lazy.box"), + join = B.newLabel("lazy.box.j"); + B.condBr(missing, create, join); + B.startBlock(create); + const stack = this.lazyStackBoxes.get(localId); + const box = stack ?? B.tmp(); + if (!stack) B.line(`${box} = ${boxNewCall(this.shapeHost, type)}`); + const value = B.tmp(); + B.line(`${value} = load ${this.llType(type)}, ptr %${mangleLocal(localId)}`); + this.boxSet(box, type, isRefCounted(type) ? this.retainValue(value, type) : value); + B.line(`store ptr ${box}, ptr ${cache}`); + B.br(join); + B.startBlock(join); + return this.loadBox(cache); + } + retainBox(box: string): string { this.needsRetainBox = true; const t = this.B.tmp(); @@ -3935,13 +3970,23 @@ export class LlEmitter { this.unwindCleanups.clear(); this.jumpTargets = []; this.currentLocals = new Map(fn.locals.map((l) => [l.id, l])); + this.lazyCaptureBoxes.clear(); + this.lazyStackBoxes.clear(); + // An unchanged captured parameter keeps its value in a plain slot; the + // binding is boxed only for closures (captureBox). + const lazyParameters = this.lazyCaptures.parameters(fn); + for (const id of lazyParameters) { + const { boxed: _boxed, ...plain } = this.currentLocals.get(id)!; + this.currentLocals.set(id, plain); + this.lazyCaptureBoxes.set(id, ""); + } this.currentConstantCallbacks = this.debug === null ? (this.constantCallbacks.get(fn.name) ?? new Map()) : new Map(); // Preserve concrete source bindings for debugger inspection. Suspended // functions keep the established array lifetime across continuations. this.streamingSplitsEnabled = this.debug === null && !fn.async && !fn.generator; this.stackCallbacks.reset(this.streamingSplitsEnabled); - this.stackCaptures.reset(fn, this.streamingSplitsEnabled); + this.stackCaptures.reset(fn, this.streamingSplitsEnabled, lazyParameters); this.privateSplitLocals = this.streamingSplitsEnabled ? findPrivateSplitLocals(fn) : new Map(); this.storedSplits.clear(); this.splitSpans.clear(); @@ -4059,7 +4104,7 @@ export class LlEmitter { B.line(`${value} = load double, ptr @${mangleGlobal(global.id)}`); this.storeIntegerView(global.id, { name: value, type: global.type }); } - for (const local of fn.locals) { + for (const local of this.currentLocals.values()) { // Boxed locals' slots hold their capture BOX (a ptr); captured // (env-borrowed) locals bind the incoming box below. A caught-typed // local is a catch binding: its slot holds the ScrCaught snapshot @@ -4115,6 +4160,20 @@ export class LlEmitter { // params (callees own their params — callers passed +1). Boxed params // allocate the shared binding and move the raw value in. const fnScope: LlScopeEntry[] = []; + for (const id of this.lazyCaptureBoxes.keys()) { + const cache = B.slot(); + B.entryAllocas.push(`${cache} = alloca ptr ; lazy box ${this.currentLocals.get(id)!.name}`); + B.line(`store ptr null, ptr ${cache}`); + fnScope.push({ slot: cache, type: this.currentLocals.get(id)!.type, boxed: true }); + this.lazyCaptureBoxes.set(id, cache); + // Environments that finish during the call can share an immortal + // frame box. Its payload owner is released with the function scope. + const stack = this.stackCaptures.emit(this, id, this.currentLocals.get(id)!.type); + if (stack) { + this.lazyStackBoxes.set(id, stack.box); + if (stack.owner) fnScope.push(stack.owner); + } + } const borrowed = this.callLifetimes.borrowed.get(fn.name); for (const [index, p] of fn.params.entries()) { const local = this.currentLocals.get(p.localId)!; @@ -5792,6 +5851,8 @@ export class LlEmitter { if (result.owner) this.ownSlot(result.owner.slot, result.owner.type); return result.value; } + const arrayRead = this.optionalArrayReads.get(e); + if (arrayRead) return emitProjectedArrayRead(this, arrayRead); if (canStackUnion(e, this.unionsById)) return emitStackUnion(this, e).value; if (e.kind === "unionNarrow") { const union = this.emitReadReceiver(e.value); @@ -5836,6 +5897,13 @@ export class LlEmitter { return this.emitExpr(e); } + /** A union operand that emitReadReceiver materializes in a private stack + * box with an independently owned payload. Later operands cannot + * invalidate it, so a projection may use it regardless of their effects. */ + canStackReceiver(e: IrExpr): boolean { + return this.optionalArrayReads.get(e) !== null || canStackUnion(e, this.unionsById); + } + canBorrowReceiver(e: IrExpr): boolean { switch (e.kind) { case "varRef": { diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 811f5e8e9..3d4094630 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -16,7 +16,6 @@ import { mangleClassStruct, mangleFnClosure, mangleFunction, - mangleLocal, mangleWrapper, mangleVtStruct, } from "../mangle.js"; @@ -586,7 +585,7 @@ export function emitCallExpr( B.line(`store i32 ${functionKind}, ptr ${kindPtr}`); } e.captures.forEach((localId, i) => { - const box = host.loadBox(`%${mangleLocal(localId)}`); + const box = host.captureBox(localId); const retained = host.retainBox(box); const caps = B.tmp(); const capp = B.tmp(); @@ -742,7 +741,7 @@ export function emitCallExpr( `${value} = call ptr @scr_classobj_new(ptr @${sym}, ${host.sizeType} ${e.captures.length})`, ); e.captures.forEach((id, index) => { - const box = host.loadBox(`%${mangleLocal(id)}`); + const box = host.captureBox(id); const owned = host.retainBox(box); const caps = B.tmp(); const slot = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/expr-control.ts b/packages/compiler/src/backend/llvm/expr-control.ts index db31cccfa..de0922f4b 100644 --- a/packages/compiler/src/backend/llvm/expr-control.ts +++ b/packages/compiler/src/backend/llvm/expr-control.ts @@ -279,7 +279,12 @@ export function emitControlExpr( throw new InternalCompilerError("llvm emitter bug: optChain union arms"); const multiple = def.arms.length - unitTags.length > 1; const narrowed = multiple ? e.receiver.type : def.arms[narrowIdx]!; - const r = host.emitExpr(e.receiver); + // A single present arm is only tested and extracted (+1) before the + // body runs, so a fresh receiver box can stay on the stack. + const r = + !multiple && host.canStackReceiver(e.receiver) + ? host.emitReadReceiver(e.receiver) + : host.emitExpr(e.receiver); const bind = B.slot(); B.entryAllocas.push(`${bind} = alloca ${host.llType(narrowed)}`); B.line( @@ -481,6 +486,8 @@ export function emitControlExpr( const unitTags = def.arms.flatMap((a, i) => (isUnitType(a) ? [i] : [])); if (unitTags.length === 0) throw new InternalCompilerError("llvm emitter bug: nullish union lacks unit arms"); + const fused = emitNullishOptionalChain(host, e); + if (fused) return fused; const l = host.emitExpr(e.left); host.moveTemp(l); const ty = host.llType(e.type); @@ -515,3 +522,62 @@ export function emitControlExpr( } } } + +/** `a?.b ?? d` narrowed to the chain body's present arm. The body wraps a + * value of the result type in a non-unit arm, so the default runs exactly + * when the receiver is nullish and the intermediate result box is never + * built. Receiver, body and default keep their evaluation order. */ +function emitNullishOptionalChain(host: LlvmEmitterContext, e: ExprOf<"nullish">): LlValue | null { + const chain = e.left; + if ( + chain.kind !== "optChain" || + chain.receiver.type.kind !== "union" || + chain.type.kind !== "union" || + chain.body.kind !== "unionWrap" || + chain.body.unionId !== chain.type.unionId || + !typeEquals(chain.body.value.type, e.type) || + isUnitType(e.type) || + e.type.kind === "void" + ) + return null; + const resultArm = host.unionsById.get(chain.type.unionId)?.arms[chain.body.tag]; + const receiverDef = host.unionsById.get(chain.receiver.type.unionId); + if (!resultArm || isUnitType(resultArm) || !typeEquals(resultArm, e.type) || !receiverDef) + return null; + const unitTags = receiverDef.arms.flatMap((a, i) => (isUnitType(a) ? [i] : [])); + const present = receiverDef.arms.flatMap((a, i) => (isUnitType(a) ? [] : [i])); + if (unitTags.length === 0 || present.length !== 1) return null; + const B = host.B; + const narrowed = receiverDef.arms[present[0]!]!; + const r = host.canStackReceiver(chain.receiver) + ? host.emitReadReceiver(chain.receiver) + : host.emitExpr(chain.receiver); + const bindTy = host.llType(narrowed); + const bind = B.slot(); + B.entryAllocas.push(`${bind} = alloca ${bindTy}`); + B.line( + `store ${bindTy} ${bindTy === "ptr" ? "null" : bindTy === "double" ? f64Lit(0) : "false"}, ptr ${bind}`, + ); + host.ownSlot(bind, narrowed); + const isUnit = host.tagInSet(r.name, unitTags); + const ty = host.llType(e.type); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty}`); + const lu = B.newLabel("nulc.u"), + lb = B.newLabel("nulc.b"), + lj = B.newLabel("nulc.j"); + B.condBr(isUnit, lu, lb); + B.startBlock(lu); + host.emitBranchInto(slot, e.right); + B.br(lj); + B.startBlock(lb); + B.line(`store ${bindTy} ${host.unionExtract(r.name, narrowed)}, ptr ${bind}`); + host.chainSlots.set(chain.id, { name: bind, type: narrowed, slot: true }); + host.emitBranchInto(slot, chain.body.value); + host.chainSlots.delete(chain.id); + B.br(lj); + B.startBlock(lj); + const t = B.tmp(); + B.line(`${t} = load ${ty}, ptr ${slot}`); + return host.own({ name: t, type: e.type }); +} diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index adb517326..03d075baa 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -1,5 +1,5 @@ import { classMembershipIntervals } from "./classes.js"; -import { emitBorrowedInput, emitBorrowedInputs } from "./borrowed-inputs.js"; +import { borrowableInputs, emitBorrowedInput, emitBorrowedInputs } from "./borrowed-inputs.js"; import { preservesDynTest } from "./checked-value-lifetimes.js"; import { typedRefConstructor } from "./shapes.js"; /* Focused LLVM expression emission extracted from emitter.ts. */ @@ -734,8 +734,9 @@ export function emitDynamicExpr( const direct = emitUnionEqWrappedScalar(host, e); if (direct) return direct; // Strict equality of the ARM values (tag compare + per-arm payload - // compare — the C per-union helper, inlined). Both boxes borrowed. - const inputs = emitBorrowedInputs(host, [e.left, e.right]); + // compare — the C per-union helper, inlined). Both boxes borrowed; + // a fresh operand box can live on the stack for the comparison. + const inputs = emitUnionEqInputs(host, e.left, e.right); const l = inputs[0]!, r = inputs[1]!; const def = host.unionsById.get(e.unionId); @@ -1044,6 +1045,19 @@ export function emitDynamicExpr( } } +/** Equality only reads both tags and payloads. Operands that would be fresh + * heap boxes use a private stack box whose payload snapshot is owned by the + * frame; other operands keep the ordinary borrowed-input rules. */ +function emitUnionEqInputs(host: LlvmEmitterContext, left: IrExpr, right: IrExpr): LlValue[] { + const inputs = [left, right]; + const borrowed = borrowableInputs(host, inputs); + return inputs.map((value, index) => + borrowed[index] || host.canStackReceiver(value) + ? host.emitReadReceiver(value) + : host.emitExpr(value), + ); +} + /** `u === v` where one side wraps a unit, bool, or number arm value * (`o.flag === true`, `o.n === undefined`): compare the other union's tag * and payload directly instead of boxing the plain side. Operands keep @@ -1074,12 +1088,14 @@ function emitUnionEqWrappedScalar( const arm = def.arms[wrap.tag]!; let union: LlValue; let plain: LlValue | null = null; + const unionInput = (value: IrExpr): LlValue => + host.canStackReceiver(value) ? host.emitReadReceiver(value) : emitBorrowedInput(host, value); if (rightWrap) { - union = emitBorrowedInput(host, e.left); + union = unionInput(e.left); if (!isUnitType(arm)) plain = host.emitExpr(wrap.value); } else { if (!isUnitType(arm)) plain = host.emitExpr(wrap.value); - union = emitBorrowedInput(host, e.right); + union = unionInput(e.right); } const tag = host.unionTag(union.name); const tagMatch = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/lazy-captures.test.ts b/packages/compiler/src/backend/llvm/lazy-captures.test.ts new file mode 100644 index 000000000..a39104dfb --- /dev/null +++ b/packages/compiler/src/backend/llvm/lazy-captures.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from "vitest"; +import { + F64, + funcOf, + type IrExpr, + type IrFunction, + type IrStmt, + type IrType, +} from "../../ir/ir.js"; +import { LazyCaptures } from "./lazy-captures.js"; + +const loc = { file: "captures.ts", start: 0, end: 0 }; +const record: IrType = { kind: "record", shapeId: "options" }; +const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: record, loc }); +const closure = (fnName: string, captures: string[]): IrStmt => ({ + kind: "exprStmt", + expr: { kind: "closure", fnName, captures, type: funcOf([], F64), loc }, + loc, +}); +const assign = (localId: string): IrStmt => ({ kind: "assign", localId, value: ref(localId), loc }); + +function outer(body: IrStmt[], extra: Partial = {}): IrFunction { + return { + name: "outer", + params: [ + { localId: "options", name: "options", type: record }, + { localId: "plain", name: "plain", type: record }, + ], + returnType: F64, + locals: [ + { id: "options", name: "options", type: record, mutable: true, boxed: true }, + { id: "plain", name: "plain", type: record, mutable: true }, + ], + body, + loc, + ...extra, + }; +} +function lifted(name: string, body: IrStmt[], captured = "options"): IrFunction { + return { + name, + params: [], + returnType: F64, + captures: [{ localId: captured, name: captured, type: record }], + locals: [{ id: captured, name: captured, type: record, mutable: true, boxed: true }], + body, + loc, + }; +} +function lazy(...functions: IrFunction[]): Set { + const map = new Map(functions.map((fn) => [fn.name, fn])); + return new LazyCaptures(map).parameters(functions[0]!); +} + +test("captured parameters that nothing rebinds are boxed lazily", () => { + const reader = lifted("reader", [{ kind: "return", value: ref("options"), loc }]); + expect(lazy(outer([closure("reader", ["options"])]), reader)).toEqual(new Set(["options"])); +}); + +test("rebinding in the body or in any nested function keeps the eager box", () => { + const reader = lifted("reader", []); + expect(lazy(outer([closure("reader", ["options"]), assign("options")]), reader).size).toBe(0); + const writer = lifted("writer", [assign("options")]); + expect(lazy(outer([closure("writer", ["options"])]), writer).size).toBe(0); + // A write two closures deep reaches the same binding. + const inner = lifted("inner", [{ kind: "return", value: ref("x"), loc }], "x"); + const middle = lifted("middle", [closure("inner", ["mid"])], "mid"); + expect(lazy(outer([closure("middle", ["options"])]), middle, inner).size).toBe(1); + const deep = lifted("inner", [assign("x")], "x"); + expect(lazy(outer([closure("middle", ["options"])]), middle, deep).size).toBe(0); +}); + +test("class environments, async bodies and unknown targets keep the eager box", () => { + const classRef: IrStmt = { + kind: "exprStmt", + expr: { + kind: "classRef", + className: "Local", + captures: ["options"], + type: { kind: "classval", className: "Local" }, + loc, + }, + loc, + }; + expect(lazy(outer([classRef])).size).toBe(0); + const reader = lifted("reader", []); + expect(lazy(outer([closure("reader", ["options"])], { async: true }), reader).size).toBe(0); + expect(lazy(outer([closure("missing", ["options"])])).size).toBe(0); +}); diff --git a/packages/compiler/src/backend/llvm/lazy-captures.ts b/packages/compiler/src/backend/llvm/lazy-captures.ts new file mode 100644 index 000000000..eb384e72d --- /dev/null +++ b/packages/compiler/src/backend/llvm/lazy-captures.ts @@ -0,0 +1,87 @@ +import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; + +/** The local a node rebinds. Writes through the bound reference, such as a + * field store or an array push, leave the binding unchanged. Catch bindings + * and rethrow targets are conservatively treated as rebinding. */ +function rebound(node: IrStmt | IrExpr): string | null { + switch (node.kind) { + case "varDecl": + case "assign": + case "forOf": + case "rethrow": + case "assignExpr": + case "incDec": + return node.localId; + case "tryCatch": + return node.catchLocalId; + default: + return null; + } +} + +/** A captured parameter that no code rebinds holds its entry value for the + * whole call, so every environment may observe a box created when the first + * closure is built instead of on function entry. Paths that never create a + * closure then allocate no box. The proof follows each capture into nested + * function bodies; class environments keep the eager box. */ +export class LazyCaptures { + private readonly unchanged = new Map(); + + constructor(private readonly functions: ReadonlyMap) {} + + parameters(fn: IrFunction): Set { + const result = new Set(); + if (fn.async || fn.generator) return result; + const locals = new Map(fn.locals.map((local) => [local.id, local])); + for (const param of fn.params) { + const local = locals.get(param.localId); + if (local?.boxed && !local.tdz) result.add(param.localId); + } + if (result.size === 0) return result; + for (const id of this.rebindings(fn, result)) result.delete(id); + return result; + } + + /** The subset of `ids` rebound in `fn` or in any function that captures + * one of them, directly or through further nesting. */ + private rebindings(fn: IrFunction, ids: ReadonlySet): Set { + const changed = new Set(); + const nested: { target: string; local: string; source: string }[] = []; + const visit = (node: IrStmt | IrExpr): boolean => { + const target = rebound(node); + if (target !== null && ids.has(target)) changed.add(target); + if (node.kind === "classRef") { + for (const id of node.captures ?? []) if (ids.has(id)) changed.add(id); + } else if (node.kind === "closure") { + const callee = this.functions.get(node.fnName); + node.captures.forEach((id, index) => { + if (!ids.has(id)) return; + const capture = callee?.captures?.[index]; + if (!capture) changed.add(id); + else nested.push({ target: node.fnName, local: capture.localId, source: id }); + }); + } + return true; + }; + everyStmtList(fn.body, { stmt: visit, expr: visit }); + for (const edge of nested) { + if (!changed.has(edge.source) && !this.isUnchanged(edge.target, edge.local)) + changed.add(edge.source); + } + return changed; + } + + private isUnchanged(fnName: string, localId: string): boolean { + const key = `${fnName}\0${localId}`; + const known = this.unchanged.get(key); + if (known !== undefined) return known; + // Capture chains follow lexical nesting and cannot cycle; the + // provisional entry only bounds work on malformed input. + this.unchanged.set(key, false); + const fn = this.functions.get(fnName); + const result = fn !== undefined && this.rebindings(fn, new Set([localId])).size === 0; + this.unchanged.set(key, result); + return result; + } +} diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index c5c634e19..432db9aba 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -255,6 +255,19 @@ export function emitCallArrayRead(host: LlvmEmitterContext, read: LocalArrayRead return { name: value, type: read.type }; } +/** A tag or payload projection consumes the box before its frame ends. The + * payload owns one reference, so later operands may mutate the array without + * invalidating the snapshot; the box itself never reaches runtime code. */ +export function emitProjectedArrayRead(host: LlvmEmitterContext, read: LocalArrayRead): LlValue { + const slot = host.B.slot(); + host.B.entryAllocas.push(`${slot} = alloca ptr`); + const owner = emitLocalArrayRead(host, { ...read, borrow: false }, slot); + if (owner) host.ownSlot(owner.slot, owner.type); + const value = host.B.tmp(); + host.B.line(`${value} = load ptr, ptr ${slot}`); + return { name: value, type: read.type }; +} + /** Share the checked dense lookup between optional stack boxes and strict * borrowed reads. Capacity, length, and presence guards precede every load; * sparse and noncanonical indices keep their runtime lookup semantics. */ diff --git a/packages/compiler/src/backend/llvm/stack-callbacks.ts b/packages/compiler/src/backend/llvm/stack-callbacks.ts index 221e06276..5c404896a 100644 --- a/packages/compiler/src/backend/llvm/stack-callbacks.ts +++ b/packages/compiler/src/backend/llvm/stack-callbacks.ts @@ -1,6 +1,5 @@ import type { IrExpr, IrFunction } from "../../ir/ir.js"; import { everyStmtList } from "../../ir/traverse.js"; -import { mangleLocal } from "../mangle.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; /** A synchronous, invocation-only consumer cannot retain the environment. @@ -39,7 +38,7 @@ export class StackCallbacks { B.line(`store i32 4, ptr ${kind}`); } value.captures.forEach((localId, index) => { - const box = host.retainBox(host.loadBox(`%${mangleLocal(localId)}`)); + const box = host.retainBox(host.captureBox(localId)); const caps = B.tmp(); const slot = B.tmp(); B.line(`${caps} = getelementptr inbounds %ScrClosure, ptr ${closure}, i32 1`); diff --git a/packages/compiler/src/backend/llvm/stack-captures.ts b/packages/compiler/src/backend/llvm/stack-captures.ts index dcf9f4f61..f0812b7b0 100644 --- a/packages/compiler/src/backend/llvm/stack-captures.ts +++ b/packages/compiler/src/backend/llvm/stack-captures.ts @@ -25,7 +25,9 @@ export class StackCaptures { private readonly callbacks: StackCallbacks, ) {} - reset(fn: IrFunction, enabled: boolean): void { + /** `parameters` are unchanged captured parameters (LazyCaptures). Their + * scope is the whole call, so they need no single declaration. */ + reset(fn: IrFunction, enabled: boolean, parameters: ReadonlySet = new Set()): void { this.locals = new Set(); this.remaining = enabled ? 2048 : 0; if (!enabled) return; @@ -34,7 +36,7 @@ export class StackCaptures { ); if (possible.size === 0) return; const invalid = new Set([ - ...fn.params.map((param) => param.localId), + ...fn.params.map((param) => param.localId).filter((id) => !parameters.has(id)), ...(fn.captures ?? []).map((capture) => capture.localId), ...(fn.classCaptures ?? []).map((capture) => capture.localId), ]); @@ -89,7 +91,8 @@ export class StackCaptures { }; if (!fn.body.every(stmt)) return; for (const id of possible) { - if (!invalid.has(id) && declarations.get(id) === 1) this.locals.add(id); + if (!invalid.has(id) && (declarations.get(id) === 1 || parameters.has(id))) + this.locals.add(id); } } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 3f5ca9eab..455da4a90 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -66,6 +66,7 @@ import { utilTypesModuleValue, } from "./lower-builtin-values.js"; import { everyExprChild, everyStmtChild, transformStmtList } from "../../ir/traverse.js"; +import { RuntimeOptionalProvenance } from "./runtime-optional-provenance.js"; import { RuntimeOptionalLocals } from "./runtime-optional-locals.js"; import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js"; import { UnregisteredClassTypes } from "./unregistered-class-types.js"; @@ -3655,6 +3656,9 @@ export class Lowerer { * but it is not a runtime proof; promoting only the affected slots keeps * the rest of the dense ABI unchanged. */ analyzeRuntimeOptionalArrayReads(parts: FileParts[]): void { + const provenance = RuntimeOptionalProvenance.fromEnvironment(); + // The site whose value the current promotion follows (debug only). + let provenanceSite: ts.Node | undefined; const optionalSymbols = new Set(); const optionalReturns = new Set(); const arithmeticReturns = new Map(); @@ -3957,6 +3961,7 @@ export class Lowerer { const symbol = symbolOf(el.name); if (symbol && !el.initializer && !optionalSymbols.has(symbol)) { optionalSymbols.add(symbol); + provenance?.note("pattern-binding", el.name, init); // Indexed flow facts can survive a mutating method call in the // checker. The array's element ABI describes every value a // position may hold after that call, so bind from that type. @@ -4103,6 +4108,7 @@ export class Lowerer { const widened = this.runtimeOptionalType(current); const previous = this.runtimeOptionalBindingTypes.get(symbol); this.runtimeOptionalBindingTypes.set(symbol, widened); + provenance?.note("callback-parameter", parameter.name, provenanceSite); if (!previous || !typeEquals(previous, widened)) changed = true; if (!optionalSymbols.has(symbol)) { optionalSymbols.add(symbol); @@ -4113,6 +4119,7 @@ export class Lowerer { const fnType = this.mapTypeOf(this.typeOf(fn)); if (fnType?.kind === "func" && bodyReturnsOptional(fn)) { const widenedReturn = this.runtimeOptionalType(fnType.ret); + provenance?.note("callback-return", fn, provenanceSite); const previousReturn = this.runtimeOptionalFunctionReturnType(fn, fnType.ret); this.runtimeOptionalFunctionReturns.set(fn, widenedReturn); if (!typeEquals(previousReturn, widenedReturn)) changed = true; @@ -4262,6 +4269,7 @@ export class Lowerer { const symbol = symbolOf(node.name); if (symbol && !optionalSymbols.has(symbol)) { optionalSymbols.add(symbol); + provenance?.note("binding", node.name, node.initializer); changed = true; } } @@ -4284,6 +4292,7 @@ export class Lowerer { if (symbol && valueSymbol && sourceOptional) { const aliasNew = !optionalReturns.has(symbol); if (aliasNew) optionalReturns.add(symbol); + if (aliasNew) provenance?.note("function-alias", node.name, node.initializer); if (aliasNew) changed = true; const valueType = this.mapTypeOf(this.typeOf(node.name)); if (valueType?.kind === "func" && sourceSig) { @@ -4320,7 +4329,10 @@ export class Lowerer { const value = ts.isPropertyAssignment(prop) ? prop.initializer : (prop.name as ts.Expression); - if (mayBeOptional(value) && noteField(node, name.text)) changed = true; + if (mayBeOptional(value) && noteField(node, name.text)) { + provenance?.note("literal-field", name, value); + changed = true; + } } } } @@ -4337,16 +4349,21 @@ export class Lowerer { const symbol = symbolOf(node.left); if (symbol && !optionalSymbols.has(symbol)) { optionalSymbols.add(symbol); + provenance?.note("assigned-binding", node.left, node.right); changed = true; } } else if (ts.isPropertyAccessExpression(node.left)) { const field = symbolOf(node.left.name); if (field && staticFieldsBySymbol.has(field) && !optionalSymbols.has(field)) { optionalSymbols.add(field); + provenance?.note("static-field", node.left, node.right); changed = true; } else if (ts.isIdentifier(node.left.expression)) { const symbol = symbolOf(node.left.expression); - if (symbol && noteFieldSymbol(symbol, node.left.name.text)) changed = true; + if (symbol && noteFieldSymbol(symbol, node.left.name.text)) { + provenance?.note("assigned-field", node.left, node.right); + changed = true; + } } } } @@ -4398,6 +4415,7 @@ export class Lowerer { } } if (nativeArrayReceiver) { + provenanceSite = node; if (promoteHofCallback(callback, callbackIndices)) changed = true; const method = node.expression.name.text; if ( @@ -4449,6 +4467,7 @@ export class Lowerer { if (typeEquals(shape.type, widened)) continue; shape.type = widened; const parameter = functionDeclBySymbol.get(target)?.parameters[i]; + provenance?.note("parameter", parameter, node, "(omitted or undefined argument)"); if (parameter) { for (const bound of boundIdentifiersOf(parameter.name)) { const boundSymbol = symbolOf(bound); @@ -4478,6 +4497,7 @@ export class Lowerer { const optionalCallbackParams = callbackSlot.params.flatMap((type, index) => type.kind === "union" && this.armTag(type.unionId, UNDEFINED_T) >= 0 ? [index] : [], ); + provenanceSite = node; if ( optionalCallbackParams.length > 0 && promoteHofCallback(arg, optionalCallbackParams) @@ -4497,6 +4517,7 @@ export class Lowerer { if (typeEquals(rest.type, widened)) continue; rest.type = widened; const parameter = functionDeclBySymbol.get(target)?.parameters[i]; + provenance?.note("rest-parameter", parameter, arg); if (parameter && ts.isIdentifier(parameter.name)) { const bound = symbolOf(parameter.name); if (bound) this.runtimeOptionalBindingTypes.set(bound, widened); @@ -4522,6 +4543,12 @@ export class Lowerer { if (set.size !== before) { changed = true; const parameter = functionDeclBySymbol.get(target)?.parameters[i]; + provenance?.note( + "parameter", + parameter, + arg, + target === symbol ? "" : "(override)", + ); if (parameter) { for (const bound of boundIdentifiersOf(parameter.name)) { const boundSymbol = symbolOf(bound); @@ -4544,6 +4571,7 @@ export class Lowerer { for (const expression of returnsOf(decl)) { if (mayBeOptional(expression) && !optionalReturns.has(symbol)) { optionalReturns.add(symbol); + provenance?.note("return", decl.name ?? decl, expression); changed = true; } const arithmetic = optionalPrimitiveResultType(expression); @@ -4560,6 +4588,7 @@ export class Lowerer { for (const [symbol, field] of staticFieldsBySymbol) { if (!optionalSymbols.has(symbol) && mayBeOptional(field.initializer)) { optionalSymbols.add(symbol); + provenance?.note("static-field", field.initializer.parent, field.initializer); changed = true; } } @@ -4588,6 +4617,12 @@ export class Lowerer { for (const symbol of family) { if (!optionalReturns.has(symbol)) { optionalReturns.add(symbol); + provenance?.note( + "return", + functionDeclBySymbol.get(symbol), + undefined, + "(override family)", + ); changed = true; } } @@ -4731,6 +4766,8 @@ export class Lowerer { } }); } + + provenance?.flush(); } run(): LowerResult { diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-provenance.ts b/packages/compiler/src/frontend/lowering/runtime-optional-provenance.ts new file mode 100644 index 000000000..bfac8ce0d --- /dev/null +++ b/packages/compiler/src/frontend/lowering/runtime-optional-provenance.ts @@ -0,0 +1,41 @@ +import * as ts from "../ts7/adapter.js"; + +/** Debug-only record of why the indexed-read analysis widened a binding, + * parameter, return, or field to a runtime-optional representation. It is + * enabled by SCRIPTC_DEBUG_RUNTIME_OPTIONAL=1 and writes one line per + * widening to stderr; the disabled path records nothing. Not a public API: + * the output format may change at any time. */ +export class RuntimeOptionalProvenance { + private readonly lines: string[] = []; + private readonly seen = new Set(); + + static fromEnvironment(): RuntimeOptionalProvenance | null { + return process.env["SCRIPTC_DEBUG_RUNTIME_OPTIONAL"] === "1" + ? new RuntimeOptionalProvenance() + : null; + } + + /** `target` names the widened slot; `cause` is the expression or site + * whose value made it optional. */ + note(kind: string, target: ts.Node | undefined, cause: ts.Node | undefined, detail = ""): void { + const line = `${kind} ${describe(target)}${detail ? ` ${detail}` : ""} <- ${describe(cause)}`; + if (this.seen.has(line)) return; + this.seen.add(line); + this.lines.push(line); + } + + flush(): void { + for (const line of this.lines) process.stderr.write(`scriptc runtime-optional ${line}\n`); + this.lines.length = 0; + } +} + +function describe(node: ts.Node | undefined): string { + if (!node) return "?"; + const sf = node.getSourceFile(); + const start = node.getStart(); + const pos = ts.getLineAndCharacterOfPosition(sf, start); + const text = sf.text.slice(start, node.end).replace(/\s+/g, " "); + const short = text.length > 60 ? `${text.slice(0, 57)}...` : text; + return `${sf.fileName}:${pos.line + 1}:${pos.character + 1} \`${short}\``; +} diff --git a/packages/compiler/test/call-lifetimes.test.ts b/packages/compiler/test/call-lifetimes.test.ts index bd6833160..af86a0583 100644 --- a/packages/compiler/test/call-lifetimes.test.ts +++ b/packages/compiler/test/call-lifetimes.test.ts @@ -315,3 +315,37 @@ console.log(alias(new Item()).value, snapshot(new Item()).value); expect(snapshot.match(/@sc_retain_Item/g)).toHaveLength(3); expect(snapshot).toContain("@sc_release_Item"); }); + +test("unchanged captured parameters are boxed when a closure first needs them", async () => { + const mod = await lower(` +function invoke(fn: (value: number) => number, value: number): number { return fn(value); } +function framed(seed: number, label: string): number { + return invoke((value) => value + seed + label.length, 3); +} +function deferred(options: { limit: number }, skip: boolean): () => number { + if (skip) return () => -1; + return () => options.limit; +} +function rebound(count: number): () => number { + const read = () => count; + count++; + return read; +} +console.log(framed(1, "ab"), deferred({ limit: 4 }, false)(), deferred({ limit: 4 }, true)(), rebound(5)()); +`); + const llvm = emitLlvmModule(mod); + // The entry block runs on every call; the lazy box is created later. + const entry = (text: string): string => { + const labels = [...text.matchAll(/^[\w.]+:$/gm)]; + return labels.length > 1 ? text.slice(0, labels[1]!.index) : text; + }; + const framed = body(llvm, "sc_f_framed"); + expect(framed).toContain("alloca %ScrBox"); + expect(framed).not.toContain("@scr_box_new"); + expect(framed).not.toContain("@scr_closure_new"); + const deferred = body(llvm, "sc_f_deferred"); + expect(deferred).toContain("lazy.box"); + expect(entry(deferred)).not.toContain("@scr_box_new"); + expect(deferred).toContain("@scr_box_new"); + expect(entry(body(llvm, "sc_f_rebound"))).toContain("@scr_box_new"); +}); diff --git a/packages/compiler/test/string-and-array-lifetimes.test.ts b/packages/compiler/test/string-and-array-lifetimes.test.ts index dd1dc0d66..92902341f 100644 --- a/packages/compiler/test/string-and-array-lifetimes.test.ts +++ b/packages/compiler/test/string-and-array-lifetimes.test.ts @@ -247,15 +247,57 @@ console.log(work(["first"])); expect(work).not.toContain("@scr_union_release"); }); -test("whole-union string comparisons keep their established owned representation", async () => { +test("whole-union string comparisons only project their operands", async () => { const module = await lower(` function size(text: string): number { return text.length + (text === "" ? 1 : 0); } function work(words: string[], index: number): number { return size(words[index]); } console.log(work([""], 0)); `); const facts = analyzeCallLifetimes(new Map(module.functions.map((fn) => [fn.name, fn]))); - expect(facts.parameters.has("size")).toBe(false); + expect(facts.parameters.get("size")?.has(0)).toBe(true); const work = body(emitLlvmModule(module), "sc_bf_work"); - expect(work).not.toContain("alloca %ScrUnion"); + expect(work).toContain("alloca %ScrUnion"); + expect(work).not.toContain("@scr_union_new"); expect(work).toContain("@sc_bf_size"); }); + +test("class element comparisons, tests and optional chains read through stack tags", async () => { + const module = await lower(` +class Part { weight = 1; } +function same(parts: Part[], index: number, probe: Part): boolean { return parts[index] === probe; } +function missing(parts: Part[], index: number): boolean { return parts[index] === undefined; } +function weight(parts: Part[], index: number): number { return parts[index]?.weight ?? 0; } +function count(parts: Part[], probe: Part): number { + let hits = 0; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part === probe) hits++; + } + return hits; +} +const parts = [new Part()]; +console.log(same(parts, 0, parts[0]), missing(parts, 3), weight(parts, 2), count(parts, parts[0])); +`); + const llvm = emitLlvmModule(module); + for (const name of ["same", "missing", "weight", "count"]) { + const work = body(llvm, `sc_bf_${name}`); + expect(work, name).toContain("alloca %ScrUnion"); + expect(work, name).not.toContain("@scr_union_new"); + expect(work, name).not.toMatch(/call ptr @sc_bf__x25_arr_idxOr/); + } +}); + +test("an element read stored beyond its comparison keeps a heap union", async () => { + const module = await lower(` +class Part { weight = 1; } +const kept: (Part | undefined)[] = []; +function keep(parts: Part[], index: number, probe: Part): boolean { + const part = parts[index]; + kept.push(part); + return part === probe; +} +console.log(keep([new Part()], 4, new Part()), kept.length); +`); + const work = body(emitLlvmModule(module), "sc_bf_keep"); + expect(work).toMatch(/@scr_union_new|@sc_bf__x25_arr_idxOr/); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 832626afc..35bca66db 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11670,7 +11670,7 @@ "diags": [ { "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cjs-require-cycle/a.js \u2192 /tests/diagnostics/cjs-require-cycle/b.js \u2192 /tests/diagnostics/cjs-require-cycle/a.js; the cycle closes through a require() edge) are not supported yet", + "message": "circular imports (/tests/diagnostics/cjs-require-cycle/a.js → /tests/diagnostics/cjs-require-cycle/b.js → /tests/diagnostics/cjs-require-cycle/a.js; the cycle closes through a require() edge) are not supported yet", "loc": { "file": "/tests/diagnostics/cjs-require-cycle/a.js", "start": 0, @@ -11707,7 +11707,7 @@ "diags": [ { "code": "SC1013", - "message": "require() below code that can reach its binding 'greet' (Node initializes the binding AT the require and throws ReferenceError on any earlier access, which is not modeled \u2014 move the require above the code that can run first) is not supported yet", + "message": "require() below code that can reach its binding 'greet' (Node initializes the binding AT the require and throws ReferenceError on any earlier access, which is not modeled — move the require above the code that can run first) is not supported yet", "loc": { "file": "/tests/diagnostics/cjs-require-tdz/main.js", "start": 336, @@ -12356,7 +12356,7 @@ "diags": [ { "code": "SC1013", - "message": "calls through a source binding named 'require' (this spelling is reserved for CommonJS/createRequire module edges \u2014 rename the binding) is not supported yet", + "message": "calls through a source binding named 'require' (this spelling is reserved for CommonJS/createRequire module edges — rename the binding) is not supported yet", "loc": { "file": "/tests/diagnostics/local-require-binding-esm.js", "start": 80, @@ -12476,7 +12476,7 @@ }, { "code": "SC1010", - "message": "the 'node:v8' module (it observes V8 engine internals \u2014 heap statistics and snapshots, GC and CPU profiles, serialize/deserialize \u2014 and a compiled binary embeds no V8 engine to observe) is not supported yet", + "message": "the 'node:v8' module (it observes V8 engine internals — heap statistics and snapshots, GC and CPU profiles, serialize/deserialize — and a compiled binary embeds no V8 engine to observe) is not supported yet", "loc": { "file": "/tests/diagnostics/module-out-of-scope.ts", "start": 347, @@ -12520,7 +12520,7 @@ }, { "code": "SC1010", - "message": "the 'node:inspector' module (it drives the V8 inspector protocol \u2014 debugger, profiler, heap access \u2014 and a compiled binary embeds no V8 engine to inspect) is not supported yet", + "message": "the 'node:inspector' module (it drives the V8 inspector protocol — debugger, profiler, heap access — and a compiled binary embeds no V8 engine to inspect) is not supported yet", "loc": { "file": "/tests/diagnostics/module-out-of-scope.ts", "start": 512, @@ -12647,7 +12647,7 @@ "diags": [ { "code": "SC1010", - "message": "the '#types' import (it resolves only to type declarations \u2014 there is no runtime implementation to compile) is not supported yet", + "message": "the '#types' import (it resolves only to type declarations — there is no runtime implementation to compile) is not supported yet", "loc": { "file": "/tests/diagnostics/pkg-import-fences/main.ts", "start": 449, @@ -12658,7 +12658,7 @@ }, { "code": "SC1010", - "message": "the 'ambientonly' module (it exists only as an ambient 'declare module' type surface \u2014 there is no runtime module to import) is not supported yet", + "message": "the 'ambientonly' module (it exists only as an ambient 'declare module' type surface — there is no runtime module to import) is not supported yet", "loc": { "file": "/tests/diagnostics/pkg-import-fences/main.ts", "start": 383, @@ -12669,7 +12669,7 @@ }, { "code": "SC1010", - "message": "the 'patternXmod' module (it exists only as an ambient 'declare module' type surface \u2014 there is no runtime module to import) is not supported yet", + "message": "the 'patternXmod' module (it exists only as an ambient 'declare module' type surface — there is no runtime module to import) is not supported yet", "loc": { "file": "/tests/diagnostics/pkg-import-fences/main.ts", "start": 416, @@ -12813,7 +12813,7 @@ "diags": [ { "code": "SC1013", - "message": "require() in an ES module (Node throws ReferenceError \u2014 use import) is not supported yet", + "message": "require() in an ES module (Node throws ReferenceError — use import) is not supported yet", "loc": { "file": "/tests/diagnostics/top-level-await-implicit-module-nested-require.js", "start": 114, @@ -13434,7 +13434,7 @@ "diags": [ { "code": "SC1010", - "message": "the 'v8' module (it observes V8 engine internals \u2014 heap statistics and snapshots, GC and CPU profiles, serialize/deserialize \u2014 and a compiled binary embeds no V8 engine to observe) is not supported yet", + "message": "the 'v8' module (it observes V8 engine internals — heap statistics and snapshots, GC and CPU profiles, serialize/deserialize — and a compiled binary embeds no V8 engine to observe) is not supported yet", "loc": { "file": "/tests/fixtures/node-types/import-fences.ts", "start": 422, @@ -13867,7 +13867,7 @@ }, "/tests/corpus/node24-esm-metadata/main.mjs": { "order": [ - "/tests/corpus/node24-esm-metadata/child with \u00fc.mjs", + "/tests/corpus/node24-esm-metadata/child with ü.mjs", "/tests/corpus/node24-esm-metadata/main.mjs" ], "diags": [] @@ -14863,7 +14863,7 @@ "diags": [ { "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cycle-default-export/a.ts \u2192 /tests/diagnostics/cycle-default-export/b.ts \u2192 /tests/diagnostics/cycle-default-export/a.ts; the default export expression at /tests/diagnostics/cycle-default-export/a.ts:2 has no initialization state inside the cycle) are not supported yet", + "message": "circular imports (/tests/diagnostics/cycle-default-export/a.ts → /tests/diagnostics/cycle-default-export/b.ts → /tests/diagnostics/cycle-default-export/a.ts; the default export expression at /tests/diagnostics/cycle-default-export/a.ts:2 has no initialization state inside the cycle) are not supported yet", "loc": { "file": "/tests/diagnostics/cycle-default-export/a.ts", "start": 0, @@ -14883,7 +14883,7 @@ "diags": [ { "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cycle-namespace-import/a.ts \u2192 /tests/diagnostics/cycle-namespace-import/b.ts \u2192 /tests/diagnostics/cycle-namespace-import/a.ts; the namespace import 'helpers' at /tests/diagnostics/cycle-namespace-import/a.ts:1 names a module of the cycle) are not supported yet", + "message": "circular imports (/tests/diagnostics/cycle-namespace-import/a.ts → /tests/diagnostics/cycle-namespace-import/b.ts → /tests/diagnostics/cycle-namespace-import/a.ts; the namespace import 'helpers' at /tests/diagnostics/cycle-namespace-import/a.ts:1 names a module of the cycle) are not supported yet", "loc": { "file": "/tests/diagnostics/cycle-namespace-import/a.ts", "start": 0, @@ -14907,6 +14907,24 @@ "/tests/corpus/cycle-generations-long-lived.ts" ], "diags": [] + }, + "/tests/corpus/4045-optional-element-projections.ts": { + "order": [ + "/tests/corpus/4045-optional-element-projections.ts" + ], + "diags": [] + }, + "/tests/corpus/4046-captured-parameter-values.ts": { + "order": [ + "/tests/corpus/4046-captured-parameter-values.ts" + ], + "diags": [] + }, + "/tests/corpus/4047-captured-parameter-rebinding.ts": { + "order": [ + "/tests/corpus/4047-captured-parameter-rebinding.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/4045-optional-element-projections.ts b/tests/corpus/4045-optional-element-projections.ts new file mode 100644 index 000000000..22740d755 --- /dev/null +++ b/tests/corpus/4045-optional-element-projections.ts @@ -0,0 +1,100 @@ +// Element reads of class arrays compared, tested, and chained directly. +// Holes, explicit undefined, out-of-range indices, operand order, and +// mutation during later operands keep JavaScript's results. +class Part { + readonly label: string; + weight: number; + constructor(label: string, weight: number) { + this.label = label; + this.weight = weight; + } + heavier(limit: number): boolean { + return this.weight > limit; + } +} + +const bolt = new Part("bolt", 2); +const gear = new Part("gear", 7); +const nut = new Part("nut", 1); +const parts: Part[] = [bolt, gear, nut]; +const order = [2, 0, 1, 5, -1, 1.5]; + +function positions(list: Part[], probe: Part): string { + const found: string[] = []; + for (let j = 0; j < order.length; j++) { + if (list[order[j]] === probe) found.push(`same@${j}`); + if (list[order[j]] !== probe) found.push(`diff@${j}`); + if (list[order[j]] === undefined) found.push(`missing@${j}`); + } + return found.join(" "); +} +console.log(positions(parts, gear)); + +function weights(list: Part[]): string { + const out: string[] = []; + for (let j = 0; j < order.length; j++) { + out.push(String(list[order[j]]?.weight ?? "none")); + out.push(list[order[j]]?.label ?? "-"); + } + return out.join(","); +} +console.log(weights(parts)); + +function looped(list: Part[], probe: Part): number { + let hits = 0; + for (let i = 0; i < list.length; i++) { + const item = list[i]; + if (item === probe) hits += 10; + if (item !== undefined && item.heavier(1)) hits++; + if (!item) hits += 100; + } + return hits; +} +console.log(looped(parts, bolt)); + +// Holes and explicit undefined values stored behind a bare element type. +const sparse: Part[] = [bolt]; +sparse[3] = nut; +const unknownSlot: Part[] = []; +sparse[2] = unknownSlot[9]; +console.log(sparse.length, looped(sparse, nut), 1 in sparse, 2 in sparse); +console.log(sparse[1] === sparse[2], sparse[1] === undefined, sparse[2] === undefined); +console.log(sparse[1]?.label ?? "hole", sparse[2]?.label ?? "explicit"); + +// The left operand is read before the right operand mutates the array. +const queue: Part[] = [bolt, gear]; +console.log(queue[0] === queue.shift(), queue.length); +console.log(queue[0] === (queue[0] = nut), queue[0].label); +let reads = 0; +function at(list: Part[], index: number): number { + reads++; + return index; +} +console.log(parts[at(parts, 1)] === parts[at(parts, 1)], reads); +console.log(parts[at(parts, 9)] === parts[at(parts, 8)], reads); + +// Reads inside callbacks and nested functions use the same rules. +const probes = [bolt, nut, new Part("bolt", 2)]; +const matches = probes.map((probe) => { + let count = 0; + for (let i = 0; i <= parts.length; i++) { + const item = parts[i]; + if (item === probe) count++; + count += item?.weight ?? 0; + } + return count; +}); +console.log(matches.join(" ")); + +// Failed member reads still throw Node's TypeError. +try { + const shelf: Part[] = [bolt]; + console.log(shelf[order[3]].heavier(0)); +} catch (error) { + console.log((error as Error).name, (error as Error).message); +} + +// Identity survives repeated reads, and a mutated element is observed. +const first = parts[0]; +parts[0].weight = 11; +console.log(first === parts[0], first.weight, parts[0] === bolt, parts.indexOf(bolt)); diff --git a/tests/corpus/4046-captured-parameter-values.ts b/tests/corpus/4046-captured-parameter-values.ts new file mode 100644 index 000000000..6d6afe997 --- /dev/null +++ b/tests/corpus/4046-captured-parameter-values.ts @@ -0,0 +1,97 @@ +// Parameters captured by nested functions but never rebound. Closures see +// the entry value and later changes to the referenced object, on early +// returns, escaping callbacks, loops, nesting, and exceptions. +interface Filter { + key: number; + skip: boolean; + label: string; +} +const values = [3, 1, 4, 1, 5, 9, 2, 6]; + +function matching(filter: Filter): number { + if (filter.skip) return -1; + return values.filter((value) => value % 3 === filter.key).length; +} +console.log(matching({ key: 1, skip: false, label: "a" }), matching({ key: 0, skip: true, label: "b" })); + +function makeReaders(filter: Filter, scale: number, tag: string, enabled: boolean): (() => string)[] { + const readers: (() => string)[] = []; + if (!enabled) return readers; + for (let i = 0; i < 3; i++) { + readers.push(() => `${tag}:${filter.label}:${filter.key * scale + i}`); + } + readers.push(() => (enabled ? tag.toUpperCase() : "off")); + return readers; +} +const shared: Filter = { key: 2, skip: false, label: "first" }; +const readers = makeReaders(shared, 10, "r", true); +shared.label = "renamed"; +shared.key = 5; +console.log(readers.map((read) => read()).join(" "), makeReaders(shared, 1, "x", false).length); + +function nested(base: number, word: string): () => () => string { + return () => { + const inner = () => `${word}${base}`; + return inner; + }; +} +const outer = nested(7, "n"); +console.log(outer()(), outer()(), nested(1, "m")()()); + +function guarded(limit: number, items: number[]): number { + if (items.length === 0) throw new Error(`empty for ${limit}`); + const picked = items.filter((item) => item < limit); + if (picked.length === 0) throw new Error(`none below ${limit}`); + return picked.reduce((sum, item) => sum + item + limit, 0); +} +for (const input of [[], [8, 9], [1, 2, 9]]) { + try { + console.log(guarded(5, input)); + } catch (error) { + console.log("caught", (error as Error).message); + } +} + +function throwsInside(code: string): string { + try { + [1, 2].forEach((n) => { + if (n === 2) throw new Error(`${code}-${n}`); + }); + return "unreachable"; + } catch (error) { + return `${(error as Error).message} ${code}`; + } +} +console.log(throwsInside("q")); + +// Recursive and repeated creation keeps each call's own value. +function countdown(n: number): string[] { + if (n === 0) return []; + const here = () => `step${n}`; + return [here(), ...countdown(n - 1), here()]; +} +console.log(countdown(3).join(",")); + +class Counter { + total = 0; + addAll(amount: number, times: number): number { + const add = () => { + this.total += amount; + }; + for (let i = 0; i < times; i++) add(); + return this.total; + } +} +const counter = new Counter(); +console.log(counter.addAll(2, 3), counter.addAll(5, 1)); + +function delayed(message: string, order: string[]): Promise { + return new Promise((resolve) => { + setTimeout(() => { + order.push(message); + resolve(); + }, 0); + }); +} +const order: string[] = []; +Promise.all([delayed("later", order), delayed("last", order)]).then(() => console.log(order.join(" "))); diff --git a/tests/corpus/4047-captured-parameter-rebinding.ts b/tests/corpus/4047-captured-parameter-rebinding.ts new file mode 100644 index 000000000..529381dc2 --- /dev/null +++ b/tests/corpus/4047-captured-parameter-rebinding.ts @@ -0,0 +1,81 @@ +// Captured parameters that are rebound anywhere keep one shared binding: +// the body, nested closures, compound and destructuring assignments, and +// loop headers all update the value every closure observes. +function reassignedAfter(count: number): () => number { + const read = () => count; + count = count * 2; + return read; +} +console.log(reassignedAfter(4)()); + +function reassignedInside(start: number): [() => number, () => void] { + return [() => start, () => { + start += 10; + }]; +} +const [get, bump] = reassignedInside(1); +bump(); +bump(); +console.log(get()); + +function deeplyReassigned(name: string): () => string { + const outer = () => { + const inner = () => { + name = name + "!"; + }; + inner(); + return name; + }; + outer(); + return () => name; +} +console.log(deeplyReassigned("hey")()); + +function compound(total: number, flag: boolean, text: string): () => string { + const show = () => `${total}/${flag}/${text}`; + total++; + total -= 3; + flag = !flag; + text ??= "unused"; + text += "+"; + return show; +} +console.log(compound(10, false, "t")()); + +function destructured(left: number, right: number): () => string { + const show = () => `${left},${right}`; + [left, right] = [right, left]; + ({ value: left } = { value: left * 100 }); + return show; +} +console.log(destructured(1, 2)()); + +function loopTarget(item: string, list: string[]): () => string { + const show = () => item; + for (item of list) { + if (item === "stop") break; + } + return show; +} +console.log(loopTarget("none", ["a", "stop", "b"])()); + +function conditional(level: number, raise: boolean): (() => number)[] { + const before = () => level; + if (raise) level = level + 1; + const after = () => level * 10; + return [before, after]; +} +console.log(conditional(1, true).map((f) => f()), conditional(1, false).map((f) => f())); + +function defaulted(size = 3, unit = "cm"): () => string { + return () => `${size}${unit}`; +} +console.log(defaulted()(), defaulted(5)(), defaulted(undefined, "mm")()); + +function earlyAndLate(value: number, early: boolean): () => number { + if (early) return () => value; + const read = () => value; + value = -value; + return read; +} +console.log(earlyAndLate(3, true)(), earlyAndLate(3, false)()); From 93482f22a83b77addd40d176d7c1d2c7f20b209b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 13:28:14 -0500 Subject: [PATCH 12/14] Keep the cycle collector small in static executables (#768) --- packages/runtime/src/scr_cycle.c | 175 +++++++++++++++++-------------- 1 file changed, 99 insertions(+), 76 deletions(-) diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 7bf5d1a71..fb3d40d51 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -255,6 +255,16 @@ static size_t scr_cyc_pass(unsigned gen_limit); /* ── when to collect ──────────────────────────────────────────────────── */ #define SCR_CYC_NURSERY_CANDIDATES 256 /* nursery trigger */ + +/* Scheduling decisions run once per pass, never per object, so they are + * kept out of line and built for size: every executable that can release a + * cycle-capable value links them, including ones that never form a cycle. */ +#define SCR_CYC_SCHEDULING __attribute__((cold, noinline, minsize)) + +/* Per-pass and cross-generation bookkeeping: work that is proportional to + * candidates, dead objects or skipped edges and dominated by the calls it + * makes, never the per-edge graph walk. Built for size for the same reason. */ +#define SCR_CYC_COMPACT __attribute__((noinline, minsize)) #define SCR_CYC_GROWTH_DIV 4 /* growth pass per +1/4 of live heap */ #define SCR_CYC_GROWTH_FLOOR 4096 /* ...but not below this many objects */ @@ -265,7 +275,7 @@ static SCR_TL size_t scr_cyc_live_after_mature = 0; /* Doublings of the full-pass growth fraction earned by consecutive * growth-triggered full passes that reclaimed little of the old generation - * (see scr_cyc_full_pass). Capped at a fraction of 1/2: the heap may grow + * (see scr_cyc_scheduled_pass). Capped at a fraction of 1/2: the heap may grow * at most by half past its size after the last full pass before the next. * The streak counts those unproductive passes since the last productive one; * the fraction only grows once two have run in a row. */ @@ -356,36 +366,38 @@ static size_t scr_cyc_backlog_threshold(unsigned gen) { return t > (SIZE_MAX >> shift) ? SIZE_MAX : t << shift; } -/* A scheduled pass at the mature or old level, adapting that level's backlog - * threshold to its yield. The explicit sweep (scr_collect_cycles) is not a - * scheduling decision and leaves the thresholds alone. Productive means at - * least one object freed per GROWTH_DIV candidates; a pass with no - * candidates says nothing. */ -static void scr_cyc_level_pass(unsigned gen) { - size_t freed = scr_cyc_pass(gen); - if (scr_cands.n == 0) return; - if (freed * SCR_CYC_GROWTH_DIV >= scr_cands.n) - scr_cyc_backlog_shift[gen] = 0; - else if (scr_cyc_backlog_shift[gen] < SCR_CYC_BACKLOG_MAX_SHIFT) - scr_cyc_backlog_shift[gen]++; -} - -/* A scheduled full pass. When heap growth triggered it, also adapt the - * growth fraction to what it reclaimed from the OLD generation — the one - * part of its work no restricted pass could have done. Weighing that against - * the growth since the last full pass keeps full passes frequent while old - * structures are being discarded (their garbage is what floats until the - * next full pass) and spaces them out while the heap is growing with live or - * young data, where each walk of the retained heap buys almost nothing. - * Backing off only after two unproductive passes in a row keeps a program - * that discards old structures at a steady rate on the base schedule: its - * passes alternate between finding the garbage just aged into the old - * generation and finding it still younger, and a single miss says little. */ -static void scr_cyc_full_pass(bool by_growth) { +/* One scheduled pass at `gen`. A mature or old pass adapts that level's + * backlog threshold to its yield; the explicit sweep (scr_collect_cycles) is + * not a scheduling decision and leaves the thresholds alone. Productive means + * at least one object freed per GROWTH_DIV candidates; a pass with no + * candidates says nothing. + * + * A full pass that heap growth triggered also adapts the growth fraction to + * what it reclaimed from the OLD generation — the one part of its work no + * restricted pass could have done. Weighing that against the growth since the + * last full pass keeps full passes frequent while old structures are being + * discarded (their garbage is what floats until the next full pass) and + * spaces them out while the heap is growing with live or young data, where + * each walk of the retained heap buys almost nothing. Backing off only after + * two unproductive passes in a row keeps a program that discards old + * structures at a steady rate on the base schedule: its passes alternate + * between finding the garbage just aged into the old generation and finding + * it still younger, and a single miss says little. + * + * Every level shares this one body, so a program that links the collector + * carries a single copy of the scheduling logic. */ +static SCR_CYC_SCHEDULING void scr_cyc_scheduled_pass(unsigned gen, bool by_growth) { size_t base = scr_cyc_growth_base(scr_cyc_live_after_full); size_t growth = scr_cyc_live > base ? scr_cyc_live - base : 0; size_t old_freed_before = scr_cyc_old_freed; - scr_cyc_level_pass(SCR_CYC_OLD); + size_t freed = scr_cyc_pass(gen); + if (gen == SCR_CYC_NURSERY) return; + if (scr_cands.n != 0) { + if (freed * SCR_CYC_GROWTH_DIV >= scr_cands.n) + scr_cyc_backlog_shift[gen] = 0; + else if (scr_cyc_backlog_shift[gen] < SCR_CYC_BACKLOG_MAX_SHIFT) + scr_cyc_backlog_shift[gen]++; + } if (!by_growth) return; size_t reclaimed = scr_cyc_old_freed - old_freed_before; if (reclaimed * SCR_CYC_GROWTH_DIV >= growth) { @@ -397,16 +409,18 @@ static void scr_cyc_full_pass(bool by_growth) { } } -static void scr_cyc_collect_due(void) { - if (scr_cyc_grown(scr_cyc_live_after_full, scr_cyc_growth_shift)) - scr_cyc_full_pass(true); - else if (scr_roots[SCR_CYC_OLD].n >= scr_cyc_backlog_threshold(SCR_CYC_OLD)) - scr_cyc_full_pass(false); +/* Cold half of the release path: pick the level and collect. It runs once + * per pass, so it is kept out of line and built for size, which leaves the + * hot buffering path in scr_cyc_on_release small. */ +static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) { + unsigned gen = SCR_CYC_NURSERY; + bool by_growth = scr_cyc_grown(scr_cyc_live_after_full, scr_cyc_growth_shift); + if (by_growth || scr_roots[SCR_CYC_OLD].n >= scr_cyc_backlog_threshold(SCR_CYC_OLD)) + gen = SCR_CYC_OLD; else if (scr_cyc_grown(scr_cyc_live_after_mature, 0) || scr_roots[SCR_CYC_MATURE].n >= scr_cyc_backlog_threshold(SCR_CYC_MATURE)) - scr_cyc_level_pass(SCR_CYC_MATURE); - else - scr_cyc_pass(SCR_CYC_NURSERY); + gen = SCR_CYC_MATURE; + scr_cyc_scheduled_pass(gen, by_growth); } /* One scheduled pass, for callers that reach a natural collection point @@ -423,7 +437,7 @@ void scr_cyc_collect_scheduled(void) { scr_cyc_scheduled_mature_age = 0; } else if (++scr_cyc_scheduled_mature_age >= scr_cyc_nursery_threshold()) { - scr_cyc_full_pass(false); + scr_cyc_scheduled_pass(SCR_CYC_OLD, false); return; } scr_cyc_collect_due(); @@ -574,7 +588,7 @@ static void scr_scan(void *obj, uintptr_t depth) { * edges were never trial-deleted, so anything it references kept that count * and was scan-blacked rather than whitened — the same conservatism that * makes restricting the walk sound in the first place. */ -static void scr_xg_pin_visit(void *child, void *ctx) { +static SCR_CYC_COMPACT void scr_xg_pin_visit(void *child, void *ctx) { (void)ctx; if (child == NULL || SCR_RC(child) == SIZE_MAX) return; if (scr_cyc_hdr(child)->gen <= scr_gen_limit) return; /* markGray had it */ @@ -600,7 +614,7 @@ static void scr_xg_destroy(void *obj) { scr_xg_freed++; } -static void scr_xg_release(void *obj) { +static SCR_CYC_COMPACT void scr_xg_release(void *obj) { if (SCR_RC(obj) > 1) { SCR_RC(obj) -= 1; scr_cyc_on_release(obj); /* lost a reference: a possible cycle root */ @@ -613,43 +627,12 @@ static void scr_xg_release(void *obj) { /* One pass over every candidate at or below `gen_limit`; returns how many * objects it freed. */ -static size_t scr_cyc_pass(unsigned gen_limit) { - if (scr_collecting) return 0; - scr_collecting = true; - scr_gen_limit = gen_limit; - - /* markRoots: keep live candidates (still purple), drop the rest — an - * object re-retained since buffering is black. Drain the buffers before - * walking: tracing only adjusts counts and worklists, so nothing can - * re-buffer underneath us. Candidates ABOVE the limit keep their slots - * and wait for a pass at their own level. */ - scr_cands.n = 0; - for (unsigned g = 0; g <= gen_limit; g++) { - for (size_t i = 0; i < scr_roots[g].n; i++) { - void *obj = scr_roots[g].v[i]; - ScrCycHdr *h = scr_cyc_hdr(obj); - h->buffered = 0; - if (h->color == SCR_CYC_PURPLE) - scr_cyc_push(&scr_cands, obj); - } - scr_roots[g].n = 0; - } - for (size_t i = 0; i < scr_cands.n; i++) scr_mark_gray(scr_cands.v[i], 0); - while (scr_pending.n) { - void *obj = scr_pending.v[--scr_pending.n]; - scr_cyc_hdr(obj)->trace(obj, scr_mg_visit, NULL); - } - - scr_white.n = 0; - for (size_t i = 0; i < scr_cands.n; i++) scr_scan(scr_cands.v[i], 0); - while (scr_pending.n) { - void *obj = scr_pending.v[--scr_pending.n]; - /* A later outside root may have restored this deferred white node and - * its descendants already. Never scan a black node's edges again. */ - if (scr_cyc_hdr(obj)->color == SCR_CYC_WHITE) - scr_cyc_hdr(obj)->trace(obj, scr_scan_visit, NULL); - } - +/* The second half of a pass: settle the white set, promote, re-buffer, + * tear down and re-arm. Its loops run once per candidate or dead object and + * spend their time in the trace and teardown calls they make, so this half is + * built for size; the graph walk above it stays fully optimized. Returns how + * many objects the pass freed. */ +static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit) { size_t dead = 0; for (size_t i = 0; i < scr_white.n; i++) { void *obj = scr_white.v[i]; @@ -747,6 +730,46 @@ static size_t scr_cyc_pass(unsigned gen_limit) { return freed; } +static size_t scr_cyc_pass(unsigned gen_limit) { + if (scr_collecting) return 0; + scr_collecting = true; + scr_gen_limit = gen_limit; + + /* markRoots: keep live candidates (still purple), drop the rest — an + * object re-retained since buffering is black. Drain the buffers before + * walking: tracing only adjusts counts and worklists, so nothing can + * re-buffer underneath us. Candidates ABOVE the limit keep their slots + * and wait for a pass at their own level. */ + scr_cands.n = 0; + for (unsigned g = 0; g <= gen_limit; g++) { + for (size_t i = 0; i < scr_roots[g].n; i++) { + void *obj = scr_roots[g].v[i]; + ScrCycHdr *h = scr_cyc_hdr(obj); + h->buffered = 0; + if (h->color == SCR_CYC_PURPLE) + scr_cyc_push(&scr_cands, obj); + } + scr_roots[g].n = 0; + } + for (size_t i = 0; i < scr_cands.n; i++) scr_mark_gray(scr_cands.v[i], 0); + while (scr_pending.n) { + void *obj = scr_pending.v[--scr_pending.n]; + scr_cyc_hdr(obj)->trace(obj, scr_mg_visit, NULL); + } + + scr_white.n = 0; + for (size_t i = 0; i < scr_cands.n; i++) scr_scan(scr_cands.v[i], 0); + while (scr_pending.n) { + void *obj = scr_pending.v[--scr_pending.n]; + /* A later outside root may have restored this deferred white node and + * its descendants already. Never scan a black node's edges again. */ + if (scr_cyc_hdr(obj)->color == SCR_CYC_WHITE) + scr_cyc_hdr(obj)->trace(obj, scr_scan_visit, NULL); + } + + return scr_cyc_settle(gen_limit); +} + void scr_collect_cycles(void) { /* The full pass, run to a fixpoint. A teardown's releases can drop the * last reference to a further cycle, so one pass does not always drain From dc98e402020dc239514437fa9ab7597ef0aebe37 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 15:41:53 -0500 Subject: [PATCH 13/14] Refresh the surface manifest for import-cycle support (#769) --- packages/compiler/surface-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 75645d769..4b1bfe59f 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -59,7 +59,7 @@ "name": "circular imports", "status": "unsupported", "code": "SC1016", - "note": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window" + "note": "ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge" }, { "id": "diagnostic.sc1020", From 269c4130f5456d2091374fa398b39b8613cebb59 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 18:21:31 -0500 Subject: [PATCH 14/14] Fix console ordering checks under pipe backpressure - Keep merged-pipe ordering probes small enough for deterministic Node output. - Preserve large-write coverage for shared files and independent streams. --- tests/fixtures/console-io/merged-order.ts | 23 ++++++++++--------- tests/harness/console-io.test.ts | 28 ++++++++++++++++++----- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/tests/fixtures/console-io/merged-order.ts b/tests/fixtures/console-io/merged-order.ts index 5ae9f9412..b7ac9ec65 100644 --- a/tests/fixtures/console-io/merged-order.ts +++ b/tests/fixtures/console-io/merged-order.ts @@ -1,8 +1,9 @@ -// stdout and stderr redirected to ONE descriptor (2>&1): every console and -// process stream write must land in source order, as Node submits each chunk -// before returning. Covers single-string and multi-argument lines, raw writes -// without newlines, lines larger than the runtime's on-stack buffer and the -// 64 KiB stdio buffer, and writes from a later event-loop turn. +// With --small, the complete output fits below pipe capacity so Node's +// independent asynchronous stdout/stderr queues cannot interleave chunks. +// The default covers large writes to a shared file or separate pipes, +// including lines beyond the runtime's stack buffer and 64 KiB stdio buffer. +const large = process.argv[2] !== "--small"; + function repeat(unit: string, count: number): string { let out = ""; for (let i = 0; i < count; i++) out += unit; @@ -20,12 +21,12 @@ for (let i = 0; i < 50; i++) { if (i % 3 === 0) console.error("loop err", i); else console.log(`loop out ${i}`); } -console.log(repeat("o", 5000)); -console.error(repeat("e", 5000)); -console.log("big", repeat("O", 70000), 1); -console.error(repeat("E", 70000)); -process.stdout.write(repeat("w", 3000)); -process.stderr.write(repeat("v", 3000) + "\n"); +console.log(repeat("o", large ? 5000 : 50)); +console.error(repeat("e", large ? 5000 : 50)); +console.log("big", repeat("O", large ? 70000 : 100), 1); +console.error(repeat("E", large ? 70000 : 100)); +process.stdout.write(repeat("w", large ? 3000 : 50)); +process.stderr.write(repeat("v", large ? 3000 : 50) + "\n"); console.log("|end of sync"); setTimeout(() => { console.error("timer err"); diff --git a/tests/harness/console-io.test.ts b/tests/harness/console-io.test.ts index 90b7fac25..874b1b544 100644 --- a/tests/harness/console-io.test.ts +++ b/tests/harness/console-io.test.ts @@ -153,21 +153,37 @@ describe(`console/process output visibility${sanitize ? " (sanitized)" : ""}`, ( expect(nativeRes.signal).toBe(nodeRes.signal); }); - /* stdout and stderr share one pipe or one regular file (2>&1), so the - * bytes record the exact submission order across both streams. */ + /* A bounded probe cannot fill the shared pipe: Node submits these writes + * immediately. With backpressure, its independent asynchronous stdout and + * stderr queues can interleave larger chunks, so compare large pipe output + * per stream and retain the shared-file ordering check below. */ const posix = process.platform !== "win32"; test.skipIf(!posix)("stdout/stderr merged into one pipe keep Node's order", async () => { const probe = await build("merged-order"); const merged = (cmd: string, args: string[]) => runToClose("sh", ["-c", 'exec "$@" 2>&1', "sh", cmd, ...args]); const [nodeRes, nativeRes] = await Promise.all([ - merged("node", [probe.sourceFile]), - merged(probe.binary, []), + merged("node", [probe.sourceFile, "--small"]), + merged(probe.binary, ["--small"]), ]); expect(nodeRes.code).toBe(0); expect(nativeRes.code).toBe(0); - expect(nativeRes.stdout.length).toBe(nodeRes.stdout.length); - expect(nativeRes.stdout.equals(nodeRes.stdout)).toBe(true); + expect(nodeRes.stdout.length).toBeLessThan(4096); + expect(nativeRes.stdout).toEqual(nodeRes.stdout); + }); + + test("large stdout/stderr pipe writes match Node per stream", async () => { + const probe = await build("merged-order"); + const [nodeRes, nativeRes] = await Promise.all([ + runToClose("node", [probe.sourceFile]), + runToClose(probe.binary, []), + ]); + expect(nodeRes.code).toBe(0); + expect(nativeRes.code).toBe(0); + expect(nodeRes.stdout.length).toBeGreaterThan(65536); + expect(nodeRes.stderr.length).toBeGreaterThan(65536); + expect(nativeRes.stdout).toEqual(nodeRes.stdout); + expect(nativeRes.stderr).toEqual(nodeRes.stderr); }); test.skipIf(!posix)("stdout/stderr merged into one file keep Node's order", async () => {