diff --git a/.changepacks/changepack_log_binding_argument_validation.json b/.changepacks/changepack_log_binding_argument_validation.json new file mode 100644 index 000000000..3483a82f1 --- /dev/null +++ b/.changepacks/changepack_log_binding_argument_validation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Validate native and WASM compiler binding arguments with matching TypeErrors before state changes, preserve existing user exceptions and seed-array observations, and use the performance-accepted extraction wrappers. Invalid binding arguments now report the function, parameter and required type; located source build errors are unchanged.", + "date": "2026-10-27T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_binding_entries_length.json b/.changepacks/changepack_log_binding_entries_length.json new file mode 100644 index 000000000..576917c54 --- /dev/null +++ b/.changepacks/changepack_log_binding_entries_length.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Batch native entries and snapshot-length access, stream WASM file maps without trusting allocation hints, and reject seed copies with unvalidated trailing holes before either engine runs. Malformed seed headers now use the existing canonical TypeError; no new source build errors. Separately fix target-dependent demanded-name resolver callback order in the shared extractor: native and WASM now resolve demanded local names lexically while preserving pass order and callback multiplicity.", + "date": "2026-10-27T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_native_binding.json b/.changepacks/changepack_log_native_binding.json new file mode 100644 index 000000000..6c663ca09 --- /dev/null +++ b/.changepacks/changepack_log_native_binding.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Exercise the production native adapter in the root Rust coverage report without preparation for plain Cargo tests, and deterministically order namespace resolver traversal and changed-constant diagnostics. Reduce native per-call control costs while preserving callbacks and public outputs; prevent private Number coercions, global String use for BigInts, Array.from use for byte diagnostics, and incorrect Symbol.iterator exception conversion. No newly rejected source or styling runtime.", + "date": "2026-10-28T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_public_native_binding.json b/.changepacks/changepack_log_public_native_binding.json new file mode 100644 index 000000000..9c2e56157 --- /dev/null +++ b/.changepacks/changepack_log_public_native_binding.json @@ -0,0 +1,15 @@ +{ + "changes": { + "bindings/devup-ui-binding/package.json": "Patch", + "bindings/devup-ui-binding-linux-x64-gnu/package.json": "Patch", + "bindings/devup-ui-binding-linux-arm64-gnu/package.json": "Patch", + "bindings/devup-ui-binding-linux-x64-musl/package.json": "Patch", + "bindings/devup-ui-binding-linux-arm64-musl/package.json": "Patch", + "bindings/devup-ui-binding-darwin-x64/package.json": "Patch", + "bindings/devup-ui-binding-darwin-arm64/package.json": "Patch", + "bindings/devup-ui-binding-win32-x64-msvc/package.json": "Patch", + "bindings/devup-ui-binding-win32-arm64-msvc/package.json": "Patch" + }, + "note": "Add the public native build-time compiler with CommonJS and ESM types and eight OS, architecture and libc-specific optional packages. Validate exact native tarballs and publish platform artifacts before the root binding. Unsupported hosts and omitted native optional packages fail explicitly; source build errors and generated styling runtime are unchanged. Plugin engine selection is a separate phase.", + "date": "2026-10-28T00:00:00.000Z" +} diff --git a/.changepacks/config.json b/.changepacks/config.json index 4d78153de..e4466e7f0 100644 --- a/.changepacks/config.json +++ b/.changepacks/config.json @@ -3,9 +3,27 @@ "baseBranch": "main", "latestPackage": null, "publish": { + "bindings/devup-ui-binding/package.json": "node ../../e2e/binding/package/publish.mjs publish", + "bindings/devup-ui-binding-linux-x64-gnu/package.json": "node ../../e2e/binding/package/publish.mjs publish", + "bindings/devup-ui-binding-linux-arm64-gnu/package.json": "node ../../e2e/binding/package/publish.mjs publish", + "bindings/devup-ui-binding-linux-x64-musl/package.json": "node ../../e2e/binding/package/publish.mjs publish", + "bindings/devup-ui-binding-linux-arm64-musl/package.json": "node ../../e2e/binding/package/publish.mjs publish", + "bindings/devup-ui-binding-darwin-x64/package.json": "node ../../e2e/binding/package/publish.mjs publish", + "bindings/devup-ui-binding-darwin-arm64/package.json": "node ../../e2e/binding/package/publish.mjs publish", + "bindings/devup-ui-binding-win32-x64-msvc/package.json": "node ../../e2e/binding/package/publish.mjs publish", + "bindings/devup-ui-binding-win32-arm64-msvc/package.json": "node ../../e2e/binding/package/publish.mjs publish", "node": "bun pm pack --quiet --filename .changepacks/publish.tgz && npm publish ../../.changepacks/publish.tgz" }, "publishDryRun": { + "bindings/devup-ui-binding/package.json": "node ../../e2e/binding/package/publish.mjs dry-run", + "bindings/devup-ui-binding-linux-x64-gnu/package.json": "node ../../e2e/binding/package/publish.mjs dry-run", + "bindings/devup-ui-binding-linux-arm64-gnu/package.json": "node ../../e2e/binding/package/publish.mjs dry-run", + "bindings/devup-ui-binding-linux-x64-musl/package.json": "node ../../e2e/binding/package/publish.mjs dry-run", + "bindings/devup-ui-binding-linux-arm64-musl/package.json": "node ../../e2e/binding/package/publish.mjs dry-run", + "bindings/devup-ui-binding-darwin-x64/package.json": "node ../../e2e/binding/package/publish.mjs dry-run", + "bindings/devup-ui-binding-darwin-arm64/package.json": "node ../../e2e/binding/package/publish.mjs dry-run", + "bindings/devup-ui-binding-win32-x64-msvc/package.json": "node ../../e2e/binding/package/publish.mjs dry-run", + "bindings/devup-ui-binding-win32-arm64-msvc/package.json": "node ../../e2e/binding/package/publish.mjs dry-run", "node": "bun pm pack --quiet --filename .changepacks/publish.tgz && npm publish ../../.changepacks/publish.tgz --dry-run" } } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ee6780bfc..1c8ba1bee 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,6 +23,269 @@ concurrency: cancel-in-progress: false jobs: + # Independent read-only lane; no existing validation job waits on it. + native-build: + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - { suffix: linux-x64-gnu, target: x86_64-unknown-linux-gnu, runner: ubuntu-latest } + - { suffix: linux-arm64-gnu, target: aarch64-unknown-linux-gnu, runner: ubuntu-latest } + - { suffix: linux-x64-musl, target: x86_64-unknown-linux-musl, runner: ubuntu-latest } + - { suffix: linux-arm64-musl, target: aarch64-unknown-linux-musl, runner: ubuntu-latest } + - { suffix: darwin-x64, target: x86_64-apple-darwin, runner: macos-latest } + - { suffix: darwin-arm64, target: aarch64-apple-darwin, runner: macos-latest } + - { suffix: win32-x64-msvc, target: x86_64-pc-windows-msvc, runner: windows-latest } + # Windows ARM has no -latest alias; this is its newest exposed image, not a pin. + - { suffix: win32-arm64-msvc, target: aarch64-pc-windows-msvc, runner: windows-11-vs2026-arm } + env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: core.autocrlf + GIT_CONFIG_VALUE_0: "false" + CARGO_BUILD_JOBS: "2" + RUSTUP_TOOLCHAIN: stable + steps: + - name: Start durable build evidence + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/native-build-${{ matrix.suffix }}" + printf '%s\n' "$GITHUB_SHA $RUNNER_OS $RUNNER_ARCH $ImageOS $ImageVersion" > "$RUNNER_TEMP/native-build-${{ matrix.suffix }}/runner.txt" + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: '*' + check-latest: true + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions-rust-lang/setup-rust-toolchain@v2 + with: + toolchain: stable + target: ${{ matrix.target }} + cache: false + - name: Install latest stable Zig and cargo-zigbuild + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + curl -fsSL https://ziglang.org/download/index.json -o "$RUNNER_TEMP/zig-index.json" + version=$(jq -r 'keys[] | select(test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))' "$RUNNER_TEMP/zig-index.json" | sort -V | tail -1) + jq --arg v "$version" '.[$v]["x86_64-linux"]' "$RUNNER_TEMP/zig-index.json" > "$RUNNER_TEMP/zig-asset.json" + cp "$RUNNER_TEMP/zig-asset.json" "$RUNNER_TEMP/native-build-${{ matrix.suffix }}/zig-asset.json" + curl -fsSL "$(jq -r .tarball "$RUNNER_TEMP/zig-asset.json")" -o "$RUNNER_TEMP/zig.tar.xz" + echo "$(jq -r .shasum "$RUNNER_TEMP/zig-asset.json") $RUNNER_TEMP/zig.tar.xz" | sha256sum -c - + mkdir "$RUNNER_TEMP/zig" + tar -xf "$RUNNER_TEMP/zig.tar.xz" --strip-components=1 -C "$RUNNER_TEMP/zig" + echo "$RUNNER_TEMP/zig" >> "$GITHUB_PATH" + CARGO_TARGET_DIR="$RUNNER_TEMP/native-zigbuild-tool-target" cargo install cargo-zigbuild --locked --jobs 2 + - name: Build fresh target and retain exact receipt + shell: bash + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/native-target-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.suffix }} + NATIVE_SUFFIX: ${{ matrix.suffix }} + NATIVE_TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + test "$GITHUB_RUN_ATTEMPT" = 1 # Never replace original payloads on release-only retries. + test ! -e "$CARGO_TARGET_DIR" + if [[ "$RUNNER_OS" == macOS ]]; then export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"; fi + if [[ "$NATIVE_SUFFIX" == win32-arm64-msvc ]]; then node -e "require('node:assert/strict').equal(process.arch,'arm64')"; fi + flags=() + if [[ "$RUNNER_OS" == Linux ]]; then flags+=(--zigbuild); fi + node e2e/binding/package/provenance.mjs "$NATIVE_TARGET" "$RUNNER_TEMP/native-build-$NATIVE_SUFFIX/payload" "$GITHUB_RUN_ID/1" "${flags[@]}" + - uses: actions/upload-artifact@v7 + if: always() + with: + name: native-build-${{ matrix.suffix }}-${{ github.run_id }} + path: ${{ runner.temp }}/native-build-${{ matrix.suffix }}/* + if-no-files-found: error + retention-days: 7 + + native-packs: + needs: [native-build] + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: core.autocrlf + GIT_CONFIG_VALUE_0: "false" + steps: + - name: Start durable collector evidence + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/native-pack-evidence" + printf '%s\n' "$GITHUB_SHA $GITHUB_RUN_ID $GITHUB_RUN_ATTEMPT $RUNNER_OS $RUNNER_ARCH $ImageOS $ImageVersion" > "$RUNNER_TEMP/native-pack-evidence/runner.txt" + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: '*' + check-latest: true + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/download-artifact@v8 + with: + pattern: native-build-*-${{ github.run_id }} + path: ${{ runner.temp }}/native-inputs + - name: Collect once from clean source and run real-artifact controls + shell: bash + run: | + set -euo pipefail + test "$GITHUB_RUN_ATTEMPT" = 1 + mkdir "$RUNNER_TEMP/native-artifacts" + for suffix in $(node --input-type=module -e "import {targets} from './e2e/binding/package/matrix.mjs'; console.log(targets.map(r=>r.suffix).join(' '))"); do + mv "$RUNNER_TEMP/native-inputs/native-build-$suffix-$GITHUB_RUN_ID/payload" "$RUNNER_TEMP/native-artifacts/$suffix" + done + mkdir -p bindings/devup-ui-binding/pkg + node bindings/devup-ui-binding/script.cjs --public "$PWD/bindings/devup-ui-binding/pkg/index.cjs" + node e2e/binding/package/collect.mjs "$RUNNER_TEMP/native-artifacts" "$RUNNER_TEMP/native-bundle" "$GITHUB_RUN_ID/1" + mkdir "$RUNNER_TEMP/native-controls" + cp -R "$RUNNER_TEMP/native-artifacts/linux-x64-gnu" "$RUNNER_TEMP/native-controls/linux-x64-gnu" + NATIVE_PROOF_ARTIFACTS="$RUNNER_TEMP/native-controls" NATIVE_PROOF_BUNDLE="$RUNNER_TEMP/native-bundle" node --test e2e/binding/package/package.test.mjs e2e/binding/package/publish.test.mjs e2e/binding/package/elf.test.mjs + sha256sum "$RUNNER_TEMP/native-bundle/"*.tgz "$RUNNER_TEMP/native-bundle/bundle.json" | tee "$RUNNER_TEMP/native-bundle/SHA256SUMS" + du -b "$RUNNER_TEMP/native-bundle/"* + - uses: actions/upload-artifact@v7 + if: always() + with: + name: native-packs-${{ github.run_id }} + path: ${{ runner.temp }}/native-bundle/* + if-no-files-found: error + retention-days: 7 + - uses: actions/upload-artifact@v7 + if: always() + with: + name: native-pack-evidence-${{ github.run_id }} + path: ${{ runner.temp }}/native-pack-evidence/* + if-no-files-found: error + retention-days: 7 + + native-consumer: + needs: [native-packs] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { suffix: linux-x64-gnu, runner: ubuntu-latest, cpu: x64, docker: amd64, image: "node:current", bun: bun-linux-x64.zip, mode: native-container } + - { suffix: linux-arm64-gnu, runner: ubuntu-latest, cpu: arm64, docker: arm64, image: "node:current", bun: bun-linux-aarch64.zip, mode: QEMU-emulated } + - { suffix: linux-x64-musl, runner: ubuntu-latest, cpu: x64, docker: amd64, image: "node:current-alpine", bun: bun-linux-x64-musl.zip, mode: native-container } + - { suffix: linux-arm64-musl, runner: ubuntu-latest, cpu: arm64, docker: arm64, image: "node:current-alpine", bun: bun-linux-aarch64-musl.zip, mode: QEMU-emulated } + - { suffix: darwin-x64, runner: macos-latest, cpu: x64, bun: bun-darwin-x64.zip, mode: Rosetta } + - { suffix: darwin-arm64, runner: macos-latest, cpu: arm64, bun: bun-darwin-aarch64.zip, mode: native } + - { suffix: win32-x64-msvc, runner: windows-latest, cpu: x64, bun: bun-windows-x64.zip, mode: native } + # Windows ARM has no -latest alias; this is its newest exposed image, not a pin. + - { suffix: win32-arm64-msvc, runner: windows-11-vs2026-arm, cpu: arm64, bun: bun-windows-aarch64.zip, mode: native } + env: + NATIVE_SUFFIX: ${{ matrix.suffix }} + NATIVE_CPU: ${{ matrix.cpu }} + NATIVE_MODE: ${{ matrix.mode }} + steps: + - name: Start durable consumer evidence + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/native-evidence" + printf '%s\n' "$NATIVE_MODE $GITHUB_SHA $RUNNER_OS $RUNNER_ARCH" > "$RUNNER_TEMP/native-evidence/start.txt" + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v8 + with: + name: native-packs-${{ github.run_id }} + path: ${{ runner.temp }}/native-bundle + - name: Enable current ARM emulation + if: runner.os == 'Linux' && matrix.cpu == 'arm64' + uses: docker/setup-qemu-action@v4 + with: + platforms: arm64 + - name: Enable Rosetta for both x64 runtimes + if: matrix.mode == 'Rosetta' + run: sudo softwareupdate --install-rosetta --agree-to-license + - uses: actions/setup-node@v7 + with: + node-version: '*' + check-latest: true + architecture: ${{ runner.os == 'Linux' && 'x64' || matrix.cpu }} + - name: Download matching latest official Bun with digest + shell: bash + env: + GH_TOKEN: ${{ github.token }} + BUN_ASSET: ${{ matrix.bun }} + run: | + set -euo pipefail + mkdir "$RUNNER_TEMP/native-tools" + gh api repos/oven-sh/bun/releases/latest > "$RUNNER_TEMP/native-evidence/bun-release.json" + node -e "const fs=require('node:fs'),r=JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP+'/native-evidence/bun-release.json'));const a=r.assets.find(a=>a.name===process.env.BUN_ASSET);if(!a?.digest?.startsWith('sha256:'))throw Error('missing official digest');fs.writeFileSync(process.env.RUNNER_TEMP+'/native-evidence/bun-asset.json',JSON.stringify(a))" + url=$(node -p "JSON.parse(require('node:fs').readFileSync(process.env.RUNNER_TEMP+'/native-evidence/bun-asset.json')).browser_download_url") + curl -fsSL "$url" -o "$RUNNER_TEMP/native-tools/bun.zip" + node -e "const fs=require('node:fs'),c=require('node:crypto'),p=process.env.RUNNER_TEMP;require('node:assert/strict').equal('sha256:'+c.createHash('sha256').update(fs.readFileSync(p+'/native-tools/bun.zip')).digest('hex'),JSON.parse(fs.readFileSync(p+'/native-evidence/bun-asset.json')).digest)" + unzip -q "$RUNNER_TEMP/native-tools/bun.zip" -d "$RUNNER_TEMP/native-tools" + bun_dir="$RUNNER_TEMP/native-tools/${BUN_ASSET%.zip}" + echo "$bun_dir" >> "$GITHUB_PATH" + printf '%s\n' "$NATIVE_MODE" > "$RUNNER_TEMP/native-evidence/execution-mode.txt" + node -e "require('node:fs').writeFileSync(process.env.RUNNER_TEMP+'/native-evidence/runner.json',JSON.stringify(Object.fromEntries(['GITHUB_SHA','GITHUB_RUN_ID','GITHUB_RUN_ATTEMPT','RUNNER_OS','RUNNER_ARCH','ImageOS','ImageVersion','NATIVE_MODE','NATIVE_SUFFIX'].map(k=>[k,process.env[k]]))))" + - name: Consume on matching macOS or Windows runtimes + if: runner.os != 'Linux' + shell: bash + run: | + set -euo pipefail + bun install --frozen-lockfile --ignore-scripts + node --input-type=module <<'NODE' 2>&1 | tee "$RUNNER_TEMP/native-evidence/consumer.log" + import assert from 'node:assert/strict' + import { dirname, join } from 'node:path' + import { realpathSync } from 'node:fs' + import getExePath from './node_modules/@typescript/native/lib/getExePath.js' + import { command } from './e2e/binding/package/provenance.mjs' + import { consume } from './e2e/binding/package/consumer.mjs' + assert.equal(process.arch, process.env.NATIVE_CPU) + assert.equal(command('bun', ['-p', 'process.arch']), process.env.NATIVE_CPU) + const tsc = getExePath() + console.log({ mode: process.env.NATIVE_MODE, tsc, version: command(tsc, ['--version']) }) + const npmCli = process.platform === 'win32' ? join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js') : realpathSync(command('which', ['npm'])) + await consume({ bundle: join(process.env.RUNNER_TEMP, 'native-bundle'), suffix: process.env.NATIVE_SUFFIX, report: join(process.env.RUNNER_TEMP, 'native-evidence/consumer.json'), tsc, npmCli }) + NODE + - name: Consume current GNU or musl container with matching Node and Bun + if: runner.os == 'Linux' + shell: bash + env: + CONTAINER_IMAGE: ${{ matrix.image }} + CONTAINER_CPU: ${{ matrix.docker }} + BUN_ASSET: ${{ matrix.bun }} + run: | + set -euo pipefail + docker pull --platform "linux/$CONTAINER_CPU" "$CONTAINER_IMAGE" + docker image inspect "$CONTAINER_IMAGE" > "$RUNNER_TEMP/native-evidence/container.json" + docker run --rm -i --platform "linux/$CONTAINER_CPU" -v "$PWD:/repo" -v "$RUNNER_TEMP:/evidence" -w /repo -e NATIVE_SUFFIX -e NATIVE_CPU -e NATIVE_MODE -e BUN_ASSET "$CONTAINER_IMAGE" sh -eu <<'SH' 2>&1 | tee "$RUNNER_TEMP/native-evidence/consumer.log" + if command -v apk >/dev/null; then apk add --no-cache libstdc++ tar; else apt-get update && apt-get install -y --no-install-recommends libatomic1; fi + export PATH="/evidence/native-tools/${BUN_ASSET%.zip}:$PATH" + bun install --frozen-lockfile --ignore-scripts + node --input-type=module <<'NODE' + import assert from 'node:assert/strict' + import { realpathSync } from 'node:fs' + import getExePath from './node_modules/@typescript/native/lib/getExePath.js' + import { command } from './e2e/binding/package/provenance.mjs' + import { consume } from './e2e/binding/package/consumer.mjs' + assert.equal(process.arch, process.env.NATIVE_CPU) + assert.equal(command('bun', ['-p', 'process.arch']), process.env.NATIVE_CPU) + const tsc = getExePath() + console.log({ mode: process.env.NATIVE_MODE, tsc, version: command(tsc, ['--version']) }) + await consume({ bundle: '/evidence/native-bundle', suffix: process.env.NATIVE_SUFFIX, report: '/evidence/native-evidence/consumer.json', tsc, npmCli: realpathSync(command('which', ['npm'])) }) + NODE + SH + - uses: actions/upload-artifact@v7 + if: always() + with: + name: native-consumer-${{ matrix.suffix }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/native-evidence/* + if-no-files-found: error + retention-days: 7 + benchmark: runs-on: ubuntu-latest timeout-minutes: 60 @@ -613,7 +876,7 @@ jobs: with: registry-url: "https://registry.npmjs.org" node-version: latest - - run: bun install + - run: bun install --frozen-lockfile - run: bun run build - name: Verify built package entry points run: bun run verify:dist @@ -624,6 +887,11 @@ jobs: run: | set -euo pipefail for manifest in bindings/*/package.json packages/*/package.json; do + # Exactly the nine native names delegate to native-packs/consumer. + name=$(node -p "require('./$manifest').name") + case "$name" in + @devup-ui/binding|@devup-ui/binding-linux-x64-gnu|@devup-ui/binding-linux-arm64-gnu|@devup-ui/binding-linux-x64-musl|@devup-ui/binding-linux-arm64-musl|@devup-ui/binding-darwin-x64|@devup-ui/binding-darwin-arm64|@devup-ui/binding-win32-x64-msvc|@devup-ui/binding-win32-arm64-msvc) continue ;; + esac package_dir="$(dirname "$manifest")" pack_dir="$RUNNER_TEMP/publish-verify/${package_dir//\//-}" mkdir -p "$pack_dir" @@ -757,10 +1025,14 @@ jobs: # passed, and is the only job that can write contents, open pull requests or # mint an OIDC token for npm. release: - needs: [publish, landing-next-e2e, react-18, release-compat] + needs: [publish, landing-next-e2e, react-18, release-compat, native-consumer] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 45 + env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: core.autocrlf + GIT_CONFIG_VALUE_0: "false" permissions: contents: write id-token: write @@ -775,6 +1047,11 @@ jobs: fetch-depth: 0 fetch-tags: true + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + name: Install bun + - uses: actions-rust-lang/setup-rust-toolchain@v2 with: toolchain: stable @@ -790,11 +1067,6 @@ jobs: target/ key: cargo-release-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - name: Install bun - - name: Cache bun dependencies uses: actions/cache@v6 with: @@ -809,13 +1081,20 @@ jobs: with: registry-url: "https://registry.npmjs.org" node-version: latest - - run: bun install + - run: bun install --frozen-lockfile - run: bun run build - name: Verify built package entry points run: bun run verify:dist - name: Verify clean publish checkout run: git diff --exit-code + # Stable artifact name deliberately survives a release-only run_attempt retry. + - uses: actions/download-artifact@v8 + with: + name: native-packs-${{ github.run_id }} + path: ${{ runner.temp }}/native-release-bundle - uses: changepacks/action@main id: changepacks + env: + NATIVE_RELEASE_BUNDLE: ${{ runner.temp }}/native-release-bundle with: publish: true diff --git a/.gitignore b/.gitignore index a267616ff..960f0d0c6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ benchmark-run.json wasm-size.json release-results.json release-package-build.json +/bindings/devup-ui-binding/pkg/ +/bindings/devup-ui-binding-*/*.node diff --git a/Cargo.lock b/Cargo.lock index 44830b571..47297b21b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,6 +456,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "convert_case" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -559,6 +568,12 @@ dependencies = [ "serial_test", ] +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + [[package]] name = "dashmap" version = "6.2.1" @@ -579,6 +594,31 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "devup-ui-binding" +version = "0.1.0" +dependencies = [ + "bimap", + "devup-ui-binding-core", + "extractor", + "napi", + "napi-build", + "napi-derive", + "serde", + "sheet", +] + +[[package]] +name = "devup-ui-binding-core" +version = "0.1.0" +dependencies = [ + "bimap", + "css", + "extractor", + "serde_json", + "sheet", +] + [[package]] name = "devup-ui-wasm" version = "0.1.0" @@ -587,12 +627,14 @@ dependencies = [ "boa_engine", "console_error_panic_hook", "css", + "devup-ui-binding-core", "extractor", "getrandom", "insta", "js-sys", "rstest", "rustc-hash", + "serde", "serde-wasm-bindgen", "serde_json", "serial_test", @@ -792,6 +834,31 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-concurrency" version = "7.7.1" @@ -841,6 +908,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -859,8 +943,13 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1161,6 +1250,16 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1219,6 +1318,70 @@ dependencies = [ "adler2", ] +[[package]] +name = "napi" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a746ad50efbfb632ade1de4025519a477decbbd798cea1a6ac41d7c44f80083" +dependencies = [ + "bitflags", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b899b545d3aa6dca985939059f258c5488d34e4ecf39c274e20009748f4b846d" + +[[package]] +name = "napi-derive" +version = "3.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff48271a0f61abb83f3e2d1989fa71169143db95f6f948c3a8903961f2855dc" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2639052c5aff9956a21d7b6202fc61d5e89c5f19c72d289b7a0641ea94e8159" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn 3.0.9", +] + +[[package]] +name = "napi-sys" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d153beb7d5c973f4b9395be8a83054a28968b61188f1dd1a2cc2321af22daea2" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nonmax" version = "0.5.5" diff --git a/Cargo.toml b/Cargo.toml index b74fcdbe4..824e9553c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["libs/extractor", "bindings/devup-ui-wasm", "libs/sheet", "libs/css"] +members = ["libs/extractor", "bindings/devup-ui-wasm", "libs/sheet", "libs/css", "libs/binding", "bindings/devup-ui-binding"] [workspace.lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } @@ -59,3 +59,7 @@ codegen-units = 1 strip = true # Abort on panic: smaller binary, no unwinding overhead panic = "abort" + +[profile.native] +inherits = "release" +opt-level = 3 diff --git a/bindings/argument-validation.cjs b/bindings/argument-validation.cjs new file mode 100644 index 000000000..66e41fb08 --- /dev/null +++ b/bindings/argument-validation.cjs @@ -0,0 +1,153 @@ +'use strict' +module.exports = function createBoundary() { + const extraction = + 'filename:s code:s _package:s css_dir:s single_css:b import_main_css_in_code:b import_main_css_in_css:b import_aliases:aliases' + const descriptors = { + codeExtract: extraction, + codeExtractWithoutSourceMap: extraction, + getCss: 'file_num:n import_main_css:b', + getThemeInterface: + 'package_name:s color_interface_name:s typography_interface_name:s length_interface_name:s shadows_interface_name:s theme_interface_name:s', + hasDevupUI: 'filename:s code:s _package:s', + importCanonicalMap: 'map_object:canonical', + importClassMap: 'sheet_object:class', + importFileMap: 'sheet_object:filemap', + importFileRoutes: 'map_object:routes', + importSheet: 'sheet_object:sheet', + registerShorthands: 'shorthands:shorthands', + registerTheme: 'theme_object:theme', + reserveClassSlots: + 'rows:rows _package:s single_css:b import_main_css_in_code:b _import_main_css_in_css:b import_aliases:aliases', + seedFileMap: 'files:a', + setAtomHoist: 'threshold:n', + setDebug: 'debug:b', + setModuleResolver: 'resolver:f', + setPrefix: 'prefix:p', + } + const labels = { + s: 'a string', + b: 'a boolean', + n: 'a number, null or undefined', + p: 'a string, null or undefined', + f: 'a function, null or undefined', + a: 'an array of strings', + aliases: 'an alias mapping', + canonical: 'a canonical mapping', + class: 'a nested class-index mapping', + filemap: 'a file-index mapping', + routes: 'a file-to-route mapping', + sheet: 'a sheet state', + shorthands: 'a shorthand mapping', + theme: 'a theme', + rows: 'an iterable of 3-string rows', + } + const object = (value) => + value !== null && (typeof value === 'object' || typeof value === 'function') + const checks = { + s: (value) => typeof value === 'string', + b: (value) => typeof value === 'boolean', + n: (value) => value == null || typeof value === 'number', + p: (value) => value == null || typeof value === 'string', + f: (value) => value == null || typeof value === 'function', + a: Array.isArray, + } + let frame + const fail = (slot) => { + frame.slot = slot + return (frame.token ??= {}) + } + const shape = (value, pair) => (pair ? value != null : object(value)) + const argumentError = (name, [parameter, kind]) => + new TypeError(`${name}(): argument ${parameter} must be ${labels[kind]}`) + return { + fail, + shape, + begin(slot) { + frame.slot = slot + frame.decoding = true + }, + end() { + frame.decoding = false + frame.caught = null + }, + caught(value) { + if (frame?.decoding && (frame.token === null || value !== frame.token)) + (frame.caught ??= new Set()).add(value) + }, + error(value) { + const caught = frame.caught?.has(value) + frame.decoding = false + frame.caught = null + return caught ? new Error(String(value)) : fail(frame.slot) + }, + guard(value, pair = false) { + // Typed map keys/sequence leaves reject this, retaining normal Rust cleanup. + if (frame?.decoding && !shape(value, pair)) return pair ? [] : {} + return value + }, + wrap(api) { + for (const [name, description] of Object.entries(descriptors)) { + const slots = description.split(' ').map((entry) => entry.split(':')) + const isExtraction = description === extraction + const target = api[name] + const apply = function (target, receiver, args) { + const parent = frame + frame = { slot: -1, token: null, caught: null, decoding: false } + try { + if (!( + isExtraction && + typeof args[0] === 'string' && + typeof args[1] === 'string' && + typeof args[2] === 'string' && + typeof args[3] === 'string' && + typeof args[4] === 'boolean' && + typeof args[5] === 'boolean' && + typeof args[6] === 'boolean' + )) { + for (let slot = 0; slot < slots.length; slot++) { + const check = checks[slots[slot][1]] + if (check && !check(args[slot])) throw fail(slot) + } + } + if (name === 'seedFileMap') { + const files = args[0] + void (files.length * 4) + const copied = [] + for (let i = 0; i < files.length; i++) { + const value = files[i] + if (typeof value !== 'string') throw fail(0) + copied.push(value) + } + const length = +files.length >>> 0 + if (length > copied.length) throw fail(0) + copied.length = length + args[0] = copied + } + return Reflect.apply(target, receiver, args) + } catch (error) { + if (frame.token !== null && error === frame.token) { + throw argumentError(name, slots[frame.slot]) + } + throw error + } finally { + frame = parent + } + } + if (description === extraction) { + const wrapped = function (...args) { + const constructor = new.target === wrapped ? target : new.target + if (constructor) return Reflect.construct(target, args, constructor) + return apply(target, this, args) + } + Object.defineProperties(wrapped, { + name: { value: target.name }, + length: { value: target.length }, + prototype: { value: target.prototype }, + }) + api[name] = wrapped + } else api[name] = new Proxy(target, { apply }) + } + return api + }, + } +} diff --git a/bindings/devup-ui-binding-darwin-arm64/package.json b/bindings/devup-ui-binding-darwin-arm64/package.json new file mode 100644 index 000000000..8d6486494 --- /dev/null +++ b/bindings/devup-ui-binding-darwin-arm64/package.json @@ -0,0 +1,19 @@ +{ + "name": "@devup-ui/binding-darwin-arm64", + "version": "0.1.0", + "repository": "https://github.com/dev-five-git/devup-ui", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "main": "binding.darwin-arm64.node", + "files": [ + "binding.darwin-arm64.node" + ] +} diff --git a/bindings/devup-ui-binding-darwin-x64/package.json b/bindings/devup-ui-binding-darwin-x64/package.json new file mode 100644 index 000000000..b0556ea94 --- /dev/null +++ b/bindings/devup-ui-binding-darwin-x64/package.json @@ -0,0 +1,19 @@ +{ + "name": "@devup-ui/binding-darwin-x64", + "version": "0.1.0", + "repository": "https://github.com/dev-five-git/devup-ui", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "main": "binding.darwin-x64.node", + "files": [ + "binding.darwin-x64.node" + ] +} diff --git a/bindings/devup-ui-binding-linux-arm64-gnu/package.json b/bindings/devup-ui-binding-linux-arm64-gnu/package.json new file mode 100644 index 000000000..5d4d9a338 --- /dev/null +++ b/bindings/devup-ui-binding-linux-arm64-gnu/package.json @@ -0,0 +1,22 @@ +{ + "name": "@devup-ui/binding-linux-arm64-gnu", + "version": "0.1.0", + "repository": "https://github.com/dev-five-git/devup-ui", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "main": "binding.linux-arm64-gnu.node", + "files": [ + "binding.linux-arm64-gnu.node" + ] +} diff --git a/bindings/devup-ui-binding-linux-arm64-musl/package.json b/bindings/devup-ui-binding-linux-arm64-musl/package.json new file mode 100644 index 000000000..47374ec8c --- /dev/null +++ b/bindings/devup-ui-binding-linux-arm64-musl/package.json @@ -0,0 +1,22 @@ +{ + "name": "@devup-ui/binding-linux-arm64-musl", + "version": "0.1.0", + "repository": "https://github.com/dev-five-git/devup-ui", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "main": "binding.linux-arm64-musl.node", + "files": [ + "binding.linux-arm64-musl.node" + ] +} diff --git a/bindings/devup-ui-binding-linux-x64-gnu/package.json b/bindings/devup-ui-binding-linux-x64-gnu/package.json new file mode 100644 index 000000000..49916fc02 --- /dev/null +++ b/bindings/devup-ui-binding-linux-x64-gnu/package.json @@ -0,0 +1,22 @@ +{ + "name": "@devup-ui/binding-linux-x64-gnu", + "version": "0.1.0", + "repository": "https://github.com/dev-five-git/devup-ui", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "main": "binding.linux-x64-gnu.node", + "files": [ + "binding.linux-x64-gnu.node" + ] +} diff --git a/bindings/devup-ui-binding-linux-x64-musl/package.json b/bindings/devup-ui-binding-linux-x64-musl/package.json new file mode 100644 index 000000000..16573253c --- /dev/null +++ b/bindings/devup-ui-binding-linux-x64-musl/package.json @@ -0,0 +1,22 @@ +{ + "name": "@devup-ui/binding-linux-x64-musl", + "version": "0.1.0", + "repository": "https://github.com/dev-five-git/devup-ui", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "main": "binding.linux-x64-musl.node", + "files": [ + "binding.linux-x64-musl.node" + ] +} diff --git a/bindings/devup-ui-binding-win32-arm64-msvc/package.json b/bindings/devup-ui-binding-win32-arm64-msvc/package.json new file mode 100644 index 000000000..1cb78d0a3 --- /dev/null +++ b/bindings/devup-ui-binding-win32-arm64-msvc/package.json @@ -0,0 +1,19 @@ +{ + "name": "@devup-ui/binding-win32-arm64-msvc", + "version": "0.1.0", + "repository": "https://github.com/dev-five-git/devup-ui", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "main": "binding.win32-arm64-msvc.node", + "files": [ + "binding.win32-arm64-msvc.node" + ] +} diff --git a/bindings/devup-ui-binding-win32-x64-msvc/package.json b/bindings/devup-ui-binding-win32-x64-msvc/package.json new file mode 100644 index 000000000..7f23cfac8 --- /dev/null +++ b/bindings/devup-ui-binding-win32-x64-msvc/package.json @@ -0,0 +1,19 @@ +{ + "name": "@devup-ui/binding-win32-x64-msvc", + "version": "0.1.0", + "repository": "https://github.com/dev-five-git/devup-ui", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "main": "binding.win32-x64-msvc.node", + "files": [ + "binding.win32-x64-msvc.node" + ] +} diff --git a/bindings/devup-ui-binding/Cargo.toml b/bindings/devup-ui-binding/Cargo.toml new file mode 100644 index 000000000..1391469aa --- /dev/null +++ b/bindings/devup-ui-binding/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "devup-ui-binding" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Synchronous native build-host adapter for Devup UI" +license = "Apache-2.0" +repository = "https://github.com/dev-five-git/devup-ui" +keywords = ["css", "react", "devup-ui", "napi"] +categories = ["development-tools", "web-programming"] + +[lib] +crate-type = ["cdylib"] +test = false +doctest = false + +[features] +native-coverage = [] + +[[test]] +name = "native_host" +path = "../../e2e/binding/native-coverage/native_host.rs" +required-features = ["native-coverage"] + +[lints] +workspace = true + +[dependencies] +devup-ui-binding-core = { path = "../../libs/binding" } +extractor = { path = "../../libs/extractor" } +sheet = { path = "../../libs/sheet" } +napi = { version = "3.15.0", features = ["napi6"] } +napi-derive = "3.6.13" +serde = { version = "1.0.229", features = ["derive"] } +bimap = { version = "0.6.3", features = ["serde"] } + +[build-dependencies] +napi-build = "2.6.0" diff --git a/bindings/devup-ui-binding/README.md b/bindings/devup-ui-binding/README.md new file mode 100644 index 000000000..993025949 --- /dev/null +++ b/bindings/devup-ui-binding/README.md @@ -0,0 +1,99 @@ +# @devup-ui/binding + +Synchronous native build-time compiler for Node.js and Bun. Both +`require('@devup-ui/binding')` and named ESM imports expose exactly the existing +29 compiler names, with identical function/class identities and no default init. +Authored CommonJS/ESM declarations are included; no WASM dependency is required. + +The private loader selects one optional platform sibling: Windows MSVC, macOS, +or Linux GNU/musl, on x64/arm64. Install with optional dependencies enabled. +Missing packages name the required sibling; genuine image/load errors propagate. +There is no browser, WASI, download or silent WASM fallback. Only `.` is exported; +raw addon helpers, positional requests, adapter and interop are not package ABI. + +From the repository, build an explicit target with +`node bindings/devup-ui-binding/build.mjs --target x86_64-pc-windows-msvc`. +The driver uses locked Cargo, optimized `native` profile and two jobs; removes +the selected previous source/copy, then copies only that target's fresh image +to its sibling. It also generates package-relative `pkg/index.cjs`. +Linux cross builds may append `--zigbuild` (current cargo-zigbuild/Zig required; +GNU compatibility floor glibc 2.28); musl uses dynamic linking, not crt-static. +The canonical eight targets are in `e2e/binding/package/matrix.mjs`. +The existing `node script.cjs ` private +producer remains unchanged in behavior; `--public ` is +the separate relocatable mode. Generated files and images are not source. + +All nine manifests seed 0.1.0 with same-release workspace optional edges; +the release pipeline must build all targets and materialize exact versions. +No install hooks are used. + +## Native CI and release + +`publish.yml` adds an independent read-only lane: eight fresh optimized builds, +one full-nine pack collector, then eight consumers of the **same** tarballs. +It does not block existing validation/benchmark jobs; main release additionally +requires all native consumers. Linux ARM consumers are QEMU emulation; macOS +x64 uses Rosetta with both x64 Node and Bun. Windows ARM builds and consumes +on the newest exposed real ARM runner (currently `windows-11-vs2026-arm`). +Linux uses current GNU/Alpine Node images and digest-verified latest official +Bun native/musl assets. Each container executes its installed TypeScript binary +before declaration checks; an incompatible binary fails, never skips the gate. +Receipts retain effective jobs2/musl flags, raw source hashes, actual images, +tools, build logs and target inspection. No compiled-target cache is reused. + +Repository scripts (arguments are forwarded unchanged): + +```sh +bun run build:native:artifact [--zigbuild] +bun run pack:native +bun run test:native:package +bun run test:native:consumer +``` + +Package tests require `NATIVE_PROOF_ARTIFACTS` (a directory containing exactly +one real host artifact directory) and `NATIVE_PROOF_BUNDLE` (its verified bundle, +full-nine in CI). Explicit dirty/host-only flags are local proof modes, not +release acceptance. Collector first needs `pkg/index.cjs` generated with the +public producer. All output directories must live outside the checkout. + +Per-manifest changepacks overrides invoke `publish.mjs` from the selected native +package directory; other packages retain their existing commands. Set +`NATIVE_RELEASE_BUNDLE` to the downloaded immutable full bundle. `dry-run` is +local source/payload/manifest validation only: no registry query, lifecycle or +`npm publish --dry-run`. Real mode requires this repository's main push, +`publish.yml` workflow identity and OIDC environment. It publishes only the +selected absolute tarball with scripts disabled; root requires all eight exact +registry versions **and** matching integrities first. Changepacks owns selection, +dependency ordering and release receipts; the dispatcher never adds siblings. + +Source validation happens inside each invocation, after the action's possible +sourceSha checkout. Every recorded input byte/commit/tree must match; only its +untracked `changepacks`/`changepacks.exe` tool is tolerated. Locks are neither +hidden, restored nor regenerated during publication. Repository installs before +clean-source or raw-source checks use `bun install --frozen-lockfile`. Bun 1.4.4 +accepts workspace-version-only drift without rewriting `bun.lock`; there is no +extra lock-consistency gate or manual Update Versions step. + +Artifacts retain seven days. A release-only retry reuses `native-packs-` +and original producer identity `/1`, regardless of release run_attempt. +Do not rerun build/collector in that run: they reject attempts after1 and cannot +overwrite original payloads. A missing/expired or incomplete bundle needs a new +fresh workflow run; older source intents fail closed rather than using current +images. No retry may silently replace a partially published release's payloads. + +Before first automated0.1.1, the owner will publish0.0.0 placeholders for all +nine exact npm names, then configure direct npm publish permission/Trusted +Publishers for this repo's `publish.yml`; npm requires each package to exist +first. Workers never publish/register names or consume0.1.1. An unrelated0.0.0 +does not block target publication or satisfy exact target-version/integrity +prerequisites. Foreign builds, +full-nine runtime execution, OIDC and row timings are CI-only until actually run. +No plugin/default-engine change is authorized by this integration. + +The synchronous Unknown-backed decoder calls the existing engine Serde implementations, without JSON preconversion. JSON.stringify is used only after invalid-type selection for the generated-WASM debug diagnostic. Resolver handles are per environment; replacement, both resets and teardown release them. Native state still does not support concurrent independent builds. + +The decoder is private and target-bounded: unused Serde hints use trait forwarding, not an additional public compatibility API. New engine Deserialize shapes require fresh boundary and real-host coverage evidence. + +Malformed-enum error paths follow shipped serde-wasm-bindgen 0.6.5: Object.entries result length is read and coerced once for the check, then read and coerced again for the invalid-length diagnostic when the first length is not one, before any index read. The second value supplies the diagnostic; errors thrown by its getter or coercion propagate unchanged. Valid enums retain one length read. + +The shared native/WASM boundary validates ordinary calls at 45 argument positions in 18 functions: malformed inputs throw a genuine `TypeError` naming the function, parameter and required type before state mutation. Preflight defaults the entire alias map on schema failure (after diagnostics) or a caught iterator-import failure whose `new Error(String(error))` conversion succeeds. Raw access failures and conversion faults escape unchanged before resolver selection; conversion runs once with the pending failure cleared, so reentry is isolated and resolver changes take effect before the outer core starts. Extraction and reservation remain strict. For example, `setPrefix(false)` throws `setPrefix(): argument prefix must be a string, null or undefined`; string/nullish prefixes remain supported. Extraction wrappers retain the measured metadata, receiver, extra-argument and apply/construct policy; `seedFileMap` validates and copies the caller's array before engine entry without rereading its indices. Same-revision Node/Bun identity, 267-case invalid-input, 73-exception preservation and seed-safety gates cover these contracts, not optimized shipping-native performance or real Node-host Linux Rust/authored-JS 100% coverage, which remain public-delivery gates. diff --git a/bindings/devup-ui-binding/adapter.cjs b/bindings/devup-ui-binding/adapter.cjs new file mode 100644 index 000000000..77bf341e9 --- /dev/null +++ b/bindings/devup-ui-binding/adapter.cjs @@ -0,0 +1,115 @@ +'use strict' +const { boundary, withAccess, resolverCause } = require('./interop.cjs') +exports.createBinding = function createBinding(raw) { + raw.init() + const call = withAccess + const index = (value) => (value == null ? undefined : value >>> 0) + function codeExtract( + filename, + code, + packageName, + cssDir, + singleCss, + importCode, + importCss, + aliases, + ) { + return call(raw.extract, [ + filename, + code, + packageName, + cssDir, + singleCss, + importCode, + importCss, + aliases, + true, + ]) + } + function codeExtractWithoutSourceMap( + filename, + code, + packageName, + cssDir, + singleCss, + importCode, + importCss, + aliases, + ) { + return call(raw.extract, [ + filename, + code, + packageName, + cssDir, + singleCss, + importCode, + importCss, + aliases, + false, + ]) + } + function setModuleResolver(resolver) { + raw.setResolver( + resolver == null + ? undefined + : (specifier, importer) => { + try { + const result = resolver.call(null, specifier, importer) + if (result == null) return undefined + if (typeof result !== 'object' || Array.isArray(result)) + return 'malformed module resolver result: expected an object {path:string,code:string} or null/undefined' + const path = Reflect.get(result, 'path') + if (typeof path !== 'string') + return 'malformed module resolver result: `path` must be a string' + if (path.length === 0) + return 'malformed module resolver result: `path` must be a non-empty string' + const code = Reflect.get(result, 'code') + if (typeof code !== 'string') + return 'malformed module resolver result: `code` must be a string' + return { path, code } + } catch (error) { + return resolverCause(error) + } + }, + ) + } + function seedFileMap(files) { + raw.seedFileMap(files) + } + if (Symbol.dispose) + raw.Output.prototype[Symbol.dispose] = raw.Output.prototype.free + return boundary.wrap({ + Output: raw.Output, + codeExtract, + codeExtractWithoutSourceMap, + exportCanonicalMap: raw.exportCanonicalMap, + exportClassMap: raw.exportClassMap, + exportFileMap: raw.exportFileMap, + exportSheet: raw.exportSheet, + getCss: (file, main) => raw.getCss(index(file), main), + getDefaultTheme: raw.getDefaultTheme, + getPrefix: raw.getPrefix, + getThemeInterface: (pkg, color, typography, length, shadows, theme) => + raw.getThemeInterface(pkg, [color, typography, length, shadows, theme]), + getUnreservedClassSlotCount: raw.getUnreservedClassSlotCount, + hasDevupUI: (file, code, pkg, aliases) => + call(raw.hasDevupUi, [file, code, pkg, aliases]), + importCanonicalMap: (value) => call(raw.importCanonicalMap, value), + importClassMap: (value) => call(raw.importClassMap, value), + importFileMap: (value) => call(raw.importFileMap, value), + importFileRoutes: (value) => call(raw.importFileRoutes, value), + importSheet: (value) => call(raw.importSheet, value), + isDebug: raw.isDebug, + registerShorthands: (value) => call(raw.registerShorthands, value), + registerTheme: (value) => call(raw.registerTheme, value), + reserveClassSlots: (rows, pkg, single, code, css, aliases) => + call(raw.reserve, [rows, pkg, single, code, css, aliases]), + resetBuildState: () => raw.reset(false), + resetStateForTesting: () => raw.reset(true), + seedFileMap, + setAtomHoist: (value) => raw.setAtomHoist(index(value)), + setDebug: (value) => raw.setDebug(value), + setModuleResolver, + setPrefix: (value) => raw.setPrefix(value == null ? undefined : value), + }) +} diff --git a/bindings/devup-ui-binding/build.mjs b/bindings/devup-ui-binding/build.mjs new file mode 100644 index 000000000..ef4673c3b --- /dev/null +++ b/bindings/devup-ui-binding/build.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { copyFileSync, mkdirSync, rmSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { targets } from '../../e2e/binding/package/matrix.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '../..') +const args = process.argv.slice(2) +assert.ok( + args.length === 2 || (args.length === 3 && args[2] === '--zigbuild'), + 'Usage: node build.mjs --target [--zigbuild]', +) +assert.equal(args[0], '--target') +const row = targets.find(({ target }) => target === args[1]) +assert.ok(row, `Unsupported build target: ${args[1]}`) +const zig = args[2] === '--zigbuild' +assert.ok(!zig || row.os === 'linux', 'zigbuild is only supported for Linux') +const targetDirectory = resolve(root, process.env.CARGO_TARGET_DIR || 'target') +const library = + row.os === 'win32' + ? 'devup_ui_binding.dll' + : row.os === 'darwin' + ? 'libdevup_ui_binding.dylib' + : 'libdevup_ui_binding.so' +const source = join(targetDirectory, row.target, 'native', library) +const destination = join( + here, + `../devup-ui-binding-${row.suffix}/binding.${row.suffix}.node`, +) +// Remove both images before Cargo: a failed build cannot leave a shippable stale copy. +rmSync(destination, { force: true }) +rmSync(source, { force: true }) +const target = zig && row.libc === 'glibc' ? `${row.target}.2.28` : row.target +const env = { ...process.env, CARGO_BUILD_JOBS: '2' } +if (row.libc === 'musl') { + env.RUSTFLAGS = `${env.RUSTFLAGS || ''} -C target-feature=-crt-static`.trim() +} +execFileSync( + 'cargo', + [ + zig ? 'zigbuild' : 'build', + '--locked', + '--profile', + 'native', + '--target', + target, + '--jobs', + '2', + '-p', + 'devup-ui-binding', + ], + { cwd: root, env, stdio: 'inherit' }, +) +assert.ok(statSync(source).size > 0, 'Cargo must produce the requested image') +copyFileSync(source, destination) +mkdirSync(join(here, 'pkg'), { recursive: true }) +execFileSync( + process.execPath, + [join(here, 'script.cjs'), '--public', join(here, 'pkg/index.cjs')], + { cwd: root, stdio: 'inherit' }, +) +console.info( + JSON.stringify({ + target: row.target, + profile: 'native', + source, + destination, + }), +) diff --git a/bindings/devup-ui-binding/build.rs b/bindings/devup-ui-binding/build.rs new file mode 100644 index 000000000..0f1b01002 --- /dev/null +++ b/bindings/devup-ui-binding/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/bindings/devup-ui-binding/index.d.cts b/bindings/devup-ui-binding/index.d.cts new file mode 100644 index 000000000..cd21d7210 --- /dev/null +++ b/bindings/devup-ui-binding/index.d.cts @@ -0,0 +1,89 @@ +export class Output { + private constructor() + free(): void + [Symbol.dispose](): void + readonly code: string + readonly cssFile: string | undefined + readonly css: string | undefined + /** Each access returns an independent array; mutating it does not change native state. */ + readonly dependencies: string[] + readonly map: string | undefined + readonly updatedBaseStyle: boolean +} + +// Positional signatures intentionally match the existing 29-name compiler ABI. +export function codeExtract( + filename: string, + code: string, + packageName: string, + cssDir: string, + singleCss: boolean, + importMainCssInCode: boolean, + importMainCssInCss: boolean, + importAliases: unknown, +): Output +export function codeExtractWithoutSourceMap( + filename: string, + code: string, + packageName: string, + cssDir: string, + singleCss: boolean, + importMainCssInCode: boolean, + importMainCssInCss: boolean, + importAliases: unknown, +): Output +export function exportCanonicalMap(): string +export function exportClassMap(): string +export function exportFileMap(): string +export function exportSheet(): string +export function getCss( + fileNum: number | null | undefined, + importMainCss: boolean, +): string +export function getDefaultTheme(): string | undefined +export function getPrefix(): string | undefined +export function getThemeInterface( + packageName: string, + color: string, + typography: string, + length: string, + shadows: string, + theme: string, +): string +export function getUnreservedClassSlotCount(): number +export function hasDevupUI( + filename: string, + code: string, + packageName: string, + importAliases: unknown, +): boolean +export function importCanonicalMap(value: unknown): void +export function importClassMap(value: unknown): void +export function importFileMap(value: unknown): void +export function importFileRoutes(value: unknown): void +export function importSheet(value: unknown): void +export function isDebug(): boolean +export function registerShorthands(value: unknown): void +export function registerTheme(value: unknown): void +export function reserveClassSlots( + rows: unknown, + packageName: string, + singleCss: boolean, + importMainCssInCode: boolean, + importMainCssInCss: boolean, + importAliases: unknown, +): void +export function resetBuildState(): void +export function resetStateForTesting(): void +export function seedFileMap(files: string[]): void +export function setAtomHoist(threshold?: number | null): void +export function setDebug(debug: boolean): void +export function setModuleResolver( + resolver?: + | (( + specifier: string, + importer: string, + ) => { readonly path: string; readonly code: string } | null | undefined) + | null, +): void +export function setPrefix(prefix?: string | null): void diff --git a/bindings/devup-ui-binding/index.d.mts b/bindings/devup-ui-binding/index.d.mts new file mode 100644 index 000000000..08c22d28e --- /dev/null +++ b/bindings/devup-ui-binding/index.d.mts @@ -0,0 +1 @@ +export * from './index.cjs' diff --git a/bindings/devup-ui-binding/index.mjs b/bindings/devup-ui-binding/index.mjs new file mode 100644 index 000000000..c9f981b23 --- /dev/null +++ b/bindings/devup-ui-binding/index.mjs @@ -0,0 +1,33 @@ +import binding from './pkg/index.cjs' + +export const { + Output, + codeExtract, + codeExtractWithoutSourceMap, + exportCanonicalMap, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + getDefaultTheme, + getPrefix, + getThemeInterface, + getUnreservedClassSlotCount, + hasDevupUI, + importCanonicalMap, + importClassMap, + importFileMap, + importFileRoutes, + importSheet, + isDebug, + registerShorthands, + registerTheme, + reserveClassSlots, + resetBuildState, + resetStateForTesting, + seedFileMap, + setAtomHoist, + setDebug, + setModuleResolver, + setPrefix, +} = binding diff --git a/bindings/devup-ui-binding/interop.cjs b/bindings/devup-ui-binding/interop.cjs new file mode 100644 index 000000000..192c70c07 --- /dev/null +++ b/bindings/devup-ui-binding/interop.cjs @@ -0,0 +1,208 @@ +'use strict' +const boundary = require('../argument-validation.cjs')() +exports.boundary = boundary +const OP = { + preflightFailure: 0, + schema: 1, + array: 2, + safeInt: 3, + bytes: 4, + debug: 5, + bigint: 6, + undefined: 7, + anyMap: 8, + entries: 9, + length: 10, + index: 11, + pair: 12, + field: 13, + iter: 14, + next: 15, + mapStart: 16, +} +exports.OP = OP +const schemaToken = class {} +const accessToken = class {} +function instance(value, type) { + try { + return value instanceof type + } catch { + return false + } +} +function debugString(value) { + if (value == null) return `${value}` + switch (typeof value) { + case 'number': + case 'boolean': + return `${value}` + case 'string': + return `"${value}"` + case 'symbol': { + const description = value.description + return description == null ? 'Symbol' : `Symbol(${description})` + } + case 'function': { + const name = value.name + return typeof name === 'string' && name.length > 0 + ? `Function(${name})` + : 'Function' + } + default: + break + } + if (Array.isArray(value)) { + const length = value.length + let text = '[' + if (length > 0) text += debugString(value[0]) + for (let i = 1; i < length; i++) text += ', ' + debugString(value[i]) + return text + ']' + } + const matches = /\[object ([^\]]+)\]/.exec(toString.call(value)) + if (!matches || matches.length < 2) return toString.call(value) + const name = matches[1] + if (name === 'Object') { + try { + return 'Object(' + JSON.stringify(value) + ')' + } catch { + return 'Object' + } + } + if (value instanceof Error) + return `${value.name}: ${value.message}\n${value.stack}` + return name +} +exports.resolverCause = function resolverCause(error) { + if (typeof error === 'string') return error + try { + const message = Reflect.get(error, 'message') + if (typeof message === 'string') return message + } catch { + return `JsValue(${debugString(error)})` + } + return `JsValue(${debugString(error)})` +} +function bytes(value) { + const source = instance(value, Uint8Array) + ? value + : instance(value, ArrayBuffer) + ? new Uint8Array(value) + : undefined + if (source === undefined) return undefined + const copy = new Uint8Array(source.length) + Uint8Array.prototype.set.call(copy, source) + return copy +} +exports.withAccess = function withAccess(target, value) { + let failure, schema + const access = (operation, value, key) => { + let wrapped = false + try { + let result + switch (operation) { + case OP.preflightFailure: { + const pending = failure + failure = undefined + pending(true) + break + } + case OP.schema: + schema = boundary.fail(key) + break + case OP.array: + result = Array.isArray(value) + break + case OP.safeInt: + result = Number.isSafeInteger(value) + break + case OP.bytes: + result = bytes(value) + break + case OP.debug: + result = `JsValue(${debugString(value)})` + break + case OP.bigint: + result = `${value}` + break + case OP.undefined: + result = undefined + break + case OP.anyMap: + result = !(Symbol.iterator in value) || instance(value, Map) + break + case OP.entries: { + const entries = Object.entries(value) + result = [entries, entries.length >>> 0] + break + } + case OP.length: + result = value.length >>> 0 + break + case OP.index: + result = value[key] + break + case OP.pair: + if (!boundary.shape(value, true)) return schemaToken + result = [value[0], value[1]] + break + case OP.field: { + const field = value[key] + result = [field !== undefined || key in value, field] + break + } + case OP.iter: + case OP.mapStart: { + if (!boundary.shape(value)) return schemaToken + const iteratorSymbol = Symbol.iterator + wrapped = true + const method = Reflect.get(value, iteratorSymbol) + if (typeof method === 'function') { + const iterator = method.call(value) + wrapped = false + result = + typeof iterator === 'object' && + iterator !== null && + typeof iterator.next === 'function' + ? iterator + : undefined + } + if (operation === OP.mapStart) { + wrapped = false + if (result !== undefined) result = [1, result] + else if (key === 1) { + const entries = Object.entries(value) + result = [2, entries, entries.length >>> 0] + } else result = [0] + } + break + } + case OP.next: { + wrapped = true + const next = value.next() + wrapped = false + if (!boundary.shape(next)) return schemaToken + result = + (+next.done | 0) !== 0 ? [true, undefined] : [false, next.value] + break + } + default: + throw new Error(`unknown private access operation: ${operation}`) + } + return result + } catch (error) { + failure = (forgive = false) => { + if (!wrapped) throw error + const converted = new Error(String(error)) + if (!forgive) throw converted + } + return accessToken + } + } + try { + return target(value, access, schemaToken, accessToken) + } catch (error) { + if (failure) return failure() + if (schema) throw schema + throw error + } +} diff --git a/bindings/devup-ui-binding/loader.cjs b/bindings/devup-ui-binding/loader.cjs new file mode 100644 index 000000000..ea4086425 --- /dev/null +++ b/bindings/devup-ui-binding/loader.cjs @@ -0,0 +1,39 @@ +'use strict' +// Private loader: generated pkg/index.cjs is the only CommonJS public entry. +const { platform, arch } = process +let libc +if (platform === 'linux') { + const report = process.report.getReport() + libc = report.header.glibcVersionRuntime ? 'gnu' : 'musl' +} +let suffix +if (arch === 'x64' || arch === 'arm64') { + switch (platform) { + case 'linux': + suffix = `${platform}-${arch}-${libc}` + break + case 'win32': + suffix = `${platform}-${arch}-msvc` + break + case 'darwin': + suffix = `${platform}-${arch}` + break + } +} +if (!suffix) { + throw new Error( + `Unsupported @devup-ui/binding target: ${platform}/${arch}/${libc ?? 'none'}`, + ) +} +const name = `@devup-ui/binding-${suffix}` +try { + require.resolve(`${name}/package.json`) +} catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') throw error + throw new Error( + `Missing optional dependency ${name}. Reinstall @devup-ui/binding with optional dependencies enabled (do not use --omit=optional).`, + { cause: error }, + ) +} +// Do not catch corrupt images, missing transitive libraries or addon failures. +module.exports = require(name) diff --git a/bindings/devup-ui-binding/loader.test.cjs b/bindings/devup-ui-binding/loader.test.cjs new file mode 100644 index 000000000..c00010b2f --- /dev/null +++ b/bindings/devup-ui-binding/loader.test.cjs @@ -0,0 +1,138 @@ +'use strict' +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const { test } = require('node:test') +const { runInNewContext } = require('node:vm') +const source = readFileSync(join(__dirname, 'loader.cjs'), 'utf8') + +// Foreign host simulation proves dispatch only, never native execution. +function fixture(platform, arch, behavior = {}) { + const calls = [] + const raw = {} + const missing = Object.assign(new Error('fixture resolution failure'), { + code: 'MODULE_NOT_FOUND', + }) + const require = (name) => { + calls.push(['load', name]) + if (behavior.loadError) throw behavior.loadError + return raw + } + require.resolve = (name) => { + calls.push(['resolve', name]) + if (behavior.resolveError) throw behavior.resolveError + if (behavior.missing) throw missing + return name + } + const module = { exports: {} } + const process = { + platform, + arch, + report: { + getReport() { + calls.push(['report']) + if (behavior.reportError) throw behavior.reportError + return { header: behavior.musl ? {} : { glibcVersionRuntime: '2.39' } } + }, + }, + } + return { + calls, + raw, + missing, + run() { + runInNewContext( + source, + { module, require, process }, + { filename: join(__dirname, 'loader.cjs') }, + ) + return module.exports + }, + } +} + +for (const platform of ['win32', 'darwin', 'linux']) { + for (const arch of ['x64', 'arm64']) { + for (const musl of platform === 'linux' ? [false, true] : [false]) { + test(`selects ${platform}/${arch}/${musl ? 'musl' : 'gnu'} once`, () => { + // Given + const host = fixture(platform, arch, { musl }) + const suffix = + platform === 'linux' + ? `-${musl ? 'musl' : 'gnu'}` + : platform === 'win32' + ? '-msvc' + : '' + const name = `@devup-ui/binding-${platform}-${arch}${suffix}` + // When + const result = host.run() + // Then: only the selected package is requested, with Linux-only libc inspection. + assert.equal(result, host.raw) + assert.deepEqual(host.calls, [ + ...(platform === 'linux' ? [['report']] : []), + ['resolve', `${name}/package.json`], + ['load', name], + ]) + }) + } + } +} +for (const [platform, arch] of [ + ['freebsd', 'x64'], + ['win32', 'ia32'], + ['linux', 'riscv64'], +]) { + test(`rejects unsupported ${platform}/${arch} without loading`, () => { + const host = fixture(platform, arch) + assert.throws( + () => host.run(), + (error) => error.message.includes(`${platform}/${arch}/`), + ) + assert.deepEqual(host.calls, platform === 'linux' ? [['report']] : []) + }) +} +test('names omitted optional package and preserves resolution cause', () => { + const host = fixture('win32', 'x64', { missing: true }) + assert.throws( + () => host.run(), + (error) => { + assert.equal(error.cause, host.missing) + assert.match(error.message, /@devup-ui\/binding-win32-x64-msvc/) + assert.match(error.message, /optional dependencies enabled/) + return true + }, + ) + assert.equal(host.calls.length, 1) +}) +test('preserves genuine manifest resolution failure', () => { + const failure = Object.assign(new Error('access denied'), { code: 'EACCES' }) + const host = fixture('darwin', 'arm64', { resolveError: failure }) + assert.throws( + () => host.run(), + (error) => error === failure, + ) + assert.equal(host.calls.length, 1) +}) +for (const code of ['ERR_DLOPEN_FAILED', 'MODULE_NOT_FOUND']) { + test(`preserves found package load failure ${code} without fallback`, () => { + const failure = Object.assign( + new Error('broken image or transitive module'), + { code }, + ) + const host = fixture('linux', 'x64', { loadError: failure }) + assert.throws( + () => host.run(), + (error) => error === failure, + ) + assert.equal(host.calls.length, 3) + }) +} +test('preserves libc inspection failure without guessing a target', () => { + const failure = new Error('report unavailable') + const host = fixture('linux', 'arm64', { reportError: failure }) + assert.throws( + () => host.run(), + (error) => error === failure, + ) + assert.deepEqual(host.calls, [['report']]) +}) diff --git a/bindings/devup-ui-binding/package.json b/bindings/devup-ui-binding/package.json new file mode 100644 index 000000000..455de46c6 --- /dev/null +++ b/bindings/devup-ui-binding/package.json @@ -0,0 +1,46 @@ +{ + "name": "@devup-ui/binding", + "version": "0.1.0", + "description": "Synchronous native Devup UI build-time compiler for Node.js and Bun", + "repository": "https://github.com/dev-five-git/devup-ui", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "main": "./pkg/index.cjs", + "module": "./index.mjs", + "types": "./index.d.cts", + "exports": { + ".": { + "import": { + "types": "./index.d.mts", + "default": "./index.mjs" + }, + "require": { + "types": "./index.d.cts", + "default": "./pkg/index.cjs" + } + } + }, + "files": [ + "pkg/index.cjs", + "loader.cjs", + "index.mjs", + "index.d.cts", + "index.d.mts" + ], + "scripts": { + "build": "node build.mjs", + "test": "node --test loader.test.cjs" + }, + "optionalDependencies": { + "@devup-ui/binding-linux-x64-gnu": "workspace:*", + "@devup-ui/binding-linux-arm64-gnu": "workspace:*", + "@devup-ui/binding-linux-x64-musl": "workspace:*", + "@devup-ui/binding-linux-arm64-musl": "workspace:*", + "@devup-ui/binding-darwin-x64": "workspace:*", + "@devup-ui/binding-darwin-arm64": "workspace:*", + "@devup-ui/binding-win32-x64-msvc": "workspace:*", + "@devup-ui/binding-win32-arm64-msvc": "workspace:*" + } +} diff --git a/bindings/devup-ui-binding/script.cjs b/bindings/devup-ui-binding/script.cjs new file mode 100644 index 000000000..ced2f7505 --- /dev/null +++ b/bindings/devup-ui-binding/script.cjs @@ -0,0 +1,37 @@ +'use strict' +const assert = require('node:assert/strict') +const { readFileSync, writeFileSync } = require('node:fs') +const { isAbsolute, join } = require('node:path') +const createBoundary = require('../argument-validation.cjs') +const [addon, destination] = process.argv.slice(2) +const publicMode = addon === '--public' +assert.ok((publicMode || isAbsolute(addon)) && isAbsolute(destination)) +const interop = readFileSync(join(__dirname, 'interop.cjs'), 'utf8') +const adapter = readFileSync(join(__dirname, 'adapter.cjs'), 'utf8') +const boundaryImport = "require('../argument-validation.cjs')()" +const interopImport = "require('./interop.cjs')" +assert.equal(interop.split(boundaryImport).length, 2) +assert.equal(adapter.split(interopImport).length, 2) +const source = [ + "'use strict'", + `const __devupBoundary = (${createBoundary.toString()})()`, + 'const interop = (() => {', + 'const exports = {}', + interop.replace(boundaryImport, '__devupBoundary'), + 'return exports', + '})()', + 'const adapter = (() => {', + 'const exports = {}', + adapter.replace(interopImport, 'interop'), + 'return exports', + '})()', + ...(publicMode + ? ["module.exports = adapter.createBinding(require('../loader.cjs'))"] + : [ + 'const native = { exports: {} }', + `process.dlopen(native, ${JSON.stringify(addon)})`, + 'module.exports = adapter.createBinding(native.exports)', + ]), +].join('\n') +assert.equal([...source.matchAll(/\brequire\(/g)].length, publicMode ? 1 : 0) +writeFileSync(destination, source, 'utf8') diff --git a/bindings/devup-ui-binding/src/access.rs b/bindings/devup-ui-binding/src/access.rs new file mode 100644 index 000000000..e320b1a88 --- /dev/null +++ b/bindings/devup-ui-binding/src/access.rs @@ -0,0 +1,209 @@ +use crate::value::{De, Error, Operation, item}; +use napi::ValueType; +use napi::bindgen_prelude::{Array, Either, Unknown}; +use serde::Deserializer as _; +use serde::de::{self, DeserializeSeed, Error as _, IntoDeserializer, Visitor}; + +/// A snapshot-length array traversal or an owned scoped JS iterator traversal. +pub(crate) struct Sequence<'a, 'env> { + source: De<'a, 'env>, + range: Option>, +} +impl<'a, 'env> Sequence<'a, 'env> { + pub(crate) fn new(source: De<'a, 'env>, pairs: bool) -> Result, Error> { + if pairs { + return Self::map(source); + } + if source.op::(Operation::Array, None)? { + return Self::array(source).map(Some); + } + let iterator: Unknown<'env> = source.op(Operation::Iter, None)?; + if iterator.get_type()? != ValueType::Undefined { + return Ok(Some(Self { + source: source.child(iterator), + range: None, + })); + } + Ok(None) + } + fn map(source: De<'a, 'env>) -> Result, Error> { + let fallback = u32::from(source.kind()? == ValueType::Object); + let packet: Array<'env> = source.op(Operation::MapStart, Some(Either::A(fallback)))?; + // Tags are exact numeric metadata, not truncating/wrapping u32 conversions. + match (item::(&packet, 0)?, packet.len()) { + (0.0, 1) => Ok(None), + (1.0, 2) => { + let iterator: Unknown<'env> = item(&packet, 1)?; + if iterator.get_type()? != ValueType::Object { + return Err(Error::Host("invalid private map iterator".to_string())); + } + Ok(Some(Self { + source: source.child(iterator), + range: None, + })) + } + (2.0, 3) => Ok(Some(Self { + source: source.child(item(&packet, 1)?), + range: Some(0..item::(&packet, 2)?), + })), + _ => Err(Error::Host("invalid private map start".to_string())), + } + } + fn array(source: De<'a, 'env>) -> Result { + Ok(Self { + range: Some(0..source.op(Operation::Length, None)?), + source, + }) + } + fn next(&mut self) -> Result>, Error> { + if let Some(range) = &mut self.range { + range + .next() + .map(|index| { + self.source + .op(Operation::Index, Some(Either::A(index))) + .map(|value| self.source.child(value)) + }) + .transpose() + } else { + let result: Array<'env> = self.source.op(Operation::Next, None)?; + if item::(&result, 0)? { + Ok(None) + } else { + Ok(Some(self.source.child(item(&result, 1)?))) + } + } + } +} +impl<'de> de::SeqAccess<'de> for Sequence<'_, '_> { + type Error = Error; + fn next_element_seed>( + &mut self, + seed: T, + ) -> Result, Error> { + self.next()? + .map(|value| seed.deserialize(value)) + .transpose() + } + fn size_hint(&self) -> Option { + self.range.as_ref().map(std::iter::ExactSizeIterator::len) + } +} +enum Keys<'a, 'env> { + Pairs(Sequence<'a, 'env>), + Fields(De<'a, 'env>, std::slice::Iter<'static, &'static str>), +} +/// Distinct pair-stream and declared-field map access, retaining a read-once value. +pub(crate) struct Entries<'a, 'env> { + keys: Keys<'a, 'env>, + value: De<'a, 'env>, +} +impl<'a, 'env> Entries<'a, 'env> { + pub(crate) const fn pairs(sequence: Sequence<'a, 'env>) -> Self { + let value = sequence.source; + Self { + keys: Keys::Pairs(sequence), + value, + } + } + pub(crate) fn fields(source: De<'a, 'env>, fields: &'static [&'static str]) -> Self { + Self { + keys: Keys::Fields(source, fields.iter()), + value: source, + } + } +} +impl<'de> de::MapAccess<'de> for Entries<'_, '_> { + type Error = Error; + fn next_key_seed>( + &mut self, + seed: K, + ) -> Result, Error> { + match &mut self.keys { + Keys::Pairs(sequence) => match sequence.next()? { + Some(pair) => { + let pair_values: Array<'_> = pair.op(Operation::Pair, None)?; + self.value = pair.child(item(&pair_values, 1)?); + seed.deserialize(pair.child(item(&pair_values, 0)?)) + .map(Some) + } + None => Ok(None), + }, + Keys::Fields(source, fields) => { + for field in fields { + let result: Array<'_> = source.op(Operation::Field, Some(Either::B(*field)))?; + if item::(&result, 0)? { + self.value = source.child(item(&result, 1)?); + return seed.deserialize((*field).into_deserializer()).map(Some); + } + } + Ok(None) + } + } + } + fn next_value_seed>(&mut self, seed: V) -> Result { + seed.deserialize(self.value) + } +} +/// Externally tagged enums use Object.entries, never iterable-map dispatch. +pub(crate) struct Variant<'a, 'env> { + tag: De<'a, 'env>, + payload: De<'a, 'env>, +} +impl<'a, 'env> Variant<'a, 'env> { + pub(crate) fn new(source: De<'a, 'env>, expected: &dyn de::Expected) -> Result { + match source.kind()? { + ValueType::String => Ok(Self { + tag: source, + payload: source.child(source.op(Operation::Undefined, None)?), + }), + ValueType::Object => { + let (entries, len): (Unknown<'env>, u32) = source.op(Operation::Entries, None)?; + let entries = source.child(entries); + if len != 1 { + let len: u32 = entries.op(Operation::Length, None)?; + return Err(Error::invalid_length( + usize::try_from(len).map_err(Error::custom)?, + &"1", + )); + } + let pair = entries.child(entries.op(Operation::Index, Some(Either::A(0)))?); + let values: Array<'env> = pair.op(Operation::Pair, None)?; + Ok(Self { + tag: source.child(item(&values, 0)?), + payload: source.child(item(&values, 1)?), + }) + } + _ => Err(source.invalid(expected)?), + } + } +} +impl<'de, 'a, 'env> de::EnumAccess<'de> for Variant<'a, 'env> { + type Error = Error; + type Variant = De<'a, 'env>; + fn variant_seed>( + self, + seed: V, + ) -> Result<(V::Value, Self::Variant), Error> { + Ok((seed.deserialize(self.tag)?, self.payload)) + } +} +impl<'de> de::VariantAccess<'de> for De<'_, '_> { + type Error = Error; + fn unit_variant(self) -> Result<(), Error> { + serde::Deserialize::deserialize(self) + } + fn newtype_variant_seed>(self, seed: T) -> Result { + seed.deserialize(self) + } + fn tuple_variant>(self, len: usize, visitor: V) -> Result { + self.deserialize_tuple(len, visitor) + } + fn struct_variant>( + self, + fields: &'static [&'static str], + visitor: V, + ) -> Result { + self.deserialize_struct("", fields, visitor) + } +} diff --git a/bindings/devup-ui-binding/src/de.rs b/bindings/devup-ui-binding/src/de.rs new file mode 100644 index 000000000..2f00da86c --- /dev/null +++ b/bindings/devup-ui-binding/src/de.rs @@ -0,0 +1,166 @@ +use crate::access::{Entries, Sequence, Variant}; +use crate::value::{De, Error, Operation, widen_index}; +use napi::ValueType; +use napi::bindgen_prelude::{FromNapiValue, Uint8Array}; +use serde::de::{self, Error as _, Unexpected, Visitor}; +use std::fmt; + +macro_rules! integers { + ($method:ident: $($name:ident),+) => {$ ( + fn $name>(self, visitor: V) -> Result { + self.$method(visitor) + } + )+}; +} +impl<'de> de::Deserializer<'de> for De<'_, '_> { + type Error = Error; + fn deserialize_any>(self, visitor: V) -> Result { + match self.kind()? { + ValueType::Null | ValueType::Undefined => visitor.visit_unit(), + ValueType::Boolean => visitor.visit_bool(bool::from_unknown(self.value)?), + ValueType::Number => { + if self.op::(Operation::SafeInt, None)? { + visitor.visit_i64(i64::from_unknown(self.value)?) + } else { + visitor.visit_f64(f64::from_unknown(self.value)?) + } + } + ValueType::BigInt => { + let text: String = self.op(Operation::Bigint, None)?; + match text.parse::() { + Ok(value) => visitor.visit_i64(value), + Err(_) => visitor.visit_u64(text.parse::().map_err(|_| Error::custom( + "Couldn't deserialize i64 or u64 from a BigInt outside i64::MIN..u64::MAX bounds"))?), + } + } + ValueType::String => visitor.visit_string(String::from_unknown(self.value)?), + ValueType::Object => { + if self.op::(Operation::Array, None)? { + return self.deserialize_seq(visitor); + } + if let Some(bytes) = self.op::>(Operation::Bytes, None)? { + return visitor.visit_byte_buf(bytes.to_vec()); + } + if self.op::(Operation::AnyMap, None)? { + return self.deserialize_map(visitor); + } + Err(self.invalid(&visitor)?) + } + _ => Err(self.invalid(&visitor)?), + } + } + fn deserialize_bool>(self, visitor: V) -> Result { + if self.kind()? == ValueType::Boolean { + visitor.visit_bool(bool::from_unknown(self.value)?) + } else { + Err(self.invalid(&visitor)?) + } + } + integers!(unsigned: deserialize_u16, deserialize_u32); + fn deserialize_u64>(self, visitor: V) -> Result { + if self.kind()? == ValueType::BigInt { + visitor.visit_u64(self.bigint("u64", "u64::MIN..u64::MAX")?) + } else { + self.unsigned(visitor) + } + } + fn deserialize_string>(self, visitor: V) -> Result { + if self.kind()? == ValueType::String { + visitor.visit_string(String::from_unknown(self.value)?) + } else { + Err(self.invalid(&visitor)?) + } + } + fn deserialize_unit>(self, visitor: V) -> Result { + if self.nullish()? { + self.deserialize_ignored_any(visitor) + } else { + Err(self.invalid(&visitor)?) + } + } + fn deserialize_option>(self, visitor: V) -> Result { + if self.nullish()? { + visitor.visit_none() + } else { + visitor.visit_some(self) + } + } + fn deserialize_newtype_struct>( + self, + _: &'static str, + visitor: V, + ) -> Result { + visitor.visit_newtype_struct(self) + } + fn deserialize_seq>(self, visitor: V) -> Result { + match Sequence::new(self, false)? { + Some(sequence) => visitor.visit_seq(sequence), + None => Err(self.invalid(&visitor)?), + } + } + fn deserialize_map>(self, visitor: V) -> Result { + match Sequence::new(self, true)? { + Some(sequence) => visitor.visit_map(Entries::pairs(sequence)), + None => Err(self.invalid(&visitor)?), + } + } + fn deserialize_struct>( + self, + _: &'static str, + fields: &'static [&'static str], + visitor: V, + ) -> Result { + if self.kind()? == ValueType::Object { + visitor.visit_map(Entries::fields(self, fields)) + } else { + Err(self.invalid(&visitor)?) + } + } + fn deserialize_enum>( + self, + _: &'static str, + _: &'static [&'static str], + visitor: V, + ) -> Result { + let variant = Variant::new(self, &visitor)?; + visitor.visit_enum(variant) + } + fn deserialize_ignored_any>(self, visitor: V) -> Result { + visitor.visit_unit() + } + serde::forward_to_deserialize_any! { + char i8 i16 i32 i64 i128 u8 u128 f32 f64 str bytes byte_buf unit_struct tuple_struct + } + fn deserialize_identifier>(self, visitor: V) -> Result { + self.deserialize_string(visitor) + } + fn deserialize_tuple>(self, _: usize, visitor: V) -> Result { + self.deserialize_seq(visitor) + } +} +/// A serialized usize is wasm32-sized on every native host, with usize diagnostics. +#[derive(PartialEq, Eq, Hash)] +pub(crate) struct Wasm32Index(pub(crate) usize); +struct IndexVisitor; +impl<'value> Visitor<'value> for IndexVisitor { + type Value = Wasm32Index; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("usize") + } + fn visit_newtype_struct>( + self, + deserializer: D, + ) -> Result { + deserializer.deserialize_u64(self) + } + fn visit_u64(self, value: u64) -> Result { + let value = u32::try_from(value) + .map_err(|_| E::invalid_value(Unexpected::Unsigned(value), &self))?; + Ok(Wasm32Index(widen_index(value))) + } +} +impl<'de> serde::Deserialize<'de> for Wasm32Index { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_newtype_struct("Wasm32Index", IndexVisitor) + } +} diff --git a/bindings/devup-ui-binding/src/host.rs b/bindings/devup-ui-binding/src/host.rs new file mode 100644 index 000000000..d7ea9891c --- /dev/null +++ b/bindings/devup-ui-binding/src/host.rs @@ -0,0 +1,252 @@ +use crate::output::NativeOutput; +use crate::value::{Access, AccessCallback, decode, decode_preflight, schema_error}; +use devup_ui_binding_core as core; +use extractor::{ExtractOption, ImportAlias, ModuleResolver, ResolvedModule}; +use napi::bindgen_prelude::{Array, Either, FnArgs, FromNapiValue, FunctionRef, Unknown}; +use napi::{Env, Error, Result}; +use napi_derive::napi; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +/// Fresh private resolver return; no original callback result reaches native code. +#[napi(object)] +pub struct Resolved { + /// Resolved physical filename. + pub path: String, + /// Resolved source contents. + pub code: String, +} +type Resolver = FunctionRef, Option>>; +struct State(Option>); + +/// Initialize environment-local ownership without overwriting existing state. +#[napi] +pub fn init(env: Env) -> Result<()> { + if env.get_instance_data::()?.is_none() { + env.set_instance_data(State(None), (), |context| drop(context.value))?; + } + Ok(()) +} +/// Replace the owned synchronous resolver in the current environment only. +#[napi] +pub fn set_resolver(env: Env, resolver: Option) -> Result<()> { + state(&env)?.0 = resolver.map(Rc::new); + Ok(()) +} +fn state(env: &Env) -> Result<&'static mut State> { + env.get_instance_data()? + .ok_or_else(|| Error::from_reason("native facade is not initialized")) +} +/// Keep production and full-test reset effects distinct, clearing the resolver in both. +#[napi] +pub fn reset(env: Env, full: bool) -> Result<()> { + let state = state(&env)?; + if full { + core::reset_state_internal(|| state.0 = None); + } else { + core::reset_build_state_internal(); + state.0 = None; + } + Ok(()) +} +fn with_resolver( + env: Env, + diagnostics: core::ResolverState, + action: impl FnOnce( + Option<&ModuleResolver>, + &dyn Fn() -> std::result::Result<(), String>, + ) -> Result, +) -> Result { + let current = state(&env)?.0.clone(); + let diagnostics = Rc::new(RefCell::new(diagnostics)); + let failure = Rc::new(RefCell::new(None)); + let resolver = current.map(|current| { + let failure = Rc::clone(&failure); + let diagnostics = Rc::clone(&diagnostics); + move |specifier: &str, importer: &str| { + let request = diagnostics.borrow().request(specifier, importer)?; + match current.borrow_back(&env).and_then(|callback| { + callback.call((specifier.to_string(), importer.to_string()).into()) + }) { + Ok(Some(Either::B(module))) => { + let module = ResolvedModule { + path: module.path, + code: module.code, + }; + diagnostics.borrow_mut().cache(&module, &request); + Some(module) + } + Ok(Some(Either::A(cause))) => { + diagnostics.borrow_mut().record(&request, &cause); + None + } + Ok(None) => None, + Err(error) => { + *failure.borrow_mut() = Some(error); + None + } + } + } + }); + let check = || { + if failure.borrow().is_some() { + return Err("private module resolver transport failed".to_string()); + } + diagnostics.borrow().check() + }; + let result = action( + resolver + .as_ref() + .map(|resolver| resolver as &ModuleResolver), + &check, + ); + match failure.take() { + Some(error) => Err(error), + None => result, + } +} +fn arg(request: &Array<'_>, index: u32) -> Result { + request + .get(index)? + .ok_or_else(|| Error::from_reason("missing private request field")) +} +fn aliases(values: HashMap>) -> HashMap { + values + .into_iter() + .map(|(key, value)| { + ( + key, + match value { + Some(name) => ImportAlias::DefaultToNamed(name), + None => ImportAlias::NamedToNamed, + }, + ) + }) + .collect() +} +/// Check extraction eligibility with forgiving aliases and this environment's resolver. +/// +/// # Errors +/// Returns an error if the private request or environment cannot be read. +#[napi] +pub fn has_devup_ui<'env>( + env: Env, + request: Array<'env>, + callback: AccessCallback<'env>, + schema_token: Unknown<'env>, + access_token: Unknown<'env>, +) -> Result { + let access = Access { + env, + callback, + schema_token, + failure_token: access_token, + }; + let filename: String = arg(&request, 0)?; + let code: String = arg(&request, 1)?; + let package: String = arg(&request, 2)?; + let aliases = aliases(decode_preflight(arg(&request, 3)?, &access)?); + with_resolver( + env, + core::ResolverState::new(&filename, &code), + |resolver, check| { + let result = core::has_devup_ui_with(&filename, &code, &package, &aliases, resolver); + check().map_err(Error::from_reason)?; + Ok(result) + }, + ) +} +/// Private positional request retains both public eight-argument extraction signatures. +#[napi] +pub fn extract<'env>( + env: Env, + request: Array<'env>, + callback: AccessCallback<'env>, + schema_token: Unknown<'env>, + access_token: Unknown<'env>, +) -> Result { + let access = Access { + env, + callback, + schema_token, + failure_token: access_token, + }; + let filename: String = arg(&request, 0)?; + let code: String = arg(&request, 1)?; + let option = ExtractOption { + package: arg(&request, 2)?, + css_dir: arg(&request, 3)?, + single_css: arg(&request, 4)?, + import_main_css: arg(&request, 5)?, + import_aliases: aliases(decode(arg(&request, 7)?, &access, 7)?), + }; + let import_main_css_in_css = arg(&request, 6)?; + let source_map = if arg(&request, 8)? { + core::SourceMapMode::Generate + } else { + core::SourceMapMode::Skip + }; + with_resolver( + env, + core::ResolverState::new(&filename, &code), + |resolver, check| { + core::code_extract( + (&filename, &code), + option, + core::ExtractionMode { + source_map, + resolver, + resolver_check: Some(check), + import_main_css_in_css, + }, + ) + .map(|output| NativeOutput(Some(output))) + .map_err(Error::from_reason) + }, + ) +} +/// Prepare the entire reservation packet before any core allocation or scratch state. +#[napi] +pub fn reserve<'env>( + env: Env, + request: Array<'env>, + callback: AccessCallback<'env>, + schema_token: Unknown<'env>, + access_token: Unknown<'env>, +) -> Result<()> { + let access = Access { + env, + callback, + schema_token, + failure_token: access_token, + }; + let value = arg(&request, 0)?; + let rows: Vec> = decode(value, &access, 0)?; + let inputs = rows + .into_iter() + .map(|row| { + let [filename, code, css_dir]: [String; 3] = row + .try_into() + .map_err(|_: Vec| schema_error(value, &access, 0))?; + Ok(core::ReservationInput { + filename, + code, + css_dir, + }) + }) + .collect::>>()?; + let option = ExtractOption { + package: arg(&request, 1)?, + single_css: arg(&request, 2)?, + import_main_css: arg(&request, 3)?, + import_aliases: aliases(decode(arg(&request, 5)?, &access, 5)?), + ..ExtractOption::default() + }; + let mut diagnostics = core::ResolverState::default(); + for input in &inputs { + diagnostics.add_source(&input.filename, &input.code); + } + with_resolver(env, diagnostics, |resolver, _check| { + core::reserve_class_slots_internal(&inputs, option, resolver); + Ok(()) + }) +} diff --git a/bindings/devup-ui-binding/src/lib.rs b/bindings/devup-ui-binding/src/lib.rs new file mode 100644 index 000000000..b53c2c733 --- /dev/null +++ b/bindings/devup-ui-binding/src/lib.rs @@ -0,0 +1,239 @@ +//! Private native implementation; adapter.cjs alone defines the package ABI. +mod access; +mod de; +mod host; +mod output; +mod value; + +pub use host::{Resolved, extract, has_devup_ui, init, reserve, reset, set_resolver}; +pub use output::NativeOutput; + +use de::Wasm32Index; +use devup_ui_binding_core as core; +use napi::bindgen_prelude::{Either, Undefined, Unknown}; +use napi::{Env, Error, Result}; +use napi_derive::napi; +use std::collections::{BTreeMap, HashMap, HashSet}; +use value::{Access, AccessCallback, decode, widen_index}; + +#[doc = "Restore a completely decoded sheet."] +#[napi] +pub fn import_sheet<'env>( + env: Env, + value: Unknown<'env>, + callback: AccessCallback<'env>, + schema_token: Unknown<'env>, + access_token: Unknown<'env>, +) -> Result<()> { + let access = Access { + env, + callback, + schema_token, + failure_token: access_token, + }; + core::import_sheet_internal(decode::(value, &access, 0)?); + Ok(()) +} +#[doc = "Register a completely decoded theme."] +#[napi] +pub fn register_theme<'env>( + env: Env, + value: Unknown<'env>, + callback: AccessCallback<'env>, + schema_token: Unknown<'env>, + access_token: Unknown<'env>, +) -> Result<()> { + let access = Access { + env, + callback, + schema_token, + failure_token: access_token, + }; + core::register_theme_internal(decode::(value, &access, 0)?); + Ok(()) +} +#[doc = "Register typed shorthand targets."] +#[napi] +pub fn register_shorthands<'env>( + env: Env, + value: Unknown<'env>, + callback: AccessCallback<'env>, + schema_token: Unknown<'env>, + access_token: Unknown<'env>, +) -> Result<()> { + let access = Access { + env, + callback, + schema_token, + failure_token: access_token, + }; + core::register_shorthands_internal(decode::>>(value, &access, 0)?) + .map_err(|error| Error::from_reason(error.to_string())) +} +#[doc = "Restore canonical bucket identities."] +#[napi] +pub fn import_canonical_map<'env>( + env: Env, + value: Unknown<'env>, + callback: AccessCallback<'env>, + schema_token: Unknown<'env>, + access_token: Unknown<'env>, +) -> Result<()> { + let access = Access { + env, + callback, + schema_token, + failure_token: access_token, + }; + core::import_canonical_map_internal(decode::>(value, &access, 0)?); + Ok(()) +} +#[doc = "Restore iterable route sets."] +#[napi] +pub fn import_file_routes<'env>( + env: Env, + value: Unknown<'env>, + callback: AccessCallback<'env>, + schema_token: Unknown<'env>, + access_token: Unknown<'env>, +) -> Result<()> { + let access = Access { + env, + callback, + schema_token, + failure_token: access_token, + }; + core::import_file_routes_internal(decode::>>(value, &access, 0)?); + Ok(()) +} +/// Restore wasm32-sized class slots, widening only after complete decoding. +#[napi] +pub fn import_class_map<'env>( + env: Env, + value: Unknown<'env>, + callback: AccessCallback<'env>, + schema_token: Unknown<'env>, + access_token: Unknown<'env>, +) -> Result<()> { + let access = Access { + env, + callback, + schema_token, + failure_token: access_token, + }; + let map: HashMap> = decode(value, &access, 0)?; + core::import_class_map_internal( + map.into_iter() + .map(|(file, entries)| { + ( + file, + entries + .into_iter() + .map(|(key, index)| (key, index.0)) + .collect(), + ) + }) + .collect(), + ); + Ok(()) +} +/// Restore file slots using the same bimap decoding and wasm32 width as WASM. +#[napi] +pub fn import_file_map<'env>( + env: Env, + value: Unknown<'env>, + callback: AccessCallback<'env>, + schema_token: Unknown<'env>, + access_token: Unknown<'env>, +) -> Result<()> { + let access = Access { + env, + callback, + schema_token, + failure_token: access_token, + }; + let map: bimap::BiHashMap = decode(value, &access, 0)?; + core::import_file_map_internal( + map.into_iter() + .map(|(file, index)| (file, index.0)) + .collect(), + ); + Ok(()) +} +#[doc = "Return the unchanged core serialization, without normalization."] +#[napi] +pub fn export_sheet() -> Result { + core::export_sheet_internal().map_err(Error::from_reason) +} +#[doc = "Return the unchanged core serialization, without normalization."] +#[napi] +pub fn export_class_map() -> Result { + core::export_class_map_internal().map_err(Error::from_reason) +} +#[doc = "Return the unchanged core serialization, without normalization."] +#[napi] +pub fn export_file_map() -> Result { + core::export_file_map_internal().map_err(Error::from_reason) +} +#[doc = "Return the unchanged core serialization, without normalization."] +#[napi] +pub fn export_canonical_map() -> Result { + core::export_canonical_map_internal().map_err(Error::from_reason) +} +/// Return the optional prefix as a string or undefined. +#[napi] +pub fn get_prefix() -> Either { + core::get_prefix().into() +} +/// Install a valid string/nullish prefix; malformed wrapper policy remains separate. +#[napi] +pub fn set_prefix(value: Option) { + core::set_prefix(value); +} +/// Return the default theme as a string or undefined. +#[napi] +pub fn get_default_theme() -> Either { + core::get_default_theme().into() +} +/// Set debug after facade WASM-i32 conversion. +#[napi] +pub fn set_debug(value: bool) { + core::set_debug(value); +} +/// Read debug state. +#[napi] +pub fn is_debug() -> bool { + core::is_debug() +} +/// Set hoisting after facade nullish/unsigned-i32 conversion. +#[napi] +pub fn set_atom_hoist(value: Option) { + core::set_atom_hoist(value.map(widen_index)); +} +/// Materialize CSS for the facade's converted file number. +#[napi] +pub fn get_css(value: Option, import_main: bool) -> String { + core::get_css(value.map(widen_index), import_main) +} +/// Seed the array-like wrapper's copied strings, not arbitrary JS iterables. +#[napi] +pub fn seed_file_map(files: Vec) { + core::seed_file_map(&files); +} +/// Return the live allocation count as a JS number, never a `BigInt`. +#[napi] +pub fn get_unreserved_class_slot_count() -> u32 { + let [a, b, c, d, ..] = core::get_unreserved_class_slot_count().to_le_bytes(); + u32::from_le_bytes([a, b, c, d]) +} +/// Generate declarations with the existing five interface-name slots. +#[napi] +pub fn get_theme_interface(package: String, names: Vec) -> Result { + let [color, typography, length, shadows, theme]: [String; 5] = names + .try_into() + .map_err(|_| Error::from_reason("invalid private interface-name request"))?; + Ok(core::get_theme_interface( + &package, + [&color, &typography, &length, &shadows, &theme], + )) +} diff --git a/bindings/devup-ui-binding/src/output.rs b/bindings/devup-ui-binding/src/output.rs new file mode 100644 index 000000000..d8ee2a7b3 --- /dev/null +++ b/bindings/devup-ui-binding/src/output.rs @@ -0,0 +1,60 @@ +use napi::bindgen_prelude::{Either, Undefined}; +use napi::{Error, Result}; +use napi_derive::napi; + +/// Materialized native output with an explicitly consumable payload. +#[napi(js_name = "Output")] +#[derive(Default)] +pub struct NativeOutput(pub(crate) Option); +impl NativeOutput { + fn live(&self) -> Result<&devup_ui_binding_core::Output> { + self.0 + .as_ref() + .ok_or_else(|| Error::from_reason("null pointer passed to rust")) + } +} +#[napi] +impl NativeOutput { + /// Construct the same empty shell allowed by the generated WASM class. + #[napi(constructor)] + pub const fn new() -> Self { + Self(None) + } + /// Consume the payload, rejecting a shell or a previously released output. + #[napi] + pub fn free(&mut self) -> Result<()> { + self.live()?; + self.0 = None; + Ok(()) + } + /// Copy transformed source from a live output. + #[napi(getter)] + pub fn code(&self) -> Result { + Ok(self.live()?.code.clone()) + } + /// Copy the optional source map, using undefined for absence. + #[napi(getter)] + pub fn map(&self) -> Result> { + Ok(self.live()?.map.clone().into()) + } + /// Copy changed CSS, using undefined for absence. + #[napi(getter)] + pub fn css(&self) -> Result> { + Ok(self.live()?.css.clone().into()) + } + /// Copy the optional stylesheet filename. + #[napi(getter, js_name = "cssFile")] + pub fn css_file(&self) -> Result> { + Ok(self.live()?.css_file.clone().into()) + } + /// Return whether shared base CSS changed. + #[napi(getter, js_name = "updatedBaseStyle")] + pub fn updated_base_style(&self) -> Result { + Ok(self.live()?.updated_base_style) + } + /// Copy dependencies independently on every access. + #[napi(getter)] + pub fn dependencies(&self) -> Result> { + Ok(self.live()?.dependencies.clone()) + } +} diff --git a/bindings/devup-ui-binding/src/value.rs b/bindings/devup-ui-binding/src/value.rs new file mode 100644 index 000000000..dccf3e7ef --- /dev/null +++ b/bindings/devup-ui-binding/src/value.rs @@ -0,0 +1,229 @@ +use napi::bindgen_prelude::{Array, Either, FnArgs, FromNapiValue, Function, Uint8Array, Unknown}; +use napi::{Env, ValueType}; +use serde::de::{self, DeserializeOwned, Error as _, Unexpected, Visitor}; +use std::fmt; + +/// Private control tags; keys and original user values are never encoded here. +pub(crate) enum Operation { + PreflightFailure, + Schema, + Array, + SafeInt, + Bytes, + Debug, + Bigint, + Undefined, + AnyMap, + Entries, + Length, + Index, + Pair, + Field, + Iter, + Next, + MapStart, +} +impl From for u32 { + fn from(operation: Operation) -> Self { + match operation { + Operation::PreflightFailure => 0, + Operation::Schema => 1, + Operation::Array => 2, + Operation::SafeInt => 3, + Operation::Bytes => 4, + Operation::Debug => 5, + Operation::Bigint => 6, + Operation::Undefined => 7, + Operation::AnyMap => 8, + Operation::Entries => 9, + Operation::Length => 10, + Operation::Index => 11, + Operation::Pair => 12, + Operation::Field => 13, + Operation::Iter => 14, + Operation::Next => 15, + Operation::MapStart => 16, + } + } +} +pub(crate) type AccessCallback<'env> = + Function<'env, FnArgs<(u32, Unknown<'env>, Option>)>, Unknown<'env>>; +/// Stack-local frame; all handles belong to this synchronous outer entry. +pub(crate) struct Access<'env> { + pub(crate) env: Env, + pub(crate) callback: AccessCallback<'env>, + pub(crate) schema_token: Unknown<'env>, + pub(crate) failure_token: Unknown<'env>, +} + +/// Decoder origin is retained independently of diagnostic text. +#[derive(Debug)] +pub(crate) enum Error { + Schema(String), + Access, + Host(String), +} +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Schema(message) | Self::Host(message) => message.fmt(f), + Self::Access => f.write_str("JavaScript access failed"), + } + } +} +impl std::error::Error for Error {} +impl de::Error for Error { + fn custom(message: T) -> Self { + Self::Schema(message.to_string()) + } +} +impl From for Error { + fn from(error: napi::Error) -> Self { + Self::Host(error.reason) + } +} +/// One original JS value; children retain the same scoped decoder and access seam. +#[derive(Clone, Copy)] +pub(crate) struct De<'a, 'env> { + pub(crate) value: Unknown<'env>, + pub(crate) access: &'a Access<'env>, +} +/// Decode without preconversion, retaining existing engine Deserialize implementations. +pub(crate) fn decode<'env, T: DeserializeOwned>( + value: Unknown<'env>, + access: &Access<'env>, + slot: u32, +) -> napi::Result { + T::deserialize(De { value, access }).map_err(|error| match error { + Error::Schema(_) => schema_error(value, access, slot), + Error::Access | Error::Host(_) => napi::Error::from_reason(format!("Error: {error}")), + }) +} +/// Preflight forgives schema errors and converted caught imports, never raw access faults. +pub(crate) fn decode_preflight<'env, T: DeserializeOwned + Default>( + value: Unknown<'env>, + access: &Access<'env>, +) -> napi::Result { + match T::deserialize(De { value, access }) { + Ok(value) => Ok(value), + Err(Error::Schema(_)) => Ok(T::default()), + Err(Error::Access) => { + De { value, access } + .op::>(Operation::PreflightFailure, None) + .map_err(|error| napi::Error::from_reason(format!("Error: {error}")))?; + Ok(T::default()) + } + Err(error @ Error::Host(_)) => Err(napi::Error::from_reason(format!("Error: {error}"))), + } +} +/// Notify only on failure; the JS facade replays access failures before this token. +pub(crate) fn schema_error<'env>( + value: Unknown<'env>, + access: &Access<'env>, + slot: u32, +) -> napi::Error { + match access + .callback + .call((u32::from(Operation::Schema), value, Some(Either::A(slot))).into()) + { + Ok(_) => napi::Error::from_reason("invalid argument"), + Err(error) => error, + } +} +/// Read only a fresh private metadata packet, never properties of an original input. +pub(crate) fn item(array: &Array<'_>, index: u32) -> Result { + array + .get(index)? + .ok_or_else(|| Error::Host("missing private access result".to_string())) +} +impl<'env> De<'_, 'env> { + pub(crate) const fn child(self, value: Unknown<'env>) -> Self { + Self { value, ..self } + } + pub(crate) fn kind(self) -> Result { + Ok(self.value.get_type()?) + } + pub(crate) fn nullish(self) -> Result { + Ok(matches!( + self.kind()?, + ValueType::Null | ValueType::Undefined + )) + } + pub(crate) fn op( + self, + operation: Operation, + key: Option>, + ) -> Result { + let result = self + .access + .callback + .call((u32::from(operation), self.value, key).into())?; + if result.get_type()? == ValueType::Function { + if self + .access + .env + .strict_equals(result, self.access.schema_token)? + { + return Err(Error::custom("invalid intrinsic receiver")); + } + if self + .access + .env + .strict_equals(result, self.access.failure_token)? + { + return Err(Error::Access); + } + } + Ok(T::from_unknown(result)?) + } + pub(crate) fn invalid(self, expected: &dyn de::Expected) -> Result { + Ok(match self.kind()? { + ValueType::Null | ValueType::Undefined => { + Error::invalid_type(Unexpected::Unit, expected) + } + ValueType::Boolean => { + Error::invalid_type(Unexpected::Bool(bool::from_unknown(self.value)?), expected) + } + ValueType::Number => { + Error::invalid_type(Unexpected::Float(f64::from_unknown(self.value)?), expected) + } + ValueType::String => Error::invalid_type( + Unexpected::Str(&String::from_unknown(self.value)?), + expected, + ), + _ => match self.op::>(Operation::Bytes, None)? { + Some(bytes) => Error::invalid_type(Unexpected::Bytes(bytes.as_ref()), expected), + None => Error::invalid_type( + Unexpected::Other(&self.op::(Operation::Debug, None)?), + expected, + ), + }, + }) + } + pub(crate) fn unsigned<'de, V: Visitor<'de>>(self, visitor: V) -> Result { + if self.op::(Operation::SafeInt, None)? { + let number = i64::from_unknown(self.value)?; + if let Ok(number) = u64::try_from(number) { + return visitor.visit_u64(number); + } + } + Err(self.invalid(&visitor)?) + } + pub(crate) fn bigint( + self, + target: &str, + bounds: &str, + ) -> Result { + self.op::(Operation::Bigint, None)? + .parse() + .map_err(|_| { + Error::custom(format!( + "Couldn't deserialize {target} from a BigInt outside {bounds} bounds" + )) + }) + } +} +/// Widen on approved native hosts, whose usize byte width is always eight. +pub(crate) fn widen_index(value: u32) -> usize { + usize::from_le_bytes(u64::from(value).to_le_bytes()) +} diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml index 849fc850e..191ab442d 100644 --- a/bindings/devup-ui-wasm/Cargo.toml +++ b/bindings/devup-ui-wasm/Cargo.toml @@ -14,6 +14,7 @@ categories = ["development-tools", "wasm", "web-programming"] crate-type = ["cdylib"] [dependencies] +devup-ui-binding-core = { path = "../../libs/binding" } wasm-bindgen = "0.2.129" extractor = { path = "../../libs/extractor" } sheet = { path = "../../libs/sheet" } @@ -27,6 +28,7 @@ rustc-hash = "2.1.3" console_error_panic_hook = { version = "0.1.7", optional = true } bimap = { version = "0.6.3", features = ["serde"] } js-sys = "0.3.106" +serde = "1.0.229" serde_json = "1.0.154" serde-wasm-bindgen = "0.6.5" # Not used directly. Declared only to enable the `wasm_js` feature on the diff --git a/bindings/devup-ui-wasm/script.js b/bindings/devup-ui-wasm/script.js index 27b3139cc..ab96e480f 100644 --- a/bindings/devup-ui-wasm/script.js +++ b/bindings/devup-ui-wasm/script.js @@ -1,4 +1,94 @@ -import { writeFileSync } from 'node:fs' +import assert from 'node:assert/strict' +import { readFileSync, writeFileSync } from 'node:fs' + +import createBoundary from '../argument-validation.cjs' + +let source = readFileSync('pkg/index.js', 'utf8') +const rewrite = (pattern, count, replacement) => { + assert.equal([...source.matchAll(pattern)].length, count, String(pattern)) + source = source.replace(pattern, replacement) +} +const caught = [ + ...source.matchAll( + /return handleError\(function \([^)]*\) \{([\s\S]*?)\}, arguments\);/g, + ), +] +const body = (text) => text.replace(/\s+/g, ' ').trim() +const reflective = + 'const ret = Reflect.get(getObject(arg0), getObject(arg1)); return addHeapObject(ret);' +assert.deepEqual( + caught.map((match) => body(match[1])).sort(), + [ + 'const ret = getObject(arg0).call(getObject(arg1), getObject(arg2), getObject(arg3)); return addHeapObject(ret);', + 'const ret = getObject(arg0).call(getObject(arg1)); return addHeapObject(ret);', + 'globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));', + reflective, + reflective, + 'const ret = getObject(arg0).next(); return addHeapObject(ret);', + ].sort(), +) +assert.equal([...source.matchAll(/\bhandleError\(/g)].length, 7) +assert.match( + source, + /function handleError\(f, args\) \{\s*try \{\s*return f.apply\(this, args\);\s*\} catch \(e\) \{\s*wasm\.\w+\(addHeapObject\(e\)\);\s*\}\s*\}/, +) +for (const operation of ['begin', 'end', 'error', 'fail']) + assert.equal( + [...source.matchAll(new RegExp(`__devupBoundary\\.${operation}\\(`, 'g'))] + .length, + 1, + ) +assert.equal([...source.matchAll(/__devupBoundary/g)].length, 4) +const names = [...source.matchAll(/^exports\.(\w+) = \1;/gm)] +assert.equal(names.length, 29) +assert.equal(new Set(names.map((match) => match[1])).size, 29) +assert.equal([...source.matchAll(/^exports\./gm)].length, 29) +assert.equal(source.includes('module.exports'), false) +for (const name of ['codeExtract', 'codeExtractWithoutSourceMap']) { + const signatures = [ + ...source.matchAll(new RegExp(`function ${name}\\(([^)]*)\\)`, 'g')), + ] + assert.equal(signatures.length, 1) + assert.equal(signatures[0][1].split(',').length, 8) +} +assert.equal([...source.matchAll(/\bpassArrayJsValueToWasm0\(/g)].length, 2) +assert.equal([...source.matchAll(/getObject\(arg0\)\[[^\r\n]+/g)].length, 3) +assert.match(source, /const ret = getObject\(arg0\)\.done;\s*return ret;/) +assert.match( + source, + /const ret = getObject\(arg0\)\.value;\s*return addHeapObject\(ret\);/, +) +assert.match( + source, + /function seedFileMap\(files\) \{\s*const ptr0 = passArrayJsValueToWasm0\(files, wasm\.\w+\);\s*const len0 = WASM_VECTOR_LEN;\s*wasm.seedFileMap\(ptr0, len0\);\s*\}/, +) +assert.match( + source, + /function passArrayJsValueToWasm0\(array, malloc\) \{\s*const ptr = malloc\(array.length \* 4, 4\) >>> 0;\s*const mem = getDataViewMemory0\(\);\s*for \(let i = 0; i < array.length; i\+\+\) \{\s*mem.setUint32\(ptr \+ 4 \* i, addHeapObject\(array\[i\]\), true\);\s*\}\s*WASM_VECTOR_LEN = array.length;\s*return ptr;\s*\}/, +) +rewrite( + /Reflect.get\(getObject\(arg0\), getObject\(arg1\)\)/g, + 2, + 'Reflect.get(__devupBoundary.guard(getObject(arg0)), getObject(arg1))', +) +rewrite( + /getObject\(arg0\)\[arg1 >>> 0\]/g, + 2, + '__devupBoundary.guard(getObject(arg0), true)[arg1 >>> 0]', +) +for (const property of ['done', 'value']) + rewrite( + new RegExp(`getObject\\(arg0\\)\\.${property}`, 'g'), + 1, + `__devupBoundary.guard(getObject(arg0)).${property}`, + ) +rewrite( + /catch \(e\) \{\s*(wasm\.\w+\(addHeapObject\(e\)\);)/g, + 1, + 'catch (e) {\n __devupBoundary.caught(e);\n $1', +) +source = `const __devupBoundary = (${createBoundary.toString()})()\n${source}\n__devupBoundary.wrap(module.exports)\n` +writeFileSync('pkg/index.js', source, 'utf8') // support mjs config writeFileSync('pkg/package.json', JSON.stringify({}), 'utf8') diff --git a/bindings/devup-ui-wasm/src/boundary.rs b/bindings/devup-ui-wasm/src/boundary.rs new file mode 100644 index 000000000..9459dc849 --- /dev/null +++ b/bindings/devup-ui-wasm/src/boundary.rs @@ -0,0 +1,284 @@ +use bimap::BiHashMap; +use serde::de::{DeserializeOwned, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use std::marker::PhantomData; +use wasm_bindgen::prelude::*; + +/// File slots grow only from decoded entries, never a JavaScript length hint. +pub(crate) struct FileMap(pub(crate) BiHashMap); + +struct FileMapVisitor; + +impl<'de> Visitor<'de> for FileMapVisitor { + type Value = FileMap; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a map") + } + + fn visit_map>(self, mut entries: A) -> Result { + let mut map = BiHashMap::new(); + while let Some((file, index)) = entries.next_entry::()? { + map.insert(file, index); + } + Ok(FileMap(map)) + } +} + +impl<'de> Deserialize<'de> for FileMap { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_map(FileMapVisitor) + } +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = __devupBoundary)] + fn begin(slot: u32); + #[wasm_bindgen(js_namespace = __devupBoundary)] + fn end(); + #[wasm_bindgen(js_namespace = __devupBoundary)] + fn error(value: JsValue) -> JsValue; + #[wasm_bindgen(js_namespace = __devupBoundary)] + pub(crate) fn fail(slot: u32) -> JsValue; +} + +/// Function-item lifecycle hooks stay statically dispatched, including on WASM. +pub(crate) struct Decoder { + lifecycle: L, + types: PhantomData (D, J)>, +} + +impl Decoder +where + B: Fn(u32), + E: Fn(), + F: Fn(J) -> J, +{ + pub(crate) fn decode<'de, T: DeserializeOwned, I>(&self, value: I, slot: u32) -> Result + where + D: Deserializer<'de> + From, + J: From, + { + (self.lifecycle.0)(slot); + match T::deserialize(D::from(value)) { + Ok(value) => { + (self.lifecycle.1)(); + Ok(value) + } + Err(cause) => Err((self.lifecycle.2)(J::from(cause))), + } + } +} + +type WasmDecoder = Decoder; + +pub(crate) const fn wasm() -> WasmDecoder JsValue> { + Decoder { + lifecycle: (begin, end, error), + types: PhantomData, + } +} + +#[cfg(test)] +mod tests { + use super::{Decoder, FileMap, wasm}; + use serde::Deserialize; + use serde::de::value::{Error, MapDeserializer}; + use std::cell::RefCell; + use std::marker::PhantomData; + + std::thread_local! { + static EVENTS: RefCell> = const { RefCell::new(Vec::new()) }; + } + + struct Original(serde_json::Value); + + impl From for serde_json::Value { + fn from(value: Original) -> Self { + EVENTS.with_borrow_mut(|events| events.push("construct".into())); + value.0 + } + } + + struct Observed(T); + + impl<'de, T: Deserialize<'de>> Deserialize<'de> for Observed { + fn deserialize>(deserializer: D) -> Result { + EVENTS.with_borrow_mut(|events| events.push("deserialize".into())); + T::deserialize(deserializer).map(Self) + } + } + + #[derive(Debug)] + struct Cause(serde_json::Error); + + impl From for Cause { + fn from(cause: serde_json::Error) -> Self { + EVENTS.with_borrow_mut(|events| events.push("convert".into())); + Self(cause) + } + } + + type NativeDecoder = Decoder; + + fn native() -> NativeDecoder Cause> { + Decoder { + lifecycle: ( + |slot| EVENTS.with_borrow_mut(|events| *events = vec![format!("begin:{slot}")]), + || EVENTS.with_borrow_mut(|events| events.push("end".into())), + |cause| { + EVENTS.with_borrow_mut(|events| events.push("error".into())); + cause + }, + ), + types: PhantomData, + } + } + + #[test] + fn ends_after_real_file_map_decode_when_input_is_valid() -> Result<(), serde_json::Error> { + // Given the production wiring can be selected without invoking JS imports. + assert_eq!(std::mem::size_of_val(&wasm()), 0); + let source = Original(serde_json::json!({"a": 7, "b": 9})); + // When the common body constructs a real Serde deserializer and decodes FileMap. + let Observed(FileMap(map)) = native().decode(source, 7).map_err(|cause| cause.0)?; + // Then decoded entries and the complete success-only lifecycle survive. + assert_eq!(map.get_by_left("a"), Some(&7)); + assert_eq!(map.get_by_left("b"), Some(&9)); + EVENTS.with_borrow(|events| { + assert_eq!(*events, ["begin:7", "construct", "deserialize", "end"]); + }); + Ok(()) + } + + #[test] + fn decodes_typed_rows_when_a_different_target_type_is_selected() -> Result<(), serde_json::Error> + { + // Given a reservation-shaped input rather than a file map. + let source = Original(serde_json::json!([["src/a.tsx", "source", "df"]])); + // When the common body deserializes a different owned target type. + let Observed(rows): Observed>> = + native().decode(source, 0).map_err(|cause| cause.0)?; + // Then the real nested rows, not an untyped pass-through, are returned. + assert_eq!(rows, [["src/a.tsx", "source", "df"]]); + EVENTS.with_borrow(|events| { + assert_eq!(*events, ["begin:0", "construct", "deserialize", "end"]); + }); + Ok(()) + } + + #[test] + fn converts_original_cause_once_when_file_map_input_is_invalid() { + // Given invalid map shapes and values, using real Serde Value deserializers. + for (source, message) in [ + ( + serde_json::json!(false), + "invalid type: boolean `false`, expected a map", + ), + ( + serde_json::json!({"a": false}), + "invalid type: boolean `false`, expected usize", + ), + ( + serde_json::json!({"a": -1}), + "invalid value: integer `-1`, expected usize", + ), + ] { + // When the same production lifecycle encounters a decoding error. + let result: Result, Cause> = native().decode(Original(source), 3); + // Then the original cause reaches conversion/error once, with no success end. + let Err(cause) = result else { + panic!("invalid input decoded successfully"); + }; + assert_eq!(cause.0.to_string(), message); + EVENTS.with_borrow(|events| { + assert_eq!( + *events, + ["begin:3", "construct", "deserialize", "convert", "error"] + ); + }); + } + } + + struct HugeHint(I); + + impl Iterator for HugeHint { + type Item = I::Item; + + fn next(&mut self) -> Option { + self.0.next() + } + + fn size_hint(&self) -> (usize, Option) { + (usize::MAX, Some(usize::MAX)) + } + } + + #[test] + fn decodes_entries_when_allocation_hint_is_huge() -> Result<(), Error> { + // Given an untrusted exact hint and a single real entry. + let entries = HugeHint([("a", 7_usize)].into_iter()); + assert_eq!(entries.size_hint(), (usize::MAX, Some(usize::MAX))); + let deserializer = MapDeserializer::<_, Error>::new(entries); + // When decoding through the concrete file-map Deserialize implementation. + let FileMap(map) = FileMap::deserialize(deserializer)?; + // Then the real entry is retained without reserving the advertised capacity. + assert_eq!(map.len(), 1); + assert_eq!(map.get_by_left("a"), Some(&7)); + Ok(()) + } + + #[test] + fn preserves_overwrite_order_when_keys_and_values_repeat() -> Result<(), serde_json::Error> { + // Given successive key and value collisions (buffering by key resurrects a). + let source = r#"{"a":1,"b":1,"b":2,"c":2,"c":3}"#; + // When consuming the pairs in source order. + let FileMap(map) = serde_json::from_str(source)?; + // Then only the last surviving bimap pair remains. + assert_eq!(map.len(), 1); + assert_eq!(map.get_by_left("c"), Some(&3)); + Ok(()) + } + + #[test] + fn rejects_invalid_shapes_and_values_when_deserializing() { + // Given a non-map and invalid typed file indices. + for (source, message) in [ + ( + "false", + "invalid type: boolean `false`, expected a map at line 1 column 5", + ), + ( + r#"{"a":false}"#, + "invalid type: boolean `false`, expected usize at line 1 column 10", + ), + ( + r#"{"a":-1}"#, + "invalid value: integer `-1`, expected usize at line 1 column 7", + ), + ] { + // When the same concrete decoder receives invalid input. + let result = serde_json::from_str::(source) + .err() + .map(|error| error.to_string()); + // Then its original Serde error, including the map expectation, survives. + assert_eq!(result, Some(message.to_string())); + } + } + + #[test] + fn rejects_non_string_keys_when_deserializing_entries() { + // Given a map stream with an integer key rather than a filename. + let deserializer = MapDeserializer::<_, Error>::new([(1_u32, 2_usize)].into_iter()); + // When the typed key seed consumes the first pair. + let result = FileMap::deserialize(deserializer) + .err() + .map(|error| error.to_string()); + // Then key decoding fails rather than coercing or inserting it. + assert_eq!( + result, + Some("invalid type: integer `1`, expected a string".to_string()) + ); + } +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 7b9fc2f2d..ab09a4b65 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1,23 +1,29 @@ +mod boundary; mod reservation; pub use reservation::{ ReservationInput, get_unreserved_class_slot_count, reserve_class_slots_internal, }; -use css::class_map::{set_class_map, with_class_map}; -use css::file_map::{ - canonical, is_global, set_canonical_map, set_file_map, with_canonical_map, with_file_map, +use binding_core::SourceMapMode; +#[cfg(test)] +use binding_core::{ + GLOBAL_STYLE_SHEET, import_class_map_internal as set_class_map, + import_file_map_internal as set_file_map, with_style_sheet, with_style_sheet_mut, }; +#[cfg(test)] +use css::file_map::canonical; +use devup_ui_binding_core as binding_core; +#[cfg(test)] +use extractor::extract; +#[cfg(test)] use extractor::extract_style::extract_style_value::ExtractStyleValue; -use extractor::{ - ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules, - extract_without_source_map, has_devup_ui_with, -}; +use extractor::{ExtractOption, ImportAlias, ModuleResolver, ResolvedModule}; +#[cfg(test)] use rustc_hash::FxHashSet; use sheet::StyleSheet; use std::cell::RefCell; use std::collections::{BTreeMap, HashMap}; use std::fmt::Display; -use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; mod resolver_state; use resolver_state::ResolverState; @@ -29,39 +35,10 @@ pub use state::{reset_state_for_testing, reset_state_internal}; #[cfg(test)] mod css_output_edge_tests; -static GLOBAL_STYLE_SHEET: LazyLock> = - LazyLock::new(|| Mutex::new(StyleSheet::default())); - -#[derive(Clone, Copy)] -enum SourceMapMode { - Generate, - Skip, -} - thread_local! { static MODULE_RESOLVER: RefCell> = const { RefCell::new(None) }; } -fn with_style_sheet(f: F) -> R -where - F: FnOnce(&StyleSheet) -> R, -{ - let guard = GLOBAL_STYLE_SHEET - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - f(&guard) -} - -fn with_style_sheet_mut(f: F) -> R -where - F: FnOnce(&mut StyleSheet) -> R, -{ - let mut guard = GLOBAL_STYLE_SHEET - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - f(&mut guard) -} - #[cfg(not(tarpaulin_include))] fn js_error(message: impl Display) -> JsValue { js_sys::Error::new(&message.to_string()).into() @@ -76,8 +53,23 @@ pub struct Output { css: Option, dependencies: Vec, } + +impl From for Output { + fn from(output: binding_core::Output) -> Self { + Self { + code: output.code, + map: output.map, + css_file: output.css_file, + updated_base_style: output.updated_base_style, + css: output.css, + dependencies: output.dependencies, + } + } +} + #[wasm_bindgen] impl Output { + #[cfg(test)] #[allow(clippy::too_many_arguments)] fn new( code: String, @@ -89,44 +81,21 @@ impl Output { import_main_css: bool, dependencies: Vec, ) -> Self { - // Use the bucket identity (single-importer collapse) so the sheet's CSS - // naming + property bucket + emitted chunk match the canonical class names - // the extractor already baked into `code`. Identity when no map is loaded. - // Global (shared-chunk) files are treated like single-css: emitted into the - // global bucket (devup-ui.css) with prefix-less naming. - let _atom_mode = css::atom_hoist::AtomModeGuard::new(single_css); - let canonical_filename = canonical(&filename); - let global = single_css || is_global(&filename); - with_style_sheet_mut(|sheet| { - // globalCss (@font-face / global selectors) is per-SOURCE-file, never - // collapsed. rm_global_css MUST use the RAW filename so a collapsed - // member (sharing the bucket-root's canonical) never wipes the root's - // globalCss. Atom property bucketing still uses canonical_filename. - let default_collected = sheet.rm_global_css(&filename, global); - let (collected, updated_base_style) = - sheet.update_styles_in_bucket(&styles, &canonical_filename, global); - Self { + binding_core::Output::new( + extractor::ExtractOutput { code, + styles, map, css_file, dependencies, - updated_base_style: updated_base_style || default_collected, - css: { - if !collected && !default_collected { - None - } else { - Some(sheet.create_css( - if global { - None - } else { - Some(&canonical_filename) - }, - import_main_css, - )) - } - }, - } - }) + }, + filename, + binding_core::CssOptions { + single_css, + import_main_css, + }, + ) + .into() } /// Get the code @@ -172,13 +141,13 @@ impl Output { #[wasm_bindgen(js_name = "setDebug")] pub fn set_debug(debug: bool) { - css::debug::set_debug(debug); + binding_core::set_debug(debug); } #[wasm_bindgen(js_name = "isDebug")] #[must_use] pub fn is_debug() -> bool { - css::debug::is_debug() + binding_core::is_debug() } /// Set the CSS class name prefix @@ -223,33 +192,31 @@ pub fn is_debug() -> bool { /// ``` #[wasm_bindgen(js_name = "setPrefix")] pub fn set_prefix(prefix: Option) { - css::set_prefix(prefix); + binding_core::set_prefix(prefix); } #[wasm_bindgen(js_name = "getPrefix")] #[must_use] pub fn get_prefix() -> Option { - css::get_prefix() + binding_core::get_prefix() } /// Internal function to import a `StyleSheet` (testable without `JsValue`) pub fn import_sheet_internal(sheet: StyleSheet) { - css::atom_hoist::restore_atom_plan(sheet.atom_plan.clone()); - with_style_sheet_mut(|global_sheet| *global_sheet = sheet); + binding_core::import_sheet_internal(sheet); } #[wasm_bindgen(js_name = "importSheet")] #[cfg(not(tarpaulin_include))] pub fn import_sheet(sheet_object: JsValue) -> Result<(), JsValue> { - let sheet: StyleSheet = serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?; + let sheet: StyleSheet = const { boundary::wasm() }.decode(sheet_object, 0)?; import_sheet_internal(sheet); Ok(()) } /// Internal function to export `StyleSheet` as JSON string (testable without `JsValue`) pub fn export_sheet_internal() -> Result { - with_style_sheet(|sheet| serde_json::to_string(&sheet.export_snapshot())) - .map_err(|e| e.to_string()) + binding_core::export_sheet_internal() } #[wasm_bindgen(js_name = "exportSheet")] @@ -260,20 +227,13 @@ pub fn export_sheet() -> Result { /// Internal function to export class map as JSON string (testable without `JsValue`) pub fn export_class_map_internal() -> Result { - with_class_map(|map| { - let sorted: BTreeMap<&String, BTreeMap<&String, &usize>> = map - .iter() - .map(|(file, classes)| (file, classes.iter().collect())) - .collect(); - serde_json::to_string(&sorted) - }) - .map_err(|e| e.to_string()) + binding_core::export_class_map_internal() } #[wasm_bindgen(js_name = "importClassMap")] #[cfg(not(tarpaulin_include))] pub fn import_class_map(sheet_object: JsValue) -> Result<(), JsValue> { - set_class_map(serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?); + binding_core::import_class_map_internal(const { boundary::wasm() }.decode(sheet_object, 0)?); Ok(()) } @@ -285,17 +245,14 @@ pub fn export_class_map() -> Result { /// Internal function to export file map as JSON string (testable without `JsValue`) pub fn export_file_map_internal() -> Result { - with_file_map(|map| { - let sorted: BTreeMap<&String, &usize> = map.iter().collect(); - serde_json::to_string(&sorted) - }) - .map_err(|e| e.to_string()) + binding_core::export_file_map_internal() } #[wasm_bindgen(js_name = "importFileMap")] #[cfg(not(tarpaulin_include))] pub fn import_file_map(sheet_object: JsValue) -> Result<(), JsValue> { - set_file_map(serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?); + let boundary::FileMap(map) = const { boundary::wasm() }.decode(sheet_object, 0)?; + binding_core::import_file_map_internal(map); Ok(()) } @@ -307,22 +264,18 @@ pub fn export_file_map() -> Result { /// Internal function to import the canonical (bucket) map (testable without `JsValue`) pub fn import_canonical_map_internal(map: HashMap) { - set_canonical_map(map); + binding_core::import_canonical_map_internal(map); } /// Internal function to export the canonical map as JSON string (testable without `JsValue`) pub fn export_canonical_map_internal() -> Result { - with_canonical_map(|map| { - let sorted: BTreeMap<&String, &String> = map.iter().collect(); - serde_json::to_string(&sorted) - }) - .map_err(|e| e.to_string()) + binding_core::export_canonical_map_internal() } #[wasm_bindgen(js_name = "importCanonicalMap")] #[cfg(not(tarpaulin_include))] pub fn import_canonical_map(map_object: JsValue) -> Result<(), JsValue> { - set_canonical_map(serde_wasm_bindgen::from_value(map_object).map_err(js_error)?); + import_canonical_map_internal(const { boundary::wasm() }.decode(map_object, 0)?); Ok(()) } @@ -337,7 +290,7 @@ pub fn export_canonical_map() -> Result { /// workers reach files in. #[wasm_bindgen(js_name = "seedFileMap")] pub fn seed_file_map(files: Vec) { - css::file_map::seed_file_numbers(&files); + binding_core::seed_file_map(&files); } /// Forget everything one build left in the engine. @@ -346,15 +299,7 @@ pub fn seed_file_map(files: Vec) { /// and the module resolver, so the next build starts from its own options /// alone. Theme, shorthands and debug mode are set by every build, and stay. pub fn reset_build_state_internal() { - css::slot_reservation::reset(); - css::class_map::reset_class_map(); - css::file_map::reset_file_map(); - css::file_map::reset_canonical_map(); - css::file_routes::set_file_routes(HashMap::new()); - css::atom_hoist::set_atom_hoist(None); - css::atom_hoist::restore_atom_plan(None); - css::set_prefix(None); - with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + binding_core::reset_build_state_internal(); MODULE_RESOLVER.with_borrow_mut(|current| *current = None); } @@ -373,12 +318,12 @@ pub fn reset_build_state() { /// Pair with `importFileRoutes` to provide the file -> routes mapping. #[wasm_bindgen(js_name = "setAtomHoist")] pub fn set_atom_hoist(threshold: Option) { - css::atom_hoist::set_atom_hoist(threshold); + binding_core::set_atom_hoist(threshold); } /// Internal function to import the file -> routes map (testable without `JsValue`) pub fn import_file_routes_internal(map: HashMap>) { - css::file_routes::set_file_routes(map); + binding_core::import_file_routes_internal(map); } /// Import the file -> set-of-route-ids mapping used to decide atom hoisting. @@ -389,9 +334,7 @@ pub fn import_file_routes_internal(map: HashMap Result<(), JsValue> { - css::file_routes::set_file_routes( - serde_wasm_bindgen::from_value(map_object).map_err(js_error)?, - ); + import_file_routes_internal(const { boundary::wasm() }.decode(map_object, 0)?); Ok(()) } @@ -498,34 +441,18 @@ fn code_extract_internal_impl( import_main_css: import_main_css_in_code, import_aliases, }; - let extracted = match (resolver, source_map) { - (Some(resolver), mode) => extract_with_modules( - filename, - code, - option, - matches!(mode, SourceMapMode::Generate), + let check = || resolver_state.map_or(Ok(()), |state| state.borrow().check()); + binding_core::code_extract( + (filename, code), + option, + binding_core::ExtractionMode { + source_map, resolver, - ), - (None, SourceMapMode::Generate) => extract(filename, code, option), - (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), - }; - - if let Some(state) = resolver_state { - state.borrow().check()?; - } - match extracted { - Ok(output) => Ok(Output::new( - output.code, - output.styles, - output.map, - single_css, - filename.to_string(), - output.css_file, + resolver_check: Some(&check), import_main_css_in_css, - output.dependencies, - )), - Err(error) => Err(error.to_string()), - } + }, + ) + .map(Output::from) } /// Set how the imports of the files being extracted are resolved. @@ -621,7 +548,7 @@ fn code_extract_js( import_aliases: JsValue, source_map: SourceMapMode, ) -> Result { - let import_aliases = import_aliases_from_js(import_aliases)?; + let import_aliases = import_aliases_from_js(import_aliases, 7)?; let state = std::rc::Rc::new(RefCell::new(ResolverState::new(filename, code))); let resolver = resolver_from_js(std::rc::Rc::clone(&state)); code_extract_internal_impl( @@ -643,9 +570,10 @@ fn code_extract_js( #[cfg(not(tarpaulin_include))] fn import_aliases_from_js( import_aliases: JsValue, + slot: u32, ) -> Result, JsValue> { let aliases: HashMap> = - serde_wasm_bindgen::from_value(import_aliases).map_err(js_error)?; + const { boundary::wasm() }.decode(import_aliases, slot)?; Ok(aliases .into_iter() .map(|(key, value)| { @@ -712,14 +640,13 @@ pub fn code_extract_without_source_map( /// Internal function to register theme (testable without `JsValue`) pub fn register_theme_internal(theme: sheet::theme::Theme) { - with_style_sheet_mut(|sheet| sheet.set_theme(theme)); + binding_core::register_theme_internal(theme); } #[wasm_bindgen(js_name = "registerTheme")] #[cfg(not(tarpaulin_include))] pub fn register_theme(theme_object: JsValue) -> Result<(), JsValue> { - let theme: sheet::theme::Theme = - serde_wasm_bindgen::from_value(theme_object).map_err(js_error)?; + let theme: sheet::theme::Theme = const { boundary::wasm() }.decode(theme_object, 0)?; register_theme_internal(theme); Ok(()) } @@ -728,37 +655,26 @@ pub fn register_theme(theme_object: JsValue) -> Result<(), JsValue> { pub fn register_shorthands_internal( shorthands: BTreeMap>, ) -> Result<(), css::shorthand::InvalidShorthandTarget> { - css::set_custom_shorthands(shorthands) + binding_core::register_shorthands_internal(shorthands) } #[wasm_bindgen(js_name = "registerShorthands")] #[cfg(not(tarpaulin_include))] pub fn register_shorthands(shorthands: JsValue) -> Result<(), JsValue> { - let shorthands = serde_wasm_bindgen::from_value(shorthands).map_err(js_error)?; + let shorthands = const { boundary::wasm() }.decode(shorthands, 0)?; register_shorthands_internal(shorthands).map_err(js_error) } #[wasm_bindgen(js_name = "getDefaultTheme")] #[cfg(not(tarpaulin_include))] pub fn get_default_theme() -> Result, JsValue> { - Ok(with_style_sheet(|sheet| sheet.theme.get_default_theme())) + Ok(binding_core::get_default_theme()) } #[wasm_bindgen(js_name = "getCss")] #[cfg(not(tarpaulin_include))] pub fn get_css(file_num: Option, import_main_css: bool) -> Result { - Ok(with_style_sheet(|sheet| { - if let Some(file_num) = file_num { - with_file_map(|map| { - sheet.create_css( - map.get_by_right(&file_num).map(String::as_str), - import_main_css, - ) - }) - } else { - sheet.create_css(None, import_main_css) - } - })) + Ok(binding_core::get_css(file_num, import_main_css)) } #[wasm_bindgen(js_name = "getThemeInterface")] @@ -771,16 +687,16 @@ pub fn get_theme_interface( shadows_interface_name: &str, theme_interface_name: &str, ) -> String { - with_style_sheet(|sheet| { - sheet.create_interface( - package_name, + binding_core::get_theme_interface( + package_name, + [ color_interface_name, typography_interface_name, length_interface_name, shadows_interface_name, theme_interface_name, - ) - }) + ], + ) } #[wasm_bindgen(js_name = "hasDevupUI")] @@ -791,10 +707,11 @@ pub fn has_devup_ui_wasm( package: &str, import_aliases: JsValue, ) -> Result { - let aliases = import_aliases_from_js(import_aliases).unwrap_or_default(); + let aliases = import_aliases_from_js(import_aliases, 3).unwrap_or_default(); let state = std::rc::Rc::new(RefCell::new(ResolverState::new(filename, code))); let resolver = resolver_from_js(std::rc::Rc::clone(&state)); - let result = has_devup_ui_with(filename, code, package, &aliases, resolver.as_deref()); + let result = + binding_core::has_devup_ui_with(filename, code, package, &aliases, resolver.as_deref()); state.borrow().check().map_err(js_error)?; Ok(result) } @@ -1451,7 +1368,7 @@ mod tests { }; let aliases = HashMap::from([("@emotion/styled".to_string(), ImportAlias::NamedToNamed)]); let check = |filename: &str, code: &str, resolver: Option<&ModuleResolver>| { - has_devup_ui_with(filename, code, "@devup-ui/react", &aliases, resolver) + binding_core::has_devup_ui_with(filename, code, "@devup-ui/react", &aliases, resolver) }; assert!(check( "a.tsx", diff --git a/bindings/devup-ui-wasm/src/reservation.rs b/bindings/devup-ui-wasm/src/reservation.rs index da8ae6151..f3c692439 100644 --- a/bindings/devup-ui-wasm/src/reservation.rs +++ b/bindings/devup-ui-wasm/src/reservation.rs @@ -1,94 +1,22 @@ //! Rule-free reservation through the real extractor and sheet allocation path. -use bimap::BiHashMap; -use css::{file_map, slot_reservation::ScratchSlots}; -use extractor::reservation_state::ScratchExtractionState; -use extractor::{ExtractOption, ModuleResolver, extract_with_modules, extract_without_source_map}; -use sheet::StyleSheet; +#[cfg(test)] +use css::file_map; +use extractor::{ExtractOption, ModuleResolver}; use wasm_bindgen::prelude::*; +#[cfg(test)] use crate::with_style_sheet_mut; +pub use devup_ui_binding_core::{ReservationInput, reserve_class_slots_internal}; mod rows; #[cfg(not(tarpaulin_include))] use crate::{ - MODULE_RESOLVER, call_module_resolver, import_aliases_from_js, js_error, + MODULE_RESOLVER, boundary, call_module_resolver, import_aliases_from_js, resolver_state::ResolverState, }; #[cfg(not(tarpaulin_include))] use std::{cell::RefCell, rc::Rc}; -/// Prepared physical input, keyed exactly as its later real transform. -pub struct ReservationInput { - pub filename: String, - pub code: String, - pub css_dir: String, -} - -struct ScratchSheet { - live: StyleSheet, - files: BiHashMap, -} - -impl Default for ScratchSheet { - fn default() -> Self { - let files = file_map::get_file_map(); - let live = with_style_sheet_mut(|sheet| { - let mut live = std::mem::take(sheet); - sheet.theme = std::mem::take(&mut live.theme); - live - }); - Self { live, files } - } -} - -impl Drop for ScratchSheet { - fn drop(&mut self) { - with_style_sheet_mut(|sheet| { - std::mem::swap(&mut self.live.theme, &mut sheet.theme); - *sheet = std::mem::take(&mut self.live); - }); - file_map::set_file_map(std::mem::take(&mut self.files)); - } -} - -/// Reserve in supplied physical-path order; suppress per-input errors, emitting nothing. -pub fn reserve_class_slots_internal( - inputs: &[ReservationInput], - option: ExtractOption, - resolver: Option<&ModuleResolver>, -) { - let _atom_mode = css::atom_hoist::AtomModeGuard::new(option.single_css); - let slots = ScratchSlots::default(); - let files: Vec = inputs.iter().map(|input| input.filename.clone()).collect(); - file_map::seed_file_numbers(&files); - for input in inputs { - let _ = file_map::get_file_num_by_filename(&input.filename); - } - let _sheet = ScratchSheet::default(); - let _extraction = ScratchExtractionState::default(); - for input in inputs { - let file_option = ExtractOption { - css_dir: input.css_dir.clone(), - ..option.clone() - }; - let extracted = match resolver { - Some(resolver) => { - extract_with_modules(&input.filename, &input.code, file_option, false, resolver) - } - None => extract_without_source_map(&input.filename, &input.code, file_option), - }; - if let Ok(output) = extracted { - let bucket = file_map::canonical(&input.filename); - let global = option.single_css || file_map::is_global(&input.filename); - with_style_sheet_mut(|sheet| { - sheet.rm_global_css(&input.filename, global); - sheet.update_styles_in_bucket(&output.styles, &bucket, global); - }); - } - } - slots.commit(); -} - /// Reserve `[filename, code, cssDir]` rows using the current resolver and configuration. #[wasm_bindgen(js_name = "reserveClassSlots")] #[cfg(not(tarpaulin_include))] @@ -100,13 +28,13 @@ pub fn reserve_class_slots( _import_main_css_in_css: bool, import_aliases: JsValue, ) -> Result<(), JsValue> { - let rows: Vec> = serde_wasm_bindgen::from_value(rows).map_err(js_error)?; - let inputs = rows::parse_rows(rows).map_err(js_error)?; + let rows: Vec> = const { boundary::wasm() }.decode(rows, 0)?; + let inputs = rows::parse_rows(rows).map_err(|_| boundary::fail(0))?; let option = ExtractOption { package: package.to_string(), single_css, import_main_css: import_main_css_in_code, - import_aliases: import_aliases_from_js(import_aliases)?, + import_aliases: import_aliases_from_js(import_aliases, 5)?, ..ExtractOption::default() }; let resolver = MODULE_RESOLVER.with_borrow(Clone::clone).map(|resolver| { @@ -133,7 +61,7 @@ pub fn reserve_class_slots( #[must_use] #[wasm_bindgen(js_name = "getUnreservedClassSlotCount")] pub fn get_unreserved_class_slot_count() -> usize { - css::slot_reservation::unreserved_slot_count() + devup_ui_binding_core::get_unreserved_class_slot_count() } #[cfg(test)] diff --git a/bindings/devup-ui-wasm/src/resolver_state.rs b/bindings/devup-ui-wasm/src/resolver_state.rs index 4f0a1dc3c..b05e53124 100644 --- a/bindings/devup-ui-wasm/src/resolver_state.rs +++ b/bindings/devup-ui-wasm/src/resolver_state.rs @@ -1,140 +1,4 @@ -use std::collections::{BTreeSet, HashMap}; - -use extractor::ResolvedModule; -use extractor::module_reference::{ModuleReferenceLocation, original_module_reference_locations}; - -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] -struct OriginSite { - filename: String, - location: ModuleReferenceLocation, -} - -#[derive(Clone)] -struct OriginSites { - first: OriginSite, - rest: Vec, -} - -struct ModuleSource { - code: String, - ingress: Option, -} - -/// A callback request with at least one original reference site. -pub(crate) struct ResolverRequest { - specifier: String, - importer: String, - origins: OriginSites, -} - -impl ResolverRequest { - pub(crate) fn specifier(&self) -> &str { - &self.specifier - } - - pub(crate) fn importer(&self) -> &str { - &self.importer - } -} - -#[derive(PartialEq, Eq, PartialOrd, Ord)] -struct ResolverFailure { - site: OriginSite, - specifier: String, - importer: String, - cause: String, -} - -/// Resolver diagnostics and original module sources owned by one extraction. -#[derive(Default)] -pub(crate) struct ResolverState { - sources: HashMap, - failures: BTreeSet, -} - -impl ResolverState { - pub(crate) fn new(filename: &str, source: &str) -> Self { - Self { - sources: HashMap::from([( - filename.to_string(), - ModuleSource { - code: source.to_string(), - ingress: None, - }, - )]), - failures: BTreeSet::new(), - } - } - - pub(crate) fn cache(&mut self, module: &ResolvedModule, request: &ResolverRequest) { - self.sources.insert( - module.path.clone(), - ModuleSource { - code: module.code.clone(), - ingress: Some(request.origins.clone()), - }, - ); - } - - pub(crate) fn add_source(&mut self, filename: &str, code: &str) { - self.sources.insert( - filename.to_string(), - ModuleSource { - code: code.to_string(), - ingress: None, - }, - ); - } - - /// Bind original references before calling JS; recursive exploratory reads - /// without a semantic reference retain the real import that loaded the module. - pub(crate) fn request(&self, specifier: &str, importer: &str) -> Option { - let source = self.sources.get(importer)?; - let mut sites = original_module_reference_locations(importer, &source.code, specifier) - .into_iter() - .map(|location| OriginSite { - filename: importer.to_string(), - location, - }); - let origins = match sites.next() { - Some(first) => OriginSites { - first, - rest: sites.collect(), - }, - None => source.ingress.clone()?, - }; - Some(ResolverRequest { - specifier: specifier.to_string(), - importer: importer.to_string(), - origins, - }) - } - - pub(crate) fn record(&mut self, request: &ResolverRequest, cause: &str) { - for site in std::iter::once(&request.origins.first).chain(&request.origins.rest) { - self.failures.insert(ResolverFailure { - site: site.clone(), - specifier: request.specifier.clone(), - importer: request.importer.clone(), - cause: cause.to_string(), - }); - } - } - - pub(crate) fn check(&self) -> Result<(), String> { - if self.failures.is_empty() { - Ok(()) - } else { - Err(self.failures.iter().map(|failure| { - let OriginSite { filename, location } = &failure.site; - format!( - "{filename}:{}:{}: module resolver failed for `{}` from `{}`: {}\nFix: repair the module resolver; return {{path:string,code:string}} with a non-empty path, or null/undefined for an unresolved module.", - location.line, location.column, failure.specifier, failure.importer, failure.cause, - ) - }).collect::>().join("\n")) - } - } -} +pub(crate) use devup_ui_binding_core::{ResolverRequest, ResolverState}; #[cfg(test)] #[path = "resolver_state_tests.rs"] diff --git a/bindings/devup-ui-wasm/src/resolver_state_tests.rs b/bindings/devup-ui-wasm/src/resolver_state_tests.rs index 0e0d1cfb0..c5ed4059a 100644 --- a/bindings/devup-ui-wasm/src/resolver_state_tests.rs +++ b/bindings/devup-ui-wasm/src/resolver_state_tests.rs @@ -1,12 +1,15 @@ use super::*; use crate::{SourceMapMode, code_extract_internal_impl, export_sheet_internal}; +use extractor::ResolvedModule; +use std::collections::HashMap; use std::{cell::RefCell, rc::Rc}; #[test] fn reservation_roots_keep_their_original_diagnostic_sites() -> Result<(), String> { // Given let mut state = ResolverState::default(); - assert_eq!(state.sources.len(), 0); + assert!(state.request("./shared", "first.ts").is_none()); + assert!(state.request("./shared", "second.ts").is_none()); state.add_source("first.ts", "import './shared';"); state.add_source("second.ts", "\nimport './shared';"); @@ -19,7 +22,8 @@ fn reservation_roots_keep_their_original_diagnostic_sites() -> Result<(), String } // Then - assert_eq!(state.sources.len(), 2); + assert!(state.request("./shared", "first.ts").is_some()); + assert!(state.request("./shared", "second.ts").is_some()); let error = state.check().err().ok_or("missing resolver diagnostics")?; let sites = error .lines() diff --git a/bindings/devup-ui-wasm/src/state.rs b/bindings/devup-ui-wasm/src/state.rs index 46a803c64..d503fad73 100644 --- a/bindings/devup-ui-wasm/src/state.rs +++ b/bindings/devup-ui-wasm/src/state.rs @@ -3,10 +3,9 @@ use wasm_bindgen::prelude::*; /// Internal test boundary: clear the engine, including calling-thread caches. /// Never call during extraction or a resolver callback, or between build environments. pub fn reset_state_internal() { - super::with_style_sheet_mut(|sheet| *sheet = sheet::StyleSheet::default()); - super::MODULE_RESOLVER.with_borrow_mut(|resolver| *resolver = None); - css::test_state::reset_state_for_testing(); - extractor::test_state::reset_caches_for_testing(); + devup_ui_binding_core::reset_state_internal(|| { + super::MODULE_RESOLVER.with_borrow_mut(|resolver| *resolver = None); + }); } /// Internal/testing-only reset, deliberately present in normal WASM artifacts. diff --git a/bun.lock b/bun.lock index 8db0e2be1..6fecc6e53 100644 --- a/bun.lock +++ b/bun.lock @@ -508,6 +508,52 @@ "vite": "^8.3.5", }, }, + "bindings/devup-ui-binding": { + "name": "@devup-ui/binding", + "version": "0.1.0", + "optionalDependencies": { + "@devup-ui/binding-darwin-arm64": "workspace:*", + "@devup-ui/binding-darwin-x64": "workspace:*", + "@devup-ui/binding-linux-arm64-gnu": "workspace:*", + "@devup-ui/binding-linux-arm64-musl": "workspace:*", + "@devup-ui/binding-linux-x64-gnu": "workspace:*", + "@devup-ui/binding-linux-x64-musl": "workspace:*", + "@devup-ui/binding-win32-arm64-msvc": "workspace:*", + "@devup-ui/binding-win32-x64-msvc": "workspace:*", + }, + }, + "bindings/devup-ui-binding-darwin-arm64": { + "name": "@devup-ui/binding-darwin-arm64", + "version": "0.1.0", + }, + "bindings/devup-ui-binding-darwin-x64": { + "name": "@devup-ui/binding-darwin-x64", + "version": "0.1.0", + }, + "bindings/devup-ui-binding-linux-arm64-gnu": { + "name": "@devup-ui/binding-linux-arm64-gnu", + "version": "0.1.0", + }, + "bindings/devup-ui-binding-linux-arm64-musl": { + "name": "@devup-ui/binding-linux-arm64-musl", + "version": "0.1.0", + }, + "bindings/devup-ui-binding-linux-x64-gnu": { + "name": "@devup-ui/binding-linux-x64-gnu", + "version": "0.1.0", + }, + "bindings/devup-ui-binding-linux-x64-musl": { + "name": "@devup-ui/binding-linux-x64-musl", + "version": "0.1.0", + }, + "bindings/devup-ui-binding-win32-arm64-msvc": { + "name": "@devup-ui/binding-win32-arm64-msvc", + "version": "0.1.0", + }, + "bindings/devup-ui-binding-win32-x64-msvc": { + "name": "@devup-ui/binding-win32-x64-msvc", + "version": "0.1.0", + }, "bindings/devup-ui-wasm": { "name": "@devup-ui/wasm", "version": "1.0.83", @@ -900,6 +946,24 @@ "@code-hike/lighter": ["@code-hike/lighter@1.0.3", "", { "dependencies": { "ansi-sequence-parser": "1.1.1", "tm-grammars": "^1.22.0" } }, "sha512-LU0TbZfu3L3fQZ7y9tZHttnxyFm7ewU96arGMFnjLbvFj+onYfVkznhQOmU1ZsQtv9rpQzZ313GRz6hCGDrlJQ=="], + "@devup-ui/binding": ["@devup-ui/binding@workspace:bindings/devup-ui-binding"], + + "@devup-ui/binding-darwin-arm64": ["@devup-ui/binding-darwin-arm64@workspace:bindings/devup-ui-binding-darwin-arm64"], + + "@devup-ui/binding-darwin-x64": ["@devup-ui/binding-darwin-x64@workspace:bindings/devup-ui-binding-darwin-x64"], + + "@devup-ui/binding-linux-arm64-gnu": ["@devup-ui/binding-linux-arm64-gnu@workspace:bindings/devup-ui-binding-linux-arm64-gnu"], + + "@devup-ui/binding-linux-arm64-musl": ["@devup-ui/binding-linux-arm64-musl@workspace:bindings/devup-ui-binding-linux-arm64-musl"], + + "@devup-ui/binding-linux-x64-gnu": ["@devup-ui/binding-linux-x64-gnu@workspace:bindings/devup-ui-binding-linux-x64-gnu"], + + "@devup-ui/binding-linux-x64-musl": ["@devup-ui/binding-linux-x64-musl@workspace:bindings/devup-ui-binding-linux-x64-musl"], + + "@devup-ui/binding-win32-arm64-msvc": ["@devup-ui/binding-win32-arm64-msvc@workspace:bindings/devup-ui-binding-win32-arm64-msvc"], + + "@devup-ui/binding-win32-x64-msvc": ["@devup-ui/binding-win32-x64-msvc@workspace:bindings/devup-ui-binding-win32-x64-msvc"], + "@devup-ui/bun-plugin": ["@devup-ui/bun-plugin@workspace:packages/bun-plugin"], "@devup-ui/components": ["@devup-ui/components@workspace:packages/components"], diff --git a/e2e/binding/comparison.mjs b/e2e/binding/comparison.mjs new file mode 100644 index 000000000..450dcba41 --- /dev/null +++ b/e2e/binding/comparison.mjs @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +function firstByte(left, right) { + let offset = 0 + while ( + offset < Math.min(left.length, right.length) && + left[offset] === right[offset] + ) + offset++ + return { + offset, + leftLength: left.length, + rightLength: right.length, + leftHex: left + .subarray(Math.max(0, offset - 32), offset + 64) + .toString('hex'), + rightHex: right + .subarray(Math.max(0, offset - 32), offset + 64) + .toString('hex'), + } +} +function leaf(left, right, path = '$') { + if ( + typeof left !== typeof right || + left === null || + right === null || + typeof left !== 'object' + ) + return Object.is(left, right) + ? null + : { + field: path, + leftType: typeof left, + rightType: typeof right, + ...firstByte( + Buffer.from( + typeof left === 'string' + ? left + : (JSON.stringify(left) ?? 'undefined'), + ), + Buffer.from( + typeof right === 'string' + ? right + : (JSON.stringify(right) ?? 'undefined'), + ), + ), + } + if ( + left.tag === 'object' && + right.tag === 'object' && + left.value.length === right.value.length + ) { + for (let i = 0; i < left.value.length; i++) { + const [key, value] = left.value[i], + [otherKey, otherValue] = right.value[i] + const diff = + leaf(key, otherKey, `${path}.key[${i}]`) ?? + leaf(value, otherValue, `${path}.${key.value ?? i}`) + if (diff) return diff + } + return null + } + for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) { + if (!Object.hasOwn(left, key) || !Object.hasOwn(right, key)) + return { + field: `${path}.${key}`, + leftPresent: Object.hasOwn(left, key), + rightPresent: Object.hasOwn(right, key), + } + const diff = leaf(left[key], right[key], `${path}.${key}`) + if (diff) return diff + } + return null +} +export function compare(output, problems) { + const comparisons = [] + for (const [a, b] of [ + ['wasm-1', 'wasm-2'], + ['wasm-1', 'wasm-3'], + ['native-1', 'native-2'], + ['native-1', 'native-3'], + ...Array.from({ length: 3 }, (_, i) => [ + `wasm-${i + 1}`, + `native-${i + 1}`, + ]), + ]) { + const comparison = { left: a, right: b, equal: false } + comparisons.push(comparison) + try { + const left = readFileSync(join(output, a, 'records.jsonl')), + right = readFileSync(join(output, b, 'records.jsonl')) + comparison.equal = left.equals(right) + if (!comparison.equal) { + comparison.firstDiff = firstByte(left, right) + const line = + left + .subarray(0, comparison.firstDiff.offset) + .toString('utf8') + .split('\n').length - 1 + const l = left.toString('utf8').split('\n')[line], + r = right.toString('utf8').split('\n')[line] + comparison.record = line + comparison.fieldDiff = leaf(JSON.parse(l), JSON.parse(r)) + problems.push({ + comparison: `${a}:${b}`, + reason: 'literal record bytes differ', + }) + } + } catch (error) { + problems.push({ comparison: `${a}:${b}`, reason: error.message }) + } + } + return comparisons +} diff --git a/e2e/binding/corpus.mjs b/e2e/binding/corpus.mjs new file mode 100644 index 000000000..c8083dcc4 --- /dev/null +++ b/e2e/binding/corpus.mjs @@ -0,0 +1,14 @@ +import { boundary } from './corpus/boundary.mjs' +import { extraction } from './corpus/extraction.mjs' +import { state } from './corpus/state.mjs' + +export { + box, + dependent, + file, + pkg, + source, + theme, +} from './corpus/extraction.mjs' + +export const corpus = [...extraction, ...state, ...boundary] diff --git a/e2e/binding/corpus/boundary.mjs b/e2e/binding/corpus/boundary.mjs new file mode 100644 index 000000000..c7dd0a5a6 --- /dev/null +++ b/e2e/binding/corpus/boundary.mjs @@ -0,0 +1,254 @@ +import assert from 'node:assert/strict' + +import { box, dependent, file, resolved, source } from './extraction.mjs' + +const cases = [] +const add = (name, run) => cases.push({ name, run }) +add('numeric-wrapper-and-boolean-abi', (h) => { + for (const value of [ + undefined, + null, + NaN, + Infinity, + -Infinity, + -1, + 0, + 0.9, + 4294967297, + '2', + false, + true, + 1n, + Symbol('index'), + ]) { + h.call('setAtomHoist', value) + h.call('getCss', value, false) + h.call('setDebug', value) + h.extract(source, { flags: [value, value, value] }) + } + h.call('setAtomHoist') + h.call('getCss') + h.call('setDebug') +}) +add('decoder-width-overflow-and-enums', (h) => { + for (const value of [ + -1, + 1.5, + NaN, + Infinity, + 4294967295, + 4294967296, + Number.MAX_SAFE_INTEGER, + 1n, + ]) { + h.call('importFileMap', { [file]: value }) + h.call('importClassMap', { [file]: { color: value } }) + h.call('importFileRoutes', { [file]: [value] }) + } + h.extract(box('_hover={{color:"red"}} _before={{content:\'"x"\'}}'), { + single: false, + }) + const sheet = JSON.parse(h.w.exportSheet()) + h.call('importSheet', sheet) + for (const value of [ + { css: 4 }, + { css: { broken: [{ selector: { Unknown: [] } }] } }, + ]) + h.call('importSheet', value) +}) +add('map-struct-value-getters-undefined-collections', (h) => { + for (const [name, good, bad] of [ + ['FileMap', { [file]: 4 }, { [file]: NaN }], + [ + 'ClassMap', + { [file]: new Map([['color', 4]]) }, + { [file]: { color: -1 } }, + ], + ['CanonicalMap', { [file]: 'src/root.tsx' }, { [file]: 8 }], + ['Sheet', {}, { css: 4 }], + ]) { + h.call(`import${name}`, good) + h.call(`import${name}`, new Map(Object.entries(good))) + h.call(`import${name}`, bad) + } + const unknown = { + get unknown() { + throw new RangeError('unknown getter Ω') + }, + } + for (const value of [ + unknown, + { colors: undefined }, + { colors: null }, + new Map([ + ['colors', new Map([['default', new Map([['primary', 'red']])]])], + ]), + { typography: { heading: { fontSize: new Set([12]) } } }, + { typography: { heading: { fontSize: new Map([['nested', 12]]) } } }, + ]) + h.call('registerTheme', value) + for (const value of [ + unknown, + { [file]: undefined }, + new Map([[file, 7]]), + { [file]: 7 }, + Object.create({ inherited: 4 }), + ]) + h.call('importFileMap', value) + h.call('importFileRoutes', new Map([[file, new Set([0, 2])]])) + h.call( + 'registerShorthands', + new Map([['insetX', new Set(['left', 'right'])]]), + ) +}) +add('diagnostic-tojson-cycle-error-getter', (h) => { + const calls = [], + cycle = {} + cycle.self = cycle + const custom = { + toJSON() { + calls.push('toJSON') + return 'diagnostic Ω' + }, + } + for (const value of [ + custom, + cycle, + { + get value() { + throw new SyntaxError('diagnostic getter Ω') + }, + }, + () => 1, + Symbol('value'), + ]) + h.call('importFileMap', { [file]: value }) + h.note('diagnostic-calls', calls) +}) +add('resolver-boundary-order-this-replacement', (h) => { + const events = [] + const variants = [ + undefined, + null, + 7, + () => undefined, + () => null, + () => 7, + () => Object.assign([], resolved), + () => ({ + path: '', + get code() { + events.push('unexpected-code') + return '' + }, + }), + () => ({ path: resolved.path, code: 7 }), + () => { + throw new TypeError('resolver Ω') + }, + () => ({ + get path() { + events.push('path') + return 7 + }, + get code() { + events.push('unexpected-code') + throw new Error('code') + }, + }), + () => ({ + get path() { + events.push('path') + throw new RangeError('path Ω') + }, + code: 'x', + }), + () => ({ + path: resolved.path, + get code() { + events.push('code') + throw new SyntaxError('code Ω') + }, + }), + () => Object.assign(function result() {}, resolved), + () => resolved, + () => { + h.w.setModuleResolver(() => resolved) + return resolved + }, + ] + for (const [i, variant] of variants.entries()) { + h.call( + 'setModuleResolver', + typeof variant === 'function' + ? function (...args) { + events.push([i, this === null, args]) + return variant() + } + : variant, + ) + h.extract(dependent, { filename: `src/resolver-${i}.tsx` }) + h.extract(dependent, { filename: `src/replaced-${i}.tsx` }) + } + h.note('resolver-events', events) + assert.ok(!events.includes('unexpected-code')) + h.call('setModuleResolver', null) +}) +add('prefix-valid-and-malformed-boolean-strict', (h) => { + for (const value of ['du-', null, undefined, '', false, true]) { + h.call('setPrefix', value) + h.extract(source) + } + h.call('setPrefix') +}) +export const boundary = cases + +export function capturePatched(patches, action) { + const originals = patches.map(([target, key]) => + Object.getOwnPropertyDescriptor(target, key), + ) + try { + for (const [target, key, value] of patches) target[key] = value + return { value: action() } + } catch (error) { + return { error } + } finally { + for (let i = 0; i < patches.length; i++) { + const [target, key] = patches[i] + const descriptor = originals[i] + if (descriptor) Object.defineProperty(target, key, descriptor) + else delete target[key] + } + } +} + +export function checkGlobalNeighbours(h) { + for (const [name, target, key] of [ + ['Reflect.get', Reflect, 'get'], + ['Uint8Array.set', Uint8Array.prototype, 'set'], + ]) { + const result = h.step(`global-neighbour-${name}`, () => { + // Given: a throwing dynamic intrinsic still observed by both real engines. + const marker = new Error(`observed ${name}`) + const input = name === 'Reflect.get' ? {} : { probe: new Uint8Array([1]) } + let calls = 0 + const replacement = () => { + calls++ + throw marker + } + // When: strict map decoding reaches iterator lookup or the typed-byte copy. + const outcome = capturePatched([[target, key, replacement]], () => + h.w.importCanonicalMap(input), + ) + // Then: iterator-import errors convert once; byte-copy errors stay raw. + assert.equal(calls, 1) + if (name === 'Reflect.get') { + assert.ok(outcome.error instanceof Error) + assert.equal(outcome.error.name, 'Error') + assert.notEqual(outcome.error, marker) + assert.equal(outcome.error.message, `Error: ${marker.message}`) + } else assert.equal(outcome.error, marker) + }) + assert.equal(result.tag, 'return', name) + } +} diff --git a/e2e/binding/corpus/extraction.mjs b/e2e/binding/corpus/extraction.mjs new file mode 100644 index 000000000..47e8112a2 --- /dev/null +++ b/e2e/binding/corpus/extraction.mjs @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict' + +export const pkg = '@devup-ui/react', + file = 'src/identity.tsx' +export const box = (props) => + `import { Box } from '${pkg}'; export const view = ` +export const source = box('p={3} bg="red" gap={gap}') +export const dependent = `import { Box } from '${pkg}'; import { color } from './tokens'; export const view = ` +export const theme = { + colors: { default: { primary: '#123456' }, dark: { primary: '#ffffff' } }, + typography: { heading: [{ fontSize: '12px' }, { fontSize: '20px' }] }, + length: { default: { gutter: ['4px', null, '8px'] } }, + shadow: { default: { card: ['0 1px 2px red', null, '0 2px 4px blue'] } }, +} +export const resolved = { + path: 'src/tokens.ts', + code: 'export const color = "purple";', +} +const cases = [] +const add = (name, run) => cases.push({ name, run }) +add('noop-and-plain-js', (h) => { + for (const code of ['', 'export const value = 1', source]) { + h.call('hasDevupUI', file, code, pkg) + h.extract(code) + } +}) +add('scaled-dynamic-repeat-map-modes', (h) => { + h.extract(source) + h.extract(source) + h.extract(box('m={2} w={width} opacity={active ? 1 : 0}'), { + method: 'codeExtractWithoutSourceMap', + single: false, + }) + for (const flags of [ + [false, true, true], + [true, true, false], + ]) + h.extract(source, { flags }) +}) +add('responsive-selector-cascade', (h) => + h.extract( + box( + 'p={[1,null,3]} _hover={{bg:["red","blue"],_focus:{color:"white"}}} selectors={{"& > span":{m:2}}}', + ), + ), +) +add('theme-tokens-and-interfaces', (h) => { + for (const value of [theme, new Map(Object.entries(theme))]) { + h.call('registerTheme', value) + h.extract( + box( + 'color="$primary" typography="heading" w="$gutter" boxShadow="$card"', + ), + ) + } +}) +add('custom-shorthands-object-map', (h) => { + for (const value of [ + { insetX: ['left', 'right'] }, + new Map([['insetX', ['left', 'right']]]), + { insetX: 4 }, + ]) { + h.call('registerShorthands', value) + h.extract(box('insetX={[1,null,3]} _hover={{insetX:2}}')) + } +}) +add('styled-emotion-stylex-ve-static-resolver', (h) => { + const barrel = + "import { Box } from './barrel'; export const view=;" + const withoutResolver = h.call('hasDevupUI', file, barrel, pkg, {}) + assert.deepEqual(withoutResolver, { + tag: 'return', + value: { tag: 'boolean', value: false }, + }) + const modules = { + './tokens': resolved, + './barrel': { + path: 'src/barrel.ts', + code: `export { Box } from '${pkg}';`, + }, + './styles.css': { + path: 'src/styles.css.ts', + code: `import { style } from '@vanilla-extract/css'; export const card = style({color:'red',padding:8});`, + }, + } + const calls = [] + h.call('setModuleResolver', function (specifier, importer) { + calls.push([specifier, importer]) + return modules[specifier] + }) + const withResolver = h.call('hasDevupUI', file, barrel, pkg, {}) + assert.deepEqual(withResolver, { + tag: 'return', + value: { tag: 'boolean', value: true }, + }) + for (const code of [ + `import { styled } from '${pkg}'; export const Card = styled('div',{bg:'red',p:2});`, + `import { css } from '@emotion/react'; export const view =
;`, + `import * as stylex from '@stylexjs/stylex'; const s=stylex.create({root:{color:'red'}}); export const view=
;`, + `import {card} from './styles.css'; export const view=
;`, + dependent, + ]) + h.extract(code) + h.note('resolver-calls', calls) + h.call('setModuleResolver', undefined) + const clearedResolver = h.call('hasDevupUI', file, barrel, pkg, {}) + assert.deepEqual(clearedResolver, { + tag: 'return', + value: { tag: 'boolean', value: false }, + }) +}) +add('aliases-object-map-null-invalid', (h) => { + const defaultImport = + "import Box from '@alias'; export const view=;" + const namedImport = + "import { Box } from '@alias'; export const view=;" + for (const [code, aliases, expected] of [ + [defaultImport, { '@alias': 'Box' }, true], + [defaultImport, new Map([['@alias', 'Box']]), true], + [namedImport, { '@alias': null }, true], + [namedImport, new Map([['@alias', null]]), true], + [namedImport, { '@alias': undefined }, true], + [defaultImport, { '@alias': 7 }, false], + [defaultImport, { '@alias': 'Box', '@invalid': 7 }, false], + [defaultImport, null, false], + [defaultImport, undefined, false], + ]) { + const result = h.call('hasDevupUI', file, code, pkg, aliases) + assert.deepEqual(result, { + tag: 'return', + value: { tag: 'boolean', value: expected }, + }) + } + for (const aliases of [ + { '@alias': 'Box' }, + new Map([['@alias', 'Box']]), + { '@alias': null }, + { '@alias': 7 }, + null, + undefined, + ]) + h.extract("import Box from '@alias'; export const view=;", { + aliases, + }) +}) +add('global-css-and-font-faces', (h) => + h.extract( + `import { globalCss } from '${pkg}'; globalCss({'html, body':{m:0},'@font-face':{fontFamily:'Identity',src:'url(identity.woff2)'},body:{color:'red'}});`, + ), +) +add('keyframes-ordinary-atom-content-boundaries', (h) => { + h.call('setAtomHoist', 1) + h.call('importFileRoutes', { [file]: [0] }) + for (const steps of [ + 'from:{opacity:0},to:{opacity:1}', + "'0%, 50%':{transform:'translate(1px, 2px)'},'100%':{transform:'none'}", + 'from:{},to:{opacity:1}', + ]) { + h.extract( + `import {keyframes,Box} from '${pkg}'; const spin=keyframes({${steps}}); export const view=;`, + ) + h.extract( + `import {keyframes,css} from '${pkg}'; const spin=keyframes({${steps}}); export const cls=css({animationName:spin});`, + { single: false }, + ) + } +}) +add('large-same-bucket-128-boxes-keyframes', (h) => + h.extract( + `import {Box,keyframes} from '${pkg}'; const spin=keyframes({from:{opacity:0,transform:'scale(0)'},to:{opacity:1,transform:'scale(1)'}}); export const view=<>${Array.from({ length: 128 }, (_, i) => ``).join('')};`, + ), +) +add('atom-hoist-canonical-routes', (h) => { + h.call('setAtomHoist', 2) + h.call('importCanonicalMap', { [file]: 'src/root.tsx' }) + for (const routes of [ + { 'src/root.tsx': [0, 3, 3] }, + new Map([['src/root.tsx', new Set([0, 3])]]), + ]) { + h.call('importFileRoutes', routes) + h.extract(source, { single: false }) + h.extract(source, { filename: 'src/root.tsx', single: false }) + } +}) +add('seed-unsorted-duplicates', (h) => { + h.call('seedFileMap', ['src/z.tsx', file, 'src/a.tsx', file]) + h.extract(source, { single: false }) + h.call('seedFileMap', new Set([file])) +}) +add('unicode-source-path-content', (h) => + h.extract( + box('title="한글 Ω 😀" content={\'"雪\\\\a"\'} color="rébeccapurple"'), + { filename: 'src/한글-Ω-😀.tsx' }, + ), +) +add('located-extractor-error', (h) => { + h.extract(source) + h.extract( + `import {css} from '${pkg}';\nexport const cls=css({color:window.name});`, + ) +}) +export const extraction = cases diff --git a/e2e/binding/corpus/state.mjs b/e2e/binding/corpus/state.mjs new file mode 100644 index 000000000..04e6ff084 --- /dev/null +++ b/e2e/binding/corpus/state.mjs @@ -0,0 +1,257 @@ +import assert from 'node:assert/strict' + +import { capturePatched as under, checkGlobalNeighbours } from './boundary.mjs' +import { + box, + dependent, + file, + pkg, + resolved, + source, + theme, +} from './extraction.mjs' + +const rows = [ + [file, source, 'df'], + ['src/second.tsx', box('color="blue"'), 'df'], +] +const cases = [] +const add = (name, run) => cases.push({ name, run }) +add('reservation-forward-reverse-new-repeat', (h) => { + for (const replay of [rows, [...rows].reverse()]) { + h.call('resetBuildState') + h.call('seedFileMap', ['src/second.tsx', file]) + h.call('reserveClassSlots', rows, pkg, false, false, false, {}) + for (const [filename, code] of replay) + h.extract(code, { filename, single: false }) + for (let i = 0; i < 2; i++) + h.extract(box('color="green" gap={gap}'), { + filename: 'src/new.tsx', + single: false, + }) + } +}) +add('reservation-malformed-second-row-arity-nonmutation', (h) => { + h.extract(source) + for (const value of [ + [rows[0], ['src/bad.tsx', 7, 'df']], + [[file, source]], + [[file, source, 'df', 'extra']], + null, + ]) + h.call('reserveClassSlots', value, pkg, false, false, false, {}) + h.call('reserveClassSlots', rows, pkg, false, false, false, { bad: 7 }) +}) +add('four-raw-states-explicit-import-roundtrip', (h) => { + h.call('registerTheme', theme) + h.call('importCanonicalMap', { [file]: 'src/root.tsx' }) + h.extract(source, { single: false }) + const packet = h.exports() + h.call('resetBuildState') + for (const name of ['Sheet', 'ClassMap', 'FileMap', 'CanonicalMap']) + h.call(`import${name}`, JSON.parse(packet[`export${name}`])) + h.call('registerTheme', theme) + h.extract(source, { single: false }) +}) +add('distinct-resets-and-resolver-teardown', (h) => { + for (const reset of ['resetBuildState', 'resetStateForTesting']) { + const calls = [] + h.call('registerTheme', theme) + h.call('registerShorthands', { insetX: ['left', 'right'] }) + h.call('setDebug', true) + h.call('setPrefix', 'dirty-') + h.call('setAtomHoist', 2) + h.call('setModuleResolver', (...args) => { + calls.push(args) + return resolved + }) + h.extract(dependent) + h.call(reset) + h.extract(dependent) + h.extract(box('insetX={2} color="$primary"')) + h.note(`${reset}-calls`, calls) + } + const check = (label, action) => + assert.equal(h.step(label, action).tag, 'return', label) + const typeError = (error, message) => { + assert.ok(error instanceof TypeError) + assert.equal(error.message, message) + } + const open = (aliases) => { + const out = h.open(source, { aliases }) + try { + return out instanceof h.w.Output + } finally { + out.free() + } + } + for (const mode of ['replaced', 'proxy', 'throwing']) { + for (const [path, action, message] of [ + ['index', () => open({ '@unused': 'Box' })], + [ + 'slot-zero', + () => h.w.importCanonicalMap({ probe: 7 }), + 'importCanonicalMap(): argument map_object must be a canonical mapping', + ], + [ + 'slot-seven', + () => open({ broken: 7 }), + 'codeExtract(): argument import_aliases must be an alias mapping', + ], + ]) + check(`global-Number-${mode}-${path}`, () => { + // Given: a callable replacement keeps the separately observed safe-int API. + const original = Number + const marker = new Error('unexpected global Number call') + let calls = 0 + const replacement = () => { + calls++ + if (mode === 'throwing') throw marker + return 99 + } + replacement.isSafeInteger = original.isSafeInteger + // When: index traversal or a schema notification crosses the real boundary. + const patched = + mode === 'proxy' + ? new Proxy(original, { apply: replacement }) + : replacement + const outcome = under([[globalThis, 'Number', patched]], action) + // Then: no private key invokes Number; success and canonical slots survive. + assert.equal(calls, 0) + if (message) typeError(outcome.error, message) + else { + assert.equal(outcome.error, undefined) + assert.equal(outcome.value, true) + } + }) + } + for (const value of [4294967295n, 4294967296n]) { + check(`global-String-BigInt-${value}`, () => { + // Given: primitive BigInts on either side of the unchanged wasm32 bound. + let calls = 0 + const replacement = () => { + calls++ + throw new Error('unexpected global String call') + } + // When: the real class-slot decoder parses the primitive BigInt. + const outcome = under([[globalThis, 'String', replacement]], () => { + h.w.importClassMap({ probe: { slot: value } }) + return h.w.exportClassMap() + }) + // Then: primitive conversion skips String without changing the bound error. + assert.equal(calls, 0) + if (value === 4294967296n) + typeError( + outcome.error, + 'importClassMap(): argument sheet_object must be a nested class-index mapping', + ) + else { + assert.equal(outcome.error, undefined) + assert.deepEqual(JSON.parse(outcome.value), { + probe: { slot: 4294967295 }, + }) + } + }) + } + for (const bytes of [new Uint8Array([1]), new ArrayBuffer(1)]) { + for (const method of ['importCanonicalMap', 'registerTheme']) { + check(`global-Array.from-${bytes.constructor.name}-${method}`, () => { + // Given: both typed-byte sources reach borrowed diagnostics or owned buffers. + let calls = 0 + const replacement = () => { + calls++ + throw new Error('unexpected Array.from call') + } + // When: authored decoding copies bytes before rejecting their domain type. + const input = + method === 'registerTheme' + ? { colors: { default: { probe: bytes } } } + : { probe: bytes } + const outcome = under([[Array, 'from', replacement]], () => + h.w[method](input), + ) + // Then: no Array.from transport occurs and the precise public error remains. + assert.equal(calls, 0) + typeError( + outcome.error, + method === 'registerTheme' + ? 'registerTheme(): argument theme_object must be a theme' + : 'importCanonicalMap(): argument map_object must be a canonical mapping', + ) + }) + } + } + for (const method of ['preflight', 'extract']) { + check(`global-Symbol.iterator-raw-${method}`, () => { + // Given: instrument both global property reads and the input iterator read. + const originalSymbol = Symbol, + originalReflect = Reflect + const get = originalReflect.get + const marker = new Error('raw Symbol.iterator property fault'), + order = [] + const aliases = new Proxy( + {}, + { + get(target, key, receiver) { + order.push('value-iterator') + return get(target, key, receiver) + }, + }, + ) + const symbol = new Proxy(originalSymbol, { + get(target, key, receiver) { + if (key === 'iterator') { + order.push('symbol') + throw marker + } + return get(target, key, receiver) + }, + }) + const reflect = new Proxy(originalReflect, { + get(target, key, receiver) { + if (key === 'get') order.push('reflect') + return get(target, key, receiver) + }, + }) + // When: either real alias entrypoint looks up an iterator. + const patches = [ + [globalThis, 'Symbol', symbol], + [globalThis, 'Reflect', reflect], + ] + const outcome = under(patches, () => + method === 'preflight' + ? h.w.hasDevupUI(file, source, pkg, aliases) + : open(aliases), + ) + // Then: a Symbol fault stays raw before Reflect.get, never wrapped/forgiven. + assert.deepEqual(order, ['symbol']) + assert.equal(outcome.error, marker) + }) + } + checkGlobalNeighbours(h) +}) +add('output-copies-release-getters-dispose', (h) => { + h.step('construct-Output', () => new h.w.Output()) + for (const release of ['free', Symbol.dispose]) { + let out + h.step('allocate-Output', () => { + out = h.open(source) + return out instanceof h.w.Output + }) + if (!out) + throw new Error('Output allocation failed outside a boundary-error case') + h.step('dependency-copy', () => { + const a = out.dependencies, + b = out.dependencies + a.push('local-only') + return { same: a === b, after: out.dependencies } + }) + for (const key of h.fields) h.step(`live-${key}`, () => out[key]) + h.step(`release-${String(release)}`, () => out[release]()) + for (const key of [...h.fields, 'free', Symbol.dispose]) + h.step(`released-${String(key)}`, () => + typeof key === 'symbol' || key === 'free' ? out[key]() : out[key], + ) + } +}) +export const state = cases diff --git a/e2e/binding/entries-cases.mjs b/e2e/binding/entries-cases.mjs new file mode 100644 index 000000000..091455202 --- /dev/null +++ b/e2e/binding/entries-cases.mjs @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict' + +import { box } from './corpus/extraction.mjs' +import { indexed, slot } from './entries-fixtures.mjs' + +export async function runCases(h) { + const { w } = h + await h.test('maps-modes-state-nested', () => { + const outputs = [] + for (const mode of ['codeExtract', 'codeExtractWithoutSourceMap']) { + for (const aliases of [ + {}, + { '@alias': 'Box' }, + new Map([['@alias', 'Box']]), + ]) { + const out = h.open({ + mode, + aliases, + code: "import Box from '@alias'; export const v=", + }) + outputs.push(h.read(out)) + } + } + for (const [name, value] of [ + ['importFileMap', {}], + ['importFileMap', { 'src/a.tsx': 4 }], + ['importCanonicalMap', {}], + ['importCanonicalMap', { 'src/a.tsx': 'src/root.tsx' }], + ['importClassMap', { 'src/a.tsx': { color: 3 } }], + ['importFileRoutes', { 'src/a.tsx': [0, 2] }], + ['registerShorthands', { insetX: ['left', 'right'] }], + ['registerTheme', { colors: { default: { primary: 'red' } } }], + ]) + w[name](value) + assert.equal(JSON.parse(w.exportFileMap())['src/a.tsx'], 4) + assert.equal(JSON.parse(w.exportClassMap())['src/a.tsx'].color, 3) + const packet = w.exportSheet() + w.importSheet(JSON.parse(packet)) + assert.equal(w.exportSheet(), packet) + outputs.push(h.read(h.open({ code: box('insetX={2} color="$primary"') }))) + return outputs + }) + for (const kind of ['map', 'enum']) { + await h.test(`${kind}-dynamic-receiver-enumeration`, () => { + const fixture = h.fixture(kind), + events = [] + const base = + kind === 'map' + ? { + get a() { + events.push('value') + return 2 + }, + } + : fixture.target + if (kind === 'map') { + Object.setPrototypeOf(base, { + get inherited() { + return assert.fail('Inherited values are not entries') + }, + }) + Object.defineProperty(base, 'hidden', { + get() { + return assert.fail('Nonenumerable values are not read') + }, + }) + } + const target = new Proxy(base, { + get(value, key, receiver) { + events.push(`get:${String(key)}`) + return Reflect.get(value, key, receiver) + }, + ownKeys(value) { + events.push('keys') + return Reflect.ownKeys(value) + }, + getOwnPropertyDescriptor(value, key) { + events.push(`descriptor:${String(key)}`) + return Reflect.getOwnPropertyDescriptor(value, key) + }, + }) + if (kind === 'enum') + Object.defineProperty(base, Symbol.iterator, { + get() { + return assert.fail('Enums must ignore Symbol.iterator') + }, + configurable: true, + }) + if (kind === 'enum') fixture.target = target + const original = Object.entries + h.patch( + { + get() { + events.push('lookup') + return function (value) { + if (value === target || value === fixture.target) { + assert.equal(this, Object) + events.push('call') + return original(target) + } + return Reflect.apply(original, this, [value]) + } + }, + }, + () => fixture.invoke(kind === 'map' ? target : fixture.target), + ) + const key = kind === 'map' ? 'a' : 'Selector' + assert.equal(events[events.indexOf('call') - 1], 'lookup') + assert.deepEqual( + events.filter((event) => event !== 'lookup'), + [ + ...(kind === 'map' ? ['get:Symbol(Symbol.iterator)'] : []), + 'call', + 'keys', + `descriptor:${key}`, + `get:${key}`, + ...(kind === 'map' ? ['value', 'descriptor:hidden'] : []), + ], + ) + return events + }) + await h.test(`${kind}-length-snapshot-mutation`, () => { + const fixture = h.fixture(kind), + events = [], + pair = kind === 'map' ? ['a', 7] : fixture.pair + let bound = 1 + const entries = indexed((value, key) => { + events.push(String(key)) + if (key === 'length') { + if (bound === 0) return 0 + return { + valueOf() { + events.push('coerce') + bound = 0 + value[0] = pair + return 1.9 + }, + } + } + assert.equal(key, '0', 'No eager iteration or out-of-snapshot access') + assert.equal(bound, 0) + return value[0] + }) + h.entries(fixture.target, entries, () => fixture.invoke(fixture.target)) + assert.deepEqual(events, ['length', 'coerce', '0']) + if (kind === 'map') + assert.deepEqual(JSON.parse(w.exportFileMap()), { a: 7 }) + return events + }) + for (const [label, length, count] of [ + ['zero', 0, 0], + ['fraction', 1.9, 1], + ['negative-wrap-one', -4294967295, 1], + ['wrap-zero', 4294967296, 0], + ['wrap-one', 4294967297, 1], + ['string-one', '1', 1], + ['negative-huge', -1, 4294967295], + ]) + await h.test(`${kind}-length-${label}`, () => { + const fixture = h.fixture(kind), + events = [], + stop = {} + const entries = indexed((_value, key) => { + events.push(String(key)) + if (key === 'length') return length + assert.equal(key, '0') + if (count > 1) throw stop + return kind === 'map' ? ['a', 5] : fixture.pair + }) + const result = h.take(() => + h.entries(fixture.target, entries, () => + fixture.invoke(fixture.target), + ), + ) + if (kind === 'enum' && count !== 1) { + assert.equal(result.caught, true) + assert.equal(result.error.constructor, TypeError) + assert.equal(result.error.message, slot.sheet) + } else if (count > 1) { + assert.equal(result.caught, true) + assert.equal(result.error, stop) + } else assert.equal(result.caught, false) + assert.deepEqual(events, [ + 'length', + ...(kind === 'enum' && count !== 1 ? ['length'] : []), + ...(count > 0 && (kind === 'map' || count === 1) ? ['0'] : []), + ]) + return events + }) + for (const [label, make, valid] of [ + ['empty-string', () => '', true], + ['string', () => 'xy', false], + ['number', () => 4, true], + ['boolean', () => true, true], + ['bigint', () => 2n, true], + ['symbol', () => Symbol('E'), true], + ['promise', () => Promise.resolve([]), true], + ['null', () => null, false], + ['undefined', () => undefined, false], + ]) + await h.test(`${kind}-result-${label}`, () => { + const fixture = h.fixture(kind) + const result = h.take(() => + h.entries(fixture.target, make(), () => + fixture.invoke(fixture.target), + ), + ) + const succeeds = kind === 'map' && valid + assert.equal(result.caught, !succeeds) + if (result.caught) assert.equal(result.error.constructor, TypeError) + return { caught: result.caught } + }) + } + await h.test('iterator-replaces-dynamic-entries', () => { + const events = [], + target = {} + Object.defineProperty(target, Symbol.iterator, { + get() { + events.push('iterator') + Object.defineProperty(Object, 'entries', { + configurable: true, + value(value) { + assert.equal(this, Object) + assert.equal(value, target) + events.push('replacement') + return [['a', 9]] + }, + }) + return undefined + }, + }) + h.patch( + { + value() { + assert.fail('Stale entries function used') + }, + }, + () => w.importFileMap(target), + ) + assert.deepEqual(events, ['iterator', 'replacement']) + assert.deepEqual(JSON.parse(w.exportFileMap()), { a: 9 }) + return events + }) + await h.test('enum-zero-multiple-no-index', () => { + const fixture = h.fixture('enum'), + events = [] + for (const length of [0, 2]) { + h.schema( + () => + h.entries( + fixture.target, + indexed((_value, key) => { + events.push(String(key)) + assert.equal(key, 'length') + return length + }), + () => fixture.invoke(), + ), + slot.sheet, + ) + } + assert.deepEqual(events, ['length', 'length', 'length', 'length']) + return events + }) +} diff --git a/e2e/binding/entries-context.mjs b/e2e/binding/entries-context.mjs new file mode 100644 index 000000000..fe641a347 --- /dev/null +++ b/e2e/binding/entries-context.mjs @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict' + +import { pkg } from './corpus/extraction.mjs' +import { runNamespaceOrder } from './entries-errors.mjs' +import { reserve } from './entries-fixtures.mjs' +import { + runAliasConversions, + runQueues, + runResolverOrder, +} from './entries-queues.mjs' + +export async function runContext(h) { + const { w } = h + const barrel = "import {Box} from './barrel'; export const v=" + const resolved = { + path: 'src/barrel.ts', + code: `export {Box} from '${pkg}';`, + } + for (const phase of ['enumeration', 'length']) { + for (const install of [true, false]) + await h.test(`resolver-before-core-${phase}-${install}`, () => { + const events = [], + aliases = {} + const resolver = function (specifier, importer) { + assert.equal(this, null) + events.push([specifier, importer]) + return resolved + } + w.setModuleResolver(install ? null : resolver) + const change = () => { + events.push('change') + w.setModuleResolver(install ? resolver : null) + } + let action + if (phase === 'enumeration') { + Object.defineProperty(aliases, 'unused', { + enumerable: true, + get() { + change() + return null + }, + }) + action = () => w.hasDevupUI('src/before.tsx', barrel, pkg, aliases) + } else { + action = () => + h.entries( + aliases, + { + get length() { + change() + return 0 + }, + }, + () => w.hasDevupUI('src/before.tsx', barrel, pkg, aliases), + ) + } + assert.equal(action(), install) + assert.equal(events[0], 'change') + assert.equal(events.some(Array.isArray), install) + return events + }) + } + for (const method of [ + 'codeExtract', + 'codeExtractWithoutSourceMap', + 'reserveClassSlots', + ]) { + for (const install of [true, false]) + await h.test(`resolver-alias-action-${method}-${install}`, () => { + const events = [] + const resolver = function (specifier, importer) { + assert.equal(this, null) + events.push([specifier, importer]) + return resolved + } + w.setModuleResolver(install ? null : resolver) + const aliases = { + get unused() { + events.push('change') + w.setModuleResolver(install ? resolver : null) + return null + }, + } + const output = + method === 'reserveClassSlots' + ? reserve(w, [['src/alias-action.tsx', barrel, 'df']], aliases) + : h.read( + h.open({ + mode: method, + code: barrel, + aliases, + file: 'src/alias-action.tsx', + }), + ) + assert.equal(events[0], 'change') + assert.equal(events.some(Array.isArray), install) + return { events, output } + }) + } + for (const mutation of [ + 'replace', + 'clear', + 'resetBuildState', + 'resetStateForTesting', + ]) { + await h.test(`resolver-clone-${mutation}`, () => { + const events = [] + const code = `import {Box} from '${pkg}'; import {color} from './one'; import {shade} from './two'; export const v=` + const replacement = function (specifier, importer) { + assert.equal(this, null) + events.push(['new', specifier, importer]) + return { + path: `src/new-${specifier.slice(2)}.ts`, + code: 'export const color="green"; export const shade="yellow";', + } + } + w.setModuleResolver(function (specifier, importer) { + assert.equal(this, null) + events.push(['old', specifier, importer]) + if (events.length === 1) { + switch (mutation) { + case 'replace': + w.setModuleResolver(replacement) + break + case 'clear': + w.setModuleResolver(null) + break + case 'resetBuildState': + w.resetBuildState() + break + case 'resetStateForTesting': + w.resetStateForTesting() + break + default: + assert.fail(mutation) + } + } + return { + path: `src/old-${specifier.slice(2)}.ts`, + code: 'export const color="purple"; export const shade="blue";', + } + }) + const first = h.read(h.open({ code, file: 'src/current.tsx' })) + assert.deepEqual(events, [ + ['old', './one', 'src/current.tsx'], + ['old', './two', 'src/current.tsx'], + ['old', './one', 'src/current.tsx'], + ['old', './two', 'src/current.tsx'], + ]) + const boundary = events.length + const second = h.read(h.open({ code, file: 'src/next.tsx' })) + assert.deepEqual( + events.slice(boundary), + mutation === 'replace' + ? [ + ['new', './one', 'src/next.tsx'], + ['new', './two', 'src/next.tsx'], + ['new', './one', 'src/next.tsx'], + ['new', './two', 'src/next.tsx'], + ] + : [], + ) + return { events, first, second } + }) + } + await h.test('resolver-result-policy-path-code-this', () => { + const observations = [] + for (const phase of [ + 'invalid-path', + 'path-throw', + 'code-throw', + 'callback-throw', + 'valid', + ]) { + const events = [] + w.setModuleResolver(function () { + assert.equal(this, null) + events.push('callback') + if (phase === 'callback-throw') throw undefined + return { + get path() { + events.push('path') + if (phase === 'path-throw') throw null + return phase === 'invalid-path' ? 4 : resolved.path + }, + get code() { + events.push('code') + if (phase === 'code-throw') + throw { + toString() { + assert.fail('Resolver cause formatting must not call String') + }, + } + return resolved.code + }, + } + }) + const result = h.take(() => + w.hasDevupUI(`src/${phase}.tsx`, barrel, pkg, {}), + ) + if (phase === 'valid') assert.equal(result.value, true) + else { + assert.equal(result.caught, true) + assert.equal(result.error.name, 'Error') + assert.ok( + result.error.message.startsWith( + `src/${phase}.tsx:1:19: module resolver failed`, + ), + ) + } + assert.deepEqual(events, [ + 'callback', + ...(phase === 'callback-throw' ? [] : ['path']), + ...(['code-throw', 'valid'].includes(phase) ? ['code'] : []), + ]) + observations.push([ + phase, + events, + result.caught ? result.error.message : result.value, + ]) + } + return observations + }) + await runAliasConversions(h, barrel, resolved) + await runResolverOrder(h) + await runQueues(h) + await runNamespaceOrder(h) +} diff --git a/e2e/binding/entries-errors.mjs b/e2e/binding/entries-errors.mjs new file mode 100644 index 000000000..8a649cf8f --- /dev/null +++ b/e2e/binding/entries-errors.mjs @@ -0,0 +1,256 @@ +import assert from 'node:assert/strict' + +import { pkg } from './corpus/extraction.mjs' +import { + indexed, + iteratorFailure, + iteratorPhases, + runAliasRaw, + slot, + wrappedPhases, +} from './entries-fixtures.mjs' +import { runSlots } from './entries-slots.mjs' + +export async function runErrors(h) { + const { w } = h + for (const kind of ['map', 'enum']) { + for (const phase of [ + 'lookup', + 'call', + 'enumeration', + 'length', + 'coercion', + ]) { + for (const sentinelName of ['undefined', 'null', 'hostile']) { + await h.test(`${kind}-raw-${phase}-${sentinelName}`, () => { + const fixture = h.fixture(kind), + events = [] + let reads = 0 + let armed = kind === 'map' + if (kind === 'enum') + fixture.prepare(() => { + armed = true + }) + const sentinel = + sentinelName === 'undefined' + ? undefined + : sentinelName === 'null' + ? null + : { + [Symbol.toPrimitive]() { + assert.fail('Raw throw must never be stringified') + }, + } + const fail = () => { + events.push(phase) + throw sentinel + } + if (phase === 'enumeration') + Object.defineProperty( + fixture.target, + kind === 'map' ? 'a' : fixture.pair[0], + { enumerable: true, configurable: true, get: fail }, + ) + const entries = indexed((_value, key) => { + assert.equal( + key, + 'length', + 'Failure must short-circuit before index', + ) + if (kind === 'enum' && ++reads === 1) return 0 + if (phase === 'length') return fail() + return { valueOf: fail } + }) + const original = Object.entries + const descriptor = + phase === 'lookup' + ? { + get() { + return armed ? fail() : original + }, + } + : { + value(value) { + if (value !== fixture.target) + return Reflect.apply(original, this, [value]) + if (phase === 'call') return fail() + if (phase === 'enumeration') return original(value) + return entries + }, + } + h.raw( + () => h.patch(descriptor, () => fixture.invoke(fixture.target)), + sentinel, + ) + assert.deepEqual(events, [phase]) + return events + }) + } + } + } + for (const hostile of [false, true]) { + const phases = hostile ? wrappedPhases : iteratorPhases + const group = hostile ? 'iterator-hostile-String' : 'iterator-error' + for (const phase of phases) { + await h.test(`${group}-${phase}`, () => { + const events = [], + conversionFailure = {} + const sentinel = { + toString() { + events.push('String') + if (hostile) throw conversionFailure + return 'iterator sentinel' + }, + } + const fail = () => { + events.push(phase) + throw sentinel + } + const target = iteratorFailure(phase, fail) + if (hostile) { + h.raw(() => w.importFileMap(target), conversionFailure) + assert.deepEqual(events, [phase, 'String']) + } else { + const result = h.take(() => w.importFileMap(target)) + assert.equal(result.caught, true) + const wrapped = wrappedPhases.includes(phase) + if (wrapped) { + assert.equal(result.error.constructor, Error) + assert.equal(result.error.message, 'iterator sentinel') + } else assert.equal(result.error, sentinel) + assert.deepEqual(events, [phase, ...(wrapped ? ['String'] : [])]) + } + return events + }) + } + } + await h.test('iterators-bypass-entries-and-fallback-shapes', () => { + const observations = [] + for (const value of [ + new Map([['a', 2]]), + { + *[Symbol.iterator]() { + yield ['a', 2] + }, + }, + ]) { + h.patch( + { + get() { + assert.fail('Iterable must bypass entries and E.length') + }, + }, + () => w.importFileMap(value), + ) + assert.deepEqual(JSON.parse(w.exportFileMap()), { a: 2 }) + } + for (const method of [ + 7, + () => null, + () => 3, + () => ({ next: 7 }), + () => + Object.assign(function iterator() {}, { + next() { + assert.fail('Callable iterator is rejected') + }, + }), + ]) { + const value = { a: 3, [Symbol.iterator]: method } + w.importFileMap(value) + observations.push(w.exportFileMap()) + assert.deepEqual(JSON.parse(w.exportFileMap()), { a: 3 }) + } + const callable = Object.assign(function source() {}, { a: 3 }) + h.schema(() => w.importFileMap(callable), slot.file) + callable[Symbol.iterator] = function* () { + yield ['a', 6] + } + w.importFileMap(callable) + assert.deepEqual(JSON.parse(w.exportFileMap()), { a: 6 }) + return observations + }) + await runAliasRaw(h) + await runSlots(h) +} + +export async function runNamespaceOrder(h) { + const { w } = h + await h.test('namespace-barrel-lexical-callback-subset', () => { + const events = [] + const modules = { + './barrel': { + path: 'src/barrel.ts', + code: "export * as ns from './namespace';", + }, + './namespace': { + path: 'src/namespace.ts', + code: "export { Box as alpha } from './left'; export { Box as beta } from './right';", + }, + './left': { + path: 'src/left.ts', + code: `export { Box } from '${pkg}';`, + }, + './right': { + path: 'src/right.ts', + code: `export { Box } from '${pkg}';`, + }, + } + w.setModuleResolver((specifier, importer) => { + events.push([specifier, importer]) + assert.ok(Object.hasOwn(modules, specifier), specifier) + return modules[specifier] + }) + const result = h.take(() => + h.read( + h.open({ + file: 'src/W74l-entry.tsx', + code: "import { ns } from './barrel'; export const view = ;", + }), + ), + ) + assert.equal(result.caught, true) + assert.ok(result.error instanceof Error) + assert.equal( + result.error.message, + 'src/W74l-entry.tsx:1:53: `ns` cannot use `./barrel` at build time: the build cannot follow its re-export of Devup UI (`./namespace` is a namespace of modules re-exporting `@devup-ui/react`); import it from the package instead', + ) + assert.deepEqual(events, [ + ['./barrel', 'src/W74l-entry.tsx'], + ['./namespace', 'src/barrel.ts'], + ['./left', 'src/namespace.ts'], + ]) + return { events, message: result.error.message } + }) + await h.test('namespace-change-lowest-object-key', () => { + const events = [] + w.setModuleResolver((specifier, importer) => { + events.push([specifier, importer]) + assert.equal(specifier, './tokens') + return { + path: 'src/tokens.ts', + code: "export const alpha = { color: 'red' };\nexport const beta = { color: 'blue' };\nalpha.color = 'pink';\nbeta.color = 'cyan';", + } + }) + const result = h.take(() => + h.read( + h.open({ + file: 'src/W74l-entry.tsx', + code: "import { css } from '@devup-ui/react'; import * as tokens from './tokens'; export const cls = css(tokens);", + }), + ), + ) + assert.equal(result.caught, true) + assert.ok(result.error instanceof Error) + assert.equal( + result.error.message, + 'src/W74l-entry.tsx:1:99: `css()` cannot use `tokens` at build time: its styles must be an object literal or a constant object, or be computed from constants\nsrc/tokens.ts:3:1: `alpha` is changed here, so the build cannot read it as a constant', + ) + const callback = ['./tokens', 'src/W74l-entry.tsx'] + assert.deepEqual( + events, + Array.from({ length: 5 }, () => callback), + ) + return { events, message: result.error.message } + }) +} diff --git a/e2e/binding/entries-fixtures.mjs b/e2e/binding/entries-fixtures.mjs new file mode 100644 index 000000000..320d44ec7 --- /dev/null +++ b/e2e/binding/entries-fixtures.mjs @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict' + +import { pkg, source } from './corpus/extraction.mjs' + +export const slot = { + file: 'importFileMap(): argument sheet_object must be a file-index mapping', + classes: + 'importClassMap(): argument sheet_object must be a nested class-index mapping', + sheet: 'importSheet(): argument sheet_object must be a sheet state', + rows: 'reserveClassSlots(): argument rows must be an iterable of 3-string rows', + aliases: + 'reserveClassSlots(): argument import_aliases must be an alias mapping', + prefix: 'setPrefix(): argument prefix must be a string, null or undefined', + filename: 'codeExtract(): argument filename must be a string', + extraction: 'codeExtract(): argument import_aliases must be an alias mapping', +} + +export const wrappedPhases = [ + 'iterator-get', + 'iterator-call', + 'next-get', + 'next-call', +] +export const iteratorPhases = [ + 'iterator-get', + 'iterator-call', + 'initial-next', + 'next-get', + 'next-call', + 'done', + 'value', +] + +export function indexed(get) { + return new Proxy({}, { get }) +} + +export function queueProbe(target, { phase, mark, enqueue }) { + if (!['iterator', 'nonfunction-method', 'noncallable-next'].includes(phase)) + return undefined + const event = phase === 'iterator' ? 'iterator' : 'probe' + const probe = () => { + mark(event) + enqueue() + } + Object.defineProperty(target, Symbol.iterator, { + get() { + if (phase === 'noncallable-next') + return () => ({ + get next() { + probe() + return 3 + }, + }) + probe() + return phase === 'iterator' ? undefined : 3 + }, + }) + return event +} + +export function reserve(w, rows, aliases) { + return w.reserveClassSlots(rows, pkg, false, false, false, aliases) +} + +export function iteratorFailure(phase, fail) { + let reads = 0 + const iterator = { + get next() { + reads++ + if (phase === 'initial-next' || (phase === 'next-get' && reads === 2)) + return fail() + return function () { + if (phase === 'next-call') return fail() + return { + get done() { + return phase === 'done' ? fail() : false + }, + get value() { + return fail() + }, + } + } + }, + } + return { + get [Symbol.iterator]() { + if (phase === 'iterator-get') return fail() + return function () { + return phase === 'iterator-call' ? fail() : iterator + } + }, + } +} + +export async function runAliasRaw(h) { + const { w } = h + for (const phase of [ + 'lookup', + 'call', + 'enumeration', + 'length', + 'coercion', + 'index', + 'pair-0', + 'pair-1', + 'initial-next', + 'done', + 'value', + 'diagnostic', + ]) { + for (const sentinelName of ['undefined', 'null', 'hostile']) + await h.test(`aliases-raw-${phase}-${sentinelName}`, () => { + const events = [] + const sentinel = + sentinelName === 'undefined' + ? undefined + : sentinelName === 'null' + ? null + : { + [Symbol.toPrimitive]() { + assert.fail('Raw alias String') + }, + } + const fail = () => { + events.push(phase) + throw sentinel + } + const aliases = ['initial-next', 'done', 'value'].includes(phase) + ? iteratorFailure(phase, fail) + : {} + w.setModuleResolver(() => { + events.push('resolver') + assert.fail('Raw alias reached resolver') + }) + if (phase === 'enumeration') + Object.defineProperty(aliases, 'bad', { enumerable: true, get: fail }) + if (phase === 'diagnostic') + aliases.bad = { + get [Symbol.toStringTag]() { + return fail() + }, + } + const entries = indexed((_value, key) => { + if (key === 'length') { + if (phase === 'length') return fail() + return phase === 'coercion' ? { valueOf: fail } : 1 + } + assert.equal(key, '0') + if (phase === 'index') return fail() + return indexed((_pair, part) => { + if (part === phase.slice(5)) return fail() + assert.equal(part, '0') + return 'unused' + }) + }) + const original = Object.entries + const descriptor = + phase === 'lookup' + ? { get: fail } + : { + value(value) { + if (value !== aliases) + return Reflect.apply(original, this, [value]) + if (phase === 'call') return fail() + return ['enumeration', 'diagnostic'].includes(phase) + ? original(value) + : entries + }, + } + h.raw( + () => + h.patch(descriptor, () => + w.hasDevupUI( + 'src/raw-alias.tsx', + "import {Box} from './barrel'; export const v=", + pkg, + aliases, + ), + ), + sentinel, + ) + assert.deepEqual(events, [phase]) + return events + }) + } +} + +export async function runAliasHostile(h) { + const { w } = h + for (const phase of wrappedPhases) + for (const conversion of ['toString', 'primitive']) + await h.test(`aliases-hostile-${phase}-${conversion}`, () => { + const events = [], + fault = {} + const convert = () => { + events.push('String') + throw fault + } + const error = + conversion === 'toString' + ? { toString: convert } + : { [Symbol.toPrimitive]: convert } + const aliases = iteratorFailure(phase, () => { + events.push(phase) + throw error + }) + w.setModuleResolver(() => { + events.push('resolver') + assert.fail('Conversion fault reached resolver') + }) + h.raw( + () => + w.hasDevupUI( + 'src/hostile.tsx', + "import {Box} from './barrel'; export const v=", + pkg, + aliases, + ), + fault, + ) + assert.deepEqual(events, [phase, 'String']) + w.setModuleResolver(null) + assert.equal(w.hasDevupUI('src/clean.tsx', source, pkg, {}), true) + const later = {} + h.raw( + () => + w.importFileMap({ + get bad() { + throw later + }, + }), + later, + ) + return events + }) +} diff --git a/e2e/binding/entries-host.mjs b/e2e/binding/entries-host.mjs new file mode 100644 index 000000000..0fe0fce65 --- /dev/null +++ b/e2e/binding/entries-host.mjs @@ -0,0 +1,257 @@ +import assert from 'node:assert/strict' +import { + appendFileSync, + readdirSync, + realpathSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { box, pkg, source } from './corpus/extraction.mjs' +import { runCases } from './entries-cases.mjs' +import { runContext } from './entries-context.mjs' +import { runErrors } from './entries-errors.mjs' +import { runQueues } from './entries-queues.mjs' +import { runResolverContracts } from './entries-resolver.mjs' +import { encode, hash } from './observation.mjs' + +const [entry, destination, host] = process.argv.slice(2) +assert.equal(process.argv.length, 5) +assert.ok(isAbsolute(entry) && isAbsolute(destination)) +assert.deepEqual(process.execArgv, []) +assert.equal(process.env.NODE_OPTIONS, '') +assert.equal(process.env.BUN_OPTIONS, '') +assert.equal(typeof process.versions.bun === 'string', host === 'bun') +const require = createRequire(import.meta.url), + loaded = realpathSync(entry) +const w = require(loaded), + here = dirname(fileURLToPath(import.meta.url)) +const save = (name, value) => + writeFileSync( + join(destination, name), + JSON.stringify(value, null, 2) + '\n', + { flag: 'wx' }, + ) +const modules = Object.keys(require.cache) +const images = [ + ...new Set([ + ...modules, + ...[...new Set(modules.map(dirname))].flatMap((directory) => + readdirSync(directory) + .filter((name) => name.endsWith('.wasm')) + .map((name) => join(directory, name)), + ), + ]), +] +save('provenance.json', { + requested: entry, + loaded, + runtime: { + execPath: process.execPath, + version: process.version, + versions: process.versions, + platform: process.platform, + arch: process.arch, + sha256: hash(process.execPath), + }, + modules: images.map((path) => ({ path, sha256: hash(path) })), + sources: [ + 'entries.mjs', + 'entries-host.mjs', + 'entries-cases.mjs', + 'entries-errors.mjs', + 'entries-context.mjs', + 'entries-fixtures.mjs', + 'entries-slots.mjs', + 'entries-queues.mjs', + 'entries-resolver.mjs', + 'entries-resolver-publication.mjs', + 'entries-resolver-fixtures.mjs', + 'observation.mjs', + 'corpus/extraction.mjs', + ].map((name) => ({ name, sha256: hash(join(here, name)) })), +}) +const take = (action) => { + try { + return { caught: false, value: action() } + } catch (error) { + return { caught: true, error } + } +} +const original = Object.getOwnPropertyDescriptor(Object, 'entries') +const patch = (descriptor, action) => { + Object.defineProperty(Object, 'entries', { + configurable: true, + enumerable: original.enumerable, + ...descriptor, + }) + try { + return action() + } finally { + Object.defineProperty(Object, 'entries', original) + } +} +const raw = (action, sentinel) => { + const result = take(action) + assert.equal( + result.caught, + true, + 'An actual throw is required, including undefined', + ) + assert.equal(result.error, sentinel, 'Original thrown identity must survive') +} +const schema = (action, message) => { + const result = take(action) + assert.equal(result.caught, true) + assert.ok(result.error instanceof TypeError) + assert.equal(result.error.constructor, TypeError) + assert.equal(result.error.message, message) +} +const open = (options = {}) => + w[options.mode ?? 'codeExtract']( + options.file ?? 'src/entries.tsx', + options.code ?? source, + pkg, + 'df', + false, + false, + false, + Object.hasOwn(options, 'aliases') ? options.aliases : {}, + ) +const read = (out) => { + assert.ok(out instanceof w.Output) + try { + return [ + 'code', + 'map', + 'css', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', + ].map((key) => [key, out[key]]) + } finally { + out.free() + } +} +const snapshot = () => ({ + raw: [ + 'exportSheet', + 'exportClassMap', + 'exportFileMap', + 'exportCanonicalMap', + ].map((name) => w[name]()), + config: [ + w.getPrefix(), + w.isDebug(), + w.getDefaultTheme(), + w.getUnreservedClassSlotCount(), + ], + css: [null, 0, 1].map((index) => w.getCss(index, false)), +}) +const ids = [] +let activeIds = ids, + recordsFile = 'records.jsonl' +const h = { + w, + take, + patch, + raw, + schema, + open, + read, + snapshot, + entries(target, result, action) { + return patch( + { + value: function (value) { + return value === target + ? result + : Reflect.apply(original.value, this, [value]) + }, + }, + action, + ) + }, + fixture(kind) { + if (kind === 'map') + return { + target: {}, + invoke(value) { + return w.importFileMap(value) + }, + } + assert.equal(kind, 'enum') + read(open({ code: box('_hover={{color:"red"}}') })) + const packet = JSON.parse(w.exportSheet()) + const find = (value) => { + if (value === null || typeof value !== 'object') return undefined + for (const key of Object.keys(value)) { + const child = value[key] + if ( + child !== null && + typeof child === 'object' && + Object.hasOwn(child, 'Selector') + ) + return { owner: value, key, target: child } + const result = find(child) + if (result !== undefined) return result + } + return undefined + } + const location = find(packet) + assert.ok( + location, + 'Actual exported sheet must contain a supported Selector enum', + ) + const fixture = { + target: location.target, + pair: original.value(location.target)[0], + prepare(action) { + Object.defineProperty(location.owner, location.key, { + enumerable: true, + configurable: true, + get() { + action() + return fixture.target + }, + }) + }, + invoke() { + return w.importSheet(packet) + }, + } + fixture.prepare(() => {}) + return fixture + }, + async test(id, action) { + assert.ok(!activeIds.includes(id), id) + w.resetStateForTesting() + const before = snapshot() + const observations = await action() + assert.deepEqual( + Object.getOwnPropertyDescriptor(Object, 'entries'), + original, + ) + appendFileSync( + join(destination, recordsFile), + JSON.stringify({ + id, + before: encode(before), + observations: encode(observations), + after: encode(snapshot()), + }) + '\n', + ) + activeIds.push(id) + }, +} +await runCases(h) +await runErrors(h) +await runContext(h) +await runResolverContracts(h) +save('complete.json', { ids }) +activeIds = [] +recordsFile = 'map-start-queues.jsonl' +await runQueues(h, ['nonfunction-method', 'noncallable-next']) +save('map-start-queues-complete.json', { ids: activeIds }) diff --git a/e2e/binding/entries-queues.mjs b/e2e/binding/entries-queues.mjs new file mode 100644 index 000000000..9dd6ada2d --- /dev/null +++ b/e2e/binding/entries-queues.mjs @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict' +import { AsyncLocalStorage } from 'node:async_hooks' + +import { pkg, source } from './corpus/extraction.mjs' +import { + indexed, + iteratorFailure, + queueProbe, + reserve, + slot, + wrappedPhases, +} from './entries-fixtures.mjs' + +export async function runAliasConversions(h, barrel, resolved) { + const { w } = h + for (const phase of wrappedPhases) + for (const mutation of ['keep', 'install', 'clear']) + await h.test(`aliases-conversion-${phase}-${mutation}`, () => { + const events = [], + inner = {} + const resolver = () => { + events.push('resolver') + return resolved + } + w.setModuleResolver(mutation === 'install' ? null : resolver) + const error = { + toString() { + events.push('String') + h.schema(() => w.importClassMap({ a: { color: -1 } }), slot.classes) + h.raw( + () => + w.hasDevupUI('src/inner.tsx', source, pkg, { + get bad() { + throw inner + }, + }), + inner, + ) + events.push('nested') + if (mutation !== 'keep') + w.setModuleResolver(mutation === 'install' ? resolver : null) + return 'forgiven' + }, + } + const aliases = () => + iteratorFailure(phase, () => { + events.push(phase) + throw error + }) + assert.equal( + w.hasDevupUI('src/converted.tsx', barrel, pkg, aliases()), + mutation !== 'clear', + ) + assert.deepEqual(events, [ + phase, + 'String', + 'nested', + ...(mutation === 'clear' ? [] : ['resolver']), + ]) + const before = events.length + assert.equal( + w.hasDevupUI('src/empty.tsx', barrel, pkg, {}), + mutation !== 'clear', + ) + assert.deepEqual( + events.slice(before), + mutation === 'clear' ? [] : ['resolver'], + ) + w.setModuleResolver(null) + for (const action of [ + () => h.open({ aliases: aliases() }), + () => + h.open({ mode: 'codeExtractWithoutSourceMap', aliases: aliases() }), + () => reserve(w, [], aliases()), + ]) { + const start = events.length, + result = h.take(action) + assert.equal(result.caught, true) + assert.equal(result.error.constructor, Error) + assert.equal(result.error.message, 'forgiven') + assert.deepEqual(events.slice(start), [phase, 'String', 'nested']) + w.setModuleResolver(null) + } + return events + }) +} + +export async function runResolverOrder(h) { + for (const [id, imports, use, expected] of [ + [ + 'resolver-order-declarations', + "import {shade} from './two'; import {color} from './one';", + '', + ['./two', './one', './one', './two'], + ], + [ + 'resolver-order-first-use', + "import {color} from './one'; import {shade} from './two';", + '', + ['./one', './two', './one', './two'], + ], + [ + 'resolver-order-local-aliases', + "import {color as shade} from './one'; import {shade as color} from './two';", + '', + ['./one', './two', './two', './one'], + ], + ]) { + await h.test(id, () => { + const events = [] + h.w.setModuleResolver(function (specifier, importer) { + assert.equal(this, null) + events.push([specifier, importer]) + return { + path: `src/${specifier.slice(2)}.ts`, + code: 'export const color="purple"; export const shade="blue";', + } + }) + const output = h.read( + h.open({ + code: `import {Box} from '${pkg}'; ${imports} export const v=${use}`, + file: 'src/order.tsx', + }), + ) + assert.deepEqual( + events, + expected.map((specifier) => [specifier, 'src/order.tsx']), + ) + return { events, output } + }) + } +} + +export async function runQueues(h, phases = ['entries', 'iterator', 'enum']) { + const { w } = h + for (const phase of phases) { + for (const queue of ['Promise', 'queueMicrotask', 'nextTick']) { + await h.test(`queue-context-${phase}-${queue}`, async () => { + const storage = new AsyncLocalStorage(), + events = [], + fixture = h.fixture(phase === 'enum' ? 'enum' : 'map') + const target = fixture.target, + original = Object.entries + let length = 1, + finish + const drained = new Promise((resolve) => { + finish = resolve + }) + const mark = (event) => events.push([event, storage.getStore()]) + const enqueue = () => { + const callback = () => { + mark('queue') + length = 0 + finish() + } + switch (queue) { + case 'Promise': + void Promise.resolve().then(callback) + break + case 'queueMicrotask': + queueMicrotask(callback) + break + case 'nextTick': + process.nextTick(callback) + break + default: + assert.fail(queue) + } + } + const entries = indexed((_value, key) => { + if (key === 'length') { + mark('length') + return length + } + assert.equal(key, '0') + mark('index') + return phase === 'enum' ? fixture.pair : ['a', 8] + }) + const probe = queueProbe(target, { phase, mark, enqueue }) + storage.run('outer-context', () => { + h.patch( + { + value(value) { + if (value !== target) + return Reflect.apply(original, this, [value]) + mark('entries') + if (probe === undefined) enqueue() + storage.run('nested-context', () => { + h.schema(() => w.setPrefix(false), slot.prefix) + assert.equal(storage.getStore(), 'nested-context') + }) + return entries + }, + }, + () => fixture.invoke(target), + ) + mark('after') + assert.deepEqual( + events.map(([event]) => event), + [ + ...(probe === undefined ? [] : [probe]), + 'entries', + 'length', + 'index', + 'after', + ], + ) + }) + await drained + assert.deepEqual( + events, + [ + ...(probe === undefined ? [] : [probe]), + 'entries', + 'length', + 'index', + 'after', + 'queue', + ].map((event) => [event, 'outer-context']), + ) + assert.equal(storage.getStore(), undefined) + if (phase !== 'enum') + assert.deepEqual(JSON.parse(w.exportFileMap()), { a: 8 }) + storage.disable() + return events + }) + } + } +} diff --git a/e2e/binding/entries-resolver-fixtures.mjs b/e2e/binding/entries-resolver-fixtures.mjs new file mode 100644 index 000000000..34a148182 --- /dev/null +++ b/e2e/binding/entries-resolver-fixtures.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' + +import { pkg } from './corpus/extraction.mjs' + +const fix = + 'Fix: repair the module resolver; return {path:string,code:string} with a non-empty path, or null/undefined for an unresolved module.' +export const diagnostic = (file, line, column, specifier, importer, cause) => + `${file}:${line}:${column}: module resolver failed for \`${specifier}\` from \`${importer}\`: ${cause}\n${fix}` +export const barrel = + "import {Box} from './barrel'; export const v=" +export const source = `import { Box } from '${pkg}';\nimport { color } from './color';\nconst x = ;` +export const expectedObject = + 'expected an object {path:string,code:string} or null/undefined' +export const rejected = (h, action) => { + const result = h.take(action) + assert.equal(result.caught, true) + assert.equal(result.error.constructor, Error) + return result.error.message +} diff --git a/e2e/binding/entries-resolver-publication.mjs b/e2e/binding/entries-resolver-publication.mjs new file mode 100644 index 000000000..36aa3cf82 --- /dev/null +++ b/e2e/binding/entries-resolver-publication.mjs @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict' + +import { pkg } from './corpus/extraction.mjs' +import { + barrel, + diagnostic, + rejected, + source, +} from './entries-resolver-fixtures.mjs' + +export async function runResolverPublication(h) { + const { w } = h + for (const mode of ['codeExtract', 'codeExtractWithoutSourceMap']) + for (const precedence of [false, true]) + await h.test(`resolver-publication-${mode}-${precedence}`, () => { + // Given: both a prior same-source sheet and unrelated styles are nonempty. + h.read( + h.open({ + mode, + file: 'src/publish.tsx', + code: `import {Box} from '${pkg}'; const x=;`, + }), + ) + h.read( + h.open({ + mode, + file: 'src/other.tsx', + code: `import {Box} from '${pkg}'; const x=;`, + }), + ) + const code = precedence + ? source.replace("bg='red'", 'styleOrder={0}') + : source + if (precedence) { + w.setModuleResolver(() => null) + const extractorError = rejected(h, () => + h.open({ mode, code, file: 'src/publish.tsx' }), + ) + assert.ok( + extractorError.includes('an integer from 1 to 254'), + extractorError, + ) + } + const before = w.exportSheet() + let calls = 0 + w.setModuleResolver(() => { + calls++ + throw 'publication failure' + }) + // When + const error = rejected(h, () => + h.open({ mode, code, file: 'src/publish.tsx' }), + ) + // Then + assert.ok(calls > 0) + assert.equal( + error, + diagnostic( + 'src/publish.tsx', + 2, + 23, + './color', + 'src/publish.tsx', + 'publication failure', + ), + ) + assert.equal(w.exportSheet(), before) + return { error, sheet: before, calls } + }) + await h.test('resolver-reservation-suppression-all-roots', () => { + // Given + h.read(h.open()) + const before = w.exportSheet(), + events = [] + w.setModuleResolver((specifier, importer) => { + events.push([specifier, importer]) + throw 'reservation failure' + }) + // When + w.reserveClassSlots( + [ + ['src/root-a.tsx', source, 'df'], + ['src/root-b.tsx', source, 'df'], + ], + pkg, + false, + false, + false, + {}, + ) + // Then + assert.equal(w.exportSheet(), before) + assert.deepEqual([...new Set(events.map((event) => event[1]))].sort(), [ + 'src/root-a.tsx', + 'src/root-b.tsx', + ]) + const error = rejected(h, () => + w.hasDevupUI('src/later.tsx', barrel, pkg, {}), + ) + assert.equal( + error, + diagnostic( + 'src/later.tsx', + 1, + 19, + './barrel', + 'src/later.tsx', + 'reservation failure', + ), + ) + return { events, error } + }) + await h.test('shorthand-invalid-target-atomic', () => { + // Given + w.registerShorthands({ + custom: ['marginRight', 'WebkitMaskImage', '--My-color', 'px'], + }) + const code = `import {Box} from '${pkg}'; const x=;` + const before = h.read(h.open({ code })) + const sheet = w.exportSheet() + // When + const error = rejected(h, () => + w.registerShorthands({ custom: ['color'], invalid: ['left', 'widht'] }), + ) + // Then + assert.equal( + error, + 'shorthands["invalid"][1]: shorthand `invalid` cannot use `widht` at build time: its targets must be supported CSS properties, built-in aliases or custom properties', + ) + const after = h.read(h.open({ code })) + assert.equal( + after.find(([key]) => key === 'code')[1], + before.find(([key]) => key === 'code')[1], + ) + const fileNum = JSON.parse(w.exportFileMap())['src/entries.tsx'] + const css = w.getCss(fileNum, false) + for (const property of [ + 'margin-right', + '-webkit-mask-image', + '--My-color', + 'padding-left', + 'padding-right', + ]) + assert.ok(css.includes(`${property}:red`), css) + assert.equal(w.exportSheet(), sheet) + h.schema( + () => w.registerShorthands({ custom: [4] }), + 'registerShorthands(): argument shorthands must be a shorthand mapping', + ) + return { error, before, after, css } + }) +} diff --git a/e2e/binding/entries-resolver.mjs b/e2e/binding/entries-resolver.mjs new file mode 100644 index 000000000..29b7d644d --- /dev/null +++ b/e2e/binding/entries-resolver.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict' + +import { pkg } from './corpus/extraction.mjs' +import { + barrel, + diagnostic, + expectedObject, + rejected, + source, +} from './entries-resolver-fixtures.mjs' +import { runResolverPublication } from './entries-resolver-publication.mjs' + +export async function runResolverContracts(h) { + const { w } = h + const malformed = [ + ['number', () => 7, expectedObject], + ['string', () => 'private-looking cause', expectedObject], + [ + 'function', + () => Object.assign(() => {}, { path: 'x', code: '' }), + expectedObject, + ], + ['array', () => Object.assign([], { path: 'x', code: '' }), expectedObject], + ['missing-path', () => ({}), '`path` must be a string'], + ['number-path', () => ({ path: 4 }), '`path` must be a string'], + [ + 'empty-path', + () => ({ + path: '', + get code() { + throw new Error('empty path must stop before code') + }, + }), + '`path` must be a non-empty string', + ], + ['missing-code', () => ({ path: 'x' }), '`code` must be a string'], + ['number-code', () => ({ path: 'x', code: 4 }), '`code` must be a string'], + ] + for (const [name, resolver, cause] of malformed) + await h.test(`resolver-strict-${name}`, () => { + // Given + w.setModuleResolver(resolver) + // When + const error = rejected(h, () => + w.hasDevupUI('src/strict.tsx', barrel, pkg, {}), + ) + // Then + assert.equal( + error, + diagnostic( + 'src/strict.tsx', + 1, + 19, + './barrel', + 'src/strict.tsx', + `malformed module resolver result: ${cause}`, + ), + ) + return error + }) + for (const [name, value, cause] of [ + ['string', 'broken Ω', 'broken Ω'], + ['error', new Error('broken Ω'), 'broken Ω'], + ['null', null, 'JsValue(null)'], + ['undefined', undefined, 'JsValue(undefined)'], + ['message', { message: 'message Ω' }, 'message Ω'], + ['nonstring-message', { message: 4 }, 'JsValue(Object({"message":4}))'], + [ + 'throwing-message', + { + get message() { + throw null + }, + }, + 'JsValue(Object)', + ], + [ + 'no-string-coercion', + { + toString() { + assert.fail('must not coerce cause') + }, + }, + 'JsValue(Object({}))', + ], + ]) + for (const phase of ['callback', 'path', 'code']) + await h.test(`resolver-cause-${name}-${phase}`, () => { + // Given + const events = [] + w.setModuleResolver(function () { + assert.equal(this, null) + events.push('callback') + if (phase === 'callback') throw value + return { + get path() { + events.push('path') + if (phase === 'path') throw value + return 'x' + }, + get code() { + events.push('code') + throw value + }, + } + }) + // When + const error = rejected(h, () => + w.hasDevupUI('src/cause.tsx', barrel, pkg, {}), + ) + // Then + assert.equal( + error, + diagnostic( + 'src/cause.tsx', + 1, + 19, + './barrel', + 'src/cause.tsx', + cause, + ), + ) + assert.deepEqual(events, [ + 'callback', + ...(phase === 'callback' ? [] : ['path']), + ...(phase === 'code' ? ['code'] : []), + ]) + return { error, events } + }) + for (const value of [null, undefined]) + await h.test(`resolver-nullish-${value}`, () => { + // Given + w.setModuleResolver(() => value) + // When + const eligible = w.hasDevupUI('src/nullish.tsx', barrel, pkg, {}) + const output = h.read(h.open({ code: source })) + // Then + assert.equal(eligible, false) + return { eligible, output } + }) + await h.test('resolver-transitive-repeated-original-sites', () => { + // Given + const code = `import {Box} from './barrel';\nexport * from './barrel';\nexport const v=` + let failures = 0 + w.setModuleResolver((specifier, importer) => { + if (importer === 'src/transitive.tsx') + return { + path: 'src/barrel.ts', + code: `export {Box} from './leaf';\nexport * from './leaf';`, + } + assert.equal(specifier, './leaf') + failures++ + throw 'leaf failure' + }) + // When + const error = rejected(h, () => + h.open({ file: 'src/transitive.tsx', code }), + ) + const repeated = rejected(h, () => + h.open({ file: 'src/transitive.tsx', code }), + ) + // Then + assert.equal(repeated, error) + assert.ok( + failures > 1, + 'exercise repeated failed resolution, not just two sites', + ) + assert.equal( + error, + [ + diagnostic( + 'src/barrel.ts', + 1, + 19, + './leaf', + 'src/barrel.ts', + 'leaf failure', + ), + diagnostic( + 'src/barrel.ts', + 2, + 15, + './leaf', + 'src/barrel.ts', + 'leaf failure', + ), + ].join('\n'), + ) + return { error, failures } + }) + await runResolverPublication(h) +} diff --git a/e2e/binding/entries-slots.mjs b/e2e/binding/entries-slots.mjs new file mode 100644 index 000000000..b1f9809c1 --- /dev/null +++ b/e2e/binding/entries-slots.mjs @@ -0,0 +1,221 @@ +import assert from 'node:assert/strict' + +import { pkg, source } from './corpus/extraction.mjs' +import { indexed, reserve, runAliasHostile, slot } from './entries-fixtures.mjs' + +export async function runSlots(h) { + const { w } = h + await h.test('root-slots-precedence-nested-schema', () => { + const before = h.snapshot() + const poison = { + get [Symbol.iterator]() { + return assert.fail('Primitive validation must precede aliases') + }, + } + h.schema( + () => w.codeExtract(4, source, pkg, 'df', false, false, false, poison), + slot.filename, + ) + const cases = [ + [() => w.importClassMap({ a: { color: -1 } }), slot.classes], + [ + () => + w.importFileMap({ + [Symbol.iterator]: function* () { + yield null + }, + }), + slot.file, + ], + [() => reserve(w, [['bad']], poison), slot.rows], + [() => reserve(w, [], { bad: 4 }), slot.aliases], + ] + for (const [action, message] of cases) h.schema(action, message) + for (const mode of ['codeExtract', 'codeExtractWithoutSourceMap']) + h.schema( + () => h.open({ mode, aliases: { bad: 4 } }), + `${mode}(): argument import_aliases must be an alias mapping`, + ) + assert.deepEqual( + h.snapshot(), + before, + 'Rejected root slots must not mutate core state', + ) + return cases.map(([, message]) => message) + }) + await h.test('aliases-forgiving-raw-and-later-error', () => { + const events = [], + sentinel = {}, + aliases = { + get a() { + events.push('get') + throw sentinel + }, + } + h.raw(() => w.hasDevupUI('src/ok.tsx', source, pkg, aliases), sentinel) + h.raw(() => h.open({ aliases }), sentinel) + h.raw(() => reserve(w, [], aliases), sentinel) + for (const value of [undefined, null, { bad: 7 }]) + assert.equal(w.hasDevupUI('src/ok.tsx', source, pkg, value), true) + const later = h.take(() => + w.hasDevupUI( + 'src/later.tsx', + "import { Box } from '@devup-ui/react'; const =", + pkg, + aliases, + ), + ) + assert.equal(later.caught, true) + assert.equal(later.error, sentinel) + assert.deepEqual(events, ['get', 'get', 'get', 'get']) + return { events, laterCaught: later.caught, laterValue: later.value } + }) + await h.test('aliases-strict-nullish-diagnostic-and-access', () => { + const events = [], + sentinel = { + [Symbol.toPrimitive]() { + assert.fail('Access replay must remain raw') + }, + } + for (const mode of ['codeExtract', 'codeExtractWithoutSourceMap']) { + for (const aliases of [null, undefined]) + h.schema( + () => h.open({ mode, aliases }), + `${mode}(): argument import_aliases must be an alias mapping`, + ) + h.raw( + () => + h.open({ + mode, + aliases: { + get a() { + events.push(mode) + throw sentinel + }, + }, + }), + sentinel, + ) + } + for (const aliases of [null, undefined]) + h.schema(() => reserve(w, [], aliases), slot.aliases) + const diagnostic = { + bad: { + toJSON() { + events.push('diagnostic') + return 7 + }, + }, + } + assert.equal( + w.hasDevupUI('src/diagnostic.tsx', source, pkg, diagnostic), + true, + ) + assert.deepEqual(events, [ + 'codeExtract', + 'codeExtractWithoutSourceMap', + 'diagnostic', + ]) + return events + }) + await runAliasHostile(h) + await h.test('aliases-schema-whole-map-diagnostics', () => { + const events = [], + code = "import Box from '@alias'; export const v=" + const check = (aliases) => w.hasDevupUI('src/whole.tsx', code, pkg, aliases) + assert.equal(check({ '@alias': 'Box' }), true) + assert.equal(check({}), false) + for (const aliases of [ + undefined, + null, + { '@alias': 'Box', bad: 7 }, + new Map([ + ['@alias', 'Box'], + ['bad', 7], + ]), + { + '@alias': 'Box', + bad: { + toJSON() { + events.push('toJSON') + throw null + }, + }, + }, + { + [Symbol.iterator]: function* () { + yield ['@alias', 'Box'] + throw { + toString() { + events.push('String') + return 'partial' + }, + } + }, + }, + ]) + assert.equal(check(aliases), false) + assert.deepEqual(events, ['toJSON', 'String']) + return events + }) + for (const phase of ['entries', 'enumeration', 'length']) + await h.test(`reentry-${phase}-outer-frame`, () => { + const events = [], + inner = {}, + outer = {}, + target = {} + const nested = () => { + h.schema(() => w.importClassMap({ a: { color: -1 } }), slot.classes) + h.raw( + () => + w.importFileMap({ + get a() { + throw inner + }, + }), + inner, + ) + events.push('nested-schema-access') + } + const original = Object.entries + const result = indexed((_value, key) => { + assert.equal(key, 'length') + nested() + throw outer + }) + h.raw( + () => + h.patch( + { + value(value) { + if (value !== target) + return Reflect.apply(original, this, [value]) + if (phase === 'entries') { + nested() + throw outer + } + if (phase === 'enumeration') + return original({ + get a() { + nested() + throw outer + }, + }) + return result + }, + }, + () => w.importFileMap(target), + ), + outer, + ) + assert.deepEqual(events, ['nested-schema-access']) + const aliases = { + get bad() { + nested() + return 4 + }, + } + h.schema(() => h.open({ aliases }), slot.extraction) + return events + }) +} diff --git a/e2e/binding/entries.mjs b/e2e/binding/entries.mjs new file mode 100644 index 000000000..da3494f23 --- /dev/null +++ b/e2e/binding/entries.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { hash } from './observation.mjs' + +assert.equal( + process.argv.length, + 7, + 'Usage: entries.mjs ', +) +const [node, bun, nativeB, wasm, evidence] = process.argv.slice(2) +for (const path of [node, bun, nativeB, wasm, evidence]) + assert.ok(isAbsolute(path), 'All paths must be absolute') +const here = dirname(fileURLToPath(import.meta.url)) +mkdirSync(evidence) +const save = (name, value) => + writeFileSync(join(evidence, name), JSON.stringify(value, null, 2) + '\n', { + flag: 'wx', + }) +save('provenance.json', { + runtimes: [node, bun].map((path) => ({ + path, + real: realpathSync(path), + sha256: hash(path), + })), + engines: [nativeB, wasm].map((path) => ({ + path, + real: realpathSync(path), + sha256: hash(path), + })), + sources: [ + 'entries.mjs', + 'entries-host.mjs', + 'entries-cases.mjs', + 'entries-errors.mjs', + 'entries-context.mjs', + 'entries-fixtures.mjs', + 'entries-slots.mjs', + 'entries-queues.mjs', + 'entries-resolver.mjs', + 'entries-resolver-publication.mjs', + 'entries-resolver-fixtures.mjs', + 'observation.mjs', + 'corpus/extraction.mjs', + ].map((name) => ({ name, sha256: hash(join(here, name)) })), +}) +const runs = [] +let reference +for (const [host, runtime] of [ + ['node', node], + ['bun', bun], +]) { + for (const [engine, entry] of [ + ['NativeB', nativeB], + ['WASM', wasm], + ]) { + for (let repeat = 1; repeat <= 3; repeat++) { + const id = `${host}-${engine}-${repeat}`, + directory = join(evidence, id) + mkdirSync(directory) + const stdout = openSync(join(directory, 'stdout.log'), 'wx') + const stderr = openSync(join(directory, 'stderr.log'), 'wx') + let child + try { + child = spawnSync( + runtime, + [join(here, 'entries-host.mjs'), entry, directory, host], + { + cwd: directory, + env: { ...process.env, NODE_OPTIONS: '', BUN_OPTIONS: '' }, + stdio: ['ignore', stdout, stderr], + timeout: 120000, + killSignal: 'SIGKILL', + }, + ) + } finally { + closeSync(stdout) + closeSync(stderr) + } + const run = { + id, + status: child.status, + signal: child.signal, + error: child.error + ? { name: child.error.name, message: child.error.message } + : null, + } + try { + assert.equal(child.error, undefined) + assert.equal(child.signal, null) + assert.equal(child.status, 0) + const complete = JSON.parse( + readFileSync(join(directory, 'complete.json'), 'utf8'), + ) + assert.ok(complete.ids.length > 0) + assert.equal(new Set(complete.ids).size, complete.ids.length) + const bytes = readFileSync(join(directory, 'records.jsonl')) + assert.ok(bytes.length > 0) + if (reference === undefined) reference = bytes + assert.ok( + bytes.equals(reference), + `${id}: complete raw record Buffer differs`, + ) + run.parity = true + } catch (error) { + assert.ok(error instanceof Error) + run.parity = false + run.failure = error.message + } + runs.push(run) + save(`${id}.json`, run) + } + } +} +save('summary.json', { children: runs.length, runs }) +assert.equal(runs.length, 12) +assert.ok( + runs.every((run) => run.parity), + 'Functional/parity failures; inspect retained evidence', +) diff --git a/e2e/binding/host.mjs b/e2e/binding/host.mjs new file mode 100644 index 000000000..f2ffa679b --- /dev/null +++ b/e2e/binding/host.mjs @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict' +import { + appendFileSync, + readdirSync, + realpathSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join } from 'node:path' + +import { corpus, file, pkg, source } from './corpus.mjs' +import { capturePatched } from './corpus/boundary.mjs' +import { capture, encode, hash } from './observation.mjs' + +export { encode } from './observation.mjs' +const [entry, destination] = process.argv.slice(2) +assert.equal(process.argv.length, 4) +assert.ok(isAbsolute(entry) && isAbsolute(destination)) +assert.equal(process.env.NODE_OPTIONS, '') +assert.equal(process.env.BUN_OPTIONS, '') +assert.deepEqual(process.execArgv, []) +const require = createRequire(import.meta.url), + loaded = realpathSync(entry) +const nativeImages = [] +const dlopen = process.dlopen +let w +process.dlopen = function (...args) { + nativeImages.push(realpathSync(args[1])) + return Reflect.apply(dlopen, this, args) +} +try { + w = require(loaded) +} finally { + process.dlopen = dlopen +} +const save = (name, value) => + writeFileSync( + join(destination, name), + JSON.stringify(value, null, 2) + '\n', + { flag: 'wx' }, + ) +const modules = Object.keys(require.cache) +const images = [ + ...new Set([ + ...modules, + ...nativeImages, + ...[...new Set(modules.map(dirname))].flatMap((directory) => + readdirSync(directory) + .filter((name) => name.endsWith('.wasm')) + .map((name) => join(directory, name)), + ), + ]), +] +save('provenance.json', { + requested: entry, + loaded, + runtime: { + execPath: process.execPath, + version: process.version, + versions: process.versions, + platform: process.platform, + arch: process.arch, + execArgv: process.execArgv, + sha256: hash(process.execPath), + }, + modules: images.map((path) => ({ path, sha256: hash(path) })), + scenarios: corpus.map((c) => c.name), +}) +const names = + 'Output codeExtract codeExtractWithoutSourceMap exportCanonicalMap exportClassMap exportFileMap exportSheet getCss getDefaultTheme getPrefix getThemeInterface getUnreservedClassSlotCount hasDevupUI importCanonicalMap importClassMap importFileMap importFileRoutes importSheet isDebug registerShorthands registerTheme reserveClassSlots resetBuildState resetStateForTesting seedFileMap setAtomHoist setDebug setModuleResolver setPrefix'.split( + ' ', + ) +const abi = { + names: Object.keys(w).sort(), + types: names.map((name) => [name, typeof w[name]]), + arities: ['codeExtract', 'codeExtractWithoutSourceMap', 'hasDevupUI'].map( + (name) => [name, w[name]?.length], + ), +} +abi.result = capture(() => { + assert.deepEqual(abi.names, [...names].sort()) + for (const name of names) assert.equal(typeof w[name], 'function', name) + for (const [name, arity] of abi.arities) + assert.equal(arity, name === 'hasDevupUI' ? 4 : 8) + assert.equal(typeof w.Output.prototype.free, 'function') +}) +save('abi.json', abi) +const fields = [ + 'code', + 'map', + 'css', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', +] +const exports4 = [ + 'exportSheet', + 'exportClassMap', + 'exportFileMap', + 'exportCanonicalMap', +] +let scenario = '', + sequence = 0 +const emit = (record) => + appendFileSync( + join(destination, 'records.jsonl'), + JSON.stringify({ scenario, sequence: sequence++, ...record }) + '\n', + ) +const exportsRaw = () => + Object.fromEntries(exports4.map((name) => [name, w[name]()])) +const snapshot = () => ({ + exports: Object.fromEntries( + exports4.map((name) => [name, capture(() => w[name]())]), + ), + config: Object.fromEntries( + [ + 'getPrefix', + 'isDebug', + 'getDefaultTheme', + 'getUnreservedClassSlotCount', + ].map((name) => [name, capture(() => w[name]())]), + ), + interface: capture(() => + w.getThemeInterface( + pkg, + 'Colors', + 'Typography', + 'Length', + 'Shadow', + 'Theme', + ), + ), + css: [null, 0, 1, 2, 3, 4, 7, 128].flatMap((index) => + [false, true].map((main) => ({ + index, + main, + result: capture(() => w.getCss(index, main)), + })), + ), +}) +const h = { + w, + fields, + exports: exportsRaw, + note: (label, value) => emit({ kind: 'note', label, value: encode(value) }), + step(label, action, input) { + const before = snapshot() + const result = capture(action) + emit({ + kind: 'step', + label, + input: encode(input), + before, + result, + after: snapshot(), + }) + return result + }, + call(name, ...args) { + return h.step(name, () => w[name](...args), args) + }, + open(code = source, options = {}) { + const { + filename = file, + method = 'codeExtract', + single = true, + flags = [single, false, false], + } = options + return w[method]( + filename, + code, + pkg, + 'df', + ...flags, + Object.hasOwn(options, 'aliases') ? options.aliases : {}, + ) + }, + extract(code = source, options = {}) { + h.step( + options.method ?? 'codeExtract', + () => { + const out = h.open(code, options) + try { + return { + instanceOfOutput: out instanceof w.Output, + ...Object.fromEntries( + fields.map((key) => [ + key, + key in out ? out[key] : { tag: 'absent' }, + ]), + ), + } + } finally { + out.free() + } + }, + [code, options], + ) + }, +} +assert.equal(corpus.length, 25) +assert.equal(new Set(corpus.map((c) => c.name)).size, 25) +for (const test of corpus) { + scenario = test.name + assert.equal(h.call('resetStateForTesting').tag, 'return') + emit({ kind: 'start', state: snapshot() }) + test.run(h) + emit({ kind: 'complete', state: snapshot() }) +} +// Number replacement is an approved engine difference, outside parity records. +const native = nativeImages.length > 0 +const originalNumber = Number +const numberUserData = [] +for (const mode of ['unmodified', 'replaced', 'throwing']) { + // Given: the same numeric user data and a replacement retaining safe-int checks. + w.resetStateForTesting() + const marker = new Error('Number user-data marker') + let calls = 0 + const replacement = (value) => { + calls++ + assert.equal(value, 8) + if (mode === 'throwing') throw marker + return 99 + } + replacement.isSafeInteger = originalNumber.isSafeInteger + // When: the real loaded engine decodes user data, never a private control key. + const outcome = capturePatched( + mode === 'unmodified' ? [] : [[globalThis, 'Number', replacement]], + () => w.importFileMap({ probe: 8 }), + ) + // Then: ordinary inputs agree; only WASM observes the replaced Number call. + const observesNumber = !native && mode !== 'unmodified' + const throwsMarker = observesNumber && mode === 'throwing' + assert.equal(calls, observesNumber ? 1 : 0) + assert.equal(outcome.error, throwsMarker ? marker : undefined) + const fileMap = JSON.parse(w.exportFileMap()) + assert.deepEqual( + fileMap, + throwsMarker ? {} : { probe: observesNumber ? 99 : 8 }, + ) + numberUserData.push({ + mode, + calls, + threwMarker: outcome.error === marker, + fileMap, + }) +} +save('number-userdata.json', { native, records: numberUserData }) +w.resetStateForTesting() +if (abi.result.tag !== 'return') process.exitCode = 1 diff --git a/e2e/binding/identity.mjs b/e2e/binding/identity.mjs new file mode 100644 index 000000000..70eb1e265 --- /dev/null +++ b/e2e/binding/identity.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + closeSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + statSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { compare } from './comparison.mjs' +import { corpus } from './corpus.mjs' + +assert.ok( + process.argv.length === 5 || process.argv.length === 6, + 'Usage: node e2e/binding/identity.mjs [runtime-executable]', +) +const [wasm, native, evidence, runtime = process.execPath] = + process.argv.slice(2) +for (const path of [wasm, native, evidence]) + assert.ok(isAbsolute(path), 'Absolute paths required') +mkdirSync(evidence) +const output = realpathSync(evidence), + here = dirname(fileURLToPath(import.meta.url)), + root = resolve(here, '../..') +const sha = (bytes) => createHash('sha256').update(bytes).digest('hex') +const save = (path, value) => + writeFileSync(join(output, path), JSON.stringify(value, null, 2) + '\n', { + flag: 'wx', + }) +function sources(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((item) => { + const path = join(directory, item.name) + return item.isDirectory() && + !['target', 'node_modules', '.git', 'pkg'].includes(item.name) + ? sources(path) + : item.isFile() && /\.(rs|toml|lock|mjs|cjs)$/.test(item.name) + ? [ + { + path, + bytes: statSync(path).size, + sha256: sha(readFileSync(path)), + }, + ] + : [] + }) +} +const provenance = { + runner: { + execPath: process.execPath, + version: process.version, + versions: process.versions, + platform: process.platform, + arch: process.arch, + }, + runtime, + entries: [wasm, native].map((path) => ({ + path, + realpath: realpathSync(path), + sha256: sha(readFileSync(path)), + })), + sources: [ + ...sources(join(root, 'libs')), + ...sources(join(root, 'bindings')), + ...sources(here), + ...['Cargo.toml', 'Cargo.lock'].map((name) => { + const path = join(root, name) + return { path, sha256: sha(readFileSync(path)) } + }), + ], +} +save('provenance.json', provenance) +const runs = [], + problems = [] +for (const [engine, entry] of [ + ['wasm', wasm], + ['native', native], +]) + for (let repeat = 1; repeat <= 3; repeat++) { + const name = `${engine}-${repeat}`, + directory = join(output, name) + mkdirSync(directory) + const stdout = openSync(join(directory, 'stdout.log'), 'wx'), + stderr = openSync(join(directory, 'stderr.log'), 'wx') + let result + try { + result = spawnSync(runtime, [join(here, 'host.mjs'), entry, directory], { + cwd: directory, + env: { ...process.env, NODE_OPTIONS: '', BUN_OPTIONS: '' }, + stdio: ['ignore', stdout, stderr], + timeout: 120000, + killSignal: 'SIGKILL', + }) + } finally { + closeSync(stdout) + closeSync(stderr) + } + const run = { + name, + engine, + repeat, + command: [runtime, join(here, 'host.mjs'), entry, directory], + status: result.status, + signal: result.signal, + error: result.error + ? { + name: result.error.name, + message: result.error.message, + code: result.error.code, + } + : null, + } + runs.push(run) + if (result.error || result.signal !== null || result.status !== 0) + problems.push({ run: name, reason: 'natural zero exit required' }) + try { + const records = readFileSync(join(directory, 'records.jsonl')) + run.recordBytes = records.length + run.recordSha256 = sha(records) + const parsed = records + .toString('utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)) + assert.deepEqual( + parsed + .filter((record) => record.kind === 'complete') + .map((record) => record.scenario), + corpus.map((test) => test.name), + ) + assert.ok(parsed.every((record, i) => record.sequence === i)) + const host = JSON.parse( + readFileSync(join(directory, 'provenance.json'), 'utf8'), + ) + assert.equal(host.loaded, realpathSync(entry)) + const abi = JSON.parse(readFileSync(join(directory, 'abi.json'), 'utf8')) + assert.equal(abi.result.tag, 'return') + } catch (error) { + problems.push({ run: name, reason: error.message }) + } + run.artifacts = readdirSync(directory).map((path) => ({ + path, + bytes: statSync(join(directory, path)).size, + sha256: sha(readFileSync(join(directory, path))), + })) + save(`${name}.json`, run) + } +const comparisons = compare(output, problems) +save('comparison.json', { + verdict: problems.length ? 'FAIL' : 'PASS', + scenarios: corpus.map((test) => test.name), + runs, + comparisons, + problems, +}) +console.info(`${problems.length ? 'FAIL' : 'PASS'}: ${output}`) +if (problems.length) process.exitCode = 1 diff --git a/e2e/binding/native-coverage/README.md b/e2e/binding/native-coverage/README.md new file mode 100644 index 000000000..99aba5337 --- /dev/null +++ b/e2e/binding/native-coverage/README.md @@ -0,0 +1,45 @@ +# Production native coverage + +`bun run test` builds the actual production addon with LLVM instrumentation, +copies it outside the Cargo target directory, and generates its entry with the +normal binding producer. The root tarpaulin invocation enables the test-only +`devup-ui-binding/native-coverage` feature and loads that same addon through +`--objects`. Its ordinary XML and stdout reports include the genuine Node +profiles alongside the Rust tests. CI still requires 100 percent coverage. + +The production crate has no code conditional on this feature. Plain +`cargo test --workspace` and `cargo test --workspace --all-targets` require no +preparation and do not run the Node integration target. Explicitly enabling +`native-coverage` without the root preparer's environment fails with an +actionable error; it never skips. + +The integration target waits for real Node children to exit naturally. They run +the existing public extraction/entry and argument-validation scenarios, followed +by the narrow private protocol suite. Successful decoding uses the authored +interop. Synthetic envelopes are limited to targeted private protocol failures. +The raw resolver failure group is separately named for the private acceptance +experiment, but the normal root entry always executes both groups. +The prepared host path is test-only: the root preparer always replaces it with +the positive scenarios script, never an inherited alternate host or control mode. + +The root preparer prints its unique temporary evidence directory. It retains the +production `.node`, generated entry, build profiles, child records and `run.json` +with commands, process IDs, statuses, durations and the addon hash. Runtime addon +profiles inherit tarpaulin's `%m`/`%p` path under its own target profile directory. +No report is replaced or manually merged. Full-source JSON is produced only by +the private evidence experiment, outside the linted repository root. Ordinary Node and the installed +latest stable Rust/tarpaulin are used on Windows and Linux. + +This instrumented development build is coverage evidence, not a shipping-native +performance measurement. Real-engine parity and optimized hosting measurements +remain separate gates. Native line totals can differ across platforms and CI's +wide source formatting; inspect the actual report rather than equating a local +percentage with the Linux CI result. + +The retained coverage spike has been retired (exact workflow and eleven fixture +files); production coverage above is unchanged. Standard Linux CI100% remains +the final authority. The inherited Windows98.09% mapping is local evidence only, +not a replacement for that gate. The parallel `native-build`/`native-packs`/ +`native-consumer` shipping lane in `publish.yml` tests optimized public packages, +not instrumented coverage or native performance. Its results cannot waive the +production Linux coverage gate. diff --git a/e2e/binding/native-coverage/native_host.rs b/e2e/binding/native-coverage/native_host.rs new file mode 100644 index 000000000..ce695e07f --- /dev/null +++ b/e2e/binding/native-coverage/native_host.rs @@ -0,0 +1,18 @@ +use std::process::Command; + +#[test] +fn production_addon_executes_in_real_node() -> Result<(), Box> { + let preparation = + "native coverage preparation is missing; run `bun run test` from the repository root"; + let node = std::env::var_os("DEVUP_NATIVE_COVERAGE_NODE").ok_or(preparation)?; + let entry = std::env::var_os("DEVUP_NATIVE_COVERAGE_ENTRY").ok_or(preparation)?; + let evidence = std::env::var_os("DEVUP_NATIVE_COVERAGE_EVIDENCE").ok_or(preparation)?; + let host = std::env::var_os("DEVUP_NATIVE_COVERAGE_HOST").ok_or(preparation)?; + let result = Command::new(node) + .args([host, entry, evidence]) + .env("NODE_OPTIONS", "") + .env("BUN_OPTIONS", "") + .status()?; + assert!(result.success(), "real Node coverage host failed: {result}"); + Ok(()) +} diff --git a/e2e/binding/native-coverage/private-host.mjs b/e2e/binding/native-coverage/private-host.mjs new file mode 100644 index 000000000..dabf6a08e --- /dev/null +++ b/e2e/binding/native-coverage/private-host.mjs @@ -0,0 +1,278 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join } from 'node:path' + +import { runProtocol } from './private-protocol.mjs' +import { runValues, withControl } from './private-values.mjs' + +const [entry, destination, group] = process.argv.slice(2) +assert.equal(process.argv.length, 5) +assert.ok(isAbsolute(entry) && isAbsolute(destination)) +assert.equal(process.versions.bun, undefined) +assert.ok(['protocol', 'raw-resolver-failure'].includes(group)) +const require = createRequire(import.meta.url) +const image = realpathSync(join(dirname(entry), 'binding.node')) +const raw = require(image) +const { + OP, + withAccess, +} = require('../../../bindings/devup-ui-binding/interop.cjs') +const { + createBinding, +} = require('../../../bindings/devup-ui-binding/adapter.cjs') +const records = [] +const check = (name, action) => { + action() + records.push(name) +} +const file = 'native-private.tsx' +const pkg = '@devup-ui/react' +const code = + 'import { Box } from "./barrel"; export const x = ' +const request = () => [file, code, pkg, {}] +const call = (method, value) => withAccess(raw[method], value) + +check('state-before-init', () => { + assert.throws(() => raw.reset(false), /native facade is not initialized/) + assert.throws( + () => raw.setResolver(undefined), + /native facade is not initialized/, + ) +}) +const w = createBinding(raw) +check('repeat-init-retains-resolver', () => { + let calls = 0 + w.setModuleResolver(() => { + calls++ + return { path: 'barrel.tsx', code: 'export { Box } from "@devup-ui/react"' } + }) + raw.init() + assert.equal(w.hasDevupUI(...request()), true) + assert.ok(calls > 0) + w.resetBuildState() + assert.equal(w.hasDevupUI(...request()), false) +}) + +if (group === 'raw-resolver-failure') { + check('unwrapped-resolver-throw', () => { + const marker = new Error('private raw resolver throw') + let calls = 0 + raw.setResolver(() => { + calls++ + throw marker + }) + assert.throws( + () => call('hasDevupUi', request()), + (error) => error === marker, + ) + assert.ok(calls > 0) + }) + check('unwrapped-resolver-invalid-resolved', () => { + let calls = 0 + raw.setResolver(() => { + calls++ + return { path: 3, code: '' } + }) + assert.throws(() => call('hasDevupUi', request()), { + code: 'InvalidArg', + message: 'Value is none of these types `String`, `Resolved`, ', + }) + assert.ok(calls > 0) + }) + check('unwrapped-resolver-failure-blocks-publication', () => { + for (const maps of [true, false]) { + w.resetStateForTesting() + const baseline = w.codeExtract( + file, + `import {Box} from '${pkg}'; const x=`, + pkg, + 'df', + false, + false, + false, + {}, + ) + baseline.free() + const before = w.exportSheet(), + marker = new Error('private publication failure') + raw.setResolver(() => { + throw marker + }) + assert.throws( + () => + call('extract', [ + file, + code, + pkg, + 'df', + false, + false, + false, + {}, + maps, + ]), + (error) => error === marker, + ) + assert.equal(w.exportSheet(), before) + } + }) + check('private-textual-failure-payload', () => { + w.resetStateForTesting() + raw.setResolver(() => 'private formatted cause') + assert.throws( + () => call('hasDevupUi', request()), + /native-private.tsx:1:21: module resolver failed for `\.\/barrel` from `native-private.tsx`: private formatted cause/, + ) + assert.doesNotThrow(() => + call('reserve', [[[file, code, 'df']], pkg, false, false, false, {}]), + ) + }) +} else { + for (const method of ['hasDevupUi', 'extract', 'reserve']) { + check(`${method}-short-request`, () => { + assert.throws(() => call(method, []), /missing private request field/) + }) + check(`${method}-wrong-request`, () => { + assert.throws( + () => call(method, method === 'reserve' ? [[], false] : [false]), + /String/, + ) + }) + } + check('interface-name-arity', () => { + for (const names of [[], ['a'], ['a', 'b', 'c', 'd', 'e', 'f']]) + assert.throws( + () => raw.getThemeInterface(pkg, names), + /invalid private interface-name request/, + ) + assert.throws(() => raw.getThemeInterface(pkg, [false]), /String/) + }) + check('schema-callback-throw', () => { + const marker = new Error('private schema callback throw') + const target = raw.importCanonicalMap + assert.throws( + () => + withControl(target, false, ({ access }) => (op, value, key) => { + if (op === OP.schema) throw marker + return access(op, value, key) + }), + (error) => error === marker, + ) + }) + check('preflight-host-failure', () => { + const target = raw.hasDevupUi + assert.throws( + () => + withControl( + target, + request(), + ({ access }) => + (op, value, key) => + op === OP.mapStart ? [] : access(op, value, key), + ), + { + name: 'Error', + code: 'GenericFailure', + message: /^Error: missing private access result$/, + }, + ) + }) + check('preflight-access-failure', () => { + const marker = new Error('private preflight access throw') + // Iterator getter failures are intentionally converted and forgiven. A raw + // entry getter is not an iterator-import failure and must escape unchanged. + const aliases = { + get broken() { + throw marker + }, + } + assert.throws( + () => call('hasDevupUi', [file, code, pkg, aliases]), + (error) => error === marker, + ) + }) + check('preflight-failure-operation-host', () => { + const target = raw.hasDevupUi + assert.throws( + () => + withControl( + target, + request(), + ({ access, accessToken }) => + (op, value, key) => { + if (op === OP.mapStart) return accessToken + if (op === OP.preflightFailure) + throw new Error('private preflight failure host') + return access(op, value, key) + }, + ), + { + name: 'Error', + code: 'GenericFailure', + message: /^Error: (?:Error: )?private preflight failure host$/, + }, + ) + }) + check('reservation-row-arity', () => { + assert.throws( + () => w.reserveClassSlots([['file.tsx']], pkg, false, false, false, {}), + TypeError, + ) + }) + check('unknown-field-is-not-read', () => { + let reads = 0 + const operations = [] + const target = raw.importSheet + withControl( + target, + { + get unknown() { + reads++ + throw Error('unknown field read') + }, + }, + ({ access }) => + (op, value, key) => { + operations.push([op, key]) + return access(op, value, key) + }, + ) + assert.equal(reads, 0) + assert.deepEqual( + operations, + [ + 'atom_plan', + 'properties', + 'css', + 'keyframes', + 'global_css_files', + 'imports', + 'font_faces', + 'layer_orders', + ].map((key) => [OP.field, key]), + ) + }) + runValues(w, check, file) + runProtocol(raw, check, request) +} +w.resetStateForTesting() +writeFileSync( + join(destination, 'complete.json'), + JSON.stringify( + { + group, + entry: realpathSync(entry), + image, + node: process.execPath, + version: process.version, + sha256: createHash('sha256').update(readFileSync(image)).digest('hex'), + records, + }, + null, + 2, + ) + '\n', + { flag: 'wx' }, +) +console.info(JSON.stringify({ group, passed: records.length })) diff --git a/e2e/binding/native-coverage/private-protocol.mjs b/e2e/binding/native-coverage/private-protocol.mjs new file mode 100644 index 000000000..7e94583d8 --- /dev/null +++ b/e2e/binding/native-coverage/private-protocol.mjs @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' + +const { OP } = createRequire(import.meta.url)( + '../../../bindings/devup-ui-binding/interop.cjs', +) + +import { runMapStart, withControl } from './private-values.mjs' + +export function runProtocol(raw, check, request) { + runMapStart(raw, check, request) + for (const [name, target, input, operation, metadata, message] of [ + [ + 'empty', + raw.importCanonicalMap, + {}, + OP.mapStart, + [], + /^Error: missing private access result$/, + ], + [ + 'missing-value', + raw.importCanonicalMap, + { file: 'canonical' }, + OP.pair, + ['file'], + /^Error: missing private access result$/, + ], + [ + 'missing-origin', + raw.importSheet, + {}, + OP.field, + [], + /^Error: missing private access result$/, + ], + [ + 'wrong-status', + raw.importCanonicalMap, + new Map([['file', 'canonical']]), + OP.next, + ['yes', undefined], + /^Error: .*`bool`$/, + ], + [ + 'wrong-value', + raw.importCanonicalMap, + {}, + OP.mapStart, + [2, [], 'length'], + /^Error: .*`u32`$/, + ], + ]) { + check(`access-${name}`, () => { + // Given: one malformed metadata packet; all other accesses stay authored. + // When: native decoding consumes that packet. + // Then: metadata conversion reports Host, not Schema. + assert.throws( + () => + withControl( + target, + input, + ({ access }) => + (op, value, key) => + op === operation ? metadata : access(op, value, key), + ), + { name: 'Error', code: 'GenericFailure', message }, + ) + }) + } + check('access-wrong-origin', () => { + // Given: a Host throw whose text resembles an intrinsic Schema failure. + const target = raw.hasDevupUi + const marker = new Error('invalid intrinsic receiver') + let resolverCalls = 0 + raw.setResolver(() => { + resolverCalls++ + return undefined + }) + try { + // When: preflight's callback throws outside the authored access catch. + // Then: Host propagates before selecting the resolver. + assert.throws( + () => + withControl(target, request(), ({ access }) => (op, value, key) => { + if (op === OP.mapStart) throw marker + return access(op, value, key) + }), + { + name: 'Error', + code: 'GenericFailure', + message: /^Error: (?:Error: )?invalid intrinsic receiver$/, + }, + ) + assert.equal(resolverCalls, 0) + } finally { + raw.setResolver(undefined) + } + }) + check('access-schema-token', () => { + // Given: a primitive iterator receiver produces the private Schema identity. + const target = raw.hasDevupUi + const input = request() + input[3] = false + let matched = false + // When: decode forgiving preflight through the real authored access callback. + const result = withControl( + target, + input, + ({ access, schemaToken }) => + (op, value, key) => { + const result = access(op, value, key) + if (op === OP.mapStart) matched = result === schemaToken + return result + }, + ) + // Then: only Schema defaults the aliases without a pending-failure replay. + assert.equal(matched, true) + assert.equal(result, false) + }) + check('access-access-token', () => { + // Given: an iterator lookup throw is wrapped and converted once in preflight. + const target = raw.hasDevupUi + const input = request() + let conversions = 0, + matched = false + const marker = { + toString() { + conversions++ + return 'forgiven' + }, + } + input[3] = { + get [Symbol.iterator]() { + throw marker + }, + } + // When: the authored callback supplies the actual private Access identity. + const result = withControl( + target, + input, + ({ access, accessToken }) => + (op, value, key) => { + const result = access(op, value, key) + if (op === OP.mapStart) matched = result === accessToken + return result + }, + ) + // Then: replay forgives that import failure exactly once. + assert.equal(matched, true) + assert.equal(conversions, 1) + assert.equal(result, false) + }) + check('access-function-result', () => { + // Given: a genuine function is a valid pair receiver, not either token. + const target = raw.importFileMap + const input = {} + const pair = () => assert.fail('Function-valued pair must not be invoked') + pair[0] = 'file' + pair[1] = 8 + const original = Object.entries + let matched = false + Object.entries = (value) => (value === input ? [pair] : original(value)) + try { + // When: the real index operation returns the function directly to native. + withControl(target, input, ({ access }) => (op, value, key) => { + const result = access(op, value, key) + if (op === OP.index) matched = result === pair + return result + }) + } finally { + Object.entries = original + } + // Then: native traverses the function pair and preserves its field values. + assert.equal(matched, true) + assert.deepEqual(JSON.parse(raw.exportFileMap()), { file: 8 }) + }) +} diff --git a/e2e/binding/native-coverage/private-values.mjs b/e2e/binding/native-coverage/private-values.mjs new file mode 100644 index 000000000..cc35f6568 --- /dev/null +++ b/e2e/binding/native-coverage/private-values.mjs @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' + +const { OP, withAccess } = createRequire(import.meta.url)( + '../../../bindings/devup-ui-binding/interop.cjs', +) + +export function withControl(target, input, intercept) { + return withAccess( + (value, access, schemaToken, accessToken) => + target( + value, + intercept({ access, schemaToken, accessToken }), + schemaToken, + accessToken, + ), + input, + ) +} + +export function runMapStart(raw, check, request) { + const iterator = { next: () => ({ done: true }) } + const callable = () => assert.fail('Metadata payload must not be invoked') + const coercible = { + valueOf: () => assert.fail('Metadata must not be coerced'), + } + const malformed = [ + undefined, + null, + {}, + callable, + [], + ['1', iterator], + [undefined], + [3], + [0.5], + [4294967296], + [NaN], + [Infinity], + [Object(1), iterator], + [coercible], + [0, 'extra'], + [1], + [1, iterator, 'extra'], + [2, []], + [2, [], 0, 'extra'], + [1, undefined], + [1, null], + [1, callable], + [2, [], 'length'], + ] + for (const [index, packet] of malformed.entries()) { + check(`map-envelope-${index}`, () => { + // Given: malformed metadata at the genuine addon's map-start boundary. + let resolver = 0, + schema = 0, + forgiveness = 0 + raw.setResolver(() => { + resolver++ + return undefined + }) + const intercept = + ({ access }) => + (op, value, key) => { + if (op === OP.schema) schema++ + if (op === OP.preflightFailure) forgiveness++ + return op === OP.mapStart ? packet : access(op, value, key) + } + try { + // When: strict and preflight decode the same invalid envelope. + for (const [target, input] of [ + [raw.importCanonicalMap, {}], + [raw.hasDevupUi, request()], + ]) + assert.throws(() => withControl(target, input, intercept), { + name: 'Error', + code: 'GenericFailure', + }) + // Then: Host never notifies Schema, forgives, or selects the resolver. + assert.deepEqual([resolver, schema, forgiveness], [0, 0, 0]) + } finally { + raw.setResolver(undefined) + } + }) + } + check('map-start-initial-next-once', () => { + // Given: a real iterator with an observable initial next getter. + let reads = 0 + const source = { + [Symbol.iterator]: () => ({ + get next() { + reads++ + return () => ({ done: true }) + }, + }), + } + // When: the genuine addon completes an empty map iteration. + withControl(raw.importCanonicalMap, source, ({ access }) => access) + // Then: one startup read plus one Next read, with no envelope revalidation. + assert.equal(reads, 2) + }) + check('map-start-unrestricted-empty-entries', () => { + for (const entries of [undefined, null, 3, callable]) { + withControl( + raw.importCanonicalMap, + {}, + ({ access }) => + (op, value, key) => + op === OP.mapStart ? [2, entries, 0] : access(op, value, key), + ) + } + }) + check('map-start-nonfunction-fallback-raw', () => { + // Given: a rejected method and a raw entries failure, including nullish throws. + const original = Object.entries + for (const marker of [undefined, null, coercible]) { + const source = { [Symbol.iterator]: 3 } + Object.entries = (value) => { + if (value === source) throw marker + return original(value) + } + try { + // When/Then: fallback preserves the original thrown identity without String. + let caught = false + try { + withControl(raw.importCanonicalMap, source, ({ access }) => access) + } catch (error) { + caught = true + assert.equal(error, marker) + } + assert.equal(caught, true) + } finally { + Object.entries = original + } + } + }) +} + +export function runValues(w, check, file) { + const sheet = (s, extra = {}) => ({ + properties: { + [file]: { + 0: { 0: [{ c: 'private', p: 'color', v: 'red', s, ...extra }] }, + }, + }, + }) + for (const kind of ['Media', { Media: null }, { Media: undefined }]) { + check(`unit-enum-${JSON.stringify(kind)}`, () => { + w.importSheet( + sheet( + { At: { kind, query: '(width > 1px)', selector: null } }, + { t: true, h: false }, + ), + ) + assert.match(w.getCss(undefined, false), /red/) + }) + } + for (const s of [ + true, + 1, + null, + new Uint8Array([1]), + { At: { kind: { Media: 1 }, query: '' } }, + ]) { + check(`malformed-selector-${String(s)}`, () => { + // Null is a valid absent selector; malformed inner unit payload is not. + if (s === null) w.importSheet(sheet(s)) + else assert.throws(() => w.importSheet(sheet(s)), TypeError) + }) + } + for (const value of [ + 1.5, + 1n, + 18446744073709551615n, + 18446744073709551616n, + -9223372036854775809n, + new Uint8Array([1]), + new ArrayBuffer(1), + new Set([1]), + Symbol('color'), + ]) { + check(`malformed-color-${String(value)}`, () => { + assert.throws( + () => w.registerTheme({ colors: { default: { probe: value } } }), + TypeError, + ) + }) + } + check('non-map-iterable-color', () => { + const value = { [Symbol.iterator]: () => [][Symbol.iterator]() } + assert.throws( + () => w.registerTheme({ colors: { default: { probe: value } } }), + TypeError, + ) + }) + check('typed-string-rejects-bytes', () => { + assert.throws( + () => w.importCanonicalMap({ file: new Uint8Array([1]) }), + TypeError, + ) + }) + check('bool-schema-and-map-array', () => { + assert.throws(() => w.importSheet(sheet(null, { t: 1 })), TypeError) + w.importFileRoutes(new Map([['a', [1, 2]]])) + w.importClassMap({ a: { zero: 0n, max: 4294967295n } }) + assert.deepEqual(JSON.parse(w.exportClassMap()), { + a: { zero: 0, max: 4294967295 }, + }) + for (const value of [18446744073709551616n, -1n, 4294967296n]) + assert.throws( + () => w.importClassMap({ a: { invalid: value } }), + TypeError, + ) + }) +} diff --git a/e2e/binding/native-coverage/run.mjs b/e2e/binding/native-coverage/run.mjs new file mode 100644 index 000000000..af8729f27 --- /dev/null +++ b/e2e/binding/native-coverage/run.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { copyFileSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') +assert.equal( + process.versions.bun, + undefined, + 'Run native Rust coverage with Node', +) +assert.ok(!process.env.RUST_MIN_STACK, 'Default Rust stack is required') +assert.doesNotMatch(process.env.RUSTFLAGS ?? '', /stack-size|STACK:/) +const evidence = mkdtempSync(join(tmpdir(), 'devup-native-coverage-')) +const image = join(evidence, 'binding.node') +const entry = join(evidence, 'binding.cjs') +const target = process.env.CARGO_TARGET_DIR + ? resolve(root, process.env.CARGO_TARGET_DIR) + : join(root, 'target') +const library = { + win32: 'devup_ui_binding.dll', + linux: 'libdevup_ui_binding.so', + darwin: 'libdevup_ui_binding.dylib', +}[process.platform] +assert.ok(library, `Unsupported native coverage host: ${process.platform}`) +const env = { ...process.env, CARGO_BUILD_JOBS: '2' } +delete env.LLVM_PROFILE_FILE +const commands = [] +const run = (program, args, childEnv) => { + const started = performance.now() + const child = spawnSync(program, args, { + cwd: root, + env: childEnv, + stdio: 'inherit', + }) + commands.push({ + program, + args, + pid: child.pid, + elapsedMs: performance.now() - started, + status: child.status, + signal: child.signal, + error: child.error?.message, + }) + if (child.error) throw child.error + assert.equal(child.signal, null, `${program}: natural exit required`) + assert.equal(child.status, 0, `${program}: native coverage failed`) +} +const hash = (path) => + createHash('sha256').update(readFileSync(path)).digest('hex') +let imageHash +try { + const buildEnv = { + ...env, + RUSTFLAGS: '-C instrument-coverage -C debuginfo=2 -C strip=none', + LLVM_PROFILE_FILE: join(evidence, 'build-%m-%p.profraw'), + } + delete buildEnv.CARGO_ENCODED_RUSTFLAGS + run( + 'cargo', + ['build', '--locked', '-p', 'devup-ui-binding', '--jobs', '2'], + buildEnv, + ) + copyFileSync(join(target, 'debug', library), image) + imageHash = hash(image) + run( + process.execPath, + [join(root, 'bindings/devup-ui-binding/script.cjs'), image, entry], + env, + ) + const args = [ + 'tarpaulin', + '--out', + 'xml', + '--out', + 'stdout', + '--all-targets', + '--engine', + 'llvm', + '--timeout', + '180', + '--features', + 'devup-ui-binding/native-coverage', + '--objects', + image, + ] + if (process.env.CI) args.push('--fail-under', '100') + run('cargo', args, { + ...env, + DEVUP_NATIVE_COVERAGE_NODE: process.execPath, + DEVUP_NATIVE_COVERAGE_ENTRY: entry, + DEVUP_NATIVE_COVERAGE_OBJECT: image, + DEVUP_NATIVE_COVERAGE_EVIDENCE: evidence, + DEVUP_NATIVE_COVERAGE_HOST: join( + root, + 'e2e/binding/native-coverage/scenarios.mjs', + ), + }) + assert.equal(hash(image), imageHash, 'Instrumented production addon changed') +} finally { + writeFileSync( + join(evidence, 'run.json'), + JSON.stringify( + { + node: process.execPath, + nodeVersion: process.version, + platform: process.platform, + image, + imageHash, + entry, + commands, + }, + null, + 2, + ) + '\n', + { flag: 'wx' }, + ) + console.info(`Native coverage evidence: ${evidence}`) +} diff --git a/e2e/binding/native-coverage/scenarios.mjs b/e2e/binding/native-coverage/scenarios.mjs new file mode 100644 index 000000000..c16fdf60e --- /dev/null +++ b/e2e/binding/native-coverage/scenarios.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { cases } from '../validation-cases.mjs' + +const [entry, evidence] = process.argv.slice(2) +assert.equal(process.argv.length, 4) +assert.ok(isAbsolute(entry) && isAbsolute(evidence)) +assert.equal(process.versions.bun, undefined, 'Rust coverage uses real Node') +const here = dirname(fileURLToPath(import.meta.url)) +const runs = [] +const run = (name, script, args) => { + const directory = join(evidence, name) + mkdirSync(directory) + const stdout = openSync(join(directory, 'stdout.log'), 'wx') + const stderr = openSync(join(directory, 'stderr.log'), 'wx') + let child + try { + child = spawnSync( + process.execPath, + [join(here, script), entry, directory, ...args], + { + env: { ...process.env, NODE_OPTIONS: '', BUN_OPTIONS: '' }, + stdio: ['ignore', stdout, stderr], + timeout: 120000, + }, + ) + } finally { + closeSync(stdout) + closeSync(stderr) + } + const result = { + name, + pid: child.pid, + status: child.status, + signal: child.signal, + error: child.error?.message, + } + runs.push(result) + writeFileSync( + join(directory, 'process.json'), + JSON.stringify(result, null, 2) + '\n', + { flag: 'wx' }, + ) + assert.equal(child.error, undefined) + assert.equal(child.signal, null) + assert.equal(child.status, 0, `${name}: see ${directory}`) + return directory +} + +const entries = run('entries', '../entries-host.mjs', ['node']) +const complete = JSON.parse( + readFileSync(join(entries, 'complete.json'), 'utf8'), +) +assert.ok(complete.ids.length >= 171) +assert.equal(new Set(complete.ids).size, complete.ids.length) +run('public', '../host.mjs', []) +for (const test of cases) + run(`validation-${test.id}`, '../validation-host.mjs', [test.id]) +for (const group of ['protocol', 'raw-resolver-failure']) + run(`private-${group}`, 'private-host.mjs', [group]) +writeFileSync( + join(evidence, 'complete.json'), + JSON.stringify({ entry, node: process.execPath, runs }, null, 2) + '\n', + { flag: 'wx' }, +) diff --git a/e2e/binding/observation.mjs b/e2e/binding/observation.mjs new file mode 100644 index 000000000..f7598c505 --- /dev/null +++ b/e2e/binding/observation.mjs @@ -0,0 +1,80 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' + +export function encode(value, seen = new Set()) { + if (value === undefined) return { tag: 'undefined' } + if (value === null) return { tag: 'null' } + const tag = typeof value + switch (tag) { + case 'string': + return { tag, value, bytes: Buffer.byteLength(value, 'utf8') } + case 'boolean': + return { tag, value } + case 'number': + return { + tag, + value: Object.is(value, -0) + ? '-0' + : Number.isFinite(value) + ? value + : String(value), + } + case 'bigint': + case 'symbol': + return { tag, value: String(value) } + case 'function': + return { tag, name: value.name } + case 'object': { + if (seen.has(value)) return { tag: 'cycle' } + const next = new Set(seen).add(value) + if (Array.isArray(value)) + return { + tag: 'array', + value: Array.from({ length: value.length }, (_, i) => + Object.hasOwn(value, i) + ? encode(value[i], next) + : { tag: 'absent' }, + ), + } + if (value instanceof Map) + return { + tag: 'Map', + value: [...value].map(([k, v]) => [encode(k, next), encode(v, next)]), + } + if (value instanceof Set) + return { tag: 'Set', value: [...value].map((v) => encode(v, next)) } + return { + tag, + value: Reflect.ownKeys(value).map((key) => { + const d = Object.getOwnPropertyDescriptor(value, key) + return [ + encode(key, next), + 'value' in d + ? encode(d.value, next) + : { tag: 'accessor', get: encode(d.get), set: encode(d.set) }, + ] + }), + } + } + default: + throw new Error(`Unsupported encoder type ${tag}`) + } +} +const failure = (error) => + error instanceof Error + ? { + tag: 'error', + class: error.constructor.name, + name: encode(error.name), + message: encode(error.message), + } + : { tag: 'thrown', value: encode(error) } +export const capture = (action) => { + try { + return { tag: 'return', value: encode(action()) } + } catch (error) { + return { tag: 'throw', error: failure(error) } + } +} +export const hash = (path) => + createHash('sha256').update(readFileSync(path)).digest('hex') diff --git a/e2e/binding/package/collect.mjs b/e2e/binding/package/collect.mjs new file mode 100644 index 000000000..e114af4e7 --- /dev/null +++ b/e2e/binding/package/collect.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import { mkdirSync, readdirSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { inspectImage, targets } from './matrix.mjs' +import { packOne, prepareWorkspace } from './pack.mjs' +import { + direct, + hashFile, + readJson, + root, + sourceIdentity, + writeJson, +} from './provenance.mjs' + +export function verifyArtifacts(artifacts, options) { + const expected = options.localHostOnly + ? targets.filter((row) => row.suffix === options.localHostOnly) + : targets + assert.ok(expected.length, 'unknown local target') + assert.equal( + artifacts.length, + expected.length, + 'exact artifact count required (default eight)', + ) + const seen = new Set() + for (const { receipt, path } of artifacts) { + assert.equal(receipt.schema, 1) + assert.equal( + receipt.status, + 'built', + 'successful fresh build receipt required', + ) + assert.equal(receipt.run, options.run, 'wrong run') + assert.deepEqual(receipt.source, options.source, 'wrong source') + assert.ok( + options.allowDirty || !receipt.source.dirty, + 'dirty source forbidden', + ) + assert.equal(receipt.profile, 'native') + const row = expected.find((item) => item.target === receipt.row.target) + assert.ok(row, 'unexpected target') + assert.deepEqual(receipt.row, row, 'wrong target selectors/libc') + assert.ok(!seen.has(row.target), 'duplicate target') + seen.add(row.target) + assert.equal(receipt.image.file, `binding.${row.suffix}.node`) + assert.equal( + receipt.image.sha256, + receipt.original.sha256, + 'original image hash mismatch', + ) + assert.equal( + hashFile(path), + receipt.image.sha256, + 'attached image hash mismatch', + ) + assert.deepEqual( + inspectImage(path, row), + receipt.image.evidence, + 'actual image evidence mismatch', + ) + } + if (options.localHostOnly) { + assert.equal( + expected[0].os, + process.platform, + 'partial mode is local-host-only', + ) + assert.equal( + expected[0].cpu, + process.arch, + 'partial mode is local-host-only', + ) + if (process.platform === 'linux') + assert.equal( + expected[0].libc, + process.report.getReport().header.glibcVersionRuntime + ? 'glibc' + : 'musl', + ) + } + return artifacts +} +export function loadArtifacts(directory) { + return readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const folder = join(directory, entry.name) + const receipt = readJson(join(folder, 'receipt.json')) + assert.ok( + targets.some( + (row) => receipt.image?.file === `binding.${row.suffix}.node`, + ), + 'unsafe image filename', + ) + return { receipt, path: join(folder, receipt.image.file) } + }) +} +export function collect(options) { + mkdirSync(options.out, { recursive: false }) + const report = { + schema: 1, + status: 'validating', + mode: options.localHostOnly + ? 'PARTIAL local-host-only two-pack' + : 'FULL nine-pack', + run: options.run, + } + let workspace + try { + report.source = sourceIdentity() + const artifacts = verifyArtifacts(loadArtifacts(options.artifacts), { + ...options, + source: report.source, + }) + report.receipts = artifacts.map(({ receipt }) => receipt) + const version = readJson( + join(root, 'bindings/devup-ui-binding/package.json'), + ).version + workspace = prepareWorkspace({ artifacts }) + report.packs = [packOne(workspace, { out: options.out, version })] + for (const { receipt } of artifacts) + report.packs.push( + packOne(workspace, { + out: options.out, + version, + row: receipt.row, + image: receipt.image, + }), + ) + assert.deepEqual( + sourceIdentity(), + report.source, + 'source changed during collection', + ) + report.status = 'verified' + return report + } catch (error) { + report.status = 'failed' + report.error = { message: error.message, stack: error.stack } + throw error + } finally { + if (workspace) rmSync(workspace, { recursive: true, force: true }) + writeJson(join(options.out, 'bundle.json'), report) + } +} +if (direct(import.meta.url)) { + const [artifacts, out, run, ...flags] = process.argv.slice(2) + const hostFlag = flags.find((flag) => flag.startsWith('--local-host-only=')) + assert.ok( + flags.every((flag) => flag === '--allow-dirty' || flag === hostFlag), + 'unknown collector flag', + ) + assert.ok(run, 'run required') + collect({ + artifacts: resolve(artifacts), + out: resolve(out), + run, + allowDirty: flags.includes('--allow-dirty'), + localHostOnly: hostFlag?.split('=')[1], + }) +} diff --git a/e2e/binding/package/consumer.mjs b/e2e/binding/package/consumer.mjs new file mode 100644 index 000000000..9bba0ce13 --- /dev/null +++ b/e2e/binding/package/consumer.mjs @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict' +import { + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { targets } from './matrix.mjs' +import { packageDirectory, packageName, verifyTarball } from './pack.mjs' +import { + command, + direct, + hashFile, + readJson, + root, + tool, + writeJson, +} from './provenance.mjs' +import { child, installConsumer, registry } from './registry.mjs' + +// This source runs from a clean install, never from repository resolution paths. +const probe = ` +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { readFileSync, realpathSync } from 'node:fs' +import { createHash } from 'node:crypto' +import * as esm from '@devup-ui/binding' +const require = createRequire(import.meta.url) +const cjs = require('@devup-ui/binding') +const expected = JSON.parse(readFileSync(new URL('./expected.json', import.meta.url))) +assert.deepEqual(Object.keys(cjs).sort(), expected.names) +assert.deepEqual(Object.keys(esm).sort(), expected.names) +for (const name of expected.names) assert.equal(esm[name], cjs[name], name) +for (const name of ['loader.cjs', 'adapter.cjs', 'pkg/index.cjs', ...(process.versions.bun ? [] : ['package.json'])]) { + assert.throws(() => require('@devup-ui/binding/' + name), (error) => + error.code === (process.versions.bun ? 'MODULE_NOT_FOUND' : 'ERR_PACKAGE_PATH_NOT_EXPORTED')) +} +cjs.resetStateForTesting() +assert.throws(() => cjs.setPrefix(false), TypeError) +cjs.setModuleResolver((specifier) => specifier === './color' ? { path: '/color.ts', code: 'export const color = "red"' } : undefined) +const output = esm.codeExtract('/fixture.tsx', 'import { Box } from "@devup-ui/react"; import { color } from "./color"; export const App = () => ', '@devup-ui/react', 'df', true, false, false, {}) +assert.ok(output instanceof cjs.Output) +assert.match(output.code, /className/) +assert.match(cjs.getCss(null, false), /red/) +assert.deepEqual(output.dependencies, ['/color.ts']) +output.dependencies.push('not-native') +assert.deepEqual(output.dependencies, ['/color.ts']) +assert.equal(typeof output.map, 'string') +output[Symbol.dispose]() +const noMap = cjs.codeExtractWithoutSourceMap('/plain.tsx', 'export const x = 1', '@devup-ui/react', 'df', true, false, false, {}) +assert.equal(noMap.map, undefined) +noMap.free() +cjs.resetBuildState() +assert.doesNotMatch(cjs.getCss(null, false), /background:red/) +cjs.resetStateForTesting() +const image = require.resolve(expected.sibling) +const entry = require.resolve('@devup-ui/binding') +const sha256 = createHash('sha256').update(readFileSync(image)).digest('hex') +assert.equal(sha256, expected.imageHash) +for (const path of [entry, image]) assert.ok(realpathSync(path).startsWith(realpathSync(new URL('.', import.meta.url)))) +console.log(JSON.stringify({ runtime: process.versions, executable: process.execPath, entry, image, sha256, names: expected.names.length, result: 'PASS' })) +` +const declarations = ` +binding.setModuleResolver((specifier: string, importer: string) => ({ path: importer, code: specifier })) +binding.setModuleResolver(null) +binding.setPrefix(undefined) +binding.setAtomHoist(null) +const output: binding.Output = binding.codeExtract('x.tsx', '', '@devup-ui/react', 'df', true, false, false, {}) +const code: string = output.code +const css: string | undefined = output.css +const dependencies: string[] = output.dependencies +output.free() +output[Symbol.dispose]() +void [code, css, dependencies] +` +export async function consume(options) { + const bundle = readJson(join(options.bundle, 'bundle.json')) + assert.equal(bundle.status, 'verified') + assert.ok( + options.localHostOnly || bundle.mode === 'FULL nine-pack', + 'partial bundle forbidden by default', + ) + assert.equal(bundle.packs.length, options.localHostOnly ? 2 : 9) + const row = targets.find((item) => item.suffix === options.suffix) + assert.ok( + row && row.os === process.platform && row.cpu === process.arch, + 'consumer must execute on matching host', + ) + if (row.os === 'linux') + assert.equal( + row.libc, + process.report.getReport().header.glibcVersionRuntime ? 'glibc' : 'musl', + ) + const host = bundle.packs.find((pack) => pack.suffix === row.suffix) + assert.ok(host, 'missing matching target pack') + for (const pack of bundle.packs) { + const actual = verifyTarball(join(options.bundle, pack.file), { + version: pack.version, + row: targets.find((item) => item.suffix === pack.suffix), + image: pack.image, + }) + assert.deepEqual(actual, pack, 'bundle tarball/metadata drift') + } + const directory = mkdtempSync(join(tmpdir(), 'native-clean-consumer-')) + const report = { + status: 'running', + directory, + mode: bundle.mode, + runtimeHost: { platform: process.platform, arch: process.arch }, + tools: { + node: tool('node'), + bun: tool('bun'), + npm: { + executable: options.npmCli, + sha256: hashFile(options.npmCli), + version: command(process.execPath, [options.npmCli, '--version']), + }, + typescript: tool(options.tsc), + }, + installs: [], + } + const server = await registry(bundle, options.bundle) + try { + const names = [ + ...readFileSync( + join(root, packageDirectory(), 'index.d.cts'), + 'utf8', + ).matchAll(/export (?:class|function) (\w+)/g), + ] + .map((match) => match[1]) + .sort() + assert.equal(names.length, 29) + for (const manager of ['npm', 'bun']) { + for (const omit of [false, true]) { + const startRequest = server.requests.length + const { fixture, env } = await installConsumer({ + directory, + manager, + omit, + version: host.version, + origin: server.origin, + npmCli: options.npmCli, + }) + const selected = targets.filter((item) => + existsSync(join(fixture, 'node_modules', packageName(item))), + ) + assert.deepEqual( + selected, + omit ? [] : [row], + 'actual optional package selection', + ) + const result = { + manager, + omit, + fixture, + selected: selected.map(packageName), + requests: server.requests.slice(startRequest), + probes: [], + } + report.installs.push(result) + if (omit) { + const check = `const assert = require('node:assert/strict'); assert.throws(() => require('@devup-ui/binding'), (e) => e.message.includes(${JSON.stringify(packageName(row))}) && e.message.includes('optional dependencies enabled'))` + for (const runtime of ['node', 'bun']) + result.probes.push( + await child(runtime, ['-e', check], { cwd: fixture, env }), + ) + continue + } + for (const name of ['@devup-ui/binding', packageName(row)]) { + const path = join(fixture, 'node_modules', name) + assert.ok(!lstatSync(path).isSymbolicLink(), 'no workspace symlinks') + assert.ok(realpathSync(path).startsWith(realpathSync(fixture))) + } + writeJson(join(fixture, 'expected.json'), { + names, + sibling: packageName(row), + imageHash: host.image.sha256, + directory: realpathSync(fixture), + }) + writeFileSync(join(fixture, 'probe.mjs'), probe) + for (const runtime of ['node', 'bun']) { + const output = await child(runtime, ['probe.mjs'], { + cwd: fixture, + env, + }) + result.probes.push(JSON.parse(output.stdout)) + } + writeFileSync( + join(fixture, 'consumer.cts'), + `import binding = require('@devup-ui/binding')\n${declarations}`, + ) + writeFileSync( + join(fixture, 'consumer.mts'), + `import * as binding from '@devup-ui/binding'\n${declarations}`, + ) + result.types = await child( + options.tsc, + [ + '--noEmit', + '--strict', + '--noUncheckedIndexedAccess', + '--exactOptionalPropertyTypes', + '--module', + 'nodenext', + '--target', + 'esnext', + '--lib', + 'esnext', + 'consumer.cts', + 'consumer.mts', + ], + { cwd: fixture, env }, + ) + } + } + report.status = 'PASS' + return report + } catch (error) { + report.status = 'failed' + report.error = { + message: error.message, + stack: error.stack, + stdout: error.stdout, + stderr: error.stderr, + } + throw error + } finally { + report.requests = server.requests + await server.close() + writeJson(options.report, report) + } +} +if (direct(import.meta.url)) { + const [bundle, suffix, report, tsc, npmCli, ...flags] = process.argv.slice(2) + assert.ok(flags.every((flag) => flag === '--local-host-only')) + await consume({ + bundle: resolve(bundle), + suffix, + report: resolve(report), + tsc: resolve(tsc), + npmCli: resolve(npmCli), + localHostOnly: flags.includes('--local-host-only'), + }) +} diff --git a/e2e/binding/package/elf.test.mjs b/e2e/binding/package/elf.test.mjs new file mode 100644 index 000000000..dfd5243c6 --- /dev/null +++ b/e2e/binding/package/elf.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' + +import { inspectImage, targets } from './matrix.mjs' + +// Decoder-only rejection controls, never foreign-built positive evidence. +function rejectedElf(dependencies, tags, cpu) { + const strings = Buffer.from(`\0${dependencies.join('\0')}\0`) + let nameOffset = 1n + const records = dependencies.map((name) => { + const record = [1n, nameOffset] + nameOffset += BigInt(Buffer.byteLength(name) + 1) + return record + }) + records.push([5n, 512n], [10n, BigInt(strings.length)]) + for (const tag of tags) records.push([tag, 0n]) + records.push([0n, 0n]) + const bytes = Buffer.alloc(512 + strings.length) + bytes.write('7f454c4602010100', 0, 'hex') + bytes.writeUInt16LE(3, 16) + bytes.writeUInt16LE(cpu === 'x64' ? 62 : 183, 18) + bytes.writeUInt32LE(1, 20) + bytes.writeBigUInt64LE(64n, 32) + bytes.writeUInt16LE(64, 52) + bytes.writeUInt16LE(56, 54) + bytes.writeUInt16LE(2, 56) + // One file-backed PT_LOAD plus a separate, terminated PT_DYNAMIC. + bytes.writeUInt32LE(1, 64) + bytes.writeBigUInt64LE(BigInt(bytes.length), 96) + bytes.writeBigUInt64LE(BigInt(bytes.length), 104) + bytes.writeUInt32LE(2, 120) + bytes.writeBigUInt64LE(176n, 128) + bytes.writeBigUInt64LE(176n, 136) + bytes.writeBigUInt64LE(BigInt(records.length * 16), 152) + bytes.writeBigUInt64LE(BigInt(records.length * 16), 160) + records.forEach(([tag, value], index) => { + bytes.writeBigUInt64LE(tag, 176 + index * 16) + bytes.writeBigUInt64LE(value, 184 + index * 16) + }) + strings.copy(bytes, 512) + return bytes +} + +for (const cpu of ['x64', 'arm64']) { + const own = `libc.musl-${cpu === 'x64' ? 'x86_64' : 'aarch64'}.so.1` + const other = `libc.musl-${cpu === 'x64' ? 'aarch64' : 'x86_64'}.so.1` + for (const [name, dependencies, tags] of [ + ['GNU presented as musl', ['libc.so.6'], []], + ['generic with DT_VERNEED presence', ['libc.so'], [0x6ffffffen]], + ['generic with DT_VERNEEDNUM presence', ['libc.so'], [0x6fffffffn]], + ['generic with conflicting musl marker', ['libc.so', other], []], + ['conventional with conflicting musl marker', [own, other], []], + ['missing libc', ['libm.so'], []], + ['generic mixed with GNU', ['libc.so', 'libc.so.6'], []], + ['conventional mixed with GNU', [own, 'libc.so.6'], []], + ['all libc spellings mixed with GNU', [own, 'libc.so', 'libc.so.6'], []], + ]) { + test(`rejects ${name} when inspecting ${cpu} musl`, () => { + // Given: valid decoder framing; zero tag values still mean tag presence. + const directory = mkdtempSync(join(tmpdir(), 'native-elf-rejection-')) + const path = join(directory, 'control.node') + const row = targets.find( + (item) => item.cpu === cpu && item.libc === 'musl', + ) + writeFileSync(path, rejectedElf(dependencies, tags, cpu)) + try { + // When / Then: require the libc policy failure, not a framing failure. + assert.throws( + () => inspectImage(path, row), + (error) => { + assert.ok(error instanceof assert.AssertionError) + assert.ok(error.message.startsWith('wrong libc: ')) + const diagnostic = JSON.parse( + error.message.slice('wrong libc: '.length), + ) + assert.deepEqual(diagnostic, { + dependencies: [...dependencies].sort(), + verneed: tags.includes(0x6ffffffen), + verneednum: tags.includes(0x6fffffffn), + }) + return true + }, + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + } +} diff --git a/e2e/binding/package/matrix.mjs b/e2e/binding/package/matrix.mjs new file mode 100644 index 000000000..58596c2ed --- /dev/null +++ b/e2e/binding/package/matrix.mjs @@ -0,0 +1,248 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +// Canonical build target table. GNU cross builds use a current Zig with glibc 2.28. +export const targets = Object.freeze( + Object.entries({ + 'linux-x64-gnu': 'x86_64-unknown-linux-gnu', + 'linux-arm64-gnu': 'aarch64-unknown-linux-gnu', + 'linux-x64-musl': 'x86_64-unknown-linux-musl', + 'linux-arm64-musl': 'aarch64-unknown-linux-musl', + 'darwin-x64': 'x86_64-apple-darwin', + 'darwin-arm64': 'aarch64-apple-darwin', + 'win32-x64-msvc': 'x86_64-pc-windows-msvc', + 'win32-arm64-msvc': 'aarch64-pc-windows-msvc', + }).map(([suffix, target]) => { + const [os, cpu, abi] = suffix.split('-') + return Object.freeze({ + suffix, + target, + os, + cpu, + ...(os === 'linux' ? { libc: abi === 'gnu' ? 'glibc' : 'musl' } : {}), + }) + }), +) + +// Decode actual target-machine bytes and dynamic imports, not filename guesses. +function region(bytes, offset, size) { + assert.ok( + Number.isSafeInteger(offset) && + Number.isSafeInteger(size) && + offset >= 0 && + size >= 0 && + offset <= bytes.length - size, + 'invalid binary region', + ) + return bytes.subarray(offset, offset + size) +} +function stringAt(bytes, offset) { + region(bytes, offset, 1) + const end = bytes.indexOf(0, offset) + assert.ok(end > offset, 'missing or empty binary string') + return bytes.toString('utf8', offset, end) +} +function peEvidence(bytes) { + const pe = bytes.readUInt32LE(0x3c) + assert.equal(bytes.toString('hex', pe, pe + 4), '50450000') + const machine = bytes.readUInt16LE(pe + 4) + const optional = pe + 24 + assert.equal(bytes.readUInt16LE(optional), 0x20b) + const sections = [] + const table = optional + bytes.readUInt16LE(pe + 20) + for (let index = 0; index < bytes.readUInt16LE(pe + 6); index++) { + const offset = table + index * 40 + sections.push({ + size: Math.max( + bytes.readUInt32LE(offset + 8), + bytes.readUInt32LE(offset + 16), + ), + rva: bytes.readUInt32LE(offset + 12), + raw: bytes.readUInt32LE(offset + 20), + }) + } + const offsetOf = (rva) => { + const section = sections.find( + (item) => rva >= item.rva && rva < item.rva + item.size, + ) + assert.ok(section, 'PE RVA must belong to a section') + return rva - section.rva + section.raw + } + const dependencies = [] + const importRva = bytes.readUInt32LE(optional + 120) + assert.ok(importRva, 'PE must contain imports') + for ( + let offset = offsetOf(importRva); + bytes.readUInt32LE(offset + 12); + offset += 20 + ) { + const name = offsetOf(bytes.readUInt32LE(offset + 12)) + dependencies.push(stringAt(bytes, name)) + } + return { + format: 'PE32+', + machine, + cpu: + machine === 0x8664 ? 'x64' : machine === 0xaa64 ? 'arm64' : 'unsupported', + dependencies: dependencies.sort(), + } +} +function elfEvidence(bytes, row) { + const header = region(bytes, 0, 64) + assert.equal(header.toString('hex', 0, 6), '7f454c460201') + const machine = header.readUInt16LE(18) + const stride = header.readUInt16LE(54) + assert.ok(stride >= 56, 'invalid ELF program header size') + const table = region( + bytes, + Number(header.readBigUInt64LE(32)), + stride * header.readUInt16LE(56), + ) + const loads = [] + let dynamic + for (let offset = 0; offset < table.length; offset += stride) { + const type = table.readUInt32LE(offset) + if (type !== 1 && type !== 2) continue + const file = Number(table.readBigUInt64LE(offset + 8)) + const address = table.readBigUInt64LE(offset + 16) + const size = Number(table.readBigUInt64LE(offset + 32)) + const data = region(bytes, file, size) + if (type === 1) loads.push({ address, data }) + else { + assert.equal(dynamic, undefined, 'duplicate ELF dynamic segment') + dynamic = data + } + } + assert.ok( + dynamic?.length && dynamic.length % 16 === 0, + 'missing or invalid ELF dynamic records', + ) + const records = new Map() + let terminated = false + for (let offset = 0; offset < dynamic.length; offset += 16) { + const tag = dynamic.readBigUInt64LE(offset) + if (tag === 0n) { + terminated = true + break + } + records.set(tag, [ + ...(records.get(tag) || []), + dynamic.readBigUInt64LE(offset + 8), + ]) + } + assert.ok(terminated, 'unterminated ELF dynamic records') + assert.equal( + records.get(5n)?.length, + 1, + 'missing or duplicate ELF string table', + ) + assert.equal( + records.get(10n)?.length, + 1, + 'missing or duplicate ELF string size', + ) + const address = records.get(5n)[0] + const size = records.get(10n)[0] + assert.ok(size > 0n, 'empty ELF string table') + const load = loads.find( + (item) => + address >= item.address && + address + size <= item.address + BigInt(item.data.length), + ) + assert.ok(load, 'ELF string table must be file-backed') + const strings = region( + load.data, + Number(address - load.address), + Number(size), + ) + const dependencies = (records.get(1n) || []) + .map((value) => stringAt(strings, Number(value))) + .sort() + const musl = dependencies.some((name) => name.startsWith('libc.musl-')) + const muslName = `libc.musl-${row.cpu === 'x64' ? 'x86_64' : 'aarch64'}.so.1` + const expectedMusl = dependencies.includes(muslName) + const conflictingMusl = dependencies.some( + (name) => name.startsWith('libc.musl-') && name !== muslName, + ) + const verneed = records.has(0x6ffffffen) + const verneednum = records.has(0x6fffffffn) + // Generic libc is only a candidate; trusted build hashes and matching Alpine + // Node/Bun consumers remain mandatory compatibility gates, not image identity. + const generic = dependencies.includes('libc.so') && !verneed && !verneednum + const glibc = dependencies.includes('libc.so.6') + assert.ok( + row.libc === 'musl' + ? (expectedMusl || generic) && !glibc && !conflictingMusl + : row.libc === 'glibc' && glibc && !musl, + `wrong libc: ${JSON.stringify({ dependencies, verneed, verneednum })}`, + ) + return { + format: 'ELF64', + machine, + cpu: machine === 62 ? 'x64' : machine === 183 ? 'arm64' : 'unsupported', + dependencies, + verneed, + verneednum, + } +} +function machoEvidence(bytes) { + const header = region(bytes, 0, 32) + assert.equal(header.readUInt32LE(0), 0xfeedfacf) + const machine = header.readUInt32LE(4) + const commands = region(bytes, 32, header.readUInt32LE(20)) + const dependencies = [] + let offset = 0 + for (let index = 0; index < header.readUInt32LE(16); index++) { + const command = region(commands, offset, 8) + const size = command.readUInt32LE(4) + assert.ok(size >= 8 && size % 8 === 0, 'invalid Mach-O command size') + const data = region(commands, offset, size) + // LOAD, WEAK, REEXPORT, LAZY and UPWARD dylibs; never LC_ID_DYLIB. + if ( + [0xc, 0x80000018, 0x8000001f, 0x20, 0x80000023].includes( + command.readUInt32LE(0), + ) + ) { + region(data, 0, 24) + const name = data.readUInt32LE(8) + assert.ok(name >= 24, 'invalid Mach-O dylib name offset') + dependencies.push(stringAt(data, name)) + } + offset += size + } + assert.equal(offset, commands.length, 'Mach-O command count/size mismatch') + return { + format: 'Mach-O64', + machine, + cpu: + machine === 0x1000007 + ? 'x64' + : machine === 0x100000c + ? 'arm64' + : 'unsupported', + dependencies: dependencies.sort(), + } +} +export function inspectImage(path, row) { + const bytes = readFileSync(path) + let evidence + switch (row.os) { + case 'win32': + evidence = peEvidence(bytes) + break + case 'linux': + evidence = elfEvidence(bytes, row) + break + case 'darwin': + evidence = machoEvidence(bytes) + break + default: + assert.fail('unsupported image format') + } + assert.equal(evidence.cpu, row.cpu, 'wrong image machine') + assert.ok( + evidence.dependencies.length, + 'dynamic dependencies must be nonempty', + ) + return evidence +} diff --git a/e2e/binding/package/pack.mjs b/e2e/binding/package/pack.mjs new file mode 100644 index 000000000..883e2320b --- /dev/null +++ b/e2e/binding/package/pack.mjs @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' + +import { inspectImage, targets } from './matrix.mjs' +import { + command, + direct, + hashFile, + readJson, + root, + writeJson, +} from './provenance.mjs' + +export const packageName = (row) => + `@devup-ui/binding${row ? `-${row.suffix}` : ''}` +export const packageDirectory = (row) => + `bindings/devup-ui-binding${row ? `-${row.suffix}` : ''}` +export const rootFiles = [ + 'pkg/index.cjs', + 'loader.cjs', + 'index.mjs', + 'index.d.cts', + 'index.d.mts', +] +const hooks = + /^(preinstall|install|postinstall|prepare|preprepare|postprepare|prepack|postpack|prepublish|prepublishOnly|publish|postpublish)$/ +export function validateManifest(manifest, row, version) { + assert.equal(manifest.name, packageName(row)) + assert.equal(manifest.version, version) + assert.equal(manifest.license, 'Apache-2.0') + assert.equal(manifest.publishConfig.access, 'public') + assert.ok( + !Object.keys(manifest.scripts || {}).some((key) => hooks.test(key)), + 'lifecycle hook', + ) + assert.ok( + !JSON.stringify(manifest).includes('workspace:'), + 'unresolved workspace version', + ) + if (row) { + assert.deepEqual(manifest.os, [row.os]) + assert.deepEqual(manifest.cpu, [row.cpu]) + assert.deepEqual(manifest.libc, row.libc ? [row.libc] : undefined) + assert.equal(manifest.main, `binding.${row.suffix}.node`) + assert.deepEqual(manifest.files, [manifest.main]) + assert.equal(manifest.dependencies, undefined) + assert.equal(manifest.optionalDependencies, undefined) + } else { + assert.deepEqual(manifest.files, rootFiles) + assert.deepEqual(Object.keys(manifest.exports), ['.']) + assert.deepEqual(manifest.exports['.'], { + import: { types: './index.d.mts', default: './index.mjs' }, + require: { types: './index.d.cts', default: './pkg/index.cjs' }, + }) + assert.deepEqual( + manifest.optionalDependencies, + Object.fromEntries(targets.map((item) => [packageName(item), version])), + ) + assert.equal(manifest.dependencies, undefined) + } +} +export function verifyTarball(file, expected) { + const cwd = dirname(resolve(file)) + const members = command('tar', ['-tf', basename(file)], { cwd }) + .split(/\r?\n/) + .filter(Boolean) + const wanted = [ + 'package/package.json', + ...(expected.row + ? [`package/binding.${expected.row.suffix}.node`] + : [...rootFiles.map((name) => `package/${name}`), 'package/README.md']), + ].sort() + assert.deepEqual(members.slice().sort(), wanted, 'exact tar members') + const listing = command('tar', ['-tvf', basename(file)], { cwd }).split( + /\r?\n/, + ) + assert.ok( + listing.every((line) => line.startsWith('-')), + 'only regular tar members', + ) + const content = (member) => + command('tar', ['-xOf', basename(file), `package/${member}`], { + cwd, + encoding: 'buffer', + }) + const manifest = JSON.parse(content('package.json').toString()) + validateManifest(manifest, expected.row, expected.version) + if (expected.row) { + const scratch = mkdtempSync(join(tmpdir(), 'native-image-check-')) + try { + const image = join(scratch, manifest.main) + writeFileSync(image, content(manifest.main)) + assert.equal(hashFile(image), expected.image.sha256, 'packed image hash') + assert.deepEqual( + inspectImage(image, expected.row), + expected.image.evidence, + 'packed machine/libc', + ) + } finally { + rmSync(scratch, { recursive: true, force: true }) + } + } else { + for (const name of rootFiles) { + const text = content(name).toString() + assert.ok( + !text.includes(root) && !text.includes(root.replaceAll('\\', '/')), + 'source checkout path', + ) + assert.ok( + !/(?:[A-Za-z]:[\\/]|file:\/\/\/|\/Users\/|\/home\/)/.test(text), + 'absolute source path', + ) + assert.ok(!/process\.dlopen/.test(text), 'private absolute image loader') + if (expected.workspace) + assert.deepEqual( + content(name), + readFileSync(join(expected.workspace, packageDirectory(), name)), + 'packed source bytes', + ) + } + } + const bytes = readFileSync(file) + return { + file: basename(file), + name: manifest.name, + version: manifest.version, + sha256: hashFile(file), + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + bytes: bytes.length, + members, + manifest, + ...(expected.row + ? { suffix: expected.row.suffix, image: expected.image } + : {}), + } +} + +// An isolated workspace supplies all eight manifests for Bun's workspace:* rewrite; +// only actual artifact rows receive images and are packed. Never forge foreign images. +export function prepareWorkspace(options) { + const workspace = mkdtempSync(join(tmpdir(), 'native-pack-workspace-')) + writeJson(join(workspace, 'package.json'), { + name: 'native-proof-fixture', + private: true, + workspaces: ['bindings/*'], + }) + for (const row of [undefined, ...targets]) { + const directory = join(workspace, packageDirectory(row)) + mkdirSync(directory, { recursive: true }) + const manifest = readJson(join(root, packageDirectory(row), 'package.json')) + if (options.version) manifest.version = options.version + writeJson(join(directory, 'package.json'), manifest) + if (!row) { + for (const name of [...rootFiles, 'README.md']) { + mkdirSync(dirname(join(directory, name)), { recursive: true }) + copyFileSync( + join(root, packageDirectory(), name), + join(directory, name), + ) + } + } + } + for (const artifact of options.artifacts || []) { + copyFileSync( + artifact.path, + join( + workspace, + packageDirectory(artifact.receipt.row), + artifact.receipt.image.file, + ), + ) + } + return workspace +} +export function packOne(workspace, options) { + const file = resolve( + options.out, + options.row ? `${options.row.suffix}.tgz` : 'binding.tgz', + ) + command('bun', ['pm', 'pack', '--ignore-scripts', '--filename', file], { + cwd: join(workspace, packageDirectory(options.row)), + }) + return verifyTarball(file, { ...options, workspace }) +} +export function versionProof(out) { + mkdirSync(out, { recursive: false }) + const results = [] + for (const version of ['0.1.0', '0.1.1']) { + const workspace = prepareWorkspace({ version }) + try { + const destination = join(out, version) + mkdirSync(destination) + const packed = packOne(workspace, { out: destination, version }) + results.push({ + version, + optionalDependencies: packed.manifest.optionalDependencies, + sha256: packed.sha256, + }) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + } + writeJson(join(out, 'version-proof.json'), { + kind: 'VERSION/PACK only; no foreign execution', + results, + }) + return results +} +if (direct(import.meta.url)) { + assert.equal(process.argv[2], '--version-proof') + console.info(JSON.stringify(versionProof(resolve(process.argv[3])))) +} diff --git a/e2e/binding/package/package.test.mjs b/e2e/binding/package/package.test.mjs new file mode 100644 index 000000000..41cd51bac --- /dev/null +++ b/e2e/binding/package/package.test.mjs @@ -0,0 +1,278 @@ +import assert from 'node:assert/strict' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { test } from 'node:test' + +import { loadArtifacts, verifyArtifacts } from './collect.mjs' +import { inspectImage, targets } from './matrix.mjs' +import { packageName, validateManifest, verifyTarball } from './pack.mjs' +import { hashFile, readJson, root } from './provenance.mjs' +import { child, registry } from './registry.mjs' + +// Negative controls use the real host receipt/image. No fabricated foreign positives. +const artifacts = loadArtifacts(process.env.NATIVE_PROOF_ARTIFACTS) +assert.equal(artifacts.length, 1) +const original = artifacts[0] +const row = original.receipt.row +const options = { + run: original.receipt.run, + source: original.receipt.source, + allowDirty: true, + localHostOnly: row.suffix, +} +const altered = (change) => { + const receipt = structuredClone(original.receipt) + change(receipt) + return { receipt, path: original.path } +} +test('requires actual loader coverage when the dedicated Node gate runs', async () => { + // Given + const loader = join(root, 'bindings/devup-ui-binding/loader.test.cjs') + // When + const output = await child(process.execPath, [ + '--test', + '--experimental-test-coverage', + '--test-coverage-include=**/loader.cjs', + '--test-coverage-lines=100', + '--test-coverage-branches=100', + '--test-coverage-functions=100', + loader, + ]) + // Then: thresholds alone accept zero files, so require the actual named row. + assert.match( + output.stdout, + /loader\.cjs\s*\|\s*100\.00\s*\|\s*100\.00\s*\|\s*100\.00/, + ) + console.info(output.stdout) +}) +for (const [name, change, message] of [ + [ + 'wrong run', + (receipt) => { + receipt.run += '-foreign' + }, + /wrong run/, + ], + [ + 'wrong source', + (receipt) => { + receipt.source.commit = 'foreign' + }, + /wrong source/, + ], + [ + 'wrong source inputs', + (receipt) => { + receipt.source.digest = 'foreign' + }, + /wrong source/, + ], + [ + 'unfinished build', + (receipt) => { + receipt.status = 'building' + }, + /successful fresh/, + ], + [ + 'wrong hash', + (receipt) => { + receipt.image.sha256 = 'wrong' + receipt.original.sha256 = 'wrong' + }, + /attached image hash/, + ], + [ + 'original hash mismatch', + (receipt) => { + receipt.original.sha256 = 'wrong' + }, + /original image hash/, + ], + [ + 'wrong machine receipt', + (receipt) => { + receipt.image.evidence.cpu = 'other' + }, + /actual image evidence/, + ], + [ + 'wrong libc selector', + (receipt) => { + receipt.row.libc = 'musl' + }, + /wrong target selectors/, + ], +]) { + test(`rejects ${name} when collecting a real artifact`, () => { + // Given + const artifact = altered(change) + // When / Then + assert.throws(() => verifyArtifacts([artifact], options), message) + }) +} +test('rejects incomplete artifacts when default full collection is requested', () => { + // Given + const full = { ...options, localHostOnly: undefined } + // When / Then + assert.throws(() => verifyArtifacts(artifacts, full), /exact artifact count/) +}) +test('rejects a missing artifact when local collection is requested', () => { + // Given / When / Then + assert.throws(() => verifyArtifacts([], options), /exact artifact count/) +}) +test('rejects duplicates when all eight slots contain one real target', () => { + // Given + const duplicates = Array(8).fill(original) + // When / Then + assert.throws( + () => verifyArtifacts(duplicates, { ...options, localHostOnly: undefined }), + /duplicate target/, + ) +}) +test('rejects dirty source when CI policy is active', () => { + // Given + const dirty = altered((receipt) => { + receipt.source.dirty = true + }) + // When / Then + assert.throws( + () => + verifyArtifacts([dirty], { + ...options, + source: dirty.receipt.source, + allowDirty: false, + }), + /dirty source forbidden/, + ) +}) +test('rejects changed image bytes when the receipt remains unchanged', () => { + // Given + const directory = mkdtempSync(join(tmpdir(), 'native-corrupt-control-')) + try { + const path = join(directory, original.receipt.image.file) + const bytes = readFileSync(original.path) + bytes[bytes.length - 1] ^= 1 + writeFileSync(path, bytes) + // When / Then + assert.throws( + () => verifyArtifacts([{ ...original, path }], options), + /attached image hash/, + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) +test('rejects missing image bytes when a receipt is present', () => { + // Given / When / Then + assert.throws( + () => + verifyArtifacts( + [{ ...original, path: `${original.path}.absent` }], + options, + ), + { code: 'ENOENT' }, + ) +}) +test('rejects actual wrong CPU when inspecting the host image as another architecture', () => { + // Given + const other = targets.find( + (item) => + item.os === row.os && item.libc === row.libc && item.cpu !== row.cpu, + ) + // When / Then + assert.throws(() => inspectImage(original.path, other), /wrong image machine/) +}) +const manifest = readJson(join(root, 'bindings/devup-ui-binding/package.json')) +manifest.optionalDependencies = Object.fromEntries( + targets.map((item) => [packageName(item), manifest.version]), +) +for (const [name, change, message] of [ + [ + 'workspace edges', + (value) => { + value.optionalDependencies[packageName(row)] = 'workspace:*' + }, + /unresolved workspace/, + ], + [ + 'mismatched optional version', + (value) => { + value.optionalDependencies[packageName(row)] = '0.1.999' + }, + /Expected values/, + ], + [ + 'install hook', + (value) => { + value.scripts.install = 'node evil.cjs' + }, + /lifecycle hook/, + ], +]) { + test(`rejects ${name} when validating the packed manifest`, () => { + // Given + const value = structuredClone(manifest) + change(value) + // When / Then + assert.throws( + () => validateManifest(value, undefined, manifest.version), + message, + ) + }) +} +test('rejects unexpected tar members when checking a non-package archive', async () => { + // Given + const directory = mkdtempSync(join(tmpdir(), 'native-tar-control-')) + try { + mkdirSync(join(directory, 'package')) + writeFileSync(join(directory, 'package/evil.cjs'), '') + const file = join(directory, 'bad.tgz') + await child('tar', ['-czf', basename(file), '-C', directory, 'package'], { + cwd: dirname(resolve(file)), + }) + // When / Then + assert.throws( + () => verifyTarball(file, { version: manifest.version }), + /exact tar members/, + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) +test('serves unchanged bytes when a real package-manager-style client requests a tarball', async () => { + // Given + const directory = process.env.NATIVE_PROOF_BUNDLE + const bundle = readJson(join(directory, 'bundle.json')) + const server = await registry(bundle, directory) + try { + const pack = bundle.packs[1] + const output = join( + mkdtempSync(join(tmpdir(), 'native-registry-control-')), + 'download.tgz', + ) + try { + const script = `const http=require('node:http'),fs=require('node:fs');http.get(process.argv[1],r=>{if(r.statusCode!==200)throw Error(r.statusCode);r.pipe(fs.createWriteStream(process.argv[2]))})` + // When + await child(process.execPath, [ + '-e', + script, + `${server.origin}/tarballs/${pack.file}`, + output, + ]) + // Then + assert.equal(hashFile(output), pack.sha256) + } finally { + rmSync(join(output, '..'), { recursive: true, force: true }) + } + } finally { + await server.close() + } +}) diff --git a/e2e/binding/package/provenance.mjs b/e2e/binding/package/provenance.mjs new file mode 100644 index 000000000..4892ce800 --- /dev/null +++ b/e2e/binding/package/provenance.mjs @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict' +import { execFileSync, spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { inspectImage, targets } from './matrix.mjs' + +export const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') +export const sha256 = (bytes) => + createHash('sha256').update(bytes).digest('hex') +export const hashFile = (path) => sha256(readFileSync(path)) +export const readJson = (path) => JSON.parse(readFileSync(path, 'utf8')) +export const writeJson = (path, value) => + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) +export const direct = (url) => + process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === url +export function command(program, args, options = {}) { + const output = execFileSync(program, args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + ...options, + }) + return typeof output === 'string' ? output.trim() : output +} +export function sourceIdentity() { + const vcs = (args) => + command('git', args, { env: { ...process.env, GIT_MASTER: '1' } }) + const paths = vcs([ + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '-z', + ]) + .split('\0') + .filter(Boolean) + .sort() + const files = Object.fromEntries( + paths.map((path) => [ + path, + existsSync(join(root, path)) ? hashFile(join(root, path)) : null, + ]), + ) + return { + commit: vcs(['rev-parse', 'HEAD']), + tree: vcs(['rev-parse', 'HEAD^{tree}']), + dirty: Boolean(vcs(['status', '--porcelain'])), + digest: sha256(JSON.stringify(files)), + files, + } +} +export function tool(program, args = ['--version']) { + const executable = isAbsolute(program) + ? program + : ['rustc', 'cargo'].includes(program) + ? command('rustup', ['which', program]) + : process.platform === 'win32' + ? command('where.exe', [program]).split(/\r?\n/)[0] + : command('which', [program]) + return { + executable, + sha256: hashFile(executable), + version: command(executable, args), + } +} + +export function buildArtifact(options) { + const row = targets.find((item) => item.target === options.target) + assert.ok(row, 'unknown target') + assert.ok(options.run, 'explicit same-run identity required') + const source = sourceIdentity() + assert.ok( + options.allowDirty || !source.dirty, + 'CI requires clean committed source', + ) + mkdirSync(options.out, { recursive: false }) + const receiptPath = join(options.out, 'receipt.json') + const receipt = { + schema: 1, + status: 'building', + run: options.run, + source, + row, + profile: 'native', + started: new Date().toISOString(), + } + writeJson(receiptPath, receipt) + try { + const args = [ + 'bindings/devup-ui-binding/build.mjs', + '--target', + row.target, + ...(options.zigbuild ? ['--zigbuild'] : []), + ] + receipt.command = [process.execPath, ...args] + receipt.cargoCommand = [ + 'cargo', + options.zigbuild ? 'zigbuild' : 'build', + '--locked', + '--profile', + 'native', + '--target', + options.zigbuild && row.libc === 'glibc' + ? `${row.target}.2.28` + : row.target, + '--jobs', + '2', + '-p', + 'devup-ui-binding', + ] + receipt.environment = Object.fromEntries( + [ + 'CARGO_BUILD_JOBS', + 'CARGO_TARGET_DIR', + 'RUSTUP_TOOLCHAIN', + 'RUSTFLAGS', + 'RUST_MIN_STACK', + 'CI', + 'PATH', + 'SDKROOT', + 'MACOSX_DEPLOYMENT_TARGET', + 'INCLUDE', + 'LIB', + ].map((key) => [key, process.env[key] ?? null]), + ) + receipt.environment.CARGO_BUILD_JOBS = '2' + if (row.libc === 'musl') + receipt.environment.RUSTFLAGS = + `${process.env.RUSTFLAGS || ''} -C target-feature=-crt-static`.trim() + receipt.identity = Object.fromEntries( + [ + 'GITHUB_SHA', + 'GITHUB_HEAD_REF', + 'GITHUB_BASE_REF', + 'GITHUB_RUN_ID', + 'GITHUB_RUN_ATTEMPT', + 'RUNNER_OS', + 'RUNNER_ARCH', + 'ImageOS', + 'ImageVersion', + ].map((key) => [key, process.env[key] ?? null]), + ) + const event = process.env.GITHUB_EVENT_PATH + ? readJson(process.env.GITHUB_EVENT_PATH) + : undefined + receipt.pullRequest = { + head: event?.pull_request?.head?.sha ?? null, + base: event?.pull_request?.base?.sha ?? null, + merge: event?.pull_request?.merge_commit_sha ?? null, + } + receipt.runner = { platform: process.platform, arch: process.arch } + receipt.tools = { + node: tool(process.execPath), + bun: tool('bun'), + cargo: tool('cargo'), + rustc: tool('rustc', ['-Vv']), + ...(options.zigbuild + ? { + zig: tool('zig', ['version']), + zigbuild: tool('cargo-zigbuild'), + } + : {}), + } + const build = spawnSync(process.execPath, args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }) + writeFileSync(join(options.out, 'build.stdout'), build.stdout || '') + writeFileSync(join(options.out, 'build.stderr'), build.stderr || '') + assert.equal( + build.status, + 0, + build.error?.message || 'native build failed; see retained logs', + ) + const library = { + win32: 'devup_ui_binding.dll', + linux: 'libdevup_ui_binding.so', + darwin: 'libdevup_ui_binding.dylib', + }[row.os] + const original = join( + resolve(root, process.env.CARGO_TARGET_DIR || 'target'), + row.target, + 'native', + library, + ) + const image = `binding.${row.suffix}.node` + const attached = join(options.out, image) + copyFileSync( + join(root, `bindings/devup-ui-binding-${row.suffix}`, image), + attached, + ) + assert.ok( + statSync(original).mtimeMs >= Date.parse(receipt.started) - 2000, + 'stale build output', + ) + receipt.original = { path: original, sha256: hashFile(original) } + receipt.image = { + file: image, + sha256: hashFile(attached), + bytes: statSync(attached).size, + evidence: inspectImage(attached, row), + } + assert.equal(receipt.original.sha256, receipt.image.sha256) + assert.deepEqual(sourceIdentity(), source, 'source changed during build') + receipt.status = 'built' + receipt.finished = new Date().toISOString() + return receipt + } catch (error) { + receipt.status = 'failed' + receipt.error = { message: error.message, stack: error.stack } + if (error.stdout) + writeFileSync(join(options.out, 'build.stdout'), error.stdout) + if (error.stderr) + writeFileSync(join(options.out, 'build.stderr'), error.stderr) + throw error + } finally { + writeJson(receiptPath, receipt) + } +} +if (direct(import.meta.url)) { + const [target, out, run, ...flags] = process.argv.slice(2) + assert.ok( + flags.every((flag) => ['--allow-dirty', '--zigbuild'].includes(flag)), + ) + buildArtifact({ + target, + out, + run, + allowDirty: flags.includes('--allow-dirty'), + zigbuild: flags.includes('--zigbuild'), + }) +} diff --git a/e2e/binding/package/publish.mjs b/e2e/binding/package/publish.mjs new file mode 100644 index 000000000..2b8e47702 --- /dev/null +++ b/e2e/binding/package/publish.mjs @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict' +import { basename, join, resolve } from 'node:path' + +import { targets } from './matrix.mjs' +import { packageDirectory, packageName, verifyTarball } from './pack.mjs' +import { + command, + direct, + readJson, + root, + sha256, + sourceIdentity, +} from './provenance.mjs' + +// Recheck inside the dispatcher: changepacks can checkout sourceSha after setup. +export function verifySource(expected, actual) { + assert.equal(expected.dirty, false, 'tested source must be clean') + assert.equal( + actual.commit, + expected.commit, + 'selected source commit mismatch', + ) + assert.equal(actual.tree, expected.tree, 'selected source tree mismatch') + assert.equal(sha256(JSON.stringify(expected.files)), expected.digest) + for (const [path, hash] of Object.entries(expected.files)) + assert.equal(actual.files[path], hash, `source input mismatch: ${path}`) + const extra = Object.keys(actual.files).filter( + (path) => !Object.hasOwn(expected.files, path), + ) + assert.ok( + extra.every((path) => /^changepacks(?:\.exe)?$/.test(path)), + 'unexpected source inputs (only untracked changepacks CLI is tolerated)', + ) + assert.ok(!actual.dirty || extra.length > 0, 'dirty release source') +} + +export function verifyBundle(bundle, context, inspect = verifyTarball) { + assert.equal(bundle.schema, 1) + assert.equal(bundle.status, 'verified') + assert.equal(bundle.mode, 'FULL nine-pack') + assert.equal( + bundle.run, + context.run, + 'original producer run/attempt required', + ) + verifySource(bundle.source, context.source) + assert.equal(bundle.packs.length, 9, 'all nine packs required') + assert.equal(bundle.receipts.length, 8, 'all eight producers required') + const version = bundle.packs[0].version + for (const row of [undefined, ...targets]) { + const matches = bundle.packs.filter( + (pack) => pack.name === packageName(row), + ) + assert.equal(matches.length, 1, 'exact package set required') + const pack = matches[0] + assert.equal(pack.version, version, 'same release version required') + assert.equal(pack.file, row ? `${row.suffix}.tgz` : 'binding.tgz') + assert.equal(basename(pack.file), pack.file) + assert.deepEqual( + inspect(join(context.directory, pack.file), { + row, + version, + image: pack.image, + }), + pack, + 'payload integrity/metadata mismatch', + ) + if (row) { + const receipts = bundle.receipts.filter( + (receipt) => receipt.row.suffix === row.suffix, + ) + assert.equal(receipts.length, 1, 'exact producer set required') + const receipt = receipts[0] + assert.equal(receipt.status, 'built') + assert.equal(receipt.run, bundle.run, 'mixed producer attempts') + assert.deepEqual( + receipt.source, + bundle.source, + 'producer source mismatch', + ) + assert.deepEqual(receipt.row, row) + assert.deepEqual(receipt.image, pack.image) + assert.equal(receipt.original.sha256, pack.image.sha256) + } + } + return bundle.packs +} + +export function authorize(env) { + assert.equal(env.GITHUB_ACTIONS, 'true', 'GitHub main/OIDC only') + assert.equal(env.GITHUB_EVENT_NAME, 'push', 'main push only') + assert.equal(env.GITHUB_REF, 'refs/heads/main', 'main push only') + assert.equal(env.GITHUB_REPOSITORY, 'dev-five-git/devup-ui') + assert.match( + env.GITHUB_WORKFLOW_REF || '', + /^dev-five-git\/devup-ui\/\.github\/workflows\/publish\.yml@refs\/heads\/main$/, + ) + assert.ok(env.ACTIONS_ID_TOKEN_REQUEST_URL, 'OIDC URL required') + assert.ok(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, 'OIDC token required') +} + +const boundary = { + source: () => { + for (const flags of [[], ['--cached']]) + command('git', ['diff', ...flags, '--exit-code', 'HEAD', '--'], { + env: { ...process.env, GIT_MASTER: '1' }, + }) + return sourceIdentity() + }, + inspect: verifyTarball, + manifest: (directory) => readJson(join(directory, 'package.json')), + registry: (pack) => + JSON.parse( + command('npm', [ + 'view', + `${pack.name}@${pack.version}`, + '--json', + '--registry=https://registry.npmjs.org', + ]), + ), + publish: (file) => + command('npm', [ + 'publish', + file, + '--ignore-scripts', + '--access=public', + '--registry=https://registry.npmjs.org', + ]), +} + +// The injected IO boundary never changes production authority or validation rules. +export function publishSelected(options, io = boundary) { + assert.ok(['publish', 'dry-run'].includes(options.mode), 'unknown mode') + if (options.mode === 'publish') authorize(options.env) + const packs = verifyBundle( + options.bundle, + { + directory: resolve(options.directory), + source: io.source(), + run: options.run, + }, + io.inspect, + ) + const selectedRow = [undefined, ...targets].find( + (row) => resolve(root, packageDirectory(row)) === resolve(options.cwd), + ) + assert.equal( + resolve(options.cwd), + resolve(root, packageDirectory(selectedRow)), + 'dispatcher must run from a native package directory', + ) + const selected = packs.find((pack) => pack.name === packageName(selectedRow)) + const manifest = io.manifest(options.cwd) + assert.equal(manifest.name, selected.name, 'selected manifest name mismatch') + assert.equal( + manifest.version, + selected.version, + 'selected manifest version mismatch', + ) + const file = resolve(options.directory, selected.file) + if (options.mode === 'dry-run') + return { + status: 'local-validation-only', + file, + integrity: selected.integrity, + } + // Root can only follow all eight exact, already-published optional siblings. + if (!selectedRow) + for (const pack of packs.filter((item) => item.suffix)) { + const published = io.registry(pack) + assert.equal( + published.name, + pack.name, + 'registry package missing/mismatched', + ) + assert.equal(published.version, pack.version, 'registry version mismatch') + assert.equal( + published.dist?.integrity, + pack.integrity, + 'registry integrity mismatch', + ) + } + // No catch: arbitrary npm failures are never treated as already-published success. + verifySource(options.bundle.source, io.source()) + assert.deepEqual( + io.inspect(file, { + row: selectedRow, + version: selected.version, + image: selected.image, + }), + selected, + 'selected payload changed before publication', + ) + io.publish(file) + return { status: 'published', file, integrity: selected.integrity } +} + +if (direct(import.meta.url)) { + const [mode, ...extra] = process.argv.slice(2) + assert.equal(extra.length, 0, 'no forwarded publication options') + const directory = process.env.NATIVE_RELEASE_BUNDLE + assert.ok(directory, 'NATIVE_RELEASE_BUNDLE required outside checkout') + console.info( + JSON.stringify( + publishSelected({ + mode, + directory, + bundle: readJson(join(directory, 'bundle.json')), + cwd: process.cwd(), + env: process.env, + run: `${process.env.GITHUB_RUN_ID}/1`, + }), + ), + ) +} diff --git a/e2e/binding/package/publish.test.mjs b/e2e/binding/package/publish.test.mjs new file mode 100644 index 000000000..4191cdf5c --- /dev/null +++ b/e2e/binding/package/publish.test.mjs @@ -0,0 +1,261 @@ +import assert from 'node:assert/strict' +import { join, resolve } from 'node:path' +import { test } from 'node:test' + +import { targets } from './matrix.mjs' +import { packageDirectory, packageName, verifyTarball } from './pack.mjs' +import { readJson, root, sha256 } from './provenance.mjs' +import { publishSelected, verifyBundle, verifySource } from './publish.mjs' + +// Dispatcher UNIT fixtures, not foreign build evidence. Only npm/IO is injected. +function fixture(row) { + const files = { 'bun.lock': 'lock-bytes', 'package.json': 'manifest-bytes' } + const source = { + commit: 'merge-source', + tree: 'merge-tree', + dirty: false, + files, + digest: sha256(JSON.stringify(files)), + } + const packs = [undefined, ...targets].map((item) => ({ + name: packageName(item), + version: '0.1.1', + file: item ? `${item.suffix}.tgz` : 'binding.tgz', + integrity: `sha512-${item?.suffix || 'root'}`, + ...(item ? { suffix: item.suffix, image: { sha256: item.suffix } } : {}), + })) + const bundle = { + schema: 1, + status: 'verified', + mode: 'FULL nine-pack', + run: '123/1', + source, + packs, + receipts: targets.map((item) => ({ + status: 'built', + run: '123/1', + source, + row: item, + image: { sha256: item.suffix }, + original: { sha256: item.suffix }, + })), + } + const calls = [] + const io = { + source: () => structuredClone(source), + inspect: (file) => + structuredClone( + packs.find((pack) => file === resolve(root, 'bundle', pack.file)), + ), + manifest: () => ({ name: packageName(row), version: '0.1.1' }), + registry: (pack) => { + calls.push(['registry', pack.name]) + return { + name: pack.name, + version: pack.version, + dist: { integrity: pack.integrity }, + } + }, + publish: (file) => calls.push(['publish', file]), + } + const options = { + mode: 'publish', + bundle, + directory: resolve(root, 'bundle'), + run: '123/1', + cwd: join(root, packageDirectory(row)), + env: { + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_NAME: 'push', + GITHUB_REF: 'refs/heads/main', + GITHUB_REPOSITORY: 'dev-five-git/devup-ui', + GITHUB_RUN_ATTEMPT: '2', + GITHUB_WORKFLOW_REF: + 'dev-five-git/devup-ui/.github/workflows/publish.yml@refs/heads/main', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://oidc.invalid', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'injected', + }, + } + return { options, io, calls } +} + +test('publishes only the selected absolute tarball when a sibling is selected', () => { + // Given + const { options, io, calls } = fixture(targets[3]) + // When + const result = publishSelected(options, io) + // Then + assert.deepEqual(calls, [ + ['publish', resolve(options.directory, `${targets[3].suffix}.tgz`)], + ]) + assert.equal(result.integrity, `sha512-${targets[3].suffix}`) +}) +test('requires eight registry integrities before root publication on a release-only retry', () => { + // Given: attempt2 still consumes original123/1 payloads, not a rebuilt bundle. + const { options, io, calls } = fixture() + // When + publishSelected(options, io) + // Then + assert.deepEqual(calls, [ + ...targets.map((row) => ['registry', packageName(row)]), + ['publish', resolve(options.directory, 'binding.tgz')], + ]) +}) +test('performs only local verification when dry-run is selected without authority', () => { + // Given + const { options, io, calls } = fixture() + // When + const result = publishSelected({ ...options, mode: 'dry-run', env: {} }, io) + // Then + assert.equal(result.status, 'local-validation-only') + assert.deepEqual(calls, []) +}) +for (const [key, value] of [ + ['GITHUB_ACTIONS', 'false'], + ['GITHUB_EVENT_NAME', 'pull_request'], + ['GITHUB_REF', 'refs/heads/other'], + ['GITHUB_REPOSITORY', 'fork/repo'], + [ + 'GITHUB_WORKFLOW_REF', + 'dev-five-git/devup-ui/.github/workflows/other.yml@refs/heads/main', + ], + ['ACTIONS_ID_TOKEN_REQUEST_URL', ''], + ['ACTIONS_ID_TOKEN_REQUEST_TOKEN', ''], +]) + test(`denies real mode when ${key} is unauthorized`, () => { + // Given + const { options, io, calls } = fixture() + // When / Then + assert.throws(() => + publishSelected( + { ...options, env: { ...options.env, [key]: value } }, + io, + ), + ) + assert.deepEqual(calls, []) + }) +for (const [name, result] of [ + ['missing', {}], + ['wrong version', { version: '0.1.0' }], + ['wrong integrity', { version: '0.1.1', dist: { integrity: 'foreign' } }], +]) + test(`denies root publication when a sibling registry result is ${name}`, () => { + // Given + const { options, io, calls } = fixture() + // When / Then + assert.throws(() => + publishSelected(options, { + ...io, + registry: (pack) => ({ name: pack.name, ...result }), + }), + ) + assert.deepEqual(calls, []) + }) +for (const [key, value] of [ + ['commit', 'older-action-sourceSha'], + ['tree', 'different-tree'], + ['files', { 'bun.lock': 'changed-lock' }], +]) + test(`denies publication when current source ${key} differs`, () => { + // Given + const { options, io, calls } = fixture() + // When / Then + assert.throws(() => + publishSelected(options, { + ...io, + source: () => ({ ...options.bundle.source, [key]: value }), + }), + ) + assert.deepEqual(calls, []) + }) +for (const [path, value] of [ + [['options', 'bundle', 'source', 'dirty'], true], + [['options', 'bundle', 'packs', 'length'], 8], + [['options', 'bundle', 'receipts', 0, 'run'], '123/2'], + [['options', 'run'], '456/1'], + [['io', 'inspect'], () => ({ integrity: 'corrupt' })], + [['io', 'manifest'], () => ({ name: packageName(), version: '0.1.0' })], + [['options', 'cwd'], join(root, 'packages/react')], +]) + test(`fails before external operations when ${path.join('.')} changes`, () => { + // Given: mutate one independent boundary value in a fresh fixture. + const f = fixture() + const parent = path.slice(0, -1).reduce((object, key) => object[key], f) + parent[path.at(-1)] = value + // When / Then + assert.throws(() => publishSelected(f.options, f.io)) + assert.deepEqual(f.calls, []) + }) +test('tolerates the untracked action executable when all recorded source bytes match', () => { + // Given + const { options, io, calls } = fixture(targets[0]) + const source = options.bundle.source + // When + publishSelected(options, { + ...io, + source: () => ({ + ...source, + dirty: true, + files: { ...source.files, 'changepacks.exe': 'tool' }, + }), + }) + // Then + assert.equal(calls[0][0], 'publish') +}) +test('rejects unrelated untracked inputs when the action executable is present', () => { + // Given + const { options } = fixture() + const source = options.bundle.source + // When / Then + assert.throws(() => + verifySource(source, { + ...source, + dirty: true, + files: { ...source.files, changepacks: 'tool', 'evil.js': 'mutation' }, + }), + ) +}) +test('propagates npm errors when the injected publication fails', () => { + // Given + const { options, io } = fixture(targets[0]) + const failure = new Error('injected registry outage') + // When / Then + assert.throws( + () => + publishSelected(options, { + ...io, + publish: () => { + throw failure + }, + }), + failure, + ) +}) +test('reinspects a real local tarball when the publisher payload boundary is used', () => { + // Given: genuine host proof, explicitly not a full-nine release positive. + const directory = process.env.NATIVE_PROOF_BUNDLE + const bundle = readJson(join(directory, 'bundle.json')) + const pack = bundle.packs.find((item) => item.suffix) + const row = targets.find((item) => item.suffix === pack.suffix) + // When + const actual = verifyTarball(join(directory, pack.file), { + row, + version: pack.version, + image: pack.image, + }) + // Then + assert.deepEqual(actual, pack) +}) +test('rejects the genuine proof when release run identity differs', () => { + // Given + const directory = process.env.NATIVE_PROOF_BUNDLE + const bundle = readJson(join(directory, 'bundle.json')) + // When / Then + assert.throws(() => + verifyBundle(bundle, { + directory, + source: bundle.source, + run: 'foreign/1', + }), + ) +}) diff --git a/e2e/binding/package/registry.mjs b/e2e/binding/package/registry.mjs new file mode 100644 index 000000000..c94864174 --- /dev/null +++ b/e2e/binding/package/registry.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { join } from 'node:path' + +import { hashFile, writeJson } from './provenance.mjs' + +// Async children are essential: a blocking install would starve this server. +export async function child(program, args, options = {}) { + return new Promise((resolve, reject) => { + const subprocess = spawn(program, args, { + env: { ...process.env, NODE_TEST_CONTEXT: undefined }, + ...options, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 180000, + }) + let stdout = '' + let stderr = '' + subprocess.stdout.setEncoding('utf8').on('data', (chunk) => { + stdout += chunk + }) + subprocess.stderr.setEncoding('utf8').on('data', (chunk) => { + stderr += chunk + }) + subprocess.once('error', reject) + subprocess.once('close', (code, signal) => { + if (code === 0) resolve({ stdout, stderr }) + else + reject( + Object.assign(new Error(`${program} exited ${code}/${signal}`), { + code, + stdout, + stderr, + }), + ) + }) + }) +} + +// Each real install has a new fixture, empty caches and no lifecycle authority. +export async function installConsumer(options) { + const fixture = join( + options.directory, + `${options.manager}-${options.omit ? 'omitted' : 'selected'}`, + ) + mkdirSync(fixture) + writeJson(join(fixture, 'package.json'), { + name: 'clean-native-consumer', + private: true, + dependencies: { '@devup-ui/binding': options.version }, + }) + const env = { + ...process.env, + NODE_PATH: '', + npm_config_userconfig: join(fixture, 'empty.npmrc'), + npm_config_globalconfig: join(fixture, 'global.npmrc'), + npm_config_cache: join(fixture, 'cache'), + npm_config_ignore_scripts: 'true', + npm_config_registry: options.origin, + BUN_INSTALL_CACHE_DIR: join(fixture, 'cache'), + } + writeFileSync(env.npm_config_userconfig, '') + writeFileSync(env.npm_config_globalconfig, '') + const args = [ + 'install', + '--ignore-scripts', + '--registry', + options.origin, + ...(options.manager === 'npm' ? ['--no-audit', '--no-fund'] : []), + ...(options.omit ? ['--omit=optional'] : []), + ] + const output = + options.manager === 'npm' + ? await child(process.execPath, [options.npmCli, ...args], { + cwd: fixture, + env, + }) + : await child('bun', args, { cwd: fixture, env }) + writeJson(join(fixture, 'install.json'), output) + return { fixture, env } +} +export async function registry(bundle, directory) { + const requests = [] + const bytes = new Map( + bundle.packs.map((pack) => { + const file = join(directory, pack.file) + assert.equal(hashFile(file), pack.sha256, 'registry tarball hash') + return [pack.file, readFileSync(file)] + }), + ) + let origin + const server = createServer((request, response) => { + const path = decodeURIComponent( + new URL(request.url, 'http://localhost').pathname, + ) + requests.push({ method: request.method, path }) + const pack = bundle.packs.find( + (item) => path === `/${item.name}` || path === `/tarballs/${item.file}`, + ) + if (request.method !== 'GET' || !pack) { + response.writeHead(404).end('not in verified bundle') + return + } + if (path.startsWith('/tarballs/')) { + response + .writeHead(200, { 'content-type': 'application/octet-stream' }) + .end(bytes.get(pack.file)) + } else { + const version = { + ...pack.manifest, + dist: { + tarball: `${origin}/tarballs/${pack.file}`, + integrity: pack.integrity, + }, + } + response.writeHead(200, { 'content-type': 'application/json' }).end( + JSON.stringify({ + name: pack.name, + 'dist-tags': { latest: pack.version }, + versions: { [pack.version]: version }, + }), + ) + } + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + origin = `http://127.0.0.1:${server.address().port}` + return { + origin, + requests, + close: () => + new Promise((resolve, reject) => { + server.closeAllConnections() + server.close((error) => (error ? reject(error) : resolve())) + }), + } +} diff --git a/e2e/binding/seed-copy.mjs b/e2e/binding/seed-copy.mjs new file mode 100644 index 000000000..70e276bb6 --- /dev/null +++ b/e2e/binding/seed-copy.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import { writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { isAbsolute } from 'node:path' + +const [entry, output] = process.argv.slice(2) +assert.equal(process.argv.length, 4) +assert.ok(isAbsolute(entry) && isAbsolute(output)) +const require = createRequire(import.meta.url) +const api = require(entry) +const createBoundary = require('../../bindings/argument-validation.cjs') +let entered = 0 +const fake = createBoundary().wrap({ + ...api, + seedFileMap() { + entered++ + }, +}) +const snapshot = () => ({ + raw: [ + 'exportSheet', + 'exportClassMap', + 'exportFileMap', + 'exportCanonicalMap', + ].map((name) => api[name]()), + config: [ + api.getPrefix(), + api.isDebug(), + api.getDefaultTheme(), + api.getUnreservedClassSlotCount(), + ], + css: [null, 0, 1].map((index) => api.getCss(index, false)), +}) +const input = (lengths, values = []) => { + const events = [] + let reads = 0 + const files = new Proxy(values, { + get(target, key) { + events.push(String(key)) + if (key !== 'length') return Reflect.get(target, key) + const index = reads++ + if (lengths[index] === -1) return -1 + return { + valueOf() { + events.push('coerce:' + index) + return lengths[index] + }, + } + }, + }) + return { files, events } +} +const header = [ + 'length', + 'coerce:0', + 'length', + 'coerce:1', + 'length', + 'coerce:2', +] +const records = [] +for (const [id, lengths] of [ + ['negative', [-1, -1, -1]], + ['growth', [0, 0, 1]], +]) { + for (const [host, target] of [ + ['fake', fake], + ['actual', api], + ]) { + // Given: nonempty actual state and a real-array proxy with no visited indices. + api.resetStateForTesting() + api.seedFileMap(['src/kept.tsx']) + const before = snapshot() + const { files, events } = input(lengths) + // When: the final header would extend the validated prefix. + assert.throws( + () => target.seedFileMap(files), + (error) => { + // Then: only the canonical, genuine TypeError crosses the boundary. + assert.ok(error instanceof TypeError) + assert.equal(error.constructor, TypeError) + assert.equal(Object.getPrototypeOf(error), TypeError.prototype) + assert.equal( + error.message, + 'seedFileMap(): argument files must be an array of strings', + ) + return true + }, + ) + assert.deepEqual( + events, + id === 'negative' ? Array(3).fill('length') : header, + ) + assert.equal(entered, 0) + assert.deepEqual(snapshot(), before) + records.push({ id, host, events, rejected: true, entered, unchanged: true }) + } +} +// Given: two validated strings, with only the first retained by the final header. +api.resetStateForTesting() +const shrinking = input([2, 2, 2, 2, 1], ['src/kept.tsx', 'src/dropped.tsx']) +// When: the actual exported API receives a valid shrinking copy. +const result = api.seedFileMap(shrinking.files) +// Then: no original index is reread, and only the retained path is seeded. +assert.equal(result, undefined) +assert.deepEqual(shrinking.events, [ + 'length', + 'coerce:0', + 'length', + 'coerce:1', + '0', + 'length', + 'coerce:2', + '1', + 'length', + 'coerce:3', + 'length', + 'coerce:4', +]) +const fileMap = JSON.parse(api.exportFileMap()) +assert.deepEqual(fileMap, { 'src/kept.tsx': 0 }) +records.push({ id: 'shrinking', events: shrinking.events, fileMap }) +writeFileSync(output, JSON.stringify(records, null, 2) + '\n', { flag: 'wx' }) diff --git a/e2e/binding/validation-cases.mjs b/e2e/binding/validation-cases.mjs new file mode 100644 index 000000000..c90b0fda0 --- /dev/null +++ b/e2e/binding/validation-cases.mjs @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict' + +import { box, pkg } from './corpus.mjs' + +export const code = box('color="orange" p={7}') +const row = ['src/new.tsx', code, 'df'] +const extraction = () => [ + 'src/new.tsx', + code, + pkg, + 'df', + false, + false, + false, + {}, +] +const extractSlots = + 'filename:s code:s _package:s css_dir:s single_css:b import_main_css_in_code:b import_main_css_in_css:b import_aliases:aliases' +// Independent acceptance oracle: no product validator or engine diagnostics imported. +export const positions = [ + ['codeExtract', extractSlots, extraction], + ['codeExtractWithoutSourceMap', extractSlots, extraction], + ['getCss', 'file_num:n import_main_css:b', () => [null, false]], + [ + 'getThemeInterface', + 'package_name:s color_interface_name:s typography_interface_name:s length_interface_name:s shadows_interface_name:s theme_interface_name:s', + () => [pkg, 'Colors', 'Typography', 'Length', 'Shadow', 'Theme'], + ], + [ + 'hasDevupUI', + 'filename:s code:s _package:s', + () => [...extraction().slice(0, 3), { '@emotion/styled': null }], + ], + ['importCanonicalMap', 'map_object:canonical', () => [{}]], + ['importClassMap', 'sheet_object:class', () => [{}]], + ['importFileMap', 'sheet_object:filemap', () => [{}]], + ['importFileRoutes', 'map_object:routes', () => [{}]], + ['importSheet', 'sheet_object:sheet', () => [{}]], + ['registerShorthands', 'shorthands:shorthands', () => [{}]], + ['registerTheme', 'theme_object:theme', () => [{}]], + [ + 'reserveClassSlots', + 'rows:rows _package:s single_css:b import_main_css_in_code:b _import_main_css_in_css:b import_aliases:aliases', + () => [[row], pkg, false, false, false, {}], + ], + ['seedFileMap', 'files:seed', () => [['src/new.tsx']]], + ['setAtomHoist', 'threshold:n', () => [2]], + ['setDebug', 'debug:b', () => [true]], + ['setModuleResolver', 'resolver:resolver', () => [null]], + ['setPrefix', 'prefix:prefix', () => ['valid-']], +].flatMap(([fn, slots, args]) => + slots.split(' ').map((slot, index) => { + const [parameter, kind] = slot.split(':') + return { fn, parameter, kind, index, args } + }), +) +const labels = { + s: 'a string', + b: 'a boolean', + n: 'a number, null or undefined', + prefix: 'a string, null or undefined', + resolver: 'a function, null or undefined', + seed: 'an array of strings', + aliases: 'an alias mapping', + canonical: 'a canonical mapping', + class: 'a nested class-index mapping', + filemap: 'a file-index mapping', + routes: 'a file-to-route mapping', + sheet: 'a sheet state', + shorthands: 'a shorthand mapping', + theme: 'a theme', + rows: 'an iterable of 3-string rows', +} +const roots = [7, false, 'bad', null, undefined] +const property = (s) => ({ + properties: { file: { 0: { 0: [{ c: 'a', p: 'color', v: 'red', s }] } } }, +}) +const invalid = { + s: [false, {}, 7, undefined, null], + b: [7, 'false', null, undefined], + n: ['2', false, 1n, Symbol('number'), {}], + prefix: [false, true, 7, {}], + resolver: [false, true, 7, 'callback', {}], + seed: [ + ...roots, + {}, + new Set(['src/new.tsx']), + ['src/new.tsx', 7], + Object.assign(['src/new.tsx'], { length: 2 }), + ], + aliases: [...roots, { bad: 7 }, { bad: false }, { bad: {} }, new Set([null])], + canonical: [...roots, { file: 7 }, { file: null }, new Set([null])], + class: [ + ...roots, + { file: 7 }, + { file: { color: 'bad' } }, + { file: null }, + new Set([null]), + ], + filemap: [...roots, { file: 'bad' }, { file: {} }, new Set([null])], + routes: [ + ...roots, + { file: 7 }, + { file: [0, 'bad'] }, + { file: [null] }, + new Set([null]), + ], + sheet: [ + ...roots, + { css: 7 }, + { css: { file: 7 } }, + { css: { file: [{ css: 7 }] } }, + property({ Unknown: [] }), + property({ Global: ['body'] }), + property({ Selector: 7 }), + { keyframes: { file: { spin: { from: [['color']] } } } }, + ], + theme: [ + ...roots, + { colors: 7 }, + { colors: { default: 7 } }, + { colors: { default: { primary: true } } }, + { typography: { heading: { fontSize: {} } } }, + { length: { default: { gutter: {} } } }, + { shadows: { default: { card: {} } } }, + ], + shorthands: [ + ...roots, + { insetX: 7 }, + { insetX: ['left', 7] }, + { insetX: null }, + new Set([null]), + ], + rows: [ + ...roots, + {}, + [row, null], + [row, 7], + [row, ['src/bad.tsx', 7, 'df']], + [row, ['src/bad.tsx', code]], + [row, ['src/bad.tsx', code, 'df', 'extra']], + [row, ['src/bad.tsx', code, {}]], + ], +} +export const cases = positions.flatMap((position) => + invalid[position.kind].map((value, variant) => ({ + ...position, + id: `${position.fn}.${position.parameter}.${variant}`, + expected: `${position.fn}(): argument ${position.parameter} must be ${labels[position.kind]}`, + input: () => + position + .args() + .map((good, index) => (index === position.index ? value : good)), + })), +) +assert.equal(positions.length, 45) +assert.equal(new Set(positions.map((p) => p.fn)).size, 18) +assert.equal(new Set(cases.map((c) => c.id)).size, cases.length) diff --git a/e2e/binding/validation-host.mjs b/e2e/binding/validation-host.mjs new file mode 100644 index 000000000..501bc10d0 --- /dev/null +++ b/e2e/binding/validation-host.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict' +import { appendFileSync, realpathSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { isAbsolute, join } from 'node:path' + +import { dependent, pkg, theme } from './corpus.mjs' +import { capture, encode, hash } from './observation.mjs' +import { cases, code } from './validation-cases.mjs' + +const [entry, destination, id] = process.argv.slice(2) +assert.equal(process.argv.length, 5) +assert.ok(isAbsolute(entry) && isAbsolute(destination)) +assert.deepEqual(process.execArgv, []) +const require = createRequire(import.meta.url), + loaded = realpathSync(entry), + w = require(loaded) +const save = (name, value) => + writeFileSync(join(destination, name), JSON.stringify(value) + '\n', { + flag: 'wx', + }) +save('provenance.json', { + loaded, + sha256: hash(loaded), + runtime: { + execPath: process.execPath, + version: process.version, + versions: process.versions, + sha256: hash(process.execPath), + }, + modules: Object.keys(require.cache).map((path) => ({ + path, + sha256: hash(path), + })), +}) +const test = cases.find((item) => item.id === id) +assert.ok(test, id) +const extract = (filename, text) => { + const out = w.codeExtract(filename, text, pkg, 'df', false, false, false, {}) + try { + return { code: out.code, dependencies: out.dependencies } + } finally { + out.free() + } +} +// Given: nonempty observable state; retain a real installed resolver for replacement cases. +w.resetStateForTesting() +w.setPrefix('kept-') +w.setDebug(true) +w.registerTheme(theme) +w.registerShorthands({ insetX: ['left', 'right'] }) +w.importCanonicalMap({ 'src/kept.tsx': 'src/root.tsx' }) +w.importFileRoutes({ 'src/kept.tsx': [0, 1] }) +w.setAtomHoist(2) +const calls = [] +w.setModuleResolver((specifier, importer) => { + calls.push([specifier, importer]) + return { + path: 'src/validation-token.ts', + code: 'export const color = "purple";', + } +}) +extract('src/kept.tsx', code) +const snapshot = () => ({ + raw: [ + 'exportSheet', + 'exportClassMap', + 'exportFileMap', + 'exportCanonicalMap', + ].map((name) => capture(() => w[name]())), + config: [ + 'getPrefix', + 'isDebug', + 'getDefaultTheme', + 'getUnreservedClassSlotCount', + ].map((name) => capture(() => w[name]())), + interface: capture(() => + w.getThemeInterface( + pkg, + 'Colors', + 'Typography', + 'Length', + 'Shadow', + 'Theme', + ), + ), + css: [null, 0, 1, 2, 3, 4, 7, 128].flatMap((index) => + [false, true].map((main) => capture(() => w.getCss(index, main))), + ), +}) +const before = snapshot(), + args = test.input() +save('before.json', { id, input: encode(args), before }) +// When: exactly one invalid position, with every sibling argument valid. +let actual +const result = capture(() => { + try { + const value = w[test.fn](...args) + if (value instanceof w.Output) value.free() + } catch (error) { + actual = error + throw error + } +}) +const trapped = actual instanceof WebAssembly.RuntimeError +const after = trapped ? { tag: 'unobservable-after-wasm-trap' } : snapshot() +save('record.json', { + id, + expected: test.expected, + input: encode(args), + result, + before, + after, +}) +// A resolver retention probe is deliberately OUTSIDE the mutation snapshot. +let postprobe, resolvedOutput +if (!trapped && test.fn === 'setModuleResolver') { + const start = calls.length + postprobe = capture(() => { + resolvedOutput = extract('src/fresh-dependent.tsx', dependent) + return resolvedOutput + }) + save('postprobe.json', { result: postprobe, calls: calls.slice(start) }) +} +// Then: diagnostics exist even when these genuine acceptance assertions fail. +assert.ok(actual instanceof TypeError, `${id}: TypeError required`) +assert.equal(actual.constructor, TypeError) +assert.equal(actual.message, test.expected) +assert.ok( + [...before.raw, ...before.config, before.interface, ...before.css].every( + (value) => value.tag === 'return', + ), + 'snapshot observations must succeed', +) +assert.deepEqual(after, before, `${id}: rejected call mutated state`) +if (test.fn === 'setModuleResolver') { + assert.equal(postprobe.tag, 'return') + assert.ok( + calls.some( + ([specifier, importer]) => + specifier === './tokens' && importer === 'src/fresh-dependent.tsx', + ), + ) + assert.deepEqual(resolvedOutput.dependencies, ['src/validation-token.ts']) +} +appendFileSync( + join(destination, 'complete.json'), + JSON.stringify({ id }) + '\n', + { flag: 'wx' }, +) diff --git a/e2e/binding/validation.mjs b/e2e/binding/validation.mjs new file mode 100644 index 000000000..499bb1b34 --- /dev/null +++ b/e2e/binding/validation.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { hash } from './observation.mjs' +import { cases, positions } from './validation-cases.mjs' + +assert.ok( + process.argv.length === 5 || process.argv.length === 6, + 'Usage: node e2e/binding/validation.mjs [runtime-executable]', +) +const [wasm, native, evidence, runtime = process.execPath] = + process.argv.slice(2) +for (const path of [wasm, native, evidence]) + assert.ok(isAbsolute(path), 'Absolute paths required') +mkdirSync(evidence) +const output = realpathSync(evidence), + here = dirname(fileURLToPath(import.meta.url)) +const save = (name, value) => + writeFileSync(join(output, name), JSON.stringify(value, null, 2) + '\n', { + flag: 'wx', + }) +save('provenance.json', { + runtime, + runner: { + execPath: process.execPath, + version: process.version, + versions: process.versions, + }, + entries: [wasm, native].map((path) => ({ + path, + loaded: realpathSync(path), + sha256: hash(path), + })), + sources: [ + 'validation.mjs', + 'validation-host.mjs', + 'validation-cases.mjs', + 'observation.mjs', + 'corpus.mjs', + 'corpus/extraction.mjs', + ].map((name) => ({ name, sha256: hash(join(here, name)) })), +}) +const runs = [] +for (const [engine, entry] of [ + ['wasm', wasm], + ['native', native], +]) { + for (const test of cases) { + const name = `${engine}-${test.id}`, + directory = join(output, name) + mkdirSync(directory) + const stdout = openSync(join(directory, 'stdout.log'), 'wx'), + stderr = openSync(join(directory, 'stderr.log'), 'wx') + let result + try { + result = spawnSync( + runtime, + [join(here, 'validation-host.mjs'), entry, directory, test.id], + { + cwd: directory, + env: { ...process.env, NODE_OPTIONS: '', BUN_OPTIONS: '' }, + stdio: ['ignore', stdout, stderr], + timeout: 120000, + killSignal: 'SIGKILL', + }, + ) + } finally { + closeSync(stdout) + closeSync(stderr) + } + const crash = Boolean( + result.error || result.signal || result.status === null, + ) + const missing = !existsSync(join(directory, 'record.json')) + const complete = existsSync(join(directory, 'complete.json')) + const failed = crash || missing || result.status !== 0 || !complete + const run = { + engine, + id: test.id, + status: result.status, + signal: result.signal, + error: result.error + ? { + name: result.error.name, + message: result.error.message, + code: result.error.code, + } + : null, + crash, + missing, + failed, + } + if (!missing) run.recordSha256 = hash(join(directory, 'record.json')) + if (complete) + assert.equal( + JSON.parse(readFileSync(join(directory, 'complete.json'), 'utf8')).id, + test.id, + ) + runs.push(run) + save(`${name}.json`, run) + } +} +const summary = { + cases: cases.length, + positions: positions.length, + functions: 18, + engines: 2, + runs: runs.length, + failed: runs.filter((run) => run.failed).length, + missing: runs.filter((run) => run.missing).length, + crash: runs.filter((run) => run.crash).length, +} +save('summary.json', { ...summary, runs }) +console.info(JSON.stringify(summary)) +if (summary.failed) process.exitCode = 1 diff --git a/eslint.config.mjs b/eslint.config.mjs index 06fbfef5a..d4ec9c1d7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,6 +9,7 @@ export default [ ignores: [ 'benchmark/next-panda-css/styled-system', 'bindings/devup-ui-wasm/pkg', + 'bindings/devup-ui-binding/pkg', 'test-results', 'release-results.json', 'release-package-build.json', diff --git a/libs/binding/Cargo.toml b/libs/binding/Cargo.toml new file mode 100644 index 000000000..aa50e11f4 --- /dev/null +++ b/libs/binding/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "devup-ui-binding-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Host-neutral extraction and state orchestration for Devup UI bindings" +license = "Apache-2.0" +repository = "https://github.com/dev-five-git/devup-ui" +keywords = ["css", "react", "devup-ui", "bindings"] +categories = ["development-tools", "web-programming"] + +[lints] +workspace = true + +[dependencies] +extractor = { path = "../extractor" } +sheet = { path = "../sheet" } +css = { path = "../css" } +serde_json = "1.0.154" +bimap = { version = "0.6.3", features = ["serde"] } diff --git a/libs/binding/README.md b/libs/binding/README.md new file mode 100644 index 000000000..2c0980b4b --- /dev/null +++ b/libs/binding/README.md @@ -0,0 +1,5 @@ +# Devup UI Binding Core + +Host-neutral extraction, stylesheet, state and reservation orchestration shared by +the build-host bindings. JavaScript values and resolver handles stay in each host +adapter; this crate does not add a styling runtime or concurrent build isolation. diff --git a/libs/binding/src/lib.rs b/libs/binding/src/lib.rs new file mode 100644 index 000000000..ee534ad40 --- /dev/null +++ b/libs/binding/src/lib.rs @@ -0,0 +1,165 @@ +//! Host-neutral binding orchestration; JavaScript values belong to the host adapter. + +mod output; +mod reservation; +mod resolver_state; + +pub use css::atom_hoist::set_atom_hoist; +pub use css::debug::{is_debug, set_debug}; +pub use css::file_routes::set_file_routes as import_file_routes_internal; +pub use css::slot_reservation::unreserved_slot_count as get_unreserved_class_slot_count; +pub use css::{get_prefix, set_custom_shorthands as register_shorthands_internal, set_prefix}; +pub use extractor::{has_devup_ui, has_devup_ui_with}; +pub use output::{CssOptions, ExtractionMode, Output, SourceMapMode, code_extract}; +pub use reservation::{ReservationInput, reserve_class_slots_internal}; +pub use resolver_state::{ResolverRequest, ResolverState}; + +use css::class_map::{set_class_map, with_class_map}; +use css::file_map::{set_canonical_map, set_file_map, with_canonical_map, with_file_map}; +use sheet::StyleSheet; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{LazyLock, Mutex}; + +/// The single stylesheet shared by extraction, reservation and host test seams. +pub static GLOBAL_STYLE_SHEET: LazyLock> = + LazyLock::new(|| Mutex::new(StyleSheet::default())); + +/// Borrow the live stylesheet, recovering its contents after an unwind. +pub fn with_style_sheet(f: F) -> R +where + F: FnOnce(&StyleSheet) -> R, +{ + let guard = GLOBAL_STYLE_SHEET + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&guard) +} + +/// Mutably borrow the live stylesheet, recovering its contents after an unwind. +pub fn with_style_sheet_mut(f: F) -> R +where + F: FnOnce(&mut StyleSheet) -> R, +{ + let mut guard = GLOBAL_STYLE_SHEET + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&mut guard) +} + +/// Restore the sheet and its frozen atom plan together. +pub fn import_sheet_internal(sheet: StyleSheet) { + css::atom_hoist::restore_atom_plan(sheet.atom_plan.clone()); + with_style_sheet_mut(|global_sheet| *global_sheet = sheet); +} + +/// Export the deterministic sheet snapshot without changing live storage. +pub fn export_sheet_internal() -> Result { + with_style_sheet(|sheet| serde_json::to_string(&sheet.export_snapshot())) + .map_err(|e| e.to_string()) +} + +/// Restore class allocations decoded by a host adapter. +pub fn import_class_map_internal(map: HashMap>) { + set_class_map(map); +} + +/// Export class allocations in file and class-key order. +pub fn export_class_map_internal() -> Result { + with_class_map(|map| { + let sorted: BTreeMap<&String, BTreeMap<&String, &usize>> = map + .iter() + .map(|(file, classes)| (file, classes.iter().collect())) + .collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) +} + +/// Restore file numbers decoded by a host adapter. +pub fn import_file_map_internal(map: bimap::BiHashMap) { + set_file_map(map); +} + +/// Export file numbers in path order. +pub fn export_file_map_internal() -> Result { + with_file_map(|map| { + let sorted: BTreeMap<&String, &usize> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) +} + +/// Restore canonical bucket identities decoded by a host adapter. +pub fn import_canonical_map_internal(map: HashMap) { + set_canonical_map(map); +} + +/// Export canonical bucket identities in path order. +pub fn export_canonical_map_internal() -> Result { + with_canonical_map(|map| { + let sorted: BTreeMap<&String, &String> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) +} + +/// Seed deterministic file numbers without replacing existing assignments. +pub fn seed_file_map(files: &[String]) { + css::file_map::seed_file_numbers(files); +} + +/// Clear production build state, retaining theme registries, shorthands and debug. +/// The host adapter separately clears its resolver handle. +pub fn reset_build_state_internal() { + css::slot_reservation::reset(); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::file_routes::set_file_routes(HashMap::new()); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + css::set_prefix(None); + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); +} + +/// Clear test state at a quiescent boundary, clearing the host resolver before registries. +pub fn reset_state_internal(clear_resolver: impl FnOnce()) { + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + clear_resolver(); + css::test_state::reset_state_for_testing(); + extractor::test_state::reset_caches_for_testing(); +} + +/// Register a decoded theme in the live stylesheet. +pub fn register_theme_internal(theme: sheet::theme::Theme) { + with_style_sheet_mut(|sheet| sheet.set_theme(theme)); +} + +/// Return the theme used without an explicit variant selector. +pub fn get_default_theme() -> Option { + with_style_sheet(|sheet| sheet.theme.get_default_theme()) +} + +/// Materialize CSS for a numbered file or the global sheet. +pub fn get_css(file_num: Option, import_main_css: bool) -> String { + with_style_sheet(|sheet| { + if let Some(file_num) = file_num { + with_file_map(|map| { + sheet.create_css( + map.get_by_right(&file_num).map(String::as_str), + import_main_css, + ) + }) + } else { + sheet.create_css(None, import_main_css) + } + }) +} + +/// Generate declarations with color, typography, length, shadow and theme interface names. +pub fn get_theme_interface(package_name: &str, names: [&str; 5]) -> String { + let [color, typography, length, shadows, theme] = names; + with_style_sheet(|sheet| { + sheet.create_interface(package_name, color, typography, length, shadows, theme) + }) +} diff --git a/libs/binding/src/output.rs b/libs/binding/src/output.rs new file mode 100644 index 000000000..284113ca4 --- /dev/null +++ b/libs/binding/src/output.rs @@ -0,0 +1,125 @@ +use css::file_map::{canonical, is_global}; +use extractor::{ + ExtractOption, ExtractOutput, ModuleResolver, extract, extract_with_modules, + extract_without_source_map, +}; + +use crate::with_style_sheet_mut; + +/// Whether extraction generates a source map. +#[derive(Clone, Copy)] +pub enum SourceMapMode { + /// Generate source maps using the existing extractor path. + Generate, + /// Skip source-map generation. + Skip, +} + +/// CSS emission settings shared by real transforms and output test seams. +#[derive(Clone, Copy)] +pub struct CssOptions { + /// Emit all atoms to the global sheet. + pub single_css: bool, + /// Import the main stylesheet from emitted file CSS. + pub import_main_css: bool, +} + +/// Host-neutral extraction routing and CSS import policy. +pub struct ExtractionMode<'a> { + /// Source-map generation policy. + pub source_map: SourceMapMode, + /// Borrow the host resolver only for this synchronous extraction. + pub resolver: Option<&'a ModuleResolver>, + /// Refuse publication after extraction, before matching its result. + pub resolver_check: Option<&'a dyn Fn() -> Result<(), String>>, + /// Import the main stylesheet from emitted file CSS. + pub import_main_css_in_css: bool, +} + +/// Materialized transform result, independent of host handles and getters. +pub struct Output { + /// Transformed source. + pub code: String, + /// Generated source map, when requested and available. + pub map: Option, + /// Emitted stylesheet filename. + pub css_file: Option, + /// Whether shared base CSS changed. + pub updated_base_style: bool, + /// Changed CSS, absent when the stylesheet is unchanged. + pub css: Option, + /// Files read through the module resolver. + pub dependencies: Vec, +} + +impl Output { + /// Commit extracted styles to their existing bucket and materialize changed CSS. + pub fn new(output: ExtractOutput, filename: String, options: CssOptions) -> Self { + // Use the bucket identity (single-importer collapse) so the sheet's CSS + // naming + property bucket + emitted chunk match the canonical class names + // the extractor already baked into `code`. Identity when no map is loaded. + // Global (shared-chunk) files are treated like single-css: emitted into the + // global bucket (devup-ui.css) with prefix-less naming. + let _atom_mode = css::atom_hoist::AtomModeGuard::new(options.single_css); + let canonical_filename = canonical(&filename); + let global = options.single_css || is_global(&filename); + with_style_sheet_mut(|sheet| { + // Global CSS belongs to the raw SOURCE file, never its collapsed bucket. + let default_collected = sheet.rm_global_css(&filename, global); + let (collected, updated_base_style) = + sheet.update_styles_in_bucket(&output.styles, &canonical_filename, global); + Self { + code: output.code, + map: output.map, + css_file: output.css_file, + dependencies: output.dependencies, + updated_base_style: updated_base_style || default_collected, + css: { + if !collected && !default_collected { + None + } else { + Some(sheet.create_css( + if global { + None + } else { + Some(&canonical_filename) + }, + options.import_main_css, + )) + } + }, + } + }) + } +} + +/// Extract a `(filename, code)` source with an optional borrowed host resolver. +pub fn code_extract( + source: (&str, &str), + option: ExtractOption, + mode: ExtractionMode<'_>, +) -> Result { + let (filename, code) = source; + let css = CssOptions { + single_css: option.single_css, + import_main_css: mode.import_main_css_in_css, + }; + let extracted = match (mode.resolver, mode.source_map) { + (Some(resolver), source_map) => extract_with_modules( + filename, + code, + option, + matches!(source_map, SourceMapMode::Generate), + resolver, + ), + (None, SourceMapMode::Generate) => extract(filename, code, option), + (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), + }; + if let Some(check) = mode.resolver_check { + check()?; + } + match extracted { + Ok(output) => Ok(Output::new(output, filename.to_string(), css)), + Err(error) => Err(error.to_string()), + } +} diff --git a/libs/binding/src/reservation.rs b/libs/binding/src/reservation.rs new file mode 100644 index 000000000..75ee97e2f --- /dev/null +++ b/libs/binding/src/reservation.rs @@ -0,0 +1,84 @@ +//! Rule-free reservation through the real extractor and sheet allocation path. + +use bimap::BiHashMap; +use css::{file_map, slot_reservation::ScratchSlots}; +use extractor::reservation_state::ScratchExtractionState; +use extractor::{ExtractOption, ModuleResolver, extract_with_modules, extract_without_source_map}; +use sheet::StyleSheet; + +use crate::with_style_sheet_mut; + +/// Prepared physical input, keyed exactly as its later real transform. +pub struct ReservationInput { + /// Physical source path. + pub filename: String, + /// Source contents. + pub code: String, + /// CSS output directory for this source. + pub css_dir: String, +} + +struct ScratchSheet { + live: StyleSheet, + files: BiHashMap, +} + +impl Default for ScratchSheet { + fn default() -> Self { + let files = file_map::get_file_map(); + let live = with_style_sheet_mut(|sheet| { + let mut live = std::mem::take(sheet); + sheet.theme = std::mem::take(&mut live.theme); + live + }); + Self { live, files } + } +} + +impl Drop for ScratchSheet { + fn drop(&mut self) { + with_style_sheet_mut(|sheet| { + std::mem::swap(&mut self.live.theme, &mut sheet.theme); + *sheet = std::mem::take(&mut self.live); + }); + file_map::set_file_map(std::mem::take(&mut self.files)); + } +} + +/// Reserve in supplied physical-path order; suppress per-input errors, emitting nothing. +pub fn reserve_class_slots_internal( + inputs: &[ReservationInput], + option: ExtractOption, + resolver: Option<&ModuleResolver>, +) { + let _atom_mode = css::atom_hoist::AtomModeGuard::new(option.single_css); + let slots = ScratchSlots::default(); + let files: Vec = inputs.iter().map(|input| input.filename.clone()).collect(); + file_map::seed_file_numbers(&files); + for input in inputs { + let _ = file_map::get_file_num_by_filename(&input.filename); + } + let _sheet = ScratchSheet::default(); + let _extraction = ScratchExtractionState::default(); + for input in inputs { + let file_option = ExtractOption { + css_dir: input.css_dir.clone(), + ..option.clone() + }; + let extracted = match resolver { + Some(resolver) => { + extract_with_modules(&input.filename, &input.code, file_option, false, resolver) + } + None => extract_without_source_map(&input.filename, &input.code, file_option), + }; + if let Ok(output) = extracted { + let bucket = file_map::canonical(&input.filename); + let global = option.single_css || file_map::is_global(&input.filename); + with_style_sheet_mut(|sheet| { + sheet.rm_global_css(&input.filename, global); + sheet.update_styles_in_bucket(&output.styles, &bucket, global); + }); + } + } + slots.commit(); +} diff --git a/libs/binding/src/resolver_state.rs b/libs/binding/src/resolver_state.rs new file mode 100644 index 000000000..31d1b6092 --- /dev/null +++ b/libs/binding/src/resolver_state.rs @@ -0,0 +1,154 @@ +use std::collections::{BTreeSet, HashMap}; + +use extractor::ResolvedModule; +use extractor::module_reference::{ModuleReferenceLocation, original_module_reference_locations}; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +struct OriginSite { + filename: String, + location: ModuleReferenceLocation, +} + +#[derive(Clone)] +struct OriginSites { + first: OriginSite, + rest: Vec, +} + +struct ModuleSource { + code: String, + ingress: Option, +} + +/// A callback request with at least one original reference site. +pub struct ResolverRequest { + specifier: String, + importer: String, + origins: OriginSites, +} + +impl ResolverRequest { + pub fn specifier(&self) -> &str { + &self.specifier + } + + pub fn importer(&self) -> &str { + &self.importer + } +} + +#[derive(PartialEq, Eq, PartialOrd, Ord)] +struct ResolverFailure { + site: OriginSite, + specifier: String, + importer: String, + cause: String, +} + +/// Resolver diagnostics and original module sources owned by one extraction. +#[derive(Default)] +pub struct ResolverState { + sources: HashMap, + failures: BTreeSet, +} + +impl ResolverState { + pub fn new(filename: &str, source: &str) -> Self { + Self { + sources: HashMap::from([( + filename.to_string(), + ModuleSource { + code: source.to_string(), + ingress: None, + }, + )]), + failures: BTreeSet::new(), + } + } + + pub fn cache(&mut self, module: &ResolvedModule, request: &ResolverRequest) { + self.sources.insert( + module.path.clone(), + ModuleSource { + code: module.code.clone(), + ingress: Some(request.origins.clone()), + }, + ); + } + + pub fn add_source(&mut self, filename: &str, code: &str) { + self.sources.insert( + filename.to_string(), + ModuleSource { + code: code.to_string(), + ingress: None, + }, + ); + } + + /// Bind original references before calling JS; recursive exploratory reads + /// without a semantic reference retain the real import that loaded the module. + pub fn request(&self, specifier: &str, importer: &str) -> Option { + let source = self.sources.get(importer)?; + let mut sites = original_module_reference_locations(importer, &source.code, specifier) + .into_iter() + .map(|location| OriginSite { + filename: importer.to_string(), + location, + }); + let origins = match sites.next() { + Some(first) => OriginSites { + first, + rest: sites.collect(), + }, + None => source.ingress.clone()?, + }; + Some(ResolverRequest { + specifier: specifier.to_string(), + importer: importer.to_string(), + origins, + }) + } + + pub fn record(&mut self, request: &ResolverRequest, cause: &str) { + for site in std::iter::once(&request.origins.first).chain(&request.origins.rest) { + self.failures.insert(ResolverFailure { + site: site.clone(), + specifier: request.specifier.clone(), + importer: request.importer.clone(), + cause: cause.to_string(), + }); + } + } + + pub fn check(&self) -> Result<(), String> { + if self.failures.is_empty() { + Ok(()) + } else { + Err(self.failures.iter().map(|failure| { + let OriginSite { filename, location } = &failure.site; + format!( + "{filename}:{}:{}: module resolver failed for `{}` from `{}`: {}\nFix: repair the module resolver; return {{path:string,code:string}} with a non-empty path, or null/undefined for an unresolved module.", + location.line, location.column, failure.specifier, failure.importer, failure.cause, + ) + }).collect::>().join("\n")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reservation_root_storage_cardinality() { + // Given: retain main's private assertions; finite public probes miss ghost sources. + let mut state = ResolverState::default(); + assert_eq!(state.sources.len(), 0); + // When + state.add_source("first.ts", "import './shared';"); + state.add_source("second.ts", "\nimport './shared';"); + // Then + assert_eq!(state.sources.len(), 2); + } +} diff --git a/libs/binding/tests/css.rs b/libs/binding/tests/css.rs new file mode 100644 index 000000000..fd9f2d9a2 --- /dev/null +++ b/libs/binding/tests/css.rs @@ -0,0 +1,47 @@ +use devup_ui_binding_core::{ExtractionMode, SourceMapMode}; +use extractor::ExtractOption; + +#[test] +fn numbered_css_selects_its_file_when_other_files_exist() -> Result<(), String> { + // Given: two independently emitted files with explicit, nonconsecutive numbers. + devup_ui_binding_core::reset_state_internal(|| {}); + devup_ui_binding_core::import_file_map_internal( + [("red.tsx".to_string(), 7), ("blue.tsx".to_string(), 3)] + .into_iter() + .collect(), + ); + let option = || ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "df".to_string(), + ..ExtractOption::default() + }; + let mode = || ExtractionMode { + source_map: SourceMapMode::Skip, + resolver: None, + resolver_check: None, + import_main_css_in_css: false, + }; + let red = devup_ui_binding_core::code_extract( + ( + "red.tsx", + "import { Box } from '@devup-ui/react'; export const view = ;", + ), + option(), + mode(), + )?; + devup_ui_binding_core::code_extract( + ( + "blue.tsx", + "import { Box } from '@devup-ui/react'; export const view = ;", + ), + option(), + mode(), + )?; + + // When: the host requests the red file by its imported number. + let selected = devup_ui_binding_core::get_css(Some(7), false); + + // Then: only the same file CSS returned by that transform is selected. + assert_eq!(Some(selected), red.css); + Ok(()) +} diff --git a/libs/extractor/src/barrel.rs b/libs/extractor/src/barrel.rs index f2af93fa0..b56784bbf 100644 --- a/libs/extractor/src/barrel.rs +++ b/libs/extractor/src/barrel.rs @@ -413,10 +413,11 @@ impl Walker<'_, '_> { if !seen.insert(module.path.clone()) { return false; } - let sources = module - .named - .values() - .filter_map(|link| match link { + let mut named: Vec<_> = module.named.iter().collect(); + named.sort_unstable_by(|left, right| left.0.cmp(right.0)); + let sources = named + .into_iter() + .filter_map(|(_, link)| match link { Link::From { source, .. } => Some(source), Link::Own => None, }) @@ -1132,3 +1133,75 @@ mod tests; #[cfg(test)] mod scope_tests; + +#[cfg(test)] +mod order_tests { + use super::*; + use std::cell::RefCell; + + #[test] + fn namespace_stops_at_lexical_first_package_export_when_names_are_unordered() { + // Given beta is declared first and alpha reaches the package through left. + let calls = Rc::new(RefCell::new(Vec::new())); + let observed = Rc::clone(&calls); + let resolver = move |source: &str, importer: &str| { + observed + .borrow_mut() + .push((source.to_string(), importer.to_string())); + Some(ResolvedModule { + path: "src/left.ts".into(), + code: "export { Box } from '@devup-ui/react';".into(), + }) + }; + let module = analyze( + &ResolvedModule { + path: "src/namespace.ts".into(), + code: "export { Box as beta } from './right'; export const own = 1; export { Box as alpha } from './left'; export * from './star';".into(), + }, + "@devup-ui/react", + ); + let mut walker = Walker { + resolver: &resolver, + package: "@devup-ui/react", + modules: FxHashMap::default(), + dependencies: BTreeSet::new(), + }; + // When namespace discovery walks the real analyzed exports. + let found = walker.touches_package(&module, &mut FxHashSet::default()); + // Then it never calls beta or the trailing star after alpha succeeds. + assert!(found); + assert_eq!( + *calls.borrow(), + [("./left".into(), "src/namespace.ts".into())] + ); + } + + #[test] + fn namespace_keeps_star_source_order_when_named_exports_do_not_reach_package() { + // Given an own export, an unresolved named link and two source-ordered stars. + let calls = Rc::new(RefCell::new(Vec::new())); + let observed = Rc::clone(&calls); + let resolver = move |source: &str, _: &str| { + observed.borrow_mut().push(source.to_string()); + None + }; + let module = analyze( + &ResolvedModule { + path: "src/namespace.ts".into(), + code: "export const own = 1; export { x as alpha } from './missing'; export * from './z'; export * from './a';".into(), + }, + "@devup-ui/react", + ); + let mut walker = Walker { + resolver: &resolver, + package: "@devup-ui/react", + modules: FxHashMap::default(), + dependencies: BTreeSet::new(), + }; + // When every followed link fails to reach the package. + let found = walker.touches_package(&module, &mut FxHashSet::default()); + // Then named links precede stars, whose source order is unchanged. + assert!(!found); + assert_eq!(*calls.borrow(), ["./missing", "./z", "./a"]); + } +} diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 7a08e1f29..ff5d0c79d 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -46,6 +46,8 @@ mod px_tests; #[path = "imported_css_root.rs"] mod imported_css_root; +mod change_presence; + #[derive(Clone, Debug)] enum Constant { String(String), @@ -169,7 +171,11 @@ impl Constant { fn change(&self) -> Option> { match self { Self::Changed(change) => Some(change.clone()), - Self::Object(object) => object.values().find_map(Self::change), + Self::Object(object) => object + .iter() + .filter_map(|(key, value)| value.change().map(|change| (key, change))) + .min_by(|left, right| left.0.cmp(right.0)) + .map(|(_, change)| change), Self::Record(entries) => entries.iter().find_map(|(_, value)| value.change()), Self::Array(values) => values.iter().find_map(Self::change), _ => None, @@ -443,7 +449,7 @@ impl Changed { None => return false, } } - value.change().is_some() + value.has_change() } /// The changes of the bindings `message` names, by the name the module @@ -750,7 +756,9 @@ fn inline_in<'a>( } } let reads_math = names.contains("Math") && !scope.binds("Math"); - for name in &names { + let mut sorted_names: Vec<&String> = names.iter().collect(); + sorted_names.sort_unstable(); + for name in sorted_names { let bound = scope.binds(name); let constant = scope.lookup(&mut modules, name); match &constant { @@ -758,7 +766,7 @@ fn inline_in<'a>( inlined.changed.whole.insert(name.clone(), change.clone()); continue; } - Some(constant) if constant.change().is_some() => { + Some(constant) if constant.has_change() => { inlined .changed .holding @@ -974,7 +982,7 @@ impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> { scope.change(&mut modules, name).is_some() || scope .lookup(&mut modules, name) - .is_some_and(|value| value.change().is_some()) + .is_some_and(|value| value.has_change()) } } @@ -3147,3 +3155,51 @@ mod package_boundary_tests; mod scope_tests; #[cfg(test)] mod stylex_scope_tests; + +#[cfg(test)] +mod order_tests { + use super::*; + + #[test] + fn nested_change_uses_lowest_object_key_when_multiple_children_are_changed() { + // Given object keys differ from change names and alpha holds a nested array. + let alpha = Rc::new(Change { + name: "zeta".into(), + site: ChangeSite::Here(3), + handed: false, + }); + let beta = Rc::new(Change { + name: "beta".into(), + site: ChangeSite::Here(4), + handed: false, + }); + let object = Constant::Object(Rc::new(FxHashMap::from_iter([ + ("beta".into(), Constant::Changed(beta)), + ( + "alpha".into(), + Constant::Array(Rc::new(vec![ + Constant::Null, + Constant::Changed(alpha.clone()), + ])), + ), + ("aaa".into(), Constant::Null), + ]))); + // When selecting the namespace's retained nested change. + let selected = object.change(); + // Then the lowest key holding a change wins, not the change name or hash order. + assert!(selected.is_some_and(|selected| Rc::ptr_eq(&selected, &alpha))); + } + + #[test] + fn nested_change_is_absent_when_object_children_are_unchanged() { + // Given an object whose descendants hold no mutation. + let object = Constant::Object(Rc::new(FxHashMap::from_iter([ + ("alpha".into(), Constant::Null), + ("beta".into(), Constant::Array(Rc::new(vec![]))), + ]))); + // When selecting a retained change. + let selected = object.change(); + // Then there is no diagnostic origin to select. + assert!(selected.is_none()); + } +} diff --git a/libs/extractor/src/imported_constants/change_presence.rs b/libs/extractor/src/imported_constants/change_presence.rs new file mode 100644 index 000000000..b2fc5ba4c --- /dev/null +++ b/libs/extractor/src/imported_constants/change_presence.rs @@ -0,0 +1,133 @@ +use super::Constant; + +impl Constant { + /// Check existence without selecting or retaining a diagnostic origin. + pub(super) fn has_change(&self) -> bool { + match self { + Self::Changed(_) => true, + Self::Object(object) => object.values().any(Self::has_change), + Self::Record(entries) => entries.iter().any(|(_, value)| value.has_change()), + Self::Array(values) => values.iter().any(Self::has_change), + Self::String(_) + | Self::Number(_) + | Self::Null + | Self::Bool(_) + | Self::Undefined + | Self::Function + | Self::Vars(_) + | Self::Theme(_) + | Self::Style(_) + | Self::Failed(_) + | Self::Unavailable(_) => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::super::{ChainReason, Change, ChangeSite, Constant}; + use rustc_hash::FxHashMap; + use std::rc::Rc; + + #[test] + fn presence_is_absent_when_leaf_variants_hold_no_change() { + // Given: every non-container variant without a retained change. + let leaves = [ + Constant::String("value".into()), + Constant::Number(1.0), + Constant::Null, + Constant::Bool(true), + Constant::Undefined, + Constant::Function, + Constant::Vars(Rc::new(FxHashMap::default())), + Constant::Theme("theme".into()), + Constant::Style(None), + Constant::Style(Some(Rc::new(vec![]))), + Constant::Failed("failure".into()), + Constant::Unavailable(ChainReason::Cycle), + Constant::Unavailable(ChainReason::Module("module".into())), + Constant::Unavailable(ChainReason::Export("export".into())), + Constant::Unavailable(ChainReason::Constant), + Constant::Unavailable(ChainReason::Static), + ]; + for leaf in leaves { + // When: asking only for change presence. + let present = leaf.has_change(); + // Then: no leaf invents an origin. + assert!(!present, "{leaf:?}"); + assert_eq!(present, leaf.change().is_some()); + } + } + + #[test] + fn presence_is_absent_when_nested_containers_have_no_changed_descendant() { + // Given: empty and populated containers nested through every container kind. + let empty = [ + Constant::Object(Rc::new(FxHashMap::default())), + Constant::Record(Rc::new(vec![])), + Constant::Array(Rc::new(vec![])), + ]; + for value in empty { + let nested = Constant::Object(Rc::new(FxHashMap::from_iter([( + "outer".into(), + Constant::Record(Rc::new(vec![( + "inner".into(), + Constant::Array(Rc::new(vec![Constant::Null, value])), + )])), + )]))); + // When: checking a fully unchanged tree. + let present = nested.has_change(); + // Then: recursive presence agrees with absent diagnostic selection. + assert!(!present); + assert_eq!(present, nested.change().is_some()); + } + } + + #[test] + fn presence_is_true_when_any_container_holds_a_changed_descendant() { + // Given: multiple origins, with a direct change and each container nesting. + let alpha = Rc::new(Change { + name: "zeta".into(), + site: ChangeSite::Here(3), + handed: false, + }); + let beta = Rc::new(Change { + name: "alpha".into(), + site: ChangeSite::Here(4), + handed: false, + }); + let changed = Constant::Changed(alpha.clone()); + let cases = [ + changed.clone(), + Constant::Object(Rc::new(FxHashMap::from_iter([ + ("beta".into(), Constant::Changed(beta)), + ( + "alpha".into(), + Constant::Array(Rc::new(vec![changed.clone()])), + ), + ]))), + Constant::Record(Rc::new(vec![ + ("empty".into(), Constant::Null), + ("changed".into(), changed.clone()), + ])), + Constant::Array(Rc::new(vec![ + Constant::Null, + Constant::Record(Rc::new(vec![("nested".into(), changed)])), + ])), + ]; + for value in cases { + let references = Rc::strong_count(&alpha); + // When: checking presence without selecting or owning an origin. + let present = value.has_change(); + // Then: existence agrees, ownership is untouched, lexical selection survives. + assert!(present); + assert_eq!(Rc::strong_count(&alpha), references); + assert_eq!(present, value.change().is_some()); + assert!( + value + .change() + .is_some_and(|origin| Rc::ptr_eq(&origin, &alpha)) + ); + } + } +} diff --git a/package.json b/package.json index d36fec1f8..29d815322 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,17 @@ "scripts": { "lint": "cargo fmt --all -- --check && cargo clippy --all-targets --all-features -- -D warnings && node --require ./eslint-typescript6.cjs ./node_modules/eslint/bin/eslint.js", "lint:fix": "node --require ./eslint-typescript6.cjs ./node_modules/eslint/bin/eslint.js --fix && cargo fmt", - "test": "cargo tarpaulin --out xml --out stdout --all-targets --engine llvm --timeout 180 $(node -p \"process.env.CI ? '--fail-under 100' : ''\") && bun run test:bun", + "test": "node e2e/binding/native-coverage/run.mjs && bun run test:bun", "test:bun": "node -e \"require('node:fs').rmSync('coverage/root', { recursive: true, force: true })\" && bun test --coverage && bun run test:vite", "test:vite": "node -e \"require('node:fs').rmSync('coverage/vite', { recursive: true, force: true })\" && bun --config=./bunfig.vite.toml test --coverage", "test:e2e": "bunx playwright test", "test:e2e:next": "LANDING_BUILD_MODE=next bunx playwright test", "test:e2e:rsc-css": "bunx playwright test -c playwright.rsc-css.config.ts", "test:release": "node e2e/release/run.mjs", + "build:native:artifact": "node e2e/binding/package/provenance.mjs", + "pack:native": "node e2e/binding/package/collect.mjs", + "test:native:package": "node --test e2e/binding/package/package.test.mjs e2e/binding/package/publish.test.mjs e2e/binding/package/elf.test.mjs", + "test:native:consumer": "node e2e/binding/package/consumer.mjs", "test:e2e:ui": "bunx playwright test --ui", "test:e2e:update": "bunx playwright test --update-snapshots", "build": "bun run --filter @devup-ui/wasm --filter @devup-ui/plugin-utils build && bun run --filter @devup-ui/react --filter @devup-ui/webpack-plugin build && bun run --filter @devup-ui/eslint-plugin --filter @devup-ui/vite-plugin --filter @devup-ui/next-plugin --filter @devup-ui/rsbuild-plugin --filter @devup-ui/bun-plugin --filter @devup-ui/components --filter @devup-ui/reset-css build",