From 1f10e83720f8533ddfba8d609c729bbdeb487e5a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 11:00:13 -0500 Subject: [PATCH] Promote canary to main (#738) - Preserve callback identity, receiver binding, and class references across unions and collections. - Track record field presence independently of undefined values and improve native runtime behavior. - Expand differential coverage and streamline CI bootstrap and compiler rebuild checks. --- .github/workflows/ci.yml | 78 ++- docs/content/docs/cli.mdx | 2 +- docs/content/docs/how-it-works.mdx | 2 +- docs/content/docs/limitations.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 | 40 +- packages/compiler/src/backend/llvm/classes.ts | 8 +- packages/compiler/src/backend/llvm/common.ts | 39 ++ .../backend/llvm/constant-callbacks.test.ts | 2 +- packages/compiler/src/backend/llvm/dyn.ts | 88 +++- .../compiler/src/backend/llvm/emitter.test.ts | 29 +- packages/compiler/src/backend/llvm/emitter.ts | 93 +++- .../compiler/src/backend/llvm/expr-calls.ts | 16 + .../src/backend/llvm/expr-dispatch.ts | 2 + .../compiler/src/backend/llvm/expr-dynamic.ts | 142 ++++- .../src/backend/llvm/expr-primitives.test.ts | 2 +- .../src/backend/llvm/expr-primitives.ts | 31 +- .../compiler/src/backend/llvm/expr-records.ts | 2 +- .../backend/llvm/expr-stream-bridges.test.ts | 2 +- .../src/backend/llvm/expr-stream-bridges.ts | 18 +- .../src/backend/llvm/lib-filesystem.ts | 7 +- .../backend/llvm/local-array-reads.test.ts | 2 +- .../backend/llvm/local-union-storage.test.ts | 2 +- .../backend/llvm/map-read-lifetimes.test.ts | 2 +- .../llvm/serialized-ir-emission.test.ts | 45 ++ .../src/backend/llvm/stack-callbacks.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 +- .../frontend/lowering/abstract-equality.ts | 71 +-- .../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-assertions.ts | 25 +- .../src/frontend/lowering/class-callbacks.ts | 22 +- .../lowering/class-dynamic-dispatch.ts | 5 +- .../lowering/class-method-mutations.ts | 431 +++++++++++++++ .../frontend/lowering/class-method-values.ts | 37 +- .../src/frontend/lowering/class-unions.ts | 156 ++++++ .../frontend/lowering/coercions/builders.ts | 9 +- .../lowering/coercions/function-adapters.ts | 2 + .../frontend/lowering/coercions/identity.ts | 35 ++ .../frontend/lowering/coercions/structural.ts | 16 +- .../frontend/lowering/collection-narrowing.ts | 36 ++ .../containers/array-callback-call.ts | 63 +++ .../lowering/containers/array-callbacks.ts | 51 +- .../lowering/containers/array-concat.ts | 158 ++++++ .../lowering/containers/array-construction.ts | 107 +++- .../lowering/containers/array-methods.ts | 190 ++----- .../lowering/containers/collection-methods.ts | 12 +- .../lowering/containers/indexed-objects.ts | 154 +++--- .../lowering/expressions/evaluation-safety.ts | 4 + .../lowering/expressions/object-literals.ts | 171 ++++-- .../lowering/expressions/union-field-write.ts | 126 ++--- .../lowering/lower-array-sort.test.ts | 31 ++ .../src/frontend/lowering/lower-array-sort.ts | 27 +- .../src/frontend/lowering/lower-assert.ts | 9 + .../frontend/lowering/lower-builtin-values.ts | 1 + .../src/frontend/lowering/lower-calls.ts | 282 +++++----- .../src/frontend/lowering/lower-classes.ts | 112 ++-- .../src/frontend/lowering/lower-exprs.ts | 493 +++++++++++++----- .../src/frontend/lowering/lower-functions.ts | 56 +- .../src/frontend/lowering/lower-inspect.ts | 37 +- .../src/frontend/lowering/lower-modules.ts | 2 + .../src/frontend/lowering/lower-stmts.ts | 120 ++--- .../compiler/src/frontend/lowering/lowerer.ts | 230 +++++++- .../src/frontend/lowering/native-ffi.ts | 4 +- .../lowering/sanitize-class-types.test.ts | 4 +- .../src/frontend/lowering/strict-equality.ts | 161 ++++++ .../src/frontend/lowering/surfaces.ts | 3 + .../src/frontend/lowering/union-comparison.ts | 57 ++ .../compiler/src/frontend/type-mapper.test.ts | 28 +- packages/compiler/src/frontend/type-mapper.ts | 95 +++- .../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 | 62 ++- .../compiler/src/ir/scalar-records.test.ts | 2 +- packages/compiler/src/ir/serialize.ts | 6 +- packages/compiler/src/ir/traverse.ts | 8 + packages/compiler/src/ir/validate.test.ts | 242 ++++++++- packages/compiler/src/ir/validate.ts | 107 +++- .../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/src/native/prepare.ts | 37 +- packages/compiler/surface-manifest.json | 6 + .../test/bytes-element-emission.test.ts | 8 +- .../compiler/test/class-field-updates.test.ts | 28 + .../test/class-narrowing-emission.test.ts | 37 ++ packages/compiler/test/emit-llvm.test.ts | 6 +- packages/compiler/test/fixtures/fib-ir.ts | 2 +- .../test/fromcharcode-emission.test.ts | 2 +- .../test/generic-class-recovery.test.ts | 45 ++ 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 | 187 +++++++ .../runtime-darwin-arm64/scripts/build.mjs | 2 +- .../runtime-pack-common/scripts/build.mjs | 2 +- packages/runtime/src/scr_array.c | 2 + packages/runtime/src/scr_closure.c | 12 + packages/runtime/src/scr_console.c | 2 +- packages/runtime/src/scr_context.c | 2 +- packages/runtime/src/scr_dc.c | 3 +- packages/runtime/src/scr_error.c | 6 + packages/runtime/src/scr_events.c | 15 +- packages/runtime/src/scr_events_emitter.c | 4 +- packages/runtime/src/scr_json.c | 67 ++- packages/runtime/src/scr_lib.c | 42 +- packages/runtime/src/scr_map.c | 2 +- packages/runtime/src/scr_runtime.h | 39 +- packages/runtime/test/test_closure.c | 18 + scripts/bootstrap-seed.mjs | 70 +++ scripts/ci-native-bootstrap.mjs | 20 +- tests/corpus/1408-string-indexing.ts | 6 +- tests/corpus/1531-delete-optional-fields.ts | 8 +- tests/corpus/2364-in-runtime-key.ts | 4 +- .../2448-entries-null-arms-regex-arrays.ts | 4 +- .../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 ++ tests/corpus/535-object-statics.ts | 4 +- tests/corpus/abstract-property-dispatch.ts | 54 ++ tests/corpus/array-concat-widening.ts | 42 ++ tests/corpus/captured-class-refinement.ts | 43 ++ tests/corpus/caught-class-identity.ts | 43 ++ tests/corpus/caught-standalone-class.ts | 38 ++ tests/corpus/caught-system-error-fields.ts | 42 ++ tests/corpus/class-collection-insertion.ts | 77 +++ tests/corpus/class-covariant-returns.ts | 67 +++ tests/corpus/class-method-spreads.ts | 51 ++ tests/corpus/class-narrowed-views.ts | 77 +++ tests/corpus/collection-data-unions.ts | 45 ++ tests/corpus/derived-numeric-updates.ts | 57 ++ tests/corpus/generic-class-recovery.ts | 34 ++ tests/corpus/map-typed-array-values.ts | 26 + tests/corpus/native-callback-dispatch.ts | 77 +++ tests/corpus/optional-class-flows.ts | 57 ++ tests/corpus/optional-flag-comparisons.ts | 60 +++ tests/corpus/primitive-union-string.ts | 14 + 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 + tests/corpus/terminal-stream-properties.ts | 25 + tests/corpus/typed-index-arguments.ts | 20 + tests/corpus/union-reference-comparison.ts | 75 +++ tests/diagnostics/catch-bindings.ts | 4 +- tests/diagnostics/index-signature-values.ts | 2 +- tests/diagnostics/inheritance.ts | 6 +- .../runtime-optional-refined-union.ts | 4 +- tests/diagnostics/stdlib-fence.ts | 19 +- .../node-types/terminal-error-fields.ts | 20 + tests/fixtures/self-hosting/class-types.ts | 2 +- tests/fixtures/terminal-geometry/main.ts | 15 +- .../__snapshots__/catch-bindings.ts.txt | 7 - .../__snapshots__/component-fences.ts.txt | 2 +- .../index-signature-values.ts.txt | 13 +- .../harness/__snapshots__/inheritance.ts.txt | 9 +- 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 | 91 ++++ tests/harness/ci-native-bootstrap.test.ts | 94 ++-- tests/harness/coverage.test.ts | 4 +- tests/harness/project-config.test.ts | 18 + .../self-hosting-native-driver.test.ts | 288 +++++----- .../self-hosting-native-toolchain.test.ts | 2 +- tests/harness/self-hosting-validator-cases.ts | 2 +- 192 files changed, 6827 insertions(+), 1485 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts 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/class-unions.ts create mode 100644 packages/compiler/src/frontend/lowering/coercions/identity.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/src/frontend/lowering/containers/array-concat.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 packages/compiler/test/class-narrowing-emission.test.ts create mode 100644 packages/compiler/test/generic-class-recovery.test.ts create mode 100644 packages/compiler/test/native-callback-emission.test.ts create mode 100644 scripts/bootstrap-seed.mjs 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 create mode 100644 tests/corpus/abstract-property-dispatch.ts create mode 100644 tests/corpus/array-concat-widening.ts create mode 100644 tests/corpus/captured-class-refinement.ts create mode 100644 tests/corpus/caught-class-identity.ts create mode 100644 tests/corpus/caught-standalone-class.ts create mode 100644 tests/corpus/caught-system-error-fields.ts create mode 100644 tests/corpus/class-collection-insertion.ts create mode 100644 tests/corpus/class-covariant-returns.ts create mode 100644 tests/corpus/class-method-spreads.ts create mode 100644 tests/corpus/class-narrowed-views.ts create mode 100644 tests/corpus/collection-data-unions.ts create mode 100644 tests/corpus/derived-numeric-updates.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/native-callback-dispatch.ts create mode 100644 tests/corpus/optional-class-flows.ts create mode 100644 tests/corpus/optional-flag-comparisons.ts create mode 100644 tests/corpus/primitive-union-string.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 create mode 100644 tests/corpus/terminal-stream-properties.ts create mode 100644 tests/corpus/typed-index-arguments.ts create mode 100644 tests/corpus/union-reference-comparison.ts create mode 100644 tests/fixtures/node-types/terminal-error-fields.ts create mode 100644 tests/harness/bootstrap-seed.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 389903a9c..2719a6548 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,23 +176,17 @@ 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. - bootstrap: - 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. + # 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] - phase: [commands, rebuild] env: - SCRIPTC_TEST_WORKERS: "1" SCRIPTC_SAN: ${{ matrix.flavor == 'san' && '1' || '' }} - SCRIPTC_BOOTSTRAP_PHASE: ${{ matrix.phase }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -208,15 +202,79 @@ jobs: - 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. + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + # 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' || '' }} + SCRIPTC_BOOTSTRAP_PHASE: ${{ matrix.phase }} + 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 + # 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 if: matrix.phase == 'commands' with: 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/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/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/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..a3c7d4664 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: [], @@ -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/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts index 65bbc1c32..913502b03 100644 --- a/packages/compiler/src/backend/llvm/common.ts +++ b/packages/compiler/src/backend/llvm/common.ts @@ -1,11 +1,13 @@ import { commentText, unsignedHex } from "../literals.js"; import { InternalCompilerError } from "../../errors.js"; +import type { BlockBuilder } from "./blocks.js"; import type { IrBytesElem, IrFfiCallbackParamClass, 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 @@ -95,3 +97,40 @@ 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; +} + +/** 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/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/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index cd0d7939f..024baebcd 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. @@ -1688,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; } @@ -1952,11 +2032,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.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..cac477795 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), @@ -1544,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 @@ -1600,7 +1610,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}()`] : []), @@ -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 @@ -1841,7 +1863,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 @@ -2427,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 }`, ``, ); } @@ -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); } @@ -3014,13 +3078,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"; } @@ -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-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-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 f7b125e44..adb517326 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -7,13 +7,14 @@ import { InternalCompilerError } from "../../errors.js"; import { streamTypedRefEligible } from "../../ir/analysis.js"; import { DYN, + type IrExpr, isDynTypedRefType, isRefCounted, isUnitType, 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"; @@ -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]); @@ -803,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; } @@ -828,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`); @@ -846,6 +864,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})`, ); @@ -993,3 +1043,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.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-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 2e8af2354..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 `==`. @@ -779,6 +789,8 @@ export function emitRecordExpr( | "recordKeyGet" | "recordOvfKeys" | "recordOvfHas" + | "recordHas" + | "fieldAbsent" >, ): LlValue { const B = host.B; @@ -794,7 +806,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 +875,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.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/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/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/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/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/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/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/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..a07741461 --- /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) 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-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/class-callbacks.ts b/packages/compiler/src/frontend/lowering/class-callbacks.ts index 3308371a9..da40f361b 100644 --- a/packages/compiler/src/frontend/lowering/class-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/class-callbacks.ts @@ -10,15 +10,16 @@ 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 { checkedIterableSpread } from "./checked-iterable-spread.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 +36,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")) ); } @@ -142,7 +146,7 @@ export function classCallbackCall( lowerer: Lowerer, receiver: IrExpr, name: string, - args: IrExpr[], + argumentNodes: readonly ts.Expression[], fallback: IrExpr, loc: SrcLoc, calleeName = name, @@ -152,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/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/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..c172b40cf --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-unions.ts @@ -0,0 +1,156 @@ +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 + * 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 }; +} + +/** 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/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/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/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/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/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-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 new file mode 100644 index 000000000..57b8caf14 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/containers/array-concat.ts @@ -0,0 +1,158 @@ +import { identityPreservingWidening } from "../coercions/identity.js"; +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, true)) { + 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; +} 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 96c53721e..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,5 @@ +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"; import * as ts from "../../ts7/adapter.js"; @@ -7,8 +9,6 @@ import { DYN, F64, type IrExpr, - type IrLocal, - type IrParam, type IrStmt, type IrType, JSVAL, @@ -46,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, @@ -582,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", @@ -594,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", @@ -693,7 +712,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 +726,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 +742,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 +755,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,155 +1923,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) - * 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/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/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/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-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-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-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..6e3a27fd6 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,3 +1,5 @@ +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 * own owners; this module selects the applicable call path. */ @@ -1422,6 +1424,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 +1892,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" || @@ -4840,14 +4866,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); } @@ -6482,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 @@ -6619,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( @@ -6676,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, ); } @@ -6718,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}`; @@ -6734,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: { @@ -6785,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, @@ -6822,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, ); } @@ -7171,6 +7150,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"; @@ -7179,9 +7161,23 @@ 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", + call, + NARROW_FIRST, + ); const sameValue = arms.some((a) => a.kind === "f64"); return { kind: "unionEq", @@ -7412,12 +7408,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]!); @@ -7822,11 +7816,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. @@ -8115,8 +8107,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; @@ -8176,49 +8168,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; @@ -8302,7 +8328,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", @@ -8991,7 +9017,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 +9049,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 +9062,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 +9072,7 @@ export function lowerObjectMethodCall( return finish({ kind: "dynCall", callee, - receiver, + ...receiver, calleeName: access.getText(), args, type: DYN, @@ -9130,7 +9158,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/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 452099ff1..3e9486b51 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 @@ -509,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; @@ -758,6 +761,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, }, @@ -2508,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 @@ -2913,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 }); @@ -3060,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. @@ -3142,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()) && @@ -3179,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); @@ -3188,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)", ); } } @@ -4831,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); @@ -5773,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); } @@ -5798,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( @@ -5984,36 +6023,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( @@ -8589,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 6afd99152..4af4e69ab 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1,3 +1,5 @@ +import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; +import { isOptionalProcessStreamProperty } from "./builtins/process.js"; import { lowerWorkerMetadata } from "./builtins/workers.js"; import { dynUndefinedExpr, @@ -18,10 +20,13 @@ 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"; import { checkedClassAssertion } from "./class-assertions.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"; import { wasiGuestPath } from "../../wasi-paths.js"; @@ -105,12 +110,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"; @@ -278,8 +284,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, @@ -487,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)); @@ -3573,6 +3586,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 && @@ -3583,6 +3600,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 @@ -3757,6 +3777,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 @@ -3792,6 +3814,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) @@ -3995,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, @@ -4316,7 +4391,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 +6491,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 +6797,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", @@ -6881,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) }, @@ -6902,7 +6990,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 @@ -7187,6 +7275,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" && @@ -8332,6 +8423,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, @@ -8525,13 +8638,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 +8648,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); @@ -8823,10 +8932,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[] = []; @@ -8983,10 +9094,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: [ @@ -9018,6 +9143,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 }; } @@ -9434,6 +9600,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); @@ -10074,7 +10242,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" || @@ -10181,6 +10349,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 @@ -10220,6 +10399,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, @@ -10252,6 +10432,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, @@ -11464,17 +11645,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 }; } @@ -11542,6 +11739,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", @@ -11848,12 +12076,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", @@ -12196,11 +12439,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. */ @@ -12252,8 +12494,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); @@ -12520,24 +12762,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 @@ -12638,27 +12865,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); @@ -13451,7 +13660,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}`); @@ -13877,12 +14090,34 @@ 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); + 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( @@ -14130,13 +14365,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/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-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 ac3f2671a..829163b07 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 { @@ -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, @@ -3583,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", @@ -3638,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 }; } @@ -4691,10 +4707,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 +5126,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 +5278,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) { @@ -5811,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 { @@ -5933,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) @@ -5951,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", @@ -6864,7 +6887,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); @@ -7085,31 +7108,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"); } @@ -7197,22 +7196,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 c71007209..eb29e8771 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,3 +1,8 @@ +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"; import { lowerWorkerMetadata } from "./builtins/workers.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../ir/build.js"; import type { FieldLift } from "./coercions/structural-plans.js"; @@ -2036,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 { @@ -2108,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(); @@ -2249,6 +2263,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 +3644,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(); @@ -3654,6 +3671,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; @@ -3758,10 +3784,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 +3823,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) && @@ -3821,6 +3849,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); @@ -3848,28 +3880,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 +4228,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)) { @@ -4268,12 +4325,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)) { @@ -4472,7 +4532,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; @@ -4483,6 +4543,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; @@ -4513,6 +4579,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) { @@ -4887,7 +4967,7 @@ export class Lowerer { this.diags.length > 0 ? null : { - irVersion: 14, + irVersion: 15, sourceFile: this.entry.fileName, functions, classes: artifacts.classes, @@ -5974,6 +6054,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 @@ -6415,6 +6497,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 @@ -6724,6 +6824,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 @@ -6749,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 @@ -7221,6 +7351,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. @@ -7903,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/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/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/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/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/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/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..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( @@ -1550,6 +1558,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 +2010,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 +2455,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 +2580,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 @@ -3196,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 @@ -4162,7 +4177,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 +4206,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 +4344,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 +5042,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: @@ -5121,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/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..cf4cd0604 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": @@ -771,15 +772,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 +901,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 @@ -2551,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 @@ -2676,7 +2684,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 @@ -2756,7 +2766,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 +3060,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 @@ -3074,9 +3104,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; @@ -3091,7 +3121,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/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/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 e6f240f87..6b5447d7a 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: [ @@ -1444,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; @@ -1454,7 +1538,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 }], @@ -1616,3 +1700,143 @@ 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", + ]); +}); + +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 b1814019a..62d56c792 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); @@ -1285,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": @@ -1357,6 +1371,8 @@ function validateFunction( case "recordKeyGet": case "recordOvfHas": case "recordOvfKeys": + case "recordHas": + case "fieldAbsent": return checkRecordExpr(e); case "dynFrom": case "dynFromJsval": @@ -2656,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); } @@ -2821,6 +2840,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)) { @@ -2997,7 +3035,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; @@ -3017,7 +3063,9 @@ function validateFunction( | "recordGet" | "recordKeyGet" | "recordOvfHas" - | "recordOvfKeys"; + | "recordOvfKeys" + | "recordHas" + | "fieldAbsent"; }, ): void { switch (e.kind) { @@ -3034,7 +3082,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) { @@ -3094,7 +3143,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); @@ -3103,6 +3152,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); @@ -3419,8 +3488,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); @@ -3455,10 +3524,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, ); } @@ -5901,7 +5970,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/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/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/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/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/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/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/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..a2cb2f6af 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" @@ -9236,6 +9254,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" @@ -9655,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" @@ -10433,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" @@ -10857,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" @@ -10893,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" @@ -14637,6 +14685,145 @@ "/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": [] + }, + "/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": [] + }, + "/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": [] + }, + "/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": [] + }, + "/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-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_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_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_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_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..fcd426f18 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]); @@ -1148,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 563b5d822..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) { @@ -4580,7 +4588,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; @@ -6912,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: @@ -7001,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; @@ -7012,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); @@ -7032,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)); @@ -9272,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_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_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 c78e34560..8b02a12c6 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 */ @@ -1698,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; @@ -1713,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/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/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/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/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/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; +} 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/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/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/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-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/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/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/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-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(",")); } 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/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/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/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/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/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/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/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/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); 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/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/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/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/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/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/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/__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__/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__/inheritance.ts.txt b/tests/harness/__snapshots__/inheritance.ts.txt index 2264a4600..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 { @@ -26,7 +19,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 { 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 new file mode 100644 index 000000000..101119c3c --- /dev/null +++ b/tests/harness/bootstrap-seed.test.ts @@ -0,0 +1,91 @@ +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[]; include?: { 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'"); + } + // 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")!; + 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/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/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-driver.test.ts b/tests/harness/self-hosting-native-driver.test.ts index 277c95923..d2234ce41 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"]; @@ -153,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; @@ -395,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, [ @@ -410,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 }); 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 }],