diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95137c5f1..5dd10076d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -622,6 +622,8 @@ jobs: if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }} name: test (tsgo and tsc-ts give the same IR) runs-on: ubuntu-24.04 + # The two corpus compiles run back to back and take close to 30 minutes on a + # slow hosted runner, so a 30-minute limit cancels otherwise healthy runs. timeout-minutes: 60 steps: - uses: actions/checkout@v4 @@ -716,8 +718,9 @@ jobs: # Shard 1 additionally runs package/install and link-parity contracts # before its LLVM differential slice. The slice itself can take ~10 # minutes on a cold hosted runner, so 20 minutes cancels an otherwise - # healthy job before the differential completes. - timeout-minutes: 35 + # healthy job before the differential completes. Slow runners need more + # headroom than that, so the limit is 50 minutes. + timeout-minutes: 50 strategy: fail-fast: false matrix: diff --git a/.github/workflows/prune-caches.yml b/.github/workflows/prune-caches.yml new file mode 100644 index 000000000..1509d56ce --- /dev/null +++ b/.github/workflows/prune-caches.yml @@ -0,0 +1,41 @@ +name: Prune caches + +# Pull request caches are scoped to that pull request, so once it is closed no +# run can use them again. Deleting them keeps the repository under its cache +# size limit, so caches that main and open pull requests use are not evicted. +on: + pull_request: + types: [closed] + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + actions: write + pull-requests: read + +jobs: + prune: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Delete caches of closed pull requests + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + # Read both lists in full before deleting anything: deleting while the cache + # listing pages shifts later pages, and a failed request must stop the job + # instead of leaving open pull requests out of the list. + open=$(gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" --jq '.[].number') + caches=$(gh api --paginate "repos/$REPO/actions/caches?per_page=100" \ + --jq '.actions_caches[] | select(.ref | startswith("refs/pull/")) | "\(.id) \(.ref)"') + while read -r id ref; do + [ -n "$id" ] || continue + number=$(echo "$ref" | cut -d/ -f3) + if ! grep -qx "$number" <<<"$open"; then + gh api --method DELETE "repos/$REPO/actions/caches/$id" >/dev/null + echo "deleted cache $id ($ref)" + fi + done <<<"$caches" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fff1931ae..799337e0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,10 +100,59 @@ jobs: if (receiptRun) appendFileSync(process.env.GITHUB_OUTPUT, `receipt_run=${receiptRun}\n`); NODE - build-native-packages: - name: Build native package (${{ matrix.platform }}) + # Collects the native compiler's profile on this commit + # (scripts/refresh-cli-profile.mjs), so every platform's CLI build optimizes + # with a profile of the code it compiles. The committed profile falls behind + # main within a day or two. If this job fails, the CLI builds use the + # committed profile. + cli-profile: + name: Collect the native compiler profile needs: check-release if: needs.check-release.outputs.should_release == 'true' && needs.check-release.outputs.reuse_artifacts != 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + continue-on-error: true + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.1.3 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: pnpm + - uses: ./.github/actions/llvm-linux + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/zig + - uses: ./.github/actions/native-packages + with: + target: linux-x64-gnu + - run: pnpm --filter @scriptc/compiler --filter scriptc build + - name: Check out tsc-ts + run: | + git clone --quiet --depth 1 https://github.com/vercel-labs/tsc-ts "$RUNNER_TEMP/tsc-ts" + echo "tsc-ts $(git -C "$RUNNER_TEMP/tsc-ts" rev-parse HEAD)" + npm ci --prefix "$RUNNER_TEMP/tsc-ts" + - name: Collect the profile + run: >- + node scripts/refresh-cli-profile.mjs --tsc-ts "$RUNNER_TEMP/tsc-ts" --jobs 4 + --output "$RUNNER_TEMP/cli-profile/cli.profdata.gz" + - uses: actions/upload-artifact@v4 + with: + name: cli-profile + path: ${{ runner.temp }}/cli-profile/cli.profdata.gz + retention-days: 7 + if-no-files-found: error + + build-native-packages: + name: Build native package (${{ matrix.platform }}) + needs: [check-release, cli-profile] + # Runs when the profile job fails; the CLI builds then use the committed profile. + if: >- + !cancelled() && needs.check-release.outputs.should_release == 'true' && + needs.check-release.outputs.reuse_artifacts != 'true' strategy: fail-fast: false matrix: @@ -243,6 +292,28 @@ jobs: - name: Build host helper for cross-libc bootstrap if: matrix.seed_helper != '' run: pnpm --filter @scriptc/${{ matrix.seed_helper }} build:native + - name: Download the profile collected on this commit + id: cli-profile + if: matrix.cli != '' + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: cli-profile + path: ${{ runner.temp }}/cli-profile + # Without the artifact, SCRIPTC_NATIVE_PROFILE_USE stays unset and + # build-native-cli.mts uses the committed profile. + - name: Select the native compiler profile + if: matrix.cli != '' + shell: bash + env: + COLLECTED: ${{ steps.cli-profile.outcome == 'success' && format('{0}/cli-profile/cli.profdata.gz', runner.temp) || '' }} + run: | + if [ -n "$COLLECTED" ]; then + echo "SCRIPTC_NATIVE_PROFILE_USE=$COLLECTED" >> "$GITHUB_ENV" + echo "The native CLI builds with the profile collected on $GITHUB_SHA" + else + echo "::warning::No profile was collected on this commit; the native CLI builds with the committed profile" + fi - name: Build native CLI distribution if: matrix.cli != '' env: @@ -558,6 +629,21 @@ jobs: - name: Package standalone compilers run: node scripts/package-native-cli.mjs packages /tmp/scriptc-release-assets + # The profile the native CLIs were built with. Runs that reuse native + # builds from before the cli-profile job, or whose profile job failed, + # have none; their CLIs used the committed profile. + - name: Download the native compiler profile + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN: ${{ needs.check-release.outputs.artifact_run }} + VERSION: ${{ needs.check-release.outputs.version }} + run: | + if gh run download "$RUN" --repo "$GITHUB_REPOSITORY" --name cli-profile --dir "$RUNNER_TEMP/cli-profile"; then + mv "$RUNNER_TEMP/cli-profile/cli.profdata.gz" "/tmp/scriptc-release-assets/scriptc-$VERSION-cli.profdata.gz" + else + echo "Run $RUN has no cli-profile artifact" + fi + - name: Create GitHub Release run: | VERSION="${{ needs.check-release.outputs.version }}" diff --git a/.gitignore b/.gitignore index b271af8e1..81b92ddd5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules/ !tests/corpus/*/node_modules/ !tests/fixtures/npm-static/node_modules/ !tests/coverage-fixtures/wasm/node_modules/ +!tests/coverage-fixtures/node-islands*/node_modules/ !tests/coverage-fixtures/dynamic-tier/package/node_modules/ !tests/fixtures/npm-static/nested/inner/node_modules/ !tests/fixtures/npm-static/node_modules/*/node_modules/ diff --git a/RELEASING.md b/RELEASING.md index b6c5239ee..42bfe75ad 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -16,7 +16,7 @@ CI (`.github/workflows/release.yml`) compares the version in `packages/cli/packa The release job builds each pinned LLVM helper, precompiled runtime pack, and native CLI on its matching host. The native CLI bundles its TypeScript checker, LLVM helper, runtime pack, declarations, and comptime evaluator. npm postinstall installs the matching native executable; Node is needed for npm installation but not native compilation or execution. The user's toolchain performs the final platform link. Sanitized builds also use it to instrument program LLVM and compile the C runtime. Platform packages publish ahead of `@scriptc/compiler` and `scriptc`. The GitHub release attaches standalone compiler archives, their SHA-256 checksums, and the surface manifest after npm publication succeeds. -Release builds of the native CLI optimize the compiler with the committed profile `packages/compiler/native/cli.profdata` on every platform. Code the profile does not describe, or describes from older compiler sources, is optimized as without a profile. Refresh the profile on a quiet Linux x64 host when compiler code has changed substantially since the last refresh: `node scripts/refresh-cli-profile.mjs --tsc-ts ` builds an instrumented compiler, runs it on tsc-ts, a slice of the corpus, and the runtime benchmark workloads, and writes the merged sparse profile. Commit the new profile on its own. +Release builds of the native CLI optimize the compiler with a profile collected on the release commit. The `cli-profile` job runs `node scripts/refresh-cli-profile.mjs` on Linux x64, which builds an instrumented compiler, runs it on tsc-ts, a slice of the corpus, and the runtime benchmark workloads, and writes the merged sparse profile. Every platform's CLI build uses that profile (`SCRIPTC_NATIVE_PROFILE_USE`), and the GitHub release attaches it as `scriptc--cli.profdata.gz`. Code the profile does not describe, or describes from older compiler sources, is optimized as without a profile. When the profile job fails, the CLI builds use the committed profile `packages/compiler/native/cli.profdata.gz`, which local and CI builds of the native compiler also use. Refresh the committed profile when compiler code has changed substantially since the last refresh: run `node scripts/refresh-cli-profile.mjs --tsc-ts ` on a quiet Linux x64 host and commit the new profile on its own. Publishing uses npm trusted publishing (OIDC) — there is no npm token secret. Each published package must have a GitHub Actions trusted publisher for diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 03238871d..520964cf3 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -10,6 +10,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `atomics-contended` | cpu | four worker threads updating `SharedArrayBuffer` counters with `Atomics`: one shared counter, one counter per worker, a `compareExchange` loop, and `Atomics.wait`/`notify` handoffs | | `channel-messages` | cpu | 200,000 MessageChannel messages in one thread, half numbers and half small objects, all posted before the port delivers any | | `csv-numbers` | cpu | `split`, `Number`, `parseInt`, `toFixed` | +| `date-math` | cpu | schedules built with Date setters (local and UTC), weekday and month-end rules through the getters, Dates sorted by time value | | `cyclic-graphs` | cpu | object graphs with back references built and dropped: trees with parent and sibling links, rings of records sharing a long-lived registry, closures capturing their owner | | `dictionary-walks` | cpu | `Record` dictionaries walked with for-in and `Object.keys`/`values`/`entries` while entries are added, updated and deleted | | `dynamic-methods` | cpu | untyped JavaScript calling builtin methods on values it does not type: string `slice`/`indexOf`/`toUpperCase`/`padStart`, array `push`/`pop`/`includes`, `Map` and `Set`, and an object's own methods | @@ -19,6 +20,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation | | `json-unions` | cpu | `JSON.parse` into discriminated unions: an event log with eight kinds and nested expression trees with nine, evaluated after the cast | | `keyed-option-writes` | cpu | class methods whose names are also keys of option records written through element accesses: `enabled[name] = true` over a union of literal names and `totals["count"] = n` | +| `line-reader` | cpu | `readline` over a file stream: 'line' events for a 67 MB access log (every 50th line not ASCII), with a status count, a path tally and byte totals | | `log-lines` | cpu | 200k immediate `console.log`/`process.stdout.write` lines into the runner's pipe | | `log-summary`, `inventory-report` | cpu | the multi-module applications from `benchmarks/builds`, on 300k-line inputs | | `number-maps` | cpu | number-keyed Map/Set: a dense-id table, sparse ids in many small maps, get/has/set mixes | @@ -30,10 +32,12 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `regex-logs` | cpu | regex `exec` with groups, `test`, global `replace` | | `region-builds` | cpu | a watch-mode build loop over `LifetimeRegion`: each build binds a program in a new region and releases the previous one, which a cache keeps referenced for two builds | | `template-render` | cpu | template literals, escaping, `map`/`join` | +| `throw-chains` | cpu | exceptions caught 2, 5 and 20 recursive calls above the throw, through direct calls and through a callback, and a backtracking parser that throws on a failed alternative | | `thread-slots`, `thread-slots-cache` | cpu | worker threads reading one published type graph from an `InternTable` and keeping per-thread state for each type they visit: a `ThreadSlots` family, or a direct-mapped cache by id in front of a `Map` by id (the `-cache` entry); under Node.js both are per thread | | `typed-array-kernels` | cpu | element-wise loops that run to one typed array's length and index others at the same position: `Float64Array` axpy, `Float32Array` blend, `Uint8Array` mask, wrapping `Int32Array` add, `Uint8ClampedArray` brighten, RGBA to gray, a row-major `Float32Array` matrix-vector product, and `reduce`/`some`/`map` callbacks | | `typed-array-scan`, `typed-array-scan-workers` | cpu | element-by-element scan loops over `Uint8Array`, `Uint16Array`, `Int32Array` and `Float64Array`; the second entry is a worker-enabled executable (it can start a worker), so each access also checks for shared storage | | `unicode-lexer` | cpu | a hand-written lexer over a 2.7M-unit bundled module whose comments and strings are in several languages: `charCodeAt` scans, line starts | +| `union-node-sets` | cpu | syntax-tree passes over a discriminated union of object literals: a `Set` of visited nodes, a `Map` from node to value, conversions to a sub-union of leaves | | `validate-errors` | cpu | throw/catch on a fraction of inputs, small call chains | | `value-objects` | cpu | small immutable records and class instances: vector helpers, method chains, particle updates | | `value-records` | cpu | records that hold strings, spread state updates and destructured tuples: token helpers, an immutable reducer, tuple returns | diff --git a/benchmarks/runtime/workloads/date-math.json b/benchmarks/runtime/workloads/date-math.json new file mode 100644 index 000000000..b43078582 --- /dev/null +++ b/benchmarks/runtime/workloads/date-math.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/date-math.ts", + "args": [ + "40" + ] +} diff --git a/benchmarks/runtime/workloads/date-math.ts b/benchmarks/runtime/workloads/date-math.ts new file mode 100644 index 000000000..56ec1066e --- /dev/null +++ b/benchmarks/runtime/workloads/date-math.ts @@ -0,0 +1,52 @@ +// Calendar arithmetic on Dates: schedules built by adjusting Dates with the +// UTC and local setters, weekday and month-end rules read back through the +// getters, Dates compared and sorted by time value, and ISO text for a +// sample. Argument: the number of rounds. +process.env.TZ = "America/New_York"; + +const rounds = Number(process.argv[2] ?? "40"); +let seed = 2026; +function random(): number { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + return seed / 4294967296; +} + +/** The next business day at 09:00 local time on or after `from`. */ +function nextBusinessDay(from: Date): Date { + const day = new Date(from.getTime()); + day.setHours(9, 0, 0, 0); + if (day < from) day.setDate(day.getDate() + 1); + while (day.getDay() === 0 || day.getDay() === 6) day.setDate(day.getDate() + 1); + return day; +} + +/** The last day of `date`'s month, in UTC. */ +function monthEnd(date: Date): number { + const end = new Date(date.getTime()); + end.setUTCDate(1); + end.setUTCMonth(end.getUTCMonth() + 1); + end.setUTCDate(0); + return end.getUTCDate(); +} + +let checksum = 0; +let sample = ""; +for (let round = 0; round < rounds; round++) { + const due: Date[] = []; + const cursor = new Date(Date.UTC(2020 + (round % 7), 0, 1, 12)); + for (let i = 0; i < 4000; i++) { + cursor.setUTCMinutes(cursor.getUTCMinutes() + Math.floor(random() * 4000)); + const next = nextBusinessDay(cursor); + checksum += next.getDate() + monthEnd(next) + next.getHours(); + if (i % 3 === 0) next.setMonth(next.getMonth() + 1, 15); + due.push(next); + } + due.sort((a, b) => +a - +b); + let previous = due[0]!; + for (const date of due) { + checksum += (+date - +previous) / 3600000; + previous = date; + } + sample = due[due.length >> 1]!.toISOString(); +} +console.log(Math.round(checksum), sample); diff --git a/benchmarks/runtime/workloads/line-reader.json b/benchmarks/runtime/workloads/line-reader.json new file mode 100644 index 000000000..85194f70c --- /dev/null +++ b/benchmarks/runtime/workloads/line-reader.json @@ -0,0 +1,7 @@ +{ + "entry": "workloads/line-reader.ts", + "input": "line-reader", + "args": [ + "{input}" + ] +} diff --git a/benchmarks/runtime/workloads/line-reader.ts b/benchmarks/runtime/workloads/line-reader.ts new file mode 100644 index 000000000..cbdf384bb --- /dev/null +++ b/benchmarks/runtime/workloads/line-reader.ts @@ -0,0 +1,34 @@ +// Line-by-line processing of a log file with readline over a file stream: +// 'line' events for about 67 MB of access-log lines, with light work per +// line (a status count, a path tally, byte totals). +import { createReadStream } from "node:fs"; +import { createInterface } from "node:readline"; + +const path = process.argv[2]!; +const statuses = new Map(); +const paths = new Map(); +let lines = 0; +let chars = 0; +let bytes = 0; +let longest = 0; + +const input = createInterface({ input: createReadStream(path), crlfDelay: Infinity }); +input.on("line", (line: string) => { + lines++; + chars += line.length; + if (line.length > longest) longest = line.length; + const quote = line.indexOf('"'); + const space = line.indexOf(" ", quote); + const path = line.slice(space + 1, line.indexOf("/", line.indexOf("/", space) + 1)); + paths.set(path, (paths.get(path) ?? 0) + 1); + const close = line.indexOf('"', space); + const status = line.slice(close + 2, close + 5); + statuses.set(status, (statuses.get(status) ?? 0) + 1); + bytes += Number(line.slice(close + 6, line.indexOf(" ", close + 6))); +}); +input.on("close", () => { + console.log(`${lines} lines, ${chars} characters, longest ${longest}`); + console.log(`bytes ${bytes}`); + for (const status of [...statuses.keys()].sort()) console.log(`status ${status}: ${statuses.get(status)}`); + for (const prefix of [...paths.keys()].sort()) console.log(`path ${prefix}: ${paths.get(prefix)}`); +}); diff --git a/benchmarks/runtime/workloads/throw-chains.json b/benchmarks/runtime/workloads/throw-chains.json new file mode 100644 index 000000000..79cf5ea87 --- /dev/null +++ b/benchmarks/runtime/workloads/throw-chains.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/throw-chains.ts", + "args": [ + "1" + ] +} diff --git a/benchmarks/runtime/workloads/throw-chains.ts b/benchmarks/runtime/workloads/throw-chains.ts new file mode 100644 index 000000000..c96816aed --- /dev/null +++ b/benchmarks/runtime/workloads/throw-chains.ts @@ -0,0 +1,196 @@ +// Exceptions that cross many frames: throws from helpers below a recursion, +// caught 2, 5 and 20 recursive calls up (direct calls, then calls through a +// callback), with each frame holding an array across its call, and a +// backtracking parser that uses exceptions for control flow. +class ParseError extends Error { + readonly at: number; + constructor(at: number, message: string) { + super(message); + this.at = at; + } +} + +/** A thrown value that is not an Error (no stack to capture). */ +class Stop { + readonly code: number; + constructor(code: number) { + this.code = code; + } +} + +const held: number[][] = []; +function fail(n: number, plain: boolean): number { + if (plain) throw new Stop(n); + throw new RangeError("chain " + n); +} +function check(n: number, plain: boolean): number { + return (n & 3) === 0 ? fail(n, plain) : n; +} +function validate(n: number, plain: boolean): number { + return check(n, plain) + (n & 1); +} +function bottom(n: number, plain: boolean): number { + const box = [n, -1]; + const r = validate(n, plain); + held[0] = box; + return r + box.length; +} +function walk(n: number, depth: number, plain: boolean): number { + const box = [n, depth]; + const r = depth === 0 ? bottom(n, plain) : walk(n, depth - 1, plain); + held[depth] = box; + return r + box.length; +} + +function chains(rounds: number): string { + const out: string[] = []; + for (const depth of [2, 5, 20]) + for (const plain of [false, true]) { + let caught = 0; + let sum = 0; + for (let i = 0; i < rounds; i++) { + try { + sum += walk(i, depth - 2, plain); + } catch (error) { + caught++; + sum += error instanceof Stop ? error.code & 7 : 1; + } + } + out.push(`depth ${depth} ${plain ? "object" : "error"}: caught ${caught} sum ${sum}`); + } + return out.join("\n"); +} + +// The same chains through a callback: each level visits the next with a +// closure, as tree visitors do. +function visit(n: number, depth: number, each: (n: number, depth: number) => number): number { + const box = [n, depth]; + const r = depth === 0 ? bottom(n, false) : each(n, depth - 1); + held[depth] = box; + return r + box.length; +} +function visits(rounds: number): string { + const out: string[] = []; + const each = (n: number, depth: number): number => visit(n, depth, each); + for (const depth of [2, 5, 20]) { + let caught = 0; + let sum = 0; + for (let i = 0; i < rounds; i++) { + try { + sum += visit(i, depth - 2, each); + } catch (error) { + caught++; + sum += error instanceof RangeError ? 1 : 0; + } + } + out.push(`visit depth ${depth}: caught ${caught} sum ${sum}`); + } + return out.join("\n"); +} + +// expr := term (("+" | "-") term)*; term := atom ("*" atom)*; +// atom := number | "(" expr ")" | name "(" expr ("," expr)* ")" | name. +// A call and a name start alike: the parser tries the call first and +// backtracks when it fails. +let text = ""; +let pos = 0; +function peek(): string { + return pos < text.length ? text[pos]! : ""; +} +function expect(c: string): void { + if (peek() !== c) throw new ParseError(pos, `expected ${c}`); + pos++; +} +function name(): string { + const start = pos; + while (pos < text.length && text.charCodeAt(pos) >= 97 && text.charCodeAt(pos) <= 122) pos++; + if (pos === start) throw new ParseError(pos, "expected a name"); + return text.slice(start, pos); +} +function call(): number { + const callee = name(); + expect("("); + let value = expr() + callee.length; + while (peek() === ",") { + pos++; + value += expr(); + } + expect(")"); + return value; +} +function atom(): number { + const c = peek(); + if (c >= "0" && c <= "9") { + let value = 0; + while (peek() >= "0" && peek() <= "9") value = value * 10 + (text.charCodeAt(pos++) - 48); + return value; + } + if (c === "(") { + pos++; + const value = expr(); + expect(")"); + return value; + } + const start = pos; + try { + return call(); + } catch (error) { + if (!(error instanceof ParseError)) throw error; + pos = start; + return name().length; + } +} +function term(): number { + let value = atom(); + while (peek() === "*") { + pos++; + value = (value * atom()) % 1000003; + } + return value; +} +function expr(): number { + let value = term(); + while (peek() === "+" || peek() === "-") { + const op = text[pos++]; + value = op === "+" ? value + term() : value - term(); + } + return value; +} + +let seed = 4242; +function random(): number { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + return seed / 4294967296; +} +function source(depth: number): string { + const r = random(); + if (depth > 6 || r < 0.25) return String(Math.floor(random() * 100)); + if (r < 0.45) return "abcdefgh".slice(0, 1 + Math.floor(random() * 8)); + if (r < 0.6) return `(${source(depth + 1)})`; + if (r < 0.8) return `${source(depth + 1)}${random() < 0.5 ? "+" : "*"}${source(depth + 1)}`; + return `f${"xyz".slice(0, Math.floor(random() * 3))}(${source(depth + 1)},${source(depth + 1)})`; +} + +function parse(count: number): string { + let total = 0; + let failed = 0; + for (let i = 0; i < count; i++) { + // Every tenth input lacks its last character, which fails deep inside it. + const full = source(0); + text = i % 10 === 0 ? full.slice(0, -1) : full; + pos = 0; + try { + total = (total + expr()) % 1000003; + if (pos !== text.length) throw new ParseError(pos, "trailing input"); + } catch (error) { + if (!(error instanceof ParseError)) throw error; + failed++; + } + } + return `parsed ${count}: total ${total} failed ${failed}`; +} + +const scale = Number(process.argv[2] ?? "1"); +console.log(chains(Math.floor(60000 * scale))); +console.log(visits(Math.floor(60000 * scale))); +console.log(parse(Math.floor(40000 * scale))); diff --git a/benchmarks/runtime/workloads/union-node-sets.json b/benchmarks/runtime/workloads/union-node-sets.json new file mode 100644 index 000000000..98e9fe2e5 --- /dev/null +++ b/benchmarks/runtime/workloads/union-node-sets.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/union-node-sets.ts", + "args": [ + "1" + ] +} diff --git a/benchmarks/runtime/workloads/union-node-sets.ts b/benchmarks/runtime/workloads/union-node-sets.ts new file mode 100644 index 000000000..16d6f4dfe --- /dev/null +++ b/benchmarks/runtime/workloads/union-node-sets.ts @@ -0,0 +1,141 @@ +// Syntax-tree passes over a discriminated union of object literals, the way +// compilers walk their IR: a DAG of shared subexpressions, a `Set` of +// visited nodes, a `Map` from node to its value, and conversions between +// the whole union and the sub-unions passes narrow it to. +type Num = { kind: "num"; value: number }; +type Var = { kind: "var"; slot: number }; +type Neg = { kind: "neg"; operand: Node }; +type Add = { kind: "add"; left: Node; right: Node }; +type Mul = { kind: "mul"; left: Node; right: Node }; +type Pick = { kind: "pick"; cond: Node; then: Node; else_: Node }; +type Leaf = Num | Var; +type Node = Leaf | Neg | Add | Mul | Pick; + +function children(node: Node, visit: (child: Node) => void): void { + switch (node.kind) { + case "num": + case "var": + return; + case "neg": + visit(node.operand); + return; + case "add": + case "mul": + visit(node.left); + visit(node.right); + return; + case "pick": + visit(node.cond); + visit(node.then); + visit(node.else_); + return; + } +} + +function leafValue(leaf: Leaf, env: number[]): number { + return leaf.kind === "num" ? leaf.value : env[leaf.slot]!; +} + +function evaluate(node: Node, env: number[], memo: Map): number { + const cached = memo.get(node); + if (cached !== undefined) return cached; + let result: number; + switch (node.kind) { + case "num": + case "var": + result = leafValue(node, env); + break; + case "neg": + result = -evaluate(node.operand, env, memo); + break; + case "add": + result = (evaluate(node.left, env, memo) + evaluate(node.right, env, memo)) % 1000003; + break; + case "mul": + result = (evaluate(node.left, env, memo) * evaluate(node.right, env, memo)) % 1000003; + break; + case "pick": + result = + evaluate(node.cond, env, memo) % 2 === 0 + ? evaluate(node.then, env, memo) + : evaluate(node.else_, env, memo); + break; + } + memo.set(node, result); + return result; +} + +function reachable(root: Node): number { + const seen = new Set(); + const stack: Node[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (seen.has(node)) continue; + seen.add(node); + children(node, (child) => { + if (!seen.has(child)) stack.push(child); + }); + } + return seen.size; +} + +function leaves(root: Node): Leaf[] { + const out: Leaf[] = []; + const seen = new Set(); + const walk = (node: Node): void => { + if (seen.has(node)) return; + seen.add(node); + if (node.kind === "num" || node.kind === "var") out.push(node); + else children(node, walk); + }; + walk(root); + return out; +} + +let seed = 2463534242; +function next(): number { + seed ^= seed << 13; + seed ^= seed >>> 17; + seed ^= seed << 5; + return seed >>> 0; +} + +function build(size: number): Node { + const pool: Node[] = []; + for (let i = 0; i < 64; i++) + pool.push(i % 2 === 0 ? { kind: "num", value: i * 7 + 1 } : { kind: "var", slot: i % 16 }); + // Operands come from the most recent nodes, so most of the DAG is + // reachable from the root and many nodes are shared. + const recent = (): Node => pool[pool.length - 1 - (next() % 48)]!; + for (let i = 0; i < size; i++) { + const a = recent(); + const b = recent(); + const r = next() % 10; + let node: Node; + if (r < 1) node = { kind: "neg", operand: a }; + else if (r < 5) node = { kind: "add", left: a, right: b }; + else if (r < 8) node = { kind: "mul", left: a, right: b }; + else node = { kind: "pick", cond: a, then: b, else_: recent() }; + pool.push(node); + } + return pool[pool.length - 1]!; +} + +const scale = Number(process.argv[2] ?? "1"); +const rounds = Math.max(1, Math.round(40 * scale)); +let checksum = 0; +let nodes = 0; +let leafCount = 0; +for (let round = 0; round < rounds; round++) { + const root = build(20_000); + nodes += reachable(root); + const found = leaves(root); + leafCount += found.length; + for (let pass = 0; pass < 4; pass++) { + const env: number[] = []; + for (let i = 0; i < 16; i++) env.push((round * 31 + pass * 7 + i) % 97); + checksum = (checksum + evaluate(root, env, new Map())) % 1000003; + for (const leaf of found) checksum = (checksum + leafValue(leaf, env)) % 1000003; + } +} +console.log("rounds", rounds, "nodes", nodes, "leaves", leafCount, "checksum", checksum); diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 9fa5e9447..4c1b7e7c6 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -210,6 +210,9 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
SCRIPTC_CHECKER
The TypeScript 7 API server that checks the program: tsc-ts (the default), which scriptc includes, or tsgo, from the typescript package scriptc also includes. Both give the same diagnostics and generated code.
+ +
SCRIPTC_CHECKER_SERVER
+
Set to 1 on Linux and macOS to check with a tsc-ts server that later builds share. It takes effect with a tsc-ts release that serves tsc-ts --api --listen. The tsc-ts 0.0.9 that this version of scriptc includes does not, so each build checks with its own tsc-ts process, as without the variable. A build connects to the server for its working directory, starting it when none answers, and the server keeps the files it parsed, so the next build of the same program loads it faster. A build that finds the server in use by another build, or cannot start or reach it, checks with its own tsc-ts process. The server exits after 5 minutes without a build, or after a build when its resident memory is above 2048 MB (TSC_TS_LISTEN_MEMORY sets another limit in megabytes). Diagnostics and generated code are the same with and without it. It applies to the default checker, tsc-ts.
```console diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index a86ed217e..cb6b93d0d 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -220,6 +220,14 @@ title: "Limitations" ../limitations/tls-ciphers.mdx +### TLS certificates + +../limitations/tls-certificates.mdx + +### TLS sessions + +../limitations/tls-sessions.mdx + ### Console and process streams ../limitations/console-and-process-streams.mdx diff --git a/docs/content/limitations/builtin-apis/assertions.mdx b/docs/content/limitations/builtin-apis/assertions.mdx index f7f5c9354..381fc3802 100644 --- a/docs/content/limitations/builtin-apis/assertions.mdx +++ b/docs/content/limitations/builtin-apis/assertions.mdx @@ -3,6 +3,8 @@ When `strictEqual` compares function values, `actual` and `expected` read as `undefined`; when `assert.throws` or `assert.rejects` reports an error that is not an instance of the expected class, or that does not match an expected object, `expected` reads as `undefined`. Values whose type can be cyclic, such as recursive records, also read as `undefined`. `assert.AssertionError` used as a value outside `instanceof` throws `SC2020`. + The comparison methods, `ok`, `fail` and `ifError` are function values that assert as the methods do; called through a stored value, `ok` without a message reports `value == true`, where Node quotes the call, and `fail` takes at most one argument. + `throws`, `rejects`, `doesNotThrow`, `doesNotReject`, `match` and `doesNotMatch` as values throw `SC2020`. `assert.deepEqual` and `assert.notDeepEqual` follow Node's loose comparison, including Maps, Sets, Dates, regular expressions, typed arrays, and errors; operands whose type can be cyclic or holds functions inside a structure are refused. `assert.partialDeepStrictEqual` follows Node's partial comparison over the same values, with the same refusals. Checked-dynamic values compared deeply have no cycle detection. diff --git a/docs/content/limitations/builtin-apis/crypto.mdx b/docs/content/limitations/builtin-apis/crypto.mdx index 329aa6809..cba219788 100644 --- a/docs/content/limitations/builtin-apis/crypto.mdx +++ b/docs/content/limitations/builtin-apis/crypto.mdx @@ -41,6 +41,10 @@ They support `type`, `asymmetricKeyType`, `asymmetricKeyDetails`, `export()`, `equals()`, and `instanceof KeyObject`. `crypto.sign`, `crypto.verify`, `createSign`, and `createVerify` support RSA PKCS#1 v1.5 and PSS padding and ECDSA with DER and IEEE P1363 encodings over SHA-1 and SHA-2 digests, and Ed25519 through `crypto.sign` and `crypto.verify` with a `null` or `undefined` algorithm. Ed25519 signing, verification, and private keys link when a string literal in the program names `ed25519` (in any case); without one they throw a coded SC2020 error, while Ed25519 public keys still import and export. + X509Certificate supports `publicKey`, `verify()`, `checkIssued()`, `checkPrivateKey()`, `checkHost()`, `checkEmail()`, `checkIP()`, `toLegacyObject()`, `toString()`, and `toJSON()`, and `String()`, template literals, and `+` give its PEM text as `toString()` does. + `publicKey`, `verify()`, and `checkPrivateKey()` are refused in wasm32-wasi builds, and an Ed25519 certificate's `verify()` needs the `ed25519` literal like the other Ed25519 operations. + Own-key walks over a certificate (`Object.keys()`, `Object.entries()`, spread, `for...in`, and `hasOwnProperty`) are refused, since Node keeps its fields as prototype getters. + `JSON.stringify()` and `console.log()` of a certificate, a certificate stored in an `unknown` or `any` slot, and its methods on a union-typed value are also refused. Sign and Verify values accumulate `update()` data and are not writable streams. Encrypted private keys (`passphrase`, `cipher`), the other key types (`rsa-pss`, `dsa`, `ed448`, `x448`, and the rest), and wasm32-wasi builds throw a coded SC2020 error or are refused. Comparing keys of different types with `equals()` answers false; Node also leaves an OpenSSL error queued, which a later key operation can report. diff --git a/docs/content/limitations/builtin-apis/date.mdx b/docs/content/limitations/builtin-apis/date.mdx index 89266b745..d783984fa 100644 --- a/docs/content/limitations/builtin-apis/date.mdx +++ b/docs/content/limitations/builtin-apis/date.mdx @@ -1,6 +1,7 @@ -- **Date:** Spread constructor arguments, setters, thrown Date values, and locale formatters are unsupported. - Dates are objects: `===` compares identity, `new Date(date)` makes a new Date, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in `Date | undefined` and `Date | null` slots, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators. - Unions of a Date with other non-nullish types are unsupported. - Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, `String(date)` and template literal conversion, relational comparison, calendar getters, `getTimezoneOffset`, and single-string `Date.parse` compile. +- **Date:** Spread constructor arguments and locale formatters are unsupported. + Dates are objects with identity: `===` and `==` compare identity, `new Date(date)` makes a new Date, setters change the one Date every reference holds, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in unions with other types, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators. + Thrown and rejected Dates keep their identity; `structuredClone` and `postMessage` copy them. + Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, the local and UTC calendar getters and setters, `setTime`, `getTimezoneOffset`, and single-string `Date.parse` compile. + Unary `+`, `-` and `Number(date)` read the time value; `String(date)`, template literals and `+` with a string read the `toString()` text; `date[Symbol.toPrimitive](hint)` compiles with a literal hint. A Date converted to `unknown` is the same Date object. See [Dates and timezones](#dates-and-timezones) for timezone data. diff --git a/docs/content/limitations/builtin-apis/intl-segmenter.mdx b/docs/content/limitations/builtin-apis/intl-segmenter.mdx index 41a61711c..2682acaf0 100644 --- a/docs/content/limitations/builtin-apis/intl-segmenter.mdx +++ b/docs/content/limitations/builtin-apis/intl-segmenter.mdx @@ -1,2 +1,3 @@ -- **Intl.Segmenter:** Default Unicode grapheme segmentation, iteration, and `containing()` compile. - Locale negotiation, word and sentence segmentation, `resolvedOptions()`, and detached methods are unsupported. +- **Intl.Segmenter:** Unicode grapheme segmentation, iteration, and `containing()` compile, with the default locale or one language tag of the form language[-script][-region] written as a string literal (grapheme clusters do not depend on the locale). + In JavaScript dependencies, word and sentence segmenters construct, and their `segment()` throws an SC2020 error. + Locale negotiation over several locales, word and sentence segmentation, `resolvedOptions()`, and detached methods are unsupported. diff --git a/docs/content/limitations/builtin-apis/object-freezing.mdx b/docs/content/limitations/builtin-apis/object-freezing.mdx index 99b78ded5..030b03b4b 100644 --- a/docs/content/limitations/builtin-apis/object-freezing.mdx +++ b/docs/content/limitations/builtin-apis/object-freezing.mdx @@ -4,7 +4,8 @@ Class instances support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible` when the static type of the argument names the class. A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code. `Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an object-literal accessor descriptor (`get` or `set`) for a declared field is refused at compile time with `SC2020`, and another accessor descriptor for a declared field throws `SC2020` when the call runs. - Freezing, sealing, or preventing extensions of a class instance held in an untyped value throws `SC2020`. + `Object.freeze`, `Object.seal`, and `Object.preventExtensions` of a class instance held in an untyped value change the instance itself, so writes through its typed references are checked the same way. + The `TypeError` for a write to a read-only property that was added to a class instance through an untyped value names `#` where Node names the class. Functions support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible`; the operations apply to the function's own properties (`name`, `length`, `prototype`, and the ones a program assigns). Class constructors are not supported. A write to a read-only property of a checked dictionary or a function throws a `TypeError` whose message omits Node's `'x' of object '#'` (or `of function '…'`) suffix. diff --git a/docs/content/limitations/builtin-apis/prototypes.mdx b/docs/content/limitations/builtin-apis/prototypes.mdx index 170b11793..858de3c7e 100644 --- a/docs/content/limitations/builtin-apis/prototypes.mdx +++ b/docs/content/limitations/builtin-apis/prototypes.mdx @@ -1,8 +1,14 @@ - **Prototypes:** Named properties and methods on program class prototypes support replacement, inheritance, and deletion. - Bare prototype reflection, accessor replacement, and prototype storage on local, generic, mixin, or runtime-provided classes are unsupported. + A module-level JavaScript class's prototype can become the prototype of other objects: `Object.create(C.prototype)`, `Object.setPrototypeOf(F.prototype, C.prototype)`, `Object.setPrototypeOf(object, C.prototype)`, and `util.inherits(F, C)` with an ordinary constructor `F`. + The inherited methods and accessors run with the inheriting object as `this`. + Methods that use `#private` members or `super` throw a TypeError for a receiver that is not an instance of the class. + `C.prototype` also reads as a value for `Object.getPrototypeOf` and for `===` and `!==` comparisons. + Changing the prototype of a class prototype (`Object.setPrototypeOf(C.prototype, X)`, `util.inherits(C, F)`, or `Object.setPrototypeOf` on a class prototype reached through an instance) is unsupported. + Other bare prototype reflection, TypeScript class prototypes as values, accessor replacement, and prototype storage on local, generic, mixin, or runtime-provided classes are unsupported. Plain objects, arrays, functions, class instances, and Date, RegExp, Map, Set, WeakMap, WeakSet, and Promise values inherit `constructor`, `valueOf`, `toString`, `hasOwnProperty`, and `propertyIsEnumerable` from `Object.prototype` as in Node, so `value.constructor === Object` and `value.valueOf === Object.prototype.valueOf` compare identities exactly. Named reads such as `value.valueOf` answer these inherited members; computed-key reads such as `value[key]` do not. `Object.prototype`'s own members read as values; its mutation and other reflection, such as `isPrototypeOf` and `__proto__`, are unsupported. The constructors `Object`, `Function`, `Date`, `RegExp`, `Map`, `Set`, `WeakMap`, `WeakSet`, and `Promise` read as function values; through a stored value only `Object` can be called. `new` through a stored `Object`, `Date`, `Map`, `Set`, `WeakMap`, or `WeakSet` builds the same value as the `new` expression. `new` through a stored `RegExp` or `Promise`, `new` of a stored `Object` with a primitive argument, and `instanceof` against these constructors are unsupported. + `instanceof Object` and `instanceof Function` on untyped values follow the prototype chain as in Node, so an object built with `Object.create(null)` is not an `Object`. diff --git a/docs/content/limitations/builtin-apis/streams-and-readline.mdx b/docs/content/limitations/builtin-apis/streams-and-readline.mdx index f9d69e3db..561840268 100644 --- a/docs/content/limitations/builtin-apis/streams-and-readline.mdx +++ b/docs/content/limitations/builtin-apis/streams-and-readline.mdx @@ -3,7 +3,8 @@ `readable.pipe(process.stdout)` and `readable.pipe(process.stderr)` compile and, as in Node, do not end the process stream. When an untyped value reaches `NodeJS.ReadableStream` or `NodeJS.WritableStream`, those two interfaces hold any value, so JavaScript stream objects (constructor-function streams, plain objects with `on` and `pipe`) pass through TypeScript functions typed with them and come back to JavaScript as the same objects. Member calls on such a value use the native stream when it is one and call the object's own members otherwise; `readable.pipe()` into a JavaScript destination follows Node's pipe protocol. - A `pipeline` stage and a `for await` source that arrive as untyped values must be native streams, or `Cipheriv` and `Decipheriv` values, at run time; other values throw a not-supported error. + The first `pipeline` stage may arrive as an untyped value that is a native stream, a `Cipheriv` or `Decipheriv` value, or an iterable: a string, an array, a generator object, or any value with a `Symbol.asyncIterator` or `Symbol.iterator` method, which the pipeline pumps into the next stage as Node does. + Later `pipeline` stages and `for await` sources that arrive as untyped values must be native streams, or `Cipheriv` and `Decipheriv` values, at run time; other values throw a not-supported error. Native streams expose no `emit` to JavaScript, so a JavaScript stream whose own `pipe()` emits on its destination (the legacy `Stream` pipe) cannot pipe into one. `Readable.from` accepts strings, Buffers, arrays, Sets, Maps, Map and Set iterators, generators, async generators, and class iterables and async iterables, and in JavaScript any iterable or async iterable. In JavaScript, a sync iterator value with a `then` method that is not a promise, and an object whose `[Symbol.asyncIterator]` or `[Symbol.iterator]` method is a generator method, are unsupported. @@ -14,7 +15,7 @@ `node:stream/consumers` and readline interfaces read object-mode streams of strings and Buffers; other values throw a not-supported error. `pipeline` accepts function stages anywhere among its streams: async generator functions (`async function* (source)`, iterating `source` with `for await`), an async generator source function (`async function* ({ signal })`), an async generator function as the destination, and an async function destination whose value resolves the promise form and reaches the callback's second argument. A stage is written inline, or named by a function declaration or a `const` bound to a function expression that nothing reassigns. - The source may also be an array, a string, a synchronous generator object, or a synchronous generator function without parameters; their elements reach the next stage on the same ticks as in Node. + The source may also be an array, a string, a `Set`, a `Map`, a generator or async generator object, or a synchronous generator function without parameters; their elements reach the next stage on the same ticks as in Node. Stages may take the `{ signal }` argument. With a function destination, `pipeline()` returns an object-mode PassThrough that receives an async generator destination's values, or an async function destination's value unless it is `null` or `undefined`. `@types/node` types that result as `NodeJS.WritableStream`, so `for await` over it needs the `PassThrough` type (`pipeline(...) as PassThrough`, or a JSDoc `@type`). diff --git a/docs/content/limitations/builtin-apis/synchronous-iteration.mdx b/docs/content/limitations/builtin-apis/synchronous-iteration.mdx index 564f9af45..38cc9c475 100644 --- a/docs/content/limitations/builtin-apis/synchronous-iteration.mdx +++ b/docs/content/limitations/builtin-apis/synchronous-iteration.mdx @@ -1,4 +1,4 @@ - **Synchronous iteration:** Checked `for...of` follows live iterator methods, including compiled class methods and abrupt-completion cleanup. Mapper-less checked `Array.from` accepts those iterators and plain array-like objects. Sparse checked arrays and general borrowed iterator factories are unsupported. - Direct statically typed class loops also restrict iterator `return` and `throw` methods. + Direct statically typed class loops accept iterators that are class instances or object literals; a loop that exits early calls the iterator's zero-parameter `return()`, as Node's IteratorClose does. diff --git a/docs/content/limitations/language-features/async-iteration.mdx b/docs/content/limitations/language-features/async-iteration.mdx index 863c9be90..b15dca7fc 100644 --- a/docs/content/limitations/language-features/async-iteration.mdx +++ b/docs/content/limitations/language-features/async-iteration.mdx @@ -1,6 +1,7 @@ - **Async iteration:** Async `yield*` delegation, stored Web or Node iterator handles, and general structural async-iterator objects are unsupported. Typed async generators support lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also accepts Node `Readable` streams, Web `ReadableStream` values, and compiled class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record. + A class instance whose `[Symbol.asyncIterator]()` returns an object literal, or that has only `[Symbol.iterator]()`, iterates through the checked-dynamic async protocol, and an early exit awaits the iterator's `return()`. Abrupt completion runs cleanup, including literal `destroyOnReturn` and `preventCancel` options. The `node:timers/promises` `setInterval(delay, value)` iterator compiles without AbortSignal options. `Array.fromAsync` accepts arrays of values or of promises and typed async generators, with an optional mapping function; a `thisArg` and arrays that mix values and promises are unsupported. diff --git a/docs/content/limitations/native-addons.mdx b/docs/content/limitations/native-addons.mdx index 469d1cdd2..8fa7f94bf 100644 --- a/docs/content/limitations/native-addons.mdx +++ b/docs/content/limitations/native-addons.mdx @@ -2,5 +2,12 @@ Node-API (N-API) and V8 `.node` addons depend on Node.js itself, so compiled pro Direct `createRequire` calls to local addons are rejected at compile time. A package that requires an addon needs a JavaScript implementation that compiles, or a replacement through native FFI. +A package that looks for an optional addon at run time compiles with `--npm-static` when the lookup is inside a `try` block. +There, a `createRequire` require call with a computed specifier, and a `require.resolve` or `require.resolve.paths` call with a computed request, evaluate their arguments and then throw an error. +Its `require.resolve` read as a value is a function that throws when called. +Coverage lists these as deferred to runtime. +The package's `catch` clause then runs as it does in Node.js when the addon is missing. +Outside a `try` block, the same uses keep the package refused. + Use [Native FFI](/docs/ffi#replacing-a-node-api-addon) to call a plain C ABI function linked from an object or static archive. The guide includes a replacement for a small Node-API helper. diff --git a/docs/content/limitations/tls-certificates.mdx b/docs/content/limitations/tls-certificates.mdx new file mode 100644 index 000000000..639dd3d83 --- /dev/null +++ b/docs/content/limitations/tls-certificates.mdx @@ -0,0 +1,14 @@ +`tlsSocket.getPeerCertificate()`, `getCertificate()`, `getPeerX509Certificate()`, and `getX509Certificate()` follow Node, including the `detailed` issuer chain, on sockets whose type is known. +On a TLS socket held in an `unknown` or `any` value, or in an untyped JavaScript parameter, they throw an error that names the call as not supported. +Client certificates follow Node: `tls.createServer`, `https.createServer`, and `new tls.TLSSocket` take `requestCert`, `rejectUnauthorized`, and `ca`, and `tls.connect` and `https.request` take `cert` and `key`. +A server's `authorized` and `authorizationError` report the client certificate's verification as Node does, and a server, client, or `tls.createSecureContext` given a `ca` also sends the chain it completes, as OpenSSL's automatic chain does. +A server verifies client certificates against the `ca` of the context it serves, including contexts from `addContext()` and `setSecureContext()`: an empty `ca` (an empty Buffer or array) trusts no certificate, and an omitted `ca` trusts the default CAs. +The same holds for a client's `ca` in `tls.connect`, `https.request`, and `http2.connect`, where an empty string is no `ca`, as in Node. +In `http2.connect`, a `ca` held in an `unknown` or `any` value that is an empty string is refused. +`requestCert` beside a TLSSocket's `secureContext` or an `SNICallback`, client certificates on http2 secure servers, and a `cert` without a `key` (or the reverse) are refused. +`server.addContext()` and `server.setSecureContext()` follow Node on tls and https servers, including wildcard names, which match one label without regard to case. +A name with regular-expression groups or alternation, or one outside ASCII, is refused, as are both methods on a server that is not a tls or https server. +The `'tlsClientError'` event reports Node's error for a failed handshake: OpenSSL's code and reason, or `socket hang up` for a client that leaves. +OpenSSL's messages leave out the thread identifier Node prints before `error:` and the source location after the reason. +A failure whose OpenSSL reason the runtime does not know, such as a malformed client hello, is reported as an error with code `SC2020` that says so. +The X509Certificate those accessors return has the same refusals as any other certificate (see Crypto), so in JavaScript narrow it, for example with `if (cert)`, before reading its fields. diff --git a/docs/content/limitations/tls-sessions.mdx b/docs/content/limitations/tls-sessions.mdx new file mode 100644 index 000000000..8d34573a8 --- /dev/null +++ b/docs/content/limitations/tls-sessions.mdx @@ -0,0 +1,3 @@ +TLS servers send no session tickets, so a connection to them cannot be resumed. +A client's `'session'` event fires once per connection, when its handshake finishes and before `secureConnect`. +Node fires it for each ticket the server sends, after `secureConnect`, and OpenSSL servers send two. diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 2475cd122..130da748c 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -13822,9 +13822,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -13845,9 +13845,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -13868,9 +13868,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -13891,9 +13891,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -13914,9 +13914,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -13937,9 +13937,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -13960,9 +13960,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -13983,9 +13983,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -14006,9 +14006,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -14029,9 +14029,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -14236,9 +14236,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -23790,9 +23790,9 @@ "anchor": "err_tls_required_server_name", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -67484,9 +67484,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -67599,9 +67599,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -67783,9 +67783,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -67921,9 +67921,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -68013,9 +68013,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -68059,9 +68059,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -68174,9 +68174,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -68312,9 +68312,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -68335,9 +68335,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index cb06685f8..c3a0f90b3 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -19394,6 +19394,8 @@ "tests": [ "tests/corpus/1358-crypto-random.ts", "tests/corpus/1534-crypto-hash-chain.ts", + "tests/corpus/1568-x509-fingerprint.ts", + "tests/corpus/1572-x509-validity-date-gettime.ts", "tests/corpus/2556-crypto-introspection.ts", "tests/corpus/2890-crypto-static-utility.ts", "tests/corpus/5250-cipher-aes-gcm.ts", @@ -19436,6 +19438,7 @@ "tests/corpus/6709-dh-generate.ts", "tests/corpus/6710-dh-encodings.cjs", "tests/corpus/6711-dh-handles.ts", + "tests/corpus/6936-x509-certificate-fields.ts", "tests/corpus/7055-cipher-streams.ts", "tests/corpus/7056-cipher-stream-errors.ts", "tests/corpus/7057-cipher-streams.mjs", @@ -19443,6 +19446,10 @@ "tests/corpus/7082-crypto-key-objects.ts", "tests/corpus/7083-crypto-sign-verify.ts", "tests/corpus/7084-crypto-key-pairs.cjs", + "tests/corpus/7090-x509-check-names.ts", + "tests/corpus/7091-x509-certificate-keys.ts", + "tests/corpus/7092-x509-legacy-object.ts", + "tests/corpus/7093-x509-methods-js.mjs", "tests/corpus/7122-webcrypto-node-crypto.ts", "tests/corpus/7123-webcrypto-node-crypto.cjs", "tests/corpus/7150-cipher-legacy-modes.ts", @@ -19461,6 +19468,7 @@ "tests/fixtures/node-types/dh.ts", "tests/fixtures/node-types/ecdh.ts", "tests/fixtures/node-types/keys.ts", + "tests/fixtures/node-types/x509-methods.ts", "tests/harness/npm-static.test.ts" ] }, @@ -23784,8 +23792,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "partial", + "evidence": "compiler-dedicated:X509Certificate", + "tests": [ + "tests/corpus/1568-x509-fingerprint.ts", + "tests/corpus/1572-x509-validity-date-gettime.ts", + "tests/corpus/6936-x509-certificate-fields.ts" + ] }, "anchorSource": "exact" }, @@ -23807,8 +23820,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "partial", + "evidence": "compiler-dedicated:x509.checkEmail", + "tests": [ + "tests/corpus/7090-x509-check-names.ts", + "tests/corpus/7091-x509-certificate-keys.ts", + "tests/corpus/7092-x509-legacy-object.ts", + "tests/corpus/7093-x509-methods-js.mjs", + "tests/fixtures/node-types/x509-methods.ts" + ] }, "anchorSource": "exact" }, @@ -23830,8 +23850,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "partial", + "evidence": "compiler-dedicated:x509.checkHost", + "tests": [ + "tests/corpus/7090-x509-check-names.ts", + "tests/corpus/7091-x509-certificate-keys.ts", + "tests/corpus/7092-x509-legacy-object.ts", + "tests/corpus/7093-x509-methods-js.mjs", + "tests/fixtures/node-types/x509-methods.ts" + ] }, "anchorSource": "exact" }, @@ -23853,8 +23880,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "partial", + "evidence": "compiler-dedicated:x509.checkIP", + "tests": [ + "tests/corpus/7090-x509-check-names.ts", + "tests/corpus/7091-x509-certificate-keys.ts", + "tests/corpus/7092-x509-legacy-object.ts", + "tests/corpus/7093-x509-methods-js.mjs", + "tests/fixtures/node-types/x509-methods.ts" + ] }, "anchorSource": "exact" }, @@ -23876,8 +23910,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "partial", + "evidence": "compiler-dedicated:x509.checkIssued", + "tests": [ + "tests/corpus/7090-x509-check-names.ts", + "tests/corpus/7091-x509-certificate-keys.ts", + "tests/corpus/7092-x509-legacy-object.ts", + "tests/corpus/7093-x509-methods-js.mjs", + "tests/fixtures/node-types/x509-methods.ts" + ] }, "anchorSource": "exact" }, @@ -23899,8 +23940,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "partial", + "evidence": "compiler-dedicated:x509.checkPrivateKey", + "tests": [ + "tests/corpus/7090-x509-check-names.ts", + "tests/corpus/7091-x509-certificate-keys.ts", + "tests/corpus/7092-x509-legacy-object.ts", + "tests/corpus/7093-x509-methods-js.mjs", + "tests/fixtures/node-types/x509-methods.ts" + ] }, "anchorSource": "exact" }, @@ -23922,8 +23970,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "partial", + "evidence": "compiler-dedicated:x509.toJSON", + "tests": [ + "tests/corpus/7090-x509-check-names.ts", + "tests/corpus/7091-x509-certificate-keys.ts", + "tests/corpus/7092-x509-legacy-object.ts", + "tests/corpus/7093-x509-methods-js.mjs", + "tests/fixtures/node-types/x509-methods.ts" + ] }, "anchorSource": "exact" }, @@ -23945,8 +24000,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "partial", + "evidence": "compiler-dedicated:x509.toLegacyObject", + "tests": [ + "tests/corpus/7090-x509-check-names.ts", + "tests/corpus/7091-x509-certificate-keys.ts", + "tests/corpus/7092-x509-legacy-object.ts", + "tests/corpus/7093-x509-methods-js.mjs", + "tests/fixtures/node-types/x509-methods.ts" + ] }, "anchorSource": "exact" }, @@ -23968,8 +24030,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "partial", + "evidence": "compiler-dedicated:x509.toString", + "tests": [ + "tests/corpus/7090-x509-check-names.ts", + "tests/corpus/7091-x509-certificate-keys.ts", + "tests/corpus/7092-x509-legacy-object.ts", + "tests/corpus/7093-x509-methods-js.mjs", + "tests/fixtures/node-types/x509-methods.ts" + ] }, "anchorSource": "exact" }, @@ -23991,8 +24060,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "partial", + "evidence": "compiler-dedicated:x509.verify", + "tests": [ + "tests/corpus/7090-x509-check-names.ts", + "tests/corpus/7091-x509-certificate-keys.ts", + "tests/corpus/7092-x509-legacy-object.ts", + "tests/corpus/7093-x509-methods-js.mjs", + "tests/fixtures/node-types/x509-methods.ts" + ] }, "anchorSource": "exact" }, @@ -24198,8 +24274,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "partial", + "evidence": "compiler-feature:crypto.publicKey", + "tests": [ + "tests/corpus/7090-x509-check-names.ts", + "tests/corpus/7091-x509-certificate-keys.ts", + "tests/corpus/7092-x509-legacy-object.ts", + "tests/corpus/7093-x509-methods-js.mjs", + "tests/fixtures/node-types/x509-methods.ts" + ] }, "anchorSource": "ancestor" }, @@ -32679,6 +32762,7 @@ "tests": [ "tests/corpus/6702-ecdh-errors.js", "tests/corpus/6708-dh-errors.js", + "tests/corpus/7440-tls-server-add-context.ts", "tests/corpus/7714-zlib-brotli-compress.cjs", "tests/corpus/caught-system-error-fields.ts", "tests/corpus/error-stack-frames.ts", @@ -39012,8 +39096,11 @@ "anchor": "err_tls_required_server_name", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:errors" + "status": "partial", + "evidence": "compiler-dedicated:ERR_TLS_REQUIRED_SERVER_NAME", + "tests": [ + "tests/corpus/7440-tls-server-add-context.ts" + ] }, "anchorSource": "exact" }, @@ -95877,6 +95964,17 @@ "tests/corpus/6722-tls-min-max-version.ts", "tests/corpus/7087-tls-socket-wrap.ts", "tests/corpus/7088-tls-socket-connect-tunnel.ts", + "tests/corpus/7095-tls-peer-certificates.ts", + "tests/corpus/7096-tls-peer-certificates.mjs", + "tests/corpus/7097-tls-client-certificates.ts", + "tests/corpus/7098-tls-client-certificates-paths.ts", + "tests/corpus/7099-tls-client-certificates.mjs", + "tests/corpus/7440-tls-server-add-context.ts", + "tests/corpus/7441-tls-client-error.ts", + "tests/corpus/7442-tls-server-contexts.mjs", + "tests/fixtures/node-types/tls-client-certificates.ts", + "tests/fixtures/node-types/tls-peer-certificates.ts", + "tests/fixtures/node-types/tls-server-contexts.ts", "tests/fixtures/node-types/tls-socket-wrap.ts" ] }, @@ -96245,8 +96343,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tls" + "status": "partial", + "evidence": "compiler-dedicated:server.addContext", + "tests": [ + "tests/corpus/7440-tls-server-add-context.ts", + "tests/corpus/7441-tls-client-error.ts", + "tests/corpus/7442-tls-server-contexts.mjs", + "tests/fixtures/node-types/tls-server-contexts.ts" + ] }, "anchorSource": "exact" }, @@ -96372,8 +96476,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tls" + "status": "partial", + "evidence": "compiler-dedicated:server.setSecureContext", + "tests": [ + "tests/corpus/7440-tls-server-add-context.ts", + "tests/corpus/7441-tls-client-error.ts", + "tests/corpus/7442-tls-server-contexts.mjs", + "tests/fixtures/node-types/tls-server-contexts.ts" + ] }, "anchorSource": "exact" }, @@ -96556,8 +96666,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tls" + "status": "partial", + "evidence": "compiler-dedicated:'tlsClientError'", + "tests": [ + "tests/corpus/7440-tls-server-add-context.ts", + "tests/corpus/7441-tls-client-error.ts", + "tests/corpus/7442-tls-server-contexts.mjs", + "tests/fixtures/node-types/tls-server-contexts.ts" + ] }, "anchorSource": "exact" }, @@ -96699,8 +96815,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tls" + "status": "partial", + "evidence": "compiler-dedicated:tlsSocket.getCertificate", + "tests": [ + "tests/corpus/7095-tls-peer-certificates.ts", + "tests/corpus/7096-tls-peer-certificates.mjs", + "tests/fixtures/node-types/tls-peer-certificates.ts" + ] }, "anchorSource": "exact" }, @@ -96791,8 +96912,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tls" + "status": "partial", + "evidence": "compiler-dedicated:tlsSocket.getPeerCertificate", + "tests": [ + "tests/corpus/7095-tls-peer-certificates.ts", + "tests/corpus/7096-tls-peer-certificates.mjs", + "tests/fixtures/node-types/tls-peer-certificates.ts" + ] }, "anchorSource": "exact" }, @@ -96860,8 +96986,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tls" + "status": "partial", + "evidence": "compiler-dedicated:tlsSocket.getPeerX509Certificate", + "tests": [ + "tests/corpus/7095-tls-peer-certificates.ts", + "tests/corpus/7096-tls-peer-certificates.mjs", + "tests/fixtures/node-types/tls-peer-certificates.ts" + ] }, "anchorSource": "exact" }, @@ -96978,8 +97109,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tls" + "status": "partial", + "evidence": "compiler-dedicated:tlsSocket.getX509Certificate", + "tests": [ + "tests/corpus/7095-tls-peer-certificates.ts", + "tests/corpus/7096-tls-peer-certificates.mjs", + "tests/fixtures/node-types/tls-peer-certificates.ts" + ] }, "anchorSource": "exact" }, @@ -97119,8 +97255,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tls" + "status": "partial", + "evidence": "compiler-dedicated:tlsSocket.authorizationError", + "tests": [ + "tests/corpus/7097-tls-client-certificates.ts", + "tests/corpus/7098-tls-client-certificates-paths.ts", + "tests/corpus/7099-tls-client-certificates.mjs", + "tests/fixtures/node-types/tls-client-certificates.ts" + ] }, "anchorSource": "exact" }, @@ -97142,8 +97284,14 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tls" + "status": "partial", + "evidence": "compiler-feature:tls.authorized", + "tests": [ + "tests/corpus/7097-tls-client-certificates.ts", + "tests/corpus/7098-tls-client-certificates-paths.ts", + "tests/corpus/7099-tls-client-certificates.mjs", + "tests/fixtures/node-types/tls-client-certificates.ts" + ] }, "anchorSource": "ancestor" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 89992231e..ab626fe21 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -208,6 +208,8 @@ "stdlib.abort": ["tests/harness/fetch-conformance.test.ts"] }, "features": [ + { "chapter": "tls", "parent": "Class: `tls.TLSSocket`", "signatures": ["`authorized` Type: {boolean}"], "status": "partial", "evidence": ["tests/corpus/7097-tls-client-certificates.ts", "tests/corpus/7098-tls-client-certificates-paths.ts", "tests/corpus/7099-tls-client-certificates.mjs", "tests/fixtures/node-types/tls-client-certificates.ts"] }, + { "chapter": "crypto", "parent": "Class: `X509Certificate`", "signatures": ["`publicKey` Type: {KeyObject}"], "status": "partial", "evidence": ["tests/corpus/7090-x509-check-names.ts", "tests/corpus/7091-x509-certificate-keys.ts", "tests/corpus/7092-x509-legacy-object.ts", "tests/corpus/7093-x509-methods-js.mjs", "tests/fixtures/node-types/x509-methods.ts"] }, { "chapter": "webcrypto", "symbols": ["subtle.importKey", "subtle.generateKey", "subtle.exportKey", "subtle.encrypt", "subtle.decrypt", "subtle.wrapKey", "subtle.unwrapKey", "subtle.deriveBits", "subtle.deriveKey", "subtle.sign", "subtle.verify", "subtle.digest"], "status": "partial", "evidence": ["tests/corpus/7130-webcrypto-wrap-key.ts", "tests/corpus/7131-webcrypto-wrap-key-errors.mjs", "tests/corpus/7132-jose-pbes2/main.mjs", "tests/corpus/7128-webcrypto-derive.ts", "tests/corpus/7129-webcrypto-derive-errors.mjs", "tests/corpus/7126-webcrypto-aes.ts", "tests/corpus/7127-webcrypto-aes-errors.mjs", "tests/corpus/7124-webcrypto-secret-keys.ts", "tests/corpus/7125-webcrypto-key-errors.mjs", "tests/fixtures/node-types/webcrypto-keys.ts", "tests/corpus/6291-webcrypto-hmac-sign-verify.ts", "tests/corpus/6292-webcrypto-hmac-errors.ts", "tests/corpus/6293-webcrypto-digest.ts", "tests/corpus/6294-webcrypto-js-values.mjs", "tests/corpus/6295-jose-hs256/main.mjs", "tests/fixtures/node-types/webcrypto-hmac.ts", "tests/corpus/7081-webcrypto-ed25519.mjs", "tests/corpus/7085-webcrypto-ecdsa-rsa.ts", "tests/corpus/7086-webcrypto-ecdsa-rsa-errors.mjs"] }, { "chapter": "webcrypto", "signatures": ["Class: `SubtleCrypto`", "`subtle` Type: {SubtleCrypto}"], "status": "partial", "evidence": ["tests/corpus/6291-webcrypto-hmac-sign-verify.ts", "tests/corpus/6292-webcrypto-hmac-errors.ts", "tests/corpus/6293-webcrypto-digest.ts", "tests/corpus/6294-webcrypto-js-values.mjs", "tests/corpus/6295-jose-hs256/main.mjs"] }, { "chapter": "webcrypto", "signatures": ["Class: `CryptoKey`", "`cryptoKey.algorithm`", "`extractable` Type: {boolean}", "`type` Type: {string} One of `'secret'`, `'private'`, or `'public'`.", "`usages` Type: {string\\[]}"], "status": "partial", "evidence": ["tests/corpus/7124-webcrypto-secret-keys.ts", "tests/corpus/6291-webcrypto-hmac-sign-verify.ts", "tests/corpus/6294-webcrypto-js-values.mjs", "tests/corpus/6295-jose-hs256/main.mjs"] }, @@ -436,6 +438,11 @@ { "symbols": ["assert", "assert.ok", "assert.equal", "assert.notEqual", "assert.strictEqual", "assert.notStrictEqual", "assert.deepStrictEqual", "assert.notDeepStrictEqual", "assert.fail", "assert.match", "assert.doesNotMatch", "assert.throws", "assert.rejects", "assert.ifError"], "status": "partial", "evidence": ["tests/corpus/1600-assert-passing.ts", "tests/corpus/1604-assert-deep-structures.ts", "tests/corpus/1609-assert-async.ts", "tests/corpus/1721-assert-throws-regex-class.ts", "tests/corpus/2936-static-loose-equality.ts", "tests/corpus/5515-assert-deep-strict-typed-messages.ts"] }, { "chapter": "crypto", "symbols": ["Cipheriv", "cipher.update", "cipher.final", "cipher.setAAD", "cipher.getAuthTag", "cipher.setAutoPadding", "Decipheriv", "decipher.update", "decipher.final", "decipher.setAAD", "decipher.setAuthTag", "decipher.setAutoPadding"], "status": "partial", "evidence": ["tests/corpus/5250-cipher-aes-gcm.ts", "tests/corpus/5251-cipher-aes-cbc.ts", "tests/corpus/5252-cipher-aes-ctr.ts", "tests/corpus/5253-cipher-chacha20-poly1305.ts", "tests/corpus/5254-cipher-errors.js", "tests/corpus/5255-cipher-encodings.cjs", "tests/corpus/5256-cipher-deprecation.cjs", "tests/corpus/5257-cipher-vectors.ts", "tests/corpus/5258-cipher-handles.js", "tests/corpus/5259-cipher-chunked.ts", "tests/corpus/7055-cipher-streams.ts", "tests/corpus/7056-cipher-stream-errors.ts", "tests/corpus/7057-cipher-streams.mjs", "tests/fixtures/node-types/cipher.ts"] }, { "chapter": "crypto", "symbols": ["crypto.webcrypto", "subtle"], "status": "partial", "evidence": ["tests/corpus/7122-webcrypto-node-crypto.ts", "tests/corpus/7123-webcrypto-node-crypto.cjs"] }, + { "chapter": "tls", "symbols": ["server.addContext", "server.setSecureContext", "'tlsClientError'"], "status": "partial", "evidence": ["tests/corpus/7440-tls-server-add-context.ts", "tests/corpus/7441-tls-client-error.ts", "tests/corpus/7442-tls-server-contexts.mjs", "tests/fixtures/node-types/tls-server-contexts.ts"] }, + { "chapter": "errors", "symbols": ["ERR_TLS_REQUIRED_SERVER_NAME"], "status": "partial", "evidence": ["tests/corpus/7440-tls-server-add-context.ts"] }, + { "chapter": "tls", "symbols": ["tlsSocket.authorizationError"], "status": "partial", "evidence": ["tests/corpus/7097-tls-client-certificates.ts", "tests/corpus/7098-tls-client-certificates-paths.ts", "tests/corpus/7099-tls-client-certificates.mjs", "tests/fixtures/node-types/tls-client-certificates.ts"] }, + { "chapter": "tls", "symbols": ["tlsSocket.getPeerCertificate", "tlsSocket.getCertificate", "tlsSocket.getPeerX509Certificate", "tlsSocket.getX509Certificate"], "status": "partial", "evidence": ["tests/corpus/7095-tls-peer-certificates.ts", "tests/corpus/7096-tls-peer-certificates.mjs", "tests/fixtures/node-types/tls-peer-certificates.ts"] }, + { "chapter": "crypto", "symbols": ["x509.checkEmail", "x509.checkHost", "x509.checkIP", "x509.checkIssued", "x509.checkPrivateKey", "x509.toJSON", "x509.toLegacyObject", "x509.toString", "x509.verify"], "status": "partial", "evidence": ["tests/corpus/7090-x509-check-names.ts", "tests/corpus/7091-x509-certificate-keys.ts", "tests/corpus/7092-x509-legacy-object.ts", "tests/corpus/7093-x509-methods-js.mjs", "tests/fixtures/node-types/x509-methods.ts"] }, { "chapter": "crypto", "symbols": ["ECDH", "ecdh.computeSecret", "ecdh.generateKeys", "ecdh.getPrivateKey", "ecdh.getPublicKey", "ecdh.setPrivateKey", "ecdh.setPublicKey"], "status": "partial", "evidence": ["tests/corpus/6700-ecdh-shared-secret.ts", "tests/corpus/6701-ecdh-formats.ts", "tests/corpus/6702-ecdh-errors.js", "tests/corpus/6703-ecdh-private-keys.ts", "tests/corpus/6704-ecdh-set-public-key.cjs", "tests/corpus/6705-ecdh-handles.ts", "tests/fixtures/node-types/ecdh.ts"] }, { "chapter": "crypto", "symbols": ["DiffieHellman", "DiffieHellmanGroup", "diffieHellman.computeSecret", "diffieHellman.generateKeys", "diffieHellman.getGenerator", "diffieHellman.getPrime", "diffieHellman.getPrivateKey", "diffieHellman.getPublicKey", "diffieHellman.setPrivateKey", "diffieHellman.setPublicKey", "diffieHellman.verifyError"], "status": "partial", "evidence": ["tests/corpus/6706-dh-groups.ts", "tests/corpus/6707-dh-explicit-params.ts", "tests/corpus/6708-dh-errors.js", "tests/corpus/6709-dh-generate.ts", "tests/corpus/6710-dh-encodings.cjs", "tests/corpus/6711-dh-handles.ts", "tests/fixtures/node-types/dh.ts"] }, { "chapter": "errors", "symbols": ["ERR_CRYPTO_UNKNOWN_DH_GROUP", "ERR_CRYPTO_INVALID_KEYLEN", "ERR_CRYPTO_INVALID_STATE"], "status": "partial", "evidence": ["tests/corpus/6708-dh-errors.js"] }, @@ -446,6 +453,7 @@ { "symbols": ["crypto.createPrivateKey", "crypto.createPublicKey", "crypto.generateKeyPair", "crypto.generateKeyPairSync", "crypto.sign", "crypto.verify", "crypto.createSign", "crypto.createVerify", "Sign", "sign.update", "sign.sign", "Verify", "verify.update", "verify.verify", "asymmetricKeyType", "asymmetricKeyDetails"], "status": "partial", "evidence": ["tests/corpus/7080-crypto-ed25519.mjs", "tests/corpus/7082-crypto-key-objects.ts", "tests/corpus/7083-crypto-sign-verify.ts", "tests/corpus/7084-crypto-key-pairs.cjs", "tests/fixtures/node-types/keys.ts"] }, { "symbols": ["crypto.getRandomValues"], "status": "supported", "evidence": ["tests/corpus/6204-crypto-get-random-values.ts", "tests/corpus/6205-crypto-get-random-values.js"] }, { "symbols": ["crypto.timingSafeEqual"], "status": "supported", "evidence": ["tests/corpus/5444-crypto-timing-safe-equal.js", "tests/corpus/2890-crypto-static-utility.ts"] }, + { "symbols": ["X509Certificate"], "status": "partial", "evidence": ["tests/corpus/1568-x509-fingerprint.ts", "tests/corpus/1572-x509-validity-date-gettime.ts", "tests/corpus/6936-x509-certificate-fields.ts"] }, { "symbols": ["crypto.randomInt"], "status": "partial", "evidence": ["tests/corpus/5442-crypto-random-values.ts", "tests/corpus/2890-crypto-static-utility.ts"] }, { "symbols": ["crypto.hkdf", "crypto.hkdfSync", "crypto.scrypt", "crypto.scryptSync"], "status": "partial", "evidence": ["tests/corpus/node24-crypto-kdf.ts", "tests/corpus/node24-crypto-kdf-errors.ts", "tests/corpus/node24-crypto-kdf-options.ts", "tests/corpus/5438-crypto-kdf-digests.ts"] }, { "symbols": ["EventEmitter", "emitter.on", "emitter.addListener", "emitter.once", "emitter.prependListener", "emitter.prependOnceListener", "emitter.off", "emitter.removeListener", "emitter.removeAllListeners", "emitter.emit", "emitter.listenerCount", "emitter.listeners", "emitter.rawListeners", "emitter.eventNames", "emitter.setMaxListeners", "emitter.getMaxListeners", "events.once"], "status": "partial", "evidence": ["tests/corpus/1677-emitter-listeners.ts", "tests/corpus/2620-ee-override-once-order.ts"] }, diff --git a/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp index 9cae164d3..14bcd1522 100644 --- a/native/llvm-codegen/src/emit.cpp +++ b/native/llvm-codegen/src/emit.cpp @@ -19,6 +19,7 @@ #include "llvm/CodeGen/MachineFunction.h" #include "llvm/CodeGen/MachineFunctionPass.h" #include "llvm/CodeGen/MachineModuleInfo.h" +#include "llvm/IR/DiagnosticHandler.h" #include "llvm/IR/DiagnosticInfo.h" #include "llvm/IR/DiagnosticPrinter.h" #include "llvm/IR/LegacyPassManager.h" @@ -41,11 +42,13 @@ #include "llvm/Support/SourceMgr.h" #include "llvm/Support/ThreadPool.h" #include "llvm/Support/Threading.h" +#include "llvm/Support/JSON.h" #include "llvm/Support/raw_ostream.h" #include "llvm/Target/TargetMachine.h" #include "llvm/TargetParser/Triple.h" #include "llvm/Transforms/Utils/SplitModule.h" +#include #include #include #include @@ -170,6 +173,108 @@ void limitProfileImports() { (void)Limited; } +// How the --profile-use profile matched the functions it annotated, summed +// over partitions: functions that received its counts, functions whose +// control flow changed since it was collected (hash mismatch), and functions +// it has no record of (new, or never run in training: the merged profile is +// sparse). A partition reused from the cache without its counts is counted +// in Unknown. +struct ProfileMatch { + std::atomic Annotated{0}; + std::atomic Mismatched{0}; + std::atomic Missing{0}; + std::atomic Unknown{0}; + + void add(const ProfileMatch &Other) { + Annotated += Other.Annotated; + Mismatched += Other.Mismatched; + Missing += Other.Missing; + } + + std::string serialize() const { + return formatv("{0} {1} {2}", Annotated.load(), Mismatched.load(), + Missing.load()) + .str(); + } + + bool deserialize(StringRef Text) { + SmallVector Parts; + Text.split(Parts, ' '); + uint64_t Values[3]; + if (Parts.size() != 3) + return false; + for (size_t I = 0; I < 3; ++I) + if (Parts[I].getAsInteger(10, Values[I])) + return false; + Annotated += Values[0]; + Mismatched += Values[1]; + Missing += Values[2]; + return true; + } +}; + +// Counts the profile annotation's hash-mismatch and missing-function +// warnings instead of printing them. Every other diagnostic takes the +// context's default handling. +class ProfileMatchHandler : public DiagnosticHandler { +public: + explicit ProfileMatchHandler(ProfileMatch &Match) : Match(Match) {} + + bool handleDiagnostics(const DiagnosticInfo &Info) override { + if (Info.getKind() != DK_PGOProfile) + return false; + std::string Message; + raw_string_ostream Stream(Message); + DiagnosticPrinterRawOStream Printer(Stream); + Info.print(Printer); + if (StringRef(Message).contains("hash mismatch")) + ++Match.Mismatched; + else if (StringRef(Message).contains_insensitive("no profile data")) + ++Match.Missing; + else + return false; + return true; + } + +private: + ProfileMatch &Match; +}; + +// The annotation warns about functions the profile does not describe only +// when asked; the match counts need those warnings. Process-wide, set once. +void reportMissingProfiles() { + static const bool Reported = [] { + if (cl::Option *Option = + cl::getRegisteredOptions().lookup("pgo-warn-missing-function")) + Option->addOccurrence(0, "pgo-warn-missing-function", "true"); + return true; + }(); + (void)Reported; +} + +// Functions of `M` that the profile annotated with counts. +uint64_t annotatedFunctions(const Module &M) { + uint64_t Count = 0; + for (const Function &F : M) { + if (F.isDeclaration()) + continue; + std::optional Entry = F.getEntryCount(); + if (Entry && !Entry->isSynthetic()) + ++Count; + } + return Count; +} + +// Prints the profile match as the successful emit's one line of JSON. +void reportProfileMatch(const ProfileMatch &Match) { + json::Object Profile{{"annotated", Match.Annotated.load()}, + {"mismatched", Match.Mismatched.load()}, + {"missing", Match.Missing.load()}, + {"unknown_partitions", Match.Unknown.load()}}; + json::Object Response{{"ok", true}, {"profile", std::move(Profile)}}; + outs() << formatv("{0}\n", json::Value(std::move(Response))); +} + // Reads the profile's header once, before any partition needs it: it must // hold IR-level counts. std::optional inspectProfile(const EmitOptions &Options) { @@ -191,6 +296,7 @@ std::optional inspectProfile(const EmitOptions &Options) { // Owns the analysis managers a pipeline needs for one module. struct Pipeline { + PassInstrumentationCallbacks Callbacks; LoopAnalysisManager LAM; FunctionAnalysisManager FAM; CGSCCAnalysisManager CGAM; @@ -199,13 +305,26 @@ struct Pipeline { Pipeline(TargetMachine &Machine, OptimizationLevel Level, std::optional Profile = std::nullopt) - : PB(&Machine, tuningFor(Level), std::move(Profile)) { + : PB(&Machine, tuningFor(Level), std::move(Profile), &Callbacks) { PB.registerModuleAnalyses(MAM); PB.registerCGSCCAnalyses(CGAM); PB.registerFunctionAnalyses(FAM); PB.registerLoopAnalyses(LAM); PB.crossRegisterProxies(LAM, FAM, CGAM, MAM); } + + // Adds to Count the functions the profile annotation gave counts to, as + // soon as that pass has run: inlining and dead code elimination later in + // the pipeline delete annotated functions. + void countAnnotated(std::atomic &Count) { + Callbacks.registerAfterPassCallback( + [&Count](StringRef PassID, Any IR, const PreservedAnalyses &) { + if (PassID != "PGOInstrumentationUse") + return; + if (const auto *M = llvm::any_cast(&IR)) + Count += annotatedFunctions(**M); + }); + } }; std::optional verify(Module &M, StringRef Code) { @@ -387,8 +506,15 @@ std::optional publish(StringRef TemporaryPath, std::optional emitModule(Module &M, TargetMachine &Machine, OptimizationLevel Level, std::optional Profile, CodeGenFileType Type, - StringRef OutputPath, std::vector> &Temporaries) { + StringRef OutputPath, std::vector> &Temporaries, + ProfileMatch &Match) { + bool Annotating = Profile && Profile->Action == PGOOptions::IRUse; + if (Annotating) + M.getContext().setDiagnosticHandler( + std::make_unique(Match)); Pipeline P(Machine, Level, std::move(Profile)); + if (Annotating) + P.countAnnotated(Match.Annotated); P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM); if (std::optional Failure = verify(M, "post_optimization_verification_failed")) @@ -555,20 +681,32 @@ std::optional compileOptimizedPartitions(const EmitOptions &Options, const PartitionPlan &Plan, OptimizationLevel Level, StringRef Salt, const ArtifactCache &Cache, - std::vector> &Objects) { + std::vector> &Objects, + ProfileMatch &Match) { size_t Count = Plan.Modules.size(); + bool Annotating = !Options.ProfileUse.empty(); std::vector> Summaries(Count); SharedFailure Failures; forEachPartition(Count, [&](size_t I) { std::string Key = hashParts({Salt, "summary", Plan.Modules[I]}); - if ((Summaries[I] = Cache.lookup(Key, "bc"))) + if ((Summaries[I] = Cache.lookup(Key, "bc"))) { + if (Annotating) { + std::unique_ptr Counts = Cache.lookup(Key, "match"); + if (!Counts || !Match.deserialize(Counts->getBuffer())) + ++Match.Unknown; + } return; + } std::string LookupError; std::unique_ptr Machine = createTargetMachine(Options.Target, Options.OptLevel, LookupError); if (!Machine) return Failures.set({"target_machine_failed", LookupError}); LLVMContext Context; + ProfileMatch PartitionMatch; + if (Annotating) + Context.setDiagnosticHandler( + std::make_unique(PartitionMatch)); Expected> Loaded = loadPartition(Options, Plan.Modules[I], I, Context, *Machine); if (!Loaded) @@ -592,12 +730,19 @@ compileOptimizedPartitions(const EmitOptions &Options, // Profile instrumentation or annotation happens here, before the thin // link; the post-link optimization reads the annotated weights. Pipeline P(*Machine, Level, profileOptions(Options)); + if (Annotating) + P.countAnnotated(PartitionMatch.Annotated); ModulePassManager MPM = P.PB.buildThinLTOPreLinkDefaultPipeline(Level); MPM.addPass(BitcodeWriterPass(Stream, /*ShouldPreserveUseListOrder=*/false, /*EmitSummaryIndex=*/true, /*EmitModuleHash=*/true)); MPM.run(Mod, P.MAM); } + if (Annotating) { + Match.add(PartitionMatch); + // Stored before the summary, so a reused summary finds its counts. + Cache.store(Key, "match", PartitionMatch.serialize()); + } StringRef Contents(Bitcode.data(), Bitcode.size()); Cache.store(Key, "bc", Contents); Summaries[I] = MemoryBuffer::getMemBufferCopy(Contents); @@ -744,7 +889,8 @@ compileOptimizedPartitions(const EmitOptions &Options, std::optional compilePartitions(const EmitOptions &Options, const PartitionPlan &Plan, - std::vector> &Objects) { + std::vector> &Objects, + ProfileMatch &Match) { // Everything besides a partition's own bytes that determines its // artifacts. The caller's cache directory already identifies the exact // helper build; source content and partition count arrive per module. @@ -778,7 +924,7 @@ compilePartitions(const EmitOptions &Options, const PartitionPlan &Plan, return compileUnoptimizedPartitions(Options, Plan, Salt, Cache, Objects); return compileOptimizedPartitions(Options, Plan, optimizationLevel(Options.OptLevel), Salt, - Cache, Objects); + Cache, Objects, Match); } // Publishes in-memory partition objects through private sibling files. @@ -819,6 +965,8 @@ int emitPartitions(const EmitOptions &Options) { std::vector> Objects; std::optional Failure; bool Compiled = false; + // A failed attempt's counts are discarded with its objects. + auto Match = std::make_unique(); { ErrorOr> Input = MemoryBuffer::getFile(Options.Input, /*IsText=*/false, @@ -830,15 +978,16 @@ int emitPartitions(const EmitOptions &Options) { Input->reset(); PartitionPlan Plan{std::move(Text->Sources), std::move(Text->PublicSymbols)}; - Compiled = !compilePartitions(Options, Plan, Objects); + Compiled = !compilePartitions(Options, Plan, Objects, *Match); } } } if (!Compiled) { + Match = std::make_unique(); PartitionPlan Plan; Failure = splitParsedModule(Options, Plan); if (!Failure) - Failure = compilePartitions(Options, Plan, Objects); + Failure = compilePartitions(Options, Plan, Objects, *Match); } std::vector> Temporaries; if (!Failure) @@ -851,6 +1000,8 @@ int emitPartitions(const EmitOptions &Options) { return reportError(Failure->Code, Failure->Message, Options.DiagnosticFormat); } + if (!Options.ProfileUse.empty()) + reportProfileMatch(*Match); return 0; } @@ -895,6 +1046,8 @@ int emit(const EmitOptions &Options) { return reportError("invalid_import", "runtime bitcode import requires an optimized build", Options.DiagnosticFormat); + if (!Options.ProfileUse.empty()) + reportMissingProfiles(); if (std::optional Failure = inspectProfile(Options)) return reportError(Failure->Code, Failure->Message, Options.DiagnosticFormat); @@ -957,9 +1110,10 @@ int emit(const EmitOptions &Options) { ? CodeGenFileType::ObjectFile : CodeGenFileType::AssemblyFile; std::vector> Temporaries; + ProfileMatch Match; std::optional Failure = emitModule(*Mod, *Machine, Level, profileOptions(Options), Type, - Options.Outputs[0], Temporaries); + Options.Outputs[0], Temporaries, Match); if (!Failure) Failure = publish(Temporaries[0], Options.Outputs[0]); if (Failure) { @@ -968,6 +1122,8 @@ int emit(const EmitOptions &Options) { return reportError(Failure->Code, Failure->Message, Options.DiagnosticFormat); } + if (!Options.ProfileUse.empty()) + reportProfileMatch(Match); return 0; } diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 485d62528..ab51511f8 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -72,7 +72,9 @@ async function tryFastPath(): Promise { values.sparse || // The full compiler resolves `native` against the build host. values["target-cpu"] !== undefined || - (values["external-types"] ?? []).length > 0 + (values["external-types"] ?? []).length > 0 || + // The full CLI validates the Node island policy flags. + hasNodeIslandFlags(values) ) return null; const backend = values.backend; @@ -187,6 +189,21 @@ async function tryFastPath(): Promise { }); } +function hasNodeIslandFlags( + values: ReturnType< + typeof parseArgs<{ options: typeof CLI_OPTIONS; allowPositionals: true; allowNegative: true }> + >["values"], +): boolean { + return ( + values["node-islands"] !== undefined || + (values["node-island"] ?? []).length > 0 || + (values.native ?? []).length > 0 || + values.node !== undefined || + values["explain-island"] !== undefined || + values["strict-user-js"] + ); +} + /** An unchanged program's coverage verdict replays from the verdict cache * without loading the compiler graph. Anything else (a miss, a comment-only * edit, options the replay does not cover) defers to the full CLI. */ @@ -218,7 +235,9 @@ async function coverageFastPath( values["emit-ir"] || (values["npm-static"] ?? []).length > 0 || values["shape-overlay"] === false || - (values["external-types"] ?? []).length > 0 + (values["external-types"] ?? []).length > 0 || + // The replay covers the default Node island policy only. + hasNodeIslandFlags(values) ) return null; let startup: typeof import("@scriptc/compiler/startup-cache"); diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts index 8ac579449..b4b11fa95 100644 --- a/packages/cli/test/native-link-info.test.ts +++ b/packages/cli/test/native-link-info.test.ts @@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => { minimum_os: "14.0", }, program: { object, entry_symbol: "main" }, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, runtime_pack: { kind: "precompiled", package: "@scriptc/runtime-darwin-arm64", @@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => { () => null, (failure: { stderr?: string }) => failure, ); - expect(error?.stderr).toContain("scr_runtime_abi_v37"); + expect(error?.stderr).toContain("scr_runtime_abi_v43"); }); }); diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 1fa787e35..1924baece 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3551,17 +3551,45 @@ declare module "crypto" { keylen: number, callback: (error: Error | null, derivedKey: ArrayBuffer) => void, ): void; - /* The lowered X509Certificate surface is the data-record slice: - * fingerprint (the SHA-1 of the DER, uppercase colon-separated) and - * the validFrom/validTo validity window (Node's ASN1_TIME_print - * strings — "Jul 1 00:00:00 2026 GMT"; the cert-expiry idiom composes - * them with new Date(...).getTime()). All compute at construction; - * unparseable input throws Node's ERR_OSSL_PEM_NO_START_LINE Error. */ + /* The lowered X509Certificate surface is the data-record slice: the + * fields below, all computed at construction (raw decodes the input, + * and the others read raw). validFrom/validTo are Node's + * ASN1_TIME_print strings ("Jul 1 00:00:00 2026 GMT"). Unparseable + * input throws Node's ERR_OSSL_PEM_NO_START_LINE Error. */ export class X509Certificate { constructor(buffer: Uint8Array | string); + readonly ca: boolean; readonly fingerprint: string; + readonly fingerprint256: string; + readonly fingerprint512: string; + readonly subject: string; + readonly subjectAltName: string | undefined; + readonly infoAccess: string | undefined; + readonly issuer: string; + readonly raw: Buffer; + readonly serialNumber: string; + readonly signatureAlgorithm: string | undefined; + readonly signatureAlgorithmOid: string; readonly validFrom: string; + readonly validFromDate: Date; readonly validTo: string; + readonly validToDate: Date; + /* the certificate's public key; every read answers the same KeyObject */ + readonly publicKey: KeyObject; + verify(publicKey: KeyObject): boolean; + checkPrivateKey(privateKey: KeyObject): boolean; + checkIssued(otherCert: X509Certificate): boolean; + checkHost(name: string, options?: X509CheckOptions): string | undefined; + checkEmail(email: string, options?: X509CheckOptions): string | undefined; + checkIP(ip: string): string | undefined; + toLegacyObject(): import("tls").PeerCertificate; + } + export interface X509CheckOptions { + subject?: "always" | "default" | "never"; + wildcards?: boolean; + partialWildcards?: boolean; + multiLabelWildcards?: boolean; + singleLabelSubdomains?: boolean; } /* The crypto introspection statics: getFips() answers 0 (a compiled * binary is never a FIPS build), the three name lists bake as fresh @@ -5473,6 +5501,11 @@ declare module "net" { /* The demux route: hand an accepted socket to another server. */ emit(event: "connection", socket: Socket): boolean; on(event: "connection" | "secureConnection", listener: (socket: Socket) => void): void; + /* tls and https servers: a handshake that failed */ + on( + event: "tlsClientError", + listener: (err: Error, tlsSocket: import("tls").TLSSocket) => void, + ): void; on(event: "close" | "listening", listener: () => void): void; on(event: "drop", listener: (data?: DropArgument) => void): void; on(event: "timeout", listener: (socket: Socket) => void): void; @@ -5492,6 +5525,11 @@ declare module "net" { ): void; /* addListener IS on (Node aliases them) — the suite spells both. */ addListener(event: "connection" | "secureConnection", listener: (socket: Socket) => void): void; + /* tls and https servers: a handshake that failed */ + addListener( + event: "tlsClientError", + listener: (err: Error, tlsSocket: import("tls").TLSSocket) => void, + ): void; addListener(event: "close" | "listening", listener: () => void): void; addListener(event: "drop", listener: (data?: DropArgument) => void): void; addListener(event: "timeout", listener: (socket: Socket) => void): void; @@ -5505,6 +5543,11 @@ declare module "net" { listener: (req: import("http").IncomingMessage, res: import("http").ServerResponse) => void, ): void; once(event: "connection" | "secureConnection", listener: (socket: Socket) => void): void; + /* tls and https servers: a handshake that failed */ + once( + event: "tlsClientError", + listener: (err: Error, tlsSocket: import("tls").TLSSocket) => void, + ): void; once(event: "close" | "listening", listener: () => void): void; once(event: "drop", listener: (data?: DropArgument) => void): void; once(event: "timeout", listener: (socket: Socket) => void): void; @@ -5958,8 +6001,15 @@ declare module "node:http" { * are PEM strings or Buffers. Handshake failures tear the socket down * without emitting an event, Node's default for 'tlsClientError' with no listener. */ declare module "tls" { - import { Server, Socket } from "net"; - export { Server }; + import { Server as NetServer, Socket } from "net"; + /** tls.Server: a net server whose connections handshake first. */ + export interface Server extends NetServer { + /* serve `context` to clients whose SNI name matches `hostname` ('*' + * matches within one label); the newest match wins */ + addContext(hostname: string, context: TlsOptions | SecureContext): void; + /* new connections serve a context made from `options` */ + setSecureContext(options: TlsOptions): void; + } export interface TlsOptions { /* Optional in the DECLARATION only (real Node programs build these * literals with every TLS knob): the lowering still requires both — @@ -5993,7 +6043,47 @@ declare module "tls" { getCipher(): CipherNameAndProtocol; /* whether the handshake resumed a cached session */ isSessionReused(): boolean; + /* the peer's certificate: {} before the handshake or without one, + * null once the socket is destroyed; detailed adds issuerCertificate */ + getPeerCertificate(detailed: true): DetailedPeerCertificate; + getPeerCertificate(detailed?: false): PeerCertificate; + getPeerCertificate(detailed?: boolean): PeerCertificate | DetailedPeerCertificate; + /* the certificate this socket serves, the same way */ + getCertificate(): PeerCertificate | object | null; + /* a new X509Certificate on each call, or undefined without one */ + getPeerX509Certificate(): import("crypto").X509Certificate | undefined; + getX509Certificate(): import("crypto").X509Certificate | undefined; } + /* A certificate as getPeerCertificate() and X509Certificate's + * toLegacyObject() answer it: subject and issuer map attribute short + * names to values, infoAccess its " - " keys to values. */ + export interface PeerCertificate { + subject: { [attribute: string]: string | string[] }; + issuer: { [attribute: string]: string | string[] }; + subjectaltname?: string; + infoAccess?: { [key: string]: string[] }; + ca: boolean; + modulus?: string; + exponent?: string; + pubkey?: Buffer; + bits?: number; + valid_from: string; + valid_to: string; + fingerprint: string; + fingerprint256: string; + fingerprint512: string; + ext_key_usage?: string[]; + serialNumber: string; + raw: Buffer; + asn1Curve?: string; + nistCurve?: string; + } + /* getPeerCertificate(true): each certificate links to its issuer; a + * self-issued root is its own issuerCertificate */ + export interface DetailedPeerCertificate extends PeerCertificate { + issuerCertificate: DetailedPeerCertificate; + } + /** new tls.TLSSocket(socket, options): the server-mode wrap of an open * socket (a server connection or an http CONNECT socket). The options * record reads at runtime; isServer, cert, key, secureContext, @@ -6048,6 +6138,8 @@ declare module "tls" { /** An opaque parsed cert/key pair — what an SNI callback answers with * (the value is a runtime handle; the members are runtime-internal). */ export interface SecureContext {} + /* @types/node's name for createSecureContext's options */ + export type SecureContextOptions = TlsOptions; export function createSecureContext(options: TlsOptions): SecureContext; /** The CA-store introspection surface (scr_tls_ca.c): per-type cached * PEM string arrays. The host bundle stands in for Node's compiled-in diff --git a/packages/compiler/ambient/scriptc-overrides.d.ts b/packages/compiler/ambient/scriptc-overrides.d.ts index 2dafbcdf0..376eba9d5 100644 --- a/packages/compiler/ambient/scriptc-overrides.d.ts +++ b/packages/compiler/ambient/scriptc-overrides.d.ts @@ -70,3 +70,24 @@ interface PromiseConstructor { reject: (reason: Error) => void; }; } + +/* Carriers of the types the lowerer gives untyped JavaScript collections + * whose uses prove them (js-collection-types.ts). Nothing reads them at run + * time. */ +declare namespace __scriptcInferredCollections { + const map_string_string: Map; + const map_string_number: Map; + const map_string_boolean: Map; + const map_number_string: Map; + const map_number_number: Map; + const map_number_boolean: Map; + const map_boolean_string: Map; + const map_boolean_number: Map; + const map_boolean_boolean: Map; + const set_string: Set; + const set_number: Set; + const set_boolean: Set; + const array_string: string[]; + const array_number: number[]; + const array_boolean: boolean[]; +} diff --git a/packages/compiler/native/cli.profdata.gz b/packages/compiler/native/cli.profdata.gz index 31423fa04..aa9398b5f 100644 Binary files a/packages/compiler/native/cli.profdata.gz and b/packages/compiler/native/cli.profdata.gz differ diff --git a/packages/compiler/native/host.c b/packages/compiler/native/host.c index f54bdaa3f..24d330cc0 100644 --- a/packages/compiler/native/host.c +++ b/packages/compiler/native/host.c @@ -135,3 +135,16 @@ void scr_cyc_set_growth_cap(long cap); void scriptc_native_bound_cycle_growth(void) { scr_cyc_set_growth_cap(2); } + +/* The runtime's nursery trigger (scr_rc.h). */ +void scr_cyc_set_nursery_threshold(long threshold); + +/* Runs nursery passes every 1024 new candidates instead of every 256. Much + * of a build's cyclic garbage outlives 256 releases: in the shorter window + * it survives a nursery pass, is promoted, and only full passes over the + * whole live heap find it. A constructor sets it before the compiler's + * modules initialize, so every pass of a build uses it. + * SCR_CYCLE_THRESHOLD still overrides it. */ +__attribute__((constructor)) static void scriptc_native_widen_cycle_nursery(void) { + scr_cyc_set_nursery_threshold(1024); +} diff --git a/packages/compiler/native/ts7-json.test.c b/packages/compiler/native/ts7-json.test.c new file mode 100644 index 000000000..b696d77c2 --- /dev/null +++ b/packages/compiler/native/ts7-json.test.c @@ -0,0 +1,33 @@ +/* Reads cases from standard input, each a little-endian u32 length, a u32 + * start and the bytes, and prints scriptc_ts7_json_value_end for each. */ +#include "ts7-process.h" +#include +#include +#ifdef _WIN32 +#include +#include +#endif + +static int read_u32(uint32_t *value) { + unsigned char bytes[4]; + if (fread(bytes, 1, 4, stdin) != 4) return 0; + *value = (uint32_t)bytes[0] | (uint32_t)bytes[1] << 8 | (uint32_t)bytes[2] << 16 | + (uint32_t)bytes[3] << 24; + return 1; +} + +int main(void) { +#ifdef _WIN32 + /* The cases are bytes: text mode would rewrite line endings. */ + _setmode(_fileno(stdin), _O_BINARY); +#endif + uint32_t length, start; + while (read_u32(&length)) { + if (!read_u32(&start)) return 1; + uint8_t *bytes = malloc(length == 0 ? 1 : length); + if (bytes == NULL || fread(bytes, 1, length, stdin) != length) return 1; + printf("%d\n", (int)scriptc_ts7_json_value_end(bytes, length, start)); + free(bytes); + } + return 0; +} diff --git a/packages/compiler/native/ts7-process-child.test.c b/packages/compiler/native/ts7-process-child.test.c index 7202cab51..386a0a7c3 100644 --- a/packages/compiler/native/ts7-process-child.test.c +++ b/packages/compiler/native/ts7-process-child.test.c @@ -12,7 +12,49 @@ #include #include #else +#include +#include +#include #include + +/* `--listen PATH --idle-exit SECONDS`: a stand-in for a shared server. In + * mode "listen-exit" it exits as an executable without --listen does. In + * mode "listen" it answers each client, in order, with its client number, + * its listen arguments and TSC_TS_LISTEN_MEMORY, then echoes until the + * client closes, and exits after SECONDS (at most 5) without a client. */ +static int listen_main(const char *mode, char **argv) { + if (strcmp(mode, "listen-exit") == 0) return 2; + if (strcmp(mode, "listen") != 0) return 73; + struct sockaddr_un address; + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + if (strlen(argv[0]) >= sizeof(address.sun_path)) return 77; + strcpy(address.sun_path, argv[0]); + int listener = socket(AF_UNIX, SOCK_STREAM, 0); + if (listener < 0 || bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + listen(listener, 4) != 0) + return 78; + const char *memory = getenv("TSC_TS_LISTEN_MEMORY"); + int idle = atoi(argv[2]); + if (idle > 5) idle = 5; + for (int client = 1;; client++) { + struct pollfd entry = {listener, POLLIN, 0}; + if (poll(&entry, 1, idle * 1000) <= 0) break; + int fd = accept(listener, NULL, NULL); + if (fd < 0) return 79; + char line[512]; + int size = snprintf(line, sizeof(line), "client %d\n%s %s\n%s\n", client, argv[0], argv[2], + memory == NULL ? "unset" : memory); + if (size <= 0 || write(fd, line, (size_t)size) != size) return 80; + uint8_t bytes[17]; + ssize_t count; + while ((count = read(fd, bytes, sizeof(bytes))) > 0) + if (write(fd, bytes, (size_t)count) != count) break; + close(fd); + } + unlink(argv[0]); + return 0; +} #endif static int child_main(int argc, char **argv) { @@ -20,9 +62,15 @@ static int child_main(int argc, char **argv) { _setmode(_fileno(stdin), _O_BINARY); _setmode(_fileno(stdout), _O_BINARY); #endif - if (argc < 5 || argc > 6 || strcmp(argv[1], "--api") != 0 || strcmp(argv[2], "--cwd") != 0 || - strncmp(argv[4], "--callbacks=", 12) != 0 || (argc == 6 && strcmp(argv[5], "--timing") != 0)) return 71; + int listening = argc >= 9 && strcmp(argv[5], "--listen") == 0 && strcmp(argv[7], "--idle-exit") == 0; + int fixed = listening ? 9 : 5; + if (argc < fixed || argc > fixed + 1 || strcmp(argv[1], "--api") != 0 || strcmp(argv[2], "--cwd") != 0 || + strncmp(argv[4], "--callbacks=", 12) != 0 || (argc == fixed + 1 && strcmp(argv[fixed], "--timing") != 0)) + return 71; const char *mode = argv[3]; +#ifndef _WIN32 + if (listening) return listen_main(mode, argv + 6); +#endif if (strncmp(mode, "argv:", 5) == 0) { fwrite(mode + 5, 1, strlen(mode + 5), stdout); fputc('\n', stdout); diff --git a/packages/compiler/native/ts7-process.c b/packages/compiler/native/ts7-process.c index ea451345d..3fd38a423 100644 --- a/packages/compiler/native/ts7-process.c +++ b/packages/compiler/native/ts7-process.c @@ -1,5 +1,8 @@ #ifndef _WIN32 #define _POSIX_C_SOURCE 200809L +/* flock(2), for the checker server's lock file, and syscall(2). */ +#define _DEFAULT_SOURCE +#define _DARWIN_C_SOURCE #endif #include "ts7-process.h" #include @@ -16,7 +19,13 @@ #include #include #include +#include #include +#include +#ifdef __linux__ +#include +#endif +#include #include #include #include @@ -38,6 +47,9 @@ typedef struct Ts7Process { HANDLE output; #else int socket; + /* The checker server's lock file, held while connected to the server + * (scriptc_ts7_attach); -1 for a server this process started. */ + int lock; #endif } Ts7Process; @@ -283,7 +295,7 @@ static int start_process(Ts7Process *p, char **args, size_t argc) { if (error == 0) error = posix_spawn(&pid, args[0], &actions, NULL, args, environment); posix_spawn_file_actions_destroy(&actions); if (error == 0) { - p->socket = sockets[0]; p->pid = (uint32_t)pid; + p->socket = sockets[0]; p->pid = (uint32_t)pid; p->lock = -1; } done: close(sockets[1]); @@ -293,15 +305,21 @@ done: static void stop_process(Ts7Process *p) { close(p->socket); + // A shared checker server keeps running; closing the connection ends this + // build's session, and closing the lock file lets another build connect. + if (p->lock >= 0) { close(p->lock); return; } int status; pid_t pid = (pid_t)p->pid; - for (int attempt = 0; attempt < 5; attempt++) { + // A server exits within a fraction of a millisecond of reading EOF, so the + // checks start 0.1 ms apart and double: 51.1 ms in all before the kill. + for (int attempt = 0; attempt < 10; attempt++) { pid_t result; do { result = waitpid(pid, &status, WNOHANG); } while (result < 0 && errno == EINTR); // ECHILD also means the PID no longer belongs to us. Never signal a // potentially recycled PID after somebody else has reaped the child. if (result == pid || (result < 0 && errno == ECHILD)) return; - struct timespec delay = {0, 10000000}; + if (attempt == 9) break; + struct timespec delay = {0, 100000L << attempt}; while (nanosleep(&delay, &delay) != 0 && errno == EINTR) {} } (void)kill(pid, SIGKILL); @@ -338,6 +356,179 @@ uint32_t scriptc_ts7_open(const uint8_t *executable, size_t executable_size, return p->id; } +#ifdef _WIN32 +uint32_t scriptc_ts7_attach(const uint8_t *name, size_t name_size, + const uint8_t *executable, size_t executable_size, + const uint8_t *cwd, size_t cwd_size, + const uint8_t *callbacks, size_t callbacks_size, + uint8_t timing, uint32_t idle_exit, uint32_t memory) { + (void)name; (void)name_size; (void)executable; (void)executable_size; (void)cwd; (void)cwd_size; + (void)callbacks; (void)callbacks_size; (void)timing; (void)idle_exit; (void)memory; + error_text("a shared checker server needs Unix sockets"); + return 0; +} +#else +/* This user's checker server directory: $XDG_RUNTIME_DIR/scriptc, or + * /tmp/scriptc-. It must be a directory this user owns with no access + * for others; one that exists otherwise is not used. */ +static int server_directory(char *path, size_t size) { + const char *runtime = getenv("XDG_RUNTIME_DIR"); + int count = runtime != NULL && runtime[0] == '/' + ? snprintf(path, size, "%s/scriptc", runtime) + : snprintf(path, size, "/tmp/scriptc-%lu", (unsigned long)geteuid()); + if (count < 0 || (size_t)count >= size) { error_text("checker server directory path is too long"); return 0; } + if (mkdir(path, 0700) != 0 && errno != EEXIST) { system_error(errno); return 0; } + struct stat info; + if (lstat(path, &info) != 0) { system_error(errno); return 0; } + if (!S_ISDIR(info.st_mode) || info.st_uid != geteuid() || (info.st_mode & 077) != 0) { + error_text("checker server directory is not private to this user"); + return 0; + } + return 1; +} + +/* A connection to the server listening at `path`, or -1. */ +static int connect_server(const char *path) { + struct sockaddr_un address; + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + memcpy(address.sun_path, path, strlen(path) + 1); + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -1; + if (fcntl(fd, F_SETFD, FD_CLOEXEC) < 0) { close(fd); return -1; } +#ifdef SO_NOSIGPIPE + int no_sigpipe = 1; + if (setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &no_sigpipe, sizeof(no_sigpipe)) != 0) { close(fd); return -1; } +#endif + int status; + do { status = connect(fd, (const struct sockaddr *)&address, sizeof(address)); } while (status != 0 && errno == EINTR); + if (status != 0) { close(fd); return -1; } + return fd; +} + +/* Starts the server in its own process group, so that a terminal's signals + * to the build do not reach it, with its standard streams on /dev/null and + * no other descriptor of this process, so that it holds no pipe a caller of + * the build waits on. `limit` (TSC_TS_LISTEN_MEMORY=...) is added to the + * environment unless it sets TSC_TS_LISTEN_MEMORY. */ +static pid_t start_server(char **args, char *limit) { +#ifdef __APPLE__ + char **inherited = *_NSGetEnviron(); +#else + char **inherited = environ; +#endif + size_t count = 0; + int has_limit = 0; + for (; inherited[count] != NULL; count++) + if (strncmp(inherited[count], "TSC_TS_LISTEN_MEMORY=", 21) == 0) has_limit = 1; + char **environment = malloc((count + 2) * sizeof(char *)); + if (environment == NULL) { error_text("out of memory"); return -1; } + memcpy(environment, inherited, count * sizeof(char *)); + environment[count] = has_limit ? NULL : limit; + environment[count + 1] = NULL; + long descriptors = sysconf(_SC_OPEN_MAX); + if (descriptors < 0 || descriptors > 65536) descriptors = 65536; + sigset_t signals; + sigemptyset(&signals); + pid_t pid = fork(); + if (pid == 0) { + // Only async-signal-safe calls until exec. + int null = open("/dev/null", O_RDWR); + if (setpgid(0, 0) != 0 || null < 0 || dup2(null, STDIN_FILENO) < 0 || dup2(null, STDOUT_FILENO) < 0 || + dup2(null, STDERR_FILENO) < 0) + _exit(127); +#if defined(__linux__) && defined(SYS_close_range) + if (syscall(SYS_close_range, 3U, ~0U, 0U) != 0) +#endif + for (long fd = 3; fd < descriptors; fd++) close((int)fd); + sigprocmask(SIG_SETMASK, &signals, NULL); + execve(args[0], args, environment); + _exit(127); + } + int error = errno; + free(environment); + if (pid < 0) { system_error(error); return -1; } + return pid; +} + +uint32_t scriptc_ts7_attach(const uint8_t *name, size_t name_size, + const uint8_t *executable, size_t executable_size, + const uint8_t *cwd, size_t cwd_size, + const uint8_t *callbacks, size_t callbacks_size, + uint8_t timing, uint32_t idle_exit, uint32_t memory) { + if (next_id == 0) { error_text("server handle IDs are exhausted"); return 0; } + char *server = copy_argument(name, name_size, ""); + if (server == NULL) return 0; + char directory[256], socket_path[sizeof(((struct sockaddr_un *)0)->sun_path)], lock_path[512]; + int named = strchr(server, '/') == NULL; + if (!named) error_text("checker server names cannot contain '/'"); + if (named) named = server_directory(directory, sizeof(directory)); + if (named) { + int count = snprintf(socket_path, sizeof(socket_path), "%s/%s.sock", directory, server); + named = count > 0 && (size_t)count < sizeof(socket_path); + if (!named) error_text("checker server socket path is too long"); + else (void)snprintf(lock_path, sizeof(lock_path), "%s.lock", socket_path); + } + free(server); + if (!named) return 0; + // One build at a time uses the server; a build that finds it in use + // starts its own server instead of waiting. + int lock = open(lock_path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600); + if (lock < 0) { system_error(errno); return 0; } + if (flock(lock, LOCK_EX | LOCK_NB) != 0) { + if (errno == EWOULDBLOCK) error_text("the checker server is in use"); + else system_error(errno); + close(lock); + return 0; + } + int fd = connect_server(socket_path); + if (fd < 0) { + char *exe = copy_argument(executable, executable_size, ""); + char *dir = exe == NULL ? NULL : copy_argument(cwd, cwd_size, ""); + char *callback_flag = dir == NULL ? NULL : copy_argument(callbacks, callbacks_size, "--callbacks="); + char idle[16], limit[48]; + (void)snprintf(idle, sizeof(idle), "%lu", (unsigned long)idle_exit); + (void)snprintf(limit, sizeof(limit), "TSC_TS_LISTEN_MEMORY=%lu", (unsigned long)memory); + pid_t pid = -1; + if (callback_flag != NULL) { + char *args[] = {exe, "--api", "--cwd", dir, callback_flag, "--listen", socket_path, "--idle-exit", idle, + timing ? "--timing" : NULL, NULL}; + pid = start_server(args, limit); + } + free(exe); free(dir); free(callback_flag); + // The server listens as it starts. One that exits first, such as a + // tsc-ts without --listen, or one that is not listening within 2 s, + // leaves this build to start its own server. + for (int attempt = 0; pid > 0; attempt++) { + fd = connect_server(socket_path); + if (fd >= 0) break; + int status; + pid_t result; + do { result = waitpid(pid, &status, WNOHANG); } while (result < 0 && errno == EINTR); + if (result != 0) { error_text("the checker server exited as it started"); break; } + if (attempt == 1000) { error_text("the checker server did not start listening"); break; } + struct timespec delay = {0, 2000000}; + while (nanosleep(&delay, &delay) != 0 && errno == EINTR) {} + } + } + if (fd < 0) { close(lock); return 0; } + Ts7Process *p = calloc(1, sizeof(*p)); + if (p == NULL) { error_text("out of memory"); close(fd); close(lock); return 0; } + if (!exit_registered) { + if (atexit(scriptc_ts7_close_all) != 0) { + error_text("could not register server cleanup"); free(p); close(fd); close(lock); return 0; + } + exit_registered = 1; + } + p->socket = fd; + p->lock = lock; + p->id = next_id++; + p->next = processes; + processes = p; + return p->id; +} +#endif + uint32_t scriptc_ts7_pid(uint32_t handle) { Ts7Process *p = find_process(handle); return p != NULL ? p->pid : 0; @@ -405,3 +596,47 @@ void scriptc_ts7_close(uint32_t handle) { void scriptc_ts7_close_all(void) { while (processes != NULL) scriptc_ts7_close(processes->id); } + +static int json_space(uint8_t byte) { + return byte == ' ' || byte == '\n' || byte == '\r' || byte == '\t'; +} + +int32_t scriptc_ts7_json_value_end(const uint8_t *bytes, size_t size, uint32_t start) { + if (size > INT32_MAX) return -1; + size_t depth = 0; + size_t index = start; + while (index < size) { + uint8_t byte = bytes[index]; + if (byte == '"') { + index++; + // The quote found stays the candidate end until an escape skips it, so + // every byte of the string is scanned once by each search. + const uint8_t *quote = index < size ? memchr(bytes + index, '"', size - index) : NULL; + for (;;) { + if (quote == NULL) return -1; + const uint8_t *escape = memchr(bytes + index, '\\', (size_t)(quote - (bytes + index))); + if (escape == NULL) { + index = (size_t)(quote - bytes); + break; + } + index = (size_t)(escape - bytes) + 2; + if (bytes + index > quote) quote = index < size ? memchr(bytes + index, '"', size - index) : NULL; + } + index++; + if (depth == 0) return (int32_t)index; + continue; + } + if (byte == '{' || byte == '[') { + depth++; + } else if (byte == '}' || byte == ']') { + if (depth == 0) return (int32_t)index; + depth--; + if (depth == 0) return (int32_t)(index + 1); + } else if (depth == 0 && (byte == ',' || json_space(byte))) { + return (int32_t)index; + } + index++; + } + if (depth != 0) return -1; + return (int32_t)index; +} diff --git a/packages/compiler/native/ts7-process.ffi.json b/packages/compiler/native/ts7-process.ffi.json index 86f3cb4d2..c503e3cae 100644 --- a/packages/compiler/native/ts7-process.ffi.json +++ b/packages/compiler/native/ts7-process.ffi.json @@ -2,11 +2,13 @@ "ffi_format": 6, "functions": [ { "name": "ts7NativeOpen", "symbol": "scriptc_ts7_open", "params": ["string", "string", "string", "bool"], "returns": "u32" }, + { "name": "ts7NativeAttach", "symbol": "scriptc_ts7_attach", "params": ["string", "string", "string", "string", "bool", "u32", "u32"], "returns": "u32" }, { "name": "ts7NativeRead", "symbol": "scriptc_ts7_read", "params": ["u32", "mutable-bytes", "u32", "u32"], "returns": "i32" }, { "name": "ts7NativeWrite", "symbol": "scriptc_ts7_write", "params": ["u32", "bytes", "u32", "u32"], "returns": "i32" }, { "name": "ts7NativePid", "symbol": "scriptc_ts7_pid", "params": ["u32"], "returns": "u32" }, { "name": "ts7NativeClose", "symbol": "scriptc_ts7_close", "params": ["u32"], "returns": "void" }, { "name": "ts7NativeCloseAll", "symbol": "scriptc_ts7_close_all", "params": [], "returns": "void" }, - { "name": "ts7NativeError", "symbol": "scriptc_ts7_error", "params": ["mutable-bytes"], "returns": "i32" } + { "name": "ts7NativeError", "symbol": "scriptc_ts7_error", "params": ["mutable-bytes"], "returns": "i32" }, + { "name": "ts7NativeJsonValueEnd", "symbol": "scriptc_ts7_json_value_end", "params": ["bytes", "u32"], "returns": "i32" } ] } diff --git a/packages/compiler/native/ts7-process.h b/packages/compiler/native/ts7-process.h index 3d2a0ba05..4199e8795 100644 --- a/packages/compiler/native/ts7-process.h +++ b/packages/compiler/native/ts7-process.h @@ -13,6 +13,19 @@ uint32_t scriptc_ts7_open(const uint8_t *executable, size_t executable_size, const uint8_t *cwd, size_t cwd_size, const uint8_t *callbacks, size_t callbacks_size, uint8_t timing); +/* Connects to the shared checker server `name` (`--api --listen`) in this + * user's private server directory, starting it in its own process group + * with `--idle-exit idle_exit` and a limit of `memory` megabytes + * (TSC_TS_LISTEN_MEMORY, unless the environment sets it) when none answers. Returns 0 with the error + * text when another build is connected to it, it cannot be started or + * reached, or the platform has no Unix sockets; the caller then starts its + * own server with scriptc_ts7_open. Closing the handle ends the connection + * and leaves the server running. */ +uint32_t scriptc_ts7_attach(const uint8_t *name, size_t name_size, + const uint8_t *executable, size_t executable_size, + const uint8_t *cwd, size_t cwd_size, + const uint8_t *callbacks, size_t callbacks_size, + uint8_t timing, uint32_t idle_exit, uint32_t memory); int32_t scriptc_ts7_read(uint32_t handle, uint8_t *bytes, size_t size, uint32_t offset, uint32_t length); int32_t scriptc_ts7_write(uint32_t handle, const uint8_t *bytes, size_t size, @@ -21,5 +34,9 @@ uint32_t scriptc_ts7_pid(uint32_t handle); void scriptc_ts7_close(uint32_t handle); void scriptc_ts7_close_all(void); int32_t scriptc_ts7_error(uint8_t *bytes, size_t size); +/* The index after the JSON value that starts at `start` in a checker reply, + * as jsonValueEnd in semantic-json.ts computes it, or -1 when the reply is + * malformed there. */ +int32_t scriptc_ts7_json_value_end(const uint8_t *bytes, size_t size, uint32_t start); #endif diff --git a/packages/compiler/native/ts7-process.test.c b/packages/compiler/native/ts7-process.test.c index e17c367c7..dc0c67ced 100644 --- a/packages/compiler/native/ts7-process.test.c +++ b/packages/compiler/native/ts7-process.test.c @@ -11,7 +11,9 @@ #include #else #include +#include #include +#include #include #include #endif @@ -171,6 +173,89 @@ static void failure_cases(void) { scriptc_ts7_close(UINT32_MAX); } +#ifndef _WIN32 +static uint32_t attach(const char *name, const char *mode, uint32_t memory) { + return scriptc_ts7_attach((const uint8_t *)name, strlen(name), (const uint8_t *)executable, strlen(executable), + (const uint8_t *)mode, strlen(mode), (const uint8_t *)callbacks, strlen(callbacks), + 0, 1, memory); +} + +static void expect_line(uint32_t id, const char *expected) { + char line[512]; + size_t at = 0; + do { + assert(at + 1 < sizeof(line)); + read_exact(id, (uint8_t *)line + at, 1); + } while (line[at++] != '\n'); + line[at] = '\0'; + assert(strcmp(line, expected) == 0); +} + +static void attach_cases(void) { + char runtime[] = "/tmp/scriptc-ts7-attach-XXXXXX"; + assert(mkdtemp(runtime) != NULL); + assert(setenv("XDG_RUNTIME_DIR", runtime, 1) == 0); + assert(unsetenv("TSC_TS_LISTEN_MEMORY") == 0); + char directory[256], socket_path[512], expected[600]; + uint8_t bytes[4]; + snprintf(directory, sizeof(directory), "%s/scriptc", runtime); + snprintf(socket_path, sizeof(socket_path), "%s/server.sock", directory); + // The first build starts the server with its idle time and memory limit. + // The server holds none of the build's descriptors, such as an output + // pipe its caller reads to the end. + int output[2]; + assert(pipe(output) == 0); + uint32_t first = attach("server", "listen", 123); + assert(first != 0 && scriptc_ts7_pid(first) == 0); + close(output[1]); + struct pollfd ended = {output[0], POLLIN, 0}; + assert(poll(&ended, 1, 5000) == 1); + assert(read(output[0], bytes, 1) == 0); + close(output[0]); + expect_line(first, "client 1\n"); + snprintf(expected, sizeof(expected), "%s 1\n", socket_path); + expect_line(first, expected); + expect_line(first, "123\n"); + write_exact(first, (const uint8_t *)"ping", 4, 0, 4); + read_exact(first, bytes, 4); + assert(memcmp(bytes, "ping", 4) == 0); + // A build that finds the server in use gets no handle. + for (int i = 0; i < 3; i++) { assert(attach("server", "listen", 123) == 0); fail_message(); } + scriptc_ts7_close(first); + scriptc_ts7_close(first); + // A later build connects to the same server. + uint32_t second = attach("server", "listen", 123); + assert(second != 0); + expect_line(second, "client 2\n"); + scriptc_ts7_close(second); + // An executable without --listen exits as it starts. + assert(attach("old", "listen-exit", 0) == 0); + fail_message(); + // A server directory others can access is not used. + assert(chmod(directory, 0755) == 0); + assert(attach("server", "listen", 123) == 0); + fail_message(); + assert(chmod(directory, 0700) == 0); + // A name that would leave the directory, or make the path too long. + assert(attach("../server", "listen", 123) == 0); + fail_message(); + char long_name[200]; + memset(long_name, 'x', sizeof(long_name) - 1); + long_name[sizeof(long_name) - 1] = '\0'; + assert(attach(long_name, "listen", 123) == 0); + fail_message(); + // The server exits after its idle second and removes its socket. + unlink(socket_path); + snprintf(expected, sizeof(expected), "%s.lock", socket_path); + unlink(expected); + snprintf(expected, sizeof(expected), "%s/old.sock.lock", directory); + unlink(expected); + rmdir(directory); + rmdir(runtime); + assert(unsetenv("XDG_RUNTIME_DIR") == 0); +} +#endif + static int process_main(int argc, char **argv) { assert(argc >= 2); executable = argv[1]; @@ -196,6 +281,9 @@ static int process_main(int argc, char **argv) { argument_cases(); exchange_cases(); failure_cases(); +#ifndef _WIN32 + attach_cases(); +#endif assert(resource_count() == before); #ifndef _WIN32 if (saved_input >= 0) { assert(dup2(saved_input, STDIN_FILENO) >= 0); close(saved_input); } diff --git a/packages/compiler/node_modules b/packages/compiler/node_modules new file mode 120000 index 000000000..6b01b0eda --- /dev/null +++ b/packages/compiler/node_modules @@ -0,0 +1 @@ +../../../shape-reg/packages/compiler/node_modules \ No newline at end of file diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index 2afb887ea..2a6c6959e 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -19,6 +19,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures { ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), + ...(features.moduleNamespace ? { moduleNamespace: true } : {}), zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), assert: features.assert, @@ -62,6 +63,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures { dh: features.dh, pki: features.pki, ed25519: features.ed25519, + x509: features.x509, webcrypto: features.webcrypto, perfLoop: features.perfLoop, }; diff --git a/packages/compiler/src/backend/library-pack.ts b/packages/compiler/src/backend/library-pack.ts index 79be316db..521f1d53e 100644 --- a/packages/compiler/src/backend/library-pack.ts +++ b/packages/compiler/src/backend/library-pack.ts @@ -64,6 +64,7 @@ export async function compilePackedLibrary( dh: false, pki: false, ed25519: false, + x509: options.x509 ?? false, webcrypto: false, perfLoop: false, tlsCa: false, @@ -152,7 +153,7 @@ export async function compilePackedLibrary( ...(await Promise.all( shards .slice(start, start + 4) - .map((shard, offset) => emit(shard.source, `shard-${start + offset}.o`)), + .map((shard, offset) => emit(shard.source(), `shard-${start + offset}.o`)), )), ); } diff --git a/packages/compiler/src/backend/llvm/blocks.ts b/packages/compiler/src/backend/llvm/blocks.ts index 354e631c6..eee4d00b9 100644 --- a/packages/compiler/src/backend/llvm/blocks.ts +++ b/packages/compiler/src/backend/llvm/blocks.ts @@ -9,6 +9,7 @@ * code after it); here the lines are DROPPED instead, and emitStmts skips * whole statements once the current block is terminated, so no dropped * definition can leak into a later block. */ +import { UNWIND_RESUME, UNWIND_SLOT } from "./unwind-abi.js"; export class BlockBuilder { private readonly blocks: { label: string; lines: string[]; term: string | null }[] = []; @@ -86,7 +87,18 @@ export class BlockBuilder { terminate(s: string): void { if (this.cur.term === null) { - if (this.returnEpilogue && s.startsWith("ret ")) this.line(this.returnEpilogue); + if ( + this.returnEpilogue && + (s.startsWith("ret ") || + s.startsWith("call void @scr_unwind_raise()") || + s === UNWIND_RESUME) + ) + this.line(this.returnEpilogue); + if (s === UNWIND_RESUME) { + const exception = this.tmp(); + this.line(`${exception} = load { ptr, i32 }, ptr ${UNWIND_SLOT}`); + s = `resume { ptr, i32 } ${exception}`; + } this.cur.term = ` ${this.withDebugLocation(s)}`; } } diff --git a/packages/compiler/src/backend/llvm/bytes-alias.ts b/packages/compiler/src/backend/llvm/bytes-alias.ts index d43620fd9..b7a672754 100644 --- a/packages/compiler/src/backend/llvm/bytes-alias.ts +++ b/packages/compiler/src/backend/llvm/bytes-alias.ts @@ -18,8 +18,10 @@ export function bytesHeaderAlias(host: LlvmEmitterContext): string { return tag === "" ? "" : `${tag}, !invariant.load !{}`; } +/** Element storage's tag, and the alias scope of the access being emitted + * in a loop version's fast copy (bytesAccessScope). */ export function bytesElementAlias(host: LlvmEmitterContext): string { - return host.memoryAliasAttachment("bytes:elements"); + return host.memoryAliasAttachment("bytes:elements") + host.bytesAccessScope; } /** A module global's slot is its own category for whole-slot loads of a diff --git a/packages/compiler/src/backend/llvm/bytes-bounds.ts b/packages/compiler/src/backend/llvm/bytes-bounds.ts index 86a1d7ac8..29b1c8e15 100644 --- a/packages/compiler/src/backend/llvm/bytes-bounds.ts +++ b/packages/compiler/src/backend/llvm/bytes-bounds.ts @@ -14,11 +14,25 @@ interface Bound extends Extent { minimum: number; } type Node = IrExpr | IrStmt; +const FLIPPED: Readonly> = { + "<": ">", + "<=": ">=", + ">": "<", + ">=": "<=", + "===": "===", + "==": "==", + "!==": "!==", + "!=": "!=", +}; /** Relational proofs for fixed typed-array lengths. Cached lengths retain * their receiver identity only when that binding is never reassigned. * Loops may use offsets in either direction, but both index integrality - * and receiver stability are established separately from the relation. */ + * and receiver stability are established separately from the relation. + * For loops hold one bound over the body. While loops start from the + * condition's bound and follow the body in order: increments raise it and + * comparisons with the length in if conditions narrow it. Indexes are + * `counter + c` or `L + d - counter`, where L is the receiver's length. */ export function findBytesBounds(fn: IrFunction, ranges: IntegerRanges): ReadonlySet { const proved = new Map(); if (fn.async || fn.generator || !fn.locals.some((local) => local.type.kind === "bytes")) @@ -96,6 +110,20 @@ export function findBytesBounds(fn: IrFunction, ranges: IntegerRanges): Readonly const result = left + (e.op === "+" ? e.right.value : -e.right.value); return Number.isSafeInteger(result) ? result : null; } + /** For an index of the form `L + d - counter` (`len - 1 - i`, `right - i` + * with `right = len - 1`), where L is the receiver's length: d. */ + function reversed(e: IrExpr, bound: Bound): number | null { + if (e.kind !== "bin" || (e.op !== "+" && e.op !== "-")) return null; + if (e.op === "-" && e.right.kind === "varRef" && e.right.localId === bound.counter) { + const length = extent(e.left, false); + return length && length.receiver === bound.receiver ? length.offset : null; + } + if (e.right.kind !== "numLit" || !Number.isSafeInteger(e.right.value)) return null; + const left = reversed(e.left, bound); + if (left === null) return null; + const result = left + (e.op === "+" ? e.right.value : -e.right.value); + return Number.isSafeInteger(result) ? result : null; + } function affineRange(e: IrExpr, bound: Bound): IntegerRange | null { if (e.kind === "varRef" && e.localId === bound.counter) { const max = Number.MAX_SAFE_INTEGER + bound.offset - 1; @@ -159,7 +187,7 @@ export function findBytesBounds(fn: IrFunction, ranges: IntegerRanges): Readonly if (s.cond.left.kind !== "varRef" || s.cond.left.localId !== counter) { if (right.kind !== "varRef" || right.localId !== counter) return null; right = s.cond.left; - op = ({ "<": ">", "<=": ">=", ">": "<", ">=": "<=" } as Record)[op] ?? ""; + op = FLIPPED[op] ?? ""; } const update = s.update; let step = 0; @@ -212,6 +240,98 @@ export function findBytesBounds(fn: IrFunction, ranges: IntegerRanges): Readonly } return result && stable(s.body, result.receiver, counter) ? result : null; } + /** A comparison of the counter with the receiver's length: the operator + * with the counter on the left, and the length's offset. */ + function lengthComparison(e: IrExpr, bound: Bound): { op: string; offset: number } | null { + if (e.kind !== "bin") return null; + let other = e.right, + op: string = e.op; + if (e.left.kind !== "varRef" || e.left.localId !== bound.counter) { + if (other.kind !== "varRef" || other.localId !== bound.counter) return null; + other = e.left; + op = FLIPPED[op] ?? op; + } + const length = extent(other, false); + return length && length.receiver === bound.receiver ? { op, offset: length.offset } : null; + } + /** The step of `counter = counter + c` with a positive literal c, or null. */ + function increment(s: IrStmt, counter: string): number | null { + return s.kind === "assign" && + s.localId === counter && + s.value.kind === "bin" && + s.value.op === "+" && + s.value.left.kind === "varRef" && + s.value.left.localId === counter && + s.value.right.kind === "numLit" && + Number.isSafeInteger(s.value.right.value) && + s.value.right.value > 0 + ? s.value.right.value + : null; + } + function writes(s: IrStmt, id: string): boolean { + return !everyStmtList([s], { + expr: (e) => (e.kind !== "assignExpr" && e.kind !== "incDec") || e.localId !== id, + stmt: (child) => + !( + ((child.kind === "assign" || child.kind === "forOf" || child.kind === "varDecl") && + child.localId === id) || + (child.kind === "tryCatch" && child.catchLocalId === id) + ), + }); + } + /** A while loop whose condition bounds an integer counter by a typed + * array's length. The body may write the counter only by statements + * that add a positive literal, and may not write the receiver. Calls + * cannot write either binding (both are uncaptured locals), and a typed + * array's length is fixed at construction, so the body may hold calls. */ + function whileBound(s: IrStmt & { kind: "while" }): Bound | null { + const cond = s.cond; + if (cond.kind !== "bin") return null; + const counterRef = [cond.left, cond.right].find( + (side, i) => + side.kind === "varRef" && + eligible(side.localId) && + extent(i === 0 ? cond.right : cond.left, false) !== null, + ); + const minimum = counterRef ? ranges.get(counterRef)?.min : undefined; + if (counterRef?.kind !== "varRef" || minimum === undefined) return null; + const counter = counterRef.localId; + const length = extent(counterRef === cond.left ? cond.right : cond.left, false)!; + const receiver = length.receiver; + const comparison = lengthComparison(cond, { receiver, counter, offset: 0, minimum }); + if (comparison?.op !== "<" && comparison?.op !== "<=") return null; + // An increment is a statement of the body: one inside an expression + // (a comma expression's statements) would run where flow does not + // follow it. + const writesOk = everyStmtList(s.body, { + expr: (e) => + ((e.kind !== "assignExpr" && e.kind !== "incDec") || + (e.localId !== counter && e.localId !== receiver)) && + everyExprChild( + e, + () => true, + (child) => !writes(child, counter), + ), + stmt: (child) => { + if (child.kind === "assign" && child.localId === counter) + return increment(child, counter) !== null; + if (child.kind === "assign" || child.kind === "forOf" || child.kind === "varDecl") + return child.localId !== counter && child.localId !== receiver; + return ( + child.kind !== "tryCatch" || + (child.catchLocalId !== counter && child.catchLocalId !== receiver) + ); + }, + }); + return writesOk + ? { + receiver, + counter, + offset: comparison.offset + (comparison.op === "<=" ? 1 : 0), + minimum, + } + : null; + } function access(node: Node, array: IrExpr, index: IrExpr, active: Bound[], width: number): void { const integer = ranges.get(index); let safe = false; @@ -228,6 +348,13 @@ export function findBytesBounds(fn: IrFunction, ranges: IntegerRanges): Readonly safe = true; break; } + // counter < L + bound.offset and counter >= bound.minimum put + // L + d - counter in [0, L - width]. + const d = integer ? reversed(index, bound) : null; + if (d !== null && bound.offset <= d + 1 && bound.minimum >= d + width) { + safe = true; + break; + } } } proved.set(node, (proved.get(node) ?? true) && safe); @@ -244,8 +371,84 @@ export function findBytesBounds(fn: IrFunction, ranges: IntegerRanges): Readonly (child) => stmt(child, active), ); } + /** The bounds where `cond` is true and where it is false, from the + * bound before it. */ + function narrow(cond: IrExpr, bound: Bound): [Bound, Bound] { + const comparison = lengthComparison(cond, bound); + if (!comparison) return [bound, bound]; + const j = comparison.offset, + offset = bound.offset; + // counter < L + j, counter <= L + j, and counter !== L + j. + const below = { ...bound, offset: Math.min(offset, j) }, + atMost = { ...bound, offset: Math.min(offset, j + 1) }, + unequal = offset === j + 1 ? { ...bound, offset: j } : bound; + switch (comparison.op) { + case "<": + return [below, bound]; + case "<=": + return [atMost, bound]; + case ">": + return [bound, atMost]; + case ">=": + return [bound, below]; + case "===": + case "==": + return [atMost, unequal]; + case "!==": + case "!=": + return [unequal, atMost]; + default: + return [bound, bound]; + } + } + /** Visits a while body in order, from the bound that holds on entry. + * Returns the bound at the end of the list. Its offset is -Infinity (and + * its minimum Infinity) when the end is unreachable, and Infinity when + * nothing bounds the counter there. Every write of the counter adds a + * positive literal (whileBound), so the minimum survives any write. */ + function flow(list: IrStmt[], active: Bound[], bound: Bound): Bound { + for (const s of list) { + const current = Number.isFinite(bound.offset) ? [...active, bound] : active; + const step = increment(s, bound.counter); + if (step !== null) { + bound = { ...bound, offset: bound.offset + step, minimum: bound.minimum + step }; + } else if (s.kind === "if") { + expr(s.cond, current); + const [then, else_] = narrow(s.cond, bound); + const left = flow(s.then, active, then), + right = s.else_ ? flow(s.else_, active, else_) : else_; + bound = { + ...bound, + offset: Math.max(left.offset, right.offset), + minimum: Math.min(left.minimum, right.minimum), + }; + } else if (s.kind === "block" && !s.labels?.length) { + bound = flow(s.body, active, bound); + } else if (writes(s, bound.counter)) { + stmt(s, active); + bound = { ...bound, offset: Infinity }; + } else { + stmt(s, current); + if ( + s.kind === "break" || + s.kind === "continue" || + s.kind === "return" || + s.kind === "throw" + ) + bound = { ...bound, offset: -Infinity, minimum: Infinity }; + } + } + return bound; + } function stmt(s: IrStmt, active: Bound[]): boolean { if (s.kind === "bytesSet") access(s, s.arr, s.index, active, 1); + if (s.kind === "while") { + expr(s.cond, active); + const bound = whileBound(s); + if (!bound) return s.body.every((child) => stmt(child, active)); + flow(s.body, active, bound); + return true; + } if (s.kind === "for") { if (s.init) stmt(s.init, active); if (s.cond) expr(s.cond, active); diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts index aef39ef75..096712c97 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts @@ -580,7 +580,9 @@ test("the guard tests a string's ASCII bit and the fast copy reads bytes", () => const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0]; const guard = body.slice(0, body.search(/^bytes\.version\.slow\d*:/m)); expect(guard).toMatch(/load i32, ptr %\S+\n\s+%\S+ = icmp slt i32 %\S+, 0/); - const fast = body.slice(body.lastIndexOf("bytes.version.fast")); + // The byte copy, up to the units copy that follows it. + const units = body.search(/^bytes\.version\.fast\.units\d+:/m); + const fast = body.slice(body.search(/^bytes\.version\.fast\d+:/m), units < 0 ? undefined : units); expect(fast).toMatch(/load i8, ptr %\S+\n\s+%\S+ = uitofp i8/); expect(fast).not.toContain("@sc_str_char_code_at_int"); // The checked copy keeps the inline helper and its fallback. @@ -699,7 +701,8 @@ test("a two-counter loop versions the typed arrays it indexes at either counter" }; const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0]; const guard = body.slice(0, body.search(/^bytes\.version\.(fast|slow)\d+:$/m)); - expect(guard).toMatch(/icmp sge i64 %\S+, 0/); + // `i` starts at the literal 0, so its test compares the integer itself. + expect(guard).toMatch(/icmp sge i64 0, 0/); expect(guard).toMatch(/icmp slt i64 %\S+, %\S+/); const fast = /^bytes\.version\.fast\d+:$[^]*?^bytes\.version\.done\d+:$/m.exec(body)![0]; // The fast copy keeps only the `src[i + 1]` check, and no second guard. diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts index 1b51deee1..de704ad42 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -1,4 +1,11 @@ -import { F64, type IrExpr, type IrLocal, type IrStmt } from "../../ir/ir.js"; +import { + BYTES_ELEMENT_SIZE, + F64, + type IrExpr, + type IrLocal, + type IrStmt, + type IrType, +} from "../../ir/ir.js"; import type { IntegerRange, IntegerRanges } from "../../ir/integer-ranges.js"; import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js"; import type { LlvmEmitterContext } from "./expr-context.js"; @@ -9,10 +16,17 @@ import { emitCountedLoopLimit, emitCountedLoopSafety, emitExactIntegerTest, + emitStartIntegerTest, } from "./counted-loops.js"; -import { emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js"; +import { emitBytesData, emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js"; import { f64Lit } from "./common.js"; -import { emitStringAsciiFlag, emitStringByteLength } from "./string-reads.js"; +import { + emitStringAsciiFlag, + emitStringByteLength, + STRING_SCAN_UNITS_DECL, + stringScanType, +} from "./string-reads.js"; +import { unitSafeLoop } from "./string-unit-versions.js"; type Node = IrExpr | IrStmt; type VarRef = IrExpr & { kind: "varRef" }; @@ -73,6 +87,14 @@ export interface BytesLoopVersion { receivers: { ref: VarRef; global: boolean; string: boolean }[]; /** Receivers the guard requires to have no shared storage. */ ownedReceivers: number[]; + /** The string reads among `bounds`. */ + stringReads: ReadonlySet; + /** The version's one string receiver, when the loop may also run over + * its UTF-16 units if it is not ASCII (string-unit-versions.ts). */ + unitsReceiver: number | null; + /** The units receiver's `length` reads in the loop. Both fast copies + * read the length the guard took (its UTF-16 length) in their place. */ + unitLengths: ReadonlySet; fixed: FixedCheck[]; affine: AffineCheck[]; /** While loops: accesses at a counter below a loop-invariant bound. */ @@ -92,6 +114,12 @@ export interface BytesLoopVersion { /** Integer ranges of affine index operands that hold in the unchecked * loop (where the guard bounds the counter). */ ranges: ReadonlyMap; + /** Typed-array receivers whose element ranges the guard proves disjoint, + * and the receiver of each of their element reads and writes. The fast + * copy gives each receiver its own alias scope, so a store to one view + * leaves the elements of the others in registers. Null when the loop + * writes no element, or reads and writes a single view. */ + scopes: { receivers: number[]; nodes: ReadonlyMap } | null; } export interface BytesLoopFacts { @@ -114,11 +142,16 @@ export interface BytesLoopFacts { /** String bindings held as a slice of another string (string-slices.ts): * they have no string header for the guard to read. */ scalarStrings: ReadonlySet; + /** The functions a loop over a string's UTF-16 units may call; absent + * or null where emitted code does not read the runtime's scan receiver. */ + unitSafeFunctions?: ReadonlySet | null; } const MAX_COST = 400; const MAX_FIXED_END = 2 ** 31; const MAX_CHECKS = 8; +/** Alias-scoped views per loop: the guard tests every pair's ranges. */ +const MAX_SCOPED_RECEIVERS = 6; const MAX_OFFSET = 2 ** 31; const SAFE = Number.MAX_SAFE_INTEGER; /** The range the guard requires of an invariant index term without a @@ -530,11 +563,24 @@ export function matchBytesLoopVersion( group.nodes.push(node); } else fixed.set(receiver, { check: { receiver, end }, nodes: [node] }); }; + // Element reads and writes of each typed-array receiver, for alias scopes. + const scoped = new Map(); + const writtenViews = new Set(); + const scope = (node: Node, array: IrExpr, write: boolean): void => { + const ref = root(array); + if (!ref || ref.type.kind !== "bytes") return; + const receiver = receiverOf(ref); + scoped.set(node, receiver); + if (write) writtenViews.add(receiver); + }; const visitExpr = (e: IrExpr): boolean => { if (e.kind === "bytesIntrinsic") { const numeric = byteNumberAccess(e); if (numeric) access(e, e.receiver, e.args[numeric.offsetArg]!, numeric.width); - else if (e.method === "get" && e.args[0]) access(e, e.receiver, e.args[0], 1); + else if (e.method === "get" && e.args[0]) { + access(e, e.receiver, e.args[0], 1); + scope(e, e.receiver, false); + } } else if ( e.kind === "strIntrinsic" && (e.method === "charCodeAt" || e.method === "charAt") && @@ -544,7 +590,10 @@ export function matchBytesLoopVersion( return everyExprChild(e, visitExpr, visitStmt); }; const visitStmt = (s: IrStmt): boolean => { - if (s.kind === "bytesSet") access(s, s.arr, s.index, 1); + if (s.kind === "bytesSet") { + access(s, s.arr, s.index, 1); + scope(s, s.arr, true); + } return everyStmtChild(s, visitExpr, visitStmt); }; loop.body.every(visitStmt); @@ -667,9 +716,33 @@ export function matchBytesLoopVersion( : [], }); } + // Alias scopes: at least two views, one of them written, each read only + // through plain loads (in a worker program, a view the guard proves + // thread-owned). + const scopedReceivers = [...new Set(scoped.values())].sort((a, b) => a - b); + const ownedSet = new Set(ownedKept.map(([receiver]) => receiver)); + const scoping = + scopedReceivers.length >= 2 && + scopedReceivers.length <= MAX_SCOPED_RECEIVERS && + scopedReceivers.some((receiver) => writtenViews.has(receiver)) && + (!facts.workers || scopedReceivers.every((receiver) => ownedSet.has(receiver))); + if (scoping) for (const receiver of scopedReceivers) used.add(receiver); // Renumber the receivers the kept checks use. const order = [...used].sort((a, b) => a - b); const renumber = new Map(order.map((old, index) => [old, index])); + const stringReads = new Set(); + for (const group of [...fixedKept, ...affineKept]) + if (receivers[group.check.receiver]!.string) + for (const node of group.nodes) stringReads.add(node); + for (const group of boundedKept) + if (receivers[group.check.receiver]!.string) + for (const { node } of group.nodes) stringReads.add(node); + const strings = order.filter((index) => receivers[index]!.string); + const unitLengths = + facts.unitSafeFunctions != null && strings.length === 1 + ? unitSafeLoop(loop, receivers[strings[0]!]!.ref.localId, facts.unitSafeFunctions) + : null; + const unitsReceiver = unitLengths !== null ? renumber.get(strings[0]!)! : null; const needsBound = affineKept.length > 0 && counted !== null && @@ -679,6 +752,9 @@ export function matchBytesLoopVersion( owned: ownedNodes, receivers: order.map((index) => receivers[index]!), ownedReceivers: ownedKept.map(([receiver]) => renumber.get(receiver)!), + stringReads, + unitsReceiver, + unitLengths: unitLengths ?? new Set(), fixed: fixedKept.map((g) => ({ receiver: renumber.get(g.check.receiver)!, end: g.check.end, @@ -704,6 +780,12 @@ export function matchBytesLoopVersion( : null, counterBound: needsBound ? bound : null, ranges, + scopes: scoping + ? { + receivers: scopedReceivers.map((receiver) => renumber.get(receiver)!), + nodes: new Map([...scoped].map(([node, receiver]) => [node, renumber.get(receiver)!])), + } + : null, }; } @@ -1018,13 +1100,22 @@ function findWhileCounterAccesses( /** Branches to `fast` when every access the version proves is in bounds for * every iteration and every view it proves thread-owned has no shared * storage, and to `slow` otherwise (including a counted loop whose - * integer form is not established, or a module receiver not yet set). */ + * integer form is not established, or a module receiver not yet set). + * + * With `unitsFast`, the version's units receiver may instead be a string + * that is not ASCII: the guard has the runtime publish it as the scan + * receiver (one read), tests the same accesses against its UTF-16 length, + * and branches to `unitsFast`. It returns the units pointer that copy + * reads, which stays valid while the loop runs (string-unit-versions.ts), + * and for a version with a units receiver, the receiver's UTF-16 length + * on both fast paths (an i64). */ export function emitBytesLoopVersionGuard( host: LlvmEmitterContext, version: BytesLoopVersion, fast: string, slow: string, -): void { + unitsFast?: string, +): { units: string | null; length: string | null } | null { const B = host.B; const next = (cond: string): void => { if (cond === "true") return; @@ -1043,19 +1134,36 @@ export function emitBytesLoopVersionGuard( } return value; }); - const lengths = receivers.map((receiver, index) => { - const len = version.receivers[index]!.string - ? emitStringByteLength(host, receiver) - : emitBytesHeaderLength(host, receiver); + const unitsIndex = unitsFast !== undefined ? version.unitsReceiver : null; + const units: { read: { pointer: string; taken: string; published: string } | null } = { + read: null, + }; + const widen = (len: string): string => { if (host.sizeType === "i64") return len; const wide = B.tmp(); B.line(`${wide} = zext ${host.sizeType} ${len} to i64`); return wide; + }; + const lengths = receivers.map((receiver, index) => { + if (index === unitsIndex) { + const read = emitUnitsLength(host, receiver); + units.read = read; + return read.length; + } + return widen( + version.receivers[index]!.string + ? emitStringByteLength(host, receiver) + : emitBytesHeaderLength(host, receiver), + ); }); const conditions: string[] = []; + // The published count is the receiver's UTF-16 length only while the + // runtime publishes the receiver itself. + if (units.read) conditions.push(units.read.published); // A string's byte length is its UTF-16 length when it is ASCII. version.receivers.forEach(({ string }, index) => { - if (string) conditions.push(emitStringAsciiFlag(host, receivers[index]!)); + if (string && index !== unitsIndex) + conditions.push(emitStringAsciiFlag(host, receivers[index]!)); }); for (const index of version.ownedReceivers) { const owned = B.tmp(); @@ -1067,6 +1175,31 @@ export function emitBytesLoopVersionGuard( B.line(`${ok} = icmp ugt i64 ${lengths[check.receiver]}, ${check.end}`); conditions.push(ok); } + if (version.scopes) { + // Every pair of scoped views covers disjoint bytes: one ends at or + // before the other starts. An empty view covers none. + const spans = version.scopes.receivers.map((index) => { + const type = version.receivers[index]!.ref.type as IrType & { kind: "bytes" }; + const start = B.tmp(), + size = B.tmp(), + end = B.tmp(); + B.line(`${start} = ptrtoint ptr ${emitBytesData(host, receivers[index]!)} to i64`); + B.line(`${size} = mul nuw i64 ${lengths[index]}, ${BYTES_ELEMENT_SIZE[type.elem]}`); + B.line(`${end} = add nuw i64 ${start}, ${size}`); + return { start, end }; + }); + spans.forEach((a, i) => { + for (const b of spans.slice(i + 1)) { + const before = B.tmp(), + after = B.tmp(), + apart = B.tmp(); + B.line(`${before} = icmp ule i64 ${a.end}, ${b.start}`); + B.line(`${after} = icmp ule i64 ${b.end}, ${a.start}`); + B.line(`${apart} = or i1 ${before}, ${after}`); + conditions.push(apart); + } + }); + } for (const counter of version.counters) { const value = host.emitExpr(counter.ref); // A counter held as an integer (an int32 slot) is exact already and @@ -1158,7 +1291,7 @@ export function emitBytesLoopVersionGuard( // Unreachable: every term has a proven range. Stay checked. B.br(slow); B.startBlock(B.newLabel("bytes.version.dead")); - return; + return null; } const wide = widenInteger(host, integer); const sum = B.tmp(); @@ -1192,8 +1325,8 @@ export function emitBytesLoopVersionGuard( // access at either one is in bounds when `lo >= 0` and `hi < length`. const { loop } = version.paired; const [low, high] = [loop.low.start, loop.high.start].map((expr) => { - const tested = emitExactIntegerTest(host, host.emitExpr(expr).name, -SAFE, SAFE); - conditions.push(tested.ok); + const tested = emitStartIntegerTest(host, expr, -SAFE, SAFE); + if (tested.ok !== "true") conditions.push(tested.ok); return tested.integer; }); const nonNegative = B.tmp(); @@ -1215,8 +1348,83 @@ export function emitBytesLoopVersionGuard( valid = all; } } - if (valid === "true") B.br(fast); - else B.condBr(valid, fast, slow); + const length = version.unitsReceiver !== null ? lengths[version.unitsReceiver]! : null; + const taken = units.read; + const target = taken ? B.newLabel("bytes.version.units") : fast; + if (valid === "true") B.br(target); + else B.condBr(valid, target, slow); + if (!taken) return { units: null, length }; + B.startBlock(target); + B.condBr(taken.taken, unitsFast!, fast); + return { units: taken.pointer, length }; +} + +/** The length of a version's units receiver `s` in UTF-16 units: its byte + * length when it is ASCII; otherwise, once it is the scan receiver, the + * published unit count (0 when the runtime does not hold its units). When + * `s` is not already the scan receiver, the runtime converts it to units + * and publishes it (scr_str_scan_units). Also the units pointer, whether + * the loop is to read it, and whether the published receiver is `s`. */ +function emitUnitsLength( + host: LlvmEmitterContext, + s: string, +): { length: string; pointer: string; taken: string; published: string } { + const B = host.B; + const sz = host.sizeType; + const type = stringScanType(sz); + const ascii = emitStringAsciiFlag(host, s); + const bytes = emitStringByteLength(host, s); + const from = B.currentLabel(); + const publish = B.newLabel("bytes.version.publish"), + join = B.newLabel("bytes.version.length"); + B.condBr(ascii, join, publish); + B.startBlock(publish); + // When `s` is already the scan receiver (an earlier loop over it published + // it), its units stay published until the runtime clears the scan: skip + // the call. + const held = B.tmp(), + holds = B.tmp(); + B.line(`${held} = load ptr, ptr @scr_str_scan`); + B.line(`${holds} = icmp eq ptr ${held}, ${s}`); + const call = B.newLabel("bytes.version.publish.units"), + load = B.newLabel("bytes.version.publish.load"); + B.condBr(holds, load, call); + B.startBlock(call); + host.declare(STRING_SCAN_UNITS_DECL); + const receiver = B.tmp(), + same = B.tmp(), + pointerSlot = B.tmp(), + pointer = B.tmp(), + countSlot = B.tmp(), + count = B.tmp(), + published = B.tmp(); + B.line(`call void @scr_str_scan_units(ptr ${s})`); + B.br(load); + B.startBlock(load); + B.line(`${receiver} = load ptr, ptr @scr_str_scan`); + B.line(`${same} = icmp eq ptr ${receiver}, ${s}`); + B.line(`${pointerSlot} = getelementptr inbounds ${type}, ptr @scr_str_scan, i32 0, i32 1`); + B.line(`${pointer} = load ptr, ptr ${pointerSlot}`); + B.line(`${countSlot} = getelementptr inbounds ${type}, ptr @scr_str_scan, i32 0, i32 2`); + B.line(`${count} = load ${sz}, ptr ${countSlot}`); + B.line(`${published} = select i1 ${same}, ${sz} ${count}, ${sz} 0`); + const end = B.currentLabel(); + B.br(join); + B.startBlock(join); + const length = B.tmp(), + units = B.tmp(), + taken = B.tmp(); + B.line(`${length} = phi ${sz} [ ${bytes}, %${from} ], [ ${published}, %${end} ]`); + B.line(`${units} = phi ptr [ null, %${from} ], [ ${pointer}, %${end} ]`); + B.line(`${taken} = phi i1 [ false, %${from} ], [ true, %${end} ]`); + const exact = B.tmp(); + B.line(`${exact} = phi i1 [ true, %${from} ], [ ${same}, %${end} ]`); + let wide = length; + if (sz !== "i64") { + wide = B.tmp(); + B.line(`${wide} = zext ${sz} ${length} to i64`); + } + return { length: wide, pointer: units, taken, published: exact }; } function scaleCounter(host: LlvmEmitterContext, value: string, scale: number): string { diff --git a/packages/compiler/src/backend/llvm/counted-loops.ts b/packages/compiler/src/backend/llvm/counted-loops.ts index 1ff4c6580..39f97717b 100644 --- a/packages/compiler/src/backend/llvm/counted-loops.ts +++ b/packages/compiler/src/backend/llvm/counted-loops.ts @@ -7,6 +7,7 @@ import { FOLLOWER_GUARD_LIMIT } from "../../ir/integer-loops.js"; import type { IrExpr } from "../../ir/ir.js"; import type { LlvmEmitterContext } from "./expr-context.js"; import { f64Lit } from "./common.js"; +import { exactInteger, widenInteger } from "./integer-values.js"; /** The caller has proved or guarded the direction's finite endpoint. Clamp * the opposite infinity before integer conversion. Fractional limits become @@ -50,8 +51,8 @@ export function emitCountedLoopLimit( } /** The run-time test of a guarded counted loop: an unproven limit is within - * the integer form's finite range and an unproven start is an exact safe - * integer. Returns `true` when nothing needs testing. */ + * the integer form's finite range and an unproven start is an exact integer + * within the loop's start bound. Returns `true` when nothing needs testing. */ export function emitCountedLoopSafety( host: LlvmEmitterContext, loop: IntegerCountedForLoop, @@ -65,7 +66,11 @@ export function emitCountedLoopSafety( `${safe} = fcmp ${loop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(loop.guardLimit)}`, ); } - if (loop.startGuarded) safe = andSafe(host, safe, emitExactStart(host, loop.start)); + if (loop.startGuarded) { + const start = host.emitExpr(loop.start); + const bound = loop.startBound; + safe = andSafe(host, safe, emitExactIntegerTest(host, start.name, -bound, bound).ok); + } for (const follower of loop.followers) { if (follower.startGuarded) safe = andSafe(host, safe, emitExactStart(host, follower.start)); if (follower.tripGuarded) safe = andSafe(host, safe, emitFollowerReach(host, loop, follower)); @@ -117,6 +122,7 @@ export function emitPairedLoopSafety(host: LlvmEmitterContext, loop: IntegerPair } function andSafe(host: LlvmEmitterContext, safe: string, ok: string): string { + if (ok === "true") return safe; if (safe === "true") return ok; const both = host.B.tmp(); host.B.line(`${both} = and i1 ${safe}, ${ok}`); @@ -125,10 +131,25 @@ function andSafe(host: LlvmEmitterContext, safe: string, ok: string): string { /** An exact integer of at most 2^53 - 1 in magnitude, and not -0 (the bit * patterns of the value and its round trip agree). NaN and infinities fail - * the ordered range tests. */ + * the ordered range tests. A start with an integer form (an int32 local, + * `p0 - 1` of one) is an exact integer already: its range answers the test, + * or two integer compares do. */ function emitExactStart(host: LlvmEmitterContext, expr: IrExpr): string { const B = host.B; const start = host.emitExpr(expr); + const integer = exactInteger(host, start, expr); + if (integer) { + const limit = Number.MAX_SAFE_INTEGER; + if (integer.range.min >= -limit && integer.range.max <= limit) return "true"; + const wide = widenInteger(host, integer), + low = B.tmp(), + high = B.tmp(), + ok = B.tmp(); + B.line(`${low} = icmp sge i64 ${wide}, ${-limit}`); + B.line(`${high} = icmp sle i64 ${wide}, ${limit}`); + B.line(`${ok} = and i1 ${low}, ${high}`); + return ok; + } const low = B.tmp(), high = B.tmp(), raw = B.tmp(), @@ -184,3 +205,39 @@ export function emitExactIntegerTest( B.line(`${ok} = and i1 ${inRange}, ${exact}`); return { ok, integer }; } + +/** The i64 start of an integer counter: the integer form the start value + * carries (an int32 local, `p0 - 1` of one), or the double's conversion, + * which the loop's guard or proof makes exact. */ +export function emitCountedStart(host: LlvmEmitterContext, expr: IrExpr): string { + const value = host.emitExpr(expr); + const integer = exactInteger(host, value, expr); + if (integer) return widenInteger(host, integer); + const start = host.B.tmp(); + host.B.line(`${start} = fptosi double ${value.name} to i64`); + return start; +} + +/** emitExactIntegerTest of a start expression: when the value carries an + * integer form (an exact integer, never -0), its range answers the test or + * two integer compares do, and the integer is the form's. */ +export function emitStartIntegerTest( + host: LlvmEmitterContext, + expr: IrExpr, + min: number, + max: number, +): { ok: string; integer: string } { + const value = host.emitExpr(expr); + const integer = exactInteger(host, value, expr); + if (!integer) return emitExactIntegerTest(host, value.name, min, max); + const wide = widenInteger(host, integer); + if (integer.range.min >= min && integer.range.max <= max) return { ok: "true", integer: wide }; + const B = host.B; + const low = B.tmp(), + high = B.tmp(), + ok = B.tmp(); + B.line(`${low} = icmp sge i64 ${wide}, ${min}`); + B.line(`${high} = icmp sle i64 ${wide}, ${max}`); + B.line(`${ok} = and i1 ${low}, ${high}`); + return { ok, integer: wide }; +} diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts index a88c38575..9ec2d5d39 100644 --- a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts @@ -122,6 +122,7 @@ const counted = ( guarded: false, limitGuarded: false, startGuarded: false, + startBound: Number.MAX_SAFE_INTEGER, guardLimit: 0, followers: [], ...fields, diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.ts b/packages/compiler/src/backend/llvm/dense-array-loops.ts index b806939b5..020ea582a 100644 --- a/packages/compiler/src/backend/llvm/dense-array-loops.ts +++ b/packages/compiler/src/backend/llvm/dense-array-loops.ts @@ -36,7 +36,7 @@ import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js"; import type { LlvmEmitterContext } from "./expr-context.js"; import { loadArrCount, SCR_ARR_FIELD } from "./common.js"; -import { emitCountedLoopLimit, emitExactIntegerTest } from "./counted-loops.js"; +import { emitCountedLoopLimit, emitStartIntegerTest } from "./counted-loops.js"; export const INT32_MAX = 2147483647; @@ -453,7 +453,7 @@ function emitPairedDenseGuard( ): string { const B = host.B; const start = (expr: IrExpr, min: number, max: number) => - emitExactIntegerTest(host, host.emitExpr(expr).name, min, max); + emitStartIntegerTest(host, expr, min, max); const low = start(pair.lowStart, 0, INT32_MAX - 1); const high = start(pair.highStart, -1, INT32_MAX - 1); let valid = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index da31ffdbb..125203bf3 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -43,7 +43,7 @@ import { isDynTypedRefType, isRefCounted, isUnitType, - recordTextCodecClass, + recordHandleCodecClass, textCodecRecordClass, typeKey, } from "../../ir/ir.js"; @@ -98,14 +98,16 @@ export const DYN_HANDLE_TAG_NUM: Record = { /** The handle tags of TextEncoder and TextDecoder records boxed as dyn * (scr_dyn.h; scr_text_codec_dyn.c boxes and unboxes them). */ -export const TEXT_CODEC_TAG_NUM: Record<"TextEncoder" | "TextDecoder", number> = { +export const TEXT_CODEC_TAG_NUM: Record<"TextEncoder" | "TextDecoder" | "StringDecoder", number> = { TextDecoder: 76, TextEncoder: 77, + StringDecoder: 78, }; /** The decoder record fields scr_text_decoder_to_dyn reads, in its * parameter order. */ const TEXT_DECODER_FIELDS = ["%TextDecoder", "%fatal", "%ignoreBOM", "%state"] as const; +const STRING_DECODER_FIELDS = ["%enc", "%pending"] as const; /** Handle kinds whose runtime unit boxes them through its own function, * which installs the tag's ops on first use (scr_crypto.h). Programs that @@ -151,6 +153,18 @@ export const DYN_KIND = { SYMBOL: 15, } as const; +/** Union arm kinds whose matchers reject every plain dyn object + * (trustedArmByLiteral). */ +const NON_OBJECT_ARM_KINDS: ReadonlySet = new Set([ + "f64", + "string", + "bool", + "nullT", + "undefinedT", + "bigint", + "symbol", +]); + /** What the dyn helpers need beyond the walker host: interned immortal * unit instances (undefined-armed dynCheck targets build them). */ export interface DynHost extends WalkerHost { @@ -174,6 +188,10 @@ export interface DynHost extends WalkerHost { dynPromiseAdapter(t: IrType): string; isErrorClass(className: string): boolean; classSubtypes(className: string): readonly string[]; + /** The strict subclasses whose class-value constructor takes the same + * arguments as `className`'s (classValueAbiEquals): the class values a + * checked `classval` slot accepts besides its own class. */ + classValueSubtypes(className: string): readonly string[]; /** emitter.ts's pendingTestLines: the inline pending-exception test. */ pendingTestLines(dest: string): string[]; } @@ -388,6 +406,51 @@ export class LlDyn { B.line(`call void @scr_dyn_build_add(ptr %d, ptr @${name}, ptr ${value})`); } + /** Whether a builder of `def` may pick a plain object's arm by its literal + * discriminator alone inside a trusted build (scr_dyn_trust_enter): the + * arms' literals are disjoint, and every arm without a literal has a kind + * no plain object matches. The value then matched exactly one arm with a + * literal, the arm whose literal it carries. */ + private trustedArmByLiteral(def: IrUnionDef): boolean { + const discriminant = def.discriminant; + if (!discriminant) return false; + const seen = new Set(); + for (const { values } of discriminant.cases) { + for (const value of values) { + const key = `${typeof value}:${String(value)}`; + if (seen.has(key)) return false; + seen.add(key); + } + } + return def.arms.every( + (arm, tag) => + discriminant.cases.some((candidate) => candidate.tag === tag) || + NON_OBJECT_ARM_KINDS.has(arm.kind), + ); + } + + /** Per arm of `def`, the dyn kind of the values a builder may convert into + * that arm without matching its structure inside a trusted build, or null. + * A value there already matched one of the union's arms, so the arm is + * known when it is the only arm that takes values of its kind: the arm + * whose literal a plain object carries (trustedArmByLiteral), or the one + * arm a plain object or an array can match at all. */ + private trustedArmKinds(def: IrUnionDef): (number | null)[] { + const byLiteral = this.trustedArmByLiteral(def); + const objectArms = def.arms.flatMap((arm, tag) => + NON_OBJECT_ARM_KINDS.has(arm.kind) ? [] : [tag], + ); + return def.arms.map((arm, tag) => { + if (def.discriminant?.cases.some((candidate) => candidate.tag === tag)) + return byLiteral ? DYN_KIND.OBJ : null; + if (objectArms.length !== 1 || objectArms[0] !== tag) return null; + if (arm.kind === "array") return DYN_KIND.ARR; + if (arm.kind === "record" && this.host.recordsById.get(arm.shapeId)?.tuple !== true) + return DYN_KIND.OBJ; + return null; + }); + } + /** The order a dyn value tries a union's arms in: the canonical order, * except that among the record arms, those with more required fields go * first, then those with more fields. Records are width-tolerant, so an @@ -455,6 +518,7 @@ export class LlDyn { none: string, preserveRefs: boolean, table: string, + trustedKinds: readonly (number | null)[] = def.arms.map(() => null), ): void { const discriminant = def.discriminant!; const literalsOf = (tag: number): readonly (string | number | boolean)[] | null => @@ -494,7 +558,7 @@ export class LlDyn { positions.forEach((position, index) => { const tag = order[position]!; const failed = B.newLabel("du.c.f"); - this.unionArmStructure(B, def, tag, hits[tag]!, failed, preserveRefs); + this.unionArmStructure(B, def, tag, hits[tag]!, failed, preserveRefs, trustedKinds[tag]!); B.startBlock(failed); const next = index === positions.length - 1 ? none : B.newLabel("du.c.n"); const same = B.tmp(); @@ -517,12 +581,23 @@ export class LlDyn { tryCase(-1); order.forEach((tag, position) => { B.startBlock(from[position]!); - this.unionArmMatch(B, def, tag, hits[tag]!, after(position), preserveRefs); + this.unionArmMatch( + B, + def, + tag, + hits[tag]!, + after(position), + preserveRefs, + trustedKinds[tag]!, + ); }); } /** Branch to a variant only after its literal discriminator and complete - * structure match. Use the same path for predicates and builders. */ + * structure match. Use the same path for predicates and builders. With a + * `trustedKind` (builders, trustedArmKinds), a value of that dyn kind + * inside a trusted build skips the structure match once its literal + * matched. */ private unionArmMatch( B: BlockBuilder, def: IrUnionDef, @@ -530,6 +605,7 @@ export class LlDyn { yes: string, no: string, preserveRefs = false, + trustedKind: number | null = null, ): void { const guard = def.discriminant?.cases.find((candidate) => candidate.tag === tag); if (guard && def.discriminant) { @@ -558,10 +634,12 @@ export class LlDyn { }); B.startBlock(structure); } - this.unionArmStructure(B, def, tag, yes, no, preserveRefs); + this.unionArmStructure(B, def, tag, yes, no, preserveRefs, trustedKind); } - /** The part of unionArmMatch past the arm's literal test. */ + /** The part of unionArmMatch past the arm's literal test. With a + * `trustedKind`, a value of that dyn kind inside a trusted build skips the + * structure match. */ private unionArmStructure( B: BlockBuilder, def: IrUnionDef, @@ -569,7 +647,21 @@ export class LlDyn { yes: string, no: string, preserveRefs: boolean, + trustedKind: number | null = null, ): void { + if (trustedKind !== null) { + this.host.declare(`declare zeroext i1 @scr_dyn_trusted_build()`); + const kind = this.kindOf(B, "%d"); + const sameKind = B.tmp(); + const trusted = B.tmp(); + const skip = B.tmp(); + B.line(`${sameKind} = icmp eq i32 ${kind}, ${trustedKind}`); + B.line(`${trusted} = call zeroext i1 @scr_dyn_trusted_build()`); + B.line(`${skip} = and i1 ${sameKind}, ${trusted}`); + const full = B.newLabel("du.full"); + B.condBr(skip, yes, full); + B.startBlock(full); + } const matches = B.tmp(); B.line( `${matches} = call zeroext i1 @${this.dynMatchHelper(def.arms[tag]!, preserveRefs)}(ptr %d)`, @@ -1474,8 +1566,9 @@ export class LlDyn { * fields, and at least one unless an index signature collects the * members), record members typed as unions of those leaves, records and * arrays, null and undefined (optional and nullable members; see - * jsonUnionSupported), and index signatures whose value type is not a - * union. Everything else keeps the checked-dynamic route alone. */ + * jsonUnionSupported), discriminated unions of records anywhere (see + * jsonDiscriminatedSupported), and index signatures whose value type is + * not a union. Everything else keeps the checked-dynamic route alone. */ private jsonSchemaSupported(t: IrType, visiting: Set, member = false): boolean { switch (t.kind) { case "f64": @@ -1502,13 +1595,42 @@ export class LlDyn { (!index || this.jsonSchemaSupported(index, visiting)) ); } - case "union": + case "union": { + const def = this.host.unionsById.get(t.unionId); + if (def?.discriminant) return this.jsonDiscriminatedSupported(def, visiting); return member && this.jsonUnionSupported(t, visiting); + } default: return false; } } + /** A discriminated union the one-pass parser writes directly, as a record + * member, an array element or the whole document: record arms that each + * name the string literals their discriminant member holds, every + * literal naming one arm, plus null and undefined arms. An object then + * selects the one arm whose literal its discriminant member holds, the + * only arm the checked route's literal tests leave to match + * (unionArmsByDiscriminant); an object holding no such literal declines. */ + private jsonDiscriminatedSupported(def: IrUnionDef, visiting: Set): boolean { + const discriminant = def.discriminant!; + const literals = new Set(); + let records = 0; + const supported = def.arms.every((arm, tag) => { + if (arm.kind === "nullT" || arm.kind === "undefinedT") return true; + if (arm.kind !== "record") return false; + records++; + const guard = discriminant.cases.find((candidate) => candidate.tag === tag); + if (!guard || guard.values.length === 0) return false; + for (const value of guard.values) { + if (typeof value !== "string" || literals.has(value)) return false; + literals.add(value); + } + return this.jsonSchemaSupported(arm, visiting); + }); + return supported && records >= 2; + } + /** A union record member the one-pass parser writes directly: every arm * a number, boolean, string, null, undefined, or a supported record or * array, with at most one record and one array arm and no discriminant. @@ -1613,9 +1735,15 @@ export class LlDyn { body = `${S} 3, ${S} ${entries.length}, ptr ${fields}, ptr ${overflow.elem}, ptr @${mangleRecordNew(t.shapeId)}, ptr null, ptr ${vAdapters(host, t).release}, ptr null, ${S} ${overflow.offset}`; break; } - case "union": - body = `${S} 5, ${S} 0, ptr null, ptr null, ptr null, ptr null, ptr null, ptr @${this.jsonUnionSchema(t, name)}, ${S} 0`; + case "union": { + // A discriminated union can be a whole document, whose value the + // parser releases when text follows it. + const release = host.unionsById.get(t.unionId)?.discriminant + ? vAdapters(host, t).release + : "null"; + body = `${S} 5, ${S} 0, ptr null, ptr null, ptr null, ptr null, ptr ${release}, ptr @${this.jsonUnionSchema(t, name)}, ${S} 0`; break; + } default: throw new InternalCompilerError(`llvm emitter bug: JSON schema of ${t.kind}`); } @@ -1630,7 +1758,11 @@ export class LlDyn { /** The ScrJsonUnion descriptor of a union member (scr_json.h layout): * its slot representation, the arm each JSON value kind selects, and the * values the runtime stores for null and for a missing member. Boxed - * unions build their boxes through `_box`. */ + * unions build their boxes through `_box`. A discriminated union + * also lists its object arms by discriminant literal (`_cases`), + * and, because it can be an array element or a whole document, whose + * values are boxes unless the union is a pointer union, it keeps its null + * value and box whatever its field representation. */ private jsonUnionSchema(t: IrType & { kind: "union" }, name: string): string { const host = this.host; const S = host.sizeType; @@ -1649,10 +1781,11 @@ export class LlDyn { ); const nullTag = tags[0]!; const absentTag = tags[6]!; - const nullValue = !inline && nullTag >= 0 ? host.unitInstanceRef(t.unionId, nullTag) : "null"; - const absentValue = !inline && absentTag >= 0 ? host.absentInstanceRef(t.unionId) : "null"; + const values = !inline || def.discriminant !== undefined; + const nullValue = values && nullTag >= 0 ? host.unitInstanceRef(t.unionId, nullTag) : "null"; + const absentValue = values && absentTag >= 0 ? host.absentInstanceRef(t.unionId) : "null"; let box = "null"; - if (repr === 2) { + if (repr === 2 || (def.discriminant && !nullable)) { box = `@${name}_box`; const lines = [`define internal ptr ${box}(i32 %tag, i64 %bits) ${FN_ATTRS} {`, `entry:`]; const cases: string[] = []; @@ -1693,9 +1826,27 @@ export class LlDyn { ``, ); } + let cases = "null"; + if (def.discriminant) { + const discriminant = def.discriminant; + const caseTy = `{ ptr, ${S}, ${S}, ptr }`; + const entries = discriminant.cases.flatMap((guard) => + guard.values.map( + (value) => + `${caseTy} { ptr ${host.cstr(String(value))}, ${S} ${Buffer.byteLength(String(value), "utf8")}, ${S} ${guard.tag}, ptr @${this.jsonSchema(def.arms[guard.tag]!)} }`, + ), + ); + const table = `@${name}_case_table`; + cases = `@${name}_cases`; + this.defs.push( + `${table} = internal constant [${entries.length} x ${caseTy}] [${entries.join(", ")}]`, + `${cases} = internal constant { ptr, ${S}, ${S}, ptr } { ptr ${host.cstr(discriminant.field)}, ${S} ${Buffer.byteLength(discriminant.field, "utf8")}, ${S} ${entries.length}, ptr ${table} }`, + ``, + ); + } const descriptor = `${name}_union`; this.defs.push( - `@${descriptor} = internal constant { ${S}, [7 x ${S}], [7 x ptr], ptr, ptr, ptr } { ${S} ${repr}, [7 x ${S}] [${tags.map((tag) => `${S} ${tag}`).join(", ")}], [7 x ptr] [${arms.join(", ")}], ptr ${nullValue}, ptr ${absentValue}, ptr ${box} }`, + `@${descriptor} = internal constant { ${S}, [7 x ${S}], [7 x ptr], ptr, ptr, ptr, ptr } { ${S} ${repr}, [7 x ${S}] [${tags.map((tag) => `${S} ${tag}`).join(", ")}], [7 x ptr] [${arms.join(", ")}], ptr ${nullValue}, ptr ${absentValue}, ptr ${box}, ptr ${cases} }`, ``, ); return descriptor; @@ -1745,7 +1896,12 @@ export class LlDyn { * dynCheck builder — which reports the exact error. Returns +1, or null * with the pending flag set. */ jsonParseHelper(t: IrType): string | null { - if (t.kind !== "record" && t.kind !== "array") return null; + if ( + t.kind !== "record" && + t.kind !== "array" && + !(t.kind === "union" && this.host.unionsById.get(t.unionId)?.discriminant) + ) + return null; const key = typeKey(t); const existing = this.jsonParsers.get(key); if (existing) return existing; @@ -1808,6 +1964,26 @@ export class LlDyn { const B = new BlockBuilder(); if (t.kind === "classval") { host.declare(`declare ptr @scr_dyn_class_check(ptr, ptr, ptr)`); + // A subclass's class value checks into the base class's slot when + // construction through the slot can call its thunk with the base + // constructor's arguments; any other subclass fails the check. + const subclasses = host.classValueSubtypes(t.className); + if (subclasses.length > 0) { + host.declare(`declare zeroext i1 @scr_dyn_class_is(ptr, ptr)`); + for (const className of subclasses) { + const subKey = host.cstr(typeKey({ kind: "classval", className })); + const matched = B.tmp(); + B.line(`${matched} = call zeroext i1 @scr_dyn_class_is(ptr %d, ptr ${subKey})`); + const yes = B.newLabel("sub.yes"); + const no = B.newLabel("sub.no"); + B.condBr(matched, yes, no); + B.startBlock(yes); + const sub = B.tmp(); + B.line(`${sub} = call ptr @scr_dyn_class_check(ptr %d, ptr ${subKey}, ptr %path)`); + B.terminate(`ret ptr ${sub}`); + B.startBlock(no); + } + } const checked = B.tmp(); B.line( `${checked} = call ptr @scr_dyn_class_check(ptr %d, ptr ${host.cstr(key)}, ptr %path)`, @@ -1825,11 +2001,18 @@ export class LlDyn { const codec = textCodecRecordClass(t, (id) => host.recordsById.get(id)); if (codec !== null) { // The record the codec's handle box holds (+1), or the path TypeError. - host.declare(`declare ptr @scr_text_codec_from_dyn(ptr, i1 zeroext, ptr, ptr)`); const checked = B.tmp(); - B.line( - `${checked} = call ptr @scr_text_codec_from_dyn(ptr %d, i1 ${codec === "TextDecoder"}, ptr %path, ptr ${want})`, - ); + if (codec === "StringDecoder") { + host.declare(`declare ptr @scr_string_decoder_from_dyn(ptr, ptr, ptr)`); + B.line( + `${checked} = call ptr @scr_string_decoder_from_dyn(ptr %d, ptr %path, ptr ${want})`, + ); + } else { + host.declare(`declare ptr @scr_text_codec_from_dyn(ptr, i1 zeroext, ptr, ptr)`); + B.line( + `${checked} = call ptr @scr_text_codec_from_dyn(ptr %d, i1 ${codec === "TextDecoder"}, ptr %path, ptr ${want})`, + ); + } B.terminate(`ret ptr ${checked}`); this.defs.push( `define internal ptr @${name}(ptr %d, ptr %path) ${FN_ATTRS} {`, @@ -2596,7 +2779,18 @@ export class LlDyn { `llvm emitter bug: dynCheck of unknown union ${t.unionId}`, ); // Arms in unionArmOrder, first FULL match wins. The matched arm's - // builder can no longer fail. + // builder can no longer fail, and it runs as a trusted build: a + // nested union inside it picks the arm trustedArmKinds names without + // matching the structure that this match already checked. + const trustedKinds = preserveRefs ? def.arms.map(() => null) : this.trustedArmKinds(def); + const trustedCall = (call: () => void): void => { + host.declare(`declare zeroext i1 @scr_dyn_trust_enter()`); + host.declare(`declare void @scr_dyn_trust_exit(i1 zeroext)`); + const previous = B.tmp(); + B.line(`${previous} = call zeroext i1 @scr_dyn_trust_enter()`); + call(); + B.line(`call void @scr_dyn_trust_exit(i1 zeroext ${previous})`); + }; const build = (i: number): void => { const arm = def.arms[i]!; if (arm.kind === "undefinedT" || arm.kind === "nullT") { @@ -2624,14 +2818,22 @@ export class LlDyn { } else if (host.nullableUnions.has(t.unionId)) { // A nullable union is its (+1) reference payload. const x = B.tmp(); - B.line(`${x} = call ptr @${this.dynCheckHelper(arm, preserveRefs)}(ptr %d, ptr %path)`); + trustedCall(() => + B.line( + `${x} = call ptr @${this.dynCheckHelper(arm, preserveRefs)}(ptr %d, ptr %path)`, + ), + ); B.terminate(`ret ptr ${x}`); } else { const rc = vAdapters(host, arm); host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`); const x = B.tmp(); const u = B.tmp(); - B.line(`${x} = call ptr @${this.dynCheckHelper(arm, preserveRefs)}(ptr %d, ptr %path)`); + trustedCall(() => + B.line( + `${x} = call ptr @${this.dynCheckHelper(arm, preserveRefs)}(ptr %d, ptr %path)`, + ), + ); B.line( `${u} = call ptr @scr_union_new_ref(i32 ${i}, ptr ${x}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host, arm)})`, ); @@ -2652,6 +2854,7 @@ export class LlDyn { unmatched, preserveRefs, `@${name}.cases`, + trustedKinds, ); for (const i of order) { B.startBlock(hits[i]!); @@ -2662,7 +2865,7 @@ export class LlDyn { for (const i of order) { const lHit = B.newLabel("dcu.h"); const lNext = B.newLabel("dcu.n"); - this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs); + this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs, trustedKinds[i]!); B.startBlock(lHit); build(i); B.startBlock(lNext); @@ -2917,14 +3120,14 @@ export class LlDyn { return matched; } - /** A TextEncoder or TextDecoder record boxes as its handle (+1), which - * holds the record by reference. A decoder passes the byte offsets of the - * fields its boxed decode reads. */ + /** A TextEncoder, TextDecoder or StringDecoder record boxes as its + * handle (+1), which holds the record by reference. The decoders pass + * the byte offsets of the fields their boxed methods read. */ private textCodecToDyn( B: BlockBuilder, t: IrType & { kind: "record" }, shape: IrRecordShape, - codec: "TextEncoder" | "TextDecoder", + codec: "TextEncoder" | "TextDecoder" | "StringDecoder", ): void { const host = this.host; const rc = vAdapters(host, t); @@ -2939,10 +3142,12 @@ export class LlDyn { const base = host.nullableUnions.fieldBase(t.shapeId); const start = B.tmp(); B.line(`${start} = ptrtoint ptr %v to ${this.S}`); - const offsets = TEXT_DECODER_FIELDS.map((field) => { + const fields: readonly string[] = + codec === "StringDecoder" ? STRING_DECODER_FIELDS : TEXT_DECODER_FIELDS; + const offsets = fields.map((field) => { const index = shape.fields.findIndex((f) => f.name === field); if (index < 0) - throw new InternalCompilerError(`llvm emitter bug: TextDecoder record lacks ${field}`); + throw new InternalCompilerError(`llvm emitter bug: ${codec} record lacks ${field}`); const p = B.tmp(); const at = B.tmp(); const offset = B.tmp(); @@ -2953,11 +3158,11 @@ export class LlDyn { B.line(`${offset} = sub ${this.S} ${at}, ${start}`); return `${this.S} ${offset}`; }); - host.declare( - `declare ptr @scr_text_decoder_to_dyn(ptr, ptr, ptr, ${this.S}, ${this.S}, ${this.S}, ${this.S})`, - ); + const fn = + codec === "StringDecoder" ? "scr_string_decoder_to_dyn" : "scr_text_decoder_to_dyn"; + host.declare(`declare ptr @${fn}(ptr, ptr, ptr, ${fields.map(() => this.S).join(", ")})`); B.line( - `${r} = call ptr @scr_text_decoder_to_dyn(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ${offsets.join(", ")})`, + `${r} = call ptr @${fn}(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ${offsets.join(", ")})`, ); } B.terminate(`ret ptr ${r}`); @@ -3137,7 +3342,7 @@ export class LlDyn { const shape = host.recordsById.get(t.shapeId); if (!shape) throw new InternalCompilerError(`llvm emitter bug: to-dyn of unknown shape ${t.shapeId}`); - const codec = recordTextCodecClass(shape); + const codec = recordHandleCodecClass(shape); if (codec !== null) { this.textCodecToDyn(B, t, shape, codec); break; diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 327d2019b..8c9ace9f3 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -20,7 +20,12 @@ import { constructorClass } from "../../ir/final-fields.js"; import { findLoopArrayBorrows } from "./loop-array-borrows.js"; import { findCallArrayReads, findLocalArrayReads } from "./local-array-reads.js"; import { findScalarStringSlices, findScratchStringSlices } from "./string-slices.js"; -import { findMapReadLifetimes, findReadNumberMapWrites } from "./map-read-lifetimes.js"; +import { + findMapReadBorrows, + findMapReadLifetimes, + findReadNumberMapWrites, +} from "./map-read-lifetimes.js"; +import { borrowableMapGet } from "./expr-containers.js"; import { findMapEntryFusions } from "./map-entry-fusion.js"; import { findLocalStackUnions } from "./stack-unions.js"; import { findLocalStackObjects, findReassignedStackObjects } from "./stack-objects.js"; @@ -56,12 +61,17 @@ import { emitIntegerClone, emitIntegerDispatcher } from "./integer-clones.js"; import { beginFunctionLoops, LOOPS } from "./loop-state.js"; import { runningExtremumUpdates } from "./running-extrema.js"; import { findIntegerOrNaNLocals } from "./wrapping-sums.js"; +import { UNWIND_PERSONALITY, UNWIND_SLOT } from "./unwind-abi.js"; export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { const B = new BlockBuilder(); emitter.B = B; emitter.currentFunctionName = fn.name; + emitter.currentUnwindAbi = emitter.unwindAbi.has(fn.name); + emitter.usesLandingPads = false; + emitter.aliasScopedVersions = 0; emitter.findInt32Allocations(fn); + emitter.speculativeLeaves = 0; emitter.currentTier = fn.tier; // Fiber switches restore the active exception cell before control // returns to a synchronous frame (emitPendingCheck). @@ -188,15 +198,27 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { fn, emitter.callLifetimes, emitter.scalarStringSlices, - emitter.lentStringParameters, + emitter.lendingFacts, ) : new Set(); emitter.mapReadLifetimes = findMapReadLifetimes( fn, emitter.boxedUnionsById, emitter.callLifetimes, + emitter.referenceEffects, + ); + const stable = emitter.callLifetimes.bindings.get(fn.name); + emitter.borrowedMapGets = findMapReadBorrows( + fn, + emitter.referenceEffects, + (decl) => + decl.init !== null && + stable?.has(decl.localId) === true && + !emitter.mapReadLifetimes.locals.has(decl.localId) && + borrowableMapGet(emitter, decl.init), ); emitter.readNumberMapWrites = findReadNumberMapWrites(fn); + emitter.numberMapKeyIntegers = new Map(); emitter.mapEntryFusion = emitter.debug === null ? findMapEntryFusions(fn) : null; emitter.localStackUnions = findLocalStackUnions( fn, @@ -597,6 +619,8 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { B.terminate(cleanup.terminator); } + if (emitter.usesLandingPads && emitter.currentUnwindAbi) + B.entryAllocas.push(`${UNWIND_SLOT} = alloca { ptr, i32 }`); if (emitter.logArgSlots > 0) { B.entryAllocas.push(`%logargs = alloca [${emitter.logArgSlots} x %ScrLogArg]`); } @@ -637,7 +661,10 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { const debug = emitter.debugScope === null ? "" : ` !dbg ${emitter.debugScope}`; const symbol = borrowed ? mangleBorrowedFunction(fn.name) : mangleFunction(fn.name); emitter.privateGlobalSlots.clear(); - let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`; + // Landing pads (emitDirectCall) need the personality. + const personality = emitter.usesLandingPads ? ` ${UNWIND_PERSONALITY}` : ""; + if (emitter.usesLandingPads) emitter.declare(`declare i32 @__gcc_personality_v0(...)`); + let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${personality}${debug} { ; ${fn.name}\n${B.render()}\n}`; if (int32Abi) body += "\n" + emitter.emitInt32AbiWrapper(fn, symbol, int32Abi); if (emitter.integerClones.members.has(fn.name)) { body += "\n" + emitIntegerClone(fn, emitter, emitter.stackChecks?.has(fn.name) === true); diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index 5b3389155..dfdd95451 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -61,7 +61,7 @@ import { hasLoneSurrogate, wtf8Bytes } from "../literals.js"; import { undefinedArmTag } from "../../ir/analysis.js"; import { NULLABLE_ABSENT, NULLABLE_NULL } from "./nullable-unions.js"; import { literalEqualityDefinition } from "./string-literal-equality.js"; -import { STRING_READ_HELPERS, stringScanDecl } from "./string-reads.js"; +import { STRING_READ_HELPERS, stringScanDecl, stringScanInline } from "./string-reads.js"; import { emitLibraryIdentityLines } from "../library-identity-markers.js"; import { InternalCompilerError } from "../../errors.js"; import { targetCpuAttribute } from "../target-cpu.js"; @@ -102,10 +102,10 @@ function emitFunctionsInChunks(emitter: LlEmitter, chunks: number): string[] { return fnDefs; } -/** Modules with fewer functions emit in one process: forking and merging - * would cost more than the workers save (SCRIPTC_EMIT_WORKER_FUNCTIONS - * lowers it, for tests). */ -function workerMinFunctions(): number { +/** Modules with fewer functions emit in one process, and compute their + * analyses in it: forking and merging would cost more than the workers save + * (SCRIPTC_EMIT_WORKER_FUNCTIONS lowers it, for tests). */ +export function workerMinFunctions(): number { const value = Number(process.env["SCRIPTC_EMIT_WORKER_FUNCTIONS"] ?? "2000"); return Number.isFinite(value) && value > 0 ? value : 2000; } @@ -1014,7 +1014,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { // LIBRARY mode: no @main — the profile-declared external // symbols specified by the library IR instead. for (const line of emitter.emitLibDefs(globals, globalReleaseLines, stamps)) out.push(line); - out.push(...emitter.inlineRcTail(flushedDecls)); + for (const line of emitter.inlineRcTail(flushedDecls)) out.push(line); out.push(...functionAttributes(emitter, hasNoInlineRecordClone)); out.push(``); out.push(...emitter.fieldAliasMetadata()); @@ -1160,7 +1160,9 @@ export function emitModuleParts(emitter: LlEmitter): string[] { : [` %native_exit = call i32 @scr_exit_code_hint_get()`, ` ret i32 %native_exit`]), `}`, ``, - ...emitter.inlineRcTail(flushedDecls), + ); + for (const line of emitter.inlineRcTail(flushedDecls)) out.push(line); + out.push( // sanitize_address is inert under the plain pipeline; the sanitized // lane's -fsanitize=address link activates instrumentation over the // emitted functions too (the runtime TUs get theirs from clang). @@ -1299,6 +1301,42 @@ export function helperDefs(emitter: LlEmitter): string[] { ` ret ptr %hashed`, `}`, ``, + // The same lookup for a key the emitter knows is an integer (`%ki`, + // the double `%k`'s exact value): the direct index tests the integer + // itself, so the double is converted only by the hashed probe. A + // negative key is not below the index's size as an unsigned value. + `define internal ptr @sc_map_entry_f64_int(ptr %m, i64 %ki, double %k) ${FN_ATTRS} {`, + `entry:`, + ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`, + ` %nd32 = load i32, ptr %ndp`, + ` %nd = zext i32 %nd32 to i64`, + ` %inside = icmp ult i64 %ki, %nd`, + ` br i1 %inside, label %slot, label %outside`, + `slot:`, + sz === "i64" ? ` %i = add i64 %ki, 0` : ` %i = trunc i64 %ki to ${sz}`, + ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`, + ` %dense = load ptr, ptr %dp`, + ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`, + ` %s = load i32, ptr %sp`, + ` %empty = icmp eq i32 %s, 0`, + ` br i1 %empty, label %miss, label %hit`, + `hit:`, + ` %e32 = sub i32 %s, 1`, + sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`, + ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, + ` %entries = load ptr, ptr %ep`, + ` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`, + ` ret ptr %found`, + `miss:`, + ` ret ptr null`, + `outside:`, + ` %dense.any = icmp ne i64 %nd, 0`, + ` br i1 %dense.any, label %miss, label %slow`, + `slow:`, + ` %hashed = call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)`, + ` ret ptr %hashed`, + `}`, + ``, // Hashing (scr_map_find_f64): the SameValueZero key word, its // scr_key_mix hash (zero remapped to one) and the absent-key filter. `define internal ptr @sc_map_entry_f64_hashed(ptr %m, double %k) noinline ${FN_ATTRS} {`, @@ -1615,9 +1653,7 @@ export function helperDefs(emitter: LlEmitter): string[] { // A scan's receiver reads inline too (ScrStrScan): from ordinary global // storage, or, in worker executables, from the thread's static TLS block, // one thread-pointer-relative load (see executableTls). - const scanInline = - emitter.executableTls || - (emitter.mod.workers !== true && emitter.mod.lib?.threadInstances !== true); + const scanInline = stringScanInline(emitter); let scanDeclared = false; for (const helper of STRING_READ_HELPERS) { if (!emitter.decls.has(helper.decl)) continue; diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index 794ba1798..4695566da 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -32,7 +32,7 @@ import { allocateLocalUnion, storeLocalUnion } from "./local-union-storage.js"; import { boxAccess, boxNewCall, elemAccess } from "./shapes.js"; import { dynHoleRead } from "./loop-array-borrows.js"; import { endsWithJump, matchStringSelfConcat } from "../../ir/analysis.js"; -import { charAtUnitRead, emitDiscardedSplice } from "./expr-containers.js"; +import { charAtUnitRead, emitBorrowedMapGet, emitDiscardedSplice } from "./expr-containers.js"; import { InternalCompilerError } from "../../errors.js"; import { emitDenseArrayGet, @@ -61,6 +61,7 @@ import { import { emitCountedLoopLimit, emitCountedLoopSafety, + emitCountedStart, emitPairedLoopSafety, } from "./counted-loops.js"; import { emitBytesLoopVersionGuard, type BytesLoopVersion } from "./bytes-loop-versions.js"; @@ -104,6 +105,19 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { break; } const b = emitter.binding(s.localId); + if ( + emitter.borrowedMapGets.has(s.localId) && + b.kind === "local" && + s.init?.kind === "mapIntrinsic" + ) { + // The map entry's own reference: no retain now, no release at scope + // exit (findMapReadBorrows). + const borrowed = emitBorrowedMapGet(emitter, s.init); + if (borrowed) { + B.line(`store ptr ${borrowed.name}, ptr ${b.slot}`); + break; + } + } if (emitter.walkBorrows.has(s.localId) && b.kind === "local") { // A borrowed walk pointer: no retain now, no release at scope exit. const v = s.init === null ? "null" : emitter.emitReadReceiver(s.init).name; @@ -570,6 +584,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { s.value, emitter.bytesBounds.has(s), emitter.bytesOwned.has(s), + emitter.bytesAliasScopes.get(s), ); break; } @@ -1112,9 +1127,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.entryAllocas.push( `${integerSlot} = alloca i64 ; integer induction ${emitter.currentLocals.get(countedLoop.localId)!.name}`, ); - const startNumber = emitter.emitExpr(countedLoop.start); - const start = B.tmp(); - B.line(`${start} = fptosi double ${startNumber.name} to i64`); + const start = emitCountedStart(emitter, countedLoop.start); B.line(`store i64 ${start}, ptr ${integerSlot}`); emitter.integerLoopBindings.set(countedLoop.localId, { slot: integerSlot, @@ -1130,9 +1143,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.entryAllocas.push( `${slot} = alloca i64 ; integer induction ${emitter.currentLocals.get(follower.localId)!.name}`, ); - const value = emitter.emitExpr(follower.start); - const followerStart = B.tmp(); - B.line(`${followerStart} = fptosi double ${value.name} to i64`); + const followerStart = emitCountedStart(emitter, follower.start); B.line(`store i64 ${followerStart}, ptr ${slot}`); followerSlots.push(slot); emitter.integerLoopBindings.set(follower.localId, { @@ -1149,9 +1160,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.entryAllocas.push( `${slot} = alloca i64 ; integer induction ${emitter.currentLocals.get(counter.localId)!.name}`, ); - const value = emitter.emitExpr(counter.start); - const start = B.tmp(); - B.line(`${start} = fptosi double ${value.name} to i64`); + const start = emitCountedStart(emitter, counter.start); B.line(`store i64 ${start}, ptr ${slot}`); pairedSlots.push(slot); const dense = LOOPS.of(emitter).denseLoop; @@ -2065,7 +2074,8 @@ function emitCallbackVersionedLoop( /** The copy of a versioned loop that runs when its guard fails. Its loops * get no speculative or int32 kind copies, which would emit each loop again * for a path that rarely runs. Proven integer loops, which emit one copy, - * are kept. */ + * are kept. A typed-array version whose guard requires thread-owned + * storage does not use it (emitVersionedLoop). */ function emitSlowCopy(emitter: LlEmitter, s: IrStmt): void { const copies = emitter.loopCopiesEnabled; emitter.loopCopiesEnabled = false; @@ -2080,6 +2090,46 @@ function emitSlowCopy(emitter: LlEmitter, s: IrStmt): void { * stops trying it. */ const SPECULATION_BAILS = 8; +/** The attachment of each scoped access in a version's fast copy: the + * scope of its own view, and no alias with the scopes of the other views, + * whose bytes the guard proved disjoint. The scopes are named after the + * function and the version, so every copy of a loop has its own, and + * written inline (string-named scopes need no numbered metadata). Also + * the scopes, which each fast copy declares on entry (declareAliasScopes). */ +function bytesAliasScopes( + emitter: LlEmitter, + scopes: NonNullable, +): { attachments: Map; scopes: string[] } { + const version = emitter.aliasScopedVersions++; + const name = emitter.currentFunctionName.replace(/[^A-Za-z0-9_.$%]/g, "_"); + const domain = '!{!"scriptc typed-array views"}'; + const scope = (receiver: number): string => `!{!"${name}.${version}.${receiver}", ${domain}}`; + const attachments = new Map(); + for (const receiver of scopes.receivers) { + const others = scopes.receivers.filter((other) => other !== receiver).map(scope); + attachments.set( + receiver, + `, !alias.scope !{${scope(receiver)}}, !noalias !{${others.join(", ")}}`, + ); + } + const result = new Map(); + for (const [node, receiver] of scopes.nodes) result.set(node, attachments.get(receiver)!); + return { attachments: result, scopes: scopes.receivers.map(scope) }; +} + +/** Declares a version's scopes where a fast copy starts. The guard proves + * the views disjoint for this entry only: an enclosing loop may pass other + * views on its next pass (`[cur, next] = [next, cur]`). A declared scope + * holds from its declaration, and LLVM gives each copy of the declaration + * its own scopes when it unrolls or duplicates the enclosing code, so the + * no-alias facts of one entry never apply to the accesses of another. */ +function declareAliasScopes(emitter: LlEmitter, scopes: readonly string[]): void { + if (scopes.length === 0) return; + emitter.declare("declare void @llvm.experimental.noalias.scope.decl(metadata)"); + for (const scope of scopes) + emitter.B.line(`call void @llvm.experimental.noalias.scope.decl(metadata !{${scope}})`); +} + /** Emit a loop's condition, body and update through `emitCopy`, twice * when speculative-integers.ts plans a fast integer copy. Both copies * share the loop's setup and exit; the ordinary copy comes first so the @@ -2096,28 +2146,69 @@ function emitVersionedLoop( const fast = B.newLabel("bytes.version.fast"), slow = B.newLabel("bytes.version.slow"), done = B.newLabel("bytes.version.done"); - emitBytesLoopVersionGuard(emitter, version, fast, slow); + const unitsFast = + version.unitsReceiver !== null ? B.newLabel("bytes.version.fast.units") : undefined; + const guard = emitBytesLoopVersionGuard(emitter, version, fast, slow, unitsFast); + const units = guard?.units ?? null; const originalBounds = emitter.bytesBounds, originalOwned = emitter.bytesOwned, - originalRanges = emitter.integerRanges; + originalRanges = emitter.integerRanges, + originalUnitReads = emitter.stringUnitReads, + originalLengthReads = emitter.stringLengthReads, + originalScopes = emitter.bytesAliasScopes; LOOPS.of(emitter).bytesLoopVersioning.add(s); try { B.startBlock(slow); - emitSlowCopy(emitter, s); + // A guard that requires thread-owned storage fails for every view of + // shared memory, which worker programs pass routinely: that checked + // copy keeps its loops' speculative and int32 kind copies. + if (version.ownedReceivers.length > 0) emitter.emitStmt(s); + else emitSlowCopy(emitter, s); B.br(done); - B.startBlock(fast); - emitter.bytesBounds = new Set([...originalBounds, ...version.bounds]); if (version.owned.size > 0) emitter.bytesOwned = new Set([...originalOwned, ...version.owned]); if (version.ranges.size > 0) emitter.integerRanges = new Map([...originalRanges, ...version.ranges]); + // Both fast copies run under the guard's span test, so they share the + // version's scopes, and each declares them on entry. + const scoped = + version.scopes && emitter.debug === null ? bytesAliasScopes(emitter, version.scopes) : null; + if (scoped) emitter.bytesAliasScopes = new Map([...originalScopes, ...scoped.attachments]); if (version.counted?.guarded || version.paired?.loop.guarded) LOOPS.of(emitter).provenCountedGuards.add(s); + B.startBlock(fast); + declareAliasScopes(emitter, scoped?.scopes ?? []); + emitter.bytesBounds = new Set([...originalBounds, ...version.bounds]); + // Both fast copies read the string's length the guard took. + const length = guard?.length; + if (length != null && version.unitLengths.size > 0) + emitter.stringLengthReads = new Map([ + ...originalLengthReads, + ...[...version.unitLengths].map((node) => [node, length] as const), + ]); emitter.emitStmt(s); B.br(done); + if (unitsFast !== undefined && units !== null) { + // The string reads take the receiver's UTF-16 units. + B.startBlock(unitsFast); + declareAliasScopes(emitter, scoped?.scopes ?? []); + emitter.bytesBounds = new Set([ + ...originalBounds, + ...[...version.bounds].filter((node) => !version.stringReads.has(node)), + ]); + emitter.stringUnitReads = new Map([ + ...originalUnitReads, + ...[...version.stringReads].map((node) => [node, units] as const), + ]); + emitter.emitStmt(s); + B.br(done); + } } finally { emitter.bytesBounds = originalBounds; emitter.bytesOwned = originalOwned; emitter.integerRanges = originalRanges; + emitter.stringUnitReads = originalUnitReads; + emitter.stringLengthReads = originalLengthReads; + emitter.bytesAliasScopes = originalScopes; LOOPS.of(emitter).provenCountedGuards.delete(s); LOOPS.of(emitter).bytesLoopVersioning.delete(s); } @@ -2151,6 +2242,7 @@ export function emitLoopCopies( ); }, (id) => emitter.speculativeLocal(id) || emitter.speculativeInvariantGlobal(id), + emitter.speculationContext(), ) : null; if (!plan) { @@ -2209,6 +2301,20 @@ export function emitLoopCopies( B.line(`${both} = and i1 ${ok}, ${trying}`); ok = both; } + // Hoisted leaves live in locals of their own, which the resume copy of + // their write reads (see SpeculativeValue). + for (const { leaves } of plan.values.values()) + for (const leaf of leaves) { + emitter.currentLocals.set(leaf.localId, { + id: leaf.localId, + name: "speculated leaf", + type: leaf.expr.type, + mutable: true, + }); + B.entryAllocas.push( + `${emitter.binding(leaf.localId).slot} = alloca double ; speculated leaf`, + ); + } const fast = B.newLabel("spec.fast"), slow = B.newLabel("spec.slow"); B.condBr(ok, fast, slow); @@ -2234,6 +2340,7 @@ export function emitLoopCopies( throw new InternalCompilerError("llvm emitter bug: speculative loop copy without a header"); B.startBlock(B.newLabel("spec.tail")); emitter.speculationResume = resume; + emitter.speculationResumeValues = plan.values; emitter.speculationHeader = () => { B.br(header!); B.startBlock(B.newLabel("spec.unused")); @@ -2242,6 +2349,7 @@ export function emitLoopCopies( emitCopy(exit); } finally { emitter.speculationResume = null; + emitter.speculationResumeValues = null; emitter.speculationHeader = null; } B.startBlock(fast); @@ -2290,6 +2398,7 @@ export function emitLoopCopies( deferred, bails, ran, + values: plan.values, }; try { const leave = B.newLabel("spec.leave"); @@ -2547,9 +2656,22 @@ export function emitSpeculativeWrite( // at once: a bailing path then owns nothing, and reads have no effect // whose reordering before the arithmetic could be observed. const reads = new Map(); - if (target.value !== null) { + const rewritten = emitter.speculation!.values?.get(s); + const value = rewritten?.value ?? target.value; + if (rewritten !== undefined) { + // Hoisted leaves run first, in the write's order, and keep their + // doubles for the resume copy. emitter.frames.push([]); - for (const read of speculativeReads(target.value)) reads.set(read, emitter.emitExpr(read)); + for (const leaf of rewritten.leaves) { + const evaluated = emitter.emitExpr(leaf.expr); + B.line(`store double ${evaluated.name}, ptr ${emitter.binding(leaf.localId).slot}`); + reads.set(leaf.ref, evaluated); + } + emitter.releaseTemps(emitter.frames.pop()!); + } + if (value !== null) { + emitter.frames.push([]); + for (const read of speculativeReads(value)) reads.set(read, emitter.emitExpr(read)); emitter.releaseTemps(emitter.frames.pop()!); } emitter.frames.push([]); @@ -2564,7 +2686,7 @@ export function emitSpeculativeWrite( B.line(`${biased} = add i64 ${result}, ${Number.MAX_SAFE_INTEGER}`); B.line(`${outside} = icmp ugt i64 ${biased}, ${2 * Number.MAX_SAFE_INTEGER}`); bail(outside); - } else result = emitSpeculativeInteger(emitter, target.value, bail, reads, resume === undefined); + } else result = emitSpeculativeInteger(emitter, value!, bail, reads, resume === undefined); // Speculable values read only numbers past this point. if (emitter.frames.pop()!.length > 0) throw new InternalCompilerError("llvm emitter bug: speculative write owns a temporary"); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e6ec6e702..c14ae8722 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -17,6 +17,7 @@ import { import { youngNurseryRequested } from "../young-nursery.js"; import { llvmBytes as llBytes, wtf8Bytes } from "../literals.js"; import { InternalCompilerError } from "../../errors.js"; +import { serializeModuleParts } from "../../ir/serialize.js"; import { createHash } from "node:crypto"; import { endsWithJump, @@ -55,6 +56,7 @@ import { typeKey, VOID, classJsName, + classValueAbiEquals, } from "../../ir/ir.js"; import { moduleRuntimeFeatures, type RuntimeFeatures } from "../../ir/runtime-features.js"; import { captureFreeFunctions, observesStacks } from "../stack-observations.js"; @@ -68,7 +70,7 @@ import { type StoredSplitSnapshot, type StringSplit, } from "./split-loops.js"; -import { scalarizeNumericRecords } from "../../ir/scalar-records.js"; +import { scalarizeLiteralResults, scalarizeNumericRecords } from "../../ir/scalar-records.js"; import { addRecordResultEntries, resultEntryName } from "./record-results.js"; import { specializeCallbackCalls } from "../../ir/callback-specialization.js"; import { @@ -80,11 +82,15 @@ import { } from "./emit-journal.js"; import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"; import { versionLoopBoundParameters } from "../../ir/parameter-versions.js"; +import { inlineExpressionFunctions } from "../../ir/expression-inlining.js"; +import { pairRowSums } from "../../ir/row-pairs.js"; import { allocatedLiteral, fillAllocationLength, int32ArrayKey, selectInt32Arrays, + type Int32ArrayProof, + type Int32ArrayRevision, } from "../../ir/int32-arrays.js"; import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { @@ -100,8 +106,9 @@ import { resolveSelfCaptures } from "../../ir/self-captures.js"; import { analyzeInt32Slots, dynamicFieldStores, + Int32RevisionConflict, type DynamicFieldStores, - type Int32Slots, + Int32Slots, } from "../../ir/int32-slots.js"; import { analyzeFinalFields, type FinalFields } from "../../ir/final-fields.js"; import { foldConstantGlobals } from "../../ir/constant-globals.js"; @@ -109,6 +116,8 @@ import { dropUnwrittenPropertyBags } from "../../ir/property-bags.js"; import { freezeInitOnlyGlobals } from "../../ir/init-only-globals.js"; import { hoistFinalFieldReads } from "../../ir/field-values.js"; import { holdCharLocalsAsUnits } from "../../ir/char-unit-locals.js"; +import { unitSafeFunctions } from "./string-unit-versions.js"; +import { stringScanInline } from "./string-reads.js"; import { findInt32Abis, INT32_ABI_SUFFIX, @@ -118,7 +127,7 @@ import { } from "./int32-abi.js"; import { type Int32Local } from "./int32-locals.js"; import { findIntegerClones, NO_INTEGER_CLONES, type IntegerClones } from "./integer-clones.js"; -import { exactInteger, narrowInteger, widenInteger } from "./integer-values.js"; +import { exactInteger, narrowInteger, widenInteger, type LlInteger } from "./integer-values.js"; import { findInitializerBindings } from "../../ir/initializer-bindings.js"; import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js"; import { @@ -128,6 +137,16 @@ import { type FfiCallbackAdapter, } from "../ffi-callbacks.js"; import { computeMayThrow } from "../may-throw.js"; +import { + UNWIND_PERSONALITY, + UNWIND_RAISE, + UNWIND_RAISE_DECLARATION, + UNWIND_RESUME, + UNWIND_SLOT, + UnwindAbiViolation, + bridgePlainUnwindCalls, + unwindAbiFunctions, +} from "./unwind-abi.js"; import { mangleAsyncInlineAwaitCall, mangleAsyncSpawn, @@ -186,8 +205,10 @@ import { StackCallbacks } from "./stack-callbacks.js"; import { findAppendedArrayLiterals } from "./appended-array-literals.js"; import { AmbientReceiverReaders, findConstantCallbacks } from "./constant-callbacks.js"; import { + findLentRecordFields, findLentStringParameters, - type LentStringParameters, + findRecordFieldProducers, + type LendingFacts, type StringSliceSnapshot, } from "./string-slices.js"; import { LazyCaptures } from "./lazy-captures.js"; @@ -343,13 +364,22 @@ import { } from "./shapes.js"; import type { ExprOf, LibCallExpr, LlStreamTypedRefAdapter, LlValue } from "./expr-context.js"; import { INLINE_LITERAL_BYTES } from "./string-literal-equality.js"; +import { + inlinableNumberFunction, + withSpeculativeValue, + type InlinableFunction, + type SpeculationContext, + type SpeculativeValue, +} from "./speculative-integers.js"; import { emitFfiCallbackDefs, emitLibDefs, emitModuleParts, emitParts, helperDefs, + workerMinFunctions, } from "./emit-module.js"; +import { forkedAnalysis } from "./forked-analysis.js"; import { emitLoopCopies, emitSpeculativeWrite, @@ -439,6 +469,12 @@ export interface ForkPrimitives { export interface LlvmTargetOptions { /** C ABI target; omitted direct emission uses the host ABI. */ targetTriple?: string; + /** Linux (glibc and musl) executables: functions reached only by direct + * calls propagate exceptions by unwinding the native stack (unwind-abi.ts). */ + unwindAbi?: boolean; + /** Functions kept on the pending-flag ABI under `unwindAbi`: the ones an + * earlier emission found breaking its rules (UnwindAbiViolation). */ + unwindExcluded?: ReadonlySet; /** Emit the function bodies in this many contiguous ranges, each by its * own emitter, and merge them in function order (emit-journal.ts). The * output is the same as one emitter's. Debug builds (`debugSources`) @@ -522,15 +558,60 @@ export interface Int32SlotAnalysis { readonly slots: Int32Slots; } -/** The int32 slot analysis of `mod`, with the inputs LlEmitter gives it. */ -function analyzeModuleInt32Slots(mod: IrModule): Int32SlotAnalysis { +/** The int32 slot analysis of `mod`, with the inputs LlEmitter gives it. + * With `revise`, the analysis revises the functions of candidates that drop + * (Int32SlotInputs.revise), and `mod` is the module it started from. */ +function analyzeModuleInt32Slots( + mod: IrModule, + revise?: (fnName: string, dropped: ReadonlySet) => IrFunction, +): Int32SlotAnalysis { const nodeIndex = new IrNodeIndex(mod); const initializerBindings = findInitializerBindings(mod, nodeIndex); const dynamicStores = dynamicFieldStores(mod, undefined, undefined, nodeIndex); - const slots = analyzeInt32Slots(mod, { dynamicStores, initializerBindings, nodes: nodeIndex }); + const slots = analyzeInt32Slots(mod, { + dynamicStores, + initializerBindings, + nodes: nodeIndex, + ...(revise === undefined ? {} : { revise }), + }); return { mod, nodeIndex, initializerBindings, dynamicStores, slots }; } +/** The candidates an analysis keeps: int32 arrays and checked ones. */ +function keptInt32Arrays(analysis: Int32SlotAnalysis): ReadonlySet { + return new Set([...analysis.slots.int32Arrays, ...analysis.slots.checkedInt32Arrays]); +} + +/** An analysis that revised functions (analyzeModuleInt32Slots' `revise`) + * as the analysis of `mod`, the module of the functions it ended with: the + * node index of `mod`, and its initializer bindings and dynamic stores, + * which must be the ones the analysis read. Null when they differ. */ +function revisedAnalysis(analysis: Int32SlotAnalysis, mod: IrModule): Int32SlotAnalysis | null { + const nodeIndex = new IrNodeIndex(mod); + const initializerBindings = findInitializerBindings(mod, nodeIndex); + const dynamicStores = dynamicFieldStores(mod, undefined, undefined, nodeIndex); + const read = analysis.initializerBindings; + if (initializerBindings.size !== read.size) return null; + for (const [name, globals] of initializerBindings) { + const other = read.get(name); + if (other?.length !== globals.length || globals.some((g, i) => other[i] !== g)) return null; + } + const stores = analysis.dynamicStores; + if ( + dynamicStores.all !== stores.all || + dynamicStores.names.size !== stores.names.size || + [...dynamicStores.names].some((name) => !stores.names.has(name)) + ) + return null; + return { + mod, + nodeIndex, + initializerBindings, + dynamicStores, + slots: analysis.slots, + }; +} + /** The functions whose calls the backend may emit as calls of their * result-slot entries (record-results.ts). */ function resultEntryTargets(mod: IrModule): ReadonlySet { @@ -545,61 +626,157 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { // Facts are keyed by node identity: emit the same module whether it was // lowered in memory or read back from its serialized form. const mod = unshareNodes(input); - // Each round of selectInt32Arrays analyzes the module it marks; the - // emitter of the module it returns reuses that round's analysis. - const rounds: { last: Int32SlotAnalysis | null } = { last: null }; - const proven = (marked: IrModule): ReadonlySet => { - const analysis = analyzeModuleInt32Slots(marked); - rounds.last = analysis; - return new Set([...analysis.slots.int32Arrays, ...analysis.slots.checkedInt32Arrays]); + // Each proof selectInt32Arrays asks for analyzes a marked module; the + // emitter of the module it returns reuses the last one. A proof that + // revised functions analyzed the module of the functions it ended with. + const rounds: { + last: Int32SlotAnalysis | null; + functions: readonly IrFunction[] | null; + revising: boolean; + } = { last: null, functions: null, revising: true }; + const proven = (marked: IrModule, revise: Int32ArrayRevision | null): Int32ArrayProof | null => { + rounds.functions = null; + if (revise === null || !rounds.revising) { + rounds.last = analyzeModuleInt32Slots(marked); + return { kept: keptInt32Arrays(rounds.last), functions: null }; + } + const latest = new Map(); + try { + rounds.last = analyzeModuleInt32Slots(marked, (fnName, dropped) => { + const fn = revise(fnName, dropped); + latest.set(fnName, fn); + return fn; + }); + } catch (error) { + if (!(error instanceof Int32RevisionConflict)) throw error; + rounds.last = null; + return null; + } + if (latest.size === 0) return { kept: keptInt32Arrays(rounds.last), functions: null }; + rounds.functions = marked.functions.map((fn) => latest.get(fn.name) ?? fn); + return { kept: keptInt32Arrays(rounds.last), functions: rounds.functions }; }; // Keep source storage intact for debugger inspection in dev builds. - const emitted = - options.debugSources === undefined - ? selectInt32Arrays( - versionParameters( - addRecordResultEntries( - scalarizeNumericRecords( - specializeCallbackCalls( - specializeNumericCalls( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits( - dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), - ), + if (options.debugSources !== undefined) return new LlEmitter(mod, options, null, null); + const passed = versionParameters( + addRecordResultEntries( + scalarizeLiteralResults( + scalarizeNumericRecords( + specializeCallbackCalls( + specializeNumericCalls( + pairRowSums( + inlineExpressionFunctions( + foldConstantGlobals( + copyStableCaptures( + resolveSelfCaptures( + hoistFinalFieldReads( + freezeInitOnlyGlobals( + holdCharLocalsAsUnits( + dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), ), ), ), ), ), - mangleFunction, ), - mangleFunction, ), ), + mangleFunction, ), + mangleFunction, ), - proven, - resultEntryTargets, - ) - : mod; - const prepared = rounds.last; + ), + ), + ), + ); + let emitted = selectInt32Arrays(passed, proven, resultEntryTargets); + let prepared = rounds.last; + // A proof that revised functions analyzed the module of the functions it + // ended with, which is the selected module unless selection fell back to + // rounds: then the selected module was proved by its own round. + if (prepared !== null && rounds.functions !== null && prepared.mod !== emitted) { + const functions = rounds.functions; + prepared = + emitted !== passed && + emitted.functions.length === functions.length && + emitted.functions.every((fn, i) => fn === functions[i]) + ? revisedAnalysis(prepared, emitted) + : null; + if (prepared === null && emitted !== passed) { + rounds.revising = false; + emitted = selectInt32Arrays(passed, proven, resultEntryTargets); + prepared = rounds.last; + } + } + if (process.env["SCRIPTC_INT32_VERIFY"] === "1") + verifyInt32Selection(passed, emitted, prepared?.mod === emitted ? prepared : null); return new LlEmitter(emitted, options, null, prepared?.mod === emitted ? prepared : null); } +/** SCRIPTC_INT32_VERIFY=1: throws unless the selection with revisions + * (`emitted`, and the analysis the emitter takes over) is the one rounds + * without revisions give: the same module, and the same facts. */ +function verifyInt32Selection( + passed: IrModule, + emitted: IrModule, + analysis: Int32SlotAnalysis | null, +): void { + let last: Int32SlotAnalysis | null = null; + const reference = selectInt32Arrays( + passed, + (marked) => { + last = analyzeModuleInt32Slots(marked); + return { kept: keptInt32Arrays(last), functions: null }; + }, + resultEntryTargets, + ); + const text = (mod: IrModule): string => serializeModuleParts(mod).join(""); + if (text(reference) !== text(emitted)) + throw new InternalCompilerError("int32 selection: the revised module differs from the rounds'"); + const facts = (mod: IrModule, prepared: Int32SlotAnalysis | null): string => + (prepared?.mod === mod ? prepared : analyzeModuleInt32Slots(mod)).slots.summary(mod); + const expected = facts(reference, last); + const actual = facts(emitted, analysis); + if (expected !== actual) { + const want = expected.split("\n"); + const got = actual.split("\n"); + const line = want.findIndex((entry, i) => entry !== got[i]); + throw new InternalCompilerError( + `int32 selection: the revised analysis differs from the rounds': ${want[line]?.slice(0, 400)} != ${got[line]?.slice(0, 400)}`, + ); + } +} + +/** Emits the module's parts, emitting it again with the functions an + * UnwindAbiViolation names on the pending-flag ABI. */ +function emitModuleText(input: IrModule, options: LlvmTargetOptions): string[] { + for (let excluded = options.unwindExcluded ?? new Set(); ;) { + const emitter = moduleEmitter(input, { ...options, unwindExcluded: excluded }); + try { + return emitter.emitParts(); + } catch (error) { + if (!(error instanceof UnwindAbiViolation)) throw error; + const names = new Set(excluded); + for (const name of emitter.unwindAbi) + for (const symbol of [mangleFunction(name), mangleBorrowedFunction(name)]) + if (error.symbols.has(symbol) || error.symbols.has(symbol + INT32_ABI_SUFFIX)) + names.add(name); + if (names.size === excluded.size) throw error; + excluded = names; + } + } +} + export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string { - return moduleEmitter(input, options).emit(); + return emitModuleText(input, options).join("\n"); } /** emitLlvmModule's text as parts that concatenate to it, without the copy * that joining them makes: a large program's text is 150 MB. */ export function emitLlvmModuleParts(input: IrModule, options: LlvmTargetOptions = {}): string[] { - return moduleEmitter(input, options) - .emitParts() - .flatMap((part, index) => (index === 0 ? [part] : ["\n", part])); + return emitModuleText(input, options).flatMap((part, index) => + index === 0 ? [part] : ["\n", part], + ); } /** Keep large translation units below the host's single-string limit. */ @@ -609,7 +786,7 @@ export function emitLlvmModuleSource( ): string | readonly string[] { // IR validation, contracts and the frontend's disposal run after lowering without checks. relieveHeapPressure(true); - const parts = moduleEmitter(input, options).emitParts(); + const parts = emitModuleText(input, options); const length = parts.reduce((total, part) => total + part.length, Math.max(0, parts.length - 1)); if (length <= 256 * 1024 * 1024) return parts.join("\n"); return parts.flatMap((part, index) => (index === 0 ? [part] : ["\n", part])); @@ -748,8 +925,9 @@ export class LlEmitter { readonly stringSlices = new Map(); /** Slices written into stack buffers (findScratchStringSlices). */ scratchStringSlices: ReadonlySet = new Set(); - /** String parameters a caller may lend scratch slices to. */ - lentStringParameters: LentStringParameters = new Map(); + /** String parameters, record fields and record producers a caller may + * lend scratch slices to. */ + lendingFacts: LendingFacts = { strings: new Map(), records: new Map(), producers: new Map() }; localStackUnions = new Map(); localStackObjects = new Map(); reassignedStackObjects = new Set(); @@ -1183,7 +1361,13 @@ export class LlEmitter { readonly checkedNarrows: CheckedNarrows; callArrayReads = new Map(); mapReadLifetimes: MapReadLifetimes = { locals: new Map(), arguments: new Map() }; + /** Locals of the current function that hold a Map.get result as the + * entry's own reference (findMapReadBorrows, borrowableMapGet). */ + borrowedMapGets: ReadonlySet = new Set(); readNumberMapWrites: ReadonlySet = new Set(); + /** The integer views of the current function's number Map/Set keys, by + * the key's double operand (noteNumberMapKey, map-number-lookup.ts). */ + numberMapKeyIntegers = new Map(); /** The current function's fused string-key Map get-then-set pairs (map-entry-fusion.ts). */ mapEntryFusion: MapEntryFusion | null = null; readonly callLifetimes: CallLifetimes; @@ -1253,6 +1437,10 @@ export class LlEmitter { /** May-throw analysis (computeMayThrow): pending * checks are emitted only after calls that can actually raise. */ readonly mayThrow: Set; + /** The functions that propagate exceptions by unwinding (unwind-abi.ts). */ + readonly unwindAbi: Set; + /** Whether the function being emitted is one of unwindAbi. */ + currentUnwindAbi = false; readonly indirectMayThrow: boolean; /** Worker programs: the functions that poll for termination on entry * (workerTerminationPolls); null outside worker programs. */ @@ -1374,7 +1562,12 @@ export class LlEmitter { /** Preorder intervals of the runtime error classes under THIS module's * class-forest numbering (main() stamps scr_error_vts with them, exactly * like the runtime ABI’s errorVtStampLines). */ - readonly errorIntervals: { kind: number; pre: number; post: number; lib: string }[] = []; + readonly errorIntervals: { + kind: number; + pre: number; + post: number; + lib: string; + }[] = []; /** The runtime emitter vtable's preorder interval, when the program * touches node:events (the class def rides the module exactly then) — * main() stamps scr_emitter_vt with it (emitterVtStampLines, ported). */ @@ -1383,7 +1576,12 @@ export class LlEmitter { * ported): the defs ride every emitter-touching module (the frontend * collects the whole emitter-rooted tree), and scr_stream.c links on * the same predicate, so the stamps always have their globals. */ - readonly streamIntervals: { vt: string; pre: number; post: number; lib: string }[] = []; + readonly streamIntervals: { + vt: string; + pre: number; + post: number; + lib: string; + }[] = []; // ── per-function state (reset in emitFunction) ───────────────────────── B = new BlockBuilder(); @@ -1419,12 +1617,22 @@ export class LlEmitter { streamingSplitsEnabled = false; readonly initializerBindings: ReturnType; /** Whole-program int32 storage and parameter/result facts. */ - readonly int32Slots: Int32Slots; + get int32Slots(): Int32Slots { + return (this.int32SlotsValue ??= this.joinInt32Slots()); + } + private int32SlotsValue: Int32Slots | undefined; + /** The join of the int32 slot analysis a forked process computes + * (startInt32Slots). */ + private int32SlotsJoin: (() => Int32Slots) | undefined; /** The program has no runtime store into a checked-dynamic value * (dynamicFieldStores), so live views of classes and records never * commit back into their objects. Library builds keep their commits: * their values cross a native boundary. */ readonly liveViewsReadOnly: boolean; + /** The program freezes, seals or prevents extensions of untyped values + * (IrModule.untypedIntegrity): the commits of class views store the + * view's integrity state in the instance's integrity word. */ + readonly untypedIntegrity: boolean; /** Functions whose direct calls use i32 parameters or results. */ readonly int32Abis: ReadonlyMap; /** Pure number functions with speculative integer clones. */ @@ -1446,7 +1654,12 @@ export class LlEmitter { /** Active induction bindings retain their width independently of size_t. */ integerLoopBindings = new Map< string, - { slot: string; type: "i32" | "i64"; signed?: boolean; range?: { min: number; max: number } } + { + slot: string; + type: "i32" | "i64"; + signed?: boolean; + range?: { min: number; max: number }; + } >(); /** Constant number locals whose latest emitted declaration stored an * exact integer beside the double, such as `const j = i * 4` in a @@ -1476,11 +1689,50 @@ export class LlEmitter { * integer loop has neither. */ bails?: string; ran?: string; + /** Writes that speculate a rewritten value (rewriteSpeculable). */ + values?: ReadonlyMap; } | null = null; /** Speculative loop sites numbered so far in the module. */ speculativeLoopSites = 0; /** The ordinary copy being emitted: resume labels still to place. */ speculationResume: Map | null = null; + /** The rewritten values of the writes `speculationResume` resumes at. */ + speculationResumeValues: ReadonlyMap | null = null; + /** Hoisted speculative leaves numbered so far in the function being + * emitted (emitFunction resets it): a function's local names must not + * depend on which functions the same emitter emitted before it, since + * function workers each emit a share of the module. */ + speculativeLeaves = 0; + private readonly inlinableFunctions = new Map(); + + /** What speculative loop planning may assume here (SpeculationContext). */ + speculationContext(): SpeculationContext { + const inlinable = (callee: string): InlinableFunction | undefined => { + if (!this.inlinableFunctions.has(callee)) { + // Unset while its body is checked: a recursive helper is not inlinable. + this.inlinableFunctions.set(callee, undefined); + const fn = this.fnByName.get(callee); + this.inlinableFunctions.set( + callee, + fn ? inlinableNumberFunction(fn, inlinable) : undefined, + ); + } + return this.inlinableFunctions.get(callee); + }; + return { + inlinable, + unaliased: (id) => { + const local = this.currentLocals.get(id); + return ( + local !== undefined && + !local.boxed && + !this.captureIds.has(id) && + !this.lazyCaptureBoxes.has(id) + ); + }, + leafId: () => `%spec.leaf.${this.speculativeLeaves++}`, + }; + } /** Run once at the top of the next loop copy's header * (`enterLoopHeader`): records the ordinary copy's header, or sends the * third copy's header there. */ @@ -1490,7 +1742,11 @@ export class LlEmitter { * (the fast copy) or as double arrays (an ordinary copy entered only * after they widened), as `function\0local` keys, and where each of the * fast copy's writes resumes in the third copy. */ - int32Kinds: { int32: Set; wide: Set; resume: Map } | null = null; + int32Kinds: { + int32: Set; + wide: Set; + resume: Map; + } | null = null; /** The resume label of the fast-copy write being emitted. */ int32KindResume: string | null = null; /** The function being emitted, for speculative globals. */ @@ -1518,9 +1774,23 @@ export class LlEmitter { * (one-unit tests and switches, charAtUnitRead), with the charAt each * stands for: a loop version's proof for the charAt covers its read. */ readonly charAtUnitReads = new Map(); + /** String reads a loop version proved in bounds of a string's UTF-16 + * units, with the units pointer to read (string-unit-versions.ts). */ + stringUnitReads: ReadonlyMap = new Map(); + /** `length` reads of a loop version's string, with the i64 UTF-16 + * length its guard took (bytes-loop-versions.ts). */ + stringLengthReads: ReadonlyMap = new Map(); /** Worker programs: typed-array accesses whose views an enclosing version * guard proved thread-owned. */ bytesOwned: ReadonlySet = new Set(); + /** The alias-scope attachment of each typed-array element read and write + * in the current loop version's fast copy (emitVersionedLoop), and the + * one the element access being emitted carries (bytesElementAlias). */ + bytesAliasScopes: ReadonlyMap = new Map(); + bytesAccessScope = ""; + /** Loop versions with alias scopes in the current function; it names + * their scopes. */ + aliasScopedVersions = 0; integerViews = new Map(); /** Module globals the current function owns outright (initializer * bindings): their function-local slot, which stands in for the global. */ @@ -1550,7 +1820,12 @@ export class LlEmitter { * releases frames/scopes down to the recorded depths and branches to * `label` (the catch, or the exception-path finally) instead of * returning out of the function. Entering a try emits no code. */ - tryStack: { label: string; used: boolean; frameDepth: number; scopeDepth: number }[] = []; + tryStack: { + label: string; + used: boolean; + frameDepth: number; + scopeDepth: number; + }[] = []; /** Return type of the function being emitted — the unwind path returns * a dummy of this type (never read: callers check the flag first). */ currentReturnType: IrType = VOID; @@ -1615,6 +1890,15 @@ export class LlEmitter { // One walk of every body, which the analyses below read instead of // walking the bodies themselves. this.nodeIndex = int32Analysis?.nodeIndex ?? new IrNodeIndex(mod); + this.initializerBindings = + int32Analysis?.initializerBindings ?? findInitializerBindings(mod, this.nodeIndex); + // One walk finds the dynamic field stores every analysis below consults. + const dynamicStores = + int32Analysis?.dynamicStores ?? dynamicFieldStores(mod, undefined, undefined, this.nodeIndex); + // Started before the other analyses, which may run while a forked + // process computes it (startInt32Slots). + if (int32Analysis !== null) this.int32SlotsValue = int32Analysis.slots; + else this.startInt32Slots(dynamicStores); this.stackTraces = observesStacks(mod, this.nodeIndex); this.nullableUnions = new NullableUnions( mod, @@ -1633,20 +1917,12 @@ export class LlEmitter { this.nullableUnions.armTypes(), ); this.constantNumericTables = findConstantNumericTables(mod); - this.initializerBindings = - int32Analysis?.initializerBindings ?? findInitializerBindings(mod, this.nodeIndex); - // One walk finds the dynamic field stores every analysis below consults. - const dynamicStores = - int32Analysis?.dynamicStores ?? dynamicFieldStores(mod, undefined, undefined, this.nodeIndex); - this.int32Slots = - int32Analysis?.slots ?? - analyzeInt32Slots(mod, { - dynamicStores, - initializerBindings: this.initializerBindings, - nodes: this.nodeIndex, - }); + this.untypedIntegrity = mod.untypedIntegrity === true; this.liveViewsReadOnly = - mod.lib === undefined && !dynamicStores.all && dynamicStores.names.size === 0; + mod.lib === undefined && + !this.untypedIntegrity && + !dynamicStores.all && + dynamicStores.names.size === 0; this.int32Abis = this.debug === null ? findInt32Abis(mod, this.int32Slots) : new Map(); this.immortalGlobals = new Set(mod.contracts?.immortalGlobals ?? []); this.programLifetime = new Set(mod.contracts?.programLifetime ?? []); @@ -1714,6 +1990,16 @@ export class LlEmitter { nodes: this.nodeIndex, }); this.mayThrow = mt.fns; + this.unwindAbi = + options.unwindAbi === true && + !this.wasi && + mod.lib === undefined && + options.debugSources === undefined && + (options.targetTriple + ? /-linux-(?:gnu|musl)/.test(options.targetTriple) + : process.platform === "linux") + ? unwindAbiFunctions(mod, this.mayThrow, options.unwindExcluded) + : new Set(); this.workerEntryPolls = mt.workerEntryPolls ?? null; this.stackChecks = mt.stackChecks ?? null; // A stackless call's eager prefix borrows its caller's stack; the @@ -1829,8 +2115,14 @@ export class LlEmitter { }, (wrap) => this.borrowableNullableWrap(wrap) === "ref", ); - this.lentStringParameters = - this.debug === null ? findLentStringParameters(this.fnByName, this.callLifetimes) : new Map(); + if (this.debug === null) { + const strings = findLentStringParameters(this.fnByName, this.callLifetimes); + this.lendingFacts = { + strings, + records: findLentRecordFields(this.fnByName, this.callLifetimes, strings), + producers: findRecordFieldProducers(this.fnByName), + }; + } this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes, this.nodeIndex); // Integer clones signal a stack overflow through their result, which // needs the single-threaded guard; worker programs also poll for @@ -1849,6 +2141,9 @@ export class LlEmitter { : undefined, ) : NO_INTEGER_CLONES; + // Integer clones and their dispatchers call each other through their + // own lowering, which keeps plain calls and flag tests. + for (const name of this.integerClones.members) this.unwindAbi.delete(name); this.stackCallbacks = new StackCallbacks(this.fnByName); this.receiverReaders = new AmbientReceiverReaders(this.fnByName, this.nodeIndex); this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks); @@ -1941,14 +2236,14 @@ export class LlEmitter { const meta = this.classMeta.get(t.className); return meta !== undefined && programLifetimeClass(this.shapeHost, meta); }, - classSubtypes: (name) => { - const target = this.classMetaOf(name); - const intervals = classMembershipIntervals(this.classMeta, target.def.name); - return [...this.classMeta.values()] - .filter((meta) => - intervals.some((range) => range.pre <= meta.pre && meta.pre <= range.post), - ) - .map((meta) => meta.def.name); + classSubtypes: (name) => this.classSubtypes(name), + classValueSubtypes: (name) => { + const target = this.classMetaOf(name).def; + return this.classSubtypes(name).filter( + (sub) => + sub !== target.name && + classValueAbiEquals(this.classMetaOf(sub).def, target, this.fnByName), + ); }, pendingTestLines: (dest) => this.pendingTestLines(dest), virtualEntry: (implFn) => this.virtualEntry(implFn), @@ -2067,6 +2362,46 @@ export class LlEmitter { ); // After the vtable slots: borrowing slots exclude their implementations. if (this.debug === null) this.analyzeBorrowedReturns(); + this.int32SlotsValue ??= this.joinInt32Slots(); + } + + /** Computes the int32 slot analysis, or starts it on a forked process. + * Dev builds read its result only near the end of the constructor + * (int32Abis is empty with debug info), so for a large module the + * process computes it while the constructor runs the other analyses. */ + private startInt32Slots(dynamicStores: DynamicFieldStores): void { + const mod = this.mod; + const nodes = this.nodeIndex; + const analyze = (): Int32Slots => + analyzeInt32Slots(mod, { + dynamicStores, + initializerBindings: this.initializerBindings, + nodes, + }); + const fork = this.targetOptions.forkPrimitives; + if ( + fork === undefined || + this.targetOptions.debugSources === undefined || + (this.targetOptions.functionWorkers ?? 1) < 2 || + mod.functions.length < workerMinFunctions() + ) { + this.int32SlotsValue = analyze(); + return; + } + this.int32SlotsJoin = forkedAnalysis( + fork, + analyze, + (slots) => slots.encode(nodes), + (text) => Int32Slots.decode(text, mod, nodes), + ); + } + + private joinInt32Slots(): Int32Slots { + const join = this.int32SlotsJoin; + if (join === undefined) + throw new InternalCompilerError("llvm emitter bug: int32 slots read before analysis"); + this.int32SlotsJoin = undefined; + return join(); } /** The borrowed parameters of a hierarchy's vtable slot (empty: owned). */ @@ -2128,7 +2463,8 @@ export class LlEmitter { return this.emitParts().join("\n"); } emitParts(): string[] { - return emitParts(this); + const parts = emitParts(this); + return this.unwindAbi.size > 0 ? bridgePlainUnwindCalls(parts) : parts; } /** The dispose-observer gates the emitted frees and record writers test @@ -3238,6 +3574,13 @@ export class LlEmitter { terminator = `br label %${target.label}`; } else if (this.currentCoro !== null) { terminator = `br label %${this.currentCoro.finalLabel}`; + } else if (this.currentUnwindAbi && this.inLandingPad) { + // The unwind that entered the landing pad goes on to the caller's. + terminator = UNWIND_RESUME; + } else if (this.currentUnwindAbi) { + // The caller's invoke takes the exception at its landing pad. + this.declare(UNWIND_RAISE_DECLARATION); + terminator = UNWIND_RAISE; } else { const t = this.llType(this.currentReturnType); terminator = @@ -3268,6 +3611,7 @@ export class LlEmitter { passThrough !== undefined && !target && this.currentCoro === null && + !this.currentUnwindAbi && !this.currentInt32Result ) { const t = this.llType(this.currentReturnType); @@ -3454,6 +3798,46 @@ export class LlEmitter { B.startBlock(B.newLabel("exc.dead")); } + /** Emits `line`, a `call` of `callee`, and reports whether the call + * needs no pending check after it: a call of an unwinding-ABI function + * becomes an `invoke` whose landing pad runs this frame's unwind (the + * same releases and handler as emitPendingCheck's unwind path), and the + * normal path continues in a fresh block. Coroutine bodies keep plain + * calls (unwindAbiFunctions excludes their callees). */ + emitDirectCall(line: string, callee: string): boolean { + const B = this.B; + if (!this.unwindAbi.has(callee) || this.currentCoro !== null || B.isTerminated()) { + B.line(line); + return false; + } + const ok = B.newLabel("inv.ok"); + const pad = B.newLabel("inv.lp"); + this.usesLandingPads = true; + // A trailing comment goes after the invoke's labels. + const comment = line.indexOf(" ; "); + const text = comment < 0 ? line : line.slice(0, comment); + B.terminate( + `${text.replace(/^((?:%[\w.]+ = )?)call /, "$1invoke ")} to label %${ok} unwind label %${pad}`, + ); + B.startBlock(pad); + const exception = B.tmp(); + B.line(`${exception} = landingpad { ptr, i32 } cleanup`); + if (this.currentUnwindAbi) B.line(`store { ptr, i32 } ${exception}, ptr ${UNWIND_SLOT}`); + this.inLandingPad = true; + this.emitUnwind(); + this.inLandingPad = false; + B.startBlock(ok); + return true; + } + + /** Whether emitUnwind runs for a landing pad, whose unwind goes on with + * `resume` instead of raising again. */ + private inLandingPad = false; + + /** Whether the function being emitted has a landing pad (its definition + * needs the personality). */ + usesLandingPads = false; + /** A recursive function's entry check: below the running stack's guard, * throw Node's RangeError and unwind like any other pending exception. */ emitStackCheck(): void { @@ -4024,6 +4408,15 @@ export class LlEmitter { // ── class plumbing ────────────────────────────────────────────────────── + /** `name` and every class whose membership interval falls in its own. */ + private classSubtypes(name: string): string[] { + const target = this.classMetaOf(name); + const intervals = classMembershipIntervals(this.classMeta, target.def.name); + return [...this.classMeta.values()] + .filter((meta) => intervals.some((range) => range.pre <= meta.pre && meta.pre <= range.post)) + .map((meta) => meta.def.name); + } + classMetaOf(className: string): LlClassMeta { const meta = this.classMeta.get(className); if (!meta) throw new InternalCompilerError(`llvm emitter bug: unknown class ${className}`); @@ -4370,11 +4763,21 @@ export class LlEmitter { workers: this.mod.workers === true, owned: this.bytesOwned, scalarStrings: new Set(this.stringSlices.keys()), + unitSafeFunctions: this.unitSafeFunctions(), }, paired, ); } + private unitSafe: ReadonlySet | null | undefined; + /** The functions a loop over a string's UTF-16 units may call, or null + * where emitted reads do not use the runtime's scan receiver. */ + private unitSafeFunctions(): ReadonlySet | null { + if (this.unitSafe === undefined) + this.unitSafe = stringScanInline(this) ? unitSafeFunctions(this.mod) : null; + return this.unitSafe; + } + /** The alias tag of a whole-slot load of a binding (module globals only). */ slotAlias(id: string): string { const binding = this.binding(id); @@ -5217,7 +5620,22 @@ export class LlEmitter { if (fn.captures !== undefined) params.unshift("ptr %sc_env"); const ret = this.llType(fn.returnType); const call = `call ${ret} @${mangleBorrowedFunction(fn.name)}(${params.join(", ")})`; - B.line(ret === "void" ? call : `%result = ${call}`); + // The owned entry of an unwinding-ABI body releases the borrowed + // parameters on the unwind too, then resumes it to its caller. + const unwinding = this.unwindAbi.has(fn.name) && kept.size === 0; + if (unwinding) { + const ok = B.newLabel("adapt.ok"); + const pad = B.newLabel("adapt.lp"); + B.terminate( + `${ret === "void" ? "" : "%result = "}invoke ${call.slice(5)} to label %${ok} unwind label %${pad}`, + ); + B.startBlock(pad); + const exception = B.tmp(); + B.line(`${exception} = landingpad { ptr, i32 } cleanup`); + for (const index of borrowed) this.releaseFrame(`%p${index}`, fn.params[index]!.type); + B.terminate(`resume { ptr, i32 } ${exception}`); + B.startBlock(ok); + } else B.line(ret === "void" ? call : `%result = ${call}`); // A borrowed result is reachable from the parameters: own it first. A // throwing body returns a null dummy, which needs no owner (and string // or array retains do not accept NULL). @@ -5243,7 +5661,9 @@ export class LlEmitter { B.terminate(ret === "void" ? "ret void" : `ret ${ret} ${result}`); const symbol = kept.size > 0 ? `${mangleBorrowedFunction(fn.name)}.virtual` : mangleFunction(fn.name); - return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS} {\n${B.render()}\n}`; + if (unwinding) this.declare(`declare i32 @__gcc_personality_v0(...)`); + const personality = unwinding ? ` ${UNWIND_PERSONALITY}` : ""; + return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS}${personality} {\n${B.render()}\n}`; } canBorrowCallArgument(value: IrExpr): boolean { return canBorrowCallArgument(this, value); @@ -5656,6 +6076,22 @@ export class LlEmitter { if (resume && !resume.placed && !this.B.isTerminated()) { // The fast copy's failed checks re-execute this statement here. resume.placed = true; + // A write with hoisted leaves resumed from its own failed check + // re-executes its arithmetic over the leaves' stored doubles: each + // leaf ran once, in the fast copy. Reached from an earlier statement + // of this copy (a failed check before it), its leaves never ran, so + // it runs as written. + const rewritten = this.speculationResumeValues?.get(s); + if (rewritten !== undefined && rewritten.leaves.length > 0) { + const join = this.B.newLabel("spec.resume.join"); + this.emitStmt(s); + if (!this.B.isTerminated()) this.B.br(join); + this.B.startBlock(resume.label); + this.emitStmt(withSpeculativeValue(s, rewritten.value)); + if (!this.B.isTerminated()) this.B.br(join); + this.B.startBlock(join); + return; + } this.B.br(resume.label); this.B.startBlock(resume.label); } @@ -6905,7 +7341,10 @@ export class LlEmitter { return childDataDynThunkFor(this, chunk); } - emitterFixedAdapter(cbT: IrType & { kind: "func" }): { fn: string; shim: string } { + emitterFixedAdapter(cbT: IrType & { kind: "func" }): { + fn: string; + shim: string; + } { return emitterFixedAdapter(this, cbT); } @@ -7052,8 +7491,9 @@ export class LlEmitter { inBounds = false, invalidNaN = false, owned = false, + scope = "", ): LlValue { - return emitBytesGet(this, elem, receiver, index, expr, inBounds, invalidNaN, owned); + return emitBytesGet(this, elem, receiver, index, expr, inBounds, invalidNaN, owned, scope); } emitToUint32(value: string, expr?: IrExpr, uint32?: string): string { @@ -7117,8 +7557,20 @@ export class LlEmitter { expr?: IrExpr, inBounds = false, owned = false, + scope = "", ): void { - return emitBytesSet(this, elem, receiver, index, value, indexExpr, expr, inBounds, owned); + return emitBytesSet( + this, + elem, + receiver, + index, + value, + indexExpr, + expr, + inBounds, + owned, + scope, + ); } emitBytesIntrinsic(e: IrExpr & { kind: "bytesIntrinsic" }): LlValue { diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 23025d00c..a50464cd6 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -235,6 +235,26 @@ export function emitBytesGet( inBounds = false, invalidNaN = false, owned = false, + scope = "", +): LlValue { + const outer = host.bytesAccessScope; + host.bytesAccessScope = scope; + try { + return emitScopedBytesGet(host, elem, receiver, index, expr, inBounds, invalidNaN, owned); + } finally { + host.bytesAccessScope = outer; + } +} + +function emitScopedBytesGet( + host: LlvmEmitterContext, + elem: IrBytesElem, + receiver: string, + index: LlValue, + expr: IrExpr | undefined, + inBounds: boolean, + invalidNaN: boolean, + owned: boolean, ): LlValue { const B = host.B; // Any invalid index traps (the array runtime's discipline). @@ -547,6 +567,27 @@ export function emitBytesSet( expr?: IrExpr, inBounds = false, owned = false, + scope = "", +): void { + const outer = host.bytesAccessScope; + host.bytesAccessScope = scope; + try { + emitScopedBytesSet(host, elem, receiver, index, value, indexExpr, expr, inBounds, owned); + } finally { + host.bytesAccessScope = outer; + } +} + +function emitScopedBytesSet( + host: LlvmEmitterContext, + elem: IrBytesElem, + receiver: string, + index: LlValue, + value: LlValue, + indexExpr: IrExpr | undefined, + expr: IrExpr | undefined, + inBounds: boolean, + owned: boolean, ): void { const B = host.B; // Invalid integer-indexed writes are ignored after evaluating the RHS. @@ -768,6 +809,7 @@ export function emitBytesIntrinsic( host.bytesBounds.has(e), e.invalidNaN === true, host.bytesOwned.has(e), + host.bytesAliasScopes.get(e), ); case "slice": return call( diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 6ff52619e..d411d9172 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -244,38 +244,46 @@ export function emitCallExpr( ? INTEGER_DISPATCH_SUFFIX : ""; const target = `@${borrowed ? mangleBorrowedFunction(e.callee) : host.callTarget(e.callee)}${abi ? INT32_ABI_SUFFIX : ""}${dispatch}`; + // A call of an unwinding-ABI callee is an invoke (emitDirectCall): + // its landing pad unwinds, so no pending check follows it. if (e.type.kind === "void") { - B.line(`call void ${target}(${argList})`); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + const invoked = host.emitDirectCall(`call void ${target}(${argList})`, e.callee); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(); if (borrowed) host.releaseTemps(host.frames.pop()!); return { name: "", type: e.type }; } const t = B.tmp(); if (abi?.union) { - B.line(`${t} = call ${SCALAR_UNION_RESULT} ${target}(${argList})`); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + const invoked = host.emitDirectCall( + `${t} = call ${SCALAR_UNION_RESULT} ${target}(${argList})`, + e.callee, + ); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(); if (borrowed) host.releaseTemps(host.frames.pop()!); if (stackResult) return host.stackScalarUnion(t, e.type); return host.own({ name: host.boxScalarUnion(t, e.type), type: e.type }); } if (abi?.result) { - B.line(`${t} = call i32 ${target}(${argList})`); + const invoked = host.emitDirectCall(`${t} = call i32 ${target}(${argList})`, e.callee); const out = host.int32Result(t, e.type); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(out); if (borrowed) host.releaseTemps(host.frames.pop()!); return out; } - B.line(`${t} = call ${host.llType(e.type)} ${target}(${argList})`); + const invoked = host.emitDirectCall( + `${t} = call ${host.llType(e.type)} ${target}(${argList})`, + e.callee, + ); let out: LlValue; if (borrowed && host.borrowedReturns.has(e.callee)) { // A borrowed-return body hands back +0 (a null dummy when it threw): // test the exception first, then own the result before the // argument snapshots that keep it reachable are released. - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(); out = host.own({ name: host.retainFrame(t, e.type), type: e.type }); } else { out = host.own({ name: t, type: e.type }); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(out); } if (borrowed) { host.toFrame(out); @@ -1366,10 +1374,11 @@ export function emitBorrowedResultCall(host: LlvmEmitterContext, e: ExprOf<"call ) .join(", "); const t = host.B.tmp(); - host.B.line( + const invoked = host.emitDirectCall( `${t} = call ${host.llType(e.type)} @${mangleBorrowedFunction(e.callee)}${abi ? INT32_ABI_SUFFIX : ""}(${argList}) ; borrowed result`, + e.callee, ); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(); return { name: t, type: e.type }; } diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index f8c21a1c5..307481737 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -42,6 +42,7 @@ import { emitStringMapGetRef, emitStringMapGetScalar, emitStringMapHas, + noteNumberMapKey, } from "./map-number-lookup.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; import { emitFusedMapSet, rememberMapEntry } from "./map-entry-fusion.js"; @@ -53,6 +54,7 @@ import { STRING_CHAR_CODE_AT_INT_MARKER, emitAsciiChar, emitAsciiCharCode, + emitUnitCharCode, STRING_UTF16_LEN_DECL, } from "./string-reads.js"; import { exactInteger, widenInteger } from "./integer-values.js"; @@ -322,6 +324,15 @@ export function charAtUnitRead( return read; } +/** The UTF-16 units an enclosing loop version proved the index of `e` + * below the count of (string-unit-versions.ts). */ +function provenUnitsRead(host: LlvmEmitterContext, e: IrExpr): string | null { + const units = host.stringUnitReads.get(e); + if (units !== undefined) return units; + const origin = host.charAtUnitReads.get(e); + return origin !== undefined ? (host.stringUnitReads.get(origin) ?? null) : null; +} + /** An enclosing loop version proved the string of `e` ASCII and its index * below the length (bytes-loop-versions.ts). */ function provenStringRead(host: LlvmEmitterContext, e: IrExpr): boolean { @@ -364,9 +375,14 @@ export function emitStrIntrinsic( const method = e.method; switch (method) { case "length": { + const t = B.tmp(); + const length = host.stringLengthReads.get(e); + if (length !== undefined) { + B.line(`${t} = sitofp i64 ${length} to double`); + return { name: t, type: e.type }; + } // sc_str_utf16_len (string-reads.ts) answers proven-ASCII strings inline. host.declare(STRING_UTF16_LEN_DECL); - const t = B.tmp(); B.line(`${t} = call double @sc_str_utf16_len(ptr ${r.name})`); return { name: t, type: e.type }; } @@ -376,6 +392,8 @@ export function emitStrIntrinsic( const index = e.args[0] ? exactInteger(host, args[0]!, e.args[0]) : null; if (index) { const wide = widenInteger(host, index); + const units = provenUnitsRead(host, e); + if (units !== null) return { name: emitUnitCharCode(host, units, wide), type: e.type }; if (provenStringRead(host, e)) return { name: emitAsciiCharCode(host, r.name, wide), type: e.type }; host.declare(STRING_CHAR_CODE_AT_INT_MARKER); @@ -1127,6 +1145,65 @@ export function emitArrIntrinsic( } } +/** Whether `e` is a Map.get that emitBorrowedMapGet can emit without a + * retain: a typed map with number or inline string keys, a reference value, + * and a nullable result (the value's pointer, or the absent arm's). */ +export function borrowableMapGet(host: LlvmEmitterContext, e: IrExpr): boolean { + if (e.kind !== "mapIntrinsic" || e.method !== "get" || e.args.length !== 1) return false; + if (!borrowsMapReadInputs(e) || e.receiver.type.kind !== "map") return false; + const { key, value } = e.receiver.type; + if (!isRefCounted(value) || value.kind === "union" || key.kind === "union") return false; + if (e.type.kind !== "union" || !host.nullableUnions.has(e.type.unionId)) return false; + const arg = e.args[0]!; + const access = mapKeyAccess(key); + return ( + (access === "f64" && arg.type.kind === "f64") || + (access === "str" && arg.type.kind === "string") + ); +} + +/** A borrowableMapGet read as the entry's own reference: no retain, and + * no owner. The caller proves that nothing can change the map while the + * value is used (findMapReadBorrows). Null, before emitting anything, for + * a key held as a split span, which takes the runtime's owned lookup. */ +export function emitBorrowedMapGet( + host: LlvmEmitterContext, + e: IrExpr & { kind: "mapIntrinsic" }, +): LlValue | null { + const arg = e.args[0]!; + if (arg.kind === "varRef" && host.splitSpans.get(arg.localId) !== undefined) return null; + const B = host.B; + const mapType = e.receiver.type as IrType & { kind: "map" }; + const r = host.emitStableReceiver(e.receiver, e.args); + const k = emitMapLookupKey(host, e.args[0]!, true); + const numberKey = inlineNumberKey(k); + const stringKey = entryStringKey(k); + const value = mapType.value; + let raw: string; + if (numberKey !== null) raw = emitNumberMapGetRef(host, r.name, numberKey, value, true); + else if (stringKey !== null) + raw = emitStringMapGetRef( + host, + r.name, + stringKey, + value, + compactMapValues(host, mapType.key, value), + true, + ); + else throw new InternalCompilerError("llvm emitter bug: borrowed Map.get without an inline key"); + const resultType = e.type as IrType & { kind: "union" }; + const absent = host.unitInstanceRef( + resultType.unionId, + undefinedArmTag(resultType, host.unionsById), + ); + if (absent === "null") return { name: raw, type: e.type }; + const isnull = B.tmp(), + t = B.tmp(); + B.line(`${isnull} = icmp eq ptr ${raw}, null`); + B.line(`${t} = select i1 ${isnull}, ptr ${absent}, ptr ${raw}`); + return { name: t, type: e.type }; +} + export function wrapNullable( host: LlvmEmitterContext, raw: string, @@ -1202,7 +1279,9 @@ function collectionOps(host: LlvmEmitterContext, key: IrType, value: IrType): st function newCollection(host: LlvmEmitterContext, key: IrType, value: IrType): string { const B = host.B; const arms = key.kind === "union" ? host.unionsById.get(key.unionId)?.arms : undefined; - const kind = mapKeyKindNum(key, arms); + // SCR_MAP_KEY_REF (2): shape-union keys are record pointers. + const kind = + key.kind === "union" && host.nullableUnions.shapes(key.unionId) ? 2 : mapKeyKindNum(key, arms); const m = B.tmp(); host.declare(`declare ptr @scr_map_new(i32, i32, ptr)`); // SCR_MAP_VAL_CREF (3): string keys and compact values (compactMapValues). @@ -1529,6 +1608,7 @@ export function emitMapLikeIntrinsic( // Key borrowed (the runtime retains stored string keys); the // value MOVES in (replacement releases the old value inside). const k = emitBorrowedInput(host, e.args[0]!); + if (kAcc === "f64") noteNumberMapKey(host, k, e.args[0]!); const v = host.emitExpr(e.args[1]!); const vAcc = elemAccess(receiverType.value); if (vAcc === "ref") host.toHeap(v); @@ -1555,6 +1635,7 @@ export function emitMapLikeIntrinsic( // Element borrowed (the runtime retains stored strings); the unit // value is 0. Re-adding overwrites in place, preserving insertion. const k = emitBorrowedInput(host, e.args[0]!); + if (kAcc === "f64") noteNumberMapKey(host, k, e.args[0]!); if (kAcc === "f64" && host.readNumberMapWrites.has(e)) emitNumberMapSetScalar(host, r.name, k.name, null, () => host.mapSet(r.name, kAcc, "f64", k.name, f64Lit(0)), @@ -1564,7 +1645,10 @@ export function emitMapLikeIntrinsic( } case "has": { const k = borrowInputs ? host.emitReadReceiver(e.args[0]!) : host.emitExpr(e.args[0]!); - if (kAcc === "f64") return { name: emitNumberMapHas(host, r.name, k.name), type: e.type }; + if (kAcc === "f64") { + noteNumberMapKey(host, k, e.args[0]!); + return { name: emitNumberMapHas(host, r.name, k.name), type: e.type }; + } if (kAcc === "str") return { name: emitStringMapHas(host, r.name, k.name, compact), type: e.type }; host.declare(`declare zeroext i1 @scr_map_has_${kAcc}(ptr, ${kTy})`); diff --git a/packages/compiler/src/backend/llvm/expr-context.ts b/packages/compiler/src/backend/llvm/expr-context.ts index c6dc506f4..ac5262571 100644 --- a/packages/compiler/src/backend/llvm/expr-context.ts +++ b/packages/compiler/src/backend/llvm/expr-context.ts @@ -30,6 +30,9 @@ export interface WideInteger { name: string; below: number; bound: number; + /** The double is the integer rounded once: the result of one operation on + * operands that always equal their doubles. */ + single?: true; } export type ExprOf = Extract; diff --git a/packages/compiler/src/backend/llvm/expr-control.ts b/packages/compiler/src/backend/llvm/expr-control.ts index 242f9997c..2f7c31813 100644 --- a/packages/compiler/src/backend/llvm/expr-control.ts +++ b/packages/compiler/src/backend/llvm/expr-control.ts @@ -7,6 +7,8 @@ import { DYN_KIND } from "./dyn.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js"; import { f64Lit } from "./common.js"; +import { NULLABLE_NULL, SHAPE_WORD_INDEX, shapeWordHeader } from "./nullable-unions.js"; +import { emitUnionTag } from "./union-repr.js"; export function emitControlExpr( host: LlvmEmitterContext, @@ -52,6 +54,8 @@ export function emitControlExpr( case "toBool": return { name: host.truthy(host.emitUnionProjection(e.operand)), type: e.type }; case "logical": { + const tagSet = e.op === "||" ? unionTagSet(e) : null; + if (tagSet) return { name: emitUnionTagSet(host, tagSet), type: e.type }; // JS value semantics: the result is the deciding operand itself. // Left evaluates once, ownership moves into the result slot; when // the branch takes the right operand the stale left releases first @@ -614,3 +618,85 @@ function literalChoice(e: IrExpr): boolean { if (e.kind === "strLit") return true; return e.kind === "ternary" && literalChoice(e.then) && literalChoice(e.else_); } + +type TagSet = { unionId: string; value: IrExpr; tags: Set }; + +/** An `||` chain of two or more tag tests of one local (`isTag(u, 1) || + * isTag(u, 4) || ...`, the shape of a union conversion's identity test). */ +function unionTagSet(e: IrExpr & { kind: "logical" }): TagSet | null { + const leaves: IrExpr[] = []; + const collect = (x: IrExpr): void => { + if (x.kind === "logical" && x.op === "||") { + collect(x.left); + collect(x.right); + } else leaves.push(x); + }; + collect(e); + const first = leaves[0]!; + if (first.kind !== "unionIsTag" || first.value.kind !== "varRef") return null; + const localId = first.value.localId; + const tags = new Set(); + for (const leaf of leaves) { + if ( + leaf.kind !== "unionIsTag" || + leaf.negated || + leaf.unionId !== first.unionId || + leaf.value.kind !== "varRef" || + leaf.value.localId !== localId + ) + return null; + tags.add(leaf.tag); + } + return { unionId: first.unionId, value: first.value, tags }; +} + +/** i1: the union value holds one of the tags. One read of the tag and one + * switch over the set; a shape union switches on the record's shape word + * after its unit encodings. */ +function emitUnionTagSet(host: LlvmEmitterContext, set: TagSet): string { + const B = host.B; + const u = host.emitUnionProjection(set.value).name; + const nullable = host.nullableUnions.get(set.unionId); + const yes = B.newLabel("tags.y"); + const no = B.newLabel("tags.n"); + const join = B.newLabel("tags.j"); + const target = (tag: number): string => (set.tags.has(tag) ? yes : no); + if (nullable?.repr === "shapes") { + if (nullable.unitTag >= 0) { + const isNull = B.tmp(); + const next = B.newLabel("tags.r"); + B.line(`${isNull} = icmp eq ptr ${u}, null`); + B.condBr(isNull, target(nullable.unitTag), next); + B.startBlock(next); + } + if (nullable.nullTag >= 0) { + const isNullArm = B.tmp(); + const next = B.newLabel("tags.r"); + B.line(`${isNullArm} = icmp eq ptr ${u}, ${NULLABLE_NULL}`); + B.condBr(isNullArm, target(nullable.nullTag), next); + B.startBlock(next); + } + const wp = B.tmp(); + const w = B.tmp(); + B.line( + `${wp} = getelementptr inbounds ${shapeWordHeader(host.sizeType)}, ptr ${u}, i32 0, i32 ${SHAPE_WORD_INDEX}`, + ); + B.line(`${w} = load i32, ptr ${wp}`); + const cases = nullable.arms + .filter((arm) => set.tags.has(arm.tag)) + .map((arm) => `i32 ${host.nullableUnions.shapeNumber(arm.shapeId)}, label %${yes}`); + B.terminate(`switch i32 ${w}, label %${no} [ ${cases.join(" ")} ]`); + } else { + const tag = nullable ? emitUnionTag(B, nullable, u) : host.unionTag(u, set.unionId); + const cases = [...set.tags].map((t) => `i32 ${t}, label %${yes}`); + B.terminate(`switch i32 ${tag}, label %${no} [ ${cases.join(" ")} ]`); + } + B.startBlock(yes); + B.br(join); + B.startBlock(no); + B.br(join); + B.startBlock(join); + const t = B.tmp(); + B.line(`${t} = phi i1 [ true, %${yes} ], [ false, %${no} ]`); + return t; +} diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index b7a5c7736..849715ab0 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -89,7 +89,7 @@ export function emitDynamicExpr( // Bytes already box their shared mutable storage directly. A second // capsule would split identity between checked and native views. const errorRef = v.type.kind === "object" && host.shapeHost.isErrorClass(v.type.className); - // A TextEncoder or TextDecoder record boxes as its own handle, which + // A TextEncoder, TextDecoder or StringDecoder record boxes as its own handle, which // keeps the record's identity (toDynHelper). const codecRef = textCodecRecordClass(v.type, (id) => host.recordsById.get(id)) !== null; if (!errorRef && !codecRef && ((e.liveRef && v.type.kind !== "bytes") || identityRef)) { @@ -121,6 +121,10 @@ export function emitDynamicExpr( const box = host.dyn.dynFuncBoxHelper(v.type); const t = B.tmp(); B.line(`${t} = call ptr @${box}(ptr ${v.name}, ptr ${name})`); + if (e.fnLength !== undefined) { + host.declare(`declare void @scr_dyn_function_set_length(ptr, i32)`); + B.line(`call void @scr_dyn_function_set_length(ptr ${t}, i32 ${e.fnLength})`); + } return host.own({ name: t, type: e.type }); } const conv = host.dyn.toDynHelper(v.type); diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index ccbe3edcc..1ff7d47a4 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -13,13 +13,14 @@ import { emitStringInputs } from "./string-lifetimes.js"; import { emitStringParts, numberPart, stringParts } from "./string-construction.js"; import { emitBorrowedInputs } from "./borrowed-inputs.js"; import { - alwaysExactInteger, + exactCompareOperands, exactInteger, int32Number, integerNumber, narrowInteger, wideArithmetic, widenInteger, + type LlInteger, } from "./integer-values.js"; import { bitwiseIdentityOperand, @@ -45,7 +46,7 @@ import { slotWidth, type ArrayAccess, } from "./dense-array-access.js"; -import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js"; +import { emitWrappingSum, integerOrNaNRange, markWrappingOperands } from "./wrapping-sums.js"; import { emitPowerOfTwoQuotient, isPowerOfTwoCall } from "./lib-filesystem.js"; /** The signed integer predicates of the number compares. */ @@ -58,6 +59,63 @@ const INTEGER_COMPARES: Record = { "!==": "ne", }; +const UNSIGNED_COMPARES: Record = { + "<": "ult", + "<=": "ule", + ">": "ugt", + ">=": "uge", + "===": "eq", + "!==": "ne", +}; + +/** A number compare of operands that are each an exact integer or an + * integer-or-NaN value (integerOrNaNRange: integer typed-array elements, + * `charCodeAt` results, sums of those), all within int32 or all within + * uint32: their 32-bit views compared, and NaN comparing false (unequal for + * `!==`). The 32-bit view is the value's ToUint32, which holds its bits + * whenever it is not NaN. Null when an operand has no such view. */ +function emitIntegerOrNaNCompare( + host: LlvmEmitterContext, + e: ExprOf<"bin">, + l: LlValue, + li: LlInteger | null, + r: LlValue, + ri: LlInteger | null, +): string | null { + const lr = li?.range ?? integerOrNaNRange(host, e.left), + rr = ri?.range ?? integerOrNaNRange(host, e.right); + if (!lr || !rr) return null; + const signed = withinInt32(lr) && withinInt32(rr); + if (!signed && !(withinUint32(lr) && withinUint32(rr))) return null; + const bits = (value: LlValue, integer: LlInteger | null): string | undefined => + integer ? narrowInteger(host, integer) : value.uint32; + const left = bits(l, li), + right = bits(r, ri); + if (left === undefined || right === undefined) return null; + const B = host.B; + const compare = B.tmp(); + B.line( + `${compare} = icmp ${(signed ? INTEGER_COMPARES : UNSIGNED_COMPARES)[e.op]} i32 ${left}, ${right}`, + ); + let result = compare; + for (const [value, integer] of [ + [l, li], + [r, ri], + ] as const) { + if (integer) continue; + const ordered = B.tmp(), + combined = B.tmp(); + B.line(`${ordered} = fcmp ord double ${value.name}, 0.0`); + if (e.op === "!==") { + const unordered = B.tmp(); + B.line(`${unordered} = xor i1 ${ordered}, true`); + B.line(`${combined} = or i1 ${result}, ${unordered}`); + } else B.line(`${combined} = and i1 ${result}, ${ordered}`); + result = combined; + } + return result; +} + export function emitLiteralExpr( host: LlvmEmitterContext, e: ExprOf<"numLit" | "boolLit" | "strLit" | "moduleNsRef" | "unitLit" | "varRef">, @@ -363,14 +421,16 @@ export function emitOperatorExpr( return value; } } else { - // An operand with only a wide view, such as `i + 1` of a speculated - // counter: compare the integers when both always equal the doubles. - const left = alwaysExactInteger(host, l, li), - right = left === null ? null : alwaysExactInteger(host, r, ri); - if (left !== null && right !== null) { - B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${left}, ${right}`); + // An operand with only a wide view, such as `i + 1` or `2 * i + 1` + // of a speculated counter: compare the integers when that gives the + // doubles' result. + const operands = exactCompareOperands(host, l, li, r, ri); + if (operands) { + B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${operands[0]}, ${operands[1]}`); return { name: t, type: e.type }; } + const nanCoded = emitIntegerOrNaNCompare(host, e, l, li, r, ri); + if (nanCoded) return { name: nanCoded, type: e.type }; B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`); } } else if (bit[e.op] !== undefined) { diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 675932d47..fa27869d0 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -1,7 +1,7 @@ import { typedRefConstructor } from "./shapes.js"; /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; -import { streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js"; +import { streamRooted, streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js"; import { type IrRecordShape, type IrType, @@ -438,10 +438,14 @@ export function streamTypedRefCommitAdapter( host.declare(`declare ptr @scr_str_new(ptr, ${host.sizeType})`); host.declare(`declare void @scr_str_release(ptr)`); host.declare(`declare void @scr_dyn_key_delete(ptr, ptr, i1 zeroext)`); + // The view of a sealed or frozen instance holds its fields as + // non-configurable entries, which the bag copies. + const withAttributes = meta.def.fields.some((field) => field.name === CLASS_ATTRIBUTES); + const placeholderSet = withAttributes ? "scr_dyn_obj_set_field" : "scr_dyn_obj_set"; if (meta.def.tracksOwnFields) { host.declare(`declare ptr @scr_dyn_retain_v(ptr)`); - host.declare(`declare void @scr_dyn_obj_set(ptr, ptr, ${host.sizeType}, ptr)`); - } + host.declare(`declare void @${placeholderSet}(ptr, ptr, ${host.sizeType}, ptr)`); + } else if (withAttributes) host.declare(`declare void @scr_dyn_obj_delete_field(ptr, ptr)`); lines.push( ` %bag = call ptr @scr_dyn_new_obj()`, ` %bag_copy = call ptr @scr_dyn_copy_property_descriptors(ptr %bag, ptr %d)`, @@ -478,14 +482,16 @@ export function streamTypedRefCommitAdapter( `bag_field${index}_present:`, ` %bag_undefined${index} = call ptr @scr_dyn_undefined()`, ` %bag_placeholder${index} = call ptr @scr_dyn_retain_v(ptr %bag_undefined${index})`, - ` call void @scr_dyn_obj_set(ptr %bag, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr %bag_placeholder${index})`, + ` call void @${placeholderSet}(ptr %bag, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr %bag_placeholder${index})`, ` br label %bag_field${index}_after`, `bag_field${index}_after:`, ); else lines.push( ` %bag_key${index} = call ptr @scr_str_new(ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")})`, - ` call void @scr_dyn_key_delete(ptr %bag, ptr %bag_key${index}, i1 zeroext false)`, + withAttributes + ? ` call void @scr_dyn_obj_delete_field(ptr %bag, ptr %bag_key${index})` + : ` call void @scr_dyn_key_delete(ptr %bag, ptr %bag_key${index}, i1 zeroext false)`, ` call void @scr_str_release(ptr %bag_key${index})`, ); }); @@ -493,7 +499,9 @@ export function streamTypedRefCommitAdapter( host.declare( meta.def.tracksOwnFields ? `declare void @scr_dyn_symbol_key_placeholder(ptr, ptr)` - : `declare void @scr_dyn_key_delete_computed(ptr, ptr, i1 zeroext)`, + : withAttributes + ? `declare void @scr_dyn_obj_delete_symbol_field(ptr, ptr)` + : `declare void @scr_dyn_key_delete_computed(ptr, ptr, i1 zeroext)`, ); [...symbols.values()].forEach((symbol, index) => { const keyLines = symbolKeyLines(host, symbol, `bag_symraw${index}`); @@ -503,7 +511,9 @@ export function streamTypedRefCommitAdapter( ...keyLines.release, meta.def.tracksOwnFields ? ` call void @scr_dyn_symbol_key_placeholder(ptr %bag, ptr %bag_sym${index})` - : ` call void @scr_dyn_key_delete_computed(ptr %bag, ptr %bag_sym${index}, i1 zeroext false)`, + : withAttributes + ? ` call void @scr_dyn_obj_delete_symbol_field(ptr %bag, ptr %bag_sym${index})` + : ` call void @scr_dyn_key_delete_computed(ptr %bag, ptr %bag_sym${index}, i1 zeroext false)`, ` call void @scr_dyn_release_v(ptr %bag_sym${index})`, ); }); @@ -519,6 +529,20 @@ export function streamTypedRefCommitAdapter( host.declare(`declare void @scr_error_commit_dyn(ptr, ptr)`); lines.push(` call void @scr_error_commit_dyn(ptr %target, ptr %d)`); } + // Untyped Object.freeze, seal and preventExtensions apply to the view; + // the instance's integrity word records them. + if (host.untypedIntegrity && meta.def.fields.some((field) => field.name === CLASS_ATTRIBUTES)) { + const bag = meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES) + ? "%bag" + : "null"; + host.declare(`declare double @scr_dyn_class_view_integrity(ptr, double, ptr)`); + lines.push( + ` %word_ptr = getelementptr inbounds %${classStructSym(t.className)}, ptr %target, i64 0, i32 ${classFieldIndex(meta, CLASS_ATTRIBUTES).index}`, + ` %word = load double, ptr %word_ptr`, + ` %word_next = call double @scr_dyn_class_view_integrity(ptr %d, double %word, ptr ${bag})`, + ` store double %word_next, ptr %word_ptr`, + ); + } lines.push(` br label %done`, `done:`, ` ret void`, `}`, ``); host.resolveThunkDefs.push(...lines); return `@${commit}`; @@ -843,8 +867,7 @@ export function streamTypedRefMaterializeAdapter( host.declare(`declare ptr @scr_error_dyn_fields(ptr)`); B.line(`${out} = call ptr @scr_error_dyn_fields(ptr %p)`); } else if ( - meta.def.runtime && - RUNTIME_STREAM_CLASSES.has(t.className) && + ((meta.def.runtime && RUNTIME_STREAM_CLASSES.has(t.className)) || streamRooted(meta)) && !meta.def.instancePrototypeHelper && !meta.def.prototypeDataHelper ) { @@ -980,9 +1003,15 @@ export function streamTypedRefMaterializeAdapter( host.declare(`declare void @scr_union_release(ptr)`); B.line(`call void @scr_union_release(ptr ${fieldValue})`); } - host.declare(`declare void @scr_dyn_symbol_key_set(ptr, ptr, ptr)`); + // As for string-keyed fields, attributes copied from the bag (a frozen + // instance's) do not stop the view taking the value. + const symbolSetter = + meta.def.tracksOwnFields || withAttributes + ? "scr_dyn_symbol_field_set" + : "scr_dyn_symbol_key_set"; + host.declare(`declare void @${symbolSetter}(ptr, ptr, ptr)`); host.declare(`declare void @scr_dyn_release_v(ptr)`); - B.line(`call void @scr_dyn_symbol_key_set(ptr ${out}, ptr ${key}, ptr ${value})`); + B.line(`call void @${symbolSetter}(ptr ${out}, ptr ${key}, ptr ${value})`); B.line(`call void @scr_dyn_release_v(ptr ${value})`); if (afterSymbol) { B.terminate(`br label %${afterSymbol}`); diff --git a/packages/compiler/src/backend/llvm/forked-analysis.ts b/packages/compiler/src/backend/llvm/forked-analysis.ts new file mode 100644 index 000000000..80ba35fac --- /dev/null +++ b/packages/compiler/src/backend/llvm/forked-analysis.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ForkPrimitives } from "./emitter.js"; + +/** Starts `compute` on a forked child process, which inherits this process + * as it is at the fork, and returns the join that answers its result. The + * child writes `encode`'s text to a file and exits; the join waits for it + * and decodes the text. When the fork fails, the child fails, or `encode` + * answers null, the join runs `compute` here instead, so the result does + * not depend on the child. Join once. */ +export function forkedAnalysis( + fork: ForkPrimitives, + compute: () => T, + encode: (value: T) => string | null, + decode: (text: string) => T, +): () => T { + const dir = mkdtempSync(join(tmpdir(), "scriptc-analysis-")); + const file = join(dir, "result.json"); + const pid = fork.fork(); + if (pid === 0) { + let code = 1; + try { + const text = encode(compute()); + if (text !== null) { + writeFileSync(file, text); + code = 0; + } + } catch { + // The parent computes the result itself. + } + fork.exit(code); + } + return () => { + let text: string | null = null; + try { + if (pid > 0 && fork.wait(pid) === 0) text = readFileSync(file, "utf8"); + } catch { + // The result is computed here. + } finally { + rmSync(dir, { recursive: true, force: true }); + } + return text === null ? compute() : decode(text); + }; +} diff --git a/packages/compiler/src/backend/llvm/integer-values.ts b/packages/compiler/src/backend/llvm/integer-values.ts index 90954f4c6..5b8935036 100644 --- a/packages/compiler/src/backend/llvm/integer-values.ts +++ b/packages/compiler/src/backend/llvm/integer-values.ts @@ -111,13 +111,17 @@ export function wideArithmetic( rv = view(r, ri); if (!lv || !rv) return null; let below: number, bound: number; + let single = false; if (op === "*") { if (lv.below !== Infinity || rv.below !== Infinity) return null; - below = EXACT_DOUBLES; bound = productBound(lv.bound, rv.bound); + // Scaling by a power of two never rounds a double that stays finite. + below = powerOfTwo(li) || powerOfTwo(ri) ? Infinity : EXACT_DOUBLES; + single = true; } else if (lv.below === Infinity && rv.below === Infinity) { below = EXACT_DOUBLES; bound = sumBound(lv.bound, rv.bound); + single = true; } else if (lv.below === Infinity || rv.below === Infinity) { const [exact, wide] = lv.below === Infinity ? [lv, rv] : [rv, lv]; below = Math.min(wide.below - exact.bound, EXACT_DOUBLES); @@ -132,7 +136,50 @@ export function wideArithmetic( host.B.line( `${name} = ${op === "+" ? "add" : op === "-" ? "sub" : "mul"} nsw i64 ${left}, ${right}`, ); - return { name, below, bound }; + return single ? { name, below, bound, single } : { name, below, bound }; +} + +/** Whether an exact integer is the constant 2^k or -2^k. */ +function powerOfTwo(integer: LlInteger | null): boolean { + if (integer === null || integer.range.min !== integer.range.max) return false; + const value = Math.abs(integer.range.min); + // Math.log2 can round to an integer for a value that is not a power of + // two (2^52 - 1), so the candidate power is compared with the value. + return value >= 1 && value <= 2 ** 52 && 2 ** Math.round(Math.log2(value)) === value; +} + +/** The i64 operands of a number compare that gives the doubles' result: + * both equal their doubles wherever they can be, or one is an exact integer + * of magnitude at most 2^53 - 1 and the other's double is its integer + * rounded once. Rounding is monotonic, and b - 1, b and b + 1 are doubles + * for a safe integer b, so an integer rounded once compares with b as the + * integer does. Null for any other pair. */ +export function exactCompareOperands( + host: LlvmEmitterContext, + l: LlValue, + li: LlInteger | null, + r: LlValue, + ri: LlInteger | null, +): [string, string] | null { + const describe = (value: LlValue, integer: LlInteger | null) => { + if (integer) return { exact: true, single: true, bound: magnitude(integer.range) }; + const wide = value.wideInteger; + if (!wide) return null; + return { exact: wide.bound <= wide.below, single: wide.single === true, bound: wide.bound }; + }; + const a = describe(l, li), + b = describe(r, ri); + if (!a || !b) return null; + const safe = (d: { exact: boolean; bound: number }): boolean => + d.exact && d.bound <= Number.MAX_SAFE_INTEGER; + const ok = + (a.exact && b.exact) || + (safe(a) && (b.exact || b.single)) || + (safe(b) && (a.exact || a.single)); + if (!ok) return null; + const operand = (value: LlValue, integer: LlInteger | null): string => + integer ? widenInteger(host, integer) : value.wideInteger!.name; + return [operand(l, li), operand(r, ri)]; } /** An i64 index for a consumer that compares it, unsigned, with a length or diff --git a/packages/compiler/src/backend/llvm/js-ic.ts b/packages/compiler/src/backend/llvm/js-ic.ts index 7516673cd..b69d3199d 100644 --- a/packages/compiler/src/backend/llvm/js-ic.ts +++ b/packages/compiler/src/backend/llvm/js-ic.ts @@ -6,7 +6,8 @@ * * load ordinary object whose shape is entry 0's own-property shape: * read entry `slot` inline unless it is an accessor; otherwise - * scr_js_load_probe (polymorphic, prototype hits, misses) + * scr_js_get_probe (polymorphic, prototype hits, absent keys, + * misses) * store ordinary object whose shape is entry 0's replace shape, outside * an intact parse region, writable data entry: replace inline; * otherwise scr_js_store_probe (adds, polymorphic, misses) @@ -384,6 +385,15 @@ function emitGet(host: Host, e: ExprOf<"call">, callee: IrFunction, key: string) incoming.push(`[ ${numberResult(host, count, e)}, %${B.currentLabel()} ]`); B.br(join); B.startBlock(other); + // So is the length of a native array passed in from typed code. + const rest = B.newLabel("icg.rest"); + const arr = nativeArray(host, receiver.name, rest); + const native = load(host, "i32", arr, 8); + const nativeCount = B.tmp(); + B.line(`${nativeCount} = uitofp i32 ${native} to double`); + incoming.push(`[ ${numberResult(host, nativeCount, e)}, %${B.currentLabel()} ]`); + B.br(join); + B.startBlock(rest); } // Inline hits (borrowed values) and probe hits (owned ones) share one // conversion to the result type. @@ -393,9 +403,10 @@ function emitGet(host: Host, e: ExprOf<"call">, callee: IrFunction, key: string) B.startBlock(object); inlineLoad(host, receiver.name, ic, INLINE_WAYS, INLINE_PROTO, (cell) => cell, hits, hit, probe); B.startBlock(probe); - host.declare(`declare ptr @scr_js_load_probe(ptr, ptr, ptr)`); + // The read's probe also answers keys the receiver's plain chain lacks. + host.declare(`declare ptr @scr_js_get_probe(ptr, ptr, ptr)`); const probed = B.tmp(); - B.line(`${probed} = call ptr @scr_js_load_probe(ptr ${receiver.name}, ptr ${name}, ptr ${ic})`); + B.line(`${probed} = call ptr @scr_js_get_probe(ptr ${receiver.name}, ptr ${name}, ptr ${ic})`); const found = B.tmp(); B.line(`${found} = icmp ne ptr ${probed}, null`); const probeEnd = B.currentLabel(); @@ -623,6 +634,98 @@ function numberKey(host: Host, key: IrExpr): NumberKey | null { }; } +/** Branches to `yes` when `d` is a capsule of a native array passed in + * from typed code (a TYPED_REF bound to live array ops, not a record or + * class instance), with the ScrArr in `yes`; else to `no`. */ +function nativeArray(host: Host, d: string, no: string): string { + const B = host.B; + const capsule = B.newLabel("ica.cap"); + const bound = B.newLabel("ica.ops"); + const yes = B.newLabel("ica.arr"); + kindIs(host, d, DYN_KIND.TYPED_REF, capsule, no); + B.startBlock(capsule); + // The flag word after `casts`: record, klass and bag_class are its top + // three bits; any of them means `array` holds other ops. + const flags = load(host, "i32", d, 88); + const kinds = B.tmp(); + const plain = B.tmp(); + B.line(`${kinds} = lshr i32 ${flags}, 29`); + B.line(`${plain} = icmp eq i32 ${kinds}, 0`); + B.condBr(plain, bound, no); + B.startBlock(bound); + const ops = load(host, "ptr", d, 64); + const live = B.tmp(); + B.line(`${live} = icmp ne ptr ${ops}, null`); + B.condBr(live, yes, no); + B.startBlock(yes); + return load(host, "ptr", d, 16); +} + +/** `xs[i]` on a native number[] passed in from typed code: an integral + * index below the array's hole-free prefix reads the element in place. + * The other cases branch to `miss`, whose probe and helper answer them as + * before. The result joins `incoming` at `join`. */ +function emitNativeNumberElement( + host: Host, + receiver: string, + e: IrExpr, + indexIn: (next: string) => string, + miss: string, + join: string, + incoming: string[], +): void { + const B = host.B; + const arr = nativeArray(host, receiver, miss); + const numbers = B.newLabel("ica.f64"); + const elem = load(host, "i8", arr, 16); + const f64 = B.tmp(); + B.line(`${f64} = icmp eq i8 ${elem}, 0 ; SCR_ELEM_F64`); + B.condBr(f64, numbers, miss); + B.startBlock(numbers); + const index = indexIn(B.newLabel("ica.idx")); + const filled = load(host, "i32", arr, 20); + const filledD = B.tmp(); + const nonNegative = B.tmp(); + const below = B.tmp(); + const ok = B.tmp(); + B.line(`${filledD} = uitofp i32 ${filled} to double`); + B.line(`${nonNegative} = fcmp oge double ${index}, 0.0`); + B.line(`${below} = fcmp olt double ${index}, ${filledD}`); + B.line(`${ok} = and i1 ${nonNegative}, ${below}`); + const ranged = B.newLabel("ica.rng"); + B.condBr(ok, ranged, miss); + B.startBlock(ranged); + const i = B.tmp(); + const back = B.tmp(); + const integral = B.tmp(); + B.line(`${i} = fptoui double ${index} to i64`); + B.line(`${back} = uitofp i64 ${i} to double`); + B.line(`${integral} = fcmp oeq double ${back}, ${index}`); + const hit = B.newLabel("ica.hit"); + B.condBr(integral, hit, miss); + B.startBlock(hit); + const data = load(host, "ptr", arr, 24); + const slot = B.tmp(); + const value = B.tmp(); + B.line(`${slot} = getelementptr inbounds double, ptr ${data}, i64 ${i}`); + B.line(`${value} = load double, ptr ${slot}`); + let out: string; + if (e.type.kind === "jsval") { + const raw = B.tmp(); + const nan = B.tmp(); + const bits = B.tmp(); + out = B.tmp(); + B.line(`${raw} = bitcast double ${value} to i64`); + B.line(`${nan} = fcmp uno double ${value}, ${value}`); + B.line(`${bits} = select i1 ${nan}, i64 ${JS_BITS.canonicalNan}, i64 ${raw}`); + B.line(`${out} = inttoptr i64 ${bits} to ptr`); + } else { + out = numberResult(host, value, e); + } + incoming.push(`[ ${out}, %${B.currentLabel()} ]`); + B.br(join); +} + function emitKeyed(host: Host, e: ExprOf<"call">, callee: IrFunction): LlValue { const B = host.B; const receiver = emitBorrowedInput(host, e.args[0]!); @@ -645,18 +748,23 @@ function emitKeyed(host: Host, e: ExprOf<"call">, callee: IrFunction): LlValue { B.line(`store ptr null, ptr ${keySlot}`); host.ownSlot(keySlot, DYN); } - kindIs(host, receiver.name, DYN_KIND.ARR, array, probe); + /** The key as a double in block `next`; other keys go to the probe. */ + const indexIn = (next: string): string => { + if (numeric === null) { + kindIs(host, key!.name, DYN_KIND.NUM, next, probe); + B.startBlock(next); + return load(host, "double", key!.name, 16); + } + B.condBr(numeric.present, next, probe); + B.startBlock(next); + return numeric.value; + }; + const other = B.newLabel("ick.other"); + kindIs(host, receiver.name, DYN_KIND.ARR, array, other); + B.startBlock(other); + emitNativeNumberElement(host, receiver.name, e, indexIn, probe, join, incoming); B.startBlock(array); - let index: string; - if (numeric === null) { - kindIs(host, key!.name, DYN_KIND.NUM, number, probe); - B.startBlock(number); - index = load(host, "double", key!.name, 16); - } else { - B.condBr(numeric.present, number, probe); - B.startBlock(number); - index = numeric.value; - } + const index = indexIn(number); const len = load(host, "i64", receiver.name, 16); const i = B.tmp(); const back = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/lib-abi.ts b/packages/compiler/src/backend/llvm/lib-abi.ts index 6917d8256..ea5ab82a2 100644 --- a/packages/compiler/src/backend/llvm/lib-abi.ts +++ b/packages/compiler/src/backend/llvm/lib-abi.ts @@ -73,6 +73,26 @@ export function rawBytes(host: LlvmEmitterContext, value: LlValue): AbiArgument[ return [ptr(data), { type: host.sizeType, name: length }]; } +/** A TLS client's `ca` as rawBytes, with a null pointer for none: an empty + * string, which Node reads as no ca (and which an omitted ca lowers to). + * A Buffer's pointer is never null, so an empty one is an empty trust + * store. */ +export function caBytes(host: LlvmEmitterContext, value: LlValue): AbiArgument[] { + const B = host.B; + const [data, length] = rawBytes(host, value); + const test = B.tmp(), + pointer = B.tmp(); + if (value.type.kind === "string") { + B.line(`${test} = icmp eq ${host.sizeType} ${length!.name}, 0`); + B.line(`${pointer} = select i1 ${test}, ptr null, ptr ${data!.name}`); + } else { + // An empty Buffer's data pointer may be null; its object stands in. + B.line(`${test} = icmp eq ptr ${data!.name}, null`); + B.line(`${pointer} = select i1 ${test}, ptr ${value.name}, ptr ${data!.name}`); + } + return [ptr(pointer), length!]; +} + export function callbackType(value: LlValue): IrType & { kind: "func" } { if (value.type.kind !== "func") throw new InternalCompilerError("runtime callback must be a function"); diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 9cd11feb1..8a98fb154 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -10,7 +10,7 @@ import { FN_ATTRS, arrNewCall, traceArg, vAdapters } from "./shapes.js"; import { InternalCompilerError } from "../../errors.js"; import { MAY_THROW_LIB_FNS } from "../../ir/builtin-effects.js"; -import { F64, type IrExpr, type IrType } from "../../ir/ir.js"; +import { BYTES_ELEMENT_SIZE, F64, type IrExpr, type IrType } from "../../ir/ir.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import { emitJsLibCall } from "./jsval.js"; import type { LlvmEmitterContext, LibCallExpr, LibCallPrefix, LlValue } from "./expr-context.js"; @@ -22,9 +22,10 @@ import { } from "./lib-shared.js"; import { isDynUndefined } from "../../ir/runtime-features.js"; import { DYN_KIND } from "./dyn.js"; -import { rawBytes } from "./lib-abi.js"; +import { caBytes } from "./lib-abi.js"; import { emitInlineAtomicsOp } from "./expr-bytes.js"; import { regexLiteralCaptures } from "./regex-matchers.js"; +import { registerReplay } from "./emit-journal.js"; /** ScrDyn.v.typed_ref.type_key_len: the low 27 bits of its bit-field word. */ const TYPE_KEY_LEN_MASK = (1 << 27) - 1; @@ -595,7 +596,8 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll // The https URL row is the http one with the TLS entry point — same // three arguments, same response-callback adapter. The https options // row is wider: rejectUnauthorized stays an i1, while its ScrStr or - // ScrBytes CA value expands to the runtime's raw pointer + length. + // ScrBytes CA value expands to the runtime's raw pointer + length + // (caBytes: a null pointer for none). const isTls = e.fn.startsWith("https."); const isUrl = e.fn.includes("requestUrl"); const isTlsOptions = isTls && !isUrl; @@ -629,7 +631,7 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll if (isTlsOptions) { callArgs = [ ...callArgs.slice(0, 8), - ...rawBytes(host, args[8]!).map((arg) => `${arg.type} ${arg.name}`), + ...caBytes(host, args[8]!).map((arg) => `${arg.type} ${arg.name}`), ]; host.declare( `declare ptr @scr_https_request(ptr, double, ptr, ptr, double, ptr, i1 zeroext, i1 zeroext, ptr, ${host.sizeType}, ptr, ptr)`, @@ -649,6 +651,15 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll if (MAY_THROW_LIB_FNS.has(e.fn) && LIB_FN_SYMS[e.fn] === undefined) { throw new LlvmUnsupportedError(`libCall:${e.fn}`, e.loc); } + // A setter's write: the time value into the Date object (ScrDate.ms). + if (e.fn === "date.store") { + const date = emitBorrowedInput(host, e.args[0]!).name; + const ms = host.emitExpr(e.args[1]!).name; + const field = B.tmp(); + B.line(`${field} = getelementptr inbounds i8, ptr ${date}, i64 8 ; ->ms`); + B.line(`store double ${ms}, ptr ${field}`); + return { name: ms, type: e.type }; + } // queueMicrotask's checked-dynamic form carries the AsyncLocalStorage // context like the static one (lib-process.ts). const sym = @@ -927,6 +938,57 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { return { name: t, type: e.type }; } if (e.fn === "bytes.absent") return host.own({ name: "null", type: e.type }); + if (e.fn === "bytes.disjoint") { + // [data, data + len * size) of each view, compared as addresses. A view + // that is not there (a binding the nest never assigned, NULL) answers + // false without reading its header, so the caller keeps its loop. + const B = host.B; + const values = e.args.map((arg) => ({ arg, value: host.emitReadReceiver(arg) })); + const present = values.map(({ value }) => { + const t = B.tmp(); + B.line(`${t} = icmp ne ptr ${value.name}, null`); + return t; + }); + const both = B.tmp(); + B.line(`${both} = and i1 ${present[0]}, ${present[1]}`); + const compare = B.newLabel("disjoint.compare"), + done = B.newLabel("disjoint.done"); + const from = B.currentLabel(); + B.condBr(both, compare, done); + B.startBlock(compare); + const range = (arg: IrExpr, pointer: string): { start: string; end: string } => { + const size = arg.type.kind === "bytes" ? BYTES_ELEMENT_SIZE[arg.type.elem] : 1; + const dataSlot = B.tmp(), + data = B.tmp(), + lenSlot = B.tmp(), + len = B.tmp(), + bytes = B.tmp(), + start = B.tmp(), + end = B.tmp(); + B.line(`${dataSlot} = getelementptr inbounds %ScrBytes, ptr ${pointer}, i64 0, i32 3`); + B.line(`${data} = load ptr, ptr ${dataSlot}`); + B.line(`${lenSlot} = getelementptr inbounds %ScrBytes, ptr ${pointer}, i64 0, i32 1`); + B.line(`${len} = load ${host.sizeType}, ptr ${lenSlot}`); + B.line(`${bytes} = mul ${host.sizeType} ${len}, ${size}`); + B.line(`${start} = ptrtoint ptr ${data} to ${host.sizeType}`); + B.line(`${end} = add ${host.sizeType} ${start}, ${bytes}`); + return { start, end }; + }; + const left = range(values[0]!.arg, values[0]!.value.name), + right = range(values[1]!.arg, values[1]!.value.name); + const before = B.tmp(), + after = B.tmp(), + apart = B.tmp(), + t = B.tmp(); + B.line(`${before} = icmp ule ${host.sizeType} ${left.end}, ${right.start}`); + B.line(`${after} = icmp ule ${host.sizeType} ${right.end}, ${left.start}`); + B.line(`${apart} = or i1 ${before}, ${after}`); + const compared = B.currentLabel(); + B.br(done); + B.startBlock(done); + B.line(`${t} = phi i1 [ false, %${from} ], [ ${apart}, %${compared} ]`); + return { name: t, type: e.type }; + } if (e.fn === "record.isFrozen") { // The frozen bit of the record's rc word (scr_rc.h SCR_RC_FROZEN) is // its second-highest bit; an immortal word sets the highest one too. @@ -1069,6 +1131,36 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { } } +/** The settlement adapter of Promise.all's tuple form for the builder + * `fnName`, which fulfills the result with the builder's tuple. */ +function allTupleAdapterFor(host: LlvmEmitterContext, fnName: string, tuple: IrType): string { + const key = `allTuple:${fnName}`; + let adapter = host.resolveThunks.get(key); + if (adapter !== undefined) return adapter; + adapter = `sc_all_tuple_${host.resolveThunks.size}`; + host.resolveThunks.set(key, adapter); + host.journal?.allocated(adapter, "allTupleAdapterFor", [fnName, tuple]); + const rc = vAdapters(host.shapeHost, tuple); + host.declare(`declare ptr @scr_promise_payload_ref(ptr)`); + host.declare(`declare void @scr_promise_fulfill_ref(ptr, ptr, ptr, ptr, ptr)`); + host.resolveThunkDefs.push( + `define internal void @${adapter}(ptr %dst, ptr %src) ${FN_ATTRS} { ; Promise.all tuple`, + `entry:`, + ` %entries = call ptr @scr_promise_payload_ref(ptr %src) ; +1, moves into the builder`, + ` %tuple = call ptr @${host.callTarget(fnName)}(ptr %entries)`, + ` call void @scr_promise_fulfill_ref(ptr %dst, ptr %tuple, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, tuple)})`, + ` ret void`, + `}`, + ``, + ); + host.journal?.built(); + return adapter; +} + +registerReplay("allTupleAdapterFor", (host, args) => + allTupleAdapterFor(host, args[0] as string, args[1] as IrType), +); + /** Promise.all's tuple form (promise.allTuple): the combinator over the * entries stores each fulfilled entry promise, and a settlement adapter on * the combinator calls the builder with them and fulfills the result with @@ -1081,26 +1173,7 @@ function emitPromiseAllTuple(host: LlvmEmitterContext, e: LibCallExpr): LlValue throw new InternalCompilerError("llvm emitter bug: promise.allTuple shape"); const entriesT = e.args[0]!.type; const entries = host.emitExpr(e.args[0]!); - const tuple = e.type.inner; - const key = `allTuple:${build.fnName}`; - let adapter = host.resolveThunks.get(key); - if (adapter === undefined) { - adapter = `sc_all_tuple_${host.resolveThunks.size}`; - host.resolveThunks.set(key, adapter); - const rc = vAdapters(host.shapeHost, tuple); - host.declare(`declare ptr @scr_promise_payload_ref(ptr)`); - host.declare(`declare void @scr_promise_fulfill_ref(ptr, ptr, ptr, ptr, ptr)`); - host.resolveThunkDefs.push( - `define internal void @${adapter}(ptr %dst, ptr %src) ${FN_ATTRS} { ; Promise.all tuple`, - `entry:`, - ` %entries = call ptr @scr_promise_payload_ref(ptr %src) ; +1, moves into the builder`, - ` %tuple = call ptr @${host.callTarget(build.fnName)}(ptr %entries)`, - ` call void @scr_promise_fulfill_ref(ptr %dst, ptr %tuple, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, tuple)})`, - ` ret void`, - `}`, - ``, - ); - } + const adapter = allTupleAdapterFor(host, build.fnName, e.type.inner); host.declare(`declare ptr @scr_promise_all(ptr, ptr, ptr)`); host.declare(`declare void @scr_promise_all_store_promise(ptr, double, ptr)`); host.declare(`declare ptr @scr_promise_new()`); diff --git a/packages/compiler/src/backend/llvm/lib-http2.ts b/packages/compiler/src/backend/llvm/lib-http2.ts index 321f1d005..d750fb7c5 100644 --- a/packages/compiler/src/backend/llvm/lib-http2.ts +++ b/packages/compiler/src/backend/llvm/lib-http2.ts @@ -9,6 +9,7 @@ import { callbackType, finishRuntimeCall, ptr, + caBytes, rawBytes, requestHandler, } from "./lib-abi.js"; @@ -204,7 +205,7 @@ export function emitHttp2LibCall(host: LlvmEmitterContext, e: LibCallExpr): LlVa ); return finishRuntimeCall(host, e, "scr_http2_connect", [ ...a.slice(0, 2), - ...rawBytes(host, args[2]!), + ...caBytes(host, args[2]!), ...handler, ]); } diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 74ab34971..75d8038fc 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -6,6 +6,7 @@ import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js import { f64Lit } from "./common.js"; import { mockTimerSym } from "./lib-shared.js"; import { FN_ATTRS } from "./shapes.js"; +import { registerReplay } from "./emit-journal.js"; export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; @@ -498,6 +499,7 @@ function boundCallbackThunk(host: LlvmEmitterContext, cbT: IrType & { kind: "fun if (sym) return sym; sym = `sc_alsbound_${host.resolveThunks.size}`; host.resolveThunks.set(key, sym); + host.journal?.allocated(sym, "boundCallbackThunk", [cbT]); host.declare("declare ptr @scr_als_bound_target(ptr)"); host.declare("declare ptr @scr_als_bound_enter(ptr)"); host.declare("declare void @scr_als_bound_leave(ptr, ptr)"); @@ -517,9 +519,14 @@ function boundCallbackThunk(host: LlvmEmitterContext, cbT: IrType & { kind: "fun `}`, ``, ); + host.journal?.built(); return sym; } +registerReplay("boundCallbackThunk", (host, args) => + boundCallbackThunk(host, args[0] as IrType & { kind: "func" }), +); + export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; if (e.fn === "process.forkTarget") { diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 7a16c37e8..def2dd6bf 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -37,9 +37,11 @@ export const ASSERT_VERDICT_LIB_FNS: ReadonlyMap = new Ma export const LIB_FN_SYMS: Record = { "assert.expectsErrDyn": "scr_assert_expects_err_dyn", + "assert.throwsValidate": "scr_assert_throws_validate", "insp.jsonDyn": "scr_util_format_j", "http.reqH2StreamOrThrow": "scr_http_req_h2_stream_or_throw", "intl.segmenterNew": "scr_intl_segmenter_new", + "intl.segmenterRefusing": "scr_intl_segmenter_refusing", "weakMap.is": "scr_weak_map_is", "weakSet.is": "scr_weak_set_is", "weakMap.new": "scr_weak_map_new", @@ -82,6 +84,7 @@ export const LIB_FN_SYMS: Record = { "util.isDeepStrictEqual": "scr_util_is_deep_strict_equal", "util.styleText": "scr_util_style_text", "util.inherits": "scr_util_inherits", + "util.inheritsPrototype": "scr_util_inherits_prototype", "util.deprecate": "scr_util_deprecate", "util.callbackify": "scr_util_callbackify", "util.promisifyDyn": "scr_util_promisify", @@ -254,6 +257,7 @@ export const LIB_FN_SYMS: Record = { "global.native": "scr_global_native", "process.builtinModule": "scr_process_builtin_module", "process.builtinUnsupported": "scr_process_builtin_unsupported", + "module.namespaceValue": "scr_module_namespace_value", "fs.callbackValue": "scr_fs_callback_value", "fs.callbackCall": "scr_fs_callback_call", "process.hrtimeValue": "scr_process_hrtime_value", @@ -334,6 +338,8 @@ export const LIB_FN_SYMS: Record = { "date.getTime": "scr_date_get_time", "date.valueOf": "scr_date_get_time", "date.fromTime": "scr_date_get_time", + "date.setFields": "scr_date_set_fields", + "date.timeClip": "scr_date_new_ms", "date.parseGetTime": "scr_date_parse", "date.utc": "scr_date_utc", "date.newLocal": "scr_date_new_local", @@ -399,6 +405,9 @@ export const LIB_FN_SYMS: Record = { "assert.eqBool": "scr_assert_eq_bool", "assert.eqSym": "scr_assert_eq_sym", "assert.eqDyn": "scr_assert_eq_dyn", + "assert.messageValue": "scr_assert_message_value", + "assert.methodValue": "scr_assert_method_value", + "assert.messageClear": "scr_assert_message_clear", "assert.looseResult": "scr_assert_loose_result", "assert.deepResult": "scr_assert_deep_result", "assert.looseDeepResult": "scr_assert_loose_deep_result", @@ -523,6 +532,7 @@ export const LIB_FN_SYMS: Record = { "fs.openSync": "scr_fs_open", "fs.openNumericSync": "scr_fs_open_numeric", "fs.readSync": "scr_fs_read_sync", + "fs.readSyncDyn": "scr_fs_read_sync_dyn", "fs.writeSync": "scr_fs_write_sync", "fs.writeStrSync": "scr_fs_write_str_sync", "fs.closeSync": "scr_fs_close", @@ -795,6 +805,7 @@ export const LIB_FN_SYMS: Record = { "procStream.write": "scr_proc_stream_write", "stream.stdioWritable": "scr_stream_stdio_writable", "stream.unboxDyn": "scr_stream_unbox_dyn", + "stream.dynIterableSource": "scr_stream_dyn_iterable_source", "stream.handleStream": "scr_stream_handle", "stream.pipeDyn": "scr_stream_pipe_dyn", "stream.unpipeDyn": "scr_stream_unpipe_dyn", @@ -987,6 +998,7 @@ export const LIB_FN_SYMS: Record = { "dyn.keySetComputed": "scr_dyn_key_set_computed", "dyn.keyDelete": "scr_dyn_key_delete", "dyn.keyDeleteComputed": "scr_dyn_key_delete_computed", + "dyn.reflectDeleteProperty": "scr_dyn_reflect_delete_property", "dyn.hasKeyComputed": "scr_dyn_has_key_computed", "dyn.hasOwnComputed": "scr_dyn_has_own_computed", "dyn.propertyIsEnumerableComputed": "scr_dyn_property_is_enumerable_computed", @@ -1052,6 +1064,8 @@ export const LIB_FN_SYMS: Record = { "dyn.nativeDateHandleIs": "scr_dyn_native_date_is", "dyn.globalConstructor": "scr_dyn_global_constructor", "dyn.builtinFunction": "scr_dyn_builtin_function", + "dyn.namespaceNew": "scr_dyn_namespace_new", + "dyn.namespaceDefine": "scr_dyn_namespace_define", "date.nativeNew": "scr_dyn_native_date_new", "date.nativeNewFields": "scr_dyn_native_date_new_fields", "date.nativeNewValue": "scr_dyn_native_date_new_value", @@ -1091,6 +1105,28 @@ export const LIB_FN_SYMS: Record = { "crypto.verifyOneshot": "scr_crypto_verify_oneshot", "crypto.createSign": "scr_crypto_create_sign", "crypto.createVerify": "scr_crypto_create_verify", + "crypto.x509Raw": "scr_crypto_x509_raw", + "crypto.x509RawDer": "scr_crypto_x509_raw_der", + "crypto.x509RawStr": "scr_crypto_x509_raw_str", + "crypto.x509Subject": "scr_crypto_x509_subject", + "crypto.x509Issuer": "scr_crypto_x509_issuer", + "crypto.x509SubjectAltName": "scr_crypto_x509_subject_alt_name", + "crypto.x509InfoAccess": "scr_crypto_x509_info_access", + "crypto.x509SerialNumber": "scr_crypto_x509_serial_number", + "crypto.x509Fingerprint256": "scr_crypto_x509_fingerprint256", + "crypto.x509Fingerprint512": "scr_crypto_x509_fingerprint512", + "crypto.x509Ca": "scr_crypto_x509_ca", + "crypto.x509SignatureAlgorithm": "scr_crypto_x509_signature_algorithm", + "crypto.x509SignatureAlgorithmOid": "scr_crypto_x509_signature_algorithm_oid", + "crypto.x509CheckHost": "scr_crypto_x509_check_host", + "crypto.x509CheckEmail": "scr_crypto_x509_check_email", + "crypto.x509CheckIP": "scr_crypto_x509_check_ip", + "crypto.x509CheckIssued": "scr_crypto_x509_check_issued", + "crypto.x509LegacyObject": "scr_crypto_x509_legacy_object", + "crypto.x509Pem": "scr_crypto_x509_pem", + "crypto.x509PublicKey": "scr_crypto_x509_public_key", + "crypto.x509Verify": "scr_crypto_x509_verify", + "crypto.x509CheckPrivateKey": "scr_crypto_x509_check_private_key", "fs.writeFileModeSync": "scr_fs_write_file_mode", "fs.chownSync": "scr_fs_chown", "fs.lchownSync": "scr_fs_lchown", diff --git a/packages/compiler/src/backend/llvm/lib-tls.ts b/packages/compiler/src/backend/llvm/lib-tls.ts index 1d1cf659c..dbe492180 100644 --- a/packages/compiler/src/backend/llvm/lib-tls.ts +++ b/packages/compiler/src/backend/llvm/lib-tls.ts @@ -8,6 +8,7 @@ import { finishRuntimeCall, nullableReference, ptr, + caBytes, rawBytes, requestHandler, } from "./lib-abi.js"; @@ -110,6 +111,31 @@ export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValu return finishRuntimeCall(host, e, "scr_tls_sock_get_cipher", a); case "tls.sockAlpnProtocol": return finishRuntimeCall(host, e, "scr_tls_sock_alpn_protocol", a); + case "tls.serverAddContext": + return finishRuntimeCall(host, e, "scr_tls_server_add_context", [...a, ptr()]); + case "tls.serverAddContextCtx": + return finishRuntimeCall(host, e, "scr_tls_server_add_context", [a[0]!, a[1]!, ptr(), a[2]!]); + case "tls.serverSetSecureContext": + return finishRuntimeCall(host, e, "scr_tls_server_set_secure_context", a); + case "tls.serverOnClientError": { + // (err, tlsSocket) listeners by arity: the adapter retains what the + // listener takes. + const cb = args[1]!; + const thunk = `scr_net_client_error_thunk${callbackType(cb).params.length}`; + return finishRuntimeCall(host, e, "scr_net_server_on_client_error", [ + a[0]!, + ...callbackAdapter(host, cb, thunk, ["ptr", "ptr", "ptr"]), + a[2]!, + ]); + } + case "tls.sockGetPeerCertificate": + return finishRuntimeCall(host, e, "scr_tls_sock_get_peer_certificate", a); + case "tls.sockGetCertificate": + return finishRuntimeCall(host, e, "scr_tls_sock_get_certificate", a); + case "tls.sockPeerCertDer": + return finishRuntimeCall(host, e, "scr_tls_sock_peer_cert_der", a); + case "tls.sockOwnCertDer": + return finishRuntimeCall(host, e, "scr_tls_sock_own_cert_der", a); case "tls.sockAuthError": { const result = callRuntime(host, "scr_tls_sock_auth_error", "ptr", a); return nullableReference(host, e.type, result, "string", "nullT"); @@ -159,7 +185,7 @@ export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValu ); return finishRuntimeCall(host, e, "scr_https_request_agent", [ ...a.slice(0, 8), - ...rawBytes(host, args[8]!), + ...caBytes(host, args[8]!), a[9]!, ...handler, ]); @@ -179,7 +205,7 @@ export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValu ["ptr", "ptr"], ); // Evaluate arguments once before selecting which transport consumes the callback. - const pem = rawBytes(host, args[9]!); + const pem = caBytes(host, args[9]!); const B = host.B, tls = B.newLabel("request.tls"), plain = B.newLabel("request.plain"), diff --git a/packages/compiler/src/backend/llvm/local-union-storage.test.ts b/packages/compiler/src/backend/llvm/local-union-storage.test.ts index b8f44408d..81019cadc 100644 --- a/packages/compiler/src/backend/llvm/local-union-storage.test.ts +++ b/packages/compiler/src/backend/llvm/local-union-storage.test.ts @@ -113,7 +113,8 @@ test("conditional and reassigned locals own payload snapshots without heap wrapp expect(facts(mod).get("item")?.ownerType).toEqual(STRING); for (const bits of [32, 64] as const) { const ir = body(mod, bits); - expect(ir).toContain("call ptr @sc_map_entry_f64("); // inline number-key read + // The inline number-key read, on the integer view of the literal key. + expect(ir).toMatch(/call ptr @sc_map_entry_f64_int\(ptr %\S+, i64 \d+, double /); expect(ir).toContain("@scr_str_release"); expect(ir).not.toMatch(/@scr_union_(?:new|retain|release)/); expect(ir).toContain("store i64 0"); diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts index 989b8b3b0..7457db42d 100644 --- a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts +++ b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts @@ -118,7 +118,7 @@ test("string keys and reference values are retained inline, boolean slots compar }); /** work(map, k): map.has(k) on a number-keyed map. */ -function lookupFixture(): IrModule { +function lookupFixture(key: IrExpr = ref("k", F64)): IrModule { const map: IrType = { kind: "map", key: F64, value: F64 }; const params = [ { localId: "map", name: "map", type: map }, @@ -128,7 +128,7 @@ function lookupFixture(): IrModule { kind: "mapIntrinsic", method: "has", receiver: ref("map", map), - args: [ref("k", F64)], + args: [key], type: BOOL, loc, }; @@ -182,6 +182,79 @@ test("hashed number-key lookups probe inline and call the runtime only for wide } }); +test("a key with an integer view tests the direct index on the integer", () => { + for (const bits of [32, 64] as const) { + const sz = bits === 64 ? "i64" : "i32"; + const integral = emitted(lookupFixture({ kind: "numLit", value: 12, type: F64, loc }), bits); + expect(integral.work).toMatch(/call ptr @sc_map_entry_f64_int\(ptr %\S+, i64 12, double /); + const helper = + /^define internal ptr @sc_map_entry_f64_int\(ptr %m, i64 %ki, double %k\)[^]*?^}/m.exec( + integral.module, + )![0]; + // An unsigned compare also rejects negative keys; the hashed probe takes + // the double. + expect(helper).toContain("icmp ult i64 %ki, %nd"); + expect(helper).toContain(`getelementptr inbounds i32, ptr %dense, ${sz} %i`); + expect(helper).toContain("call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)"); + expect(helper).not.toContain("fptosi"); + // A fractional key and a key without a proof keep the double lookup. + for (const key of [{ kind: "numLit", value: 0.5, type: F64, loc } as IrExpr, ref("k", F64)]) { + const { work } = emitted(lookupFixture(key), bits); + expect(work).toContain("call ptr @sc_map_entry_f64("); + expect(work).not.toContain("@sc_map_entry_f64_int("); + } + } +}); + +test("a key view converted in a branch is not reused by a later lookup of the same constant", () => { + // work(map, k, taken): if (taken) return map.has((k = 2 ** 40)); return map.has(2 ** 40). + const wide = 2 ** 40; + const mod = lookupFixture(); + const work = mod.functions[1]!; + const map = work.params[0]!.type; + const has = (key: IrExpr): IrExpr => ({ + kind: "mapIntrinsic", + method: "has", + receiver: ref("map", map), + args: [key], + type: BOOL, + loc, + }); + const literal: IrExpr = { kind: "numLit", value: wide, type: F64, loc }; + const taken = { localId: "taken", name: "taken", type: BOOL }; + work.params.push(taken); + work.locals.push({ id: "taken", name: "taken", type: BOOL, mutable: true }); + work.body = [ + { + kind: "if", + cond: ref("taken", BOOL), + then: [ + { + kind: "return", + value: has({ + kind: "assignExpr", + localId: "k", + value: literal, + type: F64, + loc, + }), + loc, + }, + ], + else_: null, + loc, + }, + { kind: "return", value: has(literal), loc }, + ]; + for (const bits of [32, 64] as const) { + const { work: text } = emitted(mod, bits); + // The lookup after the branch takes the literal, not a conversion made + // inside the branch. + expect(text).toMatch(/call ptr @sc_map_entry_f64_int\(ptr %\S+, i64 1099511627776, double /); + expect(text).not.toMatch(/@sc_map_entry_f64_int\(ptr %\S+, i64 %/); + } +}); + /** work(map): map.size, and keep(map): map returned as an owned value. */ function sizeFixture(): IrModule { const map: IrType = { kind: "map", key: F64, value: F64 }; diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.ts b/packages/compiler/src/backend/llvm/map-number-lookup.ts index 6d219b50a..afc8ec606 100644 --- a/packages/compiler/src/backend/llvm/map-number-lookup.ts +++ b/packages/compiler/src/backend/llvm/map-number-lookup.ts @@ -28,16 +28,44 @@ * guard excludes. for-of starts at scr_map_iter_start, past a deleted * prefix. */ -import type { IrType } from "../../ir/ir.js"; -import type { LlvmEmitterContext } from "./expr-context.js"; +import type { IrExpr, IrType } from "../../ir/ir.js"; +import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; +import { exactInteger, widenInteger } from "./integer-values.js"; import { mapKeyAccess } from "./shapes.js"; export const NUMBER_MAP_ENTRY_DECL = "declare ptr @scr_map_entry_f64(ptr, double)"; +/** Record the integer view of a number Map/Set key operand for the entry + * lookups of that key, when the view needs no instruction of its own: the + * value's integer or uint32 view, defined with the value, or an integral + * literal. A view exactInteger converts at this lookup is not recorded: the + * key cache spans the function, and a later lookup of the same operand (a + * constant, say) can be in a block the conversion does not dominate. */ +export function noteNumberMapKey(host: LlvmEmitterContext, key: LlValue, expr: IrExpr): void { + if (key.type.kind !== "f64" || host.numberMapKeyIntegers.has(key.name)) return; + if (!key.integer && key.uint32 === undefined && expr.kind !== "numLit") return; + const integer = exactInteger(host, key, expr); + if (!integer) return; + const defined = + integer.name === key.integer?.name || + integer.name === key.uint32 || + /^-?\d+$/.test(integer.name); + if (defined) host.numberMapKeyIntegers.set(key.name, integer); +} -/** The entry for `key`, or null when absent. Valid until the next mutation. */ +/** The entry for `key`, or null when absent. Valid until the next mutation. + * A key with an integer view (noteNumberMapKey) tests the direct index on + * that integer instead of converting the double back. */ export function emitNumberMapEntry(host: LlvmEmitterContext, map: string, key: string): string { host.declare(NUMBER_MAP_ENTRY_DECL); const entry = host.B.tmp(); + const integer = host.numberMapKeyIntegers.get(key); + if (integer) { + const wide = widenInteger(host, integer); + host.B.line( + `${entry} = call ptr @sc_map_entry_f64_int(ptr ${map}, i64 ${wide}, double ${key})`, + ); + return entry; + } host.B.line(`${entry} = call ptr @sc_map_entry_f64(ptr ${map}, double ${key})`); return entry; } @@ -122,14 +150,16 @@ export function emitStringMapGetScalar( } /** Map.get of a reference value: a +1 value, or null when absent — the - * contract of scr_map_get_f64_ref. */ + * contract of scr_map_get_f64_ref. `borrow`: the entry's own reference, + * without a retain, for a reader that finishes before the map can change. */ export function emitNumberMapGetRef( host: LlvmEmitterContext, map: string, key: string, value: IrType, + borrow = false, ): string { - return emitMapEntryGetRef(host, emitNumberMapEntry(host, map, key), value); + return emitMapEntryGetRef(host, emitNumberMapEntry(host, map, key), value, false, borrow); } /** Map.get of a reference value under a string key, with scr_map_get_str_ref's @@ -141,16 +171,25 @@ export function emitStringMapGetRef( key: string, value: IrType, compact = false, + borrow = false, ): string { - return emitMapEntryGetRef(host, emitStringMapEntry(host, map, key, compact), value, compact); + return emitMapEntryGetRef( + host, + emitStringMapEntry(host, map, key, compact), + value, + compact, + borrow, + ); } -/** The +1 value of a found entry, or null for a missing (null) entry. */ +/** The +1 value of a found entry (its own reference when `borrow`), or + * null for a missing (null) entry. */ function emitMapEntryGetRef( host: LlvmEmitterContext, entry: string, value: IrType, compact = false, + borrow = false, ): string { const B = host.B; const found = B.tmp(); @@ -160,10 +199,8 @@ function emitMapEntryGetRef( join = B.newLabel("mapn.join"); B.condBr(found, hit, miss); B.startBlock(hit); - const retained = host.retainFrame( - compact ? emitCompactMapSlot(host, entry) : emitNumberMapSlot(host, entry, "ptr"), - value, - ); + const loaded = compact ? emitCompactMapSlot(host, entry) : emitNumberMapSlot(host, entry, "ptr"); + const retained = borrow ? loaded : host.retainFrame(loaded, value); B.br(join); B.startBlock(miss); B.br(join); diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts index 3725db060..d91e2b211 100644 --- a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts @@ -120,7 +120,12 @@ test("local map results keep scalar and owned reference payloads without heap wr expect(ir).toContain("call ptr @sc_map_entry_str("); expect(ir).not.toContain("@scr_map_get_"); } - if (value === STRING) expect(ir).toContain("@scr_str_release"); + // Nothing can change the map before the tag test, so a reference + // result is the entry's own (findMapReadBorrows): no retain or release. + if (value === STRING) { + expect(ir).not.toContain("@scr_str_retain"); + expect(ir).not.toContain("@scr_str_release"); + } } } }); @@ -132,7 +137,8 @@ test("nullable record results are the looked-up pointer itself on both ABIs", () expect(ir).not.toContain("%ScrUnion"); expect(ir).not.toContain("@scr_union_"); expect(ir).toMatch(/icmp (?:eq|ne) ptr %\w+, null/); - expect(ir).toContain("@sc_rrelease_"); + // The pointer is the entry's own reference until the tag test. + expect(ir).not.toContain("@sc_rrelease_"); } }); diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts index 6053d70eb..ae741463d 100644 --- a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts @@ -9,6 +9,7 @@ import { } from "../../ir/ir.js"; import { everyExpr, everyStmt, everyStmtList } from "../../ir/traverse.js"; import type { CallLifetimes } from "./call-lifetimes.js"; +import type { ReferenceEffects } from "./reference-effects.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { emitNumberMapGetRef, @@ -16,6 +17,7 @@ import { emitStringMapGetScalar, emitStringMapGetRef, compactMapValues, + noteNumberMapKey, } from "./map-number-lookup.js"; import { mapKeyAccess, mapKeyParamType } from "./shapes.js"; import { rememberMapEntry } from "./map-entry-fusion.js"; @@ -68,6 +70,10 @@ export function borrowsMapMutationReceiver(expr: IrExpr): boolean { export interface LocalMapRead { /** The Map.get expression itself. */ expr: IrExpr; + /** A local's reference result may stay the entry's own reference: every + * use of the local runs before anything that can change a reference + * edge (findMapReadBorrows), so the entry still holds the value. */ + borrowed?: boolean; type: IrType; receiver: IrExpr; key: IrExpr; @@ -126,6 +132,7 @@ export function findMapReadLifetimes( fn: IrFunction, unions: ReadonlyMap, lifetimes: CallLifetimes, + effects?: ReferenceEffects, ): MapReadLifetimes { const result: MapReadLifetimes = { locals: new Map(), arguments: new Map() }; if (fn.async || fn.generator) return result; @@ -150,6 +157,125 @@ export function findMapReadLifetimes( return true; }, }); + if (effects) { + const borrowed = findMapReadBorrows(fn, effects, (decl) => { + const read = result.locals.get(decl.localId); + return read !== undefined && isRefCounted(read.value); + }); + for (const id of borrowed) result.locals.get(id)!.borrowed = true; + } + return result; +} + +/** Whether a map read's receiver keeps the map past its own statement: a + * local or a field of one. Changing either is an edge change the borrow + * proof sees. A temporary map (a call's result, `new Map()`) is released at + * the end of the declaration, and its entries with it. */ +function heldReceiver(expr: IrExpr): boolean { + if (expr.kind === "varRef") return true; + if (expr.kind === "fieldGet" || expr.kind === "recordGet") return heldReceiver(expr.obj); + if (expr.kind === "unionNarrow" || expr.kind === "downcast") return heldReceiver(expr.value); + return false; +} + +/** The locals, declared by statements `candidate` accepts, that can hold a + * map entry's own reference instead of a +1 copy. The statements after the + * declaration run in order, and every one that names the local must + * preserve reference edges (ReferenceEffects) and run before any statement + * that may not, so no write, deletion or unknown call can release the value + * while the local is read. An `if` passes its condition's state to both + * branches and joins them; any other statement that names the local is + * judged whole. A later pass of an enclosing loop declares the local again, + * so only the rest of its own statement list matters. The map must be held + * by a local or a field (heldReceiver), the read itself must preserve + * reference edges (a key whose evaluation replaces the receiver's field + * leaves the read on a map the statement releases), and locals declared + * from the local count as its uses. An update such as + * `const c = m.get(k); if (c === undefined) m.set(k, v); else c.n++` keeps + * the borrow: the write runs only on the branch that does not read `c`. */ +export function findMapReadBorrows( + fn: IrFunction, + effects: ReferenceEffects, + candidate: (decl: IrStmt & { kind: "varDecl" }) => boolean, +): Set { + const result = new Set(); + if (fn.async || fn.generator) return result; + // `ids` holds the local and the locals declared from it: a stable alias + // (`const d = c`) reads the same entry without a reference of its own. + const names = (node: IrStmt | IrExpr, ids: ReadonlySet, isStmt: boolean): boolean => { + let found = false; + const visitor = { + stmt: () => !found, + expr: (expr: IrExpr) => { + if (expr.kind === "varRef" && ids.has(expr.localId)) found = true; + return !found; + }, + }; + if (isStmt) everyStmt(node as IrStmt, visitor); + else everyExpr(node as IrExpr, visitor); + return found; + }; + // Adds the reference locals that `stmt` declares from one in `ids`, at any + // depth: a `var` inside a loop or a try stays in scope after it. + const alias = (stmt: IrStmt, ids: Set): void => { + everyStmt(stmt, { + stmt: (node) => { + if ( + node.kind === "varDecl" && + node.init !== null && + isRefCounted(node.init.type) && + names(node.init, ids, false) + ) + ids.add(node.localId); + return true; + }, + expr: () => true, + }); + }; + // Whether an edge may have changed after `list`, or null when a statement + // names the local after such a change (or changes edges itself). + const walk = (list: readonly IrStmt[], ids: Set, changed: boolean): boolean | null => { + for (const stmt of list) { + if (stmt.kind === "if") { + const preserving = effects.preserves(stmt.cond); + if (names(stmt.cond, ids, false) && (changed || !preserving)) return null; + const condition = changed || !preserving; + const then = walk(stmt.then, ids, condition); + const otherwise = stmt.else_ === null ? condition : walk(stmt.else_, ids, condition); + if (then === null || otherwise === null) return null; + changed = then || otherwise; + continue; + } + if (stmt.kind === "block") { + const inner = walk(stmt.body, ids, changed); + if (inner === null) return null; + changed = inner; + continue; + } + const preserving = effects.preservesScope([stmt]); + if (names(stmt, ids, true)) { + if (changed || !preserving) return null; + alias(stmt, ids); + } + changed ||= !preserving; + } + return changed; + }; + const visit = (list: readonly IrStmt[]): void => { + list.forEach((stmt, index) => { + if ( + stmt.kind === "varDecl" && + stmt.init?.kind === "mapIntrinsic" && + heldReceiver(stmt.init.receiver) && + effects.preserves(stmt.init) && + candidate(stmt) && + walk(list.slice(index + 1), new Set([stmt.localId]), false) !== null + ) + result.add(stmt.localId); + for (const nested of nestedStmtLists(stmt)) visit(nested); + }); + }; + visit(fn.body); return result; } @@ -265,6 +391,7 @@ export function emitMapLookupKey( const value = borrow ? host.emitReadReceiver(key) : host.emitExpr(key); const access = mapKeyAccess(key.type); const type = mapKeyParamType(access); + if (access === "f64") noteNumberMapKey(host, value, key); return { access, types: type, args: `${type} ${value.name}`, value: value.name }; } @@ -303,13 +430,14 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead): let owner: StackMapRead["owner"] = null; const number = inlineNumberKey(key); const string = entryStringKey(key); + const borrow = read.borrowed === true; if (number !== null) { if (isRefCounted(read.value)) { - const raw = emitNumberMapGetRef(host, receiver.name, number, read.value); + const raw = emitNumberMapGetRef(host, receiver.name, number, read.value, borrow); B.line(`${found} = icmp ne ptr ${raw}, null`); B.line(`store i64 0, ptr ${payload}`); B.line(`store ptr ${raw}, ptr ${payload}`); - owner = { slot: payload, type: read.value }; + if (!borrow) owner = { slot: payload, type: read.value }; } else { // Booleans are stored as 0/1, already the widened union payload. const scalar = emitNumberMapGetScalar(host, receiver.name, number); @@ -327,8 +455,10 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead): let raw: string; const mapType = read.receiver.type; const compact = mapType.kind === "map" && compactMapValues(host, mapType.key, mapType.value); + // The runtime's other key kinds return +1, which the frame then owns. + const borrowed = borrow && stringKey !== null; if (stringKey !== null) - raw = emitStringMapGetRef(host, receiver.name, stringKey, read.value, compact); + raw = emitStringMapGetRef(host, receiver.name, stringKey, read.value, compact, borrowed); else { raw = B.tmp(); host.declare(`declare ptr @scr_map_get_${access}_ref(ptr, ${keyType})`); @@ -337,7 +467,7 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead): B.line(`${found} = icmp ne ptr ${raw}, null`); B.line(`store i64 0, ptr ${payload}`); B.line(`store ptr ${raw}, ptr ${payload}`); - owner = { slot: payload, type: read.value }; + if (!borrowed) owner = { slot: payload, type: read.value }; } else { const scalar = read.value.kind === "f64" ? "f64" : "bool"; B.line(`store i64 0, ptr ${payload}`); diff --git a/packages/compiler/src/backend/llvm/nullable-unions.test.ts b/packages/compiler/src/backend/llvm/nullable-unions.test.ts index 47278453e..bf0941652 100644 --- a/packages/compiler/src/backend/llvm/nullable-unions.test.ts +++ b/packages/compiler/src/backend/llvm/nullable-unions.test.ts @@ -56,10 +56,11 @@ test("unions of two or more records become shape unions", () => { }); test("records of runtime-inspected unions keep every union over them boxed", () => { - const set: IrType = { kind: "set", elem: { kind: "union", unionId: "ab" } }; + const set: IrType = { kind: "set", elem: { kind: "union", unionId: "abf" } }; const unions = new NullableUnions( module( [ + { id: "abf", arms: [a, b, F64] }, { id: "ab", arms: [a, b] }, { id: "abu", arms: [a, b, UNDEFINED_T] }, { id: "bc", arms: [b, c] }, @@ -68,6 +69,7 @@ test("records of runtime-inspected unions keep every union over them boxed", () { globals: [{ id: "%g.s", name: "s", type: set, mutable: false }] }, ), ); + expect(unions.get("abf")).toBeNull(); expect(unions.get("ab")).toBeNull(); expect(unions.get("abu")).toBeNull(); expect(unions.get("bc")).toBeNull(); @@ -75,6 +77,34 @@ test("records of runtime-inspected unions keep every union over them boxed", () expect(unions.shapeNumber("c")).toBe(0); }); +test("unions of records alone stay shape unions as Map keys and Set elements", () => { + const set: IrType = { kind: "set", elem: { kind: "union", unionId: "ab" } }; + const map: IrType = { kind: "map", key: { kind: "union", unionId: "abc" }, value: F64 }; + const unions = new NullableUnions( + module( + [ + { id: "ab", arms: [a, b] }, + { id: "abc", arms: [a, b, c] }, + { id: "ba", arms: [b, a] }, + { id: "abu", arms: [a, b, UNDEFINED_T] }, + ], + { + globals: [ + { id: "%g.s", name: "s", type: set, mutable: false }, + { id: "%g.m", name: "m", type: map, mutable: false }, + ], + }, + ), + ); + expect(unions.shapes("ab")).not.toBeNull(); + expect(unions.shapes("abc")).not.toBeNull(); + expect(unions.shapes("abu")).not.toBeNull(); + // Unions over the same records share one release and trace helper. + expect(unions.helperOwner("ba")).toBe("ab"); + expect(unions.helperOwner("abu")).toBe("ab"); + expect(unions.helperOwner("abc")).toBe("abc"); +}); + test("library, lifetime-region and debug builds keep record unions boxed", () => { const unions = [{ id: "ab", arms: [a, b] }]; expect(new NullableUnions(module(unions), false).get("ab")).toBeNull(); diff --git a/packages/compiler/src/backend/llvm/nullable-unions.ts b/packages/compiler/src/backend/llvm/nullable-unions.ts index d9b9cdfcf..c51f3b5f6 100644 --- a/packages/compiler/src/backend/llvm/nullable-unions.ts +++ b/packages/compiler/src/backend/llvm/nullable-unions.ts @@ -118,6 +118,9 @@ export class NullableUnions { readonly retainWrappers = new Map(); /** Some nullable union encodes `null` with NULLABLE_NULL. */ readonly usesNullSentinel: boolean = false; + /** The shape union whose release and trace helpers each shape union + * uses: the first one over the same record arms. */ + private readonly helperOwners = new Map(); /** `shapes`: allow shape unions (debug builds keep boxes, whose * debug-info layouts describe every arm). */ @@ -151,6 +154,24 @@ export class NullableUnions { if (!this.shapeNumbers.has(arm.shapeId)) this.shapeNumbers.set(arm.shapeId, this.shapeNumbers.size + 2); } + // Release and trace dispatch on the shape word, which names a record + // shape module-wide, so unions over the same records share them. + const owners = new Map(); + for (const nullable of this.byId.values()) { + if (nullable.repr !== "shapes") continue; + const key = nullable.arms + .map((arm) => arm.shapeId) + .sort() + .join(" "); + const owner = owners.get(key) ?? nullable.unionId; + owners.set(key, owner); + this.helperOwners.set(nullable.unionId, owner); + } + } + + /** The shape union whose release and trace helpers this one uses. */ + helperOwner(unionId: string): string { + return this.helperOwners.get(unionId) ?? unionId; } get(unionId: string): PointerUnion | null { @@ -277,13 +298,19 @@ function shapeUnion( /** Unions the runtime itself inspects as tagged boxes keep that * representation: Map keys and Set elements hash and compare union keys by - * tag and payload inside the C map. */ + * tag and payload inside the C map. A union of records alone is the + * exception: its keys compare by record identity, which a shape union's + * record pointer carries as well (the map keys it by reference). */ function boxedUnionIds(mod: IrModule, nodes?: IrNodeIndex): Set { const boxed = new Set(); // Only the module's union definitions consult the result. if (!mod.unions?.length) return boxed; + const recordsOnly = new Set(); + for (const def of mod.unions) + if (def.arms.length >= 2 && def.arms.every((arm) => arm.kind === "record")) + recordsOnly.add(def.id); const mark = (t: IrType): void => { - if (t.kind === "union") boxed.add(t.unionId); + if (t.kind === "union" && !recordsOnly.has(t.unionId)) boxed.add(t.unionId); }; if (nodes !== undefined) { for (const t of nodes.types) { diff --git a/packages/compiler/src/backend/llvm/record-union-keys.test.ts b/packages/compiler/src/backend/llvm/record-union-keys.test.ts new file mode 100644 index 000000000..3c961cabb --- /dev/null +++ b/packages/compiler/src/backend/llvm/record-union-keys.test.ts @@ -0,0 +1,169 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../../index.js"; +import { BOOL, F64, VOID, type IrExpr, type IrModule, type IrType } from "../../ir/ir.js"; +import { validateModule } from "../../ir/validate.js"; +import { emitLlvmModule } from "./emitter.js"; + +const loc = { file: "record-union-keys.ts", start: 0, end: 0 }; + +async function llvmFor(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-record-union-keys-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { + outDir: dir, + outPath: output, + outputKind: "ir", + }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + const module = deserializeModule(await readFile(output, "utf8")); + expect(validateModule(module)).toEqual([]); + return emitLlvmModule(module); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function body(ir: string, name: string): string { + const found = new RegExp(`^define internal [^\\n]*@${name}(?:\\.i32)?\\([^]*?^}`, "m").exec(ir); + expect(found, name).not.toBeNull(); + return found![0]; +} + +test("Sets and Maps of record unions key the record pointer", async () => { + const ir = await llvmFor(` +type Num = { kind: "num"; value: number }; +type Neg = { kind: "neg"; operand: Node }; +type Add = { kind: "add"; left: Node; right: Node }; +type Node = Num | Neg | Add; +const one: Node = { kind: "num", value: 1 }; +const nodes: Node[] = [one, { kind: "neg", operand: one }, { kind: "add", left: one, right: one }]; +const seen = new Set(); +const depth = new Map(); +for (const node of nodes) { + seen.add(node); + depth.set(node, node.kind === "num" ? 0 : 1); +} +console.log(seen.size, depth.get(nodes[1]!), seen.has({ kind: "num", value: 1 })); +`); + // SCR_MAP_KEY_REF (2), never SCR_MAP_KEY_UNION_REF (3), and no union box. + expect(ir).toMatch(/call ptr @scr_map_new\(i32 2,/); + expect(ir).not.toMatch(/call ptr @scr_map_new\(i32 3,/); + expect(ir).not.toMatch(/call ptr @scr_union_new_ref/); +}); + +const recordType = (shapeId: string): IrType => ({ kind: "record", shapeId }); +const wide: IrType = { kind: "union", unionId: "wide" }; + +/** `pick(u)` answers whether `u` holds arm 0, 2 or 3 of a five-record union + * through an `||` chain of tag tests; `same` is a second union over the same + * records in another order. */ +function tagChainModule(): IrModule { + const u: IrExpr = { kind: "varRef", localId: "u", type: wide, loc }; + const isTag = (tag: number): IrExpr => ({ + kind: "unionIsTag", + unionId: "wide", + tag, + negated: false, + value: u, + type: BOOL, + loc, + }); + const or = (left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "logical", + op: "||", + left, + right, + type: BOOL, + loc, + }); + const shapes = ["a", "b", "c", "d", "e"]; + return { + irVersion: 17, + sourceFile: loc.file, + entry: "main", + records: shapes.map((id) => ({ id, fields: [{ name: id, type: F64 }] })), + unions: [ + { id: "wide", arms: shapes.map(recordType) }, + { id: "same", arms: [...shapes].reverse().map(recordType) }, + ], + functions: [ + { + name: "main", + params: [], + returnType: VOID, + locals: [], + body: [], + loc, + }, + { + name: "pick", + params: [{ localId: "u", name: "u", type: wide }], + returnType: BOOL, + locals: [{ id: "u", name: "u", type: wide, mutable: false }], + body: [{ kind: "return", value: or(or(isTag(0), isTag(2)), isTag(3)), loc }], + loc, + }, + { + name: "keep", + params: [{ localId: "s", name: "s", type: { kind: "union", unionId: "same" } }], + returnType: VOID, + locals: [ + { + id: "s", + name: "s", + type: { kind: "union", unionId: "same" }, + mutable: false, + }, + ], + body: [], + loc, + }, + ], + }; +} + +test("an || chain of tag tests is one switch on the shape word", () => { + const mod = tagChainModule(); + expect(validateModule(mod)).toEqual([]); + const ir = emitLlvmModule(mod, { pointerBits: 64 }); + const pick = body(ir, "sc_bf_pick"); + const switches = pick.match(/switch i32 [^\n]*/g) ?? []; + expect(switches).toHaveLength(1); + expect(switches[0]!.match(/label %tags\.y/g)).toHaveLength(3); + expect(pick).not.toMatch(/@sc_stag\./); +}); + +test("unions over the same records share one release", () => { + const ir = emitLlvmModule(tagChainModule(), { pointerBits: 64 }); + expect(ir.match(/^define internal void @sc_srelease\.[^\n]*noinline/gm)).toHaveLength(1); + expect(ir).toMatch(/^define internal void @sc_srelease\.wide\(/m); + expect(ir).not.toMatch(/^define internal void @sc_srelease\.same\(/m); +}); + +test("a union with a freezable arm compares its count without the frozen bit", async () => { + const ir = await llvmFor(` +type Name = { kind: "name"; text: string }; +type Count = { kind: "count"; value: number }; +type Key = Name | Count; +function name(text: string): Key { + const built: Name = { kind: "name", text }; + return Object.freeze(built); +} +const seen = new Set(); +seen.add(name("a")); +seen.add({ kind: "count", value: 1 }); +console.log(seen.size); +`); + const release = /^define internal void @sc_srelease\.[^\n]*\n[^]*?^}/m.exec(ir); + expect(release).not.toBeNull(); + // SCR_RC_FROZEN is bit 62: a frozen record's last reference has count 1. + expect(release![0]).toContain("and i64 %rc, -4611686018427387905"); + expect(release![0]).toContain("icmp ult i64 %rcm2, -4611686018427387907"); +}); diff --git a/packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts b/packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts index cdbba146c..6d2457c57 100644 --- a/packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts +++ b/packages/compiler/src/backend/llvm/serialized-ir-emission.test.ts @@ -41,6 +41,8 @@ test("sort-heavy programs emit equal LLVM from memory and from serialized IR", a pointerBits: 64, wasi: false, runtimeAbiMarker: false, + // compile() emits Linux textual LLVM with the unwinding ABI. + unwindAbi: process.platform === "linux", objectAudit: sanitize, ...inlineRcOption(options.optimization, sanitize), }); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index a966ff305..ab623f934 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -233,7 +233,7 @@ export { computeTraced } from "../cycle-analysis.js"; * `_v`-shaped. */ export function vAdapters(host: ShapeHost, t: IrType): { retain: string; release: string } { const nullable = host.nullableUnions.of(t); - if (nullable?.repr === "shapes") return shapeUnionRc(nullable); + if (nullable?.repr === "shapes") return shapeUnionRc(host, nullable); if (nullable) { const arm = vAdapters(host, nullable.arm); return { retain: nullableRetainSym(host, nullable.arm), release: arm.release }; @@ -290,7 +290,7 @@ export function vAdapters(host: ShapeHost, t: IrType): { retain: string; release export function retainSym(host: ShapeHost, t: IrType): string { if (t.kind === "jsval") return jsvalRcSym(host, "retain"); const nullable = host.nullableUnions.of(t); - if (nullable?.repr === "shapes") return shapeUnionRc(nullable).retain; + if (nullable?.repr === "shapes") return shapeUnionRc(host, nullable).retain; if (nullable) return nullableRetainSym(host, nullable.arm); const stem = runtimeRcStem(t); if (host.rcHelpers !== null && stem !== null && inlineRcFamily(host, stem, "retain") !== null) { @@ -305,10 +305,10 @@ export function retainSym(host: ShapeHost, t: IrType): string { /** A shape union's RC entry points: every record retain is the same * NULL- and immortal-skipping increment (the null sentinel is immortal), * so the first arm's serves; release dispatches on the arm. */ -function shapeUnionRc(union: ShapeUnion): { retain: string; release: string } { +function shapeUnionRc(host: ShapeHost, union: ShapeUnion): { retain: string; release: string } { return { retain: `@${mangleRecordRetain(union.arms[0]!.shapeId)}`, - release: shapeReleaseFn(union.unionId), + release: shapeReleaseFn(host.nullableUnions.helperOwner(union.unionId)), }; } @@ -352,38 +352,81 @@ export function emitShapeUnionHelpers(host: ShapeHost): string[] { `}`, ``, ); - // release: unit encodings are NULL or immortal; each arm releases - // through its own (NULL- and immortal-tolerant) helper. - out.push( - `define internal void ${shapeReleaseFn(union.unionId)}(ptr %u) ${FN_ATTRS} { ; shape union release`, - `entry:`, - ` %t = call i32 ${tagFn}(ptr %u)`, - ` switch i32 %t, label %done [ ${union.arms.map((arm) => `i32 ${arm.tag}, label %a${arm.tag}`).join(" ")} ]`, - ...union.arms.flatMap((arm) => [ - `a${arm.tag}:`, - ` call void @${mangleRecordRelease(arm.shapeId)}(ptr %u)`, - ` br label %done`, - ]), + // Release and trace: one definition for the unions over the same + // record arms (helperOwner). Unit encodings are NULL or the immortal + // null sentinel; the shape word picks the arm. + if (host.nullableUnions.helperOwner(union.unionId) !== union.unionId) continue; + const units = [ + ` %isnull = icmp eq ptr %o, null`, + ` br i1 %isnull, label %done, label %${host.nullableUnions.usesNullSentinel ? "notnull" : "rec"}`, + ...(host.nullableUnions.usesNullSentinel + ? [ + `notnull:`, + ` %isnullarm = icmp eq ptr %o, ${NULLABLE_NULL}`, + ` br i1 %isnullarm, label %done, label %rec`, + ] + : []), + `rec:`, + ]; + const armSwitch = (call: (shapeId: string) => string): string[] => [ + ` %wp = getelementptr inbounds ${header}, ptr %o, i32 0, i32 ${SHAPE_WORD_INDEX}`, + ` %w = load i32, ptr %wp`, + ` switch i32 %w, label %done [ ${union.arms.map((arm) => `i32 ${host.nullableUnions.shapeNumber(arm.shapeId)}, label %a${arm.tag}`).join(" ")} ]`, + ...union.arms.flatMap((arm) => [`a${arm.tag}:`, call(arm.shapeId), ` br label %done`]), `done:`, ` ret void`, `}`, ``, - ); + ]; + // Every record arm shares the release fast path (the count at the same + // offset, one collector header choice for the whole union), so only a + // release that may free dispatches on the arm. The release stays a call: + // inlined at every release site, it grows code more than it saves. + const traced = union.arms.map((arm) => host.tracedShapes.has(`record:${arm.shapeId}`)); + // A freezable arm's count is the word without its frozen bit, as in + // releaseFastPath: a frozen record's last reference takes the slow path. + const frozen = union.arms.some((arm) => host.recordsById.get(arm.shapeId)?.freezable === true); + const notFrozen = ~recordFrozenBit(S); + const release = shapeReleaseFn(union.unionId); + if (traced.every((t) => t === traced[0])) + out.push( + `define internal void ${release}(ptr %o) noinline ${FN_ATTRS} { ; shape union release`, + `entry:`, + ...units, + ` %rc = load ${S}, ptr %o`, + ...(frozen + ? [ + ` %refs = and ${S} %rc, ${sizeLiteral(S, notFrozen)}`, + ` %rcm2 = sub ${S} %refs, 2`, + ` %shared = icmp ult ${S} %rcm2, ${sizeLiteral(S, notFrozen - 2n)}`, + ] + : [` %rcm2 = sub ${S} %rc, 2`, ` %shared = icmp ult ${S} %rcm2, -3`]), + ` br i1 %shared, label %dec, label %slow`, + `dec:`, + ` %n = sub ${S} %rc, 1`, + ` store ${S} %n, ptr %o`, + ...(traced[0] ? cycleRootLines(host, "done") : [` br label %done`]), + `slow:`, + ` %imm = icmp eq ${S} %rc, -1`, + ` br i1 %imm, label %done, label %free`, + `free:`, + ...armSwitch((shapeId) => ` call void @${mangleRecordRelease(shapeId)}_slow(ptr %o)`), + ); + else + out.push( + `define internal void ${release}(ptr %o) ${FN_ATTRS} { ; shape union release`, + `entry:`, + ...units, + ...armSwitch((shapeId) => ` call void @${mangleRecordRelease(shapeId)}(ptr %o)`), + ); if (shapeUnionTraced(host, union)) out.push( - `define internal void ${shapeTraceFn(union.unionId)}(ptr %u, ptr %visit, ptr %ctx) ${FN_ATTRS} { ; shape union trace`, + `define internal void ${shapeTraceFn(union.unionId)}(ptr %o, ptr %visit, ptr %ctx) ${FN_ATTRS} { ; shape union trace`, `entry:`, - ` %t = call i32 ${tagFn}(ptr %u)`, - ` switch i32 %t, label %done [ ${union.arms.map((arm) => `i32 ${arm.tag}, label %a${arm.tag}`).join(" ")} ]`, - ...union.arms.flatMap((arm) => [ - `a${arm.tag}:`, - ` call void @${mangleRecordTrace(arm.shapeId)}(ptr %u, ptr %visit, ptr %ctx)`, - ` br label %done`, - ]), - `done:`, - ` ret void`, - `}`, - ``, + ...units, + ...armSwitch( + (shapeId) => ` call void @${mangleRecordTrace(shapeId)}(ptr %o, ptr %visit, ptr %ctx)`, + ), ); } return out; @@ -435,7 +478,7 @@ export function emitNullableRetainWrappers(host: ShapeHost): string[] { export function releaseSym(host: ShapeHost, t: IrType): string { if (t.kind === "jsval") return jsvalRcSym(host, "release"); const nullable = host.nullableUnions.of(t); - if (nullable?.repr === "shapes") return shapeUnionRc(nullable).release; + if (nullable?.repr === "shapes") return shapeUnionRc(host, nullable).release; if (nullable) return releaseSym(host, nullable.arm); const stem = runtimeRcStem(t); if (host.rcHelpers !== null && stem !== null && inlineRcFamily(host, stem, "release") !== null) { @@ -985,7 +1028,9 @@ export function traceAdapter(host: ShapeHost, t: IrType): string | null { case "union": { const nullable = host.nullableUnions.get(t.unionId); if (nullable?.repr === "shapes") - return shapeUnionTraced(host, nullable) ? shapeTraceFn(nullable.unionId) : null; + return shapeUnionTraced(host, nullable) + ? shapeTraceFn(host.nullableUnions.helperOwner(nullable.unionId)) + : null; if (nullable) return traceAdapter(host, nullable.arm); if (!host.tracedUnions.has(t.unionId)) return null; host.declare(`declare void @scr_union_trace_v(ptr, ptr, ptr)`); diff --git a/packages/compiler/src/backend/llvm/speculative-integers.test.ts b/packages/compiler/src/backend/llvm/speculative-integers.test.ts index 3acfec0fb..1b82768d5 100644 --- a/packages/compiler/src/backend/llvm/speculative-integers.test.ts +++ b/packages/compiler/src/backend/llvm/speculative-integers.test.ts @@ -1,9 +1,11 @@ import { expect, test } from "vitest"; import { F64, type IrExpr, type IrStmt } from "../../ir/ir.js"; import { + inlinableNumberFunction, isSpeculableValue, planProvenIntegerLoop, planSpeculativeLoop, + rewriteSpeculable, speculativeReads, } from "./speculative-integers.js"; @@ -266,3 +268,136 @@ test("bounded cursors with proven writes are shadowed in a proven integer loop", planProvenIntegerLoop(forLoop([...body, jump]) as never, cursors, all, () => false, all), ).toBeNull(); }); + +const libCall = (fn: string, args: IrExpr[]): IrExpr => + ({ kind: "libCall", fn, args, type: F64, loc }) as never; +const call = (callee: string, args: IrExpr[]): IrExpr => ({ + kind: "call", + callee, + args, + type: F64, + loc, +}); +const pow2 = (n: IrExpr): IrExpr => libCall("math.pow", [num(2), n]); +/** `function trim(v, n) { return v - Math.floor(v / Math.pow(2, n)) * Math.pow(2, n); }` */ +const trim = { + name: "trim", + params: [ + { localId: "v", name: "v", type: F64 }, + { localId: "n", name: "n", type: F64 }, + ], + returnType: F64, + locals: [], + body: [ + { + kind: "return", + value: bin( + "-", + ref("v"), + bin("*", libCall("math.floor", [bin("/", ref("v"), pow2(ref("n")))]), pow2(ref("n"))), + ), + loc, + }, + ], + loc, +} as never; + +test("calls of small pure number functions expand into a speculated write", () => { + const inlinable = inlinableNumberFunction(trim)!; + expect(inlinable.params).toEqual(["v", "n"]); + const context = { inlinable: (callee: string) => (callee === "trim" ? inlinable : undefined) }; + // key = trim(key, m) * 4 + c + const value = bin("+", bin("*", call("trim", [ref("key"), ref("m")]), num(4)), ref("c")); + expect(isSpeculableValue(value)).toBe(false); + const rewritten = rewriteSpeculable(value, context)!; + expect(rewritten.leaves).toEqual([]); + expect(JSON.stringify(rewritten.value)).not.toContain('"call"'); + const write = assign("key", value); + const plan = planSpeculativeLoop( + loop([write, increment("i")]) as never, + all, + () => false, + () => false, + all, + context, + )!; + expect(plan.writes.has(write)).toBe(true); + expect(plan.values.get(write)).toEqual(rewritten); + // `m`, an input of the expanded body, is checked on entry with the others. + expect([...plan.locals.keys()].sort()).toEqual(["c", "i", "key", "m", "n"]); +}); + +test("Math.floor needs a power-of-two divisor, and expansion pure arguments", () => { + const context = { inlinable: () => inlinableNumberFunction(trim) }; + expect(rewriteSpeculable(libCall("math.floor", [bin("/", ref("a"), num(8))]), {})).not.toBeNull(); + expect(rewriteSpeculable(libCall("math.floor", [bin("/", ref("a"), num(6))]), {})).toBeNull(); + expect(rewriteSpeculable(libCall("math.floor", [bin("/", ref("a"), ref("b"))]), {})).toBeNull(); + // An argument that is itself a call is evaluated once: no expansion. + expect(rewriteSpeculable(call("trim", [call("other", []), ref("m")]), context)).toBeNull(); + const effectful = { + ...(trim as object), + body: [{ kind: "exprStmt", expr: call("log", []), loc }], + }; + expect(inlinableNumberFunction(effectful as never)).toBeUndefined(); + // A divisor next to a power of two is not one, though Math.log2 rounds it to 52. + const near = (d: number): IrExpr => libCall("math.floor", [bin("/", ref("a"), num(d))]); + expect(rewriteSpeculable(near(2 ** 52), {})).not.toBeNull(); + expect(rewriteSpeculable(near(2 ** 52 + 1), {})).toBeNull(); + expect(rewriteSpeculable(near(2 ** 53), {})).toBeNull(); + expect(rewriteSpeculable(near(0.5), {})).toBeNull(); +}); + +test("a helper's own calls must be of helpers that expand too", () => { + // `function outer(v, n) { return trim(v, n) + other(v); }` + const outer = { + ...(trim as object), + name: "outer", + body: [ + { + kind: "return", + value: bin("+", call("trim", [ref("v"), ref("n")]), call("other", [ref("v")])), + loc, + }, + ], + }; + const helpers = (callee: string) => + callee === "trim" ? inlinableNumberFunction(trim) : undefined; + expect(inlinableNumberFunction(outer as never)).toBeUndefined(); + expect(inlinableNumberFunction(outer as never, helpers)).toBeUndefined(); + const pure = { + ...(outer as object), + body: [{ kind: "return", value: bin("*", call("trim", [ref("v"), ref("n")]), num(2)), loc }], + }; + expect(inlinableNumberFunction(pure as never, helpers)).toBeDefined(); +}); + +test("other leaves are hoisted only when no call can change the write's locals", () => { + let next = 0; + const leafId = (): string => `%leaf.${next++}`; + const value = bin( + "%", + bin("+", bin("*", ref("acc"), num(7)), call("noisy", [ref("i")])), + num(1000003), + ); + const hoisted = rewriteSpeculable(value, { unaliased: () => true, leafId })!; + expect(hoisted.leaves.map((leaf) => leaf.expr)).toEqual([call("noisy", [ref("i")])]); + expect(rewriteSpeculable(value, { unaliased: (id) => id !== "acc", leafId })).toBeNull(); + // A call whose argument writes a local stays where it is. + const bump: IrExpr = { kind: "incDec", localId: "i", op: "+", prefix: false, type: F64, loc }; + expect( + rewriteSpeculable(bin("+", ref("i"), call("noisy", [bump])), { unaliased: () => true, leafId }), + ).toBeNull(); + // Without leaf ids nothing is hoisted. + expect(rewriteSpeculable(value, { unaliased: () => true })).toBeNull(); + const write = assign("acc", value); + const plan = planSpeculativeLoop( + loop([write, increment("i")]) as never, + (id) => !id.startsWith("%leaf"), + () => false, + () => false, + (id) => !id.startsWith("%leaf"), + { unaliased: () => true, leafId }, + )!; + expect(plan.values.get(write)!.leaves).toHaveLength(1); + expect([...plan.locals.keys()].some((id) => id.startsWith("%leaf"))).toBe(false); +}); diff --git a/packages/compiler/src/backend/llvm/speculative-integers.ts b/packages/compiler/src/backend/llvm/speculative-integers.ts index 00fabb788..971bd00cb 100644 --- a/packages/compiler/src/backend/llvm/speculative-integers.ts +++ b/packages/compiler/src/backend/llvm/speculative-integers.ts @@ -1,5 +1,6 @@ -import type { IrExpr, IrStmt } from "../../ir/ir.js"; -import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; +import { everyExpr, everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import { InternalCompilerError } from "../../errors.js"; import { f64Lit } from "./common.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { exactInteger, widenInteger } from "./integer-values.js"; @@ -30,6 +31,326 @@ export interface SpeculativeLoopPlan { * than through its condition, `break` or a failed check: no calls, no * throwing operations, no labeled jumps and no `return`. */ observerFree: boolean; + /** The writes that speculate a rewritten value (rewriteSpeculable). */ + values: Map; +} + +/** A write's value as speculation evaluates it: calls of small pure number + * functions expanded in place, and other number leaves (`leaves`) replaced + * by references to fresh locals. The fast copy evaluates each leaf once, + * stores it to its local and checks it as an integer; a failed check + * resumes at a copy of the write that reads the locals, so a leaf is never + * evaluated twice. */ +export interface SpeculativeValue { + value: IrExpr; + leaves: { localId: string; expr: IrExpr; ref: IrExpr }[]; +} + +/** What the planner may assume about the code around a loop. */ +export interface SpeculationContext { + /** The parameters and the returned expression of `callee` when it is a + * small pure number function (inlinableNumberFunction). */ + inlinable?: (callee: string) => InlinableFunction | undefined; + /** A local that no call can write: not captured, boxed or global. */ + unaliased?: (localId: string) => boolean; + /** A fresh local id for a hoisted leaf. */ + leafId?: () => string; +} + +export interface InlinableFunction { + params: readonly string[]; + body: IrExpr; +} + +/** `s` (a speculated write) writing `value` instead. */ +export function withSpeculativeValue(s: IrStmt, value: IrExpr): IrStmt { + switch (s.kind) { + case "assign": + return { ...s, value }; + case "varDecl": + return { ...s, init: value }; + case "exprStmt": + if (s.expr.kind === "assignExpr") return { ...s, expr: { ...s.expr, value } }; + break; + default: + break; + } + throw new InternalCompilerError(`llvm emitter bug: speculated ${s.kind} with a rewritten value`); +} + +/** Calls nest at most this deep in one expansion. */ +const MAX_INLINE_DEPTH = 4; +/** An expansion larger than this stays a call. */ +const MAX_INLINE_NODES = 64; + +/** A function that only returns arithmetic over its number parameters: + * `function trim(v, n) { return v - Math.floor(v / Math.pow(2, n)) * Math.pow(2, n); }`. + * Its call evaluates nothing but its arguments, so a speculated write may + * expand it in place. A call in its body must be of another such function + * (`resolve`): any other call could change what the expansion reads. */ +export function inlinableNumberFunction( + fn: IrFunction, + resolve?: (callee: string) => InlinableFunction | undefined, +): InlinableFunction | undefined { + if ( + fn.async || + fn.generator || + (fn.captures?.length ?? 0) > 0 || + (fn.classCaptures?.length ?? 0) > 0 || + fn.returnType.kind !== "f64" || + fn.params.some((p) => p.type.kind !== "f64" && p.type.kind !== "union") || + fn.body.length !== 1 || + fn.body[0]!.kind !== "return" || + fn.body[0]!.value === undefined + ) + return undefined; + const params = fn.params.map((p) => p.localId); + const known = new Set(params); + let nodes = 0; + // A parameter of a union type (`number | undefined`) is read only + // through its tag test and narrowing, which a wrapped argument folds. + const union = (e: IrExpr): boolean => e.kind === "varRef" && known.has(e.localId); + const pure = (e: IrExpr): boolean => { + if (++nodes > MAX_INLINE_NODES) return false; + if (e.kind === "boolLit") return true; + if (e.kind === "unionIsTag") return union(e.value); + if (e.kind === "unionNarrow") return e.type.kind === "f64" && union(e.value); + if (e.type.kind !== "f64") return false; + switch (e.kind) { + case "numLit": + return true; + case "varRef": + return known.has(e.localId); + case "ternary": + return pure(e.cond) && pure(e.then) && pure(e.else_); + case "bin": + return (INTEGER_OPS.has(e.op) || e.op === "/") && pure(e.left) && pure(e.right); + case "unary": + return e.op === "-" && pure(e.operand); + case "libCall": + return (e.fn === "math.floor" || e.fn === "math.pow") && e.args.every(pure); + case "call": + return resolve?.(e.callee) !== undefined && e.args.every(pure); + default: + return false; + } + }; + const body = fn.body[0]!.value!; + return pure(body) ? { params, body } : undefined; +} + +/** `e` with each parameter reference replaced by its argument. A tag test + * or narrowing of a wrapped argument folds, and so does a ternary over a + * folded test. */ +function substitute(e: IrExpr, args: ReadonlyMap): IrExpr { + switch (e.kind) { + case "varRef": + return args.get(e.localId) ?? e; + case "unionIsTag": { + const value = substitute(e.value, args); + if (value.kind !== "unionWrap" || value.unionId !== e.unionId) return { ...e, value }; + return { + kind: "boolLit", + value: (value.tag === e.tag) !== (e.negated === true), + type: e.type, + loc: e.loc, + }; + } + case "unionNarrow": { + const value = substitute(e.value, args); + return value.kind === "unionWrap" && value.unionId === e.unionId && value.tag === e.tag + ? value.value + : { ...e, value }; + } + case "ternary": { + const cond = substitute(e.cond, args); + if (cond.kind === "boolLit") return substitute(cond.value ? e.then : e.else_, args); + return { ...e, cond, then: substitute(e.then, args), else_: substitute(e.else_, args) }; + } + case "bin": + return { ...e, left: substitute(e.left, args), right: substitute(e.right, args) }; + case "unary": + return { ...e, operand: substitute(e.operand, args) }; + case "libCall": + case "call": + return { ...e, args: e.args.map((a) => substitute(a, args)) }; + default: + return e; + } +} + +/** k when `value` is exactly 2^k with k in [0, 52], else null. + * Math.log2 rounds: 2^52 + 1 gives 52, so the power is compared back. */ +function exactPowerOfTwo(value: number): number | null { + if (!(value >= 1)) return null; + const k = Math.round(Math.log2(value)); + return k >= 0 && k <= 52 && 2 ** k === value ? k : null; +} + +/** The same speculated operand written twice: a local, a literal, or a + * power of two of the same exponent. */ +function sameOperand(a: IrExpr, b: IrExpr): boolean { + if (a.kind === "varRef") return b.kind === "varRef" && a.localId === b.localId; + if (a.kind === "numLit") return b.kind === "numLit" && Object.is(a.value, b.value); + return ( + a.kind === "libCall" && + b.kind === "libCall" && + a.fn === "math.pow" && + b.fn === "math.pow" && + powerOfTwo(a) && + powerOfTwo(b) && + sameOperand(a.args[1]!, b.args[1]!) + ); +} + +/** `a - Math.floor(a / p) * p` (either factor order) with p a power of two + * and `a` a local or literal: the floor-mod of `a` by p. */ +function floorModPowerOfTwo(e: IrExpr): { value: IrExpr; power: IrExpr } | null { + if (e.kind !== "bin" || e.op !== "-" || e.right.kind !== "bin" || e.right.op !== "*") return null; + const { left, right } = e.right; + const [floor, power] = + left.kind === "libCall" && left.fn === "math.floor" ? [left, right] : [right, left]; + if (floor.kind !== "libCall" || floor.fn !== "math.floor" || floor.args.length !== 1) return null; + const quotient = floor.args[0]!; + if (quotient.kind !== "bin" || quotient.op !== "/") return null; + if (!powerOfTwo(power) || !sameOperand(quotient.right, power)) return null; + if (e.left.kind !== "varRef" && e.left.kind !== "numLit") return null; + return sameOperand(quotient.left, e.left) ? { value: e.left, power } : null; +} + +/** Whether `e` is a power of two speculation can shift by: + * `Math.pow(2, n)` or a literal 2^k with k in [0, 52]. */ +function powerOfTwo(e: IrExpr): boolean { + if (e.kind === "numLit") return exactPowerOfTwo(e.value) !== null; + return ( + e.kind === "libCall" && + e.fn === "math.pow" && + e.args.length === 2 && + e.args[0]!.kind === "numLit" && + e.args[0]!.value === 2 + ); +} + +/** Typed-array element kinds whose elements are integers. */ +const INTEGER_ELEMENTS = new Set(["u8", "u8c", "i8", "u16", "i16", "u32", "i32"]); + +/** A typed-array element read of an integer kind with no effect: an + * out-of-range read gives NaN, which fails its check. Float elements would + * only bail. */ +function isSpeculativeByteRead(e: IrExpr): boolean { + return ( + e.kind === "bytesIntrinsic" && + e.method === "get" && + e.type.kind === "f64" && + e.receiver.type.kind === "bytes" && + INTEGER_ELEMENTS.has(e.receiver.type.elem) && + e.args.length === 1 && + isEffectFree(e.receiver) && + isEffectFree(e.args[0]!) + ); +} + +/** Whether evaluating `e` assigns no binding: no `++`/`--`, no assignment + * expression, and no statement inside it. */ +function writesNoLocal(e: IrExpr): boolean { + return everyExpr(e, { + expr: (node) => node.kind !== "incDec" && node.kind !== "assignExpr", + stmt: () => false, + }); +} + +/** Rewrite a write's value that isSpeculableValue refuses into one it can + * speculate (SpeculativeValue), or null. Expansion and the new operations + * (`Math.pow(2, n)`, `Math.floor(a / 2^n)`, byte reads) evaluate nothing + * but numbers, so the fast copy may evaluate them in any order. Hoisted + * leaves run in the write's evaluation order before the arithmetic; that + * reorders nothing only when the write reads no heap and every local it + * reads is one no call can write. */ +export function rewriteSpeculable( + value: IrExpr, + context: SpeculationContext, +): SpeculativeValue | null { + const leaves: SpeculativeValue["leaves"] = []; + let reads = false; + const locals: string[] = []; + const rewrite = (e: IrExpr, depth: number): IrExpr | null => { + if (e.type.kind !== "f64") return null; + switch (e.kind) { + case "numLit": + return Number.isSafeInteger(e.value) && !Object.is(e.value, -0) ? e : null; + case "varRef": + locals.push(e.localId); + return e; + case "bin": { + if (!ARITHMETIC_OPS.has(e.op)) break; + const left = rewrite(e.left, depth); + const right = left && rewrite(e.right, depth); + return left && right ? { ...e, left, right } : null; + } + case "unary": { + if (e.op !== "-") break; + const operand = rewrite(e.operand, depth); + return operand ? { ...e, operand } : null; + } + case "libCall": { + if (powerOfTwo(e) && e.kind === "libCall") { + const exponent = rewrite(e.args[1]!, depth); + return exponent ? { ...e, args: [e.args[0]!, exponent] } : null; + } + const quotient = e.args[0]; + if ( + e.fn === "math.floor" && + e.args.length === 1 && + quotient?.kind === "bin" && + quotient.op === "/" && + powerOfTwo(quotient.right) + ) { + const left = rewrite(quotient.left, depth); + const right = left && rewrite(quotient.right, depth); + return left && right ? { ...e, args: [{ ...quotient, left, right }] } : null; + } + break; + } + case "call": { + const fn = depth < MAX_INLINE_DEPTH ? context.inlinable?.(e.callee) : undefined; + // Arguments are evaluated before the body: an expansion may read + // them in another order, or twice, only when they are pure. + const plain = (a: IrExpr): boolean => + isSpeculableValue(a, false) || + (a.kind === "unionWrap" && isSpeculableValue(a.value, false)); + if (fn !== undefined && fn.params.length === e.args.length && e.args.every(plain)) { + const args = new Map(fn.params.map((id, i) => [id, e.args[i]!])); + return rewrite(substitute(fn.body, args), depth + 1); + } + break; + } + default: + if (isSpeculativeRead(e) || isSpeculativeByteRead(e)) { + reads = true; + return e; + } + break; + } + // Only calls of program functions are hoisted: other leaves (a string's + // length, a Map read) are cheap where they stand, and would turn every + // loop that adds one into a second copy. + if (context.leafId === undefined || e.kind !== "call") return null; + // Hoisting runs the arguments before the rest of the write: one that + // assigns a local would move that write ahead of the write's earlier + // reads of it. + if (!e.args.every(writesNoLocal)) return null; + const ref: IrExpr = { kind: "varRef", localId: context.leafId(), type: e.type, loc: e.loc }; + leaves.push({ localId: (ref as { localId: string }).localId, expr: e, ref }); + return ref; + }; + const rewritten = rewrite(value, 0); + if (rewritten === null) return null; + if (leaves.length > 0) { + const leafIds = new Set(leaves.map((leaf) => leaf.localId)); + if (reads || !locals.every((id) => leafIds.has(id) || context.unaliased?.(id) === true)) + return null; + } + return { value: rewritten, leaves }; } type SpeculativeLoop = Extract; @@ -170,13 +491,16 @@ function isEffectFree(e: IrExpr): boolean { } } -/** The speculative reads of a speculable value in evaluation order. */ +/** The speculative reads of a speculable value in evaluation order, those + * of a rewritten value (rewriteSpeculable) included. */ export function speculativeReads(e: IrExpr, out: IrExpr[] = []): IrExpr[] { - if (e.kind === "bin" && ARITHMETIC_OPS.has(e.op)) { + if (e.kind === "bin" && (ARITHMETIC_OPS.has(e.op) || e.op === "/")) { speculativeReads(e.left, out); speculativeReads(e.right, out); } else if (e.kind === "unary" && e.op === "-") speculativeReads(e.operand, out); - else if (isSpeculativeRead(e)) out.push(e); + else if (e.kind === "libCall" && (e.fn === "math.floor" || e.fn === "math.pow")) + for (const arg of e.args) speculativeReads(arg, out); + else if (isSpeculativeRead(e) || isSpeculativeByteRead(e)) out.push(e); return out; } @@ -195,6 +519,8 @@ function hasProduct(e: IrExpr): boolean { if (e.kind === "bin") return e.op === "*" || e.op === "%" || hasProduct(e.left) || hasProduct(e.right); if (e.kind === "unary") return hasProduct(e.operand); + // Math.floor(a / 2^n) and Math.pow(2, n) replace a division and a call. + if (e.kind === "libCall") return e.fn === "math.floor" || e.fn === "math.pow"; return false; } @@ -202,6 +528,7 @@ function hasArithmetic(e: IrExpr): boolean { if (e.kind === "bin") return ARITHMETIC_OPS.has(e.op) || hasArithmetic(e.left) || hasArithmetic(e.right); if (e.kind === "unary") return e.op === "-" || hasArithmetic(e.operand); + if (e.kind === "libCall") return e.fn === "math.floor" || e.fn === "math.pow"; return false; } @@ -236,8 +563,11 @@ export function planSpeculativeLoop( integral: (localId: string) => boolean = () => false, proven: (e: IrExpr) => boolean = () => false, invariantEligible: (localId: string) => boolean = eligible, + context: SpeculationContext = {}, ): SpeculativeLoopPlan | null { const writes = new Map(); + const values = new Map(); + const leafIds = new Set(); const unspeculable = new Set(); const reads = new Set(); // Locals with at least one read the static analysis does not prove. @@ -263,11 +593,9 @@ export function planSpeculativeLoop( // make the body's work integer. let inCondition = false; - const expr = (e: IrExpr): boolean => { - if (rejected) return false; - nodes++; - if (!OBSERVER_FREE_EXPRESSIONS.has(e.kind) || (e.kind === "bin" && e.left.type.kind !== "f64")) - observerFree = false; + // What the integer writes read and consume. A rewritten value is noted + // here alone: its original, which `expr` walks, has its effects. + const note = (e: IrExpr): void => { if (e.kind === "varRef") { reads.add(e.localId); if (!proven(e)) unproven.add(e.localId); @@ -281,6 +609,20 @@ export function planSpeculativeLoop( } else if (e.kind === "arrayGet" || e.kind === "arrayHas" || e.kind === "arrayState") consume(e.index); else if (e.kind === "bytesIntrinsic" || e.kind === "strIntrinsic") e.args.forEach(consume); + }; + // An expanded call is emitted in the fast copy: it counts toward the + // duplicated code. + const noteAll = (e: IrExpr): boolean => { + nodes++; + note(e); + return everyExprChild(e, noteAll, () => true); + }; + const expr = (e: IrExpr): boolean => { + if (rejected) return false; + nodes++; + if (!OBSERVER_FREE_EXPRESSIONS.has(e.kind) || (e.kind === "bin" && e.left.type.kind !== "f64")) + observerFree = false; + note(e); return everyExprChild(e, expr, nested); }; // Statements inside expressions (sequence expressions) run mid-expression, @@ -307,7 +649,11 @@ export function planSpeculativeLoop( if (s.kind === "arraySet" || s.kind === "bytesSet") consume(s.index); const target = speculativeWriteTarget(s); if (target) { - if (target.value === null || isSpeculableValue(target.value)) { + const rewritten = + target.value === null || isSpeculableValue(target.value) + ? null + : rewriteSpeculable(target.value, context); + if (target.value === null || isSpeculableValue(target.value) || rewritten !== null) { const list = writes.get(target.localId) ?? []; list.push(s); writes.set(target.localId, list); @@ -315,6 +661,11 @@ export function planSpeculativeLoop( reads.add(target.localId); if (s.kind !== "exprStmt" || !proven(s.expr)) unproven.add(target.localId); } else expr(target.value); + if (rewritten !== null) { + values.set(s, rewritten); + for (const leaf of rewritten.leaves) leafIds.add(leaf.localId); + noteAll(rewritten.value); + } return !rejected; } unspeculable.add(target.localId); @@ -341,25 +692,28 @@ export function planSpeculativeLoop( // write runs: speculated itself, already an integer, or loop-invariant // (checked on entry). A value computed from doubles the loop writes // would only fail its check on every entry. + const valueOf = (s: IrStmt): IrExpr | null => + values.get(s)?.value ?? speculativeWriteTarget(s)!.value; const grounded = (e: IrExpr): boolean => { if (e.kind === "varRef") { const id = e.localId; return ( + leafIds.has(id) || proven(e) || locals.has(id) || integral(id) || (!writes.has(id) && !unspeculable.has(id) && invariantEligible(id)) ); } - if (isSpeculativeRead(e)) return true; + if (isSpeculativeRead(e) || isSpeculativeByteRead(e)) return true; return everyExprChild(e, grounded, () => false); }; for (let changed = true; changed;) { changed = false; for (const id of [...locals.keys()]) { for (const s of writes.get(id)!) { - const target = speculativeWriteTarget(s)!; - if (target.value !== null && !grounded(target.value)) { + const value = valueOf(s); + if (value !== null && !grounded(value)) { locals.delete(id); changed = true; break; @@ -376,6 +730,7 @@ export function planSpeculativeLoop( if (e.kind === "varRef") { const id = e.localId; if ( + !leafIds.has(id) && !proven(e) && !locals.has(id) && !writes.has(id) && @@ -388,11 +743,11 @@ export function planSpeculativeLoop( return everyExprChild(e, invariant, () => true); }; for (const s of speculated) { - const target = speculativeWriteTarget(s)!; - if (target.value === null) arithmetic = true; + const value = valueOf(s); + if (value === null) arithmetic = true; else { - if (hasArithmetic(target.value)) arithmetic = true; - invariant(target.value); + if (hasArithmetic(value)) arithmetic = true; + invariant(value); } } if (!arithmetic) return null; @@ -401,13 +756,12 @@ export function planSpeculativeLoop( [...locals.keys()].some((id) => multiplied.has(id)) || [...speculated].some((s) => { const target = speculativeWriteTarget(s)!; - return ( - target.value !== null && - (hasProduct(target.value) || isVariableStep(target.localId, target.value)) - ); + const value = valueOf(s); + return value !== null && (hasProduct(value) || isVariableStep(target.localId, value)); }); if (!heavy) return null; - return { locals, writes: speculated, observerFree }; + for (const s of values.keys()) if (!speculated.has(s)) values.delete(s); + return { locals, writes: speculated, observerFree, values }; } /** A proven integer loop. The bindings integer-ranges.ts bounds over a for @@ -627,6 +981,37 @@ export function emitSpeculativeInteger( } case "bin": { if (e.op !== "+" && e.op !== "-" && e.op !== "*" && e.op !== "%") break; + // The floor-mod of a safe integer by 2^n (n in [0, 52]) is its low n + // bits, also for a negative value: in [0, 2^n), and never -0. + const mod = floorModPowerOfTwo(e); + if (mod) { + const value = emitSpeculativeInteger(host, mod.value, bail, reads, integral); + const n = exponentOf(host, mod.power, bail, reads, integral); + const mask = B.tmp(), + low = B.tmp(); + B.line(`${mask} = sub i64 ${shiftOne(host, n)}, 1`); + B.line(`${low} = and i64 ${value}, ${mask}`); + return low; + } + // A product with 2^n shifts: it is safe iff the other factor's + // magnitude is at most (2^53 - 1) >> n, and a zero product of a safe + // integer and a positive power is +0. + if (e.op === "*" && (powerOfTwo(e.right) || powerOfTwo(e.left))) { + const [factor, power] = powerOfTwo(e.right) ? [e.left, e.right] : [e.right, e.left]; + const value = emitSpeculativeInteger(host, factor, bail, reads, integral); + const n = exponentOf(host, power, bail, reads, integral); + const limit = B.tmp(), + magnitude = B.tmp(), + outside = B.tmp(), + product = B.tmp(); + B.line(`${limit} = ashr i64 ${SAFE}, ${n}`); + host.declare("declare i64 @llvm.abs.i64(i64, i1)"); + B.line(`${magnitude} = call i64 @llvm.abs.i64(i64 ${value}, i1 true)`); + B.line(`${outside} = icmp ugt i64 ${magnitude}, ${limit}`); + bail(outside); + B.line(`${product} = shl nsw i64 ${value}, ${n}`); + return product; + } const a = emitSpeculativeInteger(host, e.left, bail, reads, integral); const b = emitSpeculativeInteger(host, e.right, bail, reads, integral); const result = B.tmp(); @@ -682,6 +1067,19 @@ export function emitSpeculativeInteger( bail(negativeZero); return result; } + case "libCall": { + // A rewritten value's Math.pow(2, n) and Math.floor(a / 2^n) + // (rewriteSpeculable): with n an integer in [0, 52], 2^n is exact and + // so is a / 2^n for a safe integer a, whose floor the arithmetic shift + // computes, also for negative a. No result is -0. + if (e.fn === "math.pow") return shiftOne(host, exponentOf(host, e, bail, reads, integral)); + if (e.fn !== "math.floor" || e.args[0]?.kind !== "bin") break; + const a = emitSpeculativeInteger(host, e.args[0].left, bail, reads, integral); + const n = exponentOf(host, e.args[0].right, bail, reads, integral); + const result = B.tmp(); + B.line(`${result} = ashr i64 ${a}, ${n}`); + return result; + } case "unary": { if (e.op !== "-") break; const a = emitSpeculativeInteger(host, e.operand, bail, reads, integral); @@ -699,6 +1097,35 @@ export function emitSpeculativeInteger( return speculativeLeaf(host, reads.get(e) ?? host.emitExpr(e), e, bail); } +/** The exponent of a power of two (Math.pow(2, n) or a literal 2^k) as an + * i64 in [0, 52]; other exponents bail. */ +function exponentOf( + host: LlvmEmitterContext, + e: IrExpr, + bail: (condition: string) => void, + reads: ReadonlyMap, + integral: boolean, +): string { + if (e.kind === "numLit") { + const k = exactPowerOfTwo(e.value); + if (k === null) throw new InternalCompilerError("llvm emitter bug: speculative power of two"); + return String(k); + } + if (e.kind !== "libCall") + throw new InternalCompilerError("llvm emitter bug: speculative power of two"); + const n = emitSpeculativeInteger(host, e.args[1]!, bail, reads, integral); + const outside = host.B.tmp(); + host.B.line(`${outside} = icmp ugt i64 ${n}, 52`); + bail(outside); + return n; +} + +function shiftOne(host: LlvmEmitterContext, n: string): string { + const result = host.B.tmp(); + host.B.line(`${result} = shl i64 1, ${n}`); + return result; +} + /** The safe i64 of an evaluated number: its proven integer, or a checked * conversion. */ function speculativeLeaf( diff --git a/packages/compiler/src/backend/llvm/split.test.ts b/packages/compiler/src/backend/llvm/split.test.ts index a49a11545..2e0cfa5a5 100644 --- a/packages/compiler/src/backend/llvm/split.test.ts +++ b/packages/compiler/src/backend/llvm/split.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, expect, test } from "vitest"; -import { splitLlvmLibraryProgram, splitLlvmProgram } from "./split.js"; +import { splitLlvmLibraryProgram, splitLlvmProgram, splitLlvmProgramFile } from "./split.js"; import { LlvmDebugInfo } from "./debug-info.js"; import { VOID } from "../../ir/ir.js"; @@ -61,17 +61,17 @@ test("splits generated LLVM functions and promotes only private cross-shard defi expect(actual!.promotedSymbols).not.toContain("public_entry"); expect(actual!.publicSymbols).toEqual(["public_value", "public_entry"]); expect(actual!.shards[0]!.name).toBe("program-globals.ll"); - expect(actual!.shards[0]!.source).toContain("@hidden_value = hidden global i64 7"); + expect(actual!.shards[0]!.source()).toContain("@hidden_value = hidden global i64 7"); expect( actual!.shards .slice(1) - .every((shard) => shard.source.includes("@hidden_value = external hidden global i64")), + .every((shard) => shard.source().includes("@hidden_value = external hidden global i64")), ).toBe(true); expect( actual!.shards.some( (shard) => - !shard.source.includes("define hidden i64 @right() #0") && - shard.source.includes("declare hidden i64 @right() #0"), + !shard.source().includes("define hidden i64 @right() #0") && + shard.source().includes("declare hidden i64 @right() #0"), ), ).toBe(true); }); @@ -82,9 +82,115 @@ test("thread-local definitions and declarations preserve their storage model acr "@hidden_value = internal thread_local global i64 7", ); const split = splitLlvmProgram(tls, { minimumBytes: 0, targetBytes: 64 * 1024 })!; - expect(split.shards[0]!.source).toContain("@hidden_value = hidden thread_local global i64 7"); + expect(split.shards[0]!.source()).toContain("@hidden_value = hidden thread_local global i64 7"); for (const shard of split.shards.slice(1)) { - expect(shard.source).toContain("@hidden_value = external hidden thread_local global i64"); + expect(shard.source()).toContain("@hidden_value = external hidden thread_local global i64"); + } +}); + +const PADDING = Array.from( + { length: 80 }, + (_, i) => + `define internal i64 @pad_${i}() #0 {\nentry:\n ; ${"x".repeat(2048)}\n ret i64 ${i}\n}\n`, +).join("\n"); + +/** SAMPLE with more globals after @public_value and enough functions to + * split into several shards. */ +function withGlobals(globals: string, tail = ""): string { + return `${SAMPLE.replace( + "@public_value = constant i64 11", + `@public_value = constant i64 11\n${globals}`, + ).replace("define i64 @public_entry", `${PADDING}\ndefine i64 @public_entry`)}${tail}`; +} + +/** Writes each shard and compiles it with clang. */ +async function compileShards( + shards: readonly { name: string; source: () => string }[], +): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-llvm-split-shards-")); + scratch.push(dir); + for (const shard of shards) { + const source = join(dir, shard.name); + await writeFile(source, shard.source()); + execFileSync("clang", ["-Wno-override-module", "-c", source, "-o", `${source}.o`]); + } +} + +test("private unnamed_addr lookup tables are promoted like internal globals", async () => { + const split = splitLlvmProgram( + withGlobals('@table = private unnamed_addr constant [2 x i8] c"\\01\\02"'), + { minimumBytes: 0, targetBytes: 64 * 1024 }, + )!; + expect(split).not.toBeNull(); + const [globals, ...functions] = split.shards; + expect(globals!.source()).toContain('@table = hidden unnamed_addr constant [2 x i8] c"\\01\\02"'); + for (const shard of functions) + expect(shard.source()).toContain("@table = external hidden constant [2 x i8]"); + expect(split.promotedSymbols).toContain("table"); + await compileShards(split.shards); +}); + +test("aggregate initializers over several lines stay whole in the globals shard", async () => { + // The regex global's comment holds unbalanced brackets. + const split = splitLlvmProgram( + withGlobals(`@regex = internal global { ptr, i64 } { ptr @left, i64 0 } ; /(a|[b/ +@vt = internal constant [2 x { i64, ptr }] [ + { i64, ptr } { i64 1, ptr @left }, + { i64, ptr } { i64 2, ptr @right } +]`), + { minimumBytes: 0, targetBytes: 64 * 1024 }, + )!; + expect(split).not.toBeNull(); + const [globals, ...functions] = split.shards; + expect(globals!.source()).toContain( + "@vt = hidden constant [2 x { i64, ptr }] [\n { i64, ptr } { i64 1, ptr @left },\n { i64, ptr } { i64 2, ptr @right }\n]", + ); + for (const shard of functions) { + expect(shard.source()).toContain("@vt = external hidden constant [2 x { i64, ptr }]"); + expect(shard.source()).not.toContain("{ i64, ptr } { i64 1"); + } + expect(split.promotedSymbols).toEqual(expect.arrayContaining(["regex", "vt"])); + await compileShards(split.shards); +}); + +test("globals after the attributes and metadata join the globals shard", async () => { + const split = splitLlvmProgram(withGlobals("", "@flag = internal constant i1 true\n"), { + minimumBytes: 0, + targetBytes: 64 * 1024, + })!; + expect(split).not.toBeNull(); + const [globals, ...functions] = split.shards; + expect(globals!.source()).toContain("@flag = hidden constant i1 true"); + for (const shard of functions) + expect(shard.source()).toContain("@flag = external hidden constant i1"); + await compileShards(split.shards); +}); + +test("a module split from its file has the shards of the same module split in memory", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-llvm-split-file-")); + scratch.push(dir); + const options = { minimumBytes: 0, targetBytes: 64 * 1024 }; + const storage = withGlobals( + `@table = private unnamed_addr constant [2 x i8] c"\\01\\02" +@vt = internal constant [2 x { i64, ptr }] [ + { i64, ptr } { i64 1, ptr @left }, + { i64, ptr } { i64 2, ptr @right } +]`, + "@flag = internal constant i1 true\n", + ); + for (const [name, source] of [ + ["sample.ll", withGlobals("")], + ["storage.ll", storage], + ] as const) { + const path = join(dir, name); + await writeFile(path, source); + const fromFile = splitLlvmProgramFile(path, options)!; + expect(fromFile).not.toBeNull(); + const materialize = (split: NonNullable) => ({ + ...split, + shards: split.shards.map((shard) => ({ name: shard.name, source: shard.source() })), + }); + expect(materialize(fromFile)).toEqual(materialize(splitLlvmProgram(source, options)!)); } }); @@ -127,7 +233,7 @@ entry: for (const shard of split.shards) { const path = join(dir, shard.name); const object = `${path}.o`; - await writeFile(path, shard.source); + await writeFile(path, shard.source()); execFileSync("clang", ["-Wno-override-module", "-O2", "-c", path, "-o", object]); objects.push(object); } @@ -169,10 +275,10 @@ test("shared metadata bounds shard growth while preserving every definition", () expect(split).not.toBeNull(); expect(split!.shards.length).toBeGreaterThan(1); expect( - split!.shards.reduce((bytes, shard) => bytes + Buffer.byteLength(shard.source), 0), + split!.shards.reduce((bytes, shard) => bytes + Buffer.byteLength(shard.source()), 0), ).toBeLessThanOrEqual(2 * Buffer.byteLength(source)); const definitions = split!.shards.flatMap((shard) => - [...shard.source.matchAll(/^define (?:hidden )?i64 @(\w+)\(/gm)].map((match) => match[1]), + [...shard.source().matchAll(/^define (?:hidden )?i64 @(\w+)\(/gm)].map((match) => match[1]), ); expect(definitions.sort()).toEqual([...names, "left", "right", "public_entry"].sort()); expect(split!.publicSymbols).toEqual(["public_value", "public_entry"]); @@ -207,9 +313,9 @@ test("debug metadata stays on definitions when splitting LLVM modules", async () const dir = await mkdtemp(join(tmpdir(), "scriptc-debug-split-")); scratch.push(dir); for (const shard of split!.shards) { - expect(shard.source).not.toMatch(/^declare.*!dbg/m); + expect(shard.source()).not.toMatch(/^declare.*!dbg/m); const path = join(dir, shard.name); - await writeFile(path, shard.source); + await writeFile(path, shard.source()); const result = spawnSync("clang", ["-Wno-override-module", "-c", path, "-o", `${path}.o`], { encoding: "utf8", }); @@ -250,7 +356,7 @@ test("every shard compiles and its merged object exposes only canonical public d for (const shard of split.shards) { const source = join(dir, shard.name); const object = `${source}.o`; - await writeFile(source, shard.source); + await writeFile(source, shard.source()); execFileSync("clang", ["-Wno-override-module", "-c", source, "-o", object]); objects.push(object); } diff --git a/packages/compiler/src/backend/llvm/split.ts b/packages/compiler/src/backend/llvm/split.ts index 3fb41ce6b..aecef8df8 100644 --- a/packages/compiler/src/backend/llvm/split.ts +++ b/packages/compiler/src/backend/llvm/split.ts @@ -19,10 +19,15 @@ * canonical TU normally — cache optimization must never become correctness. */ +import { closeSync, openSync, readSync, statSync } from "node:fs"; + export interface LlvmProgramShard { /** Stable source identity used in object cache keys and diagnostics. */ name: string; - source: string; + /** Assembles the shard's LLVM text. A module split from a file reads its + * function bodies back from there on each call, so only the shards being + * written are in memory. */ + source: () => string; } export interface LlvmProgramSplit { @@ -54,12 +59,20 @@ const LIBRARY_TARGET_BYTES = 768 * 1024; const LIBRARY_MINIMUM_BYTES = 2 * 1024 * 1024; interface FunctionDef { - source: string; + /** UTF-8 length of the definition's text. */ + bytes: number; + /** The definition's text: its header (promoted to hidden linkage when + * internal), body and closing brace. A definition left in a file reads + * its body through `read`. */ + text: (read: RangeReader) => string; declaration: string; symbol: string; promoted: boolean; } +/** Reads the text between two byte offsets of the module file. */ +type RangeReader = (start: number, end: number) => string; + interface GlobalDef { line: string; declaration: string | null; @@ -72,50 +85,68 @@ function symbolOf(spelling: string): string | null { return match?.[1] ?? null; } -function functionDefAt( - lines: readonly string[], - start: number, -): { def: FunctionDef; end: number } | null { - const header = lines[start]!; +/** A definition's header line: the line its shard compiles, the + * declaration every other shard carries, and its linkage. */ +function functionHeader( + header: string, +): { line: string; declaration: string; symbol: string; promoted: boolean } | null { const match = /^define\s+(internal\s+)?(.+?)\s+(@[-$._A-Za-z][-$.\w]*)\((.*)\)(.*)\{(?:\s*;.*)?$/.exec( header, ); if (!match) return null; - let end = start + 1; - while (end < lines.length && lines[end] !== "}") end++; - if (end >= lines.length) return null; const symbol = symbolOf(match[3]!); if (symbol === null) return null; const promoted = match[1] !== undefined; const linkage = promoted ? "hidden " : ""; - const definitionHeader = promoted - ? header.replace(/^define internal /, "define hidden ") - : header; - const source = [definitionHeader, ...lines.slice(start + 1, end + 1)].join("\n"); + const line = promoted ? header.replace(/^define internal /, "define hidden ") : header; // Parameter names are legal on declarations. Preserve the emitted text so // attributes, zeroext, and varargs stay byte-exact. // A definition's DISubprogram cannot be attached to a declaration in a // different shard. Its owning definition retains the original metadata. const attrs = match[5]!.replace(/\s+!dbg !\d+/g, ""); const declaration = `declare ${linkage}${match[2]} ${match[3]}(${match[4]})${attrs}`.trimEnd(); - return { def: { source, declaration, symbol, promoted }, end }; + return { line, declaration, symbol, promoted }; +} + +function functionDefAt( + lines: readonly string[], + start: number, +): { def: FunctionDef; end: number } | null { + const header = functionHeader(lines[start]!); + if (header === null) return null; + let end = start + 1; + while (end < lines.length && lines[end] !== "}") end++; + if (end >= lines.length) return null; + const source = [header.line, ...lines.slice(start + 1, end + 1)].join("\n"); + return { + def: { + bytes: Buffer.byteLength(source), + text: () => source, + declaration: header.declaration, + symbol: header.symbol, + promoted: header.promoted, + }, + end, + }; } function globalDef(line: string): GlobalDef | null { const match = - /^(@[-$._A-Za-z][-$.\w]*)\s+=\s+(internal\s+)?(thread_local(?:\([^)]*\))?\s+)?(global|constant)\s+(.+)$/.exec( + /^(@[-$._A-Za-z][-$.\w]*)\s+=\s+((?:internal|private)\s+)?(thread_local(?:\([^)]*\))?\s+)?((?:local_)?unnamed_addr\s+)?(global|constant)\s+([\s\S]+)$/.exec( line, ); if (!match) return null; const symbol = symbolOf(match[1]!); if (symbol === null) return null; + // Internal and private definitions (such as private unnamed_addr lookup + // tables) are local to their module: shards reach them as hidden ones. const promoted = match[2] !== undefined; const threadLocal = match[3] === undefined ? "" : `${match[3].trim()} `; // The declaration needs only the complete type. Emitted initializers begin // after it; the generated LLVM subset uses first-class scalars/pointers or // balanced aggregate types, so one small scanner is sufficient. - const rest = match[5]!; + const rest = match[6]!; let depth = 0; let quote = false; let typeEnd = -1; @@ -133,13 +164,30 @@ function globalDef(line: string): GlobalDef | null { if (typeEnd < 0) return null; const type = rest.slice(0, typeEnd); return { - line: promoted ? line.replace(`${match[1]} = internal `, `${match[1]} = hidden `) : line, - declaration: `${match[1]} = external ${promoted ? "hidden " : ""}${threadLocal}${match[4]} ${type}`, + line: promoted ? line.replace(`${match[1]} = ${match[2]}`, `${match[1]} = hidden `) : line, + declaration: `${match[1]} = external ${promoted ? "hidden " : ""}${threadLocal}${match[5]} ${type}`, symbol, promoted, }; } +/** The change in aggregate nesting depth over one line of LLVM text, + * outside string constants and comments (a regex global's comment spells + * its source). */ +function bracketDepth(line: string): number { + let depth = 0; + let quote = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]!; + if (ch === '"') quote = !quote; + if (quote) continue; + if (ch === ";") break; + if (ch === "{" || ch === "[" || ch === "<" || ch === "(") depth++; + else if (ch === "}" || ch === "]" || ch === ">" || ch === ")") depth--; + } + return depth; +} + function stableBucket(symbol: string, count: number): number { let hash = 0x811c9dc5; for (let i = 0; i < symbol.length; i++) { @@ -154,7 +202,7 @@ function chunksOf( targetBytes: number, maximumBuckets: number, ): { bucket: number; functions: FunctionDef[] }[] { - const totalBytes = functions.reduce((sum, fn) => sum + Buffer.byteLength(fn.source) + 2, 0); + const totalBytes = functions.reduce((sum, fn) => sum + fn.bytes + 2, 0); // A power-of-two bucket count changes only when the program crosses a wide // size band. Ordinary edits therefore keep every function in the same // symbol-hash bucket; ceil(total/target) would reshuffle the whole program @@ -168,6 +216,74 @@ function chunksOf( ); } +/** A module's top-level forms, as the splitter classifies them. */ +interface ModuleParts { + preamble: string[]; + globals: GlobalDef[]; + functions: FunctionDef[]; + trailer: string[]; +} + +/** Classifies a module one line at a time. `define` lines go to + * `onFunction`, which takes the following lines through the definition's + * closing brace; every other line is offered to `line`. */ +class ModuleReader { + readonly parts: ModuleParts = { preamble: [], globals: [], functions: [], trailer: [] }; + private sawDefinition = false; + private inTrailer = false; + /** A global definition whose aggregate initializer continues over the + * following lines (vtable arrays list one element per line). */ + private pendingGlobal: { text: string; depth: number } | null = null; + failed = false; + + /** Whether a definition may start here (none may follow the trailer). */ + startFunction(): boolean { + if (this.inTrailer || this.pendingGlobal !== null) this.failed = true; + this.sawDefinition = true; + return !this.failed; + } + + line(line: string): void { + if (this.pendingGlobal !== null) { + this.pendingGlobal.text += "\n" + line; + this.pendingGlobal.depth += bracketDepth(line); + if (this.pendingGlobal.depth <= 0) { + const { text } = this.pendingGlobal; + this.pendingGlobal = null; + this.global(text); + } + return; + } + if (line.startsWith("@") && line.includes(" = ")) { + const depth = bracketDepth(line); + if (depth > 0) this.pendingGlobal = { text: line, depth }; + else this.global(line); + return; + } + if (line.startsWith("attributes ") || line.startsWith("!")) this.inTrailer = true; + if (this.inTrailer) this.parts.trailer.push(line); + else if (!this.sawDefinition || line.trim() !== "") this.parts.preamble.push(line); + } + + /** Globals may follow the attributes and metadata (the feature flags the + * link-features step appends); they are module storage like any other. */ + private global(definition: string): void { + const parsed = globalDef(definition); + if (parsed === null) { + // External declarations are already valid in every shard. + if (/^@.+\s+=\s+external\s+/.test(definition)) this.parts.preamble.push(definition); + else this.failed = true; + } else { + this.parts.globals.push(parsed); + this.sawDefinition = true; + } + } + + finish(): ModuleParts | null { + return this.failed || this.pendingGlobal !== null ? null : this.parts; + } +} + export function splitLlvmProgram( source: string, options: LlvmProgramSplitOptions = {}, @@ -175,42 +291,129 @@ export function splitLlvmProgram( const minimumBytes = options.minimumBytes ?? DEFAULT_MINIMUM_BYTES; const sourceBytes = Buffer.byteLength(source); if (sourceBytes < minimumBytes) return null; - const targetBytes = Math.max(64 * 1024, options.targetBytes ?? DEFAULT_TARGET_BYTES); const lines = source.split("\n"); - const preamble: string[] = []; - const globals: GlobalDef[] = []; - const functions: FunctionDef[] = []; - const trailer: string[] = []; - let sawDefinition = false; - let inTrailer = false; - for (let i = 0; i < lines.length; i++) { + const reader = new ModuleReader(); + for (let i = 0; i < lines.length && !reader.failed; i++) { const line = lines[i]!; if (line.startsWith("define ")) { - if (inTrailer) return null; + if (!reader.startFunction()) break; const parsed = functionDefAt(lines, i); if (parsed === null) return null; - functions.push(parsed.def); - sawDefinition = true; + reader.parts.functions.push(parsed.def); i = parsed.end; - continue; - } - if (line.startsWith("@") && line.includes(" = ")) { - if (inTrailer) return null; - const parsed = globalDef(line); - if (parsed === null) { - // External declarations are already valid in every shard. - if (/^@.+\s+=\s+external\s+/.test(line)) preamble.push(line); - else return null; - } else { - globals.push(parsed); - sawDefinition = true; - } - continue; - } - if (line.startsWith("attributes ") || line.startsWith("!")) inTrailer = true; - if (inTrailer) trailer.push(line); - else if (!sawDefinition || line.trim() !== "") preamble.push(line); + } else reader.line(line); } + const parts = reader.finish(); + return parts === null ? null : shardModule(parts, sourceBytes, options, null); +} + +/** The read size of splitLlvmProgramFile's scan. */ +const FILE_CHUNK_BYTES = 16 * 1024 * 1024; + +/** splitLlvmProgram over LLVM text already written to `path`, for modules + * too large to hold in memory more than once (or past the longest string the + * engine allows). Function bodies stay in the file: each shard's source + * reads its bodies when it is read, so only the shards being written at a + * time are in memory. */ +export function splitLlvmProgramFile( + path: string, + options: LlvmProgramSplitOptions = {}, +): LlvmProgramSplit | null { + const minimumBytes = options.minimumBytes ?? DEFAULT_MINIMUM_BYTES; + const sourceBytes = statSync(path).size; + if (sourceBytes < minimumBytes) return null; + const reader = new ModuleReader(); + // The definition being read: its header and where its body starts. + let current: { header: NonNullable>; body: number } | null = + null; + const onLine = (bytes: Buffer, start: number, end: number, offset: number): void => { + if (current !== null) { + // Only the closing brace ends a definition; its body is not decoded. + if (end - start === 1 && bytes[start] === 0x7d) { + const { header, body } = current; + const bodyEnd = offset + 1; + reader.parts.functions.push({ + bytes: Buffer.byteLength(header.line) + 1 + (bodyEnd - body), + text: (read) => `${header.line}\n${read(body, bodyEnd)}`, + declaration: header.declaration, + symbol: header.symbol, + promoted: header.promoted, + }); + current = null; + } + return; + } + const line = bytes.toString("utf8", start, end); + if (line.startsWith("define ")) { + const header = functionHeader(line); + if (header === null || !reader.startFunction()) reader.failed = true; + else current = { header, body: offset + (end - start) + 1 }; + } else reader.line(line); + }; + const fd = openSync(path, "r"); + try { + let carry: Buffer = Buffer.alloc(0); + let carryOffset = 0; + for (let position = 0; position < sourceBytes && !reader.failed;) { + const chunk = Buffer.allocUnsafe(Math.min(FILE_CHUNK_BYTES, sourceBytes - position)); + const read = readSync(fd, chunk, 0, chunk.length, position); + if (read === 0) break; + const data = + carry.length === 0 + ? chunk.subarray(0, read) + : Buffer.concat([carry, chunk.subarray(0, read)]); + const dataOffset = carry.length === 0 ? position : carryOffset; + position += read; + let lineStart = 0; + for (let newline = data.indexOf(10); newline >= 0; newline = data.indexOf(10, lineStart)) { + onLine(data, lineStart, newline, dataOffset + lineStart); + lineStart = newline + 1; + } + carry = Buffer.from(data.subarray(lineStart)); + carryOffset = dataOffset + lineStart; + } + // The text after the last newline is a line too (empty when the file + // ends with one), as String.prototype.split answers it. + if (!reader.failed) onLine(carry, 0, carry.length, carryOffset); + } finally { + closeSync(fd); + } + if (current !== null) return null; + const parts = reader.finish(); + return parts === null ? null : shardModule(parts, sourceBytes, options, path); +} + +/** Runs `use` with a reader of `path`'s byte ranges (or of nothing, for a + * module held in memory). */ +function withRangeReader(path: string | null, use: (read: RangeReader) => T): T { + if (path === null) + return use(() => { + throw new Error("an in-memory LLVM module has no file to read"); + }); + const fd = openSync(path, "r"); + try { + return use((start, end) => { + const bytes = Buffer.allocUnsafe(end - start); + for (let done = 0; done < bytes.length;) { + const read = readSync(fd, bytes, done, bytes.length - done, start + done); + if (read === 0) throw new Error(`${path} ended before byte ${end}`); + done += read; + } + return bytes.toString("utf8"); + }); + } finally { + closeSync(fd); + } +} + +function shardModule( + parts: ModuleParts, + sourceBytes: number, + options: LlvmProgramSplitOptions, + path: string | null, +): LlvmProgramSplit | null { + const { preamble, globals, functions, trailer } = parts; + const targetBytes = Math.max(64 * 1024, options.targetBytes ?? DEFAULT_TARGET_BYTES); if (functions.length < 2) return null; const globalDecls = globals.flatMap((global) => global.declaration === null ? [] : [global.declaration], @@ -230,16 +433,22 @@ export function splitLlvmProgram( const chunks = chunksOf(functions, targetBytes, 2 ** Math.floor(Math.log2(bucketLimit))); if (chunks.length < 2) return null; const tail = trailer.length === 0 ? "" : `\n${trailer.join("\n")}`; - const functionShards = chunks.map(({ bucket, functions: chunk }) => { + // A shard's source is assembled when it is read; a module split from a + // file reads its function bodies from there each time. + const functionShards = chunks.map(({ bucket, functions: chunk }): LlvmProgramShard => { const owned = new Set(chunk); - const declarations = functions.filter((fn) => !owned.has(fn)).map((fn) => fn.declaration); return { name: `program-f${bucket.toString().padStart(3, "0")}.ll`, - source: [ - [...preamble, ...globalDecls, ...declarations, ""].join("\n"), - ...chunk.map((fn) => fn.source), - tail, - ].join("\n"), + source: () => { + const declarations = functions.filter((fn) => !owned.has(fn)).map((fn) => fn.declaration); + return withRangeReader(path, (read) => + [ + [...preamble, ...globalDecls, ...declarations, ""].join("\n"), + ...chunk.map((fn) => fn.text(read)), + tail, + ].join("\n"), + ); + }, }; }); // Global initializer changes are common (literal text, generated tables) @@ -247,15 +456,15 @@ export function splitLlvmProgram( // all module storage in one dedicated shard that declares every function. const globalShard: LlvmProgramShard = { name: "program-globals.ll", - source: [ - [...preamble, ...functions.map((fn) => fn.declaration), ""].join("\n"), - ...globals.map((global) => global.line), - tail, - ].join("\n"), + source: () => + [ + [...preamble, ...functions.map((fn) => fn.declaration), ""].join("\n"), + ...globals.map((global) => global.line), + tail, + ].join("\n"), }; - const shards = [globalShard, ...functionShards]; return { - shards, + shards: [globalShard, ...functionShards], promotedSymbols: [ ...globals.filter((global) => global.promoted).map((global) => global.symbol), ...functions.filter((fn) => fn.promoted).map((fn) => fn.symbol), diff --git a/packages/compiler/src/backend/llvm/stack-objects.test.ts b/packages/compiler/src/backend/llvm/stack-objects.test.ts index a74878080..c4f2bedc4 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.test.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.test.ts @@ -286,8 +286,9 @@ function bump(s: State): State { return { ...s, count: s.count + 1 }; } export function tokens(n: number, w: string): number { const t = { kind: "id", text: w, pos: n }; const u = make("num", w, n); - const [q, r] = divmod(n, 7); - return t.pos + width(u) + width({ kind: w, text: w, pos: 0 }) + q + r; + // Read by index: a destructured literal result is inlined instead. + const qr = divmod(n, 7); + return t.pos + width(u) + width({ kind: w, text: w, pos: 0 }) + qr[0] + qr[1]; } export function states(n: number, w: string): number { let s: State = { name: w, count: 0 }; diff --git a/packages/compiler/src/backend/llvm/string-reads.ts b/packages/compiler/src/backend/llvm/string-reads.ts index 441c925f3..9fd2fa78c 100644 --- a/packages/compiler/src/backend/llvm/string-reads.ts +++ b/packages/compiler/src/backend/llvm/string-reads.ts @@ -24,6 +24,10 @@ import { literalCapWord, literalCapWord32 } from "./string-key-hash.js"; export const STRING_UTF16_LEN_DECL = "declare double @scr_str_utf16_len(ptr)"; export const STRING_CHAR_CODE_AT_DECL = "declare double @scr_str_char_code_at(ptr, double)"; +/** Converts a string to UTF-16 units and publishes it as the scan receiver + * (bytes-loop-versions.ts). Like the fallbacks below, it can evict an + * index entry, so it carries no memory restriction. */ +export const STRING_SCAN_UNITS_DECL = "declare void @scr_str_scan_units(ptr)"; /** The runtime fallbacks are cold: both common cases read inline. They * carry no * memory restriction: a fallback can evict an index entry, which resets @@ -75,6 +79,19 @@ export function emitAsciiCharCode(host: LlvmEmitterContext, s: string, index: st return code; } +/** Unit `index` (an i64, below their count) of a string's UTF-16 `units` + * (ScrStrScan): one 16-bit load, as a double. */ +export function emitUnitCharCode(host: LlvmEmitterContext, units: string, index: string): string { + const B = host.B; + const pointer = B.tmp(), + unit = B.tmp(), + code = B.tmp(); + B.line(`${pointer} = getelementptr inbounds i16, ptr ${units}, i64 ${index}`); + B.line(`${unit} = load i16, ptr ${pointer}`); + B.line(`${code} = uitofp i16 ${unit} to double`); + return code; +} + /** The one-character string of unit `index` (an i64) of a proven-ASCII * string `s` whose length covers it: the immortal `@sc_ascii_chars` entry * for its byte, as the inline charAt helpers answer. */ @@ -114,6 +131,15 @@ function loadLength(sz: string): string[] { ]; } +/** Whether emitted reads use the runtime's scan receiver: from ordinary + * global storage, or, in worker executables, from the thread's static TLS + * block (executableTls). */ +export function stringScanInline(host: LlvmEmitterContext): boolean { + return ( + host.executableTls || (host.mod.workers !== true && host.mod.lib?.threadInstances !== true) + ); +} + /** The runtime's ScrStrScan record and the declaration naming it. */ export function stringScanType(sz: string): string { return `{ ptr, ptr, ${sz} }`; diff --git a/packages/compiler/src/backend/llvm/string-slices.test.ts b/packages/compiler/src/backend/llvm/string-slices.test.ts index b2656e560..e53bf38c6 100644 --- a/packages/compiler/src/backend/llvm/string-slices.test.ts +++ b/packages/compiler/src/backend/llvm/string-slices.test.ts @@ -1,8 +1,19 @@ import { expect, test } from "vitest"; -import { BOOL, F64, STRING, VOID, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js"; +import { + BOOL, + F64, + STRING, + VOID, + type IrExpr, + type IrFunction, + type IrStmt, + type IrType, +} from "../../ir/ir.js"; import { analyzeCallLifetimes } from "./call-lifetimes.js"; import { + findLentRecordFields, findLentStringParameters, + findRecordFieldProducers, findScalarStringSlices, findScratchStringSlices, } from "./string-slices.js"; @@ -142,12 +153,12 @@ function lending(body: IrStmt[]): IrFunction { function scratch(fn: IrFunction, extra: IrFunction[] = []): Set { const functions = new Map([fn, ...extra].map((f) => [f.name, f])); const lifetimes = analyzeCallLifetimes(functions); - return findScratchStringSlices( - fn, - lifetimes, - findScalarStringSlices(fn, lifetimes), - findLentStringParameters(functions, lifetimes), - ); + const strings = findLentStringParameters(functions, lifetimes); + return findScratchStringSlices(fn, lifetimes, findScalarStringSlices(fn, lifetimes), { + strings, + records: findLentRecordFields(functions, lifetimes, strings), + producers: findRecordFieldProducers(functions), + }); } test("slices read only by lending consumers use a stack buffer", () => { const inline = slice("source", 0, 3); @@ -242,3 +253,177 @@ test("helper parameters read only by lending consumers accept stack slices", () const all = new Map([fn, ...functions].map((f) => [f.name, f])); expect([...findLentStringParameters(all, analyzeCallLifetimes(all)).keys()]).toEqual(["isGet"]); }); + +const TOKEN: IrType = { kind: "record", shapeId: "token" }; +const record = (id: string): IrExpr => ({ kind: "varRef", localId: id, type: TOKEN, loc }); +const textOf = (obj: IrExpr): IrExpr => ({ + kind: "recordGet", + obj, + shapeId: "token", + field: "text", + type: STRING, + loc, +}); +const lengthOf = (receiver: IrExpr): IrExpr => ({ + kind: "strIntrinsic", + method: "length", + receiver, + args: [], + type: F64, + loc, +}); +const tokenLit = (text: IrExpr): IrExpr => ({ + kind: "recordLit", + fields: [ + { name: "text", value: text }, + { name: "pos", value: { kind: "numLit", value: 1, type: F64, loc } }, + ], + type: TOKEN, + loc, +}); +const keepIn = (value: IrExpr): IrStmt => ({ + kind: "exprStmt", + expr: { kind: "arrayLit", elems: [value], type: { kind: "array", elem: STRING }, loc }, + loc, +}); +/** A function of one record parameter `t`. */ +function recordHelper(name: string, body: IrStmt[]): IrFunction { + return { + name, + params: [{ localId: "t", name: "t", type: TOKEN }], + locals: [{ id: "t", name: "t", type: TOKEN, mutable: false }], + returnType: F64, + loc, + body, + }; +} +/** `make(text)` returns `{ text, pos: 1 }`; `twice` also reads its parameter elsewhere. */ +function producerHelper(name: string, extra: IrStmt[] = []): IrFunction { + return { + name, + params: [{ localId: "text", name: "text", type: STRING }], + locals: [{ id: "text", name: "text", type: STRING, mutable: false }], + returnType: TOKEN, + loc, + body: [...extra, { kind: "return", value: tokenLit(ref("text")), loc }], + }; +} +function containing(init: IrExpr, declare: IrExpr, uses: IrStmt[]): IrFunction { + const fn = lending([ + { kind: "varDecl", localId: "word", init, loc }, + { kind: "varDecl", localId: "tok", init: declare, loc }, + ...uses, + ]); + fn.locals.push({ id: "tok", name: "tok", type: TOKEN, mutable: false }); + return fn; +} +const reads = recordHelper("reads", [ + { kind: "return", value: lengthOf(textOf(record("t"))), loc }, +]); +const keepsText = recordHelper("keepsText", [ + keepIn(textOf(record("t"))), + { kind: "return", value: { kind: "numLit", value: 0, type: F64, loc }, loc }, +]); +const escapes = recordHelper("escapes", [ + { + kind: "exprStmt", + expr: { kind: "arrayLit", elems: [record("t")], type: { kind: "array", elem: TOKEN }, loc }, + loc, + }, + { kind: "return", value: { kind: "numLit", value: 0, type: F64, loc }, loc }, +]); +const passCall = (callee: string, arg: IrExpr): IrStmt => ({ + kind: "exprStmt", + expr: { kind: "call", callee, args: [arg], type: F64, loc }, + loc, +}); + +test("a slice stored in a record that is only projected uses a stack buffer", () => { + const helpers = [reads, keepsText, escapes, producerHelper("make")]; + for (const declare of [tokenLit(ref("word")), callOf("make", ref("word"))]) { + if (declare.kind === "call") declare.type = TOKEN; + const init = slice("source", 1, 5); + const fn = containing(init, declare, [ + { kind: "exprStmt", expr: equals(textOf(record("tok"))), loc }, + passCall("reads", record("tok")), + ]); + expect(scratch(fn, helpers).has(init)).toBe(true); + } + const all = new Map(helpers.map((f) => [f.name, f])); + const lifetimes = analyzeCallLifetimes(all); + const lent = findLentRecordFields(all, lifetimes, findLentStringParameters(all, lifetimes)); + expect(lent.get("reads")?.get(0)).toEqual(new Set()); + expect(lent.get("keepsText")?.get(0)).toEqual(new Set(["text"])); + expect(lent.has("escapes")).toBe(false); + expect(findRecordFieldProducers(all).get("make")).toEqual(new Map([[0, "text"]])); +}); + +test.each([ + "kept-field", + "returned", + "kept-by-callee", + "escapes-callee", + "captured", + "producer-reuse", + "producer-capture", +])("a slice stored in a record that may be kept (%s) stays owned", (reason) => { + // The factory also stores a closure over its text parameter. + const capturing = producerHelper("capturing"); + const literal = (capturing.body[0] as IrStmt & { kind: "return" }).value as IrExpr & { + kind: "recordLit"; + }; + literal.fields.push({ + name: "get", + value: { kind: "closure", fnName: "get", captures: ["text"], type: F64, loc }, + }); + const helpers = [ + reads, + keepsText, + escapes, + producerHelper("make"), + producerHelper("twice", [keepIn(ref("text"))]), + capturing, + ]; + const init = slice("source", 1, 5); + const declare = + reason === "producer-reuse" + ? callOf("twice", ref("word")) + : reason === "producer-capture" + ? callOf("capturing", ref("word")) + : tokenLit(ref("word")); + if (declare.kind === "call") declare.type = TOKEN; + const uses: IrStmt[] = [{ kind: "exprStmt", expr: equals(textOf(record("tok"))), loc }]; + if (reason === "kept-field") uses.push(keepIn(textOf(record("tok")))); + if (reason === "returned") uses.push({ kind: "return", value: record("tok"), loc }); + if (reason === "kept-by-callee") uses.push(passCall("keepsText", record("tok"))); + if (reason === "escapes-callee") uses.push(passCall("escapes", record("tok"))); + const fn = containing(init, declare, uses); + if (reason === "captured") fn.locals.find((l) => l.id === "tok")!.boxed = true; + expect(scratch(fn, helpers).has(init)).toBe(false); +}); + +test("searches of string arrays lend their needle", () => { + const keywords: IrExpr = { + kind: "varRef", + localId: "keywords", + type: { kind: "array", elem: STRING }, + loc, + }; + const classify = helper("classify", [ + { + kind: "return", + value: { + kind: "arrIntrinsic", + method: "includes", + receiver: keywords, + args: [ref("text")], + type: BOOL, + loc, + }, + loc, + }, + ]); + const init = slice("source", 0, 3); + const fn = lending([{ kind: "exprStmt", expr: callOf("classify", init), loc }]); + expect(scratch(fn, [classify]).has(init)).toBe(true); +}); diff --git a/packages/compiler/src/backend/llvm/string-slices.ts b/packages/compiler/src/backend/llvm/string-slices.ts index d00c681d6..c1e1e2593 100644 --- a/packages/compiler/src/backend/llvm/string-slices.ts +++ b/packages/compiler/src/backend/llvm/string-slices.ts @@ -8,6 +8,7 @@ import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse import { F64_INF, f64Lit, sizeToDouble } from "./common.js"; import type { CallLifetimes } from "./call-lifetimes.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; +import { emitStringAsciiFlag, emitStringByteLength } from "./string-reads.js"; type Slice = IrExpr & { kind: "strIntrinsic" }; @@ -66,6 +67,58 @@ export function findScalarStringSlices( return slices; } +/** scr_slice_unit: a slice()/substring() bound `v` over `length` units. A + * non-negative bound clamps to the length; a negative one counts from the + * end for slice (truncated toward zero) and is 0 for substring, as is NaN. */ +function emitSliceUnit( + host: LlvmEmitterContext, + v: string, + length: string, + substring: boolean, +): string { + const B = host.B; + const size = host.sizeType; + const lengthNumber = B.tmp(), + nonNegative = B.tmp(), + past = B.tmp(), + whole = B.tmp(), + clamped = B.tmp(), + result = B.tmp(); + B.line(`${lengthNumber} = ${sizeToDouble(size)} ${size} ${length} to double`); + B.line(`${nonNegative} = fcmp oge double ${v}, 0.0`); + B.line(`${past} = fcmp oge double ${v}, ${lengthNumber}`); + // Frozen: the conversion is only used where the bound is in range. + const raw = B.tmp(); + B.line(`${raw} = fptosi double ${v} to i64`); + B.line(`${whole} = freeze i64 ${raw}`); + let unit = whole; + if (size !== "i64") { + unit = B.tmp(); + B.line(`${unit} = trunc i64 ${whole} to ${size}`); + } + B.line(`${clamped} = select i1 ${past}, ${size} ${length}, ${size} ${unit}`); + if (substring) { + B.line(`${result} = select i1 ${nonNegative}, ${size} ${clamped}, ${size} 0`); + return result; + } + // Negative: (-length, -1] counts back from the end; (-1, 0) truncates to + // 0, and at or below -length, and NaN, give 0. + const negLength = B.tmp(), + inside = B.tmp(), + zero = B.tmp(), + back = B.tmp(), + counted = B.tmp(), + negative = B.tmp(); + B.line(`${negLength} = fneg double ${lengthNumber}`); + B.line(`${inside} = fcmp ogt double ${v}, ${negLength}`); + B.line(`${zero} = icmp eq ${size} ${unit}, 0`); + B.line(`${back} = add ${size} ${length}, ${unit}`); + B.line(`${counted} = select i1 ${zero}, ${size} 0, ${size} ${back}`); + B.line(`${negative} = select i1 ${inside}, ${size} ${counted}, ${size} 0`); + B.line(`${result} = select i1 ${nonNegative}, ${size} ${clamped}, ${size} ${negative}`); + return result; +} + export interface StringSliceSnapshot { source: string; range: string; @@ -87,10 +140,55 @@ export function emitStringSliceSnapshot( `${range} = alloca { ${host.sizeType}, ${host.sizeType}, i32 }`, ); B.line(`store ptr ${source.name}, ptr ${sourceSlot}`); + const start = args[0]?.name ?? f64Lit(0), + end = args[1]?.name ?? F64_INF; + const substring = slice.method === "substring"; + // A proven-ASCII receiver's units are its bytes, so the range is the + // runtime's clamped bounds over its byte length, and no boundary splits a + // surrogate pair (scr_str_slice_range); other strings ask the runtime. + const ascii = B.newLabel("slice.range.ascii"), + runtime = B.newLabel("slice.range.runtime"), + done = B.newLabel("slice.range.done"); + B.condBr(emitStringAsciiFlag(host, source.name), ascii, runtime); + B.startBlock(ascii); + const size = host.sizeType; + const length = emitStringByteLength(host, source.name); + let from = emitSliceUnit(host, start, length, substring), + to = emitSliceUnit(host, end, length, substring); + if (substring) { + const swap = B.tmp(), + low = B.tmp(), + high = B.tmp(); + B.line(`${swap} = icmp ugt ${size} ${from}, ${to}`); + B.line(`${low} = select i1 ${swap}, ${size} ${to}, ${size} ${from}`); + B.line(`${high} = select i1 ${swap}, ${size} ${from}, ${size} ${to}`); + from = low; + to = high; + } + const nonEmpty = B.tmp(), + difference = B.tmp(), + units = B.tmp(), + startPtr = B.tmp(), + lengthPtr = B.tmp(), + splitPtr = B.tmp(); + B.line(`${nonEmpty} = icmp ugt ${size} ${to}, ${from}`); + B.line(`${difference} = sub ${size} ${to}, ${from}`); + B.line(`${units} = select i1 ${nonEmpty}, ${size} ${difference}, ${size} 0`); + const rangeType = `{ ${size}, ${size}, i32 }`; + B.line(`${startPtr} = getelementptr inbounds ${rangeType}, ptr ${range}, i32 0, i32 0`); + B.line(`store ${size} ${from}, ptr ${startPtr}`); + B.line(`${lengthPtr} = getelementptr inbounds ${rangeType}, ptr ${range}, i32 0, i32 1`); + B.line(`store ${size} ${units}, ptr ${lengthPtr}`); + B.line(`${splitPtr} = getelementptr inbounds ${rangeType}, ptr ${range}, i32 0, i32 2`); + B.line(`store i32 0, ptr ${splitPtr}`); + B.br(done); + B.startBlock(runtime); host.declare("declare void @scr_str_slice_range(ptr, double, double, i1 zeroext, ptr)"); B.line( - `call void @scr_str_slice_range(ptr ${source.name}, double ${args[0]?.name ?? f64Lit(0)}, double ${args[1]?.name ?? F64_INF}, i1 ${slice.method === "substring" ? "true" : "false"}, ptr ${range})`, + `call void @scr_str_slice_range(ptr ${source.name}, double ${start}, double ${end}, i1 ${substring ? "true" : "false"}, ptr ${range})`, ); + B.br(done); + B.startBlock(done); host.toFrame(source); return { source: sourceSlot, range }; } @@ -145,6 +243,10 @@ const LENDING_LIB_FNS: ReadonlySet = new Set([ "num.parseFloat", ]); +/** Searches of string arrays compare the needle with the elements during + * the call. */ +const LENDING_ARRAY_SEARCHES: ReadonlySet = new Set(["includes", "indexOf", "lastIndexOf"]); + /** Typed string-keyed collection operations: reads probe with the key, and * an insertion stores scr_str_scratch_keep's copy (scr_map_append). */ const LENDING_KEY_METHODS: ReadonlySet = new Set(["get", "has", "set", "add", "delete"]); @@ -176,6 +278,12 @@ function lentOperands(value: IrExpr, parameters: LentStringParameters): readonly return LENDING_KEY_METHODS.has(value.method) && stringKeyed(value) ? value.args.slice(0, 1) : []; + case "arrIntrinsic": + return LENDING_ARRAY_SEARCHES.has(value.method) && + value.receiver.type.kind === "array" && + value.receiver.type.elem.kind === "string" + ? value.args.slice(0, 1) + : []; case "call": { const lent = parameters.get(value.callee); return lent ? value.args.filter((_, index) => lent.has(index)) : []; @@ -185,41 +293,123 @@ function lentOperands(value: IrExpr, parameters: LentStringParameters): readonly } } +/** Record parameters, by function and index, that the body only projects: + * it reads their fields and passes them only to such parameters. The set + * lists the string fields some read keeps (reads outside a lending + * position); every other string field is lent (findLentRecordFields). */ +export type LentRecordFields = ReadonlyMap>>; + +/** Functions whose body only returns a record literal that stores some + * parameters, each read once, directly as fields: by function, parameter + * index to field (findRecordFieldProducers). A call stores those arguments + * in the fields of its result and keeps them nowhere else. */ +export type RecordFieldProducers = ReadonlyMap>; + +/** What lends a string: lent string parameters, lent record fields and + * record field producers. */ +export interface LendingFacts { + strings: LentStringParameters; + records: LentRecordFields; + producers: RecordFieldProducers; +} + +const NO_LENDING_FACTS: LendingFacts = { + strings: new Map(), + records: new Map(), + producers: new Map(), +}; + interface LendingScan { /** Slice operands of lending consumers, excluding any node also reached * in an owning position. */ scratch: Set; /** Candidates read anywhere other than a lending position. */ invalid: Set; - /** Direct callees, for the parameter fixpoint. */ + /** Record candidates read other than by a field read or a lent record + * argument. */ + invalidRecords: Set; + /** String fields of record candidates read outside a lending position. */ + keptFields: Map>; + /** Direct callees, for the parameter fixpoints. */ callees: Set; } /** One pass over `fn`: which candidate bindings only lending consumers read * (a slice initializer of a candidate local is not itself a read), and * which slice operands those consumers lend. A switch discriminant is only - * compared with the case tests. */ + * compared with the case tests. Record candidates (`records`) may only be + * projected: their field reads are checked as reads of the field, and they + * may be passed to lent record parameters. A record `container` + * declaration stores candidates in fields of a record candidate; those + * stores are not reads. */ function scanLending( fn: IrFunction, candidates: ReadonlySet, initializers: ReadonlyMap, - parameters: LentStringParameters, + facts: LendingFacts, + records: ReadonlySet = new Set(), + containers: ReadonlyMap = new Map(), ): LendingScan { - const scan: LendingScan = { scratch: new Set(), invalid: new Set(), callees: new Set() }; + const scan: LendingScan = { + scratch: new Set(), + invalid: new Set(), + invalidRecords: new Set(), + keptFields: new Map(), + callees: new Set(), + }; const owned = new Set(); + const keep = (record: string, field: string): void => { + let fields = scan.keptFields.get(record); + if (!fields) scan.keptFields.set(record, (fields = new Set())); + fields.add(field); + }; + const projected = (value: IrExpr): value is IrExpr & { kind: "recordGet" } => + value.kind === "recordGet" && value.obj.kind === "varRef" && records.has(value.obj.localId); function lent(value: IrExpr): boolean { if (value.kind === "varRef" && candidates.has(value.localId)) return true; + if (projected(value)) return true; if (!isSlice(value)) return expr(value); scan.scratch.add(value); return everyExprChild(value, expr, stmt); } + /** A call's arguments: lent string parameters lend, lent record + * parameters project their record, and `stored` arguments (a producer's + * fields in a container declaration) are not reads. */ + function callArgs(value: IrExpr & { kind: "call" }, stored: ReadonlySet | null): void { + scan.callees.add(value.callee); + const strings = facts.strings.get(value.callee); + const recordParams = facts.records.get(value.callee); + value.args.forEach((arg, index) => { + if (stored !== null && stored.has(index)) return; + const kept = recordParams?.get(index); + if (kept && arg.kind === "varRef" && records.has(arg.localId)) { + for (const field of kept) keep(arg.localId, field); + return; + } + if (strings?.has(index)) lent(arg); + else expr(arg); + }); + } function expr(value: IrExpr): boolean { if (isSlice(value)) owned.add(value); + if (projected(value)) { + if (value.type.kind === "string") + keep((value.obj as { localId: string }).localId, value.field); + return true; + } if (value.kind === "varRef" && candidates.has(value.localId)) scan.invalid.add(value.localId); + if (value.kind === "varRef" && records.has(value.localId)) + scan.invalidRecords.add(value.localId); if (value.kind === "closure" || value.kind === "classRef") - for (const id of value.captures ?? []) scan.invalid.add(id); - if (value.kind === "call") scan.callees.add(value.callee); - const operands = lentOperands(value, parameters); + for (const id of value.captures ?? []) { + scan.invalid.add(id); + scan.invalidRecords.add(id); + } + if (value.kind === "call") { + callArgs(value, null); + return true; + } + const operands = lentOperands(value, facts.strings); if (operands.length === 0) return everyExprChild(value, expr, stmt); return everyExprChild( value, @@ -231,6 +421,20 @@ function scanLending( // A candidate's single declaration initializes it with its slice. if (value.kind === "varDecl" && value.init !== null && initializers.has(value.localId)) return everyExprChild(value.init, expr, stmt); + const container = value.kind === "varDecl" ? containers.get(value.localId) : undefined; + if (container && value.kind === "varDecl" && value.init !== null) { + const init = value.init; + if (init.kind === "recordLit") + for (const field of init.fields) { + if (!container.stored.has(field.value)) expr(field.value); + } + else if (init.kind === "call") + callArgs( + init, + new Set(init.args.flatMap((arg, i) => (container.stored.has(arg) ? [i] : []))), + ); + return true; + } if (value.kind === "switch" && value.disc.type.kind === "string") { lent(value.disc); for (const entry of value.cases) { @@ -276,7 +480,7 @@ export function findLentStringParameters( const fn = functions.get(name); if (!indexes || !fn) continue; const ids = new Set([...indexes].map((index) => fn.params[index]!.localId)); - const scan = scanLending(fn, ids, new Map(), lent); + const scan = scanLending(fn, ids, new Map(), { ...NO_LENDING_FACTS, strings: lent }); for (const callee of scan.callees) { let set = callers.get(callee); if (!set) callers.set(callee, (set = new Set())); @@ -296,6 +500,151 @@ export function findLentStringParameters( return lent; } +/** Functions whose whole body is `return { ... }`, a record literal storing + * some parameters, each read nowhere else, directly as fields (a factory + * such as `token(kind, text, pos)`). A synchronous body only: the result + * then holds those arguments, and nothing else does. */ +export function findRecordFieldProducers( + functions: ReadonlyMap, +): Map> { + const producers = new Map>(); + for (const fn of functions.values()) { + if (fn.async || fn.generator || fn.body.length !== 1) continue; + const only = fn.body[0]!; + if (only.kind !== "return" || only.value?.kind !== "recordLit") continue; + const literal = only.value; + const reads = new Map(); + everyStmtList(fn.body, { + expr: (value) => { + if (value.kind === "varRef") reads.set(value.localId, (reads.get(value.localId) ?? 0) + 1); + // A closure or class that captures a parameter keeps it too. + if (value.kind === "closure" || value.kind === "classRef") + for (const id of value.captures ?? []) reads.set(id, (reads.get(id) ?? 0) + 1); + return true; + }, + stmt: () => true, + }); + const fields = new Map(); + fn.params.forEach((param, index) => { + if (param.type.kind !== "string" || reads.get(param.localId) !== 1) return; + const field = literal.fields.find( + (entry) => + !entry.overflow && entry.value.kind === "varRef" && entry.value.localId === param.localId, + ); + if (field) fields.set(index, field.name); + }); + if (fields.size > 0) producers.set(fn.name, fields); + } + return producers; +} + +/** Record parameters that every body only projects (LentRecordFields), with + * the string fields some read keeps. Start from every borrowed record + * parameter of a synchronous function, keeping no field, and grow the kept + * fields and drop escaping parameters until nothing changes; a record passed + * to another lent record parameter keeps what that parameter keeps. */ +export function findLentRecordFields( + functions: ReadonlyMap, + lifetimes: CallLifetimes, + strings: LentStringParameters, +): Map>> { + const lent = new Map>>(); + for (const fn of functions.values()) { + if (fn.async || fn.generator) continue; + const stable = lifetimes.borrowed.get(fn.name); + const indexes = new Map>(); + fn.params.forEach((param, index) => { + if (param.type.kind === "record" && stable?.has(index)) indexes.set(index, new Set()); + }); + if (indexes.size > 0) lent.set(fn.name, indexes); + } + const callers = new Map>(); + const pending = new Set(lent.keys()); + while (pending.size > 0) { + const name = pending.values().next().value as string; + pending.delete(name); + const indexes = lent.get(name); + const fn = functions.get(name); + if (!indexes || !fn) continue; + const ids = new Set([...indexes.keys()].map((index) => fn.params[index]!.localId)); + const scan = scanLending( + fn, + new Set(), + new Map(), + { strings, records: lent, producers: new Map() }, + ids, + ); + for (const callee of scan.callees) { + let set = callers.get(callee); + if (!set) callers.set(callee, (set = new Set())); + set.add(name); + } + let changed = false; + for (const [index, kept] of [...indexes]) { + const id = fn.params[index]!.localId; + if (scan.invalidRecords.has(id)) { + indexes.delete(index); + changed = true; + continue; + } + const fields = scan.keptFields.get(id); + if (fields) + for (const field of fields) { + if (kept.has(field)) continue; + kept.add(field); + changed = true; + } + } + if (!changed) continue; + if (indexes.size === 0) lent.delete(name); + const affected = callers.get(name); + if (affected) for (const caller of affected) if (lent.has(caller)) pending.add(caller); + } + return lent; +} + +/** A record local whose declaration stores slice locals directly in some of + * its fields: a record literal, or a call to a record field producer. */ +interface RecordContainer { + /** The stored candidate reads of the declaration, by field. */ + stored: Map; +} + +/** Record locals declared with slice locals in their fields. */ +function findRecordContainers( + fn: IrFunction, + bindings: ReadonlySet | undefined, + slices: ReadonlySet, + producers: RecordFieldProducers, +): Map { + const containers = new Map(); + everyStmtList(fn.body, { + expr: () => true, + stmt: (stmt) => { + if (stmt.kind !== "varDecl" || stmt.init === null || !bindings?.has(stmt.localId)) + return true; + if (stmt.init.type.kind !== "record") return true; + const stored = new Map(); + const init = stmt.init; + if (init.kind === "recordLit") { + for (const field of init.fields) + if (!field.overflow && field.value.kind === "varRef" && slices.has(field.value.localId)) + stored.set(field.value, { field: field.name, slice: field.value.localId }); + } else if (init.kind === "call") { + const fields = producers.get(init.callee); + init.args.forEach((arg, index) => { + const field = fields?.get(index); + if (field !== undefined && arg.kind === "varRef" && slices.has(arg.localId)) + stored.set(arg, { field, slice: arg.localId }); + }); + } + if (stored.size > 0) containers.set(stmt.localId, { stored }); + return true; + }, + }); + return containers; +} + /** Slices whose every reader borrows them are written into a stack buffer * instead of a heap allocation (scr_str_slice_scratch): a slice operand of * a lending consumer, and the initializer of a stable local read only by @@ -304,12 +653,18 @@ export function findLentStringParameters( * non-ASCII results keep the ordinary owned string. Each site owns its * buffer: a site runs again only after its previous value is dead, since * nothing can keep it. Coroutine frames do not keep stack buffers across a - * suspension, so they are left alone. */ + * suspension, so they are left alone. + * + * A slice local may also be stored in a field of a stable record local + * declared with it (a record literal, or a record field producer's result): + * the record is then only projected, and every read of that field lends. + * The record is declared after the slice in its scope and is never kept, so + * it is dead before the slice's site runs again. */ export function findScratchStringSlices( fn: IrFunction, lifetimes: CallLifetimes, scalar: ReadonlyMap, - parameters: LentStringParameters = new Map(), + facts: LendingFacts = NO_LENDING_FACTS, ): Set { if (fn.async || fn.generator) return new Set(); const bindings = lifetimes.bindings.get(fn.name); @@ -328,7 +683,19 @@ export function findScratchStringSlices( return true; }, }); - const scan = scanLending(fn, new Set(locals.keys()), locals, parameters); + const containers = findRecordContainers(fn, bindings, new Set(locals.keys()), facts.producers); + const scan = scanLending( + fn, + new Set(locals.keys()), + locals, + facts, + new Set(containers.keys()), + containers, + ); + for (const [record, container] of containers) + for (const { field, slice } of container.stored.values()) + if (scan.invalidRecords.has(record) || scan.keptFields.get(record)?.has(field)) + scan.invalid.add(slice); for (const [id, init] of locals) if (!scan.invalid.has(id)) scan.scratch.add(init); return scan.scratch; } diff --git a/packages/compiler/src/backend/llvm/string-unit-versions.test.ts b/packages/compiler/src/backend/llvm/string-unit-versions.test.ts new file mode 100644 index 000000000..e21803948 --- /dev/null +++ b/packages/compiler/src/backend/llvm/string-unit-versions.test.ts @@ -0,0 +1,126 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../../index.js"; +import type { IrModule, IrStmt } from "../../ir/ir.js"; +import { unitSafeFunctions, unitSafeLoop } from "./string-unit-versions.js"; + +async function lower(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-unit-versions-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const loopOf = (mod: IrModule, name: string): IrStmt & { kind: "for" } => + mod.functions.find((f) => f.name === name)!.body.find((s) => s.kind === "for") as IrStmt & { + kind: "for"; + }; + +test("loops over a string's units call only functions that read no string", async () => { + const mod = await lower(` +function lower(c: number): boolean { + return c >= 97 && c <= 122; +} +function word(c: number): boolean { + return lower(c) || (c >= 48 && c <= 57) || Math.abs(c - 200) < 50; +} +function codeOf(t: string, i: number): number { + return t.charCodeAt(i); +} +function viaCodeOf(t: string): number { + return codeOf(t, 0); +} +function words(s: string): number { + let n = 0; + const starts = [0]; + for (let i = 0; i < s.length; i++) { + if (word(s.charCodeAt(i))) n++; + else starts.push(i); + } + return n + starts.length; +} +function pairs(s: string, t: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) n += s.charCodeAt(i) + codeOf(t, i); + return n; +} +function both(s: string, t: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) n += s.charCodeAt(i) + t.charCodeAt(i); + return n; +} +function concatenates(s: string): string { + let out = ""; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 32) out += "-"; + return out; +} +console.log(words("ab c"), pairs("ab", "cd"), both("ab", "cd"), concatenates("a b"), viaCodeOf("x")); +`); + const safe = unitSafeFunctions(mod); + expect(safe.has("lower")).toBe(true); + expect(safe.has("word")).toBe(true); + // A string read, directly or through a call, makes a function unsafe. + expect(safe.has("codeOf")).toBe(false); + expect(safe.has("viaCodeOf")).toBe(false); + + const param = (fn: string, index = 0): string => + mod.functions.find((f) => f.name === fn)!.params[index]!.localId; + // A unit-safe loop answers its receiver's length reads: here the + // condition's `s.length`. + const lengths = unitSafeLoop(loopOf(mod, "words"), param("words"), safe); + expect(lengths?.size).toBe(1); + expect([...lengths!].every((e) => e.kind === "strIntrinsic" && e.method === "length")).toBe(true); + expect(unitSafeLoop(loopOf(mod, "pairs"), param("pairs"), safe)).toBeNull(); + expect(unitSafeLoop(loopOf(mod, "both"), param("both"), safe)).toBeNull(); + expect(unitSafeLoop(loopOf(mod, "concatenates"), param("concatenates"), safe)).toBeNull(); +}); + +test("loops that sort with a comparator or copy an untyped source get no units copy", async () => { + // Either can run program code that indexes other strings, which evicts + // the receiver's units while the loop reads them. + const mod = await lower(` +function sorts(s: string, values: Float64Array, compare: (a: number, b: number) => number): number { + let n = 0; + for (let i = 0; i < s.length; i++) { + n += s.charCodeAt(i); + if (i === 3) values.sort(compare); + } + return n; +} +function copies(s: string, values: Float64Array, source: any): number { + let n = 0; + for (let i = 0; i < s.length; i++) { + n += s.charCodeAt(i); + if (i === 3) values.set(source); + } + return n; +} +function sortsNumbers(s: string, values: Float64Array): number { + let n = 0; + for (let i = 0; i < s.length; i++) { + n += s.charCodeAt(i); + if (i === 3) values.sort(); + } + return n; +} +const values = new Float64Array([2, 1]); +console.log(sorts("abcdef", values, (a, b) => a - b), copies("abcdef", values, [4, 3]), sortsNumbers("abcdef", values)); +`); + const safe = unitSafeFunctions(mod); + const param = (fn: string): string => + mod.functions.find((f) => f.name === fn)!.params[0]!.localId; + expect(unitSafeLoop(loopOf(mod, "sorts"), param("sorts"), safe)).toBeNull(); + expect(unitSafeLoop(loopOf(mod, "copies"), param("copies"), safe)).toBeNull(); + // A numeric sort calls no program code. + expect(unitSafeLoop(loopOf(mod, "sortsNumbers"), param("sortsNumbers"), safe)).not.toBeNull(); +}); diff --git a/packages/compiler/src/backend/llvm/string-unit-versions.ts b/packages/compiler/src/backend/llvm/string-unit-versions.ts new file mode 100644 index 000000000..368b8eae8 --- /dev/null +++ b/packages/compiler/src/backend/llvm/string-unit-versions.ts @@ -0,0 +1,208 @@ +import type { IrBytesIntrinsicMethod, IrExpr, IrModule, IrStmt } from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; + +/* Loop versions over the UTF-16 units of a string that is not ASCII. + * + * The runtime publishes the receiver of an indexed scan with its UTF-16 + * units (ScrStrScan, string-reads.ts), and drops them when the string is + * released or appended to in place, or when its index entry is evicted: + * the runtime indexes another non-ASCII string. A loop version's string + * receiver is a binding the loop does not write, which keeps it alive and + * unappended for the whole loop. Its fast copy over units therefore only + * needs a body that indexes no other string: the operations below, and + * calls of functions made of the same operations without string reads + * (`unitSafeFunctions`). */ + +const ARRAY_METHODS = new Set(["push", "pop", "length"]); + +/* The typed-array and Buffer methods a units copy may run: those that + * call no program code and read no checked-dynamic value or string. A + * comparator sort (sortCompare) calls back into the program, and a copy + * from a checked-dynamic source (setFromDyn) reads its elements through + * getters; either can index another string, which evicts the receiver's + * units while the loop reads them. */ +const UNIT_SAFE_BYTES_METHODS: ReadonlySet = new Set([ + "buffer", + "length", + "byteLength", + "byteOffset", + "get", + "slice", + "subarray", + "copyWithin", + "sortNumeric", + "toReversed", + "with", + "toArray", + "setFrom", + "readNum", + "writeNum", + "readNumVar", + "writeNumVar", + "equals", + "compareBuf", + "indexOfNum", + "lastIndexOfNum", + "includesNum", + "indexOfElem", + "lastIndexOfElem", + "includesElem", + "fill", + "fillNum", + "fillElem", + "copy", + "swap16", + "swap32", + "swap64", + "dataViewNew", + "dvGetUint8", + "dvGetInt8", + "dvGetUint16", + "dvGetInt16", + "dvGetUint32", + "dvGetInt32", + "dvGetFloat16", + "dvGetFloat32", + "dvGetFloat64", + "dvGetBigUint64Number", + "dvGetBigInt64Number", + "dvSetUint8", + "dvSetInt8", + "dvSetUint16", + "dvSetInt16", + "dvSetUint32", + "dvSetInt32", + "dvSetFloat16", + "dvSetFloat32", + "dvSetFloat64", +]); + +/** Whether `e` (or statement `s`) uses only operations that index no + * string: numbers, booleans, locals, records and class fields, typed + * arrays, number arrays and Math. `reads` admits the string reads a + * version covers; `calls` the callees. */ +function unitSafe( + node: { e: IrExpr } | { s: IrStmt }, + reads: (e: IrExpr & { kind: "strIntrinsic" }) => boolean, + calls: (callee: string) => boolean, +): boolean { + const expr = (e: IrExpr): boolean => { + switch (e.kind) { + case "varRef": + case "numLit": + case "boolLit": + case "bin": + case "unary": + case "logical": + case "ternary": + case "toBool": + case "seqExpr": + case "assignExpr": + case "incDec": + case "recordGet": + case "fieldGet": + break; + case "bytesIntrinsic": + if (!UNIT_SAFE_BYTES_METHODS.has(e.method)) return false; + break; + case "arrayGet": + if (e.type.kind !== "f64" && e.type.kind !== "bool") return false; + break; + case "arrIntrinsic": { + // Growing or shrinking an array of numbers allocates; it reads no + // string. + const array = e.receiver.type; + if ( + !ARRAY_METHODS.has(e.method) || + array.kind !== "array" || + (array.elem.kind !== "f64" && array.elem.kind !== "bool") + ) + return false; + break; + } + case "libCall": + if (!e.fn.startsWith("math.")) return false; + break; + case "call": + if (!calls(e.callee)) return false; + break; + case "strIntrinsic": + if (!reads(e)) return false; + break; + default: + return false; + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + switch (s.kind) { + case "varDecl": + case "assign": + case "exprStmt": + case "if": + case "block": + case "while": + case "doWhile": + case "for": + case "break": + case "continue": + case "return": + case "recordSet": + case "fieldSet": + case "bytesSet": + break; + default: + return false; + } + return everyStmtChild(s, expr, stmt); + }; + return "e" in node ? expr(node.e) : stmt(node.s); +} + +/** The module's functions a units version may call: synchronous + * functions without captures whose bodies use only unit-safe operations, + * read no string, and call only such functions. */ +export function unitSafeFunctions(mod: IrModule): ReadonlySet { + const candidates = new Map( + mod.functions + .filter((fn) => !fn.async && !fn.generator && !(fn.captures?.length ?? 0)) + .map((fn) => [fn.name, fn]), + ); + const safe = new Set(candidates.keys()); + for (let settled = false; !settled;) { + settled = true; + for (const name of safe) { + const fn = candidates.get(name)!; + const ok = fn.body.every((s) => + unitSafe( + { s }, + () => false, + (callee) => safe.has(callee), + ), + ); + if (!ok) { + safe.delete(name); + settled = false; + } + } + } + return safe; +} + +/** The `length` reads of the binding `receiver` in a loop whose every + * part is unit-safe, reading only charCodeAt and length of `receiver` and + * calling only `safe` functions; null for any other loop. */ +export function unitSafeLoop( + loop: IrStmt & { kind: "for" | "while" }, + receiver: string, + safe: ReadonlySet, +): ReadonlySet | null { + const lengths = new Set(); + const reads = (e: IrExpr & { kind: "strIntrinsic" }): boolean => { + if (e.receiver.kind !== "varRef" || e.receiver.localId !== receiver) return false; + if (e.method === "length") lengths.add(e); + return e.method === "charCodeAt" || e.method === "length"; + }; + const calls = (callee: string): boolean => safe.has(callee); + return unitSafe({ s: loop }, reads, calls) ? lengths : null; +} diff --git a/packages/compiler/src/backend/llvm/test-boxed-unions.ts b/packages/compiler/src/backend/llvm/test-boxed-unions.ts index 15cf5aee9..8847d53a3 100644 --- a/packages/compiler/src/backend/llvm/test-boxed-unions.ts +++ b/packages/compiler/src/backend/llvm/test-boxed-unions.ts @@ -1,14 +1,29 @@ -import type { IrModule, IrType } from "../../ir/ir.js"; +import { F64, type IrModule, type IrType, type IrUnionDef } from "../../ir/ir.js"; /** Test helper: a Set of each union keeps it a tagged `%ScrUnion` box (Set * elements are runtime-inspected boxes), so stack-box tests exercise the - * paths that nullable-pointer unions (nullable-unions.ts) bypass. */ + * paths that nullable-pointer unions (nullable-unions.ts) bypass. A union of + * records alone is a Set element by record identity, so for those the Set + * holds a companion union with a number arm, which keeps the records out of + * every shape union. */ export function keepUnionsBoxed(mod: IrModule): IrModule { - const keep = (mod.unions ?? []).map((union) => ({ - id: `%g.keep.${union.id}`, - name: `keep_${union.id}`, - type: { kind: "set", elem: { kind: "union", unionId: union.id } } as IrType, - mutable: false, - })); - return { ...mod, globals: [...(mod.globals ?? []), ...keep] }; + const companions: IrUnionDef[] = []; + const keep = (mod.unions ?? []).map((union) => { + let elem = union.id; + if (union.arms.every((arm) => arm.kind === "record")) { + elem = `%keep.${union.id}`; + companions.push({ id: elem, arms: [...union.arms, F64] }); + } + return { + id: `%g.keep.${union.id}`, + name: `keep_${union.id}`, + type: { kind: "set", elem: { kind: "union", unionId: elem } } as IrType, + mutable: false, + }; + }); + return { + ...mod, + unions: [...(mod.unions ?? []), ...companions], + globals: [...(mod.globals ?? []), ...keep], + }; } diff --git a/packages/compiler/src/backend/llvm/unwind-abi.test.ts b/packages/compiler/src/backend/llvm/unwind-abi.test.ts new file mode 100644 index 000000000..5f7f5895b --- /dev/null +++ b/packages/compiler/src/backend/llvm/unwind-abi.test.ts @@ -0,0 +1,285 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../../index.js"; +import { validateModule } from "../../ir/validate.js"; +import { emitLlvmModule } from "./emitter.js"; +import { UnwindAbiViolation, bridgePlainUnwindCalls } from "./unwind-abi.js"; + +async function moduleFor(source: string) { + const dir = await mkdtemp(join(tmpdir(), "scriptc-unwind-abi-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + const module = deserializeModule(await readFile(output, "utf8")); + expect(validateModule(module)).toEqual([]); + return module; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The definition of the source function `name` (its i32 or borrowing entry when it has one). */ +function body(ir: string, name: string): string { + return new RegExp( + `^define internal [^\\n]*@sc_b?f_[^\\n(]*${name}(?:\\.i32)?\\([^]*?^}`, + "m", + ).exec(ir)![0]; +} + +const SOURCE = ` +function leaf(n: number): number { + if (n === 3) throw new RangeError("three"); + return n * 2; +} +function first(n: number): number { + return leaf(n) + 1; +} +function second(n: number): number { + return first(n) * 3; +} +function third(n: number): number { + return second(n) - 2; +} +function mid(n: number): number { + const parts: string[] = ["a" + n]; + const r = third(n); + parts.push("b"); + return r + parts.length; +} +class Counter { + total = 0; + add(n: number): void { this.total += mid(n); } +} +function outer(n: number): string { + try { return "ok " + mid(n); } catch (e) { return "caught " + (e as Error).message; } +} +const counter = new Counter(); +for (let i = 0; i < 5; i++) { console.log(outer(i)); try { counter.add(i); } catch { console.log("add", i); } } +const f = (x: number) => mid(x); +console.log(f(1), counter.total); +`; + +const LINUX = { targetTriple: "x86_64-unknown-linux-gnu", unwindAbi: true } as const; + +test("directly called functions raise, and their callers invoke them without a pending check", async () => { + const ir = emitLlvmModule(await moduleFor(SOURCE), LINUX); + // leaf throws: the exception starts on the pending flag, and the three + // calls above it test the flag too; mid raises it on. + for (const name of ["leaf", "first", "second", "third"]) + expect(body(ir, name)).not.toContain("@scr_unwind_raise"); + const mid = body(ir, "mid"); + expect(mid).toMatch(/= call double @sc_b?f_[^(]*third\(/); + expect(mid).toContain("call void @scr_unwind_raise()"); + // outer invokes mid; the landing pad takes the exception to its handler. + const outer = body(ir, "outer"); + expect(outer).toMatch( + /= invoke double @sc_b?f_[^(]*mid[^\n]* to label %inv\.ok\d+ unwind label %inv\.lp\d+/, + ); + expect(outer).toMatch(/inv\.lp\d+:\n\s+%t\d+ = landingpad \{ ptr, i32 \} cleanup/); + expect(outer).toContain("personality ptr @__gcc_personality_v0"); + expect(ir).toContain("declare i32 @__gcc_personality_v0(...)"); + expect(ir).toContain("declare void @scr_unwind_raise() cold"); +}); + +test("a method of a class outside any hierarchy is on the unwinding ABI; closures keep the flag", async () => { + const ir = emitLlvmModule(await moduleFor(SOURCE), LINUX); + // Counter.add invokes mid and resumes the unwind from its landing pad; the + // closure over mid is a pointer target and returns with the flag. + const add = body(ir, "Counter_add"); + expect(add).toMatch(/invoke double @sc_b?f_[^(]*mid/); + expect(add).toMatch(/resume \{ ptr, i32 \} %[\w.]+/); + expect(add).not.toContain("@scr_unwind_raise"); + const closures = ir + .split(/\n(?=define )/) + .filter((def) => /^define internal [^\n]*@sc_b?f_[^\n(]*fn_/.test(def)); + expect(closures.length).toBeGreaterThan(0); + for (const def of closures) expect(def).not.toContain("call void @scr_unwind_raise()"); +}); + +test("recursive functions keep the pending flag, also when they recurse through a callback", async () => { + const source = ` +function leaf(n: number): number { + if (n < 0) throw new RangeError("negative"); + return n; +} +function one(n: number): number { return leaf(n) + 1; } +function two(n: number): number { return one(n) + 1; } +function three(n: number): number { return two(n) + 1; } +function four(n: number): number { + const parts: string[] = ["a" + n]; + const r = three(n); + return r + parts.length; +} +function walk(n: number, depth: number): number { + const parts: string[] = ["w" + n]; + const r = depth === 0 ? four(n) : walk(n, depth - 1); + return r + parts.length; +} +function visit(n: number, depth: number, each: (n: number, depth: number) => number): number { + const parts: string[] = ["v" + n]; + const r = depth === 0 ? four(n) : each(n, depth - 1); + return r + parts.length; +} +const each = (n: number, depth: number): number => visit(n, depth, each); +for (let i = -1; i < 2; i++) { + try { console.log(walk(i, 3), visit(i, 3, each)); } catch (e) { console.log((e as Error).message); } +} +`; + const ir = emitLlvmModule(await moduleFor(source), LINUX); + expect(body(ir, "four")).toContain("call void @scr_unwind_raise()"); + for (const name of ["walk", "visit"]) { + expect(body(ir, name)).not.toContain("@scr_unwind_raise"); + expect(body(ir, name)).toMatch(/invoke double @sc_b?f_[^(]*four/); + } +}); + +test("without the option, and off Linux, emission keeps the pending-flag ABI", async () => { + const mod = await moduleFor(SOURCE); + for (const options of [{}, { targetTriple: "arm64-apple-macosx14.0.0", unwindAbi: true }]) { + const ir = emitLlvmModule(mod, options); + expect(ir).not.toContain("invoke "); + expect(ir).not.toContain("@scr_unwind_raise"); + expect(ir).not.toContain("personality"); + } +}); + +test("bridgePlainUnwindCalls keeps the pending-flag contract at plain calls of raising functions", () => { + const raising = [ + "define internal ptr @sc_bf_raises(ptr %p) #0 { ; raises", + "entry:", + " call void @scr_unwind_raise()", + " unreachable", + "}", + ].join("\n"); + const caller = [ + "define internal ptr @sc_f_adapter(ptr %p0) #0 {", + "entry:", + " %result = call ptr @sc_bf_raises(ptr %p0) ; borrowed result", + " br label %next", + "next:", + " %v = phi ptr [ %result, %entry ]", + " ret ptr %v", + "}", + ].join("\n"); + const [out] = bridgePlainUnwindCalls([`${raising}\n${caller}`]); + expect(out).toContain( + "%result.ub = invoke ptr @sc_bf_raises(ptr %p0) to label %ub.n.0 unwind label %ub.lp.0", + ); + expect(out).toContain("%result = phi ptr [ %result.ub, %ub.n.0 ], [ null, %ub.lp.0 ]"); + // The split block's successor now comes from its last part. + expect(out).toContain("%v = phi ptr [ %result, %ub.ok.0 ]"); + expect(out).toMatch( + /define internal ptr @sc_f_adapter\(ptr %p0\) #0 personality ptr @__gcc_personality_v0 \{/, + ); + expect(out).toContain("declare i32 @__gcc_personality_v0(...)"); +}); + +test("an invoked wrapper of a raising function resumes the unwind; its address must not escape", () => { + const raising = [ + "define internal double @sc_bf_body.i32(i32 %a) #0 {", + "entry:", + " call void @scr_unwind_raise()", + " unreachable", + "}", + ].join("\n"); + const wrapper = [ + "define internal double @sc_bf_body(double %a) #0 {", + "entry:", + " %i = fptosi double %a to i32", + " %r = call double @sc_bf_body.i32(i32 %i)", + " ret double %r", + "}", + ].join("\n"); + const caller = [ + "define internal double @sc_bf_caller(double %a) #0 personality ptr @__gcc_personality_v0 {", + "entry:", + " %r = invoke double @sc_bf_body(double %a) to label %ok unwind label %lp", + "lp:", + " %x = landingpad { ptr, i32 } cleanup", + " call void @scr_unwind_raise()", + " unreachable", + "ok:", + " ret double %r", + "}", + ].join("\n"); + const [out] = bridgePlainUnwindCalls([[raising, wrapper, caller].join("\n")]); + expect(out).toContain( + "%r = invoke double @sc_bf_body.i32(i32 %i) to label %ub.ok.0 unwind label %ub.lp.0", + ); + expect(out).toContain("resume { ptr, i32 } %ub.v.0"); + expect(() => + bridgePlainUnwindCalls([ + `${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`, + ]), + ).toThrow(UnwindAbiViolation); + try { + bridgePlainUnwindCalls([ + `${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`, + ]); + } catch (error) { + expect([...(error as UnwindAbiViolation).symbols]).toEqual(["sc_bf_body.i32"]); + } +}); + +test("an invoked function that never tests the pending flag needs no raise", () => { + const raising = [ + "define internal void @sc_bf_raises() #0 {", + "entry:", + " call void @scr_unwind_raise()", + " unreachable", + "}", + ].join("\n"); + const leaf = [ + "define internal double @sc_bf_leaf() #0 {", + "entry:", + " ret double 1.0", + "}", + ].join("\n"); + const caller = (callee: string) => + [ + "define internal double @sc_bf_caller() #0 personality ptr @__gcc_personality_v0 {", + "entry:", + ` %r = invoke double @${callee}() to label %ok unwind label %lp`, + "lp:", + " %x = landingpad { ptr, i32 } cleanup", + " call void @scr_unwind_raise()", + " unreachable", + "ok:", + " ret double %r", + "}", + ].join("\n"); + expect(() => + bridgePlainUnwindCalls([[raising, leaf, caller("sc_bf_leaf")].join("\n")]), + ).not.toThrow(); + const testing = leaf.replace( + " ret double 1.0", + " %f = load i32, ptr @scr_exc_flag\n ret double 1.0", + ); + expect(() => + bridgePlainUnwindCalls([[raising, testing, caller("sc_bf_leaf")].join("\n")]), + ).toThrow(UnwindAbiViolation); +}); + +test("calls that cannot unwind are marked nounwind", () => { + const raising = [ + "define internal void @sc_bf_raises(ptr %b) #0 {", + "entry:", + " %n = call i64 @scr_bytes_length(ptr %b) ; length", + " call void @scr_bytes_release(ptr %b), !dbg !4", + " %r = call ptr %b(ptr %b)", + " call void @scr_unwind_raise()", + " unreachable", + "}", + ].join("\n"); + const [out] = bridgePlainUnwindCalls([raising]); + expect(out).toContain("%n = call i64 @scr_bytes_length(ptr %b) nounwind ; length"); + expect(out).toContain("call void @scr_bytes_release(ptr %b) nounwind, !dbg !4"); + expect(out).toContain("%r = call ptr %b(ptr %b) nounwind"); + expect(out).toContain(" call void @scr_unwind_raise()\n"); +}); diff --git a/packages/compiler/src/backend/llvm/unwind-abi.ts b/packages/compiler/src/backend/llvm/unwind-abi.ts new file mode 100644 index 000000000..4dc611913 --- /dev/null +++ b/packages/compiler/src/backend/llvm/unwind-abi.ts @@ -0,0 +1,491 @@ +/* The functions that propagate exceptions by unwinding instead of the + * pending flag (see unwindAbiFunctions). */ +import type { IrExpr, IrModule } from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; + +/** How many calls above a `throw` functions keep the pending flag. */ +const THROW_LEVELS = 3; +/** The call graph's node for calls through values. */ +const ANY_FUNCTION = " any function"; + +/** The nodes of `edges` on a cycle (a recursion), by Tarjan's algorithm + * without recursion of its own: a large module's call chains are deeper + * than the host stack. */ +function recursiveFunctions(edges: ReadonlyMap): Set { + const index = new Map(); + const low = new Map(); + const stack: string[] = []; + const onStack = new Set(); + const result = new Set(); + let counter = 0; + const enter = (name: string): { name: string; next: number } => { + index.set(name, counter); + low.set(name, counter++); + stack.push(name); + onStack.add(name); + return { name, next: 0 }; + }; + for (const root of edges.keys()) { + if (index.has(root)) continue; + const work = [enter(root)]; + while (work.length > 0) { + const top = work[work.length - 1]!; + const out = edges.get(top.name) ?? []; + if (top.next < out.length) { + const callee = out[top.next++]!; + if (!index.has(callee)) work.push(enter(callee)); + else if (onStack.has(callee)) + low.set(top.name, Math.min(low.get(top.name)!, index.get(callee)!)); + continue; + } + work.pop(); + const parent = work[work.length - 1]; + if (parent !== undefined) + low.set(parent.name, Math.min(low.get(parent.name)!, low.get(top.name)!)); + if (low.get(top.name) !== index.get(top.name)) continue; + const members: string[] = []; + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + members.push(member); + } while (member !== top.name); + if (members.length > 1 || out.includes(top.name)) for (const m of members) result.add(m); + } + } + return result; +} + +/** The may-throw functions whose exceptions reach their callers by + * unwinding the native stack (scr_unwind_raise) instead of returning with + * the pending flag set. Their callers call them with `invoke`, so the call + * needs no flag test; the landing pad runs the frame's unwind. + * + * A function qualifies only when every way it can be reached is a direct + * `call` from emitted code that can lower it as an `invoke`: it is not + * async or a generator, not a constructor, not a method a virtual call can + * reach or one a vtable holds (an overridden method), not a closure + * target, not the entry or a library export, not named by any node other + * than a `call`'s callee, and not called from an async or generator body + * (coroutine bodies keep plain calls). + * + * A native unwind costs about 2 µs a raise and 0.1 µs a frame, against a + * few nanoseconds for a flag test, so the functions an exception is likely to + * cross many of keep the flag: those with a `throw` or rethrow of their own + * and those up to THROW_LEVELS calls above one (a handler near the throw + * takes it without a native unwind), and the recursive ones (deep stacks + * are recursions). */ +export function unwindAbiFunctions( + mod: IrModule, + mayThrow: ReadonlySet, + excluded: ReadonlySet = new Set(), +): Set { + const names = new Set(mod.functions.map((fn) => fn.name)); + const blocked = new Set(excluded); + blocked.add(mod.entry); + // The runtime calls the error property readers through the pointers the + // module installs (emit-module.ts). + blocked.add("%error.name.read"); + blocked.add("%error.message.read"); + for (const entry of mod.lib?.exports ?? []) blocked.add(entry.fnName); + // Methods a virtual call can reach, and the overridden methods of class + // hierarchies (whose vtables list them), are called through pointers; the + // others are called directly, like functions. A virtual call reaches its listed + // targets, or else the method in its receiver class's hierarchy; a + // receiver that is no class of the module (an interface) may reach any + // class's method of that name. + const classes = new Map((mod.classes ?? []).map((cls) => [cls.name, cls])); + const anyClass = new Set(); + for (const fn of mod.functions) + everyStmtList(fn.body, { + expr: (e: IrExpr) => { + if (e.kind !== "virtualCall") return true; + if (e.targets !== undefined) for (const target of e.targets) blocked.add(target); + else if (classes.has(e.className)) blocked.add(`%${e.className}.${e.method}`); + else anyClass.add(e.method); + return true; + }, + stmt: () => true, + }); + // A hierarchy's vtables hold the methods declared by more than one of its + // classes (an override and what it overrides). + const rootOf = (name: string): string => { + let cls = classes.get(name); + while (cls?.base !== undefined && classes.has(cls.base)) cls = classes.get(cls.base); + return cls?.name ?? name; + }; + const declarers = new Map(); + for (const cls of mod.classes ?? []) + for (const m of cls.methods ?? []) { + const key = `${rootOf(cls.name)}\0${m}`; + declarers.set(key, (declarers.get(key) ?? 0) + 1); + } + for (const cls of mod.classes ?? []) { + blocked.add(`%${cls.name}.constructor`); + blocked.add(`%${cls.name}.construct`); + const root = rootOf(cls.name); + for (const m of cls.methods ?? []) + if (anyClass.has(m) || declarers.get(`${root}\0${m}`)! > 1) blocked.add(`%${cls.name}.${m}`); + } + // Any function a node names other than as a call's callee may be reached + // through a pointer. + const referenced = new Set(); + const noteNames = (node: object, skip: string | undefined): void => { + for (const key in node) { + if (key === skip) continue; + const value = (node as Record)[key]; + if (typeof value === "string" && names.has(value)) referenced.add(value); + } + }; + // The call graph, for the functions near a throw and the recursive ones. + // Calls through values (closures, class values, dynamic calls, callbacks + // passed to the runtime) reach ANY_FUNCTION, which reaches every function + // a value can hold. + const throws = new Set(); + const callers = new Map(); + const callees = new Map(); + const edge = (from: string, to: string): void => { + let list = callers.get(to); + if (list === undefined) callers.set(to, (list = [])); + list.push(from); + let out = callees.get(from); + if (out === undefined) callees.set(from, (out = [])); + out.push(to); + }; + const methodsNamed = new Map(); + for (const cls of mod.classes ?? []) + for (const m of cls.methods ?? []) { + let list = methodsNamed.get(m); + if (list === undefined) methodsNamed.set(m, (list = [])); + list.push(`%${cls.name}.${m}`); + } + for (const fn of mod.functions) { + const coroutine = fn.async === true || fn.generator !== undefined; + everyStmtList(fn.body, { + expr: (e: IrExpr) => { + const kind = e.kind as string; + if (kind === "throw" || kind === "rethrow") throws.add(fn.name); + if (e.kind === "call") { + edge(fn.name, e.callee); + // The entry's callees (the module initializers) run once: on the + // flag they keep landing pads out of the top-level code inlined + // into the entry. + if (coroutine || fn.name === mod.entry) blocked.add(e.callee); + noteNames(e, "callee"); + return true; + } + noteNames(e, undefined); + if (e.kind === "virtualCall") + for (const target of e.targets ?? methodsNamed.get(e.method) ?? []) edge(fn.name, target); + else if (e.kind === "new") { + edge(fn.name, `%${e.className}.constructor`); + edge(fn.name, `%${e.className}.construct`); + } else if (kind === "callValue" || kind === "dynCall" || kind === "newValue") + edge(fn.name, ANY_FUNCTION); + else if ("args" in e) { + const args: unknown = e.args; + if (Array.isArray(args)) + for (const arg of args as IrExpr[]) + if (arg.type.kind === "func") edge(fn.name, ANY_FUNCTION); + } + return true; + }, + stmt: (s) => { + const kind = s.kind as string; + if (kind === "throw" || kind === "rethrow") throws.add(fn.name); + noteNames(s, undefined); + return true; + }, + }); + } + for (const name of referenced) { + blocked.add(name); + edge(ANY_FUNCTION, name); + } + // A throw starts on the pending flag, and so do the functions up to + // THROW_LEVELS calls above it. The union narrowing helpers' throws (a + // failed narrowing) don't count for their callers. + for (const name of throws) blocked.add(name); + let level = [...throws].filter((name) => !name.startsWith("%union.")); + const near = new Set(level); + for (let i = 0; i < THROW_LEVELS; i++) { + const next: string[] = []; + for (const name of level) + for (const caller of callers.get(name) ?? []) + if (!near.has(caller)) { + near.add(caller); + next.push(caller); + } + level = next; + } + for (const name of near) blocked.add(name); + for (const name of recursiveFunctions(callees)) blocked.add(name); + const result = new Set(); + for (const fn of mod.functions) { + if (!mayThrow.has(fn.name) || blocked.has(fn.name)) continue; + if (fn.async === true || fn.generator !== undefined) continue; + result.add(fn.name); + } + return result; +} + +/** What an unwinding-ABI function's unwind ends with when no handler of + * its own takes the exception. */ +export const UNWIND_RAISE = "call void @scr_unwind_raise()\n unreachable"; +/** What the unwind of a landing pad ends with when no handler of the + * function takes the exception: the unwind goes on (`resume`) with the + * exception the landing pad stored in UNWIND_SLOT. The block builder + * expands it (BlockBuilder.terminate). */ +export const UNWIND_RESUME = "resume { ptr, i32 } "; +/** The function's slot for the exception its landing pads take. */ +export const UNWIND_SLOT = "%unw.exc"; +/** scr_unwind_raise's declaration. It is not `noreturn`: sanitized builds + * would call the sanitizer's no-return hook before it, which fails on fiber + * stacks; scr_unwind_raise clears the unwound frames' shadow itself. */ +export const UNWIND_RAISE_DECLARATION = "declare void @scr_unwind_raise() cold"; + +/** The personality of functions with landing pads: the landing pads are + * cleanups that scr_unwind_raise's forced unwind runs. */ +export const UNWIND_PERSONALITY = "personality ptr @__gcc_personality_v0"; + +/** An unwind leaving the function: a raise, or a landing pad's `resume`. */ +const UNWINDS = /call void @scr_unwind_raise\(\)|\n\s+resume /; +/** A pending-flag test: the thread's exception word, the active cell, or the runtime query. */ +const PENDING_TEST = /@scr_exc_flag\b|%exc\.cell\b|@scr_exc_active\b|@scr_exc_pending\(/; + +/** Functions emission found breaking the unwinding ABI's rules + * (bridgePlainUnwindCalls), by symbol: the caller emits the module again + * with them on the pending-flag ABI. */ +export class UnwindAbiViolation extends Error { + readonly symbols: ReadonlySet; + constructor(symbols: ReadonlySet) { + super(`unwinding ABI: ${[...symbols].slice(0, 3).join(", ")} must keep the pending flag`); + this.symbols = symbols; + } +} +const DEFINE_NAME = /^define [^@\n]*@([\w.$%-]+)\(/; +const PLAIN_CALL = /^(\s*)(%[\w.$-]+) = call (.*?)@([\w.$%-]+)\(|^(\s*)call (.*?)@([\w.$%-]+)\(/; +const RETURN_ATTRIBUTES = + /\b(?:zeroext|signext|noalias|nonnull|noundef|inreg|dereferenceable(?:_or_null)?\(\d+\)|align \d+)\s+/g; + +/** A call instruction's line. */ +const CALL_LINE = /^\s*(?:%[\w.$-]+ = )?(?:(?:tail|musttail|notail) )?call /; +/** A call's callee (a function or a register) and the parenthesis that + * opens its arguments. */ +const CALLEE = /[@%][\w.$%-]+\(/; + +/** The index of the parenthesis that closes the argument list opened at `open`. */ +function argumentsEnd(line: string, open: number): number { + let depth = 0; + let close = open; + for (; close < line.length; close++) { + const c = line.charAt(close); + if (c === "(") depth++; + else if (c === ")" && --depth === 0) break; + } + return close; +} + +/** A call that cannot unwind, marked so: only scr_unwind_raise and the + * raising functions propagate an unwind (runtime code and every function + * reached through a pointer return normally), so every other call is + * `nounwind`, and the inliner keeps it a call where it inlines a raising + * function at an invoke. */ +function markNounwind(line: string): string { + const call = line.indexOf("call "); + const callee = CALLEE.exec(line.slice(call)); + if (callee === null || callee[0] === "@scr_unwind_raise(") return line; + const close = argumentsEnd(line, call + callee.index + callee[0].length - 1); + return close >= line.length + ? line + : `${line.slice(0, close + 1)} nounwind${line.slice(close + 1)}`; +} + +/** The value a bridged call yields on its landing pad: the dummy a + * pending-flag callee returns with an exception pending. */ +function dummyValue(type: string): string { + if (type === "ptr") return "null"; + if (type === "double" || type === "float") return "0.0"; + if (/^i\d+$/.test(type)) return "0"; + return "zeroinitializer"; +} + +/** Rewrites every remaining plain call of an unwinding-ABI function (one + * that raises, scr_unwind_raise, or resumes an unwind) in `parts`, the module's text, into an + * invoke whose landing pad continues after the call with the exception + * pending and the call's dummy result: the pending-flag contract those + * call sites were emitted for, whichever lowering emitted them. Calls of + * these functions that emitDirectCall lowered are invokes already. */ +export function bridgePlainUnwindCalls(parts: string[]): string[] { + const raisers = new Set(); + const split = parts.map((part) => part.split(/\n(?=define )/)); + for (const chunks of split) + for (const chunk of chunks) { + const m = DEFINE_NAME.exec(chunk); + if (m !== null && UNWINDS.test(chunk)) raisers.add(m[1]!); + } + if (raisers.size === 0) return parts; + // An invoke's callee must raise or never test the pending flag: one that + // returned with the flag pending would pass the exception by its invoking + // caller unseen. A wrapper an invoke reaches (an int32 or borrowed-ABI + // entry forwarding to the body) propagates its callee's unwind instead + // (`resume`), so it raises too. A raising function must be reached only by + // calls: a pointer to it (a vtable or table entry, a runtime callback) + // could be called without a landing pad. Functions that break either rule + // go back to the pending-flag ABI (UnwindAbiViolation). + const violations = new Set(); + const invoked = new Set(); + const testsFlag = new Set(); + for (const chunks of split) + for (const chunk of chunks) { + const m = DEFINE_NAME.exec(chunk); + if (m !== null && PENDING_TEST.test(chunk)) testsFlag.add(m[1]!); + for (const c of chunk.matchAll(/ invoke [^@\n]*@([\w.$%-]+)\(/g)) invoked.add(c[1]!); + } + const resumers = new Set(); + for (let grew = true; grew;) { + grew = false; + for (const chunks of split) + for (const chunk of chunks) { + const m = DEFINE_NAME.exec(chunk); + const name = m?.[1]; + if (name === undefined || raisers.has(name) || !invoked.has(name)) continue; + if (![...chunk.matchAll(/ call [^@\n]*@([\w.$%-]+)\(/g)].some((c) => raisers.has(c[1]!))) + continue; + if (testsFlag.has(name)) continue; + raisers.add(name); + resumers.add(name); + grew = true; + } + } + for (const name of invoked) if (!raisers.has(name) && testsFlag.has(name)) violations.add(name); + for (const chunks of split) + for (const chunk of chunks) + for (const line of chunk.split("\n")) { + if (line.startsWith("define ")) continue; + for (const m of line.matchAll(/@([\w.$%-]+)/g)) { + if (!raisers.has(m[1]!)) continue; + const at = m.index!; + if ( + /(?:call|invoke) [^@]*$/.test(line.slice(0, at)) && + line.charAt(at + m[0].length) === "(" + ) + continue; + violations.add(m[1]!); + } + } + if (violations.size > 0) throw new UnwindAbiViolation(violations); + let personality = false; + let counter = 0; + const out = split.map((chunks) => + chunks + .map((chunk) => { + if (!DEFINE_NAME.test(chunk) || !chunk.includes("call ")) return chunk; + const lines = chunk.split("\n"); + const resume = resumers.has(DEFINE_NAME.exec(chunk)![1]!); + const result: string[] = []; + // The label a block's terminator now follows, for phi operands. + const moved = new Map(); + let block = ""; + let changed = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const label = /^([\w.$-]+):/.exec(line); + if (label !== null) block = label[1]!; + const m = PLAIN_CALL.exec(line); + const callee = m === null ? undefined : (m[4] ?? m[7]); + if (m === null || callee === undefined || !raisers.has(callee)) { + const marked = CALL_LINE.test(line) ? markNounwind(line) : line; + changed ||= marked !== line; + result.push(marked); + continue; + } + // The call's text from its callee to the end of the argument list. + const close = argumentsEnd(line, line.indexOf(`@${callee}(`) + callee.length + 1); + const head = line.slice(line.indexOf("call ") + 5, close + 1); + const tail = line.slice(close + 1); + const meta = /,\s*!/.exec(tail); + const attrs = ( + meta === null ? tail.replace(/;.*$/, "") : tail.slice(0, meta.index) + ).trimEnd(); + const dbg = meta === null ? "" : tail.slice(meta.index).replace(/\s;.*$/, ""); + const n = counter++; + if (block === "") { + result.splice(1, 0, "entry.ub:"); + block = "entry.ub"; + } + const indent = m[1] ?? m[5] ?? " "; + const ok = `ub.ok.${n}`; + const pad = `ub.lp.${n}`; + if (resume) { + // The wrapper holds nothing to release: the unwind goes on to + // its caller's landing pad. + const assign = m[2] !== undefined ? `${m[2]} = ` : ""; + result.push( + `${indent}${assign}invoke ${head}${attrs} to label %${ok} unwind label %${pad}${dbg}`, + ); + result.push( + `${pad}:`, + `${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`, + `${indent}resume { ptr, i32 } %ub.v.${n}`, + ); + result.push(`${ok}:`); + } else if (m[2] !== undefined) { + const type = (m[3] ?? "").replace(RETURN_ATTRIBUTES, "").trim(); + const normal = `ub.n.${n}`; + result.push( + `${indent}${m[2]}.ub = invoke ${head}${attrs} to label %${normal} unwind label %${pad}${dbg}`, + ); + result.push(`${normal}:`, `${indent}br label %${ok}`); + result.push( + `${pad}:`, + `${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`, + `${indent}br label %${ok}`, + ); + result.push( + `${ok}:`, + `${indent}${m[2]} = phi ${type} [ ${m[2]}.ub, %${normal} ], [ ${dummyValue(type)}, %${pad} ]`, + ); + } else { + result.push( + `${indent}invoke ${head}${attrs} to label %${ok} unwind label %${pad}${dbg}`, + ); + result.push( + `${pad}:`, + `${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`, + `${indent}br label %${ok}`, + ); + result.push(`${ok}:`); + } + moved.set(block, ok); + changed = true; + } + if (!changed) return chunk; + if (moved.size === 0) return result.join("\n"); + personality = true; + const header = result[0]!; + const brace = header.lastIndexOf(" {"); + if (!header.includes("personality") && brace >= 0) { + // Before the function's metadata attachments (`!dbg`), if any. + const dbg = header.slice(0, brace).search(/\s!dbg !\d+$/); + const at = dbg >= 0 ? dbg : brace; + result[0] = `${header.slice(0, at)} ${UNWIND_PERSONALITY}${header.slice(at)}`; + } + // A split block's successors now come from its last part. + if (moved.size > 0) + for (let i = 0; i < result.length; i++) { + const line = result[i]!; + if (!line.includes(" phi ")) continue; + result[i] = line.replace(/, %([\w.$-]+) \]/g, (all, name: string) => { + const to = moved.get(name); + return to === undefined ? all : `, %${to} ]`; + }); + } + return result.join("\n"); + }) + .join("\n"), + ); + if (personality && !out.some((part) => part.includes("@__gcc_personality_v0(...)"))) + out[0] = `declare i32 @__gcc_personality_v0(...)\n${out[0]}`; + return out; +} diff --git a/packages/compiler/src/backend/llvm/wrapping-sums.ts b/packages/compiler/src/backend/llvm/wrapping-sums.ts index c736fcf72..5b487d809 100644 --- a/packages/compiler/src/backend/llvm/wrapping-sums.ts +++ b/packages/compiler/src/backend/llvm/wrapping-sums.ts @@ -4,13 +4,15 @@ import { integerArithmeticRange, type IntegerRange } from "../../ir/integer-rang import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import type { LlInteger } from "./integer-values.js"; -/** Marks the `+` and `-` operands whose number only reaches ToInt32/ToUint32 - * consumers (`| 0`, the other bitwise operators, `Math.imul`, `Math.clz32`), - * directly or through further sums, in the emitter's `wrappingSums`. Called - * by the consumer before its operands emit. */ +/** Marks the `+`, `-` and `*` operands whose number only reaches + * ToInt32/ToUint32 consumers (`| 0`, the other bitwise operators, + * `Math.imul`, `Math.clz32`), directly or through further sums and + * products, in the emitter's `wrappingSums`. Called by the consumer before + * its operands emit. */ export function markWrappingOperands(host: LlvmEmitterContext, ...operands: IrExpr[]): void { for (const operand of operands) { - if (operand.kind !== "bin" || (operand.op !== "+" && operand.op !== "-")) continue; + if (operand.kind !== "bin" || (operand.op !== "+" && operand.op !== "-" && operand.op !== "*")) + continue; if (operand.type.kind !== "f64" || host.wrappingSums.has(operand)) continue; host.wrappingSums.add(operand); markWrappingOperands(host, operand.left, operand.right); @@ -128,12 +130,15 @@ function wrappingOperand( return { low, valid }; } -/** ToUint32 of `left op right` (`+` or `-`) in 32-bit arithmetic when the - * expression only reaches ToInt32/ToUint32 consumers and both operands are - * exact integers or NaN with an exact result. Both conversions are - * arithmetic mod 2^32, so the wrapping i32 sum of the operands' low halves - * is the converted exact sum; a NaN operand makes the sum NaN, which - * converts to 0. Returns undefined, emitting nothing, for any other sum. */ +/** ToUint32 of `left op right` (`+`, `-` or `*`) in 32-bit arithmetic when + * the expression only reaches ToInt32/ToUint32 consumers and both operands + * are exact integers or NaN with an exact result. Both conversions are + * arithmetic mod 2^32, so the wrapping i32 sum or product of the operands' + * low halves is the converted exact result; a NaN operand makes the result + * NaN, which converts to 0. A product is exact when the operands' magnitude + * bounds multiply to at most 2^53; it may be -0 (`0 * -1`), which converts + * to 0 like the integer 0. Returns undefined, emitting nothing, for any + * other expression. */ export function emitWrappingSum( host: LlvmEmitterContext, e: IrExpr & { kind: "bin" }, @@ -142,15 +147,23 @@ export function emitWrappingSum( li: LlInteger | null, ri: LlInteger | null, ): string | undefined { - if ((e.op !== "+" && e.op !== "-") || !host.wrappingSums.has(e)) return undefined; + if ((e.op !== "+" && e.op !== "-" && e.op !== "*") || !host.wrappingSums.has(e)) return undefined; const leftRange = li ? li.range : integerOrNaNRange(host, e.left); const rightRange = leftRange && (ri ? ri.range : integerOrNaNRange(host, e.right)); - if (!rightRange || !integerArithmeticRange(e.op, leftRange!, rightRange)) return undefined; + if (!rightRange) return undefined; + if ( + e.op === "*" + ? !exactProduct(leftRange!, rightRange) + : !integerArithmeticRange(e.op, leftRange!, rightRange) + ) + return undefined; const B = host.B; const left = wrappingOperand(host, l, li, leftRange!); const right = wrappingOperand(host, r, ri, rightRange); const sum = B.tmp(); - B.line(`${sum} = ${e.op === "+" ? "add" : "sub"} i32 ${left.low}, ${right.low}`); + B.line( + `${sum} = ${e.op === "+" ? "add" : e.op === "-" ? "sub" : "mul"} i32 ${left.low}, ${right.low}`, + ); let valid = left.valid ?? right.valid; if (valid === null) return sum; if (left.valid && right.valid) { @@ -162,6 +175,17 @@ export function emitWrappingSum( return out; } +/** Whether every product of a value in `left` and one in `right` is an + * integer of magnitude below 2^53, which the double product holds exactly. + * The bound's own product is computed in a double: a bound of at least + * 2^53 never rounds below 2^53, while 2^53 + 1 (3 * 3002399751580331) + * rounds to 2^53, so the test is strict. */ +function exactProduct(left: IntegerRange, right: IntegerRange): boolean { + const magnitude = (range: IntegerRange): number => + Math.max(Math.abs(range.min), Math.abs(range.max)); + return magnitude(left) * magnitude(right) < 2 ** 53; +} + /** The number locals of `fn` that only hold exact integers (never -0) or NaN, with the range of * those integers: locals declared once with an initializer, neither parameters nor captured or * boxed, whose initializer and every later write `integerOrNaNRange` covers. A local joins once diff --git a/packages/compiler/src/backend/native-codegen.ts b/packages/compiler/src/backend/native-codegen.ts index 5b91c60e7..413fb7e02 100644 --- a/packages/compiler/src/backend/native-codegen.ts +++ b/packages/compiler/src/backend/native-codegen.ts @@ -30,6 +30,8 @@ import { compilerReleaseVersion } from "../library/sidecar.js"; import { nativeProfileHelperArgs, nativeProfileKey, + parseNativeProfileMatch, + type NativeProfileMatch, type NativeProfileOptions, } from "./native-profile.js"; import { writeTextParts } from "../text-parts.js"; @@ -69,6 +71,8 @@ export interface NativeCodegenArtifact { dependencies: NativeArtifactDependency[]; /** Every emitted file, starting with the requested output path. */ outputPaths: string[]; + /** How the build's profile matched, when it used one and the helper ran. */ + profileMatch?: NativeProfileMatch; } const resolvedHelperCache = new Map>(); @@ -458,7 +462,7 @@ export async function emitNativeArtifact( try { if (isLlvmFile(options.llvm)) await copyFile(options.llvm.file, input); else await writeLlvmSource(input, options.llvm, 0o600); - await invoke(helper.binaryPath, [ + const { stdout } = await invoke(helper.binaryPath, [ "emit", "--input", input, @@ -502,7 +506,9 @@ export async function emitNativeArtifact( for (let index = 0; index < stages.length; index++) await rename(stages[index]!, outputs[index]!); await pruneBuildCache(root); - return artifact; + const profileMatch = + options.profile?.use === undefined ? null : parseNativeProfileMatch(stdout); + return profileMatch === null ? artifact : { ...artifact, profileMatch }; } finally { await Promise.all([ ...stages.map((stage) => rm(stage, { force: true }).catch(() => undefined)), diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 1e9994422..f250a2ac6 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -34,6 +34,9 @@ export interface NativeLinkFeatures { /** The program uses process.stdin.setEncoding, string 'data' listeners * or stdio 'resize' events: links the SCR_STDIO_EXTENDED events unit. */ stdioExtended?: boolean; + /** Module namespace objects as checked-dynamic values link + * scr_module_namespace.c; absent (false) otherwise. */ + moduleNamespace?: boolean; zlib: boolean; /** node:zlib's Brotli forms: scr_brotli.c and the vendored brotli archive. */ brotli?: boolean; @@ -106,6 +109,9 @@ export interface NativeLinkFeatures { /** Ed25519 keys and signatures with pki: scr_ed25519.c (the vendored * libsodium Ed25519). */ ed25519: boolean; + /** X509Certificate fields beyond the validity window and fingerprint: + * scr_x509.c. */ + x509: boolean; /** perf_hooks event-loop metrics: scr_perf_loop.c. */ perfLoop: boolean; tlsCa: boolean; diff --git a/packages/compiler/src/backend/native-profile.test.ts b/packages/compiler/src/backend/native-profile.test.ts index 590c39eef..b2cb0c75d 100644 --- a/packages/compiler/src/backend/native-profile.test.ts +++ b/packages/compiler/src/backend/native-profile.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "vitest"; import { executableLinkInputs } from "./link-plan-core.js"; import { + parseNativeProfileMatch, nativeProfileHelperArgs, nativeProfileKey, nativeProfileRefusal, @@ -71,3 +72,15 @@ test("only the instrumented link adds the profile runtime", () => { "-fprofile-generate", ); }); + +test("the helper's profile match is read from its output", () => { + expect( + parseNativeProfileMatch( + '{"ok":true,"profile":{"annotated":120,"mismatched":7,"missing":40,"unknown_partitions":1}}\n', + ), + ).toEqual({ annotated: 120, mismatched: 7, missing: 40, unknownPartitions: 1 }); + // A helper that predates the report prints nothing. + expect(parseNativeProfileMatch("")).toBeNull(); + expect(parseNativeProfileMatch('{"ok":true}\n')).toBeNull(); + expect(parseNativeProfileMatch('{"ok":true,"profile":{"annotated":-1}}\n')).toBeNull(); +}); diff --git a/packages/compiler/src/backend/native-profile.ts b/packages/compiler/src/backend/native-profile.ts index 4cd766319..904994a7d 100644 --- a/packages/compiler/src/backend/native-profile.ts +++ b/packages/compiler/src/backend/native-profile.ts @@ -44,6 +44,45 @@ export function nativeProfileHelperArgs(profile: NativeProfileOptions | undefine return []; } +/** How an optimized build's profile matched the program's functions, as the + * helper reports it: functions that took the profile's counts, functions + * whose control flow changed since the profile was collected, functions the + * profile has no record of (new, or never run while it was collected), and + * partitions reused from the cache without their counts. */ +export interface NativeProfileMatch { + annotated: number; + mismatched: number; + missing: number; + unknownPartitions: number; +} + +/** The profile match in a successful helper emit's output, or null when the + * helper reports none (a helper that predates the report). */ +export function parseNativeProfileMatch(stdout: string): NativeProfileMatch | null { + for (const line of stdout.split("\n")) { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + const profile = (parsed as { profile?: Record } | null)?.profile; + if (typeof profile !== "object" || profile === null) continue; + const count = (key: string): number | null => { + const value = profile[key]; + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null; + }; + const annotated = count("annotated"); + const mismatched = count("mismatched"); + const missing = count("missing"); + const unknownPartitions = count("unknown_partitions"); + if (annotated === null || mismatched === null || missing === null || unknownPartitions === null) + continue; + return { annotated, mismatched, missing, unknownPartitions }; + } + return null; +} + /** Cache-key part; empty without a profile so other keys are unchanged. */ export function nativeProfileKey(profile: NativeProfileOptions | undefined): string { if (profile?.generate !== undefined) return `profile-generate:${profile.generate}`; diff --git a/packages/compiler/src/backend/native-toolchain.test.ts b/packages/compiler/src/backend/native-toolchain.test.ts index fd7a5325f..f1bcde404 100644 --- a/packages/compiler/src/backend/native-toolchain.test.ts +++ b/packages/compiler/src/backend/native-toolchain.test.ts @@ -3983,12 +3983,12 @@ test.skipIf( programShards: [ { name: "program-f000.ll", - source: + source: () => "define i32 @main() {\nentry:\n ret i32 7\n}\ndeclare hidden i64 @other_value()\n", }, { name: "program-f001.ll", - source: + source: () => "declare i32 @main()\ndefine hidden i64 @other_value() {\nentry:\n ret i64 9\n}\n", }, ], @@ -4038,11 +4038,11 @@ test.skipIf(!nativeShardMergeAvailable)( const programShards = [ { name: "program-f000.ll", - source: "define i64 @foo() {\nentry:\n ret i64 1\n}\ndeclare i64 @bar()\n", + source: () => "define i64 @foo() {\nentry:\n ret i64 1\n}\ndeclare i64 @bar()\n", }, { name: "program-f001.ll", - source: "declare i64 @foo()\ndefine i64 @bar() {\nentry:\n ret i64 2\n}\n", + source: () => "declare i64 @foo()\ndefine i64 @bar() {\nentry:\n ret i64 2\n}\n", }, ]; try { @@ -4082,7 +4082,7 @@ test.skipIf(!nativeShardMergeAvailable)( // The canonical TU and ABI stay fixed, but the exact shard projection // changes. The merged-object tier must not reuse the previous layout. await build("bar", join(dir, "bar-commented.lib.a"), [ - { ...programShards[0]!, source: `; projection edit\n${programShards[0]!.source}` }, + { ...programShards[0]!, source: () => `; projection edit\n${programShards[0]!.source()}` }, programShards[1]!, ]); expect(await completeArtifacts(cacheRoot, "lib")).toHaveLength(3); @@ -4131,11 +4131,11 @@ test.skipIf( const programShards = [ { name: "program-f000.ll", - source: "define i64 @foo() {\nentry:\n ret i64 1\n}\ndeclare i64 @bar()\n", + source: () => "define i64 @foo() {\nentry:\n ret i64 1\n}\ndeclare i64 @bar()\n", }, { name: "program-f001.ll", - source: "declare i64 @foo()\ndefine i64 @bar() {\nentry:\n ret i64 2\n}\n", + source: () => "declare i64 @foo()\ndefine i64 @bar() {\nentry:\n ret i64 2\n}\n", }, ]; const ldWrapper = (generation: string): string => `#!/bin/sh @@ -4227,8 +4227,8 @@ test.skipIf( // If compilation reaches either shard, this build fails. Missing ld // (and objcopy on Linux) must select the valid canonical TU instead. programShards: [ - { name: "program-f000.ll", source: "not valid LLVM IR\n" }, - { name: "program-f001.ll", source: "also not valid LLVM IR\n" }, + { name: "program-f000.ll", source: () => "not valid LLVM IR\n" }, + { name: "program-f001.ll", source: () => "also not valid LLVM IR\n" }, ], programPublicSymbols: ["public_value"], outPath, @@ -4293,12 +4293,12 @@ test.skipIf( programShards: [ { name: "program-f000.ll", - source: + source: () => "define i64 @public_value() {\nentry:\n ret i64 7\n}\ndeclare i64 @other_value()\n", }, { name: "program-f001.ll", - source: + source: () => "declare i64 @public_value()\ndefine i64 @other_value() {\nentry:\n ret i64 9\n}\n", }, ], diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 40bb2352b..e3f97710f 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -29,7 +29,7 @@ export interface CcOptions { windowsSubsystem?: WindowsSubsystem; /** Optional equivalent LLVM modules for dev compilation. Unsupported * targets or merge failures fall back to the canonical cPath TU. */ - programShards?: readonly { name: string; source: string }[]; + programShards?: readonly { name: string; source: () => string }[]; /** Canonical externally visible definitions retained while shard merging * demotes generated cross-shard linkage back to local symbols. */ programPublicSymbols?: readonly string[]; @@ -147,6 +147,9 @@ export interface CcOptions { warningReplace?: boolean; /** SCR_STDIO_EXTENDED: stdin encoding, string data listeners, resize. */ stdioExtended?: boolean; + /** Module namespace objects as checked-dynamic values: compiles + * scr_module_namespace.c. */ + moduleNamespace?: boolean; /** The program uses the process-events surface (signal/exit listeners, * stdin events, for-await over stdin — moduleUsesProcessEvents on the * IR): compiles scr_events.c into the binary. Event-free binaries keep @@ -272,6 +275,8 @@ export interface CcOptions { /** Ed25519 with pki: compiles scr_ed25519.c (the vendored libsodium * Ed25519). */ ed25519?: boolean; + /** X509Certificate fields: compiles scr_x509.c. */ + x509?: boolean; /** crypto.subtle importKey/sign/verify/digest: compiles scr_webcrypto.c. */ webcrypto?: boolean; /** perf_hooks event-loop metrics: compiles scr_perf_loop.c. */ @@ -301,7 +306,7 @@ export interface LibArchiveOptions { * `programSource` remains the canonical TU/cache identity; these stable * shards compile independently and are relocatably merged into one program * object before archive assembly. */ - programShards?: readonly { name: string; source: string }[]; + programShards?: readonly { name: string; source: () => string }[]; /** Canonical externally visible definitions retained while the shard merge * demotes generated cross-shard linkage back to local symbols. */ programPublicSymbols?: readonly string[]; @@ -357,6 +362,8 @@ export interface LibArchiveOptions { searchParams?: boolean; emitter?: boolean; zlib?: boolean; + /** X509Certificate fields: scr_x509.c. */ + x509?: boolean; copying?: boolean; textDecoderLegacy?: boolean; } diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index ceb162bcc..7f989a5e4 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -43,6 +43,7 @@ import { cacheTargetIdentity, isZigDriver, isMuslTarget, + type CcDriver, } from "./driver.js"; import { resolveProgramShardMergeIdentity, @@ -194,6 +195,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ unit((u) => u.opts.dynTier, "scr_js_value.c", "scr_js_object.c"), unit((u) => u.opts.eventTarget || u.nativeFetch, "scr_event_target.c"), unit((u) => u.opts.wasm, "scr_wasm.c"), + // Module namespace objects as checked-dynamic values. + unit((u) => u.opts.moduleNamespace === true, "scr_module_namespace.c"), unit( (u) => u.opts.dynAsync || @@ -325,6 +328,9 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ unit((u) => u.opts.webcrypto, "scr_webcrypto.c"), unit((u) => u.opts.webcrypto === true && u.opts.pki === true, "scr_webcrypto_pki.c"), unit((u) => u.opts.pki === true && u.opts.ed25519 === true, "scr_ed25519.c"), + unit((u) => u.opts.x509 === true, "scr_x509.c"), + // The TLS socket certificate accessors read scr_tls.c and build with scr_x509.c. + unit((u) => u.tls && u.opts.x509 === true, "scr_tls_x509.c"), // The crypto global's object extends scr_async_dyn.c's. unit((u) => u.opts.webcrypto && u.opts.dynAsync, "scr_webcrypto_object.c"), // The CA-store unit rides its own gate OR the tls one: scr_tls.c @@ -430,7 +436,7 @@ export async function compileCInternal( ? ["-gline-tables-only", ...(opts.cPath.endsWith(".ll") ? [] : ["-gno-column-info"])] : []; const { programShards, programPublicSymbols, programShardMergeIdentity } = - await selectProgramShards(driver, opts, optimization === "dev" && !sanitize); + await selectProgramShards(driver, opts, optimization === "dev"); // Mobile triples produce library archives, never standalone executables: // the executable-lane runtime (event loop, sockets, child processes) is // not verified on those device classes. compile() reports the SC3002 @@ -821,6 +827,7 @@ export async function compileCInternal( // generated/native input that references inflate symbols. Cross // and targetless Zig builds use vendored zlib objects in the input section above. ...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []), + ...unwinderLinkArgs(driver), // glibc keeps libm separate from libc. This must trail the generated // program and every native FFI input because GNU ld resolves archives // from left to right. @@ -883,9 +890,15 @@ export async function compileCInternal( }; const buildExecutable = async ( runtimeInput: (path: string) => string, - build: { programPath?: string; outPath?: string; compilerVisibleSource?: string } = {}, + build: { + programPath?: string; + outPath?: string; + compilerVisibleSource?: string; + /** programPath is an object already (the merged program shards). */ + programObject?: boolean; + } = {}, ): Promise => { - if (!separateSanitizedProgram) { + if (!separateSanitizedProgram || build.programObject === true) { await runClang(buildArgs(runtimeInput, build)); return; } @@ -1068,6 +1081,7 @@ export async function compileCInternal( ...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []), ...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []), ...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []), + ...unwinderLinkArgs(driver), ...driver.linkArgs, ...executableLinkFlags, ]; @@ -1080,6 +1094,7 @@ export async function compileCInternal( ...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []), ...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []), ...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []), + ...unwinderLinkArgs(driver), ...driver.linkArgs, ...executableLinkFlags, ]; @@ -1462,7 +1477,7 @@ export async function compileCInternal( .update("\0") .update(shard.name) .update("\0") - .update(shard.source) + .update(shard.source()) .digest("hex"); return { ...shard, @@ -1476,7 +1491,7 @@ export async function compileCInternal( for (let i = 0; i < entries.length; i += shardWidth) { await Promise.all( entries.slice(i, i + shardWidth).map(async (entry) => { - await writeFile(entry.sourcePath, entry.source); + await writeFile(entry.sourcePath, entry.source()); if (await copyValidCachedFile(entry.cachePath, entry.staged)) return; entry.missed = true; await runClang([ @@ -1563,7 +1578,7 @@ export async function compileCInternal( (p) => objects?.get(p) ?? p, shardedProgramObject === null ? { programPath, outPath: privateOut, compilerVisibleSource: opts.cPath } - : { programPath: shardedProgramObject, outPath: privateOut }, + : { programPath: shardedProgramObject, outPath: privateOut, programObject: true }, ); if (darwinDebugSymbols) { // clang invokes dsymutil when compiling a source TU while linking. @@ -1689,3 +1704,11 @@ export async function compileC(opts: CcOptions): Promise { await rm(`${opts.outPath}.dSYM`, { recursive: true, force: true }); } } + +/** Linux runtimes raise through the native unwinder (scr_unwind_raise): + * Zig's libunwind, or the toolchain's libgcc_eh. Both are static archives, + * so programs load no shared library for them. */ +function unwinderLinkArgs(driver: CcDriver): string[] { + if (targetPlatform(driver) !== "linux") return []; + return isZigDriver(driver) ? ["-lunwind"] : ["-lgcc_eh"]; +} diff --git a/packages/compiler/src/backend/native/library.ts b/packages/compiler/src/backend/native/library.ts index f04e9926c..f0577f3ca 100644 --- a/packages/compiler/src/backend/native/library.ts +++ b/packages/compiler/src/backend/native/library.ts @@ -73,7 +73,7 @@ import { CacheInputsChangedError } from "./session.js"; * async_free refusal already guarantees nothing references them — and * scr_library.c (sink, arena, reset registry, library funnel) joins. The gated * units a library may reach are the pure-data ones (regex + the vendored matcher, assert, - * inspect, symbol, searchParams, emitter+dyn_handle, zlib); every + * inspect, symbol, searchParams, emitter+dyn_handle, zlib, x509); every * loop-hooked or ambient unit was refused at SC4005 before emission. * External-symbol contract: undefined references only to the target's C/math * runtime and system APIs. Windows embedders additionally link advapi32, @@ -115,6 +115,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise ...(opts.dynInvoke && opts.dynBuffer ? ["scr_bytes_dyn.c"] : []), ...(opts.dynTier === true ? ["scr_js_value.c", "scr_js_object.c"] : []), ...(opts.zlib ? ["scr_zlib.c"] : []), + ...(opts.x509 ? ["scr_x509.c"] : []), ...(opts.copying ? ["scr_copying.c"] : []), ]; const cflags = [ @@ -484,7 +485,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise .update("\0") .update(shard.name) .update("\0") - .update(shard.source) + .update(shard.source()) .digest("hex"); cachePath = join(persistentCache.root, "program-shard", key); } @@ -494,7 +495,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise for (let i = 0; i < shardEntries.length; i += shardWidth) { await Promise.all( shardEntries.slice(i, i + shardWidth).map(async (entry) => { - await writeFile(entry.sourcePath, entry.source); + await writeFile(entry.sourcePath, entry.source()); if ( entry.cachePath !== null && (await copyValidCachedFile(entry.cachePath, entry.staged)) diff --git a/packages/compiler/src/backend/native/program-shards.ts b/packages/compiler/src/backend/native/program-shards.ts index 978be3a97..1afd87f7b 100644 --- a/packages/compiler/src/backend/native/program-shards.ts +++ b/packages/compiler/src/backend/native/program-shards.ts @@ -7,7 +7,7 @@ import { rememberFingerprintDependencies } from "./dependency-files.js"; export function updateProgramShardCacheIdentity( hash: ReturnType, - shards: readonly { name: string; source: string }[] | undefined, + shards: readonly { name: string; source: () => string }[] | undefined, publicSymbols: readonly string[] | undefined, mergeIdentity: string | undefined, ): void { @@ -17,7 +17,7 @@ export function updateProgramShardCacheIdentity( } else { hash.update("\0"); for (const shard of shards) { - hash.update(shard.name).update("\0").update(shard.source).update("\0"); + hash.update(shard.name).update("\0").update(shard.source()).update("\0"); } } hash.update("\0program-public-symbols\0"); @@ -78,7 +78,7 @@ export async function resolveProgramShardMergeIdentity(driver: CcDriver): Promis } /** Library builds admit shards in both optimization modes; executable builds - * only admit unsanitized dev LLVM. Both use the same validated names, required + * admit dev LLVM, sanitized or not. Both use the same validated names, required * public-symbol projection, target support, and canonical-TU fallback. */ export async function selectProgramShards( driver: CcDriver, diff --git a/packages/compiler/src/backend/native/runtime-inputs.ts b/packages/compiler/src/backend/native/runtime-inputs.ts index c7c5bc32b..d32867331 100644 --- a/packages/compiler/src/backend/native/runtime-inputs.ts +++ b/packages/compiler/src/backend/native/runtime-inputs.ts @@ -53,6 +53,7 @@ export const EXECUTABLE_RUNTIME_SOURCES = [ "scr_object.c", "scr_union.c", "scr_exception.c", + "scr_unwind.c", "scr_error.c", "scr_console.c", "scr_lib.c", diff --git a/packages/compiler/src/backend/runtime-abi.ts b/packages/compiler/src/backend/runtime-abi.ts index 11bde6c2b..d8e256349 100644 --- a/packages/compiler/src/backend/runtime-abi.ts +++ b/packages/compiler/src/backend/runtime-abi.ts @@ -2,8 +2,8 @@ * of the link contract: a program object keeps this symbol undefined and the * matching runtime defines it, so incompatible manual links fail before the * program can start. */ -export const RUNTIME_ABI_VERSION = 37 as const; -export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v37" as const; +export const RUNTIME_ABI_VERSION = 43 as const; +export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v43" as const; /** Object consumption is useful today, but the complete scr_* surface may * still change before 1.0. The versioned marker prevents accidental mixing; diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 49165d8df..22bba85d2 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -94,6 +94,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { warningReplace: boolean; stdioExtended: boolean; brotli: boolean; + moduleNamespace: boolean; nativeFetch: boolean; netEffective: boolean; httpEffective: boolean; @@ -293,6 +294,7 @@ export function effectiveRuntimeFeatures( warningReplace: features.warningReplace === true, stdioExtended: features.stdioExtended === true, brotli: features.brotli === true, + moduleNamespace: features.moduleNamespace === true, nativeFetch, netEffective, httpEffective, diff --git a/packages/compiler/src/backend/runtime-pack-native.test.ts b/packages/compiler/src/backend/runtime-pack-native.test.ts index 20214818f..7a8a2e6c4 100644 --- a/packages/compiler/src/backend/runtime-pack-native.test.ts +++ b/packages/compiler/src/backend/runtime-pack-native.test.ts @@ -1,12 +1,40 @@ +import { createHash } from "node:crypto"; +import { + mkdirSync, + readFileSync, + renameSync, + statSync, + symlinkSync, + unlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, expect, test } from "vitest"; +import { afterEach, expect, test, vi } from "vitest"; import { nativeCodegenDiag } from "../diagnostics/diagnostic.js"; +import { compilerReleaseVersion } from "../library/sidecar.js"; import type { NativeLinkFeatures } from "./native-link-info.js"; -import { RuntimePackError } from "./runtime-pack-core.js"; -import { selectNativeRuntimePack } from "./runtime-pack-native.js"; -import { WASM32_WASI_TARGET } from "./targets.js"; +import { RUNTIME_ABI_MARKER, RUNTIME_ABI_VERSION } from "./runtime-abi.js"; +import { RuntimePackError, type RuntimePackManifest } from "./runtime-pack-core.js"; +import { + runtimePackManifest, + selectNativeRuntimePack, + stageNativeRuntimeSelection, + type NativeRuntimeSelection, + type RuntimePackStore, +} from "./runtime-pack-native.js"; +import { MACOS_ARM64_TARGET, WASM32_WASI_TARGET } from "./targets.js"; + +vi.mock("node:crypto", async (original) => { + const crypto = await original(); + return { ...crypto, createHash: vi.fn(crypto.createHash) }; +}); +vi.mock("node:fs", async (original) => { + const fs = await original(); + return { ...fs, readFileSync: vi.fn(fs.readFileSync) }; +}); const dirs: string[] = []; afterEach(async () => @@ -45,3 +73,171 @@ test("a missing native helper keeps the reinstall hint", () => { "reinstall scriptc with optional dependencies enabled for this host", ); }); + +/** A runtime pack with one runtime object and a license, and a store that + * keeps entries in memory. */ +async function packFixture() { + const root = await mkdtemp(join(tmpdir(), "scriptc-runtime-pack-store-")); + dirs.push(root); + const target = MACOS_ARM64_TARGET; + const version = compilerReleaseVersion(); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: target.runtimePackPackage, version }), + ); + mkdirSync(join(root, "artifacts")); + const object = join(root, "artifacts", "base.o"); + writeFileSync(object, "base object"); + writeFileSync(join(root, "license.txt"), "license"); + const unit = { + source: "scr_base.c", + predicate: true, + variants: [ + { + id: "default", + when: {}, + defines: [], + path: "artifacts/base.o", + sha256: createHash("sha256").update("base object").digest("hex"), + size: Buffer.byteLength("base object"), + }, + ], + }; + const manifest: RuntimePackManifest = { + schema: "scriptc.runtime-pack.v1", + format: 1, + package: target.runtimePackPackage, + version, + target: { + name: target.name, + llvm_triple: target.llvmTriple, + architecture: target.architecture, + object_format: target.objectFormat, + minimum_os: target.minimumOs, + }, + runtime_abi: { version: RUNTIME_ABI_VERSION, marker: RUNTIME_ABI_MARKER }, + compiler: { command: "clang", identity: "fixture clang", target: target.llvmTriple }, + macros: { + executable: [], + excluded: ["SCR_LIB", "SCR_THREAD_INSTANCES", "SCR_RC_AUDIT"], + sanitizer: "external-toolchain-required", + }, + flavors: { + release: { optimization: "-O2", runtime_units: [unit] }, + dev: { optimization: "-O0", runtime_units: [unit] }, + }, + archives: [], + system_libraries: [{ name: "System", predicate: true }], + licenses: [{ path: "license.txt", license: "fixture" }], + }; + const manifestText = JSON.stringify(manifest); + writeFileSync(join(root, "runtime-pack.json"), manifestText); + const entries = new Map(); + const store: RuntimePackStore = { + read: (key) => entries.get(key) ?? null, + write: (key, text) => void entries.set(key, text), + }; + const select = () => + selectNativeRuntimePack( + root, + target, + version, + {} as NativeLinkFeatures, + "release", + "executable", + store, + ); + let stages = 0; + const stage = (selection: NativeRuntimeSelection) => { + const directory = join(root, `stage-${stages++}`); + mkdirSync(directory); + return stageNativeRuntimeSelection(selection, directory, store); + }; + return { root, object, manifestText, entries, select, stage }; +} + +test("a stored selection answers while the pack's files keep their identities", async () => { + const f = await packFixture(); + const first = f.select(); + expect(first.selected.systemLibraries).toEqual(["System"]); + expect(f.entries.size).toBe(1); + // The next selection reads the stored answer instead of the manifest. + const [key, text] = [...f.entries][0]!; + const stored = JSON.parse(text); + stored.selected.systemLibraries = ["Stored"]; + f.entries.set(key, JSON.stringify(stored)); + const second = f.select(); + expect(second.selected.systemLibraries).toEqual(["Stored"]); + expect(second.licenses).toEqual(first.licenses); + expect(second.manifest).toBeUndefined(); + expect(runtimePackManifest(second).flavors.release).toEqual(first.manifest!.flavors.release); + // Writing the manifest again, with the same text, gives it a new identity. + writeFileSync(join(f.root, "runtime-pack.json"), f.manifestText); + expect(f.select().selected.systemLibraries).toEqual(["System"]); +}); + +test.each(["replaced", "rewritten in place"])( + "staging hashes an artifact again when it is %s, even at the same size and mtime", + async (change) => { + const f = await packFixture(); + const selection = f.select(); + f.stage(selection); + const before = statSync(f.object); + const changed = change === "replaced" ? f.object + ".new" : f.object; + writeFileSync(changed, "x".repeat(before.size)); + utimesSync(changed, before.atime, before.mtime); + if (changed !== f.object) renameSync(changed, f.object); + expect(() => f.stage(selection)).toThrow( + "runtime pack artifact hash mismatch: artifacts/base.o", + ); + }, +); + +test("staging does not hash an unchanged artifact again", async () => { + const f = await packFixture(); + const selection = f.select(); + const hashes = () => vi.mocked(createHash).mock.calls.length; + let start = hashes(); + f.stage(selection); + const firstStage = hashes() - start; + start = hashes(); + const staged = f.stage(selection); + // Only the store key is hashed; the staged copy has the verified bytes. + expect(hashes() - start).toBe(firstStage - 1); + expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object"); +}); + +test.skipIf(process.platform === "win32")( + "staging reads an artifact's bytes from the file whose identity it checks", + async () => { + const f = await packFixture(); + // The artifact path is a symbolic link to the verified file. + const real = f.object + ".real"; + renameSync(f.object, real); + symlinkSync(real, f.object); + const selection = f.select(); + f.stage(selection); + // During the next read the link names another file of the same size, + // and it names the verified file again before the read ends. + const other = f.object + ".other"; + writeFileSync(other, "x".repeat(statSync(real).size)); + const read = vi.mocked(readFileSync); + const original = read.getMockImplementation()!; + read.mockImplementation(((source: Parameters[0], ...rest: unknown[]) => { + if (typeof source === "string" && !source.endsWith("base.o")) + return (original as (...args: unknown[]) => unknown)(source, ...rest); + read.mockImplementation(original); + unlinkSync(f.object); + symlinkSync(other, f.object); + try { + return (original as (...args: unknown[]) => unknown)(source, ...rest); + } finally { + unlinkSync(f.object); + symlinkSync(real, f.object); + } + }) as typeof readFileSync); + const staged = f.stage(selection); + expect(read.getMockImplementation()).toBe(original); + expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object"); + }, +); diff --git a/packages/compiler/src/backend/runtime-pack-native.ts b/packages/compiler/src/backend/runtime-pack-native.ts index bd38e2ef0..0fff34455 100644 --- a/packages/compiler/src/backend/runtime-pack-native.ts +++ b/packages/compiler/src/backend/runtime-pack-native.ts @@ -1,16 +1,21 @@ /** Synchronous runtime-pack host for the statically compiled driver. Each - * selected artifact is hashed and copied from the same read into private - * storage. This host has no persistent cache or mutable installed link inputs. */ + * selected artifact is verified and copied from the same read into private + * storage. With a store, a pack's selections and the identities of the + * artifacts it verified are kept between builds: an artifact whose file + * identity is unchanged since it was hashed is not hashed again. */ import type { NativeOptimization } from "./optimization.js"; import { createHash } from "node:crypto"; import { closeSync, constants, existsSync, + fstatSync, mkdirSync, openSync, readFileSync, + statSync, writeSync, + type Stats, } from "node:fs"; import { dirname, join } from "node:path"; import type { NativeLinkFeatures } from "./native-link-info.js"; @@ -34,13 +39,66 @@ export interface NativeRuntimePack { export interface NativeRuntimeSelection { root: string; - manifest: RuntimePackManifest; + /** The parsed manifest; a selection read from a store has none until + * runtimePackManifest parses it. */ + manifest?: RuntimePackManifest; + licenses: RuntimePackManifest["licenses"]; packageText: string; manifestText: string; selected: RuntimePackArtifacts; flavor: NativeOptimization; } +/** Text entries kept between builds (the native compiler's cache). */ +export interface RuntimePackStore { + read(key: string): string | null; + write(key: string, text: string): void; +} + +interface FileIdentity { + dev: number; + ino: number; + size: number; + mtime: number; + ctime: number; +} + +function fileIdentity(path: string): FileIdentity { + return statsIdentity(statSync(path)); +} + +function statsIdentity(info: Stats): FileIdentity { + return { + dev: info.dev, + ino: info.ino, + size: info.size, + mtime: info.mtimeMs, + ctime: info.ctimeMs, + }; +} + +function sameIdentity(a: FileIdentity, b: FileIdentity): boolean { + return ( + a.dev === b.dev && + a.ino === b.ino && + a.size === b.size && + a.mtime === b.mtime && + a.ctime === b.ctime + ); +} + +function storeKey(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +/** A stored selection: what selecting from the pack's files, with these + * identities, returned. */ +interface StoredSelection { + schema: 1; + licenses: RuntimePackManifest["licenses"]; + selected: RuntimePackArtifacts; +} + export function selectNativeRuntimePack( root: string, target: NativeTargetSpec, @@ -48,38 +106,204 @@ export function selectNativeRuntimePack( features: NativeLinkFeatures, flavor: NativeOptimization, mode: RuntimePackMode = "executable", + store?: RuntimePackStore, ): NativeRuntimeSelection { - if (!existsSync(join(root, "package.json"))) { + const packagePath = join(root, "package.json"); + const manifestPath = join(root, "runtime-pack.json"); + if (!existsSync(packagePath)) { throw new RuntimePackError( `runtime pack for ${target.name} is not installed; install ${target.runtimePackPackage}@${compilerVersion} in your project or set SCRIPTC_RUNTIME_PACK to its directory`, "missing", "scriptc includes the runtime pack for its own platform only; other targets need their runtime pack package in the project", ); } - const packageText = readFileSync(join(root, "package.json"), "utf8"); - const manifestText = readFileSync(join(root, "runtime-pack.json"), "utf8"); + // The pack's files by identity, with everything else selection reads. + let key: string | null = null; + let identities: FileIdentity[] = []; + if (store !== undefined) { + try { + identities = [fileIdentity(packagePath), fileIdentity(manifestPath)]; + key = storeKey( + JSON.stringify({ + schema: 1, + root, + identities, + target: target.name, + compilerVersion, + features, + flavor, + mode, + }), + ); + const stored = readStoredSelection(store.read(key)); + if (stored !== null) { + const packageText = readFileSync(packagePath, "utf8"); + const manifestText = readFileSync(manifestPath, "utf8"); + // The texts read are the ones with the stored identities. + if ( + sameIdentity(fileIdentity(packagePath), identities[0]!) && + sameIdentity(fileIdentity(manifestPath), identities[1]!) + ) { + return { + root, + licenses: stored.licenses, + packageText, + manifestText, + selected: stored.selected, + flavor, + }; + } + } + } catch { + key = null; + } + } + const packageText = readFileSync(packagePath, "utf8"); + const manifestText = readFileSync(manifestPath, "utf8"); const identity = JSON.parse(packageText) as { name?: string; version?: string }; const manifest = parseRuntimePackManifest(JSON.parse(manifestText)); validateRuntimePackIdentity(manifest, identity.name, identity.version, target, compilerVersion); const selected = selectRuntimePackArtifacts(manifest, features, flavor, process.env, mode); - return { root, manifest, packageText, manifestText, selected, flavor }; + if (store !== undefined && key !== null) { + try { + // Stored only when the files selected from still have the identities + // in the key. + if ( + sameIdentity(fileIdentity(packagePath), identities[0]!) && + sameIdentity(fileIdentity(manifestPath), identities[1]!) + ) { + const entry: StoredSelection = { schema: 1, licenses: manifest.licenses, selected }; + store.write(key, JSON.stringify(entry)); + } + } catch { + /* A failed write is a later miss. */ + } + } + return { + root, + manifest, + licenses: manifest.licenses, + packageText, + manifestText, + selected, + flavor, + }; } -function stageArtifact(root: string, stage: string, artifact: RuntimePackArtifact): string { +/** The selection's parsed manifest. */ +export function runtimePackManifest(selection: NativeRuntimeSelection): RuntimePackManifest { + return (selection.manifest ??= parseRuntimePackManifest(JSON.parse(selection.manifestText))); +} + +function readStoredSelection(text: string | null): StoredSelection | null { + if (text === null) return null; + const value: unknown = JSON.parse(text); + if (value === null || typeof value !== "object") return null; + const entry = value as Partial; + if ( + entry.schema !== 1 || + !Array.isArray(entry.licenses) || + entry.selected === undefined || + entry.selected === null || + typeof entry.selected !== "object" || + !Array.isArray(entry.selected.runtime) || + !Array.isArray(entry.selected.archives) || + !Array.isArray(entry.selected.systemLibraries) + ) + return null; + return entry as StoredSelection; +} + +/** An artifact path's identity when its bytes last matched the manifest's + * size and digest. */ +interface VerifiedArtifact { + path: string; + sha256: string; + dev: number; + ino: number; + size: number; + mtime: number; + ctime: number; +} + +interface StoredVerification { + schema: number; + artifacts: VerifiedArtifact[]; +} + +function readVerified( + store: RuntimePackStore | undefined, + key: string, +): Map { + const verified = new Map(); + try { + const text = store?.read(key) ?? null; + if (text === null) return verified; + const stored = JSON.parse(text) as StoredVerification; + if (stored.schema === 1) + for (const artifact of stored.artifacts) verified.set(artifact.path, artifact); + } catch { + /* A malformed entry verifies everything again. */ + } + return verified; +} + +function stageArtifact( + root: string, + stage: string, + artifact: RuntimePackArtifact, + verified: Map, + newlyVerified: VerifiedArtifact[], +): string { const source = join(root, artifact.path); const destination = join(stage, artifact.path); let bytes: Buffer; + let before: FileIdentity; + let after: FileIdentity; try { - bytes = readFileSync(source); + // The identities are those of the open file the bytes come from, also + // when the path is a symbolic link that changes during the read. + const input = openSync(source, constants.O_RDONLY); + try { + before = statsIdentity(fstatSync(input)); + bytes = readFileSync(input); + after = statsIdentity(fstatSync(input)); + } finally { + closeSync(input); + } } catch { throw new RuntimePackError(`runtime pack artifact is missing: ${artifact.path}`, "invalid"); } + // The bytes are those last hashed when the file kept the identity it had + // then for the whole read. + const known = verified.get(artifact.path); + const unchanged = sameIdentity(before, after); + const trusted = + known !== undefined && + known.sha256 === artifact.sha256 && + known.size === artifact.size && + unchanged && + known.dev === before.dev && + known.ino === before.ino && + known.size === before.size && + known.mtime === before.mtime && + known.ctime === before.ctime; if ( bytes.length !== artifact.size || - createHash("sha256").update(bytes).digest("hex") !== artifact.sha256 + (!trusted && createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) ) { throw new RuntimePackError(`runtime pack artifact hash mismatch: ${artifact.path}`, "invalid"); } + if (!trusted && unchanged) + newlyVerified.push({ + path: artifact.path, + sha256: artifact.sha256, + dev: before.dev, + ino: before.ino, + size: before.size, + mtime: before.mtime, + ctime: before.ctime, + }); mkdirSync(dirname(destination), { recursive: true }); const fd = openSync( destination, @@ -123,15 +347,19 @@ export function stageNativeRuntimePack( export function stageNativeRuntimeSelection( selection: NativeRuntimeSelection, stageRoot: string, + store?: RuntimePackStore, ): NativeRuntimePack { - const { root, manifest, selected, packageText, manifestText } = selection; + const { root, licenses, selected, packageText, manifestText } = selection; const packagePath = join(root, "package.json"); const manifestPath = join(root, "runtime-pack.json"); - const runtimeObjects = selected.runtime.map((artifact) => - stageArtifact(root, stageRoot, artifact), - ); - const archives = selected.archives.map((artifact) => stageArtifact(root, stageRoot, artifact)); - for (const license of manifest.licenses) { + const key = storeKey(JSON.stringify({ schema: 1, verified: root })); + const verified = readVerified(store, key); + const newlyVerified: VerifiedArtifact[] = []; + const stage = (artifact: RuntimePackArtifact): string => + stageArtifact(root, stageRoot, artifact, verified, newlyVerified); + const runtimeObjects = selected.runtime.map(stage); + const archives = selected.archives.map(stage); + for (const license of licenses) { try { readFileSync(join(root, license.path)); } catch { @@ -144,5 +372,14 @@ export function stageNativeRuntimeSelection( ) { throw new RuntimePackError("runtime pack changed while staging verified artifacts", "invalid"); } + if (store !== undefined && newlyVerified.length > 0) { + for (const artifact of newlyVerified) verified.set(artifact.path, artifact); + const entry: StoredVerification = { schema: 1, artifacts: [...verified.values()] }; + try { + store.write(key, JSON.stringify(entry)); + } catch { + /* A failed write only means hashing again next time. */ + } + } return { runtimeObjects, archives, systemLibraries: selected.systemLibraries }; } diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts index 9846e8d89..5da2b8e59 100644 --- a/packages/compiler/src/backend/runtime-pack-targets.test.ts +++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts @@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" }, flavors: { @@ -129,6 +129,7 @@ const features = { dh: false, pki: false, ed25519: false, + x509: false, webcrypto: false, perfLoop: false, }; diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index e2b24f8ca..a87fabd2e 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -65,6 +65,7 @@ const BASE: NativeLinkFeatures = { dh: false, pki: false, ed25519: false, + x509: false, webcrypto: false, perfLoop: false, }; @@ -128,7 +129,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, compiler: { command: "clang", identity: "fixture clang", diff --git a/packages/compiler/src/backend/warm-cache.ts b/packages/compiler/src/backend/warm-cache.ts index 02acb5462..c42ceb79e 100644 --- a/packages/compiler/src/backend/warm-cache.ts +++ b/packages/compiler/src/backend/warm-cache.ts @@ -71,6 +71,7 @@ export async function warmNativeCaches( dh: false, pki: false, ed25519: false, + x509: false, webcrypto: false, perfLoop: false, tlsCa: false, diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts index a57885ca8..b2f2a1c69 100644 --- a/packages/compiler/src/cli/command.ts +++ b/packages/compiler/src/cli/command.ts @@ -4,15 +4,22 @@ import { parseArgs } from "node:util"; import { isExactExternalTypeSpecifier } from "../frontend/program.js"; import { renderDiagnostics } from "../diagnostics/render.js"; import { + COVERAGE_FAIL_ON, coverageEnvelope, coveragePasses, renderCoverage, renderExplainDynamic, + renderExplainIsland, type CoverageFailOn, } from "../coverage/report.js"; +import { + NODE_ISLANDS_MODES, + type NodeIslandOptions, + type NodeIslandsMode, +} from "../node-islands/policy.js"; import { TIER_MODES, type TierMode } from "../frontend/tier-analysis.js"; import { buildEnvelope } from "../diagnostics/envelope.js"; -import { renderWarnings } from "../diagnostics/render.js"; +import { renderConfigWarnings, renderWarnings } from "../diagnostics/render.js"; import { isPerformanceCode, renderPerformanceWarnings } from "../diagnostics/dynamic-tier.js"; import { isNativeOptimization } from "../backend/optimization.js"; import { @@ -114,6 +121,7 @@ async function main(args: string[], host: CliHost): Promise { values["emit-ir"] || values.tier !== undefined || values["explain-dynamic"] !== undefined || + hasNodeIslandFlags(values) || !values["keep-llvm"] ) { fail( @@ -172,6 +180,7 @@ async function main(args: string[], host: CliHost): Promise { values["emit-ir"] || values.tier !== undefined || values["explain-dynamic"] !== undefined || + hasNodeIslandFlags(values) || !values["keep-llvm"]; if (values.out === undefined || inputs.length === 0 || others) { fail( @@ -225,7 +234,8 @@ async function main(args: string[], host: CliHost): Promise { (values["npm-static"] ?? []).length > 0 || externalTypeArgs.length > 0 || values.tier !== undefined || - values["explain-dynamic"] !== undefined + values["explain-dynamic"] !== undefined || + hasNodeIslandFlags(values) ) { fail( "scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--target-cpu/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only", @@ -298,14 +308,9 @@ async function main(args: string[], host: CliHost): Promise { fail(`--print=diagnostics is a build/coverage option\n\n${USAGE}`); } const failOnRaw = values["fail-on"]; - if ( - failOnRaw !== undefined && - failOnRaw !== "blockers" && - failOnRaw !== "divergences" && - failOnRaw !== "dynamic" - ) { + if (failOnRaw !== undefined && !(COVERAGE_FAIL_ON as readonly string[]).includes(failOnRaw)) { fail( - `unknown --fail-on value "${failOnRaw}" (supported: blockers, divergences, dynamic)\n\n${USAGE}`, + `unknown --fail-on value "${failOnRaw}" (supported: ${COVERAGE_FAIL_ON.join(", ")})\n\n${USAGE}`, ); } // --no-dynamic-tier-warning leaves out SC6101 (diagnostics/dynamic-tier.ts). @@ -326,6 +331,48 @@ async function main(args: string[], host: CliHost): Promise { if (explainDynamic !== undefined && command !== "coverage") { fail(`--explain-dynamic is a coverage option\n\n${USAGE}`); } + const explainIsland = values["explain-island"]; + if (explainIsland !== undefined && command !== "coverage") { + fail(`--explain-island is a coverage option\n\n${USAGE}`); + } + if (explainIsland === "") fail(`--explain-island needs a package name\n\n${USAGE}`); + if (values["strict-user-js"] && command !== "coverage") { + fail(`--strict-user-js is a coverage option\n\n${USAGE}`); + } + // The Node island policy's command-line half. Report-only: build and run + // validate it, coverage reports the islands it would choose. + const islandMode = values["node-islands"]; + if (islandMode !== undefined && !(NODE_ISLANDS_MODES as readonly string[]).includes(islandMode)) { + fail( + `unknown --node-islands value "${islandMode}" (supported: ${NODE_ISLANDS_MODES.join(", ")})\n\n${USAGE}`, + ); + } + const listValues = (raw: string[] | undefined): string[] => + (raw ?? []) + .flatMap((v) => v.split(",")) + .map((v) => v.trim()) + .filter((v) => v !== ""); + const islandArgs = listValues(values["node-island"]); + const nativeArgs = listValues(values.native); + for (const name of nativeArgs) { + if (islandArgs.includes(name)) + fail(`--native=${name} and --node-island=${name} contradict each other\n\n${USAGE}`); + } + if (values.node === "") fail(`--node needs the path of a node executable\n\n${USAGE}`); + const nodeIslands: NodeIslandOptions | undefined = + islandMode === undefined && + islandArgs.length === 0 && + nativeArgs.length === 0 && + values.node === undefined && + !values["strict-user-js"] + ? undefined + : { + ...(islandMode !== undefined ? { mode: islandMode as NodeIslandsMode } : {}), + ...(islandArgs.length > 0 ? { islands: islandArgs } : {}), + ...(nativeArgs.length > 0 ? { native: nativeArgs } : {}), + ...(values.node !== undefined ? { node: resolve(values.node) } : {}), + ...(values["strict-user-js"] ? { strictUserJs: true } : {}), + }; if (printNativeLinkInfo && command !== "build") { fail(`--print=native-link-info is a build option\n\n${USAGE}`); } @@ -468,6 +515,7 @@ async function main(args: string[], host: CliHost): Promise { ...(tier !== undefined ? { tier } : {}), ...(explainDynamic !== undefined ? { explainDynamic: true } : {}), ...(wasmFiles.length > 0 ? { wasm: wasmFiles } : {}), + ...(nodeIslands !== undefined ? { nodeIslands } : {}), }); if (!dynamicTierWarning) delete coverage.performance; if (printDiagnostics) { @@ -483,6 +531,17 @@ async function main(args: string[], host: CliHost): Promise { process.stdout.write( renderCoverage(coverage, { color, sourceTexts, root: process.cwd() }) + "\n", ); + if (explainIsland !== undefined) { + process.stdout.write( + "\n" + + renderExplainIsland(coverage.nodeIslands, explainIsland, { + color, + sourceTexts, + root: process.cwd(), + }) + + "\n", + ); + } if (explainDynamic !== undefined && coverage.tiers !== undefined) { process.stdout.write( "\n" + @@ -578,14 +637,21 @@ async function main(args: string[], host: CliHost): Promise { // warning names likely-hot functions that use untyped values. `run` // keeps stderr for the program's own output. const color = process.stderr.isTTY ?? false; - const divergences = warnings.filter((d) => !isPerformanceCode(d.code)); + const config = warnings.filter((d) => d.code === "SC8004"); + const divergences = warnings.filter((d) => !isPerformanceCode(d.code) && d.code !== "SC8004"); const performance = warnings.filter((d) => isPerformanceCode(d.code)); - if (divergences.length > 0) + if (config.length > 0) + process.stderr.write( + renderConfigWarnings(config, result.sourceTexts ?? new Map(), { color }) + "\n", + ); + if (divergences.length > 0) { + if (config.length > 0) process.stderr.write("\n"); process.stderr.write( renderWarnings(divergences, result.sourceTexts ?? new Map(), { color }) + "\n", ); + } if (performance.length > 0) { - if (divergences.length > 0) process.stderr.write("\n"); + if (divergences.length > 0 || config.length > 0) process.stderr.write("\n"); process.stderr.write(renderPerformanceWarnings(performance) + "\n"); } } @@ -616,6 +682,19 @@ async function main(args: string[], host: CliHost): Promise { return 0; } +/** Whether any Node island flag is present (they belong to build, run and + * coverage). */ +function hasNodeIslandFlags(values: ReturnType["values"]): boolean { + return ( + values["node-islands"] !== undefined || + (values["node-island"] ?? []).length > 0 || + (values.native ?? []).length > 0 || + values.node !== undefined || + values["explain-island"] !== undefined || + values["strict-user-js"] + ); +} + /** Both installed and seed commands use this argument and diagnostic contract. */ export async function runCli(args: string[], host: CliHost): Promise { try { diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index 292755981..ead4bbc52 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -31,11 +31,13 @@ Options: (native-link-info implies --emit=obj and never links; diagnostics prints the versioned JSON diagnostics envelope for build and coverage) - --fail-on + --fail-on coverage only: exit 1 when the entry path has blockers (a build would fail), or also when it has Node - divergences, or also when it has likely-hot functions - that use untyped values (SC6101) + divergences, likely-hot functions that use untyped + values (SC6101), any Node island (eager-node-islands is + the same until deferred evaluation lands), or a gap + island (a package scriptc should compile but cannot yet) --backend code generator (llvm) --optimization native optimization posture (default: release/-O2). dev @@ -114,6 +116,28 @@ Options: --no-dynamic-tier-warning build and coverage: leave out SC6101, the warning that names likely-hot functions that use untyped values + --node-islands + Node island policy (default: auto): npm packages outside + the native subset run in Node automatically; explicit + allows only marked islands; off allows none. REPORT-ONLY + in this version: coverage reports the islands a build + would choose; builds compile as before + --node-island + mark an npm package or one of your modules (a path or + glob) to run in a Node island (repeatable, comma lists; + report-only) + --native require an npm package to compile natively; outside the + native subset it is SC8003 (repeatable; report-only) + --node the node executable Node islands use (report-only) + --explain-island + coverage only: why a package would run in a Node island, + with the first reason per module, the native import + sites and the packages inside its closure + --strict-user-js + coverage only: report constructor functions, prototype + and shape mutation, sloppy-mode semantics and builtin + patching in your own .js/.cjs/.mjs modules as + SC8101–SC8105 blockers --wasm coverage only: report a WebAssembly module the program loads whose path its sources do not spell (repeatable; @@ -149,6 +173,12 @@ export const CLI_OPTIONS = { "explain-dynamic": { type: "string" }, "dynamic-tier-warning": { type: "boolean", default: true }, wasm: { type: "string", multiple: true }, + "node-islands": { type: "string" }, + "node-island": { type: "string", multiple: true }, + native: { type: "string", multiple: true }, + node: { type: "string" }, + "explain-island": { type: "string" }, + "strict-user-js": { type: "boolean", default: false }, lib: { type: "boolean", default: false }, profile: { type: "string" }, help: { type: "boolean", short: "h", default: false }, diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index c0b4797a4..229e8faad 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -2,9 +2,11 @@ import type { TargetCpu } from "./backend/target-cpu.js"; import type { WindowsSubsystem } from "./backend/targets.js"; import type { NativeLinkInfo } from "./backend/native-link-info.js"; import type { NativeOptimization } from "./backend/optimization.js"; +import type { NativeProfileMatch } from "./backend/native-profile.js"; import type { ScrDiagnostic } from "./diagnostics/diagnostic.js"; import type { CoverageInput } from "./coverage/report.js"; import type { TierMode } from "./frontend/tier-analysis.js"; +import type { NodeIslandOptions } from "./node-islands/policy.js"; export type CompileOutputKind = "ir" | "llvm" | "asm" | "obj" | "exe"; @@ -105,6 +107,8 @@ export type CompileArtifact = path: string; translationUnitPath: string; backend: "llvm"; + /** How a `profileUse` build's profile matched the program's functions. */ + profileMatch?: NativeProfileMatch; }; export type CompileFailure = { @@ -195,6 +199,9 @@ export interface AnalyzeOptions { /** `--wasm `: WebAssembly module files the program loads that its * sources do not name (absolute paths); reported with the discovered ones. */ wasm?: readonly string[]; + /** The Node island policy's command-line half (report-only: coverage + * reports which packages and modules would run in a Node island). */ + nodeIslands?: NodeIslandOptions; } export interface AnalyzeResult { diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts index 79017c97a..58a2cafa6 100644 --- a/packages/compiler/src/coverage/report.ts +++ b/packages/compiler/src/coverage/report.ts @@ -17,6 +17,7 @@ import { type DiagnosticsEnvelope, type LocatedDiagnostic, type SourcePosition, + type NodeIslandsEnvelope, type TierUnitPosition, type TiersEnvelope, } from "../diagnostics/envelope.js"; @@ -25,6 +26,14 @@ import type { LowerStats } from "../frontend/lowering/lowerer.js"; import type { ProvenanceSources } from "../frontend/provenance-registry.js"; import type { DemotionUnit, TierReport } from "../frontend/tier-analysis.js"; import type { WasmModuleReport } from "../wasm/discover.js"; +import { + describe as describeReason, + hasNodeIslandDecisions, + type NodeIslandEntry, + type NodeIslandReport, +} from "../node-islands/summary.js"; +import { classDescription } from "../node-islands/rules.js"; +import type { SrcLoc } from "../ir/ir.js"; export interface CoverageInput { file: string; @@ -65,6 +74,10 @@ export interface CoverageInput { /** The WebAssembly modules the build can see (wasm/discover.ts): their * size, validity and the proposals they use. */ wasmModules?: WasmModuleReport[]; + /** Node islands, report-only: the packages and user modules that would + * run in a Node island, and why. Present when there is something to + * report. */ + nodeIslands?: NodeIslandReport; /** True when tsc preflight failed — no coverage can be computed. */ preflightFailed: boolean; } @@ -148,6 +161,19 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions out.push(` ${c(RED, "not analyzable")}: ${config[0]!.message}`); return out.join("\n"); } + const projectConfig = input.diagnostics.filter((d) => d.code === "SC8004"); + if (projectConfig.length > 0) { + out.push( + ` ${c(RED, "not analyzable")}: the project's scriptc configuration has ${projectConfig.length} unknown setting${projectConfig.length === 1 ? "" : "s"}`, + ); + for (const d of projectConfig) { + const p = locator.position(d.loc.file, d.loc.start); + out.push(` ${c(DIM, "SC8004")} ${d.message}`); + out.push(` ${c(DIM, `at ${where({ file: d.loc.file, ...p })}`)}`); + if (d.hint !== undefined) out.push(` ${c(DIM, `hint: ${d.hint}`)}`); + } + return out.join("\n"); + } const tsc = input.diagnostics.filter((d) => d.code === "SC0001"); if (tsc.length > 0) { const world = tsc.filter((d) => d.typeWorld === true).length; @@ -225,6 +251,22 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions out.push(""); } + // Node islands (report-only): what would run in Node, gap islands + // first (DESIGN.md §1.3: a gap island is compiler work an island hides). + if (input.nodeIslands !== undefined && hasNodeIslandDecisions(input.nodeIslands)) { + renderNodeIslands( + input.nodeIslands, + out, + c, + (loc) => { + const p = locator.position(loc.file, loc.start); + return where({ file: loc.file, ...p }); + }, + opts.root, + ); + out.push(""); + } + // The WebAssembly modules the build can see (wasm/discover.ts). A build // translates the valid ones to native code (wasm/translate.ts) when the // program uses the WebAssembly API; the others fail to load at run time. @@ -477,9 +519,24 @@ function displayPath(file: string, root: string | undefined): string { } /** What `coverage --fail-on` checks: reached blockers (they fail a build) - * and, optionally, divergences or the performance warning. Unreached - * blockers never fail. */ -export type CoverageFailOn = "blockers" | "divergences" | "dynamic"; + * and, optionally, divergences, the performance warning or Node islands. + * Unreached blockers never fail. */ +export type CoverageFailOn = + | "blockers" + | "divergences" + | "dynamic" + | "node-islands" + | "eager-node-islands" + | "island-gaps"; + +export const COVERAGE_FAIL_ON: readonly CoverageFailOn[] = [ + "blockers", + "divergences", + "dynamic", + "node-islands", + "eager-node-islands", + "island-gaps", +]; /** Whether the analysis passes the selected gate. Preflight failures * always fail; `failOn` undefined means only preflight fails (the report's @@ -492,6 +549,16 @@ export function coveragePasses(input: CoverageInput, failOn?: CoverageFailOn): b if (input.diagnostics.length > 0) return false; if (failOn === "divergences" && (input.divergences?.length ?? 0) > 0) return false; if (failOn === "dynamic" && (input.performance?.length ?? 0) > 0) return false; + // The Node island gates (report-only islands count: they are what a + // build would choose). Every island evaluates eagerly until deferred + // evaluation lands, so eager-node-islands equals node-islands for now. + // Would-be policy errors (SC8002, SC8003) fail every island gate. + const islands = input.nodeIslands; + if (islands === undefined) return true; + const anyIsland = failOn === "node-islands" || failOn === "eager-node-islands"; + if ((anyIsland || failOn === "island-gaps") && islands.violations.length > 0) return false; + if (anyIsland && (islands.islands.length > 0 || islands.userModules.length > 0)) return false; + if (failOn === "island-gaps" && islands.islands.some((i) => i.class === "gap")) return false; return true; } @@ -555,6 +622,9 @@ export function coverageEnvelope( }), ...(input.tiers !== undefined ? { tiers: tiersEnvelope(input.tiers, locator) } : {}), ...(input.wasmModules !== undefined ? { wasmModules: input.wasmModules } : {}), + ...(input.nodeIslands !== undefined + ? { nodeIslands: nodeIslandsEnvelope(input.nodeIslands, locator) } + : {}), groups, diagnostics: located, }; @@ -692,3 +762,211 @@ export function renderExplainDynamic( } return out.join("\n"); } + +/* ── Node islands (report-only) ───────────────────────────────────────── */ + +const ISLAND_REASONS_SHOWN = 1; + +function islandName(i: { package: string; version: string | null }): string { + return i.version === null ? i.package : `${i.package}@${i.version}`; +} + +function renderNodeIslands( + report: NodeIslandReport, + out: string[], + c: (code: string, s: string) => string, + at: (loc: SrcLoc) => string, + root: string | undefined, +): void { + const n = report.islands.length + report.userModules.length; + out.push( + ` ${c(BOLD, "Node islands")} ${c(DIM, `(would-be, report-only: the build is unchanged; --node-islands=${report.policy})`)}`, + ); + if (n === 0) out.push(` ${c(DIM, "none")}`); + const widest = Math.max(0, ...report.islands.map((i) => i.class.length)); + for (const island of report.islands) { + const tone = island.class === "gap" ? RED : YELLOW; + const decided = + island.decision === "auto" + ? "" + : c( + DIM, + ` (marked by ${island.decision === "cli" ? "--node-island" : "scriptc.packages"})`, + ); + out.push(` ${c(tone, island.class.padEnd(widest))} ${islandName(island)}${decided}`); + for (const r of island.reasons.slice(0, ISLAND_REASONS_SHOWN)) { + const where = r.loc === null ? "" : `${at(r.loc)}: `; + out.push(` ${c(DIM, capMessage(`${where}${describeReason(r)}`))}`); + } + const more = island.reasons.length - ISLAND_REASONS_SHOWN; + const details = [ + more > 0 + ? `+${more} more reason${more === 1 ? "" : "s"} in ${counted(island.modules, "module")}` + : "", + island.carries.length > 0 ? `carries ${island.carries.join(", ")}` : "", + ].filter((t) => t !== ""); + if (details.length > 0) out.push(` ${c(DIM, details.join("; "))}`); + } + for (const m of report.userModules) { + out.push( + ` ${c(YELLOW, "marked".padEnd(widest))} ${displayPath(m.file, root)} ${c(DIM, `(user module, marked by ${m.source === "cli" ? "--node-island" : "nodeIslands.modules"})`)}`, + ); + } + for (const v of report.violations) { + out.push(` ${c(RED, v.code)} ${capMessage(v.message)}`); + } + const shared = report.packages.filter((p) => p.status === "shared"); + if (shared.length > 0) { + out.push( + ` ${c(DIM, `reached from native code and from an island: ${shared.map((p) => p.package).join(", ")}`)}`, + ); + } + const native = report.packages.filter((p) => p.status === "native"); + if (native.length > 0) { + out.push( + ` ${c(DIM, `${counted(native.length, "package")} stay${native.length === 1 ? "s" : ""} native: ${native.map((p) => p.package).join(", ")}`)}`, + ); + } + if (report.unknownPackages.length > 0) { + out.push( + ` ${c(YELLOW, "note")} not imported by the program: ${report.unknownPackages.join(", ")}`, + ); + } + if (report.islands.length > 0) { + out.push( + ` ${c(DIM, "hint: scriptc coverage --explain-island= lists every reason")}`, + ); + } +} + +/** `scriptc coverage --explain-island=`: why a package would run + * in a Node island — every module's first reason, the native import sites + * (the boundary), the packages inside its closure, and what later + * milestones decide. */ +export function renderExplainIsland( + report: NodeIslandReport | undefined, + query: string, + opts: CoverageRenderOptions = {}, +): string { + const c = (code: string, s: string) => (opts.color ? code + s + RESET : s); + const locator = new SourceLocator(opts.sourceTexts); + const at = (loc: SrcLoc) => { + const p = locator.position(loc.file, loc.start); + return `${displayPath(loc.file, opts.root)}:${p.line}:${p.column}`; + }; + const out: string[] = []; + const matches = (report?.islands ?? []).filter( + (i) => i.package === query || islandName(i) === query, + ); + if (matches.length === 0) { + const pkg = (report?.packages ?? []).find((p) => p.package === query); + if (pkg === undefined) { + out.push(` '${query}' is not a Node island: no npm package of that name is in the program`); + } else if (pkg.status === "inside") { + out.push( + ` '${query}' is not a Node island of its own: only ${pkg.islands.join(", ")} import${pkg.islands.length === 1 ? "s" : ""} it, so it would run inside that island`, + ); + } else if (pkg.status === "shared") { + out.push( + ` '${query}' compiles natively and is also reached from the Node island${pkg.islands.length === 1 ? "" : "s"} ${pkg.islands.join(", ")}`, + ); + } else { + out.push(` '${query}' is not a Node island: it compiles natively`); + } + return out.join("\n"); + } + for (const island of matches) { + out.push( + ` ${c(BOLD, "Node island")} ${islandName(island)} ${c(DIM, "(would-be, report-only)")}`, + ); + if (island.path !== null) out.push(` path ${displayPath(island.path, opts.root)}`); + out.push(` class ${island.class}: ${classDescription(island.class)}`); + out.push( + ` decision ${island.decision === "auto" ? "automatic (npm code outside the native subset)" : island.decision === "cli" ? "marked with --node-island" : 'marked "node" in scriptc.packages'}`, + ); + out.push( + ` evaluation eager at its module position ${c(DIM, "(deferred evaluation is decided by a later milestone)")}`, + ); + if (island.reasons.length > 0) { + out.push(` reasons ${c(DIM, "(first per module; decisive class first)")}`); + const shown = new Set(); + for (const r of island.reasons) { + const key = r.loc === null ? "" : r.loc.file; + if (shown.has(key)) continue; + shown.add(key); + const where = r.loc === null ? "package" : at(r.loc); + out.push(` ${r.class.padEnd(12)} ${where}`); + out.push(` ${c(DIM, capMessage(describeReason(r)))}`); + } + const counts = new Map(); + for (const r of island.reasons) counts.set(r.class, (counts.get(r.class) ?? 0) + 1); + const summary = [...counts].map(([cls, k]) => `${k} ${cls}`).join(", "); + out.push(` all reasons ${island.reasons.length} (${summary})`); + } + if (island.importedFrom.length > 0) { + out.push(` boundary ${counted(island.importedFrom.length, "native import site")}:`); + for (const loc of island.importedFrom.slice(0, SITES_SHOWN)) out.push(` ${at(loc)}`); + if (island.importedFrom.length > SITES_SHOWN) + out.push(` ${c(DIM, `+${island.importedFrom.length - SITES_SHOWN} more`)}`); + out.push( + ` ${c(DIM, "copy and handle decisions per boundary site come with compiler-managed islands")}`, + ); + } else { + out.push( + ` boundary ${c(DIM, "no native import site (reached only from other islands)")}`, + ); + } + if (island.carries.length > 0) out.push(` carries ${island.carries.join(", ")}`); + } + return out.join("\n"); +} + +function nodeIslandsEnvelope( + report: NodeIslandReport, + locator: SourceLocator, +): NodeIslandsEnvelope { + const pos = (loc: SrcLoc): SourcePosition => ({ + file: loc.file, + ...locator.position(loc.file, loc.start), + }); + const entry = (i: NodeIslandEntry) => { + const first = i.reasons[0]; + return { + package: i.package, + version: i.version, + path: i.path, + class: i.class, + reason: i.reason, + diagnostic: i.diagnostic, + decision: i.decision, + evaluation: i.evaluation, + ...(first !== undefined && first.loc !== null ? { at: pos(first.loc) } : {}), + reasons: i.reasons.map((r) => ({ + class: r.class, + rule: r.rule, + reason: r.reason, + code: r.code, + message: r.message, + source: r.source, + ...(r.loc !== null ? { at: pos(r.loc) } : {}), + })), + carries: i.carries, + importedFrom: i.importedFrom.map(pos), + }; + }; + return { + mode: report.mode, + policy: report.policy, + islands: report.islands.map(entry), + userModules: report.userModules.map((m) => ({ file: m.file, decision: m.source })), + npmPackages: report.packages.map((p) => ({ + package: p.package, + version: p.version, + path: p.path, + status: p.status, + islands: p.islands, + })), + violations: report.violations.map((v) => ({ code: v.code, message: v.message, ...pos(v.loc) })), + unknownPackages: report.unknownPackages, + }; +} diff --git a/packages/compiler/src/coverage/verdict-cache.ts b/packages/compiler/src/coverage/verdict-cache.ts index e5fa7242e..c92cdc45e 100644 --- a/packages/compiler/src/coverage/verdict-cache.ts +++ b/packages/compiler/src/coverage/verdict-cache.ts @@ -15,13 +15,15 @@ import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; import type { LowerStats } from "../frontend/lowering/lowerer.js"; import type { NpmStaticStatus } from "./report.js"; import type { WasmModuleReport } from "../wasm/discover.js"; +import type { NodeIslandReport } from "../node-islands/summary.js"; +import { nodeIslandOptionsKey } from "../node-islands/policy.js"; import { FrontendInputTracker, frontendInputsStillMatch, type FrontendInputSnapshot, } from "../frontend/input-tracker.js"; -const SCHEMA = "scriptc.coverage-verdict.v2"; +const SCHEMA = "scriptc.coverage-verdict.v3"; /** Key-value storage for verdict entries (best effort: failures miss). */ export interface VerdictStore { @@ -41,6 +43,7 @@ export interface CachedCoverage { performance?: ScrDiagnostic[]; npmStatic?: NpmStaticStatus[]; wasmModules?: WasmModuleReport[]; + nodeIslands?: NodeIslandReport; preflightFailed: boolean; } @@ -130,6 +133,10 @@ export function verdictKey( ...(opts.tier === "static-only" ? ["tier=static-only"] : []), // The module files' contents are probes; their names are options. ...(opts.wasm !== undefined && opts.wasm.length > 0 ? [`wasm=${opts.wasm.join("\n")}`] : []), + // The Node island policy's command-line half shapes the report. + ...(opts.nodeIslands !== undefined + ? [`node-islands=${nodeIslandOptionsKey(opts.nodeIslands)}`] + : []), ].join("\0"), ); } @@ -153,6 +160,7 @@ function cacheable(coverage: AnalyzeResult["coverage"]): CachedCoverage | null { if (coverage.performance !== undefined) out.performance = coverage.performance; if (coverage.npmStatic !== undefined) out.npmStatic = coverage.npmStatic; if (coverage.wasmModules !== undefined) out.wasmModules = coverage.wasmModules; + if (coverage.nodeIslands !== undefined) out.nodeIslands = coverage.nodeIslands; return out; } @@ -171,6 +179,7 @@ function restore(cached: CachedCoverage, sources: CachedSource[]): AnalyzeResult if (cached.performance !== undefined) coverage.performance = cached.performance; if (cached.npmStatic !== undefined) coverage.npmStatic = cached.npmStatic; if (cached.wasmModules !== undefined) coverage.wasmModules = cached.wasmModules; + if (cached.nodeIslands !== undefined) coverage.nodeIslands = cached.nodeIslands; return { coverage, sourceTexts }; } diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 34657008d..c76c0f5c3 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -68,6 +68,13 @@ * declaration types were dropped (SC6102), and shape inference's * overlay report: what it typed and how many inferred facts the * build checks (SC6103, frontend/shape-inference) + * SC8xxx Node island policy: packages and modules outside the native + * subset that run (or, in report-only mode, would run) in a + * Node island — unknown project configuration (SC8004), a + * package required to be native (SC8003) or every island + * refused (SC8002) while outside the native subset, and the + * R2 object-model constructs in user JavaScript (SC8101–SC8105, + * behind --strict-user-js) * SC9xxx internal compiler errors (still source-anchored) */ import type { SrcLoc } from "../ir/ir.js"; @@ -95,6 +102,11 @@ export interface ScrDiagnostic { * the error may not reproduce under the project's tsc. Machine-readable * output reports these under the "environment" category. */ typeWorld?: boolean; + /** Why the construct is outside the native subset, as a Node island + * reason id (node-islands/rules.ts), at the refusal sites whose code + * alone does not say it (a computed require(), a native addon, `with`). + * Report-only: never part of the rendered message. */ + reason?: string; /** A runtime fence in a module's top-level code, outside every function * and class member body: it throws when the module initializes, which is * program startup for a module the entry imports. */ @@ -211,6 +223,75 @@ export function unrelatedClassCastDivergenceDiag( }; } +/* ── SC8xxx: Node island policy ───────────────────────────────────────── */ + +/** SC8004 — an unknown key or value in `scriptc.packages` or + * `scriptc.nodeIslands`. */ +export function projectConfigDiag( + path: string, + detail: string, + hint: string, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8004", + message: `package.json '${path}' ${detail}`, + loc, + hint, + }; +} + +/** SC8002 — Node islands are off (or limited to marked islands) and an + * npm package is outside the native subset. */ +export function nodeIslandsOffDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic { + return { + code: "SC8002", + message: `npm package '${pkg}' is outside the native subset (${why}) and Node islands are off`, + loc, + hint: `allow Node islands (--node-islands=auto), mark it with --node-island=${pkg}, or replace the dependency`, + }; +} + +/** SC8002 — a user module is marked to run in a Node island while + * Node islands are off. */ +export function nodeIslandsOffModuleDiag( + file: string, + source: "config" | "cli", + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8002", + message: `user module '${file}' is marked to run in Node (${source === "cli" ? "--node-island" : "scriptc.nodeIslands.modules"}) and Node islands are off`, + loc, + hint: "allow Node islands (--node-islands=auto or explicit), or remove the marking", + }; +} + +/** SC8003 — a package marked "native" is outside the native subset. */ +export function nativePackageDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic { + return { + code: "SC8003", + message: `npm package '${pkg}' is marked native but is outside the native subset: ${why}`, + loc, + hint: `remove the "native" marking to let it run in a Node island, or replace the construct the reason names`, + }; +} + +/** SC8101–SC8105 — an R2 object-model construct in the program's own + * JavaScript (R2.1 constructor functions … R2.5 builtin patching). */ +export function userJsObjectModelDiag( + code: "SC8101" | "SC8102" | "SC8103" | "SC8104" | "SC8105", + construct: string, + loc: SrcLoc, +): ScrDiagnostic { + return { + code, + message: `${construct} needs a mutable JavaScript object model, which native code does not have`, + loc, + hint: "rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules", + }; +} + /* ── SC5xxx: native FFI ───────────────────────────────────────────────── */ /** SC5001 — the outbound native-FFI manifest is malformed or unreadable. */ @@ -625,7 +706,7 @@ export function unsupportedTypeDiag(typeText: string, loc: SrcLoc): ScrDiagnosti code: "SC2001", message: `values of type '${typeText}' cannot be compiled yet ` + - `(supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown')`, + `(supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown')`, loc, milestone: "M2", }; @@ -776,6 +857,7 @@ export function nativeAddonDiag(specifier: string, loc: SrcLoc): ScrDiagnostic { loc, milestone: "later", hint: "expose the native operation as a plain C ABI function in an object or static archive, then bind a signature-only TypeScript declaration with --ffi; Node-API callbacks themselves require Node (https://scriptc.dev/ffi#replacing-a-node-api-addon)", + reason: "native-addon", }; } diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts index b4de8db77..ad2e31507 100644 --- a/packages/compiler/src/diagnostics/envelope.ts +++ b/packages/compiler/src/diagnostics/envelope.ts @@ -148,10 +148,60 @@ export interface DiagnosticsEnvelope { /** coverage only: the WebAssembly modules the build can see, with their * size, validity, proposals and the sources that found them. */ wasmModules?: WasmModuleReport[]; + /** coverage only, when there is something to report: the packages and + * user modules that would run in a Node island (report-only). */ + nodeIslands?: NodeIslandsEnvelope; groups: DiagnosticGroup[]; diagnostics: LocatedDiagnostic[]; } +/** One reason a package is outside the native subset. */ +export interface NodeIslandReasonEnvelope { + class: string; + /** R1.1–R2.5, or null for gap and packaging reasons. */ + rule: string | null; + reason: string; + code: string | null; + message: string; + source: string; + at?: SourcePosition; +} + +/** `scriptc coverage --print=diagnostics`: the Node island decisions. In + * this version they are would-be decisions (mode "would-be"): the build + * does not change. */ +export interface NodeIslandsEnvelope { + mode: "would-be"; + policy: "auto" | "explicit" | "off"; + /** Gap islands first, then by class and name. */ + islands: { + package: string; + version: string | null; + path: string | null; + class: string; + reason: string; + diagnostic: string | null; + decision: string; + evaluation: string; + at?: SourcePosition; + reasons: NodeIslandReasonEnvelope[]; + carries: string[]; + importedFrom: SourcePosition[]; + }[]; + userModules: { file: string; decision: string }[]; + /** Every npm package that is not an island: native, inside an island's + * closure, or shared between native code and an island. */ + npmPackages: { + package: string; + version: string | null; + path: string | null; + status: string; + islands: string[]; + }[]; + violations: (SourcePosition & { code: string; message: string })[]; + unknownPackages: string[]; +} + /** The divergence band: compiles, but the program can behave differently * from Node. Warnings, never build failures. */ export function isDivergenceCode(code: string): boolean { @@ -166,6 +216,7 @@ export function categoryOf(diag: ScrDiagnostic): DiagnosticCategory { if (code === "SC0003") return "invalid-source"; if (code === "SC0002" || code === "SC0004") return "environment"; if (/^SC[35]\d{3}$/.test(code)) return "environment"; + if (code === "SC8004" || code === "SC8005" || code === "SC8012") return "environment"; if (/^SC9\d{3}$/.test(code)) return "internal"; return "unsupported"; } @@ -186,6 +237,8 @@ export function hintOf(diag: ScrDiagnostic): string { return "restate the value's type inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown')"; if (/^SC2\d{3}$/.test(code)) return "restate the type inside the compilable set (https://scriptc.dev/limitations describes the boundary)"; + if (/^SC8\d{3}$/.test(code)) + return "see the Node island policy: npm code outside the native subset runs in Node, user code must be marked (scriptc coverage --explain-island=)"; if (/^SC9\d{3}$/.test(code)) return "this is a compiler bug — please report it with the program that triggers it"; return "see https://scriptc.dev/limitations"; @@ -281,7 +334,10 @@ export function locateDiagnostic( return { code: diag.code, category: categoryOf(diag), - severity: isDivergenceCode(diag.code) || isPerformanceCode(diag.code) ? "warning" : "error", + severity: + isDivergenceCode(diag.code) || isPerformanceCode(diag.code) || diag.code === "SC8004" + ? "warning" + : "error", scope, file: diag.loc.file, line: start.line, diff --git a/packages/compiler/src/diagnostics/render.ts b/packages/compiler/src/diagnostics/render.ts index e5a75c6bc..16fe3f0bf 100644 --- a/packages/compiler/src/diagnostics/render.ts +++ b/packages/compiler/src/diagnostics/render.ts @@ -102,14 +102,12 @@ export function renderDiagnostic( frame.push( c(DIM, ` ${" ".repeat(gutterWidth)} | `) + " ".repeat(caretCol) + - c(/^SC6\d{3}$/.test(diag.code) ? YELLOW : RED, "^" + "~".repeat(spanOnLine - 1)), + c(isWarningCode(diag.code) ? YELLOW : RED, "^" + "~".repeat(spanOnLine - 1)), ); emitLine(lineNum + 1); } - // The divergence band (SC6xxx) is the one warning severity: the program - // compiles, and the site can behave differently from Node. - const warning = /^SC6\d{3}$/.test(diag.code); + const warning = isWarningCode(diag.code); out.push( `${c(BOLD, `${diag.loc.file}:${lineNum}:${colNum}`)} - ${warning ? c(YELLOW, "warning") : c(RED, "error")} ${c(BOLD, diag.code)}: ${diag.message}`, ); @@ -154,6 +152,27 @@ export function renderDiagnostics( /** Divergence warnings after a successful build: the rendered sites plus a * one-line count, on stderr. */ +/** The warning severities: the divergence band (SC6xxx), where the program + * compiles and the site can behave differently from Node, and unknown + * settings in the project's scriptc configuration (SC8004), which builds do + * not use. */ +export function isWarningCode(code: string): boolean { + return /^SC6\d{3}$/.test(code) || code === "SC8004"; +} + +/** A build's SC8004 warnings: the unknown scriptc settings it ignored. */ +export function renderConfigWarnings( + diags: readonly ScrDiagnostic[], + sourceTextByFile: Map, + opts: RenderOptions = {}, +): string { + const n = diags.length; + return ( + renderDiagnostics([...diags], sourceTextByFile, opts) + + `\n\n${n} warning${n === 1 ? "" : "s"}: the build does not use ${n === 1 ? "this setting" : "these settings"}; scriptc coverage reports Node islands once they are valid.` + ); +} + export function renderWarnings( diags: readonly ScrDiagnostic[], sourceTextByFile: Map, diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts index e1235d8ac..afae8b2c1 100644 --- a/packages/compiler/src/executable/executable-cache.test.ts +++ b/packages/compiler/src/executable/executable-cache.test.ts @@ -69,6 +69,7 @@ const native: EarlyExecutableNativeFeatures = { dh: false, pki: false, ed25519: false, + x509: false, webcrypto: false, perfLoop: false, }; diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 2bfed90f3..52d36bea9 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -62,6 +62,7 @@ export interface EarlyExecutableNativeFeatures { wasm?: boolean; warningReplace?: boolean; stdioExtended?: boolean; + moduleNamespace?: boolean; zlib: boolean; brotli?: boolean; assert: boolean; @@ -104,6 +105,7 @@ export interface EarlyExecutableNativeFeatures { dh: boolean; pki: boolean; ed25519: boolean; + x509: boolean; perfLoop: boolean; tlsCa: boolean; webcrypto: boolean; @@ -254,6 +256,7 @@ const BOOLEAN_NATIVE_KEYS = [ "dh", "pki", "ed25519", + "x509", "webcrypto", "perfLoop", ] as const satisfies readonly (keyof EarlyExecutableNativeFeatures)[]; diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index 3381dc4a3..b6538fe01 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -125,6 +125,10 @@ export function prepareExecutableModule( mod: lowered.module!, sourceTexts, stats: lowered.stats, - warnings: [...(lowered.divergences ?? []), ...(lowered.performance ?? [])], + warnings: [ + ...(fe.warnings ?? []), + ...(lowered.divergences ?? []), + ...(lowered.performance ?? []), + ], }; } diff --git a/packages/compiler/src/frontend/analysis.ts b/packages/compiler/src/frontend/analysis.ts index 776492eb1..848306b66 100644 --- a/packages/compiler/src/frontend/analysis.ts +++ b/packages/compiler/src/frontend/analysis.ts @@ -4,6 +4,10 @@ import type { AnalyzeOptions, AnalyzeResult } from "../compile-types.js"; import { loadFfiProfile, type FfiProfile } from "../ffi/ffi-manifest.js"; import { provenanceSources } from "./provenance-registry.js"; import type { FrontendFactory } from "./pipeline.js"; +import { readProjectConfig } from "../project-config.js"; +import { nodeIslandPolicy } from "../node-islands/policy.js"; +import { buildNodeIslandReport } from "../node-islands/report.js"; +import { hasNodeIslandFindings } from "../node-islands/summary.js"; /** Analysis without codegen: how much of the program compiles statically. * Unlike compile(), lowering diagnostics are data here, not failure. */ @@ -34,7 +38,9 @@ export function analyzeWithFrontend( } ffi = loaded.profile; } - const fe = createFrontend(entryPath, opts.npmStatic, opts.externalTypes); + const fe = createFrontend(entryPath, opts.npmStatic, opts.externalTypes, undefined, { + nodeIslands: true, + }); try { const emptyStats = { statementsTotal: 0, @@ -96,6 +102,23 @@ export function analyzeWithFrontend( ...(opts.wasm !== undefined && opts.wasm.length > 0 ? { wasmFiles: opts.wasm } : {}), }); const provenance = provenanceSources(); + // Node islands, report-only: which packages and modules would run in + // Node, from the detector's scan and the policy. Code generation does + // not depend on it. + const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands); + const islandReport = + fe.nodeIslandScan === undefined + ? null + : buildNodeIslandReport({ + entry: entryPath, + scan: fe.nodeIslandScan({ userJs: policy.strictUserJs }), + npm: fe.npmStatic, + diagnostics: [...lowered.diagnostics, ...(lowered.unreached?.diagnostics ?? [])], + fences: [...lowered.runtimeFences, ...(lowered.unreached?.runtimeFences ?? [])], + policy, + }); + const nodeIslands = + islandReport !== null && hasNodeIslandFindings(islandReport) ? islandReport : null; return { coverage: { file: entryPath, @@ -103,7 +126,9 @@ export function analyzeWithFrontend( // The import fences report as blockers alongside the statement-level // ones (use sites of the fenced bindings emit matching diagnostics, // which the report groups with these). - diagnostics: [...preflight, ...lowered.diagnostics], + // --strict-user-js: R2 constructs in user JavaScript are blockers + // (SC8101–SC8105), as scope B's builds will make them errors. + diagnostics: [...preflight, ...lowered.diagnostics, ...(nodeIslands?.userJs ?? [])], ...(lowered.runtimeFences.length > 0 ? { runtimeFences: lowered.runtimeFences } : {}), ...(lowered.divergences !== undefined ? { divergences: lowered.divergences } : {}), ...(lowered.performance !== undefined ? { performance: lowered.performance } : {}), @@ -116,6 +141,7 @@ export function analyzeWithFrontend( ...(lowered.provenanceElided ? { provenanceElided: lowered.provenanceElided } : {}), ...(lowered.tiers !== undefined ? { tiers: lowered.tiers } : {}), ...(lowered.wasmModules !== undefined ? { wasmModules: lowered.wasmModules } : {}), + ...(nodeIslands !== null ? { nodeIslands } : {}), preflightFailed: false, }, sourceTexts: fe.sourceTexts(), diff --git a/packages/compiler/src/frontend/lowering/abstract-equality.ts b/packages/compiler/src/frontend/lowering/abstract-equality.ts index 6c74b3546..634ff6f22 100644 --- a/packages/compiler/src/frontend/lowering/abstract-equality.ts +++ b/packages/compiler/src/frontend/lowering/abstract-equality.ts @@ -5,6 +5,7 @@ import { DYN, DYN_HANDLE_KINDS, F64, + STRING, type IrExpr, type IrStmt, type IrType, @@ -285,6 +286,43 @@ export function lowerAbstractEquality( }; return negated ? { kind: "unary", op: "!", operand: result, type: BOOL, loc } : result; } + // Two Dates are equal when they are the same object, as with ===. + if (left.type.kind === "date" && right.type.kind === "date") + return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; + // A Date and a string or number compare through the Date's default + // primitive, its toString() text. + if (left.type.kind === "date" || right.type.kind === "date") { + const otherType = left.type.kind === "date" ? right.type : left.type; + if (otherType.kind === "string" || otherType.kind === "f64") { + // Both operands evaluate before the Date converts. + const l = lowerer.declareHiddenLocal("%looseA", left.type); + const r = lowerer.declareHiddenLocal("%looseB", right.type); + const stmts: IrStmt[] = [ + { kind: "varDecl", localId: l.id, init: left, loc }, + { kind: "varDecl", localId: r.id, init: right, loc }, + ]; + const primitive = (value: IrExpr): IrExpr => { + if (value.type.kind !== "date") return value; + const text: IrExpr = { + kind: "libCall", + fn: "date.toString", + args: [value], + type: STRING, + loc, + }; + return otherType.kind === "string" + ? text + : { kind: "libCall", fn: "num.fromString", args: [text], type: F64, loc }; + }; + const a = primitive(varRef(l.id, left.type, loc)); + const b = primitive(varRef(r.id, right.type, loc)); + const result: IrExpr = + otherType.kind === "string" + ? { kind: "strEq", negated, left: a, right: b, type: BOOL, loc } + : { kind: "bin", op: negated ? "!==" : "===", left: a, right: b, type: BOOL, loc }; + return { kind: "seqExpr", stmts, result, type: BOOL, loc }; + } + } if (!supportsPair(lowerer, left.type, right.type)) return null; if (left.kind === "strLit" && right.kind === "strLit") { return { kind: "boolLit", value: (left.value === right.value) !== negated, type: BOOL, loc }; diff --git a/packages/compiler/src/frontend/lowering/argument-counts.ts b/packages/compiler/src/frontend/lowering/argument-counts.ts new file mode 100644 index 000000000..d79e7b43f --- /dev/null +++ b/packages/compiler/src/frontend/lowering/argument-counts.ts @@ -0,0 +1,229 @@ +import * as ts from "../ts7/adapter.js"; +import { bodyReadsArguments } from "../arguments-usage.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import { BOOL, F64, type IrExpr, type IrStmt, type IrType } from "../../ir/ir.js"; +import { varRef } from "../../ir/build.js"; +import { typeKey } from "../type-mapper.js"; +import type { Lowerer } from "./lowerer.js"; +import { isThisParameter } from "./call-signatures.js"; +import { buildFunctionAdapter } from "./coercions/builders.js"; +import { keyedHelperName } from "./stable-names.js"; + +/** `arguments.length` in a top-level function declaration that the program + * also uses as a value. A direct call passes the declared parameters (tsc + * checks the arity), while a call through the value passes the parameter + * count of the function type the value was passed as (`[1].map(f)` calls f + * with 3). The value is an adapter that stores that count in a global slot + * before forwarding; the function reads the slot on entry and clears it, + * so a direct call (the cleared slot) sees the declared count. */ + +/** The count slot of `decl`, or null when its `arguments.length` folds to + * the declared count (or the declaration does not qualify). */ +export function argumentCountSlot(lowerer: Lowerer, decl: ts.FunctionDeclaration): string | null { + const cached = lowerer.argumentCountSlots.get(decl); + if (cached !== undefined) return cached; + let slot: string | null = null; + if ( + decl.body !== undefined && + decl.name !== undefined && + decl.asteriskToken === undefined && + !decl.typeParameters?.nodes.length && + lowerer.isTopLevelFnSymbol(decl.name) && + decl.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) !== true && + !isJsSourceFile(ts.getSourceFileOfNode(decl)) && + fixedArity(decl) && + bodyReadsArguments(decl) && + usedAsValue(lowerer, decl) + ) { + const sig = lowerer.fnSigOf(decl.name); + if (sig) { + slot = `%g.argc.${sig.name}`; + lowerer.globalsList.push({ id: slot, name: "%argc", type: F64, mutable: true }); + } + } + lowerer.argumentCountSlots.set(decl, slot); + return slot; +} + +/** Reads `decl`'s count slot into a hidden local on entry and clears it; the + * local holds the declared count when the slot was clear. */ +export function declareArgumentCount( + lowerer: Lowerer, + decl: ts.FunctionDeclaration, + prologue: IrStmt[], +): void { + const slot = argumentCountSlot(lowerer, decl); + if (slot === null) return; + const loc = locOf(decl); + const local = lowerer.declareHiddenLocal("%argc", F64); + const stored = varRef(slot, F64, loc); + prologue.push( + { + kind: "varDecl", + localId: local.id, + init: { + kind: "ternary", + cond: { + kind: "bin", + op: "===", + left: stored, + right: { kind: "numLit", value: 0, type: F64, loc }, + type: BOOL, + loc, + }, + then: { kind: "numLit", value: declaredCount(decl), type: F64, loc }, + else_: stored, + type: F64, + loc, + }, + loc, + }, + { kind: "assign", localId: slot, value: { kind: "numLit", value: 0, type: F64, loc }, loc }, + ); + lowerer.ctx.argumentCount = local; +} + +/** `decl` as a value at `reference`: the plain closure when every call + * through the expected function type passes the declared count, otherwise + * an adapter that stores the expected type's count in the slot first. */ +export function argumentCountValue( + lowerer: Lowerer, + decl: ts.FunctionDeclaration, + reference: ts.Expression, + closure: IrExpr & { type: IrType & { kind: "func" } }, +): IrExpr { + const slot = argumentCountSlot(lowerer, decl); + if (slot === null) return closure; + const count = contextualCallCount(lowerer, reference); + if (count === null || count === "varies") { + lowerer.unsupported( + "SC1090", + reference, + count === null + ? "functions that read 'arguments.length' used as values where no function type is expected (the count each call passes is not known)" + : "functions that read 'arguments.length' used as values where the expected function type has optional or rest parameters (the count varies per call)", + ); + } + const declared = declaredCount(decl); + if (count <= declared) return closure; + const loc = locOf(reference); + const type = closure.type; + const key = `argc:${slot}:${count}:${typeKey(type)}`; + let name = lowerer.arrHofHelpers.get(key); + if (name === undefined) { + name = keyedHelperName(lowerer, "%argc.adapter", key); + lowerer.arrHofHelpers.set(key, name); + const params = type.params.map((t, i) => ({ localId: `p${i}.0`, name: `p${i}`, type: t })); + lowerer.liftedFns.push( + ...buildFunctionAdapter( + name, + type, + type, + params, + [], + [ + { + kind: "assign", + localId: slot, + value: { kind: "numLit", value: count, type: F64, loc }, + loc, + }, + { + kind: "return", + value: { + kind: "callValue", + callee: varRef("f.0", type, loc), + args: params.map((p) => varRef(p.localId, p.type, loc)), + type: type.ret, + loc, + }, + loc, + }, + ], + loc, + ), + ); + } + return { kind: "call", callee: name, args: [closure], type, loc }; +} + +/** The argument count every call through the function type `node` is + * expected as passes: that type's parameter count when it has one call + * signature with a fixed parameter list (a `this` parameter is type-only), + * "varies" when it has optional or rest parameters, null when there is no + * such type. */ +export function contextualCallCount( + lowerer: Lowerer, + node: ts.Expression, +): number | "varies" | null { + const contextual = lowerer.checker.getContextualType(node); + if (contextual === undefined) return null; + const signatures = lowerer.checker.getCallSignatures( + lowerer.checker.getNonNullableType(contextual), + ); + if (signatures.length !== 1) return null; + const declaration = signatures[0]!.declaration; + if (declaration === undefined) return null; + let count = 0; + for (const parameter of (declaration.parameters?.nodes ?? []) as ts.ParameterDeclaration[]) { + if (isThisParameter(parameter)) continue; + if (parameter.questionToken || parameter.initializer || parameter.dotDotDotToken) + return "varies"; + count++; + } + return count; +} + +/** Whether a program reference to the function declaration or method `fn` + * reads it as a value (`xs.map(f)`, `const g = f`, `xs.map(C.parse)`) rather + * than calling it. Export specifiers and default exports only name a + * function; the importing files' references resolve to it and are checked + * the same way. */ +export function usedAsValue( + lowerer: Lowerer, + fn: ts.FunctionDeclaration | ts.MethodDeclaration, +): boolean { + const name = fn.name; + if (!name || !(ts.isIdentifier(name) || ts.isPrivateIdentifier(name))) return false; + const method = ts.isMethodDeclaration(fn); + const symbolAt = (n: ts.Node): ts.Symbol | null | undefined => + method + ? lowerer.checker.getSymbolAtLocation(n) + : lowerer.resolveValueSymbol(n as ts.Identifier); + const symbol = symbolAt(name); + if (!symbol) return false; + let found = false; + for (const sf of lowerer.program.getImplementationSourceFiles()) { + ts.walkPreorder(sf, (n) => { + if (found) return "stop"; + if (ts.isTypeNode(n)) return "skip"; + if (n === name || !(ts.isIdentifier(n) || ts.isPrivateIdentifier(n))) return undefined; + if (n.text !== name.text || !n.parent) return undefined; + const parent = n.parent; + // A method is read through its member name (`C.parse`, `this.#m`). + const read = ts.isPropertyAccessExpression(parent) && parent.name === n ? parent : n; + if (method && read === n && !ts.isBindingElement(parent)) return undefined; + const use = read.parent; + if (use && ts.isCallExpression(use) && use.expression === read) return undefined; + if (ts.isExportSpecifier(parent) || ts.isExportAssignment(parent)) return undefined; + if (ts.isImportSpecifier(parent) || ts.isImportClause(parent)) return undefined; + if (symbolAt(n) === symbol) found = true; + return undefined; + }); + if (found) break; + } + return found; +} + +function fixedArity(fn: ts.FunctionLikeDeclaration): boolean { + return fn.parameters.nodes.every( + (p) => + p.questionToken === undefined && + p.initializer === undefined && + p.dotDotDotToken === undefined, + ); +} + +function declaredCount(fn: ts.FunctionLikeDeclaration): number { + return fn.parameters.nodes.filter((p) => !isThisParameter(p)).length; +} diff --git a/packages/compiler/src/frontend/lowering/binding-analysis.ts b/packages/compiler/src/frontend/lowering/binding-analysis.ts index 1987c86ba..c88c06c90 100644 --- a/packages/compiler/src/frontend/lowering/binding-analysis.ts +++ b/packages/compiler/src/frontend/lowering/binding-analysis.ts @@ -19,6 +19,18 @@ export function bindingNeverReassigned( ): boolean { const cached = lowerer.neverReassignedCache.get(sym); if (cached !== undefined) return cached; + const written = fileWritesBinding(sym, ts.getSourceFileOfNode(decl), resolve); + lowerer.neverReassignedCache.set(sym, !written); + return !written; +} + +/** True when something in `file` writes `sym` (the writes + * bindingNeverReassigned counts), resolving identifiers with `resolve`. */ +export function fileWritesBinding( + sym: ts.Symbol, + file: ts.SourceFile, + resolve: (ident: ts.Identifier) => ts.Symbol | null | undefined, +): boolean { let written = false; // Text pre-check keeps the file walk cheap: only same-named // identifiers pay a symbol resolution. @@ -62,9 +74,8 @@ export function bindingNeverReassigned( } ts.forEachChild(n, visit); }; - ts.forEachChild(ts.getSourceFileOfNode(decl), visit); - lowerer.neverReassignedCache.set(sym, !written); - return !written; + ts.forEachChild(file, visit); + return written; } /** Strips the value-preserving wrappers off an expression: parens, diff --git a/packages/compiler/src/frontend/lowering/builtin-namespaces.ts b/packages/compiler/src/frontend/lowering/builtin-namespaces.ts new file mode 100644 index 000000000..c1e40d2e6 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtin-namespaces.ts @@ -0,0 +1,286 @@ +/** The standard namespace objects (`Math`, `JSON`, `Reflect`) read as + * JavaScript values. Each is one object per program, built on first use, + * with Node's own properties in Node's order: the functions are writable, + * configurable and non-enumerable, the Math constants are read-only and + * non-configurable, and `Symbol.toStringTag` names the namespace. Function + * members hold the same function values the spelled `Math.abs` read gives; + * members without one hold a function that throws the named fence when + * called. Direct member calls (`Math.abs(x)`) keep their static lowerings. */ +import { + BOOL, + DYN, + F64, + STRING, + SYMBOL_T, + VOID, + type IrExpr, + type IrStmt, + type SrcLoc, + funcOf, +} from "../../ir/ir.js"; +import { helperLoc, varRef } from "../../ir/build.js"; +import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; +import type { Lowerer } from "./lowerer.js"; +import { InternalCompilerError } from "../../errors.js"; +import { mathMemberValue } from "./lower-stdlib.js"; +import { jsonMemberValue } from "./builtins/json.js"; +import { reflectMemberValue } from "./builtins/reflection.js"; + +/** A function member with its `length`, or a constant (`length` -1). */ +interface NamespaceMember { + readonly key: string; + readonly length: number; +} + +/** Node 24's own string-keyed properties of the namespace `name`, in + * order, or null when `name` is not a namespace. */ +function namespaceMembers(name: string): readonly NamespaceMember[] | null { + switch (name) { + case "Math": + return [ + { key: "abs", length: 1 }, + { key: "acos", length: 1 }, + { key: "acosh", length: 1 }, + { key: "asin", length: 1 }, + { key: "asinh", length: 1 }, + { key: "atan", length: 1 }, + { key: "atanh", length: 1 }, + { key: "atan2", length: 2 }, + { key: "ceil", length: 1 }, + { key: "cbrt", length: 1 }, + { key: "expm1", length: 1 }, + { key: "clz32", length: 1 }, + { key: "cos", length: 1 }, + { key: "cosh", length: 1 }, + { key: "exp", length: 1 }, + { key: "floor", length: 1 }, + { key: "fround", length: 1 }, + { key: "hypot", length: 2 }, + { key: "imul", length: 2 }, + { key: "log", length: 1 }, + { key: "log1p", length: 1 }, + { key: "log2", length: 1 }, + { key: "log10", length: 1 }, + { key: "max", length: 2 }, + { key: "min", length: 2 }, + { key: "pow", length: 2 }, + { key: "random", length: 0 }, + { key: "round", length: 1 }, + { key: "sign", length: 1 }, + { key: "sin", length: 1 }, + { key: "sinh", length: 1 }, + { key: "sqrt", length: 1 }, + { key: "tan", length: 1 }, + { key: "tanh", length: 1 }, + { key: "trunc", length: 1 }, + { key: "E", length: -1 }, + { key: "LN10", length: -1 }, + { key: "LN2", length: -1 }, + { key: "LOG10E", length: -1 }, + { key: "LOG2E", length: -1 }, + { key: "PI", length: -1 }, + { key: "SQRT1_2", length: -1 }, + { key: "SQRT2", length: -1 }, + { key: "f16round", length: 1 }, + ]; + case "JSON": + return [ + { key: "parse", length: 2 }, + { key: "stringify", length: 3 }, + { key: "rawJSON", length: 1 }, + { key: "isRawJSON", length: 1 }, + ]; + case "Reflect": + return [ + { key: "defineProperty", length: 3 }, + { key: "deleteProperty", length: 2 }, + { key: "apply", length: 3 }, + { key: "construct", length: 2 }, + { key: "get", length: 2 }, + { key: "getOwnPropertyDescriptor", length: 2 }, + { key: "getPrototypeOf", length: 1 }, + { key: "has", length: 2 }, + { key: "isExtensible", length: 1 }, + { key: "ownKeys", length: 1 }, + { key: "preventExtensions", length: 1 }, + { key: "set", length: 3 }, + { key: "setPrototypeOf", length: 2 }, + ]; + default: + return null; + } +} + +/** Property attribute bits for dyn.namespaceDefine (the members are never + * enumerable). */ +const WRITABLE = 1; +const CONFIGURABLE = 4; + +/** The namespace object the stdlib global `canonical` reads as in + * JavaScript, or null when it is not a namespace. */ +export function builtinNamespaceValue( + lowerer: Lowerer, + canonical: string, + loc: SrcLoc, +): IrExpr | null { + const members = namespaceMembers(canonical); + if (members === null) return null; + const name = `%builtin.namespace.${canonical}`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) + defineNamespaceBuilder(lowerer, canonical, members, name, helperLoc(loc)); + lowerer.noteEdge(name); + return { kind: "call", callee: name, args: [], type: DYN, loc }; +} + +/** The lifted function that builds the namespace object once and answers + * the same object afterwards. */ +function defineNamespaceBuilder( + lowerer: Lowerer, + canonical: string, + members: readonly NamespaceMember[], + name: string, + loc: SrcLoc, +): void { + const storage = `%g.${name}`; + const ready = `${storage}%ready`; + lowerer.globalsList.push( + { id: storage, name: `${canonical} namespace`, type: DYN, mutable: true }, + { id: ready, name: "%ready", type: BOOL, mutable: true }, + ); + const object = varRef("namespace.0", DYN, loc); + const body: IrStmt[] = [ + { + kind: "if", + cond: varRef(ready, BOOL, loc), + then: [{ kind: "return", value: varRef(storage, DYN, loc), loc }], + else_: null, + loc, + }, + { + kind: "varDecl", + localId: "namespace.0", + init: { + kind: "libCall", + fn: "dyn.namespaceNew", + args: [ + { kind: "strLit", value: canonical, type: STRING, loc }, + { + kind: "dynFrom", + value: { + kind: "libCall", + fn: "sym.wellKnown", + args: [{ kind: "strLit", value: "toStringTag", type: STRING, loc }], + type: SYMBOL_T, + loc, + }, + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }, + loc, + }, + ]; + for (const { key, length } of members) { + const value = + length < 0 + ? constantValue(lowerer, canonical, key, loc) + : functionValue(lowerer, canonical, key, loc); + const attributes = length < 0 ? 0 : WRITABLE | CONFIGURABLE; + body.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.namespaceDefine", + args: [ + object, + { kind: "strLit", value: key, type: STRING, loc }, + value, + { kind: "numLit", value: attributes, type: F64, loc }, + { kind: "numLit", value: length, type: F64, loc }, + ], + type: VOID, + loc, + }, + loc, + }); + } + body.push( + { kind: "assign", localId: storage, value: object, loc }, + { + kind: "assign", + localId: ready, + value: { kind: "boolLit", value: true, type: BOOL, loc }, + loc, + }, + { kind: "return", value: varRef(storage, DYN, loc), loc }, + ); + lowerer.liftedFns.push({ + name, + params: [], + locals: [{ id: "namespace.0", name: "namespace", type: DYN, mutable: false }], + returnType: DYN, + body, + loc, + }); +} + +/** A Math constant, boxed. */ +function constantValue(lowerer: Lowerer, canonical: string, key: string, loc: SrcLoc): IrExpr { + const value = canonical === "Math" ? mathMemberValue(lowerer, key, loc) : null; + if (value === null) + throw new InternalCompilerError( + `builtin namespace '${canonical}.${key}' has no constant value`, + ); + return { kind: "dynFrom", value, type: DYN, loc }; +} + +/** A function member: its function value under its own name, or a function + * that throws the named fence when called. */ +function functionValue(lowerer: Lowerer, canonical: string, key: string, loc: SrcLoc): IrExpr { + const value = + canonical === "Math" + ? mathMemberValue(lowerer, key, loc) + : canonical === "JSON" + ? jsonMemberValue(lowerer, key, loc) + : reflectMemberValue(lowerer, key, loc); + if (value !== null) + return value.type.kind === "dyn" + ? value + : { kind: "dynFrom", value, fnName: key, type: DYN, loc }; + return fencedMember(lowerer, `${canonical}.${key}`, key, loc); +} + +/** One function per member without a function value: calling it throws the + * SC1090 fence, while `typeof`, identity and the property's presence stay + * as in Node. */ +function fencedMember(lowerer: Lowerer, display: string, key: string, loc: SrcLoc): IrExpr { + const name = `%builtin.fence.${display}`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const diag = unsupportedDiag( + "SC1090", + loc, + `'${display}' as a function value (call it directly)`, + ); + lowerer.runtimeFences.push(diag); + lowerer.liftedFns.push({ + name, + params: [], + locals: [], + returnType: DYN, + body: [ + { kind: "runtimeFence", code: diag.code, message: `${diag.message} [${diag.code}]`, loc }, + ], + loc, + }); + } + return { + kind: "dynFrom", + value: { kind: "closure", fnName: name, captures: [], type: funcOf([], DYN), loc }, + fnName: key, + type: DYN, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/builtins/date-classes.ts b/packages/compiler/src/frontend/lowering/builtins/date-classes.ts index a2b0fc20a..9d152de56 100644 --- a/packages/compiler/src/frontend/lowering/builtins/date-classes.ts +++ b/packages/compiler/src/frontend/lowering/builtins/date-classes.ts @@ -2,7 +2,13 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; import { type ClassInfo } from "../lower-classes.js"; -import { DATE_METHOD_HINT, dateJsonType, dateValueMethod } from "./dates.js"; +import { + DATE_METHOD_HINT, + dateJsonType, + dateSetterTime, + dateValueMethod, + isDateSetter, +} from "./dates.js"; import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; import { BOOL, @@ -248,6 +254,22 @@ export function lowerInheritedDateMethodCall( type: result.type, loc, }); + if (isDateSetter(name) && name !== "setTime") { + // The field setters change the Date the class's time slot holds once + // the arguments are evaluated: an argument that calls setTime on the + // instance gives the slot a new Date. + const date = lowerer.declareHiddenLocal("%dateTime", DATE_T); + const current = varRef(date.id, DATE_T, loc); + const time: IrExpr = { kind: "libCall", fn: "date.getTime", args: [current], type: F64, loc }; + const next = dateSetterTime(lowerer, call, name, stmts, time); + stmts.push({ + kind: "varDecl", + localId: date.id, + init: timeOf(lowerer, value, root, loc), + loc, + }); + return done({ kind: "libCall", fn: "date.store", args: [current, next], type: F64, loc }); + } if (name === "setTime") { // Every argument is evaluated before setTime converts the first one. let ms: IrExpr = { kind: "numLit", value: Number.NaN, type: F64, loc }; diff --git a/packages/compiler/src/frontend/lowering/builtins/dates.ts b/packages/compiler/src/frontend/lowering/builtins/dates.ts index 0204386ab..075047606 100644 --- a/packages/compiler/src/frontend/lowering/builtins/dates.ts +++ b/packages/compiler/src/frontend/lowering/builtins/dates.ts @@ -2,12 +2,15 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer, own } from "../lowerer.js"; import { locOf } from "../../program.js"; import { tryLowerExpression } from "../expressions/try-lower-expression.js"; +import { lowerToNumberArguments } from "../lower-exprs.js"; import { programDeclaresDateClass } from "./date-classes.js"; import { BOOL, + DATE_T, F64, type IrExpr, type IrLibFn, + type IrLocal, type IrStmt, type IrType, NULL_T, @@ -36,6 +39,32 @@ const DATE_GETTER_FNS: Readonly> = { getTimezoneOffset: "date.getTimezoneOffset", }; +/** The setters that change calendar fields: the field code + * scr_date_set_fields takes (scr_number.h SCR_DATE_SET_*; 8 marks the UTC + * forms) and the most arguments each takes. */ +const DATE_FIELD_SETTERS: ReadonlyMap = new Map([ + ["setMilliseconds", { code: 0, max: 1 }], + ["setSeconds", { code: 1, max: 2 }], + ["setMinutes", { code: 2, max: 3 }], + ["setHours", { code: 3, max: 4 }], + ["setDate", { code: 4, max: 1 }], + ["setMonth", { code: 5, max: 2 }], + ["setFullYear", { code: 6, max: 3 }], + ["setUTCMilliseconds", { code: 8, max: 1 }], + ["setUTCSeconds", { code: 9, max: 2 }], + ["setUTCMinutes", { code: 10, max: 3 }], + ["setUTCHours", { code: 11, max: 4 }], + ["setUTCDate", { code: 12, max: 1 }], + ["setUTCMonth", { code: 13, max: 2 }], + ["setUTCFullYear", { code: 14, max: 3 }], +]); + +/** Whether `name` is a Date setter scriptc lowers: setTime or a field + * setter. */ +export function isDateSetter(name: string): boolean { + return name === "setTime" || DATE_FIELD_SETTERS.has(name); +} + const DATE_TEXT_FNS: ReadonlyMap = new Map([ ["toString", "date.toString"], ["toDateString", "date.toDateString"], @@ -43,11 +72,11 @@ const DATE_TEXT_FNS: ReadonlyMap = new Map([ ]); export const DATE_METHOD_HINT = - "getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale formatters have no lowering"; + "getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering"; -/** The Date slice: statics plus read-only Date values backed by one - * TimeClip'd millisecond scalar. Construction lives in lowerNew; - * identity and mutating methods remain fenced. */ +/** Date statics and methods on Date objects (a TimeClip'd millisecond + * time value with identity): getters, text forms and setters. + * Construction lives in lowerNew. */ export function lowerDateCall( lowerer: Lowerer, call: ts.CallExpression, @@ -67,16 +96,18 @@ export function lowerDateCall( if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "date") return null; if (!lowerer.isStdlibMember(access)) return null; const name = access.name.text; - // A Date a JavaScript file constructs is a mutable object; setTime on - // it takes the dynamic method call. So does every Date method on a + // A Date a JavaScript file constructs is a checked-dynamic value; a + // setter on it takes the dynamic method call, which changes the Date the + // handle holds. So does every Date method on a // checked-dynamic receiver when the program declares a class that extends // Date: the value can be an instance, whose class may declare the method. if ( - (name === "setTime" || programDeclaresDateClass(lowerer)) && + (isDateSetter(name) || programDeclaresDateClass(lowerer)) && tryLowerExpression(lowerer, access.expression)?.type.kind === "dyn" ) return null; if (name === "toJSON") return lowerDateToJson(lowerer, call, access); + if (isDateSetter(name)) return lowerDateSetter(lowerer, call, access, name); if (call.arguments.nodes.length !== 0) { lowerer.noLowering(`Date.prototype.${name} with arguments`, call, DATE_METHOD_HINT); } @@ -253,3 +284,151 @@ export function isDateJsonSource(lowerer: Lowerer, node: ts.Node): boolean { lowerer.isStdlibMember(access) ); } + +/** `d.setTime(t)` and the field setters (`d.setHours(h, m?, s?, ms?)` ...) + * on a Date object: the receiver, then the arguments in order, then the + * new time value (scr_date_set_fields over the time value the Date holds + * once the arguments have run) stored into the Date and answered. Missing + * optional arguments keep their fields; arguments past a setter's last + * one are evaluated and ignored. A Date local that holds its time value + * (date-values.ts) takes the new time value itself. */ +function lowerDateSetter( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + name: string, +): IrExpr { + const loc = locOf(call); + const nodes = call.arguments.nodes; + if (nodes.some((node) => ts.isSpreadElement(node))) + lowerer.noLowering(`Date.prototype.${name} with spread arguments`, call, DATE_METHOD_HINT); + const timeLocal = timeValueLocalOf(lowerer, access.expression); + const stmts: IrStmt[] = []; + let receiver: IrExpr | null = null; + if (timeLocal === null) { + // A checked-dynamic receiver takes the dynamic method call + // (lowerDateCall), so the value here is a Date object. + const value = lowerer.lowerExpr(access.expression); + if (value.type.kind !== "date") + lowerer.badType(access.expression, lowerer.typeOf(access.expression)); + const date = lowerer.declareHiddenLocal("%date", DATE_T); + stmts.push({ kind: "varDecl", localId: date.id, init: value, loc }); + receiver = { kind: "varRef", localId: date.id, type: DATE_T, loc }; + } + const time: IrExpr = + receiver === null + ? { kind: "varRef", localId: timeLocal!.id, type: F64, loc } + : { kind: "libCall", fn: "date.getTime", args: [receiver], type: F64, loc }; + const next = dateSetterTime(lowerer, call, name, stmts, time); + if (receiver === null) { + stmts.push({ kind: "assign", localId: timeLocal!.id, value: next, loc }); + return { + kind: "seqExpr", + stmts, + result: { kind: "varRef", localId: timeLocal!.id, type: F64, loc }, + type: F64, + loc, + }; + } + return { + kind: "seqExpr", + stmts, + result: { kind: "libCall", fn: "date.store", args: [receiver, next], type: F64, loc }, + type: F64, + loc, + }; +} + +/** The new time value of setter `name` called with `call`'s arguments on a + * Date whose time value `time` reads: the arguments evaluate into + * temporaries appended to `stmts` (ToNumber of each, an undefined one is + * NaN), and the result reads `time` after them. A missing first argument + * is NaN; missing optional arguments keep their fields; arguments past a + * setter's last one are evaluated and ignored. */ +export function dateSetterTime( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + stmts: IrStmt[], + time: IrExpr, +): IrExpr { + const loc = locOf(call); + const converted = lowerToNumberArguments( + lowerer, + call.arguments.nodes as readonly ts.Expression[], + ); + stmts.push(...converted.stmts); + const args: IrExpr[] = converted.values.map((value) => { + if (value.kind === "numLit") return value; + const arg = lowerer.declareHiddenLocal("%dateArg", F64); + stmts.push({ kind: "varDecl", localId: arg.id, init: value, loc }); + return { kind: "varRef", localId: arg.id, type: F64, loc }; + }); + const number = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); + if (name === "setTime") + return { + kind: "libCall", + fn: "date.timeClip", + args: [args[0] ?? number(Number.NaN)], + type: F64, + loc, + }; + const setter = DATE_FIELD_SETTERS.get(name)!; + // The first argument is required: a missing one is undefined, so NaN. + const used = args.length === 0 ? [number(Number.NaN)] : args.slice(0, setter.max); + const operands = [...used]; + while (operands.length < 4) operands.push(number(0)); + return { + kind: "libCall", + fn: "date.setFields", + args: [time, number(setter.code), number(used.length), ...operands], + type: F64, + loc, + }; +} + +/** The local a Date binding that holds its time value lowers to, for an + * identifier `node` that names one (date-values.ts); null otherwise. */ +function timeValueLocalOf(lowerer: Lowerer, node: ts.Expression): IrLocal | null { + while (ts.isParenthesizedExpression(node)) node = node.expression; + if (!ts.isIdentifier(node)) return null; + const local = lowerer.resolveLocal(node); + return local !== undefined && local !== null && lowerer.timeValueDateLocals.has(local) + ? local + : null; +} + +/** `d[Symbol.toPrimitive](hint)` on a Date with a literal hint: "number" + * answers the time value, "string" and "default" the toString() text (a + * Date's default hint is a string). Null for other calls. */ +export function lowerDateToPrimitiveCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.ElementAccessExpression, +): IrExpr | null { + const key = access.argumentExpression; + if ( + !ts.isPropertyAccessExpression(key) || + key.name.text !== "toPrimitive" || + !ts.isIdentifier(key.expression) || + !lowerer.isStdlibGlobal(key.expression, "Symbol") || + lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "date" + ) + return null; + const loc = locOf(call); + const hint = call.arguments.nodes.length === 1 ? call.arguments.nodes[0]! : null; + const text = hint !== null && ts.isStringLiteralLike(hint) ? hint.text : null; + if (text !== "number" && text !== "string" && text !== "default") + return lowerer.noLowering( + 'Date.prototype[Symbol.toPrimitive] without a "number", "string" or "default" literal hint', + call, + ); + const raw = lowerer.lowerExpr(access.expression); + const date: IrExpr = + raw.type.kind === "dyn" + ? { kind: "libCall", fn: "date.checkedValue", args: [raw], type: DATE_T, loc } + : raw; + return text === "number" + ? { kind: "libCall", fn: "date.valueOf", args: [date], type: F64, loc } + : { kind: "libCall", fn: "date.toString", args: [date], type: STRING, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 0bbc3b3fa..6b5e2d8ae 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -2,7 +2,7 @@ import { dynUndefinedExpr, boolLit, numLit, varRef } from "../../../ir/build.js" import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { lowerFsPath } from "./fs-paths.js"; -import { locOf } from "../../program.js"; +import { isJsSourceFile, locOf } from "../../program.js"; import { type BuiltinModuleFn, FS_WRITE_FILE_DOCUMENTED_OPTIONS, @@ -403,6 +403,28 @@ export function lowerFsReadSyncCall( } lowerer.noLowering("readSync with a non-literal options argument", optionsNode, supported); } + // JavaScript arguments outside the typed signature (an untyped fd or + // buffer, a bigint or untyped position) take Node's argument checks at + // run time, then the same read. + if (isJsSourceFile(ts.getSourceFileOfNode(expr))) { + const nodes = expr.arguments.nodes; + const typed = [F64, BYTES_U8, F64, F64, F64].every((want, i) => { + const node = nodes[i]; + if (node === undefined) return true; + let value: ts.Expression = node; + while (ts.isParenthesizedExpression(value)) value = value.expression; + if (i === 4 && value.kind === ts.SyntaxKind.NullKeyword) return true; + const t = lowerer.mapTypeOf(lowerer.typeOf(node)); + return t !== null && (want.kind === "bytes" ? t.kind === "bytes" : t.kind === "f64"); + }); + if (!typed) { + const args = [0, 1, 2, 3, 4].map((i): IrExpr => { + const node = nodes[i]; + return node === undefined ? dynUndefinedExpr(loc) : lowerer.lowerExprExpecting(node, DYN); + }); + return { kind: "libCall", fn: "fs.readSyncDyn", args, type: F64, loc }; + } + } const args: IrExpr[] = [ lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, F64), lowerer.lowerExprExpecting(expr.arguments.nodes[1]!, BYTES_U8), diff --git a/packages/compiler/src/frontend/lowering/builtins/fs-streams.ts b/packages/compiler/src/frontend/lowering/builtins/fs-streams.ts index 498f9b6d4..6c6f8fa79 100644 --- a/packages/compiler/src/frontend/lowering/builtins/fs-streams.ts +++ b/packages/compiler/src/frontend/lowering/builtins/fs-streams.ts @@ -4,7 +4,7 @@ * and everything inherited goes through the stream spoke. */ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; -import { locOf } from "../../program.js"; +import { isJsSourceFile, locOf } from "../../program.js"; import { isNodeTypesPath } from "../../dts-paths.js"; import { dynUndefinedExpr } from "../../../ir/build.js"; import { @@ -178,3 +178,32 @@ export function lowerFsStreamNew( locOf(expr), ); } + +/** `fs.ReadStream(path, options)` / `fs.WriteStream(...)` called without + * `new` in JavaScript: Node's stream constructors construct when called as + * functions, so the call is the `new` form. Null for other calls. */ +export function lowerFsStreamCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr | null { + if (!isJsSourceFile(ts.getSourceFileOfNode(expr))) return null; + const callee = expr.expression; + const name = ts.isPropertyAccessExpression(callee) + ? callee.name + : ts.isIdentifier(callee) + ? callee + : null; + if (name === null || !ts.isIdentifier(name)) return null; + const symbol = lowerer.checker.getSymbolAtLocation(name); + const resolved = + symbol && symbol.flags & ts.SymbolFlags.Alias + ? lowerer.checker.getAliasedSymbol(symbol) + : symbol; + const cls = fsStreamClassOf(lowerer, resolved); + if (cls === null) return null; + return fsStreamCreateExpr( + lowerer, + expr.arguments.nodes, + cls === "ReadStream", + `fs.${cls}`, + expr, + locOf(expr), + ); +} diff --git a/packages/compiler/src/frontend/lowering/builtins/json.ts b/packages/compiler/src/frontend/lowering/builtins/json.ts index 64f829865..c1cdac334 100644 --- a/packages/compiler/src/frontend/lowering/builtins/json.ts +++ b/packages/compiler/src/frontend/lowering/builtins/json.ts @@ -606,8 +606,15 @@ export function lowerJsonProperty( ): IrExpr | null { const member = lowerer.stdlibGlobalMember(expr, "JSON"); if (member === null) return null; + const value = jsonMemberValue(lowerer, member, locOf(expr)); + if (value !== null) return value; + lowerer.unsupported("SC1090", expr, `JSON methods as values (call '${member}' directly)`); +} + +/** The function value of the JSON member `member` (stringify and parse), + * or null for members without one. */ +export function jsonMemberValue(lowerer: Lowerer, member: string, loc: SrcLoc): IrExpr | null { if (member === "stringify") { - const loc = locOf(expr); const name = "%builtin.JSON.stringify"; if (!lowerer.builtinCallableValueFns.has(name)) { lowerer.builtinCallableValueFns.set(name, name); @@ -654,7 +661,6 @@ export function lowerJsonProperty( if (member === "parse") { // JSON.parse as a value: ToString of the text, then the reviver form // (a reviver that is not a function is ignored, as in Node). - const loc = locOf(expr); const name = "%builtin.JSON.parse"; if (!lowerer.builtinCallableValueFns.has(name)) { lowerer.builtinCallableValueFns.set(name, name); @@ -703,5 +709,5 @@ export function lowerJsonProperty( loc, }; } - lowerer.unsupported("SC1090", expr, `JSON methods as values (call '${member}' directly)`); + return null; } diff --git a/packages/compiler/src/frontend/lowering/builtins/module-bindings.ts b/packages/compiler/src/frontend/lowering/builtins/module-bindings.ts index 5465aeafa..98cc188f8 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-bindings.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-bindings.ts @@ -185,6 +185,32 @@ export function builtinMemberRequireDecl( return spec !== null && canonicalBuiltinModule(spec) !== null; } +/** The member's own symbol for a variable bound to a builtin module's + * member (`const SD = require("string_decoder").StringDecoder`, + * builtinMemberRequireDecl) when the member is a class, so the variable + * resolves as a named import of the class does. The symbol itself + * otherwise. */ +export function builtinMemberBindingTarget( + lowerer: Lowerer, + symbol: ts.Symbol | null, +): ts.Symbol | null { + const decl = symbol?.valueDeclaration; + if ( + decl === undefined || + !ts.isVariableDeclaration(decl) || + !builtinMemberRequireDecl(decl.name, decl.initializer) + ) + return symbol; + const init = decl.initializer as ts.PropertyAccessExpression; + let member = lowerer.checker.getSymbolAtLocation(init.name); + if (member && member.flags & ts.SymbolFlags.Alias) + member = lowerer.checker.getAliasedSymbol(member); + // Only a class member: `require("events").EventEmitter` is a static + // property of the exported class, which builtinEmitterInfoOf follows + // from the variable itself. + return member?.declarations.some(ts.isClassDeclaration) ? member : symbol; +} + /** The supported builtin module when `decl` destructures a builtin * NAMESPACE binding (`const crypto = require('crypto'); const { * createSign, sign: mySign } = crypto;` — test/common/crypto.js's @@ -213,7 +239,9 @@ export function builtinNamespaceDestructureModuleOf( if (ts.isCallExpression(init)) { const cr = createRequireSpecOf(lowerer, init); module = cr !== null && cr.spec !== null ? canonicalBuiltinModule(cr.spec) : null; - } else if (ts.isIdentifier(init)) { + } else if (ts.isIdentifier(init) || ts.isPropertyAccessExpression(init)) { + // `const { open } = fs.promises` / `= require("fs").promises`: a member + // that is itself a supported module. module = lowerer.builtinNamespaceModuleOf(init); } if (module === null) return null; diff --git a/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts b/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts index 6b2e9e589..48e7783a3 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts @@ -2,7 +2,7 @@ import { nodeThrowExpr, boolLit, strLit, varRef } from "../../../ir/build.js"; import { dirname, isAbsolute, resolve } from "node:path"; import * as ts from "../../ts7/adapter.js"; import { type Lowerer, PoisonError } from "../lowerer.js"; -import { canonicalBuiltinModule, isNodeEsmFile, locOf } from "../../program.js"; +import { canonicalBuiltinModule, isJsSourceFile, isNodeEsmFile, locOf } from "../../program.js"; import { isRelativeSpecifier } from "../../workspace-registry.js"; import { probeNodeRequireRefusal } from "../../npm.js"; import { isNpmStaticPackage } from "../../npm-static.js"; @@ -17,6 +17,7 @@ import { import { invalidJsonModuleDiag, nativeAddonDiag, + noLoweringDiag, unsupportedPackageImportDiag, } from "../../../diagnostics/diagnostic.js"; import { NODE_BUILTIN_MODULES_V24 } from "../surfaces.js"; @@ -24,12 +25,14 @@ import { BOOL, DYN, type IrExpr, + type IrStmt, type IrType, NULL_T, STRING, type SrcLoc, UNDEFINED_T, arrayOf, + funcOf, } from "../../../ir/ir.js"; import { lowerFfiMemoryModule } from "../native-ffi.js"; import { @@ -38,6 +41,7 @@ import { createRequireCalleeFileOf, } from "./module-bindings.js"; import { stripTypeCasts } from "./arguments.js"; +import { keyedHelperName } from "../stable-names.js"; /** node:module's two compiler-only calls. isBuiltin compares one evaluated * string against the pinned Node 24 list (bare builtins also accept their @@ -134,6 +138,94 @@ export function lowerNodeModuleCall( }; } +/** The fence a createRequire use with no build-time lowering throws in + * JavaScript source, where preflight admits it inside a try block + * (createRequireUse7 in program.ts). The diagnostic joins the runtime-fence + * ledger, so coverage lists the use as deferred to runtime. */ +function createRequireFence( + lowerer: Lowerer, + surface: string, + node: ts.Node, + hint: string, +): IrStmt & { kind: "runtimeFence" } { + const loc = locOf(node); + const d = noLoweringDiag(surface, loc, hint); + lowerer.runtimeFences.push(d); + const line = ts.getLineAndCharacterOfPosition(ts.getSourceFileOfNode(node), loc.start).line + 1; + return { + kind: "runtimeFence", + code: d.code, + message: `${d.message} [${d.code} at ${loc.file}:${line}]`, + loc, + }; +} + +/** A createRequire loader's `resolve` (or `resolve.paths`) read as a value + * in JavaScript source: a function that throws the fence when called, so + * `(options.resolve ?? require.resolve)(id)` still calls the option when + * one is given. */ +export function createRequireResolveFenceValue( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, + loc: SrcLoc, +): IrExpr { + const surface = "createRequire's require.resolve as a value"; + const hint = + 'call it with a string literal (require.resolve("")), which resolves at build time'; + if (!isJsSourceFile(ts.getSourceFileOfNode(access))) lowerer.noLowering(surface, access, hint); + const name = keyedHelperName(lowerer, "%createRequire.fence", `${loc.file}:${loc.start}`); + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + lowerer.liftedFns.push({ + name, + params: [], + locals: [], + returnType: DYN, + body: [createRequireFence(lowerer, surface, access, hint)], + loc, + }); + } + return { + kind: "dynFrom", + value: { kind: "closure", fnName: name, captures: [], type: funcOf([], DYN), loc }, + fnName: access.name.text, + type: DYN, + loc, + }; +} + +/** A createRequire use deferred to run time in JavaScript source: as in + * Node, the call's arguments are evaluated in order, then the call throws + * (here, the fence). Code before the call in the same statement runs, and + * the expression diverges, so nothing after it does. */ +function deferredCreateRequireThrow( + lowerer: Lowerer, + call: ts.CallExpression, + surface: string, + hint: string, + loc: SrcLoc, +): IrExpr { + const stmts: IrStmt[] = call.arguments.nodes.map((arg) => ({ + kind: "exprStmt", + expr: lowerer.lowerExpr(arg), + loc: locOf(arg), + })); + const type = lowerer.irTypeOf(call); + const slot = lowerer.declareHiddenLocal("%never", type); + slot.mutable = true; + stmts.push( + { kind: "varDecl", localId: slot.id, init: null, loc }, + createRequireFence(lowerer, surface, call, hint), + ); + return { + kind: "seqExpr", + stmts, + result: varRef(slot.id, type, loc), + diverges: true, + type, + loc, + }; +} + export function lowerCreateRequireCall( lowerer: Lowerer, call: ts.CallExpression, @@ -142,11 +234,12 @@ export function lowerCreateRequireCall( const cr = createRequireSpecOf(lowerer, call); if (cr === null) return null; if (cr.spec === null) { - lowerer.noLowering( - "createRequire's require with this argument shape", - call, - 'the compiled module graph is fixed at build time — the one lowered form is require("")', - ); + const surface = "createRequire's require with a computed specifier or this argument shape"; + const hint = + 'the compiled module graph is fixed at build time — the one lowered form is require(""); native addons are not loaded'; + if (!isJsSourceFile(ts.getSourceFileOfNode(call))) + lowerer.noLowering(surface, call, hint, undefined, "dynamic-require"); + return deferredCreateRequireThrow(lowerer, call, surface, hint, loc); } const spec = cr.spec; if (spec === "node:ffi") { @@ -367,11 +460,30 @@ export function lowerRequireResolveCall(lowerer: Lowerer, call: ts.CallExpressio } const baseFile = requireResolverBaseFile(lowerer, receiver); if (baseFile === null) return null; + const surface = pathsCall ? "require.resolve.paths" : "require.resolve"; + // A createRequire loader's query that resolves only at run time, in + // JavaScript source: preflight admits it inside a try block + // (createRequireUse7 in program.ts). Its arguments run, then it throws. + const deferred = (what: string, hint: string): IrExpr | null => + call.questionDotToken === undefined && + createRequireCalleeFileOf(lowerer, receiver) !== null && + isJsSourceFile(ts.getSourceFileOfNode(call)) + ? deferredCreateRequireThrow( + lowerer, + call, + `createRequire's ${surface} ${what}`, + hint, + locOf(call), + ) + : null; + const resolveHint = `call it with a string literal (${surface}("")), which resolves at build time`; if ( call.questionDotToken !== undefined || call.arguments.nodes.length < 1 || call.arguments.nodes.length > (pathsCall ? 1 : 2) ) { + const fenced = deferred("with this argument shape", resolveHint); + if (fenced) return fenced; lowerer.noLowering( pathsCall ? "require.resolve.paths with this argument shape" @@ -381,6 +493,8 @@ export function lowerRequireResolveCall(lowerer: Lowerer, call: ts.CallExpressio } const specifier = staticString(call.arguments.nodes[0]); if (specifier === null) { + const fenced = deferred("with a computed request", resolveHint); + if (fenced) return fenced; lowerer.noLowering( `${pathsCall ? "require.resolve.paths" : "require.resolve"} with a runtime-computed request`, call.arguments.nodes[0]!, @@ -416,6 +530,11 @@ export function lowerRequireResolveCall(lowerer: Lowerer, call: ts.CallExpressio } const paths = staticResolveOptionPaths(call.arguments.nodes[1]); if (paths === null) { + const fenced = deferred( + "with runtime-computed options", + 'omit options or pass { paths: ["", ...] }', + ); + if (fenced) return fenced; lowerer.noLowering( "require.resolve with runtime-computed options", call.arguments.nodes[1]!, diff --git a/packages/compiler/src/frontend/lowering/builtins/readline.ts b/packages/compiler/src/frontend/lowering/builtins/readline.ts index aca209017..c2b6984ce 100644 --- a/packages/compiler/src/frontend/lowering/builtins/readline.ts +++ b/packages/compiler/src/frontend/lowering/builtins/readline.ts @@ -302,6 +302,15 @@ function interfaceClassModule(lowerer: Lowerer, node: ts.Expression): string | n : null; } +/** `new readline.Interface(...)` and `new readlinePromises.Interface(...)`: + * the constructor createInterface calls, with the same arguments. Null for + * other constructors. */ +export function lowerReadlineInterfaceNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null { + const module = interfaceClassModule(lowerer, expr.expression); + if (module === null) return null; + return lowerReadlineCreateCall(lowerer, expr, locOf(expr), module === "readline/promises"); +} + /** `rl instanceof readline.Interface` and `rl instanceof * readlinePromises.Interface` over a readline Interface: an interface is * an instance of the class of the module that created it (Node's @@ -1059,10 +1068,12 @@ function lowerOutput(lowerer: Lowerer, node: ts.Expression): IrExpr | "stdout" | * goes through rl.terminal with the completer. */ export function lowerReadlineCreateCall( lowerer: Lowerer, - expr: ts.CallExpression, + expr: ts.CallExpression | ts.NewExpression, loc: SrcLoc, promises: boolean, ): IrExpr { + const argNodes = expr.arguments?.nodes ?? []; + const what = ts.isNewExpression(expr) ? "new Interface" : "createInterface"; let input: IrExpr | null = null; let output: IrExpr | "stdout" | "none" = "none"; let crlf: IrExpr = numLit(0, loc); @@ -1112,39 +1123,38 @@ export function lowerReadlineCreateCall( }); terminal = { kind: "varRef", localId: local.id, type: BOOL, loc }; }; - const optsNode = expr.arguments.nodes[0]; + const optsNode = argNodes[0]; if (optsNode === undefined) { lowerer.noLowering( - "createInterface without arguments", + `${what} without arguments`, expr, "pass { input: process.stdin, output: process.stdout } or createInterface(input, output)", ); } if (!ts.isObjectLiteralExpression(optsNode)) { // createInterface(input, output?, completer?, terminal?) - if (expr.arguments.nodes.length > 4) { + if (argNodes.length > 4) { lowerer.noLowering( - `createInterface with ${expr.arguments.nodes.length} arguments`, - expr.arguments.nodes[4]!, + `${what} with ${argNodes.length} arguments`, + argNodes[4]!, "pass createInterface(input, output, completer, terminal) or one options object", ); } input = lowerInput(lowerer, optsNode); - if (expr.arguments.nodes[1] !== undefined) - output = lowerOutput(lowerer, expr.arguments.nodes[1]); - const completerNode = expr.arguments.nodes[2]; + if (argNodes[1] !== undefined) output = lowerOutput(lowerer, argNodes[1]); + const completerNode = argNodes[2]; if ( completerNode !== undefined && !(ts.isIdentifier(completerNode) && completerNode.text === "undefined") ) { setCompleter(completerNode); } - if (expr.arguments.nodes[3] !== undefined) setTerminal(expr.arguments.nodes[3]); + if (argNodes[3] !== undefined) setTerminal(argNodes[3]); } else { - if (expr.arguments.nodes.length > 1) { + if (argNodes.length > 1) { lowerer.noLowering( - "createInterface with more than one argument", - expr.arguments.nodes[1]!, + `${what} with more than one argument`, + argNodes[1]!, "pass every option in the one options object", ); } diff --git a/packages/compiler/src/frontend/lowering/builtins/reflection.ts b/packages/compiler/src/frontend/lowering/builtins/reflection.ts index 8e1e6fd94..ef55fa7e5 100644 --- a/packages/compiler/src/frontend/lowering/builtins/reflection.ts +++ b/packages/compiler/src/frontend/lowering/builtins/reflection.ts @@ -3,7 +3,33 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; import { builtinModuleFnOf } from "../surfaces.js"; -import { BOOL, DYN, type IrExpr, STRING, arrayOf } from "../../../ir/ir.js"; +import { hasRuntimeStatics } from "../class-runtime-statics.js"; +import { + BOOL, + DYN, + type IrExpr, + STRING, + type SrcLoc, + arrayOf, + isUnitType, +} from "../../../ir/ir.js"; + +/** The function value of the Reflect member `member` (ownKeys, on native + * computed-key reflection), or null for members without one. */ +export function reflectMemberValue(lowerer: Lowerer, member: string, loc: SrcLoc): IrExpr | null { + if (member !== "ownKeys") return null; + const value = lowerer.lowerNativeCallableValue( + { + fn: "dyn.ownKeys", + params: [DYN], + result: DYN, + valueParams: [{ mode: "required", type: DYN }], + }, + "Reflect.ownKeys", + loc, + )!; + return { kind: "dynFrom", value, fnName: "ownKeys", type: DYN, loc }; +} /** Reflect.ownKeys uses native computed-key reflection. Reflect.apply(target, * thisArg, argsList) where the TARGET is a builtin @@ -63,6 +89,37 @@ export function lowerReflectCall( loc, }; } + // Reflect.deleteProperty over a value with a live property table: a + // checked-dynamic value, or a JavaScript class whose statics are runtime + // properties of its constructor + // (`Reflect.deleteProperty(Response, "getResponseState")`). + if ( + reflectMember === "deleteProperty" && + call.arguments.nodes.length === 2 && + !call.arguments.nodes.some(ts.isSpreadElement) + ) { + const loc = locOf(call); + let target = lowerer.lowerExpr(call.arguments.nodes[0]!); + const info = + target.type.kind === "classval" ? lowerer.classes.get(target.type.className) : undefined; + // A primitive target throws the TypeError, so it boxes as is. + const primitive = + isUnitType(target.type) || + ["bool", "f64", "string", "bigint", "symbol"].includes(target.type.kind); + if ( + primitive || + (info !== undefined && hasRuntimeStatics(info) && lowerer.dynConvertible(target.type)) + ) + target = lowerer.coerceToExpected(target, DYN); + if (target.type.kind !== "dyn") return null; + return { + kind: "libCall", + fn: "dyn.reflectDeleteProperty", + args: [target, lowerer.lowerExprExpecting(call.arguments.nodes[1]!, DYN)], + type: BOOL, + loc, + }; + } if (reflectMember === "get" || reflectMember === "set") { const required = reflectMember === "get" ? 2 : 3; if ( diff --git a/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts b/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts index 8d47c8e3a..39156940e 100644 --- a/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts +++ b/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts @@ -24,6 +24,36 @@ import { } from "./arguments.js"; import { type BuiltinModuleFn } from "../surfaces.js"; +/** The input of `decoder.decode(input)`. An undefined input decodes no bytes, + * as `decode()` does in Node: a statically undefined argument (after its + * effects) reads as an empty Uint8Array, and the undefined arm of a typed + * array or view that may be undefined (`Uint8Array | undefined`, + * `Uint16Array | undefined`) as an empty view of the same type. */ +function lowerDecodeInput(lowerer: Lowerer, node: ts.Expression, loc: SrcLoc): IrExpr { + const undefinedArg = lowerStaticallyUndefinedArgument(lowerer, node); + if (undefinedArg) + return defaultAfterUndefined(undefinedArg, { + kind: "bytesNew", + source: null, + type: BYTES_U8, + loc, + }); + const value = lowerer.lowerExpr(node); + if (value.type.kind === "union") { + const def = lowerer.unions.get(value.type.unionId); + const view = def?.arms.find((arm) => arm.kind === "bytes"); + if (def?.arms.length === 2 && view && def.arms.some((arm) => arm.kind === "undefinedT")) + return { + kind: "nullish", + left: value, + right: { kind: "bytesNew", source: null, type: view, loc }, + type: view, + loc, + }; + } + return value; +} + /** StringDecoder writes preserve pending bytes; end(input) performs the * final write and flushes the state. String input passes through unchanged. */ export function lowerStringDecoderMethodCall( @@ -413,7 +443,7 @@ function lowerStoredTextCodecCall( : { kind: "bytesNew", source: null, type: BYTES_U8, loc } : cls === "TextEncoder" ? lowerer.lowerExprExpecting(call.arguments.nodes[0]!, STRING) - : lowerer.lowerExpr(call.arguments.nodes[0]!); + : lowerDecodeInput(lowerer, call.arguments.nodes[0]!, loc); if ( cls === "TextDecoder" && (arg.type.kind === "dyn" || (arg.type.kind === "bytes" && arg.type.elem !== "u8")) @@ -622,7 +652,7 @@ export function lowerTextCodecCall( ); } const argNode = call.arguments.nodes[0]!; - let arg = lowerer.lowerExpr(argNode); + let arg = lowerDecodeInput(lowerer, argNode, loc); if (arg.type.kind === "dyn" || (arg.type.kind === "bytes" && arg.type.elem !== "u8")) { arg = { kind: "libCall", diff --git a/packages/compiler/src/frontend/lowering/builtins/x509.ts b/packages/compiler/src/frontend/lowering/builtins/x509.ts new file mode 100644 index 000000000..5d81070eb --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/x509.ts @@ -0,0 +1,488 @@ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer } from "../lowerer.js"; +import { locOf } from "../../program.js"; +import { + BOOL, + BYTES_U8, + DYN, + F64, + STRING, + UNDEFINED_T, + type IrExpr, + type IrFunction, + type IrLibFn, + type IrType, + type SrcLoc, +} from "../../../ir/ir.js"; +import { everyStmtList } from "../../../ir/traverse.js"; +import { unsupportedDiag } from "../../../diagnostics/diagnostic.js"; +import { X509_DER_FIELD, X509_PUBLIC_KEY_FIELD } from "../../type-mapper.js"; +import { keyedHelperName } from "../stable-names.js"; +import { x509RecordCall } from "../lower-classes.js"; + +/* node:crypto's X509Certificate members beyond its fields: the methods + * and the publicKey getter. A certificate is the Dirent-style record + * (type-mapper.ts), and each member reads the record's hidden copy of its + * DER, which writes into the `raw` Buffer leave alone, as in Node. checkHost, + * checkEmail, checkIP, checkIssued, toLegacyObject and the PEM of + * toString/toJSON run in scr_x509.c; + * publicKey, verify and checkPrivateKey read keys in scr_pki.c. The first + * publicKey read stores the KeyObject in the record's hidden slot, so + * every read answers the same object, as in Node. */ + +/** Whether `t` is the X509Certificate record (its hidden publicKey slot + * marks the shape; no program can name that field). */ +export function isX509Type(lowerer: Lowerer, t: IrType | null | undefined): boolean { + return ( + t?.kind === "record" && + lowerer.shapes.get(t.shapeId)?.fields.some((f) => f.name === X509_PUBLIC_KEY_FIELD) === true + ); +} + +/** Whether `node` is an X509Certificate record. */ +function isX509Record(lowerer: Lowerer, node: ts.Expression): boolean { + const type = lowerer.typeOf(node); + const sym = type.getSymbol(); + if (!sym || ts.symbolName(sym) !== "X509Certificate" || !lowerer.isStdlibSymbol(sym)) + return false; + return isX509Type(lowerer, lowerer.mapTypeOf(type)); +} + +/** A certificate in an `unknown` or `any` slot would cross as a plain + * object of its fields, which Node's certificate is not: its fields are + * prototype getters, and toString() and toJSON() give its PEM. Every such + * conversion in the lowered program, including a certificate inside an + * array or record that converts, is refused where it happens. */ +export function refuseX509DynValues(lowerer: Lowerer, functions: readonly IrFunction[]): void { + // Certificates exist only where `new X509Certificate` built one. + if ( + !lowerer.valueHelpers.has("x509.record:bytes") && + !lowerer.valueHelpers.has("x509.record:str") && + !lowerer.valueHelpers.has("x509.record:der") + ) + return; + const seen = new Map(); + const holds = (t: IrType): boolean => { + switch (t.kind) { + case "record": { + const known = seen.get(t.shapeId); + if (known !== undefined) return known; + seen.set(t.shapeId, false); + const shape = lowerer.shapes.get(t.shapeId); + const result = + isX509Type(lowerer, t) || + (shape !== undefined && + (shape.fields.some((f) => holds(f.type)) || + (shape.indexValue !== undefined && holds(shape.indexValue)))); + seen.set(t.shapeId, result); + return result; + } + case "array": + case "set": + return holds(t.elem); + case "map": + return holds(t.key) || holds(t.value); + case "promise": + return holds(t.inner); + case "union": + return lowerer.unions.get(t.unionId)?.arms.some(holds) === true; + default: + return false; + } + }; + const visitor = { + expr: (e: IrExpr): boolean => { + if (e.kind === "dynFrom" && holds(e.value.type)) { + lowerer.pushDiag( + unsupportedDiag( + "SC1090", + e.loc, + "X509Certificate values in 'unknown' or 'any' slots (Node keeps a certificate's fields as prototype getters, and its toString() and toJSON() give the PEM; keep the X509Certificate type)", + ), + ); + } + return true; + }, + stmt: (): boolean => true, + }; + for (const fn of functions) everyStmtList(fn.body, visitor); +} + +/** String(x509), `${x509}` and x509 + "": the certificate's toString(), + * its PEM. */ +export function x509ToString(record: IrExpr, loc: SrcLoc): IrExpr { + return { kind: "libCall", fn: "crypto.x509Pem", args: [derOf(record, loc)], type: STRING, loc }; +} + +/** Node keeps a certificate's fields as prototype getters, so own-key + * walks (Object.keys/values/entries, for-in, spread, hasOwnProperty) see + * none of them. Those walks over a certificate fence instead of listing + * the fields. */ +export function fenceX509KeyWalk( + lowerer: Lowerer, + t: IrType | null | undefined, + node: ts.Node, + what: string, +): void { + if (!isX509Type(lowerer, t)) return; + lowerer.unsupported( + "SC1090", + node, + `${what} over X509Certificate values (Node keeps their fields as prototype getters, so own-key walks see none; read the fields)`, + ); +} + +function derOf(record: IrExpr, loc: SrcLoc): IrExpr { + if (record.type.kind !== "record") return record; + return { + kind: "recordGet", + obj: record, + shapeId: record.type.shapeId, + field: X509_DER_FIELD, + type: BYTES_U8, + loc, + }; +} + +const dynUndefined = (loc: SrcLoc): IrExpr => ({ + kind: "dynFrom", + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type: DYN, + loc, +}); + +/** The method's string answer as `string | undefined`: "" is undefined. + * A lifted helper takes the text once. */ +function optionalString(lowerer: Lowerer, text: IrExpr, type: IrType, loc: SrcLoc): IrExpr { + if (type.kind !== "union") return text; + const def = lowerer.unions.get(type.unionId); + const stringTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1; + const undefinedTag = def ? def.arms.findIndex((a) => a.kind === "undefinedT") : -1; + if (stringTag < 0 || undefinedTag < 0) return text; + const key = `x509.optional:${type.unionId}`; + let helper = lowerer.valueHelpers.get(key); + if (!helper) { + helper = keyedHelperName(lowerer, "%x509.optional", key); + lowerer.valueHelpers.set(key, helper); + const s: IrExpr = { kind: "varRef", localId: "s.0", type: STRING, loc }; + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "s.0", name: "s", type: STRING }], + returnType: type, + locals: [{ id: "s.0", name: "s", type: STRING, mutable: false }], + body: [ + { + kind: "return", + value: { + kind: "ternary", + cond: { + kind: "strEq", + negated: false, + left: s, + right: { kind: "strLit", value: "", type: STRING, loc }, + type: BOOL, + loc, + }, + then: { + kind: "unionWrap", + unionId: type.unionId, + tag: undefinedTag, + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type, + loc, + }, + else_: { + kind: "unionWrap", + unionId: type.unionId, + tag: stringTag, + value: s, + type, + loc, + }, + type, + loc, + }, + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: helper, args: [text], type, loc }; +} + +/** `x509.publicKey`: the cached KeyObject (a lifted helper fills the + * hidden slot on the first read). */ +export function lowerX509PublicKey( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (access.name.text !== "publicKey" || access.questionDotToken) return null; + if (!isX509Record(lowerer, access.expression)) return null; + const loc = locOf(access); + const record = lowerer.lowerExpr(access.expression); + if (record.type.kind !== "record") return null; + const shapeId = record.type.shapeId; + const key = `x509.publicKey:${shapeId}`; + let helper = lowerer.valueHelpers.get(key); + if (!helper) { + helper = keyedHelperName(lowerer, "%x509.publicKey", key); + lowerer.valueHelpers.set(key, helper); + const c: IrExpr = { kind: "varRef", localId: "c.0", type: record.type, loc }; + const slot: IrExpr = { + kind: "recordGet", + obj: c, + shapeId, + field: X509_PUBLIC_KEY_FIELD, + type: DYN, + loc, + }; + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "c.0", name: "c", type: record.type }], + returnType: DYN, + locals: [{ id: "c.0", name: "c", type: record.type, mutable: false }], + body: [ + { + kind: "if", + cond: { kind: "dynTest", test: "undefined", value: slot, type: BOOL, loc }, + then: [ + { + kind: "recordSet", + obj: c, + shapeId, + field: X509_PUBLIC_KEY_FIELD, + value: { + kind: "libCall", + fn: "crypto.x509PublicKey", + args: [derOf(c, loc)], + type: DYN, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }, + { kind: "return", value: slot, loc }, + ], + loc, + }); + } + return { kind: "call", callee: helper, args: [record], type: DYN, loc }; +} + +type X509Method = { fn: IrLibFn; min: number; max: number; form: string }; + +const METHODS: ReadonlyMap = new Map([ + ["verify", { fn: "crypto.x509Verify", min: 1, max: 1, form: "verify(publicKey)" }], + [ + "checkPrivateKey", + { fn: "crypto.x509CheckPrivateKey", min: 1, max: 1, form: "checkPrivateKey(privateKey)" }, + ], + ["checkIssued", { fn: "crypto.x509CheckIssued", min: 1, max: 1, form: "checkIssued(otherCert)" }], + ["checkHost", { fn: "crypto.x509CheckHost", min: 1, max: 2, form: "checkHost(name[, options])" }], + [ + "checkEmail", + { fn: "crypto.x509CheckEmail", min: 1, max: 2, form: "checkEmail(email[, options])" }, + ], + ["checkIP", { fn: "crypto.x509CheckIP", min: 1, max: 2, form: "checkIP(ip[, options])" }], + ["toLegacyObject", { fn: "crypto.x509LegacyObject", min: 0, max: 0, form: "toLegacyObject()" }], + ["toString", { fn: "crypto.x509Pem", min: 0, max: 0, form: "toString()" }], + ["toJSON", { fn: "crypto.x509Pem", min: 0, max: 0, form: "toJSON()" }], +]); + +/** The X509Certificate methods: verify, checkPrivateKey, checkIssued, + * checkHost, checkEmail, checkIP, toLegacyObject, toString and toJSON. */ +export function lowerX509Call(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { + if (call.questionDotToken || !ts.isPropertyAccessExpression(call.expression)) return null; + const access = call.expression; + if (access.questionDotToken) return null; + const method = METHODS.get(access.name.text); + if (!method) return null; + if (!isX509Record(lowerer, access.expression)) return null; + const loc = locOf(call); + const args = call.arguments.nodes; + if (args.length < method.min || args.length > method.max || args.some(ts.isSpreadElement)) { + lowerer.noLowering( + `X509Certificate.${access.name.text} with ${args.length} argument(s)`, + call, + `the supported form is ${method.form}`, + ); + } + const raw = derOf(lowerer.lowerExpr(access.expression), loc); + if (method.fn === "crypto.x509LegacyObject") { + return { kind: "libCall", fn: method.fn, args: [raw], type: DYN, loc }; + } + if (method.fn === "crypto.x509Pem") + return { kind: "libCall", fn: method.fn, args: [raw], type: STRING, loc }; + if (method.fn === "crypto.x509CheckIssued") { + if (!isX509Record(lowerer, args[0]!)) { + // Node's validation of otherCert, after both operands evaluate. + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: raw, loc }], + result: { + kind: "libCall", + fn: "error.argTypeThrow", + args: [ + { kind: "strLit", value: "otherCert", type: STRING, loc }, + { kind: "strLit", value: "an instance of X509Certificate", type: STRING, loc }, + lowerer.lowerExprExpecting(args[0]!, DYN), + ], + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } + const other = derOf(lowerer.lowerExpr(args[0]!), loc); + return { kind: "libCall", fn: method.fn, args: [raw, other], type: BOOL, loc }; + } + const first = lowerer.lowerExprExpecting(args[0]!, DYN); + if (method.fn === "crypto.x509Verify" || method.fn === "crypto.x509CheckPrivateKey") { + return { kind: "libCall", fn: method.fn, args: [raw, first], type: BOOL, loc }; + } + const options = args.length > 1 ? lowerer.lowerExprExpecting(args[1]!, DYN) : dynUndefined(loc); + const text: IrExpr = { + kind: "libCall", + fn: method.fn, + args: [raw, first, options], + type: STRING, + loc, + }; + return optionalString(lowerer, text, lowerer.mapTypeOf(lowerer.typeOf(call)) ?? STRING, loc); +} + +/** The TLS socket certificate accessors. getPeerCertificate([detailed]) + * and getCertificate() answer Node's legacy object (scr_tls_x509.c): + * Node reads `detailed` only when it is true. getPeerX509Certificate() and + * getX509Certificate() build a new X509Certificate from the certificate's + * DER on each call, or answer undefined without one. Null for other + * members. */ +export function lowerTlsCertificateCall( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + receiver: () => IrExpr, +): IrExpr | null { + const loc = locOf(call); + const args = call.arguments.nodes; + if (name === "getPeerCertificate" || name === "getCertificate") { + const max = name === "getPeerCertificate" ? 1 : 0; + if (args.length > max || args.some(ts.isSpreadElement)) { + lowerer.noLowering( + `${name} with ${args.length} argument(s)`, + call, + name === "getPeerCertificate" + ? "the form is getPeerCertificate(detailed?)" + : "getCertificate() takes no arguments", + ); + } + const socket = receiver(); + const value: IrExpr = + name === "getCertificate" + ? { kind: "libCall", fn: "tls.sockGetCertificate", args: [socket], type: DYN, loc } + : { + kind: "libCall", + fn: "tls.sockGetPeerCertificate", + args: [ + socket, + args.length > 0 ? lowerer.lowerExprExpecting(args[0]!, DYN) : dynUndefined(loc), + ], + type: DYN, + loc, + }; + const declared = lowerer.mapTypeOf(lowerer.typeOf(call)); + return declared === null || declared.kind === "dyn" + ? value + : lowerer.coerceInto(call, value, declared); + } + if (name !== "getPeerX509Certificate" && name !== "getX509Certificate") return null; + if (args.length > 0) { + lowerer.noLowering( + `${name} with ${args.length} argument(s)`, + call, + `${name}() takes no arguments`, + ); + } + const type = lowerer.mapTypeOf(lowerer.typeOf(call)); + const def = type?.kind === "union" ? lowerer.unions.get(type.unionId) : undefined; + const recordTag = def ? def.arms.findIndex((arm) => isX509Type(lowerer, arm)) : -1; + const undefinedTag = def ? def.arms.findIndex((arm) => arm.kind === "undefinedT") : -1; + if (type?.kind !== "union" || !def || recordTag < 0 || undefinedTag < 0) { + lowerer.noLowering( + `tlsSocket.${name} with this result type`, + call, + "its type is X509Certificate | undefined", + ); + } + const record = def.arms[recordTag] as IrType & { kind: "record" }; + const der: IrExpr = { + kind: "libCall", + fn: name === "getPeerX509Certificate" ? "tls.sockPeerCertDer" : "tls.sockOwnCertDer", + args: [receiver()], + type: BYTES_U8, + loc, + }; + const key = `tls.x509:${type.unionId}`; + let helper = lowerer.valueHelpers.get(key); + if (!helper) { + helper = keyedHelperName(lowerer, "%tls.x509", key); + lowerer.valueHelpers.set(key, helper); + const d: IrExpr = { kind: "varRef", localId: "d.0", type: BYTES_U8, loc }; + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "d.0", name: "der", type: BYTES_U8 }], + returnType: type, + locals: [{ id: "d.0", name: "der", type: BYTES_U8, mutable: false }], + body: [ + { + kind: "return", + value: { + kind: "ternary", + cond: { + kind: "bin", + op: "===", + left: { + kind: "bytesIntrinsic", + method: "length", + receiver: d, + args: [], + type: F64, + loc, + }, + right: { kind: "numLit", value: 0, type: F64, loc }, + type: BOOL, + loc, + }, + then: { + kind: "unionWrap", + unionId: type.unionId, + tag: undefinedTag, + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type, + loc, + }, + else_: { + kind: "unionWrap", + unionId: type.unionId, + tag: recordTag, + // The handshake's DER, read without PEM detection. + value: x509RecordCall(lowerer, d, record, loc, true), + type, + loc, + }, + type, + loc, + }, + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: helper, args: [der], type, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 3b2a4bd39..cefe518dd 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -953,6 +953,8 @@ function appendArgumentsParameter( "SC1090", node, "parameterized 'arguments' outside an ES module (sloppy-mode parameter aliases)", + undefined, + "sloppy-arguments", ); } } diff --git a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts index 10549a030..d8893aa72 100644 --- a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts +++ b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts @@ -532,6 +532,8 @@ export function lowerUncompiledRequireCall( "SC1090", call, 'require() with a computed specifier (the compiled module graph is fixed at build time: require("") lowers)', + undefined, + "dynamic-require", ); } if (canonicalBuiltinModule(spec) !== null) return null; diff --git a/packages/compiler/src/frontend/lowering/class-attributes.ts b/packages/compiler/src/frontend/lowering/class-attributes.ts index 4e7bbe801..cdd53efa6 100644 --- a/packages/compiler/src/frontend/lowering/class-attributes.ts +++ b/packages/compiler/src/frontend/lowering/class-attributes.ts @@ -39,8 +39,14 @@ import { runtimeRooted } from "./class-descriptors.js"; type ClassObjectType = Extract; function attributeClass(lowerer: Lowerer, target: IrExpr): ClassInfo | null { - if (!isDynTypedRefType(target.type)) return null; - const info = lowerer.classes.get(target.type.className); + return isDynTypedRefType(target.type) + ? attributeClassNamed(lowerer, target.type.className) + : null; +} + +/** The compiled class `className`, when its instances can carry attributes. */ +function attributeClassNamed(lowerer: Lowerer, className: string): ClassInfo | null { + const info = lowerer.classes.get(className); if (!info || runtimeRooted(info) || info.decl == null) return null; return info; } @@ -62,6 +68,20 @@ function addHiddenField(info: ClassInfo, name: string): void { add(root); } +/** In a program that freezes, seals or prevents extensions of untyped + * values, gives every hierarchy whose instances can be held there the + * integrity word. The runtime applies the operation to the instance's + * checked view, whose commit stores the view's integrity state in the word + * (backend streamTypedRefCommitAdapter), and the write checks below read + * it. */ +export function addUntypedIntegrityWords(lowerer: Lowerer): void { + if (!lowerer.untypedIntegrity) return; + for (const [className, info] of lowerer.classes) { + if (isDynTypedRefType({ kind: "object", className }) && attributeClassNamed(lowerer, className)) + addHiddenField(info, CLASS_ATTRIBUTES); + } +} + function hiddenFieldGet(receiver: IrExpr, className: string, field: string, loc: SrcLoc): IrExpr { return { kind: "fieldGet", obj: receiver, className, field, type: F64, loc }; } @@ -283,6 +303,32 @@ export function lowerClassFieldDefine( }; } +/** The property key of `field` when it is the storage of a symbol-keyed + * field of `info`, as the read-only TypeError names it: the symbol's + * String() form ("Symbol(tag)"), read from the module global that holds + * the symbol. Null for other fields. */ +function symbolFieldKey( + lowerer: Lowerer, + info: ClassInfo, + field: string, + loc: SrcLoc, +): IrExpr | null { + if (info.symbolFields === undefined) return null; + for (const [symbol, name] of info.symbolFields) { + if (name !== field) continue; + const global = lowerer.globalsBySymbol.get(symbol); + if (global?.type.kind !== "symbol") return null; + return { + kind: "libCall", + fn: "sym.toString", + args: [varRef(global.id, global.type, loc)], + type: STRING, + loc, + }; + } + return null; +} + function classDisplayName(info: ClassInfo): string { return info.def.jsName ?? info.decl?.name?.text ?? info.def.name.replace(/^%/, ""); } @@ -347,12 +393,15 @@ export function guardClassAttributeWrites(lowerer: Lowerer, functions: IrFunctio if ( statement.kind !== "fieldSet" || statement.attributesChecked || - !guarded.has(statement.className) || - !isClassOwnEnumerableFieldName(statement.field) + !guarded.has(statement.className) ) return statement; const info = lowerer.classes.get(statement.className)!; const loc = statement.loc; + // A symbol-keyed field is an own property under its symbol. + const symbolKey = symbolFieldKey(lowerer, info, statement.field, loc); + if (!symbolKey && !isClassOwnEnumerableFieldName(statement.field)) return statement; + const key = (): IrExpr => symbolKey ?? strLit(statement.field, loc); const receiver = local(statement.obj.type, loc); const value = local(statement.value.type, loc); const masked = (field: string, bit: number): IrExpr => ({ @@ -385,7 +434,7 @@ export function guardClassAttributeWrites(lowerer: Lowerer, functions: IrFunctio expr: { kind: "libCall", fn: "class.throwNotExtensible", - args: [strLit(statement.field, loc)], + args: [key()], type: VOID, loc, }, @@ -400,10 +449,7 @@ export function guardClassAttributeWrites(lowerer: Lowerer, functions: IrFunctio expr: { kind: "libCall", fn: "class.throwReadOnly", - args: [ - strLit(statement.field, loc), - instanceClassName(lowerer, info, receiver, helpers, loc), - ], + args: [key(), instanceClassName(lowerer, info, receiver, helpers, loc)], type: VOID, loc, }, diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index fc6d0a922..2af84f8fb 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -54,6 +54,7 @@ import { import { classPrototypeData, hasClassPrototypeData, + prototypeReflectedFlag, observedPrototypeMethod, reflectClassPrototype, } from "./class-prototypes.js"; @@ -262,6 +263,7 @@ const KEY_CALLS = new Map([ ["dyn.propertyIsEnumerableComputed", "any"], ["dyn.hasOwnComputed", "value"], ["dyn.keyDeleteComputed", "value"], + ["dyn.reflectDeleteProperty", "object"], ["dyn.keySetComputed", "value"], ]); @@ -2928,15 +2930,19 @@ export class ClassDynamicDispatch { } const iterator = expr.kind === "libCall" && - (expr.fn === "dyn.iterator" || expr.fn === "dyn.arrayFromIterator") + (expr.fn === "dyn.iterator" || + expr.fn === "dyn.arrayFromIterator" || + expr.fn === "dyn.asyncIteratorOpen") ? expr : null; + // `for await` opens a class instance's [Symbol.asyncIterator](). + const asyncOpen = iterator?.fn === "dyn.asyncIteratorOpen"; const invoke: Invoke | null = iterator ? { kind: "dynInvoke", recv: iterator.args[0]!, - method: "sym:iterator", - calleeName: "value[Symbol.iterator]", + method: asyncOpen ? "sym:asyncIterator" : "sym:iterator", + calleeName: asyncOpen ? "value[Symbol.asyncIterator]" : "value[Symbol.iterator]", args: [], type: DYN, loc: iterator.loc, @@ -4179,6 +4185,103 @@ export class ClassDynamicDispatch { return { kind: "call", callee: name, args: [bag, key], type: BOOL, loc }; } + /** `%dyn.class.bagHasOwn`: whether an instance's property bag holds `key` + * as its own property. False before the instance has a bag. */ + private readBagHasOwn(lowerer: Lowerer, bag: IrExpr, key: IrExpr, loc: SrcLoc): IrExpr { + const name = "%dyn.class.bagHasOwn"; + let helper = lowerer.liftedFnNamed(name); + if (!helper) { + const value = varRef("p.0", DYN, loc); + helper = { + name, + params: [ + { localId: "p.0", name: "bag", type: DYN }, + { localId: "p.1", name: "key", type: STRING }, + ], + returnType: BOOL, + locals: [ + { id: "p.0", name: "bag", type: DYN, mutable: false }, + { id: "p.1", name: "key", type: STRING, mutable: false }, + ], + body: [ + { + kind: "if", + cond: { kind: "dynTest", test: "undefined", value, type: BOOL, loc }, + then: [ + { kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc }, + ], + else_: null, + loc, + }, + { + kind: "return", + value: { + kind: "libCall", + fn: "dyn.hasOwn", + args: [value, varRef("p.1", STRING, loc)], + type: BOOL, + loc, + }, + loc, + }, + ], + loc, + }; + lowerer.liftedFns.push(helper); + } + this.generated.add(helper); + return { kind: "call", callee: name, args: [bag, key], type: BOOL, loc }; + } + + /** The `constructor` an instance of `info` inherits when it has no own + * one. The class's prototype view answers its own `constructor` (a + * replacement, or the class once reflection installed it). A view + * without one answers the class itself unless reflection had populated it + * (prototypeReflectedFlag, set when the view initializes): then the + * program deleted the property, and the read continues to the base + * class's prototype and past the last one to Object.prototype, as in + * Node. A prototype's own `constructor` is read with the instance + * `receiver` as `this`, so an accessor's getter runs on the instance. */ + private inheritedConstructor( + lowerer: Lowerer, + info: ClassInfo, + key: IrExpr, + receiver: IrExpr, + loc: SrcLoc, + ): IrExpr { + const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl!), DYN); + const prototype = hasClassPrototypeData(info) ? classPrototypeData(lowerer, info, loc) : null; + if (!prototype) return cls; + const base = info.base; + const read = (): IrExpr => ({ + kind: "libCall", + fn: "dyn.reflectGet", + args: [prototype, lowerer.coerceToExpected(key, DYN), receiver], + type: DYN, + loc, + }); + // Past the last class prototype, the read continues to Object.prototype's data property. + const next: IrExpr = + base && !base.builtinError && base.decl && !base.generic && !base.localClass + ? this.inheritedConstructor(lowerer, base, key, receiver, loc) + : { kind: "dynKeyGet", value: prototype, key, type: DYN, loc }; + return { + kind: "ternary", + cond: { kind: "libCall", fn: "dyn.hasOwn", args: [prototype, key], type: BOOL, loc }, + then: read(), + else_: { + kind: "ternary", + cond: varRef(prototypeReflectedFlag(lowerer, info), BOOL, loc), + then: next, + else_: cls, + type: DYN, + loc, + }, + type: DYN, + loc, + }; + } + /** The hints for which some boxed class declares a conversion method. A * string conversion calls toString first, so without a declared toString * or Symbol.toPrimitive it answers Object.prototype.toString's @@ -5937,15 +6040,19 @@ export class ClassDynamicDispatch { !info.classDecorators ) { this.constructors.set(info.def.name, info); - const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl), DYN); const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc); const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; + // `constructor` is an own property of every class prototype, so an + // ancestor's prototype answers for an instance of this class only + // after the program deletes the class's own: the instance's own + // property answers, then the class's prototype (inheritedConstructor). + const answered = this.readBagHasOwn(lowerer, bag, key, loc); return [ { kind: "return", value: { kind: "ternary", - cond: this.readBagHas(lowerer, "dyn.hasKey", bag, key, loc), + cond: answered, then: { kind: "libCall", fn: "dyn.bagGet", @@ -5953,7 +6060,13 @@ export class ClassDynamicDispatch { type: DYN, loc, }, - else_: cls, + else_: this.inheritedConstructor( + lowerer, + info, + key, + lowerer.coerceToExpected(receiver, DYN), + loc, + ), type: DYN, loc, }, diff --git a/packages/compiler/src/frontend/lowering/class-method-values.ts b/packages/compiler/src/frontend/lowering/class-method-values.ts index 6e8256513..ab9101cf7 100644 --- a/packages/compiler/src/frontend/lowering/class-method-values.ts +++ b/packages/compiler/src/frontend/lowering/class-method-values.ts @@ -378,8 +378,33 @@ function reflectedMethodValue( !isJsSourceFile(ts.getSourceFileOfNode(declaration)) || info.localClass !== undefined || lowerer.errorHierarchyClassOf(info.def.name); + // `super.m(...)` in a prototype method reads the base class's prototype, + // which lowerSuperMethodCall answers over the call-site receiver when the + // base has a prototype object. Other uses of `super` keep the native + // receiver. + const superCalls = !staticMethod && info.base !== null && prototypeChainViewable(info.base); + // Over a dynamic receiver, private fields are brand-checked and read, and private methods + // brand-checked and called; a private accessor, or a private method read as a value, would become a + // lookup of its name. + const privateAccessors = new Set(); + const privateMethods = new Set(); + for (const other of declaration.members.nodes) + if (other.name !== undefined && ts.isPrivateIdentifier(other.name)) { + if (ts.isAccessor(other)) privateAccessors.add(other.name.text); + else if (ts.isMethodDeclaration(other)) privateMethods.add(other.name.text); + } ts.walkPreorder(member, (node) => { - if (ts.isPrivateIdentifier(node) || node.kind === ts.SyntaxKind.SuperKeyword) + if (node.kind === ts.SyntaxKind.SuperKeyword && !(superCalls && isSuperMethodCallee(node))) + needsNativeReceiver = true; + if (!ts.isPrivateIdentifier(node)) return; + // Private members on a static method's receiver (the class value) keep + // the native receiver; prototype methods brand-check theirs, except + // private accessors and private methods used other than by a call. + if ( + staticMethod || + privateAccessors.has(node.text) || + (privateMethods.has(node.text) && !isPrivateMethodCallee(node)) + ) needsNativeReceiver = true; }); if (needsNativeReceiver) return null; @@ -403,7 +428,11 @@ function reflectedMethodValue( loc: locOf(member), }; } + // The method may be lowered on top of an unrelated caller's frames (a + // `super` call in another class's method reflects this class): its `this` + // is its own receiver, never an enclosing function's. const context = newFnCtx(false, null, null, DYN); + context.thisBoundary = true; const previousClass = lowerer.currentClass; lowerer.currentClass = info; lowerer.fnStack.push(context); @@ -441,6 +470,55 @@ function reflectedMethodValue( } } +/** Whether `base` and every class above it have a complete prototype object + * (reflectClassPrototype): module-level program classes that neither extend + * Date nor a runtime-provided class. */ +function prototypeChainViewable(base: ClassInfo): boolean { + for (let c: ClassInfo | null = base; c; c = c.base) + if ( + c.decl === null || + c.localClass !== undefined || + c.generic !== undefined || + c.genericInstance !== undefined || + c.mixinInstance !== undefined || + c.callableBase !== undefined || + c.extendsDate !== undefined || + c.def.runtime || + c.builtinEmitter || + c.builtinStream || + c.builtinError || + c.builtinEventTarget + ) + return false; + return true; +} + +/** `super` as the receiver of a called member: `super.m(...)`. */ +/** Whether `node`, a private name, is the method a call calls (`x.#m(...)`). */ +function isPrivateMethodCallee(node: ts.Node): boolean { + const access = node.parent; + return ( + access !== undefined && + ts.isPropertyAccessExpression(access) && + access.name === node && + access.parent !== undefined && + ts.isCallExpression(access.parent) && + access.parent.expression === access + ); +} + +function isSuperMethodCallee(node: ts.Node): boolean { + const access = node.parent; + return ( + access !== undefined && + ts.isPropertyAccessExpression(access) && + access.expression === node && + access.parent !== undefined && + ts.isCallExpression(access.parent) && + access.parent.expression === access + ); +} + /** Complete adapters after all native class bodies and instantiations exist. */ export function finalizeClassMethodValues(lowerer: Lowerer, functions: IrFunction[]): void { const byName = new Map(functions.map((fn) => [fn.name, fn])); diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index cb2ab2280..0c6741c63 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -13,7 +13,7 @@ import { type IrStmt, type SrcLoc, } from "../../ir/ir.js"; -import { locOf } from "../program.js"; +import { isJsSourceFile, locOf } from "../program.js"; import { classMemberNameOf, classValueRef, @@ -299,6 +299,23 @@ function declaresPrototypeMembers(decl: ts.ClassLikeDeclaration): boolean { ); } +/** The global that is true once the class's prototype view has been + * populated by reflection (reflectClassPrototype): the view then holds an + * own `constructor` unless the program deleted it. A view that was never + * reflected leaves `constructor` implicit. */ +export function prototypeReflectedFlag(lowerer: Lowerer, info: ClassInfo): string { + if (info.prototypeReflectedFlag) return info.prototypeReflectedFlag; + const id = `%g.%prototype.data.${info.def.name}.reflected`; + lowerer.globalsList.push({ + id, + name: `${info.def.jsName ?? info.def.name}.prototype.reflected`, + type: BOOL, + mutable: true, + }); + info.prototypeReflectedFlag = id; + return id; +} + /** Populate the shared prototype view before exposing it to reflection. * Methods and accessors are non-enumerable and retain declaration identity. */ export function reflectClassPrototype( @@ -399,8 +416,17 @@ export function reflectClassPrototype( { key: "value", value: classValueRef(lowerer, info, info.decl) }, writable, ]), + { + kind: "assign", + localId: prototypeReflectedFlag(lowerer, info), + value: { kind: "boolLit", value: true, type: BOOL, loc }, + loc, + }, ); + // The members lower on top of whatever frames requested the view (a + // `super` call in a subclass's method); their `this` is their own receiver. const context = newFnCtx(false, null, null, DYN); + context.thisBoundary = true; lowerer.fnStack.push(context); try { for (const member of info.decl.members.nodes) { @@ -503,6 +529,69 @@ export function isErrorPrototypeValue( ); } +/** Whether a bare `C.prototype` read becomes the prototype of another object + * or is only compared or inspected: the argument of `Object.create` or + * `Object.getPrototypeOf` / `Reflect.getPrototypeOf`, the prototype argument + * of `Object.setPrototypeOf` / `Reflect.setPrototypeOf`, or an operand of + * `===` / `!==`. In these positions the complete view of the + * prototype (reflectClassPrototype) answers. Objects that inherit from it + * run the class's methods with themselves as the receiver, which the + * methods' receiver-independent lowering (reflectedClassMethodValue) + * supports. Other positions, such as `Object.setPrototypeOf(C.prototype, X)`, + * which would relink the class, keep the diagnostic. */ +function prototypeViewPosition(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { + let node: ts.Node = expr; + let parent = node.parent; + while (parent && ts.isParenthesizedExpression(parent)) { + node = parent; + parent = parent.parent; + } + if (!parent) return false; + if (ts.isCallExpression(parent) && ts.isPropertyAccessExpression(parent.expression)) { + const args = parent.arguments.nodes; + const objectMethod = lowerer.stdlibGlobalMember(parent.expression, "Object"); + const reflectMethod = lowerer.stdlibGlobalMember(parent.expression, "Reflect"); + if ( + objectMethod === "create" || + objectMethod === "getPrototypeOf" || + reflectMethod === "getPrototypeOf" + ) + return args[0] === node; + if (objectMethod === "setPrototypeOf" || reflectMethod === "setPrototypeOf") + return args[1] === node; + return false; + } + return ( + ts.isBinaryExpression(parent) && + (parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken) + ); +} + +/** A module-level JavaScript class named directly (not a class value that + * may hold a subclass at run time): its prototype has a complete view whose + * methods accept any receiver. */ +function viewableClass(lowerer: Lowerer, info: ClassInfo, receiver: ts.Expression): boolean { + return ( + info.decl !== null && + info.localClass === undefined && + isJsSourceFile(ts.getSourceFileOfNode(info.decl)) && + exactClassOfReceiver(lowerer, receiver) === info + ); +} + +/** The complete prototype view of the class `node` names, when it is a + * viewable class (util.inherits's superCtor); null otherwise. */ +export function classPrototypeView( + lowerer: Lowerer, + node: ts.Expression, + loc: SrcLoc, +): IrExpr | null { + const info = exactClassOfReceiver(lowerer, node); + if (!info || !viewableClass(lowerer, info, node)) return null; + return reflectClassPrototype(lowerer, info, loc, classValueRef(lowerer, info, node)); +} + /** Named data and method slots have native descriptors. Bare reflection and * accessor replacement still require a complete view of the prototype. */ export function lowerClassPrototypeData( @@ -566,6 +655,12 @@ export function lowerClassPrototypeData( }); return reflectClassPrototype(lowerer, info, locOf(expr), receiver); } + if ( + name === null && + prototypeViewPosition(lowerer, expr) && + viewableClass(lowerer, info, expr.expression) + ) + return reflectClassPrototype(lowerer, info, locOf(expr), classValueRef(lowerer, info, expr)); if (name === null || (isCompiledPrototypeMember(lowerer, info, name) && !method)) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/date-values.ts b/packages/compiler/src/frontend/lowering/date-values.ts index ef5eafebe..8931d0f80 100644 --- a/packages/compiler/src/frontend/lowering/date-values.ts +++ b/packages/compiler/src/frontend/lowering/date-values.ts @@ -1,5 +1,6 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; +import { isDateSetter } from "./builtins/dates.js"; /** Date methods whose receiver is only read for its time value: a Date * local used as their receiver can store the time value instead of a Date @@ -41,12 +42,15 @@ const RELATIONAL = new Set([ /** True when a local `const` (or a `let` that is never assigned again) * initialized with `new Date(...)` can keep the Date's time value instead - * of a Date object: no use observes the Date's identity. Every use reads the time value as - * a Date method receiver, a String() or template operand, a relational or - * string `+` operand, or `new Date(d)`'s argument, inside the declaring - * function, or inside the block or loop that scopes a module-level - * binding (no closure captures it). Top-level module bindings keep the - * object. Other uses box the time value into a new Date at the use. */ + * of a Date object: no use observes the Date's identity. Every use reads + * the time value as a Date method receiver (a setter replaces it; the + * arguments of a method that reads it call no setter on it), a + * String(), Number() or template operand, a unary + or - operand, a + * relational or string `+` operand, or `new Date(d)`'s argument, inside + * the declaring function, or inside the block or loop that scopes a + * module-level binding (no closure captures it). Top-level module bindings + * keep the object. Other uses box the time value into a new Date at the + * use. */ export function isTimeValueDateDeclaration( lowerer: Lowerer, decl: ts.VariableDeclaration, @@ -94,17 +98,21 @@ function decideTimeValueDate(lowerer: Lowerer, decl: ts.VariableDeclaration): bo const symbol = lowerer.checker.getSymbolAtLocation(decl.name); if (!symbol) return false; let ok = true; + let sets = false; const visit = (node: ts.Node): void => { if (!ok) return; if (ts.isIdentifier(node) && node !== decl.name && node.text === decl.name.text) { if (lowerer.checker.getSymbolAtLocation(node) === symbol) { - if (enclosingFunction(node) !== fn || !readsTimeValue(lowerer, node)) ok = false; + const use = enclosingFunction(node) === fn ? timeValueUse(lowerer, node, symbol) : null; + if (use === null) ok = false; + else if (use === "set") sets = true; } return; } ts.forEachChild(node, visit); }; visit(scope); + if (ok && sets) lowerer.timeValueDateWrites.add(decl); return ok; } @@ -164,29 +172,75 @@ function enclosingFunction(node: ts.Node): ts.FunctionLikeDeclaration | null { return null; } -/** A use of the binding that reads only its time value. */ -function readsTimeValue(lowerer: Lowerer, id: ts.Identifier): boolean { +/** How a use of the binding uses its time value: "read" for a use that + * reads only the time value, "set" for a setter call that replaces it, or + * null for any other use. */ +function timeValueUse( + lowerer: Lowerer, + id: ts.Identifier, + symbol: ts.Symbol, +): "read" | "set" | null { let node: ts.Node = id; while (ts.isParenthesizedExpression(node.parent)) node = node.parent; const parent = node.parent; + if (parent === undefined) return null; if (ts.isPropertyAccessExpression(parent) && parent.expression === node) { const call = parent.parent; - return ( - !parent.questionDotToken && - ts.isCallExpression(call) && - call.expression === parent && - !call.questionDotToken && - TIME_VALUE_METHODS.has(parent.name.text) && - lowerer.isStdlibMember(parent) - ); + if ( + parent.questionDotToken || + !ts.isCallExpression(call) || + call.expression !== parent || + call.questionDotToken || + !lowerer.isStdlibMember(parent) + ) + return null; + if (isDateSetter(parent.name.text)) return "set"; + // The method reads the time value after its arguments are evaluated: + // a setter there (`d.toJSON(d.setTime(0))`) changes what it reads. + if (call.arguments.nodes.some((arg) => callsSetterOf(lowerer, arg, symbol))) return null; + return TIME_VALUE_METHODS.has(parent.name.text) ? "read" : null; } + return readsTimeValue(lowerer, node, parent, symbol) ? "read" : null; +} + +/** True when `node` calls a setter on the binding `symbol` names. */ +function callsSetterOf(lowerer: Lowerer, node: ts.Node, symbol: ts.Symbol): boolean { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + isDateSetter(node.expression.name.text) + ) { + let receiver = node.expression.expression; + while (ts.isParenthesizedExpression(receiver)) receiver = receiver.expression; + if (ts.isIdentifier(receiver) && lowerer.checker.getSymbolAtLocation(receiver) === symbol) + return true; + } + return ( + ts.forEachChild(node, (child) => callsSetterOf(lowerer, child, symbol) || undefined) ?? false + ); +} + +/** A use of the binding, other than a method call, that reads only its + * time value. */ +function readsTimeValue( + lowerer: Lowerer, + node: ts.Node, + parent: ts.Node, + symbol: ts.Symbol, +): boolean { if (ts.isTemplateSpan(parent)) return parent.expression === node; + // Unary + and -, the number hint. + if (ts.isPrefixUnaryExpression(parent)) + return ( + parent.operator === ts.SyntaxKind.PlusToken || parent.operator === ts.SyntaxKind.MinusToken + ); if (ts.isCallExpression(parent)) { return ( parent.arguments.nodes.length === 1 && parent.arguments.nodes[0] === node && ts.isIdentifier(parent.expression) && - lowerer.isStdlibGlobal(parent.expression, "String") + (lowerer.isStdlibGlobal(parent.expression, "String") || + lowerer.isStdlibGlobal(parent.expression, "Number")) ); } if (ts.isNewExpression(parent)) { @@ -198,6 +252,9 @@ function readsTimeValue(lowerer: Lowerer, id: ts.Identifier): boolean { ); } if (ts.isBinaryExpression(parent)) { + // A left operand converts after the right one is evaluated: a setter + // there changes the time value the comparison or concatenation reads. + if (parent.left === node && callsSetterOf(lowerer, parent.right, symbol)) return false; const other = parent.left === node ? parent.right : parent.left; const otherKind = lowerer.mapTypeOf(lowerer.typeOf(other))?.kind; if (RELATIONAL.has(parent.operatorToken.kind)) return otherKind === "date"; diff --git a/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts b/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts index 4696659bc..4786585d9 100644 --- a/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts +++ b/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts @@ -168,17 +168,19 @@ test("reports the functions whose placeholders it fills", () => { expect(filled).toEqual([functions[0]]); }); -test("an update of the binding demands its initializer", () => { +test("an update or an assignment of the binding demands its initializer", () => { const { initializers, pending, lowerer } = fixture(); const updated = block(loc), - written = block({ ...loc, start: 2, end: 3 }); + written = block({ ...loc, start: 2, end: 3 }), + assigned = block({ ...loc, start: 4, end: 5 }), + unused = block({ ...loc, start: 6, end: 7 }); const update = (id: string, prefix: boolean): IrStmt => ({ kind: "exprStmt", expr: { kind: "incDec", op: "+", prefix, localId: id, type: F64, loc }, loc, }); const functions = [ - fn("init", [updated, written]), + fn("init", [updated, written, assigned, unused]), fn("use", [ update("updated", false), { @@ -187,13 +189,29 @@ test("an update of the binding demands its initializer", () => { value: { kind: "numLit", value: 1, type: F64, loc }, loc, }, + { + kind: "exprStmt", + expr: { + kind: "assignExpr", + localId: "assigned", + value: { kind: "numLit", value: 2, type: F64, loc }, + type: F64, + loc, + }, + loc, + }, ]), ]; pending.set("updated", [{ loc, lower: () => [value(2)] }]); pending.set("written", [{ loc: written.loc, lower: () => [value(3)] }]); + pending.set("assigned", [{ loc: assigned.loc, lower: () => [value(4)] }]); + pending.set("unused", [{ loc: unused.loc, lower: () => [value(5)] }]); expect(initializers.process(lowerer, functions)).toBe(true); expect(updated.body).toEqual([value(2)]); - // A plain write never observes the initial value. - expect(written.body).toEqual([]); - expect(pending.has("written")).toBe(true); + // An assignment checks that the binding is initialized and releases its + // value, so the declaration must have run. + expect(written.body).toEqual([value(3)]); + expect(assigned.body).toEqual([value(4)]); + expect(unused.body).toEqual([]); + expect(pending.has("unused")).toBe(true); }); diff --git a/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts b/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts index a44df8f75..3af054dd6 100644 --- a/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts +++ b/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts @@ -387,18 +387,22 @@ function scanStatements( const placeholders: PlaceholderBlock[] = []; const typeEvents = lowerer ? typeScanner(lowerer, body) : null; const reads = new Set(); + const observe = (id: string): void => { + if (deferred!.has(id) && !reads.has(id)) { + reads.add(id); + body.push({ kind: "read", id }); + } + }; everyStmtList(statements, { stmt: (statement) => { if (statement.kind === "block" && statement.body.length === 0) placeholders.push(statement); + if (typeEvents !== null && statement.kind === "assign") observe(statement.localId); return true; }, expr: (expr) => { if (typeEvents === null) return true; typeEvents(expr.type); - if (readsBinding(expr) && deferred!.has(expr.localId) && !reads.has(expr.localId)) { - reads.add(expr.localId); - body.push({ kind: "read", id: expr.localId }); - } + if (observesBinding(expr)) observe(expr.localId); // Every call, as the helpers are visited from a stack in call order. if ( expr.kind === "call" && @@ -411,10 +415,14 @@ function scanStatements( return { body, placeholders }; } -/** A read of a binding's current value: a plain read, or an update - * (`n++`, `--n`) that reads before it writes. */ -function readsBinding(expr: IrExpr): expr is IrExpr & { kind: "varRef" | "incDec" } { - return expr.kind === "varRef" || expr.kind === "incDec"; +/** A use that needs the binding initialized: a plain read, an update + * (`n++`, `--n`) that reads before it writes, or an assignment, which + * checks that the binding is initialized and releases its current value. + * Assignment statements count the same way (scanStatements). */ +function observesBinding( + expr: IrExpr, +): expr is IrExpr & { kind: "varRef" | "incDec" | "assignExpr" } { + return expr.kind === "varRef" || expr.kind === "incDec" || expr.kind === "assignExpr"; } function registryInitializer( diff --git a/packages/compiler/src/frontend/lowering/dispatch-sites.ts b/packages/compiler/src/frontend/lowering/dispatch-sites.ts index b9ab8f069..33890ab52 100644 --- a/packages/compiler/src/frontend/lowering/dispatch-sites.ts +++ b/packages/compiler/src/frontend/lowering/dispatch-sites.ts @@ -72,6 +72,7 @@ const DISPATCH_LIB_CALLS = new Set([ "dyn.keySetComputed", "dyn.iterator", "dyn.arrayFromIterator", + "dyn.asyncIteratorOpen", "dyn.toString", "dyn.stringConstructor", "dyn.forInKeys", diff --git a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts index c1b54a417..d8e855c5d 100644 --- a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts @@ -30,9 +30,10 @@ import { lowerCollectionSpread } from "../containers/collection-methods.js"; import { strCharsCall } from "../containers/array-construction.js"; import { generatorDrain } from "../iterator-adapters.js"; import { isSearchParamsIterable } from "../builtins/url.js"; -import { varRef } from "../../../ir/build.js"; +import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; import { arrayValueStore } from "../array-values.js"; import { classGeneratorIterable } from "../class-iterables.js"; +import { iteratorCanStep } from "../iterator-consumption.js"; /** JavaScript: whether `expr` is or holds an existing checked-dynamic * object — a binding stored as a dyn value whose checker type is an @@ -482,6 +483,14 @@ export function lowerArrayLiteral( if (src.type.kind === "object") { const drained = lowerer.classIteratorDrainCall(src, locOf(el), type.elem); if (drained) src = drained; + // In JavaScript an iterable class whose inferred iterator shape has + // no static protocol spreads through the runtime's iterator walk. + else if ( + isJsSourceFile(ts.getSourceFileOfNode(el)) && + lowerer.classes.get(src.type.className) && + lowerer.findMethodOn(lowerer.classes.get(src.type.className)!, "sym:iterator") + ) + src = lowerer.coerceInto(el.expression, src, DYN); } // `[...s]` on a STRING spreads its code-point characters (the // string iterator's walk — astral chars whole) through the same @@ -770,13 +779,17 @@ function lowerTupleSpreadLiteral( return { kind: "seqExpr", stmts, result: { kind: "recordLit", fields, type, loc }, type, loc }; } -/** Drain using checked dispatch so native class and generator iterators keep their methods. - * `spell` is the TypeError message for a source that is not iterable - * (notIterableSpelling). */ +/** Drain a checked-dynamic iterable: `next` is read once when the iterator + * opens, as JS keeps it in the iterator record, so a `next` the iterator + * assigns while it runs is not called. Runtime iterators step natively, and + * other iterators call the `next` read (class methods keep their emitted + * dispatch). `spell` is the TypeError message for a source that is not + * iterable (notIterableSpelling). */ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, loc: SrcLoc): IrExpr { const name = "%iterator.pack.checked"; if (!lowerer.liftedFns.some((fn) => fn.name === name)) { const ref = (id: string): IrExpr => ({ kind: "varRef", localId: id, type: DYN, loc }); + const flag = (id: string): IrExpr => ({ kind: "varRef", localId: id, type: BOOL, loc }); const property = (key: string): IrExpr => ({ kind: "dynKeyGet", value: ref("step"), @@ -784,6 +797,7 @@ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, lo type: DYN, loc, }); + const finished: IrStmt = { kind: "return", value: ref("result"), loc }; lowerer.liftedFns.push({ name, params: [ @@ -792,12 +806,13 @@ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, lo ], returnType: DYN, locals: [ - ...["source", "iterator", "step", "result"].map((id) => ({ + ...["source", "iterator", "next", "step", "value", "result"].map((id) => ({ id, name: id, type: DYN, mutable: id !== "source", })), + { id: "fast", name: "fast", type: BOOL, mutable: false }, { id: "spell", name: "spell", type: STRING, mutable: false }, ], body: [ @@ -813,6 +828,24 @@ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, lo }, loc, }, + { + kind: "varDecl", + localId: "next", + init: { + kind: "dynKeyGet", + value: ref("iterator"), + key: { kind: "strLit", value: "next", type: STRING, loc }, + type: DYN, + loc, + }, + loc, + }, + { + kind: "varDecl", + localId: "fast", + init: iteratorCanStep(ref("iterator"), ref("next"), loc), + loc, + }, { kind: "varDecl", localId: "result", @@ -823,25 +856,77 @@ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, lo kind: "while", cond: { kind: "boolLit", value: true, type: BOOL, loc }, body: [ - { - kind: "varDecl", - localId: "step", - init: { - kind: "dynInvoke", - recv: ref("iterator"), - method: "next", - calleeName: "iterator.next", - args: [], - type: DYN, - loc, - }, - loc, - }, + // Declared before the branches, which each assign it: a local + // declared in a branch is released when the branch ends. + { kind: "varDecl", localId: "value", init: dynUndefinedExpr(loc), loc }, { kind: "if", - cond: { kind: "dynTest", test: "truthy", value: property("done"), type: BOOL, loc }, - then: [{ kind: "return", value: ref("result"), loc }], - else_: null, + cond: flag("fast"), + then: [ + { + kind: "assign", + localId: "value", + value: { + kind: "libCall", + fn: "dyn.iteratorStep", + args: [ref("iterator")], + type: DYN, + loc, + }, + loc, + }, + { + kind: "if", + cond: { + kind: "libCall", + fn: "dyn.iteratorStepDone", + args: [ref("iterator")], + type: BOOL, + loc, + }, + then: [finished], + else_: null, + loc, + }, + ], + else_: [ + { + kind: "varDecl", + localId: "step", + init: { + kind: "libCall", + fn: "dyn.iteratorResult", + args: [ + { + kind: "dynCall", + callee: ref("next"), + receiver: ref("iterator"), + calleeName: "iterator.next", + args: [], + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }, + loc, + }, + { + kind: "if", + cond: { + kind: "dynTest", + test: "truthy", + value: property("done"), + type: BOOL, + loc, + }, + then: [finished], + else_: null, + loc, + }, + { kind: "assign", localId: "value", value: property("value"), loc }, + ], loc, }, { @@ -849,7 +934,7 @@ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, lo expr: { kind: "libCall", fn: "dyn.packPush", - args: [ref("result"), property("value")], + args: [ref("result"), ref("value")], type: VOID, loc, }, diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 8d6181a6e..22a2f019f 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -58,6 +58,7 @@ import { fenceNodeModuleMutation, lowerRequireCacheHas } from "../lower-node-mod import { errorToStringCall } from "../error-methods.js"; import { classToStringConversion, classUnionToString } from "../class-to-primitive.js"; import { isPerfEntryType, perfEntryTag } from "../builtins/performance.js"; +import { isX509Type, x509ToString } from "../builtins/x509.js"; import { guardNullProto, nullProtoRecordShape } from "../null-prototype.js"; import { numLit, varRef } from "../../../ir/build.js"; import { lowerUrlAssignment } from "../builtins/url.js"; @@ -799,6 +800,7 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr // other record is Object.prototype.toString's constant. const shape = lowerer.shapes.get(e.type.shapeId); if (isPerfEntryType(lowerer, e.type)) return perfEntryTag(lowerer, e, e.type, e.loc); + if (isX509Type(lowerer, e.type)) return x509ToString(e, e.loc); if (shape && !shape.tuple && !shape.fields.some((f) => f.name === "toString")) { // A null-prototype record (Object.groupBy) has no toString or // valueOf: ToPrimitive throws Node's TypeError. @@ -1600,6 +1602,19 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr loc, }; } + // A Date against an untyped value: the same Date object, whose + // checked-dynamic handle the untyped value would hold. + if (dynSide.type.kind === "dyn" && scalarSide.type.kind === "date") { + const boxed = lowerer.coerceToExpected(scalarSide, DYN); + return { + kind: "dynScalarEq", + left: scalarSide === left ? boxed : left, + right: scalarSide === right ? boxed : right, + ...(negated ? { negated: true as const } : {}), + type: BOOL, + loc, + }; + } if ( dynSide.type.kind === "dyn" && (scalarSide.type.kind === "f64" || @@ -2024,6 +2039,28 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr loc, }; if (left.type.kind === "string" || right.type.kind === "string") { + // A Date on the left converts after the right operand is + // evaluated, which can run its setters. + if (left.type.kind === "date" && !isSafeToRepeat(right)) { + const date = lowerer.declareHiddenLocal("%plus", left.type); + const other = lowerer.declareHiddenLocal("%plus", right.type); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: date.id, init: left, loc }, + { kind: "varDecl", localId: other.id, init: right, loc }, + ], + result: { + kind: "strConcat", + left: concatOperand(lowerer, varRef(date.id, left.type, loc), expr.left), + right: concatOperand(lowerer, varRef(other.id, right.type, loc), expr.right), + type: STRING, + loc, + }, + type: STRING, + loc, + }; + } return { kind: "strConcat", left: concatOperand(lowerer, left, expr.left), diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index f7ba7a115..62b2c7960 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -32,6 +32,7 @@ import { } from "../cjs-inline-require.js"; import { captureContextArguments } from "../function-context.js"; import { argumentsOwner, typedArgumentsCandidate } from "../typed-arguments.js"; +import { argumentCountValue } from "../argument-counts.js"; import { isCjsExportTableLiteral, isCjsJsFile, @@ -57,6 +58,7 @@ import { contextualUndefReadType, fenceEarlyAliasUse, isTemporalGlobalRoot, + cjsNamespaceImportDep, lowerNsIdentifierValue, nsUndefRead, } from "../lower-namespaces.js"; @@ -448,7 +450,17 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // semantics); the rest marker carries the trailing dyn-array ABI. const funcType: IrType = funcTypeFromParamShapes(sig.params, sig.returnType); lowerer.requireExactArityValue(expr, expr, sig.params, funcType); - return { kind: "closure", fnName: sig.name, captures: [], type: funcType, loc }; + const closure = { + kind: "closure" as const, + fnName: sig.name, + captures: [], + type: funcType, + loc, + }; + const decl = lowerer.resolveValueSymbol(expr)?.valueDeclaration; + return decl && ts.isFunctionDeclaration(decl) && funcType.kind === "func" + ? argumentCountValue(lowerer, decl, expr, { ...closure, type: funcType }) + : closure; } { // A generic function as a VALUE: monomorphized by flow — the @@ -830,7 +842,17 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: "deref()-after-collect exposes GC timing — genuinely dynamic; hold a strong reference instead", eval: "runtime code evaluation cannot be compiled ahead of time", }; - lowerer.noLowering(expr.text, expr, globalHints[expr.text], sym ?? undefined); + lowerer.noLowering( + expr.text, + expr, + globalHints[expr.text], + sym ?? undefined, + expr.text === "WeakRef" || expr.text === "FinalizationRegistry" + ? "weak-ref" + : expr.text === "eval" + ? "code-from-string" + : undefined, + ); } } // An ambient global nothing defines compiles to exactly what Node does @@ -958,6 +980,37 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // representation (ambient namespaces compile to Node's // ReferenceError instead — the object never exists at runtime). { + // `ns.name` through a namespace import of a CommonJS module, where Node's + // namespace has no `name` property (its lexer does not detect it), or + // `ns.default` (module.exports itself). + const cjsNamespace = cjsNamespaceImportDep(lowerer, expr); + if ( + cjsNamespace !== null && + ts.isPropertyAccessExpression(expr.parent) && + expr.parent.expression === expr && + ts.isIdentifier(expr.parent.name) + ) { + const name = expr.parent.name.text; + const { names, assignedLater } = lowerer.cjsNamespaceOf(cjsNamespace); + if (name === "default") + lowerer.unsupported( + "SC1090", + expr.parent, + `reads of 'default' through namespace imports of CommonJS modules (module.exports itself: read it through a default import)`, + ); + if (!names.has(name)) + lowerer.unsupported( + "SC1090", + expr.parent, + `reads of '${name}' through namespace imports of CommonJS modules (Node's lexer does not detect '${name}' among the module's exports, so Node reads undefined)`, + ); + if (assignedLater === "all" || assignedLater.has(name)) + lowerer.unsupported( + "SC1090", + expr.parent, + `reads of '${name}' through namespace imports of CommonJS modules whose functions assign exports (Node's namespace keeps the values from the end of the module's evaluation)`, + ); + } const moduleNs = lowerer.mapTypeOf(lowerer.typeOf(expr)); if (moduleNs?.kind === "moduleNs") { return { kind: "moduleNsRef", moduleId: moduleNs.moduleId, type: moduleNs, loc }; diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 4cc0bee1a..70766949a 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -28,6 +28,7 @@ import type { IndexMergeContributor } from "../containers/indexed-objects.js"; import type { KeyOrderSegment } from "../key-order-witness.js"; import { PERF_ENTRY_MARKER, + X509_PUBLIC_KEY_FIELD, isGenericCallableMemberType, jsOpenObjectType, } from "../../type-mapper.js"; @@ -38,6 +39,7 @@ import { planUnionRetag } from "../union-retag.js"; import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs.js"; import { builtinGlobalValue } from "./property-access.js"; import { holdsEvolvingArray } from "../lower-stmts.js"; +import { builtinNamespaceValue } from "../builtin-namespaces.js"; /** `{ a: 1, b: "x" }` → recordLit. The record type comes from the * contextual type when tsc has one (annotated declarations, arguments, @@ -501,6 +503,13 @@ function fenceAccessorSpreadSource( "object spread over performance entries (Node keeps their fields as prototype getters, so own-key walks see none; read the fields or call toJSON())", ); } + if (srcShape && srcShape.fields.some((f) => f.name === X509_PUBLIC_KEY_FIELD)) { + lowerer.unsupported( + "SC1090", + prop, + "object spread over X509Certificate values (Node keeps their fields as prototype getters, so own-key walks see none; read the fields)", + ); + } if (srcShape && shapeHasAccessorSlots(srcShape)) { lowerer.unsupported( "SC1090", @@ -3262,7 +3271,11 @@ function shorthandBuiltinValue( const symbol = lowerer.checker.getShorthandAssignmentValueSymbol(prop); if (!symbol || !lowerer.isStdlibSymbol(symbol)) return null; const js = isJsSourceFile(ts.getSourceFileOfNode(prop)); - return builtinGlobalValue(ts.symbolName(symbol), locOf(prop.name), js); + const name = ts.symbolName(symbol); + return ( + (js ? builtinNamespaceValue(lowerer, name, locOf(prop.name)) : null) ?? + builtinGlobalValue(name, locOf(prop.name), js) + ); } export function lowerShorthandValue( diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 1a4883e5a..35bb16cd3 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1,12 +1,14 @@ /* Property reads (`a.b`): lowerExprInner's property-access branch, intrinsic and union properties, field reads and targets, and match-group reads. */ import { indexedUnionArms, lowerIndexedUnionMember } from "../indexed-unions.js"; import { keyedHelperName } from "../stable-names.js"; +import { builtinNamespaceValue } from "../builtin-namespaces.js"; import { staticallySelected } from "../static-typeof.js"; import * as ts from "../../ts7/adapter.js"; import { lowerHistogramProperty } from "../builtins/performance.js"; import { staticFunctionNameRead } from "../static-function-names.js"; import { typedArgumentsArity } from "../typed-arguments.js"; import { isThisParameter } from "../call-signatures.js"; +import { argumentCountSlot, contextualCallCount, usedAsValue } from "../argument-counts.js"; import { checkedPrivateValue, localPrivateBrandClass, @@ -59,6 +61,7 @@ import { BYTES_ELEMENT_SIZE, } from "../../../ir/ir.js"; import { lowerNavigatorMember } from "../builtins/navigator.js"; +import { lowerX509PublicKey } from "../builtins/x509.js"; import { NODE_COMPAT_VERSION } from "../builtins/process.js"; import { inlineNamespaceRequireOf, @@ -112,6 +115,7 @@ import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../. import { typedArrayLacksMember } from "../builtins/typed-array-members.js"; import { TEMPORAL_GLOBAL } from "../../builtin-modules.js"; import { tryLowerExpression } from "./try-lower-expression.js"; +import { DATE_METHOD_HINT } from "../builtins/dates.js"; import { cjsClassExprWholeExportOf, cjsExportAssignmentOf, @@ -146,6 +150,8 @@ import { nullishGenericBindingUnitOf } from "../binding-analysis.js"; import { blockedBindingUseDiag } from "../../../diagnostics/diagnostic.js"; import { npmStaticPackageOfPath } from "../../npm-static.js"; import { importMetaField, isImportMeta } from "../import-meta.js"; +import { createRequireCalleeFileOf } from "../builtins/module-bindings.js"; +import { createRequireResolveFenceValue } from "../builtins/module-resolution.js"; import { builtinPrototypeMethod, lowerArrayIsArrayValue, @@ -160,6 +166,7 @@ import { lowerNetAddressStaticRead } from "../builtins/net-address.js"; import { STATS_TYPE_TESTS } from "../builtins/filesystem.js"; import { arrayValueRead, arrayValueType } from "../array-values.js"; import { lowerClassMethodValue } from "../class-method-values.js"; +import { reflectMemberValue } from "../builtins/reflection.js"; import { objLitGenericFnInfoOf, objLitGenericFnNodeOf, @@ -286,6 +293,21 @@ const DGRAM_METHOD_VALUES = new Set([ "unref", ]); +function isCreateRequireResolveValue(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { + let resolve: ts.Expression = expr; + if ( + expr.name.text === "paths" && + ts.isPropertyAccessExpression(expr.expression) && + !expr.expression.questionDotToken + ) + resolve = expr.expression; + return ( + ts.isPropertyAccessExpression(resolve) && + resolve.name.text === "resolve" && + createRequireCalleeFileOf(lowerer, resolve.expression) !== null + ); +} + export function lowerPropertyAccessExpr( lowerer: Lowerer, expr: ts.PropertyAccessExpression, @@ -348,6 +370,11 @@ export function lowerPropertyAccessExpr( const reading = lowerHistogramProperty(lowerer, expr); if (reading) return reading; } + // An X509Certificate's publicKey KeyObject. + { + const publicKey = lowerX509PublicKey(lowerer, expr); + if (publicKey) return publicKey; + } // `.length` of a forwarded fixed tuple (a typed `arguments`, a tuple // rest parameter, an alias of either): the signature's arity. if (expr.name.text === "length" && !expr.questionDotToken) { @@ -499,6 +526,8 @@ export function lowerPropertyAccessExpr( }; } if (lowerer.stdlibGlobalMember(expr, "Object") === "freeze") { + // Object.freeze as a value can freeze any untyped value it is passed. + lowerer.untypedIntegrity = true; const name = "%builtin.Object.freeze"; if (!lowerer.liftedFns.some((fn) => fn.name === name)) lowerer.liftedFns.push({ @@ -689,7 +718,31 @@ export function lowerPropertyAccessExpr( const declared = (fn as ts.FunctionLikeDeclaration).parameters.nodes.filter( (p) => !isThisParameter(p), ).length; - const count = Math.max(declared, contextualArgumentCount(lowerer, fn) ?? 0); + const contextual = contextualArgumentCount(lowerer, fn); + if (contextual === "varies") { + lowerer.unsupported( + "SC1090", + expr, + "'arguments.length' in function expressions passed where the expected function type has optional or rest parameters (the count varies per call)", + ); + } + // A top-level declaration also used as a value reads the count its + // caller stored (argument-counts.ts). + if (ts.isFunctionDeclaration(fn) && lowerer.ctx.argumentCount !== undefined) + return varRef(lowerer.ctx.argumentCount.id, F64, loc); + if ( + (ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn)) && + usedAsValue(lowerer, fn) + ) { + lowerer.unsupported( + "SC1090", + expr, + ts.isFunctionDeclaration(fn) && argumentCountSlot(lowerer, fn) !== null + ? "'arguments.length' in an arrow function inside a function that is also used as a value" + : "'arguments.length' in methods, nested, async and generator functions used as values (a caller of the value can pass more arguments than the function declares)", + ); + } + const count = Math.max(declared, contextual ?? 0); // Calls through the expression's own name pass the declared count, // calls through the wider slot pass more: no constant answers both. if (count > declared && referencesOwnName(lowerer, fn)) { @@ -1114,11 +1167,18 @@ export function lowerPropertyAccessExpr( ) ) { const fnName = jsFuncNameOf(expr.expression); + // A builtin function value whose length is not its adapter's + // parameter count (`Math.max.length` is 2). + const fnLength = + probed.kind === "closure" + ? lowerer.builtinCallableValueLengths.get(probed.fnName) + : undefined; const boxed: IrExpr = { kind: "dynFrom", value: probed, type: DYN, ...(fnName !== null ? { fnName } : {}), + ...(fnLength !== undefined ? { fnLength } : {}), loc, }; const key: IrExpr = { @@ -1275,6 +1335,11 @@ export function lowerPropertyAccessExpr( return declared && declared.kind !== "void" ? lowerer.coerceToExpected(read, declared) : read; } } + // A createRequire loader's resolve read as a value (passed on, or + // picked by `??`): resolution happens at build time, so only calls + // with a literal specifier lower. + if (isCreateRequireResolveValue(lowerer, expr)) + return createRequireResolveFenceValue(lowerer, expr, loc); // The lib fence's PROPERTY chokepoint: a stdlib-declared member that // no lowering above claimed ([1,2].entries, Math.SQRT2, Promise.all, // re.exec as a value, ...) reports SC2020 here. @@ -1683,19 +1748,8 @@ export function lowerIntrinsicProperty( ); } } - if (lowerer.stdlibGlobalMember(expr, "Reflect") === "ownKeys") { - const value = lowerer.lowerNativeCallableValue( - { - fn: "dyn.ownKeys", - params: [DYN], - result: DYN, - valueParams: [{ mode: "required", type: DYN }], - }, - "Reflect.ownKeys", - locOf(expr), - )!; - return { kind: "dynFrom", value, fnName: "ownKeys", type: DYN, loc: locOf(expr) }; - } + if (lowerer.stdlibGlobalMember(expr, "Reflect") === "ownKeys") + return reflectMemberValue(lowerer, "ownKeys", locOf(expr)); if (lowerer.chainBlocked(expr)) return null; // Object.prototype.constructor: the Object constructor value. if ( @@ -2055,6 +2109,9 @@ export function lowerIntrinsicProperty( ); } if (kind === "date") { + // A Date held as a checked-dynamic value (a Date a JavaScript file + // constructs) answers its method values at run time. + if (tryLowerExpression(lowerer, expr.expression)?.type.kind === "dyn") return null; const methods = new Set([ "getTime", "valueOf", @@ -2083,7 +2140,7 @@ export function lowerIntrinsicProperty( lowerer.noLowering( `Date.prototype.${name}`, expr, - "getTime(), valueOf(), toISOString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering", + DATE_METHOD_HINT, lowerer.checker.getSymbolAtLocation(expr.name), ); } @@ -4277,6 +4334,10 @@ export function builtinGlobalValueAt( loc: SrcLoc, js: boolean, ): IrExpr | null { + // A namespace object models its own properties, so member reads and + // reflection read it like any object. + const namespace = js ? builtinNamespaceValue(lowerer, canonical, loc) : null; + if (namespace !== null) return namespace; if (readsBuiltinGlobalMembers(expr)) return null; const value = builtinGlobalValue(canonical, loc, js); if (value === null) return null; @@ -4558,26 +4619,9 @@ function referencesOwnName(lowerer: Lowerer, fn: ts.Node): boolean { } /** The argument count every caller passes a function expression whose - * value flows into one fixed-arity function type, an array method's - * callback or an annotated slot: that type's parameter count. Null when - * there is no such type or its arity varies. */ -function contextualArgumentCount(lowerer: Lowerer, fn: ts.Node): number | null { - if (!ts.isFunctionExpression(fn)) return null; - const contextual = lowerer.checker.getContextualType(fn); - if (contextual === undefined) return null; - const signatures = lowerer.checker.getCallSignatures( - lowerer.checker.getNonNullableType(contextual), - ); - if (signatures.length !== 1) return null; - const declaration = signatures[0]!.declaration; - if (declaration === undefined) return null; - let count = 0; - for (const parameter of (declaration.parameters?.nodes ?? []) as ts.ParameterDeclaration[]) { - if (isThisParameter(parameter)) continue; - if (parameter.questionToken || parameter.initializer || parameter.dotDotDotToken) return null; - count++; - } - return count; + * value flows into a function type (argument-counts.ts contextualCallCount). */ +function contextualArgumentCount(lowerer: Lowerer, fn: ts.Node): number | "varies" | null { + return ts.isFunctionExpression(fn) ? contextualCallCount(lowerer, fn) : null; } /** The property names the program assigns on the global object, through diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 9477fb4b3..3ddadc093 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -304,6 +304,16 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc if (!target) { const date = lowerer.isStdlibGlobal(expr.right, "Date"); const url = lowerer.isStdlibGlobal(expr.right, "URL"); + // A catch binding holds a thrown Date as its checked-dynamic handle. + const caughtDate = date ? lowerer.caughtLocalOf(expr.left) : null; + if (caughtDate) + return { + kind: "libCall", + fn: "dyn.nativeDateIs", + args: [{ kind: "caughtToDyn", value: varRef(caughtDate.id, CAUGHT, loc), type: DYN, loc }], + type: BOOL, + loc, + }; if ((date || url) && !lowerer.caughtLocalOf(expr.left)) { const value = lowerer.lowerExpr(expr.left); if (value.type.kind === "dyn") @@ -2234,23 +2244,10 @@ function lowerRuntimeKeyIn( return { kind: "call", callee: helper, args: [keyIr, recv], type: BOOL, loc }; } -/** Standard-library constructors the untyped instanceof helper has no - * instance test for: an untyped `x instanceof Object` always refuses. */ -const DYN_INSTANCEOF_REFUSED: ReadonlySet = new Set(["Object", "Function"]); - /** The SC1090 message `x instanceof C` throws, with the site's position, - * when `C` is a runtime constructor with no instance test. A right-hand - * side that names one of DYN_INSTANCEOF_REFUSED always reaches that - * refusal, so the site is listed with the runtime fences. */ + * when `C` is a runtime constructor with no instance test. */ function dynInstanceOfRefusal(lowerer: Lowerer, expr: ts.BinaryExpression): string { const diag = unsupportedDiag("SC1090", locOf(expr), "instanceof against this native constructor"); - const symbol = lowerer.checker.getSymbolAtLocation(expr.right); - if ( - symbol !== undefined && - lowerer.isStdlibSymbol(symbol) && - DYN_INSTANCEOF_REFUSED.has(ts.symbolName(symbol)) - ) - lowerer.runtimeFences.push(diag); const sf = ts.getSourceFileOfNode(expr); const line = ts.getLineAndCharacterOfPosition(sf, diag.loc.start).line + 1; return `${diag.message} [${diag.code} at ${diag.loc.file}:${line}]`; diff --git a/packages/compiler/src/frontend/lowering/expressions/typeof.ts b/packages/compiler/src/frontend/lowering/expressions/typeof.ts index fbbc068b9..f6743768a 100644 --- a/packages/compiler/src/frontend/lowering/expressions/typeof.ts +++ b/packages/compiler/src/frontend/lowering/expressions/typeof.ts @@ -35,6 +35,7 @@ import { isExecExceptionType, refuseExecExceptionCode } from "../builtins/errors import { InternalCompilerError } from "../../../errors.js"; import { TEMPORAL_GLOBAL } from "../../builtin-modules.js"; import { isTemporalGlobalRoot } from "../lower-namespaces.js"; +import { isF64HandleTyped } from "../f64-handles.js"; export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc: SrcLoc): IrExpr { const webAssembly = lowerWebAssemblyTypeof(lowerer, expr.expression); @@ -281,13 +282,18 @@ export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc promise: "object", bytes: "object", regex: "object", + date: "object", generator: "object", classval: "function", moduleNs: "object", undefinedT: "undefined", nullT: "object", }; - const folded = FOLD[operand.type.kind]; + // An f64 handle (a readline Interface, a Timeout) is an object. + const folded = + operand.type.kind === "f64" && isF64HandleTyped(lowerer, expr.expression) + ? "object" + : FOLD[operand.type.kind]; if (folded) { const result: IrExpr = { kind: "strLit", value: folded, type: STRING, loc }; return isSafeToDiscard(operand) diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index bdcd94a44..35d5d1a2e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -120,6 +120,11 @@ function coercedUnion(lowerer: Lowerer, type: IrType): boolean { return arms.some((arm) => arm.kind !== "f64" && !isUnitType(arm)); } +/** ToNumber of a Date: its time value (valueOf, the number hint). */ +function dateTimeValue(date: IrExpr, loc: SrcLoc): IrExpr { + return { kind: "libCall", fn: "date.valueOf", args: [date], type: F64, loc }; +} + /** ToNumber of a boolean: 1 for true, 0 for false. */ function booleanToNumber(value: IrExpr, loc: SrcLoc): IrExpr { return { @@ -145,23 +150,25 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio const operand = raw.type.kind === "bool" ? booleanToNumber(raw, loc) - : coercedUnion(lowerer, raw.type) - ? ({ - kind: "libCall", - fn: "dyn.toNumberCoerce", - args: [{ kind: "dynFrom", value: raw, type: DYN, loc }], - type: F64, - loc, - } satisfies IrExpr) - : raw.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr)) + : raw.type.kind === "date" + ? dateTimeValue(raw, loc) + : coercedUnion(lowerer, raw.type) ? ({ kind: "libCall", fn: "dyn.toNumberCoerce", - args: [raw], + args: [{ kind: "dynFrom", value: raw, type: DYN, loc }], type: F64, loc, } satisfies IrExpr) - : lowerOptionalNumber(lowerer, raw, loc, expr.operand); + : raw.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr)) + ? ({ + kind: "libCall", + fn: "dyn.toNumberCoerce", + args: [raw], + type: F64, + loc, + } satisfies IrExpr) + : lowerOptionalNumber(lowerer, raw, loc, expr.operand); if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "-", [raw.type]); if (operand.kind === "numLit") return { ...operand, value: -operand.value, loc }; return { kind: "unary", op: "-", operand, type: F64, loc }; @@ -185,6 +192,8 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio return { kind: "libCall", fn: "num.fromString", args: [raw], type: F64, loc }; } if (raw.type.kind === "bool") return booleanToNumber(raw, loc); + // A Date's number hint answers its time value. + if (raw.type.kind === "date") return dateTimeValue(raw, loc); const classNumber = classToNumberConversion(lowerer, raw, expr.operand, "unary plus"); if (classNumber) return classNumber; const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand); diff --git a/packages/compiler/src/frontend/lowering/f64-handles.ts b/packages/compiler/src/frontend/lowering/f64-handles.ts new file mode 100644 index 000000000..0d7da0ff9 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/f64-handles.ts @@ -0,0 +1,15 @@ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; + +/** Whether `node`'s value is an f64 handle: an object whose class + * type-mapper.ts represents as an f64 id into a runtime registry (a + * readline Interface, a Timeout or Immediate, an AsyncLocalStorage, a + * diagnostics_channel Channel). Its checker type is an object type that + * maps to f64; the Number wrapper interface maps to f64 for primitives. */ +export function isF64HandleTyped(lowerer: Lowerer, node: ts.Node): boolean { + const type = lowerer.checker.getTypeAtLocation(node); + if ((type.flags & ts.TypeFlags.Object) === 0) return false; + const symbol = type.getSymbol(); + if (symbol !== undefined && ts.symbolName(symbol) === "Number") return false; + return lowerer.mapTypeOf(type)?.kind === "f64"; +} diff --git a/packages/compiler/src/frontend/lowering/frozen-records.ts b/packages/compiler/src/frontend/lowering/frozen-records.ts index 30eb9e455..fc770a0cb 100644 --- a/packages/compiler/src/frontend/lowering/frozen-records.ts +++ b/packages/compiler/src/frontend/lowering/frozen-records.ts @@ -24,7 +24,8 @@ import { isStrictCode } from "./strict-mode.js"; * (scr_array.h SCR_ARR_FROZEN), which the backend's array write guards test * for the array types the module lists (IrModule.freezableArrays). A type is * freezable when a static Object.freeze names it, or, in a program that - * freezes untyped values, when a value of it or of a type reaching it can be + * freezes, seals or prevents extensions of untyped values + * (noteUntypedIntegrity), when a value of it or of a type reaching it can be * held as a live untyped reference. */ type RecordType = Extract; @@ -59,6 +60,17 @@ export function lowerNativeFreeze( }; } +/** Records that the program freezes, seals or prevents extensions of the + * untyped value `node` lowers to. Unless it is a fresh object or array + * literal, the value can be any record, array or class instance the program + * holds as an untyped reference, so those types become freezable. */ +export function noteUntypedIntegrity(lowerer: Lowerer, node: ts.Expression): void { + let inner = node; + while (ts.isParenthesizedExpression(inner) || ts.isAsExpression(inner)) inner = inner.expression; + if (!ts.isObjectLiteralExpression(inner) && !ts.isArrayLiteralExpression(inner)) + lowerer.untypedIntegrity = true; +} + function isRecordWrite(s: IrStmt): s is RecordWrite { return s.kind === "recordSet" || s.kind === "recordKeySet" || s.kind === "recordKeyDelete"; } @@ -109,7 +121,6 @@ function freezableTypes( const shapes = new Set(); const arrays = new Set(); const live: IrType[] = []; - let dynFreeze = false; for (const fn of functions) everyStmtList(fn.body, { expr: (e) => { @@ -119,13 +130,12 @@ function freezableTypes( } else if (e.kind === "libCall" && e.fn === "array.freeze") { const target = e.args[0]; if (target && target.type.kind === "array") arrays.add(typeKey(target.type)); - } else if (e.kind === "libCall" && e.fn === "dyn.freeze") dynFreeze = true; - else if (e.kind === "dynFrom") live.push(e.value.type); + } else if (e.kind === "dynFrom") live.push(e.value.type); return true; }, stmt: () => true, }); - if (!dynFreeze) return { shapes, arrays }; + if (!lowerer.untypedIntegrity) return { shapes, arrays }; // An untyped reference to a class instance, or to a record or array // boxed live, reaches the values its fields and elements hold as further // live references. Class instances also cross into untyped code through diff --git a/packages/compiler/src/frontend/lowering/function-context.ts b/packages/compiler/src/frontend/lowering/function-context.ts index 92042b7d7..702574b63 100644 --- a/packages/compiler/src/frontend/lowering/function-context.ts +++ b/packages/compiler/src/frontend/lowering/function-context.ts @@ -46,6 +46,9 @@ export interface FnCtx { * rest param): the synthetic trailing dyn-array param `arguments` * reads resolve to. */ argumentsLocal?: IrLocal | null; + /** The count `arguments.length` reads in a top-level function declaration + * that is also used as a value (argument-counts.ts). */ + argumentCount?: IrLocal; /** Arrows inherit the nearest ordinary function's arguments binding. */ inheritsArguments?: boolean; /** A function lowered on demand on top of an unrelated caller's frames diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index 6d0989e2b..c4563a234 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -175,6 +175,23 @@ export function declaredReturnType( // `() => void`), and throw-only callbacks are ordinary code // (`.action(() => { throw ... })`). `never` VALUES stay unmapped. if (retTsType.flags & ts.TypeFlags.Never) return VOID; + // A JS generator whose result the checker cannot infer (mutually + // recursive methods returning `yield*` of each other, as in yaml's + // Parser) has the return type `any`: a generator over untyped values. + if ( + isJsSourceFile(ts.getSourceFileOfNode(decl)) && + decl.asteriskToken && + retTsType.flags & ts.TypeFlags.Any + ) + return { + kind: "generator", + ...(decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) + ? { async: true as const } + : {}), + yieldT: DYN, + retT: DYN, + nextT: DYN, + }; let mappedReturn = lowerer.mapTypeOf(retTsType); // A generator declared to return Iterable returns a generator over T. if (mappedReturn && decl.asteriskToken) diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index b60cb4a60..c6696b1b4 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -2088,6 +2088,10 @@ function storedImplicitArgumentType(lowerer: Lowerer, arg: ts.Expression): IrTyp isJsSourceFile(ts.getSourceFileOfNode(arg)) && (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) ) { + // A closure lowered here before (another call site's instance key, or + // a repeated demand) keeps its value for the argument's own lowering. + const known = lowerer.speculatedClosure(arg); + if (known !== null) return known.type; const saved = lowerer.diagSink; const diagnostics: typeof lowerer.diags = []; lowerer.diagSink = diagnostics; @@ -2098,8 +2102,17 @@ function storedImplicitArgumentType(lowerer: Lowerer, arg: ts.Expression): IrTyp lowerer.diagSink = saved; } // Direct inferred helpers can receive native callbacks even when their - // result is an opaque handle that cannot cross checked storage. - if (diagnostics.length === 0 && value?.type.kind === "func") return value.type; + // result is an opaque handle that cannot cross checked storage. The + // argument's own lowering then takes this value instead of lowering + // the closure a second time. + if (diagnostics.length === 0 && value?.type.kind === "func") { + lowerer.speculatedClosures.set(arg, { + ctx: lowerer.ctx, + bindings: lowerer.typeParamBindings, + value, + }); + return value.type; + } } if (arg.kind === ts.SyntaxKind.ThisKeyword) return lowerer.resolveThis()?.type ?? null; if (ts.isAwaitExpression(arg)) { diff --git a/packages/compiler/src/frontend/lowering/js-collection-types.ts b/packages/compiler/src/frontend/lowering/js-collection-types.ts new file mode 100644 index 000000000..1e3219276 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/js-collection-types.ts @@ -0,0 +1,539 @@ +/* Types of untyped JavaScript collections that their uses prove. + * + * In a JavaScript file, `const m = new Map()`, `new Set()` and `[]` type as + * Map, Set and any[] to the checker, so every use lowers to + * the checked-dynamic collection operations. When every reference of such a + * `const` is a direct use of the collection, the value never leaves the + * binding. If its keys, values or elements are then each one primitive type + * wherever the program writes and looks them up, the binding lowers as the + * typed Map, Set or T[], as a JSDoc @type would make it: + * + * - Map: `m.get(k)`, `m.has(k)`, `m.delete(k)`, `m.clear()`, `m.size`, and + * `m.set(k, v)` as a statement (its result is the map). + * - Set: `s.add(k)` as a statement, `s.has(k)`, `s.delete(k)`, `s.clear()`, + * `s.size`. + * - Array: `a.push(...)`, `a.pop()`, `a.shift()`, `a.at(i)`, `a.includes(v)`, + * `a.indexOf(v)`, `a.join(separator)`, `a.length`. + * + * Any other reference (passing, returning, storing, exporting, iterating, + * spreading, inspecting the collection, an optional call), a direct `eval` + * in the file, or any doubt about a type keeps the binding untyped. + * + * The types come from carrier declarations in scriptc-overrides.d.ts, which + * only the lowering program includes. */ + +import * as ts from "../ts7/adapter.js"; +import { isJsSourceFile } from "../program.js"; +import { overridesDtsPath } from "../dts-paths.js"; +import type { Lowerer } from "./lowerer.js"; + +type Primitive = "string" | "number" | "boolean"; +const PRIMITIVES: readonly Primitive[] = ["string", "number", "boolean"]; + +type Collection = "map" | "set" | "array"; + +interface Candidate { + readonly kind: Collection; + readonly declaration: ts.VariableDeclaration; + /** Every identifier that names the binding, its declaration's included. */ + readonly names: ts.Identifier[]; + /** Map keys or Set members written by set/add. */ + readonly keyWrites: ts.Expression[]; + /** Map values written by set. Array elements written by push. */ + readonly valueWrites: ts.Expression[]; + /** Arguments compared with keys or elements: get/has/delete, includes/indexOf. */ + readonly lookups: ts.Expression[]; + /** Array indexes passed to at(). */ + readonly indexes: ts.Expression[]; + /** Separators passed to join(). */ + readonly separators: ts.Expression[]; + key: Primitive | undefined; + value: Primitive | undefined; +} + +const MAP_LOOKUPS = new Set(["get", "has", "delete"]); +const SET_LOOKUPS = new Set(["has", "delete"]); +const ARRAY_READS = new Set(["pop", "shift"]); +const ARRAY_SEARCHES = new Set(["includes", "indexOf"]); +/** The expression lowering refuses deeper nesting (SC1090). */ +const MAX_DEPTH = 200; + +/** The inferred type of the binding a `new Map()` or `new Set()` initializes, + * which the construction adopts. */ +export function initializedCollectionType( + lowerer: Lowerer, + expr: ts.NewExpression, +): ts.Type | null { + const declaration = expr.parent; + return declaration !== undefined && + ts.isVariableDeclaration(declaration) && + declaration.initializer === expr && + ts.isIdentifier(declaration.name) + ? inferredCollectionType(lowerer, declaration.name) + : null; +} + +/** The inferred collection type of an identifier that names a proven + * binding, or null. Computed for every JavaScript file of the program on + * first use. The inference itself reads types (a candidate's constructor + * through a destructured require, for example); while it runs, those reads + * see no inferred types rather than starting it again. */ +export function inferredCollectionType(lowerer: Lowerer, node: ts.Identifier): ts.Type | null { + if (lowerer.inferredCollectionTypes === undefined) { + lowerer.inferredCollectionTypes = new Map(); + lowerer.inferredCollectionTypes = inferProgram(lowerer); + } + const types = lowerer.inferredCollectionTypes; + return types.size === 0 ? null : (types.get(node) ?? null); +} + +function inferProgram(lowerer: Lowerer): Map { + const out = new Map(); + const carriers = carrierTypes(lowerer); + if (carriers === null) return out; + for (const file of lowerer.program.getImplementationSourceFiles()) { + if (!isJsSourceFile(file)) continue; + for (const candidate of inferFile(lowerer, file)) { + const name = + candidate.kind === "map" + ? `map_${candidate.key}_${candidate.value}` + : candidate.kind === "set" + ? `set_${candidate.key}` + : `array_${candidate.value}`; + const type = carriers.get(name); + if (type === undefined) continue; + for (const id of candidate.names) out.set(id, type); + } + } + return out; +} + +/** The carrier declarations' types, by name (`map_string_number`, + * `set_number`, `array_string`, ...), or null when the lowering program does + * not include them. */ +function carrierTypes(lowerer: Lowerer): Map | null { + const file = lowerer.program.getSourceFile(overridesDtsPath()); + if (file === undefined) return null; + const out = new Map(); + for (const statement of file.statements.nodes) { + if ( + !ts.isModuleDeclaration(statement) || + !ts.isIdentifier(statement.name) || + statement.name.text !== "__scriptcInferredCollections" || + statement.body === undefined || + !ts.isModuleBlock(statement.body) + ) + continue; + for (const member of statement.body.statements.nodes) { + if (!ts.isVariableStatement(member)) continue; + for (const declaration of member.declarationList.declarations.nodes) + if (ts.isIdentifier(declaration.name)) + out.set(declaration.name.text, lowerer.checker.getTypeAtLocation(declaration.name)); + } + } + return out.size > 0 ? out : null; +} + +function inferFile(lowerer: Lowerer, file: ts.SourceFile): Candidate[] { + const candidates = new Map(); + let directEval = false; + const declarations = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "eval" + ) + directEval = true; + if (ts.isVariableDeclaration(node)) { + const kind = candidateKind(lowerer, node); + const symbol = kind !== null ? lowerer.checker.getSymbolAtLocation(node.name) : undefined; + if (kind !== null && symbol !== undefined) + candidates.set(symbol, { + kind, + declaration: node, + names: [node.name as ts.Identifier], + keyWrites: [], + valueWrites: [], + lookups: [], + indexes: [], + separators: [], + key: undefined, + value: undefined, + }); + } + }; + ts.walkPreorder(file, declarations); + if (directEval || candidates.size === 0) return []; + + const names = new Set( + [...candidates.values()].map((c) => (c.declaration.name as ts.Identifier).text), + ); + const refused = new Set(); + const references = (node: ts.Node): void => { + if (ts.isIdentifier(node) && names.has(node.text)) { + const symbol = referencedSymbol(lowerer, node); + const candidate = symbol !== undefined ? candidates.get(symbol) : undefined; + if (symbol !== undefined && candidate !== undefined && node !== candidate.declaration.name) { + if (recordUse(candidate, node)) candidate.names.push(node); + else refused.add(symbol); + } + } + }; + ts.walkPreorder(file, references); + for (const symbol of refused) candidates.delete(symbol); + const proven = [...candidates.values()].filter( + (c) => c.valueWrites.length > 0 || c.keyWrites.length > 0, + ); + solve(lowerer, proven); + return proven.filter((c) => + c.kind === "map" ? c.key && c.value : c.kind === "set" ? c.key : c.value, + ); +} + +/** The collection a `const` declaration starts empty, when the checker + * types it as an untyped Map, Set or array. */ +function candidateKind(lowerer: Lowerer, declaration: ts.VariableDeclaration): Collection | null { + if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) return null; + const list = declaration.parent; + if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) return null; + const statement = list.parent; + if ( + ts.isVariableStatement(statement) && + ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) + return null; + const init = declaration.initializer; + let kind: Collection | null = null; + if (ts.isArrayLiteralExpression(init) && init.elements.nodes.length === 0) kind = "array"; + else if ( + ts.isNewExpression(init) && + init.typeArguments === undefined && + (init.arguments?.nodes.length ?? 0) === 0 + ) { + if (lowerer.isStdlibGlobal(init.expression, "Map")) kind = "map"; + else if (lowerer.isStdlibGlobal(init.expression, "Set")) kind = "set"; + } + if (kind === null) return null; + // A JSDoc type, or anything else the checker already knows, stands. + const type = lowerer.checker.getTypeAtLocation(declaration.name); + const args = + kind === "array" + ? lowerer.checker.isArrayType(type) + ? lowerer.checker.getTypeArguments(type as ts.TypeReference) + : [] + : lowerer.checker.getTypeArguments(type as ts.TypeReference); + const arity = kind === "map" ? 2 : 1; + if (args.length !== arity || !args.every((a) => (a.flags & ts.TypeFlags.Any) !== 0)) return null; + return kind; +} + +function referencedSymbol(lowerer: Lowerer, id: ts.Identifier): ts.Symbol | undefined { + const parent = id.parent; + if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === id) + return lowerer.checker.getShorthandAssignmentValueSymbol(parent); + const symbol = lowerer.checker.getSymbolAtLocation(id); + // `export { m }` names the export, an alias of the local binding. + if ( + symbol !== undefined && + parent !== undefined && + ts.isExportSpecifier(parent) && + parent.propertyName === undefined && + (symbol.flags & ts.SymbolFlags.Alias) !== 0 + ) + return lowerer.checker.getAliasedSymbol(symbol); + return symbol; +} + +/** Records a reference's use, or answers false when the reference is not + * one of the collection uses this inference proves. */ +function recordUse(candidate: Candidate, id: ts.Identifier): boolean { + const access = id.parent; + if ( + access === undefined || + !ts.isPropertyAccessExpression(access) || + access.expression !== id || + access.questionDotToken !== undefined + ) + return false; + const name = access.name.text; + const call = access.parent; + if (call !== undefined && ts.isCallExpression(call) && call.expression === access) { + if (call.questionDotToken !== undefined) return false; + const args = call.arguments.nodes; + if (args.some(ts.isSpreadElement)) return false; + switch (candidate.kind) { + case "map": + if (MAP_LOOKUPS.has(name) && args.length === 1) + return (candidate.lookups.push(args[0]!), true); + if (name === "clear") return args.length === 0; + if (name === "set" && args.length === 2 && isStatement(call)) { + candidate.keyWrites.push(args[0]!); + candidate.valueWrites.push(args[1]!); + return true; + } + return false; + case "set": + if (SET_LOOKUPS.has(name) && args.length === 1) + return (candidate.lookups.push(args[0]!), true); + if (name === "clear") return args.length === 0; + if (name === "add" && args.length === 1 && isStatement(call)) + return (candidate.keyWrites.push(args[0]!), true); + return false; + case "array": + if (name === "push") return (candidate.valueWrites.push(...args), true); + if (ARRAY_READS.has(name)) return args.length === 0; + if (name === "at" && args.length === 1) return (candidate.indexes.push(args[0]!), true); + if (ARRAY_SEARCHES.has(name) && args.length === 1) + return (candidate.lookups.push(args[0]!), true); + if (name === "join" && args.length <= 1) return (candidate.separators.push(...args), true); + return false; + } + } + return (candidate.kind === "array" ? name === "length" : name === "size") && isPlainRead(access); +} + +function isStatement(call: ts.CallExpression): boolean { + let node: ts.Node = call; + while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent; + return node.parent !== undefined && ts.isExpressionStatement(node.parent); +} + +/** A read that neither writes nor deletes the property. */ +function isPlainRead(access: ts.PropertyAccessExpression): boolean { + let node: ts.Node = access; + while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent; + const parent = node.parent; + if (parent === undefined) return false; + if ( + ts.isBinaryExpression(parent) && + parent.left === node && + isAssignment(parent.operatorToken.kind) + ) + return false; + if ( + (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) && + (parent.operator === ts.SyntaxKind.PlusPlusToken || + parent.operator === ts.SyntaxKind.MinusMinusToken) + ) + return false; + return !ts.isDeleteExpression(parent); +} + +function isAssignment(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +/** Decides each candidate's key and value types: a fixed point in which a + * decided candidate's reads type the expressions built from them. */ +function solve(lowerer: Lowerer, candidates: Candidate[]): void { + const bySymbol = new Map(); + for (const c of candidates) { + const symbol = lowerer.checker.getSymbolAtLocation(c.declaration.name); + if (symbol) bySymbol.set(symbol, c); + } + for (let changed = true; changed;) { + changed = false; + for (const c of candidates) { + if (c.kind !== "array" && c.key === undefined) { + const key = uniform(lowerer, bySymbol, c.keyWrites); + if (key !== null) { + c.key = key; + changed = true; + } + } + if (c.kind !== "set" && c.value === undefined) { + // Each candidate type is assumed for this collection's own reads. + const accepted = PRIMITIVES.filter((assumed) => { + c.value = assumed; + const found = uniform(lowerer, bySymbol, c.valueWrites); + c.value = undefined; + return found === assumed; + }); + if (accepted.length === 1) { + c.value = accepted[0]; + changed = true; + } + } + } + } + for (const c of candidates) { + const element = c.kind === "array" ? c.value : c.key; + // Lookups compare with the stored type; an index is a number. + const lookupsAgree = + element !== undefined && + c.lookups.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === element) && + c.indexes.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === "number") && + c.separators.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === "string"); + if (!lookupsAgree) { + c.key = undefined; + c.value = undefined; + } + } +} + +/** The one primitive type of every expression, or null. */ +function uniform( + lowerer: Lowerer, + bySymbol: Map, + exprs: readonly ts.Expression[], +): Primitive | null { + let found: Primitive | null = null; + for (const e of exprs) { + const p = primitiveOf(lowerer, bySymbol, e, new Set()); + if (p === null || (found !== null && p !== found)) return null; + found = p; + } + return found; +} + +function checkerPrimitive(type: ts.Type): Primitive | null { + const flags = type.flags; + if (flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) return "string"; + if (flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral)) return "number"; + if (flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) return "boolean"; + return null; +} + +/** The primitive type `e` always evaluates to, or null. The checker answers + * where it knows; where it types `e` as `any`, the value's structure does: + * a read of a decided candidate, arithmetic and comparisons over such + * values, and constants defined from them. An expression nested deeper than + * the lowering's nesting limit answers null. */ +function primitiveOf( + lowerer: Lowerer, + bySymbol: Map, + e: ts.Expression, + visiting: Set, + depth = 0, +): Primitive | null { + if (depth > MAX_DEPTH) return null; + const type = lowerer.checker.getTypeAtLocation(e); + if ((type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) === 0) return checkerPrimitive(type); + const of = (x: ts.Expression): Primitive | null => + primitiveOf(lowerer, bySymbol, x, visiting, depth + 1); + if (ts.isParenthesizedExpression(e)) return of(e.expression); + if (ts.isPrefixUnaryExpression(e)) { + if (e.operator === ts.SyntaxKind.ExclamationToken) return "boolean"; + return e.operator !== ts.SyntaxKind.PlusPlusToken && + e.operator !== ts.SyntaxKind.MinusMinusToken && + of(e.operand) === "number" + ? "number" + : null; + } + if (ts.isConditionalExpression(e)) { + const yes = of(e.whenTrue); + return yes !== null && yes === of(e.whenFalse) ? yes : null; + } + if (ts.isBinaryExpression(e)) { + const op = e.operatorToken.kind; + switch (op) { + case ts.SyntaxKind.LessThanToken: + case ts.SyntaxKind.GreaterThanToken: + case ts.SyntaxKind.LessThanEqualsToken: + case ts.SyntaxKind.GreaterThanEqualsToken: + case ts.SyntaxKind.EqualsEqualsEqualsToken: + case ts.SyntaxKind.ExclamationEqualsEqualsToken: + case ts.SyntaxKind.EqualsEqualsToken: + case ts.SyntaxKind.ExclamationEqualsToken: + return "boolean"; + case ts.SyntaxKind.QuestionQuestionToken: + case ts.SyntaxKind.BarBarToken: { + // `m.get(k) ?? d` and `a.pop() || d`: the stored type or a default of it. + const stored = storedRead(lowerer, bySymbol, e.left); + const fallback = of(e.right); + return stored !== null && stored === fallback ? stored : null; + } + case ts.SyntaxKind.PlusToken: { + const left = of(e.left); + const right = of(e.right); + if (left === "number" && right === "number") return "number"; + return left !== null && right !== null && (left === "string" || right === "string") + ? "string" + : null; + } + case ts.SyntaxKind.MinusToken: + case ts.SyntaxKind.AsteriskToken: + case ts.SyntaxKind.SlashToken: + case ts.SyntaxKind.PercentToken: + case ts.SyntaxKind.AsteriskAsteriskToken: + case ts.SyntaxKind.BarToken: + case ts.SyntaxKind.AmpersandToken: + case ts.SyntaxKind.CaretToken: + case ts.SyntaxKind.LessThanLessThanToken: + case ts.SyntaxKind.GreaterThanGreaterThanToken: + case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken: + return of(e.left) === "number" && of(e.right) === "number" ? "number" : null; + default: + return null; + } + } + if (ts.isCallExpression(e) || ts.isPropertyAccessExpression(e)) + return candidateRead(lowerer, bySymbol, e); + // A constant defined from such an expression. + if (ts.isIdentifier(e) && !visiting.has(e)) { + const symbol = lowerer.checker.getSymbolAtLocation(e); + const declaration = symbol?.valueDeclaration; + if ( + declaration !== undefined && + ts.isVariableDeclaration(declaration) && + declaration.initializer !== undefined && + ts.isVariableDeclarationList(declaration.parent) && + (declaration.parent.flags & ts.NodeFlags.Const) !== 0 + ) { + visiting.add(e); + const p = primitiveOf(lowerer, bySymbol, declaration.initializer, visiting, depth + 1); + visiting.delete(e); + return p; + } + } + return null; +} + +/** The candidate a use reads, when `e` is `c.member` or `c.member(...)` of a + * candidate `c`. */ +function candidateOf( + lowerer: Lowerer, + bySymbol: Map, + e: ts.Expression, +): { candidate: Candidate; member: string } | null { + const access = ts.isCallExpression(e) ? e.expression : e; + if (!ts.isPropertyAccessExpression(access) || !ts.isIdentifier(access.expression)) return null; + const symbol = lowerer.checker.getSymbolAtLocation(access.expression); + const candidate = symbol !== undefined ? bySymbol.get(symbol) : undefined; + return candidate !== undefined ? { candidate, member: access.name.text } : null; +} + +/** The type of a candidate's read that may answer undefined: `m.get(k)`, + * `a.pop()`, `a.shift()`, `a.at(i)`. */ +function storedRead( + lowerer: Lowerer, + bySymbol: Map, + e: ts.Expression, +): Primitive | null { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isCallExpression(e)) return null; + const read = candidateOf(lowerer, bySymbol, e); + if (read === null) return null; + const { candidate, member } = read; + if (candidate.kind === "map" && member === "get") return candidate.value ?? null; + if (candidate.kind === "array" && (ARRAY_READS.has(member) || member === "at")) + return candidate.value ?? null; + return null; +} + +/** The type of a candidate's read that always answers a value: size, + * length, has, includes, indexOf, push. */ +function candidateRead( + lowerer: Lowerer, + bySymbol: Map, + e: ts.Expression, +): Primitive | null { + const read = candidateOf(lowerer, bySymbol, e); + if (read === null) return null; + const { candidate, member } = read; + if (!ts.isCallExpression(e)) return member === "size" || member === "length" ? "number" : null; + if (member === "has" || member === "includes" || member === "delete") return "boolean"; + if (member === "indexOf" || (candidate.kind === "array" && member === "push")) return "number"; + if (candidate.kind === "array" && member === "join") return "string"; + return null; +} diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index b996abed7..02faa7ade 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -51,11 +51,15 @@ import { STRING, type SrcLoc, VOID, + isDynTypedRefType, isUnitType, typeEquals, typeKey, } from "../../ir/ir.js"; import { boolLit, countedFor, dynUndefinedExpr, numLit, strLit, varRef } from "../../ir/build.js"; +import { everyExpr } from "../../ir/traverse.js"; +import { checkedClassInstanceOf } from "./class-construction.js"; +import { isJsSourceFile } from "../program.js"; /** node:assert/strict binds the loose NAMES to the strict comparisons — * Node's own aliasing (`strict.equal === assert.strictEqual`). */ @@ -73,6 +77,63 @@ function canonicalAssertMember(module: string, member: string): string { return member; } +/** The assert methods that are function values (scr_assert_method_value). */ +const ASSERT_FUNCTION_VALUES: ReadonlySet = new Set([ + "ok", + "fail", + "equal", + "notEqual", + "strictEqual", + "notStrictEqual", + "deepEqual", + "notDeepEqual", + "deepStrictEqual", + "notDeepStrictEqual", + "partialDeepStrictEqual", + "ifError", +]); + +/** The assert method a `const` binding holds (`const ok = assert.ok`, + * `const eq = strictEqual`), or null. */ +export function assertFunctionAliasOf( + lowerer: Lowerer, + ident: ts.Identifier, +): { module: string; member: string } | null { + const decl = lowerer.checker.getSymbolAtLocation(ident)?.valueDeclaration; + if ( + !decl || + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + !ts.isVariableDeclarationList(decl.parent) || + (decl.parent.flags & ts.NodeFlags.Const) === 0 || + !decl.initializer + ) + return null; + let init = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + const bi = ts.isPropertyAccessExpression(init) + ? lowerer.builtinMemberOf(init) + : ts.isIdentifier(init) + ? lowerer.builtinImportOf(init) + : null; + if (!bi || (bi.module !== "assert" && bi.module !== "assert/strict")) return null; + return ASSERT_FUNCTION_VALUES.has(canonicalAssertMember(bi.module, bi.member)) ? bi : null; +} + +/** An assert method read as a value (`emitter.on("x", assert.fail)`, + * `const eq = assert.strictEqual`): an untyped function that asserts as + * the method does, the same value for every read. Null for other + * members. */ +export function assertFunctionValue( + bi: { module: string; member: string }, + loc: SrcLoc, +): IrExpr | null { + if (bi.module !== "assert" && bi.module !== "assert/strict") return null; + const member = canonicalAssertMember(bi.module, bi.member); + if (!ASSERT_FUNCTION_VALUES.has(member)) return null; + return { kind: "libCall", fn: "assert.methodValue", args: [strLit(member, loc)], type: DYN, loc }; +} + /* Node's AssertionError carries actual, expected and operator. Where the * verdict is computed in compiled code, the failure branch records them * (assert.record) before the throwing call, so a passing assertion boxes @@ -166,28 +227,156 @@ function requireStatementPosition(lowerer: Lowerer, call: ts.CallExpression, wha ); } +/** The message the runtime reads from a registered value + * (SCR_ASSERT_VALUE_MESSAGE in scr_assert.h). */ +const VALUE_MESSAGE = "\u0001scriptc:assert-message-value"; + +/** A message argument that is not a string, met while one assertion + * lowers (withValueMessage). `fail` marks assert.fail's message, which + * Node uses for every value but null and undefined. */ +let valueMessage: { value: IrExpr; has: string; text: string; fail: boolean } | null = null; + /** The optional trailing message argument: Node accepts a string or an * Error (throwing the Error itself) — the STRING form lowers; a literal * `undefined` counts as omitted (Node's `message == null` test). Returns * the libCall's (msg, hasMsg) pair — a "" dummy when omitted, so the - * runtime can distinguish an omitted message from an empty one. */ + * runtime can distinguish an omitted message from an empty one. Any other + * value that crosses into the checked-dynamic tree (an untyped + * `failures.join('')`, an Error) is registered with the runtime at the call + * instead (withValueMessage). */ function lowerMessageArg( lowerer: Lowerer, node: ts.Expression | undefined, loc: SrcLoc, + fail = false, ): { msg: IrExpr; hasMsg: IrExpr } { if (!node || (ts.isIdentifier(node) && node.text === "undefined")) { return { msg: { kind: "strLit", value: "", type: STRING, loc }, hasMsg: boolLit(false, loc) }; } const msg = lowerer.lowerExpr(node); - if (msg.type.kind !== "string") { + if (msg.type.kind === "string") return { msg, hasMsg: boolLit(true, loc) }; + const value = + valueMessage === null && (msg.type.kind === "dyn" || lowerer.dynConvertible(msg.type)) + ? lowerer.coerceToExpected(msg, DYN) + : null; + if (value === null || value.type.kind !== "dyn") { lowerer.noLowering( `assert messages of type '${lowerer.fmt(msg.type)}'`, node, "only string messages lower (Node also accepts an Error to throw — construct and throw it directly)", ); } - return { msg, hasMsg: boolLit(true, loc) }; + const has = lowerer.declareHiddenLocal("assert.hasMessage", BOOL); + const text = lowerer.declareHiddenLocal("assert.messageText", STRING); + valueMessage = { value, has: has.id, text: text.id, fail }; + return { msg: varRef(text.id, STRING, loc), hasMsg: varRef(has.id, BOOL, loc) }; +} + +/** Lowers one assertion; a message value met on the way is evaluated after + * the assertion's other arguments, as Node evaluates them. A null or + * undefined message is omitted (Node's `message == null` test); a truthy + * one is registered with the runtime, which the failure reads; any other + * value is the empty message, which Node's comparison diffs treat as no + * message too (createErrDiff tests the message's truthiness). */ +function withValueMessage( + lowerer: Lowerer, + expr: ts.CallExpression, + loc: SrcLoc, + lower: () => IrExpr | null, +): IrExpr | null { + const outer = valueMessage; + valueMessage = null; + try { + const lowered = lower(); + const message = valueMessage as { + value: IrExpr; + has: string; + text: string; + fail: boolean; + } | null; + if (lowered === null || message === null) return lowered; + // assert.ok's statements evaluate the value first; its call is last. + const before = lowered.kind === "seqExpr" ? lowered.stmts : []; + const call = lowered.kind === "seqExpr" ? lowered.result : lowered; + if (call.kind !== "libCall") { + lowerer.noLowering( + "assert messages that are not strings in this assertion form", + expr, + "pass a string message", + ); + } + const stmts: IrStmt[] = [...before]; + // An argument computed from the message (assert.fail's generated flag) + // reads it after the declarations below; the others are evaluated first. + const readsMessage = (arg: IrExpr): boolean => + !everyExpr(arg, { + stmt: () => true, + expr: (e) => + !(e.kind === "varRef" && (e.localId === message.has || e.localId === message.text)), + }); + const args = call.args.map((arg, index) => + readsMessage(arg) ? arg : stableValue(lowerer, stmts, arg, `assert.arg${index}`, loc), + ); + const local = lowerer.declareHiddenLocal("assert.message", DYN); + const value = varRef(local.id, DYN, loc); + // The runtime reports a registered value: a truthy one for the other + // methods, any value but null and undefined for assert.fail (Node's + // `actual == null` test), whose other values read "Failed". + const registered: IrExpr = message.fail + ? varRef(message.has, BOOL, loc) + : { kind: "dynTest", test: "truthy", value, type: BOOL, loc }; + stmts.push( + { kind: "varDecl", localId: local.id, init: message.value, loc }, + { + kind: "varDecl", + localId: message.has, + init: { kind: "dynTest", test: "nullish", negated: true, value, type: BOOL, loc }, + loc, + }, + { + kind: "varDecl", + localId: message.text, + init: { + kind: "ternary", + cond: registered, + then: strLit(VALUE_MESSAGE, loc), + else_: strLit(message.fail ? "Failed" : "", loc), + type: STRING, + loc, + }, + loc, + }, + { + kind: "if", + cond: registered, + then: [ + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "assert.messageValue", + args: [value, { kind: "dynTest", test: "error", value, type: BOOL, loc }], + type: VOID, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }, + { kind: "exprStmt", expr: { ...call, args }, loc }, + ); + return { + kind: "seqExpr", + stmts, + result: { kind: "libCall", fn: "assert.messageClear", args: [], type: VOID, loc }, + type: VOID, + loc, + }; + } finally { + valueMessage = outer; + } } /** The module-function dispatch — the spoke's entry, called from both the @@ -201,6 +390,15 @@ export function lowerAssertModuleCall( loc: SrcLoc, ): IrExpr | null { if (bi.module !== "assert" && bi.module !== "assert/strict") return null; + return withValueMessage(lowerer, expr, loc, () => lowerAssertMember(lowerer, expr, bi, loc)); +} + +function lowerAssertMember( + lowerer: Lowerer, + expr: ts.CallExpression, + bi: { module: string; member: string }, + loc: SrcLoc, +): IrExpr | null { switch (canonicalAssertMember(bi.module, bi.member)) { case "ok": return lowerAssertOk(lowerer, expr, loc); @@ -880,7 +1078,7 @@ export function lowerAssertDirectCall( 'use the default import: import assert from "node:assert")', ); } - return lowerAssertOk(lowerer, expr, loc); + return withValueMessage(lowerer, expr, loc, () => lowerAssertOk(lowerer, expr, loc)); } /** assert(value[, message]) / assert.ok(value[, message]): the truthiness @@ -889,7 +1087,24 @@ export function lowerAssertDirectCall( * the same bytes at compile time). */ function lowerAssertOk(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr { requireStatementPosition(lowerer, expr, ts.getTextOfNode(expr.expression)); - if (expr.arguments.nodes.length < 1 || expr.arguments.nodes.length > 2) { + if (expr.arguments.nodes.length === 0) { + // Node's message for a call without arguments. + const text = "No value argument passed to `assert.ok()`"; + return { + kind: "seqExpr", + stmts: [recordOnFailure(lowerer, boolLit(true, loc), null, boolLit(true, loc), "==", loc)], + result: { + kind: "libCall", + fn: "assert.ok", + args: [boolLit(false, loc), strLit(text, loc), boolLit(true, loc)], + type: VOID, + loc, + }, + type: VOID, + loc, + }; + } + if (expr.arguments.nodes.length > 2) { lowerer.noLowering( `${ts.getTextOfNode(expr.expression)} with ${expr.arguments.nodes.length} arguments`, expr, @@ -953,22 +1168,26 @@ function lowerAssertFail(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc) generated = boolLit(true, loc); } else { // Node's AssertionError sets generatedMessage to !message, so an - // empty custom message counts as generated. - msg = stableValue( - lowerer, - stmts, - lowerMessageArg(lowerer, msgNode, loc).msg, - "%assertFailMessage", - loc, - ); - generated = { - kind: "strEq", - negated: false, - left: msg, - right: strLit("", loc), - type: BOOL, - loc, - }; + // empty custom message counts as generated. A message that is not a + // string is read from the hidden local that withValueMessage declares + // after these statements, so it is used as is. + const { msg: lowered, hasMsg } = lowerMessageArg(lowerer, msgNode, loc, true); + if (lowered.kind === "varRef" && hasMsg.kind === "varRef") { + // A value message: generated when Node reads "Failed" for null or + // undefined; the runtime sets the flag for a registered value. + msg = lowered; + generated = { kind: "unary", op: "!", operand: hasMsg, type: BOOL, loc }; + } else { + msg = stableValue(lowerer, stmts, lowered, "%assertFailMessage", loc); + generated = { + kind: "strEq", + negated: false, + left: msg, + right: strLit("", loc), + type: BOOL, + loc, + }; + } } const falseLit: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; stmts.push(recordOnFailure(lowerer, boolLit(true, loc), null, null, "fail", loc)); @@ -1056,9 +1275,22 @@ function lowerAssertEqual( // would answer false where Node can answer true (the value's own // earlier crossing) — a miscompare, never lowered. Bytes fence both // ways (the dyn copy cannot carry the Buffer/Uint8Array brand the - // checker type knows); class instances and everything non-convertible - // keep the fence too. - if (a.type.kind === "dyn" || b.type.kind === "dyn") { + // checker type knows); everything non-convertible keeps the fence too. + // A class instance crosses as a reference to the instance itself, so + // strict compares are honest reference equality, between two instances + // too (assert.strictEqual(e, e.setMaxListeners(1))). + const instanceType = (t: IrType): boolean => + isDynTypedRefType(t) && !lowerer.isSubclassOf(t.className, "%Error"); + const instance = (e: IrExpr): boolean => + !deep && + (instanceType(e.type) || + (e.type.kind === "union" && + (lowerer.unions + .get(e.type.unionId) + ?.arms.every((arm) => instanceType(arm) || isUnitType(arm)) ?? + false))) && + lowerer.dynConvertible(e.type); + if (a.type.kind === "dyn" || b.type.kind === "dyn" || (instance(a) && instance(b))) { const box = (e: IrExpr, node: ts.Expression): IrExpr => { if (e.type.kind === "dyn") return e; const k = e.type.kind; @@ -1067,6 +1299,18 @@ function lowerAssertEqual( k === "f64" || k === "string" || k === "bool" || + // A union of scalars and units crosses by value, like its arms. + (e.type.kind === "union" && + (lowerer.unions + .get(e.type.unionId) + ?.arms.every( + (arm) => + arm.kind === "f64" || + arm.kind === "string" || + arm.kind === "bool" || + isUnitType(arm), + ) ?? + false)) || (k === "func" && lowerer.dynConvertible(e.type)) || // Runtime handles cross by REFERENCE (identity preserved), so the // strict compare is honest: assert.strictEqual(this, server) — @@ -1082,6 +1326,9 @@ function lowerAssertEqual( // same). The deep walk compares the retained error data without // re-encoding and losing identity (SEMANTICS.md). (k === "object" && e.type.kind === "object" && e.type.className === "%Error") || + instance(e) || + // A symbol crosses as itself, so strict compares are identity. + (!deep && k === "symbol") || (deep && k !== "bytes" && k !== "object" && lowerer.dynConvertible(e.type)) || // Under skipPrototype the prototype a class instance or Buffer // brand would carry is not compared. @@ -1470,17 +1717,29 @@ function classifyThrowsExpected( displayName: rec ? rec.lib : info.def.name.replace(/^%/, ""), }; } - if ( - (surface === "assert.doesNotThrow" || surface === "assert.doesNotReject") && - expectedT?.kind === "func" - ) { + if (expectedT?.kind === "func") { const value = lowerer.lowerExpr(node); + // An untyped value there (a dynamic-tier function's result) is checked + // at run time like any untyped expected value. + if (value.type.kind === "jsval" || value.type.kind === "dyn") { + const boxed = lowerer.coerceToExpected(value, DYN); + if (boxed.type.kind === "dyn") return { form: "errValue", value: boxed }; + } + // A JavaScript function accepts any argument; a typed one must take + // unknown values (the caught value need not be an Error). if ( value.type.kind === "func" && - value.type.params.every((param) => param.kind === "dyn") && + (value.type.params.every((param) => param.kind === "dyn") || + isJsSourceFile(ts.getSourceFileOfNode(node))) && lowerer.dynConvertible(value.type) ) { - return { form: "predicate", value: lowerer.coerceToExpected(value, DYN) }; + // The boxed function carries its source name (Node's message names + // a named validation function). + const fnName = jsFuncNameOf(node); + return { + form: "predicate", + value: { kind: "dynFrom", value, ...(fnName !== null ? { fnName } : {}), type: DYN, loc }, + }; } lowerer.noLowering( `${surface} with this validation callback`, @@ -1546,7 +1805,9 @@ function classifyThrowsExpected( // tracingChannel suite's shape): the value crosses as a dyn error and // the runtime compares per Node's expectsError key walk. A // checked-dynamic expected rides directly; a static %Error boxes. - if (expectedT?.kind === "dyn") { + // An untyped JavaScript value (a parameter) too: a function there is + // checked at run time (assertThrowsHelper). + if (expectedT?.kind === "dyn" || expectedT === null) { const value = lowerer.lowerExpr(node); if (value.type.kind === "dyn") return { form: "errValue", value }; } @@ -1943,6 +2204,46 @@ function assertThrowsHelper( return { e: strLit("", loc), has: false }; })(); + // expectedException's function branch: a function with a prototype that + // the caught value is an instance of passes; anything else is called as + // a validation function, whose result must be true. + const validateStmts = (fn: IrExpr): IrStmt[] => { + const actual = (): IrExpr => ({ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc }); + const prototype: IrExpr = { + kind: "dynKeyGet", + value: fn, + key: strLit("prototype", loc), + optional: true, + type: DYN, + loc, + }; + return [ + ifStmt( + { kind: "dynTest", test: "undefined", negated: true, value: prototype, type: BOOL, loc }, + [ + ifStmt( + checkedClassInstanceOf( + lowerer, + actual(), + fn, + "assert.throws against this native constructor is not supported yet", + loc, + ), + [doReturn], + ), + ], + ), + lib("assert.throwsValidate", [ + actual(), + fn, + strLit(mode === "rejects" ? "rejects" : "throws", loc), + m(), + hm(), + ]), + doReturn, + ]; + }; + let catchBody: IrStmt[]; if (mode === "dnr" || mode === "dnt") { const actual = (): IrExpr => ({ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc }); @@ -2116,7 +2417,13 @@ function assertThrowsHelper( // caught value crosses as a dyn value (identity-preserving) and // the runtime walks the expected's keys — a mismatch throws the // deep-equal AssertionError. + // A function there is a validation function (or an error class + // value) as in the predicate form. catchBody = [ + ifStmt( + { kind: "dynTest", test: "function", value: varRef("ev.0", DYN, loc), type: BOOL, loc }, + validateStmts(varRef("ev.0", DYN, loc)), + ), lib("assert.expectsErrDyn", [ { kind: "caughtToDyn", value: caughtRef(), type: DYN, loc }, varRef("ev.0", DYN, loc), @@ -2127,7 +2434,8 @@ function assertThrowsHelper( ]; break; case "predicate": - throw new InternalCompilerError("assert validation callback only enters a no-error helper"); + catchBody = validateStmts(varRef("predicate.0", DYN, loc)); + break; } } diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 14565aebc..38c58a2c4 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,5 +1,8 @@ +import { checkedPrivateMethodReceiver, privateNameClass } from "./private-brands.js"; +import { assertFunctionAliasOf } from "./lower-assert.js"; import { keyedHelperName } from "./stable-names.js"; import { perKeyOrder } from "./key-order-witness.js"; +import { lowerDateToPrimitiveCall } from "./builtins/dates.js"; import { isForwardedTupleSpread, lowerForwardingApply } from "./typed-arguments.js"; import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js"; import { lowerDynStreamMemberCall } from "./dyn-streams.js"; @@ -24,7 +27,11 @@ import { import { lowerMockTimersElementCall } from "./lower-test.js"; import { objectEnumerationReceiver } from "./object-enumeration-receiver.js"; import { InternalCompilerError } from "../../errors.js"; -import { isFsStreamMember, lowerFsStreamMethodCall } from "./builtins/fs-streams.js"; +import { + isFsStreamMember, + lowerFsStreamCall, + lowerFsStreamMethodCall, +} from "./builtins/fs-streams.js"; import * as ts from "../ts7/adapter.js"; import { isZlibStreamMember, @@ -167,6 +174,7 @@ import { lowerPerfHooksCall, perfEntryTag, } from "./builtins/performance.js"; +import { fenceX509KeyWalk } from "./builtins/x509.js"; import { lowerProcStreamMethodCall, lowerProcessBuiltinModuleNamespace, @@ -249,7 +257,7 @@ import { import { fenceSymbolFieldCopy } from "./symbol-fields.js"; import { lowerClassDataDescriptor, lowerClassDescriptorRead } from "./class-descriptors.js"; import { lowerClassFieldDefine, lowerClassIntegrity } from "./class-attributes.js"; -import { freezableRecordType, lowerNativeFreeze } from "./frozen-records.js"; +import { freezableRecordType, lowerNativeFreeze, noteUntypedIntegrity } from "./frozen-records.js"; import { classStaticDataFor } from "./class-static-data.js"; import { lowerUrlFactory } from "./lower-url.js"; import { nullishValueUnitOf } from "./binding-analysis.js"; @@ -375,6 +383,10 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { const buffer = lowerBufferConstruct(lowerer, expr); if (buffer) return { kind: "dynFrom", value: buffer, type: DYN, loc }; } + { + const stream = lowerFsStreamCall(lowerer, expr); + if (stream) return stream; + } // A package shim's runtime intrinsic (a bodiless `declare function` // named scriptcUnixDgram* in a shipped shim): a direct runtime call. if (ts.isIdentifier(expr.expression) && isShimIntrinsicName(expr.expression.text)) { @@ -587,6 +599,17 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { ) { return lowerer.lowerSuperMethodCall(expr, expr.expression); } + // `value.#m(...)` on a checked-dynamic value (`this` in a JavaScript + // class's receiver-independent method): the private method of the + // brand-checked receiver. + if ( + ts.isPropertyAccessExpression(expr.expression) && + ts.isPrivateIdentifier(expr.expression.name) && + !expr.expression.questionDotToken + ) { + const privateCall = lowerCheckedPrivateMethodCall(lowerer, expr, expr.expression); + if (privateCall) return privateCall; + } const consoleMember = lowerer.consoleCallMember(expr); const consoleMethod = consoleMember === null ? lowerer.consoleMethodMember(expr) : null; @@ -1052,6 +1075,13 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { } const bi = lowerer.builtinImportOf(expr.expression); if (bi) return lowerBuiltinCall(lowerer, expr, bi, loc); + // A constant bound to an assert method (`const ok = assert.ok`) called + // directly is that assertion, with the message Node quotes the call for. + { + const alias = assertFunctionAliasOf(lowerer, expr.expression); + const served = alias ? lowerer.lowerAssertModuleCall(expr, alias, loc) : null; + if (served) return served; + } // The assert module binding called DIRECTLY (`assert(x)` — a default // import or the CJS `const assert = require("assert")`): Node's // module object IS assert.ok; namespace-import bindings fence inside @@ -1816,6 +1846,8 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { // The element spelling of an unsupported native Web method must fence // before lowering the callee into a checked-dynamic keyed read. if (ts.isElementAccessExpression(expr.expression)) { + const datePrimitive = lowerDateToPrimitiveCall(lowerer, expr, expr.expression); + if (datePrimitive) return datePrimitive; const timersDispose = lowerMockTimersElementCall(lowerer, expr, expr.expression); if (timersDispose) return timersDispose; const symbolMethod = lowerClassSymbolMethodCall(lowerer, expr); @@ -2456,6 +2488,9 @@ function lowerNumberConstructorValue( }; const optionalNumber = lowerOptionalStringNumber(lowerer, arg, loc); if (optionalNumber) return optionalNumber; + // A Date's number hint answers its time value. + if (arg.type.kind === "date") + return { kind: "libCall", fn: "date.valueOf", args: [arg], type: F64, loc }; // A program class instance converts through its own methods. const classNumber = classToNumberConversion(lowerer, arg, argNode); if (classNumber) return classNumber; @@ -2958,6 +2993,7 @@ function lowerObjectOwnCall( } else if (type.kind === "record") { const shape = lowerer.shapes.get(type.shapeId); fencePerfEntryKeyWalk(lowerer, type, receiverNode ?? call, `Object.prototype.${method}`); + fenceX509KeyWalk(lowerer, type, receiverNode ?? call, `Object.prototype.${method}`); if (!shape || shape.tuple || shapeHasAccessorSlots(shape)) { lowerer.noLowering( "Object.prototype." + method + " over this record shape", @@ -8464,7 +8500,17 @@ function lowerObjectStaticCall( ...(fnName !== null ? { fnName } : {}), }; } - if (target && isUnitType(target.type)) { + // A class value whose statics are runtime properties of its constructor + // boxes to that constructor function, as for Object.defineProperty above. + const runtimeStaticClass = + target?.type.kind === "classval" ? lowerer.classes.get(target.type.className) : undefined; + if ( + target && + (isUnitType(target.type) || + (runtimeStaticClass !== undefined && + hasRuntimeStatics(runtimeStaticClass) && + lowerer.dynConvertible(target.type))) + ) { target = { kind: "dynFrom", value: target, type: DYN, loc: locOf(call.arguments.nodes[0]!) }; } if (target?.type.kind === "dyn") { @@ -8552,6 +8598,7 @@ function lowerObjectStaticCall( isSealed: "dyn.isSealed", } as const; const fn = operation[member as keyof typeof operation]; + if (!member.startsWith("is")) noteUntypedIntegrity(lowerer, call.arguments.nodes[0]!); return { kind: "libCall", fn, @@ -8618,8 +8665,10 @@ function lowerObjectStaticCall( (ts.isObjectLiteralExpression(inner) || ts.isArrayLiteralExpression(inner)) ? lowerer.lowerExprExpecting(argNode, DYN) : lowerer.lowerExpr(argNode); - if (value.type.kind === "dyn") + if (value.type.kind === "dyn") { + noteUntypedIntegrity(lowerer, argNode); return { kind: "libCall", fn: "dyn.freeze", args: [value], type: DYN, loc: locOf(call) }; + } if (ts.isObjectLiteralExpression(inner) || ts.isArrayLiteralExpression(inner)) { return value; // fresh — freeze is identity here, honestly } @@ -8703,7 +8752,12 @@ function lowerObjectStaticCall( (probed && isDynTypedRefType(probed.type)) || (probed?.type.kind === "object" && lowerer.errorHierarchyClassOf(probed.type.className)) || (probed && constructor && (constructor.callableBase || hasRuntimeStatics(constructor))) || - (probed?.type.kind === "func" && lowerer.dynConvertible(probed.type)) + (probed?.type.kind === "func" && lowerer.dynConvertible(probed.type)) || + // null, undefined and unions of untyped JavaScript values: the same + // probe, whose ToObject throws Node's TypeError on nullish. + (probed && + (isUnitType(probed.type) || probed.type.kind === "union") && + lowerer.dynConvertible(probed.type)) ) { const loc = locOf(call); const receiver = lowerer.coerceToExpected(probed, DYN); @@ -8929,6 +8983,7 @@ function lowerObjectStaticCall( // RESULTS, invoked in key order. The static field walk models neither // (accessor slots live outside declaredOrder), so the surface fences. fencePerfEntryKeyWalk(lowerer, argIr, call, `Object.${member}`); + fenceX509KeyWalk(lowerer, argIr, call, `Object.${member}`); if (shapeHasAccessorSlots(shape)) { lowerer.unsupported( "SC1090", @@ -11401,3 +11456,35 @@ function lowerShimIntrinsicCall( ); return { kind: "libCall", fn: intrinsic.fn, args, type: intrinsic.ret, loc }; } + +/** `value.#m(args)` where `value` lowers to a checked-dynamic value and the + * declaration-level class whose body declares `#m` declares it as a method: + * the method called on the brand-checked receiver + * (checkedPrivateMethodReceiver). Null for any other private member call. */ +function lowerCheckedPrivateMethodCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (!ts.isPrivateIdentifier(access.name)) return null; + const name = access.name.text; + const owner = privateNameClass(lowerer, access.name); + const found = owner ? lowerer.findMethodOn(owner, name) : null; + if (!owner || !found || found.declarer !== owner || found.sig.abstract === true) return null; + const probed = tryLowerExpression(lowerer, access.expression); + if (!probed || !checkedDynamicOperand(probed)) return null; + const value = checkedDynamicOperand(lowerer.lowerExpr(access.expression)); + if (!value) return null; + const loc = locOf(call); + const callee = `%${owner.def.name}.${name}`; + lowerer.noteEdge(callee); + const receiver = checkedPrivateMethodReceiver(lowerer, owner, name, value, loc); + const args = lowerer.completeArgs(call.arguments.nodes, found.sig.params, loc, call); + return { kind: "call", callee, args: [receiver, ...args], type: found.sig.ret, loc }; +} + +/** A checked-dynamic value, or the dynamic value under a checked extraction. */ +function checkedDynamicOperand(expr: IrExpr): IrExpr | null { + if (expr.type.kind === "dyn") return expr; + return expr.kind === "dynCheck" && expr.value.type.kind === "dyn" ? expr.value : null; +} diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index b2036dc8a..1ab00f80a 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -12,15 +12,18 @@ import { lowerDateStaticCall } from "./builtins/dates.js"; import { keyedHelperName } from "./stable-names.js"; import { lowerValueReturningConstructorNew } from "./js-dynamic-objects.js"; import { adaptPromiseExecutor } from "./promise-executor-adapter.js"; -import { dynUndefinedExpr, nodeThrowExpr, varRef } from "../../ir/build.js"; +import { dynUndefinedExpr, nodeThrowExpr, strLit, varRef } from "../../ir/build.js"; import { constructedFromValue, constructedValue, superIntoResultSlot, } from "./constructor-returns.js"; import { checkedIterableSpread } from "./checked-iterable-spread.js"; -import { lowerReadlineUiNew } from "./builtins/readline.js"; -import { builtinMemberRequireDecl } from "./builtins/module-bindings.js"; +import { lowerReadlineInterfaceNew, lowerReadlineUiNew } from "./builtins/readline.js"; +import { + builtinMemberBindingTarget, + builtinMemberRequireDecl, +} from "./builtins/module-bindings.js"; import { checkedClassUnionAssertion } from "./class-assertions.js"; import { collectClassMethodMutations } from "./class-method-mutations.js"; import { InternalCompilerError } from "../../errors.js"; @@ -44,6 +47,7 @@ import type { FnCtx, Lowerer } from "./lowerer.js"; import { inlineNamespaceRequireOf, lowerWithInlineRequireInit } from "./cjs-inline-require.js"; import { BOOL, + BYTES_U8, canDynCheckTo, DATE_T, DYN, @@ -100,6 +104,8 @@ import { jsOpenObjectType, typeKey, withUnitArm, + X509_DER_FIELD, + X509_PUBLIC_KEY_FIELD, } from "../type-mapper.js"; import { canonicalBuiltinModule, @@ -114,6 +120,7 @@ import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./low import { isAssertClassNew } from "./assert-instances.js"; import { isCjsRequireGlobal } from "./lower-node-module.js"; import { lowerArrayConstructor } from "./containers/array-construction.js"; +import { initializedCollectionType } from "./js-collection-types.js"; import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js"; import { lowerObjectConstructor } from "./containers/indexed-objects.js"; import { knownBufEncoding, lowerBufferConstruct } from "./containers/bytes.js"; @@ -125,6 +132,7 @@ import { lowerWorkerNew, } from "./builtins/workers.js"; import { + noLoweringDiag, unsupportedPackageValueDiag, unsupportedDiag, type ScrDiagnostic, @@ -186,11 +194,14 @@ import { import { lowerObjectFactoryNew } from "./object-factory-new.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { lowerInstanceConstructorNew } from "./class-instance-constructor.js"; -import { classPrototypeData } from "./class-prototypes.js"; +import { classPrototypeData, reflectClassPrototype } from "./class-prototypes.js"; import { constructionEscape } from "./construction-escapes.js"; import { checkedClassConstruction, checkedClassConstructionPacked } from "./class-construction.js"; import { initializeRuntimeStatics, hasRuntimeStatics } from "./class-runtime-statics.js"; -import { reflectedClassStaticMethodValue } from "./class-method-values.js"; +import { + reflectedClassMethodValue, + reflectedClassStaticMethodValue, +} from "./class-method-values.js"; import { identifierReadSymbol } from "./surfaces.js"; export function returnsOnlyThis(member: ts.MethodDeclaration): boolean { @@ -482,6 +493,10 @@ export interface ClassInfo { localPrototypeData?: true; prototypeMethodValues?: Map; prototypeReflectionReady?: true; + /** The global that records at run time that the prototype view was + * populated by reflection, so it holds its own `constructor` until the + * program deletes it (prototypeReflectedFlag). */ + prototypeReflectedFlag?: string; prototypeObservedMethodsReady?: true; /** The globals holding each observed method's original prototype value * (set when the prototype data is built), by method name. A typed call @@ -5696,15 +5711,22 @@ export function lowerStaticFieldRead( ): IrExpr | null { if (expr.name.text === "prototype") return null; if (expr.questionDotToken) return null; + const info = staticReceiverClass(lowerer, expr.expression); + if (!info) return null; + return lowerClassStaticRead(lowerer, info, expr.name.text, expr.expression, expr); +} + +/** The class a static member read through `receiver` reads exactly: the + * class a name binds (directly, through a require binding, or through a + * const alias), or null. */ +export function staticReceiverClass(lowerer: Lowerer, receiver: ts.Expression): ClassInfo | null { if ( - !ts.isIdentifier(expr.expression) && - expr.expression.kind !== ts.SyntaxKind.ThisKeyword && - exactClassOfReceiver(lowerer, expr.expression) === null + !ts.isIdentifier(receiver) && + receiver.kind !== ts.SyntaxKind.ThisKeyword && + exactClassOfReceiver(lowerer, receiver) === null ) return null; - const symbol = ts.isIdentifier(expr.expression) - ? lowerer.resolveValueSymbol(expr.expression) - : null; + const symbol = ts.isIdentifier(receiver) ? lowerer.resolveValueSymbol(receiver) : null; const info = (symbol ? lowerer.classBySymbol.get(symbol) : undefined) ?? // A require binding over `module.exports = class {…}` (the alias @@ -5712,7 +5734,7 @@ export function lowerStaticFieldRead( // or `C.name` below would fall through to paths that answer for // stdlib globals instead of this class. propertyAssignedClassInfoOf(lowerer, symbol) ?? - exactClassOfReceiver(lowerer, expr.expression) ?? + exactClassOfReceiver(lowerer, receiver) ?? undefined; if (!info) return null; // A decorated name that can REBIND (a replacing decorator): the @@ -5721,22 +5743,36 @@ export function lowerStaticFieldRead( // whose devirtualization and .name rules answer for every legal // runtime value. if (info.classDecorators?.valueGlobalId !== undefined) return null; - const loc = locOf(expr); - const found = findStaticOn(lowerer, info, expr.name.text); + return info; +} + +/** The static member `name` of `info` read through `receiver` (an exact + * reference to the class, see staticReceiverClass): `C.name` for the + * property access `site`, or a destructuring element over `C` whose + * initializer is `site`. Null when the class has no such member. */ +export function lowerClassStaticRead( + lowerer: Lowerer, + info: ClassInfo, + name: string, + receiver: ts.Expression, + site: ts.Expression, +): IrExpr | null { + const loc = locOf(site); + const found = findStaticOn(lowerer, info, name); // A Date static read as a value through a class declared `extends Date`: // the statics lower only as calls. if ( !found && dateRootOf(info) && - ["now", "parse", "UTC"].includes(expr.name.text) && - !(ts.isCallExpression(expr.parent) && expr.parent.expression === expr) + ["now", "parse", "UTC"].includes(name) && + !(ts.isCallExpression(site.parent) && site.parent.expression === site) ) - lowerer.noLowering(`Date.${expr.name.text} read through a class that extends Date`, expr); + lowerer.noLowering(`Date.${name} read through a class that extends Date`, site); if (hasRuntimeStatics(info) && !found?.field) return { kind: "dynKeyGet", - value: lowerer.coerceToExpected(classValueRef(lowerer, info, expr.expression), DYN), - key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + value: lowerer.coerceToExpected(classValueRef(lowerer, info, receiver), DYN), + key: { kind: "strLit", value: name, type: STRING, loc }, type: DYN, loc, }; @@ -5744,11 +5780,11 @@ export function lowerStaticFieldRead( // name: in JS the brand lives on that one constructor object, so // `D.#s` (a subclass receiver) throws Node's TypeError instead of // reaching up the chain — reported as SC1090 instead of resolved. - if (found && expr.name.text.startsWith("#") && found.declarer !== info) { + if (found && name.startsWith("#") && found.declarer !== info) { lowerer.unsupported( "SC1090", - expr, - `reading the private static '${expr.name.text}' through the subclass '${info.def.name.replace(/^%|^%m\d+\./, "")}' (JS brands the declaring class object alone — Node throws a TypeError here; spell the declaring class's name)`, + site, + `reading the private static '${name}' through the subclass '${info.def.name.replace(/^%|^%m\d+\./, "")}' (JS brands the declaring class object alone — Node throws a TypeError here; spell the declaring class's name)`, ); } if (found?.field !== undefined) { @@ -5758,29 +5794,31 @@ export function lowerStaticFieldRead( type: found.field.type, loc, }; - return found.field.runtimeOptional ? value : lowerer.maybeNarrow(value, expr); + return found.field.runtimeOptional || !ts.isPropertyAccessExpression(site) + ? value + : lowerer.maybeNarrow(value, site); } if (found) { - return staticMethodValue(lowerer, found.declarer, expr.name.text, found.method, expr, loc); + return staticMethodValue(lowerer, found.declarer, name, found.method, site, loc); } // A GENERIC static method as a VALUE: the pinned-value rule verbatim // (lowerGenericFnValue) — a slot spelling one concrete signature names // an instance, an unpinned reference fences by name. { - const gfound = findGenericStaticOn(lowerer, info, expr.name.text); - if (gfound) return lowerer.lowerGenericFnValue(expr, gfound.info); + const gfound = findGenericStaticOn(lowerer, info, name); + if (gfound) return lowerer.lowerGenericFnValue(site, gfound.info); } // `C.name` — the JS-observable class name, a compile-time constant on // the direct spelling (tsc rejects user statics named `name`, so the // chain above can never shadow it in TypeScript sources). A class of an // import cycle reads it from its class object instead, whose reference // checks the class's initialization (checkCycleBindingAccesses). - if (expr.name.text === "name" && info.def.jsName !== undefined) { + if (name === "name" && info.def.jsName !== undefined) { if (lowerer.classInitFlags.get(info.def.name)?.cycle === true) return { kind: "libCall", fn: "class.name", - args: [classValueRef(lowerer, info, expr.expression)], + args: [classValueRef(lowerer, info, receiver)], type: STRING, loc, }; @@ -5789,26 +5827,26 @@ export function lowerStaticFieldRead( if (info.callableBase) return { kind: "dynKeyGet", - value: lowerer.coerceToExpected(classValueRef(lowerer, info, expr.expression), DYN), - key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + value: lowerer.coerceToExpected(classValueRef(lowerer, info, receiver), DYN), + key: { kind: "strLit", value: name, type: STRING, loc }, type: DYN, loc, }; - const data = classStaticDataFor(lowerer, info, expr.name.text, loc); + const data = classStaticDataFor(lowerer, info, name, loc); if (data) { const value: IrExpr = { kind: "dynKeyGet", value: data, - key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + key: { kind: "strLit", value: name, type: STRING, loc }, type: DYN, loc, }; // JavaScript often assigns public statics after the class declaration. // Their storage stays mutable and checked, while reads retain the // checker's concrete type instead of losing the method receiver. - const type = isJsSourceFile(ts.getSourceFileOfNode(expr)) + const type = isJsSourceFile(ts.getSourceFileOfNode(site)) ? null - : lowerer.mapTypeOf(lowerer.typeOf(expr)); + : lowerer.mapTypeOf(lowerer.typeOf(site)); return type ? lowerer.coerceToExpected(value, type) : value; } return null; @@ -6634,7 +6672,7 @@ function staticCallOn( kind: "dynInvoke", recv: lowerer.lowerExprExpecting(access.expression, DYN), method: access.name.text, - calleeName: access.name.text, + calleeName: ts.getTextOfNode(access), args: call.arguments.nodes.map((argument) => lowerer.lowerExprExpecting(argument, DYN)), type: DYN, loc, @@ -6990,8 +7028,8 @@ function capturesEnclosingScope( * reflection: the member a runtime lookup finds can then differ. */ function memberAssigned(lowerer: Lowerer, member: ts.MethodDeclaration, name: string): boolean { let found = false; - for (const sf of lowerer.program.getSourceFiles()) { - if (sf.isDeclarationFile || found) continue; + for (const sf of lowerer.program.getImplementationSourceFiles()) { + if (found) continue; if (!sf.text.includes(name)) continue; ts.walkPreorder(sf, (node) => { if (found) return "skip"; @@ -8210,31 +8248,83 @@ export function classMemberNameOf(lowerer: Lowerer, name: ts.PropertyName): stri export interface ClassIteratorInfo { /** The `[Symbol.iterator]()` call's receiver class + its declarer. */ className: string; - /** The iterator object's type (the method's return). */ - iterT: IrType & { kind: "object" }; + /** The iterator's type (the method's return): a class instance, or a + * record (an object literal) whose `next` field is a function. */ + iterT: IrType & ({ kind: "object" } | { kind: "record" }); /** next()'s result record type. */ resultT: IrType & { kind: "record" }; valueT: IrType; /** False: no done field — the protocol never terminates. */ hasDone: boolean; + /** The iterator declares a zero-parameter return(), which a consumer + * that stops before the end calls (IteratorClose). Only consumers that + * ask for `close` see such iterators. */ + hasReturn: boolean; } -export function classIteratorOf(lowerer: Lowerer, t: IrType): ClassIteratorInfo | null { +/** A return() result type that is always an object: IteratorClose after a + * break throws a TypeError for any other result, which these desugars do + * not model. */ +function objectResult(t: IrType): boolean { + return t.kind === "record" || t.kind === "object"; +} + +/** What a class iterable's consumer handles: `records` accepts an + * object-literal iterator; `close` accepts an iterator with return(), + * which the consumer calls when it stops early (or never stops early). */ +export interface ClassIteratorOptions { + records?: boolean; + close?: boolean; +} +export function classIteratorOf( + lowerer: Lowerer, + t: IrType, + options: ClassIteratorOptions = {}, +): ClassIteratorInfo | null { if (t.kind !== "object") return null; const info = lowerer.classes.get(t.className); if (!info) return null; const iter = findMethodOn(lowerer, info, "sym:iterator"); if (!iter || iter.sig.params.length !== 0 || iter.sig.abstract === true) return null; const iterT = iter.sig.ret; - if (iterT.kind !== "object") return null; - const itInfo = lowerer.classes.get(iterT.className); - if (!itInfo) return null; - // IteratorClose honesty: a declared return()/throw() would be called - // by JS on abrupt completion; these desugars never close. - if (findMethodOn(lowerer, itInfo, "return") || findMethodOn(lowerer, itInfo, "throw")) - return null; - const next = findMethodOn(lowerer, itInfo, "next"); - if (!next || next.sig.params.length !== 0 || next.sig.abstract === true) return null; - const resultT = next.sig.ret; + let resultT: IrType; + let hasReturn: boolean; + if (iterT.kind === "object") { + const itInfo = lowerer.classes.get(iterT.className); + if (!itInfo) return null; + // IteratorClose honesty: a declared return() would be called by JS on + // abrupt completion, so only a consumer that closes takes it; for-of + // never calls throw(), the other consumers refuse it. + const close = findMethodOn(lowerer, itInfo, "return"); + if ( + close && + (!options.close || + close.sig.abstract === true || + close.sig.params.length !== 0 || + !objectResult(close.sig.ret)) + ) + return null; + if (findMethodOn(lowerer, itInfo, "throw") && !options.close) return null; + const next = findMethodOn(lowerer, itInfo, "next"); + if (!next || next.sig.params.length !== 0 || next.sig.abstract === true) return null; + resultT = next.sig.ret; + hasReturn = !!close; + } else if (iterT.kind === "record" && options.records) { + const fields = lowerer.shapes.get(iterT.shapeId)?.fields; + const next = fields?.find((f) => f.name === "next")?.type; + if (!next || next.kind !== "func" || next.params.length !== 0 || next.rest) return null; + const close = fields?.find((f) => f.name === "return")?.type; + if ( + close && + (!options.close || + close.kind !== "func" || + close.params.length !== 0 || + close.rest || + !objectResult(close.ret)) + ) + return null; + resultT = next.ret; + hasReturn = close !== undefined; + } else return null; if (resultT.kind !== "record") return null; const shape = lowerer.shapes.get(resultT.shapeId); const value = shape?.fields.find((f) => f.name === "value"); @@ -8247,18 +8337,82 @@ export function classIteratorOf(lowerer: Lowerer, t: IrType): ClassIteratorInfo resultT, valueT: value.type, hasDone: done !== undefined, + hasReturn, }; } -/** The `it.next()` step of a class iterator as an ordinary (possibly - * virtual) method call. */ -export function classIteratorNextCall( +/** The `next` an object-literal iterator's protocol calls, read once when + * the iterator opens: JS keeps the `next` it reads then in the iterator + * record, so a `next` the iterator assigns while it runs is not called. + * Null for an iterator class, whose `next` is a method. */ +export function classIteratorNextRead( lowerer: Lowerer, cit: ClassIteratorInfo, itRef: IrExpr, loc: SrcLoc, +): (IrExpr & { type: IrType & { kind: "func" } }) | null { + const iterT = cit.iterT; + if (iterT.kind === "object") return null; + const field = lowerer.shapes.get(iterT.shapeId)!.fields.find((f) => f.name === "next")!; + return { + kind: "recordGet", + obj: itRef, + shapeId: iterT.shapeId, + field: "next", + type: field.type as IrType & { kind: "func" }, + loc, + }; +} + +/** The `it.next()` step of a class iterator: an ordinary (possibly + * virtual) method call, or the call of `next`, the object-literal + * iterator's `next` that classIteratorNextRead read when it opened. */ +export function classIteratorNextCall( + lowerer: Lowerer, + cit: ClassIteratorInfo, + itRef: IrExpr, + next: IrExpr | null, + loc: SrcLoc, ): IrExpr { - return accessorCall(lowerer, cit.iterT.className, "next", itRef, [], cit.resultT, loc); + if (next === null || next.type.kind !== "func") + return classIteratorMethodCall(lowerer, cit, "next", itRef, loc); + return { kind: "callValue", callee: next, args: [], type: next.type.ret, loc }; +} + +/** `it.next()` or `it.return()` of a class iterator: a (possibly virtual) + * method call of an iterator class, or the call of an object-literal + * iterator's function field. */ +export function classIteratorMethodCall( + lowerer: Lowerer, + cit: ClassIteratorInfo, + method: "next" | "return", + itRef: IrExpr, + loc: SrcLoc, +): IrExpr { + const iterT = cit.iterT; + if (iterT.kind === "object") { + const ret = + method === "next" + ? cit.resultT + : findMethodOn(lowerer, lowerer.classes.get(iterT.className)!, "return")!.sig.ret; + return accessorCall(lowerer, iterT.className, method, itRef, [], ret, loc); + } + const field = lowerer.shapes.get(iterT.shapeId)!.fields.find((f) => f.name === method)!; + const fnT = field.type as IrType & { kind: "func" }; + return { + kind: "callValue", + callee: { + kind: "recordGet", + obj: itRef, + shapeId: iterT.shapeId, + field: method, + type: fnT, + loc, + }, + args: [], + type: fnT.ret, + loc, + }; } /** `recv[Symbol.iterator]()` as an ordinary method call. */ @@ -8286,12 +8440,15 @@ export function classIteratorDrainCall( loc: SrcLoc, elemT?: IrType, ): IrExpr | null { - const cit = classIteratorOf(lowerer, src.type); + // A spread takes every value, so it never closes the iterator. + const cit = classIteratorOf(lowerer, src.type, { records: true, close: true }); if (!cit) return null; const outElem = elemT ?? cit.valueT; // Probe the element coercion purely: identical types, or an arm of a // union destination (the wrap coerceToExpected applies below). - if (!typeEquals(outElem, cit.valueT)) { + // A checked-dynamic value (`IteratorResult`'s value is `T | any`) + // converts to the destination element with a check. + if (!typeEquals(outElem, cit.valueT) && cit.valueT.kind !== "dyn") { if (outElem.kind !== "union" || lowerer.armTag(outElem.unionId, cit.valueT) < 0) return null; } const outT = arrayOf(outElem); @@ -8303,6 +8460,8 @@ export function classIteratorDrainCall( const recvT: IrType = { kind: "object", className: cit.className }; const recvRef: IrExpr = { kind: "varRef", localId: "r.0", type: recvT, loc }; const itRef: IrExpr = { kind: "varRef", localId: "it.0", type: cit.iterT, loc }; + const nextRead = classIteratorNextRead(lowerer, cit, itRef, loc); + const nextRef: IrExpr | null = nextRead ? varRef("next.0", nextRead.type, loc) : null; const outRef: IrExpr = { kind: "varRef", localId: "out.0", type: outT, loc }; const resRef: IrExpr = { kind: "varRef", localId: "res.0", type: cit.resultT, loc }; const valueRead: IrExpr = { @@ -8317,7 +8476,7 @@ export function classIteratorDrainCall( { kind: "varDecl", localId: "res.0", - init: classIteratorNextCall(lowerer, cit, itRef, loc), + init: classIteratorNextCall(lowerer, cit, itRef, nextRef, loc), loc, }, ...(cit.hasDone @@ -8358,6 +8517,7 @@ export function classIteratorDrainCall( locals: [ { id: "r.0", name: "r", type: recvT, mutable: false }, { id: "it.0", name: "it", type: cit.iterT, mutable: false }, + ...(nextRead ? [{ id: "next.0", name: "next", type: nextRead.type, mutable: false }] : []), { id: "out.0", name: "out", type: outT, mutable: false }, { id: "res.0", name: "res", type: cit.resultT, mutable: true }, ], @@ -8374,6 +8534,9 @@ export function classIteratorDrainCall( init: classIteratorOpenCall(lowerer, cit, recvRef, loc), loc, }, + ...(nextRead + ? [{ kind: "varDecl", localId: "next.0", init: nextRead, loc } satisfies IrStmt] + : []), { kind: "while", cond: { kind: "boolLit", value: true, type: BOOL, loc }, @@ -8409,7 +8572,7 @@ export function classIteratorRestDrainCall( loc: SrcLoc, ): IrExpr { const outT = arrayOf(cit.valueT); - const key = `it:${cit.iterT.className}`; + const key = `it:${cit.iterT.kind === "object" ? cit.iterT.className : typeKey(cit.iterT)}`; let name = lowerer.iterDrainHelpers.get(key); if (!name) { name = keyedHelperName(lowerer, "%iter.drain", key); @@ -8421,7 +8584,7 @@ export function classIteratorRestDrainCall( { kind: "varDecl", localId: "res.0", - init: classIteratorNextCall(lowerer, cit, itRef, loc), + init: classIteratorNextCall(lowerer, cit, itRef, null, loc), loc, }, ...(cit.hasDone @@ -9417,22 +9580,51 @@ export function lowerSuperMethodCall( if (!thisLocal) lowerer.unsupported("SC1080", access); const loc = locOf(call); const thisRef: IrExpr = { kind: "varRef", localId: thisLocal.id, type: thisLocal.type, loc }; - if (lowerer.prototypeMethodAccesses.has(access.name.text)) { - const prototype = classPrototypeData(lowerer, cls.base, loc); - if (prototype) { - const callee: IrExpr = { - kind: "dynKeyGet", - value: prototype, - key: { kind: "strLit", value: access.name.text, type: STRING, loc }, - type: DYN, - loc, - }; + // A method whose `this` is the call-site receiver (a JavaScript class's + // receiver-independent method value, reflectedClassMethodValue) looks the + // method up on the base class's complete prototype, as `super` reads + // [[HomeObject]].[[Prototype]], and calls it with that receiver. + const dynamicThis = thisRef.type.kind === "dyn"; + // While no code reads a prototype method by name (which could replace it), + // that method is the declaring class's own: its receiver-independent value + // is called directly. Reflecting the base's whole prototype instead would + // put its members on the prototype chain of every subclass instance's + // property bag, ahead of the subclasses' own compiled overrides. + const declared = + dynamicThis && !lowerer.prototypeMethodAccesses.has(access.name.text) + ? reflectedClassMethodValue(lowerer, found.declarer, access.name.text) + : null; + if (dynamicThis || lowerer.prototypeMethodAccesses.has(access.name.text)) { + const prototype = declared + ? null + : dynamicThis && cls.base.decl + ? reflectClassPrototype(lowerer, cls.base, loc, classValueRef(lowerer, cls.base, access)) + : classPrototypeData(lowerer, cls.base, loc); + if (declared || prototype) { + const callee: IrExpr = declared + ? lowerer.coerceToExpected(declared, DYN) + : { + kind: "dynKeyGet", + value: prototype!, + key: { kind: "strLit", value: access.name.text, type: STRING, loc }, + type: DYN, + loc, + }; + // Spread arguments are spread into the dynamic call, as in a dynamic method call. + const spreads: { arg: number; what: string }[] = []; + const args = call.arguments.nodes.map((arg, index) => { + if (!ts.isSpreadElement(arg)) return lowerer.lowerExprExpecting(arg, DYN); + const spelling = ts.getTextOfNode(arg.expression); + spreads.push({ arg: index, what: spelling }); + return checkedIterableSpread(lowerer, lowerer.lowerExpr(arg.expression), spelling, loc); + }); const value: IrExpr = { kind: "dynCall", callee, receiver: lowerer.coerceToExpected(thisRef, DYN), calleeName: ts.getTextOfNode(access), - args: call.arguments.nodes.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), + args, + ...(spreads.length > 0 ? { spreads } : {}), type: DYN, loc, }; @@ -9801,6 +9993,75 @@ function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType return lowerer.currentClass?.fields.get(parent.left.name.text) ?? null; } +/** An array element read that construction narrows to a class value with a + * check (`%new.callee`, which throws "is not a constructor"). */ +interface NarrowedConstructorRead { + read: IrExpr; + check: string; + unionT: IrType & { kind: "union" }; + calleeT: IrType & { kind: "classval" }; + message: IrExpr; +} + +/** `new arr[i](args)` through a narrowed element read: an interned lifted + * function takes the read and the completed arguments, which evaluate in + * that order as call arguments, then runs the check and constructs. JS + * evaluates the arguments before it checks that the callee constructs, and + * the arguments stay in the one call, where a spread's argument pack lives + * until every argument has read it. */ +function checkedElementConstruction( + lowerer: Lowerer, + info: ClassInfo, + narrowed: NarrowedConstructorRead, + args: IrExpr[], + loc: SrcLoc, +): IrExpr { + const type: IrType = { kind: "object", className: info.def.name }; + const key = `newChecked:${narrowed.check}:${info.def.name}:${args.map((a) => typeKey(a.type)).join(",")}`; + let name = lowerer.valueHelpers.get(key); + if (!name) { + name = keyedHelperName(lowerer, "%new.checked", key); + lowerer.valueHelpers.set(key, name); + const params: IrParam[] = [ + { localId: "value", name: "value", type: narrowed.unionT }, + { localId: "message", name: "message", type: STRING }, + ...args.map((arg, i) => ({ localId: `arg.${i}`, name: `arg${i}`, type: arg.type })), + ]; + lowerer.liftedFns.push({ + name, + params, + returnType: type, + locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + body: [ + { + kind: "return", + value: { + kind: "newValue", + callee: { + kind: "call", + callee: narrowed.check, + args: [varRef("value", narrowed.unionT, loc), varRef("message", STRING, loc)], + type: narrowed.calleeT, + loc, + }, + args: params.slice(2).map((p) => varRef(p.localId, p.type, loc)), + type, + loc, + }, + loc, + }, + ], + loc, + }); + } + return { + kind: "call", + callee: name, + args: [narrowed.read, narrowed.message, ...args], + type, + loc, + }; +} /** Map, Set and the error classes construct in lowerNew's identifier * branch, which a module member alias of one (`new mod.M()`) also takes. */ function isCollectionOrErrorClass(ref: BuiltinClassRef | null): boolean { @@ -9838,7 +10099,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { } const worker = lowerWorkerNew(lowerer, expr); if (worker) return worker; - const readlineUi = lowerReadlineUiNew(lowerer, expr); + const readlineUi = lowerReadlineUiNew(lowerer, expr) ?? lowerReadlineInterfaceNew(lowerer, expr); if (readlineUi) return readlineUi; const channel = lowerMessageChannelNew(lowerer, expr) ?? lowerBroadcastChannelNew(lowerer, expr); if (channel) return channel; @@ -9921,34 +10182,54 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { const args = expr.arguments?.nodes ?? []; const undefinedArg = (arg: ts.Expression): boolean => (lowerer.typeOf(arg).flags & ts.TypeFlags.Undefined) !== 0; - if (args.length > 2 || (args[0] && !undefinedArg(args[0]))) { + // A literal language tag: Unicode grapheme clusters have no locale + // tailoring. Only tags of the form language[-script][-region] are + // accepted; each is well-formed, so Node constructs the segmenter + // without the RangeError a malformed tag throws. + const literalLocale = (arg: ts.Expression): boolean => + ts.isStringLiteralLike(arg) && + /^[A-Za-z]{2,3}(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?$/.test(arg.text); + if (args.length > 2 || (args[0] && !undefinedArg(args[0]) && !literalLocale(args[0]))) { lowerer.noLowering( "Intl.Segmenter locale negotiation", expr, - "omit the locale or pass undefined for default Unicode grapheme segmentation", + "omit the locale, pass undefined, or pass one language tag as a string literal", ); } + // JavaScript dependencies may construct word and sentence segmenters + // that they use only on some paths (pi-tui's shared word segmenter): + // the construction compiles and their segment() throws SC2020. + // TypeScript sources keep the build-time refusal. + const granularities = isJsSourceFile(ts.getSourceFileOfNode(expr)) + ? ["grapheme", "word", "sentence"] + : ["grapheme"]; const options = args[1]; - if ( - options && - !undefinedArg(options) && - !( - ts.isObjectLiteralExpression(options) && - options.properties.nodes.every( - (property) => - ts.isPropertyAssignment(property) && - (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && - property.name.text === "granularity" && - ts.isStringLiteral(property.initializer) && - property.initializer.text === "grapheme", - ) - ) - ) { - lowerer.noLowering( - "Intl.Segmenter options", - options, - 'the native segmenter supports default options and { granularity: "grapheme" }; word and sentence segmentation remain unsupported', - ); + let granularity = "grapheme"; + if (options && !undefinedArg(options)) { + // An object literal of `granularity` entries (none: the default; a + // repeated key: the last one, as in JavaScript). + if (!ts.isObjectLiteralExpression(options)) + lowerer.noLowering( + "Intl.Segmenter options", + options, + 'the native segmenter supports default options and { granularity: "grapheme" }; word and sentence segmentation remain unsupported', + ); + for (const property of options.properties.nodes) { + if ( + !ts.isPropertyAssignment(property) || + !(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) || + property.name.text !== "granularity" || + !ts.isStringLiteral(property.initializer) || + !granularities.includes(property.initializer.text) + ) { + lowerer.noLowering( + "Intl.Segmenter options", + options, + 'the native segmenter supports default options and { granularity: "grapheme" }; word and sentence segmentation remain unsupported', + ); + } + granularity = property.initializer.text; + } } // Explicit undefined expressions can carry effects; retain argument order. const stmts: IrStmt[] = args.map((arg) => { @@ -9959,7 +10240,28 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { loc: locOf(arg), }; }); - const result: IrExpr = { kind: "libCall", fn: "intl.segmenterNew", args: [], type: DYN, loc }; + let result: IrExpr = { kind: "libCall", fn: "intl.segmenterNew", args: [], type: DYN, loc }; + // A word or sentence segmenter carries its segment() refusal, which + // coverage lists as deferred to runtime. + if (granularity !== "grapheme") { + const surface = `Intl.Segmenter segmentation with granularity '${granularity}'`; + lowerer.runtimeFences.push( + noLoweringDiag( + surface, + loc, + "only grapheme segmenters segment; word and sentence segmenters construct", + ), + ); + const line = ts.getLineAndCharacterOfPosition(ts.getSourceFileOfNode(expr), loc.start).line; + const refusal = `${surface} is not supported yet [SC2020 at ${loc.file}:${line + 1}]`; + result = { + kind: "libCall", + fn: "intl.segmenterRefusing", + args: [strLit(refusal, loc)], + type: DYN, + loc, + }; + } return stmts.length ? { kind: "seqExpr", stmts, result, type: DYN, loc } : result; } // A package declaration without a compiled constructor cannot provide @@ -10138,6 +10440,55 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { const agent = lowerHttpAgentNew(lowerer, expr); if (agent) return agent; } + // `new crypto.X509Certificate(data)` — the Dirent-style data record: + // the certificate's lowered members (fingerprint — the SHA-1 of the + // DER, uppercase colon-separated — plus the validFrom/validTo + // validity window in Node's ASN1_TIME_print shape) compute AT + // CONSTRUCTION, when Node parses too, so unparseable input throws + // Node's exact PEM error here (ERR_OSSL_PEM_NO_START_LINE) and the + // handle never exists. Both import forms (`crypto.X509Certificate` + // through the namespace, named `X509Certificate`); Buffer input only + // — the readFileSync idiom. mapType interns the matching record, so + // locals and the composed member reads all flow. The data argument + // feeds THREE field computations, so construction goes through an + // interned helper whose parameter evaluates it exactly once. + // It runs before the standard-library constructor fence below, which + // also sees `const X = require("crypto").X509Certificate` as the + // builtin class. + { + const callee = expr.expression; + const isX509 = + (ts.isPropertyAccessExpression(callee) && + callee.name.text === "X509Certificate" && + lowerer.builtinNamespaceModuleOf(callee.expression) === "crypto") || + (ts.isIdentifier(callee) && + (() => { + const bi = lowerer.builtinImportOf(callee); + return bi?.module === "crypto" && bi.member === "X509Certificate"; + })()); + if (isX509) { + const args: readonly ts.Expression[] = expr.arguments?.nodes ?? []; + if (args.length !== 1) { + lowerer.noLowering( + "X509Certificate with this argument shape", + expr, + "the supported form is new X509Certificate(readFileSync(path))", + ); + } + const data = lowerer.lowerExpr(args[0]!); + const isBytes = data.type.kind === "bytes" && data.type.elem === "u8"; + if (!isBytes && data.type.kind !== "string") { + lowerer.noLowering( + `X509Certificate over '${lowerer.fmt(data.type)}' data`, + args[0]!, + "pass the certificate Buffer or PEM string (an fs.readFileSync result)", + ); + } + const t = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (t?.kind !== "record") lowerer.badType(expr, lowerer.typeOf(expr)); + return x509RecordCall(lowerer, data, t, loc); + } + } if ( ts.isIdentifier(expr.expression) || (ts.isPropertyAccessExpression(expr.expression) && @@ -10151,7 +10502,12 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { // (a no-op for every non-import= binding). if (ts.isIdentifier(expr.expression)) fenceEarlyAliasUse(lowerer, expr.expression, expr); const identifier = ts.isIdentifier(expr.expression) ? expr.expression : expr.expression.name; - const symbol = ts.isIdentifier(identifier) ? lowerer.resolveValueSymbol(identifier) : null; + // `const SD = require("string_decoder").StringDecoder; new SD()` + // constructs the member, as a named import of it does. + const symbol = builtinMemberBindingTarget( + lowerer, + ts.isIdentifier(identifier) ? lowerer.resolveValueSymbol(identifier) : null, + ); if (ts.isIdentifier(identifier) && lowerer.isStdlibGlobal(identifier, "Array")) { return lowerArrayConstructor(lowerer, expr, expr.arguments?.nodes ?? []); } @@ -10456,6 +10812,29 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { const time: IrExpr = { kind: "libCall", fn: "date.getTime", args: [arg], type: F64, loc }; return { kind: "libCall", fn: "date.newMs", args: [time], type: DATE_T, loc }; } + // A union (`Date | string | number`) or checked-dynamic value: the + // runtime's constructor reads a Date's time value, parses a string + // and converts anything else to a primitive and a number. + if ( + (arg.type.kind === "union" || arg.type.kind === "dyn") && + lowerer.dynConvertible(arg.type) + ) { + const value: IrExpr = { + kind: "libCall", + fn: "date.nativeNewValue", + args: [ + { + kind: "dynArrLit", + elems: [lowerer.coerceToExpected(arg, DYN)], + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }; + return { kind: "libCall", fn: "date.checkedValue", args: [value], type: DATE_T, loc }; + } lowerer.noLowering( `new Date of '${lowerer.fmt(arg.type)}' values`, args[0]!, @@ -10587,7 +10966,11 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { // expression). The CONTEXTUAL type carries the annotation — adopt // it when it is a supported map. TS type arguments keep winning: // their expression type already maps. - if (mapped?.kind !== "map" || contextualSeed) { + const inferred = initializedCollectionType(lowerer, expr); + if (inferred) { + tsType = inferred; + mapped = lowerer.mapTypeOf(inferred); + } else if (mapped?.kind !== "map" || contextualSeed) { const ctx = lowerer.checker.getContextualType(expr); const ctxMapped = ctx ? lowerer.mapTypeOf(ctx) : null; if (ctx && ctxMapped?.kind === "map") { @@ -10727,7 +11110,8 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { // `new Set()`: construct + bulk add from supported values, // including live collection cursors. Duplicates collapse in insertion order. if (ts.isIdentifier(identifier) && lowerer.isStdlibGlobal(identifier, "Set")) { - const tsType = lowerer.typeOf(expr); + const inferred = initializedCollectionType(lowerer, expr); + const tsType = inferred ?? lowerer.typeOf(expr); let mapped = lowerer.mapTypeOf(tsType); const fieldType = assignedThisFieldType(lowerer, expr); if (mapped?.kind !== "set" && fieldType?.kind === "set") mapped = fieldType; @@ -10918,99 +11302,6 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { ); } } - // `new crypto.X509Certificate(data)` — the Dirent-style data record: - // the certificate's lowered members (fingerprint — the SHA-1 of the - // DER, uppercase colon-separated — plus the validFrom/validTo - // validity window in Node's ASN1_TIME_print shape) compute AT - // CONSTRUCTION, when Node parses too, so unparseable input throws - // Node's exact PEM error here (ERR_OSSL_PEM_NO_START_LINE) and the - // handle never exists. Both import forms (`crypto.X509Certificate` - // through the namespace, named `X509Certificate`); Buffer input only - // — the readFileSync idiom. mapType interns the matching record, so - // locals and the composed member reads all flow. The data argument - // feeds THREE field computations, so construction goes through an - // interned helper whose parameter evaluates it exactly once. - { - const callee = expr.expression; - const isX509 = - (ts.isPropertyAccessExpression(callee) && - callee.name.text === "X509Certificate" && - lowerer.builtinNamespaceModuleOf(callee.expression) === "crypto") || - (ts.isIdentifier(callee) && - (() => { - const bi = lowerer.builtinImportOf(callee); - return bi?.module === "crypto" && bi.member === "X509Certificate"; - })()); - if (isX509) { - const args: readonly ts.Expression[] = expr.arguments?.nodes ?? []; - if (args.length !== 1) { - lowerer.noLowering( - "X509Certificate with this argument shape", - expr, - "the supported form is new X509Certificate(readFileSync(path))", - ); - } - const data = lowerer.lowerExpr(args[0]!); - const isBytes = data.type.kind === "bytes" && data.type.elem === "u8"; - if (!isBytes && data.type.kind !== "string") { - lowerer.noLowering( - `X509Certificate over '${lowerer.fmt(data.type)}' data`, - args[0]!, - "pass the certificate Buffer or PEM string (an fs.readFileSync result)", - ); - } - const t = lowerer.mapTypeOf(lowerer.typeOf(expr)); - if (t?.kind !== "record") lowerer.badType(expr, lowerer.typeOf(expr)); - const key = `x509.record:${isBytes ? "bytes" : "str"}`; - let helper = lowerer.valueHelpers.get(key); - if (!helper) { - helper = keyedHelperName(lowerer, "%x509.record", key); - lowerer.valueHelpers.set(key, helper); - const dataT = data.type; - const dRef: IrExpr = { kind: "varRef", localId: "d.0", type: dataT, loc }; - const field = ( - name: string, - fn: - | "crypto.x509Fingerprint" - | "crypto.x509FingerprintStr" - | "crypto.x509ValidFrom" - | "crypto.x509ValidFromStr" - | "crypto.x509ValidTo" - | "crypto.x509ValidToStr", - ): { name: string; value: IrExpr } => ({ - name, - value: { kind: "libCall", fn, args: [dRef], type: STRING, loc }, - }); - lowerer.liftedFns.push({ - name: helper, - params: [{ localId: "d.0", name: "d", type: dataT }], - returnType: t, - locals: [{ id: "d.0", name: "d", type: dataT, mutable: false }], - body: [ - { - kind: "return", - value: { - kind: "recordLit", - fields: [ - field( - "fingerprint", - isBytes ? "crypto.x509Fingerprint" : "crypto.x509FingerprintStr", - ), - field("validFrom", isBytes ? "crypto.x509ValidFrom" : "crypto.x509ValidFromStr"), - field("validTo", isBytes ? "crypto.x509ValidTo" : "crypto.x509ValidToStr"), - ], - type: t, - loc, - }, - loc, - }, - ], - loc, - }); - } - return { kind: "call", callee: helper, args: [data], type: t, loc }; - } - } // `new X(...)` through a class VALUE (a classval-typed binding, array // element, map read, param): the newValue dispatch through the class // object's construct thunk. Arguments complete against the STATIC @@ -11062,8 +11353,109 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { "constructing through a class value of an uninstantiated generic type (annotate the slot with a concrete instantiation — e.g. 'new (v: number) => Box')", ); } - const callee = lowerer.lowerExpr(expr.expression); + let callee = lowerer.lowerExpr(expr.expression); if (callee.type.kind === "dyn") return lowerCheckedConstructor(lowerer, expr, callee); + /** The union-typed element read a narrowing check guards, and the + * check (`%new.callee`) that yields the class value or throws. */ + let narrowedCallee: NarrowedConstructorRead | null = null; + // An array element read (`new T[0]()` over `[Dog, Animal]`, whose + // checker type is the common base) keeps the undefined an index past + // the end reads: the class value is narrowed out with a check, which + // throws a TypeError for undefined. + if (callee.type.kind === "union") { + const arms = lowerer.unions.get(callee.type.unionId)?.arms ?? []; + const classArms = arms.filter((arm) => arm.kind === "classval"); + if ( + classArms.length === 1 && + classArms[0]!.kind === "classval" && + classArms[0]!.className === calleeT.className && + arms.every((arm) => arm.kind === "classval" || arm.kind === "undefinedT") + ) { + const unionT = callee.type; + const helper = lowerer.narrowedArmHelper(unionT.unionId, calleeT, callee.loc); + if (helper) { + const loc = callee.loc; + const name = `%new.callee:${unionT.unionId}`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const undefinedTag = lowerer.armTag(unionT.unionId, UNDEFINED_T); + const value: IrExpr = { kind: "varRef", localId: "value", type: unionT, loc }; + lowerer.liftedFns.push({ + name, + params: [ + { localId: "value", name: "value", type: unionT }, + { localId: "message", name: "message", type: STRING }, + ], + locals: [ + { id: "value", name: "value", type: unionT, mutable: false }, + { id: "message", name: "message", type: STRING, mutable: false }, + ], + returnType: calleeT, + body: [ + ...(undefinedTag >= 0 + ? [ + { + kind: "if", + cond: { + kind: "unionIsTag", + unionId: unionT.unionId, + tag: undefinedTag, + negated: false, + value, + type: BOOL, + loc, + }, + then: [ + { + kind: "throw", + value: { + kind: "libCall", + fn: "error.new", + args: [{ kind: "varRef", localId: "message", type: STRING, loc }], + type: { kind: "object", className: "%TypeError" }, + loc, + }, + loc, + }, + ], + else_: null, + loc, + } satisfies IrStmt, + ] + : []), + { + kind: "return", + value: lowerer.checkedNarrowCall(helper, value, calleeT, loc), + loc, + }, + ], + loc, + }); + } + // JavaScript evaluates the arguments before it checks that the + // callee constructs, so the check runs at the construction + // (checkedElementConstruction). + narrowedCallee = { + read: callee, + check: name, + unionT, + calleeT, + message: { + kind: "strLit", + value: `${expr.expression.getText()} is not a constructor`, + type: STRING, + loc, + }, + }; + callee = { + kind: "call", + callee: name, + args: [callee, narrowedCallee.message], + type: calleeT, + loc, + }; + } + } + } if (callee.type.kind !== "classval") lowerer.badType(expr.expression, lowerer.typeOf(expr.expression)); lowerer.noteEdge(classValueConstructor(info.def).fn); @@ -11103,13 +11495,15 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { const built = constructedFromValue( lowerer, info, - { - kind: "newValue", - callee, - args, - type: { kind: "object", className: info.def.name }, - loc, - }, + narrowedCallee !== null + ? checkedElementConstruction(lowerer, info, narrowedCallee, args, loc) + : { + kind: "newValue", + callee, + args, + type: { kind: "object", className: info.def.name }, + loc, + }, expr, ); // A runtime class slot widened from a union (`[Readable, Writable]`'s @@ -11284,14 +11678,166 @@ function genericFieldFnNodeOf( return null; } +/** The lifted `new crypto.X509Certificate(d)` constructor: raw decodes the + * input once (Node's PEM error throws here), and every other field reads + * raw. subjectAltName, infoAccess and signatureAlgorithm hold undefined + * where the runtime answers "". */ +/** The X509Certificate record `t` built from `data`, a certificate Buffer + * (PEM or DER) or PEM string, through the interned constructor helper. + * `der` marks a Buffer known to hold a certificate's DER (a TLS socket's), + * which is read without looking for PEM armor inside it. */ +export function x509RecordCall( + lowerer: Lowerer, + data: IrExpr, + t: IrType & { kind: "record" }, + loc: SrcLoc, + der = false, +): IrExpr { + const isBytes = data.type.kind === "bytes" && data.type.elem === "u8"; + const key = `x509.record:${der ? "der" : isBytes ? "bytes" : "str"}`; + let helper = lowerer.valueHelpers.get(key); + if (!helper) { + helper = keyedHelperName(lowerer, "%x509.record", key); + lowerer.valueHelpers.set(key, helper); + lowerer.liftedFns.push(x509RecordHelper(lowerer, helper, data.type, t, isBytes, der, loc)); + } + return { kind: "call", callee: helper, args: [data], type: t, loc }; +} + +function x509RecordHelper( + lowerer: Lowerer, + name: string, + dataT: IrType, + t: IrType & { kind: "record" }, + isBytes: boolean, + der: boolean, + loc: SrcLoc, +): IrFunction { + const shape = lowerer.shapes.get(t.shapeId); + if (!shape) throw new InternalCompilerError("lowerer bug: X509Certificate shape unregistered"); + const raw: IrExpr = { kind: "varRef", localId: "raw.0", type: BYTES_U8, loc }; + const call = (fn: IrLibFn, type: IrType): IrExpr => ({ + kind: "libCall", + fn, + args: [raw], + type, + loc, + }); + const locals: IrLocal[] = [ + { id: "d.0", name: "d", type: dataT, mutable: false }, + { id: "raw.0", name: "raw", type: BYTES_U8, mutable: false }, + ]; + const decls: IrStmt[] = [ + { + kind: "varDecl", + localId: "raw.0", + init: { + kind: "libCall", + fn: der ? "crypto.x509RawDer" : isBytes ? "crypto.x509Raw" : "crypto.x509RawStr", + args: [{ kind: "varRef", localId: "d.0", type: dataT, loc }], + type: BYTES_U8, + loc, + }, + loc, + }, + ]; + const validTime = (fn: IrLibFn): IrExpr => ({ + kind: "libCall", + fn: "date.newString", + args: [call(fn, STRING)], + type: DATE_T, + loc, + }); + const values: Record IrExpr> = { + ca: () => call("crypto.x509Ca", BOOL), + fingerprint: () => call("crypto.x509Fingerprint", STRING), + fingerprint256: () => call("crypto.x509Fingerprint256", STRING), + fingerprint512: () => call("crypto.x509Fingerprint512", STRING), + subject: () => call("crypto.x509Subject", STRING), + subjectAltName: (type) => optionalText("san", "crypto.x509SubjectAltName", type), + infoAccess: (type) => optionalText("access", "crypto.x509InfoAccess", type), + issuer: () => call("crypto.x509Issuer", STRING), + raw: () => raw, + serialNumber: () => call("crypto.x509SerialNumber", STRING), + signatureAlgorithm: (type) => optionalText("sigalg", "crypto.x509SignatureAlgorithm", type), + signatureAlgorithmOid: () => call("crypto.x509SignatureAlgorithmOid", STRING), + validFrom: () => call("crypto.x509ValidFrom", STRING), + validFromDate: () => validTime("crypto.x509ValidFrom"), + validTo: () => call("crypto.x509ValidTo", STRING), + validToDate: () => validTime("crypto.x509ValidTo"), + [X509_PUBLIC_KEY_FIELD]: () => ({ + kind: "dynFrom", + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type: DYN, + loc, + }), + // A copy: writes into `raw` must not reach the methods. + [X509_DER_FIELD]: () => call("crypto.x509RawDer", BYTES_U8), + }; + /** A string | undefined field: the runtime's text, or undefined for "". */ + function optionalText(local: string, fn: IrLibFn, type: IrType): IrExpr { + if (type.kind !== "union") + throw new InternalCompilerError("lowerer bug: X509Certificate optional field type"); + const def = lowerer.unions.get(type.unionId); + const stringTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1; + const undefinedTag = def ? def.arms.findIndex((a) => a.kind === "undefinedT") : -1; + if (stringTag < 0 || undefinedTag < 0) + throw new InternalCompilerError("lowerer bug: X509Certificate optional field arms"); + const id = `${local}.0`; + locals.push({ id, name: local, type: STRING, mutable: false }); + decls.push({ kind: "varDecl", localId: id, init: call(fn, STRING), loc }); + const text: IrExpr = { kind: "varRef", localId: id, type: STRING, loc }; + return { + kind: "ternary", + cond: { + kind: "strEq", + negated: false, + left: text, + right: { kind: "strLit", value: "", type: STRING, loc }, + type: BOOL, + loc, + }, + then: { + kind: "unionWrap", + unionId: type.unionId, + tag: undefinedTag, + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type, + loc, + }, + else_: { kind: "unionWrap", unionId: type.unionId, tag: stringTag, value: text, type, loc }, + type, + loc, + }; + } + const fields = shape.fields.map((f) => { + const value = values[f.name]; + if (!value) throw new InternalCompilerError(`lowerer bug: X509Certificate field ${f.name}`); + return { name: f.name, value: value(f.type) }; + }); + return { + name, + params: [{ localId: "d.0", name: "d", type: dataT }], + returnType: t, + locals, + body: [...decls, { kind: "return", value: { kind: "recordLit", fields, type: t, loc }, loc }], + loc, + }; +} + /** Whether a JavaScript class's own binding is the target of - * `Object.defineProperty`/`defineProperties` in its file - * (`Object.defineProperty(Definition, "name", { value })`): its constructor + * `Object.defineProperty`/`defineProperties` or `Reflect.deleteProperty` in + * its file (`Object.defineProperty(Definition, "name", { value })`, + * `Reflect.deleteProperty(Response, "getResponseState")`): its constructor * then needs a live own-property table. */ function definedThroughReflection(lowerer: Lowerer, decl: ts.ClassLikeDeclaration): boolean { const name = decl.name; const sf = ts.getSourceFileOfNode(decl); - if (name === undefined || !sf.text.includes("definePropert")) return false; + if ( + name === undefined || + (!sf.text.includes("definePropert") && !sf.text.includes("deleteProperty")) + ) + return false; const symbol = lowerer.checker.getSymbolAtLocation(name); if (symbol === undefined) return false; let found = false; @@ -11300,9 +11846,11 @@ function definedThroughReflection(lowerer: Lowerer, decl: ts.ClassLikeDeclaratio if ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && - (node.expression.name.text === "defineProperty" || + (((node.expression.name.text === "defineProperty" || node.expression.name.text === "defineProperties") && - lowerer.stdlibGlobalMember(node.expression, "Object") !== null + lowerer.stdlibGlobalMember(node.expression, "Object") !== null) || + (node.expression.name.text === "deleteProperty" && + lowerer.stdlibGlobalMember(node.expression, "Reflect") !== null)) ) { const target = node.arguments.nodes[0]; if ( diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 033f482d1..20d05f3b7 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -975,6 +975,18 @@ function assertionDestinationHoldsNullish( return constituentTypes(contextual).some((type) => (type.flags & nullish) !== 0); } +/** Whether `declaration` is TextDecoder's `decode` method. */ +function isTextDecoderDecode(declaration: ts.Node): boolean { + if (!ts.isMethodSignatureDeclaration(declaration) && !ts.isMethodDeclaration(declaration)) + return false; + if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "decode") return false; + const owner = declaration.parent; + return ( + (ts.isInterfaceDeclaration(owner) || ts.isClassDeclaration(owner)) && + owner.name?.text === "TextDecoder" + ); +} + /** Whether `argument` of `call` binds an optional parameter of a standard * library declaration (`decoder.decode(input?)`). The undefined such a * parameter's type holds comes from its optionality, and the builtin @@ -989,6 +1001,9 @@ function optionalStdlibParameter( const declaration = lowerer.checker.getResolvedSignature(call)?.declaration; if (declaration === undefined || !lowerer.isStdlibFile(ts.getSourceFileOfNode(declaration))) return false; + // TextDecoder.decode reads an undefined input as no bytes, as Node does + // (builtins/text-codecs.ts lowerDecodeInput). + if (isTextDecoderDecode(declaration)) return false; const index = call.arguments?.nodes.indexOf(argument) ?? -1; const parameter = declaration.parameters?.nodes[index]; return ( @@ -2339,6 +2354,7 @@ export function typeofAnswer(arm: IrType): string | null { case "set": case "generator": case "regex": + case "date": case "bytes": case "url": case "searchParams": diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index c1cca9c64..bd9b5f55d 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -1,5 +1,6 @@ import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js"; import { declareTypedArguments } from "./typed-arguments.js"; +import { declareArgumentCount } from "./argument-counts.js"; import { dynUndefinedExpr, varRef } from "../../ir/build.js"; import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; import { bindingNeverReassigned } from "./binding-analysis.js"; @@ -534,12 +535,18 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I const diagsBefore = lowerer.diags.length; lowerer.fnStack.push(ctx); try { + // The count a call through a value stored is taken (and its slot + // cleared) before the parameters bind: a destructuring default that + // throws must not leave the slot set for the next direct call. + const argumentCount: IrStmt[] = []; + declareArgumentCount(lowerer, decl, argumentCount); const receiver = decl.parameters.nodes.some(isThisParameter) || isJsSourceFile(ts.getSourceFileOfNode(decl)) ? declareCallReceiver(lowerer, decl, sig.generator !== undefined) : null; const { params, prologue } = lowerer.declareParams(decl.parameters.nodes, sig.params); if (receiver) prologue.unshift(receiver); + prologue.unshift(...argumentCount); declareFunctionArguments( lowerer, decl, diff --git a/packages/compiler/src/frontend/lowering/lower-generators.ts b/packages/compiler/src/frontend/lowering/lower-generators.ts index f837cc0af..ccfa4ad39 100644 --- a/packages/compiler/src/frontend/lowering/lower-generators.ts +++ b/packages/compiler/src/frontend/lowering/lower-generators.ts @@ -183,13 +183,9 @@ export function lowerGenMethodCall( ); } arg = lowerer.lowerExpr(argNode); - if (arg.type.kind === "date") { - lowerer.unsupported( - "SC1090", - argNode, - ".throw() with a Date value (the exception channel cannot preserve Date's object kind; throw an Error or primitive instead)", - ); - } + // A Date throws as its checked-dynamic handle, the same Date object + // (the throw statement's rule). + if (arg.type.kind === "date") arg = lowerer.coerceToExpected(arg, DYN); if (arg.type.kind === "void" || arg.type.kind === "caught" || isUnitType(arg.type)) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 48bb0f9e6..f1ccaebc8 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -1,5 +1,6 @@ import { keyedHelperName } from "./stable-names.js"; import { perKeyOrder } from "./key-order-witness.js"; +import { classPrototypeView } from "./class-prototypes.js"; import { dynUndefinedExpr, boolLit, @@ -3527,8 +3528,14 @@ function lowerUtilCallableCall( member === "inherits" && expr.arguments.nodes[1] !== undefined ? builtinClassKindOf(lowerer, expr.arguments.nodes[1]) : null; + // A superCtor class whose prototype has a complete view (a module-level + // JavaScript class named directly) passes that view to the runtime. + const superView = + member === "inherits" && builtinBase === null && expr.arguments.nodes[1] !== undefined + ? classPrototypeView(lowerer, expr.arguments.nodes[1], loc) + : null; for (const [index, arg] of expr.arguments.nodes.slice(0, classArgs).entries()) { - if (index === 1 && builtinBase !== null) continue; + if (index === 1 && (builtinBase !== null || superView !== null)) continue; if (lowerer.mapTypeOf(lowerer.typeOf(arg))?.kind === "classval") lowerer.noLowering( `util.${member} over a class constructor`, @@ -3560,7 +3567,9 @@ function lowerUtilCallableCall( for (const arg of expr.arguments.nodes.slice(arity)) stmts.push({ kind: "exprStmt", expr: lowerer.lowerExpr(arg), loc }); const type = fn === "util.inherits" ? VOID : DYN; - const result: IrExpr = { kind: "libCall", fn, args, type, loc }; + const result: IrExpr = superView + ? { kind: "libCall", fn: "util.inheritsPrototype", args: [...args, superView], type, loc } + : { kind: "libCall", fn, args, type, loc }; return { kind: "seqExpr", stmts, result, type: result.type, loc }; } diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 19f7243ed..4fe908ab4 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -350,24 +350,28 @@ function dynamicImportProgramTargetOf( } /** Every distinct program module a file's `import("literal")` sites name - * (source order, deduped). */ + * (source order, deduped). The parser lists the specifier of every such call + * in the file's module references, in source order after the static imports, + * so the file's nodes need no walk. */ function dynamicProgramImportsOf(program: ts.Program, sf: ts.SourceFile): ts.SourceFile[] { const out: ts.SourceFile[] = []; const seen = new Set(); - ts.walkPreorder(sf, (node) => { + for (const specifier of sf.imports) { + const call = specifier.parent; if ( - ts.isCallExpression(node) && - node.expression.kind === ts.SyntaxKind.ImportKeyword && - node.arguments.nodes[0] !== undefined && - ts.isStringLiteralLike(node.arguments.nodes[0]) - ) { - const dep = dynamicImportProgramTargetOf(program, sf, node.arguments.nodes[0].text); - if (dep && !seen.has(dep)) { - seen.add(dep); - out.push(dep); - } + call === undefined || + !ts.isCallExpression(call) || + call.expression.kind !== ts.SyntaxKind.ImportKeyword || + call.arguments.nodes[0] !== specifier || + !ts.isStringLiteralLike(specifier) + ) + continue; + const dep = dynamicImportProgramTargetOf(program, sf, specifier.text); + if (dep && !seen.has(dep)) { + seen.add(dep); + out.push(dep); } - }); + } return out; } diff --git a/packages/compiler/src/frontend/lowering/lower-namespaces.ts b/packages/compiler/src/frontend/lowering/lower-namespaces.ts index ea844ab1e..13d1c29fc 100644 --- a/packages/compiler/src/frontend/lowering/lower-namespaces.ts +++ b/packages/compiler/src/frontend/lowering/lower-namespaces.ts @@ -42,7 +42,7 @@ import { isPackageShimFile, isShimIntrinsicName, packageShimFor } from "../packa import type { Lowerer } from "./lowerer.js"; import { boundIdentifiersOf } from "./lowerer.js"; import type { FileParts } from "./lower-modules.js"; -import { locOf, resolveImport } from "../program.js"; +import { isCjsJsFile, isJsSourceFile, locOf, resolveImport } from "../program.js"; import { F64, type IrExpr, type IrStmt, type IrType, STRING } from "../../ir/ir.js"; import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; @@ -311,6 +311,30 @@ function moduleNsSourceFileOf(lowerer: Lowerer, e: ts.Expression): ts.SourceFile return null; } +/** The CommonJS module a JavaScript namespace import binding names + * (`import * as ns from "./m.cjs"`, `ns`), or null. */ +export function cjsNamespaceImportDep(lowerer: Lowerer, e: ts.Expression): ts.SourceFile | null { + if (!ts.isIdentifier(e)) return null; + const decl = lowerer.checker.getSymbolAtLocation(e)?.declarations.find(ts.isNamespaceImport); + const stmt = decl?.parent?.parent; + if ( + decl === undefined || + stmt === undefined || + !ts.isImportDeclaration(stmt) || + !ts.isStringLiteral(stmt.moduleSpecifier) + ) + return null; + const from = ts.getSourceFileOfNode(decl); + if (!isJsSourceFile(from)) return null; + const dep = resolveImport(lowerer.program, from, stmt.moduleSpecifier.text); + return dep !== null && + !dep.isDeclarationFile && + lowerer.fileTag.has(dep) && + isCjsJsFile(dep, lowerer.program) + ? dep + : null; +} + /** The member identifier of a qualified namespace reference, when `access` * reads a member declared in a lowered (or type-only) namespace block — * or an EXPORT of a program module read through its namespace-import @@ -330,6 +354,21 @@ export function nsMemberIdentOf( if (!ts.isIdentifier(access.name)) return null; const memberSym = lowerer.checker.getSymbolAtLocation(access.name); if (!memberSym) return null; + // A CommonJS module's namespace has the names Node's lexer detects, with + // the values they had when the module finished evaluating; the member + // then reads like the named import `import { name }`. `default` is + // module.exports itself. + const cjs = cjsNamespaceImportDep(lowerer, access.expression); + if (cjs !== null) { + const { names, assignedLater } = lowerer.cjsNamespaceOf(cjs); + const name = access.name.text; + return name !== "default" && + names.has(name) && + assignedLater !== "all" && + !assignedLater.has(name) + ? access.name + : null; + } if (nsBlockKindOfSymbol(lowerer, memberSym) !== null) return access.name; if (moduleNsSourceFileOf(lowerer, access.expression) !== null) return access.name; return null; @@ -857,6 +896,15 @@ export function nsWritableTarget( const nsMember = nsMemberIdentOf(lowerer, access); if (!nsMember) return null; const memberSym = lowerer.checker.getSymbolAtLocation(nsMember); + // An export read through a module namespace object (`import * as ns`): + // the object is read-only, and Node's assignment throws a TypeError. + if (memberSym && nsBlockKindOfSymbol(lowerer, memberSym) === null) { + lowerer.unsupported( + "SC1090", + access, + `assignment to the module namespace member '${nsMember.text}' (module namespace objects are read-only: Node throws a TypeError)`, + ); + } if (memberSym) fenceEarlyNsMemberRef(lowerer, access, memberSym); const resolved = lowerer.resolveValueSymbol(nsMember); const g = resolved ? lowerer.globalsBySymbol.get(resolved) : undefined; diff --git a/packages/compiler/src/frontend/lowering/lower-node-module.ts b/packages/compiler/src/frontend/lowering/lower-node-module.ts index 37675777c..b383d2aff 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-module.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-module.ts @@ -586,6 +586,8 @@ export function fenceNodeModuleMutation( `${action} through require.cache`, target, "a compiled binary has a fixed module graph; cache lookup and Object.keys(require.cache) are read-only", + undefined, + "loader-hook", ); } if ( diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index cb79accea..7359b56a8 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -1,4 +1,5 @@ import { keyedHelperName } from "./stable-names.js"; +import { lowerTlsCertificateCall } from "./builtins/x509.js"; import { dynUndefinedExpr, nodeThrowExpr, @@ -1765,12 +1766,82 @@ function lowerNetServerMethodCall( ); return { kind: "libCall", fn: "net.serverCloseCb", args: [receiver, cb], type: VOID, loc }; } + if (name === "addContext" || name === "setSecureContext") { + // tls.Server's SNI table and default context (scr_tls.c): addContext + // takes a SecureContext or createSecureContext's options; the runtime + // throws Node's errors and the fence on a non-TLS server. + requireStatementPosition(lowerer, call, `server.${name}(...)`); + const want = name === "addContext" ? 2 : 1; + if (args.length !== want || args.some(ts.isSpreadElement)) { + lowerer.noLowering( + `${name} with ${args.length} arguments`, + call, + name === "addContext" + ? "the form is addContext(servername, context)" + : "the form is setSecureContext(options)", + ); + } + const receiver = coerceToHandle(lowerer, access.expression, NETSERVER_T); + if (name === "setSecureContext") { + const options = lowerer.lowerExprExpecting(args[0]!, DYN); + return { + kind: "libCall", + fn: "tls.serverSetSecureContext", + args: [receiver, options], + type: VOID, + loc, + }; + } + const servername = lowerer.lowerExprExpecting(args[0]!, DYN); + const context = lowerer.lowerExpr(args[1]!); + if (context.type.kind === "secureCtx") { + return { + kind: "libCall", + fn: "tls.serverAddContextCtx", + args: [receiver, servername, context], + type: VOID, + loc, + }; + } + return { + kind: "libCall", + fn: "tls.serverAddContext", + args: [receiver, servername, lowerer.coerceInto(args[1]!, context, DYN)], + type: VOID, + loc, + }; + } if ((name === "on" || name === "once" || name === "addListener") && args.length === 2) { requireStatementPosition(lowerer, call, `server.${name}(...)`); const once = boolLit(name === "once", loc); const evT = lowerer.typeOf(args[0]!); const event = evT.isStringLiteralType() ? evT.value : null; const receiver = coerceToHandle(lowerer, access.expression, NETSERVER_T); + if (event === "tlsClientError") { + // A failed handshake on a tls or https server: (err, tlsSocket). + const listener = args[1]!; + const { cb } = lowerCallbackArgShared( + lowerer, + listener, + "tlsClientError listeners", + 2, + (p, i) => + i === 0 ? p.kind === "object" && p.className === "%Error" : p.kind === "netSocket", + "use (err, tlsSocket), (err) or ()", + { + dynTuple: [ERROR_T, NETSOCKET_T], + checkAllParams: true, + adapt: (c) => voidizedCallback(lowerer, c, locOf(listener)), + }, + ); + return { + kind: "libCall", + fn: "tls.serverOnClientError", + args: [receiver, cb, once], + type: VOID, + loc, + }; + } if (event === "connection" || event === "secureConnection") { // 'secureConnection' rides the same list: a TLS server's // 'connection' listeners are DEFERRED to handshake completion @@ -2246,8 +2317,8 @@ function lowerNetServerMethodCall( function socketEncodingReachable(lowerer: Lowerer): boolean { if (lowerer.socketEncodingReachable !== null) return lowerer.socketEncodingReachable; let reachable = false; - for (const file of lowerer.program.getSourceFiles()) { - if (file.isDeclarationFile || !file.text.includes("setEncoding")) continue; + for (const file of lowerer.program.getImplementationSourceFiles()) { + if (!file.text.includes("setEncoding")) continue; ts.walkPreorder(file, (node) => { if (reachable) return "skip"; if (!ts.isCallExpression(node)) return undefined; @@ -2278,8 +2349,8 @@ function socketEncodingReachable(lowerer: Lowerer): boolean { function unixListenReachable(lowerer: Lowerer): boolean { if (lowerer.unixListenReachable !== null) return lowerer.unixListenReachable; let reachable = false; - for (const file of lowerer.program.getSourceFiles()) { - if (file.isDeclarationFile || !file.text.includes("listen")) continue; + for (const file of lowerer.program.getImplementationSourceFiles()) { + if (!file.text.includes("listen")) continue; ts.walkPreorder(file, (node) => { if (reachable) return "skip"; if (!ts.isCallExpression(node) || node.arguments.nodes.length === 0) return undefined; @@ -2632,6 +2703,12 @@ function lowerNetSocketMethodCall( ? value : lowerer.coerceInto(call, value, declared); } + { + const certificate = lowerTlsCertificateCall(lowerer, call, name, () => + coerceToHandle(lowerer, access.expression, NETSOCKET_T), + ); + if (certificate) return certificate; + } if (name === "isSessionReused" && args.length === 0) { // TLSSocket.isSessionReused(): the client never offers a cached // session (no session resumption, a documented TLS divergence), so @@ -4941,19 +5018,12 @@ function lowerTlsServerOptions( } if (k !== "cert" && k !== "key") { const init = ts.isPropertyAssignment(prop) ? prop.initializer : null; - if ( - (k === "ca" || k === "rejectUnauthorized") && - (init === null || sideEffectFreeOptionValue(init)) - ) { - // Both are inert without requestCert (which fences): the server's - // `ca` verifies CLIENT certificates and rejectUnauthorized only - // gates that verification — Node-observably droppable, matching + if (k === "rejectUnauthorized" && (init === null || sideEffectFreeOptionValue(init))) { + // Without requestCert (a literal with requestCert or ca rides the + // runtime walk) rejectUnauthorized gates nothing — droppable, like // the runtime walk's split (effectful values keep the fence). continue; } - if (k === "requestCert" && (init === null || init.kind === ts.SyntaxKind.FalseKeyword)) { - continue; // Node's false default, spelled out - } fenceOrDropOptionKey( lowerer, prop, @@ -4964,8 +5034,6 @@ function lowerTlsServerOptions( { SNICallback: "SNICallback lowers on tls.createServer and http2.createSecureServer({ allowHTTP1: true, ... }) — serve one cert/key pair here", - requestCert: - "client-certificate handshakes are not modeled (SEMANTICS.md divergence 55) — requestCert: false is the lowered value", }, ); continue; // an undocumented key, dropped like Node drops it @@ -5063,12 +5131,19 @@ function tlsLiteralNeedsRuntimeWalk( ); } +/** The client-certificate options (requestCert, and ca, which also + * completes the server's own chain): the runtime walk implements them for + * tls.createServer and https.createServer, and the literal cert/key + * lowering does not. */ +const TLS_CLIENT_AUTH_OPTIONS: ReadonlySet = new Set(["requestCert", "ca"]); + /** tls.createServer options the runtime walk implements and the literal * cert/key lowering does not. */ const TLS_SERVER_RUNTIME_OPTIONS: ReadonlySet = new Set([ "ALPNProtocols", "minVersion", "maxVersion", + ...TLS_CLIENT_AUTH_OPTIONS, ]); function lowerTlsServerOptionsOrDyn( @@ -5092,7 +5167,7 @@ function lowerTlsServerOptionsOrDyn( lowerer.noLowering( `${what} with SNICallback and options the runtime walk reads`, sniOption, - "SNICallback lowers with cert and key; ALPNProtocols, minVersion, maxVersion and the validated options are not combined with it yet", + "SNICallback lowers with cert and key; ALPNProtocols, minVersion, maxVersion, requestCert, ca and the validated options are not combined with it yet", ); } } @@ -5124,14 +5199,14 @@ function lowerTlsServerOptionsOrDyn( * connect(options[, cb]) and connect(port[, host][, options][, cb]) with * a RUNTIME options record (the checked-dynamic JS lane's shape — a dyn * value, or a record that converts): the runtime walk implements - * port/host/socket/rejectUnauthorized/ca/servername/ALPNProtocols/minVersion/maxVersion/timeout and throws the catchable + * port/host/socket/rejectUnauthorized/ca/cert/key/servername/ALPNProtocols/minVersion/maxVersion/timeout and throws the catchable * fence for other documented members (the divergence-66 stance). The * callback (and 'secureConnect'/'connect' listeners) fires * post-handshake — Node's secureConnect timing. */ function lowerTlsConnectCall(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr { const args = expr.arguments.nodes; const FORMS_HINT = - "the supported forms are connect(options[, cb]) and connect(port[, host][, options][, cb]) — options implement port/host/socket/rejectUnauthorized/ca/servername/ALPNProtocols/minVersion/maxVersion/timeout"; + "the supported forms are connect(options[, cb]) and connect(port[, host][, options][, cb]) — options implement port/host/socket/rejectUnauthorized/ca/cert/key/servername/ALPNProtocols/minVersion/maxVersion/timeout"; if (args.length < 1 || args.length > 4 || args.some(ts.isSpreadElement)) { lowerer.noLowering(`tls.connect with ${args.length} arguments`, expr, FORMS_HINT); } @@ -5368,7 +5443,13 @@ function lowerTlsModuleCall( "the supported form is createSecureContext({ cert, key })", ); } - const opts = lowerTlsServerOptionsOrDyn(lowerer, args[0]!, "tls.createSecureContext"); + const opts = lowerTlsServerOptionsOrDyn( + lowerer, + args[0]!, + "tls.createSecureContext", + false, + TLS_CLIENT_AUTH_OPTIONS, + ); if (opts.dyn !== undefined) { return { kind: "libCall", @@ -5625,7 +5706,13 @@ function lowerHttpsModuleCall( "the supported form is createServer({ cert, key }, (req, res) => ...)", ); } - const opts = lowerTlsServerOptionsOrDyn(lowerer, args[0]!, "https.createServer", true); + const opts = lowerTlsServerOptionsOrDyn( + lowerer, + args[0]!, + "https.createServer", + true, + TLS_CLIENT_AUTH_OPTIONS, + ); if (args.length === 1) { // The handler-less form: 'request' listeners arrive later via // server.on("request", ...) — the createSecureServer emission's @@ -6281,7 +6368,9 @@ function runtimeRequestOptions( return ( (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) && - prop.name.text === "socketPath" + (prop.name.text === "socketPath" || + // a client certificate: the runtime request walk reads cert and key + (secureish && (prop.name.text === "cert" || prop.name.text === "key"))) ); }); if (!needsRuntime) return null; diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 3b5be4d23..fc0fd338f 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -2222,6 +2222,8 @@ export function lowerDynamicImportCall(lowerer: Lowerer, call: ts.CallExpression call, "dynamic import() of computed specifiers (the module graph resolves at " + "build time — the specifier must be a string literal)", + undefined, + "dynamic-import", ); } if (call.arguments.nodes.length !== 1) { @@ -2572,35 +2574,8 @@ export function lowerMathProperty( const member = lowerer.stdlibGlobalMember(expr, "Math"); if (member === null) return null; const loc = locOf(expr); - const staticValue = own(STATIC_MATH_PROPS, member); - if (staticValue !== undefined) { - return { kind: "numLit", value: staticValue, type: F64, loc }; - } - const native = own(STATIC_MATH_FNS, member); - if (native && (member === "min" || member === "max" || member === "hypot")) - return lowerer.lowerNativeCallableValue( - { - fn: member === "min" ? "math.minArr" : member === "max" ? "math.maxArr" : "math.hypotArr", - params: [arrayOf(F64)], - result: F64, - valueParams: [{ mode: "rest", type: F64 }], - }, - `Math.${member}`, - loc, - ); - if (native && member !== "min" && member !== "max" && member !== "hypot") { - const params = Array.from({ length: native.arity }, () => F64); - return lowerer.lowerNativeCallableValue( - { - fn: native.fn, - params, - result: F64, - valueParams: params.map((type) => ({ mode: "required", type })), - }, - `Math.${member}`, - loc, - ); - } + const value = mathMemberValue(lowerer, member, loc); + if (value !== null) return value; const propType = own(STDLIB_SURFACE.math.props, member); if (propType !== undefined) { lowerer.refuseUnsupportedApi(`'Math.${member}'`, expr); @@ -2614,6 +2589,40 @@ export function lowerMathProperty( return null; // declared-but-untabled members fall through generically } +/** The value of the Math member `member`: a constant, or the interned + * native function value of a Math function. Null for other members. */ +export function mathMemberValue(lowerer: Lowerer, member: string, loc: SrcLoc): IrExpr | null { + const staticValue = own(STATIC_MATH_PROPS, member); + if (staticValue !== undefined) { + return { kind: "numLit", value: staticValue, type: F64, loc }; + } + const native = own(STATIC_MATH_FNS, member); + if (!native) return null; + if (member === "min" || member === "max" || member === "hypot") + return lowerer.lowerNativeCallableValue( + { + fn: member === "min" ? "math.minArr" : member === "max" ? "math.maxArr" : "math.hypotArr", + params: [arrayOf(F64)], + result: F64, + valueParams: [{ mode: "rest", type: F64 }], + valueLength: 2, + }, + `Math.${member}`, + loc, + ); + const params = Array.from({ length: native.arity }, () => F64); + return lowerer.lowerNativeCallableValue( + { + fn: native.fn, + params, + result: F64, + valueParams: params.map((type) => ({ mode: "required", type })), + }, + `Math.${member}`, + loc, + ); +} + export function npmPackageOf(lowerer: Lowerer, type: ts.Type): string | null { const pkg = lowerer.npmPackageOfSymbol(type.getAliasSymbol() ?? type.getSymbol()); if (pkg) return pkg; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 7feb2d351..57cdc10ae 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -33,6 +33,7 @@ import { InternalCompilerError } from "../../errors.js"; * explicit resource management, and blocked-binding poisoning. */ import * as ts from "../ts7/adapter.js"; import { lowerOptionalIndex } from "./undefined-index.js"; +import { builtinNamespaceValue } from "./builtin-namespaces.js"; import { processDataMemberValue, programReadsExitCode } from "./builtins/process.js"; import { requireInitFlagStmt } from "./require-initialization.js"; import { isJsonRequireDecl } from "./json-require.js"; @@ -168,6 +169,7 @@ import { import { bindingNeverReassigned, deadUnmappableBinding, + fileWritesBinding, nullishExprUnitOf, nullishGenericBindingUnitOf, } from "./binding-analysis.js"; @@ -181,7 +183,12 @@ import { classStaticDataFor } from "./class-static-data.js"; import { objectFactorySignature } from "./object-factory-new.js"; import { isClassCallback, lowerClassCallbackAssign } from "./class-callbacks.js"; import { classForInHasKey, classPropertiesHelper } from "./class-dynamic-dispatch.js"; -import { genericIfaceBindingKeepsClass, superConditionLead } from "./lower-classes.js"; +import { + genericIfaceBindingKeepsClass, + lowerClassStaticRead, + staticReceiverClass, + superConditionLead, +} from "./lower-classes.js"; import { lowerStreamUnderscoreAssign, streamClassAliasDecl, @@ -260,6 +267,7 @@ import { constituentTypes } from "../ts7/checker.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; import { fenceNodeModuleMutation } from "./lower-node-module.js"; import { fencePerfEntryKeyWalk } from "./builtins/performance.js"; +import { fenceX509KeyWalk } from "./builtins/x509.js"; import { classGeneratorIterable } from "./class-iterables.js"; import { iterableUnionOf, lowerForOfIterable } from "./iterable-values.js"; @@ -1624,8 +1632,47 @@ const THROWN_AS_DYN: ReadonlySet = new Set([ "bytes", "func", "regex", + "date", ]); +/** A `return` at the top level of a CommonJS module (preflight admits it + * there only): it ends the module's evaluation, and its value is + * evaluated and discarded, as Node's module wrapper does. Top-level + * bindings and export assignments after it would be left uninitialized + * where Node leaves them in their temporal dead zone or absent, so a + * module with any keeps a refusal. Null for a return inside a function. */ +function lowerModuleLevelReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrStmt | null { + let top: ts.Node = stmt; + while (top.parent !== undefined && !ts.isSourceFile(top.parent)) { + if (ts.isFunctionLike(top.parent) || ts.isClassStaticBlockDeclaration(top.parent)) return null; + top = top.parent; + } + const sf = top.parent; + if (sf === undefined || !ts.isSourceFile(sf)) return null; + const stmts = sf.statements.nodes; + const later = stmts.slice(stmts.indexOf(top as ts.Statement) + 1); + if ( + later.some( + (s) => + ts.isVariableStatement(s) || ts.isClassDeclaration(s) || cjsExportAssignmentOf(s) !== null, + ) + ) { + lowerer.unsupported( + "SC1090", + stmt, + "a top-level return in a CommonJS module that declares variables or classes, or assigns exports, after it", + ); + } + const loc = locOf(stmt); + const ret: IrStmt = { kind: "return", value: null, loc }; + if (!stmt.expression) return ret; + return { + kind: "block", + body: [{ kind: "exprStmt", expr: lowerer.lowerExpr(stmt.expression), loc }, ret], + loc, + }; +} + export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | null { if (ownPrototypeResetStatement(lowerer, stmt)) return { kind: "block", body: [], loc: locOf(stmt) }; @@ -1735,6 +1782,8 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt if (ts.isForOfStatement(stmt)) return lowerer.lowerForOf(stmt); if (ts.isLabeledStatement(stmt)) return lowerLabeled(lowerer, stmt); if (ts.isReturnStatement(stmt)) { + const moduleReturn = lowerModuleLevelReturn(lowerer, stmt); + if (moduleReturn) return moduleReturn; // Implicit-any instance RETURN INFERENCE (function-returns.ts // resolveInferredReturn): the value lowers BARE and the statement // records itself — the post-pass settles the instance's return type @@ -1828,9 +1877,7 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt return { kind: "rethrow", localId: caughtLocal.id, loc: locOf(stmt) }; } // Exception-representable values move into the runtime's exception - // cell. `throw f()` of a void call has no value; Date has numeric - // storage internally, but cannot preserve its object kind through the - // untyped catch boundary. Reject both rather than changing JS identity. + // cell. `throw f()` of a void call has no value. let value = lowerer.lowerExpr(stmt.expression); if (value.type.kind === "void") lowerer.badType(stmt.expression, lowerer.typeOf(stmt.expression)); @@ -1842,19 +1889,12 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt } else if (value.type.kind === "record" && lowerer.dynConvertible(value.type)) { value = { kind: "dynFrom", value, liveRef: true, type: DYN, loc: locOf(stmt.expression) }; } else if (THROWN_AS_DYN.has(value.type.kind) && lowerer.dynConvertible(value.type)) { - // Arrays, Maps, Sets, typed arrays, Buffers, functions, regexes and - // unions over them keep their identity and contents the same way: - // the catch binding, `typeof`, `instanceof`, `Array.isArray`, - // inspection and typed views read the thrown object. + // Arrays, Maps, Sets, typed arrays, Buffers, functions, regexes, + // Dates and unions over them keep their identity and contents the + // same way: the catch binding, `typeof`, `instanceof`, + // `Array.isArray`, inspection and typed views read the thrown object. value = lowerer.coerceInto(stmt.expression, value, DYN); } - if (value.type.kind === "date") { - lowerer.unsupported( - "SC1090", - stmt.expression, - "throwing Date values (catch bindings cannot preserve Date's object kind; throw an Error or primitive instead)", - ); - } // A dyn throw rides the REF cell arm (scr_throw_ref over the checked-dynamic tree // node — identity preserved through catch bindings, caughtToDyn // passes it back by reference). The JS-lane traced-call shape: @@ -2722,7 +2762,7 @@ export function lowerBindingPattern( const itRef = (): IrExpr => ({ kind: "varRef", localId: it.id, type: citPat.iterT, loc }); for (const el of pattern.elements.nodes) { const stepLoc = locOf(el); - const step = lowerer.classIteratorNextCall(citPat, itRef(), stepLoc); + const step = lowerer.classIteratorNextCall(citPat, itRef(), null, stepLoc); if (ts.isOmittedExpression(el) || el.name === undefined) { out.push({ kind: "exprStmt", expr: step, loc: stepLoc }); continue; @@ -3053,6 +3093,18 @@ export function lowerBindingPattern( ); return; } + // A class in a JavaScript file, destructured through its own name + // (`const { ANY, parse } = Comparator`): each element is the static + // read `Comparator.ANY` — the static getter, field or method as Node + // reads it, with the element's default applied when the read answers + // undefined. Other class-valued sources keep the refusal below. + if ( + srcType.kind === "classval" && + ts.isObjectBindingPattern(pattern) && + isJsSourceFile(ts.getSourceFileOfNode(pattern)) && + lowerClassValuePattern(lowerer, pattern, isLet, out) + ) + return; if (srcType.kind === "dyn") { // A runtime computed first key evaluates after RequireObjectCoercible: // a nullish source throws before the key's side effects run. Later @@ -4280,7 +4332,8 @@ function stdlibGlobalTokenDestructure( value = canonical === "crypto" ? { kind: "libCall", fn: "webcrypto.object", args: [], type: DYN, loc: locOf(el) } - : builtinGlobalValue(canonical, locOf(el), true); + : (builtinNamespaceValue(lowerer, canonical, locOf(el)) ?? + builtinGlobalValue(canonical, locOf(el), true)); } else if (globalName === "console" && !CONSOLE_CALL_MEMBERS.has(propName)) { token = `[builtin console.${propName}]`; } else if (TOKEN_OPAQUE_GLOBALS.has(globalName)) { @@ -4364,6 +4417,60 @@ function stdlibGlobalTokenDestructure( return out; } +/** `const { ANY, parse } = Comparator` in a JavaScript file, where the + * declaration's initializer names the class exactly (staticReceiverClass): + * one static read per element, left to right. False, with nothing + * emitted, for other sources (a parameter or nested pattern over a class + * value), which keep the class-value refusal. */ +function lowerClassValuePattern( + lowerer: Lowerer, + pattern: ts.ObjectBindingPattern, + isLet: boolean, + out: IrStmt[], +): boolean { + const decl = pattern.parent; + if (!ts.isVariableDeclaration(decl) || decl.initializer === undefined) return false; + let receiver: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(receiver)) receiver = receiver.expression; + const info = staticReceiverClass(lowerer, receiver); + if (info === null) return false; + for (const el of pattern.elements.nodes) { + if (el.name === undefined) continue; + if (el.dotDotDotToken) + lowerer.unsupported( + "SC1031", + el, + "rest bindings over class values (the rest object copies the class's own enumerable statics)", + ); + const prop = el.propertyName ?? el.name; + const name = + ts.isIdentifier(prop) || + ts.isComputedPropertyName(prop) || + ts.isStringLiteralLike(prop) || + ts.isNumericLiteral(prop) + ? patternKeyNameOf(lowerer, prop as ts.PropertyName) + : null; + if (name === null) + lowerer.unsupported( + "SC1031", + el, + "destructuring with computed keys that do not fold to one property name", + ); + if (name === "prototype" || name.startsWith("#")) + lowerer.unsupported("SC1031", el, `destructuring '${name}' from a class value`); + let value = lowerClassStaticRead(lowerer, info, name, receiver, receiver); + if (value === null) + lowerer.unsupported( + "SC1031", + el, + `destructuring the property '${name}' the class '${info.def.jsName ?? info.def.name}' does not declare as a static`, + ); + if (el.initializer) value = applyBindingDefault(lowerer, el, value); + lowerer.bindPatternTarget(el.name, value, isLet, out); + } + return true; +} + /** `x = dflt` on a pattern position whose source value always EXISTS * (tuple positions, record fields): JS evaluates the default exactly * when the value is undefined — the undefined-arm ternary, lazy, so @@ -5458,7 +5565,12 @@ export function lowerVarDecl( if (isTimeValueDateDeclaration(lowerer, decl)) { const loc = locOf(decl); const date = lowerer.lowerExprExpecting(decl.initializer!, DATE_T); - const local = lowerer.declareLocal(decl.name, decl.name.text, F64, isLet); + const local = lowerer.declareLocal( + decl.name, + decl.name.text, + F64, + isLet || lowerer.timeValueDateWrites.has(decl), + ); lowerer.timeValueDateLocals.add(local); const init: IrExpr = { kind: "libCall", fn: "date.getTime", args: [date], type: F64, loc }; return { kind: "varDecl", localId: local.id, init, loc }; @@ -7279,6 +7391,66 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt ); } +/** True when the `var`/`let` binding `nameNode` names cannot change after + * the top-level CommonJS export statement `exportNode` that exports it by + * reference, so importers read the value Node copies at the export: its + * module's top level declares it once, with an initializer that runs + * before the export, and nothing assigns it. The initializer runs earlier + * in the same module, or in a module the exporting module required before + * the export (`var hljs = require('./core'); module.exports = hljs;` over + * highlight.js's `var highlight = HLJS({}); module.exports = highlight;`). */ +function unchangedAfterExport( + lowerer: Lowerer, + nameNode: ts.Identifier, + exportNode: ts.Node, +): boolean { + const topLevelDeclaration = (symbol: ts.Symbol): ts.VariableDeclaration | null => { + const decl = symbol.declarations.length === 1 ? symbol.declarations[0]! : undefined; + return decl !== undefined && + ts.isVariableDeclaration(decl) && + ts.isIdentifier(decl.name) && + decl.initializer !== undefined && + ts.isVariableDeclarationList(decl.parent) && + ts.isVariableStatement(decl.parent.parent) && + ts.isSourceFile(decl.parent.parent.parent) + ? decl + : null; + }; + const exportFile = ts.getSourceFileOfNode(exportNode); + let exportStatement: ts.Node = exportNode; + for (let parent = exportNode.parent; parent !== undefined && !ts.isSourceFile(parent);) { + if (ts.isFunctionLike(parent) || ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) + return false; + exportStatement = parent; + parent = parent.parent; + } + const runsBeforeExport = (decl: ts.VariableDeclaration): boolean => { + const statement = decl.parent?.parent; + return statement?.parent === exportFile && statement.end <= exportStatement.pos; + }; + const sym = lowerer.resolveValueSymbol(nameNode); + const decl = sym ? topLevelDeclaration(sym) : null; + if (!sym || !decl || !bindingNeverReassigned(lowerer, sym, decl)) return false; + if (ts.getSourceFileOfNode(decl) === exportFile) return runsBeforeExport(decl); + // A binding of another module: the exporting module's own binding + // `nameNode` names holds that module's require, evaluated before the + // export, and this file does not assign the binding either. + const local = lowerer.checker.getSymbolAtLocation(nameNode); + const localDecl = local ? topLevelDeclaration(local) : null; + if (!localDecl || !runsBeforeExport(localDecl)) return false; + let init: ts.Expression = localDecl.initializer!; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if ( + !ts.isCallExpression(init) || + !ts.isIdentifier(init.expression) || + init.expression.text !== "require" || + init.arguments.nodes.length !== 1 || + !ts.isStringLiteralLike(init.arguments.nodes[0]!) + ) + return false; + return !fileWritesBinding(sym, exportFile, (ident) => lowerer.resolveValueSymbol(ident)); +} + /** A top-level CommonJS export statement (see cjsExportAssignmentOf): * `exports.f = ` and each expression-valued property of * `module.exports = { ... }` assign their pre-registered export globals @@ -7462,7 +7634,7 @@ function lowerCjsExportStatement( const fenceMutable = (nameNode: ts.Identifier): void => { const sym = lowerer.resolveValueSymbol(nameNode); const g = sym ? lowerer.globalsBySymbol.get(sym) : undefined; - if (g?.mutable && !(sym && settledBeforeExport(lowerer, sym, cjs.expr))) { + if (g?.mutable && !unchangedAfterExport(lowerer, nameNode, cjs.expr)) { lowerer.unsupported( "SC1090", nameNode, @@ -7622,7 +7794,7 @@ function lowerCjsSingleValueExport( } const g = lowerer.globalsBySymbol.get(sym); if (g) { - if (g.mutable && !settledBeforeExport(lowerer, sym, rhs)) { + if (g.mutable && !unchangedAfterExport(lowerer, rhs, expr)) { lowerer.unsupported( "SC1090", rhs, @@ -10022,8 +10194,23 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { // iterator are awaited as the async-from-sync iterator awaits them, and // a value that is not iterable throws Node's TypeError. const headT = lowerer.mapTypeOf(lowerer.typeOf(stmt.expression)); + // A class instance whose [Symbol.asyncIterator]() (or + // [Symbol.iterator]()) returns an iterator object takes the same + // checked-dynamic protocol over the boxed instance. + const headClass = headT?.kind === "object" ? lowerer.classes.get(headT.className) : undefined; + const classAsync = + headClass !== undefined && lowerer.findMethodOn(headClass, "sym:asyncIterator") !== null; + // Without [Symbol.asyncIterator], the loop opens [Symbol.iterator]() and + // awaits its values, as the async-from-sync iterator does. + const classSync = + headClass !== undefined && + !classAsync && + lowerer.findMethodOn(headClass, "sym:iterator") !== null; + const classIterable = classAsync || classSync; const syncGenerator = - (headT?.kind === "generator" && headT.async !== true) || isJsSourceFile(stmt.getSourceFile()); + (headT?.kind === "generator" && headT.async !== true) || + classIterable || + isJsSourceFile(stmt.getSourceFile()); if (lowerer.ctx.isAsync && (syncGenerator || isUntypedIterable(lowerer, stmt.expression))) { const iterable = lowerer.lowerExpr(stmt.expression); if (iterable.type.kind === "generator" && iterable.type.async) @@ -10035,10 +10222,19 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { ); if (iterable.type.kind === "dyn") return lowerForOfDyn(lowerer, stmt, iterable, labels, true); if ( - (iterable.type.kind === "generator" || isJsSourceFile(stmt.getSourceFile())) && + (iterable.type.kind === "generator" || + classIterable || + isJsSourceFile(stmt.getSourceFile())) && lowerer.dynConvertible(iterable.type) ) - return lowerForOfDyn(lowerer, stmt, lowerer.coerceToExpected(iterable, DYN), labels, true); + return lowerForOfDyn( + lowerer, + stmt, + lowerer.coerceToExpected(iterable, DYN), + labels, + true, + classSync && iterable.type.kind === "object", + ); } // A NodeJS.ReadableStream iterates as the general Readable. requestGeneralStreamSurface(lowerer, stmt.expression); @@ -10286,17 +10482,30 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { ); // Class iterables ([Symbol.iterator]() + next() — classIteratorOf) // drive the same protocol with ordinary method calls. - const cit = lowerer.classIteratorOf(iterable.type); + const cit = lowerer.classIteratorOf(iterable.type, { records: true, close: true }); if (cit) return lowerForOfClassIterator(lowerer, stmt, iterable, cit, labels); // A declared [Symbol.iterator] whose shape classIteratorOf refused // (an `any`-typed iterator or result, a declared return()/throw()): // name the protocol, not the class. const info = lowerer.classes.get(iterable.type.className); + // In JavaScript the inferred shape is often a union of result + // objects: the loop runs the iterator protocol over the boxed value. + if ( + info && + isJsSourceFile(ts.getSourceFileOfNode(stmt)) && + lowerer.findMethodOn(info, "sym:iterator") + ) + return lowerForOfDyn( + lowerer, + stmt, + lowerer.coerceInto(stmt.expression, iterable, DYN), + labels, + ); if (info && lowerer.findMethodOn(info, "sym:iterator")) { lowerer.unsupported( "SC1090", stmt.expression, - `for-of over this [Symbol.iterator] shape (the method must take no parameters and return an object whose zero-parameter next() returns a { value, done? } record with a boolean done; iterator classes declaring return()/throw() stay out — IteratorClose has no lowering)`, + `for-of over this [Symbol.iterator] shape (the method must take no parameters and return an object whose zero-parameter next() returns a { value, done? } record with a boolean done, and whose return(), if any, takes no parameters and returns an object)`, ); } } @@ -11213,11 +11422,13 @@ function lowerForOfMatchAll( * } } * * A result record with NO done field never terminates — exactly JS - * (undefined is falsy forever; Node loops forever too). No - * IteratorClose: classIteratorOf already refused iterator classes - * declaring return()/throw(), so a break exits with nothing to close. - * `done: true` results bind nothing (the loop breaks before the value - * read), matching JS's ignore-the-final-value rule. */ + * (undefined is falsy forever; Node loops forever too). An iterator with + * return() is closed when the loop leaves after a value arrived (break, + * return, or a throw from the body), as IteratorClose does: a done flag + * set around each step guards the call in a finally, and after a throw + * from the body an error from return() is dropped. A throw from next() + * does not close. `done: true` results bind nothing (the loop breaks + * before the value read), matching JS's ignore-the-final-value rule. */ function lowerForOfClassIterator( lowerer: Lowerer, stmt: ts.ForOfStatement, @@ -11259,7 +11470,22 @@ function lowerForOfClassIterator( const r = lowerer.declareHiddenLocal("%cir", cit.resultT); const recvRef: IrExpr = { kind: "varRef", localId: recv.id, type: iterable.type, loc }; const itRef = (): IrExpr => ({ kind: "varRef", localId: it.id, type: cit.iterT, loc }); + const nextRead = lowerer.classIteratorNextRead(cit, itRef(), loc); + const next = nextRead ? lowerer.declareHiddenLocal("%cin", nextRead.type) : null; const rRef = (): IrExpr => ({ kind: "varRef", localId: r.id, type: cit.resultT, loc }); + const done = cit.hasReturn ? lowerer.declareHiddenLocal("%cid", BOOL) : null; + if (done) done.mutable = true; + const setDone = (value: boolean): IrStmt[] => + done + ? [ + { + kind: "assign", + localId: done.id, + value: { kind: "boolLit", value, type: BOOL, loc }, + loc, + }, + ] + : []; const varTarget = exprTarget ?? (decl ? forOfVarTarget(lowerer, decl) : null); const x = varTarget ? lowerer.declareHiddenLocal("%vof", cit.valueT) @@ -11272,10 +11498,16 @@ function lowerForOfClassIterator( const xRef: IrExpr = { kind: "varRef", localId: x.id, type: cit.valueT, loc }; const blame: ts.Node = decl?.name ?? exprTargetNode ?? stmt.initializer; const head: IrStmt[] = [ + ...setDone(true), { kind: "varDecl", localId: r.id, - init: lowerer.classIteratorNextCall(cit, itRef(), loc), + init: lowerer.classIteratorNextCall( + cit, + itRef(), + next ? varRef(next.id, next.type, loc) : null, + loc, + ), loc, }, ...(cit.hasDone @@ -11319,8 +11551,16 @@ function lowerForOfClassIterator( } satisfies IrStmt, ] : []), + ...setDone(false), ]; const body = lowerer.inCtl("loop", () => lowerer.lowerScopedBlock(stmt.statement), labels); + const loop: IrStmt = { + kind: "while", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + body: [...head, ...body], + ...(labels && { labels }), + loc, + }; return { kind: "block", body: [ @@ -11331,13 +11571,48 @@ function lowerForOfClassIterator( init: lowerer.classIteratorOpenCall(cit, recvRef, loc), loc, }, - { - kind: "while", - cond: { kind: "boolLit", value: true, type: BOOL, loc }, - body: [...head, ...body], - ...(labels && { labels }), - loc, - }, + ...(next && nextRead + ? [{ kind: "varDecl", localId: next.id, init: nextRead, loc } satisfies IrStmt] + : []), + ...(done + ? [ + { + kind: "varDecl", + localId: done.id, + init: { kind: "boolLit", value: true, type: BOOL, loc }, + loc, + } satisfies IrStmt, + { + kind: "tryCatch", + tryBody: [loop], + catchBody: null, + catchLocalId: null, + dropFinallyErrors: true, + finallyBody: [ + { + kind: "if", + cond: { + kind: "unary", + op: "!", + operand: { kind: "varRef", localId: done.id, type: BOOL, loc }, + type: BOOL, + loc, + }, + then: [ + { + kind: "exprStmt", + expr: lowerer.classIteratorReturnCall(cit, itRef(), loc), + loc, + }, + ], + else_: null, + loc, + }, + ], + loc, + } satisfies IrStmt, + ] + : [loop]), ], loc, }; @@ -11355,6 +11630,7 @@ function lowerForOfDyn( iterable: IrExpr, labels?: string[], asyncLoop = false, + syncOpen = false, ): IrStmt { const loc = locOf(stmt); const spell = notIterableSpelling(stmt.expression, asyncLoop ? "async iterable" : "iterable"); @@ -11365,6 +11641,7 @@ function lowerForOfDyn( done.mutable = true; const value = lowerer.declareHiddenLocal("%dofValue", DYN); const returned = lowerer.declareHiddenLocal("%dofReturn", DYN); + const closed = lowerer.declareHiddenLocal("%dofClosed", DYN); const needsClose = lowerer.declareHiddenLocal("%dofNeedsClose", BOOL); needsClose.mutable = true; const error = lowerer.declareHiddenLocal("%dofError", CAUGHT); @@ -11430,9 +11707,10 @@ function lowerForOfDyn( }, then: [ { - kind: "exprStmt", + kind: "varDecl", + localId: closed.id, // return()'s (awaited) result must be an object, as for next(). - expr: asyncLoop + init: asyncLoop ? check( awaitDyn({ kind: "dynCall", @@ -11447,6 +11725,27 @@ function lowerForOfDyn( : call(varRef(returned.id, DYN, loc)), loc, }, + // A synchronous iterator's return() result is read as the + // async-from-sync iterator reads it: done, then the value, which is + // awaited, so a rejected promise there rejects the loop. + ...(syncIterator + ? [ + { + kind: "if", + cond: varRef(syncIterator.id, BOOL, loc), + then: [ + { kind: "exprStmt", expr: get(varRef(closed.id, DYN, loc), "done"), loc }, + { + kind: "exprStmt", + expr: awaitDyn(get(varRef(closed.id, DYN, loc), "value")), + loc, + }, + ], + else_: null, + loc, + } satisfies IrStmt, + ] + : []), ], else_: null, loc, @@ -11582,7 +11881,7 @@ function lowerForOfDyn( localId: iterator.id, init: check({ kind: "libCall", - fn: asyncLoop ? "dyn.asyncIteratorOpen" : "dyn.iterator", + fn: asyncLoop && !syncOpen ? "dyn.asyncIteratorOpen" : "dyn.iterator", args: [iterable, { kind: "strLit", value: spell, type: STRING, loc }], type: DYN, loc, @@ -11594,13 +11893,15 @@ function lowerForOfDyn( { kind: "varDecl", localId: syncIterator.id, - init: { - kind: "libCall", - fn: "dyn.asyncIteratorWasSync", - args: [], - type: BOOL, - loc, - }, + init: syncOpen + ? { kind: "boolLit", value: true, type: BOOL, loc } + : { + kind: "libCall", + fn: "dyn.asyncIteratorWasSync", + args: [], + type: BOOL, + loc, + }, loc, } satisfies IrStmt, ] @@ -12024,6 +12325,7 @@ function lowerForIn(lowerer: Lowerer, stmt: ts.ForInStatement): IrStmt { // them (accessor slots live outside declaredOrder), so the loop // fences rather than skip keys. fencePerfEntryKeyWalk(lowerer, receiver.type, stmt.expression, "for-in"); + fenceX509KeyWalk(lowerer, receiver.type, stmt.expression, "for-in"); if (shapeHasAccessorSlots(rShape)) { lowerer.unsupported( "SC1090", @@ -12724,19 +13026,3 @@ function isAssertionInitializer(node: ts.Expression): boolean { while (ts.isParenthesizedExpression(e)) e = e.expression; return ts.isAsExpression(e) || ts.isTypeAssertion(e); } - -/** A `var` (or `let`) whose initialized declaration precedes the export - * statement in the module's top-level statements and that nothing ever - * reassigns: exporting it by reference reads the value Node copies. The - * legacy `var ns = ns || {}; …; module.exports = { ns }` shape. */ -function settledBeforeExport(lowerer: Lowerer, sym: ts.Symbol, exportNode: ts.Node): boolean { - const decl = sym.valueDeclaration; - if (!decl || !ts.isVariableDeclaration(decl) || decl.initializer === undefined) return false; - const stmt = decl.parent?.parent; - if (!stmt || !ts.isVariableStatement(stmt) || !ts.isSourceFile(stmt.parent)) return false; - let exportStmt: ts.Node = exportNode; - while (exportStmt.parent !== undefined && !ts.isSourceFile(exportStmt.parent)) - exportStmt = exportStmt.parent; - if (exportStmt.parent !== stmt.parent || stmt.end > exportStmt.pos) return false; - return bindingNeverReassigned(lowerer, sym, decl); -} diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 04c2fa043..c8699d856 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -2542,7 +2542,7 @@ function classIteratorObject( iter: IrExpr, loc: SrcLoc, ): IrExpr { - const key = `streamFromIter:${cit.iterT.className}`; + const key = `streamFromIter:${cit.iterT.kind === "object" ? cit.iterT.className : typeKey(cit.iterT)}`; let name = lowerer.valueHelpers.get(key); if (!name) { name = keyedHelperName(lowerer, "%stream.fromIter", key); @@ -2570,7 +2570,7 @@ function classIteratorObject( { kind: "varDecl", localId: r.id, - init: classIteratorNextCall(lowerer, cit, varRef(it.id, cit.iterT, loc), loc), + init: classIteratorNextCall(lowerer, cit, varRef(it.id, cit.iterT, loc), null, loc), loc, }, { @@ -2837,6 +2837,107 @@ export function streamClassAliasDecl( const PIPELINE_STAGE_EXPECTED = "of type function or an instance of Blob, ReadableStream, WritableStream, Stream, Iterable, AsyncIterable, or Promise or { readable, writable } pair"; +/** A checked-dynamic pipeline stage (an untyped source or destination): + * a boxed native stream unwraps at run time; other values (functions, + * pairs) throw the not-supported Error there. */ +function unboxDynStage(lowerer: Lowerer, v: IrExpr, node: ts.Expression): IrExpr { + noteUntypedStreamFlow(lowerer, null); + return { + kind: "libCall", + fn: "stream.unboxDyn", + args: [ + v, + strLit( + lowerer.dynNodeStreams + ? "pipeline stages other than native streams (JavaScript stream objects, iterables, generators, plain functions, and { readable, writable } pairs) are not supported yet" + : "pipeline stages other than native streams (iterables, generators, plain functions, and { readable, writable } pairs) are not supported yet", + locOf(node), + ), + ], + type: { kind: "object", className: "%Duplex" }, + loc: locOf(node), + }; +} + +/** A pipeline whose first stage is checked-dynamic, lowered with that + * stage already evaluated: `expr` is the stream it unboxes to, or, when + * `iterable` is set, the value to pump as an iterable source. */ +interface DynPipelineSource { + expr: IrExpr; + iterable: boolean; +} + +/** pipeline() whose first stage is a stream argument (not a function + * stage, an iterable of a static type, or a cipher value), lowered here. + * When the value is checked-dynamic, Node keeps a stream there and pipes + * it, and keeps an iterable (a string, an array, a generator, any value + * with an iterator method) and pumps it into the next stage. The value is + * evaluated once, and stream.dynIterableSource picks the lowering at run + * time. Null when the first stage is not such an argument. */ +function lowerDynSourcePipeline( + lowerer: Lowerer, + node: ts.Expression, + loc: SrcLoc, + build: (source: DynPipelineSource) => IrExpr, +): IrExpr | null { + if (pipelineStageFunction(lowerer, node) !== null) return null; + if (iterableSourceType(lowerer, node) !== null || isValueStream(lowerer, node)) return null; + let value = lowerer.lowerExpr(node); + // A Set, a Map or a generator object is iterated through its + // checked-dynamic value. + if (BOXED_SOURCE_KINDS.has(value.type.kind) && lowerer.dynConvertible(value.type)) { + value = { kind: "dynFrom", value, type: DYN, loc: value.loc }; + } + if (value.type.kind !== "dyn") { + let stream: IrExpr; + try { + stream = streamArgOf(lowerer, node, value, "pipeline"); + } catch (e) { + if (e instanceof StreamArgTypeThrow) return e.expr; + throw e; + } + return build({ expr: stream, iterable: false }); + } + const local = lowerer.declareHiddenLocal("%pipelineSource", DYN); + const ref = varRef(local.id, DYN, loc); + const stream = build({ expr: unboxDynStage(lowerer, ref, node), iterable: false }); + const iterable = build({ expr: ref, iterable: true }); + const type = stream.type; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }], + result: { + kind: "ternary", + cond: { kind: "libCall", fn: "stream.dynIterableSource", args: [ref], type: BOOL, loc }, + then: lowerer.coerceToExpected(iterable, type), + else_: stream, + type, + loc, + }, + type, + loc, + }; +} + +/** The static types of a pipeline source that lowerDynSourcePipeline boxes + * as checked-dynamic values. An array or a string reaches it when the + * checker types the argument as any while its value keeps a static type. */ +const BOXED_SOURCE_KINDS: ReadonlySet = new Set([ + "set", + "map", + "generator", + "array", + "string", +]); + +/** lowerPipelineStages' source arguments for a checked-dynamic first stage. */ +function stagesSource( + src: DynPipelineSource | null, +): [ReadonlyMap | undefined, IrExpr | undefined] { + if (src === null) return [undefined, undefined]; + return src.iterable ? [undefined, src.expr] : [new Map([[0, src.expr]]), undefined]; +} + /** A stream-rooted argument, lowered — or the pointed fence (pipeline's * iterable/generator/function stages have no lowering yet). `what` picks * the validation ladder: finished() rejects EVERY non-stream through @@ -2848,7 +2949,16 @@ function lowerStreamArg( node: ts.Expression, what: "finished" | "pipeline" | "addAbortSignal" | "compose", ): IrExpr { - const lowered = lowerer.lowerExpr(node); + return streamArgOf(lowerer, node, lowerer.lowerExpr(node), what); +} + +/** lowerStreamArg over the argument's lowered value. */ +function streamArgOf( + lowerer: Lowerer, + node: ts.Expression, + lowered: IrExpr, + what: "finished" | "pipeline" | "addAbortSignal" | "compose", +): IrExpr { // A Hash, Hmac, Cipheriv or Decipheriv stage is its Transform; pipeline // and compose stages keep the value alive until the stream settles. const v = isValueStream(lowerer, node) @@ -2857,24 +2967,7 @@ function lowerStreamArg( // A checked-dynamic pipeline stage (an untyped source or destination): // a boxed native stream unwraps at run time; other values (iterables, // functions) throw the not-supported Error there. - if (v.type.kind === "dyn" && what === "pipeline") { - noteUntypedStreamFlow(lowerer, null); - return { - kind: "libCall", - fn: "stream.unboxDyn", - args: [ - v, - strLit( - lowerer.dynNodeStreams - ? "pipeline stages other than native streams (JavaScript stream objects, iterables, generators, plain functions, and { readable, writable } pairs) are not supported yet" - : "pipeline stages other than native streams (iterables, generators, plain functions, and { readable, writable } pairs) are not supported yet", - locOf(node), - ), - ], - type: { kind: "object", className: "%Duplex" }, - loc: locOf(node), - }; - } + if (v.type.kind === "dyn" && what === "pipeline") return unboxDynStage(lowerer, v, node); const info = v.type.kind === "object" ? lowerer.classes.get(v.type.className) : undefined; if (!streamSidesOf(lowerer, info)) { // A provably-non-stream value in a JS source (the invalid-input @@ -3210,57 +3303,82 @@ export function lowerStreamModuleCall( ? pipelineOptions(lowerer, lastArg) : null; const stageArgs = opts ? args.slice(0, -1) : args; - if (hasPipelineFunctionStage(lowerer, stageArgs)) { - const staged = lowerPipelineStages(lowerer, stageArgs, loc); - const type = staged.destPromise ?? { kind: "promise", inner: VOID }; - // A promise destination's value settles the result (same type). - if (!opts) { - return staged.wrap({ - kind: "libCall", - fn: "sp.pipelineStages", - args: staged.args, - type, - loc, - }); - } - // the options: end, and the signal (flags bit 6) - const flagsArg = staged.args[3]!; - if (flagsArg.kind !== "numLit") - throw new InternalCompilerError("pipeline flags not a literal"); - const args = [...staged.args, boolLit(opts.end, loc)]; - if (opts.signal) { - args[3] = numLit(flagsArg.value | 64, loc); - args.push(opts.signal); - } - return staged.wrap({ kind: "libCall", fn: "sp.pipelineStagesOpts", args, type, loc }); - } - let streams: IrExpr[]; - try { - streams = stageArgs.map((a) => lowerStreamArg(lowerer, a, "pipeline")); - } catch (e) { - if (e instanceof StreamArgTypeThrow) return e.expr; - throw e; - } - if (opts) { - const head = [numLit(streams.length, loc), ...streams, boolLit(opts.end, loc)]; - return { - kind: "libCall", - fn: opts.signal ? "sp.pipelineOptsSignal" : "sp.pipelineOpts", - args: opts.signal ? [...head, opts.signal] : head, - type: { kind: "promise", inner: VOID }, - loc, - }; - } - return { - kind: "libCall", - fn: "sp.pipeline", - args: [numLit(streams.length, loc), ...streams], - type: { kind: "promise", inner: VOID }, - loc, - }; + const build = (src: DynPipelineSource | null): IrExpr => + lowerPromisePipeline(lowerer, stageArgs, opts, src, loc); + return lowerDynSourcePipeline(lowerer, stageArgs[0]!, loc, build) ?? build(null); } return null; } + return lowerStreamModuleCallbackCall(lowerer, call, bi, loc); +} + +/** stream/promises pipeline(...stages[, options]), with the first stage + * pre-lowered when it is checked-dynamic (lowerDynSourcePipeline). */ +function lowerPromisePipeline( + lowerer: Lowerer, + stageArgs: readonly ts.Expression[], + opts: ReturnType | null, + src: DynPipelineSource | null, + loc: SrcLoc, +): IrExpr { + if (src?.iterable === true || hasPipelineFunctionStage(lowerer, stageArgs)) { + const staged = lowerPipelineStages(lowerer, stageArgs, loc, ...stagesSource(src)); + const type = staged.destPromise ?? { kind: "promise", inner: VOID }; + // A promise destination's value settles the result (same type). + if (!opts) { + return staged.wrap({ + kind: "libCall", + fn: "sp.pipelineStages", + args: staged.args, + type, + loc, + }); + } + // the options: end, and the signal (flags bit 6) + const flagsArg = staged.args[3]!; + if (flagsArg.kind !== "numLit") throw new InternalCompilerError("pipeline flags not a literal"); + const args = [...staged.args, boolLit(opts.end, loc)]; + if (opts.signal) { + args[3] = numLit(flagsArg.value | 64, loc); + args.push(opts.signal); + } + return staged.wrap({ kind: "libCall", fn: "sp.pipelineStagesOpts", args, type, loc }); + } + let streams: IrExpr[]; + try { + streams = stageArgs.map((a, i) => + i === 0 && src !== null ? src.expr : lowerStreamArg(lowerer, a, "pipeline"), + ); + } catch (e) { + if (e instanceof StreamArgTypeThrow) return e.expr; + throw e; + } + if (opts) { + const head = [numLit(streams.length, loc), ...streams, boolLit(opts.end, loc)]; + return { + kind: "libCall", + fn: opts.signal ? "sp.pipelineOptsSignal" : "sp.pipelineOpts", + args: opts.signal ? [...head, opts.signal] : head, + type: { kind: "promise", inner: VOID }, + loc, + }; + } + return { + kind: "libCall", + fn: "sp.pipeline", + args: [numLit(streams.length, loc), ...streams], + type: { kind: "promise", inner: VOID }, + loc, + }; +} + +/** The node:stream and stream/consumers members of lowerStreamModuleCall. */ +function lowerStreamModuleCallbackCall( + lowerer: Lowerer, + call: ts.CallExpression, + bi: { module: string; member: string }, + loc: SrcLoc, +): IrExpr | null { if (bi.module === "stream/consumers") { const args = call.arguments.nodes; // text/json/buffer/arrayBuffer(stream) → a pending promise the @@ -3411,67 +3529,9 @@ export function lowerStreamModuleCall( "the supported form is pipeline(source, ...streams, callback)", ); } - if (hasPipelineFunctionStage(lowerer, args.slice(0, -1))) { - const staged = lowerPipelineStages(lowerer, args.slice(0, -1), loc); - const cbNode = args[args.length - 1]!; - const cbParams = - ts.isArrowFunction(cbNode) || ts.isFunctionExpression(cbNode) - ? cbNode.parameters.nodes.length - : 0; - if (staged.dest === 2 || (staged.dest === 1 && cbParams > 1)) { - // callback(err, value): the destination's value (undefined for - // an async generator) reaches the callback through the - // checked-dynamic path. - const cb = lowerer.lowerExprExpecting(args[args.length - 1]!, DYN); - // SCR_PIPE_TYPED_CB: a typed callback whose error parameter cannot - // hold undefined takes null for no error; others take undefined. - const stagedArgs = [...staged.args]; - const flagsArg = stagedArgs[3]!; - if (flagsArg.kind !== "numLit") - throw new InternalCompilerError("pipeline flags not a literal"); - if ( - !isJsSourceFile(ts.getSourceFileOfNode(call)) && - !callbackErrorAdmitsUndefined(lowerer, cb) - ) - stagedArgs[3] = numLit(flagsArg.value | 128, loc); - return staged.wrap({ - kind: "libCall", - fn: "stream.pipelineStagesDyn", - args: [...stagedArgs, cb], - type: staged.last, - loc, - }); - } - const { cb, dyn } = lowerEosCallback( - lowerer, - "pipeline", - args[args.length - 1]!, - staged.last, - ); - return staged.wrap({ - kind: "libCall", - fn: dyn ? "stream.pipelineStagesDyn" : "stream.pipelineStages", - args: [...staged.args, cb], - type: staged.last, - loc, - }); - } - let streams: IrExpr[]; - try { - streams = args.slice(0, -1).map((a) => lowerStreamArg(lowerer, a, "pipeline")); - } catch (e) { - if (e instanceof StreamArgTypeThrow) return e.expr; - throw e; - } - const last = streams[streams.length - 1]!; - const { cb, dyn } = lowerEosCallback(lowerer, "pipeline", args[args.length - 1]!, last.type); - return { - kind: "libCall", - fn: dyn ? "stream.pipelineDyn" : "stream.pipeline", - args: [numLit(streams.length, loc), ...streams, cb], - type: last.type, - loc, - }; + const build = (src: DynPipelineSource | null): IrExpr => + lowerCallbackPipeline(lowerer, call, args, src, loc); + return lowerDynSourcePipeline(lowerer, args[0]!, loc, build) ?? build(null); } if (bi.member === "compose") { @@ -3484,6 +3544,75 @@ export function lowerStreamModuleCall( return null; } +/** pipeline(...stages, callback), with the first stage pre-lowered when it + * is checked-dynamic (lowerDynSourcePipeline). */ +function lowerCallbackPipeline( + lowerer: Lowerer, + call: ts.CallExpression, + args: readonly ts.Expression[], + src: DynPipelineSource | null, + loc: SrcLoc, +): IrExpr { + if (src?.iterable === true || hasPipelineFunctionStage(lowerer, args.slice(0, -1))) { + const staged = lowerPipelineStages(lowerer, args.slice(0, -1), loc, ...stagesSource(src)); + const cbNode = args[args.length - 1]!; + const cbParams = + ts.isArrowFunction(cbNode) || ts.isFunctionExpression(cbNode) + ? cbNode.parameters.nodes.length + : 0; + if (staged.dest === 2 || (staged.dest === 1 && cbParams > 1)) { + // callback(err, value): the destination's value (undefined for + // an async generator) reaches the callback through the + // checked-dynamic path. + const cb = lowerer.lowerExprExpecting(args[args.length - 1]!, DYN); + // SCR_PIPE_TYPED_CB: a typed callback whose error parameter cannot + // hold undefined takes null for no error; others take undefined. + const stagedArgs = [...staged.args]; + const flagsArg = stagedArgs[3]!; + if (flagsArg.kind !== "numLit") + throw new InternalCompilerError("pipeline flags not a literal"); + if ( + !isJsSourceFile(ts.getSourceFileOfNode(call)) && + !callbackErrorAdmitsUndefined(lowerer, cb) + ) + stagedArgs[3] = numLit(flagsArg.value | 128, loc); + return staged.wrap({ + kind: "libCall", + fn: "stream.pipelineStagesDyn", + args: [...stagedArgs, cb], + type: staged.last, + loc, + }); + } + const { cb, dyn } = lowerEosCallback(lowerer, "pipeline", args[args.length - 1]!, staged.last); + return staged.wrap({ + kind: "libCall", + fn: dyn ? "stream.pipelineStagesDyn" : "stream.pipelineStages", + args: [...staged.args, cb], + type: staged.last, + loc, + }); + } + let streams: IrExpr[]; + try { + streams = args + .slice(0, -1) + .map((a, i) => (i === 0 && src !== null ? src.expr : lowerStreamArg(lowerer, a, "pipeline"))); + } catch (e) { + if (e instanceof StreamArgTypeThrow) return e.expr; + throw e; + } + const last = streams[streams.length - 1]!; + const { cb, dyn } = lowerEosCallback(lowerer, "pipeline", args[args.length - 1]!, last.type); + return { + kind: "libCall", + fn: dyn ? "stream.pipelineDyn" : "stream.pipeline", + args: [numLit(streams.length, loc), ...streams, cb], + type: last.type, + loc, + }; +} + /** A pipeline() function stage: an inline function or arrow function, or * an identifier bound to a function declaration or to a `const` * initialized with one, which nothing reassigns. */ @@ -3788,6 +3917,302 @@ function lowerWebStreamSource( }; } +/** A checked-dynamic pipeline source that is an iterable at run time + * (stream.dynIterableSource), as an async generator closure that iterates + * it as Node's pipeline does with `for await`: the value's async iterator, + * or its sync iterator stepped like the typed iterable sources. Each value + * is yielded; leaving early closes the iterator through its return(). */ +function lowerDynIterableSource( + lowerer: Lowerer, + value: IrExpr, + loc: SrcLoc, +): { closure: IrExpr; out: SyncGenType } | null { + const out: SyncGenType = { + kind: "generator", + async: true, + yieldT: DYN, + retT: VOID, + nextT: UNDEFINED_T, + }; + const genResultT = genResultRecord(DYN, VOID, lowerer.shapes, lowerer.unions); + if (!genResultT) return null; + const key = "dynIterableSource"; + let maker = lowerer.valueHelpers.get(key); + if (!maker) { + maker = `%stream.dynIterableSource.${lowerer.valueHelpers.size}`; + const genName = `${maker}.gen`; + const frame = new LiftedFrame(loc); + const srcId = frame.local("src", DYN, false); + const itId = frame.local("it", DYN, false); + const syncId = frame.local("sync", BOOL, false); + const nextId = frame.local("next", DYN, false); + const stepId = frame.local("step", DYN, true); + const valueId = frame.local("value", DYN, true); + const activeId = frame.local("active", BOOL, true); + const closeId = frame.local("close", DYN, false); + const it = (): IrExpr => varRef(itId, DYN, loc); + const sync = (): IrExpr => varRef(syncId, BOOL, loc); + const step = (): IrExpr => varRef(stepId, DYN, loc); + const value = (): IrExpr => varRef(valueId, DYN, loc); + // for await over a sync iterator awaits each value, the final one + // included (CreateAsyncFromSyncIterator): an object (a promise or a + // thenable) passes on its result + const awaitsValue = (): IrExpr => ({ + kind: "logical", + op: "&&", + left: sync(), + right: { + kind: "logical", + op: "||", + left: { kind: "dynTest", test: "promise", value: value(), type: BOOL, loc }, + right: { + kind: "logical", + op: "&&", + left: { + kind: "dynTest", + test: "nullish", + negated: true, + value: value(), + type: BOOL, + loc, + }, + right: { kind: "dynTest", test: "object", value: value(), type: BOOL, loc }, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }); + const get = (obj: IrExpr, name: string): IrExpr => ({ + kind: "dynKeyGet", + value: obj, + key: strLit(name, loc), + type: DYN, + loc, + }); + const awaitDyn = (v: IrExpr): IrExpr => ({ + kind: "libCall", + fn: "async.awaitDyn", + args: [v], + type: DYN, + loc, + }); + const call = (callee: IrExpr, calleeName: string): IrExpr => ({ + kind: "dynCall", + callee, + receiver: it(), + calleeName, + args: [], + type: DYN, + loc, + }); + const setActive = (on: boolean): IrStmt => ({ + kind: "assign", + localId: activeId, + value: boolLit(on, loc), + loc, + }); + const nextCall = (): IrExpr => call(varRef(nextId, DYN, loc), "iterator method"); + const body: IrStmt[] = [ + { + kind: "varDecl", + localId: itId, + init: { + kind: "libCall", + fn: "dyn.iteratorResult", + args: [ + { + kind: "libCall", + fn: "dyn.asyncIteratorOpen", + args: [varRef(srcId, DYN, loc), strLit("", loc)], + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }, + loc, + }, + { + kind: "varDecl", + localId: syncId, + init: { kind: "libCall", fn: "dyn.asyncIteratorWasSync", args: [], type: BOOL, loc }, + loc, + }, + { kind: "varDecl", localId: nextId, init: get(it(), "next"), loc }, + { kind: "varDecl", localId: stepId, init: dynUndefinedExpr(loc), loc }, + { kind: "varDecl", localId: valueId, init: dynUndefinedExpr(loc), loc }, + { kind: "varDecl", localId: activeId, init: boolLit(false, loc), loc }, + { + kind: "tryCatch", + tryBody: [ + { + kind: "while", + cond: boolLit(true, loc), + body: [ + setActive(false), + { + kind: "assign", + localId: stepId, + value: { + kind: "libCall", + fn: "dyn.iteratorResult", + args: [ + { + kind: "ternary", + cond: sync(), + then: nextCall(), + else_: awaitDyn(nextCall()), + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }, + loc, + }, + { + kind: "if", + cond: { + kind: "dynTest", + test: "truthy", + value: get(step(), "done"), + type: BOOL, + loc, + }, + // a sync iterator's final value is awaited too: a rejection + // rejects the pump, and the finished iterator is not closed. + // An async iterator's final value is not read. + then: [ + { + kind: "if", + cond: sync(), + then: [ + { kind: "assign", localId: valueId, value: get(step(), "value"), loc }, + { + kind: "if", + cond: awaitsValue(), + then: [{ kind: "exprStmt", expr: awaitDyn(value()), loc }], + else_: null, + loc, + }, + ], + else_: null, + loc, + }, + { kind: "break", loc }, + ], + else_: null, + loc, + }, + setActive(true), + { kind: "assign", localId: valueId, value: get(step(), "value"), loc }, + // a rejection here closes the iterator + { + kind: "if", + cond: awaitsValue(), + then: [{ kind: "assign", localId: valueId, value: awaitDyn(value()), loc }], + else_: null, + loc, + }, + { + kind: "exprStmt", + expr: { kind: "yieldExpr", value: value(), type: VOID, loc }, + loc, + }, + ], + loc, + }, + ], + catchBody: null, + catchLocalId: null, + finallyBody: [ + { + kind: "if", + cond: varRef(activeId, BOOL, loc), + then: [ + { kind: "varDecl", localId: closeId, init: get(it(), "return"), loc }, + { + kind: "if", + cond: { + kind: "unary", + op: "!", + operand: { + kind: "dynTest", + test: "nullish", + value: varRef(closeId, DYN, loc), + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + then: [ + { + kind: "exprStmt", + expr: awaitDyn(call(varRef(closeId, DYN, loc), "iterator return")), + loc, + }, + ], + else_: null, + loc, + }, + ], + else_: null, + loc, + }, + ], + loc, + }, + { kind: "return", value: null, loc }, + ]; + const srcLocal = frame.locals.find((l) => l.id === srcId)!; + srcLocal.boxed = true; + lowerer.valueHelpers.set(key, maker); + lowerer.liftedFns.push({ + name: genName, + params: [], + returnType: VOID, + async: true, + captures: [{ localId: srcId, name: "src", type: DYN }], + locals: frame.locals, + body, + generator: { yieldT: DYN, nextT: UNDEFINED_T, resultType: genResultT }, + loc, + }); + const makerSrc: IrLocal = { id: "src.0", name: "src", type: DYN, mutable: false, boxed: true }; + lowerer.liftedFns.push({ + name: maker, + params: [{ localId: makerSrc.id, name: makerSrc.name, type: DYN }], + returnType: funcOf([], out), + locals: [makerSrc], + body: [ + { + kind: "return", + value: { + kind: "closure", + fnName: genName, + captures: [makerSrc.id], + type: funcOf([], out), + loc, + }, + loc, + }, + ], + loc, + }); + } + return { + closure: { kind: "call", callee: maker, args: [value], type: funcOf([], out), loc }, + out, + }; +} + function lowerIterableSource( lowerer: Lowerer, node: ts.Expression, @@ -3951,6 +4376,7 @@ function lowerPipelineStages( stageArgs: readonly ts.Expression[], loc: SrcLoc, preLowered?: ReadonlyMap, + dynSource?: IrExpr, ): PipelineStagesLowering { if (stageArgs.length > 52) { lowerer.noLowering( @@ -3975,6 +4401,15 @@ function lowerPipelineStages( const argOrder: { list: IrExpr[]; index: number }[] = []; for (let j = 0; j < stageArgs.length; j++) { const arg = stageArgs[j]!; + if (j === 0 && dynSource !== undefined) { + const source = lowerDynIterableSource(lowerer, dynSource, loc); + if (source === null) lowerer.noLowering("pipeline over this source", arg); + positions += 1; + argOrder.push({ list: stages, index: stages.length }); + stages.push(source!.closure); + srcT = source!.out; + continue; + } // compose's head and tail functions arrive as Duplex.from streams const fn = preLowered?.has(j) ? null : pipelineStageFunction(lowerer, arg); const syncSourceFn = diff --git a/packages/compiler/src/frontend/lowering/lowerer.test.ts b/packages/compiler/src/frontend/lowering/lowerer.test.ts index 7fbb9661f..f36695234 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.test.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.test.ts @@ -239,7 +239,7 @@ test("optional union widening keeps refused and missing contracts current", () = unionId: lowerer.unions.recursiveRef(recursive, () => "Recursive"), }; expect(lowerer.withUndefinedArmOf(pending)).not.toBeNull(); - lowerer.unions.finalizeRecursive(recursive, [{ kind: "date" }, STRING]); + lowerer.unions.finalizeRecursive(recursive, [{ kind: "promise", inner: F64 }, STRING]); expect(lowerer.withUndefinedArmOf(pending)).toBeNull(); expect(lowerer.withUndefinedArmOf(pending)).toBeNull(); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index f8ad0a559..2d975fbb7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2,7 +2,7 @@ import { markInferredFactChecks, shapeOverlayFileNames } from "../shape-inferenc import { npmStaticPackageOfPath } from "../npm-static.js"; import { isDeclBoundaryFile } from "../decl-projection.js"; import { relieveHeapPressure } from "../../heap-pressure.js"; -import { moduleNamespaceObject } from "./module-namespace-elements.js"; +import { moduleNamespaceObject, moduleNamespaceValue } from "./module-namespace-elements.js"; import { keyedHelperName } from "./stable-names.js"; import { positionTag, StructuralPositions } from "./structural-positions.js"; import { onlyForwarded, stripTypeWrappers } from "./typed-arguments.js"; @@ -65,6 +65,7 @@ import { discoverWasmModules, type WasmModuleReport } from "../../wasm/discover. import { encodeBase64 } from "../../wasm/base64.js"; import { resolveRequireRuntime } from "../runtime-resolve.js"; import { lowerKeyObjectCall } from "./builtins/key-object.js"; +import { lowerX509Call, refuseX509DynValues } from "./builtins/x509.js"; import { isEcdhType, lowerEcdhCall } from "./builtins/ecdh.js"; import { isDiffieHellmanType, lowerDiffieHellmanCall } from "./builtins/dh.js"; import { fenceWebCryptoMember, lowerWebCryptoCall } from "./builtins/webcrypto.js"; @@ -157,7 +158,8 @@ import { boxClassInstancesExactly, trackClassFieldCreation, } from "./class-dynamic-dispatch.js"; -import { guardClassAttributeWrites } from "./class-attributes.js"; +import { addUntypedIntegrityWords, guardClassAttributeWrites } from "./class-attributes.js"; +import { inferredCollectionType } from "./js-collection-types.js"; import { guardFrozenWrites, markSloppyRecordWrites } from "./frozen-records.js"; import { applyHandleProperties } from "./handle-properties.js"; import { installBufferValue } from "./containers/bytes.js"; @@ -271,6 +273,9 @@ import { type StartupCrash, cjsExportAssignmentOf, cjsExportDiscardReason, + cjsExportsAssignedInFunctions, + cjsReexportedModules, + cjsNamespaceExportNames, fallbackDtsPath, isCheckedJsSourceFile, isCjsExportTableLiteral, @@ -365,6 +370,7 @@ import { type ClassInfo, type ClassMethodSignature, type ClassIteratorInfo, + type ClassIteratorOptions, type GenericClassInfo, registerBuiltinErrorClasses, defineClassValueConstruct, @@ -376,7 +382,9 @@ import { builtinStreamInfoOf, analyzeClassDecoration, classIteratorDrainCall, + classIteratorMethodCall, classIteratorNextCall, + classIteratorNextRead, classIteratorOf, classIteratorOpenCall, classIteratorRestDrainCall, @@ -595,7 +603,11 @@ import { lowerTestMethodCall, lowerTestCtxProperty, } from "./lower-test.js"; -import { lowerAssertModuleCall, lowerAssertDirectCall } from "./lower-assert.js"; +import { + assertFunctionValue, + lowerAssertModuleCall, + lowerAssertDirectCall, +} from "./lower-assert.js"; import type { AssertInstanceOptions } from "./assert-instances.js"; import { lowerUtilModuleCall } from "./lower-inspect.js"; import { lowerSystemErrorMapValue } from "./lower-system-errors.js"; @@ -2364,6 +2376,9 @@ export class Lowerer { * Date's time value instead of a Date object (date-values.ts). */ readonly timeValueDateDecisions = new Map(); readonly timeValueDateLocals = new Set(); + /** Declarations of those locals that a setter call writes + * (date-values.ts). */ + readonly timeValueDateWrites = new Set(); /** Record bindings whose flow class reaches storage outside the class: * they keep the declared layout, so an initializer from unchecked reads * converts once instead of the binding adopting the widened variant. */ @@ -2372,6 +2387,9 @@ export class Lowerer { * every name destructured from a present source optional. */ readonly runtimeOptionalPatternTypes = new Map(); readonly runtimeOptionalReduceTypes = new Map(); + /** Each function declaration's `arguments.length` count slot, or null + * (argument-counts.ts argumentCountSlot). */ + readonly argumentCountSlots = new Map(); /** Non-null assertions whose value is forwarded into a slot promoted to * hold undefined. `x!` has no runtime effect, so these keep the stored * undefined state instead of trapping before any dereference. */ @@ -2848,6 +2866,9 @@ export class Lowerer { /** Parameters read as `any` in the error-path copy of an error-first * callback. */ readonly anySymbols = new Set(); + /** The collection types that uses prove for untyped JavaScript bindings, + * by every identifier naming one (js-collection-types.ts). */ + inferredCollectionTypes: Map | undefined = undefined; /** overload-choice's answers per call, binding and function. */ readonly dynamicOverloadAnswers = new Map(); /** Error parameters of an error-first callback with a known value in @@ -3005,6 +3026,9 @@ export class Lowerer { readonly builtinCallableValueFns = new Map(); /** Each builtin function-value adapter's Node name, for boxed values. */ readonly builtinCallableValueNames = new Map(); + /** Builtin function values whose `length` differs from their adapter's + * parameter count (BuiltinModuleFn.valueLength), by adapter name. */ + readonly builtinCallableValueLengths = new Map(); /** Optional-chain lowering state. While a chain body lowers, the guarded * receiver NODE reads as a chainRecv (typed by the narrowed arm) instead * of re-lowering, its checker type reads non-nullish (typeOf), and the @@ -3171,6 +3195,14 @@ export class Lowerer { * (builtins/fs-paths.ts): each lowers to the hidden local that holds its * value, which is null until the path's lowering has saved it. */ readonly stagedArguments = new Map(); + /** Closure arguments an implicit-any call already lowered to learn their + * type (storedImplicitArgumentType), by node: the argument's own lowering + * in the same function and with the same type bindings takes that value + * once instead of lowering the closure again. */ + readonly speculatedClosures = new Map< + ts.Expression, + { ctx: FnCtx; bindings: Map | null; value: IrExpr } + >(); /** What each fs path argument of a URL-path call lowers to (the plans * of builtins/fs-paths.ts). */ readonly fsPathPlans = new Map(); @@ -3307,6 +3339,9 @@ export class Lowerer { readonly recordFieldDeletes = new Set(); /** typeKeys of the array types the program may freeze (IrModule.freezableArrays). */ readonly freezableArrays = new Set(); + /** Whether the program freezes, seals or prevents extensions of untyped + * values other than fresh literals (noteUntypedIntegrity). */ + untypedIntegrity = false; readonly liftedFns: IrFunction[] = []; /** The first lifted function of each name (liftedFnNamed), for the * liftedFns indexed so far. liftedFns only grows. */ @@ -3712,9 +3747,7 @@ export class Lowerer { * that comes back through a boundary cast keeps its identity. */ private declBoundaryMemo: boolean | null = null; get declBoundary(): boolean { - this.declBoundaryMemo ??= this.program - .getSourceFiles() - .some((sf) => isDeclBoundaryFile(sf.fileName)); + this.declBoundaryMemo ??= this.program.getSourceFileNames().some(isDeclBoundaryFile); return this.declBoundaryMemo; } /** Tracked classes' bag initializers over a typed `this` @@ -4003,6 +4036,42 @@ export class Lowerer { return null; } + /** For each CommonJS module read through a JavaScript namespace import: + * the property names of Node's namespace object + * (cjsNamespaceExportNames), and the exports the module assigns inside + * functions, which the namespace does not see + * (cjsExportsAssignedInFunctions). */ + private readonly cjsNamespaces = new Map< + ts.SourceFile, + { names: ReadonlySet; assignedLater: ReadonlySet | "all" } + >(); + + cjsNamespaceOf(dep: ts.SourceFile): { + names: ReadonlySet; + assignedLater: ReadonlySet | "all"; + } { + let namespace = this.cjsNamespaces.get(dep); + if (namespace === undefined) { + // A re-exported name reads the module that assigns it, so the writes + // after evaluation of every re-exported module count too. + let assignedLater: Set | "all" = new Set(); + for (const mod of cjsReexportedModules(this.program, dep)) { + const later = cjsExportsAssignedInFunctions(mod); + if (later === "all") { + assignedLater = "all"; + break; + } + for (const name of later) assignedLater.add(name); + } + namespace = { + names: cjsNamespaceExportNames(this.program, dep), + assignedLater, + }; + this.cjsNamespaces.set(dep, namespace); + } + return namespace; + } + sourceFileOfModuleNamespace(type: IrType): ts.SourceFile | null { if (type.kind !== "moduleNs" || !type.moduleId.startsWith("file:")) return null; const path = type.moduleId.slice("file:".length); @@ -7796,6 +7865,7 @@ export class Lowerer { finalizePrivateBrands(this, functions); trackClassFieldCreation(this, functions); boxClassInstancesExactly(this, functions); + addUntypedIntegrityWords(this); guardClassAttributeWrites(this, functions); guardFrozenWrites(this, functions); applyHandleProperties(this, functions); @@ -7889,6 +7959,7 @@ export class Lowerer { // every producer and consumer agrees. Programs with no unregistered // reference are untouched — byte-stability holds. checkRequireBindingAccesses(this, functions); + refuseX509DynValues(this, functions); markInferredFactChecks(functions, this.program); const module: IrModule | null = this.diags.length > 0 @@ -7918,6 +7989,7 @@ export class Lowerer { ...(this.freezableArrays.size > 0 ? { freezableArrays: [...this.freezableArrays].sort() } : {}), + ...(this.untypedIntegrity ? { untypedIntegrity: true as const } : {}), ...(isNodeEsmFile(this.entry, this.program) ? {} : { commonJsEntry: true }), ...(this.ffiImports.length > 0 ? { ffiImports: [...this.ffiImports] } : {}), }; @@ -8770,8 +8842,11 @@ export class Lowerer { node: ts.Node, featureOverride?: string, hintOverride?: string, + reason?: string, ): never { - this.pushDiag(unsupportedDiag(code, locOf(node), featureOverride, hintOverride)); + const diag = unsupportedDiag(code, locOf(node), featureOverride, hintOverride); + if (reason !== undefined) diag.reason = reason; + this.pushDiag(diag); throw new PoisonError(); } @@ -8997,8 +9072,16 @@ export class Lowerer { * nothing lowers. Poisons the statement like every other rejection. * `sym`, when given, picks the wording: surface declared only by the * adopted @types/node is blamed at @types/node. */ - noLowering(surface: string, node: ts.Node, hint?: string, sym?: ts.Symbol | null): never { - this.pushDiag(noLoweringDiag(surface, locOf(node), hint, this.nodeTypesOnlySymbol(sym))); + noLowering( + surface: string, + node: ts.Node, + hint?: string, + sym?: ts.Symbol | null, + reason?: string, + ): never { + const diag = noLoweringDiag(surface, locOf(node), hint, this.nodeTypesOnlySymbol(sym)); + if (reason !== undefined) diag.reason = reason; + this.pushDiag(diag); throw new PoisonError(); } @@ -9080,6 +9163,10 @@ export class Lowerer { } typeOf(node: ts.Node): ts.Type { + if (ts.isIdentifier(node)) { + const inferred = inferredCollectionType(this, node); + if (inferred) return inferred; + } if (this.anySymbols.size > 0 && this.readsAnySymbol(node)) return this.checker.getAnyType(); // A checked-dynamic argument that another overload could take at run // time leaves the resolved overload's return a guess, and JS bindings @@ -9805,10 +9892,8 @@ export class Lowerer { // so its module binding is too (`ReleaseEmitter = EventEmitter` over // `const EventEmitter = require("events")`). if (expr.type.kind === "moduleNs" && isJsSourceFileName(expr.loc.file)) { - const object = moduleNamespaceObject( - this, - expr as IrExpr & { type: IrType & { kind: "moduleNs" } }, - ); + const ns = expr as IrExpr & { type: IrType & { kind: "moduleNs" } }; + const object = moduleNamespaceObject(this, ns) ?? moduleNamespaceValue(this, ns); if (object) return object; } if ( @@ -9858,6 +9943,8 @@ export class Lowerer { // name Node's function carries (`[Function: randomUUID]`). const builtinName = expr.kind === "closure" ? this.builtinCallableValueNames.get(expr.fnName) : undefined; + const builtinLength = + expr.kind === "closure" ? this.builtinCallableValueLengths.get(expr.fnName) : undefined; return { kind: "dynFrom", value: expr, @@ -9865,6 +9952,7 @@ export class Lowerer { loc: expr.loc, ...(liveRef ? { liveRef: true as const } : {}), ...(builtinName !== undefined ? { fnName: builtinName } : {}), + ...(builtinLength !== undefined ? { fnLength: builtinLength } : {}), }; } return expr; @@ -12943,12 +13031,30 @@ export class Lowerer { /* ── expressions ──────────────────────────────────────────────────── */ lowerExpr(expr: ts.Expression): IrExpr { + if (this.speculatedClosures.size > 0) { + const speculated = this.speculatedClosure(expr); + if (speculated !== null) { + this.speculatedClosures.delete(expr); + return speculated; + } + } const streamValue = this.valueStreamReceivers.get(expr); if (streamValue) return valueStreamOf(varRef(streamValue.localId, streamValue.type, locOf(expr))); return this.stagedArgument(expr) ?? lowerExpr(this, expr); } + /** The value storedImplicitArgumentType lowered for `expr` in the active + * function and type bindings (speculatedClosures), or null. */ + speculatedClosure(expr: ts.Expression): IrExpr | null { + const entry = this.speculatedClosures.get(expr); + return entry !== undefined && + entry.ctx === this.ctx && + entry.bindings === this.typeParamBindings + ? entry.value + : null; + } + /** The saved value of a staged fs argument (stagedArguments, keyed * without type-only wrappers), or null for every other expression. */ private stagedArgument(node: ts.Expression): IrExpr | null { @@ -13060,6 +13166,10 @@ export class Lowerer { ) { return lowerElementAccess(this, expr); } + // `a?.[i]`: the optional chain tests the receiver's undefined arm and + // lowers this access again inside its guard. + if (expr.questionDotToken && !this.chainHandled.has(expr)) + return lowerElementAccess(this, expr); const receiver = this.runtimeOptionalIdentifierValue(expr.expression); const receiverValue: IrExpr | null = receiver && (receiver.present.kind === "array" || receiver.present.kind === "record") @@ -13339,6 +13449,7 @@ export class Lowerer { lowerStaticBlobCall(this, expr) ?? lowerWebCryptoCall(this, expr) ?? lowerKeyObjectCall(this, expr) ?? + lowerX509Call(this, expr) ?? lowerEcdhCall(this, expr) ?? lowerDiffieHellmanCall(this, expr) ?? lowerNetAddressCall(this, expr) ?? @@ -13471,16 +13582,29 @@ export class Lowerer { return accessorCall(this, className, member, obj, extraArgs, ret, loc); } - classIteratorOf(t: IrType): ClassIteratorInfo | null { - return classIteratorOf(this, t); + classIteratorOf(t: IrType, options?: ClassIteratorOptions): ClassIteratorInfo | null { + return classIteratorOf(this, t, options); + } + + classIteratorReturnCall(cit: ClassIteratorInfo, it: IrExpr, loc: SrcLoc): IrExpr { + return classIteratorMethodCall(this, cit, "return", it, loc); } classIteratorOpenCall(cit: ClassIteratorInfo, recv: IrExpr, loc: SrcLoc): IrExpr { return classIteratorOpenCall(this, cit, recv, loc); } - classIteratorNextCall(cit: ClassIteratorInfo, itRef: IrExpr, loc: SrcLoc): IrExpr { - return classIteratorNextCall(this, cit, itRef, loc); + classIteratorNextRead(cit: ClassIteratorInfo, itRef: IrExpr, loc: SrcLoc): IrExpr | null { + return classIteratorNextRead(this, cit, itRef, loc); + } + + classIteratorNextCall( + cit: ClassIteratorInfo, + itRef: IrExpr, + next: IrExpr | null, + loc: SrcLoc, + ): IrExpr { + return classIteratorNextCall(this, cit, itRef, next, loc); } classIteratorDrainCall(src: IrExpr, loc: SrcLoc, elemT?: IrType): IrExpr | null { @@ -13779,7 +13903,7 @@ export class Lowerer { if ( ts.isPropertyAccessExpression(expr) && !expr.questionDotToken && - ts.isIdentifier(expr.expression) + (ts.isIdentifier(expr.expression) || requireSpecOf(expr.expression) !== null) ) { const outer = this.builtinNamespaceModuleOf(expr.expression); if (outer === null) return null; @@ -13984,6 +14108,15 @@ export class Lowerer { `library functions as values (call '${ts.getTextOfNode(expr)}' directly)`, ); } + // A member that is a supported module in its own right (`fs.promises` + // is node:fs/promises, as in Node) reads as that module's namespace. + { + const sub = this.builtinNamespaceModuleOf(expr); + if (sub !== null) { + const type: IrType = { kind: "moduleNs", moduleId: `builtin:${sub}` }; + return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc }; + } + } this.noLowering( `${bi.module}.${bi.member}`, expr, @@ -13998,6 +14131,8 @@ export class Lowerer { * deliberately narrower than table membership because many table rows * are only dispatch sentinels for call-site-specific lowering. */ lowerBuiltinCallableValue(bi: { module: string; member: string }, loc: SrcLoc): IrExpr | null { + const assertion = assertFunctionValue(bi, loc); + if (assertion) return assertion; if (bi.module === "util" && bi.member === "types") return utilTypesModuleValue(this, loc); if (bi.module === "util/types" && builtinModuleFnOf(this, bi.module, bi.member)) return lowerUtilTypeValue(this, bi.member, loc); @@ -14048,6 +14183,8 @@ export class Lowerer { fnName = keyedHelperName(this, `%builtin.value.${fn.valueFn ?? fn.fn}`, key); this.builtinCallableValueFns.set(key, fnName); this.builtinCallableValueNames.set(fnName, display.slice(display.lastIndexOf(".") + 1)); + if (fn.valueLength !== undefined) + this.builtinCallableValueLengths.set(fnName, fn.valueLength); const params = funcType.params.map((type, index) => ({ localId: `arg.${index}`, name: `arg${index}`, diff --git a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts index cf828c6f0..2c8e6a475 100644 --- a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts +++ b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts @@ -1,4 +1,5 @@ import { dynUndefinedExpr, strLit, varRef } from "../../ir/build.js"; +import { arrayOf, funcOf } from "../../ir/ir.js"; import * as ts from "../ts7/adapter.js"; import { DYN, @@ -9,7 +10,7 @@ import { type IrType, } from "../../ir/ir.js"; import { locOf } from "../program.js"; -import { newFnCtx, type Lowerer } from "./lowerer.js"; +import { newFnCtx, PoisonError, type Lowerer } from "./lowerer.js"; /** Computed namespace reads select live exports instead of copying a * namespace into a registry snapshot. Constructor registries may contain @@ -171,3 +172,124 @@ export function moduleNamespaceObject( loc, }; } + +/** A module namespace taken as an untyped value whose exports a copy + * cannot represent: a binding that can change after initialization (`let`, + * `var`), or an export the copy cannot name. The value is the runtime's + * namespace object (scr_module_namespace_value): one per module, whose + * property reads call a compiled getter for the export's current value and + * whose writes throw Node's TypeError. An export whose value has no + * checked-dynamic form throws its fence when it is read, and listing the + * object's keys or inspecting it throws the "not supported yet" error. */ +export function moduleNamespaceValue( + lowerer: Lowerer, + ns: IrExpr & { type: IrType & { kind: "moduleNs" } }, +): IrExpr | null { + const source = lowerer.sourceFileOfModuleNamespace(ns.type); + if (!source) return null; + const symbol = lowerer.checker.getSymbolAtLocation(source); + if (!symbol) return null; + const exports: { key: string; declaration: ts.Node | undefined }[] = []; + for (const exported of lowerer.checker.getExportsOfModule(symbol)) { + const target = + exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported; + if (!(target.flags & ts.SymbolFlags.Value)) continue; + exports.push({ key: ts.symbolName(exported), declaration: target.valueDeclaration }); + } + exports.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + const name = `%module.namespace:${ns.type.moduleId}`; + const loc = ns.loc; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const context = newFnCtx(false, null, null, DYN); + const previousClass = lowerer.currentClass; + lowerer.currentClass = null; + lowerer.fnStack.push(context); + try { + const body: IrStmt[] = []; + for (const { key, declaration } of exports) { + const node = declaration ?? source; + const diagsBefore = lowerer.diags.length; + let read: IrStmt[]; + try { + const identifier = declaration?.name as ts.Node | undefined; + if (!identifier || !ts.isIdentifier(identifier)) { + lowerer.unsupported( + "SC1090", + node, + `reading the unnamed export '${key}' through a module namespace object`, + ); + } + const value = lowerer.lowerExprExpecting(identifier, DYN); + if (value.type.kind !== "dyn") { + lowerer.unsupported( + "SC1101", + identifier, + `reading the export '${key}' ('${lowerer.fmt(value.type)}') through a module namespace object`, + ); + } + read = [{ kind: "return", value, loc }]; + } catch (err) { + if (!(err instanceof PoisonError)) throw err; + const fence = lowerer.deferToRuntimeFence(diagsBefore, node, { kind: "statement" }); + if (!fence) throw err; + read = [fence]; + } + body.push({ + kind: "if", + cond: { + kind: "strEq", + negated: false, + left: varRef("key", STRING, loc), + right: strLit(key, loc), + type: { kind: "bool" }, + loc, + }, + then: read, + else_: null, + loc, + }); + } + body.push({ kind: "return", value: dynUndefinedExpr(loc), loc }); + lowerer.liftedFns.push({ + name, + params: [{ localId: "key", name: "key", type: STRING }], + returnType: DYN, + locals: [{ id: "key", name: "key", type: STRING, mutable: false }, ...context.locals], + body, + loc, + }); + } finally { + lowerer.fnStack.pop(); + lowerer.currentClass = previousClass; + } + } + const value: IrExpr = { + kind: "libCall", + fn: "module.namespaceValue", + args: [ + strLit(ns.type.moduleId, loc), + { + kind: "arrayLit", + elems: exports.map(({ key }) => strLit(key, loc)), + type: arrayOf(STRING), + loc, + }, + { + kind: "dynFrom", + value: { kind: "closure", fnName: name, captures: [], type: funcOf([STRING], DYN), loc }, + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }; + if (ns.kind === "moduleNsRef") return value; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: ns, loc }], + result: value, + type: DYN, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/private-brands.ts b/packages/compiler/src/frontend/lowering/private-brands.ts index c7881bce1..92bee4fdd 100644 --- a/packages/compiler/src/frontend/lowering/private-brands.ts +++ b/packages/compiler/src/frontend/lowering/private-brands.ts @@ -17,8 +17,9 @@ import type { Lowerer } from "./lowerer.js"; import type { ClassInfo } from "./lower-classes.js"; /** How a brand helper uses its receiver: `in` answers `#x in v`, `read` and - * `write` return the receiver as the declaring class for a field access. */ -type BrandUse = "in" | "read" | "write"; + * `write` return the receiver as the declaring class for a field access, and + * `call` for a private method call. */ +type BrandUse = "in" | "read" | "write" | "call"; const PREFIX = "%private."; @@ -214,6 +215,35 @@ export function checkedPrivateValue( return { kind: "call", callee: helper, args: [receiver], type: resultType, loc }; } +/** `value.#m(...)` on a checked-dynamic `value` in a declaration-level class + * `info` that declares the private method `#m`: the receiver as an instance + * of `info` when the value is one, else Node's TypeError ("Receiver must be + * an instance of class C"). */ +export function checkedPrivateMethodReceiver( + lowerer: Lowerer, + info: ClassInfo, + name: string, + receiver: IrExpr, + loc: SrcLoc, +): IrExpr { + const className = info.def.name; + const resultType: IrType = { kind: "object", className }; + const helper = `${PREFIX}call:${className}:${name}`; + if (!lowerer.valueHelpers.has(helper)) { + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "value.0", name: "value", type: DYN }], + returnType: resultType, + locals: [{ id: "value.0", name: "value", type: DYN, mutable: false }], + // finalizePrivateBrands fills the body. + body: [], + loc: helperLoc(loc), + }); + lowerer.valueHelpers.set(helper, helper); + } + return { kind: "call", callee: helper, args: [receiver], type: resultType, loc }; +} + /** A plain assignment, a destructuring target, or a for-in/of target writes * the field. Compound assignments and updates read it first, and Node * reports the failed read. */ @@ -358,6 +388,22 @@ export function finalizePrivateBrands(lowerer: Lowerer, functions: IrFunction[]) }, { kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc }, ); + } else if (use === "call") { + // V8 names the class by its own identifier (not a name inferred from + // a binding), "anonymous" without one, also for a nullish receiver. + const owner = lowerer.classes.get(className); + const identifier = owner?.decl?.name; + const shown = identifier !== undefined ? identifier.text : "anonymous"; + for (const unit of ["undefined", "null"] as const) { + body.push({ + kind: "if", + cond: { kind: "dynTest", test: unit, value, type: BOOL, loc }, + then: [typeError(strLit(`Cannot read properties of ${unit} (reading '${shown}')`, loc))], + else_: null, + loc, + }); + } + body.push(typeError(strLit(`Receiver must be an instance of class ${shown}`, loc))); } else { const verb = use === "read" ? "reading" : "setting"; const action = use === "read" ? "Cannot read properties" : "Cannot set properties"; diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 90acabce6..0303d34d7 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -819,6 +819,9 @@ export interface BuiltinModuleFn { * direct-call row's `fn` (a direct call picks an arity-specific entry * point; the value accepts every arity through one). */ valueFn?: IrLibFn; + /** The function value's `length` when it is not the count of its + * required value parameters (`Math.max`'s 2 over its rest pack). */ + valueLength?: number; } export type BuiltinValueParam = @@ -873,6 +876,12 @@ export const OBJECT_CALLABLE_VALUES: Record result: DYN, valueParams: exactValueParams(DYN, DYN), }, + hasOwn: { + fn: "dyn.hasOwnComputed", + params: [DYN, DYN], + result: BOOL, + valueParams: exactValueParams(DYN, DYN), + }, keys: { fn: "dyn.objKeys", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) }, values: { fn: "dyn.objValues", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) }, entries: { fn: "dyn.objEntries", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) }, @@ -2127,6 +2136,8 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< ], // The utf8 string overload; the table row is the Buffer-window form. writeSync: ["fs.writeStrSync"], + // The form whose arguments Node checks at run time. + readSync: ["fs.readSyncDyn"], // The { recursive, mode } option lowerings. mkdirSync: ["fs.mkdirModeSync", "fs.mkdirRecursiveSync", "fs.mkdirRecursiveModeSync"], // The { recursive, force } and maxRetries/retryDelay option lowerings. @@ -2338,6 +2349,13 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [ ], note: "zero arguments, one milliseconds/date-string argument, or local-time year/month fields; values are the read-only TimeClip scalar slice", }, + { + id: "stdlib.date.setters", + kind: "stdlib", + name: "Date.prototype setters", + fns: ["date.setFields", "date.timeClip", "date.store"], + note: "setTime and the local and UTC field setters, with their optional arguments", + }, { id: "stdlib.date.UTC", kind: "stdlib", diff --git a/packages/compiler/src/frontend/lowering/symbol-fields.ts b/packages/compiler/src/frontend/lowering/symbol-fields.ts index cdfb9e1b8..958af5963 100644 --- a/packages/compiler/src/frontend/lowering/symbol-fields.ts +++ b/packages/compiler/src/frontend/lowering/symbol-fields.ts @@ -394,9 +394,9 @@ function tablePropertyWritten( } return false; }; - for (const sf of lowerer.program.getSourceFiles()) { + for (const sf of lowerer.program.getImplementationSourceFiles()) { if (written) break; - if (sf.isDeclarationFile || !sf.text.includes(name)) continue; + if (!sf.text.includes(name)) continue; ts.walkPreorder(sf, (node) => { if (written) return "stop"; let target: ts.Node | undefined; diff --git a/packages/compiler/src/frontend/npm-static-namespace-defaults.test.ts b/packages/compiler/src/frontend/npm-static-namespace-defaults.test.ts index 93a776f7e..0fe7dc9a1 100644 --- a/packages/compiler/src/frontend/npm-static-namespace-defaults.test.ts +++ b/packages/compiler/src/frontend/npm-static-namespace-defaults.test.ts @@ -60,4 +60,21 @@ test("any other write, declaration or earlier mention keeps the text", () => { expect(rewrite("let ns = ns || {};\n")).toBeNull(); // Not the declared name on the left. expect(rewrite("var ns = other || {};\n")).toBeNull(); + // Written through a destructuring assignment, here by a hoisted function + // that runs before the declaration. + expect( + rewrite("init();\nvar ns = ns || {};\nfunction init() { [ns] = [{ a: 1 }]; }\n"), + ).toBeNull(); + expect( + rewrite("init();\nvar ns = ns || {};\nfunction init() { ({ ns } = { ns: { a: 1 } }); }\n"), + ).toBeNull(); + expect(rewrite("var ns = ns || {};\nfor ([ns] of [[{}]]);\n")).toBeNull(); +}); + +test("a name the CommonJS wrapper defines keeps the text", () => { + // `exports` is the module's export object here, not undefined. + const source = "var exports = exports || {};\nexports.answer = 42;\n"; + expect(rewrite(source)).toBeNull(); + expect(rewrite("var module = module || {};\n")).toBeNull(); + expect(rewrite("var require = require || null;\n")).toBeNull(); }); diff --git a/packages/compiler/src/frontend/npm-static-namespace-defaults.ts b/packages/compiler/src/frontend/npm-static-namespace-defaults.ts index 2e4bf046c..26a8e4cd0 100644 --- a/packages/compiler/src/frontend/npm-static-namespace-defaults.ts +++ b/packages/compiler/src/frontend/npm-static-namespace-defaults.ts @@ -6,8 +6,9 @@ * read in its own initializer sees `undefined` unless something writes the * variable first. When nothing else in the file ever writes it (no other * declaration, assignment or update of that name anywhere, no function or - * class of that name) and no earlier top-level statement mentions it, the - * initializer is its right operand: `var ns = {};`. Inference then types + * class of that name), no earlier top-level statement mentions it, and it + * is not a name a CommonJS module's wrapper defines (`exports`, `module`, + * ...), the initializer is its right operand: `var ns = {};`. Inference then types * the namespace from that literal and the members later statements add, * instead of `any` (the self-reference makes the checker give up). `??` * is the same. Anything else keeps the original text. */ @@ -15,6 +16,18 @@ import * as ts from "./ts7/syntax.js"; import { tokenStartOfNode } from "./ts7/node-functions.js"; import { applyEdits, unparen, type Edit } from "./npm-static-bundled-chunks.js"; +/** Names a CommonJS module's wrapper function defines before its first + * statement: its parameters and its arguments object. `var exports = + * exports || {}` keeps the module's export object, not a fresh one. */ +const COMMONJS_WRAPPER_NAMES = new Set([ + "exports", + "require", + "module", + "__filename", + "__dirname", + "arguments", +]); + /** A cheap probe: some `var X = X ||` or `?? ` may be present. */ export function mayHoldNamespaceDefault(source: string): boolean { return /\bvar\s+([A-Za-z_$][\w$]*)\s*=\s*\(?\s*\1\s*(\|\||\?\?)/.test(source); @@ -42,6 +55,7 @@ export function rewriteNamespaceDefaults(sf: ts.SourceFile): string | null { continue; const left = unparen(init.left); if (!ts.isIdentifier(left) || left.text !== name) continue; + if (COMMONJS_WRAPPER_NAMES.has(name)) continue; if (mentionedBefore(stmts, i, name) || writtenElsewhere(sf, name, decl)) continue; const right = init.right; edits.push({ @@ -82,7 +96,7 @@ function writtenElsewhere(sf: ts.SourceFile, name: string, decl: ts.VariableDecl if (written) return "stop"; if (n === decl.name) return undefined; if (ts.isBinaryExpression(n) && isAssignmentOperator(n.operatorToken.kind)) { - if (isName(n.left, name)) written = true; + if (assignsName(n.left, name)) written = true; } else if ( (ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && (n.operator === ts.SyntaxKind.PlusPlusToken || @@ -108,7 +122,7 @@ function writtenElsewhere(sf: ts.SourceFile, name: string, decl: ts.VariableDecl } else if ( (ts.isForInStatement(n) || ts.isForOfStatement(n)) && !ts.isVariableDeclarationList(n.initializer) && - isName(n.initializer, name) + assignsName(n.initializer, name) ) { written = true; } @@ -128,6 +142,15 @@ function bindsName(binding: ts.Node, name: string): boolean { return found; } +/** Whether an assignment target may write `name`: the name itself, or a + * destructuring pattern that mentions it anywhere (over-approximated: a + * property key of that name counts too). */ +function assignsName(target: ts.Node, name: string): boolean { + const t = unparen(target as ts.Expression); + if (ts.isArrayLiteralExpression(t) || ts.isObjectLiteralExpression(t)) return bindsName(t, name); + return isName(t, name); +} + function isName(node: ts.Node, name: string): boolean { const n = unparen(node as ts.Expression); return ts.isIdentifier(n) && n.text === name; diff --git a/packages/compiler/src/frontend/package-shims.ts b/packages/compiler/src/frontend/package-shims.ts index d26579719..34d8461dc 100644 --- a/packages/compiler/src/frontend/package-shims.ts +++ b/packages/compiler/src/frontend/package-shims.ts @@ -42,6 +42,7 @@ import { dirname, join, resolve } from "node:path"; import { packageShimRoot, tsgoPath } from "./dts-paths.js"; import { trackedDirectoryExists, trackedReadFile, trackedRealpath } from "./input-tracker.js"; +import { readProjectConfig } from "../project-config.js"; import { packageNameOfSpecifier } from "./workspace-registry.js"; import { BUILTIN_ALIAS_PACKAGES, @@ -55,6 +56,10 @@ interface ImplementationShim { /** Supported major versions and their entry modules, relative to the * shim root. */ versions: ReadonlyMap; + /** Major versions without an entry module that `--npm-static` compiles + * from the package's own published JavaScript. An installed version that + * is in neither list stays out of the program. */ + sourceVersions?: ReadonlySet; /** The entry has a default export (a CommonJS package's whole export * object, which a default import reads). */ defaultExport?: boolean; @@ -98,6 +103,7 @@ const IMPLEMENTATION_SHIMS: ReadonlyMap = new Map< mainFile: "source/index.js", typesFile: "source/index.d.ts", versions: new Map([[5, "chalk/v5.ts"]]), + sourceVersions: new Set([6]), }, ], [ @@ -199,45 +205,15 @@ export function packageShimEntries(): string[] { let unloadedPackages: ReadonlySet = new Set(); /** Reads the project's package-shim configuration: the `scriptc.packages` - * map of the entry's nearest package.json. Called once per program load. */ + * entries marked "unloaded", and those with `"shapes": false`, in the + * entry's nearest package.json (project-config.ts validates the rest). + * Called once per program load. */ export function configurePackageShims(entryPath: string): void { const packages = new Set(); const noShapes = new Set(); - for (let dir = dirname(resolve(entryPath)); ;) { - const text = trackedReadFile(join(dir, "package.json")); - if (text !== null) { - let parsed: unknown = null; - try { - parsed = JSON.parse(text); - } catch { - parsed = null; - } - const config = - parsed !== null && typeof parsed === "object" - ? (parsed as Record)["scriptc"] - : undefined; - const map = - config !== null && typeof config === "object" - ? (config as Record)["packages"] - : undefined; - if (map !== null && typeof map === "object") { - for (const [name, kind] of Object.entries(map as Record)) { - if (kind === "unloaded") packages.add(name); - // `{ "shapes": false }`: compile the package without shape - // inference's typed overlay. - if ( - kind !== null && - typeof kind === "object" && - (kind as Record)["shapes"] === false - ) - noShapes.add(name); - } - } - break; - } - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; + for (const [name, setting] of readProjectConfig(entryPath).packages) { + if (setting.unloaded) packages.add(name); + if (!setting.shapes) noShapes.add(name); } unloadedPackages = packages; shapelessPackages = noShapes; @@ -251,10 +227,17 @@ export function isShapelessPackage(name: string): boolean { return shapelessPackages.has(name); } -/** True for a package name the registry implements or the project marks - * never loaded: such a package never compiles its published JavaScript. */ -export function isPackageShimName(name: string): boolean { - return IMPLEMENTATION_SHIMS.has(name) || unloadedPackages.has(name); +/** True when `--npm-static` leaves the package `name` out of its package + * list: the project marks it never loaded, or the registry implements it + * and the version installed for `entryPath` has an entry module or is not + * one of the entry's `sourceVersions`. */ +export function npmStaticOmitsPackage(entryPath: string, name: string): boolean { + if (unloadedPackages.has(name)) return true; + const shim = IMPLEMENTATION_SHIMS.get(name); + if (shim === undefined) return false; + const dir = installedPackageDir(entryPath, name); + const major = dir === null ? null : installedMajorVersion(dir, name); + return major === null || shim.versions.has(major) || !shim.sourceVersions?.has(major); } /** True when `specifier` names a package the project marks never loaded. */ @@ -290,9 +273,9 @@ function installedPackageDir(fromFile: string, name: string): string | null { return found; } -/** The shim entry for an installed package directory, or null when the - * directory is not a registered package at a supported major version. */ -function shimEntryForPackageDir(pkgDir: string, expectedName: string | null): string | null { +/** The installed package's name and major version from its package.json, + * or null when either is missing. */ +function installedPackageVersion(pkgDir: string): { name: string; major: number } | null { const text = trackedReadFile(join(pkgDir, "package.json")); if (text === null) return null; let pkg: { name?: unknown; version?: unknown }; @@ -302,11 +285,24 @@ function shimEntryForPackageDir(pkgDir: string, expectedName: string | null): st return null; } if (typeof pkg.name !== "string" || typeof pkg.version !== "string") return null; - if (expectedName !== null && pkg.name !== expectedName) return null; - const shim = IMPLEMENTATION_SHIMS.get(pkg.name); + return { name: pkg.name, major: Number(/^(\d+)\./.exec(pkg.version)?.[1] ?? NaN) }; +} + +/** The major version of the package `name` installed at `pkgDir`, or null. */ +function installedMajorVersion(pkgDir: string, name: string): number | null { + const installed = installedPackageVersion(pkgDir); + return installed === null || installed.name !== name ? null : installed.major; +} + +/** The shim entry for an installed package directory, or null when the + * directory is not a registered package at a supported major version. */ +function shimEntryForPackageDir(pkgDir: string, expectedName: string | null): string | null { + const installed = installedPackageVersion(pkgDir); + if (installed === null) return null; + if (expectedName !== null && installed.name !== expectedName) return null; + const shim = IMPLEMENTATION_SHIMS.get(installed.name); if (shim === undefined) return null; - const major = Number(/^(\d+)\./.exec(pkg.version)?.[1] ?? NaN); - const entry = shim.versions.get(major); + const entry = shim.versions.get(installed.major); if (entry === undefined) return null; return join(packageShimRoot(), shim.variant?.(pkgDir) ?? entry); } diff --git a/packages/compiler/src/frontend/pipeline-native.ts b/packages/compiler/src/frontend/pipeline-native.ts index 511fd62f2..f2df52608 100644 --- a/packages/compiler/src/frontend/pipeline-native.ts +++ b/packages/compiler/src/frontend/pipeline-native.ts @@ -1,4 +1,4 @@ -import { runFrontend, type Frontend } from "./pipeline.js"; +import { runFrontend, type Frontend, type FrontendOptions } from "./pipeline.js"; import { loadProgram } from "./program.js"; import { FrontendServices, type ComptimeEvaluator } from "./services.js"; import { createNativeTs7Api } from "./ts7/native-api.js"; @@ -13,6 +13,7 @@ export function runNativeFrontend( externalTypes?: Readonly>, evaluateComptime?: ComptimeEvaluator, libraryNpmStatic: readonly string[] = [], + options: FrontendOptions = {}, ): Frontend { const services = new FrontendServices( (options) => createNativeTs7Api({ ...options, executable }), @@ -31,6 +32,7 @@ export function runNativeFrontend( npmStatic, externalTypes, libraryNpmStatic, + options, ); return { ...frontend, diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index e4b27af86..0e6b47a6a 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -57,6 +57,14 @@ import { CONTRACT_TYPES_MODULE } from "./contract-types.js"; import { THREADS_MODULE } from "./threads-import.js"; import { erasedTypeOnlyImport } from "./type-only-imports.js"; import { EXPERIMENTAL_MODULE } from "./experimental-import.js"; +import { readProjectConfig } from "../project-config.js"; +import { packageDirOf } from "../node-islands/origin.js"; +import { + scanFiles, + type NodeIslandScan, + type RefusalKind, + type RefusalRecord, +} from "../node-islands/scan.js"; /** Where a package-anchored preflight diagnostic sits, as * `:` — the SC2013 refusal that drops the package @@ -117,17 +125,33 @@ export interface Frontend { npmStatic: NpmStaticStatus[]; /** Each judged npm package's first import site, used to anchor refusals. */ npmImportSites: ReadonlyMap; + /** The Node island detector's input (report-only): the program's + * modules with their pre-scan markers, package import edges, and how + * each refused package was refused. Call before dispose. */ + nodeIslandScan?: ((opts: { userJs: boolean }) => NodeIslandScan) | undefined; + /** Diagnostics that do not fail a build: unknown settings in the + * project's scriptc configuration (SC8004), which builds do not use. */ + warnings?: ScrDiagnostic[]; /** Releases the frontend's resources (the spawned tsgo server). Call * exactly once, after the last lower(). */ dispose: () => void; } /** Hosts own transport lifetimes; shared compiler stages own the frontend. */ +/** What a frontend computes beyond the build's own needs. */ +export interface FrontendOptions { + /** The Node island report (coverage): the project's scriptc settings and + * the detector's scan of the program and its npm packages. Builds do not + * read either. */ + nodeIslands?: boolean; +} + export type FrontendFactory = ( entryPath: string, npmStatic?: readonly string[] | "auto" | "lib", externalTypes?: Readonly>, libraryNpmStatic?: readonly string[], + options?: FrontendOptions, ) => Frontend; /** Adds the program modules a literal `import()` reaches to `scanned`, @@ -398,12 +422,37 @@ function packagesNamedByDiag(message: string, optedIn: ReadonlySet): Set return hits; } +/** The modules of packages the drop-and-reload loop is about to remove, + * scanned on the program that still holds them. Report-only: a detector + * failure here never changes the build's outcome. */ +function scanDroppedPackages( + load: LoadResult, + packages: readonly string[], + into: { modules: NodeIslandScan["modules"]; edges: NodeIslandScan["edges"] }, +): void { + if (packages.length === 0) return; + try { + const wanted = new Set(packages); + const files = load.program.getImplementationSourceFiles().filter((sf) => { + if (sf.isDeclarationFile) return false; + const at = packageDirOf(sf.fileName); + return at !== null && wanted.has(at.name); + }); + const scanned = scanFiles(load.program, files, { userJs: false, dropped: true, edges: true }); + into.modules.push(...scanned.modules); + into.edges.push(...scanned.edges); + } catch { + /* report-only detection */ + } +} + export function runFrontend( entryPath: string, loadProgram: ProgramLoader, npmStatic?: readonly string[] | "auto" | "lib", externalTypes?: Readonly>, libraryNpmStatic: readonly string[] = [], + options: FrontendOptions = {}, ): Frontend { // A preflight or package probe may throw after its host has opened. Track // only live loads, releasing each immediately on normal fallback; this @@ -443,6 +492,7 @@ export function runFrontend( externalTypes, [...new Set(names)], transitive, + options, ); } catch (error) { for (const load of active) { @@ -468,6 +518,7 @@ function loadFrontend( externalTypes?: Readonly>, libraryNpmStatic: readonly string[] = [], transitive: Set = new Set(), + options: FrontendOptions = {}, ): Frontend { // Resolver package/workspace metadata is intentionally shared across the // several load attempts of ONE auto-detection fixpoint, but never across @@ -477,6 +528,16 @@ function loadFrontend( beginShapeOverlayBuild(); const statuses: NpmStaticStatus[] = []; const npmSites = new Map(); + // Node island detection (report-only): how each package was refused, and + // the modules of packages removed from the program. + const refusals: RefusalRecord[] = []; + const droppedScan: { modules: NodeIslandScan["modules"]; edges: NodeIslandScan["edges"] } = { + modules: [], + edges: [], + }; + const recordRefusal = (pkg: string, kind: RefusalKind, diag: ScrDiagnostic | null): void => { + refusals.push({ package: pkg, kind, diag }); + }; // Explicit library attempts bypass only the auto-selection heuristics. // The shared preflight/lowering refusal and dependency-closure checks remain. const judged = new Set(libraryNpmStatic); @@ -532,6 +593,7 @@ function loadFrontend( status: "refused", detail: `mapped as an external host module by --external-types (${specs.map((s) => JSON.stringify(s)).join(", ")})`, }); + recordRefusal(pkg, "external", null); return false; }); } @@ -591,19 +653,25 @@ function loadFrontend( const typecheckRefused = new Set(); while (effective.size > 0) { const reasons = new Map(npmStaticOffenders()); + const kinds = new Map(); + for (const pkg of reasons.keys()) kinds.set(pkg, { kind: "offender", diag: null }); // Bundler interop the rewrite could not finish refuses its package // only where the compiled program can load the file. if (npmStaticFileDegrades().size > 0) { const loadable = npmStaticLoadableFiles(load.program, load.moduleOrder); for (const [file, { pkg, reason }] of npmStaticFileDegrades()) { - if (!reasons.has(pkg) && (loadable === null || loadable.has(file))) + if (!reasons.has(pkg) && (loadable === null || loadable.has(file))) { reasons.set(pkg, reason); + kinds.set(pkg, { kind: "degrade", diag: null }); + } } } for (const d of preflight) { const pkg = npmStaticPackageOfPath(d.loc.file); - if (pkg !== null && !reasons.has(pkg)) + if (pkg !== null && !reasons.has(pkg)) { reasons.set(pkg, `${d.code} at ${packageDiagWhere(load, d.loc)}: ${d.message}`); + kinds.set(pkg, { kind: "preflight", diag: d }); + } } if (![...reasons.keys()].some((p) => effective.has(p))) { const named = new Map(); @@ -615,6 +683,7 @@ function loadFrontend( } for (const [pkg, count] of named) { typecheckRefused.add(pkg); + kinds.set(pkg, { kind: "typecheck", diag: null }); reasons.set( pkg, `its inferred export surface breaks ${count} import site${count === 1 ? "" : "s"} in program files (bundler-emitted surfaces type only as far as inference reaches)`, @@ -623,9 +692,12 @@ function loadFrontend( } const dropping = [...reasons.keys()].filter((p) => effective.has(p)); if (dropping.length === 0) break; + if (options.nodeIslands === true) scanDroppedPackages(load, dropping, droppedScan); for (const p of dropping) { effective.delete(p); statuses.push({ package: p, status: "refused", detail: reasons.get(p)! }); + const kind = kinds.get(p); + recordRefusal(p, kind?.kind ?? "offender", kind?.diag ?? null); } load.dispose(); load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes }); @@ -645,9 +717,12 @@ function loadFrontend( // graceful per-package degradation, never a failed gate the user cannot // act on (the note carries the why). if (effective.size > 0 && preflight.some((d) => d.code === "SC0001")) { + const lastResortDropped: string[] = []; const dropWithNote = (p: string): void => { effective.delete(p); typecheckRefused.add(p); + lastResortDropped.push(p); + recordRefusal(p, "typecheck", null); statuses.push({ package: p, status: "refused", @@ -669,11 +744,15 @@ function loadFrontend( probe.dispose(); if (probeDiags.some((d) => d.code === "SC0001")) dropWithNote(p); } + if (options.nodeIslands === true) + scanDroppedPackages(load, lastResortDropped.splice(0), droppedScan); load.dispose(); load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes }); preflight = checkPreflight(load); if (preflight.some((d) => d.code === "SC0001") && effective.size > 0) { for (const p of [...effective]) dropWithNote(p); + if (options.nodeIslands === true) + scanDroppedPackages(load, lastResortDropped.splice(0), droppedScan); load.dispose(); load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes }); preflight = checkPreflight(load); @@ -696,8 +775,16 @@ function loadFrontend( // The lowering may reload once the overlay drops refused annotations. let finalLoad = load; const finalNpmStatic = [...effective]; + // An unknown key or value in the project's scriptc configuration makes + // the Node island report unavailable (SC8004). The settings configure + // only that report: a build warns about them and goes on. + const projectConfig = readProjectConfig(entryPath); + const configDiagnostics = projectConfig.diagnostics; + const report = options.nodeIslands === true; return { - preflight, + preflight: + report && configDiagnostics.length > 0 ? [...configDiagnostics, ...preflight] : preflight, + ...(!report && configDiagnostics.length > 0 ? { warnings: configDiagnostics } : {}), entryText: () => finalLoad.entry.text, entryExports: () => entryFunctionExports(finalLoad.entry), // The contract scans the PROGRAM's source files, not the runtime @@ -724,14 +811,23 @@ function loadFrontend( // are in moduleOrder like any program module, so their bytes join the // library identity hashes (source_hash/build_id) — compiled code is // identity, whatever directory it came from. - sourceTexts: () => - new Map( + sourceTexts: () => { + const texts = new Map( [ finalLoad.entry, ...finalLoad.moduleOrder, ...finalLoad.program.getImplementationSourceFiles().filter((sf) => !sf.isDeclarationFile), ].map((sf) => [sf.fileName, sf.text]), - ), + ); + // SC8004 diagnostics point into the project's package.json. + if ( + configDiagnostics.length > 0 && + projectConfig.manifest !== null && + projectConfig.manifestText !== null + ) + texts.set(projectConfig.manifest, projectConfig.manifestText); + return texts; + }, lower: (opts, valid) => { const lowerLoad = (l: typeof finalLoad): LowerResult => { if (l.shapeOverlayState !== undefined) restoreShapeOverlayState(l.shapeOverlayState); @@ -919,6 +1015,29 @@ function loadFrontend( }, npmStatic: statuses, npmImportSites: npmSites, + nodeIslandScan: + options.nodeIslands !== true + ? undefined + : ({ userJs }) => { + const files = new Set([finalLoad.entry, ...finalLoad.moduleOrder]); + addDynamicImportGraph(finalLoad, files); + // Package edges matter only when the program has npm packages: one + // that loads, one that was refused, or one judged and dropped. + const npm = + statuses.length > 0 || + droppedScan.modules.length > 0 || + [...files].some((sf) => sf.fileName.includes("/node_modules/")); + const scanned = scanFiles(finalLoad.program, [...files], { + userJs, + dropped: false, + edges: npm, + }); + return { + modules: [...droppedScan.modules, ...scanned.modules], + edges: [...droppedScan.edges, ...scanned.edges], + refusals, + }; + }, dispose: () => finalLoad.dispose(), }; } diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 5a8d40696..aedbe4e09 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -96,8 +96,8 @@ import type { import { configurePackageShims, isPackageShimFile, - isPackageShimName, isShimmedImport, + npmStaticOmitsPackage, } from "./package-shims.js"; import { provenanceEntryFor, provenancePaths } from "./provenance-registry.js"; import { cjsLexedExportsOfFile, cjsVisibleNames } from "./cjs-syntax.js"; @@ -499,43 +499,33 @@ function visitNpmStaticDeclarationClosure( visit(entryPath); } -function npmStaticDeclarationOverloadsOf( +/** The overloads and properties of the classes a package's declaration + * closure declares, read from one parse of each declaration file. */ +function npmStaticDeclarationMetadataOf( services: FrontendServices, entryPath: string, -): NpmStaticDeclarationOverloads { - const classes = new Map>(); +): { overloads: NpmStaticDeclarationOverloads; properties: NpmStaticDeclarationProperties } { + const overloads = new Map>(); + const properties = new Map>(); visitNpmStaticDeclarationClosure(services, entryPath, (path, source) => { - for (const [className, methods] of parseNpmStaticDeclarationOverloads( - services.parse(path, source, "ts"), - )) { + const sourceFile = services.parse(path, source, "ts"); + for (const [className, methods] of parseNpmStaticDeclarationOverloads(sourceFile)) { const target = - classes.get(className) ?? new Map(); - classes.set(className, target); + overloads.get(className) ?? new Map(); + overloads.set(className, target); for (const [methodName, signatures] of methods) { if (!target.has(methodName)) target.set(methodName, signatures); } } - }); - return classes; -} - -function npmStaticDeclarationPropertiesOf( - services: FrontendServices, - entryPath: string, -): NpmStaticDeclarationProperties { - const classes = new Map>(); - visitNpmStaticDeclarationClosure(services, entryPath, (path, source) => { - for (const [className, properties] of parseNpmStaticDeclarationProperties( - services.parse(path, source, "ts"), - )) { - const target = classes.get(className) ?? new Map(); - classes.set(className, target); - for (const [propertyName, type] of properties) { + for (const [className, fields] of parseNpmStaticDeclarationProperties(sourceFile)) { + const target = properties.get(className) ?? new Map(); + properties.set(className, target); + for (const [propertyName, type] of fields) { if (!target.has(propertyName)) target.set(propertyName, type); } } }); - return classes; + return { overloads, properties }; } /** See LoadResult.startupCrash: Node's exact error message, the IR error @@ -1013,10 +1003,8 @@ function loadProgram7( } } // Shape inference's overlay: package files typed from proven facts. The - // tsc gate keeps reading the files as written. - // A load without the overlay must not see an earlier load's overlay - // files (the registry is per process). - resetShapeOverlay(); + // tsc gate keeps reading the files as written. loadProgram resets the + // overlay registry before the lowering world's load. const overlayOriginals = new Map(); if (overlayer !== undefined && jsx.refusal === null) { // Rounds: structural rewrites, then the typed overlay of the rebuilt @@ -1156,12 +1144,13 @@ export function loadProgram( } externalTypes.set(specifier, declarationPath); } - // Packages scriptc implements itself or the project marks never loaded - // (package-shims.ts) never compile their published JavaScript, even when - // named. + // Packages scriptc implements itself, and packages the project marks + // never loaded (package-shims.ts), never compile their published + // JavaScript, even when named. A registry entry's `sourceVersions` + // (chalk 6) compile from their own source. configurePackageShims(entryPath); const npmStaticPackages = [...new Set(opts?.npmStatic ?? [])].filter( - (name) => !isPackageShimName(name), + (name) => !npmStaticOmitsPackage(entryPath, name), ); // Resolve authored declaration entries before enabling the JS-only // package resolver. Safe overload metadata binds through the runtime @@ -1179,8 +1168,7 @@ export function loadProgram( ) { continue; } - const overloads = npmStaticDeclarationOverloadsOf(services, resolved.typesFile); - const properties = npmStaticDeclarationPropertiesOf(services, resolved.typesFile); + const { overloads, properties } = npmStaticDeclarationMetadataOf(services, resolved.typesFile); const classNames = new Set([...overloads.keys(), ...properties.keys()]); if (classNames.size === 0) continue; const runtime = resolveBareModule(entryPath, pkg, "js-only"); @@ -1337,6 +1325,12 @@ export function loadProgram( return projection; }; try { + // The overlay registry is per process and describes the lowering + // world's load: a load without the overlay must not see an earlier + // load's overlay files. The authored world (authored()) is a second load + // of the same build, which preflight can create after the overlay + // rounds, so it leaves the registry as this load set it. + resetShapeOverlay(); const load = loadProgram7( host, entryPath, @@ -1385,10 +1379,9 @@ export function loadProgram( * packages whose declarations the boundary projects from. */ function declBoundaryUserFiles(program: ts.Program, packages: readonly string[]): string[] { const out: string[] = []; - for (const sf of program.getSourceFiles()) { + for (const sf of program.getImplementationSourceFiles()) { const name = sf.fileName.split("\\").join("/"); - if (sf.isDeclarationFile || name.includes("/node_modules/") || !/\.[mc]?tsx?$/.test(name)) - continue; + if (name.includes("/node_modules/") || !/\.[mc]?tsx?$/.test(name)) continue; if (name.endsWith(".tsx")) continue; // JSX files: not projected yet const text = sf.text; if ( @@ -1468,9 +1461,16 @@ function suppressedJsStrictness7(d: ts.Diagnostic): boolean { return ts.flattenDiagnosticMessageText(d, "\n").includes("'any'"); } // The deprecated Buffer(...) call without `new`, which Node's Buffer - // function accepts (containers/bytes.ts lowers it). + // function accepts (containers/bytes.ts lowers it), and fs.ReadStream(...) + // and fs.WriteStream(...), which construct their stream + // (builtins/fs-streams.ts). if (d.code === 2348) { - return ts.flattenDiagnosticMessageText(d, "\n").includes("'BufferConstructor'"); + const text = ts.flattenDiagnosticMessageText(d, "\n"); + return ( + text.includes("'BufferConstructor'") || + text.includes("'typeof ReadStream'") || + text.includes("'typeof WriteStream'") + ); } return false; } @@ -2608,12 +2608,27 @@ export function isCreateRequireBinding7(program: ts.Program, callee: ts.Identifi ); } -/** Only literal calls and literal resolution queries can erase the loader - * value. In particular, assignments, exported aliases, shorthand properties, - * optional calls, and passing the loader to user code cannot pass this test. */ -function staticCreateRequireUse7(expr: ts.Expression): boolean { +/** How a use of a createRequire loader lowers: + * - "static": a literal call or a literal resolution query, which erases + * the loader value. + * - "deferred": a call with a computed specifier or another argument shape, + * or `resolve` / `resolve.paths` read as a value, inside the try block + * of a try statement in the same function. The call throws a runtime + * fence, and the resolve value is a function that throws one when called + * (module-resolution.ts), so the statement's catch clause handles them + * (packages that look for optional native addons). + * - "computed": the same shapes outside a try block. + * - "escaping": the loader itself as a value, writes, optional calls, and + * other members. */ +const COMPUTED_CREATE_REQUIRE_REASON = + "its createRequire result is used with a computed specifier, or its resolve as a value, outside a try block (static requires need literal specifiers)"; + +type CreateRequireUse7 = "static" | "deferred" | "computed" | "escaping"; + +function createRequireUse7(expr: ts.Expression): CreateRequireUse7 { let use: ts.Node = expr; while (ts.isParenthesizedExpression(use.parent)) use = use.parent; + let member = false; if ( ts.isPropertyAccessExpression(use.parent) && use.parent.expression === use && @@ -2621,6 +2636,7 @@ function staticCreateRequireUse7(expr: ts.Expression): boolean { !use.parent.questionDotToken ) { use = use.parent; + member = true; if ( ts.isPropertyAccessExpression(use.parent) && use.parent.expression === use && @@ -2630,15 +2646,42 @@ function staticCreateRequireUse7(expr: ts.Expression): boolean { use = use.parent; } const call = use.parent; + if (ts.isCallExpression(call) && call.expression === use) { + if (call.questionDotToken) return "escaping"; + if (call.arguments.nodes.length === 1 && ts.isStringLiteralLike(call.arguments.nodes[0]!)) + return "static"; + } else if (!member || isAssignmentTarget7(use)) { + return "escaping"; + } + return insideTryBlock7(use) ? "deferred" : "computed"; +} + +function isAssignmentTarget7(node: ts.Node): boolean { + const parent = node.parent; return ( - ts.isCallExpression(call) && - call.expression === use && - !call.questionDotToken && - call.arguments.nodes.length === 1 && - ts.isStringLiteralLike(call.arguments.nodes[0]!) + ts.isBinaryExpression(parent) && + parent.left === node && + parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment ); } +/** True when `node` is inside the try block of a try statement in the same + * function (a catch clause or finally block does not count). A class field + * initializer runs when the class is instantiated, so it is a boundary too. */ +function insideTryBlock7(node: ts.Node): boolean { + for (let child = node, parent = node.parent; parent; child = parent, parent = parent.parent) { + if (ts.isTryStatement(parent) && parent.tryBlock === child) return true; + if ( + ts.isFunctionLike(parent) || + ts.isClassStaticBlockDeclaration(parent) || + ts.isPropertyDeclaration(parent) + ) + return false; + } + return false; +} + function stableCreateRequireBindingReason7( program: ts.Program, decl: ts.VariableDeclaration, @@ -2671,9 +2714,10 @@ function stableCreateRequireBindingReason7( : checker.getSymbolAtLocation(ref); if (target && (target.flags & ts.SymbolFlags.Alias) !== 0) target = checker.getAliasedSymbol(target); - if (target === symbol && !staticCreateRequireUse7(ref)) { - return "its createRequire binding is reassigned, escapes, or uses a computed specifier"; - } + if (target !== symbol) continue; + const use = createRequireUse7(ref); + if (use === "computed") return COMPUTED_CREATE_REQUIRE_REASON; + if (use === "escaping") return "its createRequire binding is reassigned or escapes"; } const preceding = stmt.declarationList.declarations.nodes.slice( 0, @@ -2736,10 +2780,10 @@ function npmStaticModuleImportReason7( checker.prefetchSymbolNodesExact(identifierOccurrences7(sf, specifier.name.text)); let reason: string | null = null; const checkUse = (expr: ts.Expression): void => { - if (!staticCreateRequireUse7(expr)) { - reason = - "its createRequire result escapes or is used with a computed specifier (static requires need literal specifiers)"; - } + const use = createRequireUse7(expr); + if (use === "computed") reason = COMPUTED_CREATE_REQUIRE_REASON; + else if (use === "escaping") + reason = "its createRequire result escapes (static requires need literal specifiers)"; }; ts.walkPreorder(sf, (node) => { if ( @@ -5447,6 +5491,16 @@ function preflight7(load: LoadResult): { return new RegExp(`\\btypeof\\s*\\(?\\s*${name.replace(/\$/g, "\\$")}(?![\\w$])`).test(sf.text); }; + // A `return` at the top level of a CommonJS module (TS1108): Node runs + // the module as a function body, so the return ends the module's + // evaluation. ES modules and TypeScript keep the error. + const cjsTopLevelReturnSuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => { + if (d.code !== 1108 || d.fileName === undefined || !isJsSourceFileName(d.fileName)) + return false; + const sf = p.getSourceFile(d.fileName); + return sf !== undefined && isCjsJsFile7(sf, p); + }; + const workspaceImplicitAnySuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => { if (d.code !== 7016 || d.fileName === undefined || d.pos === undefined || d.end === undefined) return false; @@ -5493,6 +5547,7 @@ function preflight7(load: LoadResult): { !isNpmStaticSubclassArgument(p, d) && !workspaceImplicitAnySuppressed(p, d) && !typeofTestedNameSuppressed(p, d) && + !cjsTopLevelReturnSuppressed(p, d) && !jsdocTypeSuppressed(p, d, commentDup), ); }; @@ -6030,9 +6085,15 @@ function preflight7(load: LoadResult): { // to Node's no-op, the module still evaluates through its // ordinary init edge, and no member is ever read through the // lexer facade (self-imports of a CJS module included — the - // partially-built exports are never observed). Any OTHER use - // keeps the fence. - if (!nsBindingUsesAreBareStatements7(program, sf, clause.namedBindings.name)) { + // partially-built exports are never observed). In JavaScript + // sources every use passes here: member reads lower through the + // names Node's lexer detects, and the other reads refuse at + // their site (lower-namespaces.ts, cjsNamespaceImportDep). Any + // OTHER use in TypeScript keeps the fence. + if ( + !isJsSourceFileName(sf.fileName) && + !nsBindingUsesAreBareStatements7(program, sf, clause.namedBindings.name) + ) { diags.push( unsupportedDiag( "SC1013", @@ -6461,6 +6522,108 @@ function preflight7(load: LoadResult): { * Node refuses the program). This walk asks Node's question at compile * time, in Node's exact ORDER, and answers with Node's exact message. */ +/** The CommonJS program file a re-export (`module.exports = require(spec)`) + * of `from` resolves to, or null: reexport targets union in only when they + * resolve to CommonJS program files (Node's cjsPreparseModuleExports rule). */ +function cjsReexportDep7(program: ts.Program) { + return (from: ts.SourceFile, spec: string): ts.SourceFile | null => { + const dep = + resolveImport7(program, from, spec, "require") ?? + npmStaticDepSf7(program, from, spec, "require"); + return dep !== null && isCjsJsFile7(dep, program) ? dep : null; + }; +} + +/** Node's lexer analysis of a CommonJS file. A bundler rewrite spells some + * lexer-visible names as accessors. */ +function lexWithAccessorNames(sf: ts.SourceFile): ReturnType { + const lexed = cjsLexedExportsOfFile(sf); + for (const name of npmStaticLexerVisibleNames(sf.fileName)) lexed.exports.add(name); + return lexed; +} + +/** The property names of Node's namespace object for the CommonJS module + * `dep`: the names its lexer detects, CommonJS re-exports included (the + * rule of the named-import link check), plus `default` and + * `module.exports`. */ +/** `dep` and every CommonJS module it re-exports, transitively (the modules + * whose exports its namespace object shows). */ +export function cjsReexportedModules(program: ts.Program, dep: ts.SourceFile): ts.SourceFile[] { + const resolveDep = cjsReexportDep7(program); + const seen = new Set([dep]); + const order = [dep]; + for (let i = 0; i < order.length; i++) { + const mod = order[i]!; + for (const spec of lexWithAccessorNames(mod).reexports) { + const next = resolveDep(mod, spec); + if (next !== null && !seen.has(next)) { + seen.add(next); + order.push(next); + } + } + } + return order; +} + +export function cjsNamespaceExportNames(program: ts.Program, dep: ts.SourceFile): Set { + const names = cjsVisibleNames(dep, lexWithAccessorNames, cjsReexportDep7(program), new Map()); + return new Set([...names, "default", "module.exports"]); +} + +/** The export names a CommonJS module assigns inside functions + * (`exports.current = value` in a function body), or "all" when such code + * writes a computed name, replaces module.exports or redefines exports + * through Object.defineProperty. Node's namespace object of the module + * holds the values the exports had when the module finished evaluating, + * so it does not see those writes. */ +export function cjsExportsAssignedInFunctions(sf: ts.SourceFile): ReadonlySet | "all" { + const names = new Set(); + let all = false; + const exportsObject = (e: ts.Expression): boolean => + (ts.isIdentifier(e) && e.text === "exports") || isModuleExportsAccess(e); + const target = (e: ts.Expression): void => { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (isModuleExportsAccess(e)) all = true; + else if (ts.isPropertyAccessExpression(e) && exportsObject(e.expression)) + names.add(e.name.text); + else if (ts.isElementAccessExpression(e) && exportsObject(e.expression)) { + if (ts.isStringLiteralLike(e.argumentExpression)) names.add(e.argumentExpression.text); + else all = true; + } + }; + const visit = (node: ts.Node, inFunction: boolean): void => { + if (all) return; + const nested = inFunction || ts.isFunctionLike(node); + if (nested) { + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) + target(node.left); + else if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) + ) + target(node.operand); + else if (ts.isDeleteExpression(node)) target(node.expression); + else if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === "Object" && + node.arguments.nodes[0] !== undefined && + exportsObject(node.arguments.nodes[0]) + ) + all = true; + } + ts.forEachChild(node, (child) => visit(child, nested)); + }; + visit(sf, false); + return all ? "all" : names; +} + interface BadCjsImport { /** The export name requested from the CJS module. */ exportName: string; @@ -6501,25 +6664,14 @@ function cjsNamedImportLinkCheck( const npmStatic = npmStaticDepSf7(program, from, spec); return npmStatic; }; - // Reexport targets union in only when they resolve to CommonJS program - // files (Node's cjsPreparseModuleExports rule). - const resolveCjsDep = (from: ts.SourceFile, spec: string): ts.SourceFile | null => { - const dep = - resolveImport7(program, from, spec, "require") ?? - npmStaticDepSf7(program, from, spec, "require"); - return dep !== null && isCjsJsFile7(dep, program) ? dep : null; - }; - // A bundler rewrite spells some lexer-visible names as accessors. - const lexWithAccessorNames = (sf: ts.SourceFile): ReturnType => { - const lexed = cjsLexedExportsOfFile(sf); - for (const name of npmStaticLexerVisibleNames(sf.fileName)) lexed.exports.add(name); - return lexed; - }; + const resolveCjsDep = cjsReexportDep7(program); const visible = (dep: ts.SourceFile, name: string): boolean => - // `default` is the module.exports binding itself — always provided. - // The program already owns native source ASTs; reuse them without - // starting another syntax session or reparsing their source text. + // `default` and `module.exports` are the module.exports binding itself + // — always provided. The program already owns native source ASTs; + // reuse them without starting another syntax session or reparsing + // their source text. name === "default" || + name === "module.exports" || cjsVisibleNames(dep, lexWithAccessorNames, resolveCjsDep, lexMemo).has(name); /** The statement's resolved LOCAL CommonJS dependency, when it is an diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index 11fa0de72..5e493f0ba 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -24,6 +24,12 @@ import * as syntax from "./npm-static-declaration-syntax.js"; import { normalizeCjsExportsVar } from "./npm-static-cjs-exports-var.js"; import type { SourceFile } from "./ts7/ast-types.js"; +/** Whether the source can contain `word` as a keyword or identifier: it + * holds the word itself or a Unicode escape that might spell it. */ +function spells(source: string, word: string): boolean { + return source.includes(word) || source.includes("\\u"); +} + /** Evaluate an isolated, self-contained TypeScript callback under a finite * budget. Hosts supply execution; shared lowering owns capture checks and * verifies the returned value before baking it into the program. */ @@ -59,7 +65,9 @@ export class FrontendServices { // Called as a plain function: the instance is not its receiver. const createApi = this.createApi; return (this.parser ??= new Ts7SourceParser( - (options) => createApi({ ...options, collectTiming: false }), + // The parser reads no libraries, so it gains nothing from the shared + // checker server, and leaves it to the program's checker. + (options) => createApi({ ...options, collectTiming: false, shared: false }), this.cwd, )); } @@ -161,13 +169,18 @@ export class FrontendServices { return normalizeCjsExportsVar(this.parse(path, source, "js")); } nullableClassFields(path: string, source: string): syntax.NpmStaticOverloadRewrite | null { + // Rewrites `null`-initialized fields of class declarations. + if (!spells(source, "class") || !spells(source, "null")) return null; return syntax.applyNpmStaticNullableClassFields(this.parse(path, source, "js"), source); } jsDocNamepaths(path: string, source: string): string | null { - if (!source.includes("~") && !source.includes("#")) return null; + // A namepath segment joined by `#` or `~` (applyNpmStaticJsDocNamepaths). + if (!/[$\w][#~][$A-Z_a-z]/.test(source)) return null; return syntax.applyNpmStaticJsDocNamepaths(this.parse(path, source, "js"), source); } findReturnWidening(path: string, source: string): syntax.NpmStaticOverloadRewrite | null { + // Widens `find` calls returned by methods of class declarations. + if (!spells(source, "class") || !spells(source, "find")) return null; return syntax.applyNpmStaticFindReturnWidening(this.parse(path, source, "js"), source); } declarationProperties( diff --git a/packages/compiler/src/frontend/shape-inference/analyze.ts b/packages/compiler/src/frontend/shape-inference/analyze.ts index 557972868..ed5612c4d 100644 --- a/packages/compiler/src/frontend/shape-inference/analyze.ts +++ b/packages/compiler/src/frontend/shape-inference/analyze.ts @@ -142,6 +142,8 @@ export interface ShapeAnalysis { readonly escapedFunctions: ReadonlySet; /** ES5 constructors: each function's default prototype object. */ readonly protoOf: ReadonlyMap; + /** ES5 constructor instances: the function atom whose `new` made each. */ + readonly fnOfInstance: ReadonlyMap; /** Every value a declaration (parameter, variable, binding element) holds, * merged over the analysis contexts it was analyzed in. */ declValues(decl: Node): Set; @@ -289,6 +291,18 @@ const PRIMITIVE_MEMBERS: Readonly = new Set([ + "String.prototype", + "Number.prototype", + "Boolean.prototype", + "Object.prototype", +]); + +function isPrimitivePrototype(a: Atom): boolean { + return a.kind === "builtin" && a.receiver === undefined && PRIMITIVE_PROTOTYPES.has(a.path ?? ""); +} + /** The most atoms a value node tracks before it widens to ⊤ * (SCRIPTC_SHAPE_WIDEN overrides). */ const WIDEN_AT = Number(process.env["SCRIPTC_SHAPE_WIDEN"] ?? 32); @@ -365,6 +379,47 @@ function isLoop(node: Node): boolean { ); } +/** Whether control can reach the end of `stmts`. False only when every + * path ends in a return or throw, or in a `while (true)`, `do … while + * (true)` or `for (;;)` loop with no break inside; anything else is + * assumed to complete. */ +function canComplete(stmts: readonly Node[]): boolean { + return stmts.every(canCompleteStatement); +} + +function canCompleteStatement(s: Node): boolean { + if (ts.isReturnStatement(s) || ts.isThrowStatement(s)) return false; + if (ts.isBlock(s)) return canComplete(list(s.statements)); + if (ts.isIfStatement(s)) + return ( + s.elseStatement === undefined || + canCompleteStatement(s.thenStatement) || + canCompleteStatement(s.elseStatement) + ); + if (ts.isTryStatement(s)) { + if (s.finallyBlock !== undefined && !canCompleteStatement(s.finallyBlock)) return false; + return ( + canCompleteStatement(s.tryBlock) || + (s.catchClause !== undefined && canCompleteStatement(s.catchClause.block)) + ); + } + const endless = + ((ts.isWhileStatement(s) || ts.isDoStatement(s)) && + s.expression.kind === ts.SyntaxKind.TrueKeyword) || + (ts.isForStatement(s) && s.condition === undefined); + return !endless || containsBreak(s); +} + +/** Whether a break statement occurs in `node`'s body, outside nested + * functions. */ +function containsBreak(node: Node): boolean { + return ( + ts.forEachChild(node, (c) => + ts.isBreakStatement(c) || (!ts.isFunctionLike(c) && containsBreak(c)) ? true : undefined, + ) === true + ); +} + const LOOP_CALLBACK_METHODS: ReadonlySet = new Set([ "map", "forEach", @@ -409,6 +464,14 @@ class Analyzer { readonly escaped = new Map(); readonly topWriteNames = new Map(); readonly topComputedWrites: Node[] = []; + /** Names a write may add to a primitive's prototype chain, or every name + * (`primitiveExtensionsAll`). A read of a name the chain lacks reads ⊤ + * for them as well as undefined. */ + private readonly primitiveExtensions = new Set(); + private primitiveExtensionsAll = false; + /** Reads of names a primitive's prototype chain lacks, by name; each + * takes ⊤ once a write may add the name. */ + private readonly primitiveReads = new Map(); readonly fields = new Map>(); readonly elements = new Map(); readonly top: Atom; @@ -506,8 +569,7 @@ class Analyzer { private readonly popsByNode = new Map(); run(): ShapeAnalysis { - for (const sf of this.program.getSourceFiles()) { - if (sf.isDeclarationFile) continue; + for (const sf of this.program.getImplementationSourceFiles()) { const name = sf.fileName.split("\\").join("/"); const inPackage = isPackageJsFile(sf); if (!inPackage && name.includes("/node_modules/")) continue; @@ -542,6 +604,7 @@ class Analyzer { nodeLabel: this.nodeLabel, escapedFunctions: this.escapedFunctionCalls, protoOf: this.protoOf, + fnOfInstance: this.fnOfInstance, declValues: (decl) => this.declValues(decl), classChain: (decl) => this.classChain(decl), branchIfs: this.branchIfs, @@ -825,6 +888,8 @@ class Analyzer { escape(atom: Atom, reason: string): void { if (atom.kind === "prim" || atom.kind === "top") return; + // Unknown code may add any member to a primitive's prototype. + if (isPrimitivePrototype(atom)) this.extendPrimitives(null); if (atom.kind === "builtin" && atom.receiver === undefined) return; if (this.escaped.has(atom)) return; this.escaped.set(atom, reason); @@ -876,6 +941,8 @@ class Analyzer { /** Property names that untyped code writes on receivers that may be ⊤ (census). */ noteTopWrite(name: string | null, node: Node): void { + // ⊤ may be a primitive's prototype. + this.extendPrimitives(name); if (name === null) { const first = this.topComputedWrites.length === 0; this.topComputedWrites.push(node); @@ -897,6 +964,43 @@ class Analyzer { } } + /** A read of a name a primitive's prototype chain lacks: ⊤ as well, now + * or once a write may add the name there. */ + primitiveRead(name: string, dst: number): void { + if (this.primitiveExtensionsAll || this.primitiveExtensions.has(name)) { + this.add(dst, this.top); + return; + } + const reads = this.primitiveReads.get(name); + if (reads === undefined) this.primitiveReads.set(name, [dst]); + else reads.push(dst); + } + + /** A write that may add `name` (any name when null) to String.prototype, + * Number.prototype, Boolean.prototype or Object.prototype. */ + extendPrimitives(name: string | null): void { + if (this.primitiveExtensionsAll) return; + if (name === null || name === "__proto__") { + this.primitiveExtensionsAll = true; + for (const reads of this.primitiveReads.values()) + for (const dst of reads) this.add(dst, this.top); + this.primitiveReads.clear(); + return; + } + if (this.primitiveExtensions.has(name)) return; + this.primitiveExtensions.add(name); + for (const dst of this.primitiveReads.get(name) ?? []) this.add(dst, this.top); + this.primitiveReads.delete(name); + } + + /** An operation that may add, replace or remove `name` (any name when + * null) on its receiver: a primitive's prototype, or ⊤, which may be one. */ + prototypeWrite(recv: number, name: string | null): void { + this.on(recv, (t) => { + if (t.kind === "top" || isPrimitivePrototype(t)) this.extendPrimitives(name); + }); + } + // ---- classes --------------------------------------------------------------- /** The base class declaration of `decl`, via the checker; null at a @@ -1071,7 +1175,14 @@ class Analyzer { // ---- functions --------------------------------------------------------------- - functionInfo(node: Node, ownerClass: Node | null, ctxArg = ""): FunctionInfo { + /** `lexicalThis`: an arrow's receiver node, for a clone walked away from + * the function that encloses the arrow. */ + functionInfo( + node: Node, + ownerClass: Node | null, + ctxArg = "", + lexicalThis: number | null = null, + ): FunctionInfo { // A nested closure walked inside a clone shares the clone's context. const inherit = ctxArg === "" && @@ -1111,7 +1222,12 @@ class Analyzer { label: ctx === "" ? label : `${label} [${this.ctxLabel(ctx)}]`, params, rest, - thisNode: isArrow && this.currentFn !== null ? this.currentFn.thisNode : this.newNode(), + thisNode: + isArrow && lexicalThis !== null + ? lexicalThis + : isArrow && this.currentFn !== null + ? this.currentFn.thisNode + : this.newNode(), ret: this.newNode(), isArrow, isAsync: mods.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword), @@ -1155,8 +1271,11 @@ class Analyzer { } const body = fn.body; if (body !== undefined) { - if (ts.isBlock(body)) this.stmts(list(body.statements)); - else this.edge(this.expr(body as ts.Expression), info.ret); + if (ts.isBlock(body)) { + this.stmts(list(body.statements)); + // A call that runs off the end of the body returns undefined. + if (canComplete(list(body.statements))) this.add(info.ret, this.prim("undefined")); + } else this.edge(this.expr(body as ts.Expression), info.ret); } this.currentFn = saved; this.loopDepth = savedLoop; @@ -1431,6 +1550,15 @@ class Analyzer { private siteClone(info: FunctionInfo, site: Node, args: number[]): FunctionInfo | null { if (this.siteCloneLimit === 0 || info.isGenerator || ts.isSourceFile(info.node)) return null; if (ts.isClassLikeDeclaration(info.node)) return null; + // A function walked in an enclosing clone's or receiver's context reads + // and writes that context's bindings of the variables it captures. A + // clone is its own context root, so it would resolve them in the + // default context instead, and their writes would be lost. + if (info.root !== null && info.root !== info.node) return null; + // A class's constructor and methods run in receiver contexts of their + // own, rooted at each member: inside a clone they too would resolve the + // function's bindings in the default context. + if (this.declaresClass(info.node)) return null; const sc = this.argsFrom.get(args); if (sc === undefined || sc.inLoop || this.forceLoop || this.repeatingSites.has(site)) return null; @@ -1450,7 +1578,25 @@ class Analyzer { const count = this.siteClones.get(info.node) ?? 0; if (count >= this.siteCloneLimit) return null; this.siteClones.set(info.node, count + 1); - return this.functionInfo(info.node, info.ownerClass, ctx); + // The clone keeps the function's receiver: an arrow's lexical `this`, + // and whatever reaches a function's own `this`, such as the receiver + // `bind` supplies. + const clone = this.functionInfo(info.node, info.ownerClass, ctx, info.thisNode); + if (!clone.isArrow) this.edge(info.thisNode, clone.thisNode); + return clone; + } + + private readonly classDeclarers = new Map(); + /** Whether a class declaration or expression is anywhere inside `node`. */ + private declaresClass(node: Node): boolean { + let r = this.classDeclarers.get(node); + if (r === undefined) { + const visit = (n: Node): true | undefined => + ts.isClassLikeDeclaration(n) ? true : ts.forEachChild(n, visit); + r = ts.forEachChild(node, visit) === true; + this.classDeclarers.set(node, r); + } + return r; } private readonly factories = new Map(); @@ -1542,8 +1688,9 @@ class Analyzer { return; } if (ts.isReturnStatement(s)) { - if (s.expression !== undefined && this.currentFn !== null) - this.edge(this.expr(s.expression), this.currentFn.ret); + if (this.currentFn === null) return; + if (s.expression !== undefined) this.edge(this.expr(s.expression), this.currentFn.ret); + else this.add(this.currentFn.ret, this.prim("undefined")); return; } if (ts.isFunctionDeclaration(s)) { @@ -1824,6 +1971,7 @@ class Analyzer { const recv = this.expr(target.expression); const name = ts.isPropertyAccessExpression(target) ? target.name.text : null; this.ops.push({ kind: "delete", recv, name, node: e }); + this.prototypeWrite(recv, name); } return this.constNode(this.prim("boolean")); } @@ -2514,13 +2662,19 @@ class Analyzer { case "prim": if (name === "length" && a.label === "string") this.add(dst, this.prim("number")); else if (a.label === "string" && /^\d+$/.test(name)) { + // An index past the end reads the prototype chain. this.add(dst, this.prim("string")); this.add(dst, this.prim("undefined")); + this.primitiveRead(name, dst); } else if (a.label === "string" || a.label === "number" || a.label === "boolean") { - // A name the primitive's prototype chain lacks reads undefined. + // A name the primitive's prototype chain lacks reads undefined, + // unless the program may add it there. if (PRIMITIVE_MEMBERS[a.label].has(name) || OBJECT_PROTOTYPE_MEMBERS.has(name)) this.add(dst, this.builtin(`${a.label}.${name}`, a)); - else this.add(dst, this.prim("undefined")); + else { + this.add(dst, this.prim("undefined")); + this.primitiveRead(name, dst); + } } return; case "instance": { @@ -2641,8 +2795,12 @@ class Analyzer { this.add(protos, this.protoAtom(fn)); this.edge(this.field(fn, "prototype"), protos); this.on(protos, (proto) => { - if (proto.kind === "object") this.edge(this.field(proto, name), dst); - else if (proto.kind === "top") this.add(dst, this.top); + if (proto.kind === "object") { + // A method stored under a computed key (`F.prototype[k] = fn`) + // may be any name. + this.edge(this.field(proto, name), dst); + this.edge(this.anyField(proto), dst); + } else if (proto.kind === "top") this.add(dst, this.top); }); return; } @@ -2756,6 +2914,7 @@ class Analyzer { case "prim": return; default: + if (isPrimitivePrototype(a)) this.extendPrimitives(name); this.sink(value, `stored into ${a.label} at ${locLabel(site)}`); } }); @@ -2835,6 +2994,7 @@ class Analyzer { return; default: // As a store under a name: a builtin, promise or collection keeps it where it isn't tracked. + if (isPrimitivePrototype(a)) this.extendPrimitives(null); this.sink(value, `stored into ${a.label}[k] at ${locLabel(site)}`); } }); @@ -3051,7 +3211,11 @@ class Analyzer { this.activate(info); this.add(info.thisNode, inst); this.bindArgs(info, args, spread); - this.edge(info.ret, dst); + // `new` ignores a primitive result (and the undefined of a + // body that runs off its end) and yields the instance. + this.on(info.ret, (r) => { + if (r.kind !== "prim") this.add(dst, r); + }); this.edgeFor(e, info, args); } return; @@ -3305,8 +3469,10 @@ class Analyzer { if (path.startsWith("regexp.")) { if (method === "test") prim("boolean"); else if (method === "exec") { + // The match and its capture groups; a group that did not take part is undefined. const out = this.syntheticArray(site, "exec"); this.add(this.element(out), this.prim("string")); + this.add(this.element(out), this.prim("undefined")); this.add(dst, out); prim("null"); } else escapeAll(); @@ -3477,6 +3643,7 @@ class Analyzer { return; case "Object.assign": { this.ops.push({ kind: "assign", recv: arg(0), name: null, node: site }); + this.prototypeWrite(arg(0), null); this.on(arg(0), (t) => { for (const s of args.slice(1)) this.copyFields(s, t, site); if (t.kind === "top") @@ -3490,6 +3657,10 @@ class Analyzer { case "Object.defineProperties": case "Reflect.defineProperty": this.ops.push({ kind: "defineProperty", recv: arg(0), name: null, node: site }); + this.prototypeWrite( + arg(0), + path === "Object.defineProperties" ? null : literalKey(site, 1), + ); this.sink(arg(2), `property descriptor at ${locLabel(site)}`); this.sink(arg(1), `property descriptor at ${locLabel(site)}`); this.edge(arg(0), dst); @@ -3497,6 +3668,7 @@ class Analyzer { case "Object.setPrototypeOf": case "Reflect.setPrototypeOf": this.ops.push({ kind: "setPrototypeOf", recv: arg(0), name: null, node: site }); + this.prototypeWrite(arg(0), null); this.sink(arg(1), `prototype at ${locLabel(site)}`); this.edge(arg(0), dst); return; @@ -3598,12 +3770,9 @@ class Analyzer { return; } case "Reflect.deleteProperty": { - const key = ts.isCallExpression(site) ? unparen(list(site.arguments)[1]!) : undefined; - const name = - key !== undefined && (ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)) - ? key.text - : null; + const name = literalKey(site, 1); this.ops.push({ kind: "delete", recv: arg(0), name, node: site }); + this.prototypeWrite(arg(0), name); prim("boolean"); return; } @@ -3958,6 +4127,17 @@ interface PendingIf { elseDone: boolean; } +/** The string literal a call passes as argument `i`, or null. */ +function literalKey( + site: ts.CallExpression | ts.TaggedTemplateExpression, + i: number, +): string | null { + const key = ts.isCallExpression(site) ? list(site.arguments)[i] : undefined; + if (key === undefined) return null; + const k = unparen(key); + return ts.isStringLiteral(k) || ts.isNoSubstitutionTemplateLiteral(k) ? k.text : null; +} + /** Whether a value of the atom may be truthy (only `null`, `undefined` and * the literal `false` never are; ⊤ may be anything). */ function mayBeTruthy(a: Atom): boolean { diff --git a/packages/compiler/src/frontend/shape-inference/overlay.ts b/packages/compiler/src/frontend/shape-inference/overlay.ts index f900f03bf..8393dccfa 100644 --- a/packages/compiler/src/frontend/shape-inference/overlay.ts +++ b/packages/compiler/src/frontend/shape-inference/overlay.ts @@ -417,6 +417,10 @@ export function planShapeOverlay( return name; }; + // Functions called with `new` yield their instance, whatever they return. + const constructed = new Set( + [...analysis.fnOfInstance.values()].map((a) => a.node), + ); // Return values merged over every analysis context of each function. const returns = new Map>(); for (const info of analysis.functions.values()) { @@ -619,7 +623,7 @@ export function planShapeOverlay( // has one, and none at all for a signature it types as a whole. const doc = packageDocOf(fn); if (doc.whole) return null; - if (!ts.isConstructorDeclaration(fn) && !doc.returns) { + if (!ts.isConstructorDeclaration(fn) && !constructed.has(fn) && !doc.returns) { const ret = returnValues(fn); const t = ret !== undefined && ret.size > 0 ? typeOf(ret, false, 0) : null; const why = process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY_WHY"]; @@ -744,9 +748,13 @@ export function planShapeOverlay( // Dead branches: outermost first; edits inside a replaced range drop. const dead = (deadByFile.get(sf) ?? []).sort((a, b) => a.pos - b.pos); const ranges: [number, number][] = []; + const hoisted = new Map(); for (const branch of dead) { const start = ts.tokenStartOfNode(branch); if (ranges.some(([lo, hi]) => start >= lo && start < hi)) continue; + const names = hoistedNames(branch); + if (names === null) continue; + hoisted.set(start, names); ranges.push([start, branch.end]); } if (ranges.length > 0) { @@ -761,11 +769,14 @@ export function planShapeOverlay( `scriptc internal error: shape inference proved the branch at ${rel}:${line} unreachable, but it ran. ` + `This is a bug in scriptc's shape analysis, not in the program. Rebuild with --no-shape-overlay, ` + `or set "scriptc": {"packages": {"${pkg}": {"shapes": false}}} in package.json.`; + // The branch's own var names stay declared in its scope, undefined. + const names = hoisted.get(lo)!; + const vars = names.length > 0 ? `var ${names.join(", ")}; ` : ""; edits.push({ pos: lo, end: hi, id: lo, - text: `{ /** ${MARK} #${lo} */ throw new Error(${JSON.stringify(message)}); }`, + text: `{ /** ${MARK} #${lo} */ ${vars}throw new Error(${JSON.stringify(message)}); }`, }); } } @@ -970,6 +981,93 @@ function packageDocOf(node: ts.Node): PackageDoc { }; } +/** A comment range's JSDoc type tags made plain text (the range is + * trivia, so it holds no literal). The overlay's own comments carry MARK + * and are kept. Lengths are unchanged. */ +export function neutralizeCommentTags(text: string): string { + return text.replace(/\/\*\*[\s\S]*?\*\//g, (comment) => { + if (comment.includes(MARK)) return comment; + return comment.replace( + /@(param|type|typedef|returns?|template|this|callback|property|prop|arg|argument|enum|implements|extends|augments|satisfies|overload|import)\b/g, + "-$1", + ); + }); +} + +/** The `var` names a dead branch declares that its function (or file) + * declares nowhere else: replacing the branch must keep them bound, as + * undefined. Null when the branch declares a function in a block, whose + * binding outside the block depends on the file's strictness; such a + * branch is kept. */ +function hoistedNames(branch: ts.Node): readonly string[] | null { + let scope: ts.Node | undefined = branch.parent; + while (scope !== undefined && !ts.isSourceFile(scope) && !functionScope(scope)) + scope = scope.parent; + if (scope === undefined) return null; + const own = new Set(); + let blockFunction = false; + ts.walkPreorder(branch, (n) => { + if (n !== branch && ts.isFunctionDeclaration(n)) { + blockFunction = true; + return "skip"; + } + if (n !== branch && (functionScope(n) || ts.isClassDeclaration(n) || ts.isClassExpression(n))) + return "skip"; + if (ts.isVariableDeclarationList(n) && (n.flags & ts.NodeFlags.BlockScoped) === 0) + for (const d of n.declarations.nodes) addBindingNames(d.name, own); + return undefined; + }); + if (blockFunction) return null; + if (own.size === 0) return []; + // Names the scope binds outside the branch: parameters, a function + // expression's name, other var declarations and function declarations. + const elsewhere = new Set(); + if (!ts.isSourceFile(scope)) { + for (const p of list( + (scope as { parameters?: { readonly nodes: readonly ts.ParameterDeclaration[] } }).parameters, + )) + addBindingNames(p.name, elsewhere); + const name = (scope as { name?: ts.Node }).name; + if (name !== undefined && ts.isIdentifier(name)) elsewhere.add(name.text); + } + const body = ts.isSourceFile(scope) ? scope : (scope as { body?: ts.Node }).body; + if (body !== undefined) + ts.walkPreorder(body, (n) => { + if (n === branch) return "skip"; + if (n !== body && ts.isFunctionDeclaration(n) && n.name !== undefined) + elsewhere.add(n.name.text); + if (n !== body && (functionScope(n) || ts.isClassDeclaration(n) || ts.isClassExpression(n))) + return "skip"; + if (ts.isVariableDeclarationList(n) && (n.flags & ts.NodeFlags.BlockScoped) === 0) + for (const d of n.declarations.nodes) addBindingNames(d.name, elsewhere); + return undefined; + }); + return [...own].filter((name) => !elsewhere.has(name)); +} + +function functionScope(n: ts.Node): boolean { + return ( + ts.isFunctionDeclaration(n) || + ts.isFunctionExpression(n) || + ts.isArrowFunction(n) || + ts.isMethodDeclaration(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isConstructorDeclaration(n) || + ts.isClassStaticBlockDeclaration(n) + ); +} + +function addBindingNames(name: ts.BindingName, out: Set): void { + if (ts.isIdentifier(name)) { + out.add(name.text); + return; + } + for (const element of name.elements.nodes) + if (!ts.isOmittedExpression(element) && element.name !== undefined) + addBindingNames(element.name, out); +} + /** The `typeof` results the values can have. */ function typeofResults(values: ReadonlySet): Set { const out = new Set(); diff --git a/packages/compiler/src/frontend/shape-inference/registry.test.ts b/packages/compiler/src/frontend/shape-inference/registry.test.ts new file mode 100644 index 000000000..31c07daea --- /dev/null +++ b/packages/compiler/src/frontend/shape-inference/registry.test.ts @@ -0,0 +1,34 @@ +/* loadProgram resets the shape overlay's registry for the lowering world's + * load only. The authored world that preflight can create afterwards + * (load.projectWorld()) is a second load of the same build: it leaves the + * lowering load's overlay registered, so the fallback (fallback.ts) still + * finds the overlay behind a refusal. */ +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { loadProgram } from "../program-node.js"; +import { shapeOverlayActive, shapeOverlayFileNames } from "./index.js"; + +const entry = join( + import.meta.dirname, + "../../../../../tests/fixtures/npm/cases/static-objects/js-main.cjs", +); + +test("the authored world leaves the lowering load's overlay registered", () => { + const saved = process.env["SCRIPTC_SHAPE_OVERLAY"]; + process.env["SCRIPTC_SHAPE_OVERLAY"] = "1"; + try { + const load = loadProgram(entry, { npmStatic: ["static-ns"] }); + try { + expect(shapeOverlayActive()).toBe(true); + const files = shapeOverlayFileNames(); + load.projectWorld(); + expect(shapeOverlayActive()).toBe(true); + expect(shapeOverlayFileNames()).toEqual(files); + } finally { + load.dispose(); + } + } finally { + if (saved === undefined) delete process.env["SCRIPTC_SHAPE_OVERLAY"]; + else process.env["SCRIPTC_SHAPE_OVERLAY"] = saved; + } +}); diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts index 15d620800..541366364 100644 --- a/packages/compiler/src/frontend/shape-inference/static-objects.ts +++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts @@ -14,22 +14,45 @@ * methods are not enumerable, prototype assignments are); * - no code reads the function's or the methods' `name` or `toString`; * - the bodies behave the same in strict mode (class bodies are strict): - * no `arguments`, no `this` in a nested non-arrow function, no `with`, - * no `delete` of a name, no write to an undeclared name; + * no `arguments`, `eval` or strict-mode reserved word, no `this` in a + * nested non-arrow function, no `with`, no `delete` of a name, no write + * to an undeclared name or to `undefined`, `NaN` or `Infinity`, no + * repeated parameter name, no octal literal or escape, and property + * writes and deletes only through `this` (a write that sloppy mode + * ignores, into a primitive or a frozen object, throws in strict mode); + * - nothing freezes, seals or redefines the instances, so their own + * properties stay writable; + * - each method is only called as a method (`x.m(…)`): never constructed, + * read as a value or for its `prototype`, so it is never called with a + * primitive or missing `this` (which sloppy mode would replace) and + * never needs to be constructible (class methods are not); + * - a named constructor expression does not refer to its own name, which + * the class would not bind; * - the statements between the constructor and its last method only * define more functions or data (nothing runs that could observe the - * prototype half built). + * prototype half built): their stores go to constant objects, which + * have no setters; + * - nothing asks the prototype whether its members are enumerable + * (`propertyIsEnumerable`). * 2. Namespace functions become direct functions. A constant field holding * a function expression that does not use `this`, stored by a top-level * statement, is hoisted to a function declaration, and calls through * the namespace in the same file whose receiver is exactly that - * constant object call it directly. + * constant object call it directly. A member read is replaced only when + * its receiver is an initialized name or a chain of constant-object reads + * from one (nothing to evaluate, nothing to throw), and it runs after the + * store: in a top-level statement after it, or inside the stored + * function, and not inside a function declaration (which hoisting lets + * run earlier). A name is initialized when it is a parameter, a function + * declaration, or a variable declared with an initializer by an earlier + * top-level statement and never assigned again. * * Each rewrite keeps the original text unless every condition holds. */ import * as ts from "../ts7/adapter.js"; import type { Atom } from "./domain.js"; import type { ShapeAnalysis } from "./analyze.js"; import { computeConstantGraphs } from "./constant-graphs.js"; +import { resolveCjsRuntime } from "../cjs-resolve.js"; type Node = ts.Node; @@ -91,12 +114,40 @@ const REFLECTIVE_CALLS = new Set([ "setPrototypeOf", "freeze", "seal", + "preventExtensions", "ownKeys", ]); /** The most members a dispatched function table may have. */ const MAX_TABLE_ARMS = 16; +/** Words that are reserved only in strict mode. */ +const STRICT_RESERVED = new Set([ + "implements", + "interface", + "let", + "package", + "private", + "protected", + "public", + "static", + "yield", + "eval", +]); + +/** The reflective calls that freeze, seal or redefine an object. */ +const RESHAPING_CALLS = new Set([ + "freeze", + "seal", + "preventExtensions", + "defineProperty", + "defineProperties", + "setPrototypeOf", +]); + +/** Globals that sloppy mode lets a write to fail silently. */ +const READ_ONLY_GLOBALS = new Set(["undefined", "NaN", "Infinity"]); + function topStatement(n: Node): ts.Statement | null { let p: Node = n; while (p.parent !== undefined && !ts.isSourceFile(p.parent)) p = p.parent; @@ -172,7 +223,8 @@ function literalMemberFunction(fn: Node): { stmt: ts.Statement; path: string } | /** The text of a primitive literal an object literal itself gives `key` * (`{ K: 1 }`, `{ "k": "v" }`, `-1`, `true`, `null`), or null. */ function ownLiteralMember(literal: Node, key: string): string | null { - if (!ts.isObjectLiteralExpression(literal)) return null; + // `__proto__: v` in a literal sets the prototype and makes no property. + if (!ts.isObjectLiteralExpression(literal) || key === "__proto__") return null; let found: string | null = null; for (const m of literal.properties.nodes) { const name = (m as { name?: Node }).name; @@ -247,16 +299,65 @@ function isPlainRead(node: ts.Expression): boolean { return true; } +/** Whether `e` is a property access or element access through `this`. */ +function throughThis(e: ts.Expression): boolean { + while (ts.isParenthesizedExpression(e)) e = e.expression; + return ( + (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) && + e.expression.kind === ts.SyntaxKind.ThisKeyword + ); +} + /** Whether a function body behaves the same in strict mode. */ function strictSafe(checker: ts.TypeChecker, fn: ts.FunctionExpression): boolean { let safe = true; + // A repeated parameter name is a strict-mode error. + const params = new Set(); + for (const p of fn.parameters.nodes) { + if (!ts.isIdentifier(p.name) || params.has(p.name.text)) return false; + params.add(p.name.text); + } const visit = (n: Node, nested: boolean): void => { if (!safe) return; - if (ts.isIdentifier(n) && n.text === "arguments") safe = false; + if (ts.isIdentifier(n) && (n.text === "arguments" || STRICT_RESERVED.has(n.text))) safe = false; if (n.kind === ts.SyntaxKind.WithStatement) safe = false; if (nested && n.kind === ts.SyntaxKind.ThisKeyword) safe = false; - if (ts.isDeleteExpression(n) && ts.isIdentifier(n.expression)) safe = false; + if (ts.isDeleteExpression(n) && !throughThis(n.expression)) safe = false; if (ts.isNumericLiteral(n) && /^0\d/.test(n.getText())) safe = false; + // Octal escapes in strings and templates are strict-mode errors. + if ( + (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) && + /\\(0\d|[1-7])/.test(n.getText()) + ) + safe = false; + const assigns = + ts.isBinaryExpression(n) && + n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + n.operatorToken.kind <= ts.SyntaxKind.LastAssignment; + const counts = + (ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && + (n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken); + const target = assigns + ? (n as ts.BinaryExpression).left + : counts + ? (n as ts.PrefixUnaryExpression).operand + : undefined; + // A store that sloppy mode drops (into a primitive, a frozen object or a + // read-only property) throws in strict mode: only stores through `this`, + // a fresh or method receiver object, are known to behave the same. + if (target !== undefined) { + let t = target; + while (ts.isParenthesizedExpression(t)) t = t.expression; + if ((ts.isPropertyAccessExpression(t) || ts.isElementAccessExpression(t)) && !throughThis(t)) + safe = false; + if (ts.isIdentifier(t) && READ_ONLY_GLOBALS.has(t.text)) safe = false; + if ( + !ts.isIdentifier(t) && + !ts.isPropertyAccessExpression(t) && + !ts.isElementAccessExpression(t) + ) + safe = false; + } if ( ts.isBinaryExpression(n) && n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && @@ -279,6 +380,18 @@ function strictSafe(checker: ts.TypeChecker, fn: ts.FunctionExpression): boolean return safe; } +/** Whether an identifier named `name` appears in `fn`'s parameters or body. */ +function mentions(fn: ts.FunctionExpression, name: string): boolean { + let found = false; + const visit = (n: Node): void => { + if (found) return; + if (ts.isIdentifier(n) && n.text === name && n !== fn.name) found = true; + ts.forEachChild(n, visit); + }; + visit(fn); + return found; +} + function usesThis(fn: ts.FunctionExpression): boolean { let found = false; const visit = (n: Node): void => { @@ -291,33 +404,6 @@ function usesThis(fn: ts.FunctionExpression): boolean { return found; } -/** Whether a statement only defines things: function or data stores and - * declarations whose initializers run no code outside function bodies. */ -function definitionOnly(stmt: ts.Statement): boolean { - if (ts.isFunctionDeclaration(stmt)) return true; - let runs = false; - const visit = (n: Node): void => { - if (runs) return; - if (ts.isFunctionLike(n)) return; - if (ts.isCallExpression(n) || ts.isNewExpression(n) || ts.isTaggedTemplateExpression(n)) - runs = true; - if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) { - // A read could run a getter; stores into plain paths are fine. - const p = n.parent; - const isStoreTarget = - p !== undefined && - ts.isBinaryExpression(p) && - p.operatorToken.kind === ts.SyntaxKind.EqualsToken && - p.left === n; - if (!isStoreTarget && !ts.isPropertyAccessExpression(p ?? n)) runs = true; - } - ts.forEachChild(n, visit); - }; - if (ts.isExpressionStatement(stmt) || ts.isVariableStatement(stmt)) visit(stmt); - else return false; - return !runs; -} - export function planStaticObjectRewrites( analysis: ShapeAnalysis, files: readonly ts.SourceFile[], @@ -381,6 +467,12 @@ export function planStaticObjectRewrites( } const forIns: ts.Expression[] = []; const reflective: ts.CallExpression[] = []; + // Receivers of `x.propertyIsEnumerable(…)` calls, and whether the method + // is also reached any other way (`.call`, a stored reference). + const enumerabilityQueries: ts.Expression[] = []; + let enumerabilityEscapes = false; + // Names stored to by assignments or updates. + const assignedNames: ts.Identifier[] = []; for (const sf of analysis.files) { const visit = (n: Node): void => { if (ts.isForInStatement(n)) forIns.push(n.expression); @@ -392,6 +484,34 @@ export function planStaticObjectRewrites( n.expression.name.text === "propertyIsEnumerable") ) reflective.push(n); + if (ts.isIdentifier(n) && n.text === "propertyIsEnumerable") { + const access = n.parent; + const call = access?.parent; + if ( + access !== undefined && + ts.isPropertyAccessExpression(access) && + access.name === n && + call !== undefined && + ts.isCallExpression(call) && + call.expression === access + ) + enumerabilityQueries.push(access.expression); + else enumerabilityEscapes = true; + } + if ( + ts.isBinaryExpression(n) && + n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + n.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + ts.isIdentifier(n.left) + ) + assignedNames.push(n.left); + if ( + (ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && + (n.operator === ts.SyntaxKind.PlusPlusToken || + n.operator === ts.SyntaxKind.MinusMinusToken) && + ts.isIdentifier(n.operand) + ) + assignedNames.push(n.operand); ts.forEachChild(n, visit); }; visit(sf); @@ -404,6 +524,41 @@ export function planStaticObjectRewrites( return undefined; } }; + const readOps = new Map(); + const writeOps = new Map(); + for (const op of analysis.ops) { + if (op.kind === "read") readOps.set(op.node, op); + if (op.kind === "write") writeOps.set(op.node, op); + } + const reassigned = new Set(); + for (const id of assignedNames) { + const d = declOf(id); + if (d !== undefined) reassigned.add(d); + } + /** Whether evaluating `e` runs no code, so a rewrite that no longer + * evaluates it changes nothing: a name, `this`, or a member read whose + * receiver is such an expression and can only be constant objects (they + * have no accessors). A call, an assignment or any other expression may + * have effects. */ + const effectFree = (e: ts.Expression): boolean => { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (ts.isIdentifier(e) || e.kind === ts.SyntaxKind.ThisKeyword) return true; + let receiver: ts.Expression; + if (ts.isPropertyAccessExpression(e) && e.questionDotToken === undefined) + receiver = e.expression; + else if ( + ts.isElementAccessExpression(e) && + e.questionDotToken === undefined && + ts.isStringLiteralLike(e.argumentExpression) + ) + receiver = e.expression; + else return false; + const op = readOps.get(e); + if (op === undefined) return false; + const atoms = analysis.nodes[op.recv]!.atoms; + if (atoms.size === 0 || ![...atoms].every((a) => constantAtoms.has(a))) return false; + return effectFree(receiver); + }; /** Whether an expression may evaluate to one of `atoms` (identifiers * through their declarations' values, `new` of a known constructor path; * anything else may). */ @@ -428,9 +583,55 @@ export function planStaticObjectRewrites( return false; return true; }; - /** Whether a reflective call may reach one of `atoms`. */ + /** `mayBe`, which also follows member reads: a constructor's `prototype` + * is its default prototype object, and a constant object's field holds + * the values stored to it. */ + const mayBeValue = (e: ts.Expression, atoms: ReadonlySet, ctorPath: string): boolean => { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isPropertyAccessExpression(e)) return mayBe(e, atoms, ctorPath); + const op = readOps.get(e); + if (op === undefined || op.name === null) return true; + for (const a of analysis.nodes[op.recv]!.atoms) { + const proto = op.name === "prototype" ? analysis.protoOf.get(a) : undefined; + if (proto !== undefined) { + if (atoms.has(proto)) return true; + continue; + } + if (!constantAtoms.has(a)) return true; + const field = analysis.fields.get(a)?.get(op.name); + if (field !== undefined && [...analysis.nodes[field]!.atoms].some((v) => atoms.has(v))) + return true; + } + return false; + }; + /** Whether a reflective call may reach one of `atoms`: as an argument, or + * as the object `propertyIsEnumerable` is asked about. */ const reflectsOn = (atoms: ReadonlySet, ctorPath: string): boolean => + enumerabilityEscapes || + enumerabilityQueries.some((recv) => mayBeValue(recv, atoms, ctorPath)) || reflective.some((call) => call.arguments.nodes.some((arg) => mayBe(arg, atoms, ctorPath))); + /** Whether a call that freezes, seals or redefines an object may reach one of `atoms`. */ + const reshapes = (atoms: ReadonlySet, ctorPath: string): boolean => + reflective.some( + (call) => + RESHAPING_CALLS.has((call.expression as ts.PropertyAccessExpression).name.getText()) && + call.arguments.nodes.some((arg) => mayBe(arg, atoms, ctorPath)), + ); + /** The reads of each member name, for the method checks. */ + const readsOf = new Map }[]>(); + for (const op of analysis.ops) { + if (op.kind !== "read" || op.name === null) continue; + let l = readsOf.get(op.name); + if (l === undefined) readsOf.set(op.name, (l = [])); + l.push({ node: op.node, recv: analysis.nodes[op.recv]!.atoms }); + } + /** Whether every read of member `key` through one of `atoms` is the callee of a call. */ + const onlyCalled = (key: string, atoms: ReadonlySet): boolean => + (readsOf.get(key) ?? []).every(({ node, recv }) => { + if (![...recv].some((a) => atoms.has(a))) return true; + const parent = node.parent; + return parent !== undefined && ts.isCallExpression(parent) && parent.expression === node; + }); const enumerates = (atoms: ReadonlySet, ctorPath: string): boolean => forIns.some((e) => mayBe(e, atoms, ctorPath)); const noNameOrSource = (a: Atom): boolean => { @@ -438,6 +639,90 @@ export function planStaticObjectRewrites( return names === undefined || (!names.has("name") && !names.has("toString")); }; + /** Whether `node` runs only after the top-level statement `stmt` has run: + * in a later top-level statement, and not in a function declaration + * (which hoisting lets run earlier). */ + const afterStatement = (node: Node, stmt: ts.Statement): boolean => { + for (let p: Node | undefined = node.parent; p !== undefined; p = p.parent) + if (ts.isFunctionDeclaration(p)) return false; + const top = topStatement(node); + if (top === null) return false; + const stmts = node.getSourceFile().statements.nodes; + return stmts.indexOf(top) > stmts.indexOf(stmt); + }; + /** Whether the binding `id` names holds its value whenever `at` runs: a + * parameter or a function declaration, or a variable declared with an + * initializer by an earlier top-level statement of the same file. None is + * ever assigned again. A variable read earlier is undefined, or throws in + * its temporal dead zone. */ + const initializedAt = (id: ts.Identifier, at: Node): boolean => { + const d = declOf(id); + if (d === undefined || reassigned.has(d)) return false; + if (ts.isParameter(d) || ts.isFunctionDeclaration(d)) return true; + if (!ts.isVariableDeclaration(d) || d.initializer === undefined) return false; + const stmt = d.parent?.parent; + if (stmt === undefined || !ts.isVariableStatement(stmt) || !ts.isSourceFile(stmt.parent)) + return false; + return stmt.parent === at.getSourceFile() && afterStatement(at, stmt); + }; + /** Whether evaluating `e` where `at` runs runs nothing and cannot throw: an + * initialized name, or a chain of member reads from constant objects + * (plain data) starting at one. */ + const pureReceiver = (e: ts.Expression, at: Node): boolean => { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (ts.isIdentifier(e)) return initializedAt(e, at); + if (!ts.isPropertyAccessExpression(e)) return false; + const op = readOps.get(e); + if (op === undefined) return false; + const recv = analysis.nodes[op.recv]!.atoms; + if (recv.size !== 1 || !constantAtoms.has([...recv][0]!)) return false; + return pureReceiver(e.expression, at); + }; + /** Whether a store to `target` runs no code: its receiver is a constant + * object, which has no setters, reached without running anything, and the + * key is not `__proto__`, which Object.prototype stores through a setter. */ + const inertStore = (target: ts.Expression): boolean => { + if (!ts.isPropertyAccessExpression(target) || !ts.isIdentifier(target.name)) return false; + if (target.name.text === "__proto__") return false; + const op = writeOps.get(target); + if (op === undefined) return false; + const recv = analysis.nodes[op.recv]!.atoms; + if (recv.size !== 1 || !constantAtoms.has([...recv][0]!)) return false; + return pureReceiver(target.expression, target); + }; + /** Whether a statement only defines things: function declarations, and + * declarations and stores whose evaluation runs no code outside function + * bodies (no call, no read that could run a getter, no store that could run + * a setter). */ + const definitionOnly = (stmt: ts.Statement): boolean => { + if (ts.isFunctionDeclaration(stmt)) return true; + if (!ts.isExpressionStatement(stmt) && !ts.isVariableStatement(stmt)) return false; + let runs = false; + const visit = (n: Node): void => { + if (runs) return; + if (ts.isFunctionLike(n)) return; + if (ts.isCallExpression(n) || ts.isNewExpression(n) || ts.isTaggedTemplateExpression(n)) + runs = true; + if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) { + const p = n.parent; + const isStoreTarget = + p !== undefined && + ts.isBinaryExpression(p) && + p.operatorToken.kind === ts.SyntaxKind.EqualsToken && + p.left === n; + if (isStoreTarget) { + if (!inertStore(n)) runs = true; + return; + } + // A read could run a getter; the receivers of reads are part of them. + if (!ts.isPropertyAccessExpression(p ?? n)) runs = true; + } + ts.forEachChild(n, visit); + }; + visit(stmt); + return !runs; + }; + const hoisted = new Map(); const used = new Set(); for (const sf of files) for (const m of sf.text.matchAll(/[A-Za-z_$][\w$]*/g)) used.add(m[0]); @@ -510,6 +795,12 @@ export function planStaticObjectRewrites( keep("enumeration or reflection may reach the prototype or instances"); continue; } + // A frozen, sealed or redefined instance turns the stores that sloppy + // mode drops into strict-mode TypeErrors. + if (reshapes(watched, ctorPath)) { + keep("an instance may be frozen, sealed or redefined"); + continue; + } if (!noNameOrSource(fnAtom)) { keep("reads name or toString"); continue; @@ -518,6 +809,13 @@ export function planStaticObjectRewrites( keep("constructor body is not strict-mode safe"); continue; } + // A named function expression binds its name in its own body; the + // class is declared under another name. + const ownName = (fnNode as ts.FunctionExpression).name; + if (ownName !== undefined && mentions(fnNode as ts.FunctionExpression, ownName.text)) { + keep("a named constructor refers to its own name"); + continue; + } // The methods: one statement per prototype member, in this file. const methods: { name: string; fn: ts.FunctionExpression; stmt: ts.ExpressionStatement }[] = []; let ok = true; @@ -546,6 +844,26 @@ export function planStaticObjectRewrites( ok = false; break; } + // Only ever called as a method: a class method is not constructible, + // has no `prototype`, and a strict-mode call keeps a primitive or + // missing `this` where sloppy mode would replace it. + const calledAsMethod = analysis.calls + .filter((e) => e.callee.node === mNode) + .every( + (e) => + ts.isCallExpression(e.site) && + ts.isPropertyAccessExpression(e.site.expression) && + e.site.expression.name.getText() === f.key, + ); + if ( + analysis.escaped.has(m) || + escapedFns.has(mNode!) || + !calledAsMethod || + !onlyCalled(f.key, watched) + ) { + ok = false; + break; + } methods.push({ name: f.key, fn: mNode as ts.FunctionExpression, stmt: ms.stmt }); } if (!ok) { @@ -572,12 +890,20 @@ export function planStaticObjectRewrites( classes++; } + // The statement each hoisted class is declared at (before it). + const classStatements = new Map(); + // The statement that stores each hoisted function or class, and the + // function itself: a read of the member runs after the store when it is + // inside that function or in a later top-level statement. + const storeOf = new Map(); for (const plan of classPlans) { const base = `__ns_${plan.site.lhs.getText().replace(/[^\w$]/g, "_")}`; let name = base; for (let i = 1; used.has(name); i++) name = `${base}_${i}`; used.add(name); hoisted.set(plan.fnAtom, name); + classStatements.set(name, plan.site.stmt); + storeOf.set(plan.fnAtom, { stmt: plan.site.stmt, fn: plan.fn }); } const callEdits: { sf: ts.SourceFile; edit: Edit }[] = []; const hoists: { @@ -612,6 +938,7 @@ export function planStaticObjectRewrites( for (let i = 1; used.has(name); i++) name = `${base}_${i}`; used.add(name); hoisted.set(fnAtom, name); + storeOf.set(fnAtom, { stmt: site.stmt, fn }); edit(sf, { start: ts.tokenStartOfNode(fn), end: fn.end, @@ -662,6 +989,22 @@ export function planStaticObjectRewrites( exportLiterals.set(sf, found); return found; }; + const filesByName = new Map(); + for (const f of analysis.files) filesByName.set(f.fileName, f); + /** The program file `require(lit)` in `sf` loads: the checker's module, + * else the file Node's resolver finds (the checker resolves only some + * require forms), or null. */ + const requireTarget = (sf: ts.SourceFile, lit: ts.StringLiteral): ts.SourceFile | null => { + let target: Node | undefined; + try { + target = checker.getSymbolAtLocation(lit)?.declarations[0]; + } catch { + target = undefined; + } + if (target !== undefined && ts.isSourceFile(target)) return target; + const resolved = resolveCjsRuntime(sf.fileName, lit.text); + return resolved.ok ? (filesByName.get(resolved.value) ?? null) : null; + }; const requiredFiles = (sf: ts.SourceFile, node: Node): ts.SourceFile[] => { const out: ts.SourceFile[] = []; const visit = (n: Node): void => { @@ -672,20 +1015,135 @@ export function planStaticObjectRewrites( n.arguments.nodes.length === 1 && ts.isStringLiteral(n.arguments.nodes[0]!) ) { - let target: Node | undefined; - try { - target = checker.getSymbolAtLocation(n.arguments.nodes[0]!)?.declarations[0]; - } catch { - target = undefined; - } - if (target !== undefined && ts.isSourceFile(target)) out.push(target); + const target = requireTarget(sf, n.arguments.nodes[0] as ts.StringLiteral); + if (target !== null) out.push(target); } ts.forEachChild(n, visit); }; visit(node); - void sf; return out; }; + /** The files `sf` requires, directly or through the files they require. */ + const requireClosures = new Map>(); + const requiredClosure = (sf: ts.SourceFile): Set => { + let seen = requireClosures.get(sf); + if (seen !== undefined) return seen; + seen = new Set(); + const work = [sf]; + while (work.length > 0) { + const f = work.pop()!; + for (const t of requiredFiles(f, f)) + if (!seen.has(t)) { + seen.add(t); + work.push(t); + } + } + requireClosures.set(sf, seen); + return seen; + }; + /** Whether a top-level statement requires `target` each time it runs: + * the statement is `require(…)`, an assignment of it, or declarations + * one of which it initializes, optionally followed by member reads. A + * require inside a function, a condition or another expression runs only + * when that does. */ + /** The values a top-level statement computes: its declarations' + * initializers, or its expression (an assignment's right side). */ + const statementValues = (st: ts.Statement): ts.Expression[] | null => { + if (ts.isVariableStatement(st)) + return st.declarationList.declarations.nodes.flatMap((d) => + d.initializer !== undefined ? [d.initializer] : [], + ); + if (!ts.isExpressionStatement(st)) return null; + const e = st.expression; + return [ + ts.isBinaryExpression(e) && + e.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(e.left) + ? e.right + : e, + ]; + }; + /** The require call `v` is, followed by member reads, or null. */ + const requireValue = (v: ts.Expression): ts.CallExpression | null => { + let e = v; + for (;;) { + if (ts.isParenthesizedExpression(e)) e = e.expression; + else if (ts.isPropertyAccessExpression(e) && e.questionDotToken === undefined) + e = e.expression; + else break; + } + return ts.isCallExpression(e) && + ts.isIdentifier(e.expression) && + e.expression.text === "require" + ? e + : null; + }; + const requiresWhenRun = (sf: ts.SourceFile, st: ts.Statement, target: ts.SourceFile): boolean => + (statementValues(st) ?? []).some((v) => { + const call = requireValue(v); + return call !== null && requiredFiles(sf, call).includes(target); + }); + /** Whether `node` sits inside a function of its top-level statement. */ + const insideFunction = (node: Node): boolean => { + const top = topStatement(node); + for (let p: Node | undefined = node.parent; p !== undefined && p !== top; p = p.parent) + if (ts.isFunctionLike(p)) return true; + return false; + }; + /** The nodes whose statements must have run for an effect-free receiver + * (effectFree) to be the constant object it reads as: each member read's + * stores of that member, and a name's declaration. */ + const receiverDeps = (e: ts.Expression): Node[] => { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (ts.isIdentifier(e)) { + const d = declOf(e); + return d === undefined ? [] : [d]; + } + if (!ts.isPropertyAccessExpression(e) && !ts.isElementAccessExpression(e)) return []; + const op = readOps.get(e); + if (op === undefined || op.name === null) return []; + const objects = analysis.nodes[op.recv]!.atoms; + const stores = analysis.ops + .filter( + (w) => + w.kind === "write" && + w.name === op.name && + [...analysis.nodes[w.recv]!.atoms].some((a) => objects.has(a)), + ) + .map((w) => w.node); + return [...stores, ...receiverDeps(e.expression)]; + }; + /** Whether `read` can only run after the statements holding `deps`: what + * it reads exists by then, so a rewrite that no longer evaluates the read + * cannot drop the TypeError or ReferenceError of an earlier read. In + * another file, the defining module has finished loading unless it + * requires the reading file. In the same file, a top-level read comes + * after them. A read inside a function runs when the function is called: + * the statements from the one that creates the function (the first, for + * a hoisted declaration) to the last of them must only define things. */ + const createdBefore = (read: Node, deps: readonly Node[]): boolean => { + const sf = read.getSourceFile(); + const stmts = sf.statements.nodes; + let last = -1; + for (const d of deps) { + const file = d.getSourceFile(); + if (file !== sf) { + if (requiredClosure(file).has(sf)) return false; + continue; + } + const at = stmts.indexOf(topStatement(d)!); + if (at < 0) return false; + last = Math.max(last, at); + } + if (last < 0) return true; + const top = topStatement(read)!; + if (!insideFunction(read)) return stmts.indexOf(top) > last; + let fn: Node | undefined; + for (let p: Node | undefined = read.parent; p !== undefined && p !== top; p = p.parent) + if (ts.isFunctionLike(p)) fn = p; + const from = fn !== undefined && ts.isFunctionDeclaration(fn) ? 0 : stmts.indexOf(top); + return stmts.slice(from, last + 1).every(definitionOnly); + }; const moduleBindings = new Map(); const exportAdds = new Map>(); const crossFileReference = ( @@ -696,18 +1154,32 @@ export function planStaticObjectRewrites( if (process.env["SCRIPTC_SHAPE_CROSS_FILE_REFS"] === "0") return null; const sf = node.getSourceFile(); if (!fileSet.has(sf) || exportLiteralOf(defining) === null) return null; - if (requiredFiles(defining, defining).includes(sf)) return null; - // The reading file's top-level statement that requires the module. + // A cycle back to the reading file, directly or through other files, + // could run the reading file's code before the module finished. + if (requiredClosure(defining).has(sf)) return null; + // A class is declared at its constructor's statement: an exports + // literal before it would read the class before its declaration. + const classStmt = classStatements.get(name); + if (classStmt !== undefined) { + const ds = defining.statements.nodes; + const exportsAt = ds.indexOf(topStatement(exportLiteralOf(defining)!)!); + if (exportsAt < ds.indexOf(classStmt)) return null; + } + // The reading file's top-level statement that loads the module. const stmts = sf.statements.nodes; - const at = stmts.findIndex((st) => requiredFiles(sf, st).includes(defining)); + const at = stmts.findIndex((st) => requiresWhenRun(sf, st, defining)); if (at < 0) return null; const top = topStatement(node)!; - const inFunction = ((): boolean => { - for (let p: Node | undefined = node.parent; p !== undefined && p !== top; p = p.parent) - if (ts.isFunctionLike(p)) return true; - return false; - })(); + const inFunction = insideFunction(node); if (!inFunction && stmts.indexOf(top) <= at) return null; + // A function may run before the binding below is initialized if the + // statement that loads the module, or one before it, runs other code + // (`var m = require("lib"), x = f();`): then its reads stay as written. + if (inFunction) { + const values = statementValues(stmts[at]!); + const onlyLoads = values !== null && values.every((v) => requireValue(v) !== null); + if (!onlyLoads || !stmts.slice(0, at).every(definitionOnly)) return null; + } const key = `${sf.fileName}\0${defining.fileName}`; let binding = moduleBindings.get(key); if (binding === undefined) { @@ -740,6 +1212,19 @@ export function planStaticObjectRewrites( // Direct references: `e.k` (called, constructed or read) in the same file // whose receiver is exactly the constant object holding a hoisted // function or class. + /** Whether `node` runs only after `store` has stored its function. */ + const runsAfter = (node: Node, store: { stmt: ts.Statement; fn: Node }): boolean => { + for (let p: Node | undefined = node.parent; p !== undefined; p = p.parent) { + // A function declaration is created, and may be called, before any + // statement of its scope runs. + if (ts.isFunctionDeclaration(p)) return false; + if (p === store.fn) return true; + } + const top = topStatement(node); + if (top === null) return false; + const stmts = node.getSourceFile().statements.nodes; + return stmts.indexOf(top) > stmts.indexOf(store.stmt); + }; for (const { op, atoms, nullish } of mergedOps) { if (op.kind !== "read" || op.name === null) continue; if (atoms.size !== 1) continue; @@ -757,6 +1242,20 @@ export function planStaticObjectRewrites( if (parent === undefined || ts.isDeleteExpression(parent)) continue; if (!ts.isPropertyAccessExpression(node)) continue; if (topStatement(node) === null) continue; + // The receiver's evaluation is dropped, and the binding exists from the + // store on (a function) or from its declaration on (a class). + if (!pureReceiver(node.expression, node)) continue; + if (!runsAfter(node, storeOf.get(field.values[0]!)!)) continue; + // The reference replaces the whole member expression: its receiver + // must be one that evaluating runs no code. + if (!effectFree(node.expression)) { + kept.push(`reference ${node.getText()}: the receiver may have effects`); + continue; + } + if (!createdBefore(node, [field.values[0]!.node!, ...receiverDeps(node.expression)])) { + kept.push(`reference ${node.getText()}: it may run before the member is stored`); + continue; + } if (node.getSourceFile() !== field.values[0]!.node!.getSourceFile()) { const text = crossFileReference(node, field.values[0]!.node!.getSourceFile(), name); if (text === null) continue; @@ -802,6 +1301,14 @@ export function planStaticObjectRewrites( const node = op.node; if (!ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) continue; if (!isPlainRead(node) || topStatement(node) === null) continue; + if (!effectFree(node.expression)) { + kept.push(`constant read ${node.getText()}: the receiver may have effects`); + continue; + } + if (!createdBefore(node, [o.site.node, ...receiverDeps(node.expression)])) { + kept.push(`constant read ${node.getText()}: it may run before the object is created`); + continue; + } // An element key needs no parentheses (and a parenthesized literal key // would read as a computed one). const key = @@ -867,6 +1374,11 @@ export function planStaticObjectRewrites( kept.push(`${where}: not a constant object of at most ${MAX_TABLE_ARMS} members`); continue; } + // The arms call a member without evaluating the table expression. + if (!effectFree(node.expression)) { + kept.push(`${where}: the table expression may have effects`); + continue; + } const key = node.argumentExpression; if (!ts.isIdentifier(key)) continue; const keyDecl = declOf(key); @@ -890,6 +1402,11 @@ export function planStaticObjectRewrites( kept.push(`${where}: a member is not a hoisted function of this file`); continue; } + const armNodes = o.fields.map((f) => f.values[0]!.node!); + if (!createdBefore(call, [o.site.node, ...armNodes, ...receiverDeps(node.expression)])) { + kept.push(`${where}: it may run before the table is created`); + continue; + } tableCalls.push({ sf: call.getSourceFile(), call, arms, nullish }); } for (const { sf, call, arms, nullish } of tableCalls) { diff --git a/packages/compiler/src/frontend/ts7/checker-server.test.ts b/packages/compiler/src/frontend/ts7/checker-server.test.ts new file mode 100644 index 000000000..9a0371fe6 --- /dev/null +++ b/packages/compiler/src/frontend/ts7/checker-server.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, test } from "vitest"; +import { checkerServerName } from "./checker-server.js"; + +const dir = mkdtempSync(join(tmpdir(), "scriptc-checker-server-")); +const executable = join(dir, "tsc-ts"); +writeFileSync(executable, "server"); +const enabled = { SCRIPTC_CHECKER_SERVER: "1" }; +const posix = process.platform === "linux" || process.platform === "darwin"; + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +test("builds use the shared checker server only when they opt in, on Linux and macOS, with tsc-ts", () => { + expect(checkerServerName(executable, dir, false, {})).toBeNull(); + expect(checkerServerName(executable, dir, false, { SCRIPTC_CHECKER_SERVER: "0" })).toBeNull(); + expect( + checkerServerName(executable, dir, false, { ...enabled, SCRIPTC_CHECKER: "tsgo" }), + ).toBeNull(); + const name = checkerServerName(executable, dir, false, enabled); + if (!posix) expect(name).toBeNull(); + else expect(name).toMatch(/^[0-9a-f]{32}$/); +}); + +test.skipIf(!posix)( + "the server's name changes with what its answers depend on, and with nothing else", + () => { + const name = checkerServerName(executable, dir, false, enabled); + expect( + checkerServerName(executable, dir, false, { ...enabled, SCRIPTC_CHECKER: "tsc-ts" }), + ).toBe(name); + expect(checkerServerName(executable, dir, false, { ...enabled, PATH: "/elsewhere" })).toBe( + name, + ); + const others = [ + checkerServerName(executable, join(dir, "other"), false, enabled), + checkerServerName(executable, dir, true, enabled), + checkerServerName(executable, dir, false, { ...enabled, TSC_TS_LIB_DIR: dir }), + checkerServerName(executable, dir, false, { ...enabled, TSC_TS_LISTEN_MEMORY: "512" }), + ]; + for (const other of others) expect(other).not.toBe(name); + // An updated tsc-ts executable gets its own server. + utimesSync(executable, new Date(0), new Date(0)); + expect(checkerServerName(executable, dir, false, enabled)).not.toBe(name); + }, +); diff --git a/packages/compiler/src/frontend/ts7/checker-server.ts b/packages/compiler/src/frontend/ts7/checker-server.ts new file mode 100644 index 000000000..6d80e2599 --- /dev/null +++ b/packages/compiler/src/frontend/ts7/checker-server.ts @@ -0,0 +1,50 @@ +import { createHash } from "node:crypto"; +import { checkerBackend } from "./checker-backend.js"; +import { checkerExecutableIdentity } from "./lib-diagnostics.js"; +import { TS7_FILE_SYSTEM_CALLBACKS } from "./rpc-filesystem.js"; + +/** How long a shared checker server waits for the next build before it + * exits, in seconds. */ +export const CHECKER_SERVER_IDLE_EXIT = 300; + +/** The resident memory, in megabytes, above which a shared checker server + * exits after a build. tsc-ts never frees a program's syntax trees, types and + * symbols, so its memory grows with each build it serves. */ +export const CHECKER_SERVER_MEMORY = 2048; + +/** + * With `SCRIPTC_CHECKER_SERVER=1` on Linux and macOS, the native compiler + * checks with a tsc-ts server shared by the builds that follow + * (`tsc-ts --api --listen`): a build connects to it, starting it when none + * answers, and the server keeps the files it parsed for the next build. + * Returns the server's name, a digest of everything a server's answers + * depend on besides the files it reads through the build: the tsc-ts + * executable, the working directory, the file system callbacks, timing, and + * the `TSC_TS_*` environment. Returns null when the shared server is not used. + */ +export function checkerServerName( + executable: string, + cwd: string, + timing: boolean, + env: NodeJS.ProcessEnv = process.env, +): string | null { + if (env["SCRIPTC_CHECKER_SERVER"] !== "1") return null; + if (process.platform !== "linux" && process.platform !== "darwin") return null; + if (checkerBackend(env) !== "tsc-ts") return null; + const tscTsEnv = Object.keys(env) + .filter((name) => name.startsWith("TSC_TS_")) + .sort() + .map((name) => [name, env[name] ?? ""]); + return createHash("sha256") + .update( + JSON.stringify([ + checkerExecutableIdentity(executable), + cwd, + TS7_FILE_SYSTEM_CALLBACKS, + timing, + tscTsEnv, + ]), + ) + .digest("hex") + .slice(0, 32); +} diff --git a/packages/compiler/src/frontend/ts7/native-api.ts b/packages/compiler/src/frontend/ts7/native-api.ts index 259529541..f8e10efc5 100644 --- a/packages/compiler/src/frontend/ts7/native-api.ts +++ b/packages/compiler/src/frontend/ts7/native-api.ts @@ -1,7 +1,9 @@ import { Ts7Api } from "./api.js"; -import { spawnNativeTs7Wire } from "./native-process.js"; +import { checkerServerName } from "./checker-server.js"; +import { attachNativeTs7Process, spawnNativeTs7Wire } from "./native-process.js"; import { Ts7RpcClient } from "./rpc-client.js"; import type { Ts7FileSystem } from "./rpc-filesystem.js"; +import { Ts7Wire } from "./rpc-wire.js"; /** The native driver supplies its installed TypeScript executable path. * Package discovery is separate from starting and owning a server. */ @@ -10,8 +12,25 @@ export function createNativeTs7Api(options: { cwd: string; fs: Ts7FileSystem; collectTiming?: boolean; + shared?: boolean; }): Ts7Api { const timing = options.collectTiming ?? false; + const server = + options.shared === false ? null : checkerServerName(options.executable, options.cwd, timing); + const shared = + server === null + ? null + : attachNativeTs7Process(server, options.executable, options.cwd, timing); + if (shared !== null) { + const rpc = new Ts7RpcClient(new Ts7Wire(shared)); + try { + // A server that ended the connection leaves this build to start its own. + rpc.requestText("ping", "null"); + return new Ts7Api(rpc, options.fs, timing); + } catch { + rpc.close(); + } + } return new Ts7Api( new Ts7RpcClient(spawnNativeTs7Wire(options.executable, options.cwd, timing)), options.fs, diff --git a/packages/compiler/src/frontend/ts7/native-process.ts b/packages/compiler/src/frontend/ts7/native-process.ts index 7bb523d74..0582ae673 100644 --- a/packages/compiler/src/frontend/ts7/native-process.ts +++ b/packages/compiler/src/frontend/ts7/native-process.ts @@ -1,5 +1,7 @@ import { Ts7Wire, type Ts7WireIo } from "./rpc-wire.js"; import { TS7_FILE_SYSTEM_CALLBACKS } from "./rpc-filesystem.js"; +import { CHECKER_SERVER_IDLE_EXIT, CHECKER_SERVER_MEMORY } from "./checker-server.js"; +import { useNativeJsonValueEnd } from "./semantic-json.js"; // Bound by native/ts7-process.ffi.json. This module is a native entry point; // the Node host continues to use rpc-process.ts's public spawn boundary. @@ -9,6 +11,15 @@ declare function ts7NativeOpen( callbacks: string, timing: boolean, ): number; +declare function ts7NativeAttach( + name: string, + executable: string, + cwd: string, + callbacks: string, + timing: boolean, + idleExit: number, + memory: number, +): number; declare function ts7NativeRead( handle: number, bytes: Uint8Array, @@ -25,6 +36,11 @@ declare function ts7NativePid(handle: number): number; declare function ts7NativeClose(handle: number): void; declare function ts7NativeCloseAll(): void; declare function ts7NativeError(bytes: Uint8Array): number; +declare function ts7NativeJsonValueEnd(bytes: Uint8Array, start: number): number; + +// Reply decoding finds element ends with the C scanner. FFI functions can +// only be called, not passed as values, so an arrow function calls it. +useNativeJsonValueEnd((bytes, start) => ts7NativeJsonValueEnd(bytes, start)); // process.exit skips C atexit handlers. Its ordinary exit listener closes // servers too, while the C atexit sweep covers normal native termination. @@ -47,8 +63,12 @@ export class NativeTs7Process implements Ts7WireIo { private handle: number; readonly pid: number; - constructor(executable: string, cwd: string, collectTiming = false) { - this.handle = ts7NativeOpen(executable, cwd, TS7_FILE_SYSTEM_CALLBACKS, collectTiming); + /** With `handle`, a connection to a shared checker server (attachNativeTs7Process). */ + constructor(executable: string, cwd: string, collectTiming = false, handle = 0) { + this.handle = + handle !== 0 + ? handle + : ts7NativeOpen(executable, cwd, TS7_FILE_SYSTEM_CALLBACKS, collectTiming); if (this.handle === 0) throw nativeError("startup"); this.pid = ts7NativePid(this.handle); } @@ -97,6 +117,27 @@ export class NativeTs7Process implements Ts7WireIo { }; } +/** A connection to the shared checker server `name`, started when none + * answers (`checkerServerName`), or null when this build cannot use it, such + * as while another build is connected to it. */ +export function attachNativeTs7Process( + name: string, + executable: string, + cwd: string, + collectTiming = false, +): NativeTs7Process | null { + const handle = ts7NativeAttach( + name, + executable, + cwd, + TS7_FILE_SYSTEM_CALLBACKS, + collectTiming, + CHECKER_SERVER_IDLE_EXIT, + CHECKER_SERVER_MEMORY, + ); + return handle === 0 ? null : new NativeTs7Process(executable, cwd, collectTiming, handle); +} + export function spawnNativeTs7Wire( executable: string, cwd: string, diff --git a/packages/compiler/src/frontend/ts7/program-host.ts b/packages/compiler/src/frontend/ts7/program-host.ts index 3c3e81d9c..7ac461ce9 100644 --- a/packages/compiler/src/frontend/ts7/program-host.ts +++ b/packages/compiler/src/frontend/ts7/program-host.ts @@ -122,6 +122,9 @@ export interface Ts7ApiOptions { cwd: string; fs: Ts7FileSystem; collectTiming: boolean; + /** False for a connection that should not use the shared checker server + * (`SCRIPTC_CHECKER_SERVER`), such as the syntax-only source parser's. */ + shared?: boolean; } export type Ts7ApiFactory = (options: Ts7ApiOptions) => Ts7Api; diff --git a/packages/compiler/src/frontend/ts7/semantic-json.ts b/packages/compiler/src/frontend/ts7/semantic-json.ts index 3a04c741f..8913007d3 100644 --- a/packages/compiler/src/frontend/ts7/semantic-json.ts +++ b/packages/compiler/src/frontend/ts7/semantic-json.ts @@ -76,8 +76,8 @@ function malformedReply(): never { /** The index after the JSON value that starts at `start`: an object, an * array, a string or a bare literal. Strings are skipped with their escapes, - * so brackets inside them do not count. */ -function jsonValueEnd(bytes: Uint8Array, start: number): number { + * so brackets inside them do not count. Throws when the value is unclosed. */ +export function jsonValueEnd(bytes: Uint8Array, start: number): number { let depth = 0; let index = start; while (index < bytes.length) { @@ -103,6 +103,27 @@ function jsonValueEnd(bytes: Uint8Array, start: number): number { return index; } +/** The scanner the decoders find value ends with: jsonValueEnd, or the + * native compiler's scanner (useNativeJsonValueEnd). It is a property of a + * constant object: a program that installs the scanner but decodes no + * reply must still initialize it, and an assigned module variable with no + * other reader would be left uninitialized by a native build. */ +const scanner: { valueEnd: (bytes: Uint8Array, start: number) => number } = { + valueEnd: jsonValueEnd, +}; + +/** Has the decoders find value ends with `scan`, which returns what + * jsonValueEnd returns, or -1 where jsonValueEnd throws. The native compiler + * installs its C scanner (native/ts7-process.c), which skips the strings that + * make up most reply bytes with memchr. */ +export function useNativeJsonValueEnd(scan: (bytes: Uint8Array, start: number) => number): void { + scanner.valueEnd = (bytes, start) => { + const end = scan(bytes, start); + if (end < 0) malformedReply(); + return end; + }; +} + /** The id an object reply starts with (`{"id":N`), or -1 when it starts * otherwise. */ function leadingId(bytes: Uint8Array, start: number, end: number): number { @@ -158,7 +179,7 @@ export function decodeSemanticObject( ): R | null { const start = skipJsonSpace(bytes, 0); if (start === bytes.length) return null; - const end = jsonValueEnd(bytes, start); + const end = scanner.valueEnd(bytes, start); if (skipJsonSpace(bytes, end) !== bytes.length) malformedReply(); return replyElement(bytes, start, end, cached, build); } @@ -174,7 +195,7 @@ export function decodeSemanticObjects( let index = skipJsonSpace(bytes, 0); if (index === bytes.length) return null; if (bytes[index] !== OPEN_BRACKET) { - const end = jsonValueEnd(bytes, index); + const end = scanner.valueEnd(bytes, index); if ( skipJsonSpace(bytes, end) !== bytes.length || parseJsonSlice(bytes, index, end) !== null @@ -189,7 +210,7 @@ export function decodeSemanticObjects( return result; } for (;;) { - const end = jsonValueEnd(bytes, index); + const end = scanner.valueEnd(bytes, index); if (end === index) malformedReply(); result.push(replyElement(bytes, index, end, cached, build)); index = skipJsonSpace(bytes, end); diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index e6c0be7cd..a2831f1d1 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -128,10 +128,20 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ // JavaScript subclasses may widen fields or add method parameters. // Class lowering checks native storage and override ABIs at each use. 2415, + // a private or protected constructor (`new readline.Interface(...)`, + // whose declaration is protected): JavaScript has no constructor + // accessibility, and Node constructs the class. Each `new` meets the + // lowerer's per-site constructor fences. + 2673, + 2674, // unresolvable modules: Node throws WHEN the require executes — the // binding types as any and reached uses fence instead of passing unchecked 2307, 2792, + // `delete` of a property the inference typed required (2790): JS deletes + // any configurable property, and the lowering checks each delete's + // receiver itself + 2790, // useUnknownInCatchVariables' annotation demand ("'e' is of type // 'unknown'", "Object is of type 'unknown'"): a JS catch clause CANNOT // annotate its variable, so the demand is unsatisfiable in source — diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 43f28d206..63f421dab 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -169,9 +169,10 @@ export const WEBCRYPTO_DYN_TYPES: ReadonlySet = new Set([ "AesDerivedKeyParams", ]); -/** node:crypto's key and signature option interfaces: the key functions, - * KeyObject.export and sign/verify take them as checked-dynamic arguments - * (scr_lib.c, scr_pki.c). */ +/** node:crypto's key, signature and X509Certificate check option + * interfaces: the key functions, KeyObject.export, sign/verify and the + * X509Certificate checks take them as checked-dynamic arguments + * (scr_lib.c, scr_pki.c, scr_x509.c). */ export const CRYPTO_KEY_OPTION_DYN_TYPES: ReadonlySet = new Set([ "AsymmetricKeyExportOptions", "ECKeyPairKeyObjectOptions", @@ -190,6 +191,7 @@ export const CRYPTO_KEY_OPTION_DYN_TYPES: ReadonlySet = new Set([ "SigningOptions", "VerifyKeyObjectInput", "VerifyPublicKeyInput", + "X509CheckOptions", ]); export const FETCH_AMBIENT_TYPES = [ @@ -4465,13 +4467,25 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ), }; } - // crypto.X509Certificate — the Dirent-style data record: THREE visible - // fields (fingerprint and the validFrom/validTo validity window, all - // computed at construction by the new-expression lowering). Module- - // checked like SecureContext; @types/node declares the class inside - // `declare module "crypto"`, the shipped fallback mirrors it. - // JSON.stringify of one shows the three fields where Node's - // internal-slot object shows {} — the Dirent %dtype edge's cousin. + // X509Certificate.toLegacyObject()'s PeerCertificate (and tls's detailed + // form) is a plain checked-dynamic object. + if ( + psym !== undefined && + (ts.symbolName(psym) === "PeerCertificate" || + ts.symbolName(psym) === "DetailedPeerCertificate") && + psym.declarations.some( + (d) => ts.isInterfaceDeclaration(d) && ctx.isStdlibFile(ts.getSourceFileOfNode(d)), + ) + ) { + return DYN; + } + // crypto.X509Certificate — the Dirent-style data record: its fields are + // the certificate's lowered members, all computed at construction by the + // new-expression lowering (raw decodes the input once, and the others + // read raw). Module-checked like SecureContext; @types/node declares the + // class inside `declare module "crypto"`, the shipped fallback mirrors + // it. JSON.stringify of one shows the fields where Node's internal-slot + // object shows {} — the Dirent %dtype edge's cousin. if ( psym !== undefined && ts.symbolName(psym) === "X509Certificate" && @@ -4482,17 +4496,19 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { isDeclaredInAmbientModule(d, "crypto"), ) ) { + const optionalString = withUndefinedArm(STRING, ctx.unions); + if (!optionalString) return null; + const fields = X509_FIELDS.map(([name, type]) => ({ + name, + type: type === "string?" ? optionalString : type, + })); return { kind: "record", shapeId: ctx.shapes.intern( - [ - { name: "fingerprint", type: STRING }, - { name: "validFrom", type: STRING }, - { name: "validTo", type: STRING }, - ], + [...fields].sort((a, b) => (a.name < b.name ? -1 : 1)), false, undefined, - ["fingerprint", "validFrom", "validTo"], + fields.map((f) => f.name), ), }; } @@ -6701,6 +6717,39 @@ function boundVoidReturnType(p: ts.Type, resolveTypeParam?: TypeParamResolver): * result is exactly the nullable-callback shape mapType's union branch * admits, `(() => void) | undefined`) so the interned union is IDENTICAL * to what mapping the checker's own `T | undefined` produces. */ +/** The X509Certificate record's hidden slot for its publicKey KeyObject, + * filled on the first read (builtins/x509.ts). */ +export const X509_PUBLIC_KEY_FIELD = "%publicKey"; + +/** The X509Certificate record's hidden copy of its DER, which its methods + * read: a program can write into the Buffer `raw` answers, and in Node + * that changes neither the certificate's checks nor its PEM. */ +export const X509_DER_FIELD = "%der"; + +/** The X509Certificate record's fields in @types/node's declaration order + * ("string?" is string | undefined), then the hidden publicKey and DER + * slots. */ +export const X509_FIELDS: readonly (readonly [string, IrType | "string?"])[] = [ + ["ca", BOOL], + ["fingerprint", STRING], + ["fingerprint256", STRING], + ["fingerprint512", STRING], + ["subject", STRING], + ["subjectAltName", "string?"], + ["infoAccess", "string?"], + ["issuer", STRING], + ["raw", { kind: "bytes", elem: "u8" }], + ["serialNumber", STRING], + ["signatureAlgorithm", "string?"], + ["signatureAlgorithmOid", STRING], + ["validFrom", STRING], + ["validFromDate", DATE_T], + ["validTo", STRING], + ["validToDate", DATE_T], + [X509_PUBLIC_KEY_FIELD, DYN], + [X509_DER_FIELD, { kind: "bytes", elem: "u8" }], +]; + export function withUndefinedArm(t: IrType, unions: UnionRegistry): IrType | null { return withUnitArm(t, "undefinedT", unions); } diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 6711039ca..42d1ff78d 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -95,6 +95,7 @@ import { createNativeLinkPlan } from "./backend/link-plan.js"; import { nativeProfileKey, nativeProfileRefusal, + type NativeProfileMatch, type NativeProfileOptions, } from "./backend/native-profile.js"; import { @@ -103,7 +104,11 @@ import { platformLinkerSupportsPersistentCache, resolvePlatformLinker, } from "./backend/linker.js"; -import { splitLlvmLibraryProgram, splitLlvmProgram } from "./backend/llvm/split.js"; +import { + splitLlvmLibraryProgram, + splitLlvmProgram, + splitLlvmProgramFile, +} from "./backend/llvm/split.js"; import { emitLibraryIdentityLines, replaceLibraryIdentity, @@ -210,11 +215,14 @@ export { export { renderCoverage, renderExplainDynamic, + renderExplainIsland, coverageEnvelope, coveragePasses, type CoverageInput, type CoverageFailOn, } from "./coverage/report.js"; +export type { NodeIslandReport, NodeIslandEntry } from "./node-islands/summary.js"; +export type { NodeIslandOptions, NodeIslandsMode } from "./node-islands/policy.js"; export { TIER_MODES, type DemotedUnit, @@ -412,8 +420,13 @@ export async function analyzeCached( return result; } -const nodeFrontend: FrontendFactory = (entry, npmStatic, externalTypes, libraryNpmStatic) => - runFrontend(entry, loadProgram, npmStatic, externalTypes, libraryNpmStatic); +const nodeFrontend: FrontendFactory = ( + entry, + npmStatic, + externalTypes, + libraryNpmStatic, + options, +) => runFrontend(entry, loadProgram, npmStatic, externalTypes, libraryNpmStatic, options); /** The checker's part of the executable cache key (`checkerCacheIdentity`), as the CLI's startup route computes it. */ function checkerField(): { checker?: string } { @@ -575,8 +588,8 @@ async function compileExecutableNative( } const effectiveProgramSplit = programSplit ?? - (!programIsObject && features.optimization === "dev" && features.backend === "llvm" && !sanitize - ? splitLlvmProgram(await readFile(llvmPath, "utf8")) + (!programIsObject && features.optimization === "dev" && features.backend === "llvm" + ? splitLlvmProgramFile(llvmPath) : null); if (programIsObject) throw new InternalCompilerError("program objects must link against a precompiled runtime pack"); @@ -605,6 +618,7 @@ async function compileExecutableNative( ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), + ...(features.moduleNamespace ? { moduleNamespace: true } : {}), zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), assert: features.assert, @@ -650,6 +664,7 @@ async function compileExecutableNative( dh: features.dh, pki: features.pki, ed25519: features.ed25519, + x509: features.x509, webcrypto: features.webcrypto, perfLoop: features.perfLoop, ...(onArtifactReady === undefined ? {} : { onArtifactReady }), @@ -669,6 +684,7 @@ interface NativeProgramObject { partitionPaths: string[]; artifactPath: string; dependencies: NativeArtifactDependency[]; + profileMatch?: NativeProfileMatch; } async function emitNativeProgramObject( @@ -704,6 +720,7 @@ async function emitNativeProgramObject( partitionPaths: artifact.outputPaths.slice(1), artifactPath, dependencies: artifact.dependencies, + ...(artifact.profileMatch === undefined ? {} : { profileMatch: artifact.profileMatch }), }; } catch (error) { await removeNativeProgramObject(linkPath, nativePartitionPaths(linkPath, partitions)); @@ -815,6 +832,9 @@ async function prepareExecutableInput( pointerBits: buildPlatform === "wasi" ? 32 : 64, wasi: buildPlatform === "wasi", runtimeAbiMarker: outputKind === "obj", + // Textual output shows the code an executable gets; objects keep + // the pending flag, since their link may not add an unwinder. + unwindAbi: buildPlatform === "linux" && outputKind !== "obj", objectAudit: opts.sanitize === true, lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true), ...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}), @@ -885,6 +905,7 @@ async function prepareExecutableInput( pointerBits: buildPlatform === "wasi" ? 32 : 64, wasi: buildPlatform === "wasi", runtimeAbiMarker: useRuntimePack, + unwindAbi: buildPlatform === "linux", objectAudit: opts.sanitize === true, lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true), ...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}), @@ -913,7 +934,6 @@ async function prepareExecutableInput( !useRuntimePack && backend === "llvm" && (opts.optimization ?? "release") === "dev" && - !(opts.sanitize ?? false) && typeof llvmSource === "string" ? splitLlvmProgram(llvmSource) : null; @@ -1884,6 +1904,7 @@ async function compileTracked( } await pruneBuildCache(cacheRoot); timing("complete"); + const profileMatch = nativeProgramObject?.profileMatch; return { ok: true, artifact: { @@ -1891,6 +1912,7 @@ async function compileTracked( path: opts.outPath, translationUnitPath: llvmPath, backend, + ...(profileMatch === undefined ? {} : { profileMatch }), }, binaryPath: opts.outPath, llvmPath, @@ -1919,6 +1941,7 @@ function libraryNativeFeatures(mod: IrModule, backend: "llvm"): EarlyLibraryNati searchParams: features.searchParams, emitter: features.emitter, zlib: features.zlib, + x509: features.x509, copying: features.copying, textDecoderLegacy: features.legacyTextDecoder, dynInvoke: features.dynInvoke, @@ -2014,6 +2037,7 @@ async function compileLibraryNative( searchParams: features.searchParams, emitter: features.emitter, zlib: features.zlib, + x509: features.x509, copying: features.copying, textDecoderLegacy: features.textDecoderLegacy, dynInvoke: features.dynInvoke, diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 390c7fbc3..76f570e32 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -91,6 +91,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "procStream.write", "child.stdioStream", "stream.unboxDyn", + "stream.dynIterableSource", "stream.handleStream", "stream.pipeDyn", "stream.unpipeDyn", @@ -254,6 +255,11 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "tls.connectCb", "tls.wrapSocket", "tls.wrapSocketCtx", + // addContext's ERR_TLS_REQUIRED_SERVER_NAME and the context's errors, + // setSecureContext's validateObject. + "tls.serverAddContext", + "tls.serverAddContextCtx", + "tls.serverSetSecureContext", // The CA-store surface: an unknown getCACertificates type throws // Node's ERR_INVALID_ARG_VALUE TypeError; setDefaultCACertificates // throws Node's ERR_CRYPTO_OPERATION_FAILED on a certificate-free @@ -591,6 +597,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "util.styleText", "util.toUSVString", "util.inherits", + "util.inheritsPrototype", "util.deprecate", "util.callbackify", "util.promisifyDyn", @@ -683,6 +690,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.callbackCall", "dyn.keyDelete", "dyn.keyDeleteComputed", + "dyn.reflectDeleteProperty", "dyn.hasKeyComputed", "dyn.hasOwnComputed", "dyn.propertyIsEnumerableComputed", @@ -976,6 +984,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "assert.noErrorPredicate", "assert.regexDynTest", "assert.expectsErrDyn", + "assert.throwsValidate", "assert.ifErrorErr", "assert.ifErrorF64", "assert.ifErrorStr", @@ -1028,6 +1037,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.openSync", "fs.openNumericSync", "fs.readSync", + "fs.readSyncDyn", "fs.writeSync", "fs.writeStrSync", "fs.closeSync", @@ -1048,6 +1058,16 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "crypto.diffieHellman", "crypto.createSign", "crypto.createVerify", + "crypto.x509Raw", + "crypto.x509RawDer", + "crypto.x509RawStr", + // Node's argument errors (name, options, pkey) and the key parse. + "crypto.x509CheckHost", + "crypto.x509CheckEmail", + "crypto.x509CheckIP", + "crypto.x509PublicKey", + "crypto.x509Verify", + "crypto.x509CheckPrivateKey", ]); /** libCall members that always throw. Their result type is the replaced diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 61cbdda8c..e768f257f 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -196,6 +196,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * arguments; other receiver representations retain a runtime refusal. */ "dyn.keyDelete": { argTypes: [DYN, STRING, BOOL], result: VOID }, "dyn.keyDeleteComputed": { argTypes: [DYN, DYN, BOOL], result: VOID }, + "dyn.reflectDeleteProperty": { argTypes: [DYN, DYN], result: BOOL }, "dyn.hasKeyComputed": { argTypes: [DYN, DYN], result: BOOL }, "dyn.hasOwnComputed": { argTypes: [DYN, DYN], result: BOOL }, "dyn.propertyIsEnumerableComputed": { argTypes: [DYN, DYN], result: BOOL }, @@ -312,6 +313,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * timer APIs (it pulls in the event loop). The name is a compiler-chosen * literal. */ "dyn.builtinFunction": { argTypes: [STRING], result: DYN }, + "dyn.namespaceNew": { argTypes: [STRING, DYN], result: DYN }, + "dyn.namespaceDefine": { argTypes: [DYN, STRING, DYN, F64, F64], result: VOID }, "date.nativeNew": { argTypes: [DYN], result: DYN }, /** `new Date(year, monthIndex, ...)` in JavaScript: the argument array's * first seven values pass through ToNumber in order (later ones are @@ -1146,6 +1149,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * arguments (scr_util_callable.c). The function results are native dyn * wrappers; call sites with a static function type check them back. */ "util.inherits": { argTypes: [DYN, DYN], result: VOID }, + "util.inheritsPrototype": { argTypes: [DYN, DYN, DYN], result: VOID }, "util.deprecate": { argTypes: [DYN, DYN, DYN, DYN], result: DYN }, "util.callbackify": { argTypes: [DYN], result: DYN }, /** util.promisify over an untyped function value: a native dyn function @@ -1195,6 +1199,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "fs.openSync": { argTypes: [STRING, STRING], result: F64 }, "fs.openNumericSync": { argTypes: [STRING, F64, F64], result: F64 }, "fs.readSync": { argTypes: [F64, BYTES_U8, F64, F64, F64], result: F64 }, + "fs.readSyncDyn": { argTypes: [DYN, DYN, DYN, DYN, DYN], result: F64 }, "fs.writeSync": { argTypes: [F64, BYTES_U8, F64, F64, F64], result: F64 }, "fs.writeStrSync": { argTypes: [F64, STRING, F64, STRING], result: F64 }, // fs.watch's callback func type is program-dependent (zero params, or @@ -1228,6 +1233,38 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "crypto.x509ValidFromStr": { argTypes: [STRING], result: STRING }, "crypto.x509ValidTo": { argTypes: [BYTES_U8], result: STRING }, "crypto.x509ValidToStr": { argTypes: [STRING], result: STRING }, + /** The certificate's DER as a fresh Buffer (the raw field), decoded + * once from the constructor's input; the same PEM error (may-throw). */ + "crypto.x509Raw": { argTypes: [BYTES_U8], result: BYTES_U8 }, + "crypto.x509RawDer": { argTypes: [BYTES_U8], result: BYTES_U8 }, + "crypto.x509RawStr": { argTypes: [STRING], result: BYTES_U8 }, + /** The remaining X509Certificate fields over that DER, in Node's + * renderings (scr_lib.c). subjectAltName, infoAccess and + * signatureAlgorithm answer "" where Node answers undefined. Borrowed; + * never throw. */ + "crypto.x509Subject": { argTypes: [BYTES_U8], result: STRING }, + "crypto.x509Issuer": { argTypes: [BYTES_U8], result: STRING }, + "crypto.x509SubjectAltName": { argTypes: [BYTES_U8], result: STRING }, + "crypto.x509InfoAccess": { argTypes: [BYTES_U8], result: STRING }, + "crypto.x509SerialNumber": { argTypes: [BYTES_U8], result: STRING }, + "crypto.x509Fingerprint256": { argTypes: [BYTES_U8], result: STRING }, + "crypto.x509Fingerprint512": { argTypes: [BYTES_U8], result: STRING }, + "crypto.x509Ca": { argTypes: [BYTES_U8], result: BOOL }, + "crypto.x509SignatureAlgorithm": { argTypes: [BYTES_U8], result: STRING }, + "crypto.x509SignatureAlgorithmOid": { argTypes: [BYTES_U8], result: STRING }, + /** X509Certificate methods over its raw DER. checkHost/checkEmail/checkIP + * answer the matching name or "" (undefined), checkIssued compares two + * certificates, x509Pem is toString's PEM (scr_x509.c); publicKey, + * verify and checkPrivateKey read keys (scr_pki.c). */ + "crypto.x509CheckHost": { argTypes: [BYTES_U8, DYN, DYN], result: STRING }, + "crypto.x509CheckEmail": { argTypes: [BYTES_U8, DYN, DYN], result: STRING }, + "crypto.x509CheckIP": { argTypes: [BYTES_U8, DYN, DYN], result: STRING }, + "crypto.x509CheckIssued": { argTypes: [BYTES_U8, BYTES_U8], result: BOOL }, + "crypto.x509LegacyObject": { argTypes: [BYTES_U8], result: DYN }, + "crypto.x509Pem": { argTypes: [BYTES_U8], result: STRING }, + "crypto.x509PublicKey": { argTypes: [BYTES_U8], result: DYN }, + "crypto.x509Verify": { argTypes: [BYTES_U8, DYN], result: BOOL }, + "crypto.x509CheckPrivateKey": { argTypes: [BYTES_U8, DYN], result: BOOL }, "fs.closeSync": { argTypes: [F64], result: VOID }, "stats.isFile": { argTypes: [STATS_T], result: BOOL }, "stats.isDirectory": { argTypes: [STATS_T], result: BOOL }, @@ -2230,6 +2267,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "tls.sockGetProtocol": { argTypes: [NETSOCKET_T], result: DYN }, "tls.sockGetCipher": { argTypes: [NETSOCKET_T], result: DYN }, "tls.sockAlpnProtocol": { argTypes: [NETSOCKET_T], result: DYN }, + /** getPeerCertificate(detailed) and getCertificate() (the legacy object, + * {} or null; scr_tls_x509.c), and the DER behind getPeerX509Certificate() + * and getX509Certificate() (a Buffer, empty without a certificate). */ + "tls.sockGetPeerCertificate": { argTypes: [NETSOCKET_T, DYN], result: DYN }, + "tls.sockGetCertificate": { argTypes: [NETSOCKET_T], result: DYN }, + "tls.sockPeerCertDer": { argTypes: [NETSOCKET_T], result: BYTES_U8 }, + "tls.sockOwnCertDer": { argTypes: [NETSOCKET_T], result: BYTES_U8 }, "tls.sockAuthError": { argTypes: [NETSOCKET_T], result: VOID }, "tls.sockOnSecureConnect": { argTypes: [NETSOCKET_T, { kind: "func", params: [], ret: VOID }, BOOL], @@ -2261,6 +2305,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * walk — a validated { cert, key } bag builds the real context. * May-throw. */ "tls.createSecureContextDyn": { argTypes: [DYN], result: SECURECTX_T }, + /** server.addContext(servername, options | SecureContext), + * server.setSecureContext(options) and the 'tlsClientError' listener of + * a tls or https server (scr_tls.c). */ + "tls.serverAddContext": { argTypes: [NETSERVER_T, DYN, DYN], result: VOID }, + "tls.serverAddContextCtx": { argTypes: [NETSERVER_T, DYN, SECURECTX_T], result: VOID }, + "tls.serverSetSecureContext": { argTypes: [NETSERVER_T, DYN], result: VOID }, + "tls.serverOnClientError": { argTypes: [NETSERVER_T, null, BOOL], result: VOID }, /** tls.getCACertificates(type): validateString + the documented name * set, then the trailing compiler-rendered fence — ALWAYS THROWS (the * error.nodeThrow polymorphic-result carve-out). May-throw seed. */ @@ -2787,6 +2838,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ argTypes: [DYN, STRING], result: { kind: "object", className: "%Duplex" }, }, + /** Whether pipeline() pumps a checked-dynamic first stage as an + * iterable (a string, or a value with an iterator method that is not a + * stream with pipe() and on()) instead of piping it as a stream. */ + "stream.dynIterableSource": { argTypes: [DYN], result: BOOL }, /** The Transform a Cipheriv or Decipheriv reads and writes as a stream * (+1, created on first use and owned by the cipher). */ "stream.handleStream": { @@ -3426,6 +3481,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "process.builtinId": { argTypes: [DYN, arrayOf(STRING)], result: STRING }, "process.builtinModule": { argTypes: [STRING, DYN], result: DYN }, "process.builtinUnsupported": { argTypes: [STRING, STRING], result: DYN }, + /** A module namespace object as a checked-dynamic value + * (scr_module_namespace_value): the module id, its sorted export names + * and the compiled getter. One object per module. Never throws. */ + "module.namespaceValue": { argTypes: [STRING, arrayOf(STRING), DYN], result: DYN }, "fs.callbackValue": { argTypes: [STRING], result: DYN }, "fs.callbackCall": { argTypes: [STRING, DYN], result: DYN }, "process.hrtimeValue": { argTypes: [], result: DYN }, @@ -3711,6 +3770,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "ffi.argument": { argTypes: [DYN, STRING], result: DYN }, "ffi.memoryModule": { argTypes: [DYN], result: DYN }, "intl.segmenterNew": { argTypes: [], result: DYN }, + "intl.segmenterRefusing": { argTypes: [STRING], result: DYN }, "arrayBuffer.is": { argTypes: [DYN], result: BOOL }, "arrayBuffer.isView": { argTypes: [DYN], result: BOOL }, "arrayBuffer.byteLengthGetter": { argTypes: [], result: F64 }, @@ -4411,6 +4471,12 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ // The equality quartet over checked-dynamic operands (the frontend // boxes a static side into the checked-dynamic tree first). "assert.eqDyn": { argTypes: [DYN, DYN, BOOL, BOOL, STRING, BOOL], result: VOID }, + /** A message argument that is not a string, registered for the next + * failing assertion (scr_assert_message_value). */ + "assert.messageValue": { argTypes: [DYN, BOOL], result: VOID }, + /** An assert method as a function value, by canonical name. */ + "assert.methodValue": { argTypes: [STRING], result: DYN }, + "assert.messageClear": { argTypes: [], result: VOID }, "assert.looseResult": { argTypes: [BOOL, BOOL, STRING, STRING, STRING, BOOL], result: VOID }, // The throws(fn, {shape}) accumulator: begin/slot calls never throw; // shapeEnd throws the Comparison diff. The error slot is the @@ -4436,6 +4502,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * against the caught value's; a mismatch throws the deep-equal * AssertionError (scr_assert.c). MAY THROW by design. */ "assert.expectsErrDyn": { argTypes: [DYN, DYN, STRING, BOOL], result: VOID }, + /** A validation function against the caught value (Node's expectedException). */ + "assert.throwsValidate": { argTypes: [DYN, DYN, STRING, STRING, BOOL], result: VOID }, // assert.ifError's typed entries (always throw; unit args never // lower). The error slot is program-dependent — any %Error-hierarchy // class, root or subclass (the insp.error precedent, null slot). @@ -4874,6 +4942,15 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * lib-call argument passes the time value itself, and any other use * creates the Date. */ "date.fromTime": { argTypes: [F64], result: DATE_T }, + /** Date.prototype's field setters over a time value + * (scr_date_set_fields): time value, setter code, argument count and four + * arguments; answers the new time value. */ + "date.setFields": { argTypes: [F64, F64, F64, F64, F64, F64, F64], result: F64 }, + /** TimeClip of a number (setTime's argument). */ + "date.timeClip": { argTypes: [F64], result: F64 }, + /** Stores a time value into a Date object and answers it (a setter's + * write). */ + "date.store": { argTypes: [DATE_T, F64], result: F64 }, /** Date.prototype.toString / toDateString / toTimeString and String(date): * V8's local-time formats with the host zone's long name, "Invalid Date" * for an invalid date. Owned (+1) string; never throws. */ @@ -4912,6 +4989,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * and crypto callback adapters pass NULL after an error. Borrowed arg; * never throws. */ "bytes.isAbsent": { argTypes: [BYTES_U8], result: BOOL }, + /** Whether two typed arrays share no byte: their element ranges in + * memory do not overlap. Borrowed args of any element kind; never throws. */ + "bytes.disjoint": { argTypes: [null, null], result: BOOL }, /** The absent Buffer result itself (NULL), where an error-path callback * body passes its undefined result on into a Buffer slot. Owned (+1); * never throws. */ diff --git a/packages/compiler/src/ir/char-unit-locals.test.ts b/packages/compiler/src/ir/char-unit-locals.test.ts index ad16ecebd..a075f5a25 100644 --- a/packages/compiler/src/ir/char-unit-locals.test.ts +++ b/packages/compiler/src/ir/char-unit-locals.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { expect, test } from "vitest"; import { compile, deserializeModule } from "../index.js"; import { holdCharLocalsAsUnits } from "./char-unit-locals.js"; +import { validateModule } from "./validate.js"; import type { IrFunction, IrModule } from "./ir.js"; import { everyStmtList } from "./traverse.js"; @@ -96,3 +97,121 @@ console.log(escapes("a b", 1), rebound(" x", 0), wide("ab", 0), against("a", "b expect(fn(after, name)).toBe(fn(before, name)); } }); + +/** The union and dynamic comparisons left in the function. */ +function unionTests(f: IrFunction): string[] { + const out: string[] = []; + everyStmtList(f.body, { + stmt: () => true, + expr: (e) => { + if (e.kind === "unionEq" || e.kind === "unionIsTag") out.push(e.kind); + if (e.kind === "libCall" && e.fn === "js.compare") out.push(e.fn); + if (e.kind === "strIntrinsic") out.push(e.method); + return true; + }, + }); + return out; +} + +test("optional character reads compare their code unit", async () => { + const mod = holdCharLocalsAsUnits( + await lower(` +function scan(src: string, pos: number): number { + let n = 0; + if (src[pos] === " ") n += 1; + if (src[pos] !== "x") n += 2; + if (src[pos] < "a" || "z" <= src[pos]) n += 4; + if (src[pos + 1] === "=") n += 8; + const c = src[pos]; + if (c === undefined) n += 16; + if (c !== undefined && c > "m") n += 32; + switch (c) { + case "q": + n += 64; + break; + case "r": + n += 128; + } + return n; +} +console.log(scan("a b", 1), scan("q", 0), scan("ab", 5), scan("r=", 0.5)); +`), + ); + const scan = fn(mod, "scan"); + // Only charCodeAt reads remain: no union tests, dynamic compares, charAt + // or length reads. + expect(new Set(unionTests(scan))).toEqual(new Set(["charCodeAt"])); + expect(scan.locals.find((l) => l.name === "c")!.type).toEqual({ kind: "f64" }); +}); + +test("optional character reads with other uses stay reads", async () => { + const before = await lower(` +function kept(src: string, pos: number): string { + const c = src[pos]; + if (c === " ") return "space"; + return c ?? "end"; +} +function longer(src: string, pos: number): boolean { + return src[pos] === "ab"; +} +function twice(src: string, pos: number): boolean { + return src[pos * 2] === undefined; +} +console.log(kept("a b", 1), kept("ab", 0), kept("", 3), longer("ab", 0), twice("ab", 1)); +`); + const after = holdCharLocalsAsUnits(before); + for (const name of ["kept", "longer", "twice"]) { + expect(fn(before, name)).toBeDefined(); + expect(unionTests(fn(after, name))).toEqual(unionTests(fn(before, name))); + } + // The local's own test still reads it as a union. + expect(unionTests(fn(after, "kept"))).toContain("unionEq"); +}); + +test("a held local tested inside another read's index or receiver becomes its unit", async () => { + const mod = holdCharLocalsAsUnits( + await lower(` +function count(src: string, other: string): number { + let n = 0; + for (let pos = 0; pos <= src.length; pos += 1.5) { + const c = src[pos]; + if (src[c === "a" ? pos + 1 : pos] === "b") n++; + if ((c === "x" ? other : src)[pos] === "y") n += 2; + const d = src[c === "c" ? pos - 1 : pos]; + if (d === "c") n += 10; + } + return n; +} +console.log(count("abcab", "xyz"), count("cab", "yy"), count("", "")); +`), + ); + const count = fn(mod, "count"); + expect(validateModule(mod)).toEqual([]); + expect(count.locals.find((l) => l.name === "c")!.type).toEqual({ kind: "f64" }); + expect(count.locals.find((l) => l.name === "d")!.type).toEqual({ kind: "f64" }); + // No test of either local reads it as a union. + expect(unionTests(count)).not.toContain("unionEq"); +}); + +test("charAt tests in place compare charCodeAt", async () => { + const mod = holdCharLocalsAsUnits( + await lower(` +function tests(s: string, i: number): number { + let n = 0; + if (s.charAt(i) === " ") n += 1; + if ("a" <= s.charAt(i) && s.charAt(i) < "n") n += 2; + if (s.charAt(i) === "ab") n += 4; + return n; +} +console.log(tests("a b", 1), tests("abc", 0), tests("", 2)); +`), + ); + // The two-unit literal keeps its string comparison. + expect(reads(fn(mod, "tests"))).toEqual([ + "charCodeAt", + "charCodeAt", + "charCodeAt", + "strEq", + "charAt", + ]); +}); diff --git a/packages/compiler/src/ir/char-unit-locals.ts b/packages/compiler/src/ir/char-unit-locals.ts index beeeffaf3..aff952909 100644 --- a/packages/compiler/src/ir/char-unit-locals.ts +++ b/packages/compiler/src/ir/char-unit-locals.ts @@ -11,8 +11,11 @@ import { mapExprChildren, mapStmtChildren } from "./traverse.js"; type CharAt = IrExpr & { kind: "strIntrinsic" }; type UnitUse = IrExpr & { kind: "strEq" | "strCmp" }; +type CompareOp = "<" | "<=" | ">" | ">="; -/** Character locals held as their code unit. +const FLIPPED = { "<": ">", "<=": ">=", ">": "<", ">=": "<=" } as const; + +/** Character reads and locals held as their code unit. * * `const c = s[i]` (or `s.charAt(i)`) followed only by tests such as * `c === " "`, `c >= "a" && c <= "z"` or `switch (c) { case "x": ... }` @@ -28,7 +31,17 @@ type UnitUse = IrExpr & { kind: "strEq" | "strCmp" }; * * Only immutable unboxed string locals qualify, with one declaration whose * initializer is a one-argument charAt, and with no other mention of the - * local. */ + * local. The same tests of `s.charAt(i)` in place compare + * `s.charCodeAt(i)`, so a loop version sees one kind of read. + * + * `s[i]` with an index not proven in bounds reads the character or + * undefined (an optional read, see `optionalCharRead`). Its tests against + * one-unit literals compare `i === Math.floor(i) ? s.charCodeAt(i) : NaN` + * instead: undefined, like NaN, equals no literal and fails every ordered + * comparison, and charCodeAt answers NaN out of range. That holds both for + * the read in place and for an immutable local initialized from it and + * used only in such tests and in tests for undefined, which become NaN + * tests. */ export function holdCharLocalsAsUnits(mod: IrModule): IrModule { let changed = false; const functions = mod.functions.map((fn) => { @@ -39,11 +52,275 @@ export function holdCharLocalsAsUnits(mod: IrModule): IrModule { return changed ? { ...mod, functions } : mod; } +/** The read of `s[i]` for an index not proven in bounds: temps for the + * string and the index, then the string's charAt when the index is an + * integer in [0, length) and undefined otherwise. */ +interface OptionalCharRead { + /** The temps' declarations, and whether both operands are plain (local + * reads and literals), which the unit read may evaluate in place. */ + temps: [IrStmt & { kind: "varDecl" }, IrStmt & { kind: "varDecl" }]; + plain: boolean; + receiver: IrExpr; + index: IrExpr; + unionId: string; + stringTag: number; + undefinedTag: number; +} + +function optionalCharRead(e: IrExpr): OptionalCharRead | null { + if (e.kind !== "seqExpr" || e.stmts.length !== 2 || e.diverges) return null; + const [str, idx] = e.stmts; + if (str?.kind !== "varDecl" || idx?.kind !== "varDecl" || !str.init || !idx.init) return null; + const isStr = (x: IrExpr): boolean => x.kind === "varRef" && x.localId === str.localId; + const isIdx = (x: IrExpr): boolean => x.kind === "varRef" && x.localId === idx.localId; + const r = e.result; + if (r.kind !== "ternary") return null; + const c = r.cond; + if (c.kind !== "logical" || c.op !== "&&" || c.left.kind !== "logical" || c.left.op !== "&&") + return null; + const [low, high, whole] = [c.left.left, c.left.right, c.right]; + if ( + !( + low.kind === "bin" && + low.op === ">=" && + isIdx(low.left) && + low.right.kind === "numLit" && + low.right.value === 0 + ) || + !( + high.kind === "bin" && + high.op === "<" && + isIdx(high.left) && + high.right.kind === "strIntrinsic" && + high.right.method === "length" && + isStr(high.right.receiver) + ) || + !( + whole.kind === "bin" && + whole.op === "===" && + isIdx(whole.left) && + whole.right.kind === "libCall" && + whole.right.fn === "math.floor" && + whole.right.args.length === 1 && + isIdx(whole.right.args[0]!) + ) + ) + return null; + const [present, absent] = [r.then, r.else_]; + if (present.kind !== "unionWrap" || absent.kind !== "unionWrap") return null; + if (present.unionId !== absent.unionId) return null; + const read = present.value; + if ( + read.kind !== "strIntrinsic" || + read.method !== "charAt" || + !isStr(read.receiver) || + read.args.length !== 1 || + !isIdx(read.args[0]!) + ) + return null; + if (absent.value.kind !== "unitLit" || absent.value.unit !== "undefined") return null; + return { + temps: [str, idx], + plain: plainOperand(str.init) && plainOperand(idx.init), + receiver: str.init, + index: idx.init, + unionId: present.unionId, + stringTag: present.tag, + undefinedTag: absent.tag, + }; +} + +function plainOperand(e: IrExpr): boolean { + return e.kind === "varRef" || e.kind === "numLit" || e.kind === "strLit"; +} + +/** `i === Math.floor(i) ? s.charCodeAt(i) : NaN`: the code unit of an + * optional read, or NaN where it reads undefined. Plain operands are read + * in place; others keep the read's temps, so each is evaluated once. */ +function unitRead(read: OptionalCharRead, loc: IrExpr["loc"]): IrExpr { + if (!read.plain) { + const [str, idx] = read.temps; + const temp = (decl: IrStmt & { kind: "varDecl" }, type: IrExpr["type"]): IrExpr => ({ + kind: "varRef", + localId: decl.localId, + type, + loc, + }); + return { + kind: "seqExpr", + stmts: [{ ...str }, { ...idx }], + result: unitOf(temp(str, read.receiver.type), () => temp(idx, F64), loc), + type: F64, + loc, + }; + } + return unitOf({ ...read.receiver }, () => ({ ...read.index }), loc); +} + +function unitOf(receiver: IrExpr, index: () => IrExpr, loc: IrExpr["loc"]): IrExpr { + return { + kind: "ternary", + cond: { + kind: "bin", + op: "===", + left: index(), + right: { kind: "libCall", fn: "math.floor", args: [index()], type: F64, loc }, + type: BOOL, + loc, + }, + then: { + kind: "strIntrinsic", + method: "charCodeAt", + receiver, + args: [index()], + type: F64, + loc, + }, + else_: { kind: "numLit", value: NaN, type: F64, loc }, + type: F64, + loc, + }; +} + +/** A test of an operand that reads one character or undefined, against a + * literal of one code unit or for undefined, and the same test on the + * operand's code unit. `tagsOf` gives the operand's union tags. */ +interface UnitTest { + operand: IrExpr; + /** The test reads the unit twice. */ + twice?: boolean; + test: (unit: () => IrExpr) => IrExpr; +} + +function optionalUnitTest( + e: IrExpr, + tagsOf: (operand: IrExpr) => OptionalCharRead | null, +): UnitTest | null { + const loc = e.loc; + const compare = (op: "===" | "!==" | CompareOp, u: IrExpr, right: IrExpr): IrExpr => ({ + kind: "bin", + op, + left: u, + right, + type: BOOL, + loc, + }); + const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); + if (e.kind === "unionEq" && !e.sameValue) { + for (const [operand, other] of [ + [e.left, e.right], + [e.right, e.left], + ] as const) { + const tags = tagsOf(operand); + if ( + tags && + tags.unionId === e.unionId && + other.kind === "unionWrap" && + other.tag === tags.stringTag && + other.value.kind === "strLit" && + other.value.value.length === 1 + ) { + const code = other.value.value.charCodeAt(0); + return { + operand, + test: (unit) => compare(e.negated ? "!==" : "===", unit(), num(code)), + }; + } + // `case undefined:` of a switch: undefined is a NaN unit. + if ( + tags && + tags.unionId === e.unionId && + other.kind === "unionWrap" && + other.tag === tags.undefinedTag && + other.value.kind === "unitLit" && + other.value.unit === "undefined" + ) + return { + operand, + twice: true, + test: (unit) => compare(e.negated ? "===" : "!==", unit(), unit()), + }; + } + return null; + } + if (e.kind === "libCall" && e.fn === "js.compare" && e.args.length === 3) { + const [a, b, opArg] = e.args as [IrExpr, IrExpr, IrExpr]; + if (opArg.kind !== "strLit" || !(opArg.value in FLIPPED)) return null; + const op = opArg.value as CompareOp; + const literalOf = (x: IrExpr): number | null => + x.kind === "libCall" && + x.fn === "js.fromStr" && + x.args.length === 1 && + x.args[0]!.kind === "strLit" && + x.args[0]!.value.length === 1 + ? x.args[0]!.value.charCodeAt(0) + : null; + const operandOf = (x: IrExpr): IrExpr | null => + x.kind === "libCall" && + x.fn === "js.fromDyn" && + x.args.length === 1 && + x.args[0]!.kind === "dynFrom" && + tagsOf(x.args[0]!.value) + ? x.args[0]!.value + : null; + // The comparison with the operand on the left. + const left = operandOf(a); + const right = operandOf(b); + const code = left ? literalOf(b) : right ? literalOf(a) : null; + const operand = left ?? right; + if (operand === null || code === null) return null; + const ordered = left ? op : FLIPPED[op]; + return { operand, test: (unit) => compare(ordered, unit(), num(code)) }; + } + if (e.kind === "unionIsTag") { + const tags = tagsOf(e.value); + if (!tags || tags.unionId !== e.unionId) return null; + if (e.tag !== tags.undefinedTag && e.tag !== tags.stringTag) return null; + // Undefined is a NaN unit; a character is any other. + const present = (e.tag === tags.stringTag) !== e.negated; + return { + operand: e.value, + twice: true, + test: (unit) => compare(present ? "===" : "!==", unit(), unit()), + }; + } + return null; +} + function rewriteFunction(fn: IrFunction): IrFunction { + const locals = new Map(fn.locals.map((l) => [l.id, l])); const candidates = new Set( - fn.locals.filter((l) => !l.mutable && !l.boxed && l.type.kind === "string").map((l) => l.id), + fn.locals + .filter( + (l) => !l.mutable && !l.boxed && (l.type.kind === "string" || l.type.kind === "union"), + ) + .map((l) => l.id), ); - if (candidates.size === 0) return fn; + // The optional reads candidate union locals are declared from, directly + // or as copies of another such local (a switch's discriminant temp). + const reads = new Map(); + const copyOf = new Map(); + const collect = (value: unknown): void => { + if (Array.isArray(value)) for (const item of value) collect(item); + else if (value !== null && typeof value === "object") { + const node = value as Record; + if (node.kind === "varDecl" && candidates.has(node.localId as string) && node.init) { + const id = node.localId as string; + const init = node.init as IrExpr; + const read = optionalCharRead(init); + if (read) reads.set(id, read); + else if (init.kind === "varRef" && reads.has(init.localId)) { + reads.set(id, reads.get(init.localId)!); + copyOf.set(id, init.localId); + } + } + for (const key in node) if (key !== "loc") collect(node[key]); + } + }; + collect(fn.body); + const localTags = (operand: IrExpr): OptionalCharRead | null => + operand.kind === "varRef" ? (reads.get(operand.localId) ?? null) : null; + // Every mention of a candidate, in any field, against the mentions the // rewrite understands. const mentions = new Map(); @@ -59,7 +336,7 @@ function rewriteFunction(fn: IrFunction): IrFunction { for (const item of value) count(item); } else if (value !== null && typeof value === "object") { const node = value as Record; - const id = understoodMention(node); + const id = understoodMention(node, locals, reads, localTags); if (id !== null && candidates.has(id)) { note(understood, id); if (node.kind === "varDecl") note(declarations, id); @@ -68,15 +345,26 @@ function rewriteFunction(fn: IrFunction): IrFunction { } }; count(fn.body); + // A copy is held only with its source, and the source's mention in the + // copy's initializer is understood only while the copy is held. const held = new Set(); for (const id of candidates) - if ( - declarations.get(id) === 1 && - (mentions.get(id) ?? 0) > 0 && - mentions.get(id) === understood.get(id) - ) - held.add(id); - if (held.size === 0) return fn; + if (declarations.get(id) === 1 && (mentions.get(id) ?? 0) > 0) held.add(id); + for (let settled = false; !settled;) { + settled = true; + for (const id of held) { + let copies = 0; + for (const [copy, source] of copyOf) if (source === id && held.has(copy)) copies++; + const source = copyOf.get(id); + if ( + mentions.get(id) !== (understood.get(id) ?? 0) + copies || + (source !== undefined && !held.has(source)) + ) { + held.delete(id); + settled = false; + } + } + } const unitRef = (id: string, loc: IrExpr["loc"]): IrExpr => ({ kind: "varRef", @@ -84,53 +372,86 @@ function rewriteFunction(fn: IrFunction): IrFunction { type: F64, loc, }); + // Operands of optional unit tests: held union locals and reads in place. + const operandTags = (operand: IrExpr): OptionalCharRead | null => + operand.kind === "varRef" + ? held.has(operand.localId) + ? localTags(operand) + : null + : optionalCharRead(operand); + let changed = false; const rewriteExpr = (e: IrExpr): IrExpr => { if (e.kind === "strEq" || e.kind === "strCmp") { const use = unitUse(e); if (use && held.has(use.id)) { - const code: IrExpr = { kind: "numLit", value: use.code, type: F64, loc: e.loc }; - const c = unitRef(use.id, e.loc); - if (e.kind === "strEq") - return { - kind: "bin", - op: e.negated ? "!==" : "===", - left: c, - right: code, - type: BOOL, - loc: e.loc, - }; - // The comparison with the local on the left. - const op = use.left - ? e.op - : ({ "<": ">", "<=": ">=", ">": "<", ">=": "<=" } as const)[e.op]; - if (op === ">" || op === ">=") - return { kind: "bin", op, left: c, right: code, type: BOOL, loc: e.loc }; - const negation: IrExpr = { - kind: "bin", - op: op === "<" ? ">=" : ">", - left: c, - right: code, - type: BOOL, - loc: e.loc, + changed = true; + return charUnitTest(e, unitRef(use.id, e.loc), use.code, use.left); + } + // `s.charAt(i)` in place: its unit read, `s.charCodeAt(i)`. + const direct = charAtTest(e); + if (direct) { + changed = true; + const read: IrExpr = { + ...direct.charAt, + method: "charCodeAt", + receiver: rewriteExpr(direct.charAt.receiver), + args: direct.charAt.args.map(rewriteExpr), + type: F64, }; - return { kind: "unary", op: "!", operand: negation, type: BOOL, loc: e.loc }; + return charUnitTest(e, read, direct.code, direct.left); + } + } + const optional = optionalUnitTest(e, operandTags); + if (optional) { + const operand = optional.operand; + if (operand.kind === "varRef") { + changed = true; + return optional.test(() => unitRef(operand.localId, e.loc)); + } + // A read that keeps its temps declares them once: one unit read. + const read = optionalCharRead(operand)!; + if (read.plain || !optional.twice) { + changed = true; + const rewritten = rewriteRead(read); + return optional.test(() => unitRead(rewritten, operand.loc)); } } return mapExprChildren(e, rewriteExpr, rewriteStmt); }; + // A read's receiver and index may test other held locals. + const rewriteRead = (read: OptionalCharRead): OptionalCharRead => { + const [str, idx] = read.temps; + return { + ...read, + temps: [ + { ...str, init: rewriteExpr(str.init!) }, + { ...idx, init: rewriteExpr(idx.init!) }, + ], + receiver: rewriteExpr(read.receiver), + index: rewriteExpr(read.index), + }; + }; const rewriteStmt = (s: IrStmt): IrStmt => { - if (s.kind === "varDecl" && held.has(s.localId) && s.init?.kind === "strIntrinsic") - return { - ...s, - init: { - ...s.init, - method: "charCodeAt", - receiver: rewriteExpr(s.init.receiver), - args: s.init.args.map(rewriteExpr), - type: F64, - }, - }; - if (s.kind === "switch" && s.disc.kind === "varRef" && held.has(s.disc.localId)) + if (s.kind === "varDecl" && held.has(s.localId) && s.init) { + changed = true; + const source = copyOf.get(s.localId); + if (source !== undefined) return { ...s, init: unitRef(source, s.init.loc) }; + const read = reads.get(s.localId); + if (read) return { ...s, init: unitRead(rewriteRead(read), s.init.loc) }; + if (s.init.kind === "strIntrinsic") + return { + ...s, + init: { + ...s.init, + method: "charCodeAt", + receiver: rewriteExpr(s.init.receiver), + args: s.init.args.map(rewriteExpr), + type: F64, + }, + }; + } + if (s.kind === "switch" && s.disc.kind === "varRef" && held.has(s.disc.localId)) { + changed = true; return { ...s, disc: unitRef(s.disc.localId, s.disc.loc), @@ -143,11 +464,14 @@ function rewriteFunction(fn: IrFunction): IrFunction { body: c.body.map(rewriteStmt), })), }; + } return mapStmtChildren(s, rewriteExpr, rewriteStmt); }; + const body = fn.body.map(rewriteStmt); + if (!changed) return fn; return { ...fn, - body: fn.body.map(rewriteStmt), + body, locals: fn.locals.map((l) => (held.has(l.id) ? unitLocal(l) : l)), }; } @@ -157,26 +481,89 @@ function unitLocal(local: IrLocal): IrLocal { return { ...local, type: F64 }; } -/** The local a node mentions in a form the rewrite handles: the - * declaration from a one-argument charAt, a one-unit test, or a switch - * over one-unit cases. */ -function understoodMention(node: Record): string | null { +/** The local a node mentions in a form the rewrite handles: a string + * local's declaration from a one-argument charAt, one-unit test, or switch + * over one-unit cases; a union local's declaration from an optional read, + * or its test against a one-unit literal or for undefined. */ +function understoodMention( + node: Record, + locals: ReadonlyMap, + reads: ReadonlyMap, + localTags: (operand: IrExpr) => OptionalCharRead | null, +): string | null { // count() visits every object in the body (types, locations, cases), so // only the node kinds handled here are read as IR nodes const kind = node.kind; - if (kind !== "varDecl" && kind !== "strEq" && kind !== "strCmp" && kind !== "switch") return null; + if ( + kind !== "varDecl" && + kind !== "strEq" && + kind !== "strCmp" && + kind !== "switch" && + kind !== "unionEq" && + kind !== "libCall" && + kind !== "unionIsTag" + ) + return null; const n = node as unknown as IrExpr | IrStmt; - if (n.kind === "varDecl") return n.init && isCharAt(n.init) ? n.localId : null; + if (n.kind === "varDecl") { + if (!n.init) return null; + const local = locals.get(n.localId); + if (local?.type.kind === "string") return isCharAt(n.init) ? n.localId : null; + const read = reads.get(n.localId); + return read && local?.type.kind === "union" && local.type.unionId === read.unionId + ? n.localId + : null; + } if (n.kind === "strEq" || n.kind === "strCmp") return unitUse(n)?.id ?? null; if (n.kind === "switch") { - if (n.disc.kind !== "varRef") return null; + if (n.disc.kind !== "varRef" || n.disc.type.kind !== "string") return null; if (!n.cases.some((c) => c.test !== null)) return null; const units = n.cases.every( (c) => c.test === null || (c.test.kind === "strLit" && c.test.value.length === 1), ); return units ? n.disc.localId : null; } - return null; + const optional = optionalUnitTest(n as IrExpr, localTags); + return optional?.operand.kind === "varRef" ? optional.operand.localId : null; +} + +/** A one-unit test of a string that reads one character or "" (charAt), + * on its unit `unit`, which reads NaN for "": an equality, or an ordered + * comparison with the unit on the left when `unitLeft`. "" is below every + * one-unit literal, and NaN fails every ordered comparison, so `c < "x"` + * becomes `!(c >= 120)`. */ +function charUnitTest(e: UnitUse, unit: IrExpr, code: number, unitLeft: boolean): IrExpr { + const literal: IrExpr = { kind: "numLit", value: code, type: F64, loc: e.loc }; + if (e.kind === "strEq") + return { + kind: "bin", + op: e.negated ? "!==" : "===", + left: unit, + right: literal, + type: BOOL, + loc: e.loc, + }; + const op = unitLeft ? e.op : FLIPPED[e.op]; + if (op === ">" || op === ">=") + return { kind: "bin", op, left: unit, right: literal, type: BOOL, loc: e.loc }; + const negation: IrExpr = { + kind: "bin", + op: op === "<" ? ">=" : ">", + left: unit, + right: literal, + type: BOOL, + loc: e.loc, + }; + return { kind: "unary", op: "!", operand: negation, type: BOOL, loc: e.loc }; +} + +/** A test between `s.charAt(i)` and a literal of one code unit. */ +function charAtTest(e: UnitUse): { charAt: CharAt; code: number; left: boolean } | null { + const literalRight = e.right.kind === "strLit"; + const literal = literalRight ? e.right : e.left; + const other = literalRight ? e.left : e.right; + if (literal.kind !== "strLit" || literal.value.length !== 1 || !isCharAt(other)) return null; + return { charAt: other, code: literal.value.charCodeAt(0), left: literalRight }; } function isCharAt(e: IrExpr): e is CharAt { diff --git a/packages/compiler/src/ir/expression-inlining.test.ts b/packages/compiler/src/ir/expression-inlining.test.ts new file mode 100644 index 000000000..ae68c44ea --- /dev/null +++ b/packages/compiler/src/ir/expression-inlining.test.ts @@ -0,0 +1,145 @@ +import { expect, test } from "vitest"; +import { inlineExpressionFunctions } from "./expression-inlining.js"; +import { BOOL, F64, VOID, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; + +const loc = { file: "expression-inlining.ts", start: 0, end: 0 }; +const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 }); +const ref = (localId: string): IrExpr => ({ loc, kind: "varRef", localId, type: F64 }); +const bin = (op: "+" | "*" | ">>>", left: IrExpr, right: IrExpr): IrExpr => ({ + loc, + kind: "bin", + op, + left, + right, + type: F64, +}); +const call = (callee: string, args: IrExpr[]): IrExpr => ({ + loc, + kind: "call", + callee, + args, + type: F64, +}); +const local = (id: string, extra: object = {}) => ({ + id, + name: id, + type: F64, + mutable: true, + ...extra, +}); + +// a(i, j) = ((i + j) * (i + j + 1)) >>> 1 +const a: IrFunction = { + loc, + name: "a", + params: [ + { localId: "i", name: "i", type: F64 }, + { localId: "j", name: "j", type: F64 }, + ], + locals: [local("i"), local("j")], + returnType: F64, + body: [ + { + loc, + kind: "return", + value: bin( + ">>>", + bin("*", bin("+", ref("i"), ref("j")), bin("+", bin("+", ref("i"), ref("j")), num(1))), + num(1), + ), + }, + ], +}; + +/** `caller` running `body` in a loop. */ +function caller(body: IrStmt[], extra: object = {}): IrFunction { + return { + loc, + name: "caller", + params: [], + locals: [local("x", extra), local("y"), local("out")], + returnType: VOID, + body: [{ loc, kind: "while", cond: { loc, kind: "boolLit", value: true, type: BOOL }, body }], + }; +} + +const loopBody = (fn: IrFunction): IrStmt[] => (fn.body[0] as IrStmt & { kind: "while" }).body; + +const mod = (fn: IrFunction): IrModule => ({ + irVersion: 17, + sourceFile: "expression-inlining.ts", + entry: "caller", + functions: [a, fn], +}); + +test("calls passing literals and locals take the callee's expression over them", () => { + const result = inlineExpressionFunctions( + mod(caller([{ loc, kind: "assign", localId: "out", value: call("a", [ref("x"), num(3)]) }])), + ); + const assign = loopBody(result.functions[1]!)[0] as IrStmt & { kind: "assign" }; + expect(assign.value).toMatchObject({ + kind: "bin", + op: ">>>", + left: { + kind: "bin", + op: "*", + left: { + kind: "bin", + op: "+", + left: { kind: "varRef", localId: "x" }, + right: { kind: "numLit", value: 3 }, + }, + }, + }); + // Every substituted read is a fresh node. + const left = (assign.value as IrExpr & { kind: "bin" }).left as IrExpr & { kind: "bin" }; + const factors = [left.left, (left.right as IrExpr & { kind: "bin" }).left] as (IrExpr & { + kind: "bin"; + })[]; + expect(factors[0]!.left).not.toBe(factors[1]!.left); + expect(result.functions[0]).toBe(a); +}); + +test("calls with other arguments, boxed locals or callees with other bodies keep their call", () => { + const nested = call("a", [call("a", [ref("x"), ref("y")]), num(1)]); + const kept = inlineExpressionFunctions( + mod(caller([{ loc, kind: "assign", localId: "out", value: nested }])), + ); + const outer = (loopBody(kept.functions[1]!)[0] as IrStmt & { kind: "assign" }).value; + // The inner call inlines; the outer one passes a computed argument. + expect(outer).toMatchObject({ + kind: "call", + callee: "a", + args: [{ kind: "bin", op: ">>>" }, { kind: "numLit" }], + }); + const boxed = caller( + [{ loc, kind: "assign", localId: "out", value: call("a", [ref("x"), ref("y")]) }], + { + boxed: true, + }, + ); + expect(inlineExpressionFunctions(mod(boxed)).functions[1]).toBe(boxed); + const twoStatements: IrFunction = { + ...a, + body: [{ loc, kind: "exprStmt", expr: ref("i") }, ...a.body], + }; + const unchanged: IrModule = { + ...mod( + caller([{ loc, kind: "assign", localId: "out", value: call("a", [ref("x"), ref("y")]) }]), + ), + functions: [ + twoStatements, + caller([{ loc, kind: "assign", localId: "out", value: call("a", [ref("x"), ref("y")]) }]), + ], + }; + expect(inlineExpressionFunctions(unchanged)).toBe(unchanged); +}); + +test("calls outside loops keep their call", () => { + const outside: IrFunction = { + ...caller([]), + body: [{ loc, kind: "assign", localId: "out", value: call("a", [ref("x"), num(3)]) }], + }; + const m = mod(outside); + expect(inlineExpressionFunctions(m)).toBe(m); +}); diff --git a/packages/compiler/src/ir/expression-inlining.ts b/packages/compiler/src/ir/expression-inlining.ts new file mode 100644 index 000000000..377cfaf3f --- /dev/null +++ b/packages/compiler/src/ir/expression-inlining.ts @@ -0,0 +1,157 @@ +import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js"; +import { everyExprChild, everyStmtChild, mapExprChildren, transformStmtList } from "./traverse.js"; + +/** Callee bodies above this many nodes keep their call. */ +const MAX_EXPRESSION_NODES = 32; + +/** Number operators that run no other code and cannot throw. */ +const PURE_BINARY = new Set([ + "+", + "-", + "*", + "/", + "%", + "**", + "&", + "|", + "^", + "<<", + ">>", + ">>>", +]); + +/** The returned expression of a function whose body is `return ` over + * its number parameters: literals, parameter reads, and the number + * operators of PURE_BINARY, unary `-` and `~`. Null for any other function. */ +function inlinableExpression(fn: IrFunction): IrExpr | null { + if (fn.async || fn.generator || fn.captures !== undefined || fn.classCaptures?.length) + return null; + if (fn.tier === "dynamic" || fn.returnType.kind !== "f64") return null; + if (fn.body.length !== 1) return null; + const only = fn.body[0]!; + if (only.kind !== "return" || only.value === null) return null; + const params = new Set(); + for (const param of fn.params) { + if (param.type.kind !== "f64") return null; + params.add(param.localId); + } + const locals = new Map(fn.locals.map((local) => [local.id, local])); + for (const id of params) { + const local = locals.get(id); + if (local === undefined || local.boxed || local.tdz) return null; + } + let nodes = 0; + const pure = (e: IrExpr): boolean => { + if (++nodes > MAX_EXPRESSION_NODES || e.type.kind !== "f64") return false; + switch (e.kind) { + case "numLit": + return true; + case "varRef": + return params.has(e.localId); + case "bin": + return PURE_BINARY.has(e.op) && pure(e.left) && pure(e.right); + case "unary": + return (e.op === "-" || e.op === "~") && pure(e.operand); + default: + return false; + } + }; + return pure(only.value) ? only.value : null; +} + +/** A call argument that reads the same value wherever the inlined body + * reads it: a number literal or a read of one of the caller's own unboxed + * locals outside the temporal dead zone. The body writes nothing, so every + * read sees the value the call would have passed. */ +function duplicableArgument(e: IrExpr, caller: ReadonlyMap) { + if (e.type.kind !== "f64") return false; + // A literal whose source spelling does not round-trip keeps its call. + if (e.kind === "numLit") return e.spelling === undefined; + if (e.kind !== "varRef") return false; + const local = caller.get(e.localId); + return local !== undefined && !local.boxed && !local.tdz; +} + +/** A fresh copy of `e`: facts downstream are keyed by node identity. */ +function copy(e: IrExpr, substitute: (ref: IrExpr & { kind: "varRef" }) => IrExpr): IrExpr { + if (e.kind === "varRef") return substitute(e); + return mapExprChildren( + { ...e }, + (child) => copy(child, substitute), + (stmt) => stmt, + ); +} + +/** The direct calls of `body` that run inside a loop body or condition. */ +function callsInLoops(body: IrStmt[]): Set { + const found = new Set(); + let depth = 0; + const expr = (e: IrExpr): boolean => { + if (depth > 0 && e.kind === "call") found.add(e); + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + const loop = + s.kind === "for" || s.kind === "while" || s.kind === "doWhile" || s.kind === "forOf"; + if (loop) depth++; + everyStmtChild(s, expr, stmt); + if (loop) depth--; + return true; + }; + body.forEach(stmt); + return found; +} + +/** Inlines functions whose body is one number expression over their + * parameters (`function a(i, j) { return (((i + j) * (i + j + 1)) >>> 1) + + * i + 1; }`) at direct call sites that pass literals or reads of the + * caller's locals. + * + * The callee runs no other code, allocates nothing and cannot throw, so + * the call's only effect is its result. Its arguments are reads that + * nothing between them and the callee's reads can change, so substituting + * them gives the same value. The caller's loop and integer analyses then + * see the arithmetic on its own counters (`a(i, j)` inside a counted loop + * over `i` and `j`). Only calls inside loops are inlined: elsewhere the + * integer analyses gain nothing. The function itself stays for its other + * uses. */ +export function inlineExpressionFunctions(mod: IrModule): IrModule { + const members = new Set((mod.classes ?? []).flatMap((cls) => cls.methods ?? [])); + const expressions = new Map(); + for (const fn of mod.functions) { + if (fn.name === mod.entry || members.has(fn.name)) continue; + const value = inlinableExpression(fn); + if (value !== null) expressions.set(fn.name, { fn, value }); + } + if (expressions.size === 0) return mod; + let changed = false; + const functions = mod.functions.map((fn) => { + const locals = new Map(fn.locals.map((local) => [local.id, local])); + const inLoops = callsInLoops(fn.body); + if (inLoops.size === 0) return fn; + let inlined = false; + const body = transformStmtList(fn.body, { + stmt: (s) => s, + expr: (e) => { + if (e.kind !== "call" || e.callee === fn.name || !inLoops.has(e)) return e; + const target = expressions.get(e.callee); + if (target === undefined || target.fn.params.length !== e.args.length) return e; + if (!e.args.every((arg) => duplicableArgument(arg, locals))) return e; + const args = new Map(target.fn.params.map((param, i) => [param.localId, e.args[i]!])); + inlined = true; + return copy(target.value, (ref) => { + const arg = args.get(ref.localId); + if (arg?.kind === "numLit") + return { kind: "numLit", value: arg.value, type: arg.type, loc: ref.loc }; + if (arg?.kind === "varRef") + return { kind: "varRef", localId: arg.localId, type: arg.type, loc: ref.loc }; + return ref; + }); + }, + }); + if (!inlined) return fn; + changed = true; + return { ...fn, body }; + }); + return changed ? { ...mod, functions } : mod; +} diff --git a/packages/compiler/src/ir/int32-arrays.ts b/packages/compiler/src/ir/int32-arrays.ts index 353755c05..0677b2ad1 100644 --- a/packages/compiler/src/ir/int32-arrays.ts +++ b/packages/compiler/src/ir/int32-arrays.ts @@ -805,6 +805,41 @@ function rewriteCandidates( return { ...fn, body, locals: locals.filter((local) => !popLocals.has(local.id)) }; } +/** A module marked for its int32 array candidates: the candidates of + * each function (int32ArrayCandidates) and the element readers the marking + * inlines. */ +interface Int32ArrayMarking { + readonly mod: IrModule; + readonly readers: ReadonlyMap; + readonly candidates: ReadonlyMap>; +} + +/** markInt32ArrayCandidates with the candidates it marked; null when it + * marks none. */ +function markCandidates( + mod: IrModule, + keep: ReadonlySet | null, + redirected: ReadonlySet, +): Int32ArrayMarking | null { + if (mod.lib !== undefined) return null; + const readers = new Map(); + for (const fn of mod.functions) if (isElementReader(fn)) readers.set(fn.name, fn); + const all = moduleInt32ArrayCandidates(mod, new Set(readers.keys()), keep, redirected); + if (all.size === 0) return null; + const keys = new Set(); + const functions = mod.functions.map((fn) => { + const candidates = all.get(fn.name); + if (!candidates) return fn; + for (const id of candidates) keys.add(int32ArrayKey(fn.name, id)); + return rewriteCandidates(fn, candidates, readers); + }); + return { + mod: { ...mod, functions, int32ArrayCandidates: [...keys] }, + readers, + candidates: all, + }; +} + /** Propose the module's int32 array candidates (int32ArrayKey keys in * `int32ArrayCandidates`), only those in `keep` when given, and rewrite * their functions. */ @@ -813,42 +848,126 @@ export function markInt32ArrayCandidates( keep: ReadonlySet | null = null, redirected: ReadonlySet = new Set(), ): IrModule { - if (mod.lib !== undefined) return mod; - const readers = new Map(); - for (const fn of mod.functions) if (isElementReader(fn)) readers.set(fn.name, fn); - const all = moduleInt32ArrayCandidates(mod, new Set(readers.keys()), keep, redirected); - if (all.size === 0) return mod; - const keys = new Set(); - const functions = mod.functions.map((fn) => { - const candidates = all.get(fn.name); - if (!candidates) return fn; - for (const id of candidates) keys.add(int32ArrayKey(fn.name, id)); - return rewriteCandidates(fn, candidates, readers); - }); - return { ...mod, functions, int32ArrayCandidates: [...keys] }; + return markCandidates(mod, keep, redirected)?.mod ?? mod; +} + +/** Revises a function of a module marked for int32 array candidates when + * some of them drop: the function marked for its candidates outside + * `dropped` (int32ArrayKey keys), which is the unmarked function when none + * is left. */ +export type Int32ArrayRevision = (fnName: string, dropped: ReadonlySet) => IrFunction; + +/** What a proof of a marked module (selectInt32Arrays' `prove`) keeps. */ +export interface Int32ArrayProof { + /** The candidates it keeps. */ + readonly kept: ReadonlySet; + /** The functions of the module it proved, in module order, when it + * revised some through the Int32ArrayRevision it was given; otherwise + * null, for the marked module's own. */ + readonly functions: readonly IrFunction[] | null; } /** The module the backend emits: rewritten for exactly the candidates the - * int32 slot analysis (`prove`, returning its int32Arrays) keeps, so a - * module without int32 arrays is `mod` itself. A dropped candidate's - * rewrite is undone by marking again without it, until every candidate - * marked is kept. `redirected` names the functions some of whose calls the + * int32 slot analysis (`prove`) keeps, so a module without int32 arrays is + * `mod` itself. `redirected` names the functions some of whose calls the * backend emits as calls of another function (a result-slot entry): their - * parameters keep doubles. */ + * parameters keep doubles. + * + * The proof of the marked module gets an Int32ArrayRevision: as candidates + * drop, it revises their functions and goes on proving the revised module, + * so one analysis ends with the module marked for the candidates it keeps. + * That module is the answer when marking for those candidates again proposes + * exactly them, in the functions the proof revised. A proof that cannot + * follow a revision answers null; then each round marks the module, proves + * it without revisions, and marks again without the dropped candidates, + * until a round keeps every candidate it marks (four rounds at most). */ export function selectInt32Arrays( mod: IrModule, - prove: (marked: IrModule) => ReadonlySet, + prove: (marked: IrModule, revise: Int32ArrayRevision | null) => Int32ArrayProof | null, redirected: (mod: IrModule) => ReadonlySet = () => new Set(), ): IrModule { - let keep: ReadonlySet | null = null; const others = redirected(mod); - for (let round = 0; round < 4; round++) { + const marking = markCandidates(mod, null, others); + if (marking === null) return mod; + const original = new Map(mod.functions.map((fn) => [fn.name, fn])); + const markedFunctions = new Map(marking.mod.functions.map((fn) => [fn.name, fn])); + // Each function's latest revision and the candidates it is marked for. + const revisions = new Map }>(); + const revise: Int32ArrayRevision = (fnName, dropped) => { + const candidates = marking.candidates.get(fnName) ?? new Set(); + const ids = new Set(); + for (const id of candidates) if (!dropped.has(int32ArrayKey(fnName, id))) ids.add(id); + const latest = revisions.get(fnName); + if (latest !== undefined && latest.ids.size === ids.size) return latest.fn; + if (latest === undefined && ids.size === candidates.size) return markedFunctions.get(fnName)!; + const fn = + ids.size === 0 + ? original.get(fnName)! + : rewriteCandidates(original.get(fnName)!, ids, marking.readers); + revisions.set(fnName, { fn, ids }); + return fn; + }; + const proof = prove(marking.mod, revise); + const proposed = marking.mod.int32ArrayCandidates ?? []; + let keep: ReadonlySet | null = null; + let round = 0; + if (proof !== null && proof.functions === null) { + // The marked module itself was proved: the first round. + if (proposed.every((key) => proof.kept.has(key))) return marking.mod; + keep = new Set(proposed.filter((key) => proof.kept.has(key))); + round = 1; + } else if (proof !== null) { + const settled = settleRevisions(mod, marking, proof, revisions, others); + if (settled !== null) return settled; + } + for (; round < 4; round++) { const marked = markInt32ArrayCandidates(mod, keep, others); if (marked === mod) return mod; - const kept = prove(marked); - const proposed = marked.int32ArrayCandidates ?? []; - if (proposed.every((key) => kept.has(key))) return marked; - keep = new Set(proposed.filter((key) => kept.has(key))); + const kept = prove(marked, null)!.kept; + const roundProposed = marked.int32ArrayCandidates ?? []; + if (roundProposed.every((key) => kept.has(key))) return marked; + keep = new Set(roundProposed.filter((key) => kept.has(key))); } return mod; } + +/** The module marked for the candidates a revising proof kept, built from + * the functions it proved: null unless marking `mod` for those candidates + * proposes, in each function, exactly the candidates its proved function is + * marked for. `mod` itself when it proposes none. */ +function settleRevisions( + mod: IrModule, + marking: Int32ArrayMarking, + proof: Int32ArrayProof, + revisions: ReadonlyMap }>, + redirected: ReadonlySet, +): IrModule | null { + const kept = proof.kept; + const functions = proof.functions!; + if (functions.length !== mod.functions.length) return null; + const all = moduleInt32ArrayCandidates( + mod, + new Set(marking.readers.keys()), + new Set([...(marking.mod.int32ArrayCandidates ?? [])].filter((key) => kept.has(key))), + redirected, + ); + const keys = new Set(); + for (let i = 0; i < mod.functions.length; i++) { + const fn = mod.functions[i]!; + const proved = functions[i]!; + const candidates = all.get(fn.name); + const revision = revisions.get(fn.name); + const marked = revision?.ids ?? marking.candidates.get(fn.name) ?? new Set(); + if ((candidates?.size ?? 0) !== marked.size) return null; + if (candidates !== undefined) + for (const id of candidates) { + if (!marked.has(id)) return null; + keys.add(int32ArrayKey(fn.name, id)); + } + const expected = + revision?.fn ?? (marking.candidates.has(fn.name) ? marking.mod.functions[i]! : fn); + if (proved !== expected) return null; + } + if (all.size === 0) return mod; + return { ...mod, functions: [...functions], int32ArrayCandidates: [...keys] }; +} diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 1616a2a77..79702ce97 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -27,7 +27,7 @@ import { int32ArraySlot, int32ArrayStores, } from "./int32-arrays.js"; -import { everyExprChild, everyStmtChild, everyTypeChild } from "./traverse.js"; +import { everyExprChild, everyStmtChild, everyStmtList, everyTypeChild } from "./traverse.js"; /** The storage slot an array-valued expression reads, as an * IrContracts.int32Elements key: a local or parameter @@ -352,6 +352,123 @@ export class Int32Slots { for (const params of this.paramFacts.values()) count += params.size; return count; } + + /** The analysis as JSON, for a process that shares this module and its + * node index (a forked one). Proven writes are numbered by their position + * in `nodes`. Null when a proven write is not in the index. */ + encode(nodes: IrNodeIndex): string | null { + const proven = nodes.expressionPositions(this.provenWrites); + if (proven === null) return null; + const data: Int32SlotsData = { + fields: [...this.fields], + familyOf: [...this.familyOf], + paramFacts: [...this.paramFacts].map(([fn, params]) => [fn, [...params]]), + returns: [...this.returns], + returnRanges: [...this.returnRanges], + boxes: [...this.boxes], + boxGroupOf: [...this.boxGroupOf], + provenWrites: proven, + called: [...this.called], + reasons: [...this.reasons], + pinned: [...this.pinned], + int32Locals: [...this.int32Locals], + int32Elements: [...this.int32Elements], + pinViolations: this.pinViolations, + nonNegative: [...this.nonNegative], + narrowedReads: this.narrowedReads, + int32Arrays: [...this.int32Arrays], + checkedInt32Arrays: [...this.checkedInt32Arrays], + }; + return JSON.stringify(data); + } + + /** The analysis `encode` wrote for `mod`, whose node index is `nodes`. */ + static decode(text: string, mod: IrModule, nodes: IrNodeIndex): Int32Slots { + const data = JSON.parse(text) as Int32SlotsData; + const slots = new Int32Slots(new Map((mod.classes ?? []).map((c) => [c.name, c]))); + for (const family of data.fields) slots.fields.add(family); + for (const [key, family] of data.familyOf) slots.familyOf.set(key, family); + for (const [fn, params] of data.paramFacts) slots.paramFacts.set(fn, new Map(params)); + for (const fn of data.returns) slots.returns.add(fn); + for (const [fn, range] of data.returnRanges) slots.returnRanges.set(fn, range); + for (const group of data.boxes) slots.boxes.add(group); + for (const [member, group] of data.boxGroupOf) slots.boxGroupOf.set(member, group); + for (const node of nodes.expressionsAt(data.provenWrites)) slots.provenWrites.add(node); + for (const fn of data.called) slots.called.add(fn); + for (const [key, why] of data.reasons) slots.reasons.set(key, why); + for (const key of data.pinned) slots.pinned.add(key); + for (const key of data.int32Locals) slots.int32Locals.add(key); + for (const key of data.int32Elements) slots.int32Elements.add(key); + slots.pinViolations.push(...data.pinViolations); + for (const key of data.nonNegative) slots.nonNegative.add(key); + slots.narrowedReads = data.narrowedReads; + for (const key of data.int32Arrays) slots.int32Arrays.add(key); + for (const key of data.checkedInt32Arrays) slots.checkedInt32Arrays.add(key); + return slots; + } + + /** Every fact, in a canonical order, with each proven write as its + * position in `mod` (function index, then walk order): two analyses of + * equal modules hold the same facts when their summaries are equal. */ + summary(mod: IrModule): string { + const sorted = (values: Iterable): string => [...values].sort().join(","); + const lines = [ + `fields ${sorted(this.fields)}`, + `params ${sorted([...this.paramFacts].flatMap(([fn, params]) => [...params].map(([id, r]) => `${fn}\0${id}=${r.min}:${r.max}`)))}`, + `returns ${sorted(this.returns)}`, + `returnRanges ${sorted([...this.returnRanges].map(([fn, r]) => `${fn}=${r.min}:${r.max}`))}`, + `boxes ${sorted(this.boxes)}`, + `boxGroups ${sorted([...this.boxGroupOf].map(([member, group]) => `${member}=${group}`))}`, + `called ${sorted(this.called)}`, + `pinned ${sorted(this.pinned)}`, + `int32Locals ${sorted(this.int32Locals)}`, + `int32Elements ${sorted(this.int32Elements)}`, + `nonNegative ${sorted(this.nonNegative)}`, + `pinViolations ${sorted(this.pinViolations)}`, + `narrowedReads ${this.narrowedReads}`, + `int32Arrays ${sorted(this.int32Arrays)}`, + `checkedInt32Arrays ${sorted(this.checkedInt32Arrays)}`, + ]; + const positions: string[] = []; + mod.functions.forEach((fn, f) => { + let at = 0; + everyStmtList(fn.body, { + expr: (e) => { + if (this.provenWrites.has(e)) positions.push(`${f}:${at}`); + at++; + return true; + }, + stmt: () => { + at++; + return true; + }, + }); + }); + lines.push(`provenWrites ${this.provenWrites.size} ${positions.join(",")}`); + return lines.join("\n"); + } +} + +/** Int32Slots as plain data (Int32Slots.encode). */ +interface Int32SlotsData { + fields: string[]; + familyOf: [string, string | null][]; + paramFacts: [string, [string, IntegerRange][]][]; + returns: string[]; + returnRanges: [string, IntegerRange][]; + boxes: string[]; + boxGroupOf: [string, string][]; + provenWrites: number[]; + called: string[]; + reasons: [string, string][]; + pinned: string[]; + int32Locals: string[]; + int32Elements: string[]; + pinViolations: string[]; + nonNegative: string[]; + narrowedReads: boolean; + int32Arrays: string[]; + checkedInt32Arrays: string[]; } const DYN_KINDS = new Set(["dyn", "jsval", "caught"]); @@ -833,6 +950,95 @@ export interface Int32SlotInputs { initializerBindings?: ReadonlyMap; /** The module's node index, read instead of walking the bodies. */ nodes?: IrNodeIndex; + /** Revises a function of a module marked for int32 array candidates when + * candidates drop (int32-arrays.ts Int32ArrayRevision): the function + * marked without the `dropped` candidates. With it, a candidate that drops + * (and keeps no checked stores) takes its sites back to the element-reader + * calls the marking inlined, and the fixpoint goes on over the revised + * functions, so its result is the analysis of the module marked for the + * candidates it keeps. A revision that changes more of a function than + * those sites throws Int32RevisionConflict. */ + revise?: (fnName: string, dropped: ReadonlySet) => IrFunction; +} + +/** A revision (Int32SlotInputs.revise) changed a function in a way the + * fixpoint cannot follow: a call of a function no other call reaches, or + * any change besides element-reader calls taking the place of their + * inlined reads. The module needs an analysis of its own. */ +export class Int32RevisionConflict extends Error {} + +/** What the analysis's setup reads from a function body, besides direct + * calls: a revision must leave all of it unchanged. */ +function revisionStructure(fn: IrFunction): { + calls: { callee: string; args: number }[]; + rest: string; +} { + const calls: { callee: string; args: number }[] = []; + const rest: string[] = []; + const boxed = new Set(fn.locals.filter((local) => local.boxed).map((local) => local.id)); + const expr = (e: IrExpr): boolean => { + const referenced = exprFunctionReference(e); + if (referenced !== undefined) rest.push(`ref:${referenced}`); + switch (e.kind) { + case "call": + calls.push({ callee: e.callee, args: e.args.length }); + break; + case "new": + rest.push(`new:${e.className}:${e.args.length}`); + break; + case "varRef": + if (boxed.has(e.localId)) rest.push(`read:${e.localId}`); + break; + case "assignExpr": + case "incDec": + rest.push(`${e.kind}:${e.localId}`); + break; + case "fieldGet": + case "fieldIncDec": + rest.push(`${e.kind}:${e.className}\0${e.field}`); + break; + case "closure": + rest.push(`closure:${e.fnName}:${e.captures.join(",")}`); + break; + case "classRef": + rest.push(`classRef:${e.className}:${(e.captures ?? []).join(",")}`); + break; + case "virtualCall": + rest.push(`virtualCall:${e.className}.${e.method}`); + break; + case "instanceOfValue": + rest.push(`instanceOf:${e.classValue.kind}`); + break; + } + if (BOUNDARY_KINDS.has(e.kind)) rest.push(`boundary:${e.kind}`); + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + switch (s.kind) { + case "forOf": + rest.push(`forOf:${s.localId}`); + break; + case "tryCatch": + if (s.catchLocalId) rest.push(`catch:${s.catchLocalId}`); + break; + case "assign": + case "varDecl": + if (boxed.has(s.localId)) rest.push(`${s.kind}:${s.localId}`); + break; + case "throw": + rest.push("throw"); + break; + } + return everyStmtChild(s, expr, stmt); + }; + for (const s of fn.body) stmt(s); + rest.sort(); + for (const local of fn.locals) + if (local.boxed || local.tdz) + rest.push(`local:${local.id}:${local.boxed === true}:${local.tdz === true}`); + for (const capture of fn.captures ?? []) rest.push(`capture:${capture.localId}`); + for (const capture of fn.classCaptures ?? []) rest.push(`classCapture:${capture.localId}`); + return { calls, rest: rest.join("\n") }; } export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): Int32Slots { @@ -1204,6 +1410,36 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): }); return found; }; + // Candidates dropped without checked stores, and the revision of the + // functions that hold them (Int32SlotInputs.revise). + const dropped = new Set(); + const revise = (names: ReadonlySet): void => { + for (const name of names) { + const previous = functions.get(name)!; + const next = inputs.revise!(name, dropped); + if (next === previous) continue; + const before = revisionStructure(previous); + const after = revisionStructure(next); + if (before.rest !== after.rest) throw new Int32RevisionConflict(name); + // The revision adds the element-reader calls back: each must be of a + // function other calls already reach, with its arity. + const counts = new Map(); + for (const { callee } of before.calls) counts.set(callee, (counts.get(callee) ?? 0) + 1); + for (const { callee, args } of after.calls) { + const left = counts.get(callee) ?? 0; + if (left > 0) { + counts.set(callee, left - 1); + continue; + } + const target = functions.get(callee); + if (!called.has(callee) || target === undefined || target.params.length !== args) + throw new Int32RevisionConflict(name); + reads(`ret:${callee}`, name); + } + for (const left of counts.values()) if (left > 0) throw new Int32RevisionConflict(name); + functions.set(name, next); + } + }; const provenByFunction = new Map(); // Each function's parameter and result writes under its latest analysis: // at the fixpoint, the ranges of every write under the final facts. @@ -1248,6 +1484,8 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): for (const id of globalArrays) both.add(id); arrays = both; } + // Functions whose candidates this visit drops, revised after it. + const revised = new Set(); if (arrays && arrayGroups) { const requeue = (name: string): void => { if (queued.has(name)) return; @@ -1282,9 +1520,39 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): checkableInt32Allocations(fn, id) ) slots.checkedInt32Arrays.add(key); - else for (const member of group) reason(`array:${member}`, "an element store is not int32"); + else { + for (const member of group) reason(`array:${member}`, "an element store is not int32"); + if (inputs.revise !== undefined) { + // The revised module no longer marks the group: its functions + // leave the candidate sets the visits below consult. + for (const member of group) { + dropped.add(member); + if (member.startsWith("\0")) { + globalArrays.delete(member.slice(1)); + const names = arrayGroups.globalReaders.get(member); + if (names) for (const name of names) revised.add(name); + } else { + const cut = member.indexOf("\0"); + const owner = member.slice(0, cut); + const ids = arrayCandidates.get(owner); + ids?.delete(member.slice(cut + 1)); + if (ids?.size === 0) arrayCandidates.delete(owner); + revised.add(owner); + } + } + globalArrayFunctions.clear(); + for (const [member, names] of arrayGroups.globalReaders) + if (!dropped.has(member)) for (const name of names) globalArrayFunctions.add(name); + } + } } } + if (revised.size > 0) { + revise(revised); + // A revised body is analyzed again (it was queued with its readers); + // the writes of the body it replaces no longer count. + if (functions.get(fn.name) !== fn) continue; + } // An int32 written into a slot still assumed non-negative: widen the // assumption when the value may be negative, and reanalyze its readers. const settle = (slot: string, e: IrExpr): void => { diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index 2cca43a00..48be67fbf 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -552,3 +552,71 @@ test("counters stepped beside the tested one run as integers", () => { { startGuarded: true, tripGuarded: true }, ]); }); + +/** `counted(limit)` with the body `x = ((i + k) * (i + k + 1)) >>> 1` + * inside a nested `for (let j = 0; j < 4; j++)` loop. */ +function productLoop(limit: IrExpr, start: IrExpr = number(0)): IrStmt & { kind: "for" } { + const s = counted(limit); + if (s.init?.kind === "varDecl") s.init.init = start; + const sum = (extra: number): IrExpr => { + const base: IrExpr = { kind: "bin", op: "+", left: ref("i"), right: ref("k"), type: F64, loc }; + return extra === 0 + ? base + : { kind: "bin", op: "+", left: base, right: number(extra), type: F64, loc }; + }; + const product: IrExpr = { kind: "bin", op: "*", left: sum(0), right: sum(1), type: F64, loc }; + const inner = counted(number(4)); + if (inner.init?.kind === "varDecl") inner.init.localId = "j"; + if (inner.cond?.kind === "bin") inner.cond.left = ref("j"); + if (inner.update?.kind === "assign") { + inner.update.localId = "j"; + if (inner.update.value.kind === "bin") inner.update.value.left = ref("j"); + } + inner.body = [ + { + kind: "assign", + localId: "x", + value: { kind: "bin", op: ">>>", left: product, right: number(1), type: F64, loc }, + loc, + }, + ]; + s.body = [inner]; + return s; +} + +test("guarded counted loops whose products the counter bound proves exact may hold nested loops", () => { + const scope = new Map(locals); + for (const id of ["limit", "start", "k", "x", "j"]) + scope.set(id, { id, name: id, type: F64, mutable: true }); + const tight = matchIntegerCountedForLoop(productLoop(ref("limit"), ref("start")), scope); + expect(tight).toMatchObject({ + guarded: true, + limitGuarded: true, + startGuarded: true, + startBound: 2 ** 25, + guardLimit: 2 ** 25, + range: { min: -(2 ** 25), max: 2 ** 25 - 1 }, + }); + // A literal trip count past the bound keeps the safe-integer bound, and + // a guarded loop under that bound holds no nested loop. + expect(matchIntegerCountedForLoop(productLoop(number(1e9), ref("start")), scope)).toBeNull(); + const plain = counted(ref("limit")); + plain.body = [productLoop(number(4)).body[0]!]; + const inner = plain.body[0] as IrStmt & { kind: "for" }; + inner.body = [ + { + kind: "assign", + localId: "x", + value: { kind: "bin", op: "|", left: ref("i"), right: number(0), type: F64, loc }, + loc, + }, + ]; + expect(matchIntegerCountedForLoop(plain, scope)).toBeNull(); + // Without a nested loop the product bound still applies to the limit. + const flat = productLoop(ref("limit"), ref("start")); + flat.body = (flat.body[0] as IrStmt & { kind: "for" }).body; + expect(matchIntegerCountedForLoop(flat, scope)).toMatchObject({ + startBound: 2 ** 25, + guardLimit: 2 ** 25, + }); +}); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index 2f7d2bea6..7cc49b7a0 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -24,8 +24,12 @@ export interface IntegerCountedForLoop { /** The limit needs the run-time `guardLimit` test (part of `guarded`). */ limitGuarded: boolean; /** The start has no proven integer range: the versioned loop also tests - * that it is an exact integer (not -0) of at most 2^53 in magnitude. */ + * that it is an exact integer (not -0) of at most `startBound` in + * magnitude. */ startGuarded: boolean; + /** The magnitude the guarded start is tested against: 2^53 - 1, or + * PRODUCT_COUNTER_BOUND when the body multiplies sums of the counter. */ + startBound: number; guardLimit: number; range: IntegerRange; /** Counters declared and stepped beside this one that the condition does @@ -50,6 +54,64 @@ export interface IntegerLoopFollower { range: IntegerRange; } +/** The counter magnitude below which a product of two sums of counters and + * small literals stays exact in a double: `(i + j) * (i + j + 1)` with both + * counters within 2^25 is below 2^52. A guarded loop whose body multiplies + * such sums for a bitwise operator tests its counter against this bound, so + * the product's integer form is proven exact. */ +export const PRODUCT_COUNTER_BOUND = 2 ** 25; + +/** Whether `e` is a sum or difference of locals and safe integer literals + * of magnitude at most 2^20, and whether it reads `counter`. */ +function localSum(e: IrExpr, counter: string): { sum: boolean; reads: boolean } { + let reads = false; + const sum = (x: IrExpr): boolean => { + if (x.kind === "varRef") { + if (x.localId === counter) reads = true; + return true; + } + if (x.kind === "numLit") return Number.isSafeInteger(x.value) && Math.abs(x.value) <= 2 ** 20; + return x.kind === "bin" && (x.op === "+" || x.op === "-") && sum(x.left) && sum(x.right); + }; + return { sum: sum(e), reads }; +} + +/** Whether a bitwise operand `e` multiplies two sums of locals and + * literals, one of which reads `counter` (`((i + j) * (i + j + 1)) >>> 1`). */ +function multipliesCounterSums(e: IrExpr, counter: string): boolean { + if (e.kind !== "bin") return false; + if (e.op === "*") { + const left = localSum(e.left, counter), + right = localSum(e.right, counter); + return left.sum && right.sum && (left.reads || right.reads); + } + return ( + (e.op === "+" || e.op === "-") && + (multipliesCounterSums(e.left, counter) || multipliesCounterSums(e.right, counter)) + ); +} + +const BITWISE = new Set(["&", "|", "^", "<<", ">>", ">>>"]); + +/** Whether `body`, nested loops included, takes a bitwise operator of a + * product that multipliesCounterSums matches. */ +function bodyMultipliesCounter(body: IrStmt[], counter: string): boolean { + let found = false; + everyStmtList(body, { + stmt: () => !found, + expr: (e) => { + if ( + e.kind === "bin" && + BITWISE.has(e.op) && + (multipliesCounterSums(e.left, counter) || multipliesCounterSums(e.right, counter)) + ) + found = true; + return !found; + }, + }); + return found; +} + /** The bound of the run-time follower test. */ export const FOLLOWER_GUARD_LIMIT = 2 ** 52; /** The magnitude a follower that passed the test can reach. The test's four @@ -171,6 +233,23 @@ export function matchIntegerCountedForLoop( // The condition tests the driving counter; every other declaration // follows it. const driver = declared(cond.left) ?? declared(cond.right); + // A body that multiplies sums of the driver tests it against the bound + // under which those products are exact. A limit proven past that bound + // (a literal trip count) keeps the safe-integer bound instead. + const limitSide = driver !== null && declared(cond.left) === driver ? cond.right : cond.left; + const knownLimit = + limitSide.kind === "numLit" + ? { min: limitSide.value, max: limitSide.value } + : ranges.get(limitSide); + const driverBound = + driver !== null && + !( + knownLimit && + Math.max(Math.abs(knownLimit.min), Math.abs(knownLimit.max)) > PRODUCT_COUNTER_BOUND + ) && + bodyMultipliesCounter(stmt.body, driver) + ? PRODUCT_COUNTER_BOUND + : Number.MAX_SAFE_INTEGER; const counterIds = decls.map((d) => (d.kind === "varDecl" ? d.localId : "")); const stepOf = (id: string): number | null => { const steps = updates.map((u) => constantStep(u, id)).filter((v) => v !== null); @@ -191,7 +270,8 @@ export function matchIntegerCountedForLoop( ? { min: start.value, max: start.value } : ranges.get(start); const guarded = !known && start.kind !== "numLit" && start.type.kind === "f64"; - const range = guarded ? { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER } : known; + const bound = id === driver ? driverBound : Number.MAX_SAFE_INTEGER; + const range = guarded ? { min: -bound, max: bound } : known; const step = stepOf(id); if ( !range || @@ -248,7 +328,9 @@ export function matchIntegerCountedForLoop( ); if (!stableLoopValue(limit, locals, stmt.body, true, local.id, stableLength)) return null; const inclusive = op === "<=" || op === ">="; - const guardLimit = step > 0 ? 2 ** 53 - (inclusive ? 1 : 0) : -(2 ** 53) + (inclusive ? 1 : 0); + const limitBound = driverBound === Number.MAX_SAFE_INTEGER ? 2 ** 53 : driverBound; + const guardLimit = + step > 0 ? limitBound - (inclusive ? 1 : 0) : -limitBound + (inclusive ? 1 : 0); const boundRange = limit.kind === "numLit" ? { min: limit.value, max: limit.value } : ranges.get(limit); const limitGuarded = @@ -264,7 +346,9 @@ export function matchIntegerCountedForLoop( Number.MAX_SAFE_INTEGER, boundRange ? Math.ceil(boundRange.max) - (inclusive ? 0 : 1) - : Number.MAX_SAFE_INTEGER, + : limitGuarded + ? Math.ceil(guardLimit) - (inclusive ? 0 : 1) + : Number.MAX_SAFE_INTEGER, ), ), } @@ -275,7 +359,9 @@ export function matchIntegerCountedForLoop( -Number.MAX_SAFE_INTEGER, boundRange ? Math.floor(boundRange.min) + (inclusive ? 0 : 1) - : -Number.MAX_SAFE_INTEGER, + : limitGuarded + ? Math.floor(guardLimit) + (inclusive ? 0 : 1) + : -Number.MAX_SAFE_INTEGER, ), ), max: startRange.max, @@ -366,9 +452,15 @@ export function matchIntegerCountedForLoop( const bodyStmt = (s: IrStmt): boolean => { if (s.kind === "bytesSet" && counter(s.index)) useful = true; if (!guarded && s.kind === "arraySet" && counter(s.index)) useful = true; + // A guarded loop's copy duplicates nested loops too, so only a body + // whose counter products the bound proves exact may hold a nested + // `for` loop; the node budget counts its body. if ( guarded && - (s.kind === "for" || s.kind === "forOf" || s.kind === "while" || s.kind === "doWhile") + (s.kind === "forOf" || + s.kind === "while" || + s.kind === "doWhile" || + (s.kind === "for" && driverBound === Number.MAX_SAFE_INTEGER)) ) return false; return (!guarded || ++cost <= 80) && everyStmtChild(s, expr, bodyStmt); @@ -383,6 +475,7 @@ export function matchIntegerCountedForLoop( guarded, limitGuarded, startGuarded, + startBound: driverBound, guardLimit, range, followers, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 25fce8364..b663de011 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -855,9 +855,8 @@ export function funcOf(params: IrType[], ret: IrType): IrType { * promises nullable-only; declared maybe-promise unions are admitted by * maybePromiseUnionArmsOk. */ export function unionContainerArmsOk(arms: IrType[]): boolean { - return ( - arms.every((a, i) => a.kind !== "promise" || arms.every((b, j) => j === i || isUnitType(b))) && - dateUnionArmsOk(arms) + return arms.every( + (a, i) => a.kind !== "promise" || arms.every((b, j) => j === i || isUnitType(b)), ); } @@ -868,13 +867,7 @@ export function unionContainerArmsOk(arms: IrType[]): boolean { * non-thenable path). Data arms must not be thenable; the type mapper * checks that against the checker types before minting such a union. */ export function maybePromiseUnionArmsOk(arms: IrType[]): boolean { - return arms.filter((a) => a.kind === "promise").length <= 1 && dateUnionArmsOk(arms); -} - -/** A Date arm has a union home beside undefined and null arms only: no - * narrowing contract tells it from other data arms yet. */ -function dateUnionArmsOk(arms: IrType[]): boolean { - return arms.every((a, i) => a.kind !== "date" || arms.every((b, j) => j === i || isUnitType(b))); + return arms.filter((a) => a.kind === "promise").length <= 1; } /** Canonical, injective text form of an IrType — the building block of @@ -1078,6 +1071,11 @@ export interface IrModule { * (frontend frozen-records.ts): their writes test the array's frozen bit * (SCR_ARR_FROZEN) and throw Node's TypeError when it is set. */ freezableArrays?: string[]; + /** The program freezes, seals or prevents extensions of untyped values + * (frontend frozen-records.ts noteUntypedIntegrity). Class hierarchies + * that can be held there carry an integrity word, which the runtime sets + * through the commit of the instance's checked view. */ + untypedIntegrity?: true; /** The entry module is CommonJS under Node's classification. Node runs a * CommonJS main synchronously and drains process.nextTick callbacks * before promise jobs at the first checkpoint; an ES-module main's @@ -1635,6 +1633,28 @@ export function classValueConstructor(def: { name: string; runtime?: true }): { : { fn: `%${def.name}.constructor`, firstArg: 1 }; } +/** Whether construction through a class value typed as `sup` may call + * `sub`'s construct thunk: both class-value constructors exist and take the + * same argument types after the receiver. The classval upcast rule and the + * checked class-value boundary both rest on it. */ +export function classValueAbiEquals( + sub: { name: string; runtime?: true }, + sup: { name: string; runtime?: true }, + functions: ReadonlyMap, +): boolean { + const subValueCtor = classValueConstructor(sub); + const supValueCtor = classValueConstructor(sup); + const subCtor = functions.get(subValueCtor.fn); + const supCtor = functions.get(supValueCtor.fn); + if (!subCtor || !supCtor) return false; + const subArgs = subCtor.params.slice(subValueCtor.firstArg); + const supArgs = supCtor.params.slice(supValueCtor.firstArg); + return ( + subArgs.length === supArgs.length && + subArgs.every((p, i) => typeEquals(p.type, supArgs[i]!.type)) + ); +} + /** The JavaScript `.name` of a class: its jsName, or the runtime-provided * class's own name (`%TypeError` is TypeError). */ export function classJsName(def: { name: string; runtime?: true; jsName?: string }): string { @@ -1786,16 +1806,32 @@ export function recordTextCodecClass(shape: IrRecordShape): "TextEncoder" | "Tex return null; } -/** The codec class of a TextEncoder or TextDecoder record type, else null. - * Such a record crosses an `unknown` slot as a native handle that holds the - * record itself (scr_text_codec_dyn.c), never as a copy or a field view. */ +/** The class of a record that crosses checked-dynamic slots as a native + * handle: the codecs, and a StringDecoder (type-mapper.ts: its canonical + * encoding and the packing of its pending bytes). */ +export function recordHandleCodecClass( + shape: IrRecordShape, +): "TextEncoder" | "TextDecoder" | "StringDecoder" | null { + const codec = recordTextCodecClass(shape); + if (codec !== null) return codec; + return shape.fields.length === 2 && + shape.fields[0]!.name === "%enc" && + shape.fields[1]!.name === "%pending" + ? "StringDecoder" + : null; +} + +/** The codec class of a TextEncoder, TextDecoder or StringDecoder record + * type, else null. Such a record crosses an `unknown` slot as a native + * handle that holds the record itself (scr_text_codec_dyn.c), never as a + * copy or a field view. */ export function textCodecRecordClass( t: IrType, getRecord: (shapeId: string) => IrRecordShape | undefined, -): "TextEncoder" | "TextDecoder" | null { +): "TextEncoder" | "TextDecoder" | "StringDecoder" | null { if (t.kind !== "record") return null; const shape = getRecord(t.shapeId); - return shape ? recordTextCodecClass(shape) : null; + return shape ? recordHandleCodecClass(shape) : null; } /** Object-literal ACCESSOR properties (`{ get x() {...}, set x(v) {...} }`) @@ -3386,7 +3422,17 @@ export type IrExpr = * from a union) whose Web API contract exposes the reference again, such * as stream chunks and abort reasons; ordinary JSON-shaped values retain * the documented deep-copy boundary. */ - | { kind: "dynFrom"; value: IrExpr; fnName?: string; liveRef?: true; type: IrType; loc: SrcLoc } + | { + kind: "dynFrom"; + value: IrExpr; + fnName?: string; + /** A boxed function's `length` when it differs from its parameter + * count (builtin function values). */ + fnLength?: number; + liveRef?: true; + type: IrType; + loc: SrcLoc; + } /** CALLING a dyn value — `fn(a, b)` where fn is checked-dynamic (an * implicit-any JS binding, a dyn record member, a dynKeyGet result). * Arguments are ALREADY dyn-typed (typed values box through dynFrom at diff --git a/packages/compiler/src/ir/node-index.ts b/packages/compiler/src/ir/node-index.ts index 2aac7df92..be1fa506a 100644 --- a/packages/compiler/src/ir/node-index.ts +++ b/packages/compiler/src/ir/node-index.ts @@ -122,6 +122,36 @@ export class IrNodeIndex { if (statementKinds.has(kind)) this.statements[code] = 1; } + /** The positions of the expressions `wanted` in the index, which an + * index of the same module in another process resolves with + * expressionsAt. Null when an expression is not recorded, or is recorded + * more than once. */ + expressionPositions(wanted: ReadonlySet): number[] | null { + const positions: number[] = []; + if (wanted.size === 0) return positions; + const found = new Set(); + const { nodes, codes, statements } = this; + for (let i = 0; i < nodes.length; i++) { + if (statements[codes[i]!] !== 0) continue; + const node = nodes[i]! as IrExpr; + if (!wanted.has(node)) continue; + if (found.has(node)) return null; + found.add(node); + positions.push(i); + } + return found.size === wanted.size ? positions : null; + } + + /** The expressions at `positions` (expressionPositions). */ + expressionsAt(positions: readonly number[]): IrExpr[] { + return positions.map((position) => { + const code = this.codes[position]; + if (code === undefined || this.statements[code] !== 0) + throw new InternalCompilerError(`no IR expression at index position ${position}`); + return this.nodes[position]! as IrExpr; + }); + } + /** A scan for the nodes of `kinds`, for eachNodeOfKinds. Pass a * constant list: scans are kept by the list's identity. */ scan(kinds: readonly IrNodeKind[]): IrKindScan { diff --git a/packages/compiler/src/ir/row-pairs.test.ts b/packages/compiler/src/ir/row-pairs.test.ts new file mode 100644 index 000000000..f9db5b89d --- /dev/null +++ b/packages/compiler/src/ir/row-pairs.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from "vitest"; +import { BOOL, F64, VOID, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; +import { pairRowSums } from "./row-pairs.js"; + +const loc = { file: "row-pairs.ts", start: 0, end: 0 }; +const BYTES = { kind: "bytes", elem: "f64" } as const; +const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 }); +const ref = (localId: string): IrExpr => ({ loc, kind: "varRef", localId, type: F64 }); +const bytes = (localId: string): IrExpr & { kind: "varRef" } => ({ + loc, + kind: "varRef", + localId, + type: BYTES, +}); +const bin = (op: "+" | "/" | "<", left: IrExpr, right: IrExpr): IrExpr => ({ + loc, + kind: "bin", + op, + left, + right, + type: op === "<" ? BOOL : F64, +}); +const step = (id: string): IrStmt => ({ + loc, + kind: "assign", + localId: id, + value: bin("+", ref(id), num(1)), +}); +const counted = ( + id: string, + start: IrExpr, + end: IrExpr, + body: IrStmt[], +): IrStmt & { kind: "for" } => ({ + loc, + kind: "for", + init: { loc, kind: "varDecl", localId: id, init: start }, + cond: bin("<", ref(id), end), + update: step(id), + body, +}); +const get = (array: string, index: IrExpr): IrExpr => ({ + loc, + kind: "bytesIntrinsic", + method: "get", + receiver: bytes(array), + args: [index], + type: F64, + invalidNaN: true, +}); + +/** for (i = start; i < end; i++) { t = 0; for (j = 0; j < n; j++) t += u[j] / (i + j + 1); v[i] = t } */ +function rowSum( + term: IrExpr = bin("/", get("u", ref("j")), bin("+", bin("+", ref("i"), ref("j")), num(1))), +) { + return counted("i", ref("start"), ref("end"), [ + { loc, kind: "varDecl", localId: "t", init: num(0) }, + counted("j", num(0), ref("n"), [ + { loc, kind: "assign", localId: "t", value: bin("+", ref("t"), term) }, + ]), + { loc, kind: "bytesSet", arr: bytes("v"), index: ref("i"), value: ref("t") }, + ]); +} + +const local = (id: string, type: IrExpr["type"] = F64) => ({ id, name: id, type, mutable: true }); +function mod(body: IrStmt[]): IrModule { + const fn: IrFunction = { + loc, + name: "rows", + params: [], + locals: [ + ...["i", "j", "t", "start", "end", "n"].map((id) => local(id)), + local("u", BYTES), + local("v", BYTES), + ], + returnType: VOID, + body, + }; + return { irVersion: 17, sourceFile: "row-pairs.ts", entry: "rows", functions: [fn] }; +} + +test("a row-sum nest runs two rows per pass behind its bounds and overlap test", () => { + const result = pairRowSums(mod([rowSum()])); + const top = result.functions[0]!.body[0] as IrStmt & { kind: "block" }; + expect(top.kind).toBe("block"); + const [go, from, branch, rest] = top.body as [ + IrStmt & { kind: "varDecl" }, + IrStmt & { kind: "varDecl" }, + IrStmt & { kind: "if" }, + IrStmt & { kind: "for" }, + ]; + // The test: int32 bounds, start <= end, and the stored array disjoint + // from the array the term reads. + expect(JSON.stringify(go.init)).toContain('"fn":"bytes.disjoint"'); + expect(branch.else_).toBeNull(); + const pairs = branch.then[0] as IrStmt & { kind: "for" }; + expect(pairs.body.filter((s) => s.kind === "bytesSet")).toHaveLength(2); + const inner = pairs.body[2] as IrStmt & { kind: "for" }; + // One read of u[j] for both rows, then the two sums. + expect(inner.body).toHaveLength(3); + expect(inner.body[0]).toMatchObject({ kind: "varDecl", init: { kind: "bytesIntrinsic" } }); + // The second row's term reads `row + 1`. + const row = pairs.init?.kind === "varDecl" ? pairs.init.localId : ""; + expect((inner.body[2] as IrStmt & { kind: "assign" }).value).toMatchObject({ + op: "+", + right: { + op: "/", + left: { kind: "varRef" }, + right: { + op: "+", + left: { + op: "+", + left: { op: "+", left: { kind: "varRef", localId: row }, right: { value: 1 } }, + }, + }, + }, + }); + // The loop as written runs from `end - ((end - start) & 1)` after the + // pairs, or from the start when the test failed: the nest's one other copy. + expect(from.init).toMatchObject({ + kind: "ternary", + then: { op: "-" }, + else_: { localId: "start" }, + }); + expect(rest.init).toMatchObject({ + kind: "varDecl", + localId: "i", + init: { localId: from.localId }, + }); + expect(top.body).toHaveLength(4); + expect( + result.functions[0]!.locals.map((l) => l.id).filter((id) => id.startsWith("%pair.")), + ).toHaveLength(7); +}); + +test("nests whose term calls, writes or reads the sum keep their loops", () => { + const calls: IrExpr = { loc, kind: "call", callee: "f", args: [ref("j")], type: F64 }; + const readsSum = bin("+", ref("t"), get("u", ref("j"))); + for (const term of [calls, readsSum]) { + const m = mod([rowSum(term)]); + expect(pairRowSums(m)).toBe(m); + } + const stored = rowSum(); + stored.body[2] = { loc, kind: "bytesSet", arr: bytes("v"), index: ref("j"), value: ref("t") }; + const m = mod([stored]); + expect(pairRowSums(m)).toBe(m); +}); diff --git a/packages/compiler/src/ir/row-pairs.ts b/packages/compiler/src/ir/row-pairs.ts new file mode 100644 index 000000000..2e189f42e --- /dev/null +++ b/packages/compiler/src/ir/row-pairs.ts @@ -0,0 +1,447 @@ +import { + BOOL, + F64, + type IrExpr, + type IrFunction, + type IrLocal, + type IrModule, + type IrStmt, +} from "./ir.js"; +import { everyExpr, everyExprChild, mapExprChildren, transformStmtList } from "./traverse.js"; + +/** Inner-loop expressions above this many nodes keep the loop as written. */ +const MAX_TERM_NODES = 64; + +/** The number operators a term may use: they run no other code and cannot + * throw. */ +const PURE_BINARY = new Set([ + "+", + "-", + "*", + "/", + "%", + "**", + "&", + "|", + "^", + "<<", + ">>", + ">>>", +]); + +/** A loop nest that sums one term per row: + * + * for (let i = start; i < end; i++) { + * let t = ; + * for (let j = from; j < to; j++) t = t + ; + * out[i] = t; + * } + */ +interface RowSum { + row: string; + start: IrExpr; + end: IrExpr; + sum: string; + init: IrExpr & { kind: "numLit" }; + inner: IrStmt & { kind: "for" }; + column: string; + term: IrExpr; + out: IrExpr & { kind: "varRef" }; + /** The typed arrays the term reads. */ + reads: (IrExpr & { kind: "varRef" })[]; +} + +function isRef(e: IrExpr | null | undefined, id: string): boolean { + return e?.kind === "varRef" && e.localId === id; +} + +/** `id = id + 1`, or `id++` as a statement. */ +function unitStep(update: IrStmt | null, id: string): boolean { + if (update?.kind === "exprStmt") + return update.expr.kind === "incDec" && update.expr.localId === id && update.expr.op === "+"; + return ( + update?.kind === "assign" && + update.localId === id && + update.value.kind === "bin" && + update.value.op === "+" && + isRef(update.value.left, id) && + update.value.right.kind === "numLit" && + update.value.right.value === 1 + ); +} + +/** A plain number local: unboxed and outside the temporal dead zone. */ +function plainNumber(locals: ReadonlyMap, id: string): boolean { + const local = locals.get(id); + return local !== undefined && local.type.kind === "f64" && !local.boxed && !local.tdz; +} + +function readsLocal(e: IrExpr, id: string): boolean { + return !everyExpr(e, { stmt: () => true, expr: (x) => !isRef(x, id) }); +} + +/** The row-sum shape of a `for` statement, or null. Bounds and receivers + * must be literals or locals the nest never writes. */ +function matchRowSum( + s: IrStmt & { kind: "for" }, + locals: ReadonlyMap, +): RowSum | null { + if (s.labels?.length || s.init?.kind !== "varDecl" || !s.init.init) return null; + const row = s.init.localId; + if (!plainNumber(locals, row) || !unitStep(s.update, row)) return null; + const cond = s.cond; + if (cond?.kind !== "bin" || cond.op !== "<" || !isRef(cond.left, row)) return null; + const start = s.init.init, + end = cond.right; + if (s.body.length !== 3) return null; + const [decl, inner, store] = s.body as [IrStmt, IrStmt, IrStmt]; + if (decl.kind !== "varDecl" || decl.init?.kind !== "numLit") return null; + const sum = decl.localId; + if (!plainNumber(locals, sum)) return null; + if (inner.kind !== "for" || inner.labels?.length) return null; + if (inner.init?.kind !== "varDecl" || !inner.init.init) return null; + const column = inner.init.localId; + if (!plainNumber(locals, column) || !unitStep(inner.update, column)) return null; + const innerCond = inner.cond; + if (innerCond?.kind !== "bin" || innerCond.op !== "<" || !isRef(innerCond.left, column)) + return null; + if (inner.body.length !== 1) return null; + const add = inner.body[0]!; + if ( + add.kind !== "assign" || + add.localId !== sum || + add.value.kind !== "bin" || + add.value.op !== "+" || + !isRef(add.value.left, sum) + ) + return null; + const term = add.value.right; + if ( + store.kind !== "bytesSet" || + store.arr.kind !== "varRef" || + store.arr.type.kind !== "bytes" || + !isRef(store.index, row) || + !isRef(store.value, sum) + ) + return null; + const out = store.arr; + // Values the nest reads but never writes: the row counter, the column + // counter and the sum are its only writes. + const written = new Set([row, sum, column]); + // A typed array's length is fixed, so a stable receiver's length is + // stable too. + const stable = (e: IrExpr): boolean => + e.kind === "numLit" || + (e.kind === "varRef" && + !written.has(e.localId) && + locals.get(e.localId) !== undefined && + !locals.get(e.localId)!.boxed && + !locals.get(e.localId)!.tdz) || + (e.kind === "bytesIntrinsic" && + e.method === "length" && + e.args.length === 0 && + e.receiver.kind === "varRef" && + e.receiver.type.kind === "bytes" && + stable(e.receiver)); + if (!stable(start) || !stable(end) || !stable(out)) return null; + if (!stable(inner.init.init) || !stable(innerCond.right)) return null; + const reads: (IrExpr & { kind: "varRef" })[] = []; + let nodes = 0; + const pure = (e: IrExpr): boolean => { + if (++nodes > MAX_TERM_NODES) return false; + switch (e.kind) { + case "numLit": + return e.type.kind === "f64"; + case "varRef": + return e.type.kind === "f64" && (e.localId === row || e.localId === column || stable(e)); + case "bin": + return e.type.kind === "f64" && PURE_BINARY.has(e.op) && pure(e.left) && pure(e.right); + case "unary": + return e.type.kind === "f64" && (e.op === "-" || e.op === "~") && pure(e.operand); + case "bytesIntrinsic": + if (stable(e)) return true; + if ( + e.method !== "get" || + e.args.length !== 1 || + e.receiver.kind !== "varRef" || + e.receiver.type.kind !== "bytes" || + !stable(e.receiver) + ) + return false; + reads.push(e.receiver); + return pure(e.args[0]!); + default: + return false; + } + }; + if (!pure(term) || readsLocal(term, sum)) return null; + return { row, start, end, sum, init: decl.init, inner, column, term, out, reads }; +} + +/** A fresh copy of `e` with `substitute` applied to its local reads and + * `replace` to the nodes it answers for. */ +function copy( + e: IrExpr, + substitute: (ref: IrExpr & { kind: "varRef" }) => IrExpr, + replace: (node: IrExpr) => IrExpr | null = () => null, +): IrExpr { + const replaced = replace(e); + if (replaced !== null) return replaced; + if (e.kind === "varRef") return substitute(e); + return mapExprChildren( + { ...e }, + (child) => copy(child, substitute, replace), + (stmt) => stmt, + ); +} + +const bin = ( + op: "+" | "-" | "&" | "|", + left: IrExpr, + right: IrExpr, + loc: IrExpr["loc"], +): IrExpr => ({ + kind: "bin", + op, + left, + right, + type: F64, + loc, +}); + +/** Runs the row sums of `match` two rows per pass when the test holds and + * as written otherwise. */ +function pairRows( + s: IrStmt & { kind: "for" }, + match: RowSum, + fresh: (name: string) => string, + done: Set, +): IrStmt { + const loc = s.loc; + const ref = (id: string): IrExpr => ({ kind: "varRef", localId: id, type: F64, loc }); + const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); + const again = (e: IrExpr): IrExpr => copy(e, (r) => ({ ...r })); + const row = fresh("row"), + sum = fresh("sum"), + next = fresh("next"), + column = fresh("column"); + const atColumn = (r: IrExpr & { kind: "varRef" }): IrExpr => + r.localId === match.column ? { ...r, localId: column } : { ...r }; + // Element reads that do not depend on the row are read once per column + // and shared by both rows. + const shared = new Map(); + const elements: IrStmt[] = []; + const collect = (e: IrExpr): boolean => { + if (e.kind === "bytesIntrinsic" && e.method === "get" && !readsLocal(e, match.row)) { + const id = fresh("element"); + shared.set(e, id); + elements.push({ kind: "varDecl", localId: id, init: copy(e, atColumn), loc }); + return true; + } + return everyExprChild(e, collect, () => true); + }; + collect(match.term); + // The second row's term reads `row + 1`, so its counter sums stay visible + // to the counted-loop analysis. + const termAt = (rowExpr: () => IrExpr): IrExpr => + copy( + match.term, + (r) => (r.localId === match.row ? rowExpr() : atColumn(r)), + (node) => { + const id = shared.get(node); + return id === undefined ? null : { kind: "varRef", localId: id, type: node.type, loc }; + }, + ); + const innerCond = match.inner.cond as IrExpr & { kind: "bin" }; + const pairs: IrStmt = { + kind: "for", + init: { kind: "varDecl", localId: row, init: again(match.start), loc }, + cond: { + kind: "bin", + op: "<", + left: ref(row), + right: bin("-", again(match.end), num(1), loc), + type: BOOL, + loc, + }, + update: { kind: "assign", localId: row, value: bin("+", ref(row), num(2), loc), loc }, + body: [ + { kind: "varDecl", localId: sum, init: { ...match.init }, loc }, + { kind: "varDecl", localId: next, init: { ...match.init }, loc }, + { + kind: "for", + init: { + kind: "varDecl", + localId: column, + init: again((match.inner.init as IrStmt & { kind: "varDecl" }).init!), + loc, + }, + cond: { ...innerCond, left: ref(column), right: again(innerCond.right) }, + update: { kind: "assign", localId: column, value: bin("+", ref(column), num(1), loc), loc }, + body: [ + ...elements, + { + kind: "assign", + localId: sum, + value: bin( + "+", + ref(sum), + termAt(() => ref(row)), + loc, + ), + loc, + }, + { + kind: "assign", + localId: next, + value: bin( + "+", + ref(next), + termAt(() => bin("+", ref(row), num(1), loc)), + loc, + ), + loc, + }, + ], + loc, + }, + { kind: "bytesSet", arr: { ...match.out }, index: ref(row), value: ref(sum), loc }, + { + kind: "bytesSet", + arr: { ...match.out }, + index: bin("+", ref(row), num(1), loc), + value: ref(next), + loc, + }, + ], + loc, + }; + // Both bounds are int32, so `row < end - 1` is `row + 1 < end`, and the + // nest has at least two rows. The overlap tests run only then, so they never + // read an array the program assigns only when there are rows. Rows are + // independent when the written array shares no byte with an array the + // term reads; an array that is not there answers false. + const int32 = (e: IrExpr): IrExpr => ({ + kind: "bin", + op: "===", + left: bin("|", again(e), num(0), loc), + right: again(e), + type: BOOL, + loc, + }); + const tests: IrExpr[] = [ + int32(match.start), + int32(match.end), + { + kind: "bin", + op: "<", + left: bin("+", again(match.start), num(1), loc), + right: again(match.end), + type: BOOL, + loc, + }, + ...match.reads.map((read): IrExpr => ({ + kind: "libCall", + fn: "bytes.disjoint", + args: [{ ...match.out }, { ...read }], + type: BOOL, + loc, + })), + ]; + const test = tests.reduce((left, right) => ({ + kind: "logical", + op: "&&", + left, + right, + type: BOOL, + loc, + })); + // The loop as written then runs the rows the pairs left: the last one when + // the count is odd (from `end - ((end - start) & 1)`), or all of them when + // the test failed. It is the nest's only other copy. + const go = fresh("go"), + from = fresh("from"); + const paired: IrExpr = { kind: "bin", op: "===", left: ref(go), right: num(1), type: BOOL, loc }; + const rest: IrStmt = { + ...s, + init: { kind: "varDecl", localId: match.row, init: ref(from), loc }, + }; + done.add(rest); + return { + kind: "block", + body: [ + { + kind: "varDecl", + localId: go, + init: { kind: "ternary", cond: test, then: num(1), else_: num(0), type: F64, loc }, + loc, + }, + { + kind: "varDecl", + localId: from, + init: { + kind: "ternary", + cond: paired, + then: bin( + "-", + again(match.end), + bin("&", bin("-", again(match.end), again(match.start), loc), num(1), loc), + loc, + ), + else_: again(match.start), + type: F64, + loc, + }, + loc, + }, + { kind: "if", cond: { ...paired }, then: [pairs], else_: null, loc }, + rest, + ], + loc, + }; +} + +/** Runs loop nests that sum one term per row (spectral-norm's `au`) two rows + * per pass. + * + * Each row's sum keeps its order of additions, so every sum has the bits + * the nest computes as written: the second row's term is the same + * expression of `row + 1`. The term only reads numbers, the counters and + * typed-array elements, so it runs no other code and cannot throw. The + * nest runs paired when both row bounds are int32 (so `row < end - 1` is + * `row + 1 < end`), there are at least two rows, and the array the rows + * store into shares no byte with any array the term reads, so no row reads + * another row's result. An odd row count runs its last row as written. + * Otherwise the nest runs as written. Two independent sums take turns in + * the inner loop, so neither waits on the other's addition. */ +export function pairRowSums(mod: IrModule): IrModule { + let changed = false; + const functions = mod.functions.map((fn): IrFunction => { + if (fn.async || fn.generator) return fn; + const locals = new Map(fn.locals.map((local) => [local.id, local])); + const added: IrLocal[] = []; + const ids = new Set(fn.locals.map((local) => local.id)); + const fresh = (name: string): string => { + let id = `%pair.${name}.${ids.size}`; + while (ids.has(id)) id += "_"; + ids.add(id); + added.push({ id, name: `%pair.${name}`, type: F64, mutable: true }); + return id; + }; + const paired = new Set(); + const body = transformStmtList(fn.body, { + expr: (e) => e, + stmt: (s) => { + if (s.kind !== "for" || paired.has(s)) return s; + const match = matchRowSum(s, locals); + if (match === null) return s; + paired.add(s); + return pairRows(s, match, fresh, paired); + }, + }); + if (added.length === 0) return fn; + changed = true; + return { ...fn, locals: [...fn.locals, ...added], body }; + }); + return changed ? { ...mod, functions } : mod; +} diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 85b21af7b..ff8c15d0a 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -2,6 +2,7 @@ import type { IrClassDef, IrExpr, IrModule, IrRecordShape, IrType, IrUnionDef } import type { IrLibFn } from "./builtin-signatures.js"; import { isDirentShape, + recordHandleCodecClass, recordTextCodecClass, RUNTIME_EMITTER_CLASS, RUNTIME_EVENT_CLASS, @@ -144,6 +145,7 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "util.debuglog", "util.deprecate", "util.inherits", + "util.inheritsPrototype", "util.styleText", ]); @@ -173,6 +175,7 @@ const UTIL_CALLABLE_LIB_FNS: ReadonlySet = new Set([ "util.debuglog", "util.deprecate", "util.inherits", + "util.inheritsPrototype", ]); /** Entry points that link the regex unit: the regex and glob matchers (fs.glob's walk compiles its patterns with the glob matcher), and readline's terminal unit (scr_readline_tty.c), which measures with the regex unit's stripVTControlCharacters. */ @@ -263,6 +266,7 @@ const STREAM_LIB_FNS: ReadonlySet = new Set([ "stream.prop", "stream.stdioWritable", "stream.unboxDyn", + "stream.dynIterableSource", "stream.handleStream", "stream.unpipeDyn", "stream.workerStdio", @@ -316,6 +320,8 @@ const DYN_INVOKE_LIB_FNS: ReadonlySet = new Set([ "dyn.definePrototypeProps", "dyn.functionApply", "dyn.globalConstructor", + "dyn.namespaceDefine", + "dyn.namespaceNew", "dyn.objCreateWithProperties", "dyn.objectTag", "stream.dynNative", @@ -586,7 +592,7 @@ function mentionsDgramSocket(t: IrType, tables: TypeTables): boolean { const textCodecLeaf: CarrierLeaf = (t, tables) => { if (t.kind !== "record") return false; const shape = tables.records.get(t.shapeId); - return shape !== undefined && recordTextCodecClass(shape) !== null; + return shape !== undefined && recordHandleCodecClass(shape) !== null; }; const textDecoderLeaf: CarrierLeaf = (t, tables) => { @@ -596,7 +602,7 @@ const textDecoderLeaf: CarrierLeaf = (t, tables) => { }; /** Whether converting a value of type `t` to or from dyn converts a - * TextEncoder or TextDecoder record, which boxes it as a handle of + * TextEncoder, TextDecoder or StringDecoder record, which boxes it as a handle of * scr_text_codec_dyn.c (the same carrier walk as dgram sockets). */ function mentionsTextCodec(t: IrType, tables: TypeTables): boolean { tables.textCodecCarriers ??= leafCarriers(tables, textCodecLeaf); @@ -742,6 +748,9 @@ export const PKI_LIB_FNS: ReadonlySet = new Set([ "crypto.generateKeyPairSync", "crypto.signOneshot", "crypto.verifyOneshot", + "crypto.x509PublicKey", + "crypto.x509Verify", + "crypto.x509CheckPrivateKey", "crypto.diffieHellman", ]); @@ -858,7 +867,7 @@ const RUNTIME_FEATURE_RULES = { (node.kind === "dynFrom" && mentionsDgramSocket(node.value.type, tables())) || (node.kind === "dynCheck" && mentionsDgramSocket(node.type, tables())), }), - /** A TextEncoder or TextDecoder record converts to or from an untyped + /** A TextEncoder, TextDecoder or StringDecoder record converts to or from an untyped * value (its handle box, or a boxed function, record or collection that * carries one): scr_text_codec_dyn.c. */ textCodecDyn: rule({ @@ -1124,11 +1133,42 @@ const RUNTIME_FEATURE_RULES = { utilPromisify: rule({ fns: ["util.promisifyDyn"] }), /** process.emitWarning is assigned (process.setEmitWarning). */ warningReplace: rule({ fns: ["process.setEmitWarning"] }), + /** Module namespace objects as checked-dynamic values + * (scr_module_namespace.c). */ + moduleNamespace: rule({ fns: ["module.namespaceValue"] }), /** The WebAssembly API (scr_wasm.c). */ wasm: rule({ prefixes: ["wasm."] }), /** crypto.subtle importKey/sign/verify/digest and CryptoKey: * scr_webcrypto.c. */ webcrypto: rule({ prefixes: ["webcrypto."] }), + /** scr_x509.c: the X509Certificate fields beyond the validity window and + * fingerprint, which stay in scr_lib.c. */ + x509: rule({ + fns: [ + "crypto.x509Ca", + "crypto.x509Fingerprint256", + "crypto.x509Fingerprint512", + "crypto.x509InfoAccess", + "crypto.x509Issuer", + "crypto.x509Raw", + "crypto.x509RawDer", + "crypto.x509RawStr", + "crypto.x509SerialNumber", + "crypto.x509SignatureAlgorithm", + "crypto.x509SignatureAlgorithmOid", + "crypto.x509Subject", + "crypto.x509SubjectAltName", + "crypto.x509CheckHost", + "crypto.x509CheckEmail", + "crypto.x509CheckIP", + "crypto.x509CheckIssued", + "crypto.x509LegacyObject", + "crypto.x509Pem", + // getPeerCertificate and getCertificate build with scr_x509.c too. + "tls.sockGetPeerCertificate", + "tls.sockGetCertificate", + ], + }), zlib: rule({ prefixes: ["zlib."] }), /** node:zlib's Brotli forms: zlib.brotliInstall, which %main calls when * the program uses Brotli (scr_brotli.c). */ @@ -1163,6 +1203,7 @@ function noRuntimeFeatures(): RuntimeFeatures { dynInvoke: false, dynTier: false, ecdh: false, + x509: false, dh: false, emitter: false, eventTarget: false, @@ -1188,6 +1229,7 @@ function noRuntimeFeatures(): RuntimeFeatures { searchParams: false, urlIdna: false, stdioExtended: false, + moduleNamespace: false, stream: false, streamDyn: false, handleProps: false, diff --git a/packages/compiler/src/ir/scalar-records.test.ts b/packages/compiler/src/ir/scalar-records.test.ts index e3132088d..2392562a3 100644 --- a/packages/compiler/src/ir/scalar-records.test.ts +++ b/packages/compiler/src/ir/scalar-records.test.ts @@ -10,7 +10,7 @@ import { type IrStmt, type IrType, } from "./ir.js"; -import { scalarizeNumericRecords } from "./scalar-records.js"; +import { scalarizeLiteralResults, scalarizeNumericRecords } from "./scalar-records.js"; import { validateModule } from "./validate.js"; const loc = { file: "scalar-records.ts", start: 0, end: 0 }; @@ -284,3 +284,189 @@ test("bounds expansion in callers with many eligible calls", () => { expect(remaining).toBeGreaterThan(0); expect(remaining).toBeLessThan(300); }); + +const tuple: IrType = { kind: "record", shapeId: "t0" }; +const strRef = (localId: string): IrExpr => ref(localId, STRING); +const element = (localId: string, field: string, type: IrType): IrStmt => ({ + kind: "varDecl", + localId, + init: { kind: "recordGet", obj: ref("result", tuple), shapeId: "t0", field, type, loc }, + loc, +}); + +/** `split(text, at)` returning `[text.slice(0, at), text.slice(at), at > 0]`, + * and a `main` destructuring it. */ +function literalFixture(after: IrStmt[]): IrModule { + const slice = (args: IrExpr[]): IrExpr => ({ + kind: "strIntrinsic", + method: "slice", + receiver: strRef("text"), + args, + type: STRING, + loc, + }); + return { + irVersion: 17, + sourceFile: loc.file, + entry: "main", + records: [ + { + id: "t0", + fields: [ + { name: "0", type: STRING }, + { name: "1", type: STRING }, + { name: "2", type: BOOL }, + ], + tuple: true, + }, + ], + functions: [ + { + name: "split", + params: [ + { localId: "text", name: "text", type: STRING }, + { localId: "at", name: "at", type: F64 }, + ], + returnType: tuple, + locals: [ + { id: "text", name: "text", type: STRING, mutable: false }, + { id: "at", name: "at", type: F64, mutable: false }, + ], + body: [ + { + kind: "return", + value: { + kind: "recordLit", + type: tuple, + fields: [ + { name: "0", value: slice([num(0), ref("at")]) }, + { name: "1", value: slice([ref("at")]) }, + { + name: "2", + value: { kind: "bin", op: ">", left: ref("at"), right: num(0), type: BOOL, loc }, + }, + ], + loc, + }, + loc, + }, + ], + loc, + }, + { + name: "main", + params: [{ localId: "source", name: "source", type: STRING }], + returnType: VOID, + locals: [ + { id: "source", name: "source", type: STRING, mutable: false }, + { id: "result", name: "result", type: tuple, mutable: false }, + { id: "head", name: "head", type: STRING, mutable: false }, + { id: "tail", name: "tail", type: STRING, mutable: true }, + ], + body: [ + { + kind: "varDecl", + localId: "result", + init: call("split", [strRef("source"), call("effect", [num(1)])], tuple), + loc, + }, + ...after, + ], + loc, + }, + { + name: "effect", + params: [{ localId: "x", name: "x", type: F64 }], + returnType: F64, + locals: [{ id: "x", name: "x", type: F64, mutable: true }], + body: [{ kind: "return", value: ref("x"), loc }], + loc, + }, + ], + }; +} + +function literalTransformed(mod: IrModule): IrModule { + expect(validateModule(mod)).toEqual([]); + const out = scalarizeLiteralResults(mod); + expect(validateModule(out)).toEqual([]); + return out; +} + +test("declares destructured elements from a literal producer's fields in evaluation order", () => { + const mod = literalFixture([element("head", "0", STRING), element("tail", "1", STRING)]); + const snapshot = structuredClone(mod); + const out = literalTransformed(mod); + expect(mod).toEqual(snapshot); + const caller = main(out); + expect(nodes(caller.body, "recordGet")).toEqual([]); + expect(nodes(caller.body, "call").map((c) => c["callee"])).toEqual(["effect"]); + const declared = caller.body.map((s) => (s.kind === "varDecl" ? s.localId : s.kind)); + // Parameters, then every field in the literal's order: the unread third + // field still evaluates, into a binding of its own. + expect(declared.slice(2)).toEqual(["head", "tail", declared[4]]); + expect(caller.locals.find((l) => l.id === declared[4])?.type).toEqual(BOOL); + expect(caller.locals.some((l) => l.id === "result")).toBe(false); + // Slices stay slices of the renamed parameter. + expect(nodes(caller.body, "strIntrinsic").map((n) => (n["receiver"] as IrExpr).kind)).toEqual([ + "varRef", + "varRef", + ]); +}); + +test("keeps the call when the result is read other than by adjacent element declarations", () => { + const escaped = literalFixture([ + element("head", "0", STRING), + { kind: "exprStmt", expr: ref("result", tuple), loc }, + ]); + expect(literalTransformed(escaped)).toBe(escaped); + const apart = literalFixture([ + element("head", "0", STRING), + { kind: "exprStmt", expr: num(0), loc }, + element("tail", "1", STRING), + ]); + expect(literalTransformed(apart)).toBe(apart); +}); + +test("keeps the call when an argument holds a counted temporary of its own", () => { + const mod = literalFixture([element("head", "0", STRING)]); + const decl = main(mod).body[0] as IrStmt & { kind: "varDecl" }; + const length: IrExpr = { + kind: "strIntrinsic", + method: "length", + receiver: { kind: "strLit", value: "abc", type: STRING, loc }, + args: [], + type: F64, + loc, + }; + (decl.init as IrExpr & { kind: "call" }).args[1] = length; + expect(literalTransformed(mod)).toBe(mod); +}); + +test("renames a producer's parameter where its literal writes it", () => { + const mod = literalFixture([element("head", "0", STRING), element("tail", "1", STRING)]); + const split = mod.functions.find((fn) => fn.name === "split")!; + // Lowering declares every parameter mutable. + for (const l of split.locals) l.mutable = true; + const ret = split.body[0] as IrStmt & { kind: "return" }; + const literal = ret.value as IrExpr & { kind: "recordLit" }; + // `at++ > (at = 0)` + literal.fields[2]!.value = { + kind: "bin", + op: ">", + left: { kind: "incDec", op: "+", prefix: false, localId: "at", type: F64, loc }, + right: { kind: "assignExpr", localId: "at", value: num(0), type: F64, loc }, + type: BOOL, + loc, + }; + const caller = main(literalTransformed(mod)); + const [text, at] = caller.body + .slice(0, 2) + .map((s) => (s as IrStmt & { kind: "varDecl" }).localId); + expect(at).not.toBe("at"); + const writes = [...nodes(caller.body, "incDec"), ...nodes(caller.body, "assignExpr")]; + expect(writes.map((w) => w["localId"])).toEqual([at, at]); + // Only the written parameter's binding is mutable. + expect(caller.locals.find((l) => l.id === at)?.mutable).toBe(true); + expect(caller.locals.find((l) => l.id === text)?.mutable).toBe(false); +}); diff --git a/packages/compiler/src/ir/scalar-records.ts b/packages/compiler/src/ir/scalar-records.ts index 0a5b0c80f..5d57cb3ec 100644 --- a/packages/compiler/src/ir/scalar-records.ts +++ b/packages/compiler/src/ir/scalar-records.ts @@ -10,6 +10,7 @@ import { } from "./ir.js"; import { everyExpr, + transformExpr, everyExprChild, everyStmtChild, everyStmtList, @@ -362,3 +363,332 @@ export function scalarizeNumericRecords(mod: IrModule): IrModule { }); return changed ? { ...mod, functions } : mod; } + +/** A producer whose whole body is `return `, with number, boolean + * or string fields, inlined where its result is destructured. */ +interface LiteralProducer { + fn: IrFunction; + shape: IrRecordShape; + literal: IrExpr & { kind: "recordLit" }; + size: number; + /** The parameters the literal writes (`a++`, `a = b`). */ + written: ReadonlySet; +} + +/** Whether `value` holds no counted temporary of its own: every counted + * node in it only views a binding, so evaluating it in its own statement + * shortens no lifetime. A message argument of a throw counts as nothing: it + * lives until the throw, which ends the evaluation anyway. */ +function viewsOnly(value: IrExpr): boolean { + if (!isRefCounted(value.type)) return everyExprChild(value, viewsOnly, () => false); + switch (value.kind) { + case "varRef": + return true; + case "unionNarrow": + return viewsOnly(value.value); + case "ternary": + return viewsOnly(value.cond) && viewsOnly(value.then) && viewsOnly(value.else_); + case "libCall": + return value.fn === "error.nodeThrow"; + default: + return false; + } +} + +/** The node's own value may be counted (it moves into the binding that + * takes it); everything under it must hold no temporary (viewsOnly), as + * scalarTemporaries requires for the numeric pass. */ +function ownedTemporaries(value: IrExpr): boolean { + return everyExprChild(value, viewsOnly, () => false); +} + +const LITERAL_FIELD_KINDS: ReadonlySet = new Set(["f64", "bool", "string"]); + +function literalProducer( + fn: IrFunction, + shapes: ReadonlyMap, +): LiteralProducer | null { + if ( + fn.async || + fn.generator || + fn.captures?.length || + fn.classCaptures?.length || + fn.returnType.kind !== "record" || + fn.body.length !== 1 + ) + return null; + const shape = shapes.get(fn.returnType.shapeId); + if ( + !shape || + shape.indexValue || + shape.fields.length === 0 || + shape.fields.length > MAX_FIELDS || + shape.fields.some((f) => !LITERAL_FIELD_KINDS.has(f.type.kind)) + ) + return null; + // Every local is a parameter: the body declares nothing. + const params = new Set(fn.params.map((p) => p.localId)); + if (fn.locals.some((l) => l.boxed || l.tdz || !params.has(l.id))) return null; + const ret = fn.body[0]!; + const literal = ret.kind === "return" ? ret.value : null; + if ( + literal?.kind !== "recordLit" || + literal.type.kind !== "record" || + literal.type.shapeId !== shape.id || + literal.fields.length !== shape.fields.length || + !literal.fields.every( + (f) => + !f.overflow && + shape.fields.some((sf) => sf.name === f.name && sf.type.kind === f.value.type.kind) && + ownedTemporaries(f.value), + ) + ) + return null; + let size = 0; + const written = new Set(); + const small = everyExpr(literal, { + expr: (node) => { + if (node.kind === "incDec" || node.kind === "assignExpr") written.add(node.localId); + return ( + ++size <= MAX_CALLEE_NODES && + node.kind !== "closure" && + node.kind !== "selfRef" && + node.kind !== "awaitExpr" && + node.kind !== "awaitUnionExpr" && + node.kind !== "yieldExpr" + ); + }, + stmt: () => false, + }); + return small ? { fn, shape, literal, size, written } : null; +} + +/** Rewrite every statement list under `body`, innermost first. */ +function mapStatementLists(body: IrStmt[], f: (list: IrStmt[]) => IrStmt[]): IrStmt[] { + const lists = (s: IrStmt): IrStmt => { + switch (s.kind) { + case "if": + return { + ...s, + then: mapStatementLists(s.then, f), + else_: s.else_ === null ? null : mapStatementLists(s.else_, f), + }; + case "block": + case "while": + case "doWhile": + case "for": + case "forOf": + return { ...s, body: mapStatementLists(s.body, f) }; + case "switch": + return { + ...s, + cases: s.cases.map((c) => ({ + ...c, + body: mapStatementLists(c.body, f), + })), + }; + case "tryCatch": + return { + ...s, + tryBody: mapStatementLists(s.tryBody, f), + catchBody: s.catchBody === null ? null : mapStatementLists(s.catchBody, f), + finallyBody: s.finallyBody === null ? null : mapStatementLists(s.finallyBody, f), + }; + default: + return s; + } + }; + return f(body.map(lists)); +} + +/** How often each local is read, anywhere in `fn`. */ +function localReads(fn: IrFunction): Map { + const reads = new Map(); + const count = (id: string): void => { + reads.set(id, (reads.get(id) ?? 0) + 1); + }; + everyStmtList(fn.body, { + expr: (node) => { + switch (node.kind) { + case "varRef": + case "incDec": + case "assignExpr": + count(node.localId); + break; + case "closure": + case "classRef": + for (const id of node.captures ?? []) count(id); + break; + } + return true; + }, + stmt: (node) => { + if (node.kind === "assign" || node.kind === "forOf" || node.kind === "rethrow") + count(node.localId); + return true; + }, + }); + return reads; +} + +/** Inline small literal producers where the result is only destructured. + * + * `const [head, tail] = split(text, at)`, where `split` is + * `return [text.slice(0, at), text.slice(at)]`, lowers to a result binding + * and one declaration per element read from it, right after the call. Each + * such declaration now takes its field's value directly: the parameters are + * declared from the arguments in order, then the fields in the literal's + * order (unread fields go to fresh bindings), then the destructured + * bindings, so evaluation order is unchanged and no record is built. A + * slice element read only through its length then needs no copy at all + * (findScalarStringSlices). The declarations stay in the caller's own + * statement list, so every binding keeps its scope; parameters live to the + * end of it, which only lengthens the arguments' lifetimes. Every other use + * of the result binding keeps the call. */ +export function scalarizeLiteralResults(mod: IrModule): IrModule { + const shapes = new Map((mod.records ?? []).map((s) => [s.id, s])); + const producers = new Map(); + for (const fn of mod.functions) { + const p = literalProducer(fn, shapes); + if (p) producers.set(fn.name, p); + } + if (producers.size === 0) return mod; + let changed = false; + const functions = mod.functions.map((fn): IrFunction => { + if (fn.async || fn.generator) return fn; + const locals = new Map(fn.locals.map((l) => [l.id, l])); + const used = new Set([...locals.keys(), ...(mod.globals ?? []).map((g) => g.id)]); + collectLabels(fn.body, used); + let next = 0; + const fresh = (): string => { + let id: string; + do { + id = `%literal.${next++}`; + } while (used.has(id)); + used.add(id); + return id; + }; + let reads: Map | null = null; + const added: IrLocal[] = []; + const removed = new Set(); + let budget = MAX_INLINE_NODES; + const rewrite = (list: IrStmt[]): IrStmt[] => { + const out: IrStmt[] = []; + for (let i = 0; i < list.length; i++) { + const decl = list[i]!; + const call = decl.kind === "varDecl" ? decl.init : null; + const p = call?.kind === "call" ? producers.get(call.callee) : undefined; + const local = decl.kind === "varDecl" ? locals.get(decl.localId) : undefined; + if ( + decl.kind !== "varDecl" || + call?.kind !== "call" || + !p || + !local || + p.fn.name === fn.name || + p.size > budget || + local.mutable || + local.boxed || + local.tdz || + local.type.kind !== "record" || + local.type.shapeId !== p.shape.id || + call.args.length !== p.fn.params.length || + !call.args.every(ownedTemporaries) + ) { + out.push(decl); + continue; + } + // The element declarations right after the call, one per field. + const elements = new Map(); + let j = i + 1; + for (; j < list.length; j++) { + const element = list[j]!; + const get = element.kind === "varDecl" ? element.init : null; + const target = element.kind === "varDecl" ? locals.get(element.localId) : undefined; + if ( + element.kind !== "varDecl" || + get?.kind !== "recordGet" || + get.obj.kind !== "varRef" || + get.obj.localId !== local.id || + get.shapeId !== p.shape.id || + elements.has(get.field) || + !target || + target.boxed || + target.tdz || + target.type.kind !== get.type.kind + ) + break; + elements.set(get.field, element); + } + reads ??= localReads(fn); + if (elements.size === 0 || reads.get(local.id) !== elements.size) { + out.push(decl); + continue; + } + budget -= p.size; + changed = true; + removed.add(local.id); + const renamed = new Map(p.fn.params.map((param) => [param.localId, fresh()])); + for (const l of p.fn.locals) { + // Copied field by field: the native compiler does not spread a + // record whose type has a union. + const copy: IrLocal = { + id: renamed.get(l.id)!, + name: l.name, + type: l.type, + mutable: p.written.has(l.id), + }; + if (l.source !== undefined) copy.source = l.source; + added.push(copy); + } + p.fn.params.forEach((param, index) => { + out.push({ + kind: "varDecl", + localId: renamed.get(param.localId)!, + init: call.args[index]!, + loc: decl.loc, + }); + }); + // Reads and writes of a parameter (`a++`, `a = b`) both name it. + const rename = (value: IrExpr): IrExpr => + transformExpr(value, { + expr: (node) => + (node.kind === "varRef" || node.kind === "incDec" || node.kind === "assignExpr") && + renamed.has(node.localId) + ? { ...node, localId: renamed.get(node.localId)! } + : node, + stmt: (node) => node, + }); + for (const field of p.literal.fields) { + const element = elements.get(field.name); + let id = element?.localId; + if (id === undefined) { + id = fresh(); + const type = p.shape.fields.find((f) => f.name === field.name)!.type; + added.push({ + id, + name: `${local.name}.${field.name}`, + type, + mutable: false, + }); + } + out.push({ + kind: "varDecl", + localId: id, + init: rename(field.value), + loc: element?.loc ?? decl.loc, + }); + } + i = j - 1; + } + return out; + }; + const body = mapStatementLists(fn.body, rewrite); + if (!changed || removed.size === 0) return fn; + return { + ...fn, + locals: [...fn.locals.filter((l) => !removed.has(l.id)), ...added], + body, + }; + }); + return changed ? { ...mod, functions } : mod; +} diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 5bae241b1..eba319f85 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -55,10 +55,14 @@ import { typeEquals, typeKey, maybePromiseUnionArmsOk, - unionContainerArmsOk, VOID, } from "./ir.js"; -import { DYN_CLASS_PROPERTIES, callSiteReturnType, classValueConstructor } from "./ir.js"; +import { + DYN_CLASS_PROPERTIES, + callSiteReturnType, + classValueAbiEquals, + classValueConstructor, +} from "./ir.js"; import { unionWideningTags } from "./analysis.js"; import { alwaysReturns } from "./control-flow.js"; import { dynamicOperationOf, functionTier } from "./tiers.js"; @@ -123,6 +127,8 @@ export function validateModule(mod: IrModule): IrValidationError[] { errors.push({ message: "worker execution mode must be a boolean", loc: moduleLoc }); if (mod.declBoundary !== undefined && mod.declBoundary !== true) errors.push({ message: "declBoundary must be true when present", loc: moduleLoc }); + if (mod.untypedIntegrity !== undefined && mod.untypedIntegrity !== true) + errors.push({ message: "untypedIntegrity must be true when present", loc: moduleLoc }); if (mod.lifetimeRegions !== undefined && mod.lifetimeRegions !== true) errors.push({ message: "lifetimeRegions must be true when present", loc: moduleLoc }); if (mod.workers && mod.lib !== undefined) @@ -833,9 +839,6 @@ export function validateModule(mod: IrModule): IrValidationError[] { loc: noLoc, }); } - if (arm.kind === "date" && !unionContainerArmsOk(u.arms)) { - errors.push({ message: `union ${u.id}: date arm ${i} beside non-unit arms`, loc: noLoc }); - } if (arm.kind === "record" && !recordsById.has(arm.shapeId)) { errors.push({ message: `union ${u.id}: arm ${i} references undeclared shape "${arm.shapeId}"`, @@ -2997,19 +3000,13 @@ function validateFunction( } const subClass = classes.get(sub); const supClass = classes.get(sup); - const subValueCtor = subClass ? classValueConstructor(subClass) : null; - const supValueCtor = supClass ? classValueConstructor(supClass) : null; - const subCtor = subValueCtor ? functions.get(subValueCtor.fn) : undefined; - const supCtor = supValueCtor ? functions.get(supValueCtor.fn) : undefined; - if (!subCtor || !supCtor || !subValueCtor || !supValueCtor) { + const subCtor = subClass ? functions.get(classValueConstructor(subClass).fn) : undefined; + const supCtor = supClass ? functions.get(classValueConstructor(supClass).fn) : undefined; + if (!subClass || !supClass || !subCtor || !supCtor) { err(`classval upcast: "${sub}"/"${sup}" lack constructor functions`, e.loc); break; } - const subArgs = subCtor.params.slice(subValueCtor.firstArg); - const supArgs = supCtor.params.slice(supValueCtor.firstArg); - const abiEqual = - subArgs.length === supArgs.length && - subArgs.every((p, i) => typeEquals(p.type, supArgs[i]!.type)); + const abiEqual = classValueAbiEquals(subClass, supClass, functions); if (!abiEqual) { err(`classval upcast: "${sub}" and "${sup}" constructor ABIs differ`, e.loc); } @@ -6353,15 +6350,10 @@ function validateFunction( } } else { // throw: any exception-representable payload (the throw - // statement's rule). Date cannot retain its object kind in the - // untyped exception cell. + // statement's rule). if (e.arg === null) { err("genResume throw with no payload", e.loc); - } else if ( - e.arg.type.kind === "void" || - e.arg.type.kind === "caught" || - e.arg.type.kind === "date" - ) { + } else if (e.arg.type.kind === "void" || e.arg.type.kind === "caught") { err(`genResume throw of a ${e.arg.type.kind} value`, e.loc); } } @@ -6802,11 +6794,7 @@ function validateFunction( checkExpr(s.value); // dyn throws are allowed: the dyn node rides the REF cell arm by // reference (the JS-lane `throw err` of a dyn argument). - if ( - s.value.type.kind === "void" || - s.value.type.kind === "caught" || - s.value.type.kind === "date" - ) { + if (s.value.type.kind === "void" || s.value.type.kind === "caught") { err(`throw of a ${s.value.type.kind} value`, s.loc); } break; diff --git a/packages/compiler/src/library/ir-policy.ts b/packages/compiler/src/library/ir-policy.ts index f08b85232..d4f57a44b 100644 --- a/packages/compiler/src/library/ir-policy.ts +++ b/packages/compiler/src/library/ir-policy.ts @@ -64,6 +64,9 @@ const LIB_MODE_REFUSED_PREFIXES: readonly [string, string][] = [ ["crypto.generateKeyPair", "asymmetric KeyObjects, generateKeyPair, sign and verify"], ["crypto.signOneshot", "asymmetric KeyObjects, generateKeyPair, sign and verify"], ["crypto.verifyOneshot", "asymmetric KeyObjects, generateKeyPair, sign and verify"], + ["crypto.x509PublicKey", "X509Certificate publicKey, verify and checkPrivateKey"], + ["crypto.x509Verify", "X509Certificate publicKey, verify and checkPrivateKey"], + ["crypto.x509CheckPrivateKey", "X509Certificate publicKey, verify and checkPrivateKey"], ["crypto.diffieHellman", "crypto.diffieHellman"], ["webcrypto.", "WebCrypto (crypto.subtle)"], ["fs.watch", "fs.watch"], diff --git a/packages/compiler/src/library/library-cache.test.ts b/packages/compiler/src/library/library-cache.test.ts index 846d90272..c19275733 100644 --- a/packages/compiler/src/library/library-cache.test.ts +++ b/packages/compiler/src/library/library-cache.test.ts @@ -85,6 +85,7 @@ test("early library cache restores generated artifacts and metadata", async () = searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -138,6 +139,7 @@ test("early library cache publishes after creating a fresh output directory", as searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -168,6 +170,7 @@ test("early library cache hits refresh every payload's LRU time", async () => { searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -213,6 +216,7 @@ test("early library cache misses on source edits and newly-resolved candidates", searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -242,6 +246,7 @@ test("early library cache misses on source edits and newly-resolved candidates", searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -291,6 +296,7 @@ test("semantic library cache restores and rebases IR after a comment-only edit", searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -354,6 +360,7 @@ test("semantic library cache refuses token and directive edits", async () => { searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -402,6 +409,7 @@ test("semantic LLVM cache accepts comment edits that shift source lines", async searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -440,6 +448,7 @@ test("semantic LLVM cache accepts line separator normalization", async () => { searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -501,6 +510,7 @@ test("semantic LLVM cache rebases comment edits in multi-source graphs", async ( searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -540,6 +550,7 @@ test("early library cache is separated by the host Node version", async () => { searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -571,6 +582,7 @@ test("early library cache rejects corrupted artifacts and metadata", async () => searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, @@ -605,6 +617,7 @@ test("disabled early library cache performs no reads or writes", async () => { searchParams: false, emitter: false, zlib: false, + x509: false, copying: false, textDecoderLegacy: false, dynInvoke: false, diff --git a/packages/compiler/src/library/library-cache.ts b/packages/compiler/src/library/library-cache.ts index 3e77ccced..7e61e95b8 100644 --- a/packages/compiler/src/library/library-cache.ts +++ b/packages/compiler/src/library/library-cache.ts @@ -54,6 +54,7 @@ interface EarlyLibraryCacheStamp { searchParams: boolean; emitter: boolean; zlib: boolean; + x509: boolean; copying: boolean; textDecoderLegacy: boolean; }; @@ -74,6 +75,7 @@ export interface EarlyLibraryNativeFeatures { searchParams: boolean; emitter: boolean; zlib: boolean; + x509: boolean; copying: boolean; textDecoderLegacy: boolean; /** Volatile exact-source build identity emitted from the tiny identity TU. */ @@ -132,6 +134,7 @@ const BOOLEAN_NATIVE_KEYS = [ "searchParams", "emitter", "zlib", + "x509", "copying", "textDecoderLegacy", ] as const satisfies readonly (keyof EarlyLibraryNativeFeatures)[]; diff --git a/packages/compiler/src/native/cache.ts b/packages/compiler/src/native/cache.ts index 712c1c232..e204b8a6b 100644 --- a/packages/compiler/src/native/cache.ts +++ b/packages/compiler/src/native/cache.ts @@ -168,6 +168,7 @@ export class NativeCache { "sanitizer", "executable", "kept", + "runtime-pack", "binary", "dsym", "coverage", diff --git a/packages/compiler/src/native/cli.ts b/packages/compiler/src/native/cli.ts index aa46944f5..b729b2557 100644 --- a/packages/compiler/src/native/cli.ts +++ b/packages/compiler/src/native/cli.ts @@ -147,6 +147,7 @@ async function main(): Promise { dh: false, pki: false, ed25519: false, + x509: false, webcrypto: false, perfLoop: false, tlsCa: false, diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index 0188dccf7..ff49e2ac9 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -30,8 +30,11 @@ import { verifyNativeHelper, } from "../backend/native-tools.js"; import { + type NativeRuntimeSelection, selectNativeRuntimePack, + runtimePackManifest, stageNativeRuntimeSelection, + type RuntimePackStore, } from "../backend/runtime-pack-native.js"; import { RuntimePackError } from "../backend/runtime-pack-core.js"; import { @@ -109,7 +112,7 @@ export class NativeCompiler { private readonly cache: NativeCache | null = null, ) { const evaluator = toolchain.comptimeExecutable; - this.frontend = (entry, npmStatic, externalTypes, libraryNpmStatic) => + this.frontend = (entry, npmStatic, externalTypes, libraryNpmStatic, options) => runNativeFrontend( entry, toolchain.ts7Executable, @@ -120,6 +123,7 @@ export class NativeCompiler { : (source, timeout) => evaluateNativeComptime(source, timeout, toolchain.ts7Executable, evaluator), libraryNpmStatic, + options, ); } @@ -161,6 +165,17 @@ export class NativeCompiler { return result; } + /** Runtime pack selections and verified artifact identities, kept in the + * cache. */ + private runtimePackStore(): RuntimePackStore | undefined { + const cache = this.cache; + if (cache === null) return undefined; + return { + read: (key) => cache.read("runtime-pack", key)?.toString("utf8") ?? null, + write: (key, text) => cache.write("runtime-pack", key, text), + }; + } + private emitObject( input: string, output: string, @@ -281,7 +296,7 @@ export class NativeCompiler { for (const [index, shard] of split.shards.entries()) { const shardInput = join(stage, `shard-${index}.ll`); const shardOutput = join(stage, `shard-${index}` + this.toolchain.target.outputSuffixes.obj); - writeFileSync(shardInput, shard.source); + writeFileSync(shardInput, shard.source()); this.emitObject(shardInput, shardOutput, source + "." + shard.name, optimization, "obj"); objects.push(shardOutput); } @@ -387,32 +402,33 @@ export class NativeCompiler { outputKind === "exe" ? join(inputDirectory, "program" + target.outputSuffixes.obj) : stagedOutput; - const executablePack = - outputKind === "exe" - ? selectNativeRuntimePack( - toolchain.runtimePackRoot, - target, - toolchain.compilerVersion, - features, - optimization, - ) - : null; + let executablePack: NativeRuntimeSelection | null = null; + const selectPack = (): NativeRuntimeSelection => + (executablePack ??= selectNativeRuntimePack( + toolchain.runtimePackRoot, + target, + toolchain.compilerVersion, + features, + optimization, + "executable", + this.runtimePackStore(), + )); const executableCache = - executablePack === null - ? null - : openNativeExecutableCache( - this.cache, - toolchain, - digestLlvm, - options, - ffi, - executablePack, - ); + outputKind === "exe" + ? openNativeExecutableCache(this.cache, toolchain, digestLlvm, options, ffi, { + root: toolchain.runtimePackRoot, + features, + flavor: optimization, + select: selectPack, + }) + : null; // An unchanged rebuild keeps the executable the last build left. const kept = executableCache?.keep(output) ?? false; const restored = kept || (executableCache?.restore(stagedOutput) ?? false); timing(restored ? "executable-cache-hit" : "executable-cache-miss"); if (!restored) { + // A missing or invalid runtime pack fails before code generation. + if (outputKind === "exe") selectPack(); let programObjects = [object]; if (options.sanitize) { runNativeTool(sanitizerDriver(toolchain), [ @@ -452,16 +468,24 @@ export class NativeCompiler { toolchain.compilerVersion, features, optimization, + "executable", + this.runtimePackStore(), ) : null; - if (pack !== null) stageNativeRuntimeSelection(pack, join(stage, "runtime")); + if (pack !== null) + stageNativeRuntimeSelection(pack, join(stage, "runtime"), this.runtimePackStore()); const info = pack === null ? undefined : formatNativeLinkInfo( { programObject: output, target, ffi }, toolchain.compilerVersion, - pack, + { + root: pack.root, + manifest: runtimePackManifest(pack), + selected: pack.selected, + flavor: pack.flavor, + }, ); renameSync(stagedOutput, output); return { @@ -473,10 +497,10 @@ export class NativeCompiler { }, }; } - const pack = executablePack!; + const pack = selectPack(); const runtime = options.sanitize ? buildSanitizedRuntime(toolchain, pack, join(stage, "runtime"), "executable", this.cache) - : stageNativeRuntimeSelection(pack, join(stage, "runtime")); + : stageNativeRuntimeSelection(pack, join(stage, "runtime"), this.runtimePackStore()); timing("runtime-stage"); const plan = executableLinkInputs({ target, @@ -638,6 +662,7 @@ export class NativeCompiler { features, profile.optimization, profile.instancePerThread ? "library-thread" : "library", + this.runtimePackStore(), ); const runtime = options.sanitize ? buildSanitizedRuntime( @@ -647,7 +672,7 @@ export class NativeCompiler { profile.instancePerThread ? "library-thread" : "library", this.cache, ) - : stageNativeRuntimeSelection(pack, join(stage, "runtime")); + : stageNativeRuntimeSelection(pack, join(stage, "runtime"), this.runtimePackStore()); timing("runtime-stage"); const stagedArchive = join(stage, wasm ? "output.wasm" : "output.a"); const localizeSymbols = libraryLocalizeSymbols(profile); diff --git a/packages/compiler/src/native/executable-cache-open.test.ts b/packages/compiler/src/native/executable-cache-open.test.ts index 05e57148d..0c3b2832f 100644 --- a/packages/compiler/src/native/executable-cache-open.test.ts +++ b/packages/compiler/src/native/executable-cache-open.test.ts @@ -2,9 +2,10 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:f import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; +import type { NativeLinkFeatures } from "../backend/native-link-info.js"; import type { NativeRuntimeSelection } from "../backend/runtime-pack-native.js"; import { NativeCache } from "./cache.js"; -import { openNativeExecutableCache } from "./executable-cache.js"; +import { type ExecutableRuntimePack, openNativeExecutableCache } from "./executable-cache.js"; import type { NativeToolchain } from "./toolchain.js"; const directories: string[] = []; @@ -50,14 +51,20 @@ function fixture(platform: string, driver: string) { linkerArgs: [], dsymutil: "dsymutil", } as unknown as NativeToolchain; - const pack = { + const pack: ExecutableRuntimePack = { root: runtime, - manifest: { licenses: [] }, - packageText: "{}", - manifestText: "{}", - selected: { runtime: [], archives: [] }, + features: {} as NativeLinkFeatures, flavor: "release", - } as unknown as NativeRuntimeSelection; + select: () => + ({ + root: runtime, + licenses: [], + packageText: "{}", + manifestText: "{}", + selected: { runtime: [], archives: [] }, + flavor: "release", + }) as unknown as NativeRuntimeSelection, + }; const open = () => openNativeExecutableCache( new NativeCache(join(root, "cache")), diff --git a/packages/compiler/src/native/executable-cache.test.ts b/packages/compiler/src/native/executable-cache.test.ts index 97f9a3571..8bec9916e 100644 --- a/packages/compiler/src/native/executable-cache.test.ts +++ b/packages/compiler/src/native/executable-cache.test.ts @@ -300,3 +300,34 @@ test("a Linux build whose output changed links again, and then stores the execut expect(open().restore(f.destination)).toBe(true); expect(readFileSync(f.destination)).toEqual(readFileSync(f.output)); }); + +test("a Linux build that keeps its output does not read the deferred inputs", () => { + const f = fixture(); + const artifact = join(f.root, "inputs", "artifact.o"); + writeFileSync(artifact, "runtime pack artifact"); + const deferred = vi.fn(() => [artifact]); + const open = () => + new NativeExecutableCache(f.cache, f.key, "clang", false, [f.runtime], true, deferred); + const first = open(); + expect(first.trace(["-o", f.output], f.stage)).toBe(true); + first.recordOutput(f.output); + expect(deferred).toHaveBeenCalledTimes(1); + const recorded = ( + JSON.parse(f.cache.read("kept", f.key)!.toString()) as { inputs: { path: string }[] } + ).inputs.map((saved) => saved.path); + expect(recorded).toContain(artifact); + // The unchanged rebuild checks the recorded identities, the artifact's + // among them, without asking for the deferred inputs. + expect(open().keep(f.output)).toBe(true); + expect(deferred).toHaveBeenCalledTimes(1); + // A replaced artifact misses. + writeFileSync(artifact, "another runtime pack artifact"); + expect(open().keep(f.output)).toBe(false); + expect(deferred).toHaveBeenCalledTimes(1); + // restore() adds the deferred inputs before it checks an entry. + const second = open(); + expect(second.trace(["-o", f.output], f.stage)).toBe(true); + second.publish(f.output); + expect(open().restore(f.destination)).toBe(true); + expect(deferred).toHaveBeenCalledTimes(3); +}); diff --git a/packages/compiler/src/native/executable-cache.ts b/packages/compiler/src/native/executable-cache.ts index b9058e947..bafd2c020 100644 --- a/packages/compiler/src/native/executable-cache.ts +++ b/packages/compiler/src/native/executable-cache.ts @@ -9,6 +9,8 @@ import { toolchainEnvironmentFingerprint, } from "../backend/toolchain-environment.js"; import { runNativeTool } from "../backend/native-tools.js"; +import type { NativeLinkFeatures } from "../backend/native-link-info.js"; +import type { NativeOptimization } from "../backend/optimization.js"; import type { NativeRuntimeSelection } from "../backend/runtime-pack-native.js"; import type { NativeToolchain } from "./toolchain.js"; import { contentDigest, NativeCache } from "./cache.js"; @@ -204,6 +206,7 @@ export class NativeExecutableCache { private traced = false; /** The inputs of the entry restore() used. */ private restoredInputs: InputIdentity[] | null = null; + private deferredAdded = false; constructor( private readonly cache: NativeCache, @@ -214,10 +217,25 @@ export class NativeExecutableCache { /** Linux: trace with one verbose link, keep an unchanged output, and * store the executable from a key's second link on. */ private readonly linux = false, + /** Inputs that the key determines, such as the runtime pack's selected + * artifacts. keep() does not read them, because the entry it checks + * lists them; restore() and trace() add their identities first. */ + private readonly deferredPaths: () => string[] = () => [], ) { this.inputs = [...new Set(paths)].sort().map(identity); } + private addDeferredInputs(): void { + if (this.deferredAdded) return; + this.deferredAdded = true; + const listed = new Set(this.inputs.map((input) => input.path)); + for (const path of this.deferredPaths()) + if (!listed.has(path)) { + listed.add(path); + this.inputs.push(identity(path)); + } + } + /** Whether `recorded`, the inputs of an entry, still match and cover this * build's inputs. */ private covers(recorded: InputIdentity[]): boolean { @@ -249,6 +267,7 @@ export class NativeExecutableCache { try { const metadata = this.cache.read("executable", this.key); if (metadata === null) return false; + this.addDeferredInputs(); const entry: unknown = JSON.parse(metadata.toString("utf8")); if (!validEntry(entry) || !this.covers(entry.inputs)) return false; if (this.debug !== (entry.symbols !== null)) return false; @@ -275,6 +294,7 @@ export class NativeExecutableCache { trace(args: string[], stage: string): boolean { this.traced = false; try { + this.addDeferredInputs(); const roots = [resolve(stage), realpathSync(stage)]; let driverInputs = this.linux ? [] @@ -342,13 +362,24 @@ export class NativeExecutableCache { } } +/** The runtime pack an executable links: its directory, and the link features + * and flavor that select its artifacts. `select` reads and validates the + * manifest; the cache calls it only when it needs the artifacts' identities, + * so an unchanged rebuild that keeps its output never reads the manifest. */ +export interface ExecutableRuntimePack { + root: string; + features: NativeLinkFeatures; + flavor: NativeOptimization; + select: () => NativeRuntimeSelection; +} + export function openNativeExecutableCache( cache: NativeCache | null, toolchain: NativeToolchain, llvmDigest: () => string, options: CompileRequestOptions, ffi: FfiProfile | null, - pack: NativeRuntimeSelection, + pack: ExecutableRuntimePack, ): NativeExecutableCache | null { // Match the established complete-cache contract: the default Apple driver // or a Linux clang driver, known runtime inputs, and no wrapper or @@ -394,11 +425,19 @@ export function openNativeExecutableCache( join(toolchain.helperPackageRoot, "package.json"), join(toolchain.runtimePackRoot, "package.json"), join(toolchain.runtimePackRoot, "runtime-pack.json"), - ...[...pack.selected.runtime, ...pack.selected.archives, ...pack.manifest.licenses].map( - (artifact) => join(pack.root, artifact.path), - ), ]; - if (platform === "linux") paths.push(join(pack.root, "runtime-pack.json")); + const manifest = join(pack.root, "runtime-pack.json"); + if (platform === "linux") paths.push(manifest); + // The manifest's identity, the features and the flavor in the key fix + // which artifacts the selection names. + const artifacts = (): string[] => { + const selection = pack.select(); + return [ + ...selection.selected.runtime, + ...selection.selected.archives, + ...selection.licenses, + ].map((artifact) => join(selection.root, artifact.path)); + }; if (debug) { const dsymutil = commandPath(toolchain.dsymutil); if (realpathSync(dsymutil) !== "/usr/bin/dsymutil") return null; @@ -410,13 +449,13 @@ export function openNativeExecutableCache( llvm: llvmDigest(), options, toolchain, - runtimeIdentity: pack.packageText, + runtimeIdentity: readFileSync(join(pack.root, "package.json"), "utf8"), // On Linux the manifest's identity names it: restore checks that // identity again, as it does every other input. runtimeManifest: - platform === "darwin" - ? pack.manifestText - : identity(join(pack.root, "runtime-pack.json")), + platform === "darwin" ? readFileSync(manifest, "utf8") : identity(manifest), + runtimeFeatures: pack.features, + runtimeFlavor: pack.flavor, linker: identity(linker), effective: identity(effective), environment: toolchainEnvironmentFingerprint(), @@ -424,7 +463,15 @@ export function openNativeExecutableCache( path: process.env["PATH"] ?? null, }), ); - return new NativeExecutableCache(cache, key, linker, debug, paths, platform === "linux"); + return new NativeExecutableCache( + cache, + key, + linker, + debug, + paths, + platform === "linux", + artifacts, + ); } catch { return null; } diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 7118e47b9..a10d16585 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -257,6 +257,7 @@ function validFeatures(value: unknown): value is NativeLinkFeatures { "dh", "pki", "ed25519", + "x509", "webcrypto", "perfLoop", ].every((name) => typeof features[name] === "boolean"); @@ -408,6 +409,11 @@ export function prepareNativeExecutable( pointerBits: toolchain.target.pointerBits, wasi: toolchain.target.platform === "wasi", runtimeAbiMarker: outputKind === "obj" || outputKind === "exe", + // As for the Node-hosted build: executables and their textual + // LLVM and assembly, not objects. + unwindAbi: + (outputKind === "exe" || outputKind === "llvm" || outputKind === "asm") && + toolchain.target.platform === "linux", objectAudit: options.sanitize === true, lowRegion: lowRegionTarget(toolchain.target.platform, options.sanitize === true), functionWorkers: emitWorkers(), diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 1415f2971..771e2a426 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -64,6 +64,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( { root: directory, manifest, + licenses: manifest.licenses, packageText: "", manifestText: "", flavor: "dev", @@ -87,6 +88,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( warningReplace: false, stdioExtended: false, brotli: false, + moduleNamespace: false, regex: true, copying: false, textDecoderLegacy: false, @@ -126,6 +128,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( dh: false, pki: false, ed25519: false, + x509: false, webcrypto: false, perfLoop: false, nativeFetch: false, diff --git a/packages/compiler/src/native/sanitizer.ts b/packages/compiler/src/native/sanitizer.ts index cb3728ec0..5d228a615 100644 --- a/packages/compiler/src/native/sanitizer.ts +++ b/packages/compiler/src/native/sanitizer.ts @@ -4,7 +4,11 @@ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; import { NativeCodegenError } from "../backend/native-codegen-core.js"; import { requireNativeArtifact, runNativeTool } from "../backend/native-tools.js"; -import type { NativeRuntimePack, NativeRuntimeSelection } from "../backend/runtime-pack-native.js"; +import { + runtimePackManifest, + type NativeRuntimePack, + type NativeRuntimeSelection, +} from "../backend/runtime-pack-native.js"; import { runtimePackFlavorKey, type RuntimePackMode } from "../backend/runtime-pack-core.js"; import { LRE_SOURCES, ZLIB_SOURCES } from "../backend/vendor-inputs.js"; import { contentDigest, type NativeCache } from "./cache.js"; @@ -119,8 +123,9 @@ export function buildSanitizedRuntime( } return output; }; - const key = runtimePackFlavorKey(selection.manifest, selection.flavor, mode); - const flavor = selection.manifest.flavors[key]; + const manifest = runtimePackManifest(selection); + const key = runtimePackFlavorKey(manifest, selection.flavor, mode); + const flavor = manifest.flavors[key]; if (flavor === undefined) throw new NativeCodegenError("SC3003", `runtime pack lacks ${key}`); const wanted = new Set(selection.selected.runtime.map((artifact) => artifact.path)); const common = [ diff --git a/packages/compiler/src/node-islands/classify.ts b/packages/compiler/src/node-islands/classify.ts new file mode 100644 index 000000000..c41b9437c --- /dev/null +++ b/packages/compiler/src/node-islands/classify.ts @@ -0,0 +1,131 @@ +/* Diagnostic → Node island class (DESIGN.md §1.3). A refusal in a module + * of an npm package is a reason for that package to run in a Node island; + * its class says why: the runtime itself (R1), a mutable object model (R2), + * a shipping problem (packaging), or a construct inside the native subset + * that scriptc cannot compile yet (gap). The reason id on the diagnostic + * (ScrDiagnostic.reason) decides first; the code and message decide for + * refusal sites that do not carry one. */ +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import type { NodeIslandMarker } from "./detect.js"; +import { classOfRule, ruleInfo, ruleOfReason } from "./rules.js"; +import type { RefusalRecord } from "./scan.js"; +import type { NodeIslandReason, NodeIslandReasonSource } from "./summary.js"; + +/** Message shapes of refusal sites that predate reason ids. */ +const MESSAGE_REASONS: { code: string; pattern: RegExp; reason: string }[] = [ + { code: "SC2010", pattern: /./, reason: "code-from-string" }, + { code: "SC1010", pattern: /'(?:node:)?vm'/, reason: "vm" }, + { code: "SC1090", pattern: /^dynamic import\(\) of computed/, reason: "dynamic-import" }, + { code: "SC1090", pattern: /require\(\) with a computed specifier/, reason: "dynamic-require" }, + { code: "SC1090", pattern: /'with' statement/, reason: "with" }, + { code: "SC1090", pattern: /sloppy-mode parameter aliases/, reason: "sloppy-arguments" }, + { code: "SC2020", pattern: /^'(?:eval|Function)'/, reason: "code-from-string" }, + { + code: "SC2020", + pattern: /createRequire's require with this argument shape/, + reason: "dynamic-require", + }, + { code: "SC2020", pattern: /native addon/, reason: "native-addon" }, + { + code: "SC2020", + pattern: /through require\.cache|require\.extensions|Module\._|module\.register/, + reason: "loader-hook", + }, + { + code: "SC2020", + pattern: /process\.binding|_linkedBinding|internalBinding|setFlagsFromString/, + reason: "engine-internal", + }, + { code: "SC2020", pattern: /\bWeakRef\b|\bFinalizationRegistry\b/, reason: "weak-ref" }, + { code: "SC1100", pattern: /\bWeakRef\b|\bFinalizationRegistry\b/, reason: "weak-ref" }, +]; + +function reasonIdOf(d: ScrDiagnostic): string | null { + if (d.reason !== undefined) return d.reason; + for (const entry of MESSAGE_REASONS) { + if (entry.code === d.code && entry.pattern.test(d.message)) return entry.reason; + } + return null; +} + +/** Codes that are not reasons of the package they appear in: a cascade + * marker repeats a root cause, an import of another refused package is + * that package's island boundary, and divergences compile. */ +export function isIslandReasonCode(code: string): boolean { + if (code === "SC2004" || code === "SC2013") return false; + if (/^SC6\d{3}$/.test(code)) return false; + return true; +} + +export function classifyDiagnostic( + d: ScrDiagnostic, + source: NodeIslandReasonSource, +): NodeIslandReason { + const id = reasonIdOf(d); + const rule = id === null ? null : ruleOfReason(id); + if (rule !== null && id !== null) { + return { + class: classOfRule(rule), + rule, + reason: id, + code: d.code, + message: d.message, + loc: d.loc, + source, + }; + } + return { + class: "gap", + rule: null, + reason: "gap", + code: d.code, + message: d.message, + loc: d.loc, + source, + }; +} + +export function classifyMarker(m: NodeIslandMarker): NodeIslandReason { + return { + class: classOfRule(m.rule), + rule: m.rule, + reason: m.reason, + code: null, + message: `${ruleInfo(m.rule).title}: ${m.construct}`, + loc: m.loc, + source: "pre-scan", + }; +} + +/** A refused package's status (pipeline.ts): the drop-and-reload record + * when there is one, else the declaration and shipped-JS eligibility bar, + * which is a packaging reason. */ +export function classifyRefusal( + detail: string, + record: RefusalRecord | null, +): NodeIslandReason | null { + if (record !== null && record.kind === "external") return null; + if (record !== null && record.kind === "preflight" && record.diag !== null) { + return classifyDiagnostic(record.diag, "package"); + } + if (record !== null && record.kind === "offender" && /require|createRequire/.test(detail)) { + return { + class: "runtime", + rule: "R1.2", + reason: "dynamic-require", + code: "SC2013", + message: detail, + loc: null, + source: "package", + }; + } + return { + class: "packaging", + rule: null, + reason: "packaging", + code: "SC2013", + message: detail, + loc: null, + source: "package", + }; +} diff --git a/packages/compiler/src/node-islands/detect.test.ts b/packages/compiler/src/node-islands/detect.test.ts new file mode 100644 index 000000000..2a21395da --- /dev/null +++ b/packages/compiler/src/node-islands/detect.test.ts @@ -0,0 +1,165 @@ +import { afterAll, describe, expect, test } from "vitest"; +import { closeSourceParser, parseSourceFile } from "../frontend/ts7/source-parser-node.js"; +import { scanModule } from "./detect.js"; + +afterAll(() => closeSourceParser()); + +let serial = 0; +/** The markers of one JavaScript module as `rule reason` strings. */ +function markers(source: string, ext = "cjs"): string[] { + const sf = parseSourceFile(`/virtual/m${serial++}.${ext}`, source, "js"); + return scanModule(sf).map((m) => `${m.rule} ${m.reason}`); +} + +describe("R1 runtime markers", () => { + test("R1.1 code from strings", () => { + expect(markers("eval(code);")).toEqual(["R1.1 code-from-string"]); + expect(markers("(0, eval)(code);")).toEqual(["R1.1 code-from-string"]); + expect(markers("const f = new Function('a', 'return a');")).toEqual(["R1.1 code-from-string"]); + expect(markers("const f = Function('a', body);")).toEqual(["R1.1 code-from-string"]); + expect(markers("const vm = require('node:vm');")).toEqual(["R1.1 vm"]); + expect(markers("import vm from 'vm';", "mjs")).toEqual(["R1.1 vm"]); + }); + + test("R1.1 exceptions: the global-object idiom and a capability probe in try", () => { + expect(markers("const g = Function('return this')();")).toEqual([]); + expect(markers("let ok; try { new Function(''); ok = true; } catch { ok = false; }")).toEqual( + [], + ); + }); + + test("R1.2 dynamic module loading", () => { + expect(markers("require(name);")).toEqual(["R1.2 dynamic-require"]); + expect(markers("import(`./${x}.js`);", "mjs")).toEqual(["R1.2 dynamic-import"]); + expect(markers("const r = createRequire(import.meta.url); r(spec);", "mjs")).toEqual([ + "R1.2 dynamic-require", + ]); + expect(markers("require('./a' + '.js'); require.resolve(name); import('./b.js');")).toEqual([]); + }); + + test("R1.3 native addons", () => { + expect(markers("require('./build/addon.node');")).toEqual(["R1.3 native-addon"]); + expect(markers("const b = require('bindings')('addon');")).toEqual(["R1.3 native-addon"]); + expect(markers("process.dlopen(module, path);")).toEqual(["R1.3 native-addon"]); + }); + + test("R1.4 loader and require hooks", () => { + expect(markers("require.extensions['.coffee'] = compile;")).toEqual(["R1.4 loader-hook"]); + expect(markers("delete require.cache[key];")).toEqual(["R1.4 loader-hook"]); + expect(markers("const Module = require('module'); Module._load = load;")).toEqual([ + "R1.4 loader-hook", + ]); + expect(markers("const m = require('node:module'); m.registerHooks({});")).toEqual([ + "R1.4 loader-hook", + ]); + expect(markers("require('require-in-the-middle');")).toEqual(["R1.4 loader-hook"]); + }); + + test("R1.5 engine internals", () => { + expect(markers("process.binding('fs');")).toEqual(["R1.5 engine-internal"]); + expect(markers("const v8 = require('v8'); v8.setFlagsFromString('--x');")).toEqual([ + "R1.5 engine-internal", + ]); + expect(markers("global.gc();")).toEqual(["R1.5 engine-internal"]); + }); + + test("R1.6 runtime-only globals", () => { + expect(markers("const r = new WeakRef(o);")).toEqual(["R1.6 weak-ref"]); + expect(markers("const f = new FinalizationRegistry(cb);")).toEqual(["R1.6 weak-ref"]); + expect(markers("if (typeof WeakRef !== 'undefined') {}")).toEqual([]); + expect(markers("const o = { WeakRef: 1 }; o.WeakRef;")).toEqual([]); + }); +}); + +describe("R2 object-model markers", () => { + test("R2.1 constructor functions", () => { + expect(markers("function F() {} const f = new F();")).toEqual(["R2.1 constructor-function"]); + expect(markers("function F() {} F.prototype.m = function () {};")).toEqual([ + "R2.1 constructor-function", + ]); + expect(markers("const util = require('util'); util.inherits(A, B);")).toEqual([ + "R2.1 constructor-function", + ]); + expect(markers("function Child() { Parent.call(this, 1); }")).toEqual([ + "R2.1 constructor-function", + ]); + expect(markers("class C {} const c = new C();")).toEqual([]); + expect( + markers("function wrap(fn) { return function () { return fn.apply(this, arguments); }; }"), + ).toEqual([]); + }); + + test("R2.2 prototype mutation", () => { + expect(markers("Object.setPrototypeOf(a, b);")).toEqual(["R2.2 prototype-write"]); + expect(markers("o.__proto__ = p;")).toEqual(["R2.2 prototype-write"]); + expect(markers("const o = { __proto__: base };")).toEqual(["R2.2 prototype-write"]); + // The transpiled-Error idiom and null-prototype dictionaries stay. + expect( + markers( + "class E extends Error { constructor() { super(); Object.setPrototypeOf(this, E.prototype); } }", + ), + ).toEqual([]); + expect(markers("const d = { __proto__: null };")).toEqual([]); + }); + + test("R2.3 shape mutation outside the checker's view", () => { + expect(markers("Object.defineProperty(o, 'k', { get() { return 1; } });")).toEqual([ + "R2.3 define-property", + ]); + expect(markers("class C {} C.prototype.extra = 1;")).toEqual(["R2.3 define-property"]); + // Module objects and interop helpers build module records. + expect(markers("Object.defineProperty(exports, '__esModule', { value: true });")).toEqual([]); + expect( + markers( + "var __createBinding = function (o, m, k) { Object.defineProperty(o, k, { enumerable: true, get: function () { return m[k]; } }); };", + ), + ).toEqual([]); + }); + + test("R2.4 sloppy-mode semantics", () => { + expect(markers("with (o) { x; }")).toEqual(["R2.4 with"]); + expect(markers("function f(a) { arguments[0] = 1; return a; }")).toEqual([ + "R2.4 sloppy-arguments", + ]); + expect(markers("function f(a) { a = 2; return arguments[0]; }")).toEqual([ + "R2.4 sloppy-arguments", + ]); + expect(markers("function f() { return arguments.callee; }")).toEqual(["R2.4 sloppy-arguments"]); + expect(markers("var n = 010;")).toEqual(["R2.4 sloppy-mode"]); + expect(markers("var x = 1; delete x;")).toEqual(["R2.4 sloppy-mode"]); + // Strict code: a prologue, an ES module, a class body. + expect(markers("'use strict'; function f(a) { a = 2; return arguments[0]; }")).toEqual([]); + expect(markers("export function f(a) { a = 2; return arguments[0]; }", "mjs")).toEqual([]); + expect(markers("class C { m(a) { a = 2; return arguments[0]; } }")).toEqual([]); + }); + + test("R2.5 builtin and global patching", () => { + expect(markers("Array.prototype.last = function () {};")).toEqual(["R2.5 builtin-write"]); + expect(markers("Promise = Bluebird;")).toEqual(["R2.5 builtin-write"]); + expect(markers("globalThis.fetch = f;")).toEqual(["R2.5 builtin-write"]); + expect(markers("const fs = require('fs'); fs.close = patched;")).toEqual([ + "R2.5 builtin-write", + ]); + expect(markers("const p = new Proxy(t, h);")).toEqual(["R2.5 proxy"]); + // Modeled process state and tuning knobs stay; so do redeclared names. + expect( + markers("process.exitCode = 1; process.env.X = 'y'; Error.stackTraceLimit = 50;"), + ).toEqual([]); + expect(markers("var Promise = require('bluebird'); Promise.config = {};")).toEqual([]); + }); +}); + +test("markers come back in source order with their locations", () => { + const source = "Object.setPrototypeOf(a, b);\neval(x);\n"; + const sf = parseSourceFile(`/virtual/order${serial++}.cjs`, source, "js"); + const found = scanModule(sf); + expect(found.map((m) => [m.rule, source.slice(m.loc.start, m.loc.end)])).toEqual([ + ["R2.2", "Object.setPrototypeOf(a, b)"], + ["R1.1", "eval(x)"], + ]); +}); + +test("computed writes keep the member they write through", () => { + expect(markers("process.env[key] = value;")).toEqual([]); + expect(markers("Array.prototype[name] = fn;")).toEqual(["R2.5 builtin-write"]); +}); diff --git a/packages/compiler/src/node-islands/detect.ts b/packages/compiler/src/node-islands/detect.ts new file mode 100644 index 000000000..f612d58e5 --- /dev/null +++ b/packages/compiler/src/node-islands/detect.ts @@ -0,0 +1,849 @@ +/* The pre-scan (DESIGN.md §1.4, phase 1): a syntactic walk over one + * JavaScript module that finds the R1 (runtime) and R2 (object-model) + * markers of rules.ts. It is a fast path and a source of good reasons; it + * never decides alone that a module is native, and the authoritative signal + * stays the lowering's refusals (classify.ts). + * + * Every check is local to the file: a name is a "function" when the file + * declares it as a function, a builtin global when the file does not + * declare it at all, and a builtin module object when the file binds it to + * require("") or imports it from one. Checker-dependent parts of + * R2.3 (writes the declared type does not cover, freeze followed by writes) + * and R2.4 (assignments to undeclared identifiers, `this` as the global + * object) are left to the lowering. */ +import type { SrcLoc } from "../ir/ir.js"; +import * as ts from "../frontend/ts7/adapter.js"; +import { SUPPORTED_BUILTIN_MODULES } from "../frontend/builtin-modules.js"; +import type { NodeIslandRule } from "./rules.js"; + +export interface NodeIslandMarker { + rule: NodeIslandRule; + /** The reason id (rules.ts). */ + reason: string; + /** The construct, as a short phrase for messages. */ + construct: string; + loc: SrcLoc; +} + +/** Markers past this count in one module add nothing to a report. */ +const MARKER_CAP = 64; + +const BUILTIN_GLOBALS = new Set([ + "Object", + "Array", + "String", + "Number", + "Boolean", + "Function", + "Symbol", + "BigInt", + "Promise", + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "ReferenceError", + "EvalError", + "URIError", + "AggregateError", + "Map", + "Set", + "WeakMap", + "WeakSet", + "RegExp", + "Date", + "Math", + "JSON", + "Reflect", + "Intl", + "ArrayBuffer", + "SharedArrayBuffer", + "DataView", + "Uint8Array", + "Int8Array", + "Uint16Array", + "Int16Array", + "Uint32Array", + "Int32Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", + "Uint8ClampedArray", + "Buffer", + "process", + "console", + "globalThis", + "global", + "URL", + "URLSearchParams", + "TextEncoder", + "TextDecoder", + "AbortController", + "AbortSignal", + "EventTarget", + "Event", + "fetch", + "setTimeout", + "setInterval", + "setImmediate", + "clearTimeout", + "clearInterval", + "clearImmediate", + "queueMicrotask", + "structuredClone", +]); + +/** Writes to builtin members that the native subset models (process + * state, tuning knobs). */ +const ALLOWED_BUILTIN_WRITES = new Set([ + "process.exitCode", + "process.title", + "process.env", + "Error.stackTraceLimit", + "Buffer.poolSize", + "events.defaultMaxListeners", + "events.EventEmitter.defaultMaxListeners", + "EventEmitter.defaultMaxListeners", +]); + +const ADDON_LOADERS = new Set([ + "bindings", + "node-gyp-build", + "node-pre-gyp", + "@mapbox/node-pre-gyp", + "prebuild-install", + "node-gyp-build-optional-packages", +]); + +const LOADER_HOOK_PACKAGES = new Set(["import-in-the-middle", "require-in-the-middle"]); + +function builtinModuleName(spec: string): string | null { + const bare = spec.startsWith("node:") ? spec.slice(5) : spec; + if ((SUPPORTED_BUILTIN_MODULES as readonly string[]).includes(bare)) return bare; + if (bare === "vm" || bare === "v8" || bare === "module" || bare === "inspector") return bare; + return null; +} + +function literalText(node: ts.Node | undefined): string | null { + if (node === undefined) return null; + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + return null; +} + +/** A specifier that folds at build time: a literal, or a concatenation of + * literals. */ +function isFoldableSpecifier(node: ts.Node): boolean { + if (literalText(node) !== null) return true; + if (ts.isParenthesizedExpression(node)) return isFoldableSpecifier(node.expression); + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) + return isFoldableSpecifier(node.left) && isFoldableSpecifier(node.right); + return false; +} + +function isAssignmentOperator(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +function propertyName(node: ts.Node): string | null { + if (ts.isPropertyAccessExpression(node)) { + const name = node.name; + return ts.isIdentifier(name) ? name.text : null; + } + if (ts.isElementAccessExpression(node)) return literalText(node.argumentExpression); + return null; +} + +/** `a.b.c` as "a.b.c" (element accesses with literal keys included), or + * null when the chain has a computed key or does not start at a name. */ +function chainText(node: ts.Node): string | null { + if (ts.isIdentifier(node)) return node.text; + if (node.kind === ts.SyntaxKind.ThisKeyword) return "this"; + if (ts.isParenthesizedExpression(node)) return chainText(node.expression); + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + const base = chainText(node.expression); + const name = propertyName(node); + if (base === null || name === null) return null; + return `${base}.${name}`; + } + return null; +} + +/** Like chainText, with computed keys spelled `[…]`: `process.env[…]`. */ +function memberText(node: ts.Node): string | null { + const text = chainText(node); + if (text !== null) return text; + if (ts.isElementAccessExpression(node)) { + const base = memberText(node.expression); + return base === null ? null : `${base}[…]`; + } + if (ts.isPropertyAccessExpression(node)) { + const base = memberText(node.expression); + const name = propertyName(node); + return base === null || name === null ? null : `${base}.${name}`; + } + return null; +} + +/** The identifier a member chain starts at (`a` of `a.b[c].d`). */ +function chainRoot(node: ts.Node): ts.Identifier | null { + let cur = node; + for (;;) { + if (ts.isIdentifier(cur)) return cur; + if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur)) { + cur = cur.expression; + continue; + } + if (ts.isParenthesizedExpression(cur)) { + cur = cur.expression; + continue; + } + return null; + } +} + +function hasUseStrict(statements: readonly ts.Node[]): boolean { + for (const stmt of statements) { + if (!ts.isExpressionStatement(stmt)) return false; + const text = literalText(stmt.expression); + if (text === null) return false; + if (text === "use strict") return true; + } + return false; +} + +/** ES module syntax at the top level (static import or export): the + * module is strict. The parser's module indicator is not used, since it + * also marks CommonJS files under forced module detection. */ +function hasEsmSyntax(sf: ts.SourceFile): boolean { + for (const stmt of sf.statements.nodes) { + if (ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt)) return true; + if (ts.isExportAssignment(stmt)) return true; + const head = sf.text.slice(ts.tokenStartOfNode(stmt), ts.tokenStartOfNode(stmt) + 7); + if (head === "export ") return true; + } + return false; +} + +function isFunctionBoundary(node: ts.Node): boolean { + return ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isMethodDeclaration(node) || + ts.isConstructorDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) + ); +} + +/** One module's pre-scan. */ +class ModuleScanner { + readonly markers: NodeIslandMarker[] = []; + /** Every name the file declares (any scope): a builtin global the file + * redeclares is not the builtin. */ + private readonly declared = new Set(); + /** Names declared as functions (declarations and function-valued + * variables). */ + private readonly functions = new Set(); + private readonly classes = new Set(); + /** Local name → the builtin module (or `module.member`) it is bound to. */ + private readonly builtinBindings = new Map(); + /** Local names bound to a createRequire(...) result. */ + private readonly createdRequires = new Set(); + private readonly strictness = new Map(); + private readonly moduleStrict: boolean; + + constructor(private readonly sf: ts.SourceFile) { + const name = sf.fileName; + this.moduleStrict = + name.endsWith(".mjs") || + name.endsWith(".mts") || + hasEsmSyntax(sf) || + hasUseStrict(sf.statements.nodes); + } + + scan(): NodeIslandMarker[] { + this.collectDeclarations(); + ts.walkPreorder(this.sf, (node) => { + if (this.markers.length >= MARKER_CAP) return "stop"; + this.visit(node); + return undefined; + }); + return this.markers; + } + + private add(rule: NodeIslandRule, reason: string, construct: string, node: ts.Node): void { + if (this.markers.length >= MARKER_CAP) return; + this.markers.push({ + rule, + reason, + construct, + loc: { file: this.sf.fileName, start: ts.tokenStartOfNode(node), end: node.end }, + }); + } + + private bindName(name: ts.Node | undefined, init: ts.Node | undefined): void { + if (name === undefined) return; + if (ts.isIdentifier(name)) { + this.declared.add(name.text); + if (init === undefined) return; + let value = init; + while (ts.isParenthesizedExpression(value)) value = value.expression; + if (ts.isFunctionExpression(value) || ts.isArrowFunction(value)) + this.functions.add(name.text); + else if (ts.isClassExpression(value)) this.classes.add(name.text); + const bound = this.builtinOf(value); + if (bound !== null) this.builtinBindings.set(name.text, bound); + if (ts.isCallExpression(value) && this.isCreateRequireCallee(value.expression)) + this.createdRequires.add(name.text); + return; + } + if (ts.isObjectBindingPattern(name)) { + const base = init === undefined ? null : this.builtinOf(init); + for (const element of name.elements.nodes) { + if (!ts.isBindingElement(element)) continue; + const local = element.name; + if (local !== undefined && ts.isIdentifier(local) && base !== null) { + const key = element.propertyName; + const member = key !== undefined && ts.isIdentifier(key) ? key.text : local.text; + this.builtinBindings.set(local.text, `${base}.${member}`); + } + this.bindName(local, undefined); + } + return; + } + if (ts.isArrayBindingPattern(name)) { + for (const element of name.elements.nodes) { + if (ts.isBindingElement(element)) this.bindName(element.name, undefined); + } + } + } + + /** `require("fs")`, `require("node:util").inherits` → "fs", "util.inherits". */ + private builtinOf(node: ts.Node): string | null { + let value = node; + while (ts.isParenthesizedExpression(value)) value = value.expression; + if (ts.isCallExpression(value) && this.isRequireCallee(value.expression)) { + const spec = literalText(value.arguments.nodes[0]); + return spec === null ? null : builtinModuleName(spec); + } + if (ts.isPropertyAccessExpression(value)) { + const base = this.builtinOf(value.expression); + const name = propertyName(value); + return base === null || name === null ? null : `${base}.${name}`; + } + if (ts.isIdentifier(value)) return this.builtinBindings.get(value.text) ?? null; + return null; + } + + private isRequireCallee(node: ts.Node): boolean { + return ts.isIdentifier(node) && node.text === "require"; + } + + private isCreateRequireCallee(node: ts.Node): boolean { + if (ts.isIdentifier(node)) { + return ( + node.text === "createRequire" || + this.builtinBindings.get(node.text) === "module.createRequire" + ); + } + return propertyName(node) === "createRequire"; + } + + private collectDeclarations(): void { + ts.walkPreorder(this.sf, (node) => { + if (ts.isFunctionDeclaration(node) && node.name !== undefined) { + this.declared.add(node.name.text); + this.functions.add(node.name.text); + } else if (ts.isClassDeclaration(node) && node.name !== undefined) { + this.declared.add(node.name.text); + this.classes.add(node.name.text); + } else if (ts.isVariableDeclaration(node)) { + this.bindName(node.name, node.initializer); + } else if (ts.isParameter(node)) { + this.bindName(node.name, undefined); + } else if (ts.isImportDeclaration(node)) { + const spec = literalText(node.moduleSpecifier); + const module = spec === null ? null : builtinModuleName(spec); + const clause = node.importClause; + if (clause !== undefined) { + if (clause.name !== undefined) { + this.declared.add(clause.name.text); + if (module !== null) this.builtinBindings.set(clause.name.text, module); + } + const bindings = clause.namedBindings; + if (bindings !== undefined && ts.isNamespaceImport(bindings)) { + this.declared.add(bindings.name.text); + if (module !== null) this.builtinBindings.set(bindings.name.text, module); + } else if (bindings !== undefined && ts.isNamedImports(bindings)) { + for (const element of bindings.elements.nodes) { + this.declared.add(element.name.text); + const imported = element.propertyName; + const member = + imported !== undefined && ts.isIdentifier(imported) + ? imported.text + : element.name.text; + if (module !== null) + this.builtinBindings.set(element.name.text, `${module}.${member}`); + } + } + } + } + return undefined; + }); + } + + /** Whether the code at `node` runs in strict mode. */ + private isStrict(node: ts.Node): boolean { + if (this.moduleStrict) return true; + let cur: ts.Node | undefined = node.parent; + const visited: ts.Node[] = []; + let strict = false; + while (cur !== undefined) { + const known = this.strictness.get(cur); + if (known !== undefined) { + strict = known; + break; + } + visited.push(cur); + if (ts.isClassDeclaration(cur) || ts.isClassExpression(cur)) { + strict = true; + break; + } + if (isFunctionBoundary(cur) || ts.isArrowFunction(cur)) { + const body = (cur as ts.FunctionLikeDeclaration).body; + if (body !== undefined && ts.isBlock(body) && hasUseStrict(body.statements.nodes)) { + strict = true; + break; + } + } + cur = cur.parent; + } + for (const n of visited) this.strictness.set(n, strict); + return strict; + } + + private isGlobal(name: string): boolean { + return BUILTIN_GLOBALS.has(name) && !this.declared.has(name); + } + + private visit(node: ts.Node): void { + if (ts.isCallExpression(node)) this.visitCall(node); + else if (ts.isNewExpression(node)) this.visitNew(node); + else if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) + this.visitWrite(node.left, node, "write"); + else if (ts.isDeleteExpression(node)) this.visitDelete(node); + else if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) { + if ( + node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken + ) + this.visitWrite(node.operand, node, "write"); + } else if (ts.isWithStatement(node)) { + this.add("R2.4", "with", "a 'with' statement", node); + } else if (ts.isImportDeclaration(node)) { + const spec = literalText(node.moduleSpecifier); + if (spec !== null) this.visitSpecifier(spec, node); + } else if (ts.isIdentifier(node)) { + this.visitIdentifier(node); + } else if (ts.isNumericLiteral(node)) { + const text = this.sf.text.slice(ts.tokenStartOfNode(node), node.end); + if (/^0[0-9]+$/.test(text) && !this.isStrict(node)) + this.add("R2.4", "sloppy-mode", "a legacy octal literal", node); + } else if (ts.isPropertyAccessExpression(node)) { + const text = chainText(node); + if ((text === "arguments.callee" || text === "arguments.caller") && !this.isStrict(node)) + this.add("R2.4", "sloppy-arguments", `'${text}'`, node); + } else if (ts.isObjectLiteralExpression(node)) { + for (const prop of node.properties.nodes) { + if (!ts.isPropertyAssignment(prop)) continue; + const name = prop.name; + const key = ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : null; + if (key === "__proto__" && prop.initializer.kind !== ts.SyntaxKind.NullKeyword) + this.add("R2.2", "prototype-write", "an object literal with a '__proto__' value", prop); + } + } else if (isFunctionBoundary(node)) { + this.visitFunction(node as ts.FunctionLikeDeclaration); + } + } + + private visitSpecifier(spec: string, node: ts.Node): void { + const builtin = builtinModuleName(spec); + if (builtin === "vm") this.add("R1.1", "vm", "the 'vm' module", node); + else if (spec.endsWith(".node")) + this.add("R1.3", "native-addon", `the native addon '${spec}'`, node); + else if (ADDON_LOADERS.has(spec)) + this.add("R1.3", "native-addon", `the native addon loader '${spec}'`, node); + else if (LOADER_HOOK_PACKAGES.has(spec)) + this.add("R1.4", "loader-hook", `the loader hook package '${spec}'`, node); + } + + private visitCall(call: ts.CallExpression): void { + const callee = call.expression; + const args = call.arguments.nodes; + const first = args[0]; + // import(e) and require(e) + if (callee.kind === ts.SyntaxKind.ImportKeyword) { + if (first !== undefined && !isFoldableSpecifier(first)) + this.add("R1.2", "dynamic-import", "import() with a computed specifier", call); + else if (first !== undefined) this.visitSpecifier(literalText(first) ?? "", call); + return; + } + if ( + this.isRequireCallee(callee) || + (ts.isIdentifier(callee) && this.createdRequires.has(callee.text)) + ) { + if (first !== undefined && !isFoldableSpecifier(first)) + this.add("R1.2", "dynamic-require", "require() with a computed specifier", call); + else if (first !== undefined) this.visitSpecifier(literalText(first) ?? "", call); + return; + } + if (ts.isCallExpression(callee) && this.isCreateRequireCallee(callee.expression)) { + if (first !== undefined && !isFoldableSpecifier(first)) + this.add("R1.2", "dynamic-require", "createRequire(...)() with a computed specifier", call); + return; + } + // eval, Function + let target = callee; + while (ts.isParenthesizedExpression(target)) target = target.expression; + if (ts.isBinaryExpression(target) && target.operatorToken.kind === ts.SyntaxKind.CommaToken) + target = target.right; + const text = chainText(target); + if (text === null) return; + if ( + (text === "eval" && !this.declared.has("eval")) || + text === "globalThis.eval" || + text === "global.eval" || + text === "window.eval" + ) { + this.add("R1.1", "code-from-string", "eval()", call); + return; + } + if (text === "Function" && this.isGlobal("Function")) { + this.visitFunctionConstructor(call, args); + return; + } + const root = chainRoot(target); + const rootBinding = root === null ? undefined : this.builtinBindings.get(root.text); + const resolved = + rootBinding !== undefined && root !== null + ? rootBinding + text.slice(root.text.length) + : text; + // engine internals + if ( + (text === "process.binding" || text === "process._linkedBinding") && + this.isGlobal("process") + ) { + this.add("R1.5", "engine-internal", `'${text}()'`, call); + return; + } + if (text === "internalBinding" && !this.declared.has("internalBinding")) { + this.add("R1.5", "engine-internal", "'internalBinding()'", call); + return; + } + if (resolved === "v8.setFlagsFromString" || text.endsWith(".setFlagsFromString")) { + this.add("R1.5", "engine-internal", "'v8.setFlagsFromString()'", call); + return; + } + if ((text === "global.gc" || text === "globalThis.gc") && this.isGlobal(text.split(".")[0]!)) { + this.add("R1.5", "engine-internal", `'${text}()'`, call); + return; + } + if (text === "process.dlopen" && this.isGlobal("process")) { + this.add("R1.3", "native-addon", "'process.dlopen()'", call); + return; + } + // loader hooks + if (resolved === "module.register" || resolved === "module.registerHooks") { + this.add("R1.4", "loader-hook", `'${resolved}()'`, call); + return; + } + // prototype mutation + if ( + (text === "Object.setPrototypeOf" && this.isGlobal("Object")) || + (text === "Reflect.setPrototypeOf" && this.isGlobal("Reflect")) + ) { + // The transpiled-Error idiom `Object.setPrototypeOf(this, …)` stays. + if (first !== undefined && first.kind !== ts.SyntaxKind.ThisKeyword) + this.add("R2.2", "prototype-write", `'${text}()'`, call); + return; + } + // defineProperty outside the module object and interop helpers + if ( + ((text === "Object.defineProperty" || text === "Object.defineProperties") && + this.isGlobal("Object")) || + (text === "Reflect.defineProperty" && this.isGlobal("Reflect")) + ) { + if (first !== undefined && !this.isModuleObject(first) && !this.inInteropHelper(call)) + this.add("R2.3", "define-property", `'${text}()'`, call); + return; + } + if (text.endsWith(".__defineGetter__") || text.endsWith(".__defineSetter__")) { + this.add("R2.3", "define-property", `'${text.slice(text.lastIndexOf(".") + 1)}()'`, call); + return; + } + // constructor functions + if ( + args.length === 2 && + (resolved === "util.inherits" || + (text === "inherits" && !this.functions.has("inherits")) || + text.endsWith(".inherits")) + ) { + this.add("R2.1", "constructor-function", `'${text}()'`, call); + return; + } + if ( + ts.isPropertyAccessExpression(target) && + (propertyName(target) === "call" || propertyName(target) === "apply") && + first !== undefined && + first.kind === ts.SyntaxKind.ThisKeyword && + ts.isExpressionStatement(call.parent!) + ) { + const fn = target.expression; + const name = ts.isIdentifier(fn) ? fn.text : null; + if (name !== null && /^[A-Z]/.test(name) && !this.classes.has(name)) + this.add( + "R2.1", + "constructor-function", + `'${name}.${propertyName(target)}(this, …)'`, + call, + ); + } + } + + private visitFunctionConstructor(node: ts.Node, args: readonly ts.Node[]): void { + // The global-object idiom Function("return this")() folds to globalThis. + if (args.length === 1) { + const body = literalText(args[0]); + if (body !== null && body.trim() === "return this") return; + } + // A capability probe tested inside try (zod 4's allowsEval) keeps + // today's EvalError lowering. + for (let cur = node.parent; cur !== undefined; cur = cur.parent) { + if (isFunctionBoundary(cur) || ts.isArrowFunction(cur)) break; + if (ts.isBlock(cur) && cur.parent !== undefined && ts.isTryStatement(cur.parent)) { + if (cur.parent.tryBlock === cur) return; + } + } + this.add("R1.1", "code-from-string", "new Function()", node); + } + + private visitNew(node: ts.NewExpression): void { + const callee = node.expression; + if (!ts.isIdentifier(callee)) return; + const name = callee.text; + if (name === "Function" && this.isGlobal("Function")) { + this.visitFunctionConstructor(node, node.arguments?.nodes ?? []); + } else if (name === "Proxy" && !this.declared.has("Proxy")) { + this.add("R2.5", "proxy", "new Proxy()", node); + } else if (this.functions.has(name) && !this.classes.has(name)) { + this.add("R2.1", "constructor-function", `'new ${name}()' of a function`, node); + } + } + + private isModuleObject(node: ts.Node): boolean { + const text = chainText(node); + return text === "exports" || text === "module.exports" || text === "module"; + } + + /** Inside a module-interop helper (tsc's __createBinding/__exportStar, + * Babel's _interopRequireWildcard, esbuild's __export): their + * defineProperty calls build module objects the native subset models. */ + private inInteropHelper(node: ts.Node): boolean { + for (let cur = node.parent; cur !== undefined; cur = cur.parent) { + let name: string | null = null; + if (ts.isFunctionDeclaration(cur) && cur.name !== undefined) name = cur.name.text; + else if (ts.isVariableDeclaration(cur) && ts.isIdentifier(cur.name)) name = cur.name.text; + else if (ts.isBinaryExpression(cur) && isAssignmentOperator(cur.operatorToken.kind)) + name = chainText(cur.left); + if (name !== null && /(^|\.)_/.test(name)) return true; + } + return false; + } + + private visitWrite(target: ts.Node, node: ts.Node, what: "write" | "delete"): void { + let left = target; + while (ts.isParenthesizedExpression(left)) left = left.expression; + if (ts.isIdentifier(left)) { + if (this.isGlobal(left.text) && left.text !== "global" && left.text !== "globalThis") + this.add("R2.5", "builtin-write", `a ${what} of the global '${left.text}'`, node); + return; + } + if (!ts.isPropertyAccessExpression(left) && !ts.isElementAccessExpression(left)) return; + const text = chainText(left); + const root = chainRoot(left); + const base = left.expression; + const baseText = chainText(base); + // require.extensions / require.cache + if ( + text !== null && + (text.startsWith("require.extensions") || text.startsWith("require.cache")) + ) { + this.add( + "R1.4", + "loader-hook", + `a ${what} of '${text.startsWith("require.cache") ? "require.cache" : "require.extensions"}'`, + node, + ); + return; + } + if (baseText === "require.cache" || baseText === "require.extensions") { + this.add("R1.4", "loader-hook", `a ${what} of '${baseText}'`, node); + return; + } + // __proto__ writes + if (propertyName(left) === "__proto__") { + this.add("R2.2", "prototype-write", `a ${what} of '__proto__'`, node); + return; + } + if (root === null) return; + const binding = this.builtinBindings.get(root.text); + // Module internals: Module._load = …, Module.prototype.require = … + if (text !== null) { + const resolved = binding !== undefined ? binding + text.slice(root.text.length) : text; + if ( + /^(module|Module)\.(_load|_resolveFilename|_extensions|wrap|_compile|prototype\.require|prototype\._compile)$/.test( + resolved, + ) && + (binding !== undefined || !this.declared.has(root.text) || root.text === "Module") + ) { + this.add("R1.4", "loader-hook", `a ${what} of '${resolved}'`, node); + return; + } + // F.prototype… + const parts = text.split("."); + if (parts.length >= 2 && parts[1] === "prototype") { + const name = parts[0]!; + if (this.isGlobal(name)) { + this.add("R2.5", "builtin-write", `a ${what} of '${text}'`, node); + return; + } + if (binding === undefined && this.classes.has(name) && !this.functions.has(name)) { + this.add("R2.3", "define-property", `a ${what} of the class prototype '${text}'`, node); + return; + } + if (binding === undefined && name !== "this") { + this.add("R2.1", "constructor-function", `a ${what} of '${text}'`, node); + return; + } + } + } + // Builtin globals and builtin module objects. + const spelled = memberText(left) ?? `${root.text}[…]`; + if (binding !== undefined) { + const member = binding + spelled.slice(root.text.length); + if (!this.allowedWrite(member)) { + this.add( + "R2.5", + "builtin-write", + `a ${what} of the builtin module member '${member}'`, + node, + ); + } + return; + } + if (this.isGlobal(root.text)) { + const member = spelled; + if (!this.allowedWrite(member)) + this.add("R2.5", "builtin-write", `a ${what} of the builtin '${member}'`, node); + } + } + + private allowedWrite(member: string): boolean { + for (const allowed of ALLOWED_BUILTIN_WRITES) { + if ( + member === allowed || + member.startsWith(`${allowed}.`) || + member.startsWith(`${allowed}[`) + ) + return true; + } + return false; + } + + private visitDelete(node: ts.DeleteExpression): void { + let target = node.expression; + while (ts.isParenthesizedExpression(target)) target = target.expression; + if (ts.isIdentifier(target)) { + if (!this.isStrict(node)) + this.add("R2.4", "sloppy-mode", `'delete ${target.text}' of an identifier`, node); + return; + } + this.visitWrite(target, node, "delete"); + } + + private visitIdentifier(node: ts.Identifier): void { + const name = node.text; + if (name !== "WeakRef" && name !== "FinalizationRegistry") return; + if (this.declared.has(name)) return; + const parent = node.parent; + if (parent === undefined) return; + // Only value uses: not a property name, declaration name or typeof probe. + if (ts.isPropertyAccessExpression(parent) && parent.name === node) return; + if (ts.isTypeOfExpression(parent)) return; + if ((ts.isPropertyAssignment(parent) || ts.isMethodDeclaration(parent)) && parent.name === node) + return; + this.add("R1.6", "weak-ref", `'${name}'`, node); + } + + /** Observable `arguments` aliasing in a sloppy function: writes to + * arguments[i], or to a parameter while `arguments` is read. */ + private visitFunction(fn: ts.FunctionLikeDeclaration): void { + const body = fn.body; + if (body === undefined || !ts.isBlock(body)) return; + if (this.isStrict(body.statements.nodes[0] ?? body)) return; + if (hasUseStrict(body.statements.nodes)) return; + const params = new Set(); + for (const p of fn.parameters.nodes) { + if (ts.isIdentifier(p.name)) params.add(p.name.text); + } + const seen: ArgumentsUse = { reads: false, argumentsWrite: null, paramWrite: null }; + ts.walkPreorder(body, (node) => { + if ( + node !== body && + (isFunctionBoundary(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node)) + ) + return "skip"; + if (ts.isIdentifier(node) && node.text === "arguments") seen.reads = true; + if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) { + const left = node.left; + if ( + ts.isElementAccessExpression(left) && + ts.isIdentifier(left.expression) && + left.expression.text === "arguments" + ) { + if (seen.argumentsWrite === null) seen.argumentsWrite = node; + } else if (ts.isIdentifier(left) && params.has(left.text)) { + if (seen.paramWrite === null) seen.paramWrite = node; + } + } + return undefined; + }); + if (seen.argumentsWrite !== null) + this.add( + "R2.4", + "sloppy-arguments", + "a write to 'arguments[i]' in sloppy mode", + seen.argumentsWrite, + ); + else if (seen.reads && seen.paramWrite !== null) + this.add( + "R2.4", + "sloppy-arguments", + "a parameter write while 'arguments' is read in sloppy mode", + seen.paramWrite, + ); + } +} + +interface ArgumentsUse { + reads: boolean; + argumentsWrite: ts.Node | null; + paramWrite: ts.Node | null; +} + +/** The R1/R2 markers of one JavaScript module, in source order. */ +export function scanModule(sf: ts.SourceFile): NodeIslandMarker[] { + const markers = new ModuleScanner(sf).scan(); + return markers.sort((a, b) => a.loc.start - b.loc.start); +} diff --git a/packages/compiler/src/node-islands/origin.ts b/packages/compiler/src/node-islands/origin.ts new file mode 100644 index 000000000..65beebba0 --- /dev/null +++ b/packages/compiler/src/node-islands/origin.ts @@ -0,0 +1,68 @@ +/* Where a source file comes from. User code (the program's own tree and + * workspace packages compiled from source) is strict; npm code (installed + * packages under node_modules) is lenient and is decided per package + * instance; shims are scriptc's own registry implementations; builtin + * covers declaration files of the type world. */ +import { join } from "node:path"; +import { isPackageShimFile } from "../frontend/package-shims.js"; +import { trackedReadFile } from "../frontend/input-tracker.js"; + +export type Origin = "user" | "npm" | "shim" | "builtin"; + +export function originOf(file: string): Origin { + const path = file.split("\\").join("/"); + if (isPackageShimFile(path)) return "shim"; + if (/\.d\.[cm]?ts$/.test(path)) return "builtin"; + return path.includes("/node_modules/") ? "npm" : "user"; +} + +/** One installed package: the decision unit for npm code. pnpm keeps + * versions apart in different directories, so the directory identifies the + * instance. */ +export interface PackageInstance { + name: string; + /** The package directory (…/node_modules/). */ + dir: string; + version: string | null; +} + +/** The package instance of a file under node_modules: the LAST + * node_modules segment (nested installs belong to the innermost package), + * scope-aware. Null outside node_modules. */ +export function packageDirOf(file: string): { name: string; dir: string } | null { + const path = file.split("\\").join("/"); + const parts = path.split("/"); + const i = parts.lastIndexOf("node_modules"); + if (i < 0 || i + 1 >= parts.length - 1) return null; + const first = parts[i + 1]!; + if (first.startsWith("@")) { + if (i + 2 >= parts.length - 1) return null; + const name = `${first}/${parts[i + 2]!}`; + return { name, dir: parts.slice(0, i + 3).join("/") }; + } + return { name: first, dir: parts.slice(0, i + 2).join("/") }; +} + +/** The `version` field of a package directory's package.json. */ +export function packageVersionOf(dir: string): string | null { + let version: string | null = null; + const text = trackedReadFile(join(dir, "package.json")); + if (text !== null) { + try { + const parsed: unknown = JSON.parse(text); + if (parsed !== null && typeof parsed === "object") { + const v = (parsed as Record)["version"]; + if (typeof v === "string") version = v; + } + } catch { + version = null; + } + } + return version; +} + +export function packageInstanceOf(file: string): PackageInstance | null { + const at = packageDirOf(file); + if (at === null) return null; + return { name: at.name, dir: at.dir, version: packageVersionOf(at.dir) }; +} diff --git a/packages/compiler/src/node-islands/policy.ts b/packages/compiler/src/node-islands/policy.ts new file mode 100644 index 000000000..72dbf3cd0 --- /dev/null +++ b/packages/compiler/src/node-islands/policy.ts @@ -0,0 +1,167 @@ +/* The Node island policy (DESIGN.md §1.5–§1.6): user code strict, npm + * lenient. npm code outside the native subset becomes a Node island + * automatically (`nodeIslands.auto`, `--node-islands=auto`); user modules + * become islands only when marked (`nodeIslands.modules`, + * `--node-island=`); `"native"` (`--native=`) makes one + * dependency strict; `--node-islands=off` makes the whole program strict. + * + * This version is report-only: the policy decides what WOULD run in Node, + * and coverage reports it, but code generation does not change. */ +import { isAbsolute, join, resolve, dirname } from "node:path"; +import type { ProjectConfig } from "../project-config.js"; + +export type NodeIslandsMode = "auto" | "explicit" | "off"; +export const NODE_ISLANDS_MODES: readonly NodeIslandsMode[] = ["auto", "explicit", "off"]; + +/** The command-line half of the policy (`build`, `run` and `coverage` + * accept these). */ +export interface NodeIslandOptions { + /** `--node-islands=auto|explicit|off`. */ + mode?: NodeIslandsMode; + /** `--node-island=`: packages and user modules forced into a + * Node island. Paths are absolute. */ + islands?: readonly string[]; + /** `--native=`: packages that must compile natively. */ + native?: readonly string[]; + /** `--node=`: the node executable recorded for islands. */ + node?: string; + /** `--strict-user-js`: report R2 constructs in user JavaScript as + * SC8101–SC8105. */ + strictUserJs?: boolean; +} + +export type DecisionSource = "config" | "cli"; + +export interface UserModulePattern { + /** An absolute path or glob. */ + pattern: string; + source: DecisionSource; +} + +export interface NodeIslandPolicy { + mode: NodeIslandsMode; + /** Where the mode came from (an explicit `"auto": false` reads as + * "explicit"). */ + modeSource: DecisionSource | "default"; + forcedNode: Map; + forcedNative: Map; + /** `"unloaded"` packages: never loaded, never islands. */ + unloaded: Set; + userModules: UserModulePattern[]; + node: string | null; + strictUserJs: boolean; +} + +/** Whether a `--node-island` value names a module rather than a package. */ +export function isModuleIslandArgument(value: string): boolean { + return ( + value.startsWith(".") || + value.startsWith("/") || + /\.(?:[cm]?[jt]s|[jt]sx)$/.test(value) || + value.includes("*") + ); +} + +export function nodeIslandPolicy( + config: ProjectConfig, + options: NodeIslandOptions = {}, +): NodeIslandPolicy { + const forcedNode = new Map(); + const forcedNative = new Map(); + const unloaded = new Set(); + for (const [name, setting] of config.packages) { + if (setting.unloaded) unloaded.add(name); + else if (setting.run === "node") forcedNode.set(name, "config"); + else if (setting.run === "native") forcedNative.set(name, "config"); + } + const userModules: UserModulePattern[] = []; + const base = config.manifest === null ? process.cwd() : dirname(config.manifest); + for (const pattern of config.nodeIslands.modules) { + userModules.push({ + pattern: isAbsolute(pattern) ? pattern : join(base, pattern), + source: "config", + }); + } + for (const value of options.islands ?? []) { + if (isModuleIslandArgument(value)) { + userModules.push({ pattern: resolve(value), source: "cli" }); + } else { + forcedNative.delete(value); + forcedNode.set(value, "cli"); + } + } + for (const name of options.native ?? []) { + forcedNode.delete(name); + forcedNative.set(name, "cli"); + } + let mode: NodeIslandsMode = "auto"; + let modeSource: DecisionSource | "default" = "default"; + if (options.mode !== undefined) { + mode = options.mode; + modeSource = "cli"; + } else if (!config.nodeIslands.auto) { + mode = "explicit"; + modeSource = "config"; + } + return { + mode, + modeSource, + forcedNode, + forcedNative, + unloaded, + userModules, + node: options.node ?? config.nodeIslands.node, + strictUserJs: options.strictUserJs === true, + }; +} + +/** Whether a user module is marked to run in a Node island. */ +export function isMarkedUserModule(policy: NodeIslandPolicy, file: string): DecisionSource | null { + const path = file.split("\\").join("/"); + for (const entry of policy.userModules) { + if (globMatch(entry.pattern.split("\\").join("/"), path)) return entry.source; + } + return null; +} + +/** Glob matching over `/`-separated paths: `**` spans directories, `*` and + * `?` stay inside one segment. */ +export function globMatch(pattern: string, path: string): boolean { + return matchSegments(pattern.split("/"), 0, path.split("/"), 0); +} + +function matchSegments(pat: string[], pi: number, segs: string[], si: number): boolean { + if (pi === pat.length) return si === segs.length; + const p = pat[pi]!; + if (p === "**") { + for (let k = si; k <= segs.length; k++) if (matchSegments(pat, pi + 1, segs, k)) return true; + return false; + } + if (si === segs.length) return false; + return matchSegment(p, 0, segs[si]!, 0) && matchSegments(pat, pi + 1, segs, si + 1); +} + +function matchSegment(p: string, pi: number, s: string, si: number): boolean { + if (pi === p.length) return si === s.length; + const ch = p.charAt(pi); + if (ch === "*") { + for (let k = si; k <= s.length; k++) if (matchSegment(p, pi + 1, s, k)) return true; + return false; + } + if (si === s.length) return false; + if (ch === "?" || ch === s.charAt(si)) return matchSegment(p, pi + 1, s, si + 1); + return false; +} + +/** The policy's identity for caches: two analyses with the same identity + * decide the same islands. */ +export function nodeIslandOptionsKey(options: NodeIslandOptions | undefined): string { + if (options === undefined) return ""; + return [ + options.mode ?? "", + (options.islands ?? []).join(","), + (options.native ?? []).join(","), + options.node ?? "", + options.strictUserJs === true ? "strict-user-js" : "", + ].join("|"); +} diff --git a/packages/compiler/src/node-islands/report.test.ts b/packages/compiler/src/node-islands/report.test.ts new file mode 100644 index 000000000..d4221d31f --- /dev/null +++ b/packages/compiler/src/node-islands/report.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, test } from "vitest"; +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import { emptyProjectConfig, parseProjectConfig } from "../project-config.js"; +import type { NodeIslandMarker } from "./detect.js"; +import { + globMatch, + isMarkedUserModule, + nodeIslandPolicy, + type NodeIslandOptions, +} from "./policy.js"; +import { buildNodeIslandReport, type NpmOutcome } from "./report.js"; +import type { NodeIslandScan, PackageEdge, ScannedModule } from "./scan.js"; + +const NM = "/app/node_modules"; + +function mod(pkg: string | null, file: string, markers: NodeIslandMarker[] = []): ScannedModule { + return { + file, + origin: pkg === null ? "user" : "npm", + packageDir: pkg === null ? null : `${NM}/${pkg}`, + packageName: pkg, + markers, + dropped: false, + }; +} + +function marker(file: string, rule: NodeIslandMarker["rule"], reason: string): NodeIslandMarker { + return { rule, reason, construct: reason, loc: { file, start: 0, end: 1 } }; +} + +function edge(from: string | null, to: string): PackageEdge { + return { + fromDir: from === null ? null : `${NM}/${from}`, + fromFile: from === null ? "/app/main.ts" : `${NM}/${from}/index.js`, + to, + toDir: `${NM}/${to}`, + shim: false, + loc: { file: from === null ? "/app/main.ts" : `${NM}/${from}/index.js`, start: 0, end: 1 }, + }; +} + +function gapFence(pkg: string): ScrDiagnostic { + return { + code: "SC1090", + message: "a builtin function alias as an escaping value", + loc: { file: `${NM}/${pkg}/index.js`, start: 5, end: 9 }, + }; +} + +/** A program: main.ts imports a (constructor functions), b (a gap fence) + * and c (clean); a imports d (clean, only from a) and c. */ +function program(): { scan: NodeIslandScan; fences: ScrDiagnostic[] } { + return { + scan: { + modules: [ + mod(null, "/app/main.ts"), + mod("a", `${NM}/a/index.js`, [marker(`${NM}/a/index.js`, "R2.1", "constructor-function")]), + mod("b", `${NM}/b/index.js`), + mod("c", `${NM}/c/index.js`), + mod("d", `${NM}/d/index.js`), + ], + edges: [edge(null, "a"), edge(null, "b"), edge(null, "c"), edge("a", "d"), edge("a", "c")], + refusals: [], + }, + fences: [gapFence("b")], + }; +} + +function report( + options: NodeIslandOptions = {}, + npm: NpmOutcome[] = [], + config = emptyProjectConfig(), +) { + const { scan, fences } = program(); + return buildNodeIslandReport({ + entry: "/app/main.ts", + scan, + npm, + diagnostics: [], + fences, + policy: nodeIslandPolicy(config, options), + }); +} + +describe("decisions", () => { + test("auto: packages with reasons are islands, gap islands first; closure and sharing", () => { + const r = report(); + expect(r.islands.map((i) => [i.package, i.class, i.decision])).toEqual([ + ["b", "gap", "auto"], + ["a", "object-model", "auto"], + ]); + expect(r.islands[0]!.reason).toBe("SC1090: a builtin function alias as an escaping value"); + expect(r.islands[1]!.carries).toEqual(["d"]); + expect(r.packages.map((p) => [p.package, p.status, p.islands])).toEqual([ + ["c", "shared", ["a"]], + ["d", "inside", ["a"]], + ]); + expect(r.violations).toEqual([]); + }); + + test("a refused package is a packaging island, classified by its refusal", () => { + const r = report({}, [ + { package: "c", status: "refused", detail: "auto: its shipped JS looks minified" }, + ]); + const c = r.islands.find((i) => i.package === "c")!; + expect([c.class, c.reason, c.diagnostic]).toEqual([ + "packaging", + "SC2013: auto: its shipped JS looks minified", + "SC2013", + ]); + }); + + test("forced native turns reasons into SC8003; forced node islands a clean package", () => { + const r = report({ native: ["a"], islands: ["c"] }); + expect(r.islands.map((i) => [i.package, i.class, i.decision])).toEqual([ + ["b", "gap", "auto"], + ["c", "marked", "cli"], + ]); + expect(r.violations.map((v) => v.code)).toEqual(["SC8003"]); + expect(r.violations[0]!.message).toContain("'a' is marked native"); + }); + + test("off: no islands, every package outside the native subset is SC8002", () => { + const r = report({ mode: "off" }); + expect(r.islands).toEqual([]); + expect(r.violations.map((v) => [v.code, v.message.split(" (")[0]])).toEqual([ + ["SC8002", "npm package 'a' is outside the native subset"], + ["SC8002", "npm package 'b' is outside the native subset"], + ]); + }); + + test('explicit (or "auto": false): only marked islands', () => { + const config = parseProjectConfig( + "/app/package.json", + JSON.stringify({ scriptc: { nodeIslands: { auto: false }, packages: { b: "node" } } }), + ); + const r = report({}, [], config); + expect(r.policy).toBe("explicit"); + expect(r.islands.map((i) => [i.package, i.decision])).toEqual([["b", "config"]]); + expect(r.violations.map((v) => v.code)).toEqual(["SC8002"]); + }); + + test("command-line names the program does not import are reported", () => { + expect(report({ native: ["nope"] }).unknownPackages).toEqual(["nope"]); + }); + + test("unloaded packages are never islands", () => { + const config = parseProjectConfig( + "/app/package.json", + JSON.stringify({ scriptc: { packages: { a: "unloaded" } } }), + ); + expect(report({}, [], config).islands.map((i) => i.package)).toEqual(["b"]); + }); +}); + +describe("user modules", () => { + test("marked by glob in the config or by path on the command line", () => { + const config = parseProjectConfig( + "/app/package.json", + JSON.stringify({ scriptc: { nodeIslands: { modules: ["src/plugins/**/*.ts"] } } }), + ); + const policy = nodeIslandPolicy(config, { islands: ["/app/src/legacy.js"] }); + expect(isMarkedUserModule(policy, "/app/src/plugins/a/loader.ts")).toBe("config"); + expect(isMarkedUserModule(policy, "/app/src/legacy.js")).toBe("cli"); + expect(isMarkedUserModule(policy, "/app/src/main.ts")).toBe(null); + }); + + test("SC81xx for R2 markers in user JavaScript, only with --strict-user-js", () => { + const file = "/app/legacy.js"; + const scan: NodeIslandScan = { + modules: [ + mod(null, file, [ + marker(file, "R2.1", "constructor-function"), + marker(file, "R2.5", "builtin-write"), + marker(file, "R1.1", "code-from-string"), + ]), + ], + edges: [], + refusals: [], + }; + const build = (strictUserJs: boolean) => + buildNodeIslandReport({ + entry: file, + scan, + npm: [], + diagnostics: [], + fences: [], + policy: nodeIslandPolicy(emptyProjectConfig(), { strictUserJs }), + }); + expect(build(false).userJs).toEqual([]); + expect(build(true).userJs.map((d) => d.code)).toEqual(["SC8101", "SC8105"]); + }); +}); + +test("globMatch", () => { + expect(globMatch("/a/**/*.ts", "/a/b/c/d.ts")).toBe(true); + expect(globMatch("/a/**/*.ts", "/a/d.ts")).toBe(true); + expect(globMatch("/a/*.ts", "/a/b/d.ts")).toBe(false); + expect(globMatch("/a/?.js", "/a/x.js")).toBe(true); + expect(globMatch("/a/x.js", "/a/x.js")).toBe(true); +}); + +describe("reachability", () => { + const userEdge = (fromFile: string, to: string): PackageEdge => ({ + ...edge(null, to), + fromFile, + loc: { file: fromFile, start: 0, end: 1 }, + }); + const codeFromString = (pkg: string) => + mod(pkg, `${NM}/${pkg}/index.js`, [ + marker(`${NM}/${pkg}/index.js`, "R1.1", "code-from-string"), + ]); + const build = (scan: Omit, options: NodeIslandOptions = {}) => + buildNodeIslandReport({ + entry: "/app/main.ts", + scan: { ...scan, refusals: [] }, + npm: [], + diagnostics: [], + fences: [], + policy: nodeIslandPolicy(emptyProjectConfig(), options), + }); + + test("a marked user module owns the packages only it imports", () => { + const r = build( + { + modules: [ + mod(null, "/app/main.ts"), + mod(null, "/app/plugin.ts"), + mod("x", `${NM}/x/index.js`), + mod("y", `${NM}/y/index.js`), + ], + edges: [userEdge("/app/plugin.ts", "x"), userEdge("/app/plugin.ts", "y"), edge(null, "y")], + }, + { islands: ["/app/plugin.ts"] }, + ); + expect(r.packages.map((p) => [p.package, p.status, p.islands])).toEqual([ + ["x", "inside", ["/app/plugin.ts"]], + ["y", "shared", ["/app/plugin.ts"]], + ]); + }); + + test("an island imported by a native package lists that import site", () => { + const r = build({ + modules: [mod(null, "/app/main.ts"), mod("n", `${NM}/n/index.js`), codeFromString("i")], + edges: [edge(null, "n"), edge("n", "i")], + }); + expect(r.islands.map((i) => [i.package, i.importedFrom.map((l) => l.file)])).toEqual([ + ["i", [`${NM}/n/index.js`]], + ]); + }); + + test("the dependencies of a shared package are shared with the island", () => { + const r = build({ + modules: [ + mod(null, "/app/main.ts"), + codeFromString("a"), + mod("s", `${NM}/s/index.js`), + mod("d", `${NM}/d/index.js`), + ], + edges: [edge(null, "a"), edge(null, "s"), edge("a", "s"), edge("s", "d")], + }); + expect(r.packages.map((p) => [p.package, p.status, p.islands])).toEqual([ + ["d", "shared", ["a"]], + ["s", "shared", ["a"]], + ]); + }); + + test("with islands off, a marked user module is SC8002 and keeps the user JavaScript rules", () => { + const file = "/app/legacy.js"; + const r = build( + { modules: [mod(null, file, [marker(file, "R2.1", "constructor-function")])], edges: [] }, + { mode: "off", islands: [file], strictUserJs: true }, + ); + expect(r.userModules).toEqual([]); + expect(r.violations.map((v) => v.code)).toEqual(["SC8002"]); + expect(r.violations[0]!.message).toContain(`user module '${file}'`); + expect(r.userJs.map((d) => d.code)).toEqual(["SC8101"]); + }); +}); diff --git a/packages/compiler/src/node-islands/report.ts b/packages/compiler/src/node-islands/report.ts new file mode 100644 index 000000000..c82d0f664 --- /dev/null +++ b/packages/compiler/src/node-islands/report.ts @@ -0,0 +1,390 @@ +/* The Node island report: which packages and user modules would run in a + * Node island, and why (DESIGN.md §1.3–§1.7). Report-only: the decisions + * are computed from the detector and the policy, and nothing about the + * build changes. + * + * 1. Reasons per package instance: pre-scan markers in its modules, + * refusals and runtime fences the lowering produced in its modules, and + * the drop-and-reload refusal of a package that did not compile. + * 2. Decision per package: the policy (forced node or native, the mode) + * applied to its reasons. + * 3. Closure: a package imported only by island packages runs inside the + * island (it is Node's business); one reached from both sides is + * reported as shared (DESIGN.md §2.6 decides it later). */ +import { + nativePackageDiag, + nodeIslandsOffDiag, + nodeIslandsOffModuleDiag, + userJsObjectModelDiag, + type ScrDiagnostic, +} from "../diagnostics/diagnostic.js"; +import type { SrcLoc } from "../ir/ir.js"; +import { + classifyDiagnostic, + classifyMarker, + classifyRefusal, + isIslandReasonCode, +} from "./classify.js"; +import { + describe, + type IslandDecision, + type NativePackage, + type NodeIslandEntry, + type NodeIslandReason, + type NodeIslandReasonSource, + type NodeIslandReport, + type UserModuleIsland, +} from "./summary.js"; +import { packageDirOf, packageVersionOf } from "./origin.js"; +import { isMarkedUserModule, type NodeIslandPolicy } from "./policy.js"; +import { classRank, reportRank, ruleInfo } from "./rules.js"; +import type { NodeIslandScan, RefusalRecord } from "./scan.js"; + +/** One dependency's native compilation outcome (coverage/report.ts's + * NpmStaticStatus, restated to keep this module free of coverage). */ +export interface NpmOutcome { + package: string; + status: "static" | "refused"; + detail?: string; +} + +export interface NodeIslandReportInput { + entry: string; + scan: NodeIslandScan; + npm: readonly NpmOutcome[]; + /** Lowering diagnostics of the analysis (reached and unreached), runtime + * fences separately. */ + diagnostics: readonly ScrDiagnostic[]; + fences: readonly ScrDiagnostic[]; + policy: NodeIslandPolicy; +} + +/** Reasons kept per package (the report renders the first per module). */ +const REASON_CAP = 200; + +interface PackageState { + key: string; + name: string; + dir: string | null; + reasons: NodeIslandReason[]; + importers: Set; + /** Every import site, with its importer ("" native user code, + * "user:" a marked user module, else a package key). */ + sites: { from: string; loc: SrcLoc }[]; + island: boolean; + decision: IslandDecision; + carriedBy: string | null; + violation: boolean; +} + +export function buildNodeIslandReport(input: NodeIslandReportInput): NodeIslandReport { + const policy = input.policy; + const states = new Map(); + const byName = new Map(); + const state = (name: string, dir: string | null): PackageState => { + const key = dir ?? `name:${name}`; + let s = states.get(key); + if (s === undefined) { + s = { + key, + name, + dir, + reasons: [], + importers: new Set(), + sites: [], + island: false, + decision: "auto", + carriedBy: null, + violation: false, + }; + states.set(key, s); + const keys = byName.get(name) ?? []; + keys.push(key); + byName.set(name, keys); + } + return s; + }; + const addReason = (s: PackageState, r: NodeIslandReason): void => { + if (s.reasons.length < REASON_CAP) s.reasons.push(r); + }; + const ignored = (name: string): boolean => policy.unloaded.has(name); + + // Modules and their markers. + for (const m of input.scan.modules) { + if (m.origin !== "npm" || m.packageName === null) continue; + if (ignored(m.packageName)) continue; + const s = state(m.packageName, m.packageDir); + for (const marker of m.markers) addReason(s, classifyMarker(marker)); + } + // Import edges. A marked user module is an island owner of its own + // (unless islands are off, where it is a violation and stays native). + const USER = "user:"; + const userIsland = (file: string): boolean => + policy.mode !== "off" && isMarkedUserModule(policy, file) !== null; + const imports = new Map>(); + for (const e of input.scan.edges) { + if (e.shim || ignored(e.to)) continue; + const s = state(e.to, e.toDir); + const from = e.fromDir ?? (userIsland(e.fromFile) ? USER + e.fromFile : ""); + if (from === s.key) continue; + s.importers.add(from); + s.sites.push({ from, loc: e.loc }); + let out = imports.get(from); + if (out === undefined) imports.set(from, (out = new Set())); + out.add(s.key); + } + // Lowering refusals and fences inside npm modules. + const addDiagnostics = (diags: readonly ScrDiagnostic[], source: NodeIslandReasonSource) => { + for (const d of diags) { + if (!isIslandReasonCode(d.code)) continue; + const at = packageDirOf(d.loc.file); + if (at === null || ignored(at.name)) continue; + addReason(state(at.name, at.dir), classifyDiagnostic(d, source)); + } + }; + addDiagnostics(input.diagnostics, "refusal"); + addDiagnostics(input.fences, "fence"); + // Packages that did not compile. + const records = new Map(); + for (const r of input.scan.refusals) if (!records.has(r.package)) records.set(r.package, r); + for (const outcome of input.npm) { + if (outcome.status !== "refused" || ignored(outcome.package)) continue; + const reason = classifyRefusal( + outcome.detail ?? "its static compilation was refused", + records.get(outcome.package) ?? null, + ); + if (reason === null) continue; + const keys = byName.get(outcome.package); + if (keys === undefined || keys.length === 0) { + addReason(state(outcome.package, null), reason); + continue; + } + for (const key of keys) addReason(states.get(key)!, reason); + } + + // Decisions. + const violations: ScrDiagnostic[] = []; + const fallbackLoc: SrcLoc = { file: input.entry, start: 0, end: 0 }; + for (const s of states.values()) { + sortReasons(s.reasons); + const forcedNode = policy.forcedNode.get(s.name); + const forcedNative = policy.forcedNative.get(s.name); + const outside = s.reasons.length > 0; + const loc = (s.sites.find((x) => x.from === "") ?? s.sites[0])?.loc ?? fallbackLoc; + if (forcedNative !== undefined) { + if (outside) { + s.violation = true; + violations.push(nativePackageDiag(s.name, describe(s.reasons[0]!), loc)); + } + continue; + } + if (policy.mode === "off") { + if (outside || forcedNode !== undefined) { + s.violation = true; + violations.push( + nodeIslandsOffDiag( + s.name, + outside ? describe(s.reasons[0]!) : "it is marked to run in Node", + loc, + ), + ); + } + continue; + } + if (forcedNode !== undefined) { + s.island = true; + s.decision = forcedNode; + continue; + } + if (!outside) continue; + if (policy.mode === "explicit") { + s.violation = true; + violations.push(nodeIslandsOffDiag(s.name, describe(s.reasons[0]!), loc)); + continue; + } + s.island = true; + s.decision = "auto"; + } + + // The island that owns an importer: a marked user module, an island + // package, or the island a carried package runs inside; null for native. + const ownerOf = (from: string): string | null => { + if (from.startsWith(USER)) return from; + const importer = states.get(from); + return importer === undefined ? null : importer.island ? importer.key : importer.carriedBy; + }; + const ownerName = (owner: string): string => + owner.startsWith(USER) ? owner.slice(USER.length) : states.get(owner)!.name; + + // Closure: packages imported only by islands (or by packages inside + // them) run inside the island. + for (let changed = true; changed;) { + changed = false; + for (const s of states.values()) { + if (s.island || s.carriedBy !== null || s.violation) continue; + if (s.importers.size === 0 || s.importers.has("")) continue; + let carrier: string | null = null; + let all = true; + for (const from of s.importers) { + const owner = ownerOf(from); + if (owner === null) { + all = false; + break; + } + carrier ??= owner; + } + if (all && carrier !== null) { + s.carriedBy = carrier; + changed = true; + } + } + } + + // Transitive reach: every non-island package an island loads, through + // carried and shared packages alike (an island package is a boundary). + const reach = new Map>(); + const owners = [...imports.keys()].filter( + (k) => k.startsWith(USER) || states.get(k)?.island === true, + ); + for (const owner of owners) { + const name = ownerName(owner); + const queue = [...(imports.get(owner) ?? new Set())]; + const seen = new Set(queue); + while (queue.length > 0) { + const key = queue.pop()!; + if (states.get(key)?.island === true) continue; + let names = reach.get(key); + if (names === undefined) reach.set(key, (names = new Set())); + names.add(name); + for (const next of imports.get(key) ?? new Set()) { + if (!seen.has(next)) { + seen.add(next); + queue.push(next); + } + } + } + } + + // Entries. + const islands: NodeIslandEntry[] = []; + const packages: NativePackage[] = []; + for (const s of states.values()) { + const version = s.dir === null ? null : packageVersionOf(s.dir); + if (s.island) { + const first = s.reasons[0]; + const modules = new Set(); + for (const r of s.reasons) if (r.loc !== null) modules.add(r.loc.file); + const carries: string[] = []; + for (const other of states.values()) { + if (other.carriedBy === s.key) carries.push(other.name); + } + carries.sort(); + islands.push({ + package: s.name, + version, + path: s.dir, + class: first === undefined ? "marked" : first.class, + reason: first === undefined ? markedReason(s.decision) : describe(first), + diagnostic: first === undefined ? null : first.code, + decision: s.decision, + reasons: s.reasons, + modules: modules.size, + carries, + importedFrom: s.sites.filter((x) => ownerOf(x.from) === null).map((x) => x.loc), + evaluation: "eager", + }); + continue; + } + if (s.violation) continue; + // A package that resolves to no installed JavaScript never loads (an + // optional dependency that is absent): nothing to compile or island. + if (s.dir === null && s.reasons.length === 0) continue; + const reachingIslands = [...(reach.get(s.key) ?? new Set())].sort(); + packages.push({ + package: s.name, + version, + path: s.dir, + status: s.carriedBy !== null ? "inside" : reachingIslands.length > 0 ? "shared" : "native", + islands: s.carriedBy !== null ? [ownerName(s.carriedBy)] : reachingIslands, + }); + } + islands.sort( + (a, b) => + reportRank(a.class) - reportRank(b.class) || + compareText(a.package, b.package) || + compareText(a.path ?? "", b.path ?? ""), + ); + packages.sort( + (a, b) => compareText(a.package, b.package) || compareText(a.path ?? "", b.path ?? ""), + ); + + // Marked user modules and SC81xx in user JavaScript. + const userModules: UserModuleIsland[] = []; + const userJs: ScrDiagnostic[] = []; + for (const m of input.scan.modules) { + if (m.origin !== "user") continue; + const marked = isMarkedUserModule(policy, m.file); + if (marked !== null && policy.mode !== "off") { + userModules.push({ file: m.file, source: marked }); + continue; + } + if (marked !== null) { + violations.push(nodeIslandsOffModuleDiag(m.file, marked, { file: m.file, start: 0, end: 0 })); + } + if (!policy.strictUserJs) continue; + for (const marker of m.markers) { + const code = ruleInfo(marker.rule).userJsCode; + if (code === null) continue; + userJs.push(userJsObjectModelDiag(code, capitalize(marker.construct), marker.loc)); + } + } + userModules.sort((a, b) => compareText(a.file, b.file)); + + const unknownPackages: string[] = []; + // Only command-line names: a project's configuration may list packages + // for other entries. + for (const forced of [policy.forcedNode, policy.forcedNative]) { + for (const [name, source] of forced) { + if (source === "cli" && !byName.has(name) && !unknownPackages.includes(name)) + unknownPackages.push(name); + } + } + unknownPackages.sort(); + + return { + mode: "would-be", + policy: policy.mode, + islands, + packages, + userModules, + violations, + userJs, + unknownPackages, + }; +} + +function compareText(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function capitalize(text: string): string { + return text.length === 0 ? text : text.charAt(0).toUpperCase() + text.slice(1); +} + +function markedReason(decision: IslandDecision): string { + return decision === "cli" ? "marked with --node-island" : 'marked "node" in scriptc.packages'; +} + +/** Decisive class first; within a class, refusals before pre-scan markers + * (the lowering is authoritative), then source order. */ +function sortReasons(reasons: NodeIslandReason[]): void { + const sourceRank = (r: NodeIslandReason): number => + r.source === "package" ? 0 : r.source === "refusal" ? 1 : r.source === "fence" ? 2 : 3; + reasons.sort( + (a, b) => + classRank(a.class) - classRank(b.class) || + sourceRank(a) - sourceRank(b) || + compareText(a.loc?.file ?? "", b.loc?.file ?? "") || + (a.loc?.start ?? 0) - (b.loc?.start ?? 0), + ); +} diff --git a/packages/compiler/src/node-islands/rules.ts b/packages/compiler/src/node-islands/rules.ts new file mode 100644 index 000000000..25f686314 --- /dev/null +++ b/packages/compiler/src/node-islands/rules.ts @@ -0,0 +1,109 @@ +/* The refusal rules of scope B (results/design-scope-b/DESIGN.md §1.2): + * the constructs that put a module outside the native subset, grouped in + * eleven rule families, and the four classes the detector reports. + * + * runtime (R1) needs the JavaScript runtime itself + * object-model (R2) needs a mutable object model + * gap inside the native subset, but scriptc cannot compile it yet + * packaging not compilable as shipped (no declarations, minified, ...) + * + * A reason id names one construct; refusal sites carry it on their + * diagnostic (ScrDiagnostic.reason) and the pre-scan (detect.ts) reports it + * with the rule. */ + +export type NodeIslandClass = "runtime" | "object-model" | "gap" | "packaging" | "marked"; + +export type NodeIslandRule = + | "R1.1" + | "R1.2" + | "R1.3" + | "R1.4" + | "R1.5" + | "R1.6" + | "R2.1" + | "R2.2" + | "R2.3" + | "R2.4" + | "R2.5"; + +export interface RuleInfo { + rule: NodeIslandRule; + title: string; + /** The SC81xx code the construct is in user JavaScript (R2 only). */ + userJsCode: "SC8101" | "SC8102" | "SC8103" | "SC8104" | "SC8105" | null; +} + +const RULES: RuleInfo[] = [ + { rule: "R1.1", title: "code from strings", userJsCode: null }, + { rule: "R1.2", title: "dynamic module loading", userJsCode: null }, + { rule: "R1.3", title: "native addons", userJsCode: null }, + { rule: "R1.4", title: "loader and require hooks", userJsCode: null }, + { rule: "R1.5", title: "engine internals", userJsCode: null }, + { rule: "R1.6", title: "runtime-only globals", userJsCode: null }, + { rule: "R2.1", title: "constructor functions", userJsCode: "SC8101" }, + { rule: "R2.2", title: "prototype mutation", userJsCode: "SC8102" }, + { rule: "R2.3", title: "shape mutation outside the checker's view", userJsCode: "SC8103" }, + { rule: "R2.4", title: "sloppy-mode semantics", userJsCode: "SC8104" }, + { rule: "R2.5", title: "builtin and global patching", userJsCode: "SC8105" }, +]; + +export function ruleInfo(rule: NodeIslandRule): RuleInfo { + for (const info of RULES) if (info.rule === rule) return info; + throw new Error(`unknown Node island rule ${rule}`); +} + +/** Reason id → rule. Ids without a rule (gap, packaging) are absent. */ +const REASON_RULES: Record = { + "code-from-string": "R1.1", + vm: "R1.1", + "dynamic-require": "R1.2", + "dynamic-import": "R1.2", + "unresolved-import": "R1.2", + "native-addon": "R1.3", + "loader-hook": "R1.4", + "engine-internal": "R1.5", + "weak-ref": "R1.6", + "constructor-function": "R2.1", + "prototype-write": "R2.2", + "define-property": "R2.3", + with: "R2.4", + "sloppy-arguments": "R2.4", + "sloppy-mode": "R2.4", + "builtin-write": "R2.5", + proxy: "R2.5", +}; + +export function ruleOfReason(reason: string): NodeIslandRule | null { + return REASON_RULES[reason] ?? null; +} + +export function classOfRule(rule: NodeIslandRule): NodeIslandClass { + return rule.startsWith("R1.") ? "runtime" : "object-model"; +} + +/** The decisive class first: a runtime need outranks an object-model need, + * which outranks shipping problems; a gap is a package's class only when + * nothing else keeps it out of the native subset (fixing the gap would make + * it native). */ +export function classRank(cls: NodeIslandClass): number { + if (cls === "runtime") return 0; + if (cls === "object-model") return 1; + if (cls === "packaging") return 2; + if (cls === "gap") return 3; + return 4; +} + +/** Report order: gap islands first (compiler work hidden behind an + * island), then the rest by rank. */ +export function reportRank(cls: NodeIslandClass): number { + if (cls === "gap") return 0; + return classRank(cls) + 1; +} + +export function classDescription(cls: NodeIslandClass): string { + if (cls === "runtime") return "needs the JavaScript runtime"; + if (cls === "object-model") return "needs a mutable object model"; + if (cls === "gap") return "inside the native subset, but scriptc cannot compile it yet"; + if (cls === "packaging") return "not compilable as shipped"; + return "marked to run in Node"; +} diff --git a/packages/compiler/src/node-islands/scan.ts b/packages/compiler/src/node-islands/scan.ts new file mode 100644 index 000000000..0ac082e73 --- /dev/null +++ b/packages/compiler/src/node-islands/scan.ts @@ -0,0 +1,155 @@ +/* The program-level half of the detector: which modules a program loads, + * the pre-scan markers in each, and the package import edges the island + * closure needs. The frontend calls it on its final program (and on the + * program that still held a package the drop-and-reload loop removed). */ +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import type { SrcLoc } from "../ir/ir.js"; +import * as ts from "../frontend/ts7/adapter.js"; +import { canonicalBuiltinModule, npmBuiltinAliasOf } from "../frontend/builtin-modules.js"; +import { erasedTypeOnlyImport } from "../frontend/type-only-imports.js"; +import { isShimmedImport } from "../frontend/package-shims.js"; +import { resolveBareModule } from "../frontend/resolve.js"; +import { isJsSourceFileName } from "../frontend/tsc-codes.js"; +import { isRelativeSpecifier, packageNameOfSpecifier } from "../frontend/workspace-registry.js"; +import { moduleSourceCandidates } from "../frontend/module-source-candidates.js"; +import { scanModule, type NodeIslandMarker } from "./detect.js"; +import { originOf, packageDirOf, type Origin } from "./origin.js"; + +export interface ScannedModule { + file: string; + origin: Origin; + /** The npm package directory, for npm modules. */ + packageDir: string | null; + packageName: string | null; + markers: NodeIslandMarker[]; + /** True for a module of a package the drop-and-reload loop removed. */ + dropped: boolean; +} + +/** One import of an npm package. */ +export interface PackageEdge { + /** The importing package's directory; null for user code. */ + fromDir: string | null; + fromFile: string; + /** The imported package's name. */ + to: string; + /** Its directory when the import resolves to installed JavaScript. */ + toDir: string | null; + /** Served by a registry shim, a builtin alias, or marked unloaded. */ + shim: boolean; + loc: SrcLoc; +} + +/** How the drop-and-reload loop refused a package. */ +export type RefusalKind = "preflight" | "offender" | "degrade" | "typecheck" | "external"; + +export interface RefusalRecord { + package: string; + kind: RefusalKind; + /** The package-anchored preflight diagnostic, for kind "preflight". */ + diag: ScrDiagnostic | null; +} + +export interface NodeIslandScan { + modules: ScannedModule[]; + edges: PackageEdge[]; + refusals: RefusalRecord[]; +} + +export function emptyScan(): NodeIslandScan { + return { modules: [], edges: [], refusals: [] }; +} + +function locOfNode(node: ts.Node): SrcLoc { + const sf = ts.getSourceFileOfNode(node); + return { file: sf.fileName, start: ts.tokenStartOfNode(node), end: node.end }; +} + +/** The literal module specifiers of a file with their sites: top-level + * import and export declarations, and the require() and import() calls the + * program's shared module-source scan already collected (no second walk). */ +function specifiersOf(program: ts.Program, sf: ts.SourceFile): { spec: string; node: ts.Node }[] { + const out: { spec: string; node: ts.Node }[] = []; + for (const node of sf.statements.nodes) { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier !== undefined && + ts.isStringLiteral(node.moduleSpecifier) + ) { + // Type-only imports load nothing. + const typeOnly = ts.isImportDeclaration(node) ? erasedTypeOnlyImport(node) : node.isTypeOnly; + if (!typeOnly) out.push({ spec: node.moduleSpecifier.text, node }); + } + } + for (const call of moduleSourceCandidates(program, sf).calls) { + const arg = call.arguments.nodes[0]; + if ( + arg !== undefined && + ts.isStringLiteralLike(arg) && + (call.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(call.expression) && call.expression.text === "require")) + ) + out.push({ spec: arg.text, node: call }); + } + return out; +} + +/** Scans `files`: markers for npm JavaScript (and for user JavaScript when + * `userJs`), and package edges for every file when `edges` (a program with + * no npm package has none to find). */ +export function scanFiles( + program: ts.Program, + files: readonly ts.SourceFile[], + opts: { userJs: boolean; dropped: boolean; edges: boolean }, +): { modules: ScannedModule[]; edges: PackageEdge[] } { + const modules: ScannedModule[] = []; + const edges: PackageEdge[] = []; + const seen = new Set(); + for (const sf of files) { + if (seen.has(sf.fileName)) continue; + seen.add(sf.fileName); + const origin = originOf(sf.fileName); + if (origin === "builtin" || origin === "shim") continue; + const at = origin === "npm" ? packageDirOf(sf.fileName) : null; + const js = isJsSourceFileName(sf.fileName); + const markers = js && (origin === "npm" || opts.userJs) ? scanModule(sf) : []; + modules.push({ + file: sf.fileName, + origin, + packageDir: at === null ? null : at.dir, + packageName: at === null ? null : at.name, + markers, + dropped: opts.dropped, + }); + if (!opts.edges) continue; + for (const { spec, node } of specifiersOf(program, sf)) { + if (isRelativeSpecifier(spec) || spec.startsWith("/") || spec.startsWith("#")) continue; + // scriptc's own packages lower at their use sites. + if (spec.startsWith("@scriptc/")) continue; + const alias = npmBuiltinAliasOf(spec); + if (alias === null && (spec.startsWith("node:") || canonicalBuiltinModule(spec) !== null)) + continue; + const name = packageNameOfSpecifier(spec); + const shim = alias !== null || isShimmedImport(sf.fileName, spec); + let toDir: string | null = null; + if (!shim) { + const resolved = resolveBareModule(sf.fileName, spec, "js-only"); + if (resolved !== null) { + const target = packageDirOf(resolved.typesFile); + // A workspace package compiled from source is user code. + if (target === null) continue; + toDir = target.dir; + } + } + edges.push({ + fromDir: at === null ? null : at.dir, + fromFile: sf.fileName, + to: name, + toDir, + shim, + loc: locOfNode(node), + }); + } + } + return { modules, edges }; +} diff --git a/packages/compiler/src/node-islands/summary.ts b/packages/compiler/src/node-islands/summary.ts new file mode 100644 index 000000000..8108d985c --- /dev/null +++ b/packages/compiler/src/node-islands/summary.ts @@ -0,0 +1,110 @@ +/* The Node island report's data model and the small helpers coverage + * rendering needs. Kept free of compiler imports: the coverage report (and + * the CLI's verdict-replay fast path behind it) loads this module. */ +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import type { SrcLoc } from "../ir/ir.js"; +import type { DecisionSource, NodeIslandPolicy } from "./policy.js"; +import type { NodeIslandClass, NodeIslandRule } from "./rules.js"; + +export type NodeIslandReasonSource = "pre-scan" | "refusal" | "fence" | "package"; + +export interface NodeIslandReason { + class: NodeIslandClass; + rule: NodeIslandRule | null; + /** Reason id: a rules.ts id, "gap" or "packaging". */ + reason: string; + /** The SC code of the refusal, when the reason is a diagnostic. */ + code: string | null; + message: string; + loc: SrcLoc | null; + source: NodeIslandReasonSource; +} + +export type IslandDecision = "auto" | "config" | "cli"; + +export interface NodeIslandEntry { + package: string; + version: string | null; + /** The package directory (the instance), when it is installed. */ + path: string | null; + class: NodeIslandClass; + /** The first decisive reason, rendered. */ + reason: string; + /** The SC code of the first decisive reason, when it is a refusal. */ + diagnostic: string | null; + decision: IslandDecision; + /** Every reason, in report order (decisive class first, then source + * order). */ + reasons: NodeIslandReason[]; + /** Modules of the package with at least one reason. */ + modules: number; + /** Packages imported only through this island, which run inside it. */ + carries: string[]; + /** Where native code imports the package: the island boundary. */ + importedFrom: SrcLoc[]; + /** Report-only: deferral (NI-7) is not decided yet, so every island + * would evaluate eagerly at its module position. */ + evaluation: "eager"; +} + +export interface UserModuleIsland { + file: string; + source: DecisionSource; +} + +export interface NativePackage { + package: string; + version: string | null; + path: string | null; + /** "native": compiles natively; "inside": runs inside an island's + * closure; "shared": reached from native code and from an island. */ + status: "native" | "inside" | "shared"; + /** For inside and shared: the islands that reach it. */ + islands: string[]; +} + +export interface NodeIslandReport { + mode: "would-be"; + policy: NodeIslandPolicy["mode"]; + islands: NodeIslandEntry[]; + packages: NativePackage[]; + userModules: UserModuleIsland[]; + /** Would-be errors: SC8002 (islands off) and SC8003 (marked native). */ + violations: ScrDiagnostic[]; + /** SC8101–SC8105 for user JavaScript (only with --strict-user-js). */ + userJs: ScrDiagnostic[]; + /** --native / --node-island / config package names the program does + * not import. */ + unknownPackages: string[]; +} + +/** "R2.1 constructor functions: 'new Foo()' of a function" or + * "SC2012: ". */ +export function describe(r: NodeIslandReason): string { + if (r.source === "pre-scan" && r.rule !== null) return `${r.rule} ${r.message}`; + const rule = r.rule === null ? "" : `${r.rule} `; + return r.code === null ? `${rule}${r.message}` : `${rule}${r.code}: ${r.message}`; +} + +/** True when the report has decisions to show (islands, marked modules, + * policy errors or unknown names); SC81xx findings alone render as + * blockers. */ +export function hasNodeIslandDecisions(report: NodeIslandReport): boolean { + return ( + report.islands.length > 0 || + report.userModules.length > 0 || + report.violations.length > 0 || + report.unknownPackages.length > 0 + ); +} + +/** True when the report has anything to show. */ +export function hasNodeIslandFindings(report: NodeIslandReport): boolean { + return ( + report.islands.length > 0 || + report.userModules.length > 0 || + report.violations.length > 0 || + report.userJs.length > 0 || + report.unknownPackages.length > 0 + ); +} diff --git a/packages/compiler/src/project-config.test.ts b/packages/compiler/src/project-config.test.ts new file mode 100644 index 000000000..c29c65081 --- /dev/null +++ b/packages/compiler/src/project-config.test.ts @@ -0,0 +1,185 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { parseProjectConfig, readProjectConfig } from "./project-config.js"; + +const manifest = "/project/package.json"; + +function config(scriptc: unknown) { + return parseProjectConfig(manifest, JSON.stringify({ name: "app", scriptc }, null, 2)); +} + +test("a package.json without a scriptc key has the default configuration", () => { + const c = parseProjectConfig(manifest, '{ "name": "app" }'); + expect(c.diagnostics).toEqual([]); + expect(c.packages.size).toBe(0); + expect(c.nodeIslands).toEqual({ + auto: true, + modules: [], + node: null, + nodeVersion: null, + packaging: "embedded", + speculativeStart: false, + }); + expect(parseProjectConfig(manifest, "{ not json").diagnostics).toEqual([]); +}); + +test("readProjectConfig reads the nearest package.json like parseProjectConfig", () => { + const root = mkdtempSync(join(tmpdir(), "scriptc-project-config-")); + try { + const manifests = [ + '{ "name": "app", "type": "module" }', + '{ "name": "app", "\\u0073criptc": { "packages": { "encoding": "unloaded" } } }', + '{ "name": "app", "scriptc": { "packages": { "encoding": "unloaded", "x": "bogus" } } }', + ]; + manifests.forEach((text, i) => { + const dir = join(root, `p${i}`, "src"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(root, `p${i}`, "package.json"), text); + const read = readProjectConfig(join(dir, "main.ts")); + const parsed = parseProjectConfig(join(root, `p${i}`, "package.json"), text); + expect(read.manifest).toBe(parsed.manifest); + expect(read.manifestText).toBe(text); + expect([...read.packages]).toEqual([...parsed.packages]); + expect(read.nodeIslands).toEqual(parsed.nodeIslands); + expect(read.diagnostics).toEqual(parsed.diagnostics); + }); + expect( + readProjectConfig(join(root, "p1", "src", "main.ts")).packages.get("encoding")?.unloaded, + ).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("package settings: strings and objects", () => { + const c = config({ + packages: { + encoding: "unloaded", + "dd-trace": "node", + "hot-shots": "native", + semver: "auto", + "proper-lockfile": { run: "node", evaluate: "lazy", share: "duplicate" }, + }, + }); + expect(c.diagnostics).toEqual([]); + expect(c.packages.get("encoding")?.unloaded).toBe(true); + expect(c.packages.get("dd-trace")?.run).toBe("node"); + expect(c.packages.get("hot-shots")?.run).toBe("native"); + expect(c.packages.get("semver")?.run).toBe("auto"); + expect(c.packages.get("proper-lockfile")).toEqual({ + unloaded: false, + shapes: true, + run: "node", + evaluate: "lazy", + share: "duplicate", + marshal: "copy", + schedule: "lockstep", + }); +}); + +test("shapes: false turns off the shape overlay for one package", () => { + const c = config({ + packages: { + zod: { shapes: false }, + effect: { shapes: true, run: "native" }, + ms: { shapes: "no" }, + }, + }); + expect(c.packages.get("zod")?.shapes).toBe(false); + expect(c.packages.get("zod")?.run).toBe("auto"); + expect(c.packages.get("effect")?.shapes).toBe(true); + expect(c.packages.get("effect")?.run).toBe("native"); + expect(c.packages.has("ms")).toBe(false); + expect(c.diagnostics.map((d) => d.message)).toEqual([ + `package.json 'scriptc.packages.ms.shapes' must be true or false, not "no"`, + ]); +}); + +test("nodeIslands settings", () => { + const c = config({ + nodeIslands: { + auto: false, + modules: ["src/plugins/*.ts"], + node: "/usr/local/bin/node", + nodeVersion: ">=24.13.1", + packaging: "beside", + speculativeStart: true, + }, + wasm: ["build/a.wasm", 3], + }); + expect(c.diagnostics).toEqual([]); + expect(c.nodeIslands).toEqual({ + auto: false, + modules: ["src/plugins/*.ts"], + node: "/usr/local/bin/node", + nodeVersion: ">=24.13.1", + packaging: "beside", + speculativeStart: true, + }); + // scriptc.wasm keeps its lenient reading. + expect(c.wasm).toEqual(["/project/build/a.wasm"]); +}); + +test("unknown keys and values are SC8004, located at the key", () => { + const text = JSON.stringify( + { + name: "app", + scriptc: { + packages: { + encoding: "unloded", + debug: { run: "node", evaluate: "later", color: true }, + ms: 3, + }, + nodeIslands: { auto: "yes", mode: "auto", modules: "src/x.ts", packaging: "zip" }, + }, + }, + null, + 2, + ); + const c = parseProjectConfig(manifest, text); + expect(c.diagnostics.map((d) => [d.code, d.message])).toEqual([ + ["SC8004", `package.json 'scriptc.packages.encoding' has the unknown value "unloded"`], + ["SC8004", `package.json 'scriptc.packages.debug.evaluate' has the unknown value "later"`], + ["SC8004", "package.json 'scriptc.packages.debug.color' is not a known package setting"], + ["SC8004", "package.json 'scriptc.packages.ms' must be a string or an object, not 3"], + ["SC8004", `package.json 'scriptc.nodeIslands.auto' must be true or false, not "yes"`], + ["SC8004", "package.json 'scriptc.nodeIslands.mode' is not a known Node island setting"], + [ + "SC8004", + `package.json 'scriptc.nodeIslands.modules' must be an array of module paths or globs, not "src/x.ts"`, + ], + ["SC8004", `package.json 'scriptc.nodeIslands.packaging' has the unknown value "zip"`], + ]); + // Locations point at the offending key inside the scriptc object. + const at = (i: number) => text.slice(c.diagnostics[i]!.loc.start).split(":")[0]; + expect(at(0)).toBe('"encoding"'); + expect(at(1)).toBe('"evaluate"'); + expect(at(5)).toBe('"mode"'); + for (const d of c.diagnostics) expect(d.loc.file).toBe(manifest); + // Invalid entries are not applied; valid siblings are. + expect(c.packages.has("encoding")).toBe(false); + expect(c.packages.has("debug")).toBe(false); + expect(c.nodeIslands.auto).toBe(true); +}); + +test("a non-object packages or nodeIslands value is SC8004", () => { + expect(config({ packages: ["a"] }).diagnostics.map((d) => d.message)).toEqual([ + "package.json 'scriptc.packages' must be an object mapping package names to settings, not an array", + ]); + expect(config({ nodeIslands: true }).diagnostics.map((d) => d.message)).toEqual([ + "package.json 'scriptc.nodeIslands' must be an object, not true", + ]); + // Other keys of the scriptc object belong to other readers. + expect(config({ somethingElse: 1 }).diagnostics).toEqual([]); +}); + +test("package setting keys named like Object.prototype members are unknown settings", () => { + const c = config({ packages: { debug: { constructor: "node", toString: "node" } } }); + expect(c.diagnostics.map((d) => [d.code, d.message])).toEqual([ + ["SC8004", "package.json 'scriptc.packages.debug.constructor' is not a known package setting"], + ["SC8004", "package.json 'scriptc.packages.debug.toString' is not a known package setting"], + ]); + expect(c.packages.has("debug")).toBe(false); +}); diff --git a/packages/compiler/src/project-config.ts b/packages/compiler/src/project-config.ts new file mode 100644 index 000000000..b7d4e00d7 --- /dev/null +++ b/packages/compiler/src/project-config.ts @@ -0,0 +1,449 @@ +/* The project configuration: the `scriptc` key of the entry's nearest + * package.json. One reader for every consumer (package shims, the + * WebAssembly scanner, the Node island policy), with a schema: a value + * this compiler does not know in `scriptc.packages` or + * `scriptc.nodeIslands` is SC8004 instead of being ignored, because an + * ignored setting is a silent divergence from what the project asked for. + * + * "scriptc": { + * "packages": { + * "encoding": "unloaded", // never loaded (package-shims.ts) + * "dd-trace": "node", // run in a Node island + * "hot-shots": "native", // must compile natively (SC8003) + * "proper-lockfile": { "run": "node", "evaluate": "lazy" }, + * "zod": { "shapes": false } // no shape-inference overlay + * }, + * "nodeIslands": { "auto": true, "modules": ["src/plugins/*.ts"] }, + * "wasm": ["build/module.wasm"] + * } + * + * Node islands are report-only in this version: the policy decides which + * packages and modules WOULD run in Node, and coverage reports it, but code + * generation does not change. Other keys of the `scriptc` object are left + * to their own readers. */ +import { dirname, join, resolve } from "node:path"; +import { projectConfigDiag, type ScrDiagnostic } from "./diagnostics/diagnostic.js"; +import { trackedReadFile } from "./frontend/input-tracker.js"; + +/** How a package runs: compiled natively, in a Node island, or decided by + * the detector. */ +export type PackageRun = "native" | "node" | "auto"; +export type PackageEvaluate = "auto" | "lazy" | "eager"; +export type PackageShare = "auto" | "shared" | "duplicate"; +export type PackageMarshal = "copy" | "handles"; +export type PackageSchedule = "lockstep" | "free"; + +/** One `scriptc.packages.` entry. `unloaded` packages never load; + * `shapes: false` compiles the package without shape inference's typed + * overlay (shape-inference/overlay.ts); the other settings choose where the + * package runs. */ +export interface PackageSetting { + unloaded: boolean; + shapes: boolean; + run: PackageRun; + evaluate: PackageEvaluate; + share: PackageShare; + marshal: PackageMarshal; + schedule: PackageSchedule; +} + +export interface NodeIslandsSetting { + /** Automatic Node islands for npm code outside the native subset. */ + auto: boolean; + /** User modules (paths or globs relative to the package.json) that run + * in Node. */ + modules: string[]; + /** The `node` executable recorded into the binary. */ + node: string | null; + /** A semver range checked at the helper handshake. */ + nodeVersion: string | null; + packaging: "embedded" | "beside"; + speculativeStart: boolean; +} + +export interface ProjectConfig { + /** The package.json the configuration came from, or null. */ + manifest: string | null; + /** Its text, for diagnostic code frames. */ + manifestText: string | null; + packages: Map; + nodeIslands: NodeIslandsSetting; + /** `scriptc.wasm`: module paths, resolved against the manifest. */ + wasm: string[]; + /** SC8004 for every unknown key or value. */ + diagnostics: ScrDiagnostic[]; +} + +const PACKAGE_STRINGS = ["unloaded", "node", "native", "auto"]; +const PACKAGE_OBJECT_KEYS: Record = { + run: ["native", "node", "auto"], + evaluate: ["auto", "lazy", "eager"], + share: ["auto", "shared", "duplicate"], + marshal: ["copy", "handles"], + schedule: ["lockstep", "free"], +}; +/** Every key of a package setting object: the string-valued ones above and + * the boolean `shapes`. */ +const PACKAGE_SETTING_KEYS = [...Object.keys(PACKAGE_OBJECT_KEYS), "shapes"]; +const NODE_ISLANDS_KEYS = [ + "auto", + "modules", + "node", + "nodeVersion", + "packaging", + "speculativeStart", +]; + +function defaultPackageSetting(): PackageSetting { + return { + unloaded: false, + shapes: true, + run: "auto", + evaluate: "auto", + share: "auto", + marshal: "copy", + schedule: "lockstep", + }; +} + +function defaultNodeIslands(): NodeIslandsSetting { + return { + auto: true, + modules: [], + node: null, + nodeVersion: null, + packaging: "embedded", + speculativeStart: false, + }; +} + +/** The empty configuration of a project without a package.json. */ +export function emptyProjectConfig(): ProjectConfig { + return { + manifest: null, + manifestText: null, + packages: new Map(), + nodeIslands: defaultNodeIslands(), + wasm: [], + diagnostics: [], + }; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function quoted(values: string[]): string { + return values.map((v) => `"${v}"`).join(", "); +} + +/** A short rendering of a JSON value for a message. */ +function shown(value: unknown): string { + if (typeof value === "string") return `"${value}"`; + if (value === null) return "null"; + if (Array.isArray(value)) return "an array"; + if (typeof value === "object") return "an object"; + return String(value); +} + +/** Reads and validates one package.json text. Exported for tests. */ +export function parseProjectConfig(manifest: string, text: string): ProjectConfig { + const config = emptyProjectConfig(); + config.manifest = manifest; + config.manifestText = text; + let parsed: unknown = null; + try { + parsed = JSON.parse(text); + } catch { + // Node reports a malformed package.json itself; the configuration is + // simply absent here, as before. + return config; + } + if (!isRecord(parsed)) return config; + const root = parsed["scriptc"]; + if (!isRecord(root)) return config; + const locate = new JsonLocator(text); + const problem = (path: string[], detail: string, hint: string): void => { + config.diagnostics.push( + projectConfigDiag(`scriptc.${path.join(".")}`, detail, hint, { + file: manifest, + start: locate.offsetOf(["scriptc", ...path]), + end: locate.offsetOf(["scriptc", ...path]), + }), + ); + }; + + const packages = root["packages"]; + if (packages !== undefined) { + if (!isRecord(packages)) { + problem( + ["packages"], + `must be an object mapping package names to settings, not ${shown(packages)}`, + `write "packages": { "": "auto" }`, + ); + } else { + for (const name of Object.keys(packages)) { + const value = packages[name]; + const setting = defaultPackageSetting(); + if (typeof value === "string") { + if (!PACKAGE_STRINGS.includes(value)) { + problem( + ["packages", name], + `has the unknown value ${shown(value)}`, + `use one of ${quoted(PACKAGE_STRINGS)}, or an object with ${PACKAGE_SETTING_KEYS.join(", ")}`, + ); + continue; + } + if (value === "unloaded") setting.unloaded = true; + else setting.run = value as PackageRun; + config.packages.set(name, setting); + continue; + } + if (!isRecord(value)) { + problem( + ["packages", name], + `must be a string or an object, not ${shown(value)}`, + `use one of ${quoted(PACKAGE_STRINGS)}, or an object with ${PACKAGE_SETTING_KEYS.join(", ")}`, + ); + continue; + } + let valid = true; + for (const key of Object.keys(value)) { + if (key === "shapes") { + const v = value[key]; + if (typeof v !== "boolean") { + problem( + ["packages", name, key], + `must be true or false, not ${shown(v)}`, + "use a boolean", + ); + valid = false; + } else setting.shapes = v; + continue; + } + const allowed = Object.hasOwn(PACKAGE_OBJECT_KEYS, key) + ? PACKAGE_OBJECT_KEYS[key] + : undefined; + if (allowed === undefined) { + problem( + ["packages", name, key], + "is not a known package setting", + `the settings are ${PACKAGE_SETTING_KEYS.join(", ")}`, + ); + valid = false; + continue; + } + const v = value[key]; + if (typeof v !== "string" || !allowed.includes(v)) { + problem( + ["packages", name, key], + `has the unknown value ${shown(v)}`, + `use one of ${quoted(allowed)}`, + ); + valid = false; + continue; + } + if (key === "run") setting.run = v as PackageRun; + else if (key === "evaluate") setting.evaluate = v as PackageEvaluate; + else if (key === "share") setting.share = v as PackageShare; + else if (key === "marshal") setting.marshal = v as PackageMarshal; + else setting.schedule = v as PackageSchedule; + } + if (valid) config.packages.set(name, setting); + } + } + } + + const islands = root["nodeIslands"]; + if (islands !== undefined) { + if (!isRecord(islands)) { + problem( + ["nodeIslands"], + `must be an object, not ${shown(islands)}`, + `write "nodeIslands": { "auto": true }`, + ); + } else { + const setting = config.nodeIslands; + for (const key of Object.keys(islands)) { + const v = islands[key]; + if (!NODE_ISLANDS_KEYS.includes(key)) { + problem( + ["nodeIslands", key], + "is not a known Node island setting", + `the settings are ${NODE_ISLANDS_KEYS.join(", ")}`, + ); + } else if (key === "auto" || key === "speculativeStart") { + if (typeof v !== "boolean") { + problem( + ["nodeIslands", key], + `must be true or false, not ${shown(v)}`, + "use a boolean", + ); + } else if (key === "auto") setting.auto = v; + else setting.speculativeStart = v; + } else if (key === "modules") { + const list: string[] = []; + let ok = Array.isArray(v); + if (Array.isArray(v)) { + for (const entry of v as unknown[]) { + if (typeof entry === "string" && entry !== "") list.push(entry); + else ok = false; + } + } + if (!ok) { + problem( + ["nodeIslands", key], + `must be an array of module paths or globs, not ${shown(v)}`, + `write "modules": ["src/plugins/loader.ts"]`, + ); + } else setting.modules = list; + } else if (key === "packaging") { + if (v !== "embedded" && v !== "beside") { + problem( + ["nodeIslands", key], + `has the unknown value ${shown(v)}`, + `use "embedded" or "beside"`, + ); + } else setting.packaging = v; + } else if (typeof v !== "string" || v === "") { + problem( + ["nodeIslands", key], + `must be a non-empty string, not ${shown(v)}`, + key === "node" + ? "give the path of the node executable" + : `write a range such as ">=24.13.1"`, + ); + } else if (key === "node") setting.node = v; + else setting.nodeVersion = v; + } + } + } + + // `scriptc.wasm` keeps its lenient reading: string entries of an array. + const wasm = root["wasm"]; + if (Array.isArray(wasm)) { + for (const entry of wasm as unknown[]) { + if (typeof entry === "string") config.wasm.push(resolve(dirname(manifest), entry)); + } + } + return config; +} + +let cachedManifest: string | null = null; +let cachedText: string | null = null; +let cachedConfig: ProjectConfig | null = null; + +/** The configuration of the entry's nearest package.json (the first one + * found walking up from the entry's directory, readable or not). */ +export function readProjectConfig(entryPath: string): ProjectConfig { + for (let dir = dirname(resolve(entryPath)); ;) { + const manifest = join(dir, "package.json"); + const text = trackedReadFile(manifest); + if (text !== null) { + if (cachedConfig !== null && cachedManifest === manifest && cachedText === text) + return cachedConfig; + // A manifest without a `scriptc` key (none spelled out, none behind a + // \u escape) has the empty configuration; builds of such projects + // skip parsing and validating it. + const config = + text.includes("scriptc") || text.includes("\\u") + ? parseProjectConfig(manifest, text) + : { ...emptyProjectConfig(), manifest, manifestText: text }; + cachedManifest = manifest; + cachedText = text; + cachedConfig = config; + return config; + } + const parent = dirname(dir); + if (parent === dir) return emptyProjectConfig(); + dir = parent; + } +} + +/** Offsets of keys in a JSON text, for diagnostic locations. It follows + * object nesting so `["scriptc", "packages", "x"]` finds the key `"x"` + * inside `scriptc.packages`, not an earlier `"x"` elsewhere. */ +class JsonLocator { + constructor(private readonly text: string) {} + + /** The offset of the last key of `path` that could be found (0 when + * none). */ + offsetOf(path: string[]): number { + let start = 0; + let end = this.text.length; + let found = 0; + for (const key of path) { + const at = this.keyIn(key, start, end); + if (at < 0) break; + found = at; + // The key's value: from after the colon to the end of its object. + const colon = this.text.indexOf(":", at); + if (colon < 0) break; + start = colon + 1; + end = this.valueEnd(start, end); + } + return found; + } + + /** A direct key of the object starting at or after `start`. */ + private keyIn(key: string, start: number, end: number): number { + const text = this.text; + let depth = 0; + let i = start; + while (i < end) { + const ch = text.charCodeAt(i); + if (ch === 34 /* " */) { + const close = this.stringEnd(i); + if (depth === 1 && text.slice(i + 1, close) === key) { + let j = close + 1; + while (j < end && /\s/.test(text.charAt(j))) j++; + if (text.charAt(j) === ":") return i; + } + i = close + 1; + continue; + } + if (ch === 123 /* { */ || ch === 91 /* [ */) depth++; + else if (ch === 125 /* } */ || ch === 93 /* ] */) { + depth--; + if (depth <= 0) return -1; + } + i++; + } + return -1; + } + + private stringEnd(open: number): number { + const text = this.text; + let i = open + 1; + while (i < text.length) { + const ch = text.charCodeAt(i); + if (ch === 92 /* \ */) i += 2; + else if (ch === 34) return i; + else i++; + } + return text.length; + } + + /** The end of the JSON value that starts at or after `start`. */ + private valueEnd(start: number, end: number): number { + const text = this.text; + let i = start; + while (i < end && /\s/.test(text.charAt(i))) i++; + let depth = 0; + while (i < end) { + const ch = text.charCodeAt(i); + if (ch === 34) { + i = this.stringEnd(i) + 1; + if (depth === 0) return i; + continue; + } + if (ch === 123 || ch === 91) depth++; + else if (ch === 125 || ch === 93) { + depth--; + if (depth <= 0) return i + 1; + } else if (depth === 0 && ch === 44 /* , */) return i; + i++; + } + return end; + } +} diff --git a/packages/compiler/src/wasm/discover.ts b/packages/compiler/src/wasm/discover.ts index de38bf64f..3cdc3eb96 100644 --- a/packages/compiler/src/wasm/discover.ts +++ b/packages/compiler/src/wasm/discover.ts @@ -24,11 +24,8 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import * as ts from "../frontend/ts7/adapter.js"; -import { - trackedAccessibleEntries, - trackedFileExists, - trackedReadFile, -} from "../frontend/input-tracker.js"; +import { trackedAccessibleEntries, trackedReadFile } from "../frontend/input-tracker.js"; +import { readProjectConfig } from "../project-config.js"; import { analyzeWasm, isWasmHeader, type WasmModuleSummary } from "./binary.js"; import { decodeBase64 } from "./base64.js"; @@ -282,24 +279,10 @@ class Discovery { // ── source 4: configuration ───────────────────────────────────────── scanConfig(): void { - const manifest = nearestPackageJson(dirname(this.input.entry)); - if (manifest !== null) { - const text = trackedReadFile(manifest); - if (text !== null) { - let listed: unknown; - try { - const parsed = JSON.parse(text) as { scriptc?: { wasm?: unknown } }; - listed = parsed.scriptc?.wasm; - } catch { - listed = undefined; - } - if (Array.isArray(listed)) { - for (const entry of listed) { - if (typeof entry === "string") { - this.addFile(resolve(dirname(manifest), entry), { kind: "config", file: manifest }); - } - } - } + const config = readProjectConfig(this.input.entry); + if (config.manifest !== null) { + for (const path of config.wasm) { + this.addFile(path, { kind: "config", file: config.manifest }); } } for (const path of this.input.configured) { @@ -396,16 +379,6 @@ function collectWasmFiles(dir: string, depth: number, out: string[]): void { } } -function nearestPackageJson(dir: string): string | null { - for (;;) { - const candidate = join(dir, "package.json"); - if (trackedFileExists(candidate)) return candidate; - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - /** Every WebAssembly module the program's sources, packages and * configuration make visible to the build, in discovery order. */ export function discoverWasmModules(input: WasmDiscoveryInput): WasmModuleReport[] { diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 1afd4cd3a..58b3da7e2 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -4229,6 +4229,13 @@ "status": "static", "note": "one date string, using V8's date parser shared with new Date(dateString)" }, + { + "id": "stdlib.date.setters", + "kind": "stdlib", + "name": "Date.prototype setters", + "status": "static", + "note": "setTime and the local and UTC field setters, with their optional arguments" + }, { "id": "stdlib.date.toDateString", "kind": "stdlib", diff --git a/packages/compiler/test/chunked-emission.test.ts b/packages/compiler/test/chunked-emission.test.ts index 2aec1e49b..6d2b2e06a 100644 --- a/packages/compiler/test/chunked-emission.test.ts +++ b/packages/compiler/test/chunked-emission.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -25,6 +25,7 @@ const PROGRAMS = [ "6305-json-discriminant-dispatch.ts", "7321-speculative-loop-bail-sites.ts", "7360-worker-typed-records.ts", + "7110-als-completion-callbacks.ts", "node24-text-utilities-coercion.js", ]; @@ -70,8 +71,42 @@ test("function bodies emitted in chunks merge into the text one emitter gives", } }, 600_000); +// Thunks a range creates are journaled, so the merging emitter defines +// them: here Promise.all's tuple adapter, requested by a function that is +// not async (async functions are emitted again when the merging emitter +// selects stackless bodies). +test("a Promise.all tuple adapter requested in a range is defined", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-chunked-tuple-")); + try { + const entry = join(dir, "main.ts"); + await writeFile( + entry, + [ + "function both(a: Promise, b: Promise): Promise<[number, string]> {", + " return Promise.all([a, b]);", + "}", + 'both(Promise.resolve(1), Promise.resolve("x")).then((v) => console.log(v[0], v[1]));', + "", + ].join("\n"), + ); + const outPath = join(dir, "main.ir.json"); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const ir = await readFile(outPath, "utf8"); + const serial = text(emitLlvmModuleSource(deserializeModule(ir), {})); + expect(serial).toContain("define internal void @sc_all_tuple_0("); + const chunked = text(emitLlvmModuleSource(deserializeModule(ir), { functionChunks: 2 })); + expect(chunked === serial).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}, 60_000); + // Debug metadata is numbered per emitter (debug-info.ts) and not journaled: -// a debug build emits its function bodies in one emitter. +// a debug build emits its function bodies in one emitter. Its one fork +// computes the int32 slot analysis; when the fork fails, the emitter +// computes it itself. test("debug builds emit function bodies serially, with every metadata reference defined", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-chunked-debug-")); const minFunctions = process.env["SCRIPTC_EMIT_WORKER_FUNCTIONS"]; @@ -115,7 +150,7 @@ test("debug builds emit function bodies serially, with every metadata reference const referenced = new Set([...emitted.matchAll(/(![0-9]+)\b/g)].map((m) => m[1])); expect([...referenced].filter((id) => !defined.has(id))).toEqual([]); } - expect(forks).toBe(0); + expect(forks).toBe(1); } finally { if (minFunctions === undefined) delete process.env["SCRIPTC_EMIT_WORKER_FUNCTIONS"]; else process.env["SCRIPTC_EMIT_WORKER_FUNCTIONS"] = minFunctions; diff --git a/packages/compiler/test/class-attributes.test.ts b/packages/compiler/test/class-attributes.test.ts index 1a6fea654..a0e6efa62 100644 --- a/packages/compiler/test/class-attributes.test.ts +++ b/packages/compiler/test/class-attributes.test.ts @@ -82,3 +82,33 @@ b.x = 2; expect(throwing.length).toBeGreaterThan(0); expect(throwing.every((name) => name.includes("constructor"))).toBe(true); }); + +test("an untyped freeze gives every class hierarchy attribute state", async () => { + const mod = await lowerModule(` +export {}; +class Point { x = 1; } +class Labeled extends Point { label = "a"; } +function freezeUnknown(value: unknown): unknown { return Object.freeze(value); } +function move(p: Point): void { p.x = 2; } +freezeUnknown(new Labeled()); +move(new Point()); +`); + expect(mod.untypedIntegrity).toBe(true); + expect(fieldsOf(mod, "Point")).toContain("%attributes"); + expect(fieldsOf(mod, "Labeled")).toContain("%attributes"); + expect(functionsCalling(mod, "class.throwReadOnly")).toContain("move"); +}); + +test("untyped freezes of fresh literals and tagged templates leave classes unchanged", async () => { + const mod = await lowerModule( + ` +class Point { constructor() { this.x = 1; } } +const tag = (strings) => strings.length; +const frozen = Object.freeze({ a: 1 }); +console.log(tag\`a\${1}b\`, frozen.a, new Point().x); +`, + "main.js", + ); + expect(mod.untypedIntegrity).toBeUndefined(); + expect(fieldsOf(mod, "Point")).not.toContain("%attributes"); +}); diff --git a/packages/compiler/test/constant-graphs.test.ts b/packages/compiler/test/constant-graphs.test.ts index e197359ad..478932341 100644 --- a/packages/compiler/test/constant-graphs.test.ts +++ b/packages/compiler/test/constant-graphs.test.ts @@ -147,3 +147,24 @@ test("constant graphs: constructors, apply calls and callbacks that one site run new RegExp(`near miss ns-fixture/index\\.js:10 \\.${field}: written-after-init`), ); }); + +// A prototype method stored under a computed key and called on instances. +const computedMethodSource = ` +var lib = {}; +lib.touched = { count: 0 }; +lib.Widget = function () {}; +var methodName = ["to", "uch"].join(""); +lib.Widget.prototype[methodName] = function () { lib.touched.count++; }; +lib.useWidget = function () { new lib.Widget().touch(); }; +module.exports = { lib }; +`; + +test("constant graphs: prototype methods stored under computed keys run on their instances", async () => { + await writeFile(join(dir, "node_modules", "ns-fixture", "index.js"), computedMethodSource); + await writeFile( + join(dir, "main.cjs"), + 'const { lib } = require("ns-fixture");\nfor (let i = 0; i < 3; i++) lib.useWidget();\nconsole.log(lib.touched.count);\n', + ); + const text = (await report()).join("\n"); + expect(text).toMatch(/near miss ns-fixture\/index\.js:3 \.count: written-after-init/); +}); diff --git a/packages/compiler/test/int32-element-array-emission.test.ts b/packages/compiler/test/int32-element-array-emission.test.ts index 605f64e04..fccf06973 100644 --- a/packages/compiler/test/int32-element-array-emission.test.ts +++ b/packages/compiler/test/int32-element-array-emission.test.ts @@ -3,8 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; import { compile, deserializeModule } from "../src/index.js"; -import type { IrModule } from "../src/ir/ir.js"; +import type { IrFunction, IrModule } from "../src/ir/ir.js"; import { markInt32ArrayCandidates, selectInt32Arrays } from "../src/ir/int32-arrays.js"; +import { analyzeInt32Slots, type Int32Slots } from "../src/ir/int32-slots.js"; +import { serializeModule } from "../src/ir/serialize.js"; async function emitLlvm(source: string): Promise { const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-elements-")); @@ -210,12 +212,12 @@ test("only the candidates the proof keeps are rewritten", async () => { // An array that reaches `join` is never proposed. expect(proposed.some((key) => key.startsWith("joined\0"))).toBe(false); // Without a kept candidate, the module is the input itself. - expect(selectInt32Arrays(mod, () => new Set())).toBe(mod); + expect(selectInt32Arrays(mod, () => ({ kept: new Set(), functions: null }))).toBe(mod); const kept = new Set(["compose\0p.0", "compose\0q.0"]); - const selected = selectInt32Arrays( - mod, - (marked) => new Set((marked.int32ArrayCandidates ?? []).filter((key) => kept.has(key))), - ); + const selected = selectInt32Arrays(mod, (marked) => ({ + kept: new Set((marked.int32ArrayCandidates ?? []).filter((key) => kept.has(key))), + functions: null, + })); expect(selected.int32ArrayCandidates).toEqual([...kept]); for (const fn of selected.functions) if (fn.name !== "compose") expect(fn).toBe(mod.functions.find((f) => f.name === fn.name)); @@ -259,3 +261,47 @@ test("arrays passed to helpers and held in module globals get 4-byte slots", asy expect(functions(llvm, "mean")).not.toMatch(/getelementptr inbounds i32, ptr/); expect(llvm).toMatch(/@scr_arr_new(_small)?\(i32 7/); }); + +/** The module selectInt32Arrays selects, the analysis of its last proof, + * and how many proofs it asked for: with `revising`, each proof revises + * the functions of candidates that drop. */ +function selection( + mod: IrModule, + revising: boolean, +): { selected: IrModule; slots: Int32Slots; proofs: number } { + let slots: Int32Slots | null = null; + let proofs = 0; + const selected = selectInt32Arrays(mod, (marked, revise) => { + proofs++; + const latest = new Map(); + slots = analyzeInt32Slots( + marked, + revising && revise !== null + ? { + revise: (fnName, dropped) => { + const fn = revise(fnName, dropped); + latest.set(fnName, fn); + return fn; + }, + } + : {}, + ); + const kept = new Set([...slots.int32Arrays, ...slots.checkedInt32Arrays]); + if (latest.size === 0) return { kept, functions: null }; + return { kept, functions: marked.functions.map((fn) => latest.get(fn.name) ?? fn) }; + }); + return { selected, slots: slots!, proofs }; +} + +test("one proof that revises dropped candidates selects what rounds of proofs select", async () => { + for (const program of [source, sharedSource]) { + const mod = await lowerIr(program); + const rounds = selection(mod, false); + const revised = selection(mod, true); + // The rounds drop a candidate and prove the module again. + expect(rounds.proofs).toBeGreaterThan(1); + expect(revised.proofs).toBe(1); + expect(serializeModule(revised.selected, true)).toBe(serializeModule(rounds.selected, true)); + expect(revised.slots.summary(revised.selected)).toBe(rounds.slots.summary(rounds.selected)); + } +}); diff --git a/packages/compiler/test/int32-slots-transfer.test.ts b/packages/compiler/test/int32-slots-transfer.test.ts new file mode 100644 index 000000000..3ea4f6a6a --- /dev/null +++ b/packages/compiler/test/int32-slots-transfer.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { findInitializerBindings } from "../src/ir/initializer-bindings.js"; +import { analyzeInt32Slots, dynamicFieldStores, Int32Slots } from "../src/ir/int32-slots.js"; +import type { IrExpr, IrModule } from "../src/ir/ir.js"; +import { IrNodeIndex } from "../src/ir/node-index.js"; +import { unshareNodes } from "../src/ir/unshare.js"; + +// Programs with int32 fields, parameters, results, captured boxes, contract +// pins, int32 arrays and disqualified candidates. +const PROGRAMS = [ + "4451-int32-field-storage.ts", + "4452-int32-field-dynamic-writes.ts", + "4453-int32-closed-locals.ts", + "4630-contract-int32.ts", + "4970-int32-call-abi.ts", + "5323-int32-nonnegative-slot-facts.ts", + "5332-int32-narrowed-slot-facts.ts", + "5930-contract-int32-arrays.ts", + "7326-int32-element-arrays.ts", + "7580-int32-arrays-across-calls.ts", +]; + +async function lower(program: string, dir: string): Promise { + const entry = join(import.meta.dirname, "../../../tests/corpus", program); + const outPath = join(dir, `${program}.ir.json`); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return readFile(outPath, "utf8"); +} + +/** The analysis LlEmitter makes of `mod`, with its node index. */ +function analyze(mod: IrModule): { nodes: IrNodeIndex; slots: Int32Slots } { + const nodes = new IrNodeIndex(mod); + const slots = analyzeInt32Slots(mod, { + nodes, + dynamicStores: dynamicFieldStores(mod, undefined, undefined, nodes), + initializerBindings: findInitializerBindings(mod, nodes), + }); + return { nodes, slots }; +} + +// A forked process holds its own copy of the module: the encoded analysis +// names nodes by index position, which resolve in any copy of the module. +test("the int32 slot analysis decodes against another copy of its module", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-transfer-")); + try { + let provenWrites = 0; + for (const program of PROGRAMS) { + const ir = await lower(program, dir); + const first = analyze(unshareNodes(deserializeModule(ir))); + const copy = unshareNodes(deserializeModule(ir)); + const second = analyze(copy); + const text = first.slots.encode(first.nodes); + expect(text, program).not.toBeNull(); + const decoded = Int32Slots.decode(text!, copy, second.nodes); + expect(decoded, program).toEqual(second.slots); + for (const node of decoded.provenWrites) + expect(second.slots.provenWrites.has(node), program).toBe(true); + provenWrites += decoded.provenWrites.size; + } + expect(provenWrites).toBeGreaterThan(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}, 120_000); + +test("an analysis whose proven writes the index does not record does not encode", () => { + const loc = { file: "test.ts", start: 0, end: 0 }; + const mod: IrModule = { + irVersion: 17, + sourceFile: "test.ts", + entry: "%main", + functions: [ + { loc, name: "%main", params: [], locals: [], returnType: { kind: "void" }, body: [] }, + ], + }; + const { nodes, slots } = analyze(mod); + expect(slots.encode(nodes)).not.toBeNull(); + const elsewhere: IrExpr = { loc, kind: "numLit", value: 1, type: { kind: "f64" } }; + slots.provenWrites.add(elsewhere); + expect(nodes.expressionPositions(slots.provenWrites)).toBeNull(); + expect(slots.encode(nodes)).toBeNull(); +}); diff --git a/packages/compiler/test/integer-view-compares.test.ts b/packages/compiler/test/integer-view-compares.test.ts index e988d48bd..4e814368d 100644 --- a/packages/compiler/test/integer-view-compares.test.ts +++ b/packages/compiler/test/integer-view-compares.test.ts @@ -22,7 +22,7 @@ siftDown(a, 0, Number(process.argv[2] ?? "5")); console.log(Array.from(a).join()); `; -test("integer sums of speculated locals compare and index as integers", async () => { +test("integer values compare and index as integers", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-integer-views-")); try { const entry = join(dir, "sift.ts"); @@ -43,9 +43,17 @@ test("integer sums of speculated locals compare and index as integers", async () expect(uses("icmp slt").length).toBeGreaterThan(0); // and `a[child + 1]` bounds the integer index without a double round trip. expect(uses("icmp ult").length).toBeGreaterThan(0); - // `2 * root + 1 < end` may pass 2^53, so it keeps the double compare. - expect(body).toMatch(/fmul double 0x4000000000000000, %t\d+\n\s+%t\d+ = mul nsw i64 2/); - expect(body).toContain("fcmp olt double"); + // `2 * root + 1` may pass 2^53, but its double is its integer rounded + // once (doubling is exact) and `end` is a safe integer, so the integers + // compare as the doubles do. + const doubled = /(%t\d+) = mul nsw i64 2, %t\d+\n/.exec(body)![1]!; + const plusOne = new RegExp(`(%t\\d+) = add nsw i64 \\${doubled}, 1\\n`).exec(body)![1]!; + expect(body).toMatch(new RegExp(`= icmp slt i64 \\${plusOne}, %t\\d+\\n`)); + // `a[child]! < a[child + 1]!` compares the elements' int32 bits, and is + // false when either read is out of range (NaN). + const ordered = String.raw`\s+%t\d+ = fcmp ord double %t\d+, 0\.0\n\s+%t\d+ = and i1 %t\d+, %t\d+\n`; + expect(body).toMatch(new RegExp(String.raw`icmp slt i32 %t\d+, %t\d+\n(${ordered}){2}`)); + expect(body).toMatch(new RegExp(String.raw`icmp sge i32 %t\d+, %t\d+\n(${ordered}){2}`)); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/packages/compiler/test/ir.test.ts b/packages/compiler/test/ir.test.ts index 7f5e9f3c9..5d835ef8a 100644 --- a/packages/compiler/test/ir.test.ts +++ b/packages/compiler/test/ir.test.ts @@ -8,12 +8,10 @@ test("hand-built fib module validates", () => { expect(validateModule(fibModule)).toEqual([]); }); -test("validator rejects Date union arms beside other data arms", () => { - const bad = structuredClone(fibModule); - bad.unions = [{ id: "u-date", arms: [{ kind: "date" }, { kind: "string" }] }]; - expect(validateModule(bad).map((e) => e.message)).toContain( - "union u-date: date arm 0 beside non-unit arms", - ); +test("validator accepts Date union arms beside other arms", () => { + const mixed = structuredClone(fibModule); + mixed.unions = [{ id: "u-date", arms: [{ kind: "date" }, { kind: "string" }] }]; + expect(validateModule(mixed)).toEqual([]); const nullable = structuredClone(fibModule); nullable.unions = [{ id: "u-date", arms: [{ kind: "date" }, { kind: "undefinedT" }] }]; expect(validateModule(nullable)).toEqual([]); diff --git a/packages/compiler/test/js-collection-types.test.ts b/packages/compiler/test/js-collection-types.test.ts new file mode 100644 index 000000000..7a5ef967a --- /dev/null +++ b/packages/compiler/test/js-collection-types.test.ts @@ -0,0 +1,91 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; + +// Corpus 7985 exercises every refusal: only the collections whose uses +// prove their types lower as typed collections; the rest stay untyped. +test("untyped JavaScript collections take the types their uses prove, and only those", async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-js-collection-types-")); + try { + const entry = join( + import.meta.dirname, + "../../../tests/corpus/7985-untyped-collection-types.mjs", + ); + const output = join(dir, "main.ir.json"); + const lowered = await compile(entry, { outputKind: "ir", outDir: dir, outPath: output }); + if (!lowered.ok) throw new Error(JSON.stringify(lowered.diagnostics)); + const module = deserializeModule(readFileSync(output, "utf8")); + expect(validateModule(module)).toEqual([]); + const types = new Map(module.globals.map((g) => [g.name, JSON.stringify(g.type)])); + const map = (key: string, value: string) => + JSON.stringify({ kind: "map", key: { kind: key }, value: { kind: value } }); + const set = (elem: string) => JSON.stringify({ kind: "set", elem: { kind: elem } }); + const array = (elem: string) => JSON.stringify({ kind: "array", elem: { kind: elem } }); + // Untyped collections hold checked-dynamic keys, values and elements. + const typed = (t: string) => !t.includes('"dyn"'); + expect(Object.fromEntries([...types].filter(([, t]) => typed(t)))).toEqual({ + counts: map("string", "f64"), + seen: set("f64"), + stack: array("f64"), + words: array("string"), + byNumber: map("f64", "string"), + nums: array("f64"), + flags: set("bool"), + cache: map("string", "f64"), + "%loaded": '{"kind":"bool"}', + }); + for (const untyped of [ + "strings", + "numbers", + "mixedKeys", + "mixedValues", + "mixedElements", + "passed", + "stored", + "iterated", + "eachSet", + "shown", + "chained", + "optional", + "indexed", + "truncated", + "spread", + "unread", + "exported", + "listed", + "renamed", + ]) + expect(typed(types.get(untyped)!), untyped).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// Resolving a candidate's constructor through a destructured require reads +// types while the inference runs; those reads must not start it again. +test("the inference runs once when a candidate's values come from a destructured require", async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-js-collection-reentry-")); + try { + const entry = join(dir, "main.cjs"); + writeFileSync( + entry, + [ + 'const util = require("node:util");', + "const { TextDecoder } = util;", + "const decoder = new TextDecoder();", + 'const decoders = new Map([["utf-8", decoder]]);', + "const seen = new Set([decoder]);", + "console.log(decoders.size, seen.size);", + "", + ].join("\n"), + ); + const output = join(dir, "main.ir.json"); + const lowered = await compile(entry, { outputKind: "ir", outDir: dir, outPath: output }); + if (!lowered.ok) throw new Error(JSON.stringify(lowered.diagnostics)); + expect(validateModule(deserializeModule(readFileSync(output, "utf8")))).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/js-ic-emission.test.ts b/packages/compiler/test/js-ic-emission.test.ts index 54ea13d26..15e8e92bc 100644 --- a/packages/compiler/test/js-ic-emission.test.ts +++ b/packages/compiler/test/js-ic-emission.test.ts @@ -48,7 +48,7 @@ console.log(read(o), at(dyn([5, 6]), 1), call(o), o.n); expect(llvm).toMatch( /@sc_js_ic_0 = internal (thread_local )?global \[21 x i64\] zeroinitializer/, ); - expect(body(llvm, "read")).toContain("@scr_js_load_probe("); + expect(body(llvm, "read")).toContain("@scr_js_get_probe("); expect(body(llvm, "write")).toContain("@scr_js_store_probe("); expect(body(llvm, "at")).toContain("@scr_js_keyed_probe("); expect(body(llvm, "call")).toContain("@scr_js_load_probe("); @@ -100,7 +100,7 @@ console.log(read(o), read(p), read(t), at(dyn([5, 6]), 1), at(p, "x"), at(t, "ta const mod = deserializeModule(await readFile(outPath, "utf8")); expect(validateModule(mod)).toEqual([]); const llvm = emitLlvmModule(mod); - expect(body(llvm, "read")).toContain("@scr_js_load_probe("); + expect(body(llvm, "read")).toContain("@scr_js_get_probe("); expect(body(llvm, "write")).toContain("@scr_js_store_probe("); expect(body(llvm, "at")).toContain("@scr_js_keyed_probe("); } finally { @@ -134,3 +134,44 @@ console.log(len2({ x: 3, y: 4 }), typeof parsed); await rm(dir, { recursive: true, force: true }); } }); + +test("keyed and length sites read native number arrays in place", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-js-ic-native-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(join(dir, "package.json"), `{ "type": "module" }`); + await writeFile( + join(dir, "lib.cjs"), + `exports.total = function (xs) { + let s = 0; + for (let i = 0; i < xs.length; i++) s += xs[i]; + return s; +}; +`, + ); + await writeFile( + entry, + `import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const lib: { total(xs: number[]): number } = require("./lib.cjs"); +console.log(lib.total([1, 2, 3])); +`, + ); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const llvm = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8"))); + const fn = llvm.split("\ndefine ").find((f) => f.includes("ica.f64")) ?? ""; + // The keyed read tests the capsule's element kind and loads the double + // in place; other receivers keep the probe. + expect(fn).toMatch(/icmp eq i8 %t\d+, 0 ; SCR_ELEM_F64/); + expect(fn).toMatch(/load double, ptr %t\d+/); + expect(fn).toContain("@scr_js_keyed_probe("); + // The length read answers from the native array's length. + expect(fn).toContain("icg.rest"); + expect(fn).toContain("@scr_js_get_probe("); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/json-schema-parse-emission.test.ts b/packages/compiler/test/json-schema-parse-emission.test.ts index b96c9a032..dd6bf52ae 100644 --- a/packages/compiler/test/json-schema-parse-emission.test.ts +++ b/packages/compiler/test/json-schema-parse-emission.test.ts @@ -58,13 +58,13 @@ console.log(orders.length, typed.sku, loose.name, mixed.length, pair[0], either. // record pointer (0), the inline scalar pair (1), and a tagged box built // through the schema's box function (2), with arm tags by JSON kind. expect(llvm).toMatch( - /@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr \} \{ i64 0, /, + /@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr, ptr \} \{ i64 0, /, ); expect(llvm).toMatch( - /@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr \} \{ i64 1, /, + /@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr, ptr \} \{ i64 1, /, ); expect(llvm).toMatch( - /@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr \} \{ i64 2, .*ptr @sc_jsch_\d+_box \}/, + /@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr, ptr \} \{ i64 2, .*ptr @sc_jsch_\d+_box, ptr null \}/, ); expect(llvm).toMatch(/^define internal ptr @sc_jsch_\d+_box\(i32 %tag, i64 %bits\)/m); // A pure dictionary has no declared fields: its index value schema and @@ -78,9 +78,59 @@ console.log(orders.length, typed.sku, loose.name, mixed.length, pair[0], either. const wasm = emitLlvmModule(mod, { pointerBits: 32, wasi: true }); expect(wasm).toContain("= internal constant { i32, i32, ptr, ptr, ptr, ptr, ptr, ptr, i32 }"); - expect(wasm).toContain("= internal constant { i32, [7 x i32], [7 x ptr], ptr, ptr, ptr }"); + expect(wasm).toContain("= internal constant { i32, [7 x i32], [7 x ptr], ptr, ptr, ptr, ptr }"); expect(wasm).toMatch(/\{ ptr, i32, i32, ptr \} \{ ptr @sc_cs_\d+, i32 3, i32 ptrtoint/); } finally { await rm(dir, { recursive: true, force: true }); } }); + +test("JSON.parse casts to discriminated unions fuse into the schema parser", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-json-schema-union-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile( + entry, + ` +type Event = + | { kind: "click"; x: number; y: number } + | { kind: "focus" | "blur"; target: string } + | { kind: "load"; ms: number; next: Event | null }; +type Expr = { kind: "num"; value: number } | { kind: "add"; items: Expr[] }; +type Loose = { kind: string; a: number } | { kind: string; b: string }; +const text = process.argv[2] ?? "[]"; +const events = JSON.parse(text) as Event[]; +const tree = JSON.parse(text) as Expr; +const loose = JSON.parse(text) as Loose[]; +console.log(events.length, tree.kind, loose.length); +`, + ); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const llvm = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8"))); + // Event[] and the whole-document Expr take the fused path; a union + // without literal discriminants keeps the checked-dynamic route alone. + expect(llvm.match(/^define internal ptr @sc_jp_\d+(?:\.i32)?\(ptr %text\)/gm)).toHaveLength(2); + expect(llvm.match(/call ptr @scr_json_parse\(ptr /g)).toHaveLength(3); + // Each literal names its arm's tag and record schema; an arm with two + // literals is listed under both. Event, `Event | null` and Expr each + // have a table. + const cases = [...llvm.matchAll(/^@sc_jsch_\d+_case_table = internal constant \[(\d+) x /gm)]; + expect(cases.map((m) => Number(m[1])).sort()).toEqual([2, 4, 4]); + expect(llvm).toMatch( + /^@sc_jsch_\d+_cases = internal constant \{ ptr, i64, i64, ptr \} \{ ptr @sc_cs_\d+, i64 4, i64 4, ptr @sc_jsch_\d+_case_table \}/m, + ); + // The union descriptors point at their cases, and a union schema that + // can be a whole document carries its release. + expect(llvm).toMatch( + /@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr, ptr \} \{ .*, ptr @sc_jsch_\d+_cases \}/, + ); + expect(llvm).toMatch( + /= internal constant \{ i64, i64, ptr, ptr, ptr, ptr, ptr, ptr, i64 \} \{ i64 5, i64 0, ptr null, ptr null, ptr null, ptr null, ptr @\S+, ptr @sc_jsch_\d+_union, i64 0 \}/, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/map-get-borrow-emission.test.ts b/packages/compiler/test/map-get-borrow-emission.test.ts new file mode 100644 index 000000000..4e3c91766 --- /dev/null +++ b/packages/compiler/test/map-get-borrow-emission.test.ts @@ -0,0 +1,93 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +test("a Map.get local read before any reference write holds the entry's reference", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-map-get-borrow-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile( + entry, + ` +class Counter { + num = 1; +} +function count(m: Map, keys: number[]): void { + for (const key of keys) { + const cur = m.get(key); + if (cur === undefined) m.set(key, new Counter()); + else cur.num++; + } +} +function named(m: Map, key: string): number { + const cur = m.get(key); + return cur === undefined ? 0 : cur.num; +} +function replaced(m: Map): number { + const old = m.get(1); + m.set(1, new Counter()); + return old === undefined ? 0 : old.num; +} +function aliased(m: Map): number { + const held = m.get(1); + const copy = held; + m.set(1, new Counter()); + return copy === undefined ? 0 : copy.num; +} +function fresh(): Map { + return new Map([[1, new Counter()]]); +} +function temporary(keys: number[]): number { + let total = 0; + for (const key of keys) { + const held = fresh().get(key); + if (held !== undefined) total += held.num; + } + return total; +} +class Holder { + m = new Map([[1, new Counter()]]); + key(): number { + this.m = new Map(); + return 1; + } + read(): number { + const held = this.m.get(this.key()); + return held === undefined ? 0 : held.num; + } +} +const numbers = new Map(); +count(numbers, [1, 2, 1]); +console.log(named(new Map([["a", new Counter()]]), "a"), replaced(numbers), aliased(numbers), temporary([1, 2]), new Holder().read()); +`, + ); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + const ll = emitLlvmModule(mod); + const fn = (name: string): string => + new RegExp(`^define internal [^\\n]*@sc_bf_${name}(?:\\.i32)?\\([^]*?^}`, "m").exec(ll)![0]; + // The update writes the map only where the entry is absent, and a + // string key's lookup is inline: neither read retains the value. + for (const name of ["count", "named"]) { + expect(fn(name)).not.toContain("@sc_retain_Counter"); + expect(fn(name)).not.toContain("@sc_release_Counter"); + } + // A write between the read and its use keeps the read's own reference, + // also when the use goes through a local declared from the read, and so + // does a read from a map that is released after the declaration. + for (const name of ["replaced", "aliased", "temporary"]) + expect(fn(name)).toContain("@sc_retain_Counter"); + // A key whose evaluation replaces the receiver's field leaves the read + // on the old map, which the statement releases: the read keeps its own + // reference. + expect(fn("_x25_Holder_read")).toContain("@sc_retain_Counter"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/namespace-member-fences.test.ts b/packages/compiler/test/namespace-member-fences.test.ts new file mode 100644 index 000000000..c040f35c7 --- /dev/null +++ b/packages/compiler/test/namespace-member-fences.test.ts @@ -0,0 +1,67 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +// Static member calls (`Math.abs(x)`) do not read the namespace objects, so +// a change to a function member's value fences (SC1090) instead of leaving +// those calls with the original. New properties and attribute-only changes +// behave as in Node (corpus 7188). +test("replacing or deleting a namespace member fences", async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-namespace-fences-")); + try { + const entry = join(dir, "main.js"); + writeFileSync( + entry, + ` +const m = /** @type {any} */ (Math); +const j = /** @type {any} */ (JSON); +function attempt(label, run) { + try { console.log(label, run()); } catch (error) { console.log(label, error.code); } +} +attempt("alias", () => { m.abs = () => 0; return "replaced"; }); +attempt("direct", () => { Math.max = () => 0; return "replaced"; }); +attempt("define", () => Object.defineProperty(m, "min", { value: () => 0 }) === m); +attempt("same", () => Object.defineProperty(m, "min", { value: Math.min, enumerable: true }) === m); +attempt("reflect", () => Reflect.set(j, "parse", () => 0)); +attempt("assign", () => Object.assign(m, { floor: 1 }) === m); +attempt("delete", () => delete m.ceil); +attempt("added", () => { m.extra = 5; return m.extra; }); +console.log(Math.abs(-1), Math.max(1, 2), Math.min(1, 2), JSON.parse("[3]")[0], Math.floor(1.5), Math.ceil(1.5)); +console.log(Object.keys(Math).join(",")); +`, + ); + const result = await compile(entry, { + backend: "llvm", + sanitize, + outDir: dir, + outPath: join(dir, "program"), + }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + if (!result.ok) return; + const child = spawnSync(result.binaryPath, [], { encoding: "utf8" }); + expect(child.stderr).toBe(""); + expect(child.status).toBe(0); + expect(child.stdout).toBe( + [ + "alias SC1090", + "direct SC1090", + "define SC1090", + "same true", + "reflect SC1090", + "assign SC1090", + "delete SC1090", + "added 5", + "1 2 1 3 1 2", + "min,extra", + "", + ].join("\n"), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts index cecebb49c..8e8439749 100644 --- a/packages/compiler/test/native-codegen-integration.test.ts +++ b/packages/compiler/test/native-codegen-integration.test.ts @@ -378,7 +378,7 @@ done: expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"])); expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"])); expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]); - expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v37"); + expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v43"); const clangExe = join(dir, "clang-program"); const linkDriver = join(dir, "clang-driver.c"); @@ -712,7 +712,7 @@ done: .split("\n"); expect(undefinedSymbols).toContain("_sf_scale"); expect(undefinedSymbols).toContain("_sf_callback_mix"); - expect(undefinedSymbols).toContain("_scr_runtime_abi_v37"); + expect(undefinedSymbols).toContain("_scr_runtime_abi_v43"); }); test("helper and clang object paths retain the same outbound FFI C ABI", async () => { diff --git a/packages/compiler/test/native-json-value-end.test.ts b/packages/compiler/test/native-json-value-end.test.ts new file mode 100644 index 000000000..0818fbab6 --- /dev/null +++ b/packages/compiler/test/native-json-value-end.test.ts @@ -0,0 +1,151 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { jsonValueEnd } from "../src/frontend/ts7/semantic-json.js"; + +const sources = join(import.meta.dirname, "../native"); +const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp"; + +/** jsonValueEnd's result, or -1 where it throws. */ +function expectedEnd(bytes: Uint8Array, start: number): number { + try { + return jsonValueEnd(bytes, start); + } catch { + return -1; + } +} + +/** A deterministic generator, so a failing case can be reproduced. */ +function random(seed: number): () => number { + let state = seed; + return () => { + state = (state * 1103515245 + 12345) % 2147483648; + return state / 2147483648; + }; +} + +const ALPHABET = ["{", "}", "[", "]", '"', "\\", ",", " ", "\n", "\t", "a", "1", "n", ":", "é"]; + +function cases(): { bytes: Uint8Array; start: number }[] { + const next = random(1); + const out: { bytes: Uint8Array; start: number }[] = []; + const texts = [ + "", + '""', + '"unclosed', + '"\\"', + '"\\\\"', + '"a\\"b"', + '"\\\\\\""', + "null", + "null,", + "123 ", + '{"id":1}', + '{"id":1,"name":"a}b"}', + '[{"id":1},null,{"id":2,"declarations":["1.2./a.ts","3.4./b \\"c\\".ts"]}]', + '{"id":1,"nested":{"list":[1,{"x":"]"}]}}, next', + "{", + "[[]", + "}", + "]", + ",", + " ", + ]; + for (const text of texts) { + const bytes = Buffer.from(text, "utf8"); + for (let start = 0; start <= bytes.length; start++) out.push({ bytes, start }); + } + for (let n = 0; n < 4000; n++) { + let text = ""; + const length = Math.floor(next() * 40); + for (let k = 0; k < length; k++) text += ALPHABET[Math.floor(next() * ALPHABET.length)]; + const bytes = Buffer.from(text, "utf8"); + out.push({ bytes, start: Math.floor(next() * (bytes.length + 1)) }); + } + const reply = JSON.stringify( + Array.from({ length: 200 }, (_, id) => + id % 7 === 0 + ? null + : { id, name: `name ${id} "q" \\ {[`, declarations: [`${id}.80./src/a ${id}.ts`] }, + ), + ); + const bytes = Buffer.from(reply, "utf8"); + for (let start = 0; start < bytes.length; start += 3) out.push({ bytes, start }); + return out; +} + +/** The native scanner's results for `all`, from a harness built in `dir`. */ +function nativeEnds( + dir: string, + all: { bytes: Uint8Array; start: number }[], + timeout?: number, +): number[] { + const harness = join(dir, "scan" + (process.platform === "win32" ? ".exe" : "")); + execFileSync("clang", [ + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + ...(process.env["SCRIPTC_SAN"] === "1" ? ["-fsanitize=address,undefined"] : []), + join(sources, "ts7-json.test.c"), + join(sources, "ts7-process.c"), + "-o", + harness, + ]); + const chunks: Buffer[] = []; + for (const { bytes, start } of all) { + const header = Buffer.alloc(8); + header.writeUInt32LE(bytes.length, 0); + header.writeUInt32LE(start, 4); + chunks.push(header, Buffer.from(bytes)); + } + const output = execFileSync(harness, { + input: Buffer.concat(chunks), + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + ...(timeout === undefined ? {} : { timeout }), + }); + return output.trim().split(/\r?\n/).map(Number); +} + +test("the native scanner finds the value ends jsonValueEnd finds", () => { + const dir = mkdtempSync(join(tempRoot, "scriptc-ts7-json-")); + try { + const all = cases(); + const actual = nativeEnds(dir, all); + expect(actual.length).toBe(all.length); + for (let k = 0; k < all.length; k++) { + const { bytes, start } = all[k]!; + const expected = expectedEnd(bytes, start); + if (actual[k] !== expected) + expect({ text: Buffer.from(bytes).toString("utf8"), start, end: actual[k] }).toEqual({ + text: Buffer.from(bytes).toString("utf8"), + start, + end: expected, + }); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("the native scanner reads strings with many escapes in one pass", () => { + const dir = mkdtempSync(join(tempRoot, "scriptc-ts7-json-")); + try { + // Strings of 2,000,000 escapes each: newlines, backslashes and quotes, + // alone and as a member of an object. + const strings = ["\\n", "\\\\", '\\"'].map((escape) => `"${escape.repeat(2_000_000)}"`); + const all = [ + ...strings.map((text) => ({ bytes: Buffer.from(text, "utf8"), start: 0 })), + ...strings.map((text) => ({ bytes: Buffer.from(`{"value":${text}},1`, "utf8"), start: 0 })), + ]; + // A scan that searched for the closing quote again after each escape + // would take minutes here. + const actual = nativeEnds(dir, all, 60_000); + expect(actual).toEqual(all.map(({ bytes, start }) => expectedEnd(bytes, start))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/native-profile.test.ts b/packages/compiler/test/native-profile.test.ts index 53d077017..9d04739d0 100644 --- a/packages/compiler/test/native-profile.test.ts +++ b/packages/compiler/test/native-profile.test.ts @@ -110,6 +110,65 @@ function expectedExit(argc: number, edit = 0): number { return Number(total % 251n); } +const INLINED = 6; + +/** A program whose helpers are too large for the inliner that runs before the + * profile annotation and each called twice, so they reach the annotation, + * and which the profile-guided inliner then inlines into their hot call + * sites in `main` and deletes. */ +function inlinedProgram(): string { + const lines = ["; profiled program, helpers inlined after annotation"]; + for (let index = 0; index < INLINED; index++) { + lines.push( + `define internal i64 @g${index}(i64 %n) {`, + "entry:", + " %x0 = add i64 %n, " + index, + ); + for (let step = 1; step <= 40; step++) + lines.push(` %x${step} = ${step % 2 === 0 ? "xor" : "mul"} i64 %x${step - 1}, ${step + 3}`); + lines.push(" ret i64 %x40", "}"); + } + lines.push( + "define i32 @main(i32 %argc, ptr %argv) {", + "entry:", + " %wide = sext i32 %argc to i64", + " %limit = mul i64 %wide, 1000", + " br label %loop", + "loop:", + " %i = phi i64 [ 0, %entry ], [ %next, %loop ]", + " %total = phi i64 [ 0, %entry ], [ %grown, %loop ]", + ...Array.from({ length: INLINED }, (_, index) => [ + ` %a${index} = call i64 @g${index}(i64 %i)`, + ` %b${index} = call i64 @g${index}(i64 %a${index})`, + ` %t${index} = add i64 ${index === 0 ? "%total" : `%t${index - 1}`}, %b${index}`, + ]).flat(), + ` %grown = add i64 %t${INLINED - 1}, 0`, + " %next = add i64 %i, 1", + " %again = icmp ult i64 %next, %limit", + " br i1 %again, label %loop, label %exit", + "exit:", + " %low = urem i64 %grown, 251", + " %code = trunc i64 %low to i32", + " ret i32 %code", + "}", + ); + return lines.join("\n") + "\n"; +} + +function inlinedExit(argc: number): number { + const mask = (1n << 64n) - 1n; + const g = (index: number, n: bigint) => { + let x = (n + BigInt(index)) & mask; + for (let step = 1; step <= 40; step++) + x = step % 2 === 0 ? x ^ BigInt(step + 3) : (x * BigInt(step + 3)) & mask; + return x; + }; + let total = 0n; + for (let i = 0n; i < BigInt(argc * 1000); i++) + for (let index = 0; index < INLINED; index++) total = (total + g(index, g(index, i))) & mask; + return Number(total % 251n); +} + type EmitOptions = { outputs: number; name: string; @@ -119,12 +178,17 @@ type EmitOptions = { }; async function emit(dir: string, source: string, options: EmitOptions): Promise { + return (await emitReporting(dir, source, options)).outputs; +} + +/** `emit`, also returning the helper's stdout (its profile match report). */ +async function emitReporting(dir: string, source: string, options: EmitOptions) { const input = join(dir, `${options.name}.ll`); await writeFile(input, source); const outputs = Array.from({ length: options.outputs }, (_, index) => join(dir, `${options.name}.${index}.o`), ); - await execFileAsync(helper!, [ + const { stdout } = await execFileAsync(helper!, [ "emit", "--input", input, @@ -145,7 +209,7 @@ async function emit(dir: string, source: string, options: EmitOptions): Promise< ...(options.use === undefined ? [] : ["--profile-use", options.use]), ...(options.cache === undefined ? [] : ["--cache-dir", options.cache]), ]); - return outputs; + return { outputs, stdout }; } async function link(dir: string, objects: string[], name: string, instrumented = false) { @@ -181,12 +245,18 @@ async function merge(output: string, inputs: string[]) { /** An instrumented build of `source` run with 1 and 3 arguments, its raw * profiles merged into one indexed profile. */ -async function collect(dir: string, source: string, outputs: number, name: string) { +async function collect( + dir: string, + source: string, + outputs: number, + name: string, + expected: (argc: number) => number = expectedExit, +) { const raw = join(dir, `${name}-raw`); const objects = await emit(dir, source, { outputs, name, generate: join(raw, "%p.profraw") }); const executable = await link(dir, objects, name, true); - expect(await run(executable, 1)).toBe(expectedExit(1)); - expect(await run(executable, 3)).toBe(expectedExit(3)); + expect(await run(executable, 1)).toBe(expected(1)); + expect(await run(executable, 3)).toBe(expected(3)); const profiles = (await readdir(raw)).map((file) => join(raw, file)); expect(profiles).toHaveLength(2); const profile = join(dir, `${name}.profdata`); @@ -286,6 +356,26 @@ describe.runIf(helper !== null)("LLVM profile-guided optimization", () => { expect(await run(await link(dir, objects, "edited"), 2)).toBe(expectedExit(2, 7)); }); + test("the match report counts annotated functions that inlining removes", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-match-")); + dirs.push(dir); + const { profile } = await collect(dir, inlinedProgram(), 1, "train", inlinedExit); + const { outputs, stdout } = await emitReporting(dir, inlinedProgram(), { + outputs: 1, + name: "guided", + use: profile, + }); + expect(await run(await link(dir, outputs, "guided"), 2)).toBe(inlinedExit(2)); + // Every g and main received counts, though optimization then + // inlined the helpers into main and deleted them. + expect(JSON.parse(stdout.trim().split("\n").at(-1)!)).toEqual({ + ok: true, + profile: { annotated: INLINED + 1, mismatched: 0, missing: 0, unknown_partitions: 0 }, + }); + const symbols = await execFileAsync("nm", [outputs[0]!]); + expect(symbols.stdout).not.toMatch(/\bg\d\b/); + }); + test("the profile keys the partition cache", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-cache-")); dirs.push(dir); diff --git a/packages/compiler/test/shape-inference.test.ts b/packages/compiler/test/shape-inference.test.ts index 8bf97de10..36f089250 100644 --- a/packages/compiler/test/shape-inference.test.ts +++ b/packages/compiler/test/shape-inference.test.ts @@ -103,6 +103,17 @@ export function revivedResult(text) { const r = { fromReviver: 1 }; return leak( export function constructedProxy() { return Reflect.construct(Proxy, [{ constructedProxy: 1 }, {}]); } export function globalProxy() { return new globalThis.Proxy({ globalProxy: 1 }, {}); } function same(value) { return value; } +String.prototype.shout = function () { return this.toUpperCase(); }; +function addedMember(s) { return s.shout; } +function missingMember(s) { return s.whisper; } +Object.defineProperty(Number.prototype, "half", { get() { return this / 2; } }); +function definedMember(n) { return n.half; } +function execGroups(s) { const m = /(a)|(b)/.exec(s); return m === null ? "none" : m[2]; } +export function primitives() { + const out = []; + for (const s of ["a", "b"]) out.push(addedMember(s), missingMember(s), definedMember(s.length), execGroups(s)); + return out; +} export function manyShapes() { return [same({ w0: 0 }), same({ w1: 1 }), same({ w2: 2 }), same({ w3: 3 }), same({ w4: 4 }), same({ w5: 5 }), same({ w6: 6 }), same({ w7: 7 }), same({ w8: 8 }), same({ w9: 9 }), same({ w10: 10 }), same({ w11: 11 }), same({ w12: 12 }), same({ w13: 13 }), same({ w14: 14 }), same({ w15: 15 }), same({ w16: 16 }), same({ w17: 17 }), same({ w18: 18 }), same({ w19: 19 }), same({ w20: 20 }), same({ w21: 21 }), same({ w22: 22 }), same({ w23: 23 }), same({ w24: 24 }), same({ w25: 25 }), same({ w26: 26 }), same({ w27: 27 }), same({ w28: 28 }), same({ w29: 29 }), same({ w30: 30 }), same({ w31: 31 }), same({ w32: 32 }), same({ w33: 33 }), same({ w34: 34 }), same({ w35: 35 }), same({ w36: 36 }), same({ w37: 37 }), same({ w38: 38 }), same({ w39: 39 })].length; } @@ -130,6 +141,7 @@ export declare function revivedResult(text: string): unknown; export declare function constructedProxy(): unknown; export declare function globalProxy(): unknown; export declare function manyShapes(): number; +export declare function primitives(): unknown; `; const mainSource = ` @@ -143,6 +155,7 @@ import { jsonCallbacks, leak, obj, + primitives, proxied, reflect, reflectDelete, @@ -172,6 +185,7 @@ const more = [ constructedProxy(), globalProxy(), manyShapes(), + primitives(), ]; console.log(more.length); `; @@ -309,3 +323,16 @@ test("S1 report is off by default and changes nothing", async () => { }); expect(writes.mock.calls.some(([c]) => String(c).includes("[shapes]"))).toBe(false); }); + +test("S1 report: members added to primitive prototypes and unmatched capture groups", async () => { + const lines = await report(); + const ret = (name: string): string | undefined => + lines.find((l) => l.startsWith(`fn ${name}@`))?.replace(/^.*, ret=/, ""); + // String.prototype.shout is assigned and Number.prototype.half defined: unknown values + expect(ret("addedMember")).toBe("undefined | ⊤"); + expect(ret("definedMember")).toBe("undefined | ⊤"); + // a name no prototype has and no write adds + expect(ret("missingMember")).toBe("undefined"); + // RegExp.prototype.exec: a group that did not take part is undefined + expect(ret("execGroups")).toBe("string | undefined"); +}); diff --git a/packages/compiler/test/speculative-loop-emission.test.ts b/packages/compiler/test/speculative-loop-emission.test.ts index 3f6aef158..d26a0c96c 100644 --- a/packages/compiler/test/speculative-loop-emission.test.ts +++ b/packages/compiler/test/speculative-loop-emission.test.ts @@ -129,3 +129,57 @@ if (isMainThread) { expect(order.slice(slow, fast).some((l) => l.startsWith("integer.leave"))).toBe(true); expect(order.slice(fast).some((l) => l.startsWith("integer.leave"))).toBe(true); }); + +test("the shared-storage copy of a typed-array version keeps its speculative copy", async () => { + // In a program that can start a worker, the guard requires thread-owned + // storage, so every view of a SharedArrayBuffer runs the checked copy. + const llvm = await emitLlvm(` +import { Worker, isMainThread } from "node:worker_threads"; +function trim(value: number, bits: number): number { + return value - Math.floor(value / Math.pow(2, bits)) * Math.pow(2, bits); +} +function key(bytes: Uint8Array, bits: number): number { + let k = 0; + for (let i = 0; i < bytes.length; i++) k = trim(k, bits) * 4 + bytes[i]!; + return k; +} +if (isMainThread) { + if (process.argv[2] === "spawn") new Worker(new URL(import.meta.url)); + const shared = new Uint8Array(new SharedArrayBuffer(4)); + shared.set([1, 2, 3, 0]); + console.log(key(shared, 6), key(new Uint8Array([3, 2, 1, 0]), 6)); +} +`); + const fn = /^define internal double @sc_bf_\w*key[\w.]*\([^]*?^}/m.exec(llvm)?.[0] ?? ""; + const order = labels(fn); + const slow = order.findIndex((l) => l.startsWith("bytes.version.slow")); + const fast = order.findIndex((l) => l.startsWith("bytes.version.fast")); + expect(slow).toBeGreaterThanOrEqual(0); + expect(fast).toBeGreaterThan(slow); + expect(order.slice(slow, fast).some((l) => l.startsWith("spec.fast"))).toBe(true); + expect(order.slice(fast).some((l) => l.startsWith("spec.fast"))).toBe(true); +}); + +test("a floor-mod by a power of two speculates as a mask and a product by one as a shift", async () => { + const llvm = await emitLlvm(` +function trim(value: number, bits: number): number { + return value - Math.floor(value / Math.pow(2, bits)) * Math.pow(2, bits); +} +function key(bytes: Uint8Array, bits: number): number { + let k = 0; + for (let i = 0; i < bytes.length; i++) k = trim(k, bits) * 4 + bytes[i]!; + return k; +} +console.log(key(new Uint8Array([1, 2, 3, 0]), 6)); +`); + const fn = /^define internal double @sc_bf_\w*key[\w.]*\([^]*?^}/m.exec(llvm)?.[0] ?? ""; + const fast = fn.slice(fn.search(/^spec\.fast\d+:/m)); + expect(fast.length).toBeLessThan(fn.length); + // trim(k, bits): the low `bits` bits; * 4: a shift by two. + expect(fast).toMatch(/= sub i64 %\S+, 1\n\s+%\S+ = and i64 %\S+, %\S+\n/); + expect(fast).toMatch(/= shl nsw i64 %\S+, 2\n/); + // No floor division of k (an arithmetic shift of it) and no checked + // multiply remain. + expect(fast).not.toMatch(/ashr i64 %/); + expect(fast).not.toContain("@llvm.smul.with.overflow.i64"); +}); diff --git a/packages/compiler/test/string-char-loop-emission.test.ts b/packages/compiler/test/string-char-loop-emission.test.ts index 6f5756ce8..9f2553d85 100644 --- a/packages/compiler/test/string-char-loop-emission.test.ts +++ b/packages/compiler/test/string-char-loop-emission.test.ts @@ -43,8 +43,12 @@ console.log(spaces("a b c"), letters("abC1z"), chars("xyz").join("-"), chars("é ll.match( new RegExp(`define internal [^\\n]+ @sc_bf_${name}\\([^\\n]*\\) #0 \\{[\\s\\S]*?\\n\\}`), )![0]; - // The fast copy follows the checked one. - const fast = (fn: string): string => fn.slice(fn.lastIndexOf("bytes.version.fast")); + // The byte copy follows the checked one, and the units copy (for + // strings that are not ASCII) follows it. + const fast = (fn: string): string => { + const units = fn.search(/^bytes\.version\.fast\.units\d+:/m); + return fn.slice(fn.search(/^bytes\.version\.fast\d+:/m), units < 0 ? undefined : units); + }; // `s[i] === " "` compares the byte; the checked copy keeps the helper. const spaces = body("spaces"); diff --git a/packages/compiler/test/string-scan-emission.test.ts b/packages/compiler/test/string-scan-emission.test.ts index 427e2d543..76242e3d4 100644 --- a/packages/compiler/test/string-scan-emission.test.ts +++ b/packages/compiler/test/string-scan-emission.test.ts @@ -37,7 +37,9 @@ console.log(scan("é-é", "ü-ü", "ö-ö"), at("é", 0.5)); expect(llvm).toContain("@scr_str_scan = external global"); const fallbacks = llvm .split("\n") - .filter((line) => /call double @scr_str_(utf16_len|char_code_at)\(/.test(line)); + .filter((line) => + /call (double|void) @scr_str_(utf16_len|char_code_at|scan_units)\(/.test(line), + ); expect(fallbacks.length).toBeGreaterThanOrEqual(3); for (const line of fallbacks) expect(line).not.toMatch(/\bmemory\(/); } finally { diff --git a/packages/compiler/test/string-unit-loop-emission.test.ts b/packages/compiler/test/string-unit-loop-emission.test.ts new file mode 100644 index 000000000..fa11af007 --- /dev/null +++ b/packages/compiler/test/string-unit-loop-emission.test.ts @@ -0,0 +1,152 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +test("scans of strings that are not ASCII read their UTF-16 units", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-unit-loops-")); + try { + const entry = join(dir, "main.ts"); + await writeFile( + entry, + ` +function spaces(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 32) n++; + return n; +} +function skip(s: string): number { + let pos = 0; + while (pos < s.length && s.charCodeAt(pos) !== 32) pos++; + return pos; +} +function pairs(s: string, t: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === t.charCodeAt(i % t.length)) n++; + return n; +} +let text = ""; +for (let k = 0; k < 400; k++) text += k % 3 === 0 ? "café " : "naïve façade "; +const other = text.replace(/a/g, "ä"); +let total = 0; +for (let pass = 0; pass < 4; pass++) + total += spaces(text) + spaces(other) + pairs(text, other) + skip(text); +console.log(total, spaces("plain ascii text"), spaces("")); +`, + ); + const outPath = join(dir, "main.ir.json"); + const lowered = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!lowered.ok) + throw new Error(lowered.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const ll = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8"))); + const body = (name: string): string => + ll.match( + new RegExp( + `define internal [^\\n]+ @sc_bf_${name}(?:\\.i32)?\\([^\\n]*\\) #0 \\{[\\s\\S]*?\\n\\}`, + ), + )![0]; + + const spaces = body("spaces"); + // A string that is not ASCII is converted to units and published as the + // scan receiver once, before the loop, and its copy loads units. + expect(spaces).toMatch(/call void @scr_str_scan_units\(ptr %\S+\)/); + // The copies follow the checked one: bytes, then units. + const bytesAt = spaces.search(/^bytes\.version\.fast\d+:/m); + const unitsAt = spaces.search(/^bytes\.version\.fast\.units\d+:/m); + expect(bytesAt).toBeGreaterThan(0); + expect(unitsAt).toBeGreaterThan(bytesAt); + const units = spaces.slice(unitsAt); + expect(units).toMatch(/load i16, ptr %\S+\n\s+%\S+ = uitofp i16/); + expect(units).not.toContain("@sc_str_char_code_at_int"); + // An ASCII string still reads bytes. + const bytes = spaces.slice(bytesAt, unitsAt); + expect(bytes).toMatch(/load i8, ptr %\S+\n\s+%\S+ = uitofp i8/); + expect(bytes).not.toContain("@sc_str_char_code_at_int"); + // A while loop over a counter below the length reads units too. + const skip = body("skip"); + expect(skip).toMatch(/^bytes\.version\.fast\.units\d+:/m); + // Its fast copies read the length the guard took; the checked copy + // still asks for it. + const skipFastAt = skip.search(/^bytes\.version\.fast\d+:/m); + expect(skip.slice(0, skipFastAt)).toContain("@sc_str_utf16_len"); + expect(skip.slice(skipFastAt)).not.toContain("@sc_str_utf16_len"); + // Two strings cannot both be the scan receiver. + expect(body("pairs")).not.toContain("bytes.version.fast.units"); + + const exe = join(dir, "main"); + const built = await compile(entry, { outputKind: "exe", outDir: dir, outPath: exe }); + if (!built.ok) throw new Error(JSON.stringify(built.diagnostics)); + let text = ""; + for (let k = 0; k < 400; k++) text += k % 3 === 0 ? "café " : "naïve façade "; + const other = text.replace(/a/g, "ä"); + const count = (s: string): number => s.split(" ").length - 1; + let pairs = 0; + for (let i = 0; i < text.length; i++) + if (text.charCodeAt(i) === other.charCodeAt(i % other.length)) pairs++; + const total = 4 * (count(text) + count(other) + pairs + text.indexOf(" ")); + expect(execFileSync(exe, { encoding: "utf8" })).toBe(`${total} 2 0\n`); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("loops that start at each token publish the scan receiver only when it changed", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-unit-republish-")); + try { + const entry = join(dir, "main.ts"); + await writeFile( + entry, + ` +function words(s: string): number { + let n = 0, + pos = 0; + while (pos < s.length) { + while (pos < s.length && s.charCodeAt(pos) !== 32) pos++; + while (pos < s.length && s.charCodeAt(pos) === 32) pos++; + n++; + } + return n; +} +let text = ""; +for (let k = 0; k < 400; k++) text += k % 3 === 0 ? "café " : "naïve façade "; +console.log(words(text), words(text.replace(/a/g, "ä")), words("plain ascii")); +`, + ); + const outPath = join(dir, "main.ir.json"); + const lowered = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!lowered.ok) + throw new Error(lowered.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const ll = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8"))); + const words = ll.match( + /define internal [^\n]+ @sc_bf_words(?:\.i32)?\([^\n]*\) #0 \{[\s\S]*?\n\}/, + )![0]; + // Each inner loop's entry compares the scan receiver with `s` and calls + // the runtime to publish `s` only when they differ. + const guards = [ + ...words.matchAll( + /^bytes\.version\.publish\d+:[^\n]*\n\s+(%\S+) = load ptr, ptr @scr_str_scan\n\s+(%\S+) = icmp eq ptr \1, (%\S+)\n\s+br i1 \2, label %(bytes\.version\.publish\.load\d+), label %(bytes\.version\.publish\.units\d+)\n/gm, + ), + ]; + expect(guards.length).toBeGreaterThanOrEqual(2); + for (const [, , , s, load, convert] of guards) { + const block = words.slice(words.search(new RegExp(`^${convert}:`, "m"))); + expect(block).toMatch( + new RegExp( + `^${convert}:[^\\n]*\\n\\s+call void @scr_str_scan_units\\(ptr ${s}\\)\\n\\s+br label %${load}\\n`, + ), + ); + } + const calls = words.match(/call void @scr_str_scan_units\(ptr %\S+\)/g)!; + expect(calls.length).toBe(guards.length); + + const exe = join(dir, "main"); + const built = await compile(entry, { outputKind: "exe", outDir: dir, outPath: exe }); + if (!built.ok) throw new Error(JSON.stringify(built.diagnostics)); + expect(execFileSync(exe, { encoding: "utf8" })).toBe("666 666 2\n"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6308-number-map-integer-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6308-number-map-integer-keys.ts.json new file mode 100644 index 000000000..b58240a3e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6308-number-map-integer-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6308-number-map-integer-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6928-class-iterator-protocol.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6928-class-iterator-protocol.ts.json new file mode 100644 index 000000000..2efd8521a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6928-class-iterator-protocol.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6928-class-iterator-protocol.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6929-class-iterator-protocol.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6929-class-iterator-protocol.mjs.json new file mode 100644 index 000000000..49e5e4304 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6929-class-iterator-protocol.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6929-class-iterator-protocol.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6935-class-async-iterator-protocol.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6935-class-async-iterator-protocol.ts.json new file mode 100644 index 000000000..93775b96b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6935-class-async-iterator-protocol.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6935-class-async-iterator-protocol.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6936-class-async-iterator-protocol.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6936-class-async-iterator-protocol.mjs.json new file mode 100644 index 000000000..378f7d18b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6936-class-async-iterator-protocol.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6936-class-async-iterator-protocol.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6936-x509-certificate-fields.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6936-x509-certificate-fields.ts.json new file mode 100644 index 000000000..2f7224882 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6936-x509-certificate-fields.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6936-x509-certificate-fields.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6937-class-value-arrays.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6937-class-value-arrays.mjs.json new file mode 100644 index 000000000..eefa82916 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6937-class-value-arrays.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6937-class-value-arrays.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6938-class-value-arrays.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6938-class-value-arrays.ts.json new file mode 100644 index 000000000..ff2a51546 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6938-class-value-arrays.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6938-class-value-arrays.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7090-x509-check-names.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7090-x509-check-names.ts.json new file mode 100644 index 000000000..e375f8d72 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7090-x509-check-names.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7090-x509-check-names.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7091-x509-certificate-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7091-x509-certificate-keys.ts.json new file mode 100644 index 000000000..d4e9c4a6d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7091-x509-certificate-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7091-x509-certificate-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7092-x509-legacy-object.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7092-x509-legacy-object.ts.json new file mode 100644 index 000000000..fd0b42f36 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7092-x509-legacy-object.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7092-x509-legacy-object.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7093-x509-methods-js.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7093-x509-methods-js.mjs.json new file mode 100644 index 000000000..deeeb0047 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7093-x509-methods-js.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7093-x509-methods-js.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7095-tls-peer-certificates.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7095-tls-peer-certificates.ts.json new file mode 100644 index 000000000..b6ebaa3e2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7095-tls-peer-certificates.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7095-tls-peer-certificates.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7096-tls-peer-certificates.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7096-tls-peer-certificates.mjs.json new file mode 100644 index 000000000..2b85b584a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7096-tls-peer-certificates.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7096-tls-peer-certificates.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7097-tls-client-certificates.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7097-tls-client-certificates.ts.json new file mode 100644 index 000000000..ecd6a35d7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7097-tls-client-certificates.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7097-tls-client-certificates.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7098-tls-client-certificates-paths.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7098-tls-client-certificates-paths.ts.json new file mode 100644 index 000000000..8be352a07 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7098-tls-client-certificates-paths.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7098-tls-client-certificates-paths.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7099-tls-client-certificates.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7099-tls-client-certificates.mjs.json new file mode 100644 index 000000000..9dc6fbc9f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7099-tls-client-certificates.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7099-tls-client-certificates.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7160-json-parse-deep-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7160-json-parse-deep-unions.ts.json new file mode 100644 index 000000000..fb4ef3c1f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7160-json-parse-deep-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7160-json-parse-deep-unions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7161-dynamic-values-deep-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7161-dynamic-values-deep-unions.ts.json new file mode 100644 index 000000000..8ad1579da --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7161-dynamic-values-deep-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7161-dynamic-values-deep-unions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7186-namespace-object-values.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7186-namespace-object-values.js.json new file mode 100644 index 000000000..a130b4af6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7186-namespace-object-values.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7186-namespace-object-values.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7187-namespace-object-reflection.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7187-namespace-object-reflection.js.json new file mode 100644 index 000000000..b2d9bc751 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7187-namespace-object-reflection.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7187-namespace-object-reflection.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7188-namespace-object-extensions.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7188-namespace-object-extensions.js.json new file mode 100644 index 000000000..96cbad8c2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7188-namespace-object-extensions.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7188-namespace-object-extensions.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7304-string-optional-char-reads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7304-string-optional-char-reads.ts.json new file mode 100644 index 000000000..36ea6f8ec --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7304-string-optional-char-reads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7304-string-optional-char-reads.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7305-string-unit-loop-versions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7305-string-unit-loop-versions.ts.json new file mode 100644 index 000000000..21b8255e4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7305-string-unit-loop-versions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7305-string-unit-loop-versions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7307-string-unit-loop-evictions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7307-string-unit-loop-evictions.ts.json new file mode 100644 index 000000000..cb5864986 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7307-string-unit-loop-evictions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7307-string-unit-loop-evictions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7381-function-value-arguments-length.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7381-function-value-arguments-length.ts.json new file mode 100644 index 000000000..b9e5eacd1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7381-function-value-arguments-length.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7381-function-value-arguments-length.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7407-frozen-class-instances.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7407-frozen-class-instances.ts.json new file mode 100644 index 000000000..da868130c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7407-frozen-class-instances.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7407-frozen-class-instances.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7408-frozen-js-class-symbol-fields.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7408-frozen-js-class-symbol-fields.js.json new file mode 100644 index 000000000..a57d1c73b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7408-frozen-js-class-symbol-fields.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7408-frozen-js-class-symbol-fields.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7440-tls-server-add-context.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7440-tls-server-add-context.ts.json new file mode 100644 index 000000000..8acc4aade --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7440-tls-server-add-context.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7440-tls-server-add-context.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7441-tls-client-error.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7441-tls-client-error.ts.json new file mode 100644 index 000000000..ee5e78748 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7441-tls-client-error.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7441-tls-client-error.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7442-tls-server-contexts.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7442-tls-server-contexts.mjs.json new file mode 100644 index 000000000..22eda3ac8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7442-tls-server-contexts.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7442-tls-server-contexts.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7443-tls-final-flight.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7443-tls-final-flight.ts.json new file mode 100644 index 000000000..2a8d134cc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7443-tls-final-flight.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7443-tls-final-flight.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7444-tls-final-flight.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7444-tls-final-flight.mjs.json new file mode 100644 index 000000000..98f68721a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7444-tls-final-flight.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7444-tls-final-flight.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7454-tls-peer-embedded-pem.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7454-tls-peer-embedded-pem.ts.json new file mode 100644 index 000000000..e4300cd15 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7454-tls-peer-embedded-pem.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7454-tls-peer-embedded-pem.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7455-tls-als-contexts.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7455-tls-als-contexts.mjs.json new file mode 100644 index 000000000..cd99090a6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7455-tls-als-contexts.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7455-tls-als-contexts.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7503-pipeline-untyped-sources.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7503-pipeline-untyped-sources.mjs.json new file mode 100644 index 000000000..4d93af132 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7503-pipeline-untyped-sources.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7503-pipeline-untyped-sources.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7504-pipeline-untyped-source-errors.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7504-pipeline-untyped-source-errors.mjs.json new file mode 100644 index 000000000..19402586b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7504-pipeline-untyped-source-errors.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7504-pipeline-untyped-source-errors.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7505-pipeline-untyped-sources.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7505-pipeline-untyped-sources.cjs.json new file mode 100644 index 000000000..29d85351d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7505-pipeline-untyped-sources.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7505-pipeline-untyped-sources.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7506-pipeline-any-source.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7506-pipeline-any-source.ts.json new file mode 100644 index 000000000..942a2b3e8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7506-pipeline-any-source.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7506-pipeline-any-source.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7507-stream-write-error-drain.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7507-stream-write-error-drain.mjs.json new file mode 100644 index 000000000..6fa55d09d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7507-stream-write-error-drain.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7507-stream-write-error-drain.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7514-stream-after-write-order.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7514-stream-after-write-order.ts.json new file mode 100644 index 000000000..a6c705cd7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7514-stream-after-write-order.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7514-stream-after-write-order.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7621-class-methods-foreign-receivers.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7621-class-methods-foreign-receivers.mjs.json new file mode 100644 index 000000000..8f50b09a3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7621-class-methods-foreign-receivers.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7621-class-methods-foreign-receivers.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7742-weak-collections-many-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7742-weak-collections-many-keys.ts.json new file mode 100644 index 000000000..e1a75c14e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7742-weak-collections-many-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7742-weak-collections-many-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7757-speculative-key-chains.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7757-speculative-key-chains.ts.json new file mode 100644 index 000000000..274151b18 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7757-speculative-key-chains.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7757-speculative-key-chains.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7983-integer-compares.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7983-integer-compares.ts.json new file mode 100644 index 000000000..f7c61e778 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7983-integer-compares.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7983-integer-compares.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7985-untyped-collection-types.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7985-untyped-collection-types.mjs.json new file mode 100644 index 000000000..a807063cc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7985-untyped-collection-types.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7985-untyped-collection-types.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8003-while-reversed-byte-reads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8003-while-reversed-byte-reads.ts.json new file mode 100644 index 000000000..26031ac1f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8003-while-reversed-byte-reads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8003-while-reversed-byte-reads.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8006-buffer-base64-decode.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8006-buffer-base64-decode.ts.json new file mode 100644 index 000000000..135fe4af6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8006-buffer-base64-decode.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8006-buffer-base64-decode.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8020-date-setters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8020-date-setters.ts.json new file mode 100644 index 000000000..5b23d8ecd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8020-date-setters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8020-date-setters.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8021-date-coercions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8021-date-coercions.ts.json new file mode 100644 index 000000000..176788025 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8021-date-coercions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8021-date-coercions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8022-date-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8022-date-unions.ts.json new file mode 100644 index 000000000..ab8c55b7b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8022-date-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8022-date-unions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8023-date-clone-throw.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8023-date-clone-throw.ts.json new file mode 100644 index 000000000..934d260d8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8023-date-clone-throw.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8023-date-clone-throw.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8024-date-setters-dynamic.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8024-date-setters-dynamic.mjs.json new file mode 100644 index 000000000..505f6305d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8024-date-setters-dynamic.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8024-date-setters-dynamic.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8025-date-subclass-setters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8025-date-subclass-setters.ts.json new file mode 100644 index 000000000..f1c4d20e0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8025-date-subclass-setters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8025-date-subclass-setters.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8026-date-subclass-setters-dynamic.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8026-date-subclass-setters-dynamic.mjs.json new file mode 100644 index 000000000..8a0fe9b5a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8026-date-subclass-setters-dynamic.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8026-date-subclass-setters-dynamic.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8027-date-setter-order.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8027-date-setter-order.ts.json new file mode 100644 index 000000000..cfdfddfc6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8027-date-setter-order.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8027-date-setter-order.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8028-date-setter-order-dynamic.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8028-date-setter-order-dynamic.mjs.json new file mode 100644 index 000000000..0a1b69c5f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8028-date-setter-order-dynamic.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8028-date-setter-order-dynamic.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8062-assert-message-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8062-assert-message-values.cjs.json new file mode 100644 index 000000000..78ba99298 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8062-assert-message-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8062-assert-message-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8065-cjs-top-level-return/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8065-cjs-top-level-return/main.js.json new file mode 100644 index 000000000..22edecf3f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8065-cjs-top-level-return/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8065-cjs-top-level-return/early.js", + "/tests/corpus/8065-cjs-top-level-return/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8066-object-hasown-delete.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8066-object-hasown-delete.cjs.json new file mode 100644 index 000000000..bf862b8f2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8066-object-hasown-delete.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8066-object-hasown-delete.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8067-assert-throws-validators.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8067-assert-throws-validators.cjs.json new file mode 100644 index 000000000..89f59e97c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8067-assert-throws-validators.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8067-assert-throws-validators.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8076-assert-instance-identity.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8076-assert-instance-identity.cjs.json new file mode 100644 index 000000000..b4212e234 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8076-assert-instance-identity.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8076-assert-instance-identity.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8078-instanceof-object-function.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8078-instanceof-object-function.cjs.json new file mode 100644 index 000000000..90af753d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8078-instanceof-object-function.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8078-instanceof-object-function.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8114-record-field-slices.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8114-record-field-slices.ts.json new file mode 100644 index 000000000..58ce71d85 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8114-record-field-slices.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8114-record-field-slices.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8116-destructured-results.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8116-destructured-results.ts.json new file mode 100644 index 000000000..f19871791 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8116-destructured-results.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8116-destructured-results.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8117-scalar-slice-bounds.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8117-scalar-slice-bounds.ts.json new file mode 100644 index 000000000..1e12a8cd2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8117-scalar-slice-bounds.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8117-scalar-slice-bounds.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8176-tls-empty-context-ca.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8176-tls-empty-context-ca.ts.json new file mode 100644 index 000000000..536027066 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8176-tls-empty-context-ca.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8176-tls-empty-context-ca.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8177-tls-empty-client-ca.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8177-tls-empty-client-ca.ts.json new file mode 100644 index 000000000..b28587594 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8177-tls-empty-client-ca.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8177-tls-empty-client-ca.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8179-tls-reject-unauthorized-values.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8179-tls-reject-unauthorized-values.mjs.json new file mode 100644 index 000000000..ef98570e2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8179-tls-reject-unauthorized-values.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8179-tls-reject-unauthorized-values.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8181-map-get-borrows.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8181-map-get-borrows.ts.json new file mode 100644 index 000000000..39f43aa85 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8181-map-get-borrows.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8181-map-get-borrows.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8181-x509-raw-writes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8181-x509-raw-writes.ts.json new file mode 100644 index 000000000..d92d8f8fd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8181-x509-raw-writes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8181-x509-raw-writes.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8182-typed-array-alias-scopes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8182-typed-array-alias-scopes.ts.json new file mode 100644 index 000000000..c941e8b5d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8182-typed-array-alias-scopes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8182-typed-array-alias-scopes.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8190-assert-function-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8190-assert-function-values.cjs.json new file mode 100644 index 000000000..730dd6360 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8190-assert-function-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8190-assert-function-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8191-assert-function-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8191-assert-function-values.ts.json new file mode 100644 index 000000000..0837dc2ee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8191-assert-function-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8191-assert-function-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8193-counted-loop-products.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8193-counted-loop-products.ts.json new file mode 100644 index 000000000..7a3b97b6c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8193-counted-loop-products.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8193-counted-loop-products.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8194-row-pair-sums.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8194-row-pair-sums.ts.json new file mode 100644 index 000000000..afb53a915 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8194-row-pair-sums.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8194-row-pair-sums.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8195-wrapping-products-near-2-53.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8195-wrapping-products-near-2-53.ts.json new file mode 100644 index 000000000..234bcebd5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8195-wrapping-products-near-2-53.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8195-wrapping-products-near-2-53.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8196-counted-loops-integer-starts.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8196-counted-loops-integer-starts.ts.json new file mode 100644 index 000000000..6569a8326 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8196-counted-loops-integer-starts.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8196-counted-loops-integer-starts.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8214-unshimmed-package-version/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8214-unshimmed-package-version/main.ts.json new file mode 100644 index 000000000..6a6a885dc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8214-unshimmed-package-version/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8214-unshimmed-package-version/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8216-module-namespace-live-values/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8216-module-namespace-live-values/main.ts.json new file mode 100644 index 000000000..d856e307e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8216-module-namespace-live-values/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8216-module-namespace-live-values/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8217-intl-segmenter-word-construct/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8217-intl-segmenter-word-construct/main.ts.json new file mode 100644 index 000000000..d2ad44948 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8217-intl-segmenter-word-construct/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8217-intl-segmenter-word-construct/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8218-cjs-namespace-imports/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8218-cjs-namespace-imports/main.mjs.json new file mode 100644 index 000000000..d627f9153 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8218-cjs-namespace-imports/main.mjs.json @@ -0,0 +1,9 @@ +{ + "order": [ + "/tests/corpus/8218-cjs-namespace-imports/table.cjs", + "/tests/corpus/8218-cjs-namespace-imports/client/transport.js", + "/tests/corpus/8218-cjs-namespace-imports/client/index.mjs", + "/tests/corpus/8218-cjs-namespace-imports/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8219-cjs-stable-var-exports/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8219-cjs-stable-var-exports/main.js.json new file mode 100644 index 000000000..71e59739d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8219-cjs-stable-var-exports/main.js.json @@ -0,0 +1,9 @@ +{ + "order": [ + "/tests/corpus/8219-cjs-stable-var-exports/lib/core.js", + "/tests/corpus/8219-cjs-stable-var-exports/lib/index.js", + "/tests/corpus/8219-cjs-stable-var-exports/lib/options.js", + "/tests/corpus/8219-cjs-stable-var-exports/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8220-js-class-static-destructuring/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8220-js-class-static-destructuring/main.js.json new file mode 100644 index 000000000..7a2ede696 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8220-js-class-static-destructuring/main.js.json @@ -0,0 +1,9 @@ +{ + "order": [ + "/tests/corpus/8220-js-class-static-destructuring/classes/comparator.js", + "/tests/corpus/8220-js-class-static-destructuring/ranges/outside.js", + "/tests/corpus/8220-js-class-static-destructuring/formdata.js", + "/tests/corpus/8220-js-class-static-destructuring/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8221-js-recursive-generator-returns/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8221-js-recursive-generator-returns/main.ts.json new file mode 100644 index 000000000..003137231 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8221-js-recursive-generator-returns/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8221-js-recursive-generator-returns/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8222-js-class-static-reflection.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8222-js-class-static-reflection.js.json new file mode 100644 index 000000000..2e961ad08 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8222-js-class-static-reflection.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8222-js-class-static-reflection.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8223-argument-count-destructured-parameter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8223-argument-count-destructured-parameter.ts.json new file mode 100644 index 000000000..6cae202d3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8223-argument-count-destructured-parameter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8223-argument-count-destructured-parameter.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8230-readline-interface-constructor.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8230-readline-interface-constructor.cjs.json new file mode 100644 index 000000000..05573974c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8230-readline-interface-constructor.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8230-readline-interface-constructor.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8231-builtin-member-binding-new.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8231-builtin-member-binding-new.cjs.json new file mode 100644 index 000000000..1f54f4886 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8231-builtin-member-binding-new.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8231-builtin-member-binding-new.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8232-native-streams-untyped.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8232-native-streams-untyped.cjs.json new file mode 100644 index 000000000..2b07cde52 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8232-native-streams-untyped.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8232-native-streams-untyped.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8233-string-decoder-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8233-string-decoder-values.cjs.json new file mode 100644 index 000000000..a734fb48c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8233-string-decoder-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8233-string-decoder-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8234-handle-typeof.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8234-handle-typeof.ts.json new file mode 100644 index 000000000..7f37dbc5d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8234-handle-typeof.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8234-handle-typeof.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8241-literal-result-slices.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8241-literal-result-slices.ts.json new file mode 100644 index 000000000..9cd832dca --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8241-literal-result-slices.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8241-literal-result-slices.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8242-string-scan-republish.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8242-string-scan-republish.ts.json new file mode 100644 index 000000000..78a4f52e6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8242-string-scan-republish.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8242-string-scan-republish.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8243-json-parse-discriminated-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8243-json-parse-discriminated-unions.ts.json new file mode 100644 index 000000000..96ef2084c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8243-json-parse-discriminated-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8243-json-parse-discriminated-unions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8244-speculative-floor-mod-powers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8244-speculative-floor-mod-powers.ts.json new file mode 100644 index 000000000..65e7ccb38 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8244-speculative-floor-mod-powers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8244-speculative-floor-mod-powers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8260-class-prototype-views.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8260-class-prototype-views.mjs.json new file mode 100644 index 000000000..6cf42e773 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8260-class-prototype-views.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8260-class-prototype-views.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8270-fs-promises-member.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8270-fs-promises-member.cjs.json new file mode 100644 index 000000000..eb3938040 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8270-fs-promises-member.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8270-fs-promises-member.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8271-cjs-top-level-return.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8271-cjs-top-level-return.cjs.json new file mode 100644 index 000000000..2fcecbe67 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8271-cjs-top-level-return.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8271-cjs-top-level-return.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8272-fs-stream-call-without-new.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8272-fs-stream-call-without-new.cjs.json new file mode 100644 index 000000000..7fdb114e4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8272-fs-stream-call-without-new.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8272-fs-stream-call-without-new.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8273-fs-read-write-argument-checks.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8273-fs-read-write-argument-checks.cjs.json new file mode 100644 index 000000000..60e64d369 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8273-fs-read-write-argument-checks.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8273-fs-read-write-argument-checks.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8291-frozen-record-union-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8291-frozen-record-union-keys.ts.json new file mode 100644 index 000000000..3dcadeb0a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8291-frozen-record-union-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8291-frozen-record-union-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8300-native-array-capsule-reads/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8300-native-array-capsule-reads/main.ts.json new file mode 100644 index 000000000..fb6e3a0bf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8300-native-array-capsule-reads/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8300-native-array-capsule-reads/lib.cjs", + "/tests/corpus/8300-native-array-capsule-reads/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8302-class-super-method-values/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8302-class-super-method-values/main.ts.json new file mode 100644 index 000000000..24fe2decc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8302-class-super-method-values/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8302-class-super-method-values/lib.cjs", + "/tests/corpus/8302-class-super-method-values/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8320-assign-only-deferred-binding/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8320-assign-only-deferred-binding/main.ts.json new file mode 100644 index 000000000..399baa239 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8320-assign-only-deferred-binding/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8320-assign-only-deferred-binding/scanner.ts", + "/tests/corpus/8320-assign-only-deferred-binding/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8326-never-promise-instanceof.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8326-never-promise-instanceof.ts.json new file mode 100644 index 000000000..d10bc2c18 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8326-never-promise-instanceof.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8326-never-promise-instanceof.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8330-optional-element-undefined-receivers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8330-optional-element-undefined-receivers.ts.json new file mode 100644 index 000000000..ef78d4391 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8330-optional-element-undefined-receivers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8330-optional-element-undefined-receivers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8344-readline-chunk-splitting.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8344-readline-chunk-splitting.ts.json new file mode 100644 index 000000000..8c01e6b47 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8344-readline-chunk-splitting.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8344-readline-chunk-splitting.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/absent-property-reads.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/absent-property-reads.mjs.json new file mode 100644 index 000000000..3c0646fa9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/absent-property-reads.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/absent-property-reads.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/symbol-property-key-reuse.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/symbol-property-key-reuse.mjs.json new file mode 100644 index 000000000..e8fb5f353 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/symbol-property-key-reuse.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/symbol-property-key-reuse.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/text-decoder-undefined-input.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/text-decoder-undefined-input.ts.json new file mode 100644 index 000000000..a1f5f49d9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/text-decoder-undefined-input.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/text-decoder-undefined-input.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-value-arguments-length.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-value-arguments-length.ts.json new file mode 100644 index 000000000..6aa894918 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-value-arguments-length.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/function-value-arguments-length.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/x509-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/x509-fences.ts.json new file mode 100644 index 000000000..e2b83a7c2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/x509-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/x509-fences.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-client-certificates.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-client-certificates.ts.json new file mode 100644 index 000000000..449e0f3ac --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-client-certificates.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/tls-client-certificates.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-peer-certificates.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-peer-certificates.ts.json new file mode 100644 index 000000000..c5f8ed40a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-peer-certificates.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/tls-peer-certificates.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-server-contexts.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-server-contexts.ts.json new file mode 100644 index 000000000..73e294ea2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-server-contexts.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/tls-server-contexts.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/x509-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/x509-methods.ts.json new file mode 100644 index 000000000..c42f8f9cc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/x509-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/x509-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/shape-overlay-facts/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/shape-overlay-facts/main.ts.json new file mode 100644 index 000000000..f01de4ae5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/shape-overlay-facts/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/shape-overlay-facts/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/static-objects-order/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/static-objects-order/main.ts.json new file mode 100644 index 000000000..c8706884e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/static-objects-order/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/static-objects-order/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/var-export-redeclared/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/var-export-redeclared/main.ts.json new file mode 100644 index 000000000..ccc1a3653 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/var-export-redeclared/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/var-export-redeclared/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/typed-array-alias-scope-emission.test.ts b/packages/compiler/test/typed-array-alias-scope-emission.test.ts new file mode 100644 index 000000000..c94b58d59 --- /dev/null +++ b/packages/compiler/test/typed-array-alias-scope-emission.test.ts @@ -0,0 +1,64 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +test("a typed-array loop version proves its views disjoint and scopes their accesses", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-alias-scopes-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile( + entry, + ` +function copy(dst: Float64Array, src: Float64Array, n: number): void { + for (let i = 0; i < n; i++) dst[i] = src[i]! + 1; +} +function scale(xs: Float64Array, n: number): void { + for (let i = 0; i < n; i++) xs[i] = xs[i]! * 2; +} +function dot(a: Float64Array, b: Float64Array, n: number): number { + let sum = 0; + for (let i = 0; i < n; i++) sum += a[i]! * b[i]!; + return sum; +} +const a = new Float64Array(8), b = new Float64Array(8); +copy(a, b, 8); +scale(a, 8); +console.log(dot(a, b, 8)); +`, + ); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + const ll = emitLlvmModule(mod); + const fn = (name: string): string => + new RegExp(`^define internal [^\\n]*@sc_bf_${name}(?:\\.i32)?\\([^]*?^}`, "m").exec(ll)![0]; + // Two views, one written: the guard compares their byte ranges, and + // each view's accesses in the fast copy carry its own scope. + const copy = fn("copy"); + expect(copy).toMatch(/ptrtoint ptr %\w+ to i64/); + expect(copy).toMatch(/icmp ule i64 %\w+, %\w+/); + expect(copy).toMatch( + /load double, ptr %\w+, align 1[^\n]*!alias\.scope !\{!\{!"copy\.0\.\d", !\{!"scriptc typed-array views"\}\}\}, !noalias/, + ); + expect(copy).toMatch(/store double %\w+, ptr %\w+, align 1[^\n]*!alias\.scope/); + // The fast copy declares both scopes where it starts, so they hold for + // this entry only, and declares nothing else. + for (const receiver of [0, 1]) + expect(copy).toContain( + `call void @llvm.experimental.noalias.scope.decl(metadata !{!{!"copy.0.${receiver}", !{!"scriptc typed-array views"}}})`, + ); + expect(copy.match(/noalias\.scope\.decl/g)).toHaveLength(2); + // One view, or views that are only read, need no scopes. + for (const name of ["scale", "dot"]) { + expect(fn(name)).not.toContain("!alias.scope"); + expect(fn(name)).not.toContain("ptrtoint"); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/while-bytes-bounds-emission.test.ts b/packages/compiler/test/while-bytes-bounds-emission.test.ts new file mode 100644 index 000000000..a2604eed1 --- /dev/null +++ b/packages/compiler/test/while-bytes-bounds-emission.test.ts @@ -0,0 +1,150 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function emitLlvm(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-while-bounds-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile(entry, source); + const result = await compile(entry, { + outDir: dir, + outPath, + outputKind: "llvm", + }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + return await readFile(outPath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The number of bounds-checked element reads and writes in a function's + * statement body (the borrowed-argument body when there is one). */ +function checks(ll: string, name: string): number { + const definition = (prefix: string): number => + ll.search(new RegExp(`define [^\\n]*@${prefix}_${name}\\(`)); + const start = definition("sc_bf") >= 0 ? definition("sc_bf") : definition("sc_f"); + expect(start).toBeGreaterThanOrEqual(0); + const fn = ll.slice(start, ll.indexOf("\n}\n", start)); + return fn.split("label %bytes.index.valid").length - 1; +} + +test("reads from the end of a typed array in a while loop are proven in bounds", async () => { + const ll = await emitLlvm(` +function code(c: number): number { + switch (c) { + case 65: return 84; + case 84: return 65; + } + return 0; +} +function reversed(line: Uint8Array): number { + const len = line.length; + const right = len - 1; + let i = 0; + let total = 0; + while (i < len) { + let c = line[right - i]!; + i++; + if (c === 10) { + if (i === len) break; + c = line[right - i]!; + i++; + } + total += code(c); + } + return total; +} +function pairs(values: Uint16Array): number { + let i = 0; + let total = 0; + while (values.length > i) { + i += 2; + total += values[i - 2]!; + if (i < values.length) total += values[values.length - 1 - i]!; + } + return total; +} +console.log(reversed(new Uint8Array([65, 10, 84])), pairs(new Uint16Array([1, 2, 3]))); +`); + expect(checks(ll, "reversed")).toBe(0); + expect(checks(ll, "pairs")).toBe(0); +}); + +test("reads a while loop does not keep in bounds keep their checks", async () => { + const ll = await emitLlvm(` +function inclusive(line: Uint8Array): number { + const len = line.length; + let i = 0; + let total = 0; + while (i <= len) { + total += line[len - i]!; + i++; + } + return total; +} +function unguarded(line: Uint8Array): number { + const right = line.length - 1; + let i = 0; + let total = 0; + while (i < line.length) { + total += line[right - i]!; + i++; + total += line[right - i]!; + } + return total; +} +function ahead(values: Uint16Array): number { + let i = 0; + let total = 0; + while (i < values.length) { + i += 2; + total += values[i - 1]!; + } + return total; +} +function nested(line: Uint8Array): number { + const len = line.length; + let i = 0; + let total = 0; + while (i < len) { + const c = line[len - 1 - i]!; + i++; + while (i < len && line[len - 1 - i]! === c) i++; + total += line[len - 1 - i]!; + } + return total; +} +function comma(line: Uint8Array): number { + const len = line.length; + let i = 0; + let total = 0; + while (i < len) { + if ((i++, i > 0)) total += line[len - 1 - i]!; + total += line[len - 1 - i]!; + } + return total; +} +console.log( + comma(new Uint8Array(2)), + inclusive(new Uint8Array(2)), + unguarded(new Uint8Array(2)), + ahead(new Uint16Array(3)), + nested(new Uint8Array(3)), +); +`); + // The increment in the comma expression is not a statement of the + // body, so the loop has no bound and both reads keep their checks. + expect(checks(ll, "comma")).toBe(2); + expect(checks(ll, "inclusive")).toBe(1); + // The read before the increment is in bounds; the one after it is not. + expect(checks(ll, "unguarded")).toBe(1); + expect(checks(ll, "ahead")).toBe(1); + // The read before the nested loop is in bounds. The nested loop's + // condition is not a bound, and the read after the loop can be at -1. + expect(checks(ll, "nested")).toBe(2); +}); diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index b6b855c05..84c3b83b6 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -366,7 +366,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: RUNTIME_PACK_MATRIX.target, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime-linux-arm64-gnu/scripts/build.mjs b/packages/runtime-linux-arm64-gnu/scripts/build.mjs index 729c3fbb6..d16fb1528 100644 --- a/packages/runtime-linux-arm64-gnu/scripts/build.mjs +++ b/packages/runtime-linux-arm64-gnu/scripts/build.mjs @@ -24,6 +24,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ }, ...toolchain, compileFlags: ["-ffunction-sections", "-fdata-sections"], - systemLibraries: [{ name: "m", predicate: true }], + // scr_unwind_raise: the static libgcc_eh, which loads no shared library. + systemLibraries: [ + { name: "m", predicate: true }, + { name: "gcc_eh", predicate: true }, + ], }); await import("../../runtime-pack-common/scripts/build.mjs"); diff --git a/packages/runtime-linux-arm64-musl/scripts/build.mjs b/packages/runtime-linux-arm64-musl/scripts/build.mjs index 10635d857..6c9b38244 100644 --- a/packages/runtime-linux-arm64-musl/scripts/build.mjs +++ b/packages/runtime-linux-arm64-musl/scripts/build.mjs @@ -26,6 +26,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ }, targetArgs: ["-target", "aarch64-linux-musl"], compileFlags: ["-ffunction-sections", "-fdata-sections"], - systemLibraries: [{ name: "m", predicate: true }], + // scr_unwind_raise: Zig's libunwind. + systemLibraries: [ + { name: "m", predicate: true }, + { name: "unwind", predicate: true }, + ], }); await import("../../runtime-pack-common/scripts/build.mjs"); diff --git a/packages/runtime-linux-x64-gnu/scripts/build.mjs b/packages/runtime-linux-x64-gnu/scripts/build.mjs index 811bb537b..53df5f7e7 100644 --- a/packages/runtime-linux-x64-gnu/scripts/build.mjs +++ b/packages/runtime-linux-x64-gnu/scripts/build.mjs @@ -24,6 +24,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ }, ...toolchain, compileFlags: ["-ffunction-sections", "-fdata-sections"], - systemLibraries: [{ name: "m", predicate: true }], + // scr_unwind_raise: the static libgcc_eh, which loads no shared library. + systemLibraries: [ + { name: "m", predicate: true }, + { name: "gcc_eh", predicate: true }, + ], }); await import("../../runtime-pack-common/scripts/build.mjs"); diff --git a/packages/runtime-linux-x64-musl/scripts/build.mjs b/packages/runtime-linux-x64-musl/scripts/build.mjs index 33fc4c3d1..88538de76 100644 --- a/packages/runtime-linux-x64-musl/scripts/build.mjs +++ b/packages/runtime-linux-x64-musl/scripts/build.mjs @@ -26,6 +26,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ }, targetArgs: ["-target", "x86_64-linux-musl"], compileFlags: ["-ffunction-sections", "-fdata-sections"], - systemLibraries: [{ name: "m", predicate: true }], + // scr_unwind_raise: Zig's libunwind. + systemLibraries: [ + { name: "m", predicate: true }, + { name: "unwind", predicate: true }, + ], }); await import("../../runtime-pack-common/scripts/build.mjs"); diff --git a/packages/runtime-pack-common/library-matrix.mjs b/packages/runtime-pack-common/library-matrix.mjs index d9878ec7c..1a621a375 100644 --- a/packages/runtime-pack-common/library-matrix.mjs +++ b/packages/runtime-pack-common/library-matrix.mjs @@ -13,6 +13,7 @@ export function withLibraryRuntimeFlavors(matrix) { "scr_events_emitter.c", "scr_dyn_handle.c", "scr_zlib.c", + "scr_x509.c", "scr_copying.c", "scr_dyn_invoke.c", "scr_bytes_dyn.c", diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 600637ba6..b4868d5cb 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -30,6 +30,7 @@ export const BASE_RUNTIME_SOURCES = [ "scr_object.c", "scr_union.c", "scr_exception.c", + "scr_unwind.c", "scr_error.c", "scr_console.c", "scr_lib.c", @@ -80,6 +81,8 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_perf.c", "dynAsync"], // perf_hooks event-loop metrics, only for programs that read them. ["scr_perf_loop.c", "perfLoop"], + // Module namespace objects as checked-dynamic values. + ["scr_module_namespace.c", "moduleNamespace"], // Blob and File box their reads as dyn promises through scr_async_dyn.c. ["scr_blob.c", "dynAsync"], ["scr_zlib.c", "zlib"], @@ -164,6 +167,9 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_webcrypto.c", "webcrypto"], ["scr_webcrypto_pki.c", all("webcrypto", "pki")], ["scr_ed25519.c", all("pki", "ed25519")], + ["scr_x509.c", "x509"], + // The TLS socket certificate accessors read scr_tls.c and build with scr_x509.c. + ["scr_tls_x509.c", all("tlsEffective", "x509")], // WebCrypto's AES ciphers run over the cipher feature's mbedTLS archive. ["scr_webcrypto_aes.c", all("cipher", "webcrypto")], ["scr_fetch.c", "nativeFetch"], diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index 479d8aad4..545c6651b 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -469,7 +469,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: matrix.target, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime-wasm32-wasi/scripts/build.mjs b/packages/runtime-wasm32-wasi/scripts/build.mjs index cbe744e4e..2a2766f4b 100644 --- a/packages/runtime-wasm32-wasi/scripts/build.mjs +++ b/packages/runtime-wasm32-wasi/scripts/build.mjs @@ -26,6 +26,7 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ "scr_pki.c", "scr_webcrypto_pki.c", "scr_ed25519.c", + "scr_tls_x509.c", "scr_webcrypto_aes.c", "scr_wasm.c", "scr_fetch.c", diff --git a/packages/runtime/src/scr_assert.c b/packages/runtime/src/scr_assert.c index 4917b1291..bf5d1c875 100644 --- a/packages/runtime/src/scr_assert.c +++ b/packages/runtime/src/scr_assert.c @@ -212,7 +212,56 @@ bool scr_assert_skip_prototype(void) { return (g_instance_options & SCR_ASSERT_SKIP_PROTOTYPE) != 0; } +/* A message argument that is not a string (an untyped value, or an Error + * to throw): the compiled call registers it before the assertion and passes + * SCR_ASSERT_VALUE_MESSAGE as its message, which the failure paths compose + * like any custom message. A failure whose message starts with it throws + * the Error itself (innerFail's `message instanceof Error`) or puts + * String(value) in its place with generatedMessage `!value`, as Node's + * AssertionError does. Matching the sentinel keeps a registration that no + * failure consumed from reaching another assertion. */ +static SCR_TL ScrDyn *g_message_value; +static SCR_TL bool g_message_error; + +void scr_assert_message_value(ScrDyn *value, bool error) { + if (g_message_value) scr_dyn_release(g_message_value); + g_message_value = scr_dyn_retain(value); + g_message_error = error; +} + +void scr_assert_message_clear(void) { + if (g_message_value) scr_dyn_release(g_message_value); + g_message_value = NULL; +} + void scr_assert_fail(ScrStr *message, bool generated) { + static const char sentinel[] = SCR_ASSERT_VALUE_MESSAGE; + const size_t n = sizeof sentinel - 1; + if (g_message_value && message->len >= n && memcmp(message->data, sentinel, n) == 0) { + ScrDyn *value = g_message_value; + g_message_value = NULL; + if (g_message_error) { + scr_str_release(message); + scr_assert_record(NULL, NULL, NULL); + g_instance_options = 0; + scr_dyn_throw(value); + return; + } + ScrStr *text = scr_dyn_string_coerce_js(value); + generated = !scr_dyn_truthy(value); + scr_dyn_release(value); + if (!text) { + scr_str_release(message); + return; + } + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_write(&b, text->data, text->len); + scr_jb_write(&b, message->data + n, message->len - n); + scr_str_release(text); + scr_str_release(message); + message = scr_jb_finish(&b); + } ScrError *e = scr_assert_error(message, generated, scr_assert_diff_full() ? "full" : "simple"); g_instance_options = 0; scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); @@ -2703,6 +2752,60 @@ bool scr_assert_no_error_predicate(ScrDyn *predicate, ScrDyn *actual) { return matched; } +/* assert.throws/rejects with a validation function (expectedException's + * function branch, after its instanceof check): the function runs with a + * fresh object as `this` and the caught value, and any result but true + * fails, with Node's generated message unless the call passed one. The + * caught value and the function are the AssertionError's actual and + * expected. Borrows all. */ +void scr_assert_throws_validate(ScrDyn *actual, ScrDyn *validate, ScrStr *op, ScrStr *msg, + bool has_msg) { + ScrDyn *receiver = scr_dyn_new_obj(); + scr_dyn_this_push_dyn(receiver); + ScrDyn *result = scr_dyn_call(validate, &actual, 1, "expected"); + scr_dyn_this_pop(); + scr_dyn_release(receiver); + if (!result) return; + if (result->kind == SCR_DYN_BOOL && result->v.b) { + scr_dyn_release(result); + return; + } + ScrStr *message; + bool generated = !(has_msg && msg->len > 0); + if (!generated) { + message = scr_str_retain(msg); + } else { + ScrAssertBuf b = {0}; + ab_cstr(&b, "The "); + ScrDyn *name = scr_dyn_fn_get(validate, "name", 4); + if (name && name->kind == SCR_DYN_STR && name->v.str->len > 0) { + ab_cstr(&b, "\""); + ab_bytes(&b, name->v.str->data, name->v.str->len); + ab_cstr(&b, "\" "); + } + if (name) scr_dyn_release(name); + ab_cstr(&b, "validation function is expected to return \"true\". Received "); + ScrStr *shown = scr_assert_cf_inspect(result); + ab_bytes(&b, shown->data, shown->len); + scr_str_release(shown); + if (scr_dyn_ref_is_error(actual)) { + ScrStr *text = scr_dyn_string_coerce_js(actual); + if (!text) { + scr_dyn_release(result); + scr_str_release(ab_take(&b)); + return; + } + ab_cstr(&b, "\n\nCaught error:\n\n"); + ab_bytes(&b, text->data, text->len); + scr_str_release(text); + } + message = ab_take(&b); + } + scr_dyn_release(result); + scr_assert_record_dyn(actual, validate, op); + scr_assert_fail(message, generated); +} + /* ── assert.ifError ─────────────────────────────────────────────────── * Node throws for ANY value but null/undefined (falsy included); the * frontend routes null/undefined to a no-op and everything else here. @@ -2879,3 +2982,225 @@ ScrError *scr_assert_error_new(ScrDyn *options) { scr_assert_attach_values(e, generated, actual, expected, op, diff); return e; } + +/* ── assert functions as values ────────────────────────────────────────── + * `assert.fail` stored or passed as a function (`emitter.on("x", + * assert.fail)`): one function per method, which takes its arguments as + * untyped values and asserts as the method does. */ + +/* Node's message argument: absent or null/undefined (a generated message), + * a string, an Error (thrown when the assertion fails), or another value + * (String(value)). The value is registered as scr_assert_fail reads it; the + * caller clears it after the assertion. +1 into *msg. */ +static void scr_av_message(ScrDyn *const *args, size_t argc, size_t i, ScrStr **msg, bool *has) { + const ScrDyn *m = i < argc ? args[i] : NULL; + if (!m || m->kind == SCR_DYN_UNDEF || m->kind == SCR_DYN_NULL) { + *msg = scr_str_new("", 0); + *has = false; + return; + } + *has = true; + if (m->kind == SCR_DYN_STR) { + *msg = scr_str_retain(m->v.str); + return; + } + bool error = scr_dyn_ref_is_error(m) || + (m->kind == SCR_DYN_OBJ && scr_dyn_obj_get(m, "%error", 6) != NULL); + scr_assert_message_value((ScrDyn *)m, error); + *msg = scr_str_new(SCR_ASSERT_VALUE_MESSAGE, sizeof SCR_ASSERT_VALUE_MESSAGE - 1); +} + +static const ScrDyn *scr_av_arg(ScrDyn *const *args, size_t argc, size_t i) { + return i < argc ? args[i] : scr_dyn_undefined(); +} + +static ScrDyn *scr_av_done(ScrStr *msg) { + scr_str_release(msg); + scr_assert_message_clear(); + return scr_dyn_retain(scr_dyn_undefined()); +} + +/* assert(value, message) and assert.ok: Node's generated message quotes + * the call's source, which a function value has no fixed site for; the + * message here is AssertionError's own `value == true`. */ +static ScrDyn *scr_av_ok(ScrClosure *c, ScrDyn *const *args, size_t argc) { + (void)c; + const ScrDyn *value = scr_av_arg(args, argc, 0); + if (scr_dyn_truthy(value)) return scr_dyn_retain(scr_dyn_undefined()); + scr_assert_record(scr_dyn_retain((ScrDyn *)value), scr_dyn_new_bool(true), "=="); + if (argc == 0) { + static const char text[] = "No value argument passed to `assert.ok()`"; + scr_assert_fail(scr_str_new(text, sizeof text - 1), true); + return NULL; + } + ScrStr *msg; + bool has; + scr_av_message(args, argc, 1, &msg, &has); + if (has) { + scr_assert_fail(scr_str_retain(msg), false); + return scr_av_done(msg); + } + ScrStr *shown = scr_assert_cf_inspect(value); + ScrAssertBuf b = {0}; + ab_str(&b, shown); + ab_cstr(&b, " == true"); + scr_str_release(shown); + scr_assert_fail(ab_take(&b), true); + return scr_av_done(msg); +} + +/* assert.fail(message): "Failed" without a message. */ +static ScrDyn *scr_av_fail(ScrClosure *c, ScrDyn *const *args, size_t argc) { + (void)c; + if (argc > 1) { + static const char text[] = "assert.fail with more than one argument through a function value is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, text, sizeof text - 1, "SC2020"); + return NULL; + } + ScrStr *msg; + bool has; + scr_av_message(args, argc, 0, &msg, &has); + scr_assert_record(scr_dyn_retain(scr_dyn_undefined()), scr_dyn_retain(scr_dyn_undefined()), "fail"); + if (has) scr_assert_fail(scr_str_retain(msg), false); + else scr_assert_fail(scr_str_new("Failed", 6), true); + return scr_av_done(msg); +} + +/* The comparison methods take both operands: Node's ERR_MISSING_ARGS + * TypeError otherwise. False with it pending. */ +static bool scr_av_operands(size_t argc) { + if (argc >= 2) return true; + static const char text[] = "The \"actual\" and \"expected\" arguments must be specified"; + scr_throw_error_msg_code(SCR_ERR_TYPE, text, sizeof text - 1, "ERR_MISSING_ARGS"); + return false; +} + +/* equal / notEqual: Node's loose comparison, NaN equal to NaN. */ +static ScrDyn *scr_av_loose(ScrDyn *const *args, size_t argc, bool negated) { + if (!scr_av_operands(argc)) return NULL; + const ScrDyn *a = scr_av_arg(args, argc, 0), *b = scr_av_arg(args, argc, 1); + bool nan = a->kind == SCR_DYN_NUM && b->kind == SCR_DYN_NUM && isnan(a->v.num) && isnan(b->v.num); + bool equal = nan || scr_dyn_abstract_eq(a, b); + if (scr_exc_pending()) return NULL; + if (negated ? !equal : equal) return scr_dyn_retain(scr_dyn_undefined()); + ScrStr *msg; + bool has; + scr_av_message(args, argc, 2, &msg, &has); + scr_assert_record(scr_dyn_retain((ScrDyn *)a), scr_dyn_retain((ScrDyn *)b), negated ? "!=" : "=="); + ScrStr *ia = scr_assert_cf_inspect(a), *ib = scr_assert_cf_inspect(b); + scr_assert_loose_result(equal, negated, ia, ib, msg, has); + scr_str_release(ia); + scr_str_release(ib); + return scr_av_done(msg); +} + +static ScrDyn *scr_av_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_loose(a, n, false); +} +static ScrDyn *scr_av_not_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_loose(a, n, true); +} + +/* The strict and deep comparisons, through the untyped assert paths. */ +enum { SCR_AV_STRICT, SCR_AV_DEEP_STRICT, SCR_AV_DEEP, SCR_AV_PARTIAL }; + +static ScrDyn *scr_av_compare(ScrDyn *const *args, size_t argc, int form, bool negated) { + if (!scr_av_operands(argc)) return NULL; + ScrDyn *a = (ScrDyn *)scr_av_arg(args, argc, 0), *b = (ScrDyn *)scr_av_arg(args, argc, 1); + ScrStr *msg; + bool has; + scr_av_message(args, argc, 2, &msg, &has); + if (form == SCR_AV_DEEP) scr_assert_loose_deep_dyn(a, b, negated, msg, has); + else if (form == SCR_AV_PARTIAL) scr_assert_partial_deep_dyn(a, b, msg, has); + else scr_assert_eq_dyn(a, b, negated, form == SCR_AV_DEEP_STRICT, msg, has); + return scr_av_done(msg); +} + +static ScrDyn *scr_av_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_STRICT, false); +} +static ScrDyn *scr_av_not_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_STRICT, true); +} +static ScrDyn *scr_av_deep_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP_STRICT, false); +} +static ScrDyn *scr_av_not_deep_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP_STRICT, true); +} +static ScrDyn *scr_av_deep_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP, false); +} +static ScrDyn *scr_av_not_deep_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP, true); +} +static ScrDyn *scr_av_partial_deep_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_PARTIAL, false); +} + +static ScrDyn *scr_av_if_error(ScrClosure *c, ScrDyn *const *args, size_t argc) { + (void)c; + scr_assert_iferror_dyn(scr_av_arg(args, argc, 0)); + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); +} + +static const struct { + const char *name; + ScrDynThunk thunk; + uint32_t arity; +} scr_av_methods[] = { + {"ok", &scr_av_ok, 0}, + {"fail", &scr_av_fail, 1}, + {"equal", &scr_av_equal, 3}, + {"notEqual", &scr_av_not_equal, 3}, + {"strictEqual", &scr_av_strict_equal, 3}, + {"notStrictEqual", &scr_av_not_strict_equal, 3}, + {"deepEqual", &scr_av_deep_equal, 3}, + {"notDeepEqual", &scr_av_not_deep_equal, 3}, + {"deepStrictEqual", &scr_av_deep_strict_equal, 3}, + {"notDeepStrictEqual", &scr_av_not_deep_strict_equal, 3}, + {"partialDeepStrictEqual", &scr_av_partial_deep_strict_equal, 3}, + {"ifError", &scr_av_if_error, 1}, +}; +#define SCR_AV_COUNT (sizeof scr_av_methods / sizeof scr_av_methods[0]) +static SCR_TL ScrDyn *scr_av_values[SCR_AV_COUNT]; +static SCR_TL bool scr_av_registered; + +static void scr_av_cleanup(void) { + for (size_t i = 0; i < SCR_AV_COUNT; i++) { + scr_dyn_release(scr_av_values[i]); + scr_av_values[i] = NULL; + } + scr_av_registered = false; +} + +/* The function value of an assert method by its canonical name (the frontend + * maps assert/strict's aliases), +1; one value per method, so stored copies + * compare equal. */ +ScrDyn *scr_assert_method_value(ScrStr *name) { + for (size_t i = 0; i < SCR_AV_COUNT; i++) { + if (strlen(scr_av_methods[i].name) != name->len || + memcmp(scr_av_methods[i].name, name->data, name->len) != 0) + continue; + if (!scr_av_values[i]) { + if (!scr_av_registered) { + scr_av_registered = true; + scr_atexit(scr_av_cleanup); + } + scr_av_values[i] = scr_dyn_new_func(scr_closure_new(NULL, 0), scr_av_methods[i].thunk, + scr_av_methods[i].arity, "native:assert", + scr_av_methods[i].name); + } + return scr_dyn_retain(scr_av_values[i]); + } + return scr_dyn_retain(scr_dyn_undefined()); +} diff --git a/packages/runtime/src/scr_assert.h b/packages/runtime/src/scr_assert.h index 8037f08ec..57158a69a 100644 --- a/packages/runtime/src/scr_assert.h +++ b/packages/runtime/src/scr_assert.h @@ -24,6 +24,19 @@ void scr_assert_fail(ScrStr *message, bool generated); /* takes ownership; alway * consumes them; a passing call clears them. */ void scr_assert_set_options(int options); void scr_assert_clear_options(void); +/* A message argument that is not a string, for the next assertion that + * fails with SCR_ASSERT_VALUE_MESSAGE as its message (scr_assert.c): an + * Error (`error`) is thrown as it is, anything else reports String(value). + * Borrows value; clear drops a registration the call did not consume. */ +#define SCR_ASSERT_VALUE_MESSAGE "\x01scriptc:assert-message-value" +void scr_assert_message_value(struct ScrDyn *value, bool error); +void scr_assert_message_clear(void); +/* An assert method as a function value, by canonical name (+1). */ +struct ScrDyn *scr_assert_method_value(ScrStr *name); +/* assert.throws/rejects with a validation function: its result must be + * true (Node's expectedException), else the AssertionError. Borrows all. */ +void scr_assert_throws_validate(struct ScrDyn *actual, struct ScrDyn *validate, ScrStr *op, + ScrStr *msg, bool has_msg); bool scr_assert_diff_full(void); bool scr_assert_skip_prototype(void); void scr_assert_record(ScrDyn *actual, ScrDyn *expected, const char *op); /* values move in */ diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 0ae774df7..f9c6944c2 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1275,7 +1275,7 @@ static ScrClosure *scr_als_bind(ScrClosure *cb /*moves*/); /* A tick runs with the AsyncLocalStorage context active when it was * queued, as Node's process.nextTick does. */ void scr_next_tick(ScrClosure *cb /*moves*/) { - ScrNtick *t = calloc(1, sizeof *t); + ScrNtick *t = scr_mem_calloc(sizeof *t); if (!t) scr_oom(); t->cb = scr_als_bind(cb); if (scr_nt_tail) scr_nt_tail->next = t; @@ -1288,7 +1288,7 @@ void scr_next_tick(ScrClosure *cb /*moves*/) { * adapter ABI also used by fs.rename: emitted adapters construct the * program's Error | null union, and NULL selects its null arm. */ void scr_process_write_callback(ScrClosure *cb /*moves*/, ScrFsRenameFn fn) { - ScrNtick *t = calloc(1, sizeof *t); + ScrNtick *t = scr_mem_calloc(sizeof *t); if (!t) scr_oom(); t->cb = cb; t->error_cb = fn; @@ -1303,7 +1303,7 @@ void scr_process_write_callback(ScrClosure *cb /*moves*/, ScrFsRenameFn fn) { * endReadableNT, ...). The hook dispatches exactly one stream tick; * teardown just drops markers (the stream queue owns its entries). */ void scr_next_tick_raw(void (*fn)(void)) { - ScrNtick *t = calloc(1, sizeof *t); + ScrNtick *t = scr_mem_calloc(sizeof *t); if (!t) scr_oom(); t->raw = fn; if (scr_nt_tail) scr_nt_tail->next = t; @@ -1314,7 +1314,7 @@ void scr_next_tick_raw(void (*fn)(void)) { /* A raw tick at the front of the queue: it runs before the ticks already * queued. */ static void scr_next_tick_raw_first(void (*fn)(void)) { - ScrNtick *t = calloc(1, sizeof *t); + ScrNtick *t = scr_mem_calloc(sizeof *t); if (!t) scr_oom(); t->raw = fn; t->next = scr_nt_head; @@ -1327,7 +1327,7 @@ void scr_nticks_teardown(void) { ScrNtick *t = scr_nt_head; scr_nt_head = t->next; if (t->cb) scr_closure_release(t->cb); - free(t); + scr_mem_free(t); } scr_nt_tail = NULL; } @@ -4435,7 +4435,7 @@ static bool scr_loop_run_ticks(void) { ScrClosure *cb = t->cb; ScrFsRenameFn error_cb = t->error_cb; void (*raw)(void) = t->raw; - free(t); + scr_mem_free(t); if (cb) { if (error_cb) error_cb(cb, NULL); else ((void (*)(ScrClosure *))cb->fn)(cb); diff --git a/packages/runtime/src/scr_bigint.c b/packages/runtime/src/scr_bigint.c index 5af2eafaa..69b7009fc 100644 --- a/packages/runtime/src/scr_bigint.c +++ b/packages/runtime/src/scr_bigint.c @@ -269,11 +269,15 @@ static inline bi_limb bi_subc(bi_limb x, bi_limb y, bi_limb *borrow) { static bi_limb bi_add_n(bi_limb *r, const bi_limb *a, const bi_limb *b, size_t n) { bi_limb carry = 0; size_t i = 0; - for (; i + 4 <= n; i += 4) { + for (; i + 8 <= n; i += 8) { r[i] = bi_addc(a[i], b[i], &carry); r[i + 1] = bi_addc(a[i + 1], b[i + 1], &carry); r[i + 2] = bi_addc(a[i + 2], b[i + 2], &carry); r[i + 3] = bi_addc(a[i + 3], b[i + 3], &carry); + r[i + 4] = bi_addc(a[i + 4], b[i + 4], &carry); + r[i + 5] = bi_addc(a[i + 5], b[i + 5], &carry); + r[i + 6] = bi_addc(a[i + 6], b[i + 6], &carry); + r[i + 7] = bi_addc(a[i + 7], b[i + 7], &carry); } for (; i < n; i++) r[i] = bi_addc(a[i], b[i], &carry); return carry; @@ -283,11 +287,15 @@ static bi_limb bi_add_n(bi_limb *r, const bi_limb *a, const bi_limb *b, size_t n static bi_limb bi_sub_n(bi_limb *r, const bi_limb *a, const bi_limb *b, size_t n) { bi_limb borrow = 0; size_t i = 0; - for (; i + 4 <= n; i += 4) { + for (; i + 8 <= n; i += 8) { r[i] = bi_subc(a[i], b[i], &borrow); r[i + 1] = bi_subc(a[i + 1], b[i + 1], &borrow); r[i + 2] = bi_subc(a[i + 2], b[i + 2], &borrow); r[i + 3] = bi_subc(a[i + 3], b[i + 3], &borrow); + r[i + 4] = bi_subc(a[i + 4], b[i + 4], &borrow); + r[i + 5] = bi_subc(a[i + 5], b[i + 5], &borrow); + r[i + 6] = bi_subc(a[i + 6], b[i + 6], &borrow); + r[i + 7] = bi_subc(a[i + 7], b[i + 7], &borrow); } for (; i < n; i++) r[i] = bi_subc(a[i], b[i], &borrow); return borrow; @@ -409,6 +417,23 @@ static bi_limb bi_submul1(bi_limb *r, const bi_limb *a, size_t n, bi_limb m) { return carry; } +/* y[0..n) = the low n limbs of x << shift, for 0 < shift < BI_LIMB_BITS + * and y disjoint from x. Each limb is formed from two source limbs with no + * dependency between limbs, so the loop vectorizes. */ +static void bi_lshift(bi_limb *restrict y, const bi_limb *restrict x, size_t n, unsigned shift) { + unsigned back = BI_LIMB_BITS - shift; + y[0] = x[0] << shift; + for (size_t i = 1; i < n; i++) y[i] = (x[i] << shift) | (x[i - 1] >> back); +} + +/* y[0..n) = x[0..n) >> shift, for 0 < shift < BI_LIMB_BITS and y disjoint + * from x; like bi_lshift, each limb reads two source limbs. */ +static void bi_rshift(bi_limb *restrict y, const bi_limb *restrict x, size_t n, unsigned shift) { + unsigned back = BI_LIMB_BITS - shift; + for (size_t i = 0; i + 1 < n; i++) y[i] = (x[i] >> shift) | (x[i + 1] << back); + y[n - 1] = x[n - 1] >> shift; +} + /* Schoolbook product: r[0..an+bn) = a * b, r disjoint from a and b. */ static void bi_mul_school(bi_limb *r, const bi_limb *a, size_t an, const bi_limb *b, size_t bn) { r[an] = bi_mul1(r, a, an, b[0], 0); @@ -589,11 +614,9 @@ static void bi_divmod_knuth(bi_limb *q, bi_limb *r, const bi_limb *a, size_t an, bi_limb *un = bi_limbs(an + 1 + bn); bi_limb *vn = un + an + 1; if (s) { - for (size_t i = bn - 1; i > 0; i--) vn[i] = (b[i] << s) | (b[i - 1] >> (BI_LIMB_BITS - s)); - vn[0] = b[0] << s; + bi_lshift(vn, b, bn, s); + bi_lshift(un, a, an, s); un[an] = a[an - 1] >> (BI_LIMB_BITS - s); - for (size_t i = an - 1; i > 0; i--) un[i] = (a[i] << s) | (a[i - 1] >> (BI_LIMB_BITS - s)); - un[0] = a[0] << s; } else { memcpy(vn, b, bn * sizeof(bi_limb)); memcpy(un, a, an * sizeof(bi_limb)); @@ -631,8 +654,7 @@ static void bi_divmod_knuth(bi_limb *q, bi_limb *r, const bi_limb *a, size_t an, } if (r) { if (s) { - for (size_t i = 0; i + 1 < bn; i++) r[i] = (un[i] >> s) | (un[i + 1] << (BI_LIMB_BITS - s)); - r[bn - 1] = un[bn - 1] >> s; + bi_rshift(r, un, bn, s); } else { memcpy(r, un, bn * sizeof(bi_limb)); } @@ -674,6 +696,15 @@ static bi_limb bi_div_qlimb(bi_limb *r, const bi_limb *a, const bi_limb *b, size rhat += d1; rhat_big = rhat < d1; } + /* Without a remainder, the top limbs decide when they leave room: + * a - qhat * b >= (rhat * B + u0 - qhat * d0) * B^(n-2) - qhat * B^(n-2) + * as normalized, so a margin of at least qhat proves qhat. */ + if (!r && !rhat_big) { + bi_wide top = ((bi_wide)rhat << BI_LIMB_BITS) | u0, sub = (bi_wide)qhat * d0; + if (top - sub >= qhat) return qhat; + } else if (!r) { + return qhat; /* rhat >= B: the remainder exceeds B^(n-1) >= qhat * b's low part */ + } /* a - qhat * b: negative when the product's high limb or the borrow * reaches past a's n limbs */ bi_limb carry = 0, borrow = 0; @@ -926,13 +957,8 @@ static ScrBigInt *bi_shl_abs(const ScrBigInt *a, size_t count, int sign) { if (!shift) { memcpy(r->limb + words, a->limb, a->len * sizeof(bi_limb)); } else { - bi_limb carry = 0; - for (size_t i = 0; i < a->len; i++) { - bi_limb v = a->limb[i]; - r->limb[i + words] = (v << shift) | carry; - carry = v >> (BI_LIMB_BITS - shift); - } - if (grow) r->limb[n] = carry; + bi_lshift(r->limb + words, a->limb, a->len, shift); + if (grow) r->limb[n] = a->limb[a->len - 1] >> (BI_LIMB_BITS - shift); } r->len = n + grow; r->sign = sign; @@ -955,9 +981,7 @@ static ScrBigInt *bi_shr_abs(const ScrBigInt *a, size_t count, int sign, bool *d if (!shift) { memcpy(r->limb, a->limb + words, n * sizeof(bi_limb)); } else { - for (size_t i = 0; i + 1 < n; i++) - r->limb[i] = (a->limb[i + words] >> shift) | (a->limb[i + words + 1] << (BI_LIMB_BITS - shift)); - r->limb[n - 1] = a->limb[a->len - 1] >> shift; + bi_rshift(r->limb, a->limb + words, n, shift); } return bi_finish(r, n, sign); } diff --git a/packages/runtime/src/scr_blob.h b/packages/runtime/src/scr_blob.h index 15f4a10d4..0882e41b6 100644 --- a/packages/runtime/src/scr_blob.h +++ b/packages/runtime/src/scr_blob.h @@ -1,5 +1,6 @@ -/* scriptc runtime — Blob and File (scr_blob.c). Included by scr_runtime.h, - * which says where new declarations go. */ +/* scriptc runtime — Blob and File (scr_blob.c), and module namespace + * objects as checked-dynamic values (scr_module_namespace.c). Included by + * scr_runtime.h, which says where new declarations go. */ #ifndef SCR_BLOB_H #define SCR_BLOB_H @@ -23,9 +24,16 @@ ScrPromise *scr_blob_array_buffer(ScrDyn *self); bool scr_blob_is(const ScrDyn *value, double kind); ScrDyn *scr_intl_segmenter_new(void); +/* A word or sentence Intl.Segmenter whose segment() throws `refusal` + * (borrowed), the compiler's SC2020 message. */ +ScrDyn *scr_intl_segmenter_refusing(ScrStr *refusal); ScrStr *scr_process_builtin_id(ScrDyn *id, ScrArr *known); ScrDyn *scr_process_builtin_module(ScrStr *id, ScrDyn *getter); ScrDyn *scr_process_builtin_unsupported(ScrStr *id, ScrStr *member); +/* A module namespace object as a checked-dynamic value (scr_module_namespace.c): + * one handle per module `id`, whose reads call `getter` with the export + * name when `keys` lists it. Borrows all three; +1 handle. */ +ScrDyn *scr_module_namespace_value(ScrStr *id, ScrArr *keys, ScrDyn *getter); ScrDyn *scr_global_native(ScrArr *known); ScrDyn *scr_global_native_init(ScrArr *known, ScrDyn *(*console_get)(void)); ScrDyn *scr_console_native(void); diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index e6df837c8..31716604e 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -2672,20 +2672,66 @@ static ScrBytes *scr_bytes_conversion_storage(size_t capacity) { return scr_bytes_conversion_storage_in(NULL, capacity); } +/* scr_b64_values, each value shifted into its place in a four-character + * group's 24 bits; a character outside both alphabets sets bit 24. */ +#define SCR_B64_VALUE(c) \ + ((c) >= 'A' && (c) <= 'Z' ? (c) - 'A' \ + : (c) >= 'a' && (c) <= 'z' ? (c) - 'a' + 26 \ + : (c) >= '0' && (c) <= '9' ? (c) - '0' + 52 \ + : (c) == '+' || (c) == '-' ? 62 \ + : (c) == '/' || (c) == '_' ? 63 \ + : -1) +#define SCR_B64_AT(c, shift) \ + (SCR_B64_VALUE(c) < 0 ? UINT32_C(0x1000000) : (uint32_t)SCR_B64_VALUE(c) << (shift)) +#define SCR_B64_4(c, s) SCR_B64_AT(c, s), SCR_B64_AT(c + 1, s), SCR_B64_AT(c + 2, s), SCR_B64_AT(c + 3, s) +#define SCR_B64_16(c, s) SCR_B64_4(c, s), SCR_B64_4(c + 4, s), SCR_B64_4(c + 8, s), SCR_B64_4(c + 12, s) +#define SCR_B64_64(c, s) SCR_B64_16(c, s), SCR_B64_16(c + 16, s), SCR_B64_16(c + 32, s), SCR_B64_16(c + 48, s) +#define SCR_B64_256(s) SCR_B64_64(0, s), SCR_B64_64(64, s), SCR_B64_64(128, s), SCR_B64_64(192, s) +static const uint32_t scr_b64_shifted[4][256] = { + {SCR_B64_256(18)}, {SCR_B64_256(12)}, {SCR_B64_256(6)}, {SCR_B64_256(0)}}; + +/* One group's 24 bits as three bytes, in a four-byte store whose last byte + * the next store overwrites (the destination holds two spare bytes). */ +static inline void scr_b64_put_group(uint8_t *out, uint32_t bits) { +#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + uint32_t word = __builtin_bswap32(bits << 8); +#else + uint32_t word = bits << 8; +#endif + memcpy(out, &word, 4); +} + static ScrBytes *scr_bytes_decode_b64(ScrBytesSink *sink, const uint8_t *in, size_t n) { ScrBytes *b = scr_bytes_conversion_storage_in(sink, n / 4 * 3 + 2); + /* A local copy: a byte store could otherwise change b->data. */ + uint8_t *out = b->data; + const uint32_t(*t)[256] = scr_b64_shifted; size_t i = 0, o = 0; unsigned acc = 0, have = 0; while (i < n) { + /* Complete groups, two per step: each character's shifted value comes + * from a table and one bit test covers all eight. The second store + * ends at o + 6, within the destination while eight characters remain. */ + if (have == 0) { + while (n - i >= 8) { + uint32_t first = t[0][in[i]] | t[1][in[i + 1]] | t[2][in[i + 2]] | t[3][in[i + 3]]; + uint32_t second = t[0][in[i + 4]] | t[1][in[i + 5]] | t[2][in[i + 6]] | t[3][in[i + 7]]; + if ((first | second) & UINT32_C(0x1000000)) break; + scr_b64_put_group(out + o, first); + scr_b64_put_group(out + o + 3, second); + o += 6; + i += 8; + } + } /* Common complete groups need neither per-character branches nor state. */ if (have == 0 && n - i >= 4) { int a = scr_b64_values[in[i]], c = scr_b64_values[in[i + 1]]; int d = scr_b64_values[in[i + 2]], e = scr_b64_values[in[i + 3]]; if ((a | c | d | e) >= 0) { unsigned v = ((unsigned)a << 18) | ((unsigned)c << 12) | ((unsigned)d << 6) | (unsigned)e; - b->data[o++] = (uint8_t)(v >> 16); - b->data[o++] = (uint8_t)(v >> 8); - b->data[o++] = (uint8_t)v; + out[o++] = (uint8_t)(v >> 16); + out[o++] = (uint8_t)(v >> 8); + out[o++] = (uint8_t)v; i += 4; continue; } @@ -2696,17 +2742,17 @@ static ScrBytes *scr_bytes_decode_b64(ScrBytesSink *sink, const uint8_t *in, siz if (v < 0) continue; acc = (acc << 6) | (unsigned)v; if (++have == 4) { - b->data[o++] = (uint8_t)(acc >> 16); - b->data[o++] = (uint8_t)(acc >> 8); - b->data[o++] = (uint8_t)acc; + out[o++] = (uint8_t)(acc >> 16); + out[o++] = (uint8_t)(acc >> 8); + out[o++] = (uint8_t)acc; acc = 0; have = 0; } } - if (have == 2) b->data[o++] = (uint8_t)(acc >> 4); + if (have == 2) out[o++] = (uint8_t)(acc >> 4); else if (have == 3) { - b->data[o++] = (uint8_t)(acc >> 10); - b->data[o++] = (uint8_t)(acc >> 2); + out[o++] = (uint8_t)(acc >> 10); + out[o++] = (uint8_t)(acc >> 2); } b->len = o; return b; @@ -2722,7 +2768,9 @@ static ScrBytes *scr_bytes_encode_str(ScrBytesSink *sink, const ScrStr *s, const /* Node writes charCodeAt(i) & 0xFF for BOTH spellings — an astral * code point contributes its two surrogates' low bytes. */ ScrBytes *b = scr_bytes_conversion_storage_in(sink, n); - size_t o = 0, i = 0; + /* An ASCII prefix (all of an ASCII string) is its own encoding. */ + size_t o = s->ascii ? n : scr_bytes_ascii_prefix(in, n), i = o; + memcpy(b->data, in, o); while (i < n) { uint32_t cp = scr_bytes_next_cp(in, &i); if (cp > 0xffff) { @@ -2762,7 +2810,7 @@ static ScrBytes *scr_bytes_encode_str(ScrBytesSink *sink, const ScrStr *s, const /* Node interprets encoded text as the low byte of each UTF-16 unit. * ASCII can borrow storage; non-ASCII must preserve that truncation. */ ScrBytes *narrowed = NULL; - if (scr_bytes_ascii_prefix(in, n) != n) { + if (!scr_str_known_ascii(s) && scr_bytes_ascii_prefix(in, n) != n) { ScrStr *latin1 = scr_str_new("latin1", 6); narrowed = scr_bytes_encode_str(NULL, s, latin1); scr_str_release(latin1); diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 7c0a935cc..0710f3460 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -369,6 +369,12 @@ ScrDyn *scr_text_decoder_to_dyn(void *rec, void *(*retain)(void *), void (*relea void *scr_text_codec_from_dyn(const ScrDyn *d, bool decoder, const struct ScrDynPath *path, const char *want); bool scr_text_codec_is(const ScrDyn *d, bool decoder); +/* StringDecoder records as checked-dynamic handles (the same unit): the + * byte offsets of the encoding (string) and pending (f64) fields. */ +ScrDyn *scr_string_decoder_to_dyn(void *rec, void *(*retain)(void *), void (*release)(void *), + size_t encoding, size_t pending); +void *scr_string_decoder_from_dyn(const ScrDyn *d, const struct ScrDynPath *path, + const char *want); bool scr_buffer_is_ascii(const ScrDyn *input); bool scr_buffer_is_utf8(const ScrDyn *input); ScrBytes *scr_buffer_transcode(const ScrDyn *source, const ScrDyn *from, const ScrDyn *to); @@ -524,6 +530,8 @@ ScrDyn *scr_fs_lchmod_sync_chk(const ScrDyn *path, const ScrDyn *mode); ScrPromise *scr_fsp_lchmod_chk(const ScrDyn *path, const ScrDyn *mode); void scr_fs_read_chk(const ScrDyn *fd, const ScrDyn *buffer, const ScrDyn *offset, const ScrDyn *length, const ScrDyn *position, const ScrStr *fence); +double scr_fs_read_sync_dyn(const ScrDyn *fd, const ScrDyn *buffer, const ScrDyn *offset, + const ScrDyn *length, const ScrDyn *position); void scr_fs_stream_opts_chk(const ScrDyn *path, const ScrDyn *opts, const ScrStr *fence); /* The fs streams' option pieces (scr_fs_stream.c): the getOptions, * options.fd and path checks above without the fence; the checked path diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 253cad1da..daeb47c60 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -635,9 +635,12 @@ void scr_fs_cb_schedule(const ScrDyn *callback, ScrDyn *result, const ScrDyn *bu #endif } -static bool scr_fs_read_validate(const ScrDyn *, const ScrDyn *, const ScrDyn *, const ScrDyn *, const ScrDyn *); +static bool scr_fs_read_validate(const ScrDyn *, const ScrDyn *, const ScrDyn *, const ScrDyn *, const ScrDyn *, + double *); static bool scr_fs_encoding_chk(const ScrDyn *); static bool scr_fs_int_range_chk(const ScrDyn *, const char *, double, double, const char *); +static bool scr_fs_length_int32(const ScrDyn *, double *); +static bool scr_fs_write_validate(const ScrDyn *, const ScrDyn *, const ScrDyn *); static bool scr_fs_mode_chk(const ScrDyn *, const char *); /* Where Node's signature handling finds the callback: a fixed position, or @@ -856,7 +859,7 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc * (below); every other form checks it here, before its path. */ bool late = !strcmp(op, "access") || !strcmp(op, "copyFile") || !strcmp(op, "chmod") || !strcmp(op, "open") || !strcmp(op, "mkdir") || !strcmp(op, "fchmod") || !strcmp(op, "ftruncate") || !strcmp(op, "truncate") || - !strcmp(op, "read"); + !strcmp(op, "read") || !strcmp(op, "write"); if (!late && !scr_fs_cb_chk(callback, legacy ? "callback" : "cb")) { scr_arr_release(vectors); return NULL; } size_t count = timestamp ? argc : slot < argc ? slot : argc; #define ARG(index) scr_fs_cb_arg(args, count, index) @@ -889,7 +892,8 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc const ScrDyn *params = count == 3 ? ARG(2) : scr_dyn_bytes_is(ARG(1), SCR_BYTES_U8) ? NULL : ARG(1); bool object = !params || scr_fs_dyn_absent(params) || params->kind == SCR_DYN_OBJ || params->kind == SCR_DYN_BYTES || - (params->kind == SCR_DYN_TYPED_REF && !scr_dyn_ref_is_array(params)); + (params->kind == SCR_DYN_TYPED_REF && !scr_dyn_ref_is_array(params)) || + (params->kind == SCR_DYN_PROXY && scr_dyn_is_object(params)); if (!object) { scr_dyn_arg_type_fail("options", "of type object", params); return NULL; @@ -901,7 +905,28 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc } if (!scr_fs_cb_chk(callback, "cb")) return NULL; } - if (!strcmp(op, "read") && count >= 4 && !scr_fs_read_validate(ARG(0), ARG(1), ARG(2), ARG(3), ARG(4))) return NULL; + /* The read length after `length |= 0`, converted once (a valueOf call + * runs once, as in Node). */ + double readLength = 0; + if (!strcmp(op, "read") && count >= 4 && + !scr_fs_read_validate(ARG(0), ARG(1), ARG(2), ARG(3), ARG(4), &readLength)) + return NULL; + /* write checks its fd first, then the buffer form's callback, offset and + * length (validateOffsetLengthWrite), or the string form's data and + * callback. */ + if (!strcmp(op, "write")) { + if (!scr_fs_int_range_chk(ARG(0), "fd", 0, 2147483647.0, ">= 0 && <= 2147483647")) return NULL; + if (ARG(1)->kind == SCR_DYN_BYTES) { + if (!scr_fs_cb_chk(callback, "cb") || !scr_fs_write_validate(ARG(1), ARG(2), ARG(3))) return NULL; + } else { + if (ARG(1)->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("buffer", "of type string or an instance of Buffer, TypedArray, or DataView", + ARG(1)); + return NULL; + } + if (!scr_fs_cb_chk(callback, "cb")) return NULL; + } + } bool descriptor = timestamp_fd || vector || newControl || !strcmp(op, "close") || !strcmp(op, "fstat") || !strcmp(op, "fsync") || !strcmp(op, "read") || !strcmp(op, "write") || !strcmp(op, "fchown"); /* truncate checks len, then the callback, before the open its request * starts with. */ @@ -1100,7 +1125,10 @@ static ScrDyn *scr_fs_cb_invoke(ScrStr *member, ScrDyn *const *args, size_t argc return NULL; } else { double offset = ownedBuffer ? scr_fs_cb_option_number(options, "offset", 0) : scr_fs_cb_number(ARG(2), "offset", 0); - double length = ownedBuffer ? scr_fs_cb_option_number(options, "length", bytes->len - offset) : scr_fs_cb_number(ARG(3), "length", bytes->len - offset); + double length = bytes->len - offset; + if (ownedBuffer) length = scr_fs_cb_option_number(options, "length", length); + else if (readOp && count >= 4) length = readLength; + else length = scr_fs_cb_number(ARG(3), "length", length); double position = ownedBuffer ? scr_fs_cb_option_number(options, "position", -1) : scr_fs_cb_number(ARG(4), "position", -1); if (!scr_exc_pending()) result = scr_dyn_new_num(readOp ? scr_fs_read_sync(fd, bytes, offset, length, position) : scr_fs_write_sync(fd, bytes, offset, length, position)); } @@ -1877,68 +1905,136 @@ static bool scr_fs_int_range_chk(const ScrDyn *v, const char *name, double min, return true; } -static bool scr_fs_read_validate(const ScrDyn *fd, const ScrDyn *buffer, const ScrDyn *offset, - const ScrDyn *length, const ScrDyn *position) { - if (buffer->kind != SCR_DYN_BYTES) { - scr_dyn_arg_type_fail("buffer", "an instance of Buffer, TypedArray, or DataView", buffer); +/* `length |= 0`: ToInt32 of the length argument (a non-numeric value is + * 0). False with the exception pending when ToNumber throws. */ +static bool scr_fs_length_int32(const ScrDyn *length, double *out) { + double n; + if (length->kind == SCR_DYN_NUM) { + n = length->v.num; + } else if (!scr_dyn_number_coerce_js(length, &n)) { return false; } + if (!isfinite(n)) { + *out = 0; + return true; + } + double m = fmod(trunc(n), 4294967296.0); + if (m < 0) m += 4294967296.0; + *out = m >= 2147483648.0 ? m - 4294967296.0 : m; + return true; +} + +/* A bigint read position in [-1n, 2n ** 63n - 1n - BigInt(length)] + * (validatePosition); false with ERR_OUT_OF_RANGE pending otherwise. The + * received value groups the digits past 2^32 in threes, as Node's + * ERR_OUT_OF_RANGE does for bigints. */ +static bool scr_fs_bigint_position_chk(ScrBigInt *position, double length) { + ScrBigInt *top = scr_bigint_from_i64(INT64_MAX); + ScrBigInt *span = scr_bigint_from_f64(length); + ScrBigInt *max = scr_bigint_sub(top, span); + scr_bigint_release(top); + scr_bigint_release(span); + ScrBigInt *low = scr_bigint_from_i64(-1); + bool ok = scr_bigint_cmp_f64(position, low) >= 0 && scr_bigint_cmp_f64(position, max) <= 0; + scr_bigint_release(low); + if (ok) { + scr_bigint_release(max); + return true; + } + ScrStr *limit = scr_bigint_to_string(max, 10); + scr_bigint_release(max); + ScrStr *decimal = scr_bigint_to_string(position, 10); + if (!limit || !decimal) { + if (limit) scr_str_release(limit); + if (decimal) scr_str_release(decimal); + return false; + } + ScrBigInt *bound = scr_bigint_from_i64((int64_t)1 << 32); + ScrBigInt *negBound = scr_bigint_from_i64(-((int64_t)1 << 32)); + bool group = scr_bigint_cmp_f64(position, bound) > 0 || scr_bigint_cmp_f64(position, negBound) < 0; + scr_bigint_release(bound); + scr_bigint_release(negBound); + /* addNumericalSeparator: underscores every three digits from the right, + * after an optional sign. */ + char digits[200]; + size_t len = decimal->len < 120 ? decimal->len : 120, out = 0; + size_t start = len > 0 && decimal->data[0] == '-' ? 1 : 0; + for (size_t i = 0; i < len; i++) { + if (group && i > start && (len - i) % 3 == 0) digits[out++] = '_'; + digits[out++] = (char)decimal->data[i]; + } + digits[out] = '\0'; + char msg[384]; + int n = snprintf(msg, sizeof msg, + "The value of \"position\" is out of range. It must be >= -1 && <= %.*s. Received %sn", + (int)limit->len, (const char *)limit->data, digits); + scr_str_release(limit); + scr_str_release(decimal); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, n < 0 ? 0 : (size_t)n, "ERR_OUT_OF_RANGE"); + return false; +} + + +/* fs.read(fd, buffer, offset, length, position, cb)'s argument checks, in + * Node's order: the fd, the buffer, the offset (an integer >= 0), the + * length as `length |= 0`, the position (validatePosition, against that + * length), then for a non-empty read the empty-buffer and + * validateOffsetLengthRead checks. The converted length goes to + * *length_out when it is not NULL, so the read does not convert again. */ +static bool scr_fs_read_validate(const ScrDyn *fd, const ScrDyn *buffer, const ScrDyn *offset, + const ScrDyn *length, const ScrDyn *position, double *length_out) { if (fd->kind != SCR_DYN_NUM) { scr_dyn_arg_type_fail("fd", "of type number", fd); return false; } + if (buffer->kind != SCR_DYN_BYTES) { + scr_dyn_arg_type_fail("buffer", "an instance of Buffer, TypedArray, or DataView", buffer); + return false; + } double buflen = scr_bytes_byte_len(buffer->v.bytes); + double off = 0; if (!scr_fs_dyn_absent(offset)) { - /* validateInteger's MAX_SAFE range first, the buffer bound second — - * Node renders each with its own max. */ if (!scr_fs_int_range_chk(offset, "offset", 0, 9007199254740991.0, ">= 0 && <= 9007199254740991")) { return false; } - if (offset->v.num > buflen) { - char range[64]; - snprintf(range, sizeof range, ">= 0 && <= %.0f", buflen); - if (!scr_fs_int_range_chk(offset, "offset", 0, buflen, range)) return false; - } + off = offset->v.num; } + double count = 0; + if (!scr_fs_length_int32(length, &count)) return false; + if (length_out) *length_out = count; if (!scr_fs_dyn_absent(position)) { - if (position->kind != SCR_DYN_NUM) { + if (position->kind == SCR_DYN_BIGINT) { + if (!scr_fs_bigint_position_chk(position->v.bigint, count)) return false; + } else if (position->kind != SCR_DYN_NUM) { scr_dyn_arg_type_fail("position", "of type bigint or integer", position); return false; - } - if (!scr_fs_int_range_chk(position, "position", -1, 9007199254740991.0, - ">= -1 && <= 9007199254740991")) { + } else if (!scr_fs_int_range_chk(position, "position", -1, 9007199254740991.0, + ">= -1 && <= 9007199254740991")) { return false; } } - double off = offset->kind == SCR_DYN_NUM ? offset->v.num : 0; - if (!scr_fs_dyn_absent(length)) { - if (length->kind != SCR_DYN_NUM || !(isfinite(length->v.num) && trunc(length->v.num) == length->v.num) || - length->v.num < 0) { - /* Node renders the bare ">= 0" form here (checkPosition's cousin). */ - if (length->kind == SCR_DYN_NUM) { - char recv[48], msg[160]; - scr_num_received(length->v.num, recv); - const char *shape = (isfinite(length->v.num) && trunc(length->v.num) == length->v.num) - ? "It must be >= 0." - : "It must be an integer."; - int len = snprintf(msg, sizeof msg, - "The value of \"length\" is out of range. %s Received %s", shape, recv); - scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return false; - } - scr_dyn_arg_type_fail("length", "of type number", length); - return false; - } - if (length->v.num > buflen - off) { - char recv[48], msg[160]; - scr_num_received(length->v.num, recv); - int len = snprintf(msg, sizeof msg, - "The value of \"length\" is out of range. It must be <= %.0f. Received %s", - buflen - off, recv); - scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return false; + if (count == 0) return true; + if (buflen == 0) { + if (scr_bytes_is_buffer(buffer->v.bytes)) { + static const char empty[] = + "The argument 'buffer' is empty and cannot be written. Received "; + scr_throw_error_msg_code(SCR_ERR_TYPE, empty, sizeof empty - 1, "ERR_INVALID_ARG_VALUE"); + } else { + scr_dyn_arg_value_fail("buffer", "is empty and cannot be written", buffer); } + return false; + } + if (count < 0 || off + count > buflen) { + char recv[48], range[64], msg[192]; + scr_num_received(count, recv); + if (count < 0) snprintf(range, sizeof range, ">= 0"); + else snprintf(range, sizeof range, "<= %.0f", buflen - off); + int len = snprintf(msg, sizeof msg, + "The value of \"length\" is out of range. It must be %s. Received %s", range, + recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; } return true; } @@ -1946,7 +2042,7 @@ static bool scr_fs_read_validate(const ScrDyn *fd, const ScrDyn *buffer, const S void scr_fs_read_chk(const ScrDyn *fd, const ScrDyn *buffer, const ScrDyn *offset, const ScrDyn *length, const ScrDyn *position, const ScrStr *fence) { - if (scr_fs_read_validate(fd, buffer, offset, length, position)) scr_throw_lowering_fence(fence); + if (scr_fs_read_validate(fd, buffer, offset, length, position, NULL)) scr_throw_lowering_fence(fence); } /* createReadStream/createWriteStream(path, options?): getOptions' @@ -1991,3 +2087,68 @@ bool scr_fs_stream_mode(const ScrDyn *value, double *mode) { } return scr_fs_cb_file_mode(value, "mode", mode); } + +/* fs.readSync(fd, buffer, offset, length[, position]) with arguments the + * compiler could not type (JavaScript): Node's checks (validateBuffer + * first, then the fd, the offset, `length |= 0` and the position, which + * may be a bigint), then the typed read. */ +double scr_fs_read_sync_dyn(const ScrDyn *fd, const ScrDyn *buffer, const ScrDyn *offset, + const ScrDyn *length, const ScrDyn *position) { + if (buffer->kind != SCR_DYN_BYTES) { + scr_dyn_arg_type_fail("buffer", "an instance of Buffer, TypedArray, or DataView", buffer); + return 0; + } + double count = 0; + if (!scr_fs_read_validate(fd, buffer, offset, length, position, &count)) return 0; + if (count == 0) return 0; + double at = scr_fs_dyn_absent(position) ? -1 + : position->kind == SCR_DYN_BIGINT ? scr_bigint_to_f64(position->v.bigint) + : position->v.num; + ScrBytes *bytes = scr_dyn_bytes_unbox(buffer); + double read = scr_fs_read_sync(fd->v.num, bytes, scr_fs_dyn_absent(offset) ? 0 : offset->v.num, + count, at); + scr_bytes_release(bytes); + return read; +} + +/* ERR_OUT_OF_RANGE for the number `n` of argument `name`. Always false. */ +static bool scr_fs_num_range_fail(const char *name, const char *range, double n) { + char recv[48], msg[192]; + scr_num_received(n, recv); + int len = snprintf(msg, sizeof msg, "The value of \"%s\" is out of range. It must be %s. Received %s", + name, range, recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; +} + +/* fs.write(fd, buffer, offset, length, position, cb)'s buffer checks: the + * offset (an integer >= 0 unless omitted, null or the callback), a + * non-number length defaulting to the rest of the buffer, then + * validateOffsetLengthWrite. An options object in the offset slot is read + * by the write itself. */ +static bool scr_fs_write_validate(const ScrDyn *buffer, const ScrDyn *offset, const ScrDyn *length) { + if (offset->kind == SCR_DYN_OBJ || offset->kind == SCR_DYN_TYPED_REF || offset->kind == SCR_DYN_PROXY || + offset->kind == SCR_DYN_NULL) + return true; + double buflen = scr_bytes_byte_len(buffer->v.bytes); + double off = 0; + if (!scr_fs_dyn_absent(offset) && offset->kind != SCR_DYN_FUNC) { + if (!scr_fs_int_range_chk(offset, "offset", 0, 9007199254740991.0, ">= 0 && <= 9007199254740991")) + return false; + off = offset->v.num; + } + double len = length->kind == SCR_DYN_NUM ? length->v.num : buflen - off; + char range[64]; + if (off > buflen) { + snprintf(range, sizeof range, "<= %.0f", buflen); + return scr_fs_num_range_fail("offset", range, off); + } + if (len > buflen - off) { + snprintf(range, sizeof range, "<= %.0f", buflen - off); + return scr_fs_num_range_fail("length", range, len); + } + if (len < 0) return scr_fs_num_range_fail("length", ">= 0", len); + if (!(isfinite(len) && trunc(len) == len)) return scr_fs_num_range_fail("length", "an integer", len); + if (len > 2147483647.0) return scr_fs_num_range_fail("length", ">= 0 && <= 2147483647", len); + return true; +} diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index 086340014..2f7c91b7d 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -148,7 +148,7 @@ static void scr_enable_utf8_console_output(void) { } #endif -void scr_runtime_abi_v37(void) {} +void scr_runtime_abi_v43(void) {} void scr_init(void) { #ifdef SCR_WORKERS diff --git a/packages/runtime/src/scr_core.h b/packages/runtime/src/scr_core.h index 3707b8d50..5d6d80778 100644 --- a/packages/runtime/src/scr_core.h +++ b/packages/runtime/src/scr_core.h @@ -81,7 +81,7 @@ void scr_init(void); /* Program objects emitted by the bundled LLVM helper reference this symbol. * Its versioned spelling makes a mismatched manual runtime link fail before * the program can start. */ -void scr_runtime_abi_v37(void); +void scr_runtime_abi_v43(void); void scr_runtime_workers_v8(void); /* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ────── diff --git a/packages/runtime/src/scr_crypto.h b/packages/runtime/src/scr_crypto.h index 03a9dd109..15180e880 100644 --- a/packages/runtime/src/scr_crypto.h +++ b/packages/runtime/src/scr_crypto.h @@ -305,5 +305,36 @@ ScrDyn *scr_crypto_create_sign(ScrDyn *algorithm); ScrDyn *scr_crypto_create_verify(ScrDyn *algorithm); bool scr_crypto_is_asymmetric_key(const ScrDyn *value); const char *scr_crypto_asymmetric_key_type(const ScrDyn *value); +/* X509Certificate fields (scr_x509.c): raw decodes the constructor's input (+1 Buffer, + * or NULL with Node's PEM error pending); the others read that DER + * (borrowed) and never throw. subjectAltName, infoAccess and + * signatureAlgorithm answer "" where Node answers undefined. */ +ScrBytes *scr_crypto_x509_raw(ScrBytes *data); +ScrBytes *scr_crypto_x509_raw_der(ScrBytes *der); +ScrBytes *scr_crypto_x509_raw_str(ScrStr *pem); +ScrStr *scr_crypto_x509_subject(ScrBytes *der); +ScrStr *scr_crypto_x509_issuer(ScrBytes *der); +ScrStr *scr_crypto_x509_subject_alt_name(ScrBytes *der); +ScrStr *scr_crypto_x509_info_access(ScrBytes *der); +ScrStr *scr_crypto_x509_serial_number(ScrBytes *der); +ScrStr *scr_crypto_x509_fingerprint256(ScrBytes *der); +ScrStr *scr_crypto_x509_fingerprint512(ScrBytes *der); +bool scr_crypto_x509_ca(ScrBytes *der); +ScrStr *scr_crypto_x509_signature_algorithm(ScrBytes *der); +ScrStr *scr_crypto_x509_signature_algorithm_oid(ScrBytes *der); +ScrStr *scr_crypto_x509_pem(ScrBytes *der); +/* X509Certificate methods over the DER (borrowed). checkHost, checkEmail + * and checkIP answer the matching name ("" for none, Node's undefined) or + * NULL with Node's argument error pending (scr_x509.c). publicKey, verify + * and checkPrivateKey read keys (scr_pki.c): publicKey is +1 or NULL, and + * the two checks answer false with the error pending when they throw. */ +ScrStr *scr_crypto_x509_check_host(ScrBytes *der, ScrDyn *name, ScrDyn *options); +ScrStr *scr_crypto_x509_check_email(ScrBytes *der, ScrDyn *email, ScrDyn *options); +ScrStr *scr_crypto_x509_check_ip(ScrBytes *der, ScrDyn *ip, ScrDyn *options); +bool scr_crypto_x509_check_issued(ScrBytes *der, ScrBytes *issuer_der); +ScrDyn *scr_crypto_x509_legacy_object(ScrBytes *der); +ScrDyn *scr_crypto_x509_public_key(ScrBytes *der); +bool scr_crypto_x509_verify(ScrBytes *der, ScrDyn *key); +bool scr_crypto_x509_check_private_key(ScrBytes *der, ScrDyn *key); #endif /* SCR_CRYPTO_H */ diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index dbd636306..d08f54a21 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -637,14 +637,25 @@ static SCR_TL bool scr_cyc_idle_points = false; static SCR_TL size_t scr_cyc_death_due_live = 0; #endif +/* The nursery trigger a program sets for itself + * (scr_cyc_set_nursery_threshold), 0 for the default; SCR_CYCLE_THRESHOLD + * takes precedence over it. */ +static long scr_cyc_nursery_threshold_set = 0; +static SCR_TL size_t scr_cyc_nursery_threshold_cached = 0; + static size_t scr_cyc_nursery_threshold(void) { - static SCR_TL size_t cached = 0; - if (cached == 0) { + if (scr_cyc_nursery_threshold_cached == 0) { const char *env = scr_getenv("SCR_CYCLE_THRESHOLD"); long v = env ? strtol(env, NULL, 10) : 0; - cached = v > 0 ? (size_t)v : SCR_CYC_NURSERY_CANDIDATES; + if (v <= 0) v = scr_cyc_nursery_threshold_set; + scr_cyc_nursery_threshold_cached = v > 0 ? (size_t)v : SCR_CYC_NURSERY_CANDIDATES; } - return cached; + return scr_cyc_nursery_threshold_cached; +} + +void scr_cyc_set_nursery_threshold(long threshold) { + scr_cyc_nursery_threshold_set = threshold; + scr_cyc_nursery_threshold_cached = 0; } /* The number of buffered candidates (across every generation) at which the diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 5e31d2aa9..57533a8e0 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -186,6 +186,7 @@ typedef enum { X(MATCH_SOURCE, 55) /* a match result's pending metadata (scr_regex.c) */ \ X(MESSAGE_EVENT, 50) /* ScrEvent with data — BroadcastChannel's 'message' */ \ X(MESSAGE_PORT, 36) \ + X(MODULE_NAMESPACE, 79) /* module namespace object read through its exports (scr_module_namespace.c) */ \ X(NET_BLOCK_LIST, 48) /* net.BlockList (scr_net.c) */ \ X(NET_SERVER, 3) /* ScrNetServer — net.Server (http.Server rides the same handle) */ \ X(NET_SOCKET, 2) /* ScrNetSocket — net.Socket */ \ @@ -195,11 +196,13 @@ typedef enum { X(REGEXP, 26) /* native RegExp, shared compiled pattern */ \ X(SEARCH_PARAMS, 33) /* live WHATWG URLSearchParams */ \ X(SEGMENTER, 19) /* default Unicode grapheme segmentation */ \ + X(SEGMENTER_REFUSING, 80) /* word or sentence Segmenter whose segment() throws (scr_grapheme.c) */ \ X(SEGMENTS, 20) /* iterable immutable segmentation result */ \ X(SET, 25) /* native Set, shared backing map */ \ X(SHARED_ARRAY_BUFFER, 37) \ X(SIGN, 67) /* node:crypto Sign (scr_pki.c) */ \ X(STDIO, 27) /* stable process stdin/stdout/stderr values */ \ + X(STRING_DECODER, 78) /* compiled StringDecoder record (scr_text_codec_dyn.c) */ \ X(TEXT_DECODER, 76) /* compiled TextDecoder record (scr_text_codec_dyn.c) */ \ X(TEXT_ENCODER, 77) /* compiled TextEncoder record (scr_text_codec_dyn.c) */ \ X(URL, 28) /* native WHATWG URL */ \ @@ -324,6 +327,9 @@ typedef struct { * must not inspect the value: an earlier cycle member may already be freed. * Ordinary mutations need not maintain this collector-only decision. */ bool value_traced : 1; + /* A namespace object's function member (scr_dyn_namespace_define), which + * static member calls bypass: a change to its value fences instead. */ + bool fenced : 1; /* Ordinary object insertion sets all three attributes true. */ bool writable; bool enumerable; @@ -517,7 +523,9 @@ struct ScrDyn { * identity while the source reference remains observable. */ ScrDyn *materialized; ScrDynTypedCast *casts; - /* One word keeps this, the largest payload, at its previous size. */ + /* One word keeps this, the largest payload, at its previous size. + * Dynamic-tier inline caches read record, klass and bag_class as + * the word's top three bits (backend/llvm/js-ic.ts nativeArray). */ uint32_t type_key_len : 27; uint32_t traced : 1; /* native referent has a cycle header */ uint32_t observed : 1; /* native release reports this identity's disposal */ @@ -870,11 +878,16 @@ bool scr_disposable_stack_use(const ScrDyn *stack, const ScrDyn *value); bool scr_disposable_stack_use_callback(const ScrDyn *stack, const ScrDyn *value, const ScrDyn *dispose); bool scr_disposable_stack_adopt(const ScrDyn *stack, const ScrDyn *value, const ScrDyn *on_dispose); void scr_dyn_symbol_key_set(ScrDyn *value, ScrDyn *key, ScrDyn *stored); +/* scr_dyn_obj_set_field for a symbol-keyed field of a class instance's + * view: replaces the value whatever the attributes copied into the view. + * Does not consume stored. */ +void scr_dyn_symbol_field_set(ScrDyn *value, ScrDyn *key, ScrDyn *stored); void scr_dyn_symbol_key_placeholder(ScrDyn *value, ScrDyn *key); bool scr_dyn_has_key_computed(const ScrDyn *value, const ScrDyn *key); bool scr_dyn_has_own_computed(const ScrDyn *value, const ScrDyn *key); bool scr_dyn_property_is_enumerable_computed(const ScrDyn *value, const ScrDyn *key); void scr_dyn_key_delete_computed(ScrDyn *value, const ScrDyn *key, bool strict); +bool scr_dyn_reflect_delete_property(ScrDyn *target, const ScrDyn *key); ScrStr *scr_dyn_string_constructor(const ScrDyn *value); ScrDyn *scr_dyn_property_key_value(const ScrDyn *value); ScrDyn *scr_dyn_to_numeric(const ScrDyn *value); @@ -897,6 +910,19 @@ ScrDyn *scr_dyn_error_constructor(const char *name); /* A builtin global function (setTimeout, setImmediate, process.nextTick, ...) * as an interned dynamic function value: +1. */ ScrDyn *scr_dyn_builtin_function(const ScrStr *name); +/* A standard namespace object (Math, JSON, Reflect read as a JavaScript + * value): an ordinary object whose Symbol.toStringTag is `tag` (+1). + * `tag_key` is the Symbol.toStringTag key (borrowed), which the compiler + * passes so this unit needs no symbol entry points. */ +ScrDyn *scr_dyn_namespace_new(const ScrStr *tag, ScrDyn *tag_key); +/* Defines the own data property `key` of a namespace object. `attributes` + * has bits 1 writable, 2 enumerable, 4 configurable; a function value + * takes `length` as its length when it is not negative. */ +void scr_dyn_namespace_define(ScrDyn *object, const ScrStr *key, ScrDyn *value, double attributes, + double length); +/* Marks the own property `key` of `object` as a fenced namespace member + * (ScrDynEntry.fenced). */ +void scr_dyn_fence_member(ScrDyn *object, const ScrStr *key); /* A constant-key read of an Object.prototype member name: takes the * ordinary read's +1 result and answers the inherited member in place of * an undefined one (scr_dyn_invoke.c). */ @@ -1206,6 +1232,12 @@ void scr_dyn_obj_set_field(ScrDyn *obj, const char *key, size_t key_len, ScrDyn ScrDyn *scr_dyn_define_property(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor); void scr_dyn_class_field_attributes(ScrDyn *view, ScrStr *name, double bits); void scr_dyn_class_view_attributes(ScrDyn *view, double bits); +/* The integrity state of a class instance's checked view as CLASS_* bits, + * added to `bits`. The commits of hierarchies with an integrity word store + * it in the word, in programs that freeze, seal or prevent extensions of + * untyped values. `bag`, the instance's new property bag or NULL, stops + * being extensible with the view. */ +double scr_dyn_class_view_integrity(const ScrDyn *view, double bits, ScrDyn *bag); ScrDyn *scr_class_define_field(ScrDyn *current, double field_bits, double object_bits, ScrStr *name, ScrDyn *descriptor); double scr_class_defined_bits(ScrDyn *holder, ScrStr *name); @@ -1235,6 +1267,12 @@ void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value); void scr_dyn_arr_named_put(ScrDyn *arr, const char *key, size_t key_len, ScrDyn *value); void scr_dyn_key_set_computed(ScrDyn *recv, ScrDyn *key, ScrDyn *value); void scr_dyn_key_delete(ScrDyn *recv, const ScrStr *key, bool strict); +/* Removes the own property `key` of an ordinary object whatever its + * attributes, as scr_dyn_obj_set_field replaces values. */ +void scr_dyn_obj_delete_field(ScrDyn *obj, const ScrStr *key); +/* The same for the own symbol-keyed property `symbol` of an ordinary + * object. */ +void scr_dyn_obj_delete_symbol_field(ScrDyn *obj, ScrDyn *symbol); /* This thread's global object box (borrowed). Every spelling of the global * object answers it; its symbol-keyed properties are the global object's * (scr_node_builtin.c). */ @@ -1450,6 +1488,9 @@ ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, con /* Gives a function box made with thunk its typed message entry (an * EventEmitter listener registered from it keeps the entry). */ void scr_dyn_func_set_message(ScrDyn *fn, ScrDynThunk thunk, ScrDynMessageListener listener); +/* Sets a freshly boxed function's `length` (a builtin function value whose + * length is not its adapter's parameter count). */ +void scr_dyn_function_set_length(ScrDyn *fn, uint32_t length); ScrDyn *scr_dyn_new_class(ScrClassObj *cls, const char *type_key); /* EventEmitter and the stream constructors as untyped constructor values, * and instanceof over untyped constructor functions @@ -1581,6 +1622,19 @@ void scr_dyn_build_note(const ScrDyn *d, const void *builder, void *value); void scr_dyn_build_rekey(const ScrDyn *d, const void *builder, void *value); ScrDyn *scr_dyn_visit_view(const ScrDyn *capsule); +/* A union's builder converts the arm whose literal discriminator and + * complete structure matched; every value inside that arm then matched its + * own type too. Between scr_dyn_trust_enter and scr_dyn_trust_exit (which + * restores the value enter returned), scr_dyn_trusted_build answers true, + * and the builder of a nested union whose arms' literals are disjoint picks + * a plain object's arm by its literal alone instead of matching the arm's + * structure again (dyn.ts unionArmMatch). Code a getter or proxy trap runs + * sees false, and so does the rest of the conversion once such code has + * run: it can return a value the match never saw. */ +bool scr_dyn_trust_enter(void); +void scr_dyn_trust_exit(bool previous); +bool scr_dyn_trusted_build(void); + /* ── native handles in the checked-dynamic tree (SCR_DYN_HANDLE) ──────────────────────── * Per-tag dispatch ops, registered by the handle's owning unit * (scr_http_dyn_install / scr_net_dyn_install — emitted main() calls diff --git a/packages/runtime/src/scr_dyn_for_await.c b/packages/runtime/src/scr_dyn_for_await.c index 3a1a6458d..a313f040b 100644 --- a/packages/runtime/src/scr_dyn_for_await.c +++ b/packages/runtime/src/scr_dyn_for_await.c @@ -44,7 +44,11 @@ void scr_dyn_async_iterator_set_sync(bool sync) { } /* Whether the iterator the last scr_dyn_async_iterator_open returned is a - * synchronous one. Read immediately after the open. */ + * synchronous one. Read immediately after the open; the read clears it, so + * an iterator a class's [Symbol.asyncIterator]() returned (opened without + * the runtime) reads false. */ bool scr_dyn_async_iterator_was_sync(void) { - return scr_for_await_sync; + bool sync = scr_for_await_sync; + scr_for_await_sync = false; + return sync; } diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 4901fa694..0597468cd 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -2288,6 +2288,35 @@ ScrDyn *scr_dyn_builtin_function(const ScrStr *name) { return scr_dyn_retain(value); } +/* An own data property of a namespace object. Attribute bits: 1 writable, + * 2 enumerable, 4 configurable. */ +static void dyn_namespace_put(ScrDyn *object, ScrDyn *key, ScrDyn *value, unsigned attributes) { + ScrDyn *descriptor = scr_dyn_new_obj(); + scr_dyn_obj_set(descriptor, "value", 5, scr_dyn_retain(value)); + scr_dyn_obj_set(descriptor, "writable", 8, scr_dyn_new_bool((attributes & 1) != 0)); + scr_dyn_obj_set(descriptor, "enumerable", 10, scr_dyn_new_bool((attributes & 2) != 0)); + scr_dyn_obj_set(descriptor, "configurable", 12, scr_dyn_new_bool((attributes & 4) != 0)); + scr_dyn_release(scr_dyn_define_property(object, key, descriptor)); + scr_dyn_release(descriptor); +} + +ScrDyn *scr_dyn_namespace_new(const ScrStr *tag, ScrDyn *tag_key) { + ScrDyn *object = scr_dyn_new_obj(); + ScrDyn *value = scr_dyn_new_str((ScrStr *)tag); + dyn_namespace_put(object, tag_key, value, 4); + scr_dyn_release(value); + return object; +} + +void scr_dyn_namespace_define(ScrDyn *object, const ScrStr *key, ScrDyn *value, double attributes, + double length) { + if (length >= 0 && value->kind == SCR_DYN_FUNC) value->v.fn.arity = (uint32_t)length; + ScrDyn *name = scr_dyn_new_str((ScrStr *)key); + dyn_namespace_put(object, name, value, (unsigned)attributes); + scr_dyn_release(name); + if (length >= 0) scr_dyn_fence_member(object, key); +} + ScrDyn *scr_dyn_array_prototype(void) { static SCR_TL bool initialized; ScrDyn *prototype = scr_dyn_array_prototype_base(); diff --git a/packages/runtime/src/scr_exception.h b/packages/runtime/src/scr_exception.h index e99f84243..63dd4a3ca 100644 --- a/packages/runtime/src/scr_exception.h +++ b/packages/runtime/src/scr_exception.h @@ -123,6 +123,11 @@ static inline ScrExcCell *scr_exc_swap_cell_inline(ScrExcCell *cell) { _Static_assert(offsetof(ScrExcCell, kind) == 0 && sizeof(ScrExcKind) == 4 && SCR_EXC_NONE == 0, "the LLVM emitter's inline pending check loads an i32 kind at offset 0"); +/* Unwinds the native stack to the nearest landing pad of emitted code with + * the exception pending in the active cell (the unwinding ABI of emitted + * functions, unwind-abi.ts). Linux (glibc and musl) only; it never + * returns. */ +SCR_COLD _Noreturn void scr_unwind_raise(void); SCR_COLD void scr_throw_f64(double v); SCR_COLD void scr_throw_bool(bool v); SCR_COLD void scr_throw_str(ScrStr *v); /* takes ownership */ diff --git a/packages/runtime/src/scr_grapheme.c b/packages/runtime/src/scr_grapheme.c index 2c93bc395..f9e5ca28d 100644 --- a/packages/runtime/src/scr_grapheme.c +++ b/packages/runtime/src/scr_grapheme.c @@ -201,3 +201,58 @@ ScrDyn *scr_intl_segmenter_new(void) { scr_dyn_handle_install(SCR_DYNH_SEGMENTS, &segments); return scr_graphemes_box(NULL, SCR_DYNH_SEGMENTER); } + +/* A word or sentence Segmenter, which JavaScript dependencies may build at + * module load and use on some paths only: it constructs, and its segment() + * throws the compiler's SC2020 message. */ +typedef struct { + size_t rc; + ScrStr *refusal; /* retained */ +} ScrRefusingSegmenter; + +static void *scr_refusing_segmenter_retain(void *ptr) { + ScrRefusingSegmenter *value = ptr; + value->rc++; + return value; +} + +static void scr_refusing_segmenter_release(void *ptr) { + ScrRefusingSegmenter *value = ptr; + if (--value->rc) return; + scr_str_release(value->refusal); + free(value); +} + +static ScrDyn *scr_refusing_segmenter_invoke(void *ptr, ScrDyn *self, const char *method, + ScrDyn *const *args, size_t argc, const char *what) { + ScrRefusingSegmenter *value = ptr; + if (strcmp(method, "segment") == 0) { + ScrStr *input = scr_dyn_string_coerce_js(argc ? args[0] : scr_dyn_undefined()); + if (!input) return NULL; + scr_str_release(input); + scr_throw_error_msg_code(SCR_ERR_ERROR, value->refusal->data, value->refusal->len, "SC2020"); + return NULL; + } + return scr_graphemes_invoke(&(ScrGraphemes){ .rc = 1 }, self, method, args, argc, what); +} + +static ScrDyn *scr_refusing_segmenter_get(void *ptr, const char *key, size_t len) { + (void)ptr; + return scr_graphemes_get(&(ScrGraphemes){ .rc = 1 }, key, len); +} + +ScrDyn *scr_intl_segmenter_refusing(ScrStr *refusal) { + static const ScrDynHandleOps segmenter = { + .cls = "Segmenter", .retain = scr_refusing_segmenter_retain, + .release = scr_refusing_segmenter_release, .invoke = scr_refusing_segmenter_invoke, + .get = scr_refusing_segmenter_get, .set = scr_graphemes_set, + }; + scr_dyn_handle_install(SCR_DYNH_SEGMENTER_REFUSING, &segmenter); + ScrRefusingSegmenter *value = calloc(1, sizeof *value); + if (!value) scr_trap("scriptc: out of memory\n"); + value->rc = 1; + value->refusal = scr_str_retain(refusal); + ScrDyn *boxed = scr_dyn_new_handle(value, SCR_DYNH_SEGMENTER_REFUSING); + scr_refusing_segmenter_release(value); + return boxed; +} diff --git a/packages/runtime/src/scr_http2.c b/packages/runtime/src/scr_http2.c index 55d880c01..489946a12 100644 --- a/packages/runtime/src/scr_http2.c +++ b/packages/runtime/src/scr_http2.c @@ -2617,11 +2617,11 @@ static void scr_h2_on_established(void *ctx) { * TLS client transport with ALPN ["h2"] (scr_tls.c — linked whenever * this unit is): the same preface/SETTINGS buffer through the handshake * and everything after establishment is protocol-identical. `ca` - * replaces the client trust anchors (len 0 = none) and + * replaces the client trust anchors (NULL = none) and * reject_unauthorized false disables the verify gate — the local-CA * self-connect shapes. Throws catchably on unparsable input. */ ScrH2Session *scr_http2_connect(ScrStr *url /*borrowed*/, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, ScrClosure *cb /*moves, nullable*/, ScrH2ConnectFn fn) { const char *p = url->data; const char *sep = strstr(p, "://"); diff --git a/packages/runtime/src/scr_http2.h b/packages/runtime/src/scr_http2.h index 25e236e6f..c4ce850d0 100644 --- a/packages/runtime/src/scr_http2.h +++ b/packages/runtime/src/scr_http2.h @@ -69,7 +69,7 @@ void scr_http2_server_on_session(ScrNetServer *s, ScrClosure *cb /*moves*/, ScrH * reject_unauthorized false disables the verify gate — the local-CA * self-connect shapes); throws catchably on bad input */ ScrH2Session *scr_http2_connect(ScrStr *url /*borrowed*/, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, ScrClosure *cb /*moves, nullable*/, ScrH2ConnectFn fn); /* +1 */ /* end_stream: -1 = the method's payload-meaningless default, 0/1 explicit */ @@ -173,7 +173,7 @@ ScrHttpClientReq *scr_https_request(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn); /* +1 */ /* The URL-string form (https.get('https://host/path')): scr_http.c's * shared parse, then the options form's dial with Node's no-options @@ -187,7 +187,7 @@ ScrHttpClientReq *scr_https_request_agent(ScrStr *host /*borrowed*/, double port ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, const struct ScrDyn *agent /*borrowed*/, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn); /* +1 */ /* The fetch unit's https leg (defined in scr_tls.c): a client transport diff --git a/packages/runtime/src/scr_js.h b/packages/runtime/src/scr_js.h index 1d874b31e..3ff9becd3 100644 --- a/packages/runtime/src/scr_js.h +++ b/packages/runtime/src/scr_js.h @@ -231,6 +231,10 @@ typedef struct ScrJsIc { /* `receiver.key` for a plain data property: +1 value, or NULL. */ ScrDyn *scr_js_load_probe(ScrDyn *receiver, const ScrStr *key, ScrJsIc *ic); +/* The same for a property read site, which also answers (+1) undefined for + * a key that the receiver and its plain prototype chain lack, and caches + * that answer. A method read uses scr_js_load_probe. */ +ScrDyn *scr_js_get_probe(ScrDyn *receiver, const ScrStr *key, ScrJsIc *ic); /* `receiver.key = value` (value borrowed) when it replaces a writable own * data property or adds one the prototype chain lacks: true when done. */ bool scr_js_store_probe(ScrDyn *receiver, const ScrStr *key, ScrDyn *value, ScrJsIc *ic); diff --git a/packages/runtime/src/scr_js_object.c b/packages/runtime/src/scr_js_object.c index 0e0ec0908..e97651557 100644 --- a/packages/runtime/src/scr_js_object.c +++ b/packages/runtime/src/scr_js_object.c @@ -76,9 +76,21 @@ static SCR_TL size_t scr_js_filled_capacity; * which answer with a miss until the key set changes. */ static ScrJsShape scr_js_unshaped; +/* The second-level cache of load sites whose own record is full: one entry + * per (receiver shape, key bytes) slot, filled by the probe and checked like + * a site entry. It names shapes, so the shape reset clears it. */ +#define SCR_JS_SHARED_LOADS 2048 +typedef struct ScrJsSharedLoad { + const char *key; + ScrJsIcEntry entry; +} ScrJsSharedLoad; +static SCR_TL ScrJsSharedLoad *scr_js_shared_loads; + static void scr_js_shapes_reset(void) { for (size_t i = 0; i < scr_js_filled_count; i++) memset(scr_js_filled_ics[i], 0, sizeof *scr_js_filled_ics[i]); + free(scr_js_shared_loads); + scr_js_shared_loads = NULL; free(scr_js_filled_ics); scr_js_filled_ics = NULL; scr_js_filled_count = 0; @@ -244,38 +256,155 @@ static ScrDyn *scr_js_load_hit(ScrDyn *receiver, const ScrJsIcEntry *entry) { return found->accessor ? NULL : scr_dyn_retain(found->value); } -ScrDyn *scr_js_load_probe(ScrDyn *receiver, const ScrStr *key, ScrJsIc *ic) { +/* An absent entry: its target is this tag and its holder is a placeholder + * that is never a prototype, so the inline test of entry 0 in compiled code + * (a prototype hit needs the receiver's prototype to be the holder) always + * sends such a receiver to the probe. Its holder_shape and slot name the one + * prototype the read walked past (shape and object), or NULL and 0 when the + * receiver had no prototype. */ +static const ScrJsShape scr_js_absent; +static const ScrDyn scr_js_absent_holder; + +/* Keys whose read on a plain object is not just its [[Get]]: the members of + * Object.prototype, which reads of an absent own property answer through + * the inherited-member path, and the error constructor read. */ +static bool scr_js_absent_cacheable(const ScrStr *key) { +#define SCR_JS_INHERITED(name) {name, sizeof name - 1} + static const struct { + const char *name; + size_t len; + } inherited[] = { + SCR_JS_INHERITED("constructor"), SCR_JS_INHERITED("__proto__"), + SCR_JS_INHERITED("toString"), SCR_JS_INHERITED("toLocaleString"), + SCR_JS_INHERITED("valueOf"), SCR_JS_INHERITED("hasOwnProperty"), + SCR_JS_INHERITED("isPrototypeOf"), SCR_JS_INHERITED("propertyIsEnumerable"), + SCR_JS_INHERITED("__defineGetter__"), SCR_JS_INHERITED("__defineSetter__"), + SCR_JS_INHERITED("__lookupGetter__"), SCR_JS_INHERITED("__lookupSetter__"), + }; +#undef SCR_JS_INHERITED + for (size_t i = 0; i < sizeof inherited / sizeof inherited[0]; i++) + if (key->len == inherited[i].len && memcmp(key->data, inherited[i].name, key->len) == 0) + return false; + return true; +} + +static ScrJsSharedLoad *scr_js_shared_load(const ScrJsShape *shape, const ScrStr *key) { + if (!scr_js_shared_loads) { + scr_js_shared_loads = calloc(SCR_JS_SHARED_LOADS, sizeof *scr_js_shared_loads); + if (!scr_js_shared_loads) scr_trap("scriptc: out of memory\n"); + } + uint64_t mixed = scr_key_mix((uint64_t)(uintptr_t)shape ^ ((uint64_t)(uintptr_t)key->data << 1)); + return &scr_js_shared_loads[mixed & (SCR_JS_SHARED_LOADS - 1)]; +} + +/* Whether an absent entry still holds for `receiver`, whose shape matched: + * its own keys lack the key, so the walked prototype (if any) must be the + * same object with the same shape, and the chain must end there. */ +static bool scr_js_absent_hit(const ScrDyn *receiver, const ScrJsIcEntry *entry) { + const ScrDyn *prototype = receiver->prototype; + const ScrDyn *walked = (const ScrDyn *)entry->slot; + if (!walked) return prototype == NULL; + return prototype == walked && walked->kind == SCR_DYN_OBJ && + walked->v.obj.shape == entry->holder_shape && walked->prototype == NULL; +} + +/* A load site's probe. With `absent`, a read of a key that the receiver and + * its chain of plain shaped objects (ending in the default prototype) lack + * answers undefined and caches that answer. Method reads leave absent + * methods to the slow path, which names the missing function. */ +/* The answer of a matching entry for `receiver`, or NULL when the entry no + * longer holds. */ +static ScrDyn *scr_js_entry_answer(ScrDyn *receiver, const ScrJsIcEntry *entry) { + if (entry->target == &scr_js_absent) + return scr_js_absent_hit(receiver, entry) ? scr_dyn_retain(scr_dyn_undefined()) : NULL; + return scr_js_load_hit(receiver, entry); +} + +/* The object an entry's answer depends on besides the receiver: the + * prototype it read from or walked past, or NULL for an own property. */ +static const ScrDyn *scr_js_entry_prototype(const ScrJsIcEntry *entry) { + if (entry->target == &scr_js_absent) return (const ScrDyn *)entry->slot; + return entry->target ? entry->holder : NULL; +} + +/* Records `entry` in the site and, when the site's record is full, in the + * shared slot. A matching entry whose answer failed is replaced when it + * depends on the same prototype (that prototype changed); one that names + * another prototype stays, so receivers of one shape with different + * prototypes each keep an entry. */ +static void scr_js_load_fill(ScrJsIc *ic, uint32_t stale, ScrJsSharedLoad *shared, + const ScrStr *key, ScrJsIcEntry entry) { + if (stale < ic->count && + scr_js_entry_prototype(&ic->entry[stale]) != scr_js_entry_prototype(&entry)) + stale = UINT32_MAX; + if (shared) *shared = (ScrJsSharedLoad){key->data, entry}; + scr_js_ic_add(ic, stale, entry); +} + +/* A load site's probe. With `absent`, a read of a key that the receiver and + * its chain of plain shaped objects (ending in the default prototype) lack + * answers undefined and caches that answer. Method reads leave absent + * methods to the slow path, which names the missing function. */ +static ScrDyn *scr_js_probe_load(ScrDyn *receiver, const ScrStr *key, ScrJsIc *ic, bool absent) { const ScrJsShape *shape = scr_js_shape_of(receiver); if (!shape) return NULL; uint32_t stale = UINT32_MAX; for (uint32_t i = 0; i < ic->count; i++) { if (ic->entry[i].shape != shape) continue; - ScrDyn *value = scr_js_load_hit(receiver, &ic->entry[i]); + ScrDyn *value = scr_js_entry_answer(receiver, &ic->entry[i]); if (value) return value; - stale = i; - break; + if (stale == UINT32_MAX) stale = i; + } + ScrJsSharedLoad *shared = NULL; + if (ic->count == SCR_JS_IC_WAYS) { + shared = scr_js_shared_load(shape, key); + if (shared->key == key->data && shared->entry.shape == shape && + (absent || shared->entry.target != &scr_js_absent)) { + ScrDyn *value = scr_js_entry_answer(receiver, &shared->entry); + if (value) return value; + } } size_t slot = scr_js_shape_find(shape, key->data, key->len); if (slot != SIZE_MAX) { const ScrDynEntry *found = &receiver->v.obj.entries[slot]; if (found->accessor) return NULL; - scr_js_ic_add(ic, stale, (ScrJsIcEntry){shape, NULL, NULL, NULL, slot}); + scr_js_load_fill(ic, stale, shared, key, (ScrJsIcEntry){shape, NULL, NULL, NULL, slot}); return scr_dyn_retain(found->value); } + absent = absent && scr_js_absent_cacheable(key); /* One prototype link: an ordinary object other than the builtin * Object.prototype (whose reads the generic path fences). */ ScrDyn *holder = receiver->prototype; - if (!holder) return NULL; + if (!holder) { + if (!absent) return NULL; + scr_js_load_fill(ic, stale, shared, key, + (ScrJsIcEntry){shape, &scr_js_absent, &scr_js_absent_holder, NULL, 0}); + return scr_dyn_retain(scr_dyn_undefined()); + } const ScrJsShape *holder_shape = scr_js_shape_of(holder); if (!holder_shape) return NULL; slot = scr_js_shape_find(holder_shape, key->data, key->len); - if (slot == SIZE_MAX) return NULL; + if (slot == SIZE_MAX) { + if (!absent || holder->prototype != NULL) return NULL; + scr_js_load_fill(ic, stale, shared, key, + (ScrJsIcEntry){shape, &scr_js_absent, &scr_js_absent_holder, holder_shape, + (size_t)holder}); + return scr_dyn_retain(scr_dyn_undefined()); + } const ScrDynEntry *found = &holder->v.obj.entries[slot]; if (found->accessor) return NULL; - scr_js_ic_add(ic, stale, (ScrJsIcEntry){shape, holder_shape, holder, NULL, slot}); + scr_js_load_fill(ic, stale, shared, key, (ScrJsIcEntry){shape, holder_shape, holder, NULL, slot}); return scr_dyn_retain(found->value); } +ScrDyn *scr_js_load_probe(ScrDyn *receiver, const ScrStr *key, ScrJsIc *ic) { + return scr_js_probe_load(receiver, key, ic, false); +} + +ScrDyn *scr_js_get_probe(ScrDyn *receiver, const ScrStr *key, ScrJsIc *ic) { + return scr_js_probe_load(receiver, key, ic, true); +} + /* ── stores ───────────────────────────────────────────────────────────── */ /* The generic store keeps a native Error's fields in sync with these keys @@ -292,7 +421,7 @@ static bool scr_js_error_synced_key(const ScrStr *key) { static bool scr_js_replace(ScrDyn *receiver, size_t slot, ScrDyn *value) { ScrDynEntry *found = &receiver->v.obj.entries[slot]; - if (found->accessor || !found->writable) return false; + if (found->accessor || !found->writable || found->fenced) return false; scr_dyn_region_store(receiver, value); ScrDyn *old = found->value; found->value = scr_dyn_retain(value); @@ -337,7 +466,7 @@ bool scr_js_store_probe(ScrDyn *receiver, const ScrStr *key, ScrDyn *value, ScrJ size_t slot = scr_js_shape_find(shape, key->data, key->len); if (slot != SIZE_MAX) { const ScrDynEntry *found = &receiver->v.obj.entries[slot]; - if (found->accessor || !found->writable) return false; + if (found->accessor || !found->writable || found->fenced) return false; scr_js_ic_add(ic, stale, (ScrJsIcEntry){shape, shape, NULL, NULL, slot}); return scr_js_replace(receiver, slot, value); } @@ -355,6 +484,9 @@ bool scr_js_store_probe(ScrDyn *receiver, const ScrStr *key, ScrDyn *value, ScrJ /* ── keyed loads ──────────────────────────────────────────────────────── */ ScrDyn *scr_js_index_probe(ScrDyn *receiver, double index) { + /* A native array passed in from typed code answers its elements in + * place, without forming the index's key string. */ + if (receiver->kind == SCR_DYN_TYPED_REF) return scr_dyn_index_get(receiver, index); if (receiver->kind != SCR_DYN_ARR || !(index >= 0) || index >= (double)receiver->v.arr.len) return NULL; size_t i = (size_t)index; diff --git a/packages/runtime/src/scr_js_object.test.c b/packages/runtime/src/scr_js_object.test.c index 6b6934dea..06b804759 100644 --- a/packages/runtime/src/scr_js_object.test.c +++ b/packages/runtime/src/scr_js_object.test.c @@ -103,8 +103,128 @@ static void immortal_objects_stay_unshaped(void) { scr_str_release(key); } +/* A read site's probe answers a key that the receiver and its plain + * prototype chain lack with undefined, and caches that answer until the + * receiver's keys, its prototype, or the prototype's keys change. */ +static ScrJsIc get_ic; +static ScrJsIc other_ic; + +/* Each site reads one key, so each key below has its own record. */ +static bool get_reads_undefined_at(ScrJsIc *ic, ScrDyn *object, const ScrStr *key) { + ScrDyn *value = scr_js_get_probe(object, key, ic); + if (!value) return false; + assert(value->kind == SCR_DYN_UNDEF); + scr_dyn_release(value); + return true; +} + +static bool get_reads_undefined(ScrDyn *object, const ScrStr *key) { + ScrDyn *value = scr_js_get_probe(object, key, &get_ic); + if (!value) return false; + assert(value->kind == SCR_DYN_UNDEF); + scr_dyn_release(value); + return true; +} + +static bool get_reads(ScrDyn *object, const ScrStr *key, double n) { + ScrDyn *value = scr_js_get_probe(object, key, &get_ic); + if (!value) return false; + assert(value->kind == SCR_DYN_NUM && value->v.num == n); + scr_dyn_release(value); + return true; +} + +static void absent_reads(void) { + ScrStr *own = scr_str_new("own", 3); + ScrStr *missing = scr_str_new("missing", 7); + ScrStr *inherited = scr_str_new("toString", 8); + + /* No prototype: undefined, from the cache the second time. */ + ScrDyn *object = object_with(own, 1); + memset(&get_ic, 0, sizeof get_ic); + assert(get_reads_undefined(object, missing)); + assert(get_ic.count == 1); + assert(get_reads_undefined(object, missing)); + assert(get_ic.count == 1); + /* Method reads and Object.prototype member names keep the slow path. */ + memset(&load_ic, 0, sizeof load_ic); + assert(!scr_js_load_probe(object, missing, &load_ic)); + memset(&other_ic, 0, sizeof other_ic); + assert(!get_reads_undefined_at(&other_ic, object, inherited)); + /* The key added: the same record answers its value. */ + scr_dyn_obj_set(object, missing->data, missing->len, scr_dyn_new_num(2)); + assert(get_reads(object, missing, 2)); + + /* One plain prototype that lacks the key, then gains it. */ + ScrDyn *prototype = object_with(own, 5); + ScrDyn *child = object_with(own, 6); + child->prototype = scr_dyn_retain(prototype); + memset(&get_ic, 0, sizeof get_ic); + assert(get_reads_undefined(child, missing)); + assert(get_reads_undefined(child, missing)); + scr_dyn_obj_set(prototype, missing->data, missing->len, scr_dyn_new_num(3)); + assert(get_reads(child, missing, 3)); + + /* A receiver of the same shape with another prototype that lacks the + * key: both answers stay cached. */ + ScrDyn *other_prototype = object_with(own, 7); + ScrDyn *sibling = object_with(own, 8); + sibling->prototype = scr_dyn_retain(other_prototype); + assert(get_reads_undefined(sibling, missing)); + assert(get_reads(child, missing, 3)); + assert(get_reads_undefined(sibling, missing)); + + /* A longer chain is left to the generic read, even for a receiver whose + * absent answer was cached. */ + ScrDyn *grandparent = object_with(own, 9); + other_prototype->prototype = scr_dyn_retain(grandparent); + assert(!get_reads_undefined(sibling, missing)); + + /* A prototype swapped on a cached receiver: the entry no longer holds. */ + ScrDyn *plain = object_with(own, 10); + memset(&get_ic, 0, sizeof get_ic); + assert(get_reads_undefined(plain, missing)); + plain->prototype = scr_dyn_retain(prototype); + assert(get_reads(plain, missing, 3)); + + /* More shapes than the record holds: the shared cache answers the reads + * that the full record misses, absent keys included. */ + memset(&get_ic, 0, sizeof get_ic); + memset(&other_ic, 0, sizeof other_ic); + ScrDyn *shapes[SCR_JS_IC_WAYS + 3]; + char name[16]; + for (size_t i = 0; i < sizeof shapes / sizeof shapes[0]; i++) { + int n = snprintf(name, sizeof name, "k%zu", i); + shapes[i] = object_with(own, (double)i); + scr_dyn_obj_set(shapes[i], name, (size_t)n, scr_dyn_new_num((double)i)); + } + for (int round = 0; round < 3; round++) + for (size_t i = 0; i < sizeof shapes / sizeof shapes[0]; i++) { + assert(get_reads(shapes[i], own, (double)i)); + assert(get_reads_undefined_at(&other_ic, shapes[i], missing)); + } + for (size_t i = 0; i < sizeof shapes / sizeof shapes[0]; i++) scr_dyn_release(shapes[i]); + + scr_dyn_release(plain); + scr_dyn_release(sibling); + scr_dyn_release(other_prototype); + scr_dyn_release(grandparent); + scr_dyn_release(child); + scr_dyn_release(prototype); + scr_dyn_release(object); + scr_str_release(inherited); + scr_str_release(missing); + scr_str_release(own); +} + int main(int argc, char **argv) { scr_init(); + /* Shapes count against the budget, so these checks run on their own. */ + if (argc > 1 && strcmp(argv[1], "absent") == 0) { + absent_reads(); + puts("absent read checks passed"); + return 0; + } immortal_objects_stay_unshaped(); if (argc > 1 && strcmp(argv[1], "key-bytes") == 0) { /* 64 KiB keys spend the key-byte budget after 64 shapes. */ diff --git a/packages/runtime/src/scr_js_object.test.ts b/packages/runtime/src/scr_js_object.test.ts index 0a5aa1d5b..1fb90cdaa 100644 --- a/packages/runtime/src/scr_js_object.test.ts +++ b/packages/runtime/src/scr_js_object.test.ts @@ -7,7 +7,7 @@ import { expect, test } from "vitest"; const exec = promisify(execFile); -test("dynamic-tier shapes stay within the shape and key-byte budgets", async () => { +test("dynamic-tier shapes stay within the shape and key-byte budgets, and absent reads are cached", async () => { const scratch = await mkdtemp(join(tmpdir(), "scriptc-js-object-")); const src = import.meta.dirname; const binary = join(scratch, "js-object"); @@ -50,15 +50,19 @@ test("dynamic-tier shapes stay within the shape and key-byte budgets", async () ].map((file) => join(src, file)), ...(process.platform === "linux" ? ["-D_GNU_SOURCE", "-lm"] : []), ]); - for (const args of [[], ["key-bytes"]]) { - const result = await exec(binary, args, { + for (const [args, stdout] of [ + [[], "shape budget checks passed\n"], + [["key-bytes"], "shape budget checks passed\n"], + [["absent"], "absent read checks passed\n"], + ] as const) { + const result = await exec(binary, [...args], { env: { ...process.env, ASAN_OPTIONS: "detect_leaks=0:halt_on_error=1", UBSAN_OPTIONS: "halt_on_error=1", }, }); - expect(result.stdout).toBe("shape budget checks passed\n"); + expect(result.stdout).toBe(stdout); expect(result.stderr).toBe(""); } } finally { diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 39e930cf1..629b7463f 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -52,6 +52,14 @@ static bool scr_dyn_error_prototype_fence(const ScrDyn *value) { SCR_TL const ScrHandleStreamHooks *scr_handle_stream_hooks = NULL; +/* Static member calls (`Math.abs(x)`) do not read the namespace objects, + * so replacing or deleting one of their function members would not reach + * those calls: the change throws instead. */ +static void scr_dyn_namespace_member_fence(void) { + static const char message[] = "Replacing or deleting a Math, JSON or Reflect member is not supported"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC1090"); +} + /* Constructor capsules expose identity and name/length, but do not model * per-class static property tables yet. Never report a fabricated empty view. */ static bool scr_dyn_class_reflection_fence(const ScrDyn *value) { @@ -1778,6 +1786,11 @@ static void scr_dyn_obj_put(ScrDyn *obj, const char *key, size_t key_len, ScrDyn ScrDynEntry *existing = obj->v.obj.index ? scr_dyn_find_entry(obj, key, key_len) : scr_dyn_find_linear_entry(obj, key, key_len); if (existing) { + if (existing->fenced) { + scr_dyn_release(value); + scr_dyn_namespace_member_fence(); + return; + } if (existing->accessor || !existing->writable) { scr_dyn_release(value); static const char message[] = "Cannot assign to read only property"; @@ -1819,6 +1832,7 @@ static void scr_dyn_obj_put(ScrDyn *obj, const char *key, size_t key_len, ScrDyn e->getter = NULL; e->setter = NULL; e->accessor = false; + e->fenced = false; e->writable = true; e->enumerable = true; e->configurable = true; @@ -2219,6 +2233,40 @@ static bool scr_dyn_prototype_valid(const ScrDyn *prototype) { } /* A valid receiver or prototype whose kind this operation does not handle. */ +/* Compiled class prototypes. Compiled code binds a class's methods, its + * base chain and its instanceof answers when it compiles, so the + * prototype's own [[Prototype]] is fixed: scr_dyn_set_prototype refuses to + * change it. Each module-level class prototype registers when it is + * created (scr_dyn_name_class_prototype) and stays alive until its thread + * exits, as the class does. */ +static SCR_TL ScrDyn **scr_dyn_fixed_prototypes; +static SCR_TL size_t scr_dyn_fixed_count, scr_dyn_fixed_cap; + +static void scr_dyn_fixed_prototypes_cleanup(void) { + for (size_t i = 0; i < scr_dyn_fixed_count; i++) scr_dyn_release(scr_dyn_fixed_prototypes[i]); + free(scr_dyn_fixed_prototypes); + scr_dyn_fixed_prototypes = NULL; + scr_dyn_fixed_count = scr_dyn_fixed_cap = 0; +} + +static void scr_dyn_fix_prototype(ScrDyn *prototype) { + if (scr_dyn_fixed_count == scr_dyn_fixed_cap) { + if (scr_dyn_fixed_cap == 0) scr_atexit(scr_dyn_fixed_prototypes_cleanup); + size_t cap = scr_dyn_fixed_cap ? scr_dyn_fixed_cap * 2 : 8; + ScrDyn **grown = realloc(scr_dyn_fixed_prototypes, cap * sizeof *grown); + if (!grown) scr_json_oom(); + scr_dyn_fixed_prototypes = grown; + scr_dyn_fixed_cap = cap; + } + scr_dyn_fixed_prototypes[scr_dyn_fixed_count++] = scr_dyn_retain(prototype); +} + +static bool scr_dyn_prototype_fixed(const ScrDyn *object) { + for (size_t i = 0; i < scr_dyn_fixed_count; i++) + if (scr_dyn_fixed_prototypes[i] == object) return true; + return false; +} + static void scr_dyn_prototype_unsupported(const char *operation) { ScrJsonBuf b; scr_jb_init(&b); @@ -2338,6 +2386,11 @@ ScrDyn *scr_dyn_set_prototype(ScrDyn *object, ScrDyn *prototype) { return NULL; } if (!scr_dyn_prototype_valid(prototype)) return NULL; + if (object->kind == SCR_DYN_OBJ && scr_dyn_fixed_count && scr_dyn_prototype_fixed(object) && + !(prototype->kind == SCR_DYN_NULL ? object->null_proto : prototype == object->prototype)) { + scr_dyn_prototype_unsupported("Object.setPrototypeOf"); + return NULL; + } bool typed = scr_dyn_typed_ref_prototype(prototype); if (scr_exc_pending()) return NULL; if ((object->kind != SCR_DYN_OBJ && object->kind != SCR_DYN_ARR) || @@ -2444,9 +2497,78 @@ void scr_record_throw_frozen(ScrStr *key, ScrStr *owner, double operation) { scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer)); } -/* A class instance's integrity state lives in its native layout, which only - * statically typed calls can reach (the compiler adds the state to the - * hierarchies they name). Through an untyped value the operation throws. */ +/* ── class instance attributes ─────────────────────────────────────────── + * A hierarchy passed to Object.freeze, Object.seal, Object.preventExtensions + * or Object.defineProperty keeps an integrity state per instance and an + * attribute word per redefined field (CLASS_* and FIELD_* bits in the + * compiler's ir.ts). The checked view of an instance carries them, so + * reflection over the view answers as Node does. */ +enum { + SCR_CLASS_NOT_EXTENSIBLE = 1, + SCR_CLASS_SEALED = 2, + SCR_CLASS_FROZEN = 4, + SCR_FIELD_NOT_WRITABLE = 1, + SCR_FIELD_NOT_ENUMERABLE = 2, + SCR_FIELD_NOT_CONFIGURABLE = 4, +}; + +/* Set by scr_dyn_class_view_integrity, which only the commits of + * hierarchies with an integrity word call. */ +static SCR_TL bool scr_class_integrity_committed; + +double scr_dyn_class_view_integrity(const ScrDyn *view, double bits, ScrDyn *bag) { + scr_class_integrity_committed = true; + unsigned state = (unsigned)bits; + if (view->kind != SCR_DYN_OBJ || !view->non_extensible) return state; + state |= SCR_CLASS_NOT_EXTENSIBLE; + if (bag && bag->kind == SCR_DYN_OBJ) bag->non_extensible = true; + bool sealed = true, frozen = true; + /* The string-keyed entries, then the symbol-keyed ones. */ + for (const ScrDyn *table = view; table; table = table == view ? view->symbol_properties : NULL) { + if (table->kind != SCR_DYN_OBJ) continue; + for (size_t i = 0; i < table->v.obj.len; i++) { + const ScrDynEntry *entry = &table->v.obj.entries[i]; + if (entry->configurable) sealed = false; + if (!entry->accessor && entry->writable) frozen = false; + } + } + if (sealed) state |= SCR_CLASS_SEALED; + if (sealed && frozen) state |= SCR_CLASS_FROZEN; + return state; +} + +/* Object.freeze, Object.seal or Object.preventExtensions (`operation`) of + * the class instance a capsule refers to. In a program that applies them to + * untyped values, every hierarchy that can be held there keeps an + * integrity word (class-attributes.ts), and its commit stores the + * integrity state of the instance's checked view in the word + * (scr_dyn_class_view_integrity). The operation applies to the view, and + * the commit records it. A first commit of the unchanged view finds out + * whether the hierarchy has the word. Answers false for other values and + * for instances whose hierarchy has no word, whose state stays as it was, + * and true when the operation applied or threw. */ +static bool scr_dyn_class_integrity(ScrDyn *object, ScrDyn *(*operation)(ScrDyn *)) { + if (object->kind != SCR_DYN_TYPED_REF || !object->v.typed_ref.commit || + object->v.typed_ref.type_key_len < 7 || memcmp(object->v.typed_ref.type_key, "object:", 7) != 0) + return false; + ScrDyn *view = scr_dyn_typed_ref_materialize(object); + bool word = false; + if (!scr_exc_pending()) { + scr_class_integrity_committed = false; + scr_dyn_typed_ref_commit(object); + word = scr_class_integrity_committed; + } + if (word && !scr_exc_pending()) { + scr_dyn_release(operation(view)); + if (!scr_exc_pending()) scr_dyn_typed_ref_commit(object); + } + scr_dyn_release(view); + return word || scr_exc_pending(); +} + +/* A class instance's integrity state lives in its native layout. Through an + * untyped value whose hierarchy has no integrity word, the operation + * throws. */ static bool scr_dyn_class_integrity_fence(const ScrDyn *object, const char *operation) { if (object->kind != SCR_DYN_TYPED_REF) return false; ScrJsonBuf buffer; @@ -2511,6 +2633,8 @@ ScrDyn *scr_dyn_prevent_extensions(ScrDyn *object) { scr_map_prevent_extensions(collection); return scr_dyn_retain(object); } + if (scr_dyn_class_integrity(object, scr_dyn_prevent_extensions)) + return scr_exc_pending() ? NULL : scr_dyn_retain(object); if (scr_dyn_class_integrity_fence(object, "Object.preventExtensions")) return NULL; ScrDyn *table = scr_dyn_fn_integrity_table(object); if (table) { @@ -2549,6 +2673,8 @@ ScrDyn *scr_dyn_seal(ScrDyn *object) { scr_map_prevent_extensions(collection); return scr_dyn_retain(object); } + if (scr_dyn_class_integrity(object, scr_dyn_seal)) + return scr_exc_pending() ? NULL : scr_dyn_retain(object); if (scr_dyn_class_integrity_fence(object, "Object.seal")) return NULL; if (object->symbol_properties) scr_dyn_release(scr_dyn_seal(object->symbol_properties)); ScrDyn *table = scr_dyn_fn_integrity_table(object); @@ -4680,6 +4806,115 @@ int scr_dyn_has_instance_hook(const ScrDyn *value, const ScrDyn *callee) { return truthy ? 1 : 0; } +/* `value instanceof Object`: whether Object.prototype is on the value's + * prototype chain. Primitives answer false, and so does an object whose + * chain ends in null first (Object.create(null), a null-prototype record). + * Objects without a chain the runtime walks (class instances, native + * handles, errors, promises) inherit Object.prototype. False with the + * exception pending when a prototype read throws. */ +static bool scr_dyn_instanceof_object(const ScrDyn *value) { + switch (value->kind) { + case SCR_DYN_UNDEF: + case SCR_DYN_NULL: + case SCR_DYN_BOOL: + case SCR_DYN_NUM: + case SCR_DYN_STR: + case SCR_DYN_BIGINT: + case SCR_DYN_SYMBOL: + return false; + default: + break; + } + /* Object.prototype is on the chain from the value's own prototype on: + * Object.prototype itself has a null prototype, so it is not an Object. */ + ScrDyn *current = scr_dyn_retain((ScrDyn *)value); + for (bool first = true;; first = false) { + if (!first && current == scr_builtin_object_prototype) { + scr_dyn_release(current); + return true; + } + if (current == scr_builtin_object_prototype) { + scr_dyn_release(current); + return false; + } + ScrDyn *next = NULL; + if (current->kind == SCR_DYN_FUNC) { + /* A function's prototype is Function.prototype, whose own is + * Object.prototype, unless the program set it. */ + if (scr_closure_identity(current->v.fn.clo)->props) { + ScrDyn *table = scr_dyn_fn_properties(current); + next = table->prototype ? scr_dyn_retain(table->prototype) + : table->null_proto ? scr_dyn_new_null() : NULL; + scr_dyn_release(table); + } + } else if (current->kind == SCR_DYN_OBJ || current->kind == SCR_DYN_ARR || + (current->kind == SCR_DYN_TYPED_REF && + strncmp(current->v.typed_ref.type_key, "object:", 7) != 0)) { + next = scr_dyn_get_prototype(current); + if (!next) { + scr_dyn_release(current); + return false; /* the read threw: pending */ + } + } + scr_dyn_release(current); + if (!next) return true; + if (next->kind == SCR_DYN_NULL) { + scr_dyn_release(next); + return false; + } + current = next; + } +} + +/* `value instanceof Function`: whether a function is on the value's + * prototype chain. A function's own prototype is Function.prototype unless + * the program set it, so the walk ends true at the first function that + * kept its prototype; it follows set prototypes through functions and + * objects alike (`Object.create(function () {})` is a Function instance), + * and ends false at null or at a value without a chain the runtime walks. + * False with the exception pending when a prototype read throws. */ +static bool scr_dyn_instanceof_function(const ScrDyn *value) { + ScrDyn *current = NULL; + if (value->kind == SCR_DYN_FUNC) { + if (!scr_closure_identity(value->v.fn.clo)->props) return true; + ScrDyn *table = scr_dyn_fn_properties((ScrDyn *)value); + current = table->prototype ? scr_dyn_retain(table->prototype) : NULL; + bool unset = !table->prototype && !table->null_proto; + scr_dyn_release(table); + if (unset) return true; + } else if (value->kind == SCR_DYN_OBJ || value->kind == SCR_DYN_ARR) { + current = scr_dyn_get_prototype((ScrDyn *)value); + if (!current) return false; /* the read threw: pending */ + } + while (current && current->kind != SCR_DYN_NULL) { + ScrDyn *next = NULL; + if (current->kind == SCR_DYN_FUNC) { + if (!scr_closure_identity(current->v.fn.clo)->props) { + scr_dyn_release(current); + return true; + } + ScrDyn *table = scr_dyn_fn_properties(current); + bool unset = !table->prototype && !table->null_proto; + next = table->prototype ? scr_dyn_retain(table->prototype) : NULL; + scr_dyn_release(table); + if (unset) { + scr_dyn_release(current); + return true; + } + } else if (current->kind == SCR_DYN_OBJ || current->kind == SCR_DYN_ARR) { + next = scr_dyn_get_prototype(current); + if (!next) { + scr_dyn_release(current); + return false; /* the read threw: pending */ + } + } + scr_dyn_release(current); + current = next; + } + if (current) scr_dyn_release(current); + return false; +} + bool scr_bytes_instanceof_at(const ScrDyn *value, const ScrDyn *callee, const ScrStr *refusal) { if (!callee || callee->kind != SCR_DYN_FUNC) { static const char message[] = "Right-hand side of 'instanceof' is not callable"; @@ -4688,6 +4923,13 @@ bool scr_bytes_instanceof_at(const ScrDyn *value, const ScrDyn *callee, const Sc } int hooked = scr_dyn_has_instance_hook(value, callee); if (hooked >= 0) return hooked == 1; + if (callee->v.fn.sig && callee->v.fn.name) { + if (strcmp(callee->v.fn.sig, "native:constructor") == 0 && strcmp(callee->v.fn.name, "Object") == 0) + return scr_dyn_instanceof_object(value); + if (strcmp(callee->v.fn.sig, "native:function-constructor") == 0 && + strcmp(callee->v.fn.name, "Function") == 0) + return scr_dyn_instanceof_function(value); + } /* An error constructor held as a value (`inst instanceof params.Parent` * with Parent === Error): the error kind's interval, and for Error itself * objects inheriting Error.prototype. */ @@ -5273,6 +5515,15 @@ ScrDyn *scr_dyn_new_chunk(const ScrBytes *b) { /* A boxed static function value (the compiler's static→dyn converters). * Ownership of the closure MOVES in; sig/name are static literals. */ +void scr_dyn_function_set_length(ScrDyn *fn, uint32_t length) { + if (fn->kind == SCR_DYN_FUNC) fn->v.fn.arity = length; +} + +void scr_dyn_fence_member(ScrDyn *object, const ScrStr *key) { + ScrDynEntry *entry = scr_dyn_entry(object, key); + if (entry) entry->fenced = true; +} + ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, const char *sig, const char *name) { if (clo->function_kind & 8) { ScrDyn *original = scr_box_get_ref(clo->caps[0]); @@ -6143,6 +6394,11 @@ void scr_dyn_obj_set_accessor(ScrDyn *obj, const char *key, size_t key_len, ScrD void scr_dyn_obj_set_field(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *value) { ScrDynEntry *existing = obj->v.obj.index ? scr_dyn_find_entry(obj, key, key_len) : scr_dyn_find_linear_entry(obj, key, key_len); + if (existing && existing->fenced) { + scr_dyn_release(value); + scr_dyn_namespace_member_fence(); + return; + } if (existing && !existing->accessor) { scr_dyn_region_store(obj, value); ScrDyn *old = existing->value; @@ -6426,7 +6682,9 @@ static void scr_dyn_name_prototype_chain(const ScrDyn *object) { } void scr_dyn_name_class_prototype(ScrDyn *prototype, const ScrStr *name) { - if (prototype->kind != SCR_DYN_OBJ || prototype->v.obj.class_name || name->len == 0) return; + if (prototype->kind != SCR_DYN_OBJ) return; + if (!scr_dyn_prototype_fixed(prototype)) scr_dyn_fix_prototype(prototype); + if (prototype->v.obj.class_name || name->len == 0) return; scr_dyn_stamp_prototype_name(prototype, name->data, name->len); } @@ -6458,11 +6716,44 @@ ScrDyn *scr_dyn_construct(const ScrDyn *callee, const ScrDyn *args, const ScrStr /* Symbol descriptors use the ordinary descriptor implementation in an * inaccessible table. Keep a retained key list so identities cannot be - * recycled while properties exist, and reflection returns real symbols. */ + * recycled while properties exist, and reflection returns real symbols. + * + * The storage key is the symbol's address in text, which depends on the + * address alone, so a small cache by address answers repeated defines and + * lookups without formatting a new string each time. A recycled address + * names the same key text, so entries never go stale. */ +#define SCR_SYMBOL_KEY_CACHE 256 +static SCR_TL struct { + const void *symbol; + ScrStr *key; +} scr_symbol_key_cache[SCR_SYMBOL_KEY_CACHE]; +static SCR_TL bool scr_symbol_key_cache_registered; + +static void scr_symbol_key_cache_cleanup(void) { + for (size_t i = 0; i < SCR_SYMBOL_KEY_CACHE; i++) { + scr_str_release(scr_symbol_key_cache[i].key); + scr_symbol_key_cache[i].key = NULL; + scr_symbol_key_cache[i].symbol = NULL; + } + scr_symbol_key_cache_registered = false; +} + static ScrStr *scr_dyn_symbol_storage_key(const ScrDyn *key) { + const void *symbol = key->v.symbol.value; + size_t slot = ((uintptr_t)symbol >> 4) & (SCR_SYMBOL_KEY_CACHE - 1); + if (scr_symbol_key_cache[slot].key && scr_symbol_key_cache[slot].symbol == symbol) + return scr_str_retain(scr_symbol_key_cache[slot].key); char buffer[2 * sizeof(void *) + 4]; - int length = snprintf(buffer, sizeof buffer, "%p", (void *)key->v.symbol.value); - return scr_str_new(buffer, (size_t)length); + int length = snprintf(buffer, sizeof buffer, "%p", (void *)symbol); + ScrStr *name = scr_str_new(buffer, (size_t)length); + if (!scr_symbol_key_cache_registered) { + scr_symbol_key_cache_registered = true; + scr_atexit(scr_symbol_key_cache_cleanup); + } + scr_str_release(scr_symbol_key_cache[slot].key); + scr_symbol_key_cache[slot].symbol = symbol; + scr_symbol_key_cache[slot].key = scr_str_retain(name); + return name; } static ScrDyn *scr_dyn_symbol_receiver(const ScrDyn *value) { @@ -7916,6 +8207,37 @@ void scr_dyn_symbol_key_set(ScrDyn *value, ScrDyn *key, ScrDyn *stored) { scr_dyn_release(receiver); } +/* scr_dyn_obj_set_field for a symbol-keyed field of a class instance's + * view: the view takes the native value whatever the attributes copied into + * it from the instance's property bag (a frozen instance's view is built the + * same way as any other). An accessor entry takes the ordinary assignment. + * Does not consume value. */ +void scr_dyn_symbol_field_set(ScrDyn *obj, ScrDyn *key, ScrDyn *value) { + ScrDynEntry *entry = obj->kind == SCR_DYN_OBJ && obj->symbol_properties + ? scr_dyn_symbol_entry(obj, key, true) + : NULL; + if (entry && !entry->accessor) { + scr_dyn_region_store(obj->symbol_properties, value); + ScrDyn *old = entry->value; + entry->value = scr_dyn_retain(value); + scr_dyn_release(old); + return; + } + if (entry || obj->kind != SCR_DYN_OBJ) { + scr_dyn_symbol_key_set(obj, key, value); + return; + } + bool non_extensible = obj->non_extensible; + ScrDyn *table = obj->symbol_properties; + bool table_non_extensible = table && table->non_extensible; + obj->non_extensible = false; + if (table) table->non_extensible = false; + scr_dyn_symbol_key_set(obj, key, value); + obj->non_extensible = non_extensible; + if (obj->symbol_properties) + obj->symbol_properties->non_extensible = table ? table_non_extensible : non_extensible; +} + static ScrDyn *scr_dyn_define_symbol(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor) { ScrDyn *receiver = scr_dyn_symbol_receiver(target); if (!receiver) { @@ -7985,6 +8307,22 @@ bool scr_dyn_has_key_computed(const ScrDyn *value, const ScrDyn *key) { return s bool scr_dyn_has_own_computed(const ScrDyn *value, const ScrDyn *key) { return scr_dyn_key_probe_computed(value, key, 1); } bool scr_dyn_property_is_enumerable_computed(const ScrDyn *value, const ScrDyn *key) { return scr_dyn_key_probe_computed(value, key, 2); } +static void scr_json_delete_member(ScrDyn *object, const ScrStr *key); + +void scr_dyn_obj_delete_symbol_field(ScrDyn *obj, ScrDyn *symbol) { + if (obj->kind != SCR_DYN_OBJ || !obj->symbol_properties) return; + ScrStr *name = scr_dyn_symbol_storage_key(symbol); + scr_json_delete_member(obj->symbol_properties, name); + scr_str_release(name); + for (size_t i = 0; obj->symbol_keys && i < obj->symbol_keys->v.arr.len; i++) { + if (!scr_dyn_strict_eq(obj->symbol_keys->v.arr.items[i], symbol)) continue; + scr_dyn_release(obj->symbol_keys->v.arr.items[i]); + size_t remaining = --obj->symbol_keys->v.arr.len - i; + memmove(&obj->symbol_keys->v.arr.items[i], &obj->symbol_keys->v.arr.items[i + 1], remaining * sizeof(ScrDyn *)); + break; + } +} + void scr_dyn_key_delete_computed(ScrDyn *value, const ScrDyn *raw_key, bool strict) { ScrDyn *key = scr_dyn_property_key_value(raw_key); if (!key) return; @@ -8017,6 +8355,45 @@ void scr_dyn_key_delete_computed(ScrDyn *value, const ScrDyn *raw_key, bool stri scr_dyn_release(key); } +/* Reflect.deleteProperty(target, key): a TypeError for a primitive target, + * then the key's ToPropertyKey and a sloppy-mode delete. Answers whether + * the property is absent afterwards (false when it is non-configurable). + * A Proxy would observe the own-property check through its traps, and a + * compiled object's native fields keep their values after a delete through + * its checked-dynamic view, so both refuse. */ +bool scr_dyn_reflect_delete_property(ScrDyn *target, const ScrDyn *raw_key) { + switch (target->kind) { + case SCR_DYN_NULL: + case SCR_DYN_UNDEF: + case SCR_DYN_BOOL: + case SCR_DYN_NUM: + case SCR_DYN_STR: + case SCR_DYN_BIGINT: + case SCR_DYN_SYMBOL: { + static const char message[] = "Reflect.deleteProperty called on non-object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return false; + } + default: + break; + } + if (target->kind == SCR_DYN_PROXY) { + scr_dyn_proxy_unsupported("Reflect.deleteProperty"); + return false; + } + if (target->kind == SCR_DYN_TYPED_REF) { + static const char message[] = "Reflect.deleteProperty on a compiled object is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return false; + } + ScrDyn *key = scr_dyn_property_key_value(raw_key); + if (!key) return false; + scr_dyn_key_delete_computed(target, key, false); + bool deleted = !scr_exc_pending() && !scr_dyn_has_own_computed(target, key); + scr_dyn_release(key); + return deleted; +} + /* Native call/apply/bind lowering still owns these prototype operations. * Refuse overrides until every typed call site observes the property table. */ static bool scr_dyn_fn_property_fence(const ScrStr *key) { @@ -8187,6 +8564,13 @@ ScrDyn *scr_dyn_define_property(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor) scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer)); return NULL; } + if (prior && prior->fenced && + (accessor || (value && !scr_dyn_property_same_value(prior->value, value)))) { + scr_dyn_descriptor_fields_drop(fields); + scr_str_release(name); + scr_dyn_namespace_member_fence(); + return NULL; + } if (prior) { if (accessor != prior->accessor) { ScrDyn *old_value = prior->value, *old_getter = prior->getter, *old_setter = prior->setter; @@ -9819,6 +10203,8 @@ ScrStr *scr_dyn_string_coerce_js(const ScrDyn *d) { scr_error_release(error); return text; } + // Object.prototype.toString: "[object Object]", or the object's + // Symbol.toStringTag ("[object Math]"). return scr_dyn_object_tag(d); } if (!m || m->kind != SCR_DYN_FUNC) { scr_dyn_release(m); continue; } @@ -10489,6 +10875,10 @@ static void scr_dyn_object_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value, con scr_dyn_throw_property(receiver, key->data, key->len, false); return; } + if (entry->fenced) { + scr_dyn_namespace_member_fence(); + return; + } scr_dyn_region_store(recv, value); ScrDyn *old = entry->value; entry->value = scr_dyn_retain(value); @@ -10886,6 +11276,14 @@ static SCR_TL ScrDynVisitLog scr_dyn_viewed_log; /* The member value a matcher or builder read last and holds one * reference to until it calls scr_dyn_release_held. */ static SCR_TL const ScrDyn *scr_dyn_visit_held; +/* Set while a union's builder builds the arm it matched in full + * (scr_dyn_trust_enter). */ +static SCR_TL bool scr_dyn_trusted; +/* Set once a getter or proxy trap has run during this session, or during + * a trusted build outside any session. Their code can return a value the + * match never saw or change one it saw, so no build in the rest of the + * session, or of the outermost trusted build, trusts an earlier match. */ +static SCR_TL bool scr_dyn_session_ran_code; /* Tables up to this many slots stay allocated for the next session. */ #define SCR_DYN_VISIT_KEEP 4096 @@ -10983,27 +11381,33 @@ static void scr_dyn_visit_clear(ScrDynVisitSet *s, ScrDynVisitLog *log) { static void scr_dyn_visit_exit(void) { if (--scr_dyn_visit_depth != 0) return; + scr_dyn_session_ran_code = false; scr_dyn_visit_clear(&scr_dyn_built, &scr_dyn_built_log); scr_dyn_visit_clear(&scr_dyn_viewed, &scr_dyn_viewed_log); } /* Getters and proxy traps run user code, which may start conversions of - * its own and catch their failures. It runs with the session set aside, - * so its conversions use their own tables and leave the session's - * entries alone. */ + * its own and catch their failures. It runs with the session and any + * trusted build set aside, so its conversions use their own tables and + * leave the session's entries and the trusted build's state alone. */ typedef struct { size_t depth; ScrDynVisitSet matching, built, viewed; ScrDynVisitLog built_log, viewed_log; bool track; const ScrDyn *held; + bool trusted; + bool ran_code; } ScrDynVisitState; static void scr_dyn_visit_suspend(ScrDynVisitState *saved) { - *saved = (ScrDynVisitState){scr_dyn_visit_depth, scr_dyn_matching, scr_dyn_built, - scr_dyn_viewed, scr_dyn_built_log, scr_dyn_viewed_log, - scr_dyn_build_track, scr_dyn_visit_held}; + *saved = (ScrDynVisitState){scr_dyn_visit_depth, scr_dyn_matching, scr_dyn_built, + scr_dyn_viewed, scr_dyn_built_log, scr_dyn_viewed_log, + scr_dyn_build_track, scr_dyn_visit_held, scr_dyn_trusted, + scr_dyn_session_ran_code}; scr_dyn_visit_depth = 0; + scr_dyn_trusted = false; + scr_dyn_session_ran_code = false; scr_dyn_matching = scr_dyn_built = scr_dyn_viewed = (ScrDynVisitSet){NULL, 0, 0}; scr_dyn_built_log = scr_dyn_viewed_log = (ScrDynVisitLog){NULL, 0, 0}; scr_dyn_visit_held = NULL; @@ -11023,11 +11427,15 @@ static void scr_dyn_visit_resume(const ScrDynVisitState *saved) { scr_dyn_viewed_log = saved->viewed_log; scr_dyn_build_track = saved->track; scr_dyn_visit_held = saved->held; + scr_dyn_trusted = saved->trusted; + /* The getter or trap this session was set aside for has run. */ + scr_dyn_session_ran_code = true; } -/* Calls a getter for a member read, outside any visit session. */ +/* Calls a getter for a member read, outside any visit session or trusted + * build. */ static ScrDyn *scr_dyn_visit_call_getter(ScrDyn *getter) { - if (scr_dyn_visit_depth == 0) return scr_dyn_call(getter, NULL, 0, "getter"); + if (scr_dyn_visit_depth == 0 && !scr_dyn_trusted) return scr_dyn_call(getter, NULL, 0, "getter"); ScrDynVisitState saved; scr_dyn_visit_suspend(&saved); ScrDyn *result = scr_dyn_call(getter, NULL, 0, "getter"); @@ -11035,9 +11443,10 @@ static ScrDyn *scr_dyn_visit_call_getter(ScrDyn *getter) { return result; } -/* A proxy's get trap for a member read, outside any visit session. */ +/* A proxy's get trap for a member read, outside any visit session or + * trusted build. */ static ScrDyn *scr_dyn_visit_proxy_get(const ScrDyn *d, ScrStr *name) { - if (scr_dyn_visit_depth == 0) return scr_dyn_proxy_get(d, name); + if (scr_dyn_visit_depth == 0 && !scr_dyn_trusted) return scr_dyn_proxy_get(d, name); ScrDynVisitState saved; scr_dyn_visit_suspend(&saved); ScrDyn *result = scr_dyn_proxy_get(d, name); @@ -11045,6 +11454,21 @@ static ScrDyn *scr_dyn_visit_proxy_get(const ScrDyn *d, ScrStr *name) { return result; } +bool scr_dyn_trust_enter(void) { + bool previous = scr_dyn_trusted; + scr_dyn_trusted = true; + return previous; +} + +void scr_dyn_trust_exit(bool previous) { + scr_dyn_trusted = previous; + /* The outermost trusted build outside a session ends: later conversions + * may trust their own matches again. A session clears it on its exit. */ + if (!previous && scr_dyn_visit_depth == 0) scr_dyn_session_ran_code = false; +} + +bool scr_dyn_trusted_build(void) { return scr_dyn_trusted && !scr_dyn_session_ran_code; } + ScrDyn *scr_dyn_obj_read_held(const ScrDyn *d, const char *key, size_t key_len) { ScrDyn *value = scr_dyn_obj_read(d, key, key_len); scr_dyn_visit_held = value; @@ -11751,6 +12175,7 @@ static void scr_dyn_obj_append_shared(ScrDyn *obj, char *key, size_t key_len, Sc e->getter = NULL; e->setter = NULL; e->accessor = false; + e->fenced = false; e->writable = true; e->enumerable = true; e->configurable = true; @@ -11959,7 +12384,8 @@ static bool scr_jschema_read(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot *o static void scr_jschema_drop(const ScrJsonSchema *s, ScrJsonSlot *v) { if (s->kind == SCR_JSCHEMA_STR) scr_str_release(v->ptr); - else if (s->kind == SCR_JSCHEMA_REC || s->kind == SCR_JSCHEMA_ARR) s->release(v->ptr); + else if (s->kind == SCR_JSCHEMA_REC || s->kind == SCR_JSCHEMA_ARR || s->kind == SCR_JSCHEMA_UNION) + s->release(v->ptr); } static bool scr_jschema_string_skip(ScrJsonP *p) { @@ -12073,14 +12499,79 @@ static void scr_jschema_inline_store(char *slot, size_t tag, uint64_t bits) { memcpy(slot, &pair, sizeof pair); } -/* A union member value written into its field slot. Each JSON value kind - * selects at most one arm (the compiler admits no two arms one kind could - * both match), so the checked route's first full match is that arm, and - * any other value declines. */ -static __attribute__((noinline)) bool scr_jschema_union(ScrJsonP *p, const ScrJsonUnion *u, - char *slot) { - scr_json_ws(p); - if (p->pos >= p->len) return false; +/* The string at p->pos (a quote), as a span into the text or, when it + * holds escapes, decoded into *decoded (released by the caller). */ +static bool scr_jschema_string_at(ScrJsonP *p, const char **span, size_t *span_len, + ScrStr **decoded) { + *decoded = NULL; + int r = scr_json_string_span(p, span, span_len); + if (r > 0) return true; + if (r < 0) return false; + *decoded = scr_json_string_slow(p, *span_len); + if (!*decoded) return false; + *span = (*decoded)->data; + *span_len = (*decoded)->len; + return true; +} + +/* The case of discriminated union `d` that the object at p->pos (its + * opening brace) selects: the one whose literal the object's discriminant + * member holds. A shallow scan of the members up to that one finds it and + * restores p->pos. NULL when the member is missing or not a string, when + * no case has its value, or when the text before it is malformed: the + * checked route then decides. A repeated discriminant member selects by + * its first value; the arm's record then declines the repeat. */ +static __attribute__((noinline)) const ScrJsonCase *scr_jschema_case(ScrJsonP *p, + const ScrJsonDiscriminant *d) { + const size_t start = p->pos, depth = p->depth; + const ScrJsonCase *found = NULL; + if (++p->depth > SCR_JSON_MAX_DEPTH) goto done; + p->pos++; /* '{' */ + for (;;) { + scr_json_ws(p); + if (p->pos >= p->len || p->s[p->pos] != '"') goto done; + const char *key; + size_t key_len; + ScrStr *decoded; + if (!scr_jschema_string_at(p, &key, &key_len, &decoded)) goto done; + bool discriminant = key_len == d->field_len && scr_key_equal(key, d->field, key_len); + scr_str_release(decoded); + scr_json_ws(p); + if (p->pos >= p->len || p->s[p->pos] != ':') goto done; + p->pos++; + if (discriminant) { + scr_json_ws(p); + if (p->pos >= p->len || p->s[p->pos] != '"') goto done; + const char *value; + size_t value_len; + if (!scr_jschema_string_at(p, &value, &value_len, &decoded)) goto done; + for (size_t i = 0; i < d->ncases; i++) { + const ScrJsonCase *c = &d->cases[i]; + if (c->value_len == value_len && memcmp(c->value, value, value_len) == 0) { + found = c; + break; + } + } + scr_str_release(decoded); + goto done; + } + if (!scr_jschema_skip(p)) goto done; + scr_json_ws(p); + if (p->pos >= p->len || p->s[p->pos] != ',') goto done; + p->pos++; + } +done: + p->pos = start; + p->depth = depth; + return found; +} + +/* A union value's arm tag and payload at p->pos (whitespace skipped): the + * arm its JSON value kind selects or, for an object of a discriminated + * union, its case's record. A null value reads no payload (*arm_out is + * SCR_JARM_NULL). False when the value declines. */ +static bool scr_jschema_union_payload(ScrJsonP *p, const ScrJsonUnion *u, size_t *tag, + size_t *arm_out, ScrJsonSlot *v) { char c = p->s[p->pos]; size_t arm; if (c == 'n') arm = SCR_JARM_NULL; @@ -12090,16 +12581,36 @@ static __attribute__((noinline)) bool scr_jschema_union(ScrJsonP *p, const ScrJs else if (c == 't' || c == 'f') arm = SCR_JARM_BOOL; else if (c == '-' || (c >= '0' && c <= '9')) arm = SCR_JARM_NUM; else return false; - size_t tag = u->tags[arm]; - if (tag == SCR_JSCHEMA_NO_ARM) return false; + *arm_out = arm; + if (arm == SCR_JARM_OBJ && u->disc) { + const ScrJsonCase *selected = scr_jschema_case(p, u->disc); + if (!selected) return false; + *tag = selected->tag; + return scr_jschema_read(p, selected->record, v); + } + *tag = u->tags[arm]; + if (*tag == SCR_JSCHEMA_NO_ARM) return false; + if (arm == SCR_JARM_NULL) return scr_json_lit(p, "null", 4); + return scr_jschema_read(p, u->arms[arm], v); +} + +/* A union member value written into its field slot. Each JSON value kind + * selects at most one arm, and an object of a discriminated union selects + * its case's arm (the compiler admits no two arms one value could both + * match), so the checked route's first full match is that arm, and any + * other value declines. */ +static __attribute__((noinline)) bool scr_jschema_union(ScrJsonP *p, const ScrJsonUnion *u, + char *slot) { + scr_json_ws(p); + if (p->pos >= p->len) return false; + size_t tag, arm; + ScrJsonSlot v; + if (!scr_jschema_union_payload(p, u, &tag, &arm, &v)) return false; if (arm == SCR_JARM_NULL) { - if (!scr_json_lit(p, "null", 4)) return false; if (u->repr == SCR_JUNION_INLINE) scr_jschema_inline_store(slot, tag, 0); else memcpy(slot, &u->null_value, sizeof u->null_value); /* immortal */ return true; } - ScrJsonSlot v; - if (!scr_jschema_read(p, u->arms[arm], &v)) return false; uint64_t bits = 0; if (arm == SCR_JARM_NUM) memcpy(&bits, &v.f, sizeof bits); else if (arm == SCR_JARM_BOOL) bits = v.b ? 1 : 0; @@ -12115,6 +12626,19 @@ static __attribute__((noinline)) bool scr_jschema_union(ScrJsonP *p, const ScrJs return true; } +/* A discriminated union value outside a record field: an array element or + * the whole document. A pointer union's value is the case's record itself; + * any other union's is a box. */ +static bool scr_jschema_union_value(ScrJsonP *p, const ScrJsonUnion *u, ScrJsonSlot *out) { + size_t tag, arm; + ScrJsonSlot v; + if (!scr_jschema_union_payload(p, u, &tag, &arm, &v)) return false; + if (arm == SCR_JARM_NULL) out->ptr = u->null_value; /* immortal */ + else if (u->repr == SCR_JUNION_NULLABLE) out->ptr = v.ptr; + else out->ptr = u->box((uint32_t)tag, (uint64_t)(uintptr_t)v.ptr); /* the record moves in */ + return true; +} + /* A declared member value written into its (zeroed) slot of `rec`. */ static inline __attribute__((always_inline)) bool scr_jschema_field(ScrJsonP *p, const ScrJsonSchemaField *f, @@ -12460,6 +12984,8 @@ static bool scr_jschema_read(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot *o p->depth--; return ok; } + case SCR_JSCHEMA_UNION: + return scr_jschema_union_value(p, s->uni, out); } return false; } @@ -12543,6 +13069,10 @@ static void scr_json_delete_member(ScrDyn *object, const ScrStr *key) { for (size_t i = 0; i < object->v.obj.len; i++) { ScrDynEntry *entry = &object->v.obj.entries[i]; if (entry->key_len != key->len || memcmp(entry->key, key->data, key->len) != 0) continue; + if (entry->fenced) { + scr_dyn_namespace_member_fence(); + return; + } ScrDynEntry removed = *entry; memmove(entry, entry + 1, (object->v.obj.len - i - 1) * sizeof *entry); object->v.obj.len--; @@ -12560,6 +13090,10 @@ static void scr_json_delete_member(ScrDyn *object, const ScrStr *key) { } } +void scr_dyn_obj_delete_field(ScrDyn *obj, const ScrStr *key) { + if (obj->kind == SCR_DYN_OBJ) scr_json_delete_member(obj, key); +} + /* Ordinary native objects carry removable own data properties. Dense * arrays, typed references and handles keep their explicit boundary; * deleting from a materialized snapshot would lose the mutation. */ @@ -14139,7 +14673,9 @@ bool scr_dyn_reflect_define(ScrDyn *receiver, ScrDyn *raw_key, ScrDyn *value) { ScrDynEntry *entry = key->kind == SCR_DYN_SYMBOL ? (symbols ? scr_dyn_symbol_entry(symbols, key, true) : NULL) : receiver->kind == SCR_DYN_OBJ ? scr_dyn_entry(receiver, key->v.str) : receiver->kind == SCR_DYN_ARR && receiver->v.arr.properties ? scr_dyn_entry(receiver->v.arr.properties, key->v.str) : NULL; - if (!(entry && (entry->accessor || !entry->writable)) && !(receiver->non_extensible && !scr_dyn_has_own_computed(receiver, key))) { + if (entry && entry->fenced) { + scr_dyn_namespace_member_fence(); + } else if (!(entry && (entry->accessor || !entry->writable)) && !(receiver->non_extensible && !scr_dyn_has_own_computed(receiver, key))) { if (entry) { ScrDyn *old = entry->value; entry->value = scr_dyn_retain(value); @@ -16320,26 +16856,176 @@ typedef struct ScrWeakEntry { unsigned kind; ScrDyn *value; ScrWeakMap *owner; - struct ScrWeakEntry *next, *all_next; + /* The owner's entries, and every collection's entries for the same key + * pointer (whatever their kind): both doubly linked, so an entry leaves + * either list in constant time. */ + struct ScrWeakEntry *next, *prev, *key_next, *key_prev; } ScrWeakEntry; struct ScrWeakMap { size_t rc; ScrWeakEntry *entries; bool is_set; }; /* Weak observers borrow key identities. Values are traced strong edges; - * value-to-key cycles require explicit deletion or container disposal. */ -#define SCR_WEAK_BUCKETS 1024 -static SCR_TL ScrWeakEntry *scr_weak_entries[SCR_WEAK_BUCKETS]; -static size_t scr_weak_bucket(void *key) { - uintptr_t bits = (uintptr_t)key; - return ((bits >> 4) ^ (bits >> 14)) & (SCR_WEAK_BUCKETS - 1); + * value-to-key cycles require explicit deletion or container disposal. + * + * Two open-addressing indexes per thread find entries in expected constant + * time: scr_weak_index by (collection, key, kind), for get/set/has/delete, + * and scr_weak_keys by key pointer, the head of that key's entries, for a + * disposal, which ends the key's entries in every collection. Both use + * linear probing with backward-shift deletion, grow at half load, and are + * freed when their last entry goes. */ +static SCR_TL ScrWeakEntry **scr_weak_index; +static SCR_TL size_t scr_weak_index_mask, scr_weak_index_used; +typedef struct { void *key; ScrWeakEntry *head; } ScrWeakKeySlot; +static SCR_TL ScrWeakKeySlot *scr_weak_keys; +static SCR_TL size_t scr_weak_keys_mask, scr_weak_keys_used; + +/* Mixed in 64 bits on every target: uintptr_t is 32 bits on wasm32, where + * these shifts would exceed its width. */ +static inline size_t scr_weak_mix(uint64_t bits) { + bits ^= bits >> 33; + bits *= UINT64_C(0xff51afd7ed558ccd); + bits ^= bits >> 29; + return (size_t)bits; +} +static inline size_t scr_weak_entry_hash(const ScrWeakMap *owner, const void *key, unsigned kind) { + return scr_weak_mix((uint64_t)(uintptr_t)key ^ ((uint64_t)(uintptr_t)owner * UINT64_C(0x9E3779B97F4A7C15)) ^ + ((uint64_t)kind << 56)); +} +static inline size_t scr_weak_key_hash(const void *key) { return scr_weak_mix((uint64_t)(uintptr_t)key); } + +static void scr_weak_index_put(ScrWeakEntry *entry) { + size_t j = scr_weak_entry_hash(entry->owner, entry->key, entry->kind) & scr_weak_index_mask; + while (scr_weak_index[j]) j = (j + 1) & scr_weak_index_mask; + scr_weak_index[j] = entry; +} +static void scr_weak_index_reserve(void) { + if (scr_weak_index && (scr_weak_index_used + 1) * 2 <= scr_weak_index_mask + 1) return; + ScrWeakEntry **old = scr_weak_index; + size_t n = old ? scr_weak_index_mask + 1 : 0, cap = n ? n * 2 : 64; + scr_weak_index = calloc(cap, sizeof *scr_weak_index); + if (!scr_weak_index) scr_json_oom(); + scr_weak_index_mask = cap - 1; + for (size_t i = 0; i < n; i++) + if (old[i]) scr_weak_index_put(old[i]); + free(old); +} +static ScrWeakEntry *scr_weak_find(ScrWeakMap *map, void *ptr, unsigned kind) { + if (!scr_weak_index) return NULL; + for (size_t j = scr_weak_entry_hash(map, ptr, kind) & scr_weak_index_mask;; j = (j + 1) & scr_weak_index_mask) { + ScrWeakEntry *entry = scr_weak_index[j]; + if (!entry) return NULL; + if (entry->owner == map && entry->key == ptr && entry->kind == kind) return entry; + } +} +static void scr_weak_index_delete(ScrWeakEntry *entry) { + size_t mask = scr_weak_index_mask; + size_t i = scr_weak_entry_hash(entry->owner, entry->key, entry->kind) & mask; + while (scr_weak_index[i] != entry) i = (i + 1) & mask; + if (--scr_weak_index_used == 0) { + /* Empty: give the table back, so a thread that exits leaves none. */ + free(scr_weak_index); + scr_weak_index = NULL; + return; + } + for (size_t k = i;;) { + scr_weak_index[i] = NULL; + for (;;) { + k = (k + 1) & mask; + ScrWeakEntry *e = scr_weak_index[k]; + if (!e) return; + size_t home = scr_weak_entry_hash(e->owner, e->key, e->kind) & mask; + /* e stays unless its home lies cyclically outside (i, k]. */ + if (i <= k ? (i < home && home <= k) : (i < home || home <= k)) continue; + scr_weak_index[i] = e; + i = k; + break; + } + } +} + +static ScrWeakKeySlot *scr_weak_key_slot(const void *key) { + if (!scr_weak_keys) return NULL; + for (size_t j = scr_weak_key_hash(key) & scr_weak_keys_mask;; j = (j + 1) & scr_weak_keys_mask) { + if (scr_weak_keys[j].key == key) return &scr_weak_keys[j]; + if (!scr_weak_keys[j].key) return NULL; + } +} +static ScrWeakKeySlot *scr_weak_key_slot_put(void *key) { + if (!scr_weak_keys || (scr_weak_keys_used + 1) * 2 > scr_weak_keys_mask + 1) { + ScrWeakKeySlot *old = scr_weak_keys; + size_t n = old ? scr_weak_keys_mask + 1 : 0, cap = n ? n * 2 : 64; + scr_weak_keys = calloc(cap, sizeof *scr_weak_keys); + if (!scr_weak_keys) scr_json_oom(); + scr_weak_keys_mask = cap - 1; + for (size_t i = 0; i < n; i++) { + if (!old[i].key) continue; + size_t j = scr_weak_key_hash(old[i].key) & scr_weak_keys_mask; + while (scr_weak_keys[j].key) j = (j + 1) & scr_weak_keys_mask; + scr_weak_keys[j] = old[i]; + } + free(old); + } + size_t j = scr_weak_key_hash(key) & scr_weak_keys_mask; + while (scr_weak_keys[j].key && scr_weak_keys[j].key != key) j = (j + 1) & scr_weak_keys_mask; + if (!scr_weak_keys[j].key) { + scr_weak_keys[j].key = key; + scr_weak_keys[j].head = NULL; + scr_weak_keys_used++; + } + return &scr_weak_keys[j]; +} +static void scr_weak_key_slot_delete(ScrWeakKeySlot *slot) { + size_t mask = scr_weak_keys_mask; + size_t i = (size_t)(slot - scr_weak_keys); + if (--scr_weak_keys_used == 0) { + free(scr_weak_keys); + scr_weak_keys = NULL; + return; + } + for (size_t k = i;;) { + scr_weak_keys[i].key = NULL; + scr_weak_keys[i].head = NULL; + for (;;) { + k = (k + 1) & mask; + if (!scr_weak_keys[k].key) return; + size_t home = scr_weak_key_hash(scr_weak_keys[k].key) & mask; + if (i <= k ? (i < home && home <= k) : (i < home || home <= k)) continue; + scr_weak_keys[i] = scr_weak_keys[k]; + i = k; + break; + } + } +} + +/* Indexes a new entry (its key, kind and owner set) under its key. */ +static void scr_weak_link(ScrWeakEntry *entry) { + scr_weak_index_reserve(); + scr_weak_index_put(entry); + scr_weak_index_used++; + ScrWeakKeySlot *slot = scr_weak_key_slot_put(entry->key); + entry->key_prev = NULL; + entry->key_next = slot->head; + if (slot->head) slot->head->key_prev = entry; + slot->head = entry; +} + +/* Takes an entry out of both indexes (it stays in its owner's list). */ +static void scr_weak_unlink(ScrWeakEntry *entry) { + scr_weak_index_delete(entry); + if (entry->key_next) entry->key_next->key_prev = entry->key_prev; + if (entry->key_prev) entry->key_prev->key_next = entry->key_next; + else { + ScrWeakKeySlot *slot = scr_weak_key_slot(entry->key); + if (entry->key_next) slot->head = entry->key_next; + else scr_weak_key_slot_delete(slot); + } } static void scr_weak_remove_impl(ScrWeakEntry *entry, bool collected) { - ScrWeakEntry **slot = &entry->owner->entries; - while (*slot != entry) slot = &(*slot)->next; - *slot = entry->next; - slot = &scr_weak_entries[scr_weak_bucket(entry->key)]; - while (*slot != entry) slot = &(*slot)->all_next; - *slot = entry->all_next; + ScrWeakMap *owner = entry->owner; + if (entry->next) entry->next->prev = entry->prev; + if (entry->prev) entry->prev->next = entry->next; + else owner->entries = entry->next; + scr_weak_unlink(entry); ScrDyn *value = entry->value; free(entry); /* Unlink before release: releasing a value can dispose another key. */ @@ -16363,10 +17049,9 @@ static void scr_weak_map_gcfree(void *ptr) { static void scr_weak_disposed(void *key) { for (;;) { - ScrWeakEntry *entry = scr_weak_entries[scr_weak_bucket(key)]; - while (entry && entry->key != key) entry = entry->all_next; - if (!entry) return; - scr_weak_remove(entry); + ScrWeakKeySlot *slot = scr_weak_key_slot(key); + if (!slot) return; + scr_weak_remove(slot->head); } } @@ -16424,12 +17109,6 @@ static int scr_weak_key(const ScrDyn *key, void **ptr, unsigned *kind) { } } -static ScrWeakEntry *scr_weak_find(ScrWeakMap *map, void *ptr, unsigned kind) { - for (ScrWeakEntry *entry = map->entries; entry; entry = entry->next) - if (entry->key == ptr && entry->kind == kind) return entry; - return NULL; -} - static bool scr_weak_set(ScrWeakMap *map, ScrDyn *key, ScrDyn *value) { void *ptr = NULL; unsigned kind = 0; @@ -16458,10 +17137,9 @@ static bool scr_weak_set(ScrWeakMap *map, ScrDyn *key, ScrDyn *value) { entry->value = scr_dyn_retain(value); entry->owner = map; entry->next = map->entries; + if (map->entries) map->entries->prev = entry; map->entries = entry; - size_t bucket = scr_weak_bucket(ptr); - entry->all_next = scr_weak_entries[bucket]; - scr_weak_entries[bucket] = entry; + scr_weak_link(entry); return true; } @@ -17201,6 +17879,8 @@ ScrDyn *scr_dyn_freeze(ScrDyn *value) { if (scr_dyn_class_reflection_fence(value) || scr_dyn_error_prototype_fence(value)) return NULL; ScrDyn *native = scr_dyn_freeze_native(value); if (native) return native; + if (scr_dyn_class_integrity(value, scr_dyn_freeze_data)) + return scr_exc_pending() ? NULL : scr_dyn_retain(value); if (scr_dyn_class_integrity_fence(value, "Object.freeze")) return NULL; ScrDyn *table = scr_dyn_fn_integrity_table(value); if (!table) return scr_dyn_freeze_data(value); @@ -17306,21 +17986,6 @@ ScrDyn *scr_caught_to_dyn(const ScrCaught *c) { } } -/* ── class instance attributes ─────────────────────────────────────────── - * A hierarchy passed to Object.freeze, Object.seal, Object.preventExtensions - * or Object.defineProperty keeps an integrity state per instance and an - * attribute word per redefined field (CLASS_* and FIELD_* bits in the - * compiler's ir.ts). The checked view of an instance carries them, so - * reflection over the view answers as Node does. */ -enum { - SCR_CLASS_NOT_EXTENSIBLE = 1, - SCR_CLASS_SEALED = 2, - SCR_CLASS_FROZEN = 4, - SCR_FIELD_NOT_WRITABLE = 1, - SCR_FIELD_NOT_ENUMERABLE = 2, - SCR_FIELD_NOT_CONFIGURABLE = 4, -}; - void scr_dyn_class_field_attributes(ScrDyn *view, ScrStr *name, double bits) { unsigned b = (unsigned)bits; if (b == 0 || view->kind != SCR_DYN_OBJ) return; @@ -17331,17 +17996,27 @@ void scr_dyn_class_field_attributes(ScrDyn *view, ScrStr *name, double bits) { if (b & SCR_FIELD_NOT_CONFIGURABLE) entry->configurable = false; } -void scr_dyn_class_view_attributes(ScrDyn *view, double bits) { - unsigned b = (unsigned)bits; - if (b == 0 || view->kind != SCR_DYN_OBJ) return; - view->non_extensible = true; - for (size_t i = 0; i < view->v.obj.len; i++) { - ScrDynEntry *entry = &view->v.obj.entries[i]; +/* The integrity state `b` on the entries of `table`: a view, or the table + * of its symbol-keyed properties. */ +static void scr_dyn_class_table_attributes(ScrDyn *table, unsigned b) { + table->non_extensible = true; + for (size_t i = 0; i < table->v.obj.len; i++) { + ScrDynEntry *entry = &table->v.obj.entries[i]; if (b & (SCR_CLASS_SEALED | SCR_CLASS_FROZEN)) entry->configurable = false; if ((b & SCR_CLASS_FROZEN) && !entry->accessor) entry->writable = false; } } +void scr_dyn_class_view_attributes(ScrDyn *view, double bits) { + unsigned b = (unsigned)bits; + if (b == 0 || view->kind != SCR_DYN_OBJ) return; + scr_dyn_class_table_attributes(view, b); + /* Symbol-keyed fields, and symbol-keyed properties added to the + * instance, take the same state. */ + if (view->symbol_properties && view->symbol_properties->kind == SCR_DYN_OBJ) + scr_dyn_class_table_attributes(view->symbol_properties, b); +} + /* Object.defineProperty on a declared class field. The descriptor applies to * a one-property object holding the field's current value and attributes, * so validation and errors match ordinary objects. Answers that object (+1), @@ -17698,16 +18373,14 @@ static bool scr_dyn_decl_rekey(ScrDyn *node, void *ptr) { bool moved = false; if (!scr_weak_observing) return false; for (;;) { - ScrWeakEntry **slot = &scr_weak_entries[scr_weak_bucket(node)]; - while (*slot && !((*slot)->key == (void *)node && (*slot)->kind == 0)) slot = &(*slot)->all_next; - ScrWeakEntry *entry = *slot; + ScrWeakKeySlot *slot = scr_weak_key_slot(node); + ScrWeakEntry *entry = slot ? slot->head : NULL; + while (entry && entry->kind != 0) entry = entry->key_next; if (!entry) return moved; - *slot = entry->all_next; + scr_weak_unlink(entry); entry->key = ptr; entry->kind = 32; - ScrWeakEntry **head = &scr_weak_entries[scr_weak_bucket(ptr)]; - entry->all_next = *head; - *head = entry; + scr_weak_link(entry); moved = true; } } diff --git a/packages/runtime/src/scr_json.h b/packages/runtime/src/scr_json.h index 8a4ecc62d..a8a59b60c 100644 --- a/packages/runtime/src/scr_json.h +++ b/packages/runtime/src/scr_json.h @@ -13,11 +13,14 @@ ScrDyn *scr_json_parse(ScrStr *text); /* Compiler-emitted description of a statically known JSON.parse target * (`JSON.parse(text) as T`). Constant data in the program image; the LLVM * backend mirrors this layout ({size, size, ptr, ptr, ptr, ptr, ptr, ptr, size}, - * {ptr, size, size, ptr} and {size, [7 x size], [7 x ptr], ptr, ptr, ptr}). - * Records list their declared fields (at most 64), each with its byte - * offset in the record struct; bool fields are one byte, f64 fields - * doubles, inline union fields a {uint32 tag, uint64 bits} pair, every - * other kind a +1 pointer. Unions appear only as record field types. A + * {ptr, size, size, ptr}, {size, [7 x size], [7 x ptr], ptr, ptr, ptr, ptr}, + * and for discriminated unions {ptr, size, size, ptr} with {ptr, size, size, + * ptr} cases). Records list their declared fields (at most 64), each with + * its byte offset in the record struct; bool fields are one byte, f64 + * fields doubles, inline union fields a {uint32 tag, uint64 bits} pair, + * every other kind a +1 pointer. Unions appear as record field types; + * discriminated unions also as array elements and whole documents, whose + * values are the union's box (or its pointer, for a pointer union). A * record with an index signature names its index value type in `elem` and * its overflow map's offset in `ovf_offset`. */ enum { @@ -57,6 +60,22 @@ typedef struct { size_t offset; const ScrJsonSchema *type; } ScrJsonSchemaField; +/* One string literal of a discriminated union's discriminant member and + * the record arm (canonical tag and schema) that holds it. */ +typedef struct { + const char *value; + size_t value_len; + size_t tag; + const ScrJsonSchema *record; +} ScrJsonCase; +/* A discriminated union's object arms by discriminant literal: every + * literal names one arm. */ +typedef struct { + const char *field; + size_t field_len; + size_t ncases; + const ScrJsonCase *cases; +} ScrJsonDiscriminant; typedef struct { size_t repr; size_t tags[SCR_JARM_COUNT]; /* canonical arm tag, or SCR_JSCHEMA_NO_ARM */ @@ -66,6 +85,9 @@ typedef struct { /* boxed: a +1 box of the value arm `tag`; bits hold its double, its * bool, or its +1 payload pointer (moved in). */ void *(*box)(uint32_t tag, uint64_t bits); + /* discriminated unions: the object arms; NULL for other unions, whose + * object arm is arms[SCR_JARM_OBJ] */ + const ScrJsonDiscriminant *disc; } ScrJsonUnion; struct ScrJsonSchema { size_t kind; diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index fb3524c81..bd5fdfda1 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4236,27 +4236,35 @@ double scr_fs_read_sync(double fd, ScrBytes *buf, double offset, double length, scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)mlen, "ERR_OUT_OF_RANGE"); return 0; } - /* Node returns after the offset and position validation when the - * requested length normalizes to zero: buffer-window bounds and fd are - * not consulted. Preserve the existing size_t coercion's [0, 1) case. */ - if (length >= 0 && length < 1) return 0; - if (offset > (double)bytelen) { - numbuf[scr_f64_to_str(offset, numbuf)] = 0; + /* `length |= 0`, then Node's order: a zero length reads nothing after + * the offset and position checks; otherwise the buffer must not be + * empty and the window must fit it (validateOffsetLengthRead). */ + if (!isfinite(length)) { + length = 0; + } else { + double wrapped = fmod(trunc(length), 4294967296.0); + if (wrapped < 0) wrapped += 4294967296.0; + length = wrapped >= 2147483648.0 ? wrapped - 4294967296.0 : wrapped; + } + if (length == 0) return 0; + if (bytelen == 0) { + static const char empty[] = + "The argument 'buffer' is empty and cannot be written. Received "; + scr_throw_error_msg_code(SCR_ERR_TYPE, empty, sizeof empty - 1, "ERR_INVALID_ARG_VALUE"); + return 0; + } + if (length < 0 || offset + length > (double)bytelen) { + char recv[48], range[48]; + scr_num_received(length, recv); + if (length < 0) snprintf(range, sizeof range, ">= 0"); + else snprintf(range, sizeof range, "<= %.0f", (double)bytelen - offset); mlen = snprintf(msg, sizeof msg, - "The value of \"offset\" is out of range. It must be >= 0 && <= 9007199254740991. Received %s", - numbuf); + "The value of \"length\" is out of range. It must be %s. Received %s", range, + recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)mlen, "ERR_OUT_OF_RANGE"); return 0; } size_t off = (size_t)offset; - if (length < 0 || (double)length > (double)(bytelen - off)) { - numbuf[scr_f64_to_str(length, numbuf)] = 0; - mlen = snprintf(msg, sizeof msg, - "The value of \"length\" is out of range. It must be <= %zu. Received %s", - bytelen - off, numbuf); - scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)mlen, "ERR_OUT_OF_RANGE"); - return 0; - } size_t want = (size_t)length; uint8_t *bounce = buf->shared ? malloc(want ? want : 1) : NULL; if (buf->shared && !bounce) scr_trap("scriptc: out of memory\n"); @@ -10526,10 +10534,29 @@ static bool scr_x509_der(const uint8_t *in, size_t n, const uint8_t **der, size_ *der = NULL; *der_len = 0; *decoded = NULL; + /* One whole DER SEQUENCE is the certificate itself: these members read + * the DER the constructor already decoded, whose extensions can carry + * PEM armor as text. */ + if (n >= 2 && in[0] == 0x30) { + size_t len = in[1], head = 2; + if (len & 0x80) { + size_t k = len & 0x7f; + len = 0; + head = 2 + k; + for (size_t i = 0; i < k && k <= 4 && head <= n; i++) len = (len << 8) | in[2 + i]; + if (k == 0 || k > 4) head = n + 1; + } + if (head <= n && len == n - head) { + *der = in; + *der_len = n; + return true; + } + } /* PEM: base64-decode the armor's body. */ const uint8_t *b = NULL; + /* OpenSSL's PEM reader looks for the armor at the start of a line. */ for (size_t i = 0; i + sizeof begin - 1 <= n; i++) { - if (memcmp(in + i, begin, sizeof begin - 1) == 0) { + if ((i == 0 || in[i - 1] == '\n') && memcmp(in + i, begin, sizeof begin - 1) == 0) { b = in + i + sizeof begin - 1; break; } @@ -11660,6 +11687,56 @@ double scr_date_new_local(double y, double mo, double d, return scr_date_from_local(scr_date_v8_make_day(y, mo, d), scr_date_v8_make_time(h, mi, s, ms)); } +/* Date.prototype's setters over a time value, as V8's builtins-date.cc + * computes them: the fields of `t` (local fields for the local setters) + * with the first `argc` of them replaced, in the order the setter names + * them, by `a`..`d`, through MakeDay/MakeTime/MakeDate, then UTC(t) for + * the local setters and TimeClip. An invalid `t` stays invalid, except + * that setFullYear and setUTCFullYear start from +0: January 1 of the + * epoch year at midnight (local midnight for setFullYear). Returns the new + * time value; the caller stores it into the Date. Never throws. */ +double scr_date_set_fields(double t, double setter_code, double argc_count, + double a, double b, double c, double d) { + int32_t setter = (int32_t)setter_code, argc = (int32_t)argc_count; + bool utc = (setter & SCR_DATE_SET_UTC) != 0; + int32_t field = setter & ~SCR_DATE_SET_UTC; + ScrDateParts p; + if (isnan(t)) { + if (field != SCR_DATE_SET_FULL_YEAR) return NAN; + p = (ScrDateParts){1970, 0, 1, 4, 0, 0, 0, 0, 0}; + } else if (utc) { + if (!scr_date_utc_parts(t, &p)) return NAN; + } else if (!scr_date_local_parts(t, &p)) { + return NAN; + } + double y = (double)p.year, mo = p.month, dt = p.date; + double h = p.hours, mi = p.minutes, s = p.seconds, ms = p.milliseconds; + double args[4] = {a, b, c, d}; + double *slots[7] = {&y, &mo, &dt, &h, &mi, &s, &ms}; + /* The first field each setter names, in slots' order. */ + int first; + switch (field) { + case SCR_DATE_SET_MILLISECONDS: first = 6; break; + case SCR_DATE_SET_SECONDS: first = 5; break; + case SCR_DATE_SET_MINUTES: first = 4; break; + case SCR_DATE_SET_HOURS: first = 3; break; + case SCR_DATE_SET_DATE: first = 2; break; + case SCR_DATE_SET_MONTH: first = 1; break; + case SCR_DATE_SET_FULL_YEAR: first = 0; break; + default: return NAN; + } + /* Each setter takes at most the fields from its own down to the next + * larger unit's end: setHours up to milliseconds, setMonth up to the + * date, setFullYear up to the date. */ + int last = first <= 2 ? 2 : 6; + for (int i = 0; i < argc && first + i <= last && i < 4; i++) *slots[first + i] = args[i]; + double day = scr_date_v8_make_day(y, mo, dt); + double time = scr_date_v8_make_time(h, mi, s, ms); + if (!utc) return scr_date_from_local(day, time); + if (!isfinite(day) || !isfinite(time)) return NAN; + return scr_date_new_ms(day * 86400000.0 + time); +} + /* ── V8's date-string parser, the fallback ───────────────────────────── * scr_date_parse answers the bounded grammars directly and asks * this parser only when they produce NaN. It follows V8's DateParser @@ -13203,6 +13280,27 @@ ScrDate *scr_dyn_native_date_check(const ScrDyn *value, const ScrDynPath *path) static const char scr_date_to_date_string_name[] = "toDateString"; static const char scr_date_to_time_string_name[] = "toTimeString"; +/* The field setters a checked-dynamic Date answers (scr_date_set_fields). */ +static const struct { + const char *name; + int code, max; +} scr_native_date_setters[] = { + {"setMilliseconds", SCR_DATE_SET_MILLISECONDS, 1}, + {"setSeconds", SCR_DATE_SET_SECONDS, 2}, + {"setMinutes", SCR_DATE_SET_MINUTES, 3}, + {"setHours", SCR_DATE_SET_HOURS, 4}, + {"setDate", SCR_DATE_SET_DATE, 1}, + {"setMonth", SCR_DATE_SET_MONTH, 2}, + {"setFullYear", SCR_DATE_SET_FULL_YEAR, 3}, + {"setUTCMilliseconds", SCR_DATE_SET_UTC | SCR_DATE_SET_MILLISECONDS, 1}, + {"setUTCSeconds", SCR_DATE_SET_UTC | SCR_DATE_SET_SECONDS, 2}, + {"setUTCMinutes", SCR_DATE_SET_UTC | SCR_DATE_SET_MINUTES, 3}, + {"setUTCHours", SCR_DATE_SET_UTC | SCR_DATE_SET_HOURS, 4}, + {"setUTCDate", SCR_DATE_SET_UTC | SCR_DATE_SET_DATE, 1}, + {"setUTCMonth", SCR_DATE_SET_UTC | SCR_DATE_SET_MONTH, 2}, + {"setUTCFullYear", SCR_DATE_SET_UTC | SCR_DATE_SET_FULL_YEAR, 3}, +}; + static ScrDyn *scr_native_date_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, const char *what) { (void)self; (void)what; @@ -13232,6 +13330,25 @@ static ScrDyn *scr_native_date_invoke(void *ptr, ScrDyn *self, const char *metho ((ScrDate *)ptr)->ms = scr_date_new_ms(time); return scr_dyn_new_num(((ScrDate *)ptr)->ms); } + for (size_t i = 0; i < sizeof scr_native_date_setters / sizeof *scr_native_date_setters; i++) { + if (strcmp(method, scr_native_date_setters[i].name)) continue; + /* V8's order: ToNumber of the first argument, then the time value, + * then (unless the date is invalid) ToNumber of the optional ones the + * setter takes. */ + int code = scr_native_date_setters[i].code, max = scr_native_date_setters[i].max; + double values[4] = {NAN, 0, 0, 0}; + if (argc > 0 && !scr_dyn_number_coerce_js(args[0], &values[0])) return NULL; + double t = ((ScrDate *)ptr)->ms; + int used = argc < (size_t)max ? (int)argc : max; + if (used < 1) used = 1; + if (!isnan(t) || (code & ~SCR_DATE_SET_UTC) == SCR_DATE_SET_FULL_YEAR) { + for (int k = 1; k < used; k++) + if (!scr_dyn_number_coerce_js(args[k], &values[k])) return NULL; + } + double next = scr_date_set_fields(t, code, used, values[0], values[1], values[2], values[3]); + ((ScrDate *)ptr)->ms = next; + return scr_dyn_new_num(next); + } #define DATE_GET(name, fn) if (!strcmp(method, name)) return scr_dyn_new_num(fn(ms)) DATE_GET("getFullYear", scr_date_get_full_year_local); DATE_GET("getUTCFullYear", scr_date_get_full_year_utc); @@ -13311,7 +13428,9 @@ static const char *const scr_native_date_methods[] = { "getFullYear", "getUTCFullYear", "getMonth", "getUTCMonth", "getDate", "getUTCDate", "getDay", "getUTCDay", "getHours", "getUTCHours", "getMinutes", "getUTCMinutes", "getSeconds", "getUTCSeconds", "getMilliseconds", - "getUTCMilliseconds", "getTimezoneOffset", "setTime", + "getUTCMilliseconds", "getTimezoneOffset", "setTime", "setMilliseconds", "setSeconds", + "setMinutes", "setHours", "setDate", "setMonth", "setFullYear", "setUTCMilliseconds", + "setUTCSeconds", "setUTCMinutes", "setUTCHours", "setUTCDate", "setUTCMonth", "setUTCFullYear", }; #define SCR_NATIVE_DATE_METHODS (sizeof scr_native_date_methods / sizeof scr_native_date_methods[0]) static SCR_TL ScrDyn *scr_native_date_method_values[SCR_NATIVE_DATE_METHODS]; @@ -13397,12 +13516,40 @@ static ScrDyn *scr_native_date_get(void *ptr, const char *key, size_t key_len) { return NULL; } +/* structuredClone of a Date: a new Date with the same time value. */ +static ScrDyn *scr_native_date_clone(void *h) { + return scr_dyn_native_date_from_ms(((ScrDate *)h)->ms); +} + +/* A Date crossing threads (postMessage, workerData) carries its time value + * in a plain allocation any thread may read and free. */ +static void *scr_native_date_share(void *h) { + double *ms = malloc(sizeof *ms); + if (!ms) scr_trap("scriptc: out of memory\n"); + *ms = ((ScrDate *)h)->ms; + return ms; +} + +static ScrDyn *scr_native_date_adopt(void *payload) { + return scr_dyn_native_date_from_ms(*(double *)payload); +} + +static void scr_native_date_drop(void *payload) { free(payload); } + +static const ScrDynHandleTransfer scr_native_date_transfer = { + .share = scr_native_date_share, .adopt = scr_native_date_adopt, .drop = scr_native_date_drop}; + /* A checked-dynamic handle for the Date object `date` (borrowed; the * handle retains it), so the value stays the same object. */ ScrDyn *scr_dyn_native_date(ScrDate *date) { static const ScrDynHandleOps ops = { - "Date", &scr_date_retain_v, &scr_date_release_v, &scr_native_date_invoke, - &scr_native_date_get, NULL, NULL, NULL, + .cls = "Date", + .retain = &scr_date_retain_v, + .release = &scr_date_release_v, + .invoke = &scr_native_date_invoke, + .get = &scr_native_date_get, + .clone = &scr_native_date_clone, + .transfer = &scr_native_date_transfer, }; scr_dyn_handle_install(SCR_DYNH_DATE, &ops); return scr_dyn_new_handle(date, SCR_DYNH_DATE); diff --git a/packages/runtime/src/scr_module_namespace.c b/packages/runtime/src/scr_module_namespace.c new file mode 100644 index 000000000..b0d7e80c4 --- /dev/null +++ b/packages/runtime/src/scr_module_namespace.c @@ -0,0 +1,121 @@ +/* Module namespace objects as checked-dynamic values + * (module-namespace-elements.ts lowers `import * as ns` values here). */ +#include "scr_runtime.h" + +#include +#include + +/* A module namespace object taken as a checked-dynamic value + * (module-namespace-elements.ts). The compiled getter answers an export's + * current value by name, so reads see live bindings; `keys` lists the + * namespace's export names. One object per module keeps its identity. + * Writes throw Node's strict-mode TypeError for a frozen namespace. */ +typedef struct ScrModuleNamespace { + size_t rc; + ScrStr *id; + ScrDyn *getter; + ScrArr *keys; + struct ScrModuleNamespace *next; +} ScrModuleNamespace; + +static SCR_TL ScrModuleNamespace *scr_module_namespaces; +static SCR_TL bool scr_module_namespace_cleanup_registered; + +static void *scr_module_namespace_retain(void *handle) { + ScrModuleNamespace *ns = handle; + ns->rc++; + return ns; +} + +static void scr_module_namespace_release(void *handle) { + ScrModuleNamespace *ns = handle; + if (--ns->rc) return; + scr_str_release(ns->id); + scr_dyn_release(ns->getter); + scr_arr_release(ns->keys); + free(ns); +} + +static void scr_module_namespace_cleanup(void) { + while (scr_module_namespaces) { + ScrModuleNamespace *ns = scr_module_namespaces; + scr_module_namespaces = ns->next; + scr_module_namespace_release(ns); + } +} + +static bool scr_module_namespace_has(const ScrModuleNamespace *ns, const char *key, size_t length) { + for (size_t i = 0; i < ns->keys->len; i++) { + ScrStr *entry = (ScrStr *)scr_arr_get_ref(ns->keys, (double)i); + bool same = entry->len == length && memcmp(entry->data, key, length) == 0; + scr_str_release(entry); + if (same) return true; + } + return false; +} + +static ScrDyn *scr_module_namespace_get(void *handle, const char *key, size_t length) { + ScrModuleNamespace *ns = handle; + if (!scr_module_namespace_has(ns, key, length)) return NULL; + ScrStr *name = scr_str_new(key, length); + ScrDyn *arg = scr_dyn_new_str(name); + scr_str_release(name); + ScrDyn *result = scr_dyn_call(ns->getter, &arg, 1, "module namespace export"); + scr_dyn_release(arg); + return result; +} + +static bool scr_module_namespace_set(void *handle, const char *key, size_t length, const ScrDyn *value) { + (void)value; + ScrModuleNamespace *ns = handle; + ScrJsonBuf text; + scr_jb_init(&text); + if (scr_module_namespace_has(ns, key, length)) { + scr_jb_puts(&text, "Cannot assign to read only property '"); + scr_jb_write(&text, key, length); + scr_jb_puts(&text, "' of object '[object Module]'"); + } else { + scr_jb_puts(&text, "Cannot add property "); + scr_jb_write(&text, key, length); + scr_jb_puts(&text, ", object is not extensible"); + } + ScrStr *message = scr_jb_finish(&text); + scr_throw_error_msg(SCR_ERR_TYPE, message->data, message->len); + scr_str_release(message); + return true; +} + +static ScrDyn *scr_module_namespace_invoke(void *handle, ScrDyn *self, const char *key, + ScrDyn *const *args, size_t argc, const char *what) { + ScrDyn *fn = scr_module_namespace_get(handle, key, strlen(key)); + if (fn == NULL) { + if (scr_exc_pending()) return NULL; + fn = scr_dyn_retain(scr_dyn_undefined()); + } + scr_dyn_this_push_dyn(self); + ScrDyn *result = scr_dyn_call(fn, args, argc, what); + scr_dyn_this_pop(); + scr_dyn_release(fn); + return result; +} + +ScrDyn *scr_module_namespace_value(ScrStr *id, ScrArr *keys, ScrDyn *getter) { + static const ScrDynHandleOps ops = { + .cls = "Module", .retain = scr_module_namespace_retain, .release = scr_module_namespace_release, + .get = scr_module_namespace_get, .set = scr_module_namespace_set, .invoke = scr_module_namespace_invoke, + }; + scr_dyn_handle_install(SCR_DYNH_MODULE_NAMESPACE, &ops); + for (ScrModuleNamespace *ns = scr_module_namespaces; ns; ns = ns->next) { + if (ns->id->len == id->len && memcmp(ns->id->data, id->data, id->len) == 0) + return scr_dyn_new_handle(ns, SCR_DYNH_MODULE_NAMESPACE); + } + if (!scr_module_namespace_cleanup_registered) { + scr_atexit(scr_module_namespace_cleanup); + scr_module_namespace_cleanup_registered = true; + } + ScrModuleNamespace *ns = malloc(sizeof(*ns)); + if (!ns) scr_trap("scriptc: out of memory\n"); + *ns = (ScrModuleNamespace){ 1, scr_str_retain(id), scr_dyn_retain(getter), scr_arr_retain(keys), scr_module_namespaces }; + scr_module_namespaces = ns; + return scr_dyn_new_handle(ns, SCR_DYNH_MODULE_NAMESPACE); +} diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 9ff9ba8b0..bd1233978 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -451,6 +451,9 @@ struct ScrNetServer { ScrStr *unix_path; /* listen(path): the bound socket file, removed at close */ size_t nconns; ScrNetLs conn_ls, err_ls, close_ls, listening_cbs, timeout_ls; + /* tls/https servers: 'tlsClientError' (err, tlsSocket), fired by the + * TLS unit for a handshake that fails */ + ScrNetLs client_error_ls; /* server.maxConnections (unset = undefined) and its 'drop' listeners */ bool max_conns_set; double max_conns; @@ -2134,6 +2137,53 @@ void scr_net_server_set_close_override(ScrNetServer *s, ScrClosure *ov /*moves*/ s->close_override = ov; } +void scr_net_server_on_client_error(ScrNetServer *s, ScrClosure *cb /*moves*/, void *fn, bool once) { + if (s->close_emitted) { + scr_closure_release(cb); + return; + } + scr_net_ls_add(&s->client_error_ls, cb, fn, once); +} + +typedef void (*ScrNetClientErrorFn)(ScrClosure *, ScrError *, ScrNetSocket *); + +/* Node emits 'tlsClientError' from the accepted connection's handshake, + * which has no AsyncLocalStorage store, not from the server's listen() + * context the connection's other events run in. */ +void scr_net_server_fire_client_error(ScrNetServer *s, ScrError *err /*borrowed*/, + ScrNetSocket *sock /*borrowed*/) { + if (s->client_error_ls.n == 0) return; + ScrNetL *snap; + size_t n = scr_net_ls_snapshot(&s->client_error_ls, &snap); + ScrAlsCtx *prev = scr_als_dispatch_enter(NULL); + scr_dyn_this_push(s, SCR_DYNH_NET_SERVER); + for (size_t i = 0; i < n; i++) { + if (!scr_exc_pending()) ((ScrNetClientErrorFn)snap[i].fn)(snap[i].cb, err, sock); + scr_closure_release(snap[i].cb); + } + scr_dyn_this_pop(); + scr_als_dispatch_leave(prev); + free(snap); +} + +/* The 'tlsClientError' listener adapters by arity; the arguments arrive + * borrowed and the listener takes them +1. */ +void scr_net_client_error_thunk0(ScrClosure *cb, ScrError *err, ScrNetSocket *sock) { + (void)err; + (void)sock; + ((void (*)(ScrClosure *))cb->fn)(cb); +} + +void scr_net_client_error_thunk1(ScrClosure *cb, ScrError *err, ScrNetSocket *sock) { + (void)sock; + ((void (*)(ScrClosure *, ScrError *))cb->fn)(cb, scr_error_retain(err)); +} + +void scr_net_client_error_thunk2(ScrClosure *cb, ScrError *err, ScrNetSocket *sock) { + ((void (*)(ScrClosure *, ScrError *, ScrNetSocket *))cb->fn)(cb, scr_error_retain(err), + scr_net_sock_retain(sock)); +} + void scr_net_server_on_error(ScrNetServer *s, ScrClosure *cb /*moves*/, ScrChildErrFn fn, bool once) { if (s->close_emitted) { @@ -5318,6 +5368,17 @@ void scr_net_sock_transport_error_code(ScrNetSocket *s, const char *msg, const c scr_net_sock_transport_error(s, msg); } +void scr_net_sock_transport_report(ScrNetSocket *s, const char *msg, const char *code) { + if (s->pending_err) return; + s->pending_err = scr_str_new(msg, strlen(msg)); + s->pending_code = code; +} + +void scr_net_sock_transport_ended(ScrNetSocket *s) { + s->rd_eof = true; + scr_net_fire0_this(&s->end_ls, s, SCR_DYNH_NET_SOCKET); +} + void scr_net_sock_transport_error(ScrNetSocket *s, const char *msg) { if (msg != NULL) { if (!s->pending_err) s->pending_err = scr_str_new(msg, strlen(msg)); @@ -5405,6 +5466,7 @@ static void scr_net_server_settle(ScrNetServer *srv) { scr_net_ls_drop(&srv->close_ls); scr_net_ls_drop(&srv->listening_cbs); scr_net_ls_drop(&srv->timeout_ls); + scr_net_ls_drop(&srv->client_error_ls); if (srv->proto_settle && srv->http_ctx) srv->proto_settle(srv->http_ctx); scr_net_server_unregister(srv); } @@ -5450,6 +5512,9 @@ static bool scr_net_sock_settle(ScrNetSocket *sock) { scr_net_fire_arg_this(&sock->close_ls, had_error, sock, SCR_DYNH_NET_SOCKET); scr_dyn_release(had_error); } + /* the transport settles first: a listener its last events add (a TLS + * server's 'tlsClientError') drops with the rest */ + if (sock->tops && sock->tops->settle) sock->tops->settle(sock->tctx); /* settle: listeners drop (cycle story) */ scr_net_ls_drop(&sock->data_ls); scr_net_ls_drop(&sock->end_ls); @@ -5462,7 +5527,6 @@ static bool scr_net_sock_settle(ScrNetSocket *sock) { scr_net_ls_drop(&sock->wcb_ls); scr_net_ls_drop(&sock->ready_ls); scr_net_ls_drop(&sock->lookup_ls); - if (sock->tops && sock->tops->settle) sock->tops->settle(sock->tctx); if (sock->pipe_dst) { scr_net_sock_release(sock->pipe_dst); sock->pipe_dst = NULL; @@ -5716,6 +5780,7 @@ static bool scr_net_sock_event(ScrNetSocket *s, unsigned events) { if (!scr_exc_pending() && s->fd >= 0) scr_net_sock_update_write(s); } else { s->t_want_write = false; + if (s->tops && s->tops->drain) s->tops->drain(s->tctx); scr_net_sock_flush(s); } } diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index 9e6dd1e0b..95490aaa5 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -186,6 +186,14 @@ void scr_net_server_close_direct(ScrNetServer *s, ScrClosure *cb /*moves, nullab * zero-arg wrapper); reassignment releases the old one. */ void scr_net_server_set_close_override(ScrNetServer *s, ScrClosure *ov /*moves*/); void scr_net_server_on_error(ScrNetServer *s, ScrClosure *cb /*moves*/, ScrChildErrFn fn, bool once); +/* tls/https servers' 'tlsClientError': registration (fn is one of the + * thunks below, by the listener's arity) and the TLS unit's fire. */ +void scr_net_server_on_client_error(ScrNetServer *s, ScrClosure *cb /*moves*/, void *fn, bool once); +void scr_net_server_fire_client_error(ScrNetServer *s, ScrError *err /*borrowed*/, + ScrNetSocket *sock /*borrowed*/); +void scr_net_client_error_thunk0(ScrClosure *cb, ScrError *err, ScrNetSocket *sock); +void scr_net_client_error_thunk1(ScrClosure *cb, ScrError *err, ScrNetSocket *sock); +void scr_net_client_error_thunk2(ScrClosure *cb, ScrError *err, ScrNetSocket *sock); void scr_net_server_on_close(ScrNetServer *s, ScrClosure *cb /*moves*/, bool once); /* server.on('listening', cb): the deferred 'listening' list listen(port, * cb) rides — fires once per successful bind; late registrations on an @@ -362,6 +370,9 @@ typedef struct ScrNetTransportOps { /* nullable: runs when the closed socket settles, after its own * listeners drop; the transport drops the listeners it holds */ void (*settle)(void *tctx); + /* nullable: the established socket's fd is writable; the transport + * writes what it holds back (TLS holds its final handshake flight) */ + void (*drain)(void *tctx); } ScrNetTransportOps; void scr_net_sock_set_transport(ScrNetSocket *s, const ScrNetTransportOps *ops, void *tctx); /* new tls.TLSSocket(socket) and tls.connect({ socket }): a fresh socket @@ -406,7 +417,14 @@ void scr_net_sock_replay(ScrNetSocket *s, const char *data, size_t len); /* Transport-level failure: msg (borrowed, Node's message shape) defers * 'error' + teardown to the sweep; NULL tears down without an 'error' * event, Node's default for an unhandled server-side handshake failure. */ +/* The peer ended the stream before the transport's handshake finished: + * 'end' fires now, ahead of the failure the transport reports next. */ +void scr_net_sock_transport_ended(ScrNetSocket *s); void scr_net_sock_transport_error(ScrNetSocket *s, const char *msg); +/* An error that leaves the socket open: 'error' fires from the sweep with + * `code` (static storage), and the socket ends when the peer closes, as + * Node's TLS socket reports a fatal alert after its handshake. */ +void scr_net_sock_transport_report(ScrNetSocket *s, const char *msg, const char *code); /* The same with the error's code given (static storage), for messages * whose text does not name it. */ void scr_net_sock_transport_error_code(ScrNetSocket *s, const char *msg, const char *code); diff --git a/packages/runtime/src/scr_number.h b/packages/runtime/src/scr_number.h index a71b0ccfd..39564c061 100644 --- a/packages/runtime/src/scr_number.h +++ b/packages/runtime/src/scr_number.h @@ -111,6 +111,23 @@ double scr_date_now(void); /* integer ms since epoch, like Node */ extern SCR_TL const double *scr_date_mock_now; double scr_date_new_ms(double ms); /* TimeClip (NaN when invalid) */ double scr_date_get_time(double ms); +/* Date.prototype's setters (setMilliseconds ... setFullYear and their UTC + * forms) over a time value: the new time value for `argc` arguments + * `a`..`d` (scr_lib.c). `setter` is an SCR_DATE_SET_* code; it and `argc` + * are whole numbers passed as doubles, as the compiler's lib calls pass + * numbers. */ +enum { + SCR_DATE_SET_MILLISECONDS = 0, + SCR_DATE_SET_SECONDS = 1, + SCR_DATE_SET_MINUTES = 2, + SCR_DATE_SET_HOURS = 3, + SCR_DATE_SET_DATE = 4, + SCR_DATE_SET_MONTH = 5, + SCR_DATE_SET_FULL_YEAR = 6, + SCR_DATE_SET_UTC = 8, +}; +double scr_date_set_fields(double t, double setter, double argc, + double a, double b, double c, double d); /* A Date object: the TimeClip'd time value in milliseconds (NaN for an * invalid date). Dates are immutable; their identity is the pointer. * Retain/release follow the runtime's RC conventions (SIZE_MAX immortal). */ diff --git a/packages/runtime/src/scr_pki.c b/packages/runtime/src/scr_pki.c index a1d5b98a6..72136afdb 100644 --- a/packages/runtime/src/scr_pki.c +++ b/packages/runtime/src/scr_pki.c @@ -2804,3 +2804,220 @@ bool scr_pki_verify(ScrPkiKey *key, const char *hash, bool pss, int salt, const SpSigKey k = {.key = key, .padding = pss ? 6 : 1, .salt = salt, .p1363 = true}; return sp_verify_hash(&k, md, digest, mbedtls_md_get_size(info), signature, signature_len); } + +/* ── X509Certificate members over keys ───────────────────────────────── */ + +/* The certificate's public key, +1; NULL with the error pending. */ +static SpKey *sp_x509_key(ScrBytes *der) { + ScrBytes *view = der; + SCR_BYTES_SNAPSHOT(view); + const unsigned char *spki; + size_t spki_len; + if (!sp_cert_spki(view->data, (size_t)scr_bytes_byte_len(view), &spki, &spki_len)) { + sp_unsupported_input(); + return NULL; + } + return sp_parse(spki, spki_len, false); +} + +/* x509.publicKey: the certificate's public key as a new KeyObject. */ +ScrDyn *scr_crypto_x509_public_key(ScrBytes *der) { + SpKey *k = sp_x509_key(der); + return k ? sp_key_box(k) : NULL; +} + +/* The pkey argument of verify (a public key) and checkPrivateKey (a + * private key), borrowed; NULL with Node's TypeError pending. */ +static SpKey *sp_x509_pkey(const ScrDyn *value, bool private_key) { + SpKey *k = sp_key_of(value); + size_t len; + if (!k && !scr_key_object_bytes(value, &len)) { + scr_dyn_arg_type_fail("pkey", "an instance of KeyObject", value); + return NULL; + } + if (!k || k->private_key != private_key) { + const char *cls = !k ? "SecretKeyObject" : k->private_key ? "PrivateKeyObject" : "PublicKeyObject"; + scr_pki_throw_fmt(SCR_ERR_TYPE, "ERR_INVALID_ARG_VALUE", "The argument 'pkey' is invalid. Received %s [KeyObject] {}", + cls); + return NULL; + } + return k; +} + +/* The digest of a signature algorithm OID's last arc (RSA and ECDSA). */ +static mbedtls_md_type_t sp_x509_rsa_md(unsigned char arc) { + switch (arc) { + case 5: return MBEDTLS_MD_SHA1; + case 11: return MBEDTLS_MD_SHA256; + case 12: return MBEDTLS_MD_SHA384; + case 13: return MBEDTLS_MD_SHA512; + case 14: return MBEDTLS_MD_SHA224; + default: return MBEDTLS_MD_NONE; + } +} + +/* A hash AlgorithmIdentifier inside RSASSA-PSS-params. */ +static mbedtls_md_type_t sp_x509_hash_alg(unsigned char *p, const unsigned char *end) { + size_t n; + if (mbedtls_asn1_get_tag(&p, end, &n, MBEDTLS_ASN1_CONSTRUCTED | MBEDTLS_ASN1_SEQUENCE) != 0) + return MBEDTLS_MD_NONE; + mbedtls_asn1_buf oid = {0}; + oid.tag = MBEDTLS_ASN1_OID; + if (mbedtls_asn1_get_tag(&p, end, &oid.len, MBEDTLS_ASN1_OID) != 0) return MBEDTLS_MD_NONE; + oid.p = p; + mbedtls_md_type_t md; + return mbedtls_oid_get_md_alg(&oid, &md) == 0 ? md : MBEDTLS_MD_NONE; +} + +/* x509.verify(publicKey): whether publicKey verifies the certificate's + * signature. An algorithm the key cannot verify answers false. */ +bool scr_crypto_x509_verify(ScrBytes *der, ScrDyn *key) { + SpKey *k = sp_x509_pkey(key, false); + if (!k) return false; + ScrBytes *view = der; + SCR_BYTES_SNAPSHOT(view); + unsigned char *p = view->data; + const unsigned char *end = p + (size_t)scr_bytes_byte_len(view); + size_t n; + if (mbedtls_asn1_get_tag(&p, end, &n, MBEDTLS_ASN1_CONSTRUCTED | MBEDTLS_ASN1_SEQUENCE) != 0) + return false; + end = p + n; + const unsigned char *tbs = p; + if (mbedtls_asn1_get_tag(&p, end, &n, MBEDTLS_ASN1_CONSTRUCTED | MBEDTLS_ASN1_SEQUENCE) != 0) + return false; + p += n; + size_t tbs_len = (size_t)(p - tbs); + const unsigned char *outer_alg = p; + if (mbedtls_asn1_get_tag(&p, end, &n, MBEDTLS_ASN1_CONSTRUCTED | MBEDTLS_ASN1_SEQUENCE) != 0) + return false; + const unsigned char *alg_end = p + n; + /* As in OpenSSL's X509_verify, the TBSCertificate's signature field must + * be the same AlgorithmIdentifier as the outer one. */ + { + unsigned char *q = (unsigned char *)tbs; + const unsigned char *tbs_end = tbs + tbs_len; + size_t len; + if (mbedtls_asn1_get_tag(&q, tbs_end, &len, MBEDTLS_ASN1_CONSTRUCTED | MBEDTLS_ASN1_SEQUENCE) != 0) + return false; + if (q < tbs_end && *q == (MBEDTLS_ASN1_CONTEXT_SPECIFIC | MBEDTLS_ASN1_CONSTRUCTED | 0)) { + if (mbedtls_asn1_get_tag(&q, tbs_end, &len, MBEDTLS_ASN1_CONTEXT_SPECIFIC | MBEDTLS_ASN1_CONSTRUCTED | 0) != 0) + return false; + q += len; + } + if (mbedtls_asn1_get_tag(&q, tbs_end, &len, MBEDTLS_ASN1_INTEGER) != 0) return false; + q += len; + const unsigned char *inner_alg = q; + if (mbedtls_asn1_get_tag(&q, tbs_end, &len, MBEDTLS_ASN1_CONSTRUCTED | MBEDTLS_ASN1_SEQUENCE) != 0) + return false; + size_t inner_len = (size_t)(q + len - inner_alg); + if (inner_len != (size_t)(alg_end - outer_alg) || memcmp(inner_alg, outer_alg, inner_len) != 0) return false; + } + size_t oid_len; + if (mbedtls_asn1_get_tag(&p, alg_end, &oid_len, MBEDTLS_ASN1_OID) != 0) return false; + const unsigned char *oid = p; + unsigned char *params = p + oid_len; + p = (unsigned char *)alg_end; + size_t sig_len; + if (mbedtls_asn1_get_tag(&p, end, &sig_len, MBEDTLS_ASN1_BIT_STRING) != 0 || sig_len < 1 || *p != 0) + return false; + const unsigned char *sig = p + 1; + sig_len -= 1; + static const unsigned char rsa_prefix[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01}; + static const unsigned char ecdsa_prefix[] = {0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04}; + static const unsigned char ed25519_oid[] = {0x2B, 0x65, 0x70}; + if (oid_len == sizeof ed25519_oid && memcmp(oid, ed25519_oid, oid_len) == 0) { + if (!sp_is_ed(k)) return false; + const ScrPkiEd25519 *ed = sp_ed(); + return ed != NULL && sp_ed_verify(ed, k->ed_pub, tbs, tbs_len, sig, sig_len); + } + if (sp_is_ed(k)) return false; + unsigned char hash[MBEDTLS_MD_MAX_SIZE]; + if (oid_len == sizeof rsa_prefix + 1 && memcmp(oid, rsa_prefix, sizeof rsa_prefix) == 0) { + if (!sp_is_rsa(k)) return false; + mbedtls_rsa_context *rsa = mbedtls_pk_rsa(k->pk); + if (oid[sizeof rsa_prefix] == 10) { /* RSASSA-PSS with its parameters */ + mbedtls_md_type_t md = MBEDTLS_MD_SHA1, mgf = MBEDTLS_MD_SHA1; + int salt = 20; + unsigned char *q = params; + size_t plen; + if (mbedtls_asn1_get_tag(&q, alg_end, &plen, MBEDTLS_ASN1_CONSTRUCTED | MBEDTLS_ASN1_SEQUENCE) == 0) { + const unsigned char *pend = q + plen; + while (q < pend) { + unsigned char tag = *q; + size_t len; + if (mbedtls_asn1_get_tag(&q, pend, &len, tag) != 0) return false; + unsigned char *field = q; + q += len; + if (tag == (MBEDTLS_ASN1_CONTEXT_SPECIFIC | MBEDTLS_ASN1_CONSTRUCTED | 0)) { + md = sp_x509_hash_alg(field, q); + } else if (tag == (MBEDTLS_ASN1_CONTEXT_SPECIFIC | MBEDTLS_ASN1_CONSTRUCTED | 1)) { + /* MGF1 with its hash: SEQUENCE { id-mgf1, AlgorithmIdentifier } */ + size_t mlen, ilen; + unsigned char *m = field; + if (mbedtls_asn1_get_tag(&m, q, &mlen, MBEDTLS_ASN1_CONSTRUCTED | MBEDTLS_ASN1_SEQUENCE) != 0 || + mbedtls_asn1_get_tag(&m, q, &ilen, MBEDTLS_ASN1_OID) != 0) + return false; + mgf = sp_x509_hash_alg(m + ilen, q); + } else if (tag == (MBEDTLS_ASN1_CONTEXT_SPECIFIC | MBEDTLS_ASN1_CONSTRUCTED | 2)) { + if (mbedtls_asn1_get_int(&field, q, &salt) != 0) return false; + } + } + } + if (md == MBEDTLS_MD_NONE || mgf == MBEDTLS_MD_NONE || sig_len != mbedtls_rsa_get_len(rsa)) + return false; + const mbedtls_md_info_t *info = mbedtls_md_info_from_type(md); + mbedtls_md(info, tbs, tbs_len, hash); + return mbedtls_rsa_rsassa_pss_verify_ext(rsa, md, mbedtls_md_get_size(info), hash, mgf, salt, + sig) == 0; + } + mbedtls_md_type_t md = sp_x509_rsa_md(oid[sizeof rsa_prefix]); + if (md == MBEDTLS_MD_NONE) return false; + const mbedtls_md_info_t *info = mbedtls_md_info_from_type(md); + mbedtls_md(info, tbs, tbs_len, hash); + /* PKCS#1 v1.5, whatever padding the key's last signature used */ + if (mbedtls_rsa_set_padding(rsa, MBEDTLS_RSA_PKCS_V15, MBEDTLS_MD_NONE) != 0) return false; + return mbedtls_pk_verify(&k->pk, md, hash, mbedtls_md_get_size(info), sig, sig_len) == 0; + } + if (oid_len >= sizeof ecdsa_prefix + 1 && memcmp(oid, ecdsa_prefix, sizeof ecdsa_prefix) == 0) { + if (!sp_is_ec(k)) return false; + mbedtls_md_type_t md = MBEDTLS_MD_NONE; + if (oid_len == 7 && oid[6] == 0x01) md = MBEDTLS_MD_SHA1; + else if (oid_len == 8 && oid[6] == 0x03) { + static const mbedtls_md_type_t sha2[] = {MBEDTLS_MD_NONE, MBEDTLS_MD_SHA224, MBEDTLS_MD_SHA256, + MBEDTLS_MD_SHA384, MBEDTLS_MD_SHA512}; + if (oid[7] >= 1 && oid[7] <= 4) md = sha2[oid[7]]; + } + if (md == MBEDTLS_MD_NONE) return false; + const mbedtls_md_info_t *info = mbedtls_md_info_from_type(md); + mbedtls_md(info, tbs, tbs_len, hash); + return mbedtls_pk_verify(&k->pk, md, hash, mbedtls_md_get_size(info), sig, sig_len) == 0; + } + return false; +} + +/* x509.checkPrivateKey(privateKey): whether privateKey is the private half + * of the certificate's public key. */ +bool scr_crypto_x509_check_private_key(ScrBytes *der, ScrDyn *key) { + SpKey *k = sp_x509_pkey(key, true); + if (!k) return false; + SpKey *cert = sp_x509_key(der); + if (!cert) return false; + bool same = false; + if (sp_is_ed(k) || sp_is_ed(cert)) { + same = sp_is_ed(k) && sp_is_ed(cert) && memcmp(k->ed_pub, cert->ed_pub, 32) == 0; + } else if (mbedtls_pk_get_type(&k->pk) == mbedtls_pk_get_type(&cert->pk)) { + SpKey *pub = sp_public_of(k); + if (pub) { + unsigned char *a = malloc(SP_DER_MAX), *b = malloc(SP_DER_MAX); + if (!a || !b) scr_trap("scriptc: out of memory\n"); + int na = sp_write_spki_der(pub, a, SP_DER_MAX); + int nb = sp_write_spki_der(cert, b, SP_DER_MAX); + same = na > 0 && na == nb && memcmp(a + SP_DER_MAX - na, b + SP_DER_MAX - nb, (size_t)na) == 0; + free(a); + free(b); + sp_key_release(pub); + } + } + sp_key_release(cert); + return same; +} diff --git a/packages/runtime/src/scr_qs.h b/packages/runtime/src/scr_qs.h index fc5ca5da5..2232d2d48 100644 --- a/packages/runtime/src/scr_qs.h +++ b/packages/runtime/src/scr_qs.h @@ -62,6 +62,8 @@ bool scr_util_terminal_colors(void); * (scr_util_promisify.c). Arguments are borrowed; function results are +1 * native dyn functions, or NULL with Node's TypeError pending. */ void scr_util_inherits(struct ScrDyn *ctor, struct ScrDyn *super_ctor); +void scr_util_inherits_prototype(struct ScrDyn *ctor, struct ScrDyn *super_ctor, + struct ScrDyn *super_prototype); struct ScrDyn *scr_util_deprecate(struct ScrDyn *fn, struct ScrDyn *msg, struct ScrDyn *code, struct ScrDyn *options); struct ScrDyn *scr_util_callbackify(struct ScrDyn *original); diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index 4d8e059a9..de9f21f44 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -700,6 +700,12 @@ void scr_collect_cycles_phase(void); * that knows its own memory profile; the environment variable takes * precedence. Call it before the program allocates much. */ void scr_cyc_set_growth_cap(long cap); +/* Sets the nursery trigger SCR_CYCLE_THRESHOLD sets (scr_cycle.c): the + * number of new candidates at which the release path runs a pass, for a + * program that knows its own collection profile. The environment variable + * takes precedence; 0 restores the default. The trigger re-arms with it at + * the end of the next pass. Call it before the program allocates much. */ +void scr_cyc_set_nursery_threshold(long threshold); void scr_cyc_context_cleanup(void); /* One pass on the normal generational schedule, for callers that reach a diff --git a/packages/runtime/src/scr_readline.c b/packages/runtime/src/scr_readline.c index 1bc48bbe7..178d620c6 100644 --- a/packages/runtime/src/scr_readline.c +++ b/packages/runtime/src/scr_readline.c @@ -283,7 +283,23 @@ static void scr_rl_buf_append(ScrRl *rl, const char *p, size_t n) { rl->line_len += n; } -/* kNormalWrite over decoded UTF-8 text. */ +/* The end of the line starting at `i` in text without a CR: the next LF, + * or n when the text ends first. */ +static size_t scr_rl_next_lf(const char *p, size_t i, size_t n) { + const char *lf = memchr(p + i, '\n', n - i); + return lf ? (size_t)(lf - p) : n; +} + +/* The end of the line starting at `i`: the next LF or CR, or n. */ +static size_t scr_rl_next_break(const char *p, size_t i, size_t n) { + while (i < n && p[i] != '\n' && p[i] != '\r') i++; + return i; +} + +/* kNormalWrite over decoded UTF-8 text. The text is scanned once: LF-only + * text (no CR anywhere in it) moves from LF to LF with memchr, other text + * splits at LF, CR and CRLF. The unfinished line before the text only + * joins the first line, so the rest of the text is never copied whole. */ static void scr_rl_feed_text(ScrRl *rl, const char *p, size_t n) { if (rl->tty) { scr_rl_tty_ops->feed(rl, p, n); @@ -296,43 +312,31 @@ static void scr_rl_feed_text(ScrRl *rl, const char *p, size_t n) { } rl->saw_return_at = 0; } - bool ending = false; - for (size_t i = 0; i < n && !ending; i++) ending = p[i] == '\n' || p[i] == '\r'; - if (!ending) { + bool cr = n > 0 && memchr(p, '\r', n) != NULL; + size_t end = n == 0 ? 0 : cr ? scr_rl_next_break(p, 0, n) : scr_rl_next_lf(p, 0, n); + if (end == n) { if (n > 0) scr_rl_buf_append(rl, p, n); return; } - /* The unfinished line joins the new text; the scan runs over both. */ - char *text = p ? (char *)p : (char *)""; - size_t len = n; - char *joined = NULL; - if (rl->line_len > 0) { - joined = malloc(rl->line_len + n); - if (!joined) scr_rl_oom(); - memcpy(joined, rl->line_buf, rl->line_len); - memcpy(joined + rl->line_len, p, n); - text = joined; - len = rl->line_len + n; - } - rl->line_len = 0; - rl->saw_return_at = len > 0 && text[len - 1] == '\r' ? scr_now_ms() : 0; + rl->saw_return_at = p[n - 1] == '\r' ? scr_now_ms() : 0; /* Split first (the remainder becomes the new unfinished line before any * line is delivered, as in Node), then deliver in order. */ size_t n_lines = 0, cap_lines = 0; ScrStr **lines = NULL; size_t start = 0; - for (size_t i = 0; i < len; i++) { - size_t adv; - if (text[i] == '\n') adv = 1; - else if (text[i] == '\r') adv = i + 1 < len && text[i + 1] == '\n' ? 2 : 1; - else continue; + while (end < n) { lines = scr_rl_grow(lines, &cap_lines, n_lines + 1, sizeof *lines); - lines[n_lines++] = scr_str_new(text + start, i - start); - i += adv - 1; - start = i + 1; + if (n_lines == 0 && rl->line_len > 0) { + scr_rl_buf_append(rl, p, end); + lines[n_lines++] = scr_str_new(rl->line_buf, rl->line_len); + } else { + lines[n_lines++] = scr_str_new(p + start, end - start); + } + start = end + (p[end] == '\r' && end + 1 < n && p[end + 1] == '\n' ? 2 : 1); + end = start >= n ? n : cr ? scr_rl_next_break(p, start, n) : scr_rl_next_lf(p, start, n); } - if (start < len) scr_rl_buf_append(rl, text + start, len - start); - free(joined); + rl->line_len = 0; + if (start < n) scr_rl_buf_append(rl, p + start, n - start); for (size_t i = 0; i < n_lines; i++) { if (rl->closed || scr_exc_pending()) { scr_str_release(lines[i]); @@ -366,6 +370,11 @@ static void scr_rl_feed_bytes(ScrRl *rl, const char *p, size_t n) { rl->parked_len += n; return; } + /* ASCII with no held bytes decodes to itself: split it in place. */ + if (rl->n_held == 0 && scr_bytes_ascii_span(p, n) == n) { + scr_rl_feed_text(rl, p, n); + return; + } size_t total = rl->n_held + n; unsigned char *all = malloc(total ? total : 1); if (!all) scr_rl_oom(); @@ -401,6 +410,12 @@ static void scr_rl_feed_end(ScrRl *rl) { void scr_rl_input_bytes(double id, ScrBytes *chunk) { ScrRl *rl = scr_rl_find(id); if (!rl || rl->closed) return; + /* The splitter copies every line it keeps before it delivers one, so it + * can read a chunk's own storage; shared memory is copied out first. */ + if (!chunk->shared) { + scr_rl_feed_bytes(rl, (const char *)chunk->data, chunk->len); + return; + } char *p = malloc(chunk->len ? chunk->len : 1); if (!p) scr_rl_oom(); scr_bytes_read(chunk, 0, p, chunk->len); diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index ec061e10c..406d91599 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -176,6 +176,9 @@ struct ScrStreamState { size_t n, cap, head; int corked; bool writing, wsync, need_drain, after_scheduled; + /* the queued SCR_ST_AFTER_WRITE tick later synchronous writes with + * the same callback join (Node's afterWriteTickInfo), or NULL */ + struct ScrStreamTick *after_tick; /* Deferred afterWrites Node also defers (a user callback, a 'drain' * or a destroyed stream): its pendingcb, which holds the finish until * they ran — afterWrite's finishMaybe then puts 'finish' on a tick @@ -543,6 +546,9 @@ typedef struct ScrStreamTick { ScrError *err; /* owned or NULL (SCR_ST_ERROR) */ ScrClosure *cb; /* owned or NULL (SCR_ST_AFTER_WRITE / SCR_ST_WCB_ERR / SCR_ST_WRITE_ERR) */ bool hold; /* SCR_ST_AFTER_WRITE: counted in w.after_holds */ + /* SCR_ST_AFTER_WRITE: the synchronous writes it completes, which share + * cb (Node's afterWriteTickInfo.count) */ + size_t count; /* The AsyncLocalStorage context active when the tick was scheduled (+1, * NULL outside a store): Node schedules these steps with * process.nextTick, so their events and callbacks run in it. */ @@ -557,7 +563,7 @@ static void scr_stream_dispatch_one(void); static void scr_st_tick(ScrStream *s, ScrStreamTickOp op, ScrError *err /*moves*/, ScrClosure *cb /*moves*/) { - ScrStreamTick *t = calloc(1, sizeof *t); + ScrStreamTick *t = scr_mem_calloc(sizeof *t); if (!t) scr_stream_oom(); t->s = scr_stream_retain(s); t->op = op; @@ -580,15 +586,57 @@ static bool scr_stream_ticks_pending(void) { return scr_st_head != NULL; } /* ── emit helpers ─────────────────────────────────────────────────────── */ +/* The event names the stream unit emits, as immortal strings (the layout + * of an emitted string literal), so an emit allocates no name. */ +#define SCR_STREAM_EVENT_NAME(name) \ + static const struct { \ + size_t rc; \ + size_t len; \ + size_t cap; \ + char data[sizeof #name]; \ + } scr_stream_lit_##name = {SIZE_MAX, sizeof #name - 1, \ + SCR_STR_CAP_WORD(sizeof #name - 1, 0, 1), #name} +SCR_STREAM_EVENT_NAME(data); +SCR_STREAM_EVENT_NAME(error); +SCR_STREAM_EVENT_NAME(close); +SCR_STREAM_EVENT_NAME(drain); +SCR_STREAM_EVENT_NAME(end); +SCR_STREAM_EVENT_NAME(finish); +SCR_STREAM_EVENT_NAME(pause); +SCR_STREAM_EVENT_NAME(prefinish); +SCR_STREAM_EVENT_NAME(readable); +SCR_STREAM_EVENT_NAME(resume); +SCR_STREAM_EVENT_NAME(pipe); +SCR_STREAM_EVENT_NAME(unpipe); +#undef SCR_STREAM_EVENT_NAME + +static ScrStr *const scr_stream_event_names[] = { + (ScrStr *)&scr_stream_lit_data, (ScrStr *)&scr_stream_lit_error, + (ScrStr *)&scr_stream_lit_close, (ScrStr *)&scr_stream_lit_drain, + (ScrStr *)&scr_stream_lit_end, (ScrStr *)&scr_stream_lit_finish, + (ScrStr *)&scr_stream_lit_pause, (ScrStr *)&scr_stream_lit_prefinish, + (ScrStr *)&scr_stream_lit_readable, (ScrStr *)&scr_stream_lit_resume, + (ScrStr *)&scr_stream_lit_pipe, (ScrStr *)&scr_stream_lit_unpipe, +}; + +/* The event name as a string: an immortal one for the names above, else a + * new string (+1 either way; releasing an immortal string does nothing). */ +static ScrStr *scr_stream_event_name(const char *name) { + for (size_t i = 0; i < sizeof scr_stream_event_names / sizeof *scr_stream_event_names; i++) { + if (strcmp(scr_stream_event_names[i]->data, name) == 0) return scr_stream_event_names[i]; + } + return scr_str_new(name, strlen(name)); +} + static bool scr_stream_emit0(ScrStream *s, const char *name) { - ScrStr *n = scr_str_new(name, strlen(name)); + ScrStr *n = scr_stream_event_name(name); bool had = scr_emitter_emit((ScrEmitter *)s, n); scr_str_release(n); return had; } static void scr_stream_emit_stream(ScrStream *s, const char *name, ScrStream *payload) { - ScrStr *n = scr_str_new(name, strlen(name)); + ScrStr *n = scr_stream_event_name(name); scr_emitter_emit((ScrEmitter *)s, n, payload); scr_str_release(n); } @@ -596,7 +644,7 @@ static void scr_stream_emit_stream(ScrStream *s, const char *name, ScrStream *pa /* 'error': unhandled throws the payload (Node's crash), via the emitter's * error contract. Borrows err. */ static void scr_stream_emit_error(ScrStream *s, ScrError *err) { - ScrStr *n = scr_str_new("error", 5); + ScrStr *n = (ScrStr *)&scr_stream_lit_error; scr_emitter_emit_error((ScrEmitter *)s, n, err); scr_str_release(n); } @@ -765,7 +813,7 @@ static void scr_stream_emit_data(ScrStream *s, void *chunk, ScrEntryKind kind) { } else { b = chunk; } - ScrStr *n = scr_str_new("data", 4); + ScrStr *n = (ScrStr *)&scr_stream_lit_data; scr_emitter_emit((ScrEmitter *)s, n, b, str, value); scr_str_release(n); if (scr_exc_pending()) return; @@ -2075,7 +2123,7 @@ static void scr_stream_error_buffer(ScrStream *s) { * empty), THEN the user's write(chunk, cb) callback, then finishMaybe. * The buffered queue was already restarted by write_done (Node's onwrite * calls clearBuffer before afterWrite). cb moves. */ -static void scr_stream_after_write(ScrStream *s, ScrClosure *cb) { +static void scr_stream_after_write(ScrStream *s, ScrClosure *cb, size_t count) { ScrStreamState *st = s->st; if (st->w.length == 0 && st->w.need_drain && !st->w.ending && !st->destroyed) { st->w.need_drain = false; @@ -2095,7 +2143,11 @@ static void scr_stream_after_write(ScrStream *s, ScrClosure *cb) { } } if (cb) { - ((void (*)(ScrClosure *))cb->fn)(cb); + /* afterWrite: the callback once per write the tick completes */ + for (size_t i = 0; i < count; i++) { + ((void (*)(ScrClosure *))cb->fn)(cb); + if (scr_exc_pending()) break; + } scr_closure_release(cb); if (scr_exc_pending()) return; } @@ -6634,6 +6686,12 @@ ScrStream *scr_stream_on_dyn(ScrStream *s, ScrStr *name, ScrDyn *cb, bool once, return (ScrStream *)scr_emitter_on_dyn((ScrEmitter *)s, name, cb, adapter, error ? scr_ee_inv_fixed1 : scr_ee_inv_fixed0, once, prepend); } + /* The stream emits 'pipe' and 'unpipe' with its source stream; other + * names reach the listener only through emit calls, with every argument + * they pass. */ + bool piping = (name->len == 4 && memcmp(name->data, "pipe", 4) == 0) || + (name->len == 6 && memcmp(name->data, "unpipe", 6) == 0); + if (!piping) return (ScrStream *)scr_emitter_on_flex((ScrEmitter *)s, name, cb, once, prepend); static const char message[] = "this stream event has no checked-dynamic listener adapter"; scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); return NULL; @@ -6866,10 +6924,12 @@ static bool scr_stream_write_chunk_enc(ScrStream *s, ScrBytes *chunk /*borrowed* scr_stream_do_write(s, scr_stream_wchunk_retain(st, chunk), cb, enc); } /* Node computes the answer AFTER the (possibly synchronous) write: - * a sync completion has already drained its bytes from length. */ - bool ret = st->w.length < st->w.hwm && !st->destroyed && !st->errored; - if (!ret) st->w.need_drain = true; - return ret; + * a sync completion has already drained its bytes from length. Only + * the length sets needDrain; an errored or destroyed stream answers + * false without waiting for 'drain'. */ + bool below = st->w.length < st->w.hwm; + if (!below) st->w.need_drain = true; + return below && !st->destroyed && !st->errored; } static bool scr_stream_write_chunk(ScrStream *s, ScrBytes *chunk /*borrowed*/, ScrClosure *cb /*moves*/) { @@ -7120,13 +7180,24 @@ void scr_stream_write_done(ScrStream *s, ScrError *err /*moves*/) { } if (was_sync) { bool hold = ucb != NULL || (st->w.need_drain && st->w.length == 0) || st->destroyed; - scr_st_tick(s, SCR_ST_AFTER_WRITE, NULL, ucb); - if (hold) { - scr_st_tail->hold = true; + ScrStreamTick *pending = st->w.after_tick; + if (pending && pending->cb == ucb) { + /* Node's onwrite: a synchronous write with the callback of the + * afterWrite tick already queued joins it (count++) */ + pending->count++; + if (ucb) scr_closure_release(ucb); + } else { + scr_st_tick(s, SCR_ST_AFTER_WRITE, NULL, ucb); + pending = scr_st_tail; + pending->count = 1; + st->w.after_tick = pending; + } + if (hold && !pending->hold) { + pending->hold = true; st->w.after_holds++; } } else { - scr_stream_after_write(s, ucb); + scr_stream_after_write(s, ucb, 1); } } @@ -7672,13 +7743,19 @@ static void scr_stream_run_tick(ScrStreamTick *t) { case SCR_ST_AFTER_WRITE: { st->w.after_scheduled = false; if (t->hold) st->w.after_holds--; + /* afterWriteTick: the batch closes when any afterWrite tick runs, so + * a write its callback makes queues a new tick */ + st->w.after_tick = NULL; ScrClosure *cb = t->cb; t->cb = NULL; if (!st->destroyed) { - scr_stream_after_write(s, cb); + scr_stream_after_write(s, cb, t->count); } else { if (cb) { - ((void (*)(ScrClosure *))cb->fn)(cb); + for (size_t i = 0; i < t->count; i++) { + ((void (*)(ScrClosure *))cb->fn)(cb); + if (scr_exc_pending()) break; + } scr_closure_release(cb); if (scr_exc_pending()) break; } @@ -7848,11 +7925,12 @@ static void scr_stream_run_tick_in_context(ScrStreamTick *t) { } static void scr_stream_tick_free(ScrStreamTick *t) { + if (t->s->st && t->s->st->w.after_tick == t) t->s->st->w.after_tick = NULL; scr_stream_release(t->s); if (t->err) scr_error_release(t->err); if (t->cb) scr_closure_release(t->cb); scr_als_ctx_release(t->ctx); - free(t); + scr_mem_free(t); } /* One tick-marker's dispatch: the queue's FIFO head (markers and entries @@ -8058,10 +8136,83 @@ static ScrStream *scr_stream_view_receiver(const ScrDyn *value) { /* The members a native stream answers to checked-dynamic calls. */ static const char *const scr_stream_dyn_methods[] = { "on", "once", "addListener", "prependListener", "prependOnceListener", - "off", "removeListener", "pipe", "unpipe", "read", "pause", "resume", "isPaused", - "setEncoding", "write", "end", "destroy", "cork", "uncork", + "off", "removeListener", "removeAllListeners", "listenerCount", "eventNames", "pipe", + "unpipe", "read", "push", "unshift", "pause", "resume", "isPaused", "setEncoding", "write", + "end", "destroy", "cork", "uncork", "setDefaultEncoding", }; +/* An event name argument: a string, +1; other values throw the "not + * supported yet" Error. */ +static ScrStr *scr_stream_dyn_event_name(const ScrDyn *value) { + if (value->kind == SCR_DYN_STR) return scr_str_retain(value->v.str); + static const char message[] = "event names other than strings on a native stream are not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return NULL; +} + +/* push(chunk, encoding): a string with an encoding pushes the string's + * bytes in that encoding, undefined pushes an empty chunk outside object + * mode, and other chunks push as push(chunk) does. */ +static ScrDyn *scr_stream_dyn_push(ScrStream *stream, ScrDyn *const *args, size_t argc) { + ScrDyn *chunk = argc ? args[0] : scr_dyn_undefined(); + ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); + if (chunk->kind == SCR_DYN_STR && encoding->kind == SCR_DYN_STR) { + ScrStr *canonical = scr_bytes_normalize_encoding(encoding->v.str); + if (!canonical) return NULL; + bool more = scr_stream_push_str_enc(stream, chunk->v.str, canonical); + scr_str_release(canonical); + return scr_dyn_new_bool(more); + } + if (chunk->kind == SCR_DYN_UNDEF && !(stream->st->has_r && stream->st->r.object_entries)) { + ScrStr *empty = scr_str_new("", 0); + bool more = scr_stream_push_str(stream, empty); + scr_str_release(empty); + return scr_dyn_new_bool(more); + } + bool more = scr_stream_push_dyn(stream, chunk); + return scr_exc_pending() ? NULL : scr_dyn_new_bool(more); +} + +/* unshift(chunk[, encoding]): a byte-mode stream takes a string chunk with + * an encoding as the bytes Buffer.from(chunk, encoding) holds, as Node's + * readableAddChunkUnshift converts it; an unknown encoding throws + * ERR_UNKNOWN_ENCODING. Answers whether the stream takes more data + * afterwards, Node's canPushMore. */ +static ScrDyn *scr_stream_dyn_unshift(ScrStream *stream, ScrDyn *const *args, size_t argc) { + ScrDyn *chunk = argc ? args[0] : scr_dyn_undefined(); + ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); + if (chunk->kind == SCR_DYN_STR && encoding->kind == SCR_DYN_STR && + !(stream->st->has_r && stream->st->r.object_entries)) { + ScrStr *canonical = scr_bytes_normalize_encoding(encoding->v.str); + if (!canonical) return NULL; + ScrBytes *b = scr_stream_buffer(scr_bytes_from_str(chunk->v.str, canonical)); + scr_str_release(canonical); + scr_stream_unshift(stream, b); + scr_bytes_release(b); + } else { + scr_stream_unshift_dyn(stream, chunk); + } + if (scr_exc_pending()) return NULL; + ScrStreamState *st = stream->st; + bool more = chunk->kind != SCR_DYN_NULL && st->has_r && !st->destroyed && !st->errored && + !st->r.ended && (st->r.length < st->r.hwm || st->r.length == 0); + return scr_dyn_new_bool(more); +} + +/* eventNames(): the stream's event names as a string array, +1. */ +static ScrDyn *scr_stream_dyn_event_names(ScrStream *stream) { + ScrArr *names = scr_emitter_event_names((ScrEmitter *)stream); + ScrDyn *result = scr_dyn_new_arr(); + double length = scr_arr_len(names); + for (double i = 0; i < length; i++) { + ScrStr *name = scr_arr_get_ref(names, i); + scr_dyn_arr_push(result, scr_dyn_new_str(name)); + scr_str_release(name); + } + scr_arr_release(names); + return result; +} + /* A checked-dynamic call of one of a native stream's members (borrowed * stream): the result, +1, with `this` results answering `self`, or the * current `this` when self is NULL. */ @@ -8086,6 +8237,29 @@ static ScrDyn *scr_stream_dyn_call(ScrStream *stream, ScrDyn *self, const char * argc > 1 ? args[1] : scr_dyn_undefined()); scr_emitter_release(registered); } + } else if (!strcmp(method, "removeAllListeners")) { + bool all = first->kind == SCR_DYN_UNDEF; + ScrStr *name = all ? scr_str_new("", 0) : scr_stream_dyn_event_name(first); + if (name) { + scr_emitter_release(scr_emitter_remove_all((ScrEmitter *)stream, name, all)); + scr_str_release(name); + } + } else if (!strcmp(method, "listenerCount")) { + ScrStr *name = scr_stream_dyn_event_name(first); + if (name) { + ScrDyn *only = argc > 1 ? args[1] : scr_dyn_undefined(); + double count = only->kind == SCR_DYN_UNDEF || only->kind == SCR_DYN_NULL + ? scr_emitter_listener_count((ScrEmitter *)stream, name) + : only->kind == SCR_DYN_FUNC ? scr_emitter_listener_count_dyn((ScrEmitter *)stream, name, only) : 0; + scr_str_release(name); + if (!scr_exc_pending()) result = scr_dyn_new_num(count); + } + } else if (!strcmp(method, "eventNames")) result = scr_stream_dyn_event_names(stream); + else if (!strcmp(method, "push")) result = scr_stream_dyn_push(stream, args, argc); + else if (!strcmp(method, "unshift")) result = scr_stream_dyn_unshift(stream, args, argc); + else if (!strcmp(method, "setDefaultEncoding")) { + ScrStream *returned = scr_stream_set_default_encoding(stream, first); + if (returned) scr_stream_release(returned); } else if (!strcmp(method, "pipe") || !strcmp(method, "unpipe")) { ScrStream *destination = first->kind == SCR_DYN_UNDEF && !strcmp(method, "unpipe") ? NULL : scr_stream_view_receiver(first); @@ -8220,6 +8394,11 @@ ScrDyn *scr_stream_dyn_view(ScrStream *stream) { const char *name = scr_stream_dyn_properties[i]; scr_dyn_obj_set(view, name, strlen(name), scr_stream_dyn_property(stream, name)); } + if (stream->st->has_r) { + int flowing = stream->st->r.flowing; + scr_dyn_obj_set(view, "readableFlowing", 15, + flowing < 0 ? scr_dyn_new_null() : scr_dyn_new_bool(flowing == 1)); + } if (stream->st->native && stream->st->native_ops && stream->st->native_ops->view) { ScrClosure *cb = scr_closure_new((void *)scr_stream_view_call, 2); cb->caps[0] = scr_box_new_obj(scr_stream_retain_v, scr_stream_release_v, scr_stream_trace); @@ -8407,3 +8586,31 @@ ScrStream *scr_stream_unbox_dyn(const ScrDyn *value, const ScrStr *message) { scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "SC2020"); return NULL; } + +/* pipeline.js's first stage: a value with pipe() and on() methods is a + * readable node stream, which the stage ladder tests before isIterable; + * a string, or a value with a Symbol.asyncIterator or Symbol.iterator + * method, is an iterable the pipeline pumps into the next stage. */ +bool scr_stream_dyn_iterable_source(const ScrDyn *value) { + ScrStream *stream = scr_stream_of_handle(value); + if (!stream) stream = scr_stream_boxed(value); + if (stream) { + scr_stream_release(stream); + return false; + } + if (value->kind == SCR_DYN_STR) return true; + if (value->kind == SCR_DYN_OBJ) { + ScrDyn *pipe = scr_dyn_obj_read(value, "pipe", 4); + if (!pipe) return false; + bool streamish = pipe->kind == SCR_DYN_FUNC; + scr_dyn_release(pipe); + if (streamish) { + ScrDyn *on = scr_dyn_obj_read(value, "on", 2); + if (!on) return false; + streamish = on->kind == SCR_DYN_FUNC; + scr_dyn_release(on); + if (streamish) return false; + } + } + return scr_dyn_iterable_kind(value) > 0; +} diff --git a/packages/runtime/src/scr_stream.h b/packages/runtime/src/scr_stream.h index a4798c629..7acad0eeb 100644 --- a/packages/runtime/src/scr_stream.h +++ b/packages/runtime/src/scr_stream.h @@ -145,6 +145,10 @@ ScrStream *scr_stream_pipe_general(ScrStream *src, ScrStream *dst, bool end); /* A boxed native stream out of a checked-dynamic pipeline stage (+1), or * NULL with `message` thrown as the not-supported Error. */ ScrStream *scr_stream_unbox_dyn(const struct ScrDyn *value, const ScrStr *message); +/* Whether pipeline() keeps a checked-dynamic first stage as an iterable + * to pump rather than as a stream (false with an exception pending when + * reading the value's members throws). */ +bool scr_stream_dyn_iterable_source(const struct ScrDyn *value); /* Handles that are Transforms (ScrDynHandleOps.transform: Cipheriv and * Decipheriv). scr_stream_of_handle: the handle's Transform (+1), built on * first use, or NULL when the value is not such a handle. diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index 37b92e48c..d2d397e25 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -1889,6 +1889,21 @@ static bool scr_sidx_units(const ScrStr *s, ScrSidx *e) { return true; } +/* A loop about to read every unit of s (bytes-loop-versions.ts) converts it + * at once instead of after the threshold's reads, and publishes it as the + * scan's receiver. ASCII receivers and receivers past SCR_SIDX_UNITS_MAX + * publish nothing; the caller compares scr_str_scan.s with s. */ +void scr_str_scan_units(ScrStr *s) { + if (s->ascii || s->len > SCR_SIDX_UNITS_MAX) return; + ScrSidx *e = scr_sidx(s); + if (!e->units) e->units = scr_str_units(s, &e->nunits); + if (scr_str_scan.s != s) { + scr_str_scan.s = s; + scr_str_scan.units = e->units; + scr_str_scan.n = e->nunits; + } +} + typedef enum { SCR_UNIT_FIRST, SCR_UNIT_LAST, SCR_UNIT_AT } ScrUnitSearch; /* The UTF-16 index of needle in s: the first match at or after `from`, @@ -2210,6 +2225,17 @@ static void scr_str_slice_bounds(ScrStr *s, ScrSidx *e, double start, double end void scr_str_slice_range(ScrStr *s, double start, double end, bool substring, ScrStringSlice *out) { + if (__builtin_expect(s->ascii, 1)) { + /* Units are bytes: no index entry, and no boundary splits a pair. */ + size_t length = s->len; + size_t from = scr_slice_unit(start, length, substring); + size_t to = scr_slice_unit(end, length, substring); + if (substring && from > to) { size_t swap = from; from = to; to = swap; } + out->start = from; + out->length = to > from ? to - from : 0; + out->split = 0; + return; + } ScrSidx *e = scr_sidx_for(s); size_t from, to; scr_str_slice_bounds(s, e, start, end, substring, &from, &to); diff --git a/packages/runtime/src/scr_string_methods.h b/packages/runtime/src/scr_string_methods.h index 1f25b8766..820b1c9e6 100644 --- a/packages/runtime/src/scr_string_methods.h +++ b/packages/runtime/src/scr_string_methods.h @@ -42,6 +42,11 @@ extern SCR_TL ScrStrScan scr_str_scan; * [0, length) — note charCodeAt(1.5) is index 1, matching Node. */ double scr_str_char_code_at(ScrStr *s, double i); +/* Converts a non-ASCII s up to the units cap to UTF-16 at once and + * publishes it in scr_str_scan, for a loop about to read all of it. Leaves + * scr_str_scan unchanged for any other s. */ +void scr_str_scan_units(ScrStr *s); + /* indexOf(needle, fromIndex): UTF-16 index of the first occurrence at or * after fromIndex (clamped to [0, length]), or -1. Empty needle returns the * clamped fromIndex per spec. */ diff --git a/packages/runtime/src/scr_symbol.c b/packages/runtime/src/scr_symbol.c index 727e008b5..3169d6b13 100644 --- a/packages/runtime/src/scr_symbol.c +++ b/packages/runtime/src/scr_symbol.c @@ -22,6 +22,10 @@ struct ScrSym { ScrStr *desc; /* owned; NULL = no description (Symbol()) */ ScrStr *reg_key; /* owned; non-NULL exactly for Symbol.for symbols */ struct ScrSym *reg_next; /* the registry's intrusive chain */ + /* The symbol's checked-dynamic box while one is alive (not retained: the + * box retains the symbol and clears this when it is freed), shared by + * every conversion of the symbol to a checked value. */ + ScrDyn *box; }; ScrSym *scr_sym_retain(ScrSym *s) { @@ -50,6 +54,7 @@ ScrSym *scr_sym_new(ScrStr *desc) { s->desc = desc ? scr_str_retain(desc) : NULL; s->reg_key = NULL; s->reg_next = NULL; + s->box = NULL; return s; } @@ -142,8 +147,17 @@ ScrStr *scr_sym_desc(ScrSym *s) { return s->desc ? scr_str_retain(s->desc) : NUL ScrStr *scr_sym_key_for(ScrSym *s) { return s->reg_key ? scr_str_retain(s->reg_key) : NULL; } +/* The release of a symbol's shared box: the box is going away. */ +static void scr_sym_release_box(ScrSym *s) { + s->box = NULL; + scr_sym_release(s); +} + ScrDyn *scr_dyn_new_symbol(ScrSym *value) { - return scr_dyn_symbol_ref(scr_sym_retain(value), scr_sym_release, scr_sym_to_string, scr_sym_desc); + if (value->box) return scr_dyn_retain(value->box); + ScrDyn *box = scr_dyn_symbol_ref(scr_sym_retain(value), scr_sym_release_box, scr_sym_to_string, scr_sym_desc); + value->box = box; + return box; } ScrStr *scr_sym_to_string(ScrSym *s) { diff --git a/packages/runtime/src/scr_text_codec_dyn.c b/packages/runtime/src/scr_text_codec_dyn.c index 5102bc558..37fdfb27b 100644 --- a/packages/runtime/src/scr_text_codec_dyn.c +++ b/packages/runtime/src/scr_text_codec_dyn.c @@ -1,9 +1,10 @@ -/* WHATWG TextEncoder and TextDecoder instances as checked-dynamic values - * (SCR_DYNH_TEXT_ENCODER, SCR_DYNH_TEXT_DECODER). A codec is a compiled +/* WHATWG TextEncoder and TextDecoder instances, and node:string_decoder + * StringDecoder instances, as checked-dynamic values (SCR_DYNH_TEXT_ENCODER, + * SCR_DYNH_TEXT_DECODER, SCR_DYNH_STRING_DECODER). A codec is a compiled * record (type-mapper.ts): the handle holds that record by reference, so * a codec stored in an untyped slot and read back keeps its identity and * a decoder keeps its streaming state. The compiler passes the record's - * reference-count entry points and, for decoders, the byte offsets of the + * reference-count entry points and, for a TextDecoder, the byte offsets of the * encoding, fatal, ignoreBOM and state fields when it boxes one. A unit * of its own so programs that never box a codec link none of it * (native-toolchain gating: the textCodecDyn runtime feature). */ @@ -213,3 +214,124 @@ bool scr_text_codec_is(const ScrDyn *d, bool decoder) { return d->kind == SCR_DYN_HANDLE && d->v.handle.tag == (decoder ? SCR_DYNH_TEXT_DECODER : SCR_DYNH_TEXT_ENCODER); } + +/* node:string_decoder StringDecoder instances (SCR_DYNH_STRING_DECODER). + * The decoder is a compiled record of its canonical encoding (a string) + * and its pending partial sequence (an f64 packing, scr_bytes.c's + * scr_strdec_* helpers); the compiler passes both fields' byte offsets + * when it boxes one. */ +typedef struct { + void *(*retain)(void *); + void (*release)(void *); + size_t encoding, pending; +} ScrStringDecoderLayout; + +static SCR_TL ScrStringDecoderLayout scr_sdd_layout; + +static void *scr_sdd_retain(void *rec) { return scr_sdd_layout.retain(rec); } +static void scr_sdd_release(void *rec) { scr_sdd_layout.release(rec); } + +static ScrStr *scr_sdd_encoding(const void *rec) { + ScrStr *encoding; + memcpy(&encoding, (const char *)rec + scr_sdd_layout.encoding, sizeof encoding); + return encoding; +} + +static double scr_sdd_pending(const void *rec) { + double pending; + memcpy(&pending, (const char *)rec + scr_sdd_layout.pending, sizeof pending); + return pending; +} + +static void scr_sdd_set_pending(void *rec, double pending) { + memcpy((char *)rec + scr_sdd_layout.pending, &pending, sizeof pending); +} + +/* write(buf): a string answers itself; an ArrayBuffer view decodes with + * the pending bytes before it; other values throw Node's + * ERR_INVALID_ARG_TYPE. +1 or NULL. */ +static ScrStr *scr_sdd_write(void *rec, const ScrDyn *buf) { + if (buf->kind == SCR_DYN_STR) return scr_str_retain(buf->v.str); + if (buf->kind != SCR_DYN_BYTES) { + scr_dyn_arg_type_fail("buf", "an instance of Buffer, TypedArray, or DataView", buf); + return NULL; + } + ScrBytes *bytes = scr_bytes_raw_view(buf->v.bytes); + ScrStr *encoding = scr_sdd_encoding(rec); + double pending = scr_sdd_pending(rec); + ScrStr *text = scr_strdec_write(encoding, pending, bytes); + scr_sdd_set_pending(rec, scr_strdec_next(encoding, pending, bytes)); + scr_bytes_release(bytes); + return text; +} + +static ScrDyn *scr_sdd_invoke(void *rec, ScrDyn *self, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + (void)self; + ScrDyn *buf = scr_tcd_codec_arg(args, argc, 0); + if (strcmp(method, "write") == 0) { + ScrStr *text = scr_sdd_write(rec, buf); + return text ? scr_tcd_str(text) : NULL; + } + if (strcmp(method, "end") == 0) { + /* end(buf): write(buf) when buf is not undefined, then the pending + * bytes' replacement text. */ + ScrStr *head = buf->kind == SCR_DYN_UNDEF ? scr_str_new("", 0) : scr_sdd_write(rec, buf); + if (!head) return NULL; + ScrStr *tail = scr_strdec_end(scr_sdd_encoding(rec), scr_sdd_pending(rec)); + scr_sdd_set_pending(rec, 0); + ScrStr *text = scr_str_concat(head, tail); + scr_str_release(head); + scr_str_release(tail); + return scr_tcd_str(text); + } + if (strcmp(method, "text") == 0) { + static const char message[] = "StringDecoder.prototype.text is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return NULL; + } + scr_tcd_codec_not_a_function(what); + return NULL; +} + +static ScrDyn *scr_sdd_get(void *rec, const char *key, size_t len) { + if (len == 8 && memcmp(key, "encoding", 8) == 0) + return scr_dyn_new_str(scr_sdd_encoding(rec)); + if ((len == 8 && memcmp(key, "lastChar", 8) == 0) || + (len == 8 && memcmp(key, "lastNeed", 8) == 0) || + (len == 9 && memcmp(key, "lastTotal", 9) == 0)) { + static const char message[] = "StringDecoder lastChar, lastNeed and lastTotal are not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + } + return NULL; +} + +static const ScrDynHandleMethod scr_sdd_methods[] = { + {.name = "write", .length = 1}, + {.name = "end", .length = 1}, + {.name = "text", .length = 2}, + {.name = NULL}, +}; + +static const ScrDynHandleOps scr_sdd_ops = { + .cls = "StringDecoder", + .retain = scr_sdd_retain, + .release = scr_sdd_release, + .invoke = scr_sdd_invoke, + .get = scr_sdd_get, + .set = scr_tcd_codec_set, + .methods = scr_sdd_methods, +}; + +ScrDyn *scr_string_decoder_to_dyn(void *rec, void *(*retain)(void *), void (*release)(void *), + size_t encoding, size_t pending) { + if (!scr_sdd_layout.retain) { + scr_sdd_layout = (ScrStringDecoderLayout){retain, release, encoding, pending}; + scr_dyn_handle_install(SCR_DYNH_STRING_DECODER, &scr_sdd_ops); + } + return scr_dyn_new_handle(rec, SCR_DYNH_STRING_DECODER); +} + +void *scr_string_decoder_from_dyn(const ScrDyn *d, const ScrDynPath *path, const char *want) { + return scr_dyn_handle_unbox(d, SCR_DYNH_STRING_DECODER, path, want); +} diff --git a/packages/runtime/src/scr_tls.c b/packages/runtime/src/scr_tls.c index e7436ab69..547fef27a 100644 --- a/packages/runtime/src/scr_tls.c +++ b/packages/runtime/src/scr_tls.c @@ -64,10 +64,15 @@ * expired") through the socket's pending-error sweep — then 'close', * Node's order, exactly like ECONNREFUSED. * + * Client certificates: servers take requestCert, rejectUnauthorized and + * ca and report socket.authorized/authorizationError as Node does; + * clients send a cert and key when asked. A `ca` also completes the + * chain its owner sends, as OpenSSL's automatic chain does. + * * Bounds (SEMANTICS.md divergence 55): no session resumption or - * tickets, no renegotiation/post-handshake auth, no client - * certificates, and non-verify client handshake failures carry a generic - * message where Node prints an OpenSSL one. + * tickets, no renegotiation/post-handshake auth, and non-verify client + * handshake failures carry a generic message where Node prints an + * OpenSSL one. * * Certificate names: a client always checks the leaf against one name, * the servername when it is non-empty and the host otherwise. SNI carries @@ -82,6 +87,7 @@ #include #include #include +#include #include #include #ifdef _WIN32 @@ -202,6 +208,42 @@ static void scr_tls_bad_material(const char *what, int err) { _Exit(1); } +/* OpenSSL's automatic chain (ssl_add_cert_chain): a certificate option + * that holds only the leaf is sent with the issuers `store` holds, up to + * a self-issued one, as far as they are found. A chain given in the + * option is sent as it is. */ +static void scr_tls_auto_chain(mbedtls_x509_crt *own, const mbedtls_x509_crt *store) { + if (own->raw.len == 0 || own->next != NULL) return; + const mbedtls_x509_crt *at = own; + for (int depth = 0; depth < 8; depth++) { + if (at->issuer_raw.len == at->subject_raw.len && + memcmp(at->issuer_raw.p, at->subject_raw.p, at->subject_raw.len) == 0) + return; + const mbedtls_x509_crt *issuer = NULL; + for (const mbedtls_x509_crt *c = store; c != NULL && c->raw.len > 0 && issuer == NULL; c = c->next) { + if (c->subject_raw.len == at->issuer_raw.len && + memcmp(c->subject_raw.p, at->issuer_raw.p, c->subject_raw.len) == 0) + issuer = c; + } + if (issuer == NULL) return; + for (const mbedtls_x509_crt *c = own; c != NULL; c = c->next) { + if (c->raw.len == issuer->raw.len && memcmp(c->raw.p, issuer->raw.p, c->raw.len) == 0) return; + } + if (mbedtls_x509_crt_parse_der(own, issuer->raw.p, issuer->raw.len) != 0) return; + while (at->next != NULL) at = at->next; + } +} + +/* A copy of a certificate chain, completed from `store` as the automatic + * chain does, for a use that must leave the original as it is. */ +static void scr_tls_chained_copy(mbedtls_x509_crt *out, const mbedtls_x509_crt *chain, + const mbedtls_x509_crt *store) { + for (const mbedtls_x509_crt *c = chain; c != NULL && c->raw.len > 0; c = c->next) { + if (mbedtls_x509_crt_parse_der(out, c->raw.p, c->raw.len) != 0) scr_tls_bad_material("certificate", 0); + } + scr_tls_auto_chain(out, store); +} + /* ── SecureContext (tls.createSecureContext) ─────────────────────────── */ /* An opaque refcounted parsed cert/key pair — what an SNI callback @@ -210,6 +252,12 @@ struct ScrSecureCtx { size_t rc; mbedtls_x509_crt cert; mbedtls_pk_context pk; + /* the ca option: a socket created from this context (a TLSSocket's + * secureContext) sends the chain it completes, and a server that serves + * the context verifies client certificates against it. has_ca tells an + * empty ca (an empty trust store) from none (the default store). */ + bool has_ca; + mbedtls_x509_crt ca; }; #ifdef SCR_RC_AUDIT @@ -224,6 +272,7 @@ ScrSecureCtx *scr_tls_create_secure_context(const char *cert, size_t cert_len, if (!c) scr_tls_oom(); c->rc = 1; mbedtls_x509_crt_init(&c->cert); + mbedtls_x509_crt_init(&c->ca); mbedtls_pk_init(&c->pk); int r = scr_tls_parse_crt(&c->cert, cert, cert_len); if (r != 0) scr_tls_bad_material("certificate", r); @@ -243,6 +292,7 @@ ScrSecureCtx *scr_secure_ctx_retain(ScrSecureCtx *c) { void scr_secure_ctx_release(ScrSecureCtx *c) { if (!c || --c->rc != 0) return; mbedtls_x509_crt_free(&c->cert); + mbedtls_x509_crt_free(&c->ca); mbedtls_pk_free(&c->pk); free(c); #ifdef SCR_RC_AUDIT @@ -372,6 +422,29 @@ typedef struct ScrTlsSrv { * certificate and key this config serves (+1; NULL when cert/key are the * config's own) */ ScrSecureCtx *ctx; + /* client certificates: requestCert asks for one and verifies it against + * `ca` (the default store without one); with rejectUnauthorized a client + * that sends none fails the handshake and one that fails verification + * is dropped before 'secureConnection', as in Node */ + bool request_cert; + bool reject_unauthorized; + bool has_ca; + mbedtls_x509_crt ca; + /* new tls.TLSSocket(socket, { isServer }): Node verifies client + * certificates only in tls.Server, so a standalone socket reports no + * verdict (authorized false, authorizationError null) and drops no + * client for one; rejectUnauthorized there only requires a certificate */ + bool standalone; + /* server.addContext(servername, context): lowercased name patterns + * ('*' matches within one label) with their contexts (+1), newest + * last; server.setSecureContext's replacement default (+1, NULL for the + * server's own). Both serve through scr_tls_f_cert. */ + struct ScrTlsNamedCtx { + char *pattern; + ScrSecureCtx *ctx; + } *contexts; + size_t ncontexts; + ScrSecureCtx *default_ctx; } ScrTlsSrv; /* The ALPN answers (static storage — mbedTLS keeps the pointers). Plain @@ -389,6 +462,7 @@ static ScrTlsSrv *scr_tls_srv_new(const char *cert, size_t cert_len, const char if (!s) scr_tls_oom(); s->rc = 1; mbedtls_x509_crt_init(&s->cert); + mbedtls_x509_crt_init(&s->ca); mbedtls_pk_init(&s->pk); mbedtls_ssl_config_init(&s->conf); int r = scr_tls_parse_crt(&s->cert, cert, cert_len); @@ -401,7 +475,8 @@ static ScrTlsSrv *scr_tls_srv_new(const char *cert, size_t cert_len, const char scr_tls_oom(); } mbedtls_ssl_conf_rng(&s->conf, mbedtls_ctr_drbg_random, &scr_tls_drbg); - mbedtls_ssl_conf_authmode(&s->conf, MBEDTLS_SSL_VERIFY_NONE); /* no client certs, Node's default */ + /* no client certificate unless requestCert asks (scr_tls_srv_client_auth) */ + mbedtls_ssl_conf_authmode(&s->conf, MBEDTLS_SSL_VERIFY_NONE); if (mbedtls_ssl_conf_own_cert(&s->conf, &s->cert, &s->pk) != 0) { scr_tls_bad_material("certificate/key pair", 0); } @@ -416,6 +491,7 @@ static ScrTlsSrv *scr_tls_srv_from_ctx(ScrSecureCtx *ctx /*borrowed*/) { if (!s) scr_tls_oom(); s->rc = 1; mbedtls_x509_crt_init(&s->cert); + mbedtls_x509_crt_init(&s->ca); mbedtls_pk_init(&s->pk); mbedtls_ssl_config_init(&s->conf); if (mbedtls_ssl_config_defaults(&s->conf, MBEDTLS_SSL_IS_SERVER, MBEDTLS_SSL_TRANSPORT_STREAM, @@ -425,7 +501,13 @@ static ScrTlsSrv *scr_tls_srv_from_ctx(ScrSecureCtx *ctx /*borrowed*/) { mbedtls_ssl_conf_rng(&s->conf, mbedtls_ctr_drbg_random, &scr_tls_drbg); mbedtls_ssl_conf_authmode(&s->conf, MBEDTLS_SSL_VERIFY_NONE); s->ctx = scr_secure_ctx_retain(ctx); - if (mbedtls_ssl_conf_own_cert(&s->conf, &ctx->cert, &ctx->pk) != 0) { + /* the context's ca completes the chain a socket created from it sends */ + const mbedtls_x509_crt *own = &ctx->cert; + if (ctx->ca.raw.len > 0) { + scr_tls_chained_copy(&s->cert, &ctx->cert, &ctx->ca); + own = &s->cert; + } + if (mbedtls_ssl_conf_own_cert(&s->conf, (mbedtls_x509_crt *)own, &ctx->pk) != 0) { scr_tls_bad_material("certificate/key pair", 0); } return s; @@ -443,18 +525,60 @@ static void scr_tls_srv_release(ScrTlsSrv *s) { scr_closure_release(s->sni_cb); mbedtls_ssl_config_free(&s->conf); mbedtls_x509_crt_free(&s->cert); + mbedtls_x509_crt_free(&s->ca); mbedtls_pk_free(&s->pk); scr_tls_alpn_free(s->alpn); scr_secure_ctx_release(s->ctx); + for (size_t i = 0; i < s->ncontexts; i++) { + free(s->contexts[i].pattern); + scr_secure_ctx_release(s->contexts[i].ctx); + } + free(s->contexts); + scr_secure_ctx_release(s->default_ctx); free(s); } static void scr_tls_srv_release_v(void *p) { scr_tls_srv_release((ScrTlsSrv *)p); } +/* The client-certificate options of a server: requestCert, rejectUnauthorized + * (Node's true default) and ca (+1, NULL for none). */ +typedef struct { + bool request; + bool reject; + ScrBytes *ca; +} ScrTlsClientAuth; + +static mbedtls_x509_crt *scr_tls_system_ca(void); + +/* Applies a server's client-certificate options. A `ca` also completes the + * server's own chain, as OpenSSL's automatic chain sends it. */ +static void scr_tls_srv_client_auth(ScrTlsSrv *s, const ScrTlsClientAuth *auth) { + if (auth == NULL) return; + /* An empty ca Buffer is an empty trust store, not the default one. */ + if (auth->ca != NULL) { + if (auth->ca->len > 0) { + int r = scr_tls_parse_crt(&s->ca, (const char *)auth->ca->data, auth->ca->len); + if (r != 0) scr_tls_bad_material("ca certificate", r); + if (s->ctx == NULL) scr_tls_auto_chain(&s->cert, &s->ca); + } + s->has_ca = true; + } + if (!auth->request) return; + s->request_cert = true; + s->reject_unauthorized = auth->reject; + /* OPTIONAL: the handshake records the verdict and goes on, as Node's + * verify callback does; scr_tls_handshake applies rejectUnauthorized */ + mbedtls_ssl_conf_authmode(&s->conf, MBEDTLS_SSL_VERIFY_OPTIONAL); + mbedtls_ssl_conf_ca_chain(&s->conf, s->has_ca ? &s->ca : scr_tls_system_ca(), NULL); +} + /* ── the client config (one per https.request, owned by its tctx) ────── */ typedef struct ScrTlsCli { mbedtls_ssl_config conf; + /* the ca option, which replaces the default store; has_ca tells an + * empty ca (an empty trust store) from none */ + bool has_ca; mbedtls_x509_crt ca; char *hostname; /* the SNI name; NULL sends none (an IP literal, servername: "") */ char *verify_name; /* the name the leaf is checked against; NULL fails the check */ @@ -463,6 +587,7 @@ typedef struct ScrTlsCli { * VERIFY_NONE socket (the https client's rejectUnauthorized:false) * never verified, so authorized answers false like Node's. */ bool verify_recorded; + bool reject; /* rejectUnauthorized: a verification failure fails the handshake */ /* The ALPNProtocols option's names, NULL-terminated, in the caller's * order (mbedtls_ssl_conf_alpn_protocols keeps the pointer); NULL when * none are offered. */ @@ -470,6 +595,11 @@ typedef struct ScrTlsCli { /* No TLS version is in the configured range: the handshake fails as * soon as it starts (Node's ERR_SSL_NO_PROTOCOLS_AVAILABLE). */ bool no_protocols; + /* the client certificate (cert and key options), sent when the server + * asks for one */ + bool has_own; + mbedtls_x509_crt own; + mbedtls_pk_context own_pk; } ScrTlsCli; /* The default trust anchors when no `ca` option is given: the host trust @@ -562,6 +692,8 @@ static ScrTlsCli *scr_tls_cli_new2(ScrStr *host, bool reject_unauthorized, const if (!c) scr_tls_oom(); mbedtls_ssl_config_init(&c->conf); mbedtls_x509_crt_init(&c->ca); + mbedtls_x509_crt_init(&c->own); + mbedtls_pk_init(&c->own_pk); if (mbedtls_ssl_config_defaults(&c->conf, MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT) != 0) { @@ -569,6 +701,8 @@ static ScrTlsCli *scr_tls_cli_new2(ScrStr *host, bool reject_unauthorized, const } mbedtls_ssl_conf_rng(&c->conf, mbedtls_ctr_drbg_random, &scr_tls_drbg); c->verify_recorded = reject_unauthorized || verify_optional; + c->reject = reject_unauthorized; + c->has_ca = ca != NULL; if (ca_len > 0) { int r = scr_tls_parse_crt(&c->ca, ca, ca_len); if (r != 0) scr_tls_bad_material("ca certificate", r); @@ -578,7 +712,7 @@ static ScrTlsCli *scr_tls_cli_new2(ScrStr *host, bool reject_unauthorized, const } else { mbedtls_ssl_conf_authmode(&c->conf, reject_unauthorized ? MBEDTLS_SSL_VERIFY_REQUIRED : MBEDTLS_SSL_VERIFY_OPTIONAL); - mbedtls_ssl_conf_ca_chain(&c->conf, ca_len > 0 ? &c->ca : scr_tls_system_ca(), NULL); + mbedtls_ssl_conf_ca_chain(&c->conf, c->has_ca ? &c->ca : scr_tls_system_ca(), NULL); } /* The host is the verified name; it is also the SNI name unless it is * an IP literal. */ @@ -620,11 +754,27 @@ static void scr_tls_cli_free(ScrTlsCli *c) { mbedtls_ssl_config_free(&c->conf); scr_tls_alpn_free(c->alpn); mbedtls_x509_crt_free(&c->ca); + mbedtls_x509_crt_free(&c->own); + mbedtls_pk_free(&c->own_pk); free(c->hostname); free(c->verify_name); free(c); } +/* The cert and key options of a client: the certificate (with the issuers + * its `ca` holds, OpenSSL's automatic chain) is sent when the server asks + * for one. */ +static void scr_tls_cli_set_own(ScrTlsCli *c, const ScrBytes *cert, const ScrBytes *key) { + int r = scr_tls_parse_crt(&c->own, (const char *)cert->data, cert->len); + if (r != 0) scr_tls_bad_material("certificate", r); + r = scr_tls_parse_key(&c->own_pk, (const char *)key->data, key->len); + if (r != 0) scr_tls_bad_material("private key", r); + if (c->ca.raw.len > 0) scr_tls_auto_chain(&c->own, &c->ca); + if (mbedtls_ssl_conf_own_cert(&c->conf, &c->own, &c->own_pk) != 0) + scr_tls_bad_material("certificate/key pair", 0); + c->has_own = true; +} + /* ── the per-socket transport ────────────────────────────────────────── */ /* The SNI pre-parse states: NONE (no callback on the server — the @@ -656,6 +806,9 @@ typedef struct ScrTlsSock { unsigned char *hello; /* the raw ClientHello flight, replayed at DONE */ size_t hello_len, hello_cap; ScrSecureCtx *sni_ctx; /* owned; the answer (NULL = default cert) */ + /* scr_tls_f_cert verified client certificates against sni_ctx's ca + * (or the default store without one), not the server's */ + bool ctx_verifies; /* new tls.TLSSocket(socket, { isServer: true }): the program holds this * socket before the handshake, so a failed handshake is its 'error' * (tls.createServer's sockets fail without one) */ @@ -665,8 +818,32 @@ typedef struct ScrTlsSock { /* the 'secure' event's listeners */ ScrNetLs secure_ls; bool settled; /* the socket closed: listeners added later never fire */ + /* a TLS 1.3 client sent no certificate to a server that requires one; + * the alert goes out once the handshake is over */ + bool cert_missing; + /* a fatal alert was reported without closing: reads end the stream */ + bool alert_reported; + /* a server socket: the accepting server (+1), which 'tlsClientError' + * fires on, and a hang-up waiting for the socket to close (Node emits + * ECONNRESET 'socket hang up' from the socket's 'close') */ + ScrNetServer *server; + bool hang_up_pending; + /* a client whose verification failed: like Node's, it closes without + * sending the alert, so later writes are dropped */ + bool quiet_failure; + /* the served context's chain completed from the server's ca */ + mbedtls_x509_crt served_chain; + /* what the handshake's last step and the established callbacks write: + * Node flushes it after the nextTick queue and before the immediates + * queued in those callbacks, so a destroy() there drops it. `holding` + * collects; held[held_off..held_len) waits for the fd. */ + bool holding; + unsigned char *held; + size_t held_len, held_off, held_cap; } ScrTlsSock; +static bool scr_tls_alert_error(ScrTlsSock *t, unsigned char alert); + /* checkServerIdentity for an IP literal: the address must be one of the * certificate's IP SANs (no DNS SAN or CN applies). */ static bool scr_tls_leaf_has_ip(const mbedtls_x509_crt *crt, const char *ip) { @@ -711,6 +888,9 @@ static int scr_tls_vrfy_cb(void *ctx, mbedtls_x509_crt *crt, int depth, uint32_t *flags |= name_flags & MBEDTLS_X509_BADCERT_CN_MISMATCH; } } + /* a rejecting client fails the handshake on any flag; Node's client + * then closes without sending an alert (its Finished is discarded) */ + if (*flags != 0 && t->cli != NULL && t->cli->reject) t->quiet_failure = true; bool self = crt->issuer_raw.len == crt->subject_raw.len && memcmp(crt->issuer_raw.p, crt->subject_raw.p, crt->subject_raw.len) == 0; if (*flags & MBEDTLS_X509_BADCERT_EXPIRED) t->vrfy_expired = true; @@ -725,8 +905,52 @@ static int scr_tls_vrfy_cb(void *ctx, mbedtls_x509_crt *crt, int depth, uint32_t /* The BIOs: nonblocking fd I/O in mbedTLS's own convention. A send that * would block arms the socket's write filter so the handshake resumes on * writability. */ +static void scr_tls_hold(ScrTlsSock *t, const unsigned char *buf, size_t len) { + if (t->held_len + len > t->held_cap) { + size_t cap = t->held_cap ? t->held_cap : 4096; + while (cap < t->held_len + len) cap *= 2; + unsigned char *grown = realloc(t->held, cap); + if (grown == NULL) scr_tls_oom(); + t->held = grown; + t->held_cap = cap; + } + memcpy(t->held + t->held_len, buf, len); + t->held_len += len; +} + +/* Writes what is held: 1 once it is all out, 0 when the fd would block + * (the write filter is armed for the rest), -1 when the connection is + * gone (the bytes are dropped). */ +static int scr_tls_flush_held(ScrTlsSock *t) { + int fd = scr_net_sock_fd(t->sock); + while (t->held_off < t->held_len) { + if (fd < 0) break; + ssize_t n = write(fd, t->held + t->held_off, t->held_len - t->held_off); + if (n > 0) { + t->held_off += (size_t)n; + continue; + } + if (n < 0 && errno == EINTR) continue; + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + scr_net_sock_transport_want_write(t->sock); + return 0; + } + break; + } + bool out = t->held_off == t->held_len; + t->held_off = t->held_len = 0; + return out ? 1 : -1; +} + static int scr_tls_bio_send(void *ctx, const unsigned char *buf, size_t len) { ScrTlsSock *t = (ScrTlsSock *)ctx; + if (t->quiet_failure) return (int)len; + if (t->holding) { + scr_tls_hold(t, buf, len); + return (int)len; + } + /* held bytes go first; mbedTLS retries this record once they are out */ + if (t->held_len > t->held_off && scr_tls_flush_held(t) == 0) return MBEDTLS_ERR_SSL_WANT_WRITE; int fd = scr_net_sock_fd(t->sock); if (fd < 0) return MBEDTLS_ERR_NET_CONN_RESET; ssize_t n = write(fd, buf, len); @@ -742,7 +966,7 @@ static int scr_tls_bio_send(void *ctx, const unsigned char *buf, size_t len) { /* Keeps the first bytes a wrapped socket's peer sends: a handshake that * fails on them reports what they looked like, as OpenSSL does. */ static void scr_tls_note_head(ScrTlsSock *t, const unsigned char *buf, size_t n) { - if (!t->wrapped || t->head_len == sizeof t->head) return; + if (t->srv == NULL || t->head_len == sizeof t->head) return; size_t take = sizeof t->head - t->head_len; if (take > n) take = n; memcpy(t->head + t->head_len, buf, take); @@ -770,6 +994,21 @@ static int scr_tls_bio_recv(void *ctx, unsigned char *buf, size_t len) { return MBEDTLS_ERR_NET_RECV_FAILED; } +/* Node reads what the kernel holds (one 64 KiB libuv read) before + * OpenSSL sees the records, so a failed handshake closes with nothing + * unread and the peer gets a FIN. Closing with the rest of, say, an HTTP + * request still queued would make the kernel send a reset instead. */ +static void scr_tls_drain_unread(ScrTlsSock *t) { + int fd = scr_net_sock_fd(t->sock); + if (fd < 0) return; + unsigned char sink[4096]; + for (size_t total = 0; total < 65536; ) { + ssize_t n = read(fd, sink, sizeof sink); + if (n <= 0) break; + total += (size_t)n; + } +} + /* ── the transport ops ───────────────────────────────────────────────── */ static void scr_tls_fire_session(ScrTlsSock *t); @@ -793,6 +1032,14 @@ static ssize_t scr_tls_xread(void *tctx, char *buf, size_t n) { errno = EAGAIN; return -1; } + if (t->alert_reported) return 0; /* the server closing after its alert */ + /* a TLS 1.3 server rejects a missing client certificate after the + * client finished its handshake */ + if (r == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE && t->cli != NULL && + t->ssl.MBEDTLS_PRIVATE(in_msg) != NULL && scr_tls_alert_error(t, t->ssl.MBEDTLS_PRIVATE(in_msg)[1])) { + errno = EAGAIN; + return -1; + } errno = ECONNRESET; /* protocol-level death reads as a reset */ return -1; } @@ -1081,6 +1328,99 @@ void scr_tls_sni_answer(ScrClosure *self, bool has_err, ScrSecureCtx *ctx /*move * ECONNRESET, and bytes that are not a TLS record name what they were * (OpenSSL's reasons without the per-process prefix and source location, * SEMANTICS.md divergence 55). */ +/* The OpenSSL reason (without its per-process prefix and source location, + * SEMANTICS.md divergence 55) and Node's code for a server handshake that + * failed with mbedTLS error `r`; false when the peer closed or reset the + * connection instead. A failure mbedTLS reports without a reason OpenSSL + * would give answers a coded SC2020 refusal. */ +static bool scr_tls_server_reason(ScrTlsSock *t, int r, const char **msg, const char **code) { + if (r == MBEDTLS_ERR_SSL_CONN_EOF || r == MBEDTLS_ERR_NET_CONN_RESET) return false; + if (r == MBEDTLS_ERR_SSL_NO_CLIENT_CERTIFICATE) { + *msg = "error:0A0000C7:SSL routines:tls_process_client_certificate:peer did not return a certificate"; + *code = "ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE"; + return true; + } + const unsigned char *h = t->head; + bool record = t->head_len >= 3 && h[0] >= 20 && h[0] <= 23 && h[1] == 3; + if (t->head_len >= 5 && !record) { + static const char *const http[] = { "GET ", "POST ", "HEAD ", "PATCH", "OPTIO", + "DELET", "TRACE", "PUT ", NULL }; + *msg = "error:0A00010B:SSL routines:tls_validate_record_header:wrong version number"; + *code = "ERR_SSL_WRONG_VERSION_NUMBER"; + for (size_t i = 0; http[i] != NULL; i++) { + if (memcmp(h, http[i], strlen(http[i])) == 0) { + *msg = "error:0A00009C:SSL routines:tls_validate_record_header:http request"; + *code = "ERR_SSL_HTTP_REQUEST"; + } + } + if (memcmp(h, "CONNE", 5) == 0) { + *msg = "error:0A00009B:SSL routines:tls_validate_record_header:https proxy request"; + *code = "ERR_SSL_HTTPS_PROXY_REQUEST"; + } + return true; + } + if (r == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE && t->ssl.MBEDTLS_PRIVATE(in_msg) != NULL) { + /* an alert the client sent: OpenSSL's reason is 1000 + its number */ + static const struct { unsigned char alert; const char *msg, *code; } alerts[] = { + { 40, "error:0A000410:SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure", + "ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE" }, + { 42, "error:0A000412:SSL routines:ssl3_read_bytes:sslv3 alert bad certificate", + "ERR_SSL_SSLV3_ALERT_BAD_CERTIFICATE" }, + { 45, "error:0A000415:SSL routines:ssl3_read_bytes:sslv3 alert certificate expired", + "ERR_SSL_SSLV3_ALERT_CERTIFICATE_EXPIRED" }, + { 46, "error:0A000416:SSL routines:ssl3_read_bytes:sslv3 alert certificate unknown", + "ERR_SSL_SSLV3_ALERT_CERTIFICATE_UNKNOWN" }, + { 48, "error:0A000418:SSL routines:ssl3_read_bytes:tlsv1 alert unknown ca", + "ERR_SSL_TLSV1_ALERT_UNKNOWN_CA" }, + { 49, "error:0A000419:SSL routines:ssl3_read_bytes:tlsv1 alert access denied", + "ERR_SSL_TLSV1_ALERT_ACCESS_DENIED" }, + { 51, "error:0A00041B:SSL routines:ssl3_read_bytes:tlsv1 alert decrypt error", + "ERR_SSL_TLSV1_ALERT_DECRYPT_ERROR" }, + { 70, "error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version", + "ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION" }, + { 80, "error:0A000438:SSL routines:ssl3_read_bytes:tlsv1 alert internal error", + "ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR" }, + }; + unsigned char alert = t->ssl.MBEDTLS_PRIVATE(in_msg)[1]; + for (size_t i = 0; i < sizeof alerts / sizeof alerts[0]; i++) { + if (alerts[i].alert == alert) { + *msg = alerts[i].msg; + *code = alerts[i].code; + return true; + } + } + } + if (r == MBEDTLS_ERR_SSL_BAD_PROTOCOL_VERSION) { + *msg = "error:0A000102:SSL routines:tls_early_post_process_client_hello:unsupported protocol"; + *code = "ERR_SSL_UNSUPPORTED_PROTOCOL"; + return true; + } + if (r == MBEDTLS_ERR_SSL_NO_APPLICATION_PROTOCOL) { + *msg = "error:0A0000EB:SSL routines:tls_handle_alpn:no application protocol"; + *code = "ERR_SSL_NO_APPLICATION_PROTOCOL"; + return true; + } + static SCR_TL char refusal[128]; + snprintf(refusal, sizeof refusal, + "a TLS handshake failure (mbedTLS error -0x%04X) without the reason OpenSSL gives is not supported yet", + (unsigned)-r); + *msg = refusal; + *code = "SC2020"; + return true; +} + +/* 'tlsClientError' on the accepting server: Node's Error with `code`, and + * the socket. Without a listener the socket is only destroyed. */ +static void scr_tls_client_error(ScrTlsSock *t, const char *msg, const char *code) { + if (t->server == NULL) return; + ScrStr *m = scr_str_new(msg, strlen(msg)); + ScrError *e = scr_error_new(SCR_ERR_ERROR, m); + scr_str_release(m); + scr_error_set_code(e, code); + scr_net_server_fire_client_error(t->server, e, t->sock); + scr_error_release(e); +} + static void scr_tls_wrapped_failure(ScrTlsSock *t, int r) { if (r == MBEDTLS_ERR_SSL_CONN_EOF) { scr_net_sock_transport_eof(t->sock); @@ -1090,27 +1430,86 @@ static void scr_tls_wrapped_failure(ScrTlsSock *t, int r) { scr_net_sock_transport_error(t->sock, "read ECONNRESET"); return; } - const unsigned char *h = t->head; - bool record = t->head_len >= 3 && h[0] >= 20 && h[0] <= 23 && h[1] == 3; - if (t->head_len >= 5 && !record) { - static const char *const http[] = { "GET ", "POST ", "HEAD ", "PATCH", "OPTIO", - "DELET", "TRACE", "PUT ", NULL }; - const char *reason = "error:0A00010B:SSL routines:tls_validate_record_header:wrong version number"; - const char *code = "ERR_SSL_WRONG_VERSION_NUMBER"; - for (size_t i = 0; http[i] != NULL; i++) { - if (memcmp(h, http[i], strlen(http[i])) == 0) { - reason = "error:0A00009C:SSL routines:tls_validate_record_header:http request"; - code = "ERR_SSL_HTTP_REQUEST"; - } - } - if (memcmp(h, "CONNE", 5) == 0) { - reason = "error:0A00009B:SSL routines:tls_validate_record_header:https proxy request"; - code = "ERR_SSL_HTTPS_PROXY_REQUEST"; - } - scr_net_sock_transport_error_code(t->sock, reason, code); - return; + const char *msg, *code; + (void)scr_tls_server_reason(t, r, &msg, &code); + scr_net_sock_transport_error_code(t->sock, msg, code); +} + +/* A fatal alert a server sends a client without the certificate it + * requires: the client's error is OpenSSL's reason (without its + * per-process prefix and source location, SEMANTICS.md divergence 55) and + * Node's code. False for other alerts. */ +static bool scr_tls_alert_error(ScrTlsSock *t, unsigned char alert) { + if (alert == MBEDTLS_SSL_ALERT_MSG_CERT_REQUIRED) { + /* after the client's handshake: Node emits 'error' and the socket + * then ends when the server closes, with close(false) */ + scr_net_sock_transport_report(t->sock, + "error:0A00045C:SSL routines:ssl3_read_bytes:tlsv13 alert certificate required", + "ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED"); + t->alert_reported = true; + return true; } - scr_net_sock_transport_error(t->sock, "server TLS handshake failed"); + if (alert == MBEDTLS_SSL_ALERT_MSG_HANDSHAKE_FAILURE) { + scr_net_sock_transport_error_code(t->sock, + "error:0A000410:SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure", + "ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE"); + return true; + } + return false; +} + +/* A server that requires a client certificate (requestCert with + * rejectUnauthorized) runs its handshake a step at a time, so a client + * that sends none fails as soon as its empty Certificate is read, before + * the server's Finished in TLS 1.2, with OpenSSL's alert: certificate + * required in TLS 1.3, handshake failure in TLS 1.2. */ +static int scr_tls_handshake_requiring_cert(ScrTlsSock *t) { + while (!mbedtls_ssl_is_handshake_over(&t->ssl)) { + int r = mbedtls_ssl_handshake_step(&t->ssl); + if (r != 0) return r; + if (t->cert_missing) continue; + /* all ones until the client's Certificate is read */ + uint32_t verdict = mbedtls_ssl_get_verify_result(&t->ssl); + if (verdict == UINT32_MAX || !(verdict & MBEDTLS_X509_BADCERT_MISSING)) continue; + if (mbedtls_ssl_get_version_number(&t->ssl) != MBEDTLS_SSL_VERSION_TLS1_3) { + (void)mbedtls_ssl_send_alert_message(&t->ssl, MBEDTLS_SSL_ALERT_LEVEL_FATAL, + MBEDTLS_SSL_ALERT_MSG_HANDSHAKE_FAILURE); + return MBEDTLS_ERR_SSL_NO_CLIENT_CERTIFICATE; + } + /* TLS 1.3: the client has finished and reads under the server's + * application keys, which mbedTLS writes with only once the handshake + * is over, so the alert waits for that */ + t->cert_missing = true; + } + if (t->cert_missing) { + (void)mbedtls_ssl_send_alert_message(&t->ssl, MBEDTLS_SSL_ALERT_LEVEL_FATAL, + MBEDTLS_SSL_ALERT_MSG_CERT_REQUIRED); + return MBEDTLS_ERR_SSL_NO_CLIENT_CERTIFICATE; + } + return 0; +} + +static ScrTlsSock *scr_tls_sock_of(ScrNetSocket *s); + +/* The immediate that ends the hold: queued as the handshake finishes, + * before the established callbacks run, so it comes ahead of any + * immediate they queue. A socket destroyed by then has no fd, and what + * was held is dropped. */ +static void scr_tls_release_hold(ScrClosure *self) { + ScrNetSocket *s = scr_box_get_ref(self->caps[0]); /* +1 */ + ScrTlsSock *t = scr_tls_sock_of(s); + if (t != NULL && t->holding) { + t->holding = false; + if (t->held_len > t->held_off) (void)scr_tls_flush_held(t); + } + scr_net_sock_release(s); +} + +static void scr_tls_hold_until_immediate(ScrTlsSock *t) { + ScrClosure *cb = scr_closure_new((void *)&scr_tls_release_hold, 1); + cb->caps[0] = scr_box_new_obj(&scr_net_sock_retain_v, &scr_net_sock_release_v, NULL); + scr_box_set_ref(cb->caps[0], scr_net_sock_retain(t->sock)); + (void)scr_set_immediate(cb); } static int scr_tls_handshake(void *tctx) { @@ -1129,8 +1528,27 @@ static int scr_tls_handshake(void *tctx) { } if (t->sni_state == SCR_SNI_WAIT) return 0; /* the callback is deciding */ scr_tls_current = t; - int r = mbedtls_ssl_handshake(&t->ssl); + t->holding = true; + int r = t->srv != NULL && t->srv->request_cert && t->srv->reject_unauthorized + ? scr_tls_handshake_requiring_cert(t) + : mbedtls_ssl_handshake(&t->ssl); scr_tls_current = NULL; + if (r == 0) { + scr_tls_hold_until_immediate(t); + } else { + /* a step that did not finish the handshake sends what it wrote now */ + t->holding = false; + if (t->held_len > t->held_off) (void)scr_tls_flush_held(t); + } + if (r == 0 && t->srv != NULL && t->srv->request_cert && t->srv->reject_unauthorized && + !t->srv->standalone && mbedtls_ssl_get_verify_result(&t->ssl) != 0) { + /* a client certificate that fails verification: Node's server + * destroys the socket before 'secureConnection', and its + * 'tlsClientError' is the hang-up */ + scr_net_sock_transport_error(t->sock, NULL); + t->hang_up_pending = true; + return -1; + } if (r == 0) return 1; if (r == MBEDTLS_ERR_SSL_WANT_READ || r == MBEDTLS_ERR_SSL_WANT_WRITE) return 0; if (t->srv != NULL && t->wrapped) { @@ -1138,21 +1556,38 @@ static int scr_tls_handshake(void *tctx) { return -1; } if (t->srv != NULL) { - /* Node's server default: 'tlsClientError' with no listener destroys - * the socket without an error event — exactly what portless (no - * listener) sees. */ + /* the socket is destroyed without an error event, and the server's + * 'tlsClientError' gets the failure: now, before the socket closes, + * for an OpenSSL reason; once it closes for a hang-up, as Node + * emits ECONNRESET from the socket's 'close' */ + const char *msg, *code; + bool reason = scr_tls_server_reason(t, r, &msg, &code); + /* Node's socket still has its handle when an OpenSSL reason is + * emitted, so remoteAddress answers; the address is cached before + * the fd closes */ + if (reason) scr_str_release(scr_net_sock_remote_address(t->sock)); + scr_tls_drain_unread(t); scr_net_sock_transport_error(t->sock, NULL); + if (reason) scr_tls_client_error(t, msg, code); + else t->hang_up_pending = true; return -1; } char msg[160]; if (r == MBEDTLS_ERR_X509_CERT_VERIFY_FAILED) { scr_tls_verify_msg(t, msg, sizeof msg); - } else if (r == MBEDTLS_ERR_SSL_CONN_EOF || r == MBEDTLS_ERR_NET_CONN_RESET) { - /* The peer closed or reset the connection mid-handshake: Node's - * ECONNRESET for tls.connect and https.request alike. */ + } else if (r == MBEDTLS_ERR_SSL_CONN_EOF) { + /* The peer closed the connection mid-handshake: Node's socket ends, + * then fails with ECONNRESET, for tls.connect and https.request + * alike. */ + scr_net_sock_transport_ended(t->sock); + if (scr_exc_pending()) return -1; scr_net_sock_transport_error( t->sock, "Client network socket disconnected before secure TLS connection was established"); return -1; + } else if (r == MBEDTLS_ERR_NET_CONN_RESET) { + /* a reset is the read's own error, as in Node */ + scr_net_sock_transport_error(t->sock, "read ECONNRESET"); + return -1; } else if (r == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE && t->ssl.MBEDTLS_PRIVATE(in_msg) != NULL && t->ssl.MBEDTLS_PRIVATE(in_msg)[1] == MBEDTLS_SSL_ALERT_MSG_NO_APPLICATION_PROTOCOL) { @@ -1167,6 +1602,9 @@ static int scr_tls_handshake(void *tctx) { t->ssl.MBEDTLS_PRIVATE(in_msg)[1] == MBEDTLS_SSL_ALERT_MSG_PROTOCOL_VERSION) { /* The server accepts none of the offered versions. */ snprintf(msg, sizeof msg, "error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version"); + } else if (r == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE && t->ssl.MBEDTLS_PRIVATE(in_msg) != NULL && + scr_tls_alert_error(t, t->ssl.MBEDTLS_PRIVATE(in_msg)[1])) { + return -1; } else { /* non-verify handshake failures: honest but not Node's exact openssl * string (SEMANTICS.md divergence 55) */ @@ -1215,6 +1653,9 @@ static void scr_tls_shutdown_write(void *tctx) { ScrTlsSock *t = (ScrTlsSock *)tctx; if (t->notify_sent) return; t->notify_sent = true; + /* end() sends what is held, then the close_notify, ahead of the FIN */ + t->holding = false; + if (t->held_len > t->held_off) (void)scr_tls_flush_held(t); (void)mbedtls_ssl_close_notify(&t->ssl); /* best effort; EAGAIN drops it */ } @@ -1227,9 +1668,18 @@ static void scr_tls_free(void *tctx) { scr_net_ls_drop(&t->secure_ls); free(t->hello); scr_secure_ctx_release(t->sni_ctx); + mbedtls_x509_crt_free(&t->served_chain); + if (t->server) scr_net_server_release(t->server); + free(t->held); free(t); } +/* The fd is writable again: the rest of what was held goes out. */ +static void scr_tls_drain(void *tctx) { + ScrTlsSock *t = (ScrTlsSock *)tctx; + if (!t->holding && t->held_len > t->held_off) (void)scr_tls_flush_held(t); +} + /* The 'secure' event: after a client's secureConnect listeners, as Node * emits it. */ static void scr_tls_after_established(void *tctx) { @@ -1247,6 +1697,10 @@ static void scr_tls_settle(void *tctx) { t->settled = true; scr_net_ls_drop(&t->secure_ls); scr_net_ls_drop(&t->session_ls); + if (t->hang_up_pending) { + t->hang_up_pending = false; + scr_tls_client_error(t, "socket hang up", "ECONNRESET"); + } } static const ScrNetTransportOps SCR_TLS_OPS = { @@ -1259,6 +1713,7 @@ static const ScrNetTransportOps SCR_TLS_OPS = { .free = scr_tls_free, .settle = scr_tls_settle, .after_established = scr_tls_after_established, + .drain = scr_tls_drain, }; /* ── the server surface ──────────────────────────────────────────────── */ @@ -1272,8 +1727,11 @@ static void scr_tls_srv_on_conn(void *ctx, ScrNetSocket *sock) { if (!t) scr_tls_oom(); mbedtls_ssl_init(&t->ssl); if (mbedtls_ssl_setup(&t->ssl, &srv->conf) != 0) scr_tls_oom(); + if (srv->request_cert) mbedtls_ssl_set_verify(&t->ssl, &scr_tls_vrfy_cb, t); t->sock = sock; t->srv = scr_tls_srv_retain(srv); + ScrNetServer *server = scr_net_sock_server(sock); + t->server = server != NULL ? scr_net_server_retain(server) : NULL; if (srv->sni_cb != NULL || srv->max_tls12) t->sni_state = SCR_SNI_COLLECT; /* pre-parse the hello */ mbedtls_ssl_set_bio(&t->ssl, t, scr_tls_bio_send, scr_tls_bio_recv, NULL); scr_net_sock_set_transport(sock, &SCR_TLS_OPS, t); @@ -1285,8 +1743,10 @@ static ScrNetServer *scr_tls_create_server_alpn(const char *cert, size_t cert_le ScrNetConnFn fn, char **alpn /*moves, nullable*/, int min_version, int max_version, ScrClosure *sni_cb /*moves, nullable*/, - void *sni_answer_fn) { + void *sni_answer_fn, + const ScrTlsClientAuth *auth /*nullable*/) { ScrTlsSrv *srv = scr_tls_srv_new(cert, cert_len, key, key_len, false); + scr_tls_srv_client_auth(srv, auth); if (sni_cb != NULL) { srv->sni_cb = sni_cb; /* moves; released with srv */ srv->sni_answer_fn = sni_answer_fn; @@ -1319,7 +1779,7 @@ ScrNetServer *scr_tls_create_server(const char *cert, size_t cert_len, const cha size_t key_len, ScrClosure *handler /*moves, nullable*/, ScrNetConnFn fn) { return scr_tls_create_server_alpn(cert, cert_len, key, key_len, handler, fn, NULL, 12, 13, NULL, - NULL); + NULL, NULL); } /* tls.createServer({ cert, key, SNICallback }): the pre-parse machinery @@ -1332,14 +1792,15 @@ ScrNetServer *scr_tls_create_server_sni(const char *cert, size_t cert_len, const ScrNetConnFn fn, ScrClosure *sni_cb /*moves, nullable*/, void *sni_answer_fn) { return scr_tls_create_server_alpn(cert, cert_len, key, key_len, handler, fn, NULL, 12, 13, sni_cb, - sni_answer_fn); + sni_answer_fn, NULL); } -ScrNetServer *scr_https_create_server(const char *cert, size_t cert_len, const char *key, - size_t key_len, ScrClosure *handler /*moves*/, - ScrHttpReqFn fn) { +static ScrNetServer *scr_https_create_server_auth(const char *cert, size_t cert_len, const char *key, + size_t key_len, ScrClosure *handler /*moves*/, + ScrHttpReqFn fn, const ScrTlsClientAuth *auth /*nullable*/) { ScrNetServer *s = scr_http_create_server(handler, fn); ScrTlsSrv *srv = scr_tls_srv_new(cert, cert_len, key, key_len, true /* ALPN http/1.1 */); + scr_tls_srv_client_auth(srv, auth); /* wrap the parser hook: TLS first, parser at establishment */ scr_net_server_get_native_conn(s, &srv->inner_conn, &srv->inner_ctx, &srv->inner_ctx_free); scr_net_server_set_native_conn(s, &scr_tls_srv_on_conn, srv, &scr_tls_srv_release_v); @@ -1347,6 +1808,12 @@ ScrNetServer *scr_https_create_server(const char *cert, size_t cert_len, const c return s; } +ScrNetServer *scr_https_create_server(const char *cert, size_t cert_len, const char *key, + size_t key_len, ScrClosure *handler /*moves*/, + ScrHttpReqFn fn) { + return scr_https_create_server_auth(cert, cert_len, key, key_len, handler, fn, NULL); +} + /* The SNI-callback form of the allowHTTP1 compatibility server: the same * handler-less https server, plus the pre-parse machinery (design note * atop the SNI section). A NULL sni_cb — the conditional spread's @@ -1432,8 +1899,8 @@ static ScrStr *scr_tls_pool_key(ScrStr *host, bool reject_unauthorized, const ch uint64_t h = 1469598103934665603ull; for (size_t i = 0; i < ca_len; i++) h = (h ^ (unsigned char)ca[i]) * 1099511628211ull; char buf[64]; - int n = snprintf(buf, sizeof buf, "%d:%zu:%016llx:", reject_unauthorized ? 1 : 0, ca_len, - (unsigned long long)h); + int n = snprintf(buf, sizeof buf, "%d:%s%zu:%016llx:", reject_unauthorized ? 1 : 0, + ca != NULL ? "" : "-", ca_len, (unsigned long long)h); ScrJsonBuf b; scr_jb_init(&b); scr_jb_write(&b, buf, (size_t)n); @@ -1505,12 +1972,22 @@ static int scr_tls_tunnel_bio_recv(void *ctx, unsigned char *buf, size_t len) { return scr_tls_tunnel_read((ScrTlsTunnel *)ctx, buf, len); } +/* A tunnel leg's handshake has finished. The tunnel runs no established + * callbacks before it writes the CONNECT request or the request itself, so + * the last flight goes out now instead of waiting for the immediate that + * ends a socket's hold (which does not see the tunnel's legs). */ +static void scr_tls_tunnel_end_hold(ScrTlsSock *t) { + t->holding = false; + if (t->held_len > t->held_off) (void)scr_tls_flush_held(t); +} + static int scr_tls_tunnel_handshake(void *tctx) { ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; ScrNetSocket *sock = tun->outer->sock; if (tun->stage == SCR_TUN_OUTER) { int r = scr_tls_handshake(tun->outer); if (r <= 0) return r; + scr_tls_tunnel_end_hold(tun->outer); tun->outer->established = true; tun->stage = SCR_TUN_SEND; } @@ -1579,6 +2056,7 @@ static int scr_tls_tunnel_handshake(void *tctx) { if (tun->stage == SCR_TUN_INNER) { int r = scr_tls_handshake(tun->inner); if (r <= 0) return r; + scr_tls_tunnel_end_hold(tun->inner); tun->stage = SCR_TUN_UP; } return 1; @@ -1625,12 +2103,16 @@ static void scr_tls_tunnel_free(void *tctx) { free(tun); } +/* The fd is writable again: the rest of what the outer leg held. */ +static void scr_tls_tunnel_drain(void *tctx) { scr_tls_drain(((ScrTlsTunnel *)tctx)->outer); } + static const ScrNetTransportOps SCR_TLS_TUNNEL_OPS = { .xread = scr_tls_tunnel_xread, .xwrite = scr_tls_tunnel_xwrite, .handshake = scr_tls_tunnel_handshake, .on_established = scr_tls_tunnel_on_established, .pending = scr_tls_tunnel_pending, + .drain = scr_tls_tunnel_drain, .shutdown_write = scr_tls_tunnel_shutdown_write, .free = scr_tls_tunnel_free, }; @@ -1676,7 +2158,7 @@ ScrHttpClientReq *scr_https_request(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn) { ScrStr *servername = scr_https_take_servername(); ScrTlsCli *cli = scr_tls_cli_for(host, reject_unauthorized, ca, ca_len, servername); @@ -1717,7 +2199,7 @@ ScrHttpClientReq *scr_https_request_agent(ScrStr *host /*borrowed*/, double port ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, const ScrDyn *agent /*borrowed*/, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn) { ScrStr *servername = scr_https_take_servername(); @@ -1750,8 +2232,8 @@ static void scr_tls_opt_fence(const char *api, const char *key, const char *hint scr_throw_error(SCR_ERR_ERROR, scr_jb_finish(&b)); } -/* JS truthiness over the dyn kinds — Node reads requestCert / - * rejectUnauthorized through `if (options.x)`. */ +/* JS truthiness over the dyn kinds — Node reads requestCert (and a + * standalone TLSSocket's rejectUnauthorized) through `if (options.x)`. */ static bool scr_tls_dyn_truthy(const ScrDyn *v) { switch (v->kind) { case SCR_DYN_UNDEF: @@ -1763,6 +2245,12 @@ static bool scr_tls_dyn_truthy(const ScrDyn *v) { } } +/* tls.Server and tls.connect read rejectUnauthorized as + * `options.rejectUnauthorized !== false`: null, 0 and "" keep it on. */ +static bool scr_tls_dyn_not_false(const ScrDyn *v) { + return v->kind != SCR_DYN_BOOL || v->v.b; +} + /* PEM material from a runtime option value: a string, a Buffer/Uint8Array, * or an array of those. `concat` (the client `ca` bundle) joins array * entries; cert/key arrays accept exactly one entry (one identity per @@ -1770,6 +2258,14 @@ static bool scr_tls_dyn_truthy(const ScrDyn *v) { * the exception pending. `what` names the fence ("a tls.createServer * 'cert' option"). */ static ScrBytes *scr_tls_pem_dyn(const ScrDyn *v, const char *what, bool concat) { + if (v->kind == SCR_DYN_TYPED_REF) { + /* a typed array (an array literal in a typed record): its entries */ + ScrDyn *plain = scr_dyn_typed_ref_materialize(v); + if (plain == NULL) return NULL; + ScrBytes *b = scr_tls_pem_dyn(plain, what, concat); + scr_dyn_release(plain); + return b; + } if (v->kind == SCR_DYN_STR) { ScrBytes *b = scr_bytes_new(SCR_BYTES_U8, (double)v->v.str->len); memcpy(b->data, v->v.str->data, v->v.str->len); @@ -1820,12 +2316,40 @@ static ScrBytes *scr_tls_pem_dyn(const ScrDyn *v, const char *what, bool concat) return NULL; } +/* A `ca` option from a runtime value: NULL for none (an empty string, + * which Node reads as no ca), empty bytes for an empty Buffer or array + * (an empty trust store), else the PEM bundle (+1). False with the + * exception pending. */ +static bool scr_tls_ca_dyn(const ScrDyn *v, const char *what, ScrBytes **out) { + *out = NULL; + ScrDyn *plain = v->kind == SCR_DYN_TYPED_REF ? scr_dyn_typed_ref_materialize(v) : NULL; + if (v->kind == SCR_DYN_TYPED_REF && plain == NULL) return false; + const ScrDyn *ca = plain != NULL ? plain : v; + bool ok = true; + if (ca->kind == SCR_DYN_ARR && ca->v.arr.len == 0) { + *out = scr_bytes_new(SCR_BYTES_U8, 0); + } else if (ca->kind != SCR_DYN_STR || ca->v.str->len > 0) { + *out = scr_tls_pem_dyn(ca, what, true); + ok = *out != NULL; + } + scr_dyn_release(plain); + return ok; +} + +/* A client's ca for scr_tls_cli_new2: NULL for none, and never NULL for a + * given ca, even an empty one. */ +static const char *scr_tls_ca_data(const ScrBytes *ca) { + if (ca == NULL) return NULL; + return ca->data != NULL ? (const char *)ca->data : ""; +} + /* https.request/get with a runtime options record: the http walk, then * the TLS options the literal https path lowers (rejectUnauthorized, ca, - * servername). Other tls.connect options fence when they carry a value. */ + * servername) and a client certificate (cert and key). Other tls.connect + * options fence when they carry a value. */ static const char *const SCR_HTTPS_REQ_FENCED_OPTIONS[] = { - "ALPNProtocols", "cert", "checkServerIdentity", "ciphers", "crl", "dhparam", "ecdhCurve", - "honorCipherOrder", "key", "maxVersion", "minVersion", "passphrase", "pfx", "secureContext", + "ALPNProtocols", "checkServerIdentity", "ciphers", "crl", "dhparam", "ecdhCurve", + "honorCipherOrder", "maxVersion", "minVersion", "passphrase", "pfx", "secureContext", "secureOptions", "secureProtocol", "session", "sessionIdContext", "sigalgs", NULL, }; @@ -1840,6 +2364,7 @@ ScrHttpClientReq *scr_https_request_dyn(const ScrDyn *opts /*borrowed*/, bool au const ScrDyn *rec = o.view != NULL ? o.view : opts; bool reject = true; ScrBytes *ca = NULL; + ScrBytes *own_cert = NULL, *own_key = NULL; ScrStr *servername = NULL; bool ok = true; if (rec != NULL && rec->kind == SCR_DYN_OBJ) { @@ -1848,7 +2373,7 @@ ScrHttpClientReq *scr_https_request_dyn(const ScrDyn *opts /*borrowed*/, bool au const ScrDyn *v = scr_dyn_obj_get(rec, key, strlen(key)); if (v != NULL && scr_tls_dyn_truthy(v)) { scr_http_req_opts_fence("https.request", key, - "rejectUnauthorized, ca, and servername are the supported TLS options"); + "rejectUnauthorized, ca, servername, cert, and key are the supported TLS options"); ok = false; } } @@ -1856,8 +2381,22 @@ ScrHttpClientReq *scr_https_request_dyn(const ScrDyn *opts /*borrowed*/, bool au if (r != NULL && r->kind == SCR_DYN_BOOL && !r->v.b) reject = false; const ScrDyn *c = ok ? scr_dyn_obj_get(rec, "ca", 2) : NULL; if (c != NULL && c->kind != SCR_DYN_UNDEF && c->kind != SCR_DYN_NULL) { - ca = scr_tls_pem_dyn(c, "an https.request 'ca' option", true); - if (ca == NULL) ok = false; + ok = scr_tls_ca_dyn(c, "an https.request 'ca' option", &ca); + } + const ScrDyn *oc = ok ? scr_dyn_obj_get(rec, "cert", 4) : NULL; + if (oc != NULL && oc->kind != SCR_DYN_UNDEF && oc->kind != SCR_DYN_NULL) { + own_cert = scr_tls_pem_dyn(oc, "an https.request 'cert' option", false); + if (own_cert == NULL) ok = false; + } + const ScrDyn *ok_ = ok ? scr_dyn_obj_get(rec, "key", 3) : NULL; + if (ok_ != NULL && ok_->kind != SCR_DYN_UNDEF && ok_->kind != SCR_DYN_NULL) { + own_key = scr_tls_pem_dyn(ok_, "an https.request 'key' option", false); + if (own_key == NULL) ok = false; + } + if (ok && (own_cert == NULL) != (own_key == NULL)) { + scr_http_req_opts_fence("https.request", own_cert != NULL ? "cert" : "key", + "a client certificate lowers with both cert and key"); + ok = false; } const ScrDyn *sn = ok ? scr_dyn_obj_get(rec, "servername", 10) : NULL; if (sn != NULL && sn->kind != SCR_DYN_UNDEF && sn->kind != SCR_DYN_NULL) { @@ -1871,14 +2410,32 @@ ScrHttpClientReq *scr_https_request_dyn(const ScrDyn *opts /*borrowed*/, bool au } if (!ok) { if (ca) scr_bytes_release(ca); + scr_bytes_release(own_cert); + scr_bytes_release(own_key); scr_http_req_opts_free(&o); if (cb) scr_closure_release(cb); return NULL; } - const char *ca_data = ca ? (const char *)ca->data : NULL; + const char *ca_data = scr_tls_ca_data(ca); size_t ca_len = ca ? ca->len : 0; ScrTlsCli *cli = scr_tls_cli_for(o.host, reject, ca_data, ca_len, servername); ScrStr *key = scr_tls_pool_key(o.host, reject, ca_data, ca_len, servername); + if (own_cert != NULL) { + /* Node's agent keys pooled sockets by the client certificate too */ + scr_tls_cli_set_own(cli, own_cert, own_key); + uint64_t h = 1469598103934665603ull; + for (size_t i = 0; i < own_cert->len; i++) h = (h ^ own_cert->data[i]) * 1099511628211ull; + for (size_t i = 0; i < own_key->len; i++) h = (h ^ own_key->data[i]) * 1099511628211ull; + char suffix[24]; + int n = snprintf(suffix, sizeof suffix, ":c%016llx", (unsigned long long)h); + ScrStr *tail = scr_str_new(suffix, (size_t)n); + ScrStr *keyed = scr_str_concat(key, tail); + scr_str_release(tail); + scr_str_release(key); + key = keyed; + scr_bytes_release(own_cert); + scr_bytes_release(own_key); + } scr_str_release(servername); ScrHttpClientReq *req = scr_http_req_opts_send(&o, auto_end, cb, fn, add_abort, &scr_tls_cli_wrap, cli, key); @@ -1890,13 +2447,17 @@ ScrHttpClientReq *scr_https_request_dyn(const ScrDyn *opts /*borrowed*/, bool au /* The literal path's runtime-valued cert/key extraction (the compiler's * tls.pemDyn libCall): `what` arrives precomposed. undefined throws — the - * literal walk required the member's presence. */ + * literal walk required the member's presence — and so does an empty + * string, which Node reads as no material while empty bytes here are an + * empty ca. */ ScrBytes *scr_tls_pem_from_dyn(const ScrDyn *v, const char *what) { - if (v->kind == SCR_DYN_UNDEF || v->kind == SCR_DYN_NULL) { + bool empty = v->kind == SCR_DYN_STR && v->v.str->len == 0; + if (v->kind == SCR_DYN_UNDEF || v->kind == SCR_DYN_NULL || empty) { ScrJsonBuf b; scr_jb_init(&b); scr_jb_puts(&b, what); - scr_jb_puts(&b, " holding undefined has no static lowering — pass PEM material (a string or a Buffer)"); + scr_jb_puts(&b, empty ? " holding an empty string" : " holding undefined"); + scr_jb_puts(&b, " has no static lowering — pass PEM material (a string or a Buffer)"); scr_throw_error(SCR_ERR_ERROR, scr_jb_finish(&b)); return NULL; } @@ -2014,9 +2575,22 @@ static bool scr_tls_opts_validate(const ScrDyn *opts) { /* `server_ok`: tls.createServer reads ALPNProtocols, minVersion and * maxVersion itself; the walk's other callers keep their fences. */ static bool scr_tls_srv_opts_walk_ex(const ScrDyn *opts, const char *api, ScrBytes **cert_out, - ScrBytes **key_out, bool server_ok) { + ScrBytes **key_out, bool server_ok, ScrTlsClientAuth *auth) { + if (opts != NULL && opts->kind == SCR_DYN_TYPED_REF) { + /* a typed record (an options binding): its fields, as a plain object */ + ScrDyn *plain = scr_dyn_typed_ref_materialize(opts); + if (plain == NULL) { + *cert_out = NULL; + *key_out = NULL; + return false; + } + bool ok = scr_tls_srv_opts_walk_ex(plain, api, cert_out, key_out, server_ok, auth); + scr_dyn_release(plain); + return ok; + } *cert_out = NULL; *key_out = NULL; + if (auth != NULL) *auth = (ScrTlsClientAuth){.request = false, .reject = true, .ca = NULL}; if (opts == NULL || opts->kind != SCR_DYN_OBJ) { scr_dyn_arg_type_fail("options", "of type object", opts ? opts : scr_dyn_undefined()); return false; @@ -2038,20 +2612,32 @@ static bool scr_tls_srv_opts_walk_ex(const ScrDyn *opts, const char *api, ScrByt continue; } if (strcmp(e->key, "ca") == 0) { - /* the server's `ca` verifies CLIENT certificates — inert without - * requestCert (which fences below), so Node-observably droppable */ + /* the server's `ca` verifies client certificates and completes the + * server's own chain (scr_tls_srv_client_auth); a walk without + * client certificates drops it */ + if (auth != NULL) { + char what[96]; + snprintf(what, sizeof what, "a %s 'ca' option", api); + scr_bytes_release(auth->ca); + if (!scr_tls_ca_dyn(v, what, &auth->ca)) ok = false; + } continue; } if (strcmp(e->key, "requestCert") == 0) { + if (auth != NULL) { + auth->request = scr_tls_dyn_truthy(v); + continue; + } if (scr_tls_dyn_truthy(v)) { scr_tls_opt_fence(api, "requestCert", - "client-certificate handshakes are not modeled (SEMANTICS.md divergence 55) — requestCert stays at Node's false default"); + "requestCert lowers on tls.createServer, https.createServer and new tls.TLSSocket"); ok = false; } continue; } if (strcmp(e->key, "rejectUnauthorized") == 0) { - continue; /* meaningful only with requestCert, which fences */ + if (auth != NULL) auth->reject = scr_tls_dyn_not_false(v); + continue; /* without client certificates it has no effect */ } if (server_ok && (strcmp(e->key, "ALPNProtocols") == 0 || strcmp(e->key, "minVersion") == 0 || strcmp(e->key, "maxVersion") == 0)) @@ -2085,13 +2671,17 @@ static bool scr_tls_srv_opts_walk_ex(const ScrDyn *opts, const char *api, ScrByt scr_bytes_release(*key_out); *cert_out = NULL; *key_out = NULL; + if (auth != NULL) { + scr_bytes_release(auth->ca); + auth->ca = NULL; + } } return ok; } bool scr_tls_srv_opts_walk(const ScrDyn *opts, const char *api, ScrBytes **cert_out, ScrBytes **key_out) { - return scr_tls_srv_opts_walk_ex(opts, api, cert_out, key_out, false); + return scr_tls_srv_opts_walk_ex(opts, api, cert_out, key_out, false, NULL); } /* createSecureContext over a RUNTIME options record: Node's typed @@ -2101,9 +2691,23 @@ bool scr_tls_srv_opts_walk(const ScrDyn *opts, const char *api, ScrBytes **cert_ * +1, or NULL with the exception pending. */ ScrSecureCtx *scr_tls_create_secure_context_dyn(const ScrDyn *opts /*borrowed*/) { ScrBytes *cert, *key; - if (!scr_tls_srv_opts_walk(opts, "tls.createSecureContext", &cert, &key)) return NULL; + /* requestCert and rejectUnauthorized are no context options: Node's + * createSecureContext ignores them */ + ScrTlsClientAuth auth; + if (!scr_tls_srv_opts_walk_ex(opts, "tls.createSecureContext", &cert, &key, false, &auth)) return NULL; ScrSecureCtx *c = scr_tls_create_secure_context((const char *)cert->data, cert->len, (const char *)key->data, key->len); + if (auth.ca != NULL) { + /* kept for sockets created from the context (a TLSSocket's + * secureContext); an SNI answer's chain comes from the server's + * context in OpenSSL, so the answer's ca is not used there */ + if (auth.ca->len > 0) { + int r = scr_tls_parse_crt(&c->ca, (const char *)auth.ca->data, auth.ca->len); + if (r != 0) scr_tls_bad_material("ca certificate", r); + } + c->has_ca = true; + scr_bytes_release(auth.ca); + } scr_bytes_release(cert); scr_bytes_release(key); return c; @@ -2142,7 +2746,8 @@ ScrNetServer *scr_tls_create_server_dyn(const ScrDyn *opts /*borrowed*/, return NULL; } ScrBytes *cert, *key; - if (!scr_tls_srv_opts_walk_ex(opts, "tls.createServer", &cert, &key, true)) { + ScrTlsClientAuth auth; + if (!scr_tls_srv_opts_walk_ex(opts, "tls.createServer", &cert, &key, true, &auth)) { scr_closure_release(handler); return NULL; } @@ -2153,12 +2758,14 @@ ScrNetServer *scr_tls_create_server_dyn(const ScrDyn *opts /*borrowed*/, if (protocols != NULL && !scr_tls_alpn_dyn(protocols, &alpn)) { scr_bytes_release(cert); scr_bytes_release(key); + scr_bytes_release(auth.ca); scr_closure_release(handler); return NULL; } ScrNetServer *s = scr_tls_create_server_alpn((const char *)cert->data, cert->len, (const char *)key->data, key->len, handler, fn, - alpn, min_version, max_version, NULL, NULL); + alpn, min_version, max_version, NULL, NULL, &auth); + scr_bytes_release(auth.ca); scr_bytes_release(cert); scr_bytes_release(key); return s; @@ -2189,18 +2796,148 @@ ScrNetServer *scr_https_create_server_dyn(const ScrDyn *opts /*borrowed*/, } } ScrBytes *cert, *key; - if (!scr_tls_srv_opts_walk(opts, "https.createServer", &cert, &key)) { + ScrTlsClientAuth auth; + if (!scr_tls_srv_opts_walk_ex(opts, "https.createServer", &cert, &key, false, &auth)) { scr_closure_release(handler); return NULL; } - ScrNetServer *s = scr_https_create_server((const char *)cert->data, cert->len, - (const char *)key->data, key->len, handler, fn); + ScrNetServer *s = scr_https_create_server_auth((const char *)cert->data, cert->len, + (const char *)key->data, key->len, handler, fn, &auth); + scr_bytes_release(auth.ca); if (has_timeout_buffer) scr_net_server_timeout_set(s, 4, timeout_buffer); scr_bytes_release(cert); scr_bytes_release(key); return s; } +/* ── server.addContext and server.setSecureContext ───────────────────── */ + +/* Node's addContext pattern: the name with '*' as [^.]* (any run within a + * label, empty too), matched without regard to case. */ +static bool scr_tls_name_match(const char *pattern, const unsigned char *name, size_t len) { + if (*pattern == '\0') return len == 0; + if (*pattern == '*') { + for (size_t k = 0;; k++) { + if (scr_tls_name_match(pattern + 1, name + k, len - k)) return true; + if (k == len || name[k] == '.') return false; + } + } + return len > 0 && (unsigned char)tolower(name[0]) == (unsigned char)*pattern && + scr_tls_name_match(pattern + 1, name + 1, len - 1); +} + +/* The certificate a server handshake serves when the server has contexts + * (mbedTLS's certificate callback, after the SNI name is read): the + * newest addContext entry whose pattern matches the name, else the + * setSecureContext default, else the server's own. An SNICallback answer + * (or a server with an SNICallback, whose contexts Node does not read) + * keeps its own path. OpenSSL completes the served chain from the store + * of the connection's context, the server's current default, and verifies + * client certificates against the served context's. */ +static int scr_tls_f_cert(mbedtls_ssl_context *ssl) { + ScrTlsSock *t = scr_tls_current; + if (t == NULL || t->srv == NULL || t->sni_ctx != NULL) return 0; + ScrTlsSrv *s = t->srv; + ScrSecureCtx *pick = NULL; + if (s->sni_cb == NULL) { + size_t len = 0; + const unsigned char *name = mbedtls_ssl_get_hs_sni(ssl, &len); + for (size_t i = s->ncontexts; name != NULL && i-- > 0;) { + if (scr_tls_name_match(s->contexts[i].pattern, name, len)) { + pick = s->contexts[i].ctx; + break; + } + } + } + if (pick == NULL) pick = s->default_ctx; + if (pick == NULL) return 0; + t->sni_ctx = scr_secure_ctx_retain(pick); + const mbedtls_x509_crt *store = s->default_ctx != NULL ? &s->default_ctx->ca : &s->ca; + mbedtls_x509_crt *own = &pick->cert; + if (store->raw.len > 0 && pick->cert.next == NULL) { + scr_tls_chained_copy(&t->served_chain, &pick->cert, store); + own = &t->served_chain; + } + if (s->request_cert) { + t->ctx_verifies = true; + mbedtls_ssl_set_hs_ca_chain(ssl, pick->has_ca ? &pick->ca : scr_tls_system_ca(), NULL); + } + return mbedtls_ssl_set_hs_own_cert(ssl, own, &pick->pk); +} + +/* The TLS config of a tls or https server, or NULL with the fence thrown. */ +static ScrTlsSrv *scr_tls_srv_of_server(ScrNetServer *server, const char *what) { + ScrNetNativeConnFn fn = NULL; + void *ctx = NULL; + void (*ctx_free)(void *) = NULL; + scr_net_server_get_native_conn(server, &fn, &ctx, &ctx_free); + if (fn == &scr_tls_srv_on_conn && ctx != NULL) return (ScrTlsSrv *)ctx; + char msg[160]; + int n = snprintf(msg, sizeof msg, "%s on a server that is not a tls or https server is not supported yet", what); + scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)(n < (int)sizeof msg ? n : (int)sizeof msg - 1), "SC2020"); + return NULL; +} + +/* server.addContext(servername, context): `context` is a SecureContext + * (borrowed, nullable) or the options createSecureContext reads. Node + * builds a RegExp from the name, so a name with regular-expression groups + * or alternation throws the fence. */ +void scr_tls_server_add_context(ScrNetServer *server, const ScrDyn *servername, + const ScrDyn *options /*nullable*/, ScrSecureCtx *ctx /*nullable*/) { + ScrTlsSrv *s = scr_tls_srv_of_server(server, "server.addContext"); + if (s == NULL) return; + if (!scr_tls_dyn_truthy(servername)) { + static const char msg[] = "\"servername\" is required parameter for Server.addContext"; + scr_throw_error_msg_code(SCR_ERR_ERROR, msg, sizeof msg - 1, "ERR_TLS_REQUIRED_SERVER_NAME"); + return; + } + if (servername->kind != SCR_DYN_STR) { + scr_tls_opt_fence("server.addContext", "servername", "the servername lowers as a string"); + return; + } + const ScrStr *name = servername->v.str; + for (size_t i = 0; i < name->len; i++) { + if (name->data[i] == '(' || name->data[i] == ')' || name->data[i] == '|') { + scr_tls_opt_fence("server.addContext", "servername", + "a servername with regular-expression groups or alternation has no lowering"); + return; + } + /* the RegExp's case folding beyond ASCII differs from tolower's */ + if ((unsigned char)name->data[i] >= 0x80) { + scr_tls_opt_fence("server.addContext", "servername", "a servername outside ASCII has no lowering"); + return; + } + } + ScrSecureCtx *c = ctx != NULL ? scr_secure_ctx_retain(ctx) : scr_tls_create_secure_context_dyn(options); + if (c == NULL) return; + char *pattern = malloc(name->len + 1); + struct ScrTlsNamedCtx *grown = realloc(s->contexts, (s->ncontexts + 1) * sizeof *s->contexts); + if (pattern == NULL || grown == NULL) scr_tls_oom(); + for (size_t i = 0; i < name->len; i++) pattern[i] = (char)tolower((unsigned char)name->data[i]); + pattern[name->len] = '\0'; + s->contexts = grown; + s->contexts[s->ncontexts].pattern = pattern; + s->contexts[s->ncontexts].ctx = c; + s->ncontexts++; + mbedtls_ssl_conf_cert_cb(&s->conf, &scr_tls_f_cert); +} + +/* server.setSecureContext(options): new connections serve a context made + * from `options`; open ones keep theirs. */ +void scr_tls_server_set_secure_context(ScrNetServer *server, const ScrDyn *options) { + ScrTlsSrv *s = scr_tls_srv_of_server(server, "server.setSecureContext"); + if (s == NULL) return; + if (options == NULL || (options->kind != SCR_DYN_OBJ && options->kind != SCR_DYN_TYPED_REF)) { + scr_dyn_arg_type_fail("options", "of type object", options != NULL ? options : scr_dyn_undefined()); + return; + } + ScrSecureCtx *c = scr_tls_create_secure_context_dyn(options); + if (c == NULL) return; + scr_secure_ctx_release(s->default_ctx); + s->default_ctx = c; + mbedtls_ssl_conf_cert_cb(&s->conf, &scr_tls_f_cert); +} + /* ── new tls.TLSSocket(socket, options) ───────────────────────────────── */ /* The server-mode wrap: a fresh TLS socket takes over raw's connection @@ -2226,7 +2963,7 @@ static bool scr_tls_wrap_socket_opts_check(const ScrDyn *record, const char *api if (strcmp(e->key, "requestCert") == 0) { if (!scr_tls_dyn_truthy(e->value)) continue; scr_tls_opt_fence(api, "requestCert", - "client-certificate handshakes are not modeled (SEMANTICS.md divergence 55) — requestCert stays at Node's false default"); + "requestCert lowers with cert and key options, not beside a secureContext"); return false; } if (strcmp(e->key, "SNICallback") == 0 || strcmp(e->key, "ALPNCallback") == 0) { @@ -2271,13 +3008,17 @@ ScrNetSocket *scr_tls_wrap_socket(ScrNetSocket *raw, const ScrDyn *opts /*borrow /* Node serves a secureContext as it is: the record's cert, key and * other context options are not read then. The socket's own options * still apply, so the ones without a lowering fence either way: - * requestCert asks for a client certificate this runtime cannot - * verify. */ + * requestCert lowers with cert and key, whose ca the walk reads. */ int min_version = 12, max_version = 13; ScrBytes *cert = NULL, *key = NULL; + ScrTlsClientAuth auth = {.request = false, .reject = false, .ca = NULL}; if (ctx == NULL) { if (!scr_tls_versions_dyn(record, &min_version, &max_version)) return NULL; - if (!scr_tls_srv_opts_walk_ex(record, api, &cert, &key, true)) return NULL; + if (!scr_tls_srv_opts_walk_ex(record, api, &cert, &key, true, &auth)) return NULL; + /* a standalone socket requires a certificate only for a truthy + * rejectUnauthorized; tls.Server's true default does not apply */ + const ScrDyn *reject = scr_dyn_obj_get(record, "rejectUnauthorized", 18); + auth.reject = reject != NULL && scr_tls_dyn_truthy(reject); } else if (!scr_tls_wrap_socket_opts_check(record, api)) { return NULL; } @@ -2286,6 +3027,7 @@ ScrNetSocket *scr_tls_wrap_socket(ScrNetSocket *raw, const ScrDyn *opts /*borrow if (protocols != NULL && protocols->kind != SCR_DYN_UNDEF && !scr_tls_alpn_dyn(protocols, &alpn)) { scr_bytes_release(cert); scr_bytes_release(key); + scr_bytes_release(auth.ca); return NULL; } ScrTlsSrv *srv = ctx != NULL ? scr_tls_srv_from_ctx(ctx) @@ -2293,6 +3035,9 @@ ScrNetSocket *scr_tls_wrap_socket(ScrNetSocket *raw, const ScrDyn *opts /*borrow (const char *)key->data, key->len, false); scr_bytes_release(cert); scr_bytes_release(key); + srv->standalone = true; + scr_tls_srv_client_auth(srv, ctx == NULL ? &auth : NULL); + scr_bytes_release(auth.ca); srv->alpn = alpn; /* moves */ if (alpn != NULL) mbedtls_ssl_conf_alpn_protocols(&srv->conf, (const char **)srv->alpn); if (!scr_tls_conf_versions(&srv->conf, min_version, max_version)) { @@ -2314,6 +3059,7 @@ ScrNetSocket *scr_tls_wrap_socket(ScrNetSocket *raw, const ScrDyn *opts /*borrow if (!ts) scr_tls_oom(); mbedtls_ssl_init(&ts->ssl); if (mbedtls_ssl_setup(&ts->ssl, &srv->conf) != 0) scr_tls_oom(); + if (srv->request_cert) mbedtls_ssl_set_verify(&ts->ssl, &scr_tls_vrfy_cb, ts); ts->sock = t; ts->srv = srv; /* moves */ ts->wrapped = true; @@ -2468,10 +3214,10 @@ static bool scr_tls_alpn_dyn(const ScrDyn *v, char ***out) { * throws (the server walk's stance). */ static const char *const SCR_TLS_CONNECT_FENCED_OPTIONS[] = { "allowHalfOpen", "autoSelectFamily", - "autoSelectFamilyAttemptTimeout", "cert", "certificateCompression", "checkServerIdentity", + "autoSelectFamilyAttemptTimeout", "certificateCompression", "checkServerIdentity", "ciphers", "clientCertEngine", "crl", "dhparam", "ecdhCurve", "enableTrace", "family", "fd", "hints", "highWaterMark", - "honorCipherOrder", "keepAlive", "keepAliveInitialDelay", "key", + "honorCipherOrder", "keepAlive", "keepAliveInitialDelay", "localAddress", "localPort", "lookup", "minDHSize", "noDelay", "onread", "passphrase", "path", "pfx", "privateKeyEngine", "privateKeyIdentifier", "pskCallback", "readable", "secureContext", "secureOptions", "secureProtocol", "session", @@ -2479,12 +3225,32 @@ static const char *const SCR_TLS_CONNECT_FENCED_OPTIONS[] = { "writable", NULL, }; +static ScrNetSocket *scr_tls_connect_plain(double port, ScrStr *host /*borrowed, nullable*/, + const ScrDyn *opts /*borrowed, nullable*/, + ScrClosure *cb /*moves, nullable*/); + ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable*/, const ScrDyn *opts /*borrowed, nullable*/, ScrClosure *cb /*moves, nullable*/) { + if (opts == NULL || opts->kind != SCR_DYN_TYPED_REF) return scr_tls_connect_plain(port, host, opts, cb); + /* a typed record (an options binding): its fields, as a plain object */ + ScrDyn *plain = scr_dyn_typed_ref_materialize(opts); + if (plain == NULL) { + scr_closure_release(cb); + return NULL; + } + ScrNetSocket *s = scr_tls_connect_plain(port, host, plain, cb); + scr_dyn_release(plain); + return s; +} + +static ScrNetSocket *scr_tls_connect_plain(double port, ScrStr *host /*borrowed, nullable*/, + const ScrDyn *opts /*borrowed, nullable*/, + ScrClosure *cb /*moves, nullable*/) { scr_tls_rng_init(); bool reject = true; ScrBytes *ca = NULL; + ScrBytes *own_cert = NULL, *own_key = NULL; /* the client certificate */ const ScrStr *servername = NULL; const ScrStr *opt_host = NULL; char **alpn = NULL; @@ -2540,13 +3306,20 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable continue; } if (strcmp(e->key, "rejectUnauthorized") == 0) { - reject = scr_tls_dyn_truthy(v); + reject = scr_tls_dyn_not_false(v); continue; } if (strcmp(e->key, "ca") == 0) { scr_bytes_release(ca); - ca = scr_tls_pem_dyn(v, "a tls.connect 'ca' option", true); - if (ca == NULL) ok = false; + if (!scr_tls_ca_dyn(v, "a tls.connect 'ca' option", &ca)) ok = false; + continue; + } + if (strcmp(e->key, "cert") == 0 || strcmp(e->key, "key") == 0) { + ScrBytes **slot = e->key[0] == 'c' ? &own_cert : &own_key; + scr_bytes_release(*slot); + *slot = scr_tls_pem_dyn(v, e->key[0] == 'c' ? "a tls.connect 'cert' option" : "a tls.connect 'key' option", + false); + if (*slot == NULL) ok = false; continue; } if (strcmp(e->key, "ALPNProtocols") == 0) { @@ -2607,6 +3380,11 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable timeout_ms = timeout_value->v.num; } } + if (ok && (own_cert == NULL) != (own_key == NULL)) { + scr_tls_opt_fence("tls.connect", own_cert != NULL ? "cert" : "key", + "a client certificate lowers with both cert and key"); + ok = false; + } if (ok && port < 0 && wrap == NULL) { scr_dyn_arg_type_fail("options.port", "of type number", scr_dyn_undefined()); ok = false; @@ -2616,6 +3394,8 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable if (!ok) { scr_tls_alpn_free(alpn); scr_bytes_release(ca); + scr_bytes_release(own_cert); + scr_bytes_release(own_key); scr_closure_release(cb); return NULL; } @@ -2639,6 +3419,8 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable if (s == NULL) { scr_tls_alpn_free(alpn); scr_bytes_release(ca); + scr_bytes_release(own_cert); + scr_bytes_release(own_key); scr_closure_release(cb); scr_str_release(localhost_fallback); scr_str_release(socket_host); @@ -2653,9 +3435,11 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable if (dial_addr) scr_str_release(dial_addr); } ScrTlsCli *cli = scr_tls_cli_new2(verify_host, reject, - ca != NULL ? (const char *)ca->data : NULL, - ca != NULL ? ca->len : 0, !reject); + scr_tls_ca_data(ca), ca != NULL ? ca->len : 0, !reject); if (servername != NULL) scr_tls_cli_set_servername(cli, servername); + if (own_cert != NULL) scr_tls_cli_set_own(cli, own_cert, own_key); + scr_bytes_release(own_cert); + scr_bytes_release(own_key); if (alpn != NULL) { cli->alpn = alpn; /* moves */ mbedtls_ssl_conf_alpn_protocols(&cli->conf, (const char **)cli->alpn); @@ -2705,7 +3489,7 @@ void scr_tls_server_wrap_h2(ScrNetServer *s, const char *cert, size_t cert_len, /* The h2-over-TLS CLIENT wrap (http2.connect with an https authority): * a client transport offering ALPN ["h2"] — SNI/verification against - * the authority host; `ca` replaces the trust anchors ("" = none) and + * the authority host; `ca` replaces the trust anchors (NULL = none) and * reject_unauthorized false records like tls.connect's. The session's * preface/SETTINGS ride the socket's write buffer until establishment. */ void scr_tls_h2_client_wrap(ScrNetSocket *sock, ScrStr *host /*borrowed*/, @@ -2731,6 +3515,20 @@ static const char *scr_tls_auth_code(ScrTlsSock *t) { return "UNABLE_TO_VERIFY_LEAF_SIGNATURE"; } +/* A server's authorizationError for the client certificate. OpenSSL + * keeps the last error its verification met: the validity period is + * checked last, the purpose (clientAuth) before it, the chain first. A + * client that sent none is UNABLE_TO_GET_ISSUER_CERT, as Node reports it. */ +static const char *scr_tls_client_cert_code(ScrTlsSock *t) { + uint32_t vr = mbedtls_ssl_get_verify_result(&t->ssl); + if (vr & MBEDTLS_X509_BADCERT_MISSING) return "UNABLE_TO_GET_ISSUER_CERT"; + if (t->vrfy_expired) return "CERT_HAS_EXPIRED"; + if (vr & (MBEDTLS_X509_BADCERT_EXT_KEY_USAGE | MBEDTLS_X509_BADCERT_KEY_USAGE)) return "INVALID_PURPOSE"; + if (t->vrfy_leaf_self) return "DEPTH_ZERO_SELF_SIGNED_CERT"; + if (t->vrfy_chain_self) return "SELF_SIGNED_CERT_IN_CHAIN"; + return "UNABLE_TO_VERIFY_LEAF_SIGNATURE"; +} + static ScrTlsSock *scr_tls_sock_of(ScrNetSocket *s) { return (ScrTlsSock *)scr_net_sock_transport_ctx_for(s, &SCR_TLS_OPS); } @@ -2775,22 +3573,107 @@ static void scr_tls_fire_session(ScrTlsSock *t) { /* The STATIC-lane TLSSocket members (the lowered socket surface). */ +/* Whether the socket verified its peer: a client against its trust + * anchors, a server that requested a client certificate against its. */ +static bool scr_tls_sock_verifies(const ScrTlsSock *t) { + return t->established && ((t->cli != NULL && t->cli->verify_recorded) || + (t->srv != NULL && t->srv->request_cert && !t->srv->standalone)); +} + bool scr_tls_sock_authorized(ScrNetSocket *s) { ScrTlsSock *t = scr_tls_sock_of(s); - return t != NULL && t->established && t->cli != NULL && t->cli->verify_recorded && - mbedtls_ssl_get_verify_result(&t->ssl) == 0; + return t != NULL && scr_tls_sock_verifies(t) && mbedtls_ssl_get_verify_result(&t->ssl) == 0; } ScrStr *scr_tls_sock_auth_error(ScrNetSocket *s) { ScrTlsSock *t = scr_tls_sock_of(s); - if (t == NULL || !t->established || t->cli == NULL || !t->cli->verify_recorded || - mbedtls_ssl_get_verify_result(&t->ssl) == 0) { + if (t == NULL || !scr_tls_sock_verifies(t) || mbedtls_ssl_get_verify_result(&t->ssl) == 0) { return NULL; /* the null arm — authorized, or never verified */ } - const char *code = scr_tls_auth_code(t); + const char *code = t->srv != NULL ? scr_tls_client_cert_code(t) : scr_tls_auth_code(t); return scr_str_new(code, strlen(code)); } +/* ── certificates for the X509Certificate accessors ────────────────── + * + * getPeerCertificate, getCertificate and their X509Certificate forms read + * the DER below (scr_tls_x509.c builds the objects, so this unit needs no + * X.509 code). Node answers from the socket's handle, which a destroyed + * socket no longer has. */ + +bool scr_tls_sock_has_handle(ScrNetSocket *s) { + return scr_tls_sock_of(s) != NULL && !scr_net_sock_destroyed(s); +} + +/* The certificates the peer sent, leaf first (borrowed DER), up to `max`; + * none before the handshake. Answers the count. */ +size_t scr_tls_sock_peer_chain(ScrNetSocket *s, const uint8_t **der, size_t *len, size_t max) { + ScrTlsSock *t = scr_tls_sock_of(s); + if (t == NULL || !t->established) return 0; + size_t n = 0; + for (const mbedtls_x509_crt *c = mbedtls_ssl_get_peer_cert(&t->ssl); c != NULL && c->raw.len > 0 && n < max; + c = c->next) { + der[n] = c->raw.p; + len[n] = c->raw.len; + n++; + } + return n; +} + +/* The certificate the socket serves (borrowed DER), or NULL: a server's + * SNI answer, secureContext or own certificate. Clients send none. */ +const uint8_t *scr_tls_sock_own_cert(ScrNetSocket *s, size_t *len) { + ScrTlsSock *t = scr_tls_sock_of(s); + if (t != NULL && t->cli != NULL && t->cli->has_own) { + *len = t->cli->own.raw.len; + return t->cli->own.raw.p; + } + if (t == NULL || t->srv == NULL) return NULL; + const mbedtls_x509_crt *c = t->sni_ctx != NULL ? &t->sni_ctx->cert + : t->srv->ctx != NULL ? &t->srv->ctx->cert + : &t->srv->cert; + if (c->raw.len == 0) return NULL; + *len = c->raw.len; + return c->raw.p; +} + +/* Visits the trust anchors the socket verified its peer against (its + * `ca`, or the default store) until `visit` answers true. */ +void scr_tls_sock_each_trusted(ScrNetSocket *s, bool (*visit)(void *ctx, const uint8_t *der, size_t len), + void *ctx) { + ScrTlsSock *t = scr_tls_sock_of(s); + if (t == NULL || (t->cli == NULL && t->srv == NULL)) return; + const mbedtls_x509_crt *c = t->cli != NULL ? (t->cli->has_ca ? &t->cli->ca : scr_tls_system_ca()) + : t->ctx_verifies ? (t->sni_ctx->has_ca ? &t->sni_ctx->ca : scr_tls_system_ca()) + : t->srv->has_ca ? &t->srv->ca + : scr_tls_system_ca(); + for (; c != NULL && c->raw.len > 0; c = c->next) + if (visit(ctx, c->raw.p, c->raw.len)) return; +} + +static ScrBytes *scr_tls_der_buffer(const uint8_t *der, size_t len) { + ScrBytes *b = scr_bytes_new(SCR_BYTES_U8, (double)len); + b->is_buffer = true; + if (len) memcpy(b->data, der, len); + return b; +} + +/* getPeerX509Certificate() and getX509Certificate(): the certificate's + * DER (+1 Buffer), empty when there is none (undefined in Node). */ +ScrBytes *scr_tls_sock_peer_cert_der(ScrNetSocket *s) { + const uint8_t *der; + size_t len; + if (!scr_tls_sock_has_handle(s) || scr_tls_sock_peer_chain(s, &der, &len, 1) == 0) + return scr_tls_der_buffer(NULL, 0); + return scr_tls_der_buffer(der, len); +} + +ScrBytes *scr_tls_sock_own_cert_der(ScrNetSocket *s) { + size_t len = 0; + const uint8_t *der = scr_tls_sock_has_handle(s) ? scr_tls_sock_own_cert(s, &len) : NULL; + return scr_tls_der_buffer(der, der ? len : 0); +} + /* tlsSocket.getProtocol(): the negotiated version ("TLSv1.2", * "TLSv1.3"); before the handshake the highest version on offer, as * OpenSSL answers, and null after close. +1. */ diff --git a/packages/runtime/src/scr_tls.h b/packages/runtime/src/scr_tls.h index cc8e3336e..8e044f12d 100644 --- a/packages/runtime/src/scr_tls.h +++ b/packages/runtime/src/scr_tls.h @@ -58,6 +58,19 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable bool scr_tls_sock_authorized(ScrNetSocket *s); ScrStr *scr_tls_sock_auth_error(ScrNetSocket *s); /* +1 or NULL */ ScrDyn *scr_tls_sock_get_protocol(ScrNetSocket *s); /* +1: string or null */ +/* The certificates behind getPeerCertificate, getCertificate and their + * X509Certificate forms (scr_tls_x509.c reads them). */ +bool scr_tls_sock_has_handle(ScrNetSocket *s); +size_t scr_tls_sock_peer_chain(ScrNetSocket *s, const uint8_t **der, size_t *len, size_t max); +const uint8_t *scr_tls_sock_own_cert(ScrNetSocket *s, size_t *len); +void scr_tls_sock_each_trusted(ScrNetSocket *s, bool (*visit)(void *ctx, const uint8_t *der, size_t len), + void *ctx); +ScrBytes *scr_tls_sock_peer_cert_der(ScrNetSocket *s); /* +1 Buffer, empty for none */ +ScrBytes *scr_tls_sock_own_cert_der(ScrNetSocket *s); /* +1 Buffer, empty for none */ +/* scr_tls_x509.c: getPeerCertificate([detailed]) and getCertificate(), +1 + * (an object, {} or null). */ +ScrDyn *scr_tls_sock_get_peer_certificate(ScrNetSocket *s, const ScrDyn *detailed); +ScrDyn *scr_tls_sock_get_certificate(ScrNetSocket *s); ScrDyn *scr_tls_sock_get_cipher(ScrNetSocket *s); /* +1: object, undefined or null */ ScrDyn *scr_tls_sock_alpn_protocol(ScrNetSocket *s); /* +1: string, false or null */ void scr_tls_sock_on_secure_connect(ScrNetSocket *s, ScrClosure *cb /*moves*/, bool once); @@ -72,7 +85,7 @@ void scr_tls_sock_on_secure(ScrNetSocket *s, ScrClosure *cb /*moves*/, bool once * every http2-using binary links the TLS unit (the moduleUsesTls * switch), so no registration indirection is needed. */ void scr_tls_server_wrap_h2(ScrNetServer *s, const char *cert, size_t cert_len, const char *key, size_t key_len, ScrNetNativeConnFn h2_conn, void *h2_ctx /*moves*/, void (*h2_ctx_free)(void *), ScrClosure *sni_cb /*moves, nullable*/, void *sni_answer_fn); -void scr_tls_h2_client_wrap(ScrNetSocket *sock, ScrStr *host /*borrowed*/, bool reject_unauthorized, const char *ca /*borrowed, len 0 = none*/, size_t ca_len); +void scr_tls_h2_client_wrap(ScrNetSocket *sock, ScrStr *host /*borrowed*/, bool reject_unauthorized, const char *ca /*borrowed, NULL = none*/, size_t ca_len); /* ── tls.SecureContext + the SNI callback (scr_tls.c) ───────────────── * A SecureContext is an opaque refcounted parsed cert/key pair @@ -97,6 +110,12 @@ ScrSecureCtx *scr_tls_create_secure_context(const char *cert, size_t cert_len, c * validations first, then the pem walk (+1, or NULL with the exception * pending). Borrowed. */ ScrSecureCtx *scr_tls_create_secure_context_dyn(const ScrDyn *opts); +/* server.addContext(servername, context) (a SecureContext, or options when + * NULL) and server.setSecureContext(options) on a tls or https server; + * Node's errors pending on failure. */ +void scr_tls_server_add_context(ScrNetServer *server, const ScrDyn *servername, + const ScrDyn *options /*nullable*/, ScrSecureCtx *ctx /*nullable*/); +void scr_tls_server_set_secure_context(ScrNetServer *server, const ScrDyn *options); /* new tls.TLSSocket(socket, options) in server mode: a fresh TLS socket * takes over socket's connection and runs the server handshake, serving * ctx (options.secureContext; borrowed, nullable) or the options record's diff --git a/packages/runtime/src/scr_tls_x509.c b/packages/runtime/src/scr_tls_x509.c new file mode 100644 index 000000000..d1dfd819b --- /dev/null +++ b/packages/runtime/src/scr_tls_x509.c @@ -0,0 +1,119 @@ +/* tlsSocket.getPeerCertificate([detailed]) and getCertificate(): Node's + * legacy certificate objects, built by scr_x509.c's toLegacyObject over + * the certificates scr_tls.c keeps. Linked into programs that use both + * units, so neither unit depends on the other. */ +#include "scr_runtime.h" + +#include +#include + +/* The peer chain entries read; a longer chain is cut, as no real chain is + * this long. */ +enum { SCR_TLS_X509_MAX_CHAIN = 16 }; + +static ScrBytes *scr_tls_x509_bytes(const uint8_t *der, size_t len) { + ScrBytes *b = scr_bytes_new(SCR_BYTES_U8, (double)len); + if (len) memcpy(b->data, der, len); + return b; +} + +static bool scr_tls_x509_same(const ScrBytes *a, const ScrBytes *b) { + return a->len == b->len && memcmp(a->data, b->data, a->len) == 0; +} + +/* tlsSocket.getCertificate(): the certificate the socket serves, {} + * without one, null once the socket is destroyed. +1. */ +ScrDyn *scr_tls_sock_get_certificate(ScrNetSocket *s) { + if (!scr_tls_sock_has_handle(s)) return scr_dyn_new_null(); + size_t len = 0; + const uint8_t *der = scr_tls_sock_own_cert(s, &len); + if (der == NULL) return scr_dyn_new_obj(); + ScrBytes *cert = scr_tls_x509_bytes(der, len); + ScrDyn *result = scr_crypto_x509_legacy_object(cert); + scr_bytes_release(cert); + return result; +} + +typedef struct { + ScrBytes *subject; /* borrowed */ + ScrBytes *found; /* +1, NULL until found */ +} ScrTlsX509Lookup; + +/* SSL_CTX_get_issuer: the first trust anchor that issued the subject. */ +static bool scr_tls_x509_issuer_visit(void *ctx, const uint8_t *der, size_t len) { + ScrTlsX509Lookup *lookup = ctx; + ScrBytes *candidate = scr_tls_x509_bytes(der, len); + if (scr_crypto_x509_check_issued(lookup->subject, candidate)) { + lookup->found = candidate; + return true; + } + scr_bytes_release(candidate); + return false; +} + +/* Sets `chain`'s issuerCertificate to the legacy object of `issuer` + * (borrowed) and answers that object (borrowed; `chain` holds it). */ +static ScrDyn *scr_tls_x509_link(ScrDyn *chain, ScrBytes *issuer) { + ScrDyn *object = scr_crypto_x509_legacy_object(issuer); + scr_dyn_obj_set(chain, "issuerCertificate", 17, object); + return object; +} + +/* tlsSocket.getPeerCertificate([detailed]): the peer's certificate, {} + * before the handshake or without one, null once the socket is destroyed. + * With `detailed`, issuerCertificate links each certificate to its issuer + * as Node's GetPeerCert does: first from the certificates the peer sent, + * in their order of issue, then from the trust anchors; a self-issued + * last certificate is its own issuerCertificate. +1. */ +ScrDyn *scr_tls_sock_get_peer_certificate(ScrNetSocket *s, const ScrDyn *detailed_arg) { + /* Node reads the argument with IsTrue(): only true itself. */ + bool detailed = detailed_arg->kind == SCR_DYN_BOOL && detailed_arg->v.b; + if (!scr_tls_sock_has_handle(s)) return scr_dyn_new_null(); + const uint8_t *der[SCR_TLS_X509_MAX_CHAIN]; + size_t len[SCR_TLS_X509_MAX_CHAIN]; + size_t n = scr_tls_sock_peer_chain(s, der, len, SCR_TLS_X509_MAX_CHAIN); + if (n == 0) return scr_dyn_new_obj(); + ScrBytes *cert = scr_tls_x509_bytes(der[0], len[0]); + ScrDyn *result = scr_crypto_x509_legacy_object(cert); + if (!detailed) { + scr_bytes_release(cert); + return result; + } + ScrBytes *pool[SCR_TLS_X509_MAX_CHAIN]; + size_t pooled = 0; + for (size_t i = 1; i < n; i++) pool[pooled++] = scr_tls_x509_bytes(der[i], len[i]); + ScrDyn *chain = result; + /* AddIssuerChainToObject: take the issuer out of the peer's list, and + * stop when none issued the current certificate or the issuer taken was + * the list's last entry. */ + for (;;) { + size_t i; + for (i = 0; i < pooled; i++) { + if (!scr_crypto_x509_check_issued(cert, pool[i])) continue; + chain = scr_tls_x509_link(chain, pool[i]); + scr_bytes_release(cert); + cert = pool[i]; + memmove(pool + i, pool + i + 1, (pooled - i - 1) * sizeof pool[0]); + pooled--; + break; + } + if (i == pooled) break; + } + for (size_t i = 0; i < pooled; i++) scr_bytes_release(pool[i]); + /* GetLastIssuedCert: then the trust anchors, until a self-issued one + * (bounded, for anchors that issued each other). */ + for (size_t step = 0; step < SCR_TLS_X509_MAX_CHAIN && !scr_crypto_x509_check_issued(cert, cert); step++) { + ScrTlsX509Lookup lookup = {cert, NULL}; + scr_tls_sock_each_trusted(s, scr_tls_x509_issuer_visit, &lookup); + if (lookup.found == NULL) break; + chain = scr_tls_x509_link(chain, lookup.found); + bool same = scr_tls_x509_same(cert, lookup.found); + scr_bytes_release(cert); + cert = lookup.found; + if (same) break; + } + if (scr_crypto_x509_check_issued(cert, cert)) + scr_dyn_obj_set(chain, "issuerCertificate", 17, scr_dyn_retain(chain)); + scr_bytes_release(cert); + return result; +} diff --git a/packages/runtime/src/scr_unwind.c b/packages/runtime/src/scr_unwind.c new file mode 100644 index 000000000..50474e82e --- /dev/null +++ b/packages/runtime/src/scr_unwind.c @@ -0,0 +1,83 @@ +/* scr_unwind_raise: the native unwind of the unwinding exception ABI of + * emitted functions (the compiler's unwind-abi.ts). Linux (glibc and musl) + * only; elsewhere this unit is empty. */ +#include "scr_runtime.h" + +#if defined(__linux__) && !defined(__ANDROID__) && !defined(__wasi__) +#include +#include +#include +#include +#include + +/* Weak, so the runtime unit alone does not link the unwinder into every + * program: programs that raise reference the personality routine from + * their landing pads, and its unit references the unwinder's entry points, + * which link it in. */ +#pragma weak _Unwind_ForcedUnwind +#pragma weak _Unwind_GetCFA + +#if defined(__SANITIZE_ADDRESS__) +#define SCR_UNWIND_ASAN 1 +#elif defined(__has_feature) +#if __has_feature(address_sanitizer) +#define SCR_UNWIND_ASAN 1 +#endif +#endif +#ifdef SCR_UNWIND_ASAN +#include +#endif + +#ifdef SCR_UNWIND_ASAN +/* The lowest stack address the unwind has not cleared yet. The unwind + * outlives the frame that started it: landing pads resume it. */ +static SCR_TL uintptr_t scr_unwind_low; +#endif + +/* Emitted landing pads are cleanups: they release the frame's references + * and then either take the exception (a handler of theirs, or a frame on + * the pending-flag ABI that returns with it pending) or resume the unwind. + * So the unwind is forced, a single pass through every landing pad; the + * exception itself stays in the active cell. */ +static _Unwind_Reason_Code scr_unwind_stop(int version, _Unwind_Action actions, + _Unwind_Exception_Class cls, + struct _Unwind_Exception *exc, + struct _Unwind_Context *context, void *arg) { + (void)version; + (void)cls; + (void)exc; +#ifdef SCR_UNWIND_ASAN + /* The frames this unwind passes never return, so it clears their + * redzones' shadow itself. Emitted code calls scr_unwind_raise as an + * ordinary function: the sanitizer's hook before no-return calls needs + * stack bounds, which fiber stacks do not report. */ + uintptr_t cfa = _Unwind_GetCFA(context); + if (cfa > scr_unwind_low) { + __asan_unpoison_memory_region((void *)scr_unwind_low, cfa - scr_unwind_low); + scr_unwind_low = cfa; + } +#else + (void)context; +#endif + (void)arg; + if (actions & _UA_END_OF_STACK) { + fputs("scriptc: an exception unwound past the program's outermost frame\n", stderr); + abort(); + } + return _URC_NO_REASON; +} + +static SCR_TL struct _Unwind_Exception scr_unwind_exception; + +void scr_unwind_raise(void) { + struct _Unwind_Exception *exc = &scr_unwind_exception; + memset(exc, 0, sizeof *exc); + exc->exception_class = 0x5343524950540000ull; /* "SCRIPT\0\0" */ +#ifdef SCR_UNWIND_ASAN + scr_unwind_low = (uintptr_t)__builtin_frame_address(0); +#endif + if (_Unwind_ForcedUnwind != NULL) _Unwind_ForcedUnwind(exc, scr_unwind_stop, NULL); + fputs("scriptc: the native unwinder could not raise an exception\n", stderr); + abort(); +} +#endif diff --git a/packages/runtime/src/scr_util_callable.c b/packages/runtime/src/scr_util_callable.c index c905ea8ae..60de92121 100644 --- a/packages/runtime/src/scr_util_callable.c +++ b/packages/runtime/src/scr_util_callable.c @@ -149,23 +149,9 @@ static bool uc_define(ScrDyn *target, const char *key, ScrDyn *descriptor) { * Node 24's lib/util.js: nullish arguments and an undefined * superCtor.prototype throw ERR_INVALID_ARG_TYPE; then super_ becomes a * writable, configurable, non-enumerable own property and - * ctor.prototype's prototype becomes superCtor.prototype. */ -void scr_util_inherits(ScrDyn *ctor, ScrDyn *super_ctor) { - if (uc_nullish(ctor)) { - scr_dyn_arg_type_fail("ctor", "of type function", ctor); - return; - } - if (uc_nullish(super_ctor)) { - scr_dyn_arg_type_fail("superCtor", "of type function", super_ctor); - return; - } - if ((ctor->kind == SCR_DYN_FUNC && ctor->v.fn.class_obj) || - (super_ctor->kind == SCR_DYN_FUNC && super_ctor->v.fn.class_obj)) { - uc_fence("util.inherits over class constructors has no lowering; declare the subclass with extends"); - return; - } - ScrDyn *super_prototype = uc_get(super_ctor, "prototype"); - if (!super_prototype) return; + * ctor.prototype's prototype becomes superCtor.prototype. super_prototype + * is superCtor.prototype (owned), or NULL when superCtor is no object. */ +static void uc_inherits(ScrDyn *ctor, ScrDyn *super_ctor, ScrDyn *super_prototype) { if (super_prototype->kind == SCR_DYN_UNDEF) { scr_dyn_prop_type_fail("superCtor.prototype", "of type object", super_prototype); scr_dyn_release(super_prototype); @@ -198,6 +184,40 @@ void scr_util_inherits(ScrDyn *ctor, ScrDyn *super_ctor) { scr_dyn_release(super_prototype); } +void scr_util_inherits(ScrDyn *ctor, ScrDyn *super_ctor) { + if (uc_nullish(ctor)) { + scr_dyn_arg_type_fail("ctor", "of type function", ctor); + return; + } + if (uc_nullish(super_ctor)) { + scr_dyn_arg_type_fail("superCtor", "of type function", super_ctor); + return; + } + if ((ctor->kind == SCR_DYN_FUNC && ctor->v.fn.class_obj) || + (super_ctor->kind == SCR_DYN_FUNC && super_ctor->v.fn.class_obj)) { + uc_fence("util.inherits over class constructors has no lowering; declare the subclass with extends"); + return; + } + ScrDyn *super_prototype = uc_get(super_ctor, "prototype"); + if (!super_prototype) return; + uc_inherits(ctor, super_ctor, super_prototype); +} + +/* util.inherits(ctor, C) where C is a compiled class whose prototype the + * compiler exposes as super_prototype (borrowed): the class value's own + * properties do not hold it. ctor itself may not be a class. */ +void scr_util_inherits_prototype(ScrDyn *ctor, ScrDyn *super_ctor, ScrDyn *super_prototype) { + if (uc_nullish(ctor)) { + scr_dyn_arg_type_fail("ctor", "of type function", ctor); + return; + } + if (ctor->kind == SCR_DYN_FUNC && ctor->v.fn.class_obj) { + uc_fence("util.inherits over class constructors has no lowering; declare the subclass with extends"); + return; + } + uc_inherits(ctor, super_ctor, scr_dyn_retain(super_prototype)); +} + /* ── util.deprecate ──────────────────────────────────────────────────── * The wrapper warns once (and once per code across wrappers) before each * call reaches fn with the call's receiver and arguments; construction diff --git a/packages/runtime/src/scr_x509.c b/packages/runtime/src/scr_x509.c new file mode 100644 index 000000000..7b853d68a --- /dev/null +++ b/packages/runtime/src/scr_x509.c @@ -0,0 +1,1820 @@ +/* X509Certificate fields (raw, subject, issuer, subjectAltName, + * infoAccess, serialNumber, fingerprint256, fingerprint512, ca, + * signatureAlgorithm, signatureAlgorithmOid), linked only into programs + * that read them (the x509 runtime feature). */ +#include "scr_runtime.h" + +#include +#include +#include + +/* Copies of scr_lib.c's PEM front half and DER header reader for the + * X509Certificate members it keeps, so that unit is unchanged for + * programs that do not read these fields. */ + +/* PEM armor → DER (or raw-DER passthrough), the shared front half of + * every X509Certificate member. On success `*der`/`*der_len` hold the + * certificate bytes and `*decoded` the malloc'd base64 buffer to free + * (NULL for raw-DER input); unparseable input throws Node's exact PEM + * error and answers false with the exception pending. */ +static bool scr_x509_der(const uint8_t *in, size_t n, const uint8_t **der, size_t *der_len, + uint8_t **decoded) { + static const char begin[] = "-----BEGIN CERTIFICATE-----"; + static const char end[] = "-----END CERTIFICATE-----"; + *der = NULL; + *der_len = 0; + *decoded = NULL; + /* PEM: base64-decode the armor's body. */ + const uint8_t *b = NULL; + /* OpenSSL's PEM reader looks for the armor at the start of a line. */ + for (size_t i = 0; i + sizeof begin - 1 <= n; i++) { + if ((i == 0 || in[i - 1] == '\n') && memcmp(in + i, begin, sizeof begin - 1) == 0) { + b = in + i + sizeof begin - 1; + break; + } + } + if (b != NULL) { + const uint8_t *stop = in + n; + for (const uint8_t *p = b; p + sizeof end - 1 <= in + n; p++) { + if (memcmp(p, end, sizeof end - 1) == 0) { + stop = p; + break; + } + } + uint8_t *out = malloc(((size_t)(stop - b) / 4 + 1) * 3 + 3); + if (!out) { + scr_trap("scriptc: out of memory\n"); + } + size_t o = 0; + unsigned acc = 0; + int have = 0; + for (const uint8_t *p = b; p < stop; p++) { + int v; + uint8_t c = *p; + if (c >= 'A' && c <= 'Z') v = c - 'A'; + else if (c >= 'a' && c <= 'z') v = c - 'a' + 26; + else if (c >= '0' && c <= '9') v = c - '0' + 52; + else if (c == '+') v = 62; + else if (c == '/') v = 63; + else continue; /* whitespace, '=' */ + acc = (acc << 6) | (unsigned)v; + if (++have == 4) { + out[o++] = (uint8_t)(acc >> 16); + out[o++] = (uint8_t)(acc >> 8); + out[o++] = (uint8_t)acc; + acc = 0; + have = 0; + } + } + if (have == 2) out[o++] = (uint8_t)(acc >> 4); + else if (have == 3) { + out[o++] = (uint8_t)(acc >> 10); + out[o++] = (uint8_t)(acc >> 2); + } + *decoded = out; + *der = out; + *der_len = o; + return true; + } + if (n > 0 && in[0] == 0x30) { + /* Raw DER: the certificate SEQUENCE, without bytes after it, as + * d2i_X509 reads it. */ + *der = in; + *der_len = n; + if (n >= 2) { + size_t len = in[1], head = 2; + if (len & 0x80) { + size_t k = len & 0x7f; + len = 0; + head = 2 + k; + for (size_t i = 0; i < k && k <= 4 && head <= n; i++) len = (len << 8) | in[2 + i]; + if (k == 0 || k > 4) head = n + 1; + } + if (head <= n && len <= n - head) *der_len = head + len; + } + return true; + } + scr_throw_error_msg_code(SCR_ERR_ERROR, + "error:0480006C:PEM routines::no start line", + 42, "ERR_OSSL_PEM_NO_START_LINE"); + return false; /* exception pending */ +} + +/* Reads one DER TL header at p (before end): tag to *tag, content length + * to *len, and answers the content pointer (NULL on malformed/overlong). */ +static const uint8_t *scr_der_tl(const uint8_t *p, const uint8_t *end, uint8_t *tag, size_t *len) { + if (p == NULL || end - p < 2) return NULL; + *tag = p[0]; + uint8_t l0 = p[1]; + const uint8_t *content = p + 2; + size_t l; + if (l0 < 0x80) { + l = l0; + } else { + size_t nb = l0 & 0x7f; + if (nb == 0 || nb > sizeof(size_t) || end - content < (ptrdiff_t)nb) return NULL; + l = 0; + for (size_t i = 0; i < nb; i++) l = (l << 8) | content[i]; + content += nb; + } + if ((size_t)(end - content) < l) return NULL; + *tag = p[0]; + *len = l; + return content; +} + +/* ── X509Certificate fields over the DER ───────────────────────────────── + * + * `raw` decodes the constructor's input once (scr_x509_der); every other + * field reads those DER bytes. The walks follow RFC 5280's Certificate ::= + * SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue } and + * render each value as Node does: names through X509_NAME_print_ex (one + * attribute per line, RFC 2253 escaping), alternative names and access + * descriptions through Node's PrintGeneralName, the serial number through + * BN_bn2hex. A field whose part of the certificate is missing or malformed + * answers Node's value for an absent part. */ + +typedef struct { + char *data; + size_t len, cap; +} ScrX509Buf; + +static void scr_x509_put(ScrX509Buf *b, const void *p, size_t n) { + if (b->len + n > b->cap) { + size_t cap = b->cap ? b->cap * 2 : 128; + while (cap < b->len + n) cap *= 2; + char *grown = realloc(b->data, cap); + if (!grown) scr_trap("scriptc: out of memory\n"); + b->data = grown; + b->cap = cap; + } + memcpy(b->data + b->len, p, n); + b->len += n; +} + +static void scr_x509_puts(ScrX509Buf *b, const char *s) { scr_x509_put(b, s, strlen(s)); } + +static void scr_x509_putc(ScrX509Buf *b, char c) { scr_x509_put(b, &c, 1); } + +static ScrStr *scr_x509_buf_str(ScrX509Buf *b) { + ScrStr *s = scr_str_new(b->data ? b->data : "", b->len); + free(b->data); + return s; +} + +/* The DER of a certificate field's input (raw or the bytes constructor + * input). */ +#define SCR_X509_DER(bytes) \ + ((const uint8_t *)(bytes)->data), ((bytes)->len * scr_bytes_elem_size((bytes)->elem)) + +typedef struct { + const uint8_t *tbs, *tbs_end; /* tbsCertificate contents */ + const uint8_t *sig_alg; /* outer signatureAlgorithm contents */ + size_t sig_alg_len; + const uint8_t *serial; /* serialNumber contents */ + size_t serial_len; + const uint8_t *issuer, *subject; /* Name contents */ + size_t issuer_len, subject_len; + const uint8_t *validity; /* Validity contents */ + size_t validity_len; + const uint8_t *exts; /* Extensions SEQUENCE contents, or NULL */ + size_t exts_len; + const uint8_t *spki; /* SubjectPublicKeyInfo contents, or NULL */ + size_t spki_len; + const uint8_t *spki_tlv; /* the whole SubjectPublicKeyInfo DER */ + size_t spki_tlv_len; +} ScrX509Parts; + +/* Splits the certificate into the parts the fields read; false when the + * outer structure does not parse. */ +static bool scr_x509_parts(const uint8_t *der, size_t n, ScrX509Parts *out) { + memset(out, 0, sizeof *out); + const uint8_t *end = der + n; + uint8_t tag; + size_t len; + const uint8_t *cert = scr_der_tl(der, end, &tag, &len); + if (!cert || tag != 0x30) return false; + const uint8_t *cert_end = cert + len; + const uint8_t *tbs = scr_der_tl(cert, cert_end, &tag, &len); + if (!tbs || tag != 0x30) return false; + out->tbs = tbs; + out->tbs_end = tbs + len; + const uint8_t *alg = scr_der_tl(tbs + len, cert_end, &tag, &len); + if (alg && tag == 0x30) { + out->sig_alg = alg; + out->sig_alg_len = len; + } + const uint8_t *p = tbs; + const uint8_t *c = scr_der_tl(p, out->tbs_end, &tag, &len); + if (c && tag == 0xA0) { /* [0] version */ + p = c + len; + c = scr_der_tl(p, out->tbs_end, &tag, &len); + } + if (!c || tag != 0x02) return true; + out->serial = c; + out->serial_len = len; + p = c + len; + /* signature AlgorithmIdentifier */ + c = scr_der_tl(p, out->tbs_end, &tag, &len); + if (!c) return true; + p = c + len; + c = scr_der_tl(p, out->tbs_end, &tag, &len); + if (!c || tag != 0x30) return true; + out->issuer = c; + out->issuer_len = len; + p = c + len; + c = scr_der_tl(p, out->tbs_end, &tag, &len); + if (!c || tag != 0x30) return true; + out->validity = c; + out->validity_len = len; + p = c + len; + c = scr_der_tl(p, out->tbs_end, &tag, &len); + if (!c || tag != 0x30) return true; + out->subject = c; + out->subject_len = len; + p = c + len; + c = scr_der_tl(p, out->tbs_end, &tag, &len); + if (c && tag == 0x30) { + out->spki = c; + out->spki_len = len; + out->spki_tlv = p; + out->spki_tlv_len = (size_t)(c + len - p); + } + /* subjectPublicKeyInfo, then the optional [1] [2] unique IDs and [3] + * extensions */ + while (p < out->tbs_end) { + c = scr_der_tl(p, out->tbs_end, &tag, &len); + if (!c) break; + if (tag == 0xA3) { + const uint8_t *seq = scr_der_tl(c, c + len, &tag, &len); + if (seq && tag == 0x30) { + out->exts = seq; + out->exts_len = len; + } + break; + } + p = c + len; + } + return true; +} + +/* Finds the extension with the DER-encoded OID `oid` (contents only): + * its extnValue contents, or NULL. */ +static const uint8_t *scr_x509_ext(const ScrX509Parts *parts, const uint8_t *oid, size_t oid_len, + size_t *value_len) { + if (!parts->exts) return NULL; + const uint8_t *p = parts->exts; + const uint8_t *end = parts->exts + parts->exts_len; + uint8_t tag; + size_t len; + while (p < end) { + const uint8_t *ext = scr_der_tl(p, end, &tag, &len); + if (!ext || tag != 0x30) return NULL; + const uint8_t *ext_end = ext + len; + p = ext_end; + const uint8_t *id = scr_der_tl(ext, ext_end, &tag, &len); + if (!id || tag != 0x06) continue; + bool match = len == oid_len && memcmp(id, oid, len) == 0; + const uint8_t *q = id + len; + const uint8_t *v = scr_der_tl(q, ext_end, &tag, &len); + if (v && tag == 0x01) { /* critical */ + q = v + len; + v = scr_der_tl(q, ext_end, &tag, &len); + } + if (match && v && tag == 0x04) { + *value_len = len; + return v; + } + } + return NULL; +} + +/* An OID's dotted text (OBJ_obj2txt with no_name). */ +static void scr_x509_oid_dotted(ScrX509Buf *b, const uint8_t *oid, size_t n) { + char tmp[24]; + uint64_t v = 0; + bool first = true; + for (size_t i = 0; i < n; i++) { + v = (v << 7) | (oid[i] & 0x7f); + if (oid[i] & 0x80) continue; + if (first) { + unsigned top = v < 40 ? 0 : v < 80 ? 1 : 2; + snprintf(tmp, sizeof tmp, "%u.%llu", top, (unsigned long long)(v - top * 40)); + first = false; + } else { + snprintf(tmp, sizeof tmp, ".%llu", (unsigned long long)v); + } + scr_x509_puts(b, tmp); + v = 0; + } +} + +typedef struct { + const char *oid; /* dotted */ + const char *name; +} ScrX509OidName; + +/* OpenSSL's short names for the attribute types names use (XN_FLAG_FN_SN). */ +static const ScrX509OidName scr_x509_attr_names[] = { + {"2.5.4.3", "CN"}, + {"2.5.4.4", "SN"}, + {"2.5.4.5", "serialNumber"}, + {"2.5.4.6", "C"}, + {"2.5.4.7", "L"}, + {"2.5.4.8", "ST"}, + {"2.5.4.9", "street"}, + {"2.5.4.10", "O"}, + {"2.5.4.11", "OU"}, + {"2.5.4.12", "title"}, + {"2.5.4.13", "description"}, + {"2.5.4.15", "businessCategory"}, + {"2.5.4.16", "postalAddress"}, + {"2.5.4.17", "postalCode"}, + {"2.5.4.18", "postOfficeBox"}, + {"2.5.4.20", "telephoneNumber"}, + {"2.5.4.41", "name"}, + {"2.5.4.42", "GN"}, + {"2.5.4.43", "initials"}, + {"2.5.4.44", "generationQualifier"}, + {"2.5.4.45", "x500UniqueIdentifier"}, + {"2.5.4.46", "dnQualifier"}, + {"2.5.4.65", "pseudonym"}, + {"2.5.4.72", "role"}, + {"2.5.4.97", "organizationIdentifier"}, + {"1.2.840.113549.1.9.1", "emailAddress"}, + {"1.2.840.113549.1.9.2", "unstructuredName"}, + {"0.9.2342.19200300.100.1.1", "UID"}, + {"0.9.2342.19200300.100.1.25", "DC"}, + {"1.3.6.1.4.1.311.60.2.1.1", "jurisdictionL"}, + {"1.3.6.1.4.1.311.60.2.1.2", "jurisdictionST"}, + {"1.3.6.1.4.1.311.60.2.1.3", "jurisdictionC"}, +}; + +/* OpenSSL's long names for the signature algorithms certificates use. */ +static const ScrX509OidName scr_x509_sig_names[] = { + {"1.2.840.113549.1.1.4", "md5WithRSAEncryption"}, + {"1.2.840.113549.1.1.5", "sha1WithRSAEncryption"}, + {"1.2.840.113549.1.1.10", "rsassaPss"}, + {"1.2.840.113549.1.1.11", "sha256WithRSAEncryption"}, + {"1.2.840.113549.1.1.12", "sha384WithRSAEncryption"}, + {"1.2.840.113549.1.1.13", "sha512WithRSAEncryption"}, + {"1.2.840.113549.1.1.14", "sha224WithRSAEncryption"}, + {"1.2.840.10045.4.1", "ecdsa-with-SHA1"}, + {"1.2.840.10045.4.3.1", "ecdsa-with-SHA224"}, + {"1.2.840.10045.4.3.2", "ecdsa-with-SHA256"}, + {"1.2.840.10045.4.3.3", "ecdsa-with-SHA384"}, + {"1.2.840.10045.4.3.4", "ecdsa-with-SHA512"}, + {"1.2.840.10040.4.3", "dsaWithSHA1"}, + {"2.16.840.1.101.3.4.3.1", "dsa_with_SHA224"}, + {"2.16.840.1.101.3.4.3.2", "dsa_with_SHA256"}, + {"1.3.101.112", "ED25519"}, + {"1.3.101.113", "ED448"}, +}; + +/* OpenSSL's long names for the access methods infoAccess prints. */ +static const ScrX509OidName scr_x509_access_names[] = { + {"1.3.6.1.5.5.7.48.1", "OCSP"}, + {"1.3.6.1.5.5.7.48.2", "CA Issuers"}, + {"1.3.6.1.5.5.7.48.3", "AD Time Stamping"}, + {"1.3.6.1.5.5.7.48.5", "CA Repository"}, +}; + +/* Appends the table name of an OID, or its dotted text; answers whether + * the table named it. */ +static bool scr_x509_oid_name(ScrX509Buf *b, const uint8_t *oid, size_t n, + const ScrX509OidName *table, size_t count) { + ScrX509Buf dotted = {0}; + scr_x509_oid_dotted(&dotted, oid, n); + for (size_t i = 0; i < count; i++) { + if (strlen(table[i].oid) == dotted.len && memcmp(table[i].oid, dotted.data, dotted.len) == 0) { + scr_x509_puts(b, table[i].name); + free(dotted.data); + return true; + } + } + scr_x509_put(b, dotted.data, dotted.len); + free(dotted.data); + return false; +} + +/* Appends one code point as UTF-8. */ +static void scr_x509_put_utf8(ScrX509Buf *b, uint32_t c) { + char u[4]; + size_t n; + if (c < 0x80) { + u[0] = (char)c; + n = 1; + } else if (c < 0x800) { + u[0] = (char)(0xC0 | (c >> 6)); + u[1] = (char)(0x80 | (c & 0x3F)); + n = 2; + } else if (c < 0x10000) { + u[0] = (char)(0xE0 | (c >> 12)); + u[1] = (char)(0x80 | ((c >> 6) & 0x3F)); + u[2] = (char)(0x80 | (c & 0x3F)); + n = 3; + } else { + u[0] = (char)(0xF0 | (c >> 18)); + u[1] = (char)(0x80 | ((c >> 12) & 0x3F)); + u[2] = (char)(0x80 | ((c >> 6) & 0x3F)); + u[3] = (char)(0x80 | (c & 0x3F)); + n = 4; + } + scr_x509_put(b, u, n); +} + +/* An attribute value as UTF-8 bytes (ASN1_STRFLGS_UTF8_CONVERT): a + * UTF8String as stored, BMPString and UniversalString decoded, every other + * type read as Latin-1. */ +static void scr_x509_value_utf8(ScrX509Buf *out, uint8_t tag, const uint8_t *v, size_t n) { + if (tag == 0x0C) { + scr_x509_put(out, v, n); + } else if (tag == 0x1E) { + for (size_t i = 0; i + 1 < n; i += 2) scr_x509_put_utf8(out, ((uint32_t)v[i] << 8) | v[i + 1]); + } else if (tag == 0x1C) { + for (size_t i = 0; i + 3 < n; i += 4) + scr_x509_put_utf8(out, ((uint32_t)v[i] << 24) | ((uint32_t)v[i + 1] << 16) | + ((uint32_t)v[i + 2] << 8) | v[i + 3]); + } else { + for (size_t i = 0; i < n; i++) scr_x509_put_utf8(out, v[i]); + } +} + +/* Appends a value with ASN1_STRFLGS_ESC_2253 escaping: the characters + * `"+,;<>\` get a backslash, as do a leading '#' or space and a trailing + * space; with `esc_ctrl`, control characters print as \XX. Bytes above + * 0x7f pass through. */ +static void scr_x509_put_escaped(ScrX509Buf *b, const uint8_t *s, size_t n, bool esc_ctrl) { + static const char hex[] = "0123456789ABCDEF"; + for (size_t i = 0; i < n; i++) { + uint8_t c = s[i]; + bool first = i == 0; + bool last = i + 1 == n; + if (c == '"' || c == '+' || c == ',' || c == ';' || c == '<' || c == '>' || c == '\\' || + (first && (c == '#' || c == ' ')) || (last && c == ' ')) { + scr_x509_putc(b, '\\'); + scr_x509_putc(b, (char)c); + } else if (esc_ctrl && (c < 0x20 || c == 0x7f)) { + char e[3] = {'\\', hex[c >> 4], hex[c & 0x0f]}; + scr_x509_put(b, e, 3); + } else { + scr_x509_putc(b, (char)c); + } + } +} + +/* Prints a Name. `multiline` is Node's subject/issuer form (attributes in + * order, one RDN per line, ESC_CTRL); otherwise the RFC 2253 form DirName + * alternative names use (RDNs reversed and comma-separated, unknown + * attribute types dumped as #hex of the value's DER). RDNs with several + * attributes join them with " + " or "+". */ +static void scr_x509_name_print(ScrX509Buf *b, const uint8_t *name, size_t n, bool multiline) { + const uint8_t *end = name + n; + const uint8_t *rdns[64]; + size_t rdn_lens[64]; + size_t count = 0; + uint8_t tag; + size_t len; + for (const uint8_t *p = name; p < end && count < 64;) { + const uint8_t *set = scr_der_tl(p, end, &tag, &len); + if (!set || tag != 0x31) break; + rdns[count] = set; + rdn_lens[count++] = len; + p = set + len; + } + for (size_t k = 0; k < count; k++) { + size_t r = multiline ? k : count - 1 - k; + if (k > 0) scr_x509_puts(b, multiline ? "\n" : ","); + const uint8_t *set_end = rdns[r] + rdn_lens[r]; + bool first_attr = true; + for (const uint8_t *q = rdns[r]; q < set_end;) { + const uint8_t *atv = scr_der_tl(q, set_end, &tag, &len); + if (!atv || tag != 0x30) break; + const uint8_t *atv_end = atv + len; + q = atv_end; + const uint8_t *oid = scr_der_tl(atv, atv_end, &tag, &len); + if (!oid || tag != 0x06) continue; + size_t oid_len = len; + const uint8_t *value_tl = oid + len; + uint8_t vtag; + size_t vlen; + const uint8_t *value = scr_der_tl(value_tl, atv_end, &vtag, &vlen); + if (!value) continue; + if (!first_attr) scr_x509_puts(b, multiline ? " + " : "+"); + first_attr = false; + bool known = scr_x509_oid_name(b, oid, oid_len, scr_x509_attr_names, + sizeof scr_x509_attr_names / sizeof scr_x509_attr_names[0]); + scr_x509_putc(b, '='); + if (!multiline && !known) { + static const char hex[] = "0123456789ABCDEF"; + scr_x509_putc(b, '#'); + for (const uint8_t *d = value_tl; d < value + vlen; d++) { + scr_x509_putc(b, hex[*d >> 4]); + scr_x509_putc(b, hex[*d & 0x0f]); + } + continue; + } + ScrX509Buf text = {0}; + scr_x509_value_utf8(&text, vtag, value, vlen); + scr_x509_put_escaped(b, (const uint8_t *)text.data, text.len, multiline); + free(text.data); + } + } +} + +/* Node's PrintAltName: a name with none of `"\,'` and only printable + * characters (in UTF-8 names, only non-control bytes) prints as is; + * otherwise it prints as a JSON string, `prefix:` inside the quotes, with + * `\\`, `\"` and \u00XX for commas, control characters and (outside + * UTF-8 names) bytes above 0x7e. */ +static void scr_x509_put_alt_name(ScrX509Buf *b, const uint8_t *s, size_t n, bool utf8, + const char *prefix) { + bool safe = true; + for (size_t i = 0; i < n && safe; i++) { + uint8_t c = s[i]; + if (c == '"' || c == '\\' || c == ',' || c == '\'') safe = false; + else if (utf8 ? (c < ' ' || c == 0x7f) : (c < ' ' || c > '~')) safe = false; + } + if (safe) { + if (prefix) { + scr_x509_puts(b, prefix); + scr_x509_putc(b, ':'); + } + scr_x509_put(b, s, n); + return; + } + static const char hex[] = "0123456789abcdef"; + scr_x509_putc(b, '"'); + if (prefix) { + scr_x509_puts(b, prefix); + scr_x509_putc(b, ':'); + } + for (size_t i = 0; i < n; i++) { + uint8_t c = s[i]; + if (c == '\\') { + scr_x509_puts(b, "\\\\"); + } else if (c == '"') { + scr_x509_puts(b, "\\\""); + } else if ((c >= ' ' && c != ',' && c <= '~') || (utf8 && (c & 0x80))) { + scr_x509_putc(b, (char)c); + } else { + char u[6] = {'\\', 'u', '0', '0', hex[c >> 4], hex[c & 0x0f]}; + scr_x509_put(b, u, 6); + } + } + scr_x509_putc(b, '"'); +} + +static const uint8_t SCR_X509_OID_UPN[] = {0x2B, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x14, 0x02, 0x03}; +static const uint8_t SCR_X509_OID_XMPP[] = {0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x08, 0x05}; +static const uint8_t SCR_X509_OID_SRV[] = {0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x08, 0x07}; +static const uint8_t SCR_X509_OID_NAI[] = {0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x08, 0x08}; +static const uint8_t SCR_X509_OID_SMTP_UTF8[] = {0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x08, 0x09}; + +/* Node's PrintGeneralName for one GeneralName element (tag and contents). */ +static void scr_x509_general_name(ScrX509Buf *b, uint8_t tag, const uint8_t *v, size_t n) { + switch (tag) { + case 0x81: /* rfc822Name */ + scr_x509_puts(b, "email:"); + scr_x509_put_alt_name(b, v, n, false, NULL); + return; + case 0x82: /* dNSName */ + scr_x509_puts(b, "DNS:"); + scr_x509_put_alt_name(b, v, n, false, NULL); + return; + case 0x86: /* uniformResourceIdentifier */ + scr_x509_puts(b, "URI:"); + scr_x509_put_alt_name(b, v, n, false, NULL); + return; + case 0x87: { /* iPAddress */ + char tmp[48]; + scr_x509_puts(b, "IP Address:"); + if (n == 4) { + snprintf(tmp, sizeof tmp, "%d.%d.%d.%d", v[0], v[1], v[2], v[3]); + scr_x509_puts(b, tmp); + } else if (n == 16) { + for (unsigned j = 0; j < 8; j++) { + snprintf(tmp, sizeof tmp, j == 0 ? "%X" : ":%X", (unsigned)((v[2 * j] << 8) | v[2 * j + 1])); + scr_x509_puts(b, tmp); + } + } else { + snprintf(tmp, sizeof tmp, "", n); + scr_x509_puts(b, tmp); + } + return; + } + case 0x88: /* registeredID */ + scr_x509_puts(b, "Registered ID:"); + scr_x509_oid_dotted(b, v, n); + return; + case 0xA4: { /* directoryName: [4] EXPLICIT Name */ + uint8_t ntag; + size_t nlen; + const uint8_t *name = scr_der_tl(v, v + n, &ntag, &nlen); + ScrX509Buf text = {0}; + if (name && ntag == 0x30) scr_x509_name_print(&text, name, nlen, false); + scr_x509_puts(b, "DirName:"); + scr_x509_put_alt_name(b, (const uint8_t *)(text.data ? text.data : ""), text.len, true, NULL); + free(text.data); + return; + } + case 0xA0: { /* otherName: type-id OID, [0] EXPLICIT value */ + uint8_t t; + size_t len; + const uint8_t *end = v + n; + const uint8_t *oid = scr_der_tl(v, end, &t, &len); + const char *prefix = NULL; + bool unicode = true; + if (oid && t == 0x06) { +#define SCR_X509_OID_IS(o) (len == sizeof(o) && memcmp(oid, (o), sizeof(o)) == 0) + if (SCR_X509_OID_IS(SCR_X509_OID_SMTP_UTF8)) prefix = "SmtpUTF8Mailbox"; + else if (SCR_X509_OID_IS(SCR_X509_OID_XMPP)) prefix = "XmppAddr"; + else if (SCR_X509_OID_IS(SCR_X509_OID_SRV)) { + prefix = "SRVName"; + unicode = false; + } else if (SCR_X509_OID_IS(SCR_X509_OID_UPN)) prefix = "UPN"; + else if (SCR_X509_OID_IS(SCR_X509_OID_NAI)) prefix = "NAIRealm"; +#undef SCR_X509_OID_IS + } + const uint8_t *value = NULL; + size_t value_len = 0; + uint8_t vtag = 0; + if (oid && prefix) { + const uint8_t *wrap = scr_der_tl(oid + len, end, &t, &len); + if (wrap && t == 0xA0) value = scr_der_tl(wrap, wrap + len, &vtag, &value_len); + } + if (!value || vtag != (unicode ? 0x0C : 0x16)) { + scr_x509_puts(b, "othername:"); + } else { + scr_x509_puts(b, "othername:"); + scr_x509_put_alt_name(b, value, value_len, unicode, prefix); + } + return; + } + case 0xA3: + scr_x509_puts(b, "X400Name:"); + return; + case 0xA5: + scr_x509_puts(b, "EdiPartyName:"); + return; + default: + return; + } +} + +/* The DER contents of the TBSCertificate parts, or an empty string when + * the certificate does not parse. */ +static ScrStr *scr_x509_name_field(ScrBytes *der, bool subject) { + ScrX509Parts parts; + ScrX509Buf b = {0}; + if (scr_x509_parts(SCR_X509_DER(der), &parts)) { + const uint8_t *name = subject ? parts.subject : parts.issuer; + if (name) scr_x509_name_print(&b, name, subject ? parts.subject_len : parts.issuer_len, true); + } + return scr_x509_buf_str(&b); +} + +/* new X509Certificate(data).raw: the certificate's DER as a fresh Buffer. + * Unparseable input throws Node's PEM error (exception pending, NULL). */ +static ScrBytes *scr_x509_raw(const uint8_t *in, size_t n) { + const uint8_t *der; + size_t der_len; + uint8_t *decoded; + if (!scr_x509_der(in, n, &der, &der_len, &decoded)) return NULL; + ScrBytes *out = scr_buffer_alloc_unsafe_slow((double)der_len); + memcpy(out->data, der, der_len); + free(decoded); + return out; +} + +ScrBytes *scr_crypto_x509_raw(ScrBytes *data) { + SCR_BYTES_SNAPSHOT(data); + return scr_x509_raw(SCR_X509_DER(data)); +} + +/* raw of a certificate whose DER a TLS handshake delivered: a copy, with + * no search for PEM armor that its extensions could carry. */ +ScrBytes *scr_crypto_x509_raw_der(ScrBytes *der) { + SCR_BYTES_SNAPSHOT(der); + size_t n = (size_t)scr_bytes_byte_len(der); + ScrBytes *out = scr_buffer_alloc_unsafe_slow((double)n); + memcpy(out->data, der->data, n); + return out; +} + +ScrBytes *scr_crypto_x509_raw_str(ScrStr *pem) { + return scr_x509_raw((const uint8_t *)pem->data, pem->len); +} + +ScrStr *scr_crypto_x509_subject(ScrBytes *der) { return scr_x509_name_field(der, true); } + +ScrStr *scr_crypto_x509_issuer(ScrBytes *der) { return scr_x509_name_field(der, false); } + +static const uint8_t SCR_X509_OID_SAN[] = {0x55, 0x1D, 0x11}; +static const uint8_t SCR_X509_OID_BASIC_CONSTRAINTS[] = {0x55, 0x1D, 0x13}; +static const uint8_t SCR_X509_OID_KEY_USAGE[] = {0x55, 0x1D, 0x0F}; +static const uint8_t SCR_X509_OID_AIA[] = {0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x01, 0x01}; + +/* subjectAltName: the GeneralNames joined with ", "; an empty string when + * the certificate has no subjectAltName extension (Node's undefined). */ +ScrStr *scr_crypto_x509_subject_alt_name(ScrBytes *der) { + ScrX509Parts parts; + ScrX509Buf b = {0}; + size_t n; + const uint8_t *ext = scr_x509_parts(SCR_X509_DER(der), &parts) + ? scr_x509_ext(&parts, SCR_X509_OID_SAN, sizeof SCR_X509_OID_SAN, &n) + : NULL; + uint8_t tag; + size_t len; + const uint8_t *names = ext ? scr_der_tl(ext, ext + n, &tag, &len) : NULL; + if (names && tag == 0x30) { + const uint8_t *end = names + len; + bool first = true; + for (const uint8_t *p = names; p < end;) { + const uint8_t *v = scr_der_tl(p, end, &tag, &len); + if (!v) break; + if (!first) scr_x509_puts(&b, ", "); + first = false; + scr_x509_general_name(&b, tag, v, len); + p = v + len; + } + } + return scr_x509_buf_str(&b); +} + +/* infoAccess: one " - " line per AccessDescription; + * an empty string when the extension is absent (Node's undefined). */ +ScrStr *scr_crypto_x509_info_access(ScrBytes *der) { + ScrX509Parts parts; + ScrX509Buf b = {0}; + size_t n; + const uint8_t *ext = scr_x509_parts(SCR_X509_DER(der), &parts) + ? scr_x509_ext(&parts, SCR_X509_OID_AIA, sizeof SCR_X509_OID_AIA, &n) + : NULL; + uint8_t tag; + size_t len; + const uint8_t *descs = ext ? scr_der_tl(ext, ext + n, &tag, &len) : NULL; + if (descs && tag == 0x30) { + const uint8_t *end = descs + len; + bool first = true; + for (const uint8_t *p = descs; p < end;) { + const uint8_t *desc = scr_der_tl(p, end, &tag, &len); + if (!desc || tag != 0x30) break; + const uint8_t *desc_end = desc + len; + p = desc_end; + const uint8_t *method = scr_der_tl(desc, desc_end, &tag, &len); + if (!method || tag != 0x06) continue; + if (!first) scr_x509_putc(&b, '\n'); + first = false; + scr_x509_oid_name(&b, method, len, scr_x509_access_names, + sizeof scr_x509_access_names / sizeof scr_x509_access_names[0]); + scr_x509_puts(&b, " - "); + const uint8_t *location = scr_der_tl(method + len, desc_end, &tag, &len); + if (location) scr_x509_general_name(&b, tag, location, len); + } + } + return scr_x509_buf_str(&b); +} + +/* serialNumber: BN_bn2hex of the INTEGER ("0" for zero, whole bytes in + * uppercase hex without leading zero bytes, '-' before a negative). */ +ScrStr *scr_crypto_x509_serial_number(ScrBytes *der) { + ScrX509Parts parts; + ScrX509Buf b = {0}; + if (scr_x509_parts(SCR_X509_DER(der), &parts) && parts.serial_len > 0) { + static const char hex[] = "0123456789ABCDEF"; + size_t n = parts.serial_len; + uint8_t *mag = malloc(n); + if (!mag) scr_trap("scriptc: out of memory\n"); + memcpy(mag, parts.serial, n); + bool neg = (mag[0] & 0x80) != 0; + if (neg) { /* two's complement magnitude */ + for (size_t i = 0; i < n; i++) mag[i] = (uint8_t)~mag[i]; + for (size_t i = n; i-- > 0;) { + if (++mag[i] != 0) break; + } + } + size_t i = 0; + while (i < n && mag[i] == 0) i++; + if (i == n) { + scr_x509_putc(&b, '0'); + } else { + if (neg) scr_x509_putc(&b, '-'); + for (; i < n; i++) { + scr_x509_putc(&b, hex[mag[i] >> 4]); + scr_x509_putc(&b, hex[mag[i] & 0x0f]); + } + } + free(mag); + } + return scr_x509_buf_str(&b); +} + +static ScrStr *scr_x509_fingerprint_alg(ScrBytes *der, const char *alg) { + unsigned char digest[64]; + size_t dn = scr_crypto_digest_raw(alg, SCR_X509_DER(der), digest); + static const char hex[] = "0123456789ABCDEF"; + char buf[192]; + size_t o = 0; + for (size_t i = 0; i < dn; i++) { + if (i > 0) buf[o++] = ':'; + buf[o++] = hex[digest[i] >> 4]; + buf[o++] = hex[digest[i] & 0x0f]; + } + return scr_str_new(buf, o); +} + +ScrStr *scr_crypto_x509_fingerprint256(ScrBytes *der) { + return scr_x509_fingerprint_alg(der, "sha256"); +} + +ScrStr *scr_crypto_x509_fingerprint512(ScrBytes *der) { + return scr_x509_fingerprint_alg(der, "sha512"); +} + +/* ca: X509_check_ca() == 1 — basicConstraints with cA TRUE, and a + * keyUsage extension, when present, that allows keyCertSign. */ +bool scr_crypto_x509_ca(ScrBytes *der) { + ScrX509Parts parts; + if (!scr_x509_parts(SCR_X509_DER(der), &parts)) return false; + uint8_t tag; + size_t len, n; + const uint8_t *ku = scr_x509_ext(&parts, SCR_X509_OID_KEY_USAGE, sizeof SCR_X509_OID_KEY_USAGE, &n); + if (ku) { + const uint8_t *bits = scr_der_tl(ku, ku + n, &tag, &len); + if (!bits || tag != 0x03 || len < 2 || (bits[1] & 0x04) == 0) return false; + } + const uint8_t *bc = scr_x509_ext(&parts, SCR_X509_OID_BASIC_CONSTRAINTS, + sizeof SCR_X509_OID_BASIC_CONSTRAINTS, &n); + if (!bc) return false; + const uint8_t *seq = scr_der_tl(bc, bc + n, &tag, &len); + if (!seq || tag != 0x30) return false; + const uint8_t *flag = scr_der_tl(seq, seq + len, &tag, &len); + return flag && tag == 0x01 && len == 1 && flag[0] != 0; +} + +/* The signature AlgorithmIdentifier's OID contents, or NULL. */ +static const uint8_t *scr_x509_sig_oid(ScrBytes *der, size_t *n) { + ScrX509Parts parts; + if (!scr_x509_parts(SCR_X509_DER(der), &parts) || !parts.sig_alg) return NULL; + uint8_t tag; + const uint8_t *oid = scr_der_tl(parts.sig_alg, parts.sig_alg + parts.sig_alg_len, &tag, n); + return oid && tag == 0x06 ? oid : NULL; +} + +/* signatureAlgorithm: OpenSSL's long name for the signature algorithm; an + * empty string for an algorithm OpenSSL does not name (Node's + * undefined). */ +ScrStr *scr_crypto_x509_signature_algorithm(ScrBytes *der) { + size_t n; + const uint8_t *oid = scr_x509_sig_oid(der, &n); + ScrX509Buf b = {0}; + if (oid && !scr_x509_oid_name(&b, oid, n, scr_x509_sig_names, + sizeof scr_x509_sig_names / sizeof scr_x509_sig_names[0])) + b.len = 0; + return scr_x509_buf_str(&b); +} + +/* signatureAlgorithmOid: the signature algorithm's dotted OID. */ +ScrStr *scr_crypto_x509_signature_algorithm_oid(ScrBytes *der) { + size_t n; + const uint8_t *oid = scr_x509_sig_oid(der, &n); + ScrX509Buf b = {0}; + if (oid) scr_x509_oid_dotted(&b, oid, n); + return scr_x509_buf_str(&b); +} + +/* toString() and toJSON(): PEM_write_bio_X509's armor over the DER, 64 + * base64 characters a line. */ +ScrStr *scr_crypto_x509_pem(ScrBytes *der) { + static const char digits[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const uint8_t *d = (const uint8_t *)der->data; + size_t n = der->len * scr_bytes_elem_size(der->elem); + ScrX509Buf b = {0}; + scr_x509_puts(&b, "-----BEGIN CERTIFICATE-----\n"); + size_t col = 0; + for (size_t i = 0; i < n; i += 3) { + uint32_t v = (uint32_t)d[i] << 16; + if (i + 1 < n) v |= (uint32_t)d[i + 1] << 8; + if (i + 2 < n) v |= d[i + 2]; + char q[4] = {digits[(v >> 18) & 63], digits[(v >> 12) & 63], i + 1 < n ? digits[(v >> 6) & 63] : '=', + i + 2 < n ? digits[v & 63] : '='}; + for (int k = 0; k < 4; k++) { + scr_x509_putc(&b, q[k]); + if (++col == 64) { + scr_x509_putc(&b, '\n'); + col = 0; + } + } + } + if (col) scr_x509_putc(&b, '\n'); + scr_x509_puts(&b, "-----END CERTIFICATE-----\n"); + return scr_x509_buf_str(&b); +} + +/* ── checkHost, checkEmail and checkIP ───────────────────────────────── */ + +/* X509_CHECK_FLAG_* as Node's getFlags sets them. */ +enum { + SCR_X509_ALWAYS_CHECK_SUBJECT = 0x1, + SCR_X509_NO_WILDCARDS = 0x2, + SCR_X509_NO_PARTIAL_WILDCARDS = 0x4, + SCR_X509_MULTI_LABEL_WILDCARDS = 0x8, + SCR_X509_SINGLE_LABEL_SUBDOMAINS = 0x10, + SCR_X509_NEVER_CHECK_SUBJECT = 0x20, +}; + +/* Node's getFlags(options): validateObject, then the subject string and + * the four booleans, in Node's order. -1 with the error pending. */ +static int scr_x509_check_flags(const ScrDyn *options) { + if (options == NULL || options->kind == SCR_DYN_UNDEF) return 0; + if (options->kind == SCR_DYN_TYPED_REF) { + ScrDyn *value = scr_dyn_typed_ref_materialize((ScrDyn *)options); + if (!value) return -1; + int flags = scr_x509_check_flags(value); + scr_dyn_release(value); + return flags; + } + if (options->kind != SCR_DYN_OBJ) { + scr_dyn_arg_type_fail("options", "of type object", options); + return -1; + } + const ScrDyn *subject = scr_dyn_obj_get(options, "subject", 7); + static const char *const bool_names[] = {"wildcards", "partialWildcards", "multiLabelWildcards", + "singleLabelSubdomains"}; + static const bool bool_defaults[] = {true, true, false, false}; + bool values[4]; + if (subject != NULL && subject->kind != SCR_DYN_UNDEF && subject->kind != SCR_DYN_STR) { + scr_dyn_prop_type_fail("options.subject", "of type string", subject); + return -1; + } + for (size_t i = 0; i < 4; i++) { + const ScrDyn *v = scr_dyn_obj_get(options, bool_names[i], strlen(bool_names[i])); + if (v == NULL || v->kind == SCR_DYN_UNDEF) { + values[i] = bool_defaults[i]; + continue; + } + if (v->kind != SCR_DYN_BOOL) { + char name[48]; + snprintf(name, sizeof name, "options.%s", bool_names[i]); + scr_dyn_prop_type_fail(name, "of type boolean", v); + return -1; + } + values[i] = v->v.b; + } + int flags = 0; + if (subject != NULL && subject->kind == SCR_DYN_STR) { + const ScrStr *s = subject->v.str; + if (s->len == 6 && memcmp(s->data, "always", 6) == 0) flags |= SCR_X509_ALWAYS_CHECK_SUBJECT; + else if (s->len == 5 && memcmp(s->data, "never", 5) == 0) flags |= SCR_X509_NEVER_CHECK_SUBJECT; + else if (!(s->len == 7 && memcmp(s->data, "default", 7) == 0)) { + scr_dyn_arg_value_fail("options.subject", NULL, subject); + return -1; + } + } + if (!values[0]) flags |= SCR_X509_NO_WILDCARDS; + if (!values[1]) flags |= SCR_X509_NO_PARTIAL_WILDCARDS; + if (values[2]) flags |= SCR_X509_MULTI_LABEL_WILDCARDS; + if (values[3]) flags |= SCR_X509_SINGLE_LABEL_SUBDOMAINS; + return flags; +} + +static bool scr_x509_ieq(const uint8_t *a, const uint8_t *b, size_t n) { + for (size_t i = 0; i < n; i++) { + uint8_t x = a[i], y = b[i]; + if (x >= 'A' && x <= 'Z') x = (uint8_t)(x + 32); + if (y >= 'A' && y <= 'Z') y = (uint8_t)(y + 32); + if (x != y) return false; + } + return true; +} + +/* OpenSSL's equal_nocase after skip_prefix: a host that starts with '.' + * compares with the name's equal-length suffix when the characters + * skipped hold no NUL (and, under singleLabelSubdomains, no dot). */ +static bool scr_x509_host_equal(const uint8_t *pattern, size_t plen, const uint8_t *host, + size_t hlen, int flags) { + if (hlen > 1 && host[0] == '.') { + const uint8_t *p = pattern; + size_t n = plen; + while (n > hlen && *p) { + if ((flags & SCR_X509_SINGLE_LABEL_SUBDOMAINS) && *p == '.') break; + p++; + n--; + } + if (n == hlen) { + pattern = p; + plen = n; + } + } + return plen == hlen && memchr(pattern, 0, plen) == NULL && scr_x509_ieq(pattern, host, hlen); +} + +static bool scr_x509_label_char(uint8_t c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); +} + +/* OpenSSL's valid_star: the '*' of a wildcard pattern, or NULL when the + * pattern is not a valid wildcard (the star outside the first label, in + * an IDNA label, partial when partial wildcards are off, or fewer than two + * labels after it). */ +static const uint8_t *scr_x509_valid_star(const uint8_t *p, size_t len, int flags) { + const uint8_t *star = NULL; + int dots = 0; + bool atstart = true, idna = false, first = true; + for (size_t i = 0; i < len; i++) { + if (p[i] == '*') { + bool atend = i == len - 1 || p[i + 1] == '.'; + if (star != NULL || !first || idna) return NULL; + if ((flags & SCR_X509_NO_PARTIAL_WILDCARDS) && (!atstart || !atend)) return NULL; + if (!atstart && !atend) return NULL; + star = &p[i]; + atstart = false; + } else if (p[i] == '.') { + if (atstart) return NULL; + idna = false; + dots++; + atstart = true; + first = false; + } else if (p[i] == '-' || scr_x509_label_char(p[i])) { + if (atstart && len - i >= 4 && scr_x509_ieq(&p[i], (const uint8_t *)"xn--", 4)) idna = true; + atstart = false; + } else { + return NULL; + } + } + if (star == NULL || dots < 2 || atstart) return NULL; + return star; +} + +/* OpenSSL's wildcard_match: prefix and suffix around the star match the + * host case-insensitively, the star covers at least one character, never + * a dot unless multi-label wildcards are on, and never an IDNA label. */ +static bool scr_x509_wildcard_match(const uint8_t *prefix, size_t prefix_len, + const uint8_t *suffix, size_t suffix_len, + const uint8_t *host, size_t hlen, int flags) { + if (hlen < prefix_len + suffix_len) return false; + if (!scr_x509_ieq(prefix, host, prefix_len)) return false; + const uint8_t *wild_start = host + prefix_len; + const uint8_t *wild_end = host + hlen - suffix_len; + if (!scr_x509_ieq(wild_end, suffix, suffix_len)) return false; + if (prefix_len == 0 && suffix_len > 0 && *suffix == '.') { + if (wild_start == wild_end) return false; + if (wild_end - wild_start > 4 && scr_x509_ieq(wild_start, (const uint8_t *)"xn--", 4)) + return false; + } + bool allow_multi = (flags & SCR_X509_MULTI_LABEL_WILDCARDS) != 0; + for (const uint8_t *p = wild_start; p != wild_end; p++) { + if (!scr_x509_label_char(*p) && *p != '-' && !(*p == '.' && allow_multi)) return false; + } + return true; +} + +static bool scr_x509_host_match(const uint8_t *pattern, size_t plen, const uint8_t *host, + size_t hlen, int flags) { + /* a host that starts with '.' matches a wildcard only as a suffix */ + if (!(flags & SCR_X509_NO_WILDCARDS) && !(hlen > 1 && host[0] == '.')) { + const uint8_t *star = scr_x509_valid_star(pattern, plen, flags); + if (star != NULL) + return scr_x509_wildcard_match(pattern, (size_t)(star - pattern), star + 1, + plen - (size_t)(star - pattern) - 1, host, hlen, flags); + } + return scr_x509_host_equal(pattern, plen, host, hlen, flags); +} + +/* OpenSSL's equal_email: the local part as is, the domain case- + * insensitively. */ +static bool scr_x509_email_match(const uint8_t *pattern, size_t plen, const uint8_t *email, + size_t elen) { + if (plen != elen || memchr(pattern, 0, plen) != NULL) return false; + size_t at = elen; + while (at > 0 && email[at - 1] != '@') at--; + if (at == 0) return scr_x509_ieq(pattern, email, elen); + return memcmp(pattern, email, at) == 0 && scr_x509_ieq(pattern + at, email + at, elen - at); +} + +enum { SCR_X509_GEN_EMAIL = 0x81, SCR_X509_GEN_DNS = 0x82, SCR_X509_GEN_IP = 0x87 }; + +/* do_x509_check over the subjectAltName entries of one type, then the + * subject's CN (hosts) or emailAddress (emails) entries when the flags + * allow. Writes the matching certificate name into *out. */ +static bool scr_x509_check(ScrBytes *der, uint8_t gen, const uint8_t *chk, size_t chk_len, + int flags, ScrX509Buf *out) { + ScrX509Parts parts; + if (!scr_x509_parts(SCR_X509_DER(der), &parts)) return false; + bool san_present = false; + size_t n; + const uint8_t *ext = scr_x509_ext(&parts, SCR_X509_OID_SAN, sizeof SCR_X509_OID_SAN, &n); + uint8_t tag; + size_t len; + const uint8_t *names = ext ? scr_der_tl(ext, ext + n, &tag, &len) : NULL; + if (names && tag == 0x30) { + const uint8_t *end = names + len; + for (const uint8_t *p = names; p < end;) { + const uint8_t *v = scr_der_tl(p, end, &tag, &len); + if (!v) break; + p = v + len; + if (tag != gen) continue; + san_present = true; + bool hit = gen == SCR_X509_GEN_IP ? len == chk_len && memcmp(v, chk, len) == 0 + : gen == SCR_X509_GEN_DNS ? scr_x509_host_match(v, len, chk, chk_len, flags) + : scr_x509_email_match(v, len, chk, chk_len); + if (hit) { + scr_x509_put(out, v, len); + return true; + } + } + } + if (gen == SCR_X509_GEN_IP || (flags & SCR_X509_NEVER_CHECK_SUBJECT) || + (san_present && !(flags & SCR_X509_ALWAYS_CHECK_SUBJECT)) || !parts.subject) + return false; + /* the subject's CN (2.5.4.3) or emailAddress (1.2.840.113549.1.9.1) */ + static const uint8_t cn[] = {0x55, 0x04, 0x03}; + static const uint8_t email[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x09, 0x01}; + const uint8_t *want = gen == SCR_X509_GEN_DNS ? cn : email; + size_t want_len = gen == SCR_X509_GEN_DNS ? sizeof cn : sizeof email; + const uint8_t *end = parts.subject + parts.subject_len; + for (const uint8_t *p = parts.subject; p < end;) { + const uint8_t *set = scr_der_tl(p, end, &tag, &len); + if (!set || tag != 0x31) break; + const uint8_t *set_end = set + len; + p = set_end; + for (const uint8_t *q = set; q < set_end;) { + const uint8_t *atv = scr_der_tl(q, set_end, &tag, &len); + if (!atv || tag != 0x30) break; + const uint8_t *atv_end = atv + len; + q = atv_end; + const uint8_t *oid = scr_der_tl(atv, atv_end, &tag, &len); + if (!oid || tag != 0x06 || len != want_len || memcmp(oid, want, len) != 0) continue; + uint8_t vtag; + size_t vlen; + const uint8_t *value = scr_der_tl(oid + len, atv_end, &vtag, &vlen); + if (!value) continue; + ScrX509Buf text = {0}; + scr_x509_value_utf8(&text, vtag, value, vlen); + bool hit = gen == SCR_X509_GEN_DNS + ? scr_x509_host_match((const uint8_t *)text.data, text.len, chk, chk_len, flags) + : scr_x509_email_match((const uint8_t *)text.data, text.len, chk, chk_len); + if (hit) scr_x509_put(out, text.data, text.len); + free(text.data); + if (hit) return true; + } + } + return false; +} + +/* x509.checkHost(name[, options]) and x509.checkEmail(email[, options]): + * the certificate name that matched (checkHost) or the argument + * (checkEmail), "" for none (Node's undefined), or NULL with the + * validation error pending. */ +static ScrStr *scr_x509_check_name(ScrBytes *der, const ScrDyn *value, const ScrDyn *options, + const char *arg, uint8_t gen) { + if (value->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail(arg, "of type string", value); + return NULL; + } + int flags = scr_x509_check_flags(options); + if (flags < 0) return NULL; + ScrX509Buf b = {0}; + const ScrStr *s = value->v.str; + bool hit = s->len > 0 && scr_x509_check(der, gen, (const uint8_t *)s->data, s->len, flags, &b); + if (hit && gen == SCR_X509_GEN_EMAIL) { + /* checkEmail answers its argument (X509_check_email reports no name) */ + free(b.data); + return scr_str_retain((ScrStr *)s); + } + return scr_x509_buf_str(&b); +} + +ScrStr *scr_crypto_x509_check_host(ScrBytes *der, ScrDyn *name, ScrDyn *options) { + return scr_x509_check_name(der, name, options, "name", SCR_X509_GEN_DNS); +} + +ScrStr *scr_crypto_x509_check_email(ScrBytes *der, ScrDyn *email, ScrDyn *options) { + return scr_x509_check_name(der, email, options, "email", SCR_X509_GEN_EMAIL); +} + +/* inet_pton's IPv4 form, which Node's checkIP parses: four decimal parts, + * each at most 255, without leading zeros. */ +static bool scr_x509_ipv4(const char *s, size_t n, uint8_t out[4]) { + size_t part = 0, i = 0; + while (part < 4) { + if (i >= n || s[i] < '0' || s[i] > '9') return false; + unsigned v = 0; + size_t digits = 0; + if (s[i] == '0' && i + 1 < n && s[i + 1] >= '0' && s[i + 1] <= '9') return false; + while (i < n && s[i] >= '0' && s[i] <= '9') { + v = v * 10 + (unsigned)(s[i++] - '0'); + if (++digits > 3 || v > 255) return false; + } + out[part++] = (uint8_t)v; + if (part < 4) { + if (i >= n || s[i] != '.') return false; + i++; + } + } + return i == n; +} + +/* inet_pton's IPv6 form: hex groups with at most one "::", and an IPv4 + * address in place of the last two groups. */ +static bool scr_x509_ipv6(const char *s, size_t n, uint8_t out[16]) { + uint8_t head[16], tail[16]; + size_t head_len = 0, tail_len = 0; + bool compressed = false; + size_t i = 0; + if (n >= 2 && s[0] == ':' && s[1] == ':') { + compressed = true; + i = 2; + if (i == n) { + memset(out, 0, 16); + return true; + } + } else if (n > 0 && s[0] == ':') { + return false; + } + while (i < n) { + uint8_t *dst = compressed ? tail : head; + size_t *dst_len = compressed ? &tail_len : &head_len; + /* an IPv4 tail */ + const char *dot = memchr(s + i, '.', n - i); + const char *colon = memchr(s + i, ':', n - i); + if (dot != NULL && colon == NULL) { + if (*dst_len + 4 > 16 || !scr_x509_ipv4(s + i, n - i, dst + *dst_len)) return false; + *dst_len += 4; + i = n; + break; + } + unsigned v = 0; + size_t digits = 0; + while (i < n && s[i] != ':') { + char c = s[i++]; + unsigned d = c >= '0' && c <= '9' ? (unsigned)(c - '0') + : c >= 'a' && c <= 'f' ? (unsigned)(c - 'a' + 10) + : c >= 'A' && c <= 'F' ? (unsigned)(c - 'A' + 10) + : 16; + if (d == 16 || ++digits > 4) return false; + v = (v << 4) | d; + } + if (digits == 0 || *dst_len + 2 > 16) return false; + dst[(*dst_len)++] = (uint8_t)(v >> 8); + dst[(*dst_len)++] = (uint8_t)v; + if (i == n) break; + i++; /* ':' */ + if (i < n && s[i] == ':') { + if (compressed) return false; + compressed = true; + i++; + if (i == n) break; + } else if (i == n) { + return false; + } + } + if (compressed) { + if (head_len + tail_len > 14) return false; + } else if (head_len != 16) { + return false; + } + memset(out, 0, 16); + memcpy(out, head, head_len); + memcpy(out + 16 - tail_len, tail, tail_len); + return true; +} + +/* x509.checkIP(ip[, options]): the argument itself when an iPAddress + * alternative name equals it, "" for none, NULL with the error pending. + * The options are validated like checkHost's and have no effect. */ +ScrStr *scr_crypto_x509_check_ip(ScrBytes *der, ScrDyn *ip, ScrDyn *options) { + if (ip->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("ip", "of type string", ip); + return NULL; + } + if (scr_x509_check_flags(options) < 0) return NULL; + const ScrStr *s = ip->v.str; + uint8_t addr[16]; + size_t addr_len = 0; + if (memchr(s->data, ':', s->len) != NULL) { + if (scr_x509_ipv6(s->data, s->len, addr)) addr_len = 16; + } else if (scr_x509_ipv4(s->data, s->len, addr)) { + addr_len = 4; + } + if (addr_len == 0) { + static const char msg[] = "Invalid name"; + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, sizeof msg - 1, "ERR_INVALID_ARG_VALUE"); + return NULL; + } + ScrX509Buf b = {0}; + if (scr_x509_check(der, SCR_X509_GEN_IP, addr, addr_len, 0, &b)) { + free(b.data); + return scr_str_retain((ScrStr *)s); + } + return scr_x509_buf_str(&b); +} + +/* ── checkIssued ──────────────────────────────────────────────────────── */ + +/* The string types OpenSSL canonicalizes before comparing names + * (ASN1_MASK_CANON). */ +static bool scr_x509_canon_type(uint8_t tag) { + return tag == 0x0C || tag == 0x1E || tag == 0x1C || tag == 0x13 || tag == 0x14 || tag == 0x16 || + tag == 0x1A; +} + +static bool scr_x509_space(uint8_t c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r'; +} + +/* asn1_string_canon: UTF-8, leading and trailing whitespace dropped, + * inner runs of whitespace as one space, ASCII in lower case. */ +static void scr_x509_canon(ScrX509Buf *out, uint8_t tag, const uint8_t *v, size_t n) { + ScrX509Buf text = {0}; + scr_x509_value_utf8(&text, tag, v, n); + const uint8_t *p = (const uint8_t *)text.data; + size_t len = text.len; + while (len > 0 && scr_x509_space(*p)) p++, len--; + while (len > 0 && scr_x509_space(p[len - 1])) len--; + for (size_t i = 0; i < len; i++) { + uint8_t c = p[i]; + if (scr_x509_space(c)) { + scr_x509_putc(out, ' '); + while (i + 1 < len && scr_x509_space(p[i + 1])) i++; + } else { + scr_x509_putc(out, (char)(c >= 'A' && c <= 'Z' ? c + 32 : c)); + } + } + free(text.data); +} + +/* One Name as comparable text: per RDN, each attribute's OID and its + * canonical value (or raw DER for non-string types), with separators that + * keep the RDN grouping. */ +static void scr_x509_name_canon(ScrX509Buf *out, const uint8_t *name, size_t n) { + const uint8_t *end = name + n; + uint8_t tag; + size_t len; + for (const uint8_t *p = name; p < end;) { + const uint8_t *set = scr_der_tl(p, end, &tag, &len); + if (!set || tag != 0x31) break; + const uint8_t *set_end = set + len; + p = set_end; + scr_x509_putc(out, '\x01'); + for (const uint8_t *q = set; q < set_end;) { + const uint8_t *atv = scr_der_tl(q, set_end, &tag, &len); + if (!atv || tag != 0x30) break; + const uint8_t *atv_end = atv + len; + q = atv_end; + const uint8_t *oid = scr_der_tl(atv, atv_end, &tag, &len); + if (!oid || tag != 0x06) continue; + scr_x509_putc(out, '\x02'); + scr_x509_put(out, oid, len); + uint8_t vtag; + size_t vlen; + const uint8_t *value_tl = oid + len; + const uint8_t *value = scr_der_tl(value_tl, atv_end, &vtag, &vlen); + if (!value) continue; + scr_x509_putc(out, '\x03'); + if (scr_x509_canon_type(vtag)) scr_x509_canon(out, vtag, value, vlen); + else scr_x509_put(out, value_tl, (size_t)(value + vlen - value_tl)); + } + } +} + +static bool scr_x509_name_eq(const uint8_t *a, size_t an, const uint8_t *b, size_t bn) { + ScrX509Buf x = {0}, y = {0}; + scr_x509_name_canon(&x, a, an); + scr_x509_name_canon(&y, b, bn); + bool eq = x.len == y.len && (x.len == 0 || memcmp(x.data, y.data, x.len) == 0); + free(x.data); + free(y.data); + return eq; +} + +static const uint8_t SCR_X509_OID_SKID[] = {0x55, 0x1D, 0x0E}; +static const uint8_t SCR_X509_OID_AKID[] = {0x55, 0x1D, 0x23}; + +/* The public key algorithm a signature algorithm needs: 1 RSA, 2 EC, + * 3 Ed25519, 4 Ed448, 0 unknown. */ +static int scr_x509_sig_key_kind(const uint8_t *oid, size_t n) { + static const uint8_t rsa_prefix[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01}; + static const uint8_t ecdsa_prefix[] = {0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04}; + if (n == sizeof rsa_prefix + 1 && memcmp(oid, rsa_prefix, sizeof rsa_prefix) == 0) { + uint8_t last = oid[sizeof rsa_prefix]; + return last == 4 || last == 5 || (last >= 10 && last <= 14) ? 1 : 0; + } + if (n >= sizeof ecdsa_prefix + 1 && memcmp(oid, ecdsa_prefix, sizeof ecdsa_prefix) == 0) + return 2; + if (n == 3 && oid[0] == 0x2B && oid[1] == 0x65 && oid[2] == 0x70) return 3; + if (n == 3 && oid[0] == 0x2B && oid[1] == 0x65 && oid[2] == 0x71) return 4; + return 0; +} + +/* The kind (as above) of a SubjectPublicKeyInfo's algorithm. */ +static int scr_x509_spki_kind(const ScrX509Parts *parts) { + if (!parts->spki) return 0; + uint8_t tag; + size_t len; + const uint8_t *alg = scr_der_tl(parts->spki, parts->spki + parts->spki_len, &tag, &len); + if (!alg || tag != 0x30) return 0; + const uint8_t *oid = scr_der_tl(alg, alg + len, &tag, &len); + if (!oid || tag != 0x06) return 0; + static const uint8_t rsa[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01}; + static const uint8_t pss[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0A}; + static const uint8_t ec[] = {0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01}; + if ((len == sizeof rsa && memcmp(oid, rsa, len) == 0) || + (len == sizeof pss && memcmp(oid, pss, len) == 0)) + return 1; + if (len == sizeof ec && memcmp(oid, ec, len) == 0) return 2; + if (len == 3 && oid[0] == 0x2B && oid[1] == 0x65 && oid[2] == 0x70) return 3; + if (len == 3 && oid[0] == 0x2B && oid[1] == 0x65 && oid[2] == 0x71) return 4; + return 0; +} + +/* X509_check_akid: the subject's authorityKeyIdentifier against the + * issuer's subjectKeyIdentifier, serial number and issuer name. */ +static bool scr_x509_akid_ok(const ScrX509Parts *issuer, const ScrX509Parts *subject) { + size_t n; + const uint8_t *akid = scr_x509_ext(subject, SCR_X509_OID_AKID, sizeof SCR_X509_OID_AKID, &n); + if (!akid) return true; + uint8_t tag; + size_t len; + const uint8_t *seq = scr_der_tl(akid, akid + n, &tag, &len); + if (!seq || tag != 0x30) return true; + const uint8_t *end = seq + len; + for (const uint8_t *p = seq; p < end;) { + const uint8_t *v = scr_der_tl(p, end, &tag, &len); + if (!v) break; + p = v + len; + if (tag == 0x80) { /* keyIdentifier */ + size_t sn; + const uint8_t *skid = scr_x509_ext(issuer, SCR_X509_OID_SKID, sizeof SCR_X509_OID_SKID, &sn); + uint8_t stag; + size_t slen; + const uint8_t *id = skid ? scr_der_tl(skid, skid + sn, &stag, &slen) : NULL; + if (id && stag == 0x04 && (slen != len || memcmp(id, v, len) != 0)) return false; + } else if (tag == 0x82) { /* authorityCertSerialNumber */ + if (issuer->serial && (issuer->serial_len != len || memcmp(issuer->serial, v, len) != 0)) + return false; + } else if (tag == 0xA1 && issuer->issuer) { /* authorityCertIssuer */ + bool found = false; + const uint8_t *names_end = v + len; + for (const uint8_t *q = v; q < names_end && !found;) { + uint8_t gtag; + size_t glen; + const uint8_t *g = scr_der_tl(q, names_end, >ag, &glen); + if (!g) break; + q = g + glen; + if (gtag != 0xA4) continue; + uint8_t ntag; + size_t nlen; + const uint8_t *name = scr_der_tl(g, g + glen, &ntag, &nlen); + if (name && ntag == 0x30 && scr_x509_name_eq(name, nlen, issuer->issuer, issuer->issuer_len)) + found = true; + } + if (!found) return false; + } + } + return true; +} + +/* x509.checkIssued(otherCert): X509_check_issued(otherCert, x509) — the + * names match, the authority key identifier fits, the signature algorithm + * suits the issuer's key, and the issuer's keyUsage, when present, allows + * certificate signing. */ +bool scr_crypto_x509_check_issued(ScrBytes *der, ScrBytes *issuer_der) { + ScrX509Parts subject, issuer; + if (!scr_x509_parts(SCR_X509_DER(der), &subject) || + !scr_x509_parts(SCR_X509_DER(issuer_der), &issuer)) + return false; + if (!subject.issuer || !issuer.subject || + !scr_x509_name_eq(issuer.subject, issuer.subject_len, subject.issuer, subject.issuer_len)) + return false; + if (!scr_x509_akid_ok(&issuer, &subject)) return false; + uint8_t tag; + size_t len; + const uint8_t *sig_oid = subject.sig_alg + ? scr_der_tl(subject.sig_alg, subject.sig_alg + subject.sig_alg_len, + &tag, &len) + : NULL; + if (!sig_oid || tag != 0x06) return false; + int need = scr_x509_sig_key_kind(sig_oid, len); + if (need == 0 || need != scr_x509_spki_kind(&issuer)) return false; + size_t n; + const uint8_t *ku = scr_x509_ext(&issuer, SCR_X509_OID_KEY_USAGE, sizeof SCR_X509_OID_KEY_USAGE, &n); + if (ku) { + const uint8_t *bits = scr_der_tl(ku, ku + n, &tag, &len); + if (!bits || tag != 0x03 || len < 2 || (bits[1] & 0x04) == 0) return false; + } + return true; +} + +/* ── toLegacyObject ───────────────────────────────────────────────────── */ + +static ScrDyn *scr_x509_dyn_text(const char *s, size_t n) { + ScrStr *str = scr_str_new(s, n); + ScrDyn *d = scr_dyn_new_str(str); + scr_str_release(str); + return d; +} + +/* A fresh Buffer holding n bytes. */ +static ScrDyn *scr_x509_dyn_buffer(const uint8_t *p, size_t n) { + ScrBytes *b = scr_buffer_alloc_unsafe_slow((double)n); + if (n) memcpy(b->data, p, n); + ScrDyn *d = scr_dyn_new_bytes(b); + scr_bytes_release(b); + return d; +} + +/* A Name as Node's legacy object: one property per attribute, keyed by + * OpenSSL's short name (the dotted OID otherwise), its value the UTF-8 + * text; a repeated key collects its values in an array. */ +static ScrDyn *scr_x509_name_object(const uint8_t *name, size_t n) { + ScrDyn *obj = scr_dyn_new_obj_null_proto(); + const uint8_t *end = name + n; + uint8_t tag; + size_t len; + for (const uint8_t *p = name; p < end;) { + const uint8_t *set = scr_der_tl(p, end, &tag, &len); + if (!set || tag != 0x31) break; + const uint8_t *set_end = set + len; + p = set_end; + for (const uint8_t *q = set; q < set_end;) { + const uint8_t *atv = scr_der_tl(q, set_end, &tag, &len); + if (!atv || tag != 0x30) break; + const uint8_t *atv_end = atv + len; + q = atv_end; + const uint8_t *oid = scr_der_tl(atv, atv_end, &tag, &len); + if (!oid || tag != 0x06) continue; + size_t oid_len = len; + uint8_t vtag; + size_t vlen; + const uint8_t *value = scr_der_tl(oid + len, atv_end, &vtag, &vlen); + if (!value) continue; + ScrX509Buf key = {0}, text = {0}; + scr_x509_oid_name(&key, oid, oid_len, scr_x509_attr_names, + sizeof scr_x509_attr_names / sizeof scr_x509_attr_names[0]); + scr_x509_value_utf8(&text, vtag, value, vlen); + ScrDyn *v = scr_x509_dyn_text(text.data ? text.data : "", text.len); + const ScrDyn *prior = scr_dyn_obj_get(obj, key.data, key.len); + if (prior == NULL) { + scr_dyn_obj_set(obj, key.data, key.len, v); + } else if (prior->kind == SCR_DYN_ARR) { + scr_dyn_arr_push((ScrDyn *)prior, v); + } else { + ScrDyn *arr = scr_dyn_new_arr(); + scr_dyn_arr_push(arr, scr_dyn_retain((ScrDyn *)prior)); + scr_dyn_arr_push(arr, v); + scr_dyn_obj_set(obj, key.data, key.len, arr); + } + free(key.data); + free(text.data); + } + } + return obj; +} + +/* The JSON string literal at s (a quoted alternative name), decoded. */ +static void scr_x509_json_unquote(ScrX509Buf *out, const char *s, size_t n) { + for (size_t i = 1; i < n; i++) { + char c = s[i]; + if (c == '"') return; + if (c != '\\' || i + 1 >= n) { + scr_x509_putc(out, c); + continue; + } + c = s[++i]; + if (c == 'u' && i + 4 < n) { + unsigned v = 0; + for (int k = 1; k <= 4; k++) { + char h = s[i + k]; + v = v * 16 + (unsigned)(h <= '9' ? h - '0' : (h | 0x20) - 'a' + 10); + } + scr_x509_put_utf8(out, v); + i += 4; + } else { + scr_x509_putc(out, c == 'n' ? '\n' : c == 't' ? '\t' : c); + } + } +} + +/* Node's legacy infoAccess: the infoAccess lines split at their first + * colon into " - " keys, each holding its values in an + * array (a quoted value is JSON-decoded). */ +static ScrDyn *scr_x509_info_access_object(ScrBytes *der) { + ScrStr *text = scr_crypto_x509_info_access(der); + if (text->len == 0) { + scr_str_release(text); + return scr_dyn_undefined(); + } + ScrDyn *obj = scr_dyn_new_obj_null_proto(); + const char *s = text->data; + size_t n = text->len; + for (size_t start = 0; start < n;) { + size_t eol = start; + while (eol < n && s[eol] != '\n') eol++; + size_t colon = start; + while (colon < eol && s[colon] != ':') colon++; + if (colon < eol) { + const char *val = s + colon + 1; + size_t val_len = eol - colon - 1; + ScrX509Buf v = {0}; + if (val_len > 0 && val[0] == '"') scr_x509_json_unquote(&v, val, val_len); + else scr_x509_put(&v, val, val_len); + ScrDyn *item = scr_x509_dyn_text(v.data ? v.data : "", v.len); + free(v.data); + const ScrDyn *prior = scr_dyn_obj_get(obj, s + start, colon - start); + if (prior != NULL && prior->kind == SCR_DYN_ARR) { + scr_dyn_arr_push((ScrDyn *)prior, item); + } else { + ScrDyn *arr = scr_dyn_new_arr(); + scr_dyn_arr_push(arr, item); + scr_dyn_obj_set(obj, s + start, colon - start, arr); + } + } + start = eol + 1; + } + scr_str_release(text); + return obj; +} + +/* Hex of a big-endian magnitude: BN_bn2hex (whole bytes, upper case, no + * leading zero bytes) or, with `lower_trim`, %llx-style text. */ +static void scr_x509_put_hex(ScrX509Buf *b, const uint8_t *p, size_t n, bool lower_trim) { + const char *hex = lower_trim ? "0123456789abcdef" : "0123456789ABCDEF"; + while (n > 0 && *p == 0) p++, n--; + if (n == 0) { + scr_x509_putc(b, '0'); + return; + } + for (size_t i = 0; i < n; i++) { + if (!(lower_trim && i == 0 && (p[i] >> 4) == 0)) scr_x509_putc(b, hex[p[i] >> 4]); + scr_x509_putc(b, hex[p[i] & 0x0f]); + } +} + +typedef struct { + const char *oid; + const char *name; + const char *nist; + int bits; +} ScrX509Curve; + +static const ScrX509Curve scr_x509_curves[] = { + {"1.2.840.10045.3.1.1", "prime192v1", "P-192", 192}, + {"1.3.132.0.33", "secp224r1", "P-224", 224}, + {"1.2.840.10045.3.1.7", "prime256v1", "P-256", 256}, + {"1.3.132.0.34", "secp384r1", "P-384", 384}, + {"1.3.132.0.35", "secp521r1", "P-521", 521}, + {"1.3.132.0.31", "secp192k1", NULL, 192}, + {"1.3.132.0.32", "secp224k1", NULL, 225}, + {"1.3.132.0.10", "secp256k1", NULL, 256}, + {"1.3.36.3.3.2.8.1.1.7", "brainpoolP256r1", NULL, 256}, + {"1.3.36.3.3.2.8.1.1.11", "brainpoolP384r1", NULL, 384}, + {"1.3.36.3.3.2.8.1.1.13", "brainpoolP512r1", NULL, 512}, +}; + +/* The public key members of the legacy object: modulus, exponent, + * pubkey, bits, asn1Curve and nistCurve (each undefined where the key + * type has none). */ +static void scr_x509_key_fields(const ScrX509Parts *parts, ScrDyn *slots[6]) { + for (int i = 0; i < 6; i++) slots[i] = scr_dyn_undefined(); + if (!parts->spki) return; + int kind = scr_x509_spki_kind(parts); + uint8_t tag; + size_t len; + const uint8_t *spki_end = parts->spki + parts->spki_len; + const uint8_t *alg = scr_der_tl(parts->spki, spki_end, &tag, &len); + if (!alg || tag != 0x30) return; + const uint8_t *alg_end = alg + len; + const uint8_t *oid = scr_der_tl(alg, alg_end, &tag, &len); + if (!oid) return; + const uint8_t *params = oid + len; + uint8_t ptag; + size_t plen; + const uint8_t *param = scr_der_tl(params, alg_end, &ptag, &plen); + const uint8_t *bits = scr_der_tl(alg_end, spki_end, &tag, &len); + if (!bits || tag != 0x03 || len < 1) return; + const uint8_t *key = bits + 1; + size_t key_len = len - 1; + static const uint8_t rsa_oid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01}; + bool rsa = kind == 1 && (size_t)(params - oid) == sizeof rsa_oid && + memcmp(oid, rsa_oid, sizeof rsa_oid) == 0; + if (rsa) { + const uint8_t *seq = scr_der_tl(key, key + key_len, &tag, &len); + if (!seq || tag != 0x30) return; + const uint8_t *seq_end = seq + len; + size_t nlen, elen; + const uint8_t *nmod = scr_der_tl(seq, seq_end, &tag, &nlen); + if (!nmod || tag != 0x02) return; + const uint8_t *nexp = scr_der_tl(nmod + nlen, seq_end, &tag, &elen); + if (!nexp || tag != 0x02) return; + ScrX509Buf m = {0}, e = {0}; + scr_x509_put_hex(&m, nmod, nlen, false); + scr_x509_puts(&e, "0x"); + scr_x509_put_hex(&e, nexp, elen, true); + slots[0] = scr_x509_dyn_text(m.data, m.len); + slots[1] = scr_x509_dyn_text(e.data, e.len); + free(m.data); + free(e.data); + slots[2] = scr_x509_dyn_buffer(parts->spki_tlv, parts->spki_tlv_len); + size_t i = 0; + while (i < nlen && nmod[i] == 0) i++; + int nbits = 0; + if (i < nlen) { + nbits = (int)((nlen - i - 1) * 8); + for (uint8_t top = nmod[i]; top; top >>= 1) nbits++; + } + slots[3] = scr_dyn_new_num(nbits); + return; + } + if (kind == 2 && param && ptag == 0x06) { + ScrX509Buf dotted = {0}; + scr_x509_oid_dotted(&dotted, param, plen); + for (size_t c = 0; c < sizeof scr_x509_curves / sizeof scr_x509_curves[0]; c++) { + const ScrX509Curve *curve = &scr_x509_curves[c]; + if (strlen(curve->oid) != dotted.len || memcmp(curve->oid, dotted.data, dotted.len) != 0) + continue; + slots[2] = scr_x509_dyn_buffer(key, key_len); + slots[3] = scr_dyn_new_num(curve->bits); + slots[4] = scr_x509_dyn_text(curve->name, strlen(curve->name)); + if (curve->nist) slots[5] = scr_x509_dyn_text(curve->nist, strlen(curve->nist)); + break; + } + free(dotted.data); + } +} + +static const uint8_t SCR_X509_OID_EKU[] = {0x55, 0x1D, 0x25}; + +/* x509.toLegacyObject(): Node's X509ToObject, member order included. */ +ScrDyn *scr_crypto_x509_legacy_object(ScrBytes *der) { + ScrX509Parts parts; + ScrDyn *obj = scr_dyn_new_obj(); + bool ok = scr_x509_parts(SCR_X509_DER(der), &parts); + if (!ok) memset(&parts, 0, sizeof parts); + scr_dyn_obj_set(obj, "subject", 7, + parts.subject ? scr_x509_name_object(parts.subject, parts.subject_len) + : scr_dyn_new_obj_null_proto()); + scr_dyn_obj_set(obj, "issuer", 6, + parts.issuer ? scr_x509_name_object(parts.issuer, parts.issuer_len) + : scr_dyn_new_obj_null_proto()); + ScrStr *san = scr_crypto_x509_subject_alt_name(der); + scr_dyn_obj_set(obj, "subjectaltname", 14, san->len ? scr_dyn_new_str(san) : scr_dyn_undefined()); + scr_str_release(san); + scr_dyn_obj_set(obj, "infoAccess", 10, scr_x509_info_access_object(der)); + scr_dyn_obj_set(obj, "ca", 2, scr_dyn_new_bool(scr_crypto_x509_ca(der))); + ScrDyn *key[6]; + scr_x509_key_fields(&parts, key); + scr_dyn_obj_set(obj, "modulus", 7, key[0]); + scr_dyn_obj_set(obj, "exponent", 8, key[1]); + scr_dyn_obj_set(obj, "pubkey", 6, key[2]); + scr_dyn_obj_set(obj, "bits", 4, key[3]); + static const struct { + const char *key; + ScrStr *(*field)(ScrBytes *); + } texts[] = { + {"valid_from", scr_crypto_x509_valid_from}, + {"valid_to", scr_crypto_x509_valid_to}, + {"fingerprint", scr_crypto_x509_fingerprint}, + {"fingerprint256", scr_crypto_x509_fingerprint256}, + {"fingerprint512", scr_crypto_x509_fingerprint512}, + }; + for (size_t i = 0; i < sizeof texts / sizeof texts[0]; i++) { + ScrStr *s = texts[i].field(der); + scr_dyn_obj_set(obj, texts[i].key, strlen(texts[i].key), scr_dyn_new_str(s)); + scr_str_release(s); + } + size_t n; + const uint8_t *eku = ok ? scr_x509_ext(&parts, SCR_X509_OID_EKU, sizeof SCR_X509_OID_EKU, &n) : NULL; + uint8_t tag; + size_t len; + const uint8_t *purposes = eku ? scr_der_tl(eku, eku + n, &tag, &len) : NULL; + if (purposes && tag == 0x30) { + ScrDyn *arr = scr_dyn_new_arr(); + const uint8_t *end = purposes + len; + for (const uint8_t *p = purposes; p < end;) { + const uint8_t *oid = scr_der_tl(p, end, &tag, &len); + if (!oid) break; + p = oid + len; + if (tag != 0x06) continue; + ScrX509Buf dotted = {0}; + scr_x509_oid_dotted(&dotted, oid, len); + scr_dyn_arr_push(arr, scr_x509_dyn_text(dotted.data, dotted.len)); + free(dotted.data); + } + scr_dyn_obj_set(obj, "ext_key_usage", 13, arr); + } else { + scr_dyn_obj_set(obj, "ext_key_usage", 13, scr_dyn_undefined()); + } + ScrStr *serial = scr_crypto_x509_serial_number(der); + scr_dyn_obj_set(obj, "serialNumber", 12, scr_dyn_new_str(serial)); + scr_str_release(serial); + { + ScrBytes *view = der; + SCR_BYTES_SNAPSHOT(view); + scr_dyn_obj_set(obj, "raw", 3, scr_x509_dyn_buffer(SCR_X509_DER(view))); + } + scr_dyn_obj_set(obj, "asn1Curve", 9, key[4]); + scr_dyn_obj_set(obj, "nistCurve", 9, key[5]); + return obj; +} diff --git a/packages/runtime/test/json.test.ts b/packages/runtime/test/json.test.ts index 14aaed199..b08f2fdd3 100644 --- a/packages/runtime/test/json.test.ts +++ b/packages/runtime/test/json.test.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -84,3 +84,32 @@ test("json runtime: parser, dyn, dynCheck failure path, stringify buffer", async ].join("\n"), ); }); + +function zigOnPath(): boolean { + try { + execFileSync("zig", ["version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +// 32-bit uintptr_t: the weak-collection hashes mix in 64 bits, so no shift +// exceeds the width of its operand on wasm32. +test.skipIf(!zigOnPath())("json runtime builds for wasm32 without oversized shifts", async () => { + await execFileAsync("zig", [ + "cc", + "-target", + "wasm32-wasi", + "-std=c11", + "-O2", + "-D_GNU_SOURCE", + "-D_WASI_EMULATED_SIGNAL", + "-D_WASI_EMULATED_PROCESS_CLOCKS", + "-Werror=shift-count-overflow", + "-c", + "-o", + join(testDir, "build", "scr_json.wasm32.o"), + join(testDir, "../src/scr_json.c"), + ]); +}); diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index 160083d6b..0ab827e47 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -557,6 +557,24 @@ static void check_program_growth_cap(void) { scr_cyc_set_growth_cap(0); } +/* scr_cyc_set_nursery_threshold sets the trigger SCR_CYCLE_THRESHOLD sets, + * from the end of the next pass: with a trigger of 1024, dead rings released + * after a full pass wait until the 1024th, which collects them all. 0 + * restores the default. */ +static void check_program_nursery_threshold(void) { + enum { THRESHOLD = 1024 }; + if (getenv("SCR_CYCLE_THRESHOLD") != NULL) return; /* it takes precedence */ + scr_cyc_set_nursery_threshold(THRESHOLD); + scr_collect_cycles(); /* re-arm with the new trigger */ + size_t before = freed; + for (size_t i = 0; i < THRESHOLD - 1; i++) release_live(make_ring(1)); + check(freed == before, "a program's nursery trigger did not hold the pass back"); + release_live(make_ring(1)); + check(freed - before == THRESHOLD, "a program's nursery trigger did not run the pass"); + scr_cyc_set_nursery_threshold(0); + scr_collect_cycles(); +} + /* The phase sweep (scr_collect_cycles_phase) is for a caller that knows a * phase's data has just died: it sweeps, and the growth schedule starts * over. After passes that find nothing have widened the old level's @@ -1522,6 +1540,7 @@ int main(void) { check_unjudged_backlog_follows_growth(); check_acyclic_objects(); check_program_growth_cap(); + check_program_nursery_threshold(); printf("cycle collection checks passed: threshold=%zu growth_cap=%zu\n", configured_nursery_threshold(), configured_growth_cap()); return 0; diff --git a/packages/runtime/test/test_string.c b/packages/runtime/test/test_string.c index e49d8eb8b..3e14a5958 100644 --- a/packages/runtime/test/test_string.c +++ b/packages/runtime/test/test_string.c @@ -522,6 +522,30 @@ static void short_string_asserts(void) { scr_str_release(source); } +/* A loop's publish converts a non-ASCII receiver before any read, while a + * lone read still walks bytes. ASCII receivers publish nothing. */ +static void scan_units_asserts(void) { + ScrStr *s = scr_str_new("a\xC3\xA9\xF0\x9F\x98\x80" "b", 8); /* a é 😀 b */ + static const double codes[] = {97, 233, 0xD83D, 0xDE00, 98}; + if (scr_str_char_code_at(s, 0) != 97 || scr_str_scan.s == s) failed++; + scr_str_scan_units(s); + if (scr_str_scan.s != s || scr_str_scan.n != 5) failed++; + for (size_t i = 0; i < 5; i++) + if (scr_str_scan.n == 5 && (scr_str_scan.units[i] != codes[i] || + scr_str_char_code_at(s, (double)i) != codes[i])) failed++; + if (!isnan(scr_str_char_code_at(s, 5))) failed++; + scr_str_scan_units(s); /* republishing keeps the same units */ + const uint16_t *units = scr_str_scan.units; + scr_str_scan_units(s); + if (scr_str_scan.s != s || scr_str_scan.units != units) failed++; + ScrStr *ascii = scr_str_new("plain", 5); + scr_str_scan_units(ascii); + if (scr_str_scan.s != s) failed++; + scr_str_release(ascii); + scr_str_release(s); + if (scr_str_scan.s != NULL) failed++; + total++; +} #ifdef SCR_SIDX_TEST static void sidx_fail(const char *what) { failed++; @@ -1153,6 +1177,7 @@ int main(int argc, char **argv) { split_scratch_asserts(); accumulation_asserts(); short_string_asserts(); + scan_units_asserts(); #ifdef SCR_SIDX_TEST sparse_index_asserts(); sparse_ascii_prefix_asserts(); diff --git a/scripts/bench-runtime.mjs b/scripts/bench-runtime.mjs index b45e3ebdf..d89a4e759 100644 --- a/scripts/bench-runtime.mjs +++ b/scripts/bench-runtime.mjs @@ -180,6 +180,16 @@ function generateInput(kind) { ["sku-" + i, "category-" + (i % 17), "product " + i, i % 40, 199 + (i % 5000)].join("\t"), ).join("\n") + "\n", ); + } else if (kind === "line-reader") { + // About 67 MB of access-log lines; every 50th path is not ASCII. + writeFileSync( + path, + Array.from( + { length: 600_000 }, + (_, i) => + `10.${i % 251}.${i % 7}.${i % 13} - - [24/Oct/2026:${String(i % 24).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}:00 +0000] "${i % 9 === 0 ? "POST" : "GET"} /${i % 50 === 0 ? "caf\u00e9" : "api"}/items/${i % 997}?page=${i % 31} HTTP/1.1" ${i % 23 === 0 ? 404 : 200} ${(i * 7919) % 50000} "scriptc-bench/1.0"`, + ).join("\n") + "\n", + ); } else if (kind === "cli-config") { writeFileSync(path, JSON.stringify(deployManifest(), null, 2) + "\n"); } else throw new Error(`unknown input kind ${kind}`); diff --git a/scripts/build-native-cli.mts b/scripts/build-native-cli.mts index e539cea1d..4d3f00ff5 100644 --- a/scripts/build-native-cli.mts +++ b/scripts/build-native-cli.mts @@ -17,9 +17,16 @@ const sanitize = process.env["SCRIPTC_SAN"] === "1"; // SCRIPTC_NATIVE_PROFILE_GENERATE= builds a compiler that writes a // raw profile per run (scripts/refresh-cli-profile.mjs). Optimized builds // otherwise use the committed profile, or the indexed profile that -// SCRIPTC_NATIVE_PROFILE_USE names; an empty SCRIPTC_NATIVE_PROFILE_USE -// builds without one. +// SCRIPTC_NATIVE_PROFILE_USE names (gzip-compressed when the name ends in +// .gz, as refresh-cli-profile.mjs writes it); an empty +// SCRIPTC_NATIVE_PROFILE_USE builds without one. const profileGenerate = process.env["SCRIPTC_NATIVE_PROFILE_GENERATE"] || undefined; +// Below this share of matching functions, the build warns that the profile +// has lost most of its gain. A profile refreshed on the same commit matched +// 92.7% of the native compiler's profiled functions, one refreshed 1.5 days +// and about 200 commits earlier 87.2%, and that one kept about half of the +// fresh profile's gain on a cold tsc-ts build. +const PROFILE_MATCH_WARNING = 0.9; const committedProfile = join(root, "packages/compiler/native/cli.profdata.gz"); const requestedProfile = process.env["SCRIPTC_NATIVE_PROFILE_USE"]; const useProfile = @@ -45,10 +52,11 @@ const seed = join(dirname(output), ".scriptc", basename(output) + "-seed"); for (const dir of [bin, lib, seed]) mkdirSync(dir, { recursive: true }); let profileUse: string | undefined; if (useProfile) { - profileUse = requestedProfile; - if (profileUse === undefined) { + profileUse = requestedProfile ?? committedProfile; + if (profileUse.endsWith(".gz")) { + const compressed = profileUse; profileUse = join(seed, "cli.profdata"); - writeFileSync(profileUse, gunzipSync(readFileSync(committedProfile))); + writeFileSync(profileUse, gunzipSync(readFileSync(compressed))); } } const compiler = process.env["SCRIPTC_CC"] ?? target.defaultLinker; @@ -127,6 +135,25 @@ if (!result.ok) ) .join("\n"), ); +// How well the profile still describes the compiler: a function whose +// control flow changed since the profile was collected is optimized as +// without one. The share falls as compiler code changes, and with it the +// profile's gain (refresh it with scripts/refresh-cli-profile.mjs). +const match = useProfile && "artifact" in result ? result.artifact.profileMatch : undefined; +if (match !== undefined) { + const profiled = match.annotated + match.mismatched; + const share = profiled === 0 ? 0 : match.annotated / profiled; + console.log( + `native compiler profile: ${match.annotated} of ${profiled} profiled functions match (${(100 * share).toFixed(1)}%); ${match.missing} functions have no profile` + + (match.unknownPartitions > 0 + ? `; ${match.unknownPartitions} partitions reused without counts` + : ""), + ); + if (match.unknownPartitions === 0 && share < PROFILE_MATCH_WARNING) + console.warn( + `warning: the native compiler profile matches ${(100 * share).toFixed(1)}% of the functions it describes; run scripts/refresh-cli-profile.mjs`, + ); +} const compilerVersion = ( JSON.parse(readFileSync(join(root, "packages/compiler/package.json"), "utf8")) as { version: string; diff --git a/scripts/refresh-cli-profile.mjs b/scripts/refresh-cli-profile.mjs index e9554e911..1285b5503 100644 --- a/scripts/refresh-cli-profile.mjs +++ b/scripts/refresh-cli-profile.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node -/* Refreshes packages/compiler/native/cli.profdata.gz, the profile that release - * builds of the native compiler optimize with (scripts/build-native-cli.mts). +/* Refreshes packages/compiler/native/cli.profdata.gz, the profile that builds + * of the native compiler optimize with (scripts/build-native-cli.mts). The + * release workflow's cli-profile job runs it with --output on the release + * commit, and release builds use that profile instead of the committed one. * * Builds an instrumented native compiler for this Linux host, runs it on the * training builds below, and merges the raw profiles it writes into one @@ -26,7 +28,7 @@ import { writeFileSync, } from "node:fs"; import { cpus, tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { constants, gzipSync } from "node:zlib"; @@ -55,6 +57,9 @@ const tscTsPackage = join(tscTs, "packages/tsc-ts"); if (!existsSync(join(tscTsPackage, "src/main.ts"))) throw new Error(`${tscTs} is not a tsc-ts checkout with installed dependencies`); const jobs = Number(values.jobs); +// The output's directory is created up front, before the instrumented build. +const profileOutput = resolve(values.output); +mkdirSync(dirname(profileOutput), { recursive: true }); const work = resolve(values.work ?? mkdtempSync(join(tmpdir(), "scriptc-cli-profile-"))); const raw = join(work, "raw"); const builds = join(work, "builds"); @@ -196,10 +201,12 @@ const merged = spawnSync(scriptc, ["merge-profiles", "--sparse", "-o", indexed, }); if (merged.status !== 0) throw new Error("merge-profiles failed"); // The indexed profile compresses to about an eighth of its size. -const output = resolve(values.output); -writeFileSync(output, gzipSync(readFileSync(indexed), { level: constants.Z_BEST_COMPRESSION })); +writeFileSync( + profileOutput, + gzipSync(readFileSync(indexed), { level: constants.Z_BEST_COMPRESSION }), +); console.log( - `wrote ${output} (${statSync(output).size} bytes; ${statSync(indexed).size} uncompressed)`, + `wrote ${profileOutput} (${statSync(profileOutput).size} bytes; ${statSync(indexed).size} uncompressed)`, ); // A work directory the caller named stays for inspection. if (values.work === undefined) rmSync(work, { recursive: true, force: true }); diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index a1ab3ca60..7ae33d64a 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -395,7 +395,9 @@ export function dynamicTierSymbols(names) { * bench/channel-messages and bench/worker-messages, added later, link them * through worker messages: * node:worker_threads values are dyn objects. bench/json-unions, added - * later too, decodes JSON.parse trees. bench/dynamic-methods and + * later too, decodes JSON.parse trees. bench/throw-chains, also added + * later, tests caught values with `instanceof`, as bench/validate-errors + * does. bench/dynamic-methods and * bench/dynamic-properties are untyped JavaScript. Every other bench workload is fully typed and must link none. The gate and * tests/harness/bench-dynamic-tier.test.ts enforce that, and the test also * fails when a listed workload stops linking them, so remove a workload here @@ -416,7 +418,9 @@ export const DYNAMIC_WORKLOADS = new Set([ "bench/typed-array-scan-workers", "bench/log-summary", "bench/inventory-report", + "bench/line-reader", "bench/json-unions", + "bench/throw-chains", ]); /** Names of the functions (text and weak symbols) in `nm` output. Mach-O diff --git a/tests/corpus/6308-number-map-integer-keys.ts b/tests/corpus/6308-number-map-integer-keys.ts new file mode 100644 index 000000000..75efb3a72 --- /dev/null +++ b/tests/corpus/6308-number-map-integer-keys.ts @@ -0,0 +1,77 @@ +// Number-keyed Map and Set operations whose keys the compiler knows are +// integers (loop counters, `| 0` results, typed-array bytes, integer +// arithmetic) test the map's direct index on the integer. Results must +// match Node while the index covers the keys, after a key outside it moves +// the map to hashing, for negative keys and for keys past the index, and +// when a double key and an integer key name the same entry. + +const counts = new Map(); +for (let i = 0; i < 200; i++) { + const key = (i * 7) % 13; + counts.set(key, (counts.get(key) ?? 0) + 1); +} +const dense: string[] = []; +for (let k = -2; k < 16; k++) dense.push(`${k}:${counts.has(k) ? counts.get(k) : "-"}`); +console.log(dense.join(" ")); + +// A key past the direct index moves the map to hashing; integer reads still +// find every key. +counts.set(1_000_000, 5); +let found = 0; +for (let k = -2; k < 16; k++) if (counts.has(k)) found += counts.get(k)!; +console.log(found, counts.get(1_000_000), counts.has(999_999), counts.size); + +// Negative keys, and keys built with `| 0` and shifts. +const signed = new Map(); +for (let i = -5; i <= 5; i++) signed.set((i * 3) | 0, `v${i}`); +const reads: string[] = []; +for (let i = -16; i <= 16; i += 4) reads.push(`${i}:${signed.get(i) ?? "-"}`); +console.log(reads.join(" "), signed.get(-0), signed.get(0)); + +// Bytes of a typed array as keys, in a Set. +const bytes = new Uint8Array([3, 250, 3, 0, 17, 250, 255, 0]); +const seen = new Set(); +let repeats = 0; +for (let i = 0; i < bytes.length; i++) { + if (seen.has(bytes[i]!)) repeats++; + seen.add(bytes[i]!); +} +console.log([...seen].join(","), repeats); + +// A double key and an integer key name the same entry. +const mixed = new Map(); +mixed.set(2.0, 1); +mixed.set(0.5, 2); +for (let i = 0; i < 4; i++) mixed.set(i, (mixed.get(i) ?? 0) + 10); +console.log([...mixed.entries()].map(([k, v]) => `${k}=${v}`).join(" "), mixed.get(0.5)); + +// Deletion through integer keys, then reinsertion order. +const order = new Map(); +for (let i = 0; i < 10; i++) order.set(i, i * i); +for (let i = 0; i < 10; i += 3) order.delete(i); +for (let i = 9; i >= 0; i -= 2) order.set(i, -i); +console.log([...order.entries()].map(([k, v]) => `${k}=${v}`).join(" ")); + +// Large integer keys near 2^53, from integer arithmetic. +const wide = new Map(); +let key = 2 ** 52; +for (let i = 0; i < 5; i++) { + wide.set(key, i); + key = key * 2 - 1; +} +console.log([...wide.keys()].join(" "), wide.get(2 ** 52), wide.get(2 ** 53 - 1)); + +// A lookup whose key is an assignment of a wide integer constant, in a +// branch, then a lookup of the same constant after the branch. +const far = new Map(); +far.set(1099511627776, 7); +function probeFar(taken: boolean): number { + let k = 0; + let hits = 0; + if (taken) { + if (far.has((k = 1099511627776))) hits += 1; + } + hits += far.get(1099511627776) ?? -1; + return hits + k; +} +console.log(probeFar(true), probeFar(false)); diff --git a/tests/corpus/6611-child-properties-across-modules/definer.cjs b/tests/corpus/6611-child-properties-across-modules/definer.cjs index 8b1c8f1a3..ea58bc046 100644 --- a/tests/corpus/6611-child-properties-across-modules/definer.cjs +++ b/tests/corpus/6611-child-properties-across-modules/definer.cjs @@ -21,5 +21,5 @@ module.exports = function run() { console.log(e instanceof TypeError, e.message.length, e.message.slice(0, 30), e.message.slice(-40)); } child.on("close", (code) => console.log("close", code, child.killedBy)); - child.kill("SIGTERM"); + child.stdout.once("data", () => child.kill("SIGTERM")); }; diff --git a/tests/corpus/6611-child-properties-across-modules/main.cjs b/tests/corpus/6611-child-properties-across-modules/main.cjs index a3f422c64..3dfb6756a 100644 --- a/tests/corpus/6611-child-properties-across-modules/main.cjs +++ b/tests/corpus/6611-child-properties-across-modules/main.cjs @@ -5,17 +5,24 @@ // and writes a getter-only property with a long name. const patched = require("./patcher.cjs"); const definer = require("./definer.cjs"); -const child = patched(); -console.log(child.label); -child.label = "x"; -console.log(child.stored, child.same); -try { - child.same = 1; -} catch (e) { - console.log(e instanceof TypeError, e.message); +if (process.argv.at(2) === "child") { + // The child reports that it started, then waits for the parent's SIGTERM. + // It exits after 30 seconds without one. + process.stdout.write("ready"); + setTimeout(() => {}, 30000); +} else { + const child = patched(); + console.log(child.label); + child.label = "x"; + console.log(child.stored, child.same); + try { + child.same = 1; + } catch (e) { + console.log(e instanceof TypeError, e.message); + } + child.on("close", (code) => { + console.log("close", code, child.killedBy); + }); + child.stdout.once("data", () => child.kill("SIGTERM")); + child.on("close", () => definer()); } -child.on("close", (code) => { - console.log("close", code, child.killedBy); -}); -child.kill("SIGTERM"); -child.on("exit", () => definer()); diff --git a/tests/corpus/6611-child-properties-across-modules/spawner.cjs b/tests/corpus/6611-child-properties-across-modules/spawner.cjs index d0c52d9b7..5701145a8 100644 --- a/tests/corpus/6611-child-properties-across-modules/spawner.cjs +++ b/tests/corpus/6611-child-properties-across-modules/spawner.cjs @@ -1,5 +1,5 @@ "use strict"; const { spawn } = require("child_process"); module.exports = function start() { - return spawn(process.execPath, ["-e", "process.stdout.write('ok')"]); + return spawn(process.execPath, [process.argv[1], "child"]); }; diff --git a/tests/corpus/6928-class-iterator-protocol.ts b/tests/corpus/6928-class-iterator-protocol.ts new file mode 100644 index 000000000..2c2eecccc --- /dev/null +++ b/tests/corpus/6928-class-iterator-protocol.ts @@ -0,0 +1,112 @@ +// for-of and spread over classes whose [Symbol.iterator]() returns an iterator object, +// with return() called on early exits as Node's IteratorClose does. +class Bag { + readonly items: string[]; + constructor(items: string[]) { this.items = items; } + [Symbol.iterator]() { + let i = 0; + const items = this.items; + return { + next(): IteratorResult { + return i < items.length ? { value: items[i++]!, done: false } : { value: undefined, done: true }; + }, + return(): IteratorResult { + console.log("return at", i); + return { value: undefined, done: true }; + }, + }; + } +} +const bag = new Bag(["a", "b", "c", "d"]); +function firstLong(): string | undefined { + for (const s of bag) if (s >= "b") return s; + return undefined; +} +console.log(firstLong()); +try { + for (const s of bag) if (s === "c") throw new Error("stop at " + s); +} catch (e) { + console.log((e as Error).message); +} +outer: for (const n of [1, 2]) { + for (const s of bag) { + if (s === "b") continue outer; + console.log(n, s); + } +} +let all = ""; +for (const s of bag) all += s; +console.log(all); + +class Counter { + n = 0; + [Symbol.iterator](): CounterIterator { return new CounterIterator(3); } +} +class CounterIterator { + i = 0; + readonly limit: number; + constructor(limit: number) { this.limit = limit; } + next(): { value: number; done: boolean } { + return this.i < this.limit ? { value: this.i++, done: false } : { value: -1, done: true }; + } + return(): { value: number; done: boolean } { + console.log("class return", this.i); + return { value: -1, done: true }; + } +} +for (const v of new Counter()) { if (v === 1) break; console.log("v", v); } +console.log([...new Counter()].join()); + +class Fussy { + [Symbol.iterator]() { + let i = 0; + return { + next(): IteratorResult { return { value: i++, done: i > 5 }; }, + return(): IteratorResult { throw new Error("return failed"); }, + }; + } +} +try { + for (const v of new Fussy()) if (v === 1) break; +} catch (e) { + console.log("break:", (e as Error).message); +} +try { + for (const v of new Fussy()) if (v === 1) throw new Error("body failed"); +} catch (e) { + console.log("throw:", (e as Error).message); +} + +class Range { + readonly n: number; + constructor(n: number) { this.n = n; } + [Symbol.iterator]() { + let i = 0; + const n = this.n; + return { + next(): IteratorResult { return i < n ? { value: i++, done: false } : { value: undefined, done: true }; }, + }; + } +} +let total = 0; +for (const v of new Range(4)) total += v; +console.log(total, [...new Range(3)].join()); + +// An object-literal iterator that replaces its next() while it runs: the +// loop and the spread keep calling the next() they read when it opened. +class Swapping { + [Symbol.iterator]() { + let i = 0; + const it = { + next(): { value: number; done: boolean } { + i++; + if (i === 2) it.next = () => ({ value: -1, done: true }); + return { value: i, done: i > 4 }; + }, + }; + return it; + } +} +const swapped: number[] = []; +for (const v of new Swapping()) swapped.push(v); +console.log(swapped.join(), [...new Swapping()].join()); diff --git a/tests/corpus/6929-class-iterator-protocol.mjs b/tests/corpus/6929-class-iterator-protocol.mjs new file mode 100644 index 000000000..6a7827f15 --- /dev/null +++ b/tests/corpus/6929-class-iterator-protocol.mjs @@ -0,0 +1,97 @@ +// for-of and spread in JavaScript over classes whose [Symbol.iterator]() returns an +// iterator object, with return() called on early exits as Node's IteratorClose does. +class Bag { + + constructor(items) { this.items = items; } + [Symbol.iterator]() { + let i = 0; + const items = this.items; + return { + next() { + return i < items.length ? { value: items[i++], done: false } : { value: undefined, done: true }; + }, + return() { + console.log("return at", i); + return { value: undefined, done: true }; + }, + }; + } +} +const bag = new Bag(["a", "b", "c", "d"]); +function firstLong() { + for (const s of bag) if (s >= "b") return s; + return undefined; +} +console.log(firstLong()); +try { + for (const s of bag) if (s === "c") throw new Error("stop at " + s); +} catch (e) { + console.log(e.message); +} +outer: for (const n of [1, 2]) { + for (const s of bag) { + if (s === "b") continue outer; + console.log(n, s); + } +} +let all = ""; +for (const s of bag) all += s; +console.log(all); + +class Counter { + n = 0; + [Symbol.iterator]() { return new CounterIterator(3); } +} +class CounterIterator { + i = 0; + + constructor(limit) { this.limit = limit; } + next() { + return this.i < this.limit ? { value: this.i++, done: false } : { value: -1, done: true }; + } + return() { + console.log("class return", this.i); + return { value: -1, done: true }; + } +} +for (const v of new Counter()) { if (v === 1) break; console.log("v", v); } +console.log([...new Counter()].join()); + +class Fussy { + [Symbol.iterator]() { + let i = 0; + return { + next() { return { value: i++, done: i > 5 }; }, + return() { if (i >= 0) throw new Error("return failed"); return { value: undefined, done: true }; }, + }; + } +} +try { + for (const v of new Fussy()) if (v === 1) break; +} catch (e) { + console.log("break:", e.message); +} +try { + for (const v of new Fussy()) if (v === 1) throw new Error("body failed"); +} catch (e) { + console.log("throw:", e.message); +} + +// An object-literal iterator that replaces its next() while it runs: the +// loop and the spread keep calling the next() they read when it opened. +class Swapping { + [Symbol.iterator]() { + let i = 0; + const it = { + next() { + i++; + if (i === 2) it.next = () => ({ value: -1, done: true }); + return { value: i, done: i > 4 }; + }, + }; + return it; + } +} +const swapped = []; +for (const v of new Swapping()) swapped.push(v); +console.log(swapped.join(), [...new Swapping()].join()); diff --git a/tests/corpus/6935-class-async-iterator-protocol.ts b/tests/corpus/6935-class-async-iterator-protocol.ts new file mode 100644 index 000000000..25626a773 --- /dev/null +++ b/tests/corpus/6935-class-async-iterator-protocol.ts @@ -0,0 +1,100 @@ +// for await over classes whose [Symbol.asyncIterator]() or [Symbol.iterator]() +// returns an iterator object: early exits await return(), as in Node. +class Ticks { + readonly n: number; + constructor(n: number) { this.n = n; } + [Symbol.asyncIterator]() { + let i = 0; + const n = this.n; + return { + async next(): Promise> { + return i < n ? { value: i++, done: false } : { value: undefined, done: true }; + }, + async return(): Promise> { + console.log("closed at", i); + return { value: undefined, done: true }; + }, + }; + } +} +class Pages { + [Symbol.asyncIterator](): PageCursor { return new PageCursor(); } +} +class PageCursor { + page = 0; + async next(): Promise<{ value: string; done: boolean }> { + this.page++; + return this.page <= 3 ? { value: "page " + this.page, done: false } : { value: "", done: true }; + } +} +class Letters { + [Symbol.iterator]() { + const letters = ["x", "y", "z"]; + let i = 0; + return { + next(): IteratorResult { return i < letters.length ? { value: letters[i++]!, done: false } : { value: undefined, done: true }; }, + }; + } +} +async function main(): Promise { + let sum = 0; + for await (const v of new Ticks(4)) sum += v * 2; + console.log("sum", sum); + for await (const v of new Ticks(5)) { + if (v === 2) break; + console.log("early", v); + } + try { + for await (const v of new Ticks(5)) if (v === 1) throw new Error("body " + v); + } catch (e) { + console.log("caught", (e as Error).message); + } + async function find(): Promise { + for await (const v of new Ticks(9)) if (v > 2) return v; + return -1; + } + console.log("found", await find()); + for await (const p of new Pages()) console.log(p); + for await (const l of new Letters()) console.log("letter", l); + // return()'s value is awaited when the loop leaves early: a pending + // promise settles first, and a rejection rejects the loop after a break + // (after a throw from the body, the body's error wins). + for await (const v of new Closing((resolve) => setTimeout(() => resolve(1), 5))) + if (v === 1) break; + console.log("after break"); + try { + for await (const v of new Closing((_, reject) => reject(new Error("closing failed")))) + if (v === 1) break; + } catch (e) { + console.log("break:", (e as Error).message); + } + try { + for await (const v of new Closing((_, reject) => reject(new Error("closing failed")))) + if (v === 1) throw new Error("body failed"); + } catch (e) { + console.log("throw:", (e as Error).message); + } +} +class Closing { + readonly settle: (resolve: (v: number) => void, reject: (e: Error) => void) => void; + constructor(settle: (resolve: (v: number) => void, reject: (e: Error) => void) => void) { + this.settle = settle; + } + [Symbol.iterator]() { + let i = 0; + const settle = this.settle; + return { + next(): IteratorResult { + return { value: i++, done: false }; + }, + return(): IteratorResult { + console.log("return called"); + const value = new Promise((resolve, reject) => + settle((v) => (console.log("value settled"), resolve(v)), reject), + ); + return { value: value as unknown as number, done: true }; + }, + }; + } +} +main(); diff --git a/tests/corpus/6936-class-async-iterator-protocol.mjs b/tests/corpus/6936-class-async-iterator-protocol.mjs new file mode 100644 index 000000000..4cc67b0d1 --- /dev/null +++ b/tests/corpus/6936-class-async-iterator-protocol.mjs @@ -0,0 +1,99 @@ +// for await in JavaScript over classes whose [Symbol.asyncIterator]() or [Symbol.iterator]() +// returns an iterator object: early exits await return(), as in Node. +class Ticks { + + constructor(n) { this.n = n; } + [Symbol.asyncIterator]() { + let i = 0; + const n = this.n; + return { + async next() { + return i < n ? { value: i++, done: false } : { value: undefined, done: true }; + }, + async return() { + console.log("closed at", i); + return { value: undefined, done: true }; + }, + }; + } +} +class Pages { + [Symbol.asyncIterator]() { return new PageCursor(); } +} +class PageCursor { + page = 0; + async next() { + this.page++; + return this.page <= 3 ? { value: "page " + this.page, done: false } : { value: "", done: true }; + } +} +class Letters { + [Symbol.iterator]() { + const letters = ["x", "y", "z"]; + let i = 0; + return { + next() { return i < letters.length ? { value: letters[i++], done: false } : { value: undefined, done: true }; }, + }; + } +} +async function main() { + let sum = 0; + for await (const v of new Ticks(4)) sum += v * 2; + console.log("sum", sum); + for await (const v of new Ticks(5)) { + if (v === 2) break; + console.log("early", v); + } + try { + for await (const v of new Ticks(5)) if (v === 1) throw new Error("body " + v); + } catch (e) { + console.log("caught", e.message); + } + async function find() { + for await (const v of new Ticks(9)) if (v > 2) return v; + return -1; + } + console.log("found", await find()); + for await (const p of new Pages()) console.log(p); + for await (const l of new Letters()) console.log("letter", l); + // return()'s value is awaited when the loop leaves early: a pending + // promise settles first, and a rejection rejects the loop after a break + // (after a throw from the body, the body's error wins). + for await (const v of new Closing((resolve) => setTimeout(() => resolve(1), 5))) + if (v === 1) break; + console.log("after break"); + try { + for await (const v of new Closing((_, reject) => reject(new Error("closing failed")))) + if (v === 1) break; + } catch (e) { + console.log("break:", e.message); + } + try { + for await (const v of new Closing((_, reject) => reject(new Error("closing failed")))) + if (v === 1) throw new Error("body failed"); + } catch (e) { + console.log("throw:", e.message); + } +} +class Closing { + constructor(settle) { + this.settle = settle; + } + [Symbol.iterator]() { + let i = 0; + const settle = this.settle; + return { + next() { + return { value: i++, done: false }; + }, + return() { + console.log("return called"); + const value = new Promise((resolve, reject) => + settle((v) => (console.log("value settled"), resolve(v)), reject), + ); + return { value, done: true }; + }, + }; + } +} +main(); diff --git a/tests/corpus/6936-x509-certificate-fields.ts b/tests/corpus/6936-x509-certificate-fields.ts new file mode 100644 index 000000000..de4b78f9a --- /dev/null +++ b/tests/corpus/6936-x509-certificate-fields.ts @@ -0,0 +1,189 @@ +// X509Certificate fields match Node: subject and issuer (one attribute +// per line, RFC 2253 escaping), subjectAltName and infoAccess (undefined +// without the extension), serialNumber, the SHA-1/256/512 fingerprints, ca, +// the signature algorithm, the validity window as strings and Dates, and +// raw, over PEM Buffer, PEM string and DER input. +import * as crypto from "node:crypto"; + +// An RSA CA with a multi-attribute subject. +const CA = `-----BEGIN CERTIFICATE----- +MIIEITCCAwmgAwIBAgIIASNFZ4mrze8wDQYJKoZIhvcNAQELBQAwgZwxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNp +c2NvMRQwEgYDVQQKDAtFeGFtcGxlIE9yZzERMA8GA1UECwwIRGV2IFRlYW0xGDAW +BgNVBAMMD0V4YW1wbGUgUm9vdCBDQTEdMBsGCSqGSIb3DQEJARYOY2FAZXhhbXBs +ZS5jb20wIBcNMjYxMDIxMTYzMzUyWhgPMjEyNjA5MjcxNjMzNTJaMIGcMQswCQYD +VQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j +aXNjbzEUMBIGA1UECgwLRXhhbXBsZSBPcmcxETAPBgNVBAsMCERldiBUZWFtMRgw +FgYDVQQDDA9FeGFtcGxlIFJvb3QgQ0ExHTAbBgkqhkiG9w0BCQEWDmNhQGV4YW1w +bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0YGcCr0pxR8d +6qDdLuILcoT7vhtAjyHwfkoChnH6N3TLWzDUugnBp9NbDb7gDLqbRT4uTDoNb6zS +tEJZ4Q7/hhd0jDvoPmOy0Y3Kyj9uNsdf25GdbvwwEnlddELJCZprTZ4OhEg6w2s0 +Yy4SlviLzDXLmsggTypxeS+K8JHT9onMipi5L4GFu8n1QZbzXvSS4BkRgMXEyrtr +FOE9qSo2qkubFyd/WNKRTRIpTSc5/GGsqJ0FKDDJnN5adotcUucbJFn4noNnFUWh +M8mhBEj7ENDdJsD2EVFsI6H958jA2SbNNu4ngzAOs2AKNBYBfQK02ZQAdRNOSL3K +5FkfbII5LwIDAQABo2MwYTAdBgNVHQ4EFgQUP+yQyItpNYEls26Jfv+NnciGaK8w +HwYDVR0jBBgwFoAUP+yQyItpNYEls26Jfv+NnciGaK8wDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBABDw3Va/C+RFc/Hs +TJnHtQJtxFL0ZhnHUqg4DcDA3m+JDdIDNL8sNpLEbtn/pr0VeEWgG+MImWAQlrUW +IAZkLj9/Zg1wSO0oZ86ZyZJZmp54lUYXgG17CNl4g+F7VHt3bBwUJHTbWye/FKM6 +5OVZetCYKVj7Z+5BYuf7cumzvRqcn32fXidB/kjRX14SJ4JDPn2yFZWCr2ygIqWQ +SOuEmWbNAbYGaVZkQr2Ma8U28DWY2jb0f6nP8jLt49t9o6N/e6Fwd6I5t/mx9Mfj +0403omkxmr9vqN2kBamzVnun+2mDkRZcKPCr72Q4sfbvmDi/W5Nf1Iu2cQW8VfDO +SqpyT54= +-----END CERTIFICATE----- +`; + +// An EC leaf with alternative names, access descriptions and extended key usage, signed by CA. +const LEAF = `-----BEGIN CERTIFICATE----- +MIIDuzCCAqOgAwIBAgIPAPAN+s4AESIzRFVmd4iZMA0GCSqGSIb3DQEBDAUAMIGc +MQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2Fu +IEZyYW5jaXNjbzEUMBIGA1UECgwLRXhhbXBsZSBPcmcxETAPBgNVBAsMCERldiBU +ZWFtMRgwFgYDVQQDDA9FeGFtcGxlIFJvb3QgQ0ExHTAbBgkqhkiG9w0BCQEWDmNh +QGV4YW1wbGUuY29tMB4XDTI2MTAyMTE2MzM1MloXDTM2MTAxODE2MzM1MlowJzER +MA8GA1UECgwITGVhZiBPcmcxEjAQBgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABJVfVy9EH/VHQuRqpFFnXk0F00ncDht6APgVPDoybK8H +PlcM4T5H1yAOMHnlH29LK1jtMqU1aAOiiQRzPdCvqoujggE3MIIBMzAJBgNVHRME +AjAAMGgGA1UdEQRhMF+CCWxvY2FsaG9zdIINKi5leGFtcGxlLmNvbYcEfwAAAYcQ +AAAAAAAAAAAAAAAAAAAAAYERYWRtaW5AZXhhbXBsZS5jb22GGGh0dHBzOi8vZXhh +bXBsZS5jb20vcGF0aDBdBggrBgEFBQcBAQRRME8wIwYIKwYBBQUHMAGGF2h0dHA6 +Ly9vY3NwLmV4YW1wbGUuY29tMCgGCCsGAQUFBzAChhxodHRwOi8vY2EuZXhhbXBs +ZS5jb20vY2EuY3J0MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAdBgNV +HQ4EFgQUdKIx6ULFLPNP/kzwUFjSa5D19RswHwYDVR0jBBgwFoAUP+yQyItpNYEl +s26Jfv+NnciGaK8wDQYJKoZIhvcNAQEMBQADggEBABmQ8f2VsXNaMM9X1MEhi27D +LjwcOaaGEINNZq7xu/HL6UljMb1Odsuz66DkiH3Exd3MrWy6OlOpJJF0VzsqMsoD +0CRBxXF28o6Jil3vOGVwlYNmRidpkAkj4VyWDvv8FCS9VbsBtXaSHwu9wcCwePE0 +cGJRfTKoZ6KJ8vQ57DBEZ/ZszuhehOZYqrTmoUdjZ123kXdxQvK+ALn4SlIXHBhN +9unhd+8i00EmkXO/REw8paMcMVVGR+ioOEjyNHqZo6vkRqgpKggn1Yq8cTTDOMnS +P1o42EQZLlbuvx0kqInBqwqjDx4nZiPaifoDc9BF6iZPBvm8dhrEfghfB1tkNk0= +-----END CERTIFICATE----- +`; + +// Escaped subject characters, UTF-8 and a comma in a DNS name. +const ODD = `-----BEGIN CERTIFICATE----- +MIICXzCCAeWgAwIBAgIBATAKBggqhkjOPQQDAjBvMQ0wCwYDVQQDDARhLCBiMQ4w +DAYDVQQHDAUjbGVhZDESMBAGA1UECAwJICBzcGFjZSAgMSYwJAYDVQQKDB1RdW90 +ZSBxIGJhY2tcc2xhc2g7IHNlbWkgPGx0PjESMBAGA1UECwwJQ2Fmw6kg4oKsMB4X +DTI2MTAyMTE2MzM1MloXDTI3MTAyMTE2MzM1MlowbzENMAsGA1UEAwwEYSwgYjEO +MAwGA1UEBwwFI2xlYWQxEjAQBgNVBAgMCSAgc3BhY2UgIDEmMCQGA1UECgwdUXVv +dGUgcSBiYWNrXHNsYXNoOyBzZW1pIDxsdD4xEjAQBgNVBAsMCUNhZsOpIOKCrDB2 +MBAGByqGSM49AgEGBSuBBAAiA2IABMLLzwDsSeezCzScnC9xTIFl35dFAX+1Uagk +0y4Y72rOOgVFA13SMIbMfv2InIyg2vXOdqn0y7RqiaaqqnAYZB2RHHL0SD7t2MVL +K3BQajy7Oi6IeDDRGK1jM4D6nk/1AKNVMFMwMgYDVR0RBCswKYILYSxiLmV4YW1w +bGWCDXBsYWluLmV4YW1wbGWBBXhAeS56hwQKAAABMB0GA1UdDgQWBBSR4U6nIpTt +5rLoIZZxfxSyx9Uc9zAKBggqhkjOPQQDAgNoADBlAjEAlznFL0Nrt1jjZrT3/GN0 +yN/8gxO3SawyqoWrfP3uk1GScTh0PlrTEOrpSyuq3wl5AjBRntVI1ZoUM46pCwIw +0Vl3c3lns7OljcQFiR7o8yRhEW9ejdiR2W6PLBrjCly6U3I= +-----END CERTIFICATE----- +`; + +// A multi-valued RDN and serial number 0. +const MULTI = `-----BEGIN CERTIFICATE----- +MIICNzCCAaCgAwIBAgIBADANBgkqhkiG9w0BAQsFADA3MSgwCgYDVQQLDANyZG4w +DAYDVQQDDAVtdWx0aTAMBgNVBAoMBXRocmVlMQswCQYDVQQGEwJERTAeFw0yNjEw +MjExNjM0MDVaFw0yNjEwMzExNjM0MDVaMDcxKDAKBgNVBAsMA3JkbjAMBgNVBAMM +BW11bHRpMAwGA1UECgwFdGhyZWUxCzAJBgNVBAYTAkRFMIGfMA0GCSqGSIb3DQEB +AQUAA4GNADCBiQKBgQDdv64EOPdpcWdjVMAlEQvNzm3ZbQYXrhOwRF9yzQp25SuA +3uKvStZOt8hyTifAd30oDXIfgyYI39xKHEkHfARTV1E2Jwyiz86/pDHmFypPFJuI +v/vOjYLLeisiVi8TFfHgmoGqGvySR83fwiUnoOOWj9Bqt/5EVBQbBeWrU9qH6QID +AQABo1MwUTAdBgNVHQ4EFgQUZrGU0gkgqCkmegRtu6vQ3h7kpPUwHwYDVR0jBBgw +FoAUZrGU0gkgqCkmegRtu6vQ3h7kpPUwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG +9w0BAQsFAAOBgQAMzFkzJElc9VJYhGx7AngO8Yve22iCTOVLO3/be8w55klb/HJX +vfESQz+EJLYVaP5Q/uChoE0I27NytkeUSpJFwmjyqoQq2j6kJWGKONqhQSnXDrms +HMPA7iik/C5hOqpwzDZ98wNU/e86TrqZxzpFkwbnZx9MSfiGr690s5BfpA== +-----END CERTIFICATE----- +`; + +// An Ed25519 certificate with otherName, directoryName, registeredID and IPv6 names, a BMPString subject, a negative serial and keyUsage without keyCertSign. +const NAMES = `-----BEGIN CERTIFICATE----- +MIICSDCCAfqgAwIBAgIB+zAFBgMrZXAwLDEVMBMGA1UEAx4MAGIAbQBwACAA6U4t +MRMwEQYDVQQKHgoAcABsAGEAaQBuMB4XDTI2MTAyMTE2NDQzM1oXDTI2MTEyMDE2 +NDQzM1owLDEVMBMGA1UEAx4MAGIAbQBwACAA6U4tMRMwEQYDVQQKHgoAcABsAGEA +aQBuMCowBQYDK2VwAyEAP+2Vcp8nwu2AhFvSczYTxGs1jV/ckA76Jqvwj9QDGqij +ggE/MIIBOzCBtwYDVR0RBIGvMIGsoBsGCisGAQQBgjcUAgOgDQwLdXNlckBkb21h +aW6gEAYDKgMEoAkMB3Vua25vd26kRTBDMQswCQYDVQQGEwJVSzEZMBcGA1UECh4Q +AEQAaQByACwAIABPAHIAZzEZMBcGA1UEAx4QAGQAaQByACAAbgBhAG0AZYgEKgME +BYcQ/oAAAAAAAAAAAAAAAAEAAocQIAENuAAAAAAAAP8AAEKDKYYKaHR0cDovL3gv +cTAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIHgDBFBggrBgEFBQcBAQQ5MDcwIAYI +KwYBBQUHMAWGFGh0dHA6Ly9yZXBvLmV4YW1wbGUvMBMGBCoDBAWCC29kZC5leGFt +cGxlMB0GA1UdDgQWBBQYMJ0fQJ1Nx0PmatIfTJusAEA9WTAFBgMrZXADQQCixubE +nuunBZYYPXF52+joQSX/a5DFkOEDmzRTuH7IIpPFWPo37rmJOSdPA/PV8v9clzYF +LP3EjM4YOAatcbsO +-----END CERTIFICATE----- +`; + +// An RSA-PSS signature and a control character in the subject. +const PSS = `-----BEGIN CERTIFICATE----- +MIIDdTCCAiigAwIBAgICAIAwQgYJKoZIhvcNAQEKMDWgDzANBglghkgBZQMEAgEF +AKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEFAKIEAgIA3jAeMQwwCgYDVQQD +DANwc3MxDjAMBgNVBAoMBWN0bAF4MB4XDTI2MTAyMTE2NDQzM1oXDTI2MTEyMDE2 +NDQzM1owHjEMMAoGA1UEAwwDcHNzMQ4wDAYDVQQKDAVjdGwBeDCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAL2pno8Ybwihdv3hnMhWPPt85SXQmFAJpFoS +E+sT9H6FbwFCIyPkY+SWk8H7y8V+D+388tNvJZmGjX3uvWpK2Wx6anSB3/WMpkUR +2ExP4cpSxZNr9EFzC/cdqLvsxJcHMHkc/uJuFbtjsuiH1ygOe3nyhWSNiPwGdwHE +cKkBFkzW3j2C/4Jkgt4k+sMe3gy5litKtMi4zZB8ixCm5oGhveGhUZy0Kb01twgZ +RP4FEd32sSRqq3NCx4cB0kyreTuzmk6U7BkxcdPzaj6DifjRznTQmjMdoCNZQRCj +G3KBOMHHyvmV2B4lNGDuMlcSap/TpYq7n32AH5sDvELrUnPwVzsCAwEAAaNTMFEw +HQYDVR0OBBYEFC/QJvAQsbQ25sErc1To2G1VuNZ0MB8GA1UdIwQYMBaAFC/QJvAQ +sbQ25sErc1To2G1VuNZ0MA8GA1UdEwEB/wQFMAMBAf8wQgYJKoZIhvcNAQEKMDWg +DzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEFAKIE +AgIA3gOCAQEAu3lP2Q3Bb5fWUQtgFr/82Oke1ZRW/GRbj03mZ30izADHC7yk0OYm +02f7B5sub04Jk6yuRVmEBjJHSPNwAo0RJWtwvD77KLhG8aObr7Z3+pkfFvpGzqeS +MQ4FtK2MPps3kA1a5D4XLwkwDJtHWZVuVyXpPgxkptfLzZgrieKrpNyOKR8Pz/PO +Wlwv6bF7SXvAV8Vrd7KDvTqwQ5iq9UFhP6GeMDWezYtzLdjlvje7MhmZ96zgSY6m +qumNWOPHmiN4eoOE4M69B+0fa3WyMzubW0nVGM/sDhtS0k5zHs6Y4RLn11HolhXH +LvbvDgqtl/m5i0uHxEld+Z1uhzJscqFQRg== +-----END CERTIFICATE----- +`; + +// A version 1 certificate without extensions. +const V1 = `-----BEGIN CERTIFICATE----- +MIIBSTCB7wIUI3kYI5NpR07YqZe6RwOx22je9IowCgYIKoZIzj0EAwIwJzERMA8G +A1UECgwITGVhZiBPcmcxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNjEwMjExNjMz +NTJaFw0yNjExMjAxNjMzNTJaMCcxETAPBgNVBAoMCExlYWYgT3JnMRIwEAYDVQQD +DAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASVX1cvRB/1R0Lk +aqRRZ15NBdNJ3A4begD4FTw6MmyvBz5XDOE+R9cgDjB55R9vSytY7TKlNWgDookE +cz3Qr6qLMAoGCCqGSM49BAMCA0kAMEYCIQCFfFOw9qzzfSOQO1zRnT0sOXibjLWl +hHDyqecxhY7gtQIhAPMgqg+p1JXV5bgGpqgDfP3sE53luxMGlnAC39uhwBUh +-----END CERTIFICATE----- +`; + + +function show(label: string, cert: crypto.X509Certificate): void { + console.log(label, "subject", JSON.stringify(cert.subject)); + console.log(label, "issuer", JSON.stringify(cert.issuer)); + console.log(label, "subjectAltName", JSON.stringify(cert.subjectAltName), typeof cert.subjectAltName); + console.log(label, "infoAccess", JSON.stringify(cert.infoAccess), cert.infoAccess === undefined); + console.log(label, "serialNumber", cert.serialNumber); + console.log(label, "fingerprints", cert.fingerprint, cert.fingerprint256); + console.log(label, "fingerprint512", cert.fingerprint512); + console.log(label, "ca", cert.ca); + console.log(label, "signature", cert.signatureAlgorithm, cert.signatureAlgorithmOid); + console.log(label, "validity", cert.validFrom, "|", cert.validTo); + console.log(label, "dates", cert.validFromDate.toISOString(), cert.validToDate.getTime()); + console.log(label, "raw", cert.raw.length, Buffer.isBuffer(cert.raw), cert.raw.subarray(0, 4).toString("hex")); +} + +const pems: [string, string][] = [ + ["ca", CA], + ["leaf", LEAF], + ["odd", ODD], + ["multi", MULTI], + ["names", NAMES], + ["pss", PSS], + ["v1", V1], +]; +for (const [label, pem] of pems) show(label, new crypto.X509Certificate(Buffer.from(pem))); + +// A PEM string, and the DER bytes of raw. +const fromString = new crypto.X509Certificate(LEAF); +show("string", fromString); +const fromDer = new crypto.X509Certificate(fromString.raw); +console.log("der", fromDer.subject === fromString.subject, fromDer.fingerprint256 === fromString.fingerprint256); +console.log("der raw copy", fromDer.raw !== fromString.raw, fromDer.raw.equals(fromString.raw)); + +try { + new crypto.X509Certificate(Buffer.from("not a certificate")); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log("garbage", err.name, err.code); +} diff --git a/tests/corpus/6937-class-value-arrays.mjs b/tests/corpus/6937-class-value-arrays.mjs new file mode 100644 index 000000000..8fcfcb815 --- /dev/null +++ b/tests/corpus/6937-class-value-arrays.mjs @@ -0,0 +1,53 @@ +// Arrays of class values that mix a base class and its subclasses: the +// elements construct, compare and name like the classes themselves. +class Animal { + speak() { + return "..."; + } +} +class Dog extends Animal { + speak() { + return "woof"; + } +} +class Cat extends Animal { + speak() { + return "meow"; + } +} +const kinds = [Dog, Cat, Animal]; +console.log(kinds.length, kinds.map((C) => C.name).join()); +console.log(new kinds[0]().speak(), new kinds[1]().speak(), new kinds[2]().speak()); +console.log(kinds[0] === Dog, kinds.indexOf(Cat), kinds.includes(Animal)); +const made = kinds.map((C) => new C()); +console.log(made.map((a) => a.speak()).join(), made[0] instanceof Animal, made[1] instanceof Dog); +try { + new kinds[5](); +} catch (e) { + console.log(e.name, e.message); +} +const byName = { dog: Dog, cat: Cat }; +console.log(new byName.dog().speak(), new byName["cat"]().speak()); +// The arguments are evaluated before the missing element throws. +class Point { + constructor(x, y) { + this.sum = x + y; + } +} +class Point3 extends Point {} +const points = [Point3, Point]; +const evaluated = []; +const arg = (v) => (evaluated.push(v), v); +console.log(new points[0](arg(1), arg(2)).sum, evaluated.join()); +try { + new points[2](arg(3), arg(4)); +} catch (e) { + console.log(e.name, e.message, evaluated.join()); +} +// Spread arguments too, through a valid index and past the end. +console.log(new points[1](...[5, 6]).sum); +try { + new points[3](...[arg(7), arg(8)]); +} catch (e) { + console.log(e.name, e.message, evaluated.join()); +} diff --git a/tests/corpus/6938-class-value-arrays.ts b/tests/corpus/6938-class-value-arrays.ts new file mode 100644 index 000000000..db695bc0a --- /dev/null +++ b/tests/corpus/6938-class-value-arrays.ts @@ -0,0 +1,48 @@ +// Arrays of class values that mix a base class and its subclasses, with the +// element type the checker infers (the base class): elements construct and +// dispatch to the subclass, and an index past the end throws Node's TypeError. +class Shape { + area(): number { + return 0; + } +} +class Square extends Shape { + area(): number { + return 4; + } +} +class Circle extends Shape { + area(): number { + return 3; + } +} +const shapes = [Square, Circle, Shape]; +console.log(shapes.length, new shapes[0]().area(), new shapes[1]().area(), new shapes[2]().area()); +let total = 0; +for (const S of shapes) total += new S().area(); +console.log(total, shapes[1] === Circle, shapes.map((S) => S.name).join()); +try { + new shapes[3](); +} catch (e) { + console.log((e as Error).name, (e as Error).message); +} +// A class value checked into a `typeof Shape` slot: a subclass whose +// constructor takes the same arguments constructs, and one whose constructor +// takes an argument the slot does not pass throws a TypeError (Node's from +// reading the missing argument, the compiled program's from the check). +class Sized extends Shape { + label: string; + constructor(side: number) { + super(); + this.label = side.toFixed(1); + } +} +const values: any[] = [Square, Sized, Shape]; +for (const value of values) { + try { + const S: typeof Shape = value; + console.log(new S().area()); + } catch (e) { + console.log((e as Error).name); + } +} diff --git a/tests/corpus/7090-x509-check-names.ts b/tests/corpus/7090-x509-check-names.ts new file mode 100644 index 000000000..31ec7244f --- /dev/null +++ b/tests/corpus/7090-x509-check-names.ts @@ -0,0 +1,141 @@ +// X509Certificate.checkHost, checkEmail and checkIP match Node: the +// subject alternative names of the asked kind first, then the subject's +// common name or emailAddress when the certificate has none (or as the +// subject option says), the wildcard rules and their options, case rules, +// IPv4 and IPv6 parsing, and an invalid subject option. +import { X509Certificate, type X509CheckOptions } from "node:crypto"; + +// DNS names with every wildcard form, an IP address and an email name. +const WILD = `-----BEGIN CERTIFICATE----- +MIIB/TCCAaOgAwIBAgIBCTAKBggqhkjOPQQDAjAXMRUwEwYDVQQDDAwqLmNuLmV4 +YW1wbGUwIBcNMjYxMDIyMTAwMTMxWhgPMjEyNjA5MjgxMDAxMzFaMBcxFTATBgNV +BAMMDCouY24uZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABGJ1KKs0 +givD5gd5LXXbPc37WfT64D0ePORDm7dB9mxEYJ6K8IQxBKzn5xQT+e/lZZ1raRBZ +q/E8aW4cW1iZLXSjgd0wgdowgbgGA1UdEQSBsDCBrYIOZiouZXhhbXBsZS5jb22C +Dypvby5leGFtcGxlLm5ldIIXKi54bi0tYmNoZXIta3ZhLmV4YW1wbGWCDXhuLS0q +LmV4YW1wbGWCBSouY29tgg9hLiouZXhhbXBsZS5vcmeCASqCDyouTWl4ZWQuRXhh +bXBsZYIMLmRvdC5leGFtcGxlgg9iKnouZXhhbXBsZS5lZHWHBMCoAQGBEU1peGVk +QEV4YW1wbGUuQ09NMB0GA1UdDgQWBBR8lJ0Uw8GcVD1cGUSprHt346HjDDAKBggq +hkjOPQQDAgNIADBFAiALkTbNe6bIavd8T9qKlAqzmEPQrFRZUVoOHSQPI/zV5QIh +AM0et8JtbWP+4ySiUPPqCXtlup00GfhcqGXy+lSl32vL +-----END CERTIFICATE----- +`; + +// No alternative names; the subject has a wildcard CN and an emailAddress. +const CN = `-----BEGIN CERTIFICATE----- +MIIBVDCB+gIBCjAKBggqhkjOPQQDAjA1MRcwFQYDVQQDDA5zZWNvbmQuZXhhbXBs +ZTEaMBgGCSqGSIb3DQEJARYLYUBiLmV4YW1wbGUwIBcNMjYxMDIyMTAwMTMxWhgP +MjEyNjA5MjgxMDAxMzFaMDUxFzAVBgNVBAMMDnNlY29uZC5leGFtcGxlMRowGAYJ +KoZIhvcNAQkBFgthQGIuZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA +BGJ1KKs0givD5gd5LXXbPc37WfT64D0ePORDm7dB9mxEYJ6K8IQxBKzn5xQT+e/l +ZZ1raRBZq/E8aW4cW1iZLXQwCgYIKoZIzj0EAwIDSQAwRgIhAIE0IFtuCNllBodV +go5WWgb8h1McrS/o31V5K0JrpF7QAiEAil8AEHuMiVtef/txZsEKPPojDKfPTYC8 +zplMuFd9gQA= +-----END CERTIFICATE----- +`; + +// A leaf with DNS, email, IPv4, IPv6 and URI names. +const LEAF = `-----BEGIN CERTIFICATE----- +MIIDqjCCApKgAwIBAgICCwswDQYJKoZIhvcNAQELBQAwPjELMAkGA1UEBhMCVVMx +FDASBgNVBAoMC0V4YW1wbGUgT3JnMRkwFwYDVQQDDBBFeGFtcGxlIFJTQSBSb290 +MCAXDTI2MTAyMjA5NDY0MVoYDzIxMjYwOTI4MDk0NjQxWjBfMQswCQYDVQQGEwJV +UzEUMBIGA1UECgwLRXhhbXBsZSBPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNv +bTEgMB4GCSqGSIb3DQEJARYRYWRtaW5AZXhhbXBsZS5jb20wWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAARidSirNIIrw+YHeS112z3N+1n0+uA9HjzkQ5u3QfZsRGCe +ivCEMQSs5+cUE/nv5WWda2kQWavxPGluHFtYmS10o4IBWDCCAVQwCQYDVR0TBAIw +ADALBgNVHQ8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMHsG +A1UdEQR0MHKCDSouZXhhbXBsZS5jb22CC2V4YW1wbGUuY29tghV4bi0tYmNoZXIt +a3ZhLmV4YW1wbGWBD29wc0BleGFtcGxlLmNvbYcECgAAAYcQIAENuAAAAAAAAAAA +AAAAAYYUaHR0cHM6Ly9leGFtcGxlLmNvbS8wXgYIKwYBBQUHAQEEUjBQMCQGCCsG +AQUFBzABhhhodHRwOi8vb2NzcC5leGFtcGxlLmNvbS8wKAYIKwYBBQUHMAKGHGh0 +dHA6Ly9jYS5leGFtcGxlLmNvbS9jYS5jcnQwHQYDVR0OBBYEFHyUnRTDwZxUPVwZ +RKmse3fjoeMMMB8GA1UdIwQYMBaAFAgaWVveRbA7VKqcHzjMqBPzi1/LMA0GCSqG +SIb3DQEBCwUAA4IBAQAfMTcpidoH47PXOQVxRttfrG7K9BcntyXP4+5hUAoSfMr6 +Rxy7X/9ba7R0yCGlNsUCusObaBpbdvwX8v9TiTYKVL+C9rEVGrTHnh6kbgZaaCQc +L3oWbfRrYnEBYuSlFuadjVvX42aqzHV28E4ITgywCxwubu2hAbacbcUHpnRSqLDq +lV+VZSQA/6cPNct7PR6AkJU80D+5wVOk7RVi4rsfbw1wFDeksCHu+0EThs9zjzL+ +S7HTCjB6AOHpbu1Sx+0d5YEKSE7EPF+dSFATNbhjTHzYQySnHK+Lm8nON8wmY1rC +7opOhvUlid1jA1d2BKO2ZxlxLir4pyIehTC1ddG2 +-----END CERTIFICATE----- +`; + +// A self-signed certificate without alternative names. +const PLAIN = `-----BEGIN CERTIFICATE----- +MIIC8jCCAdoCAQcwDQYJKoZIhvcNAQELBQAwPjEaMBgGA1UEAwwRcGxhaW4uZXhh +bXBsZS5vcmcxIDAeBgkqhkiG9w0BCQEWEXBsYWluQGV4YW1wbGUub3JnMCAXDTI2 +MTAyMjA5NDY0MVoYDzIxMjYwOTI4MDk0NjQxWjA+MRowGAYDVQQDDBFwbGFpbi5l +eGFtcGxlLm9yZzEgMB4GCSqGSIb3DQEJARYRcGxhaW5AZXhhbXBsZS5vcmcwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq01NcNAy9Ng04mVuAFvmZUvsm +rY7aOaMgtODmptulgZ2qw25E09RgGYCn1S+4IYDu9hH9X4s+28Ag7Khmj4nmeoXN +of5095wche8DR2AAUElqyr/Nmgm2kTLdIJX4wl0eWXHIlmSPdOUNovA6JjGKH99b +8W7a2TqY/TsSadpPSvvN2esr7Jr4KgW7MXPUOnwNbcBOVcQP9rtFrduAWz01q0HM +02LboclnUnk/e58BXAj8vgdVojljDcc0bhbdz/Hh1/5gaaNILjJNOd4AgwZaCT02 +KojGz4Nb6MSgFmON2iMgAZjY1fXXkJ7wsfVPytjW73e7uV/RFuqSHQZenxupAgMB +AAEwDQYJKoZIhvcNAQELBQADggEBAJX1DGuiSLoCKBDI3oCgAu7X86G01ovEgZJr +8JrmGHW5q7ujjo53t46h8XPm3LJ8UV9/MZ+Yry43xB2txXYv3b3g/i+7JdTWLgUD +INA5VWvVkEZfm0lBEpU1Agpy4lV5uUCKZTgP/3MY5hnJ0YhQIs4BOjbwueMRFshL +yyHVb73MMbpWaZ7+T368seafa+ekeuY6gQly/HacuszjoYd6KKV3n8eDewenClCF +HoOF1Y4I+CvmR+a6B3iBczJ+a4tceFIIWsZUIeqzvGHpM7G9YwlmbvGprfLgR8Nv +igUib/D9tU3hdL6VWN5nMjdXV/8Le4nDnBjVBjYDkVbsnmT91AQ= +-----END CERTIFICATE----- +`; + +const wild = new X509Certificate(WILD); +const cn = new X509Certificate(Buffer.from(CN)); +const leaf = new X509Certificate(LEAF); +const plain = new X509Certificate(PLAIN); + +const hosts = [ + "foo.example.com", "f.example.com", "fx.example.com", "a.b.example.com", "foo.example.net", + "oo.example.net", "x.xn--bcher-kva.example", "xn--abc.example", "example.com", + "a.b.example.org", "x", "a.mixed.example", "A.MIXED.EXAMPLE", "a.dot.example", ".dot.example", + "dot.example", "bz.example.edu", "baz.example.edu", "b.z.example.edu", "x.cn.example", + "second.example", "192.168.1.1", ".example.com", ".mixed.example", "a.b.mixed.example", +]; +const opts: [string, X509CheckOptions | undefined][] = [ + ["default", undefined], + ["nowild", { wildcards: false }], + ["nopartial", { partialWildcards: false }], + ["multi", { multiLabelWildcards: true }], + ["single", { singleLabelSubdomains: true }], + ["always", { subject: "always" }], + ["never", { subject: "never" }], +]; +for (const [label, o] of opts) { + console.log(label, hosts.map((h) => `${h}=${wild.checkHost(h, o)}`).join(" ")); + console.log(label, "cn", hosts.map((h) => `${h}=${cn.checkHost(h, o)}`).join(" ")); +} + +for (const h of ["www.example.com", "example.com", "EXAMPLE.COM", "xn--bcher-kva.example", "", "foo.example.com."]) { + console.log("leaf", JSON.stringify(h), leaf.checkHost(h), leaf.checkHost(h, { subject: "always" })); +} +console.log("plain", plain.checkHost("plain.example.org"), plain.checkHost("plain.example.org", { subject: "never" })); +const found: string | undefined = leaf.checkHost("api.example.com"); +console.log("typed", found === undefined ? "none" : found.toUpperCase()); + +for (const e of ["Mixed@example.com", "mixed@example.com", "Mixed@EXAMPLE.COM", "a@b.example", "a@B.EXAMPLE", "A@b.example"]) { + console.log("email", e, wild.checkEmail(e), cn.checkEmail(e), cn.checkEmail(e, { subject: "never" })); +} +for (const e of ["ops@example.com", "OPS@EXAMPLE.COM", "admin@example.com"]) console.log("email", e, leaf.checkEmail(e)); +console.log("email plain", plain.checkEmail("plain@example.org"), plain.checkEmail("plain@example.org", { subject: "always" })); + +const ips = [ + "192.168.1.1", "192.168.001.001", "192.168.1.1.", "1.2.3", "::", "::ffff:192.168.1.1", + "0:0:0:0:0:0:0:0", "1::2::3", "fe80::1%eth0", " 192.168.1.1", "192.168.1.256", + "10.0.0.1", "2001:db8::1", "2001:DB8:0:0:0:0:0:1", "2001:db8:0::0:1", "::ffff:10.0.0.1", "", +]; +for (const ip of ips) { + try { + console.log("ip", JSON.stringify(ip), wild.checkIP(ip), leaf.checkIP(ip)); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log("ip", JSON.stringify(ip), err.name, err.code, err.message); + } +} + +try { + leaf.checkHost("www.example.com", { subject: "sometimes" as "always" }); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log("subject", err.name, err.code, err.message); +} diff --git a/tests/corpus/7091-x509-certificate-keys.ts b/tests/corpus/7091-x509-certificate-keys.ts new file mode 100644 index 000000000..b9b89ae1e --- /dev/null +++ b/tests/corpus/7091-x509-certificate-keys.ts @@ -0,0 +1,332 @@ +// X509Certificate.publicKey, verify, checkIssued and checkPrivateKey match +// Node: the public key as a cached KeyObject, signature checks for RSA +// PKCS#1, RSA-PSS, ECDSA and Ed25519 signatures, issuer checks by name, +// key identifier and key type, private key matches, and the pkey errors. +import { + X509Certificate, + constants, + createPrivateKey, + createPublicKey, + createSecretKey, + generateKeyPairSync, + sign, + verify, + type KeyObject, +} from "node:crypto"; + +// An RSA root and the EC leaf it signed. +const ROOT = `-----BEGIN CERTIFICATE----- +MIIDPTCCAiWgAwIBAgIDChHOMA0GCSqGSIb3DQEBCwUAMD4xCzAJBgNVBAYTAlVT +MRQwEgYDVQQKDAtFeGFtcGxlIE9yZzEZMBcGA1UEAwwQRXhhbXBsZSBSU0EgUm9v +dDAgFw0yNjEwMjIwOTQ2NDFaGA8yMTI2MDkyODA5NDY0MVowPjELMAkGA1UEBhMC +VVMxFDASBgNVBAoMC0V4YW1wbGUgT3JnMRkwFwYDVQQDDBBFeGFtcGxlIFJTQSBS +b290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwsfidupnXQcn8PUF +XaCKRYw8XKI1GUn29U5/IUGjAnJVaRmcB6BPTmblbKt4y7gLvU4h1Hxuypx8FZSZ +30eZOS1ixyrymDthG5zUHqzXk5zAGsNvZeXjN3YxTC9vX5CpQ9Uz2i+bLOi3PJsE +qyKVZ+GGT9fpIaALG11YrWmXTUko/a0AKGot0w8O/RaMpXQpkbeyTBF6f4pHNMqx +QGy+KlpC/+yIJ9psT6qyU33HF3Wa+DOy+0txAFEQNw9m1j7adOLnU0zBpkM/bnP2 +fSwxMkDB2Di2P5H6PkdqLT4zBf/BLdg7WqpxxSqqgVmaSXhEqUQ6xjf51yLR3wy/ +xBWhMQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAd +BgNVHQ4EFgQUCBpZW95FsDtUqpwfOMyoE/OLX8swDQYJKoZIhvcNAQELBQADggEB +AChUtpNi8dPPxA4sf4EpZhEcYBN53X3WUcG3KJGtSf8JbPKru9lK9O+sZtJADbph +WoMPdWh8g+fEMk5D6l8Y5pDk/OR++oWNY7XCLFNyigmUGn+4SopJynBQiDk2dYPV +b6swDPPiV85TuAHVn79l766rtFdHdYwRe5HvfEkiXax7Egonlx7NR73xxm/XgvHQ +jhKbTZN9k/DEjkQuo5rt9ldVupUMM0iMhDMKX5oWEx9nvRwHPbjESS+Shy/FA4nA +2WHWO++4IU+imO40an1B/8SmFF+X9hj3UE6qaZLynVZRUiouBZibwZCcdCpibFM3 +kmB2s+NZXRvzhCjIxO97Ib4= +-----END CERTIFICATE----- +`; +const LEAF = `-----BEGIN CERTIFICATE----- +MIIDqjCCApKgAwIBAgICCwswDQYJKoZIhvcNAQELBQAwPjELMAkGA1UEBhMCVVMx +FDASBgNVBAoMC0V4YW1wbGUgT3JnMRkwFwYDVQQDDBBFeGFtcGxlIFJTQSBSb290 +MCAXDTI2MTAyMjA5NDY0MVoYDzIxMjYwOTI4MDk0NjQxWjBfMQswCQYDVQQGEwJV +UzEUMBIGA1UECgwLRXhhbXBsZSBPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNv +bTEgMB4GCSqGSIb3DQEJARYRYWRtaW5AZXhhbXBsZS5jb20wWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAARidSirNIIrw+YHeS112z3N+1n0+uA9HjzkQ5u3QfZsRGCe +ivCEMQSs5+cUE/nv5WWda2kQWavxPGluHFtYmS10o4IBWDCCAVQwCQYDVR0TBAIw +ADALBgNVHQ8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMHsG +A1UdEQR0MHKCDSouZXhhbXBsZS5jb22CC2V4YW1wbGUuY29tghV4bi0tYmNoZXIt +a3ZhLmV4YW1wbGWBD29wc0BleGFtcGxlLmNvbYcECgAAAYcQIAENuAAAAAAAAAAA +AAAAAYYUaHR0cHM6Ly9leGFtcGxlLmNvbS8wXgYIKwYBBQUHAQEEUjBQMCQGCCsG +AQUFBzABhhhodHRwOi8vb2NzcC5leGFtcGxlLmNvbS8wKAYIKwYBBQUHMAKGHGh0 +dHA6Ly9jYS5leGFtcGxlLmNvbS9jYS5jcnQwHQYDVR0OBBYEFHyUnRTDwZxUPVwZ +RKmse3fjoeMMMB8GA1UdIwQYMBaAFAgaWVveRbA7VKqcHzjMqBPzi1/LMA0GCSqG +SIb3DQEBCwUAA4IBAQAfMTcpidoH47PXOQVxRttfrG7K9BcntyXP4+5hUAoSfMr6 +Rxy7X/9ba7R0yCGlNsUCusObaBpbdvwX8v9TiTYKVL+C9rEVGrTHnh6kbgZaaCQc +L3oWbfRrYnEBYuSlFuadjVvX42aqzHV28E4ITgywCxwubu2hAbacbcUHpnRSqLDq +lV+VZSQA/6cPNct7PR6AkJU80D+5wVOk7RVi4rsfbw1wFDeksCHu+0EThs9zjzL+ +S7HTCjB6AOHpbu1Sx+0d5YEKSE7EPF+dSFATNbhjTHzYQySnHK+Lm8nON8wmY1rC +7opOhvUlid1jA1d2BKO2ZxlxLir4pyIehTC1ddG2 +-----END CERTIFICATE----- +`; +// A self-signed RSA certificate and a self-signed Ed25519 certificate. +const PLAIN = `-----BEGIN CERTIFICATE----- +MIIC8jCCAdoCAQcwDQYJKoZIhvcNAQELBQAwPjEaMBgGA1UEAwwRcGxhaW4uZXhh +bXBsZS5vcmcxIDAeBgkqhkiG9w0BCQEWEXBsYWluQGV4YW1wbGUub3JnMCAXDTI2 +MTAyMjA5NDY0MVoYDzIxMjYwOTI4MDk0NjQxWjA+MRowGAYDVQQDDBFwbGFpbi5l +eGFtcGxlLm9yZzEgMB4GCSqGSIb3DQEJARYRcGxhaW5AZXhhbXBsZS5vcmcwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq01NcNAy9Ng04mVuAFvmZUvsm +rY7aOaMgtODmptulgZ2qw25E09RgGYCn1S+4IYDu9hH9X4s+28Ag7Khmj4nmeoXN +of5095wche8DR2AAUElqyr/Nmgm2kTLdIJX4wl0eWXHIlmSPdOUNovA6JjGKH99b +8W7a2TqY/TsSadpPSvvN2esr7Jr4KgW7MXPUOnwNbcBOVcQP9rtFrduAWz01q0HM +02LboclnUnk/e58BXAj8vgdVojljDcc0bhbdz/Hh1/5gaaNILjJNOd4AgwZaCT02 +KojGz4Nb6MSgFmON2iMgAZjY1fXXkJ7wsfVPytjW73e7uV/RFuqSHQZenxupAgMB +AAEwDQYJKoZIhvcNAQELBQADggEBAJX1DGuiSLoCKBDI3oCgAu7X86G01ovEgZJr +8JrmGHW5q7ujjo53t46h8XPm3LJ8UV9/MZ+Yry43xB2txXYv3b3g/i+7JdTWLgUD +INA5VWvVkEZfm0lBEpU1Agpy4lV5uUCKZTgP/3MY5hnJ0YhQIs4BOjbwueMRFshL +yyHVb73MMbpWaZ7+T368seafa+ekeuY6gQly/HacuszjoYd6KKV3n8eDewenClCF +HoOF1Y4I+CvmR+a6B3iBczJ+a4tceFIIWsZUIeqzvGHpM7G9YwlmbvGprfLgR8Nv +igUib/D9tU3hdL6VWN5nMjdXV/8Le4nDnBjVBjYDkVbsnmT91AQ= +-----END CERTIFICATE----- +`; +const ED = `-----BEGIN CERTIFICATE----- +MIIBljCCAUigAwIBAgIUNFuRt3zKS19UAUMdLWewqHrGBigwBQYDK2VwMC8xFDAS +BgNVBAoMC0V4YW1wbGUgT3JnMRcwFQYDVQQDDA5lZC5leGFtcGxlLm5ldDAgFw0y +NjEwMjIxMDEwMTJaGA8yMTI2MDkyODEwMTAxMlowLzEUMBIGA1UECgwLRXhhbXBs +ZSBPcmcxFzAVBgNVBAMMDmVkLmV4YW1wbGUubmV0MCowBQYDK2VwAyEAOCSLKkel +kOH/5aqUwI3PNJVUUNyhgu2s19LFC254fNCjdDByMB0GA1UdDgQWBBR+50zUcqL8 +739jh6/rEYojULDmlTAfBgNVHSMEGDAWgBR+50zUcqL8739jh6/rEYojULDmlTAP +BgNVHRMBAf8EBTADAQH/MB8GA1UdEQQYMBaCDmVkLmV4YW1wbGUubmV0hwTAAAIH +MAUGAytlcANBAC/c0Bi0SVUnHCg/1pn7MaCgVivSHuoISi2SCq/0EnLefRdgsIir +0QxjVnxeuKN2Y1yfEO/R1W8Nwovo1l2M1gQ= +-----END CERTIFICATE----- +`; +// A second RSA root with its EC leaf, a self-signed RSA-PSS certificate +// and a self-signed v1 certificate with the second leaf's subject. +const ROOT2 = `-----BEGIN CERTIFICATE----- +MIIEITCCAwmgAwIBAgIIASNFZ4mrze8wDQYJKoZIhvcNAQELBQAwgZwxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNp +c2NvMRQwEgYDVQQKDAtFeGFtcGxlIE9yZzERMA8GA1UECwwIRGV2IFRlYW0xGDAW +BgNVBAMMD0V4YW1wbGUgUm9vdCBDQTEdMBsGCSqGSIb3DQEJARYOY2FAZXhhbXBs +ZS5jb20wIBcNMjYxMDIxMTYzMzUyWhgPMjEyNjA5MjcxNjMzNTJaMIGcMQswCQYD +VQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j +aXNjbzEUMBIGA1UECgwLRXhhbXBsZSBPcmcxETAPBgNVBAsMCERldiBUZWFtMRgw +FgYDVQQDDA9FeGFtcGxlIFJvb3QgQ0ExHTAbBgkqhkiG9w0BCQEWDmNhQGV4YW1w +bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0YGcCr0pxR8d +6qDdLuILcoT7vhtAjyHwfkoChnH6N3TLWzDUugnBp9NbDb7gDLqbRT4uTDoNb6zS +tEJZ4Q7/hhd0jDvoPmOy0Y3Kyj9uNsdf25GdbvwwEnlddELJCZprTZ4OhEg6w2s0 +Yy4SlviLzDXLmsggTypxeS+K8JHT9onMipi5L4GFu8n1QZbzXvSS4BkRgMXEyrtr +FOE9qSo2qkubFyd/WNKRTRIpTSc5/GGsqJ0FKDDJnN5adotcUucbJFn4noNnFUWh +M8mhBEj7ENDdJsD2EVFsI6H958jA2SbNNu4ngzAOs2AKNBYBfQK02ZQAdRNOSL3K +5FkfbII5LwIDAQABo2MwYTAdBgNVHQ4EFgQUP+yQyItpNYEls26Jfv+NnciGaK8w +HwYDVR0jBBgwFoAUP+yQyItpNYEls26Jfv+NnciGaK8wDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBABDw3Va/C+RFc/Hs +TJnHtQJtxFL0ZhnHUqg4DcDA3m+JDdIDNL8sNpLEbtn/pr0VeEWgG+MImWAQlrUW +IAZkLj9/Zg1wSO0oZ86ZyZJZmp54lUYXgG17CNl4g+F7VHt3bBwUJHTbWye/FKM6 +5OVZetCYKVj7Z+5BYuf7cumzvRqcn32fXidB/kjRX14SJ4JDPn2yFZWCr2ygIqWQ +SOuEmWbNAbYGaVZkQr2Ma8U28DWY2jb0f6nP8jLt49t9o6N/e6Fwd6I5t/mx9Mfj +0403omkxmr9vqN2kBamzVnun+2mDkRZcKPCr72Q4sfbvmDi/W5Nf1Iu2cQW8VfDO +SqpyT54= +-----END CERTIFICATE----- +`; +const LEAF2 = `-----BEGIN CERTIFICATE----- +MIIDuzCCAqOgAwIBAgIPAPAN+s4AESIzRFVmd4iZMA0GCSqGSIb3DQEBDAUAMIGc +MQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2Fu +IEZyYW5jaXNjbzEUMBIGA1UECgwLRXhhbXBsZSBPcmcxETAPBgNVBAsMCERldiBU +ZWFtMRgwFgYDVQQDDA9FeGFtcGxlIFJvb3QgQ0ExHTAbBgkqhkiG9w0BCQEWDmNh +QGV4YW1wbGUuY29tMB4XDTI2MTAyMTE2MzM1MloXDTM2MTAxODE2MzM1MlowJzER +MA8GA1UECgwITGVhZiBPcmcxEjAQBgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABJVfVy9EH/VHQuRqpFFnXk0F00ncDht6APgVPDoybK8H +PlcM4T5H1yAOMHnlH29LK1jtMqU1aAOiiQRzPdCvqoujggE3MIIBMzAJBgNVHRME +AjAAMGgGA1UdEQRhMF+CCWxvY2FsaG9zdIINKi5leGFtcGxlLmNvbYcEfwAAAYcQ +AAAAAAAAAAAAAAAAAAAAAYERYWRtaW5AZXhhbXBsZS5jb22GGGh0dHBzOi8vZXhh +bXBsZS5jb20vcGF0aDBdBggrBgEFBQcBAQRRME8wIwYIKwYBBQUHMAGGF2h0dHA6 +Ly9vY3NwLmV4YW1wbGUuY29tMCgGCCsGAQUFBzAChhxodHRwOi8vY2EuZXhhbXBs +ZS5jb20vY2EuY3J0MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAdBgNV +HQ4EFgQUdKIx6ULFLPNP/kzwUFjSa5D19RswHwYDVR0jBBgwFoAUP+yQyItpNYEl +s26Jfv+NnciGaK8wDQYJKoZIhvcNAQEMBQADggEBABmQ8f2VsXNaMM9X1MEhi27D +LjwcOaaGEINNZq7xu/HL6UljMb1Odsuz66DkiH3Exd3MrWy6OlOpJJF0VzsqMsoD +0CRBxXF28o6Jil3vOGVwlYNmRidpkAkj4VyWDvv8FCS9VbsBtXaSHwu9wcCwePE0 +cGJRfTKoZ6KJ8vQ57DBEZ/ZszuhehOZYqrTmoUdjZ123kXdxQvK+ALn4SlIXHBhN +9unhd+8i00EmkXO/REw8paMcMVVGR+ioOEjyNHqZo6vkRqgpKggn1Yq8cTTDOMnS +P1o42EQZLlbuvx0kqInBqwqjDx4nZiPaifoDc9BF6iZPBvm8dhrEfghfB1tkNk0= +-----END CERTIFICATE----- +`; +const PSS = `-----BEGIN CERTIFICATE----- +MIIDdTCCAiigAwIBAgICAIAwQgYJKoZIhvcNAQEKMDWgDzANBglghkgBZQMEAgEF +AKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEFAKIEAgIA3jAeMQwwCgYDVQQD +DANwc3MxDjAMBgNVBAoMBWN0bAF4MB4XDTI2MTAyMTE2NDQzM1oXDTI2MTEyMDE2 +NDQzM1owHjEMMAoGA1UEAwwDcHNzMQ4wDAYDVQQKDAVjdGwBeDCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAL2pno8Ybwihdv3hnMhWPPt85SXQmFAJpFoS +E+sT9H6FbwFCIyPkY+SWk8H7y8V+D+388tNvJZmGjX3uvWpK2Wx6anSB3/WMpkUR +2ExP4cpSxZNr9EFzC/cdqLvsxJcHMHkc/uJuFbtjsuiH1ygOe3nyhWSNiPwGdwHE +cKkBFkzW3j2C/4Jkgt4k+sMe3gy5litKtMi4zZB8ixCm5oGhveGhUZy0Kb01twgZ +RP4FEd32sSRqq3NCx4cB0kyreTuzmk6U7BkxcdPzaj6DifjRznTQmjMdoCNZQRCj +G3KBOMHHyvmV2B4lNGDuMlcSap/TpYq7n32AH5sDvELrUnPwVzsCAwEAAaNTMFEw +HQYDVR0OBBYEFC/QJvAQsbQ25sErc1To2G1VuNZ0MB8GA1UdIwQYMBaAFC/QJvAQ +sbQ25sErc1To2G1VuNZ0MA8GA1UdEwEB/wQFMAMBAf8wQgYJKoZIhvcNAQEKMDWg +DzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEFAKIE +AgIA3gOCAQEAu3lP2Q3Bb5fWUQtgFr/82Oke1ZRW/GRbj03mZ30izADHC7yk0OYm +02f7B5sub04Jk6yuRVmEBjJHSPNwAo0RJWtwvD77KLhG8aObr7Z3+pkfFvpGzqeS +MQ4FtK2MPps3kA1a5D4XLwkwDJtHWZVuVyXpPgxkptfLzZgrieKrpNyOKR8Pz/PO +Wlwv6bF7SXvAV8Vrd7KDvTqwQ5iq9UFhP6GeMDWezYtzLdjlvje7MhmZ96zgSY6m +qumNWOPHmiN4eoOE4M69B+0fa3WyMzubW0nVGM/sDhtS0k5zHs6Y4RLn11HolhXH +LvbvDgqtl/m5i0uHxEld+Z1uhzJscqFQRg== +-----END CERTIFICATE----- +`; +const V1 = `-----BEGIN CERTIFICATE----- +MIIBSTCB7wIUI3kYI5NpR07YqZe6RwOx22je9IowCgYIKoZIzj0EAwIwJzERMA8G +A1UECgwITGVhZiBPcmcxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNjEwMjExNjMz +NTJaFw0yNjExMjAxNjMzNTJaMCcxETAPBgNVBAoMCExlYWYgT3JnMRIwEAYDVQQD +DAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASVX1cvRB/1R0Lk +aqRRZ15NBdNJ3A4begD4FTw6MmyvBz5XDOE+R9cgDjB55R9vSytY7TKlNWgDookE +cz3Qr6qLMAoGCCqGSM49BAMCA0kAMEYCIQCFfFOw9qzzfSOQO1zRnT0sOXibjLWl +hHDyqecxhY7gtQIhAPMgqg+p1JXV5bgGpqgDfP3sE53luxMGlnAC39uhwBUh +-----END CERTIFICATE----- +`; + +const ROOT_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDCx+J26mddByfw +9QVdoIpFjDxcojUZSfb1Tn8hQaMCclVpGZwHoE9OZuVsq3jLuAu9TiHUfG7KnHwV +lJnfR5k5LWLHKvKYO2EbnNQerNeTnMAaw29l5eM3djFML29fkKlD1TPaL5ss6Lc8 +mwSrIpVn4YZP1+khoAsbXVitaZdNSSj9rQAoai3TDw79FoyldCmRt7JMEXp/ikc0 +yrFAbL4qWkL/7Ign2mxPqrJTfccXdZr4M7L7S3EAURA3D2bWPtp04udTTMGmQz9u +c/Z9LDEyQMHYOLY/kfo+R2otPjMF/8Et2DtaqnHFKqqBWZpJeESpRDrGN/nXItHf +DL/EFaExAgMBAAECggEADsvlabgryvbYs2bBlhe0eU0KZtURqI6kWoUwhiO40N5D +m7zMzA2DCMcrRYKPJhagmaxeSIh9WBnkGTWVLM+BFcVbXBrom+Ti3xieSohBxh5h +v4a6Jyj8BkINw6ZU1RMlsJf5HOmB5br19H4oMCHr7JzGubkCpkHEpjsEmgPL8Rxs +LYb9palDyTD1z5xVAQDAkvnCYQMX2DsiH8RuO4Ih0OoWMIsAGasS1dTQCkOzN9wF +cMg+hmJvQLN5xLYI+VPy/iZn+bmerCx8/NQBrbo+zibmnv7cDK1U9emVD70/Nd1d +B7ZEm+a4gReaxWVQGEkFMvs29u/TDWXYRnw0PGjUYwKBgQDrmKEx+ZiZwNNk/S3l +s8FgVrLFDr/+t5X33jaD5nVuSN6/AoR3qfh9piIyCrdNKdHicAcCnWP6skUFYLq3 +/tyMBATX3j+f3PHW3i2ZUAK26hgfp66F/3IyowePZbBDlGSmGsffVlc8on5fCk1Q +k3G0znktbrMCNxeeY9GSi1CHewKBgQDTpled6pZepZ54OGnKVJFo6DbQC0l4HQ6y +Rwv9BMtAz+AxWXda1oYK5bbJZrCmHyVP85VxkzRHOMxicv7M2kR9y2zZsJD7INqr +Nvt+lo2yGtIGv7kOdwWzvs4Pn7aCWSApzYBjuKrpP1xDzaMHhusAQtkk77YGCZCu +cCAQ/FjEQwKBgBq/1KWI8lOauiXAPULUWwNmNJv9Jb10+LkQ6N2m//RKn8fOMF4p +1UXm5AEMiG1hbywnjhQ07JIdRYrrxad4b3ASUL51xIGRI4BC76h1lilQgPavIjii +nUO0kPouEIpLwfW+Gq137yW6SWDfGZIpe4KM4uNOq1J4EN1x9eyOltEbAoGAP4bj +L5QVFE2T1jPglQlzJ9DjeuiYYcQfACXRFF3XABolCcr3xz0yuJygk60OAp7AnVHY +nmF3xJToy/gGQMiW3Ect4d1cdvQyjkePjzxzRW1Fcx8i1ZOmE4CG6yueJVvJCqMT +EVuBeUc6U2Oqtnyplsd509tbgE5oreMbzN1ha28CgYAHpG10ZQRDeqWFIo/ttk6Q +Q1Gf53eqZFCgh8GO1hBJ7+Fp+Ec5S9UPMBDjZb6GET7ifF8Zit/lR4gN2PuELwJA +YaXTIehCugH7Q3oTjNcQtls6tW+/+jEGjbjSHyZvwk8EFHc0J7PWRszB36s+qM+O +YPorNipExSeWti1506ljLQ== +-----END PRIVATE KEY----- +`; +const LEAF_KEY = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEINqbifA6kN/DSN0cMD2s7fXiNtPXZqipFrbCs/3mCGbtoAoGCCqGSM49 +AwEHoUQDQgAEYnUoqzSCK8PmB3ktdds9zftZ9PrgPR485EObt0H2bERgnorwhDEE +rOfnFBP57+VlnWtpEFmr8TxpbhxbWJktdA== +-----END EC PRIVATE KEY----- +`; +const PLAIN_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCq01NcNAy9Ng04 +mVuAFvmZUvsmrY7aOaMgtODmptulgZ2qw25E09RgGYCn1S+4IYDu9hH9X4s+28Ag +7Khmj4nmeoXNof5095wche8DR2AAUElqyr/Nmgm2kTLdIJX4wl0eWXHIlmSPdOUN +ovA6JjGKH99b8W7a2TqY/TsSadpPSvvN2esr7Jr4KgW7MXPUOnwNbcBOVcQP9rtF +rduAWz01q0HM02LboclnUnk/e58BXAj8vgdVojljDcc0bhbdz/Hh1/5gaaNILjJN +Od4AgwZaCT02KojGz4Nb6MSgFmON2iMgAZjY1fXXkJ7wsfVPytjW73e7uV/RFuqS +HQZenxupAgMBAAECggEAAxY5ACZHoaanpAFvbBfeMEVV2PvTZEGc30MtejWDLBE0 +yGhqJm++e3MShlXdnCHs2sSTDCVNFphxdxbXryzCVOobX10hRDtSNJi9+izwwOiy +fu3FfAFf0g77IJBNbXaTqK/8lJ+VFxuJZQ7v8NkOVZzgVCC/fFBnFOskhV9ozLx6 +Uk+5Rg/UFenUhhHqrzwxCLKR3TAX8pBdidmbJF61ypxt6Cn3pOA/pB1gUBi76nau +w4kefdNj8AXzSjzOfVEg0quywfeoOq6ESdy3p4RQMt6NZ3wnCgzr/uuWjVQzJbJU +Mg2TVlrKzbiinel2ZqKV8lpymhFTlRaWNzK7PzD91QKBgQDaSDpdQIk2+CJMAVzU +LsI5tdZa0cMDbAZzxBmg1W6kwIrMzYVeMo7RPBarhCuQ/3gJFVvA7LdvXR87BbYa +UuDXbvO4Gv+2wqxxX961sWK85VqhwAqXhFbkS9KNJ0twmlmAcX9lvl0Zn4ciO3c+ +AMJT4JN4WR6wI/dt1TCHLa1wtQKBgQDIV9bgKinpUzhSy9byTH0CwghwudAPx2CA +9gJ6VcjyUKeBdgJ0PQMTwppyrPs0fz+NgJ6+LiTvp9BS9vrXmtnjbD1oAxFcC9uU +NL7BJSRY+wgmnPgbldA25xq1TRJmB8YMr1CO4gYKGSNM7I1HlNv4dvMcS295RhTG +cdDkl4T7pQKBgQCzJmygdIMplmWvs76Ydvf5nYAcceR4PoTdJqlSE1oH5e8FP/ih +4yTMHWENzCBllQbTjbAMmzvCLs+fBYT7/eGDbxCgnagaO5SESIbk4H2FQ0grUQ5g +ZMFcBc79Aq8r78JdZMmGm8KVcRTbx6qKPN1BMiRWte416o78meiGSbr7sQKBgQCJ +2AY8TkZwB5ZlQWMva3lIAIEWLoL9mEzJ7npd+dA9CUl3Rd8CY0hOvELQTDy7LiJo +FesjJ1S6oFgx/UUyO5VXaQ4HFrVHA9Gc832cHxYXMwG0r6P2NuoAHJq/y7uxAABA +kg93gARvxkQHlWrPqZiAKDe1bCHS62k38O+tmKMjEQKBgHF7a9tqZVoc8CqUL/I8 +FXRMRmdzC4RoWC2NDqMazW9qxXpFZmPux1q9ds6ikoG0v3pAxZmRH0eMTIujtLPn +chtAnvTvkg5Q+/xa429PFThJ2b04zsHCmnzfdMaIPvqhBqoPmyAmptjUeMB+yzQU +d3AigXwTBk4l4yPleDD4qUbO +-----END PRIVATE KEY----- +`; +const ED_KEY = `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEID8VP7HLPAW9FXkVxo3eYbqZ8zkXsPFi3c2RwdDdwy/O +-----END PRIVATE KEY----- +`; + +const certs: [string, X509Certificate][] = [ + ["root", new X509Certificate(ROOT)], + ["leaf", new X509Certificate(Buffer.from(LEAF))], + ["plain", new X509Certificate(PLAIN)], + ["ed", new X509Certificate(ED)], + ["root2", new X509Certificate(ROOT2)], + ["leaf2", new X509Certificate(LEAF2)], + ["pss", new X509Certificate(PSS)], + ["v1", new X509Certificate(V1)], +]; + +function describe(key: KeyObject): string { + const details = key.asymmetricKeyDetails ?? {}; + const size = key.asymmetricKeyType === "ed25519" ? "32 bytes" : `${details.modulusLength ?? details.namedCurve}`; + return `${key.type} ${key.asymmetricKeyType} ${size}`; +} + +for (const [name, cert] of certs) { + const key = cert.publicKey; + const spki = key.export({ type: "spki", format: "der" }); + console.log(name, describe(key), key === cert.publicKey, spki.length, createPublicKey(cert.toString()).equals(key)); +} + +for (const [name, cert] of certs) { + const by: string[] = []; + const issued: string[] = []; + for (const [other, signer] of certs) { + if (cert.verify(signer.publicKey)) by.push(other); + if (cert.checkIssued(signer)) issued.push(other); + } + console.log(name, "verifies with", by.join(",") || "-", "issued by", issued.join(",") || "-"); +} + +const keys: [string, KeyObject][] = [ + ["root", createPrivateKey(ROOT_KEY)], + ["leaf", createPrivateKey(Buffer.from(LEAF_KEY))], + ["plain", createPrivateKey({ key: PLAIN_KEY, format: "pem" })], + ["ed", createPrivateKey(ED_KEY)], + ["fresh", generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey], +]; +for (const [name, cert] of certs) { + console.log(name, "private", keys.filter(([, key]) => cert.checkPrivateKey(key)).map(([k]) => k).join(",") || "-"); +} + +const leaf = certs[1]![1]; +function attempt(label: string, f: () => boolean): void { + try { + console.log(label, f()); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(label, err.name, err.code, err.message); + } +} +attempt("verify private", () => leaf.verify(keys[0]![1])); +attempt("verify secret", () => leaf.verify(createSecretKey(Buffer.alloc(16)))); +attempt("check public", () => leaf.checkPrivateKey(leaf.publicKey)); +attempt("check secret", () => leaf.checkPrivateKey(createSecretKey(Buffer.alloc(16)))); + +// A PSS signature check on the root's public key leaves no padding behind +// for the certificate checks that follow. +const root = certs[0]![1]; +const message = Buffer.from("pss"); +const pssSignature = sign("sha256", message, { key: keys[0]![1], padding: constants.RSA_PKCS1_PSS_PADDING }); +const pssValid = verify("sha256", message, { key: root.publicKey, padding: constants.RSA_PKCS1_PSS_PADDING }, pssSignature); +console.log("after pss", pssValid, root.verify(root.publicKey), leaf.verify(root.publicKey)); + +// An outer signatureAlgorithm that differs from the TBSCertificate's, here +// without its NULL parameters, does not verify. +function header(der: Buffer, at: number): [number, number] { + const first = der[at + 1]!; + if (first < 0x80) return [2, first]; + let length = 0; + for (let i = 0; i < (first & 0x7f); i++) length = length * 256 + der[at + 2 + i]!; + return [2 + (first & 0x7f), length]; +} +const rootDer = root.raw; +const [outerHeader] = header(rootDer, 0); +const [tbsHeader, tbsLength] = header(rootDer, outerHeader); +const algAt = outerHeader + tbsHeader + tbsLength; +const [algHeader, algLength] = header(rootDer, algAt); +const algorithm = rootDer.subarray(algAt, algAt + algHeader + algLength); +const oid = algorithm.subarray(2, 2 + 2 + algorithm[3]!); +const bare = Buffer.concat([Buffer.from([0x30, oid.length]), oid]); +const body = Buffer.concat([rootDer.subarray(outerHeader, algAt), bare, rootDer.subarray(algAt + algHeader + algLength)]); +const altered = new X509Certificate(Buffer.concat([Buffer.from([0x30, 0x82, body.length >> 8, body.length & 0xff]), body])); +console.log("outer algorithm", algorithm.toString("hex"), "->", bare.toString("hex"), altered.subject.split("\n").join("/"), altered.verify(root.publicKey)); diff --git a/tests/corpus/7092-x509-legacy-object.ts b/tests/corpus/7092-x509-legacy-object.ts new file mode 100644 index 000000000..6b423860a --- /dev/null +++ b/tests/corpus/7092-x509-legacy-object.ts @@ -0,0 +1,158 @@ +// X509Certificate.toLegacyObject and toString match Node: the +// legacy object's members in Node's order (subject and issuer objects +// with repeated attributes as arrays, alternative names, access +// descriptions by method, the RSA modulus, exponent and SPKI or the EC +// point and curve names, the validity window, fingerprints, extended key +// usages, serial number and raw bytes), its inspect output, and the PEM +// text of toString, String() and template literals. +import { X509Certificate } from "node:crypto"; + +// An EC leaf with every alternative name kind, and the RSA root that signed it. +const LEAF = `-----BEGIN CERTIFICATE----- +MIIDqjCCApKgAwIBAgICCwswDQYJKoZIhvcNAQELBQAwPjELMAkGA1UEBhMCVVMx +FDASBgNVBAoMC0V4YW1wbGUgT3JnMRkwFwYDVQQDDBBFeGFtcGxlIFJTQSBSb290 +MCAXDTI2MTAyMjA5NDY0MVoYDzIxMjYwOTI4MDk0NjQxWjBfMQswCQYDVQQGEwJV +UzEUMBIGA1UECgwLRXhhbXBsZSBPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNv +bTEgMB4GCSqGSIb3DQEJARYRYWRtaW5AZXhhbXBsZS5jb20wWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAARidSirNIIrw+YHeS112z3N+1n0+uA9HjzkQ5u3QfZsRGCe +ivCEMQSs5+cUE/nv5WWda2kQWavxPGluHFtYmS10o4IBWDCCAVQwCQYDVR0TBAIw +ADALBgNVHQ8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMHsG +A1UdEQR0MHKCDSouZXhhbXBsZS5jb22CC2V4YW1wbGUuY29tghV4bi0tYmNoZXIt +a3ZhLmV4YW1wbGWBD29wc0BleGFtcGxlLmNvbYcECgAAAYcQIAENuAAAAAAAAAAA +AAAAAYYUaHR0cHM6Ly9leGFtcGxlLmNvbS8wXgYIKwYBBQUHAQEEUjBQMCQGCCsG +AQUFBzABhhhodHRwOi8vb2NzcC5leGFtcGxlLmNvbS8wKAYIKwYBBQUHMAKGHGh0 +dHA6Ly9jYS5leGFtcGxlLmNvbS9jYS5jcnQwHQYDVR0OBBYEFHyUnRTDwZxUPVwZ +RKmse3fjoeMMMB8GA1UdIwQYMBaAFAgaWVveRbA7VKqcHzjMqBPzi1/LMA0GCSqG +SIb3DQEBCwUAA4IBAQAfMTcpidoH47PXOQVxRttfrG7K9BcntyXP4+5hUAoSfMr6 +Rxy7X/9ba7R0yCGlNsUCusObaBpbdvwX8v9TiTYKVL+C9rEVGrTHnh6kbgZaaCQc +L3oWbfRrYnEBYuSlFuadjVvX42aqzHV28E4ITgywCxwubu2hAbacbcUHpnRSqLDq +lV+VZSQA/6cPNct7PR6AkJU80D+5wVOk7RVi4rsfbw1wFDeksCHu+0EThs9zjzL+ +S7HTCjB6AOHpbu1Sx+0d5YEKSE7EPF+dSFATNbhjTHzYQySnHK+Lm8nON8wmY1rC +7opOhvUlid1jA1d2BKO2ZxlxLir4pyIehTC1ddG2 +-----END CERTIFICATE----- +`; +const ROOT = `-----BEGIN CERTIFICATE----- +MIIDPTCCAiWgAwIBAgIDChHOMA0GCSqGSIb3DQEBCwUAMD4xCzAJBgNVBAYTAlVT +MRQwEgYDVQQKDAtFeGFtcGxlIE9yZzEZMBcGA1UEAwwQRXhhbXBsZSBSU0EgUm9v +dDAgFw0yNjEwMjIwOTQ2NDFaGA8yMTI2MDkyODA5NDY0MVowPjELMAkGA1UEBhMC +VVMxFDASBgNVBAoMC0V4YW1wbGUgT3JnMRkwFwYDVQQDDBBFeGFtcGxlIFJTQSBS +b290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwsfidupnXQcn8PUF +XaCKRYw8XKI1GUn29U5/IUGjAnJVaRmcB6BPTmblbKt4y7gLvU4h1Hxuypx8FZSZ +30eZOS1ixyrymDthG5zUHqzXk5zAGsNvZeXjN3YxTC9vX5CpQ9Uz2i+bLOi3PJsE +qyKVZ+GGT9fpIaALG11YrWmXTUko/a0AKGot0w8O/RaMpXQpkbeyTBF6f4pHNMqx +QGy+KlpC/+yIJ9psT6qyU33HF3Wa+DOy+0txAFEQNw9m1j7adOLnU0zBpkM/bnP2 +fSwxMkDB2Di2P5H6PkdqLT4zBf/BLdg7WqpxxSqqgVmaSXhEqUQ6xjf51yLR3wy/ +xBWhMQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAd +BgNVHQ4EFgQUCBpZW95FsDtUqpwfOMyoE/OLX8swDQYJKoZIhvcNAQELBQADggEB +AChUtpNi8dPPxA4sf4EpZhEcYBN53X3WUcG3KJGtSf8JbPKru9lK9O+sZtJADbph +WoMPdWh8g+fEMk5D6l8Y5pDk/OR++oWNY7XCLFNyigmUGn+4SopJynBQiDk2dYPV +b6swDPPiV85TuAHVn79l766rtFdHdYwRe5HvfEkiXax7Egonlx7NR73xxm/XgvHQ +jhKbTZN9k/DEjkQuo5rt9ldVupUMM0iMhDMKX5oWEx9nvRwHPbjESS+Shy/FA4nA +2WHWO++4IU+imO40an1B/8SmFF+X9hj3UE6qaZLynVZRUiouBZibwZCcdCpibFM3 +kmB2s+NZXRvzhCjIxO97Ib4= +-----END CERTIFICATE----- +`; +// An EC leaf with access descriptions and extended key usages. +const ACCESS = `-----BEGIN CERTIFICATE----- +MIIDuzCCAqOgAwIBAgIPAPAN+s4AESIzRFVmd4iZMA0GCSqGSIb3DQEBDAUAMIGc +MQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2Fu +IEZyYW5jaXNjbzEUMBIGA1UECgwLRXhhbXBsZSBPcmcxETAPBgNVBAsMCERldiBU +ZWFtMRgwFgYDVQQDDA9FeGFtcGxlIFJvb3QgQ0ExHTAbBgkqhkiG9w0BCQEWDmNh +QGV4YW1wbGUuY29tMB4XDTI2MTAyMTE2MzM1MloXDTM2MTAxODE2MzM1MlowJzER +MA8GA1UECgwITGVhZiBPcmcxEjAQBgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABJVfVy9EH/VHQuRqpFFnXk0F00ncDht6APgVPDoybK8H +PlcM4T5H1yAOMHnlH29LK1jtMqU1aAOiiQRzPdCvqoujggE3MIIBMzAJBgNVHRME +AjAAMGgGA1UdEQRhMF+CCWxvY2FsaG9zdIINKi5leGFtcGxlLmNvbYcEfwAAAYcQ +AAAAAAAAAAAAAAAAAAAAAYERYWRtaW5AZXhhbXBsZS5jb22GGGh0dHBzOi8vZXhh +bXBsZS5jb20vcGF0aDBdBggrBgEFBQcBAQRRME8wIwYIKwYBBQUHMAGGF2h0dHA6 +Ly9vY3NwLmV4YW1wbGUuY29tMCgGCCsGAQUFBzAChhxodHRwOi8vY2EuZXhhbXBs +ZS5jb20vY2EuY3J0MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAdBgNV +HQ4EFgQUdKIx6ULFLPNP/kzwUFjSa5D19RswHwYDVR0jBBgwFoAUP+yQyItpNYEl +s26Jfv+NnciGaK8wDQYJKoZIhvcNAQEMBQADggEBABmQ8f2VsXNaMM9X1MEhi27D +LjwcOaaGEINNZq7xu/HL6UljMb1Odsuz66DkiH3Exd3MrWy6OlOpJJF0VzsqMsoD +0CRBxXF28o6Jil3vOGVwlYNmRidpkAkj4VyWDvv8FCS9VbsBtXaSHwu9wcCwePE0 +cGJRfTKoZ6KJ8vQ57DBEZ/ZszuhehOZYqrTmoUdjZ123kXdxQvK+ALn4SlIXHBhN +9unhd+8i00EmkXO/REw8paMcMVVGR+ioOEjyNHqZo6vkRqgpKggn1Yq8cTTDOMnS +P1o42EQZLlbuvx0kqInBqwqjDx4nZiPaifoDc9BF6iZPBvm8dhrEfghfB1tkNk0= +-----END CERTIFICATE----- +`; +// A multi-valued RDN, an Ed25519 key and names that need escaping. +const MULTI = `-----BEGIN CERTIFICATE----- +MIICNzCCAaCgAwIBAgIBADANBgkqhkiG9w0BAQsFADA3MSgwCgYDVQQLDANyZG4w +DAYDVQQDDAVtdWx0aTAMBgNVBAoMBXRocmVlMQswCQYDVQQGEwJERTAeFw0yNjEw +MjExNjM0MDVaFw0yNjEwMzExNjM0MDVaMDcxKDAKBgNVBAsMA3JkbjAMBgNVBAMM +BW11bHRpMAwGA1UECgwFdGhyZWUxCzAJBgNVBAYTAkRFMIGfMA0GCSqGSIb3DQEB +AQUAA4GNADCBiQKBgQDdv64EOPdpcWdjVMAlEQvNzm3ZbQYXrhOwRF9yzQp25SuA +3uKvStZOt8hyTifAd30oDXIfgyYI39xKHEkHfARTV1E2Jwyiz86/pDHmFypPFJuI +v/vOjYLLeisiVi8TFfHgmoGqGvySR83fwiUnoOOWj9Bqt/5EVBQbBeWrU9qH6QID +AQABo1MwUTAdBgNVHQ4EFgQUZrGU0gkgqCkmegRtu6vQ3h7kpPUwHwYDVR0jBBgw +FoAUZrGU0gkgqCkmegRtu6vQ3h7kpPUwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG +9w0BAQsFAAOBgQAMzFkzJElc9VJYhGx7AngO8Yve22iCTOVLO3/be8w55klb/HJX +vfESQz+EJLYVaP5Q/uChoE0I27NytkeUSpJFwmjyqoQq2j6kJWGKONqhQSnXDrms +HMPA7iik/C5hOqpwzDZ98wNU/e86TrqZxzpFkwbnZx9MSfiGr690s5BfpA== +-----END CERTIFICATE----- +`; +const ED = `-----BEGIN CERTIFICATE----- +MIICSDCCAfqgAwIBAgIB+zAFBgMrZXAwLDEVMBMGA1UEAx4MAGIAbQBwACAA6U4t +MRMwEQYDVQQKHgoAcABsAGEAaQBuMB4XDTI2MTAyMTE2NDQzM1oXDTI2MTEyMDE2 +NDQzM1owLDEVMBMGA1UEAx4MAGIAbQBwACAA6U4tMRMwEQYDVQQKHgoAcABsAGEA +aQBuMCowBQYDK2VwAyEAP+2Vcp8nwu2AhFvSczYTxGs1jV/ckA76Jqvwj9QDGqij +ggE/MIIBOzCBtwYDVR0RBIGvMIGsoBsGCisGAQQBgjcUAgOgDQwLdXNlckBkb21h +aW6gEAYDKgMEoAkMB3Vua25vd26kRTBDMQswCQYDVQQGEwJVSzEZMBcGA1UECh4Q +AEQAaQByACwAIABPAHIAZzEZMBcGA1UEAx4QAGQAaQByACAAbgBhAG0AZYgEKgME +BYcQ/oAAAAAAAAAAAAAAAAEAAocQIAENuAAAAAAAAP8AAEKDKYYKaHR0cDovL3gv +cTAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIHgDBFBggrBgEFBQcBAQQ5MDcwIAYI +KwYBBQUHMAWGFGh0dHA6Ly9yZXBvLmV4YW1wbGUvMBMGBCoDBAWCC29kZC5leGFt +cGxlMB0GA1UdDgQWBBQYMJ0fQJ1Nx0PmatIfTJusAEA9WTAFBgMrZXADQQCixubE +nuunBZYYPXF52+joQSX/a5DFkOEDmzRTuH7IIpPFWPo37rmJOSdPA/PV8v9clzYF +LP3EjM4YOAatcbsO +-----END CERTIFICATE----- +`; +const ODD = `-----BEGIN CERTIFICATE----- +MIICXzCCAeWgAwIBAgIBATAKBggqhkjOPQQDAjBvMQ0wCwYDVQQDDARhLCBiMQ4w +DAYDVQQHDAUjbGVhZDESMBAGA1UECAwJICBzcGFjZSAgMSYwJAYDVQQKDB1RdW90 +ZSBxIGJhY2tcc2xhc2g7IHNlbWkgPGx0PjESMBAGA1UECwwJQ2Fmw6kg4oKsMB4X +DTI2MTAyMTE2MzM1MloXDTI3MTAyMTE2MzM1MlowbzENMAsGA1UEAwwEYSwgYjEO +MAwGA1UEBwwFI2xlYWQxEjAQBgNVBAgMCSAgc3BhY2UgIDEmMCQGA1UECgwdUXVv +dGUgcSBiYWNrXHNsYXNoOyBzZW1pIDxsdD4xEjAQBgNVBAsMCUNhZsOpIOKCrDB2 +MBAGByqGSM49AgEGBSuBBAAiA2IABMLLzwDsSeezCzScnC9xTIFl35dFAX+1Uagk +0y4Y72rOOgVFA13SMIbMfv2InIyg2vXOdqn0y7RqiaaqqnAYZB2RHHL0SD7t2MVL +K3BQajy7Oi6IeDDRGK1jM4D6nk/1AKNVMFMwMgYDVR0RBCswKYILYSxiLmV4YW1w +bGWCDXBsYWluLmV4YW1wbGWBBXhAeS56hwQKAAABMB0GA1UdDgQWBBSR4U6nIpTt +5rLoIZZxfxSyx9Uc9zAKBggqhkjOPQQDAgNoADBlAjEAlznFL0Nrt1jjZrT3/GN0 +yN/8gxO3SawyqoWrfP3uk1GScTh0PlrTEOrpSyuq3wl5AjBRntVI1ZoUM46pCwIw +0Vl3c3lns7OljcQFiR7o8yRhEW9ejdiR2W6PLBrjCly6U3I= +-----END CERTIFICATE----- +`; + +const certs: [string, X509Certificate][] = [ + ["leaf", new X509Certificate(LEAF)], + ["root", new X509Certificate(Buffer.from(ROOT))], + ["access", new X509Certificate(ACCESS)], + ["multi", new X509Certificate(MULTI)], + ["ed", new X509Certificate(ED)], + ["odd", new X509Certificate(ODD)], +]; + +for (const [name, cert] of certs) { + const legacy = cert.toLegacyObject(); + const shown = JSON.stringify(legacy, (key: string, value: unknown) => { + if (value === undefined) return ""; + if (key === "raw" || key === "pubkey") return `<${(value as { data: number[] }).data.length} bytes>`; + return value; + }); + console.log(name, shown); + const absent = Object.keys(legacy).filter((key) => (legacy as unknown as Record)[key] === undefined); + console.log(name, "absent", absent.join(","), Buffer.isBuffer(legacy.raw), legacy.raw.equals(cert.raw), legacy.raw === cert.raw); + const spki: Buffer = cert.publicKey.export({ type: "spki", format: "der" }); + if (legacy.pubkey !== undefined) console.log(name, "pubkey", legacy.pubkey.equals(spki)); +} + +console.log(certs[0]![1].toLegacyObject()); +console.log(certs[3]![1].toLegacyObject()); + +for (const [name, cert] of certs) { + const pem = cert.toString(); + console.log(name, pem.length, pem === String(cert), `${cert}` === pem, new X509Certificate(pem).fingerprint256 === cert.fingerprint256); +} +console.log(certs[3]![1].toString()); diff --git a/tests/corpus/7093-x509-methods-js.mjs b/tests/corpus/7093-x509-methods-js.mjs new file mode 100644 index 000000000..a8144d071 --- /dev/null +++ b/tests/corpus/7093-x509-methods-js.mjs @@ -0,0 +1,158 @@ +// X509Certificate's methods from JavaScript match Node: publicKey, +// verify, checkIssued, checkPrivateKey, checkHost, checkEmail, checkIP, +// toLegacyObject, toString and toJSON, including the TypeErrors for +// arguments of the wrong type. +import { X509Certificate, createPrivateKey, createPublicKey, createSecretKey } from "node:crypto"; + +// An RSA root, the EC leaf it signed, and a self-signed RSA certificate. +const ROOT = `-----BEGIN CERTIFICATE----- +MIIDPTCCAiWgAwIBAgIDChHOMA0GCSqGSIb3DQEBCwUAMD4xCzAJBgNVBAYTAlVT +MRQwEgYDVQQKDAtFeGFtcGxlIE9yZzEZMBcGA1UEAwwQRXhhbXBsZSBSU0EgUm9v +dDAgFw0yNjEwMjIwOTQ2NDFaGA8yMTI2MDkyODA5NDY0MVowPjELMAkGA1UEBhMC +VVMxFDASBgNVBAoMC0V4YW1wbGUgT3JnMRkwFwYDVQQDDBBFeGFtcGxlIFJTQSBS +b290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwsfidupnXQcn8PUF +XaCKRYw8XKI1GUn29U5/IUGjAnJVaRmcB6BPTmblbKt4y7gLvU4h1Hxuypx8FZSZ +30eZOS1ixyrymDthG5zUHqzXk5zAGsNvZeXjN3YxTC9vX5CpQ9Uz2i+bLOi3PJsE +qyKVZ+GGT9fpIaALG11YrWmXTUko/a0AKGot0w8O/RaMpXQpkbeyTBF6f4pHNMqx +QGy+KlpC/+yIJ9psT6qyU33HF3Wa+DOy+0txAFEQNw9m1j7adOLnU0zBpkM/bnP2 +fSwxMkDB2Di2P5H6PkdqLT4zBf/BLdg7WqpxxSqqgVmaSXhEqUQ6xjf51yLR3wy/ +xBWhMQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAd +BgNVHQ4EFgQUCBpZW95FsDtUqpwfOMyoE/OLX8swDQYJKoZIhvcNAQELBQADggEB +AChUtpNi8dPPxA4sf4EpZhEcYBN53X3WUcG3KJGtSf8JbPKru9lK9O+sZtJADbph +WoMPdWh8g+fEMk5D6l8Y5pDk/OR++oWNY7XCLFNyigmUGn+4SopJynBQiDk2dYPV +b6swDPPiV85TuAHVn79l766rtFdHdYwRe5HvfEkiXax7Egonlx7NR73xxm/XgvHQ +jhKbTZN9k/DEjkQuo5rt9ldVupUMM0iMhDMKX5oWEx9nvRwHPbjESS+Shy/FA4nA +2WHWO++4IU+imO40an1B/8SmFF+X9hj3UE6qaZLynVZRUiouBZibwZCcdCpibFM3 +kmB2s+NZXRvzhCjIxO97Ib4= +-----END CERTIFICATE----- +`; +const LEAF = `-----BEGIN CERTIFICATE----- +MIIDqjCCApKgAwIBAgICCwswDQYJKoZIhvcNAQELBQAwPjELMAkGA1UEBhMCVVMx +FDASBgNVBAoMC0V4YW1wbGUgT3JnMRkwFwYDVQQDDBBFeGFtcGxlIFJTQSBSb290 +MCAXDTI2MTAyMjA5NDY0MVoYDzIxMjYwOTI4MDk0NjQxWjBfMQswCQYDVQQGEwJV +UzEUMBIGA1UECgwLRXhhbXBsZSBPcmcxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNv +bTEgMB4GCSqGSIb3DQEJARYRYWRtaW5AZXhhbXBsZS5jb20wWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAARidSirNIIrw+YHeS112z3N+1n0+uA9HjzkQ5u3QfZsRGCe +ivCEMQSs5+cUE/nv5WWda2kQWavxPGluHFtYmS10o4IBWDCCAVQwCQYDVR0TBAIw +ADALBgNVHQ8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMHsG +A1UdEQR0MHKCDSouZXhhbXBsZS5jb22CC2V4YW1wbGUuY29tghV4bi0tYmNoZXIt +a3ZhLmV4YW1wbGWBD29wc0BleGFtcGxlLmNvbYcECgAAAYcQIAENuAAAAAAAAAAA +AAAAAYYUaHR0cHM6Ly9leGFtcGxlLmNvbS8wXgYIKwYBBQUHAQEEUjBQMCQGCCsG +AQUFBzABhhhodHRwOi8vb2NzcC5leGFtcGxlLmNvbS8wKAYIKwYBBQUHMAKGHGh0 +dHA6Ly9jYS5leGFtcGxlLmNvbS9jYS5jcnQwHQYDVR0OBBYEFHyUnRTDwZxUPVwZ +RKmse3fjoeMMMB8GA1UdIwQYMBaAFAgaWVveRbA7VKqcHzjMqBPzi1/LMA0GCSqG +SIb3DQEBCwUAA4IBAQAfMTcpidoH47PXOQVxRttfrG7K9BcntyXP4+5hUAoSfMr6 +Rxy7X/9ba7R0yCGlNsUCusObaBpbdvwX8v9TiTYKVL+C9rEVGrTHnh6kbgZaaCQc +L3oWbfRrYnEBYuSlFuadjVvX42aqzHV28E4ITgywCxwubu2hAbacbcUHpnRSqLDq +lV+VZSQA/6cPNct7PR6AkJU80D+5wVOk7RVi4rsfbw1wFDeksCHu+0EThs9zjzL+ +S7HTCjB6AOHpbu1Sx+0d5YEKSE7EPF+dSFATNbhjTHzYQySnHK+Lm8nON8wmY1rC +7opOhvUlid1jA1d2BKO2ZxlxLir4pyIehTC1ddG2 +-----END CERTIFICATE----- +`; +const PLAIN = `-----BEGIN CERTIFICATE----- +MIIC8jCCAdoCAQcwDQYJKoZIhvcNAQELBQAwPjEaMBgGA1UEAwwRcGxhaW4uZXhh +bXBsZS5vcmcxIDAeBgkqhkiG9w0BCQEWEXBsYWluQGV4YW1wbGUub3JnMCAXDTI2 +MTAyMjA5NDY0MVoYDzIxMjYwOTI4MDk0NjQxWjA+MRowGAYDVQQDDBFwbGFpbi5l +eGFtcGxlLm9yZzEgMB4GCSqGSIb3DQEJARYRcGxhaW5AZXhhbXBsZS5vcmcwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq01NcNAy9Ng04mVuAFvmZUvsm +rY7aOaMgtODmptulgZ2qw25E09RgGYCn1S+4IYDu9hH9X4s+28Ag7Khmj4nmeoXN +of5095wche8DR2AAUElqyr/Nmgm2kTLdIJX4wl0eWXHIlmSPdOUNovA6JjGKH99b +8W7a2TqY/TsSadpPSvvN2esr7Jr4KgW7MXPUOnwNbcBOVcQP9rtFrduAWz01q0HM +02LboclnUnk/e58BXAj8vgdVojljDcc0bhbdz/Hh1/5gaaNILjJNOd4AgwZaCT02 +KojGz4Nb6MSgFmON2iMgAZjY1fXXkJ7wsfVPytjW73e7uV/RFuqSHQZenxupAgMB +AAEwDQYJKoZIhvcNAQELBQADggEBAJX1DGuiSLoCKBDI3oCgAu7X86G01ovEgZJr +8JrmGHW5q7ujjo53t46h8XPm3LJ8UV9/MZ+Yry43xB2txXYv3b3g/i+7JdTWLgUD +INA5VWvVkEZfm0lBEpU1Agpy4lV5uUCKZTgP/3MY5hnJ0YhQIs4BOjbwueMRFshL +yyHVb73MMbpWaZ7+T368seafa+ekeuY6gQly/HacuszjoYd6KKV3n8eDewenClCF +HoOF1Y4I+CvmR+a6B3iBczJ+a4tceFIIWsZUIeqzvGHpM7G9YwlmbvGprfLgR8Nv +igUib/D9tU3hdL6VWN5nMjdXV/8Le4nDnBjVBjYDkVbsnmT91AQ= +-----END CERTIFICATE----- +`; +const ROOT_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDCx+J26mddByfw +9QVdoIpFjDxcojUZSfb1Tn8hQaMCclVpGZwHoE9OZuVsq3jLuAu9TiHUfG7KnHwV +lJnfR5k5LWLHKvKYO2EbnNQerNeTnMAaw29l5eM3djFML29fkKlD1TPaL5ss6Lc8 +mwSrIpVn4YZP1+khoAsbXVitaZdNSSj9rQAoai3TDw79FoyldCmRt7JMEXp/ikc0 +yrFAbL4qWkL/7Ign2mxPqrJTfccXdZr4M7L7S3EAURA3D2bWPtp04udTTMGmQz9u +c/Z9LDEyQMHYOLY/kfo+R2otPjMF/8Et2DtaqnHFKqqBWZpJeESpRDrGN/nXItHf +DL/EFaExAgMBAAECggEADsvlabgryvbYs2bBlhe0eU0KZtURqI6kWoUwhiO40N5D +m7zMzA2DCMcrRYKPJhagmaxeSIh9WBnkGTWVLM+BFcVbXBrom+Ti3xieSohBxh5h +v4a6Jyj8BkINw6ZU1RMlsJf5HOmB5br19H4oMCHr7JzGubkCpkHEpjsEmgPL8Rxs +LYb9palDyTD1z5xVAQDAkvnCYQMX2DsiH8RuO4Ih0OoWMIsAGasS1dTQCkOzN9wF +cMg+hmJvQLN5xLYI+VPy/iZn+bmerCx8/NQBrbo+zibmnv7cDK1U9emVD70/Nd1d +B7ZEm+a4gReaxWVQGEkFMvs29u/TDWXYRnw0PGjUYwKBgQDrmKEx+ZiZwNNk/S3l +s8FgVrLFDr/+t5X33jaD5nVuSN6/AoR3qfh9piIyCrdNKdHicAcCnWP6skUFYLq3 +/tyMBATX3j+f3PHW3i2ZUAK26hgfp66F/3IyowePZbBDlGSmGsffVlc8on5fCk1Q +k3G0znktbrMCNxeeY9GSi1CHewKBgQDTpled6pZepZ54OGnKVJFo6DbQC0l4HQ6y +Rwv9BMtAz+AxWXda1oYK5bbJZrCmHyVP85VxkzRHOMxicv7M2kR9y2zZsJD7INqr +Nvt+lo2yGtIGv7kOdwWzvs4Pn7aCWSApzYBjuKrpP1xDzaMHhusAQtkk77YGCZCu +cCAQ/FjEQwKBgBq/1KWI8lOauiXAPULUWwNmNJv9Jb10+LkQ6N2m//RKn8fOMF4p +1UXm5AEMiG1hbywnjhQ07JIdRYrrxad4b3ASUL51xIGRI4BC76h1lilQgPavIjii +nUO0kPouEIpLwfW+Gq137yW6SWDfGZIpe4KM4uNOq1J4EN1x9eyOltEbAoGAP4bj +L5QVFE2T1jPglQlzJ9DjeuiYYcQfACXRFF3XABolCcr3xz0yuJygk60OAp7AnVHY +nmF3xJToy/gGQMiW3Ect4d1cdvQyjkePjzxzRW1Fcx8i1ZOmE4CG6yueJVvJCqMT +EVuBeUc6U2Oqtnyplsd509tbgE5oreMbzN1ha28CgYAHpG10ZQRDeqWFIo/ttk6Q +Q1Gf53eqZFCgh8GO1hBJ7+Fp+Ec5S9UPMBDjZb6GET7ifF8Zit/lR4gN2PuELwJA +YaXTIehCugH7Q3oTjNcQtls6tW+/+jEGjbjSHyZvwk8EFHc0J7PWRszB36s+qM+O +YPorNipExSeWti1506ljLQ== +-----END PRIVATE KEY----- +`; +const LEAF_KEY = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEINqbifA6kN/DSN0cMD2s7fXiNtPXZqipFrbCs/3mCGbtoAoGCCqGSM49 +AwEHoUQDQgAEYnUoqzSCK8PmB3ktdds9zftZ9PrgPR485EObt0H2bERgnorwhDEE +rOfnFBP57+VlnWtpEFmr8TxpbhxbWJktdA== +-----END EC PRIVATE KEY----- +`; + +const ca = new X509Certificate(ROOT); +const leaf = new X509Certificate(Buffer.from(LEAF)); +const plain = new X509Certificate(PLAIN); +const caKey = createPrivateKey(ROOT_KEY); +const leafKey = createPrivateKey(LEAF_KEY); +function t(label, f) { + try { + const v = f(); + console.log(label, typeof v === "string" ? JSON.stringify(v) : v); + } catch (e) { + console.log(label, "THROW", e.name, e.code, e.message); + } +} +t("pub", () => [leaf.publicKey.asymmetricKeyType, ca.publicKey.asymmetricKeyType, ca.publicKey.asymmetricKeyDetails.modulusLength]); +t("pub same", () => leaf.publicKey === leaf.publicKey); +t("pub equals", () => leaf.publicKey.equals(createPublicKey(leafKey))); +t("verify ca", () => leaf.verify(ca.publicKey)); +t("verify self", () => leaf.verify(leaf.publicKey)); +t("verify ca self", () => ca.verify(ca.publicKey)); +t("verify private", () => leaf.verify(caKey)); +t("verify string", () => leaf.verify("x")); +t("verify secret", () => leaf.verify(createSecretKey(Buffer.alloc(16)))); +t("issued", () => [leaf.checkIssued(ca), ca.checkIssued(leaf), ca.checkIssued(ca), plain.checkIssued(plain), leaf.checkIssued(plain)]); +t("issued bad", () => leaf.checkIssued({})); +t("pk", () => [leaf.checkPrivateKey(leafKey), leaf.checkPrivateKey(caKey), ca.checkPrivateKey(caKey)]); +t("pk public", () => leaf.checkPrivateKey(leaf.publicKey)); +t("pk string", () => leaf.checkPrivateKey("x")); +for (const h of ["www.example.com", "foo.example.com", "a.b.example.com", "example.com", "EXAMPLE.COM", "xn--bcher-kva.example", "other.org", "10.0.0.1", "", "foo.example.com."]) t("host " + h, () => leaf.checkHost(h)); +t("host plain", () => plain.checkHost("plain.example.org")); +t("host plain never", () => plain.checkHost("plain.example.org", { subject: "never" })); +t("host leaf always", () => leaf.checkHost("www.example.com", { subject: "always" })); +t("host nowild", () => leaf.checkHost("foo.example.com", { wildcards: false })); +t("host partial", () => leaf.checkHost("foo.example.com", { partialWildcards: false })); +t("host multi", () => leaf.checkHost("a.b.example.com", { multiLabelWildcards: true })); +t("host single", () => leaf.checkHost("example.com", { singleLabelSubdomains: true })); +t("host num", () => leaf.checkHost(1)); +t("host opts bad", () => leaf.checkHost("x", { subject: "sometimes" })); +t("host opts type", () => leaf.checkHost("x", 5)); +t("host wild type", () => leaf.checkHost("x", { wildcards: "yes" })); +for (const e of ["ops@example.com", "OPS@EXAMPLE.COM", "admin@example.com", "nobody@example.com"]) t("email " + e, () => leaf.checkEmail(e)); +t("email plain", () => plain.checkEmail("plain@example.org")); +t("email plain never", () => plain.checkEmail("plain@example.org", { subject: "never" })); +t("email num", () => leaf.checkEmail(1)); +for (const ip of ["10.0.0.1", "2001:db8::1", "2001:DB8:0:0:0:0:0:1", "10.0.0.2", "::ffff:10.0.0.1", "bogus", ""]) t("ip " + ip, () => leaf.checkIP(ip)); +t("ip num", () => leaf.checkIP(1)); +t("ip opts", () => leaf.checkIP("10.0.0.1", {})); +for (const cert of [leaf, ca, plain]) { + const legacy = cert.toLegacyObject(); + console.log(Object.keys(legacy).join(","), legacy.subject.CN, legacy.bits, legacy.raw.length); +} +t("pem", () => [leaf.toString() === leaf.toJSON(), String(ca) === `${ca}`, ca.toString().split("\n").length]); +t("pem round trip", () => new X509Certificate(plain.toJSON()).fingerprint === plain.fingerprint); diff --git a/tests/corpus/7095-tls-peer-certificates.ts b/tests/corpus/7095-tls-peer-certificates.ts new file mode 100644 index 000000000..eee3c9ac8 --- /dev/null +++ b/tests/corpus/7095-tls-peer-certificates.ts @@ -0,0 +1,188 @@ +// tlsSocket.getPeerCertificate([detailed]), getCertificate(), +// getPeerX509Certificate() and getX509Certificate() match Node: the +// legacy certificate objects, {} before a handshake or without a +// certificate and null once the socket is destroyed; the detailed +// issuerCertificate chain from the certificates the peer sent and then +// the trust anchors, in any order, ending at a self-issued root that is its +// own issuer; and the certificates served by SNI answers, secureContext +// wraps and https servers. +import { X509Certificate } from "node:crypto"; +import { readFileSync } from "node:fs"; +import * as https from "node:https"; +import * as net from "node:net"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const d = "tests/fixtures/server/certs"; +const read = (f: string): Buffer => readFileSync(`${d}/${f}`); +const ca = read("ca.pem"), ca2 = read("ca2.pem"), cert = read("localhost.pem"), key = read("localhost-key.pem"); +const self = read("selfsigned.pem"), selfKey = read("selfsigned-key.pem"), alt = read("alt.pem"), altKey = read("alt-key.pem"); + +function describe(label: string, o: tls.PeerCertificate | tls.DetailedPeerCertificate | object | null): string { + if (o === null) return `${label} null`; + const keys = Object.keys(o); + if (keys.length === 0) return `${label} {}`; + const p = o as tls.DetailedPeerCertificate; + const issuer = p.issuerCertificate; + const link = issuer ? ` issuerCert:${issuer.subject.CN} self:${issuer === issuer.issuerCertificate} same:${issuer === p}` : ""; + return `${label} ${keys.join(",")} ${p.subject.CN} ${p.issuer.CN}${link}`; +} +function show(label: string, o: tls.PeerCertificate | tls.DetailedPeerCertificate | object | null): void { + console.log(describe(label, o)); +} +// A server's lines print once its client has the reply, so the output +// order does not depend on which side's callback runs first. +const serverLines: string[] = []; +function flushServer(): void { + for (const line of serverLines.splice(0)) console.log(line); +} +await new Promise((done) => { +const server = tls.createServer({ cert, key }, (s) => { + serverLines.push(describe("server peer", s.getPeerCertificate())); + serverLines.push(describe("server peer detailed", s.getPeerCertificate(true))); + serverLines.push(`server peerX509 ${s.getPeerX509Certificate()?.subject}`); + serverLines.push(describe("server own", s.getCertificate())); + serverLines.push(`server ownX509 ${s.getX509Certificate()?.subject}`); + s.end("hi"); +}); +server.listen(0, () => { + const c = tls.connect({ port: (server.address() as AddressInfo).port, ca, servername: "localhost" }, () => { + show("client peer", c.getPeerCertificate()); + const det = c.getPeerCertificate(true); + show("client peer detailed", det); + show("client issuer", det.issuerCertificate); + const x = c.getPeerX509Certificate(); + if (x) console.log("client peerX509", x.subject, x.issuer, x === c.getPeerX509Certificate(), x.checkIssued(new X509Certificate(ca)), x.checkHost("localhost")); + show("client own", c.getCertificate()); + console.log("client ownX509", c.getX509Certificate()?.subject); + console.log("peer raw eq", c.getPeerCertificate().raw.equals(x!.raw), c.getPeerCertificate() === c.getPeerCertificate()); + }); + c.on("data", () => {}); + c.on("end", () => { + flushServer(); + show("after end peer", c.getPeerCertificate()); + c.destroy(); + }); + c.on("close", () => { + show("after close peer", c.getPeerCertificate()); + console.log("after close X509", c.getPeerX509Certificate()?.subject, c.getCertificate()); + server.close(() => done()); + }); + show("before handshake", c.getPeerCertificate()); + console.log("before X509", c.getPeerX509Certificate()?.subject, c.getCertificate(), c.getX509Certificate()?.subject); +}); +}); + +function chain(o: tls.DetailedPeerCertificate | null): string { + if (o === null) return "null"; + if (Object.keys(o).length === 0) return "{}"; + const names: string[] = []; + let at: tls.DetailedPeerCertificate | undefined = o; + for (let i = 0; at !== undefined && i < 6; i++) { + names.push(at.subject.CN as string); + const next: tls.DetailedPeerCertificate | undefined = at.issuerCertificate; + if (next === at) { + names.push("(self)"); + break; + } + at = next; + } + return names.join(" -> "); +} +async function run(label: string, served: Buffer, servedKey: Buffer, trust: Buffer | null): Promise { + await new Promise((done) => { + const server = tls.createServer({ cert: served, key: servedKey }, (s) => s.end()); + server.listen(0, () => { + const port = (server.address() as AddressInfo).port; + const socket = + trust === null + ? tls.connect({ port, servername: "localhost", rejectUnauthorized: false }) + : tls.connect({ port, servername: "localhost", ca: trust, rejectUnauthorized: false }); + socket.on("secureConnect", () => { + console.log(label, socket.authorized, chain(socket.getPeerCertificate(true))); + console.log(label, "abbreviated", "issuerCertificate" in socket.getPeerCertificate(), "issuerCertificate" in socket.getPeerCertificate(false)); + socket.destroy(); + console.log(label, "destroyed", socket.getPeerCertificate(), socket.getPeerX509Certificate()?.subject, socket.getCertificate()); + server.close(() => done()); + }); + socket.on("error", (e) => console.log(label, "error", e.message)); + }); + }); +} +await run("leaf", cert, key, ca); +await run("sent chain", Buffer.concat([cert, ca]), key, ca); +await run("sent chain, default store", Buffer.concat([cert, ca]), key, null); +await run("out of order", Buffer.concat([cert, ca2, ca]), key, ca); +await run("self-signed", self, selfKey, null); +await run("wrong ca", cert, key, ca2); +await run("leaf, default store", cert, key, null); + +// SNI and secureContext: the certificate the server socket serves. +await new Promise((done) => { + const altCtx = tls.createSecureContext({ cert: alt, key: altKey }); + const server = tls.createServer({ cert, key, SNICallback: (name: string, cb: (err: Error | null, ctx?: tls.SecureContext) => void) => cb(null, name === "alt.localhost" ? altCtx : undefined) }, (s) => { + serverLines.push(`sni own ${(s.getCertificate() as tls.PeerCertificate).subject.CN} ${s.getX509Certificate()?.subject}`); + s.end("x"); + }); + server.listen(0, () => { + const port = (server.address() as AddressInfo).port; + const a = tls.connect({ port, ca, servername: "alt.localhost" }, () => { + console.log("sni peer", a.getPeerCertificate().subject.CN, a.getPeerX509Certificate()?.subjectAltName); + }); + a.resume(); + a.on("end", () => { + flushServer(); + a.destroy(); + const b = tls.connect({ port, ca, servername: "localhost" }, () => { + console.log("sni default peer", b.getPeerCertificate().subject.CN); + }); + b.resume(); + b.on("end", () => { + flushServer(); + b.destroy(); + server.close(() => done()); + }); + }); + }); +}); + +// new tls.TLSSocket over a net connection, with a secureContext. +await new Promise((done) => { + const context = tls.createSecureContext({ cert: alt, key: altKey }); + const server = net.createServer((raw) => { + const secure = new tls.TLSSocket(raw, { isServer: true, secureContext: context }); + console.log("wrap before", (secure.getCertificate() as tls.PeerCertificate).subject?.CN, secure.getX509Certificate()?.subject, JSON.stringify(secure.getPeerCertificate())); + secure.on("secure", () => { + serverLines.push(`wrap secure ${(secure.getCertificate() as tls.PeerCertificate).subject.CN} ${secure.getPeerX509Certificate()?.subject}`); + secure.end("x"); + }); + }); + server.listen(0, () => { + const c = tls.connect({ port: (server.address() as AddressInfo).port, ca, servername: "alt.localhost" }, () => { + console.log("wrap client", chain(c.getPeerCertificate(true))); + }); + c.resume(); + c.on("end", () => { + flushServer(); + c.destroy(); + server.close(() => done()); + }); + }); +}); + +// https: both sides of a request. +await new Promise((done) => { + const server = https.createServer({ cert, key }, (req, res) => { + const s = req.socket as tls.TLSSocket; + console.log("https server", (s.getCertificate() as tls.PeerCertificate).subject.CN, JSON.stringify(s.getPeerCertificate())); + res.end("ok"); + }); + server.listen(0, () => { + https.get({ port: (server.address() as AddressInfo).port, ca, host: "localhost", path: "/" }, (res) => { + const s = res.socket as tls.TLSSocket; + console.log("https client", chain(s.getPeerCertificate(true)), s.getPeerX509Certificate()?.issuer); + res.resume(); + res.on("end", () => server.close(() => done())); + }); + }); +}); diff --git a/tests/corpus/7096-tls-peer-certificates.mjs b/tests/corpus/7096-tls-peer-certificates.mjs new file mode 100644 index 000000000..ef1bfeb2a --- /dev/null +++ b/tests/corpus/7096-tls-peer-certificates.mjs @@ -0,0 +1,35 @@ +// tlsSocket.getPeerCertificate from JavaScript matches Node: only a +// detailed argument that is true itself adds the issuerCertificate chain, +// and the legacy object is a plain object with a Buffer raw and arrays for +// extended key usages. +import * as tls from "node:tls"; +import { readFileSync } from "node:fs"; + +const d = "tests/fixtures/server/certs"; +const ca = readFileSync(`${d}/ca.pem`); +const cert = readFileSync(`${d}/localhost.pem`); +const key = readFileSync(`${d}/localhost-key.pem`); + +const server = tls.createServer({ cert, key }, (s) => s.end("x")); +server.listen(0, () => { + const c = tls.connect({ port: server.address().port, ca, servername: "localhost" }, () => { + for (const arg of [true, false, 1, "yes", null, undefined, {}]) { + const peer = c.getPeerCertificate(arg); + console.log(String(arg), "issuerCertificate" in peer, peer.issuer.CN); + } + const legacy = c.getPeerCertificate(); + console.log(Object.keys(legacy).length, Buffer.isBuffer(legacy.raw), legacy.ext_key_usage, JSON.stringify(legacy.subject)); + const detailed = c.getPeerCertificate(true); + const root = detailed.issuerCertificate; + console.log(root.subject.CN, root.issuerCertificate === root, root.ca, Buffer.isBuffer(root.raw)); + const x509 = c.getPeerX509Certificate(); + if (x509) console.log(x509.subject, x509.checkHost("localhost"), x509.checkIssued(x509), legacy.raw.equals(x509.raw)); + console.log(c.getCertificate(), c.getX509Certificate() === undefined); + }); + c.resume(); + c.on("end", () => { + c.destroy(); + console.log(c.getPeerCertificate(), c.getPeerX509Certificate() === undefined, c.getCertificate()); + server.close(); + }); +}); diff --git a/tests/corpus/7097-tls-client-certificates.ts b/tests/corpus/7097-tls-client-certificates.ts new file mode 100644 index 000000000..b45560084 --- /dev/null +++ b/tests/corpus/7097-tls-client-certificates.ts @@ -0,0 +1,84 @@ +// Client certificates match Node: tls.createServer's requestCert, +// rejectUnauthorized and ca against clients that send no certificate, a +// valid one, one without the clientAuth purpose and a self-signed one +// (socket.authorized and authorizationError on the server, the peer +// certificate, the client's own certificate), the client's chain completed +// from its ca, and a server that requires a certificate rejecting a client +// without one in TLS 1.3 and TLS 1.2 (the client's events and codes). +import { readFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const r = (f: string): Buffer => readFileSync(`${D}/${f}`); +const ca = r("ca.pem"), ca2 = r("ca2.pem"), cert = r("localhost.pem"), key = r("localhost-key.pem"); +const client = r("client.pem"), clientKey = r("client-key.pem"); +const self = r("selfsigned.pem"), selfKey = r("selfsigned-key.pem"); + +type Handler = (s: tls.TLSSocket) => void; +const servers: [string, (h: Handler) => tls.Server][] = [ + ["request", (h) => tls.createServer({ cert, key, requestCert: true, rejectUnauthorized: false, ca }, h)], + ["reject", (h) => tls.createServer({ cert, key, requestCert: true, ca }, h)], + ["request-noca", (h) => tls.createServer({ cert, key, requestCert: true, rejectUnauthorized: false }, h)], + ["request-ca2", (h) => tls.createServer({ cert, key, requestCert: true, rejectUnauthorized: false, ca: ca2 }, h)], + // an empty Buffer is an empty trust store; an empty string is no ca + ["request-empty", (h) => tls.createServer({ cert, key, requestCert: true, rejectUnauthorized: false, ca: Buffer.alloc(0) }, h)], + ["request-emptystr", (h) => tls.createServer({ cert, key, requestCert: true, rejectUnauthorized: false, ca: "" }, h)], + ["plain", (h) => tls.createServer({ cert, key }, h)], +]; +const clients: [string, Buffer | null, Buffer | null][] = [ + ["none", null, null], + ["valid", client, clientKey], + ["serverEku", cert, key], + ["self", self, selfKey], +]; +async function run(label: string, make: (h: Handler) => tls.Server, name: string, clientCert: Buffer | null, clientKeyPem: Buffer | null): Promise { + await new Promise((done) => { + const events: string[] = []; + const server = make((s) => { + events.push(`server secureConnection authorized=${s.authorized} error=${s.authorizationError} peer=${(s.getPeerCertificate() as tls.PeerCertificate).subject?.CN} x509=${s.getPeerX509Certificate()?.subject}`); + s.end("ok"); + }); + server.listen(0, () => { + const port = (server.address() as AddressInfo).port; + const c = + clientCert === null || clientKeyPem === null + ? tls.connect({ port, ca, servername: "localhost" }) + : tls.connect({ port, ca, servername: "localhost", cert: clientCert, key: clientKeyPem }); + c.on("secureConnect", () => events.push(`client secureConnect own=${(c.getCertificate() as tls.PeerCertificate | null)?.subject?.CN}`)); + c.on("data", (d: Buffer) => events.push(`client data ${d}`)); + c.on("end", () => events.push("client end")); + c.on("error", (e: NodeJS.ErrnoException) => events.push(`client error ${e.code}`)); + c.on("close", () => { + setTimeout(() => { + console.log(label, name, "|", events.join(" ; ")); + server.close(() => done()); + }, 50); + }); + }); + }); +} +for (const [label, make] of servers) { + for (const [name, c, k] of clients) await run(label, make, name, c, k); +} + +// A server that requires a certificate, per protocol version: TLS 1.3 +// rejects after the client's handshake, TLS 1.2 during it. +for (const maxVersion of ["TLSv1.3", "TLSv1.2"] as const) { + await new Promise((done) => { + const server = tls.createServer({ cert, key, requestCert: true, ca, maxVersion }, (s) => s.end("ok")); + server.listen(0, () => { + const events: string[] = []; + const c = tls.connect({ port: (server.address() as AddressInfo).port, ca, servername: "localhost" }); + c.on("secureConnect", () => events.push("secureConnect")); + c.on("data", () => events.push("data")); + c.on("end", () => events.push("end")); + c.on("error", (e: NodeJS.ErrnoException) => events.push(`error ${e.code}`)); + c.on("close", (hadError: boolean) => { + events.push(`close ${hadError}`); + console.log(maxVersion, events.join(" ; ")); + server.close(() => done()); + }); + }); + }); +} diff --git a/tests/corpus/7098-tls-client-certificates-paths.ts b/tests/corpus/7098-tls-client-certificates-paths.ts new file mode 100644 index 000000000..b71e72b6f --- /dev/null +++ b/tests/corpus/7098-tls-client-certificates-paths.ts @@ -0,0 +1,161 @@ +// Client certificates on the other paths match Node: a server's (and a +// SecureContext's) ca completes the certificate chain it sends, as +// OpenSSL's automatic chain does; new tls.TLSSocket with requestCert and +// rejectUnauthorized, where Node reports no verdict; and https on both +// sides, including an agent that pools sockets per client certificate. +import { readFileSync } from "node:fs"; +import * as http from "node:http"; +import * as https from "node:https"; +import * as net from "node:net"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const r = (f: string): Buffer => readFileSync(`${D}/${f}`); +const ca = r("ca.pem"), ca2 = r("ca2.pem"), cert = r("localhost.pem"), key = r("localhost-key.pem"); +const client = r("client.pem"), clientKey = r("client-key.pem"); + +// A server's ca completes the chain it sends. +for (const [label, make] of [ + ["no ca", () => tls.createServer({ cert, key }, (s) => s.end())], + ["ca", () => tls.createServer({ cert, key, ca }, (s) => s.end())], + ["other ca", () => tls.createServer({ cert, key, ca: ca2 }, (s) => s.end())], + // An SNI answer's own ca does not complete its chain. + ["sni ctx ca, server no ca", () => { + const context = tls.createSecureContext({ cert, key, ca }); + return tls.createServer({ cert, key, SNICallback: (name: string, cb: (err: Error | null, ctx?: tls.SecureContext) => void) => cb(null, context) }, (s) => s.end()); + }], +] as [string, () => tls.Server][]) { + await new Promise((done) => { + const server = make(); + server.listen(0, () => { + const c = tls.connect({ port: (server.address() as AddressInfo).port, ca: ca2, servername: "localhost", rejectUnauthorized: false }, () => { + const names: string[] = []; + let p: tls.DetailedPeerCertificate | undefined = c.getPeerCertificate(true); + for (let i = 0; p !== undefined && i < 4; i++) { + names.push(p.subject.CN as string); + if (p.issuerCertificate === p) break; + p = p.issuerCertificate; + } + console.log(label, c.authorizationError, names.join(" -> ")); + c.destroy(); + server.close(() => done()); + }); + }); + }); +} + +// A TLSSocket's secureContext sends the chain its ca completes. +for (const [label, context] of [ + ["wrap ctx ca", tls.createSecureContext({ cert, key, ca })], + ["wrap ctx", tls.createSecureContext({ cert, key })], +] as [string, tls.SecureContext][]) { + await new Promise((done) => { + const server = net.createServer((raw) => { + const s = new tls.TLSSocket(raw, { isServer: true, secureContext: context }); + s.on("secure", () => s.end()); + }); + server.listen(0, () => { + const c = tls.connect({ port: (server.address() as AddressInfo).port, ca: ca2, servername: "localhost", rejectUnauthorized: false }, () => { + const names: string[] = []; + let p: tls.DetailedPeerCertificate | undefined = c.getPeerCertificate(true); + for (let i = 0; p !== undefined && i < 4; i++) { + names.push(p.subject.CN as string); + if (p.issuerCertificate === p) break; + p = p.issuerCertificate; + } + console.log(label, c.authorizationError, names.join(" -> ")); + c.destroy(); + server.close(() => done()); + }); + }); + }); +} + +// new tls.TLSSocket with client certificates: Node reports no verdict there. +for (const [label, wrap] of [ + ["wrap request", (raw: net.Socket) => new tls.TLSSocket(raw, { isServer: true, cert, key, requestCert: true, ca })], + ["wrap reject", (raw: net.Socket) => new tls.TLSSocket(raw, { isServer: true, cert, key, requestCert: true, rejectUnauthorized: true, ca })], +] as [string, (raw: net.Socket) => tls.TLSSocket][]) { + for (const withCert of [false, true]) { + await new Promise((done) => { + const events: string[] = []; + const server = net.createServer((raw) => { + const s = wrap(raw); + s.on("secure", () => { + events.push(`wrap secure authorized=${s.authorized} error=${s.authorizationError} peer=${(s.getPeerCertificate() as tls.PeerCertificate).subject?.CN}`); + s.end("ok"); + }); + s.on("error", (e: NodeJS.ErrnoException) => events.push(`wrap error ${e.code}`)); + }); + server.listen(0, () => { + const port = (server.address() as AddressInfo).port; + const c = withCert + ? tls.connect({ port, ca, servername: "localhost", cert: client, key: clientKey }) + : tls.connect({ port, ca, servername: "localhost" }); + c.on("secureConnect", () => events.push("client secureConnect")); + c.on("data", (d: Buffer) => events.push(`client data ${d}`)); + c.on("error", (e: NodeJS.ErrnoException) => events.push(`client error ${e.code}`)); + c.on("close", () => { + setTimeout(() => { + console.log(label, withCert ? "cert" : "none", "|", events.join(" ; ")); + server.close(() => done()); + }, 50); + }); + }); + }); + } +} + +// https with client certificates, on both sides. +await new Promise((done) => { + const server = https.createServer({ cert, key, requestCert: true, rejectUnauthorized: false, ca }, (req, res) => { + const s = req.socket as tls.TLSSocket; + res.end(`${s.authorized} ${s.authorizationError} ${(s.getPeerCertificate() as tls.PeerCertificate).subject?.CN}`); + }); + server.listen(0, () => { + const port = (server.address() as AddressInfo).port; + https.get({ port, ca, host: "localhost", path: "/", cert: client, key: clientKey }, (res) => { + let body = ""; + res.on("data", (d: Buffer) => (body += d)); + res.on("end", () => { + console.log("https with cert", body); + https.get({ port, ca, host: "localhost", path: "/", agent: false }, (res2) => { + let body2 = ""; + res2.on("data", (d: Buffer) => (body2 += d)); + res2.on("end", () => { + console.log("https without cert", body2); + server.close(() => done()); + }); + }); + }); + }); + }); +}); + +// An agent pools sockets per client certificate. +let connections = 0; +const server = https.createServer({ cert, key, requestCert: true, rejectUnauthorized: false, ca }, (req, res) => { + const s = req.socket as tls.TLSSocket; + res.end(`${s.authorized} ${(s.getPeerCertificate() as tls.PeerCertificate).subject?.CN}`); +}); +server.on("secureConnection", () => connections++); +function get(port: number, withCert: boolean): Promise { + return new Promise((resolve) => { + const done = (res: http.IncomingMessage) => { + let body = ""; + res.on("data", (d: Buffer) => (body += d)); + res.on("end", () => resolve(body)); + }; + if (withCert) https.get({ port, ca, host: "localhost", path: "/", cert: client, key: clientKey }, done); + else https.get({ port, ca, host: "localhost", path: "/" }, done); + }); +} +server.listen(0, async () => { + // the agent keeps one socket per client certificate + const port = (server.address() as AddressInfo).port; + for (const withCert of [true, true, false, false, true]) console.log(withCert, await get(port, withCert)); + console.log("connections", connections); + server.close(); + https.globalAgent.destroy(); +}); diff --git a/tests/corpus/7099-tls-client-certificates.mjs b/tests/corpus/7099-tls-client-certificates.mjs new file mode 100644 index 000000000..0c9eeed2f --- /dev/null +++ b/tests/corpus/7099-tls-client-certificates.mjs @@ -0,0 +1,34 @@ +// Client certificates from JavaScript match Node: a server that requests +// one reads socket.authorized, authorizationError and the peer +// certificate's chain, and a client sends its cert and key. +import * as tls from "node:tls"; +import { readFileSync } from "node:fs"; + +const D = "tests/fixtures/server/certs"; +const ca = readFileSync(`${D}/ca.pem`); +const cert = readFileSync(`${D}/localhost.pem`); +const key = readFileSync(`${D}/localhost-key.pem`); +const clientCert = readFileSync(`${D}/client.pem`); +const clientKey = readFileSync(`${D}/client-key.pem`); + +const lines = []; +const server = tls.createServer({ cert, key, ca, requestCert: true, rejectUnauthorized: false }, (socket) => { + const peer = socket.getPeerCertificate(true); + lines.push(`server ${socket.authorized} ${socket.authorizationError} ${peer.subject?.CN} ${peer.issuerCertificate?.subject.CN} ${peer.ext_key_usage}`); + socket.end("hello"); +}); +server.listen(0, () => { + const port = server.address().port; + const first = tls.connect({ port, ca, servername: "localhost", cert: clientCert, key: clientKey }, () => { + lines.push(`client ${first.authorized} ${first.getCertificate().subject.CN} ${first.getX509Certificate()?.subject}`); + }); + first.resume(); + first.on("end", () => { + const second = tls.connect({ port, ca, servername: "localhost" }, () => lines.push(`anonymous ${second.authorized}`)); + second.resume(); + second.on("end", () => { + for (const line of lines) console.log(line); + server.close(); + }); + }); +}); diff --git a/tests/corpus/7160-json-parse-deep-unions.ts b/tests/corpus/7160-json-parse-deep-unions.ts new file mode 100644 index 000000000..bcbf5ea9d --- /dev/null +++ b/tests/corpus/7160-json-parse-deep-unions.ts @@ -0,0 +1,164 @@ +// JSON.parse into recursive discriminated unions: deep and wide trees, +// unions with primitive arms beside the records, unions whose records share +// a literal, and unions with a record arm that has no literal. + +type Expr = + | { kind: "num"; value: number } + | { kind: "str"; text: string } + | { kind: "add"; left: Expr; right: Expr } + | { kind: "neg"; operand: Expr } + | { kind: "call"; callee: string; args: Expr[] } + | { kind: "cond"; test: Expr; then: Expr; otherwise: Expr | null } + | { kind: "label"; inner: Expr; name?: string }; + +type Stmt = + | { kind: "expr"; expr: Expr } + | { kind: "ret"; value: Expr | undefined } + | { kind: "block"; body: Stmt[] }; + +interface Fn { + name: string; + body: Stmt[]; +} + +function evaluate(e: Expr): number { + switch (e.kind) { + case "num": + return e.value; + case "str": + return e.text.length; + case "add": + return evaluate(e.left) + evaluate(e.right); + case "neg": + return -evaluate(e.operand); + case "call": + return e.args.reduce((sum, arg) => sum + evaluate(arg), e.callee.length); + case "cond": + return evaluate(e.test) !== 0 + ? evaluate(e.then) + : e.otherwise === null + ? 0 + : evaluate(e.otherwise); + case "label": + return evaluate(e.inner) + (e.name?.length ?? 0); + } +} + +function depth(e: Expr): number { + switch (e.kind) { + case "num": + case "str": + return 1; + case "add": + return 1 + Math.max(depth(e.left), depth(e.right)); + case "neg": + return 1 + depth(e.operand); + case "call": + return 1 + e.args.reduce((max, arg) => Math.max(max, depth(arg)), 0); + case "cond": + return 1 + Math.max(depth(e.test), depth(e.then), e.otherwise ? depth(e.otherwise) : 0); + case "label": + return 1 + depth(e.inner); + } +} + +function chain(n: number): string { + let text = '{"kind":"num","value":1}'; + for (let i = 0; i < n; i++) { + text = + i % 3 === 0 + ? `{"kind":"neg","operand":${text}}` + : i % 3 === 1 + ? `{"kind":"add","left":${text},"right":{"kind":"str","text":"ab"}}` + : `{"kind":"label","inner":${text}${i % 2 === 0 ? ',"name":"x"' : ""}}`; + } + return text; +} + +function wide(n: number, level: number): string { + if (level === 0) return `{"kind":"num","value":${n}}`; + const args: string[] = []; + for (let i = 0; i < 3; i++) args.push(wide(n + i, level - 1)); + return `{"kind":"call","callee":"f${level}","args":[${args.join(",")}]}`; +} + +for (const n of [0, 1, 5, 60, 400]) { + const e = JSON.parse(chain(n)) as Expr; + console.log("chain", n, e.kind, depth(e), evaluate(e)); +} + +const tree = JSON.parse(wide(1, 6)) as Expr; +console.log("wide", depth(tree), evaluate(tree)); + +const cond = JSON.parse( + '{"kind":"cond","test":{"kind":"num","value":0},"then":{"kind":"num","value":5},"otherwise":null}', +) as Expr; +console.log("cond", evaluate(cond)); + +const fn = JSON.parse( + JSON.stringify({ + name: "main", + body: [ + { kind: "expr", expr: JSON.parse(chain(30)) }, + { kind: "block", body: [{ kind: "ret", value: JSON.parse(wide(2, 3)) }, { kind: "ret" }] }, + ], + }), +) as Fn; +let statements = 0; +const visit = (stmts: Stmt[]): void => { + for (const s of stmts) { + statements++; + if (s.kind === "block") visit(s.body); + else if (s.kind === "expr") console.log("stmt expr", evaluate(s.expr)); + else console.log("stmt ret", s.value === undefined ? "none" : evaluate(s.value)); + } +}; +visit(fn.body); +console.log("statements", statements); + +// Primitive arms beside the records. +type Value = number | string | null | { kind: "list"; items: Value[] } | { kind: "pair"; first: Value; second: Value }; +function show(v: Value): string { + if (v === null || typeof v === "number" || typeof v === "string") return JSON.stringify(v); + return v.kind === "list" ? `[${v.items.map(show).join(" ")}]` : `(${show(v.first)} . ${show(v.second)})`; +} +const values = JSON.parse( + '[1,"a",null,{"kind":"list","items":[2,{"kind":"pair","first":"b","second":{"kind":"list","items":[null,3]}}]}]', +) as Value[]; +console.log("values", values.map(show).join(" | ")); + +// Two records that share a literal: the arm follows the other fields. +type Shared = { kind: "a"; count: number } | { kind: "a"; name: string } | { kind: "b"; next: Shared[] }; +function describe(s: Shared): string { + if (s.kind === "b") return `b(${s.next.map(describe).join(",")})`; + return "count" in s ? `count=${s.count}` : `name=${s.name}`; +} +const shared = JSON.parse( + '{"kind":"b","next":[{"kind":"a","count":1},{"kind":"a","name":"n"},{"kind":"b","next":[{"kind":"a","name":"m"}]}]}', +) as Shared; +console.log("shared", describe(shared)); + +// A record arm without a literal beside records that have one. +type Loose = { kind: "leaf"; value: number } | { kind: "node"; children: Loose[] } | { weight: number; child: Loose }; +function total(l: Loose): number { + if ("weight" in l) return l.weight * total(l.child); + return l.kind === "leaf" ? l.value : l.children.reduce((sum, c) => sum + total(c), 0); +} +const loose = JSON.parse( + '{"kind":"node","children":[{"kind":"leaf","value":2},{"weight":3,"child":{"kind":"node","children":[{"kind":"leaf","value":4},{"weight":2,"child":{"kind":"leaf","value":5}}]}}]}', +) as Loose; +console.log("loose", total(loose)); + +// Optional and nullable members holding records and arrays of records. +type Box = { value: number; child?: Box; rest: Box[] | null }; +function weigh(b: Box): number { + let total = b.value; + if (b.child !== undefined) total += 2 * weigh(b.child); + for (const r of b.rest ?? []) total += weigh(r); + return total; +} +let boxText = '{"value":1,"rest":null}'; +for (let i = 2; i < 120; i++) + boxText = i % 2 === 0 ? `{"value":${i},"child":${boxText},"rest":null}` : `{"value":${i},"rest":[${boxText},{"value":1,"rest":[]}]}`; +const boxes = JSON.parse(boxText) as Box; +console.log("boxes", weigh(boxes) % 1000003); diff --git a/tests/corpus/7161-dynamic-values-deep-unions.ts b/tests/corpus/7161-dynamic-values-deep-unions.ts new file mode 100644 index 000000000..3e85b2689 --- /dev/null +++ b/tests/corpus/7161-dynamic-values-deep-unions.ts @@ -0,0 +1,73 @@ +// Dynamic values cast into recursive discriminated unions: trees built as +// untyped objects, trees that mix typed records into untyped ones, and casts +// of the same values twice. + +type Node = + | { kind: "leaf"; value: number } + | { kind: "pair"; left: Node; right: Node } + | { kind: "wrap"; inner: Node; tag: string } + | { kind: "many"; items: Node[] }; + +function sum(n: Node): number { + switch (n.kind) { + case "leaf": + return n.value; + case "pair": + return sum(n.left) + sum(n.right); + case "wrap": + return sum(n.inner) + n.tag.length; + case "many": + return n.items.reduce((total, item) => total + sum(item), 0); + } +} + +function height(n: Node): number { + switch (n.kind) { + case "leaf": + return 1; + case "pair": + return 1 + Math.max(height(n.left), height(n.right)); + case "wrap": + return 1 + height(n.inner); + case "many": + return 1 + n.items.reduce((max, item) => Math.max(max, height(item)), 0); + } +} + +function untyped(levels: number): any { + let node: any = { kind: "leaf", value: 1 }; + for (let i = 0; i < levels; i++) { + node = + i % 3 === 0 + ? { kind: "wrap", inner: node, tag: "t" } + : i % 3 === 1 + ? { kind: "pair", left: node, right: { kind: "leaf", value: i } } + : { kind: "many", items: [node, { kind: "leaf", value: 2 }] }; + } + return node; +} + +for (const levels of [0, 2, 10, 90, 300]) { + const value: unknown = untyped(levels); + const node = value as Node; + console.log("untyped", levels, node.kind, height(node), sum(node)); +} + +// Typed records inside an untyped tree. +const typedLeaf: Node = { kind: "leaf", value: 7 }; +const typedPair: Node = { kind: "pair", left: typedLeaf, right: { kind: "leaf", value: 3 } }; +const mixed: any = { kind: "many", items: [typedPair, untyped(20), { kind: "wrap", inner: typedLeaf, tag: "xy" }] }; +const fromMixed = mixed as Node; +console.log("mixed", height(fromMixed), sum(fromMixed)); + +// The same untyped value cast twice gives equal trees. +const again: any = untyped(40); +const first = again as Node; +const second = again as Node; +console.log("twice", sum(first) === sum(second), height(first), height(second)); + +// A tree that changes between casts. +const changing: any = { kind: "pair", left: { kind: "leaf", value: 1 }, right: untyped(5) }; +console.log("before", sum(changing as Node)); +changing.left = { kind: "wrap", inner: { kind: "leaf", value: 10 }, tag: "abc" }; +console.log("after", sum(changing as Node)); diff --git a/tests/corpus/7186-namespace-object-values.js b/tests/corpus/7186-namespace-object-values.js new file mode 100644 index 000000000..33c30e12f --- /dev/null +++ b/tests/corpus/7186-namespace-object-values.js @@ -0,0 +1,27 @@ +// Math, JSON and Reflect read as values are objects with Node's own +// properties: typeof, String, `in`, aliasing and member calls through +// the value behave as in Node, and every spelling is the same object. +function describe(value) { + return [typeof value, String(value), Object.prototype.toString.call(value)].join(" "); +} +console.log(describe(Math), "|", describe(JSON), "|", describe(Reflect)); + +const m = Math; +console.log(m.abs(-2), m.max(1, 5, 3), m.PI.toFixed(5), "trunc" in m, m === Math); +console.log(m === globalThis.Math, JSON === globalThis.JSON); +const { Math: fromGlobal } = globalThis; +console.log(fromGlobal === Math, fromGlobal.sqrt(81)); + +const known = new Set([Math, JSON, Reflect]); +console.log(known.has(Math), known.has(globalThis.JSON), known.size); + +const parse = JSON.parse; +console.log(parse("[1,2]").length, JSON.stringify({ a: [1] })); + +console.log(typeof Math === "object" && typeof Reflect === "object"); +console.log(Math.max.length, Math.min.length, Math.hypot.length, Math.abs.length); + +const r = Reflect; +const ownKeys = r.ownKeys; +console.log(ownKeys === Reflect.ownKeys, r.ownKeys === Reflect.ownKeys, typeof ownKeys); +console.log(r.ownKeys({ a: 1, b: 2 }).length, ownKeys({ c: 3 })[0], ownKeys.name, ownKeys.length); diff --git a/tests/corpus/7187-namespace-object-reflection.js b/tests/corpus/7187-namespace-object-reflection.js new file mode 100644 index 000000000..d62699dea --- /dev/null +++ b/tests/corpus/7187-namespace-object-reflection.js @@ -0,0 +1,23 @@ +// Reflection over the namespace objects answers Node's property +// attributes: members are own, non-enumerable properties, the Math +// constants are read-only, and Symbol.toStringTag names the namespace. +function show(object, key) { + const d = Object.getOwnPropertyDescriptor(object, key); + if (d === undefined) return `${String(key)}: none`; + const value = typeof d.value === "function" ? `fn/${d.value.length}/${d.value.name}` : String(d.value); + return `${String(key)}: ${value} ${d.writable} ${d.enumerable} ${d.configurable}`; +} +function inspect(object) { + return [ + Object.hasOwn(object, "parse"), + Object.hasOwn(object, "abs"), + Object.keys(object).length, + Object.getOwnPropertyNames(object).length, + ].join(" "); +} +console.log(show(Math, "abs"), "|", show(Math, "PI"), "|", show(Math, "max")); +console.log(show(Math, Symbol.toStringTag), "|", show(JSON, "stringify"), "|", show(Reflect, "apply")); +console.log(inspect(Math), "|", inspect(JSON), "|", inspect(Reflect)); +console.log(Object.getOwnPropertyNames(JSON).join(",")); +console.log(Object.getOwnPropertyNames(Math).slice(0, 5).join(","), Object.getOwnPropertyNames(Math).at(-1)); +console.log(Object.prototype.hasOwnProperty.call(Math, "random"), Object.keys(Reflect).length); diff --git a/tests/corpus/7188-namespace-object-extensions.js b/tests/corpus/7188-namespace-object-extensions.js new file mode 100644 index 000000000..0a33f7155 --- /dev/null +++ b/tests/corpus/7188-namespace-object-extensions.js @@ -0,0 +1,16 @@ +// Namespace objects take new properties and attribute-only redefinitions +// as in Node, and their members keep the values static calls use. +const m = Math; +m.tau = 2 * Math.PI; +console.log(m.tau.toFixed(4), Math.tau === m.tau, "tau" in Math, Object.keys(Math).join(",")); +Object.defineProperty(Math, "hypot", { enumerable: true }); +Object.defineProperty(Math, "trunc", { value: Math.trunc, writable: false }); +const d = Object.getOwnPropertyDescriptor(Math, "trunc"); +console.log(Object.keys(Math).join(","), d.writable, d.enumerable, d.configurable, Math.trunc(-1.5)); +console.log(Math.hypot(3, 4), m.hypot === Math.hypot, Object.getOwnPropertyDescriptor(m, "hypot").enumerable); +const j = JSON; +j.label = "json"; +console.log(j.label, Object.keys(JSON).join(","), Reflect.ownKeys(j).length); +const r = Reflect; +r.extra = [1, 2]; +console.log(Reflect.extra.length, Object.getOwnPropertyNames(Reflect).at(-1), "extra" in r); diff --git a/tests/corpus/7189-non-null-assertion-nullish-destination.ts b/tests/corpus/7189-non-null-assertion-nullish-destination.ts index 632655cff..4cd7e291f 100644 --- a/tests/corpus/7189-non-null-assertion-nullish-destination.ts +++ b/tests/corpus/7189-non-null-assertion-nullish-destination.ts @@ -46,8 +46,8 @@ console.log(String(names.get("a")!), String(names.get("b")!)); console.log(Number(amounts.get("a")!), Number(amounts.get("b")!)); console.log(kindOf(names.get("a")!), kindOf(amounts.get("a")!), kindOf(names.get("b")!)); -// An optional parameter of a standard library method keeps the checked -// narrowing: `decode(input?)` reads the asserted Uint8Array. +// TextDecoder.decode reads the asserted Uint8Array; an undefined input +// decodes no bytes (text-decoder-undefined-input.ts). const chunks = new Map([["a", new TextEncoder().encode("chunk")]]); interface Read { done: boolean; diff --git a/tests/corpus/7304-string-optional-char-reads.ts b/tests/corpus/7304-string-optional-char-reads.ts new file mode 100644 index 000000000..6e47abb75 --- /dev/null +++ b/tests/corpus/7304-string-optional-char-reads.ts @@ -0,0 +1,135 @@ +// s[i] with an index not proven in bounds, tested against one-unit +// literals: equality, inequality and ordered comparisons on either side, +// peeks at i - 1 and i + 1, tests for undefined, switches, character +// locals with and without other uses, fractional, negative, -0, NaN and +// infinite indices, reads past the end, a two-unit literal, Object.is, a +// class-based lexer with its position in a field, and ASCII, Latin-1, BMP, +// surrogate pairs, lone surrogates and empty strings. + +class Lexer { + pos = 0; + src: string; + constructor(src: string) { + this.src = src; + } + skipSpaces(): void { + const src = this.src; + let pos = this.pos; + while (pos < src.length && (src[pos] === " " || src[pos] === "\t")) pos++; + this.pos = pos; + } + word(): string { + const src = this.src; + const start = this.pos; + let pos = start; + while (pos < src.length) { + const c = src[pos]; + if ((c < "a" || c > "z") && (c < "0" || c > "9")) break; + pos++; + } + this.pos = pos; + return src.slice(start, pos); + } + operator(): string { + const src = this.src; + const c = src[this.pos]; + switch (c) { + case "=": + this.pos += src[this.pos + 1] === "=" ? 2 : 1; + return src[this.pos - 1] === "=" && src[this.pos - 2] === "=" ? "==" : "="; + case "+": + case "-": + this.pos++; + return c; + case undefined: + return ""; + default: + this.pos++; + return "?"; + } + } + tokens(): string[] { + const out: string[] = []; + while (this.pos < this.src.length) { + this.skipSpaces(); + const w = this.word(); + if (w !== "") out.push(w); + else if (this.pos < this.src.length) out.push(this.operator()); + } + return out; + } +} + +function tests(src: string, pos: number): string { + const bits: number[] = []; + bits.push(src[pos] === " " ? 1 : 0, src[pos] !== "x" ? 1 : 0); + bits.push(src[pos] < "a" ? 1 : 0, src[pos] <= "a" ? 1 : 0); + bits.push(src[pos] > "a" ? 1 : 0, src[pos] >= "a" ? 1 : 0); + bits.push("m" < src[pos] ? 1 : 0, "m" >= src[pos] ? 1 : 0); + bits.push(src[pos + 1] === "b" ? 1 : 0, src[pos - 1] > "\0" ? 1 : 0); + bits.push(src[pos] === undefined ? 1 : 0, src[pos] !== undefined ? 1 : 0); + bits.push(src[pos * 2] === undefined ? 1 : 0, src[pos] === "ab" ? 1 : 0); + bits.push(Object.is(src[pos], "a") ? 1 : 0); + return bits.join(""); +} + +function local(src: string, pos: number): string { + const c = src[pos]; + let n = 0; + if (c === undefined) n += 1; + if (c !== undefined && c >= "a") n += 2; + if ("z" > c) n += 4; + switch (c) { + case "é": + n += 8; + break; + case "b": + n += 16; + break; + default: + n += 32; + } + return String(n); +} + +function kept(src: string, pos: number): string { + const c = src[pos]; + if (c === " ") return "space"; + return c ?? "end"; +} + +const samples = ["abc", "", " ", "a b\tc", "café", "日本 語", "😀x", "\uD800z", "ab=b"]; +const indices = [0, 1, 2, 3, -1, -0, 0.5, 1.5, NaN, Infinity, 10]; +for (const s of samples) { + const row: string[] = []; + for (const i of indices) row.push(`${tests(s, i)}:${local(s, i)}:${kept(s, i)}`); + console.log(JSON.stringify(s), row.join(" ")); +} + +const programs = [ + "let x = a + b - c", + " alpha==beta gamma = delta1 ", + "x=y==z+", + "", + "abc é def", + "\tq\t=\t9", +]; +for (const p of programs) console.log(JSON.stringify(p), JSON.stringify(new Lexer(p).tokens())); + +let text = ""; +for (let k = 0; k < 30; k++) text += (k % 4 === 0 ? "==" : "w" + k) + " "; +console.log(new Lexer(text).tokens().length, new Lexer(text).tokens().slice(0, 6).join(",")); + +// A character local tested inside another read's index or receiver. +function nested(src: string, other: string): number { + let n = 0; + for (let pos = 0; pos <= src.length; pos += 1.5) { + const c = src[pos]; + if (src[c === "a" ? pos + 1 : pos] === "b") n++; + if ((c === "x" ? other : src)[pos] === "y") n += 2; + const d = src[c === "c" ? pos - 1 : pos]; + if (d === "c") n += 10; + } + return n; +} +console.log(nested("abcab", "xyz"), nested("cab", "yy"), nested("xyxy", "yyyy"), nested("", "")); diff --git a/tests/corpus/7305-string-unit-loop-versions.ts b/tests/corpus/7305-string-unit-loop-versions.ts new file mode 100644 index 000000000..c7600167d --- /dev/null +++ b/tests/corpus/7305-string-unit-loop-versions.ts @@ -0,0 +1,219 @@ +// charCodeAt scans of strings that are not ASCII, repeated so the runtime +// holds their UTF-16 units: counts, hashes, neighbor reads, descending +// loops, reads past the end, line starts pushed to a number array, helper +// functions that read no string, a helper that reads another string, a +// second non-ASCII string read in the same loop, two texts scanned in +// turn, a short text, a text appended to between scans, surrogate pairs +// and lone surrogates, ASCII texts beside them, and while loops over a +// counter below the length: word and identifier scans, peeks past the +// counter, and a lexer class whose position is a field. Loops also read +// the length in their bodies, and loops over a fresh text that no scan +// has published run from its end and over its last units. + +function isWordUnit(c: number): boolean { + return (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c > 127; +} + +function isWordOrDigit(c: number): boolean { + return isWordUnit(c) || (c >= 48 && c <= 57); +} + +function spaces(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 32) n++; + return n; +} + +function hash(s: string): number { + let h = 7; + for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0; + return h; +} + +function rises(s: string): number { + let n = 0; + for (let i = 1; i < s.length - 1; i++) + if (s.charCodeAt(i) > s.charCodeAt(i - 1) && s.charCodeAt(i) >= s.charCodeAt(i + 1)) n++; + return n; +} + +function backwards(s: string): number { + let h = 0; + for (let i = s.length - 1; i >= 0; i -= 3) h = (h * 7 + s.charCodeAt(i)) % 1000003; + return h; +} + +function pastEnd(s: string): string { + let sum = 0; + for (let i = 0; i <= s.length; i++) sum += s.charCodeAt(i); + return String(sum); +} + +function lineStarts(s: string): number[] { + const starts = [0]; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) starts.push(i + 1); + return starts; +} + +function wordUnits(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (isWordOrDigit(s.charCodeAt(i))) n++; + return n; +} + +function codeOf(t: string, i: number): number { + return t.charCodeAt(i % t.length); +} + +function mixed(s: string, t: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) n = (n + s.charCodeAt(i) * codeOf(t, i)) % 1000003; + return n; +} + +function sameAt(a: string, b: string): number { + let n = 0; + const end = Math.min(a.length, b.length); + for (let i = 0; i < end; i++) if (a.charCodeAt(i) === b.charCodeAt(i)) n++; + return n; +} + +function surrogates(s: string): string { + let high = 0, + low = 0; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c >= 0xd800 && c < 0xdc00) high++; + else if (c >= 0xdc00 && c < 0xe000) low++; + } + return `${high}/${low}`; +} + +function wordCount(s: string): number { + let pos = 0, + n = 0; + const end = s.length; + while (pos < end) { + while (pos < end && s.charCodeAt(pos) === 32) pos++; + if (pos >= end) break; + n++; + while (pos < end && s.charCodeAt(pos) !== 32) pos++; + } + return n; +} + +function wordUnitRuns(s: string): number { + let pos = 0, + total = 0; + while (pos < s.length) { + if (isWordUnit(s.charCodeAt(pos))) { + const start = pos; + while (pos < s.length && isWordOrDigit(s.charCodeAt(pos))) pos++; + total += pos - start; + } else pos = (pos + 1) | 0; + } + return total; +} + +function doubled(s: string): number { + let pos = 0, + n = 0; + while (pos < s.length) { + if (s.charCodeAt(pos) === s.charCodeAt(pos + 1)) n++; + pos += 1; + } + return n; +} + +class Scanner { + pos = 0; + src: string; + constructor(src: string) { + this.src = src; + } + line(): number { + const src = this.src; + const start = this.pos; + let pos = start; + while (pos < src.length && src.charCodeAt(pos) !== 10) pos++; + this.pos = pos + 1; + return pos - start; + } +} + +function lineLengths(s: string): number { + const scanner = new Scanner(s); + let total = 0, + lines = 0; + while (scanner.pos < s.length) { + total = (total * 31 + scanner.line()) % 1000003; + lines++; + } + return total * 1000 + lines; +} + +function remaining(s: string): number { + let pos = 0; + let sum = 0; + while (pos < s.length) { + if (s.charCodeAt(pos) === 32) sum += s.length - pos; + pos++; + } + return sum; +} + +function lastLineLength(s: string): number { + let last = s.length; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) last = s.length - i - 1; + return last; +} + +function from(s: string, start: number): number { + let pos = start; + let sum = 0; + while (pos < s.length) { + sum = sum * 3 + s.charCodeAt(pos); + pos++; + } + return sum; +} + +const words = ["Größe", "naïve", "façade", "東京", "ok", "✓", "mañana", "plain", "😀", "end\n"]; +function text(count: number, seed: number): string { + let out = ""; + for (let k = 0; k < count; k++) out += words[(k * 7 + seed) % words.length] + (k % 5 === 0 ? " " : " "); + return out; +} + +const big = text(3000, 1); +const other = text(2000, 4); +const ascii = big.replace(/[^\x00-\x7f]/g, "x"); +const short = "café ✓ 東京"; +const lone = "a\uD800b\uDFFFc " + big.slice(0, 500) + "\uD83D"; +for (let pass = 0; pass < 3; pass++) { + console.log( + pass, + spaces(big), + hash(big), + rises(big), + backwards(big), + pastEnd(big), + lineStarts(big).length, + wordUnits(big), + ); + console.log(" ", spaces(other), hash(other), spaces(big), hash(other), wordUnits(other)); + console.log(" ", mixed(big, other), sameAt(big, other), surrogates(big), surrogates(lone)); + console.log(" ", spaces(ascii), hash(ascii), spaces(short), hash(short), hash(lone), rises(lone)); + console.log(" ", wordCount(big), wordUnitRuns(big), doubled(big), lineLengths(big)); + console.log(" ", wordCount(ascii), wordUnitRuns(other), doubled(lone), lineLengths(lone), wordCount("")); + const fresh = text(40 + pass, pass) + "ü"; + console.log(" ", remaining(big), remaining(ascii), lastLineLength(big), lastLineLength(other)); + console.log(" ", from(fresh, fresh.length), from(fresh, fresh.length - 3), from(big, big.length - 4)); +} + +let grown = text(1500, 2); +for (let round = 0; round < 3; round++) { + console.log("grown", round, grown.length, spaces(grown), hash(grown), hash(grown)); + grown += text(200, round) + "Ünïcödé"; +} +console.log("empty", spaces(""), hash(""), pastEnd(""), lineStarts("").length); diff --git a/tests/corpus/7307-string-unit-loop-evictions.ts b/tests/corpus/7307-string-unit-loop-evictions.ts new file mode 100644 index 000000000..94e98e5b4 --- /dev/null +++ b/tests/corpus/7307-string-unit-loop-evictions.ts @@ -0,0 +1,73 @@ +// A units copy must not run program code that indexes other strings: a +// comparator sort or a copy from an untyped source can evict the scanned +// text's UTF-16 units while the loop reads them. + +function bigText(seed: number): string { + let s = ""; + for (let i = 0; i < 9000; i++) s += String.fromCharCode(0xe9 + ((i * seed) % 7)) + "ab "; + return s; +} + +const others = [bigText(3), bigText(5), bigText(11), bigText(13), bigText(17)]; + +let touched = 0; +function touchOthers(): void { + for (let k = 0; k < others.length; k++) { + const o = others[k]; + for (let j = 0; j < 80; j++) touched += o.charCodeAt((j * 997 + k) % o.length); + } +} + +function spaces(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 32) n++; + return n; +} + +function sortedWhileScanning( + s: string, + values: Float64Array, + compare: (a: number, b: number) => number, +): number { + let sum = 0; + for (let i = 0; i < s.length; i++) { + sum += s.charCodeAt(i); + if (i % 4096 === 100) values.sort(compare); + } + return sum; +} + +function copiedWhileScanning(s: string, values: Float64Array, source: any): number { + let sum = 0; + for (let i = 0; i < s.length; i++) { + sum += s.charCodeAt(i); + if (i % 4096 === 100) values.set(source); + } + return sum; +} + +const text = bigText(7); +// Repeated scans make the runtime hold the text's units. +let warm = 0; +for (let r = 0; r < 4; r++) warm += spaces(text); +console.log("warm", warm); + +const values = new Float64Array([3, 1, 2]); +const byValue = (a: number, b: number): number => { + touchOthers(); + return a - b; +}; +console.log("sorted", sortedWhileScanning(text, values, byValue), values.join(",")); +for (let r = 0; r < 2; r++) warm += spaces(text); + +const source: any = { + length: 3, + get 0() { + touchOthers(); + return 9; + }, + 1: 8, + 2: 7, +}; +console.log("copied", copiedWhileScanning(text, values, source), values.join(",")); +console.log("touched", touched > 0, spaces(text)); diff --git a/tests/corpus/7381-function-value-arguments-length.ts b/tests/corpus/7381-function-value-arguments-length.ts new file mode 100644 index 000000000..059ff1487 --- /dev/null +++ b/tests/corpus/7381-function-value-arguments-length.ts @@ -0,0 +1,27 @@ +// `arguments.length` in a function declaration counts the arguments its +// caller passes: a direct call passes the declared parameters, and a call +// through the function as a value passes the parameters of the function +// type it was passed as (3 for map and filter, 4 for reduce, 2 for sort). +function scaled(value: number): number { + return value * 10 + arguments.length; +} +function total(acc: number, value: number): number { + return acc + value + arguments.length; +} +function compare(a: number, b: number): number { + return arguments.length === 2 ? a - b : 0; +} +console.log([1, 2].map(scaled).join(), scaled(3)); +console.log([5, 6].filter(scaled).length, [1, 2].reduce(total, 0), total(1, 2)); +console.log([3, 1, 2].sort(compare).join()); + +function apply(f: (a: number, b: number, c: number) => number): number { + return f(1, 2, 3); +} +console.log(apply(scaled)); + +// A direct call inside a call through the value sees its own count. +function depth(n: number): number { + return n <= 0 ? arguments.length : depth(n - 1) + arguments.length; +} +console.log([2].map(depth).join(), depth(2)); diff --git a/tests/corpus/7407-frozen-class-instances.ts b/tests/corpus/7407-frozen-class-instances.ts new file mode 100644 index 000000000..6a3a14c54 --- /dev/null +++ b/tests/corpus/7407-frozen-class-instances.ts @@ -0,0 +1,181 @@ +// Object.freeze, Object.seal and Object.preventExtensions of class instances +// held in untyped values. The operation changes the instance itself: writes +// through its typed references, through untyped references and through its +// methods then throw Node's TypeError (this module is strict mode code), and +// Object.isFrozen, isSealed and isExtensible report the state through either +// reference. +export {}; + +function report(e: unknown): void { + console.log((e as Error).name + ": " + (e as Error).message); +} + +function freezeUnknown(value: unknown): unknown { + return Object.freeze(value); +} + +function sealUnknown(value: unknown): unknown { + return Object.seal(value); +} + +function preventUnknown(value: unknown): unknown { + return Object.preventExtensions(value); +} + +function integrity(value: unknown): string { + return [Object.isFrozen(value), Object.isSealed(value), Object.isExtensible(value)].join(" "); +} + +function writeAny(value: any, key: string, next: unknown): void { + value[key] = next; +} + +function deleteAny(value: any, key: string): void { + delete value[key]; +} + +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + move(dx: number): void { + this.x += dx; + } +} + +class Labeled extends Point { + label: string; + constructor(x: number, y: number, label: string) { + super(x, y); + this.label = label; + } + rename(label: string): void { + this.label = label; + } +} + +class Counter { + count = 0; + tags: string[] = []; + bump(): number { + return ++this.count; + } +} + +function frozen(): void { + const p = new Point(1, 2); + console.log(integrity(p)); + const same = freezeUnknown(p); + console.log(same === p, integrity(p), Object.isFrozen(p)); + try { p.x = 5; } catch (e) { report(e); } + try { p.move(1); } catch (e) { report(e); } + try { writeAny(p, "y", 9); } catch (e) { report(e); } + try { writeAny(p, "z", 1); } catch (e) { report(e); } + console.log(p.x, p.y, JSON.stringify(p)); + + const l = new Labeled(3, 4, "a"); + const base: Point = l; + freezeUnknown(base); + console.log(integrity(l), integrity(base)); + try { l.rename("b"); } catch (e) { report(e); } + try { base.x = 0; } catch (e) { report(e); } + try { writeAny(l, "label", "c"); } catch (e) { report(e); } + console.log(l.x, l.label, l instanceof Labeled); + freezeUnknown(l); + console.log(integrity(l)); +} + +function sealed(): void { + const c = new Counter(); + sealUnknown(c); + console.log(integrity(c)); + console.log(c.bump(), c.bump()); + c.tags.push("kept"); + try { writeAny(c, "extra", 1); } catch (e) { report(e); } + writeAny(c, "count", 10); + console.log(c.count, c.tags.join(), JSON.stringify(c)); + freezeUnknown(c); + console.log(integrity(c)); + try { c.bump(); } catch (e) { report(e); } + c.tags.push("still"); + console.log(c.count, c.tags.join()); +} + +function notExtensible(): void { + const l = new Labeled(0, 0, "n"); + preventUnknown(l); + console.log(integrity(l)); + l.move(2); + l.rename("m"); + try { writeAny(l, "extra", true); } catch (e) { report(e); } + console.log(l.x, l.label, Object.keys(l).join()); + sealUnknown(l); + console.log(integrity(l)); + writeAny(l, "y", 7); + console.log(l.y); +} + +function untouched(): void { + const p = new Point(5, 6); + const q = new Point(7, 8); + freezeUnknown(q); + p.x = 50; + writeAny(p, "y", 60); + console.log(p.x, p.y, integrity(p), integrity(q)); +} + +function added(): void { + const p = new Point(1, 1); + writeAny(p, "extra", 1); + freezeUnknown(p); + console.log(integrity(p), Object.keys(p).join()); + try { deleteAny(p, "extra"); } catch (e) { report(e); } + console.log((p as any).extra, JSON.stringify(p)); +} + +const tag = Symbol("tag"); + +class Tagged { + [tag] = 1; + name = "n"; + bump(): void { + this[tag] += 1; + } +} + +class Subtagged extends Tagged { + extra = 0; +} + +function symbols(): void { + const t = new Tagged(); + freezeUnknown(t); + console.log(integrity(t), Object.getOwnPropertyDescriptor(t, tag)?.writable); + try { t[tag] = 5; } catch (e) { report(e); } + try { t.bump(); } catch (e) { report(e); } + console.log(t[tag]); + + const s = new Subtagged(); + sealUnknown(s); + s[tag] = 3; + console.log(integrity(s), s[tag], Object.getOwnPropertyDescriptor(s, tag)?.configurable); + freezeUnknown(s); + try { s[tag] = 4; } catch (e) { report(e); } + console.log(integrity(s), s[tag]); + + const u = new Tagged(); + Object.freeze(u); + console.log(integrity(u)); + try { u[tag] = 9; } catch (e) { report(e); } + console.log(u[tag]); +} + +frozen(); +sealed(); +notExtensible(); +untouched(); +added(); +symbols(); diff --git a/tests/corpus/7408-frozen-js-class-symbol-fields.js b/tests/corpus/7408-frozen-js-class-symbol-fields.js new file mode 100644 index 000000000..9ce5a1c53 --- /dev/null +++ b/tests/corpus/7408-frozen-js-class-symbol-fields.js @@ -0,0 +1,32 @@ +// A JavaScript class whose instances track which fields they have (one +// field is created by a method) and carry a symbol-keyed field. Freezing +// or sealing such an instance makes its symbol entry read-only; later +// reflection rebuilds the instance's view and takes the field's value. +const tag = Symbol("tag"); +class Box { + constructor(v) { + this.v = v; + this[tag] = v; + } + later() { + this.w = 3; + } +} + +const frozen = new Box(1); +frozen.extra = 2; +Object.freeze(frozen); +console.log(Object.isFrozen(frozen)); +console.log(Object.isFrozen(frozen), Object.isSealed(frozen), Object.isExtensible(frozen)); +console.log(frozen[tag], frozen.v, frozen.extra, frozen.w); +console.log(JSON.stringify(Object.getOwnPropertyDescriptor(frozen, tag))); + +const sealed = new Box(2); +sealed.later(); +Object.seal(sealed); +console.log(Object.isSealed(sealed), Object.isFrozen(sealed), sealed[tag], sealed.w); +console.log(JSON.stringify(Object.getOwnPropertyDescriptor(sealed, tag))); + +const closed = new Box(4); +Object.preventExtensions(closed); +console.log(Object.isExtensible(closed), Object.isSealed(closed), closed[tag]); diff --git a/tests/corpus/7440-tls-server-add-context.ts b/tests/corpus/7440-tls-server-add-context.ts new file mode 100644 index 000000000..3cb29ebb3 --- /dev/null +++ b/tests/corpus/7440-tls-server-add-context.ts @@ -0,0 +1,119 @@ +// tls.Server's addContext and setSecureContext match Node: SNI names +// matched against exact and wildcard patterns (a "*" covers one label, +// case-insensitively, the newest match winning), the default context when +// nothing matches or an SNICallback is set, setSecureContext replacing the +// default context, the argument errors, the chain each context sends +// (completed from the default context's ca), and the store that verifies +// client certificates after each change. +import { readFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const r = (f: string): Buffer => readFileSync(`${D}/${f}`); +const ca = r("ca.pem"), ca2 = r("ca2.pem"), cert = r("localhost.pem"), key = r("localhost-key.pem"); +const alt = r("alt.pem"), altKey = r("alt-key.pem"), self = r("selfsigned.pem"), selfKey = r("selfsigned-key.pem"); +const client = r("client.pem"), clientKey = r("client-key.pem"); + +const listen = (server: tls.Server): Promise => + new Promise((d) => server.listen(0, () => d((server.address() as AddressInfo).port))); + +function peer(port: number, servername: string): Promise { + return new Promise((res) => { + const c = tls.connect({ port, servername, rejectUnauthorized: false }, () => { + const p = c.getPeerCertificate(); + res(`${p.subject.CN}${p.subject.CN === "localhost" && p.issuer.CN === "localhost" ? "(self)" : ""}`); + c.destroy(); + }); + c.on("error", (e: NodeJS.ErrnoException) => res(`error ${e.code}`)); + }); +} + +function chain(c: tls.TLSSocket): string { + const names: string[] = []; + let p: tls.DetailedPeerCertificate | undefined = c.getPeerCertificate(true); + for (let i = 0; p !== undefined && i < 4; i++) { + names.push(p.subject.CN as string); + if (p.issuerCertificate === p) break; + p = p.issuerCertificate; + } + return names.join(" -> "); +} + +function look(port: number, servername: string): Promise { + return new Promise((res) => { + const c = tls.connect({ port, servername, ca: ca2, rejectUnauthorized: false }, () => { + const line = `${c.authorizationError} ${chain(c)}`; + c.destroy(); + res(line); + }); + }); +} + +function attempt(label: string, run: () => void): void { + try { + run(); + console.log(label, "ok"); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(label, err.name, err.code, err.message.split("\n")[0]); + } +} + +{ + const server = tls.createServer({ cert, key }); + server.addContext("alt.localhost", { cert: alt, key: altKey }); + server.addContext("*.wild.test", tls.createSecureContext({ cert: self, key: selfKey })); + server.addContext("a.b.wild.test", { cert: alt, key: altKey }); + const port = await listen(server); + const names = ["alt.localhost", "ALT.localhost", "x.wild.test", "a.b.wild.test", "c.b.wild.test", "wild.test", ".wild.test", "other", "alt.localhostx"]; + for (const name of names) console.log("sni", name, await peer(port, name)); + attempt("addContext empty", () => server.addContext("", { cert, key })); + attempt("addContext string ctx", () => server.addContext("x.test", "nope" as unknown as tls.SecureContextOptions)); + server.addContext("alt.localhost", { cert: self, key: selfKey }); + console.log("override", await peer(port, "alt.localhost")); + server.setSecureContext({ cert: alt, key: altKey }); + console.log("setSecureContext default", await peer(port, "nomatch"), await peer(port, "alt.localhost")); + attempt("setSecureContext string", () => server.setSecureContext("x" as unknown as tls.SecureContextOptions)); + server.close(); +} + +{ + const server = tls.createServer({ cert, key, SNICallback: (n: string, cb: (err: Error | null, ctx?: tls.SecureContext) => void) => cb(null, undefined) }); + server.addContext("alt.localhost", { cert: alt, key: altKey }); + console.log("with SNICallback", await peer(await listen(server), "alt.localhost")); + server.close(); +} + +{ + const server = tls.createServer({ cert, key, ca }); + server.addContext("alt.localhost", { cert: alt, key: altKey }); + const port = await listen(server); + console.log("server ca, sni", await look(port, "alt.localhost")); + server.setSecureContext({ cert, key }); + console.log("after set without ca, sni", await look(port, "alt.localhost")); + console.log("after set without ca, default", await look(port, "localhost")); + server.close(); +} + +{ + const lines: string[] = []; + const server = tls.createServer({ cert, key, ca, requestCert: true, rejectUnauthorized: false }, (s) => { + lines.push(`server ${s.authorized} ${s.authorizationError}`); + s.end(); + }); + const port = await listen(server); + const once = (servername: string): Promise => + new Promise((res) => { + const c = tls.connect({ port, servername, ca, cert: client, key: clientKey }); + c.on("data", () => {}); + c.on("close", () => res()); + }); + await once("localhost"); + server.setSecureContext({ cert, key, ca: ca2 }); + await once("localhost"); + server.addContext("alt.localhost", { cert: alt, key: altKey, ca }); + await once("alt.localhost"); + console.log(lines.join(" | ")); + server.close(); +} diff --git a/tests/corpus/7441-tls-client-error.ts b/tests/corpus/7441-tls-client-error.ts new file mode 100644 index 000000000..431ea9019 --- /dev/null +++ b/tests/corpus/7441-tls-client-error.ts @@ -0,0 +1,93 @@ +// tls.Server's 'tlsClientError' event matches Node: the error a failed +// handshake reports (OpenSSL's codes and messages for an HTTP or proxy +// request, a client that rejects the certificate, a missing required +// certificate, an unsupported version and no common ALPN protocol; a +// "socket hang up" ECONNRESET for a client that leaves), the socket it +// passes, what the client sees, and the same event on https.Server. +import * as https from "node:https"; +import * as net from "node:net"; +import type { AddressInfo } from "node:net"; +import { readFileSync } from "node:fs"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const r = (f: string): Buffer => readFileSync(`${D}/${f}`); +const ca = r("ca.pem"), ca2 = r("ca2.pem"), cert = r("localhost.pem"), key = r("localhost-key.pem"); +const clean = (m: string): string => m.replace(/^[0-9A-F]+:/, "").split(":../")[0]!; + +type Drive = (port: number, client: string[], fin: () => void) => void; + +async function scenario(label: string, make: (h: (s: tls.TLSSocket) => void) => tls.Server, drive: Drive): Promise { + await new Promise((done) => { + const events: string[] = []; + const client: string[] = []; + const server = make((s) => { + events.push("secureConnection"); + s.end("ok"); + }); + server.on("tlsClientError", (err: Error, socket: tls.TLSSocket) => { + events.push(`tlsClientError ${err.name} ${(err as NodeJS.ErrnoException).code} [${clean(err.message)}] destroyed=${socket.destroyed} remote=${typeof socket.remoteAddress}`); + socket.on("close", () => events.push("server socket close")); + }); + server.listen(0, () => { + drive((server.address() as AddressInfo).port, client, () => + setTimeout(() => { + console.log(label, "|", events.join(" ; "), "| client:", client.join(" ; ")); + server.close(() => done()); + }, 100), + ); + }); + }); +} + +const raw = (bytes: Buffer | null, endFirst: boolean): Drive => (port, client, fin) => { + const s = net.connect(port, "127.0.0.1", () => { + if (bytes !== null) s.write(bytes); + if (endFirst) s.end(); + }); + s.on("data", (d: Buffer) => client.push(`data ${d.length}`)); + s.on("end", () => client.push("end")); + s.on("error", (e: NodeJS.ErrnoException) => client.push(`error ${e.code}`)); + s.on("close", (hadError: boolean) => { + client.push(`close ${hadError}`); + fin(); + }); +}; + +const tlsc = (trust: Buffer, maxVersion: "TLSv1.2" | "TLSv1.3", alpn: boolean): Drive => (port, client, fin) => { + const c = alpn + ? tls.connect({ port, servername: "localhost", ca: trust, maxVersion, ALPNProtocols: ["http/1.1"] }) + : tls.connect({ port, servername: "localhost", ca: trust, maxVersion }); + c.on("secureConnect", () => client.push("secureConnect")); + c.on("error", (e: NodeJS.ErrnoException) => client.push(`error ${e.code}`)); + c.on("data", () => {}); + c.on("close", (hadError: boolean) => { + client.push(`close ${hadError}`); + fin(); + }); +}; + +const plain = (h: (s: tls.TLSSocket) => void): tls.Server => tls.createServer({ cert, key }, h); +await scenario("close before hello", plain, raw(null, true)); +await scenario("http request", plain, raw(Buffer.from("GET / HTTP/1.1\r\nHost: x\r\n\r\n"), false)); +await scenario("connect request", plain, raw(Buffer.from("CONNECT x:443 HTTP/1.1\r\n\r\n"), false)); +await scenario("half hello", plain, raw(Buffer.from([0x16, 0x03, 0x01, 0x02, 0x00, 0x01]), true)); +await scenario("client rejects cert", plain, tlsc(ca2, "TLSv1.3", false)); +await scenario("no cert, required", (h) => tls.createServer({ cert, key, requestCert: true, ca }, h), tlsc(ca, "TLSv1.3", false)); +await scenario("version", (h) => tls.createServer({ cert, key, minVersion: "TLSv1.3" }, h), tlsc(ca, "TLSv1.2", false)); +await scenario("alpn", (h) => tls.createServer({ cert, key, ALPNProtocols: ["h2"] }, h), tlsc(ca, "TLSv1.3", true)); +await scenario("ok", plain, tlsc(ca, "TLSv1.3", false)); + +{ + const server = https.createServer({ cert, key }, (req, res) => res.end("ok")); + server.on("tlsClientError", (err: Error, socket: tls.TLSSocket) => console.log("https tlsClientError", (err as NodeJS.ErrnoException).code, socket.destroyed)); + await new Promise((d) => server.listen(0, () => d())); + await new Promise((res) => { + const s = net.connect((server.address() as AddressInfo).port, "127.0.0.1", () => s.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n")); + s.on("data", () => {}); + s.on("error", (e: NodeJS.ErrnoException) => console.log("https client error", e.code)); + s.on("close", () => res()); + }); + await new Promise((d) => setTimeout(d, 50)); + server.close(); +} diff --git a/tests/corpus/7442-tls-server-contexts.mjs b/tests/corpus/7442-tls-server-contexts.mjs new file mode 100644 index 000000000..97f797129 --- /dev/null +++ b/tests/corpus/7442-tls-server-contexts.mjs @@ -0,0 +1,51 @@ +// tls.Server's addContext, setSecureContext and 'tlsClientError' from +// JavaScript match Node: a wildcard SNI context, the default context +// replaced, and the errors a plain-text client and one that leaves +// before its hello report. +import * as net from "node:net"; +import * as tls from "node:tls"; +import { readFileSync } from "node:fs"; + +const D = "tests/fixtures/server/certs"; +const cert = readFileSync(`${D}/localhost.pem`); +const key = readFileSync(`${D}/localhost-key.pem`); +const alt = readFileSync(`${D}/alt.pem`); +const altKey = readFileSync(`${D}/alt-key.pem`); + +const server = tls.createServer({ cert, key }, (socket) => socket.end()); +server.addContext("*.localhost", { cert: alt, key: altKey }); +server.on("tlsClientError", (err, socket) => { + const reason = err.message.replace(/^[0-9A-F]+:/, "").split(":../")[0]; + console.log("tlsClientError", err.code, reason, socket.destroyed); +}); + +function peer(port, servername) { + return new Promise((resolve) => { + const c = tls.connect({ port, servername, rejectUnauthorized: false }, () => { + resolve(c.getPeerCertificate().subject.CN); + c.end(); + }); + }); +} + +function raw(port, bytes) { + return new Promise((resolve) => { + const s = net.connect(port, "127.0.0.1", () => { + if (bytes) s.write(bytes); + else s.end(); + }); + s.on("data", () => {}); + s.on("error", (err) => console.log("client error", err.code)); + s.on("close", () => setTimeout(resolve, 50)); + }); +} + +server.listen(0, async () => { + const { port } = server.address(); + console.log("sni", await peer(port, "alt.localhost"), await peer(port, "localhost")); + server.setSecureContext({ cert: alt, key: altKey }); + console.log("after setSecureContext", await peer(port, "example.test")); + await raw(port, "GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + await raw(port, null); + server.close(); +}); diff --git a/tests/corpus/7443-tls-final-flight.ts b/tests/corpus/7443-tls-final-flight.ts new file mode 100644 index 000000000..b4de4b4d4 --- /dev/null +++ b/tests/corpus/7443-tls-final-flight.ts @@ -0,0 +1,114 @@ +// The last handshake flight is flushed when Node flushes it: after the +// callbacks that see the handshake finish and their nextTick callbacks, +// before the immediates they queue. A destroy() in secureConnect or a tick +// drops a TLS 1.3 client's Finished (the server reports a hang-up), one in +// secureConnection drops a TLS 1.2 server's Finished (the client ends, then +// fails), and data written beside it is dropped too; end() and a destroy +// in an immediate let it go out. A client whose server closes or resets +// mid-handshake gets Node's events, from tls.connect and https.request. +import { readFileSync } from "node:fs"; +import * as https from "node:https"; +import * as net from "node:net"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const cert = readFileSync(`${D}/localhost.pem`), key = readFileSync(`${D}/localhost-key.pem`); +type Version = "TLSv1.2" | "TLSv1.3"; +const hows = ["destroy", "tick", "immediate", "write+destroy", "end"]; + +function act(s: tls.TLSSocket, how: string): void { + if (how === "destroy") s.destroy(); + else if (how === "tick") process.nextTick(() => s.destroy()); + else if (how === "immediate") setImmediate(() => s.destroy()); + else if (how === "write+destroy") { + s.write("hi"); + s.destroy(); + } else s.end("bye"); +} + +const listen = (server: net.Server): Promise => + new Promise((d) => server.listen(0, "127.0.0.1", () => d((server.address() as AddressInfo).port))); + +// the client acts in secureConnect; the server reports what arrived +for (const v of ["TLSv1.2", "TLSv1.3"] as Version[]) { + for (const how of hows) { + const ev: string[] = []; + const server = tls.createServer({ cert, key, maxVersion: v }, (s) => { + ev.push("secureConnection"); + s.on("data", (d: Buffer) => ev.push(`data ${d}`)); + // A client that closes before it reads the server's TLS 1.3 session + // tickets resets the connection. Whether the tickets are read first + // depends on the platform's timing, so that reset is not printed. + s.on("error", (e: NodeJS.ErrnoException) => { + if (e.code !== "ECONNRESET") ev.push(`server socket error ${e.code}`); + }); + }); + server.on("tlsClientError", (e: Error) => ev.push(`tlsClientError ${(e as NodeJS.ErrnoException).code}`)); + const port = await listen(server); + await new Promise((res) => { + const c = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => act(c, how)); + c.on("close", () => setTimeout(res, 100)); + }); + console.log("client", v, how, "|", ev.join(" ; ")); + server.close(); + } +} + +// the server acts in secureConnection; the client reports what arrived +for (const v of ["TLSv1.2", "TLSv1.3"] as Version[]) { + for (const how of hows) { + const ev: string[] = []; + const server = tls.createServer({ cert, key, maxVersion: v }, (s) => { + s.on("error", () => {}); + act(s, how); + }); + const port = await listen(server); + await new Promise((res) => { + const c = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => ev.push("secureConnect")); + c.on("data", (d: Buffer) => ev.push(`data ${d}`)); + c.on("end", () => ev.push("end")); + c.on("error", (e: NodeJS.ErrnoException) => ev.push(`error ${e.code} ${e.message}`)); + c.on("close", (hadError: boolean) => { + ev.push(`close ${hadError}`); + setTimeout(res, 50); + }); + }); + console.log("server", v, how, "|", ev.join(" ; ")); + server.close(); + } +} + +// a peer that closes or resets after the client's hello +for (const how of ["fin", "rst"]) { + for (const via of ["tls.connect", "https.request"]) { + const ev: string[] = []; + const server = net.createServer((s) => { + s.once("data", () => { + if (how === "rst") s.resetAndDestroy(); + else s.end(); + }); + }); + const port = await listen(server); + await new Promise((res) => { + if (via === "tls.connect") { + const c = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + c.on("end", () => ev.push("end")); + c.on("error", (e: NodeJS.ErrnoException) => ev.push(`error ${e.code} ${e.message}`)); + c.on("close", (hadError: boolean) => { + ev.push(`close ${hadError}`); + res(); + }); + } else { + const req = https.request({ host: "127.0.0.1", port, path: "/", rejectUnauthorized: false, agent: false }); + req.on("error", (e: NodeJS.ErrnoException) => { + ev.push(`request error ${e.code} ${e.message}`); + res(); + }); + req.end(); + } + }); + console.log("peer", how, via, "|", ev.join(" ; ")); + server.close(); + } +} diff --git a/tests/corpus/7444-tls-final-flight.mjs b/tests/corpus/7444-tls-final-flight.mjs new file mode 100644 index 000000000..e21de782c --- /dev/null +++ b/tests/corpus/7444-tls-final-flight.mjs @@ -0,0 +1,28 @@ +// The last handshake flight from JavaScript: a TLS 1.3 client that +// destroys its socket in secureConnect or a tick never sends its Finished, +// so the server reports a hang-up; one that waits for an immediate, or +// ends the socket, completes the server's handshake. +import * as tls from "node:tls"; +import { readFileSync } from "node:fs"; + +const D = "tests/fixtures/server/certs"; +const cert = readFileSync(`${D}/localhost.pem`); +const key = readFileSync(`${D}/localhost-key.pem`); + +for (const how of ["destroy", "tick", "immediate", "end"]) { + const events = []; + const server = tls.createServer({ cert, key }, () => events.push("secureConnection")); + server.on("tlsClientError", (err) => events.push(`tlsClientError ${err.code}`)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + await new Promise((resolve) => { + const client = tls.connect({ port: server.address().port, host: "127.0.0.1", rejectUnauthorized: false }, () => { + if (how === "destroy") client.destroy(); + else if (how === "tick") process.nextTick(() => client.destroy()); + else if (how === "immediate") setImmediate(() => client.destroy()); + else client.end(); + }); + client.on("close", () => setTimeout(resolve, 100)); + }); + console.log(how, events.join(" ; ")); + server.close(); +} diff --git a/tests/corpus/7454-tls-peer-embedded-pem.ts b/tests/corpus/7454-tls-peer-embedded-pem.ts new file mode 100644 index 000000000..1937320e9 --- /dev/null +++ b/tests/corpus/7454-tls-peer-embedded-pem.ts @@ -0,0 +1,46 @@ +// A peer certificate whose extension carries another certificate's PEM as +// text: getPeerX509Certificate() and getX509Certificate() give the +// certificate the handshake delivered, and its fields, not the embedded +// one. new X509Certificate() of the same DER reads the PEM inside it first, +// as Node's PEM-before-DER parse does. +import { X509Certificate } from "node:crypto"; +import { readFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const cert = readFileSync(`${D}/embedded.pem`), key = readFileSync(`${D}/embedded-key.pem`); +const pem = new X509Certificate(cert); + +function describe(label: string, x: X509Certificate | undefined): void { + if (!x) return console.log(label, "undefined"); + console.log(label, x.subject, x.serialNumber, x.fingerprint256 === pem.fingerprint256, x.fingerprint === pem.fingerprint, x.validTo, x.raw.length); +} + +// raw is the certificate SEQUENCE, without bytes after it, and PEM armor +// counts only at the start of a line +const trailing = new X509Certificate(Buffer.concat([pem.raw, Buffer.from([1, 2, 3])])); +console.log("trailing bytes", trailing.raw.length === pem.raw.length, trailing.fingerprint256 === pem.fingerprint256, trailing.fingerprint === pem.fingerprint, trailing.validFrom === pem.validFrom); +for (const [label, input] of [["after a line", Buffer.concat([Buffer.from("text\n"), cert])], ["inside a line", Buffer.concat([Buffer.from("text"), cert])]] as const) { + try { + console.log(label, new X509Certificate(input).subject); + } catch (e) { + console.log(label, (e as NodeJS.ErrnoException).code); + } +} + +const server = tls.createServer({ cert, key }, (s) => { + describe("server own", s.getX509Certificate()); + s.end(); +}); +server.listen(0, "127.0.0.1", () => { + const c = tls.connect({ port: (server.address() as AddressInfo).port, host: "127.0.0.1", rejectUnauthorized: false }, () => { + const peer = c.getPeerX509Certificate(); + describe("client peer", peer); + const legacy = c.getPeerCertificate(); + console.log("legacy", legacy.subject.CN, legacy.fingerprint256 === pem.fingerprint256); + if (peer) describe("rebuilt from raw", new X509Certificate(peer.raw)); + }); + c.resume(); + c.on("close", () => server.close()); +}); diff --git a/tests/corpus/7455-tls-als-contexts.mjs b/tests/corpus/7455-tls-als-contexts.mjs new file mode 100644 index 000000000..bc59a78d4 --- /dev/null +++ b/tests/corpus/7455-tls-als-contexts.mjs @@ -0,0 +1,48 @@ +// The AsyncLocalStorage store in TLS server and client events: a server's +// connections run in its listen() context, except 'tlsClientError', which +// Node emits with no store; a client's events run in its connect() context. +import * as tls from "node:tls"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { readFileSync } from "node:fs"; + +const D = "tests/fixtures/server/certs"; +const cert = readFileSync(`${D}/localhost.pem`); +const key = readFileSync(`${D}/localhost-key.pem`); +const als = new AsyncLocalStorage(); +const serverEvents = []; +const clientEvents = []; +const server = tls.createServer({ cert, key }, (socket) => { + serverEvents.push(`secureConnection ${als.getStore()}`); + socket.on("data", () => { + serverEvents.push(`data ${als.getStore()}`); + socket.end("pong"); + }); +}); +server.on("tlsClientError", (err) => serverEvents.push(`tlsClientError ${err.code} ${als.getStore()}`)); +await als.run("listen", () => new Promise((resolve) => server.listen(0, "127.0.0.1", () => { + serverEvents.push(`listening ${als.getStore()}`); + resolve(undefined); +}))); +const { port } = server.address(); +await als.run("client", () => new Promise((resolve) => { + const client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => { + clientEvents.push(`secureConnect ${als.getStore()}`); + client.write("ping"); + }); + client.on("data", () => clientEvents.push(`data ${als.getStore()}`)); + client.on("close", () => { + clientEvents.push(`close ${als.getStore()}`); + resolve(undefined); + }); +})); +await als.run("untrusted", () => new Promise((resolve) => { + const client = tls.connect({ port, host: "127.0.0.1" }); + client.on("error", (err) => { + clientEvents.push(`error ${err.code} ${als.getStore()}`); + setTimeout(resolve, 100); + }); +})); +server.close(() => { + console.log("server:", serverEvents.join(" ; ")); + console.log("client:", clientEvents.join(" ; ")); +}); diff --git a/tests/corpus/7503-pipeline-untyped-sources.mjs b/tests/corpus/7503-pipeline-untyped-sources.mjs new file mode 100644 index 000000000..e676fd607 --- /dev/null +++ b/tests/corpus/7503-pipeline-untyped-sources.mjs @@ -0,0 +1,102 @@ +// pipeline() over untyped iterable sources: arrays, strings, Sets, generator +// objects, async generator objects, class iterables and object values, +// into Writables, a Transform chain, a function stage and the promise form. +// Each source is pumped as Node pumps an iterable, on the same ticks. +import { pipeline, Writable, Transform } from "node:stream"; +import { pipeline as pipelineP } from "node:stream/promises"; + +const log = []; +function show(chunk) { + return typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString() : JSON.stringify(chunk); +} +function objectSink(label) { + return new Writable({ + objectMode: true, + write(chunk, _enc, cb) { + log.push(`${label} write ${show(chunk)}`); + cb(); + }, + final(cb) { log.push(`${label} final`); cb(); }, + }); +} +function sink(label, objectMode = false) { + if (objectMode) return objectSink(label); + return new Writable({ + write(chunk, _enc, cb) { + log.push(`${label} write ${typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString() : JSON.stringify(chunk)}`); + cb(); + }, + final(cb) { log.push(`${label} final`); cb(); }, + }); +} +function* gen() { + log.push("gen start"); + yield "g1"; + log.push("gen mid"); + yield "g2"; + log.push("gen end"); +} +async function* agen() { + yield "a1"; + await null; + yield "a2"; +} +const sources = { + array: () => ["x", "y"], + string: () => "hey", + set: () => new Set(["s1", "s2"]), + gen: () => gen(), + agen: () => agen(), + iterable: () => ({ *[Symbol.iterator]() { yield "i1"; yield "i2"; } }), + objects: () => [{ n: 1 }, { n: 2 }], +}; +const names = Object.keys(sources); +async function run(name) { + const src = sources[name](); + const objectMode = name === "objects"; + await new Promise((resolve) => { + queueMicrotask(() => log.push(`${name} microtask`)); + pipeline(src, sink(name, objectMode), (err) => { + log.push(`${name} done ${err ? err.code ?? err.message : err}`); + resolve(undefined); + }); + log.push(`${name} after call`); + }); +} +for (const n of names) await run(n); + +// a transform chain and a function stage after an untyped source +const src2 = ["p", "q"]; +await new Promise((resolve) => + pipeline( + src2, + new Transform({ transform(c, _e, cb) { cb(null, c.toString().toUpperCase()); } }), + sink("transform"), + (err) => { log.push(`transform done ${err}`); resolve(undefined); }, + ), +); +const src3 = new Set(["f1", "f2"]); +await new Promise((resolve) => + pipeline( + src3, + async function* (source) { for await (const c of source) yield c + "!"; }, + sink("fnstage"), + (err) => { log.push(`fnstage done ${err}`); resolve(undefined); }, + ), +); +// promises form +const src4 = ["m", "n"]; +await pipelineP(src4, sink("promises")); +log.push("promises resolved"); +// an error thrown by the source +function* bad() { yield "ok"; throw new Error("source failed"); } +const src5 = bad(); +await new Promise((resolve) => + pipeline(src5, sink("bad"), (err) => { log.push(`bad done ${err && err.message}`); resolve(undefined); }), +); +// a source of non-string values into a byte-mode writable +const src6 = [1, 2]; +await new Promise((resolve) => + pipeline(src6, sink("numbers"), (err) => { log.push(`numbers done ${err && err.code}`); resolve(undefined); }), +); +console.log(log.join("\n")); diff --git a/tests/corpus/7504-pipeline-untyped-source-errors.mjs b/tests/corpus/7504-pipeline-untyped-source-errors.mjs new file mode 100644 index 000000000..9dd671606 --- /dev/null +++ b/tests/corpus/7504-pipeline-untyped-source-errors.mjs @@ -0,0 +1,171 @@ +// pipeline() over untyped iterable sources: a destination that fails closes +// the source, backpressure waits for drain, an abort signal stops the pump, +// async generator and Map sources reach object-mode destinations, and a +// sync source's promise values, its final value included, are awaited. +import { pipeline, Writable, PassThrough } from "node:stream"; +import { pipeline as pipelineP } from "node:stream/promises"; + +function* counted(label, n) { + try { + for (let i = 0; i < n; i++) { + console.log(label, "yield", i); + yield `${label}${i}`; + } + } finally { + console.log(label, "finally"); + } +} +function pick(v) { + return v; +} + +// the destination fails on its second write: the source's finally runs +await new Promise((resolve) => { + let n = 0; + const dst = new Writable({ + write(chunk, _e, cb) { + n++; + console.log("fail write", String(chunk)); + cb(n === 2 ? new Error("write failed") : null); + }, + }); + pipeline(pick(counted("f", 5)), dst, (err) => { + console.log("fail done", err && err.message); + resolve(undefined); + }); +}); + +// backpressure: a one-byte highWaterMark and asynchronous writes +await new Promise((resolve) => { + const dst = new Writable({ + highWaterMark: 1, + write(chunk, _e, cb) { + console.log("slow write", String(chunk)); + setTimeout(cb, 1); + }, + }); + pipeline(pick(counted("b", 3)), dst, (err) => { + console.log("slow done", err); + resolve(undefined); + }); +}); + +// an abort signal +const ac = new AbortController(); +try { + await pipelineP( + pick(counted("s", 10)), + new Writable({ + write(chunk, _e, cb) { + console.log("signal write", String(chunk)); + if (String(chunk) === "s1") ac.abort(); + cb(); + }, + }), + { signal: ac.signal }, + ); +} catch (err) { + console.log("signal rejected", err.name); +} + +// an untyped async generator into a PassThrough read with for await +const out = new PassThrough({ objectMode: true }); +const both = pipelineP( + pick((async function* () { + yield "u1"; + yield "u2"; + })()), + out, +); +for await (const c of out) console.log("passthrough", c); +await both; +console.log("passthrough resolved"); + +// a Map source of entries into an object-mode sink +await new Promise((resolve) => { + pipeline( + pick(new Map([["k", 1]])), + new Writable({ objectMode: true, write(c, _e, cb) { console.log("map write", JSON.stringify(c)); cb(); } }), + (err) => { console.log("map done", err); resolve(undefined); }, + ); +}); + +// sync sources whose values are promises: each value is awaited, as +// for await over a sync iterator does, and a rejection closes the source +await pipelineP( + pick([Promise.resolve("p1"), "p2", new Promise((r) => setTimeout(() => r("p3"), 1))]), + new Writable({ write(c, _e, cb) { console.log("byte write", String(c)); cb(); } }), +); +function* promised() { + try { + yield Promise.resolve({ n: 1 }); + yield Promise.reject(new Error("value rejected")); + yield "never"; + } finally { + console.log("promised finally"); + } +} +try { + await pipelineP( + pick(promised()), + new Writable({ objectMode: true, write(c, _e, cb) { console.log("object write", JSON.stringify(c)); cb(); } }), + ); +} catch (err) { + console.log("promised rejected", err.message); +} + +// a sync source's final value is awaited too: a rejected one rejects the +// pipeline, and a resolved one is not written +function* finalRejects() { + yield "f1"; + return Promise.reject(new Error("final value rejected")); +} +function* finalResolves() { + yield "r1"; + return Promise.resolve("not written"); +} +for (const [label, source] of [["finalRejects", finalRejects], ["finalResolves", finalResolves]]) { + try { + await pipelineP( + pick(source()), + new Writable({ objectMode: true, write(c, _e, cb) { console.log(label, "write", c); cb(); } }), + ); + console.log(label, "resolved"); + } catch (err) { + console.log(label, "rejected", err.message); + } +} + +// the final result's value is read for a sync iterator only: an async +// iterator's done result is not read, so a throwing value getter there is +// never called +function finalResult(label, i) { + if (i === 1) return { value: `${label}1`, done: false }; + return { + done: true, + get value() { + throw new Error(`${label} final value read`); + }, + }; +} +function asyncFinal() { + let i = 0; + return { [Symbol.asyncIterator]() { return { async next() { i++; return finalResult("async", i); } }; } }; +} +function syncFinal() { + let i = 0; + return { [Symbol.iterator]() { return { next() { i++; return finalResult("sync", i); } }; } }; +} +async function runFinal(label, source) { + try { + await pipelineP( + pick(source), + new Writable({ objectMode: true, write(c, _e, cb) { console.log(label, "write", c); cb(); } }), + ); + console.log(label, "resolved"); + } catch (err) { + console.log(label, "rejected", err.message); + } +} +await runFinal("async final", asyncFinal()); +await runFinal("sync final", syncFinal()); diff --git a/tests/corpus/7505-pipeline-untyped-sources.cjs b/tests/corpus/7505-pipeline-untyped-sources.cjs new file mode 100644 index 000000000..6dd67b4ff --- /dev/null +++ b/tests/corpus/7505-pipeline-untyped-sources.cjs @@ -0,0 +1,40 @@ +// pipeline() over untyped sources in CommonJS: a source that a function +// answers, which may be an array, a generator, a stream, a string, or a +// Buffer, whose bytes are numbers that a byte-mode Writable refuses. +const { pipeline, Readable, Writable } = require("node:stream"); + +function sink(label) { + const parts = []; + return new Writable({ + write(chunk, _enc, cb) { + parts.push(String(chunk)); + cb(); + }, + final(cb) { + console.log(label, parts.join("|")); + cb(); + }, + }); +} + +function* letters() { + yield "a"; + yield "b"; +} + +const sources = [ + () => ["one", "two"], + () => letters(), + () => Readable.from(["r1", "r2"]), + () => "xyz", + () => Buffer.from("buf"), +]; + +function run(i) { + if (i === sources.length) return; + pipeline(sources[i](), sink(`source ${i}`), (err) => { + console.log(`source ${i} done`, err === undefined ? "ok" : err.code); + run(i + 1); + }); +} +run(0); diff --git a/tests/corpus/7506-pipeline-any-source.ts b/tests/corpus/7506-pipeline-any-source.ts new file mode 100644 index 000000000..84eec0cf4 --- /dev/null +++ b/tests/corpus/7506-pipeline-any-source.ts @@ -0,0 +1,48 @@ +// pipeline() in TypeScript over sources typed any or unknown, and over a +// Set, a Map and an async generator object, which are pumped as iterables. +import { pipeline, Writable } from "node:stream"; +import { pipeline as pipelineP } from "node:stream/promises"; + +function sink(label: string): Writable { + const parts: string[] = []; + return new Writable({ + objectMode: true, + write(chunk: unknown, _enc, cb) { + parts.push(JSON.stringify(chunk)); + cb(); + }, + final(cb) { + console.log(label, parts.join(" ")); + cb(); + }, + }); +} + +async function* ticks(): AsyncGenerator { + yield "t1"; + await new Promise((r) => setTimeout(r, 1)); + yield "t2"; +} + +async function main(): Promise { + const anySource: any = ["any1", "any2"]; + await pipelineP(anySource, sink("any")); + const unknownSource: unknown = new Set([1, 2, 3]); + await pipelineP(unknownSource as any, sink("unknown")); + await pipelineP(new Set(["s"]), sink("set")); + await pipelineP( + new Map([ + ["a", 1], + ["b", 2], + ]), + sink("map"), + ); + await pipelineP(ticks(), sink("async generator")); + await new Promise((resolve) => { + pipeline(anySource, sink("callback"), (err?: Error | null) => { + console.log("callback done", err); + resolve(); + }); + }); +} +void main(); diff --git a/tests/corpus/7507-stream-write-error-drain.mjs b/tests/corpus/7507-stream-write-error-drain.mjs new file mode 100644 index 000000000..8298bcb42 --- /dev/null +++ b/tests/corpus/7507-stream-write-error-drain.mjs @@ -0,0 +1,37 @@ +// A write whose callback fails synchronously: write() answers false and +// writableNeedDrain stays false, so no 'drain' follows the error, and a +// pipeline pumping a generator stops at the failed write. +import { pipeline, Writable } from "node:stream"; + +const w = new Writable({ + write(_chunk, _enc, cb) { + cb(new Error("bad write")); + }, +}); +w.on("error", (e) => console.log("error event", e.message)); +w.on("drain", () => console.log("drain")); +const r = w.write("a"); +console.log("returned", r, "needDrain", w.writableNeedDrain, "errored", w.errored.message); + +function* source() { + try { + for (let i = 0; i < 4; i++) { + console.log("yield", i); + yield `c${i}`; + } + } finally { + console.log("source finally"); + } +} +let writes = 0; +pipeline( + source(), + new Writable({ + write(chunk, _enc, cb) { + writes++; + console.log("write", String(chunk)); + cb(writes === 2 ? new Error("second write failed") : null); + }, + }), + (err) => console.log("pipeline done", err.message), +); diff --git a/tests/corpus/7514-stream-after-write-order.ts b/tests/corpus/7514-stream-after-write-order.ts new file mode 100644 index 000000000..1c14de6eb --- /dev/null +++ b/tests/corpus/7514-stream-after-write-order.ts @@ -0,0 +1,102 @@ +// Write callbacks of synchronous writes run in Node's order: consecutive +// writes with the same callback complete in one afterWrite step, ahead of +// a process.nextTick queued between them, and a write with another +// callback (or none) starts the next step; end's callback and 'finish' +// follow. A write made from a write callback starts a new afterWrite step +// even when its callback matches a later queued one. A PassThrough read by +// 'data' runs each write's own callback. +import { PassThrough, Writable } from "node:stream"; + +const log: string[] = []; + +function run(): Promise { + return new Promise((resolve) => { + const w = new Writable({ + highWaterMark: 4, + write(chunk: Buffer, _enc, cb) { + log.push(`write ${String(chunk)}`); + cb(); + }, + }); + const shared = (): void => { + log.push("shared callback"); + }; + w.on("finish", () => { + log.push("finish"); + resolve(); + }); + w.write("a", shared); + w.write("b", shared); + process.nextTick(() => log.push("tick between")); + w.write("c", shared); + w.write("d", () => log.push("own callback d")); + w.write("e"); + w.write("f"); + queueMicrotask(() => log.push("microtask")); + w.write("g", shared); + w.write("h", shared); + process.nextTick(() => log.push("tick after")); + w.end(() => log.push("end callback")); + }); +} + +function nested(): Promise { + return new Promise((resolve) => { + const w = new Writable({ + write(chunk: Buffer, _enc, cb) { + log.push(`nested write ${String(chunk)}`); + cb(); + }, + }); + let laterCalls = 0; + const later = (): void => { + log.push("later callback"); + if (++laterCalls === 2) resolve(); + }; + w.write("a", () => { + log.push("first callback"); + w.write("from first", later); + }); + process.nextTick(() => log.push("nested tick 1")); + w.write("b", later); + process.nextTick(() => log.push("nested tick 2")); + }); +} + +function passThrough(): Promise { + return new Promise((resolve) => { + const pt = new PassThrough({ highWaterMark: 8 }); + let bytes = 0; + pt.on("data", (chunk: Buffer) => { + bytes += chunk.length; + }); + pt.on("end", () => { + log.push(`passthrough end ${bytes}`); + resolve(); + }); + let i = 0; + const more = (): void => { + while (i < 40) { + i++; + const n = i; + if (!pt.write(`chunk${n}`, () => { + if (n % 10 === 0) log.push(`callback ${n}`); + })) { + pt.once("drain", more); + return; + } + } + pt.end(); + }; + more(); + }); +} + +async function main(): Promise { + await run(); + await nested(); + await passThrough(); + console.log(log.join("\n")); +} + +main(); diff --git a/tests/corpus/7621-class-methods-foreign-receivers.mjs b/tests/corpus/7621-class-methods-foreign-receivers.mjs new file mode 100644 index 000000000..70826a192 --- /dev/null +++ b/tests/corpus/7621-class-methods-foreign-receivers.mjs @@ -0,0 +1,218 @@ +// Prototype methods called with a receiver that is not an instance of +// their class (`C.prototype.m.call(other)`): `#private` members throw +// Node's TypeErrors for such a receiver, and `super.m()` looks the method up +// on the base class's prototype and calls it with the receiver. +function report(label, run) { + try { + console.log(label, run()); + } catch (error) { + console.log(label, error.constructor.name, error.message); + } +} + +class Counter { + #count = 0; + #step = 1; + constructor(name) { + this.name = name; + } + read() { + return this.#count; + } + bump() { + this.#count += this.#step; + return this.#count; + } + reset(value) { + this.#count = value; + return this.name; + } + #label() { + return `${this.name}#${this.#count}`; + } + label() { + return this.#label(); + } + owns(value) { + return #count in value; + } + ownsSelf() { + return #count in this; + } +} + +const counter = new Counter("c"); +console.log(counter.bump(), counter.bump(), counter.read(), counter.label()); +console.log(counter.owns(counter), counter.owns({}), counter.ownsSelf(), counter.reset(7), counter.read()); + +const plain = { name: "plain" }; +report("read", () => Counter.prototype.read.call(plain)); +report("bump", () => Counter.prototype.bump.call(plain)); +report("reset", () => Counter.prototype.reset.call(plain, 3)); +report("label", () => Counter.prototype.label.call(plain)); +report("ownsSelf", () => Counter.prototype.ownsSelf.call(plain)); +report("owns primitive", () => Counter.prototype.owns.call(counter, 5)); +report("label undefined", () => Counter.prototype.label.call(undefined)); +report("label null", () => Counter.prototype.label.call(null)); +report("label number", () => Counter.prototype.label.call(4)); +report("read other instance", () => Counter.prototype.read.call(new Counter("d"))); +report("label other instance", () => Counter.prototype.label.call(new Counter("e"))); + +const Anonymous = class { + #m() { + return 1; + } + call() { + return this.#m(); + } +}; +const Named = class Inner { + #m() { + return 2; + } + call() { + return this.#m(); + } +}; +report("anonymous", () => Anonymous.prototype.call.call({})); +report("named", () => Named.prototype.call.call({})); +console.log(new Anonymous().call(), new Named().call()); + +class Shape { + constructor(name) { + this.name = name; + } + describe() { + return `shape ${this.name}`; + } + area() { + return 0; + } +} +class Square extends Shape { + describe() { + return `square: ${super.describe()} area ${this.area()}`; + } + area() { + return this.size * this.size; + } + both() { + return [super.describe(), super.area()].join("|"); + } +} +class Tiny extends Square { + #tag = "t"; + describe() { + return `tiny<${super.describe()}>`; + } + tag() { + return `${this.#tag}:${super.both()}`; + } +} + +const square = new Square("s"); +square.size = 3; +console.log(square.describe(), square.both()); +const tiny = new Tiny("t"); +tiny.size = 1; +console.log(tiny.describe(), tiny.tag()); +const foreign = { + name: "f", + size: 2, + area() { + return 40; + }, +}; +report("square describe", () => Square.prototype.describe.call(foreign)); +report("square both", () => Square.prototype.both.call(foreign)); +report("tiny describe", () => Tiny.prototype.describe.call(foreign)); +report("tiny tag", () => Tiny.prototype.tag.call(foreign)); +report("square describe on tiny", () => Square.prototype.describe.call(tiny)); + +// A base method that builds `new this.constructor()` on a subclass instance +// builds the subclass, also once the base prototype is complete (a super call +// above reads it) and `copy` is read through its prototype slot. +class Node3 { + constructor() { + this.a = 0; + } + copy(source) { + this.a = source.a; + return this; + } + clone() { + return new this.constructor().copy(this); + } +} +class Mesh3 extends Node3 { + constructor(geometry) { + super(); + this.geometry = geometry; + } + copy(source) { + super.copy(source); + this.geometry = source.geometry; + return this; + } +} +const copyValue = Node3.prototype.copy; +const mesh = new Mesh3({ id: 1 }); +mesh.a = 5; +const meshClone = mesh.clone(); +console.log( + meshClone instanceof Mesh3, + meshClone.constructor === Mesh3, + mesh.constructor === Mesh3, + meshClone.a, + JSON.stringify(meshClone.geometry), + typeof copyValue, +); + +// Methods that use a private accessor or return a private method keep their own receiver, as values too. +class Vault { + #v = 1; + get #value() { + return this.#v * 10; + } + set #value(x) { + this.#v = x; + } + #hidden() { + return "hidden " + this.#v; + } + read() { + return this.#value; + } + bump() { + this.#value = this.#v + 1; + return this.#v; + } + hiddenValue() { + return this.#hidden; + } +} +const vault = new Vault(); +const readVault = Vault.prototype.read; +const bumpVault = Vault.prototype.bump; +const hiddenOf = Vault.prototype.hiddenValue; +console.log(readVault.call(vault), bumpVault.call(vault), readVault.call(vault), typeof hiddenOf.call(vault)); +console.log(hiddenOf.call(vault).call(vault)); +try { + readVault.call({}); +} catch (error) { + console.log(error.constructor.name); +} + +// A method value whose body calls super with spread arguments. +class Summer { + add(...xs) { + return xs.reduce((sum, x) => sum + x, 0); + } +} +class Doubler extends Summer { + add(...xs) { + return super.add(...xs) * 2; + } +} +const addValue = Doubler.prototype.add; +console.log(addValue.call(new Doubler(), 1, 2, 3), addValue.apply(new Doubler(), [4, 5])); diff --git a/tests/corpus/7742-weak-collections-many-keys.ts b/tests/corpus/7742-weak-collections-many-keys.ts new file mode 100644 index 000000000..2cb2b103e --- /dev/null +++ b/tests/corpus/7742-weak-collections-many-keys.ts @@ -0,0 +1,70 @@ +// Weak collections with many keys: entries found among tens of thousands +// in WeakMaps and WeakSets that share keys, deleted and replaced, and gone +// with their keys, so blocks a new key reuses are not found under it. +// Keys in reference cycles die by the cycle collector. +class Key { + readonly id: number; + peer: Key | null = null; + constructor(id: number) { + this.id = id; + } +} + +const n = 40000; +let keys: Key[] = []; +for (let i = 0; i < n; i++) keys.push(new Key(i)); +const values = new WeakMap(); +const labels = new WeakMap(); +const marked = new WeakSet(); +for (const k of keys) { + values.set(k, k.id * 2); + if (k.id % 3 === 0) labels.set(k, "k" + k.id); + if (k.id % 2 === 0) marked.add(k); +} +let sum = 0; +let labelled = 0; +let counted = 0; +for (const k of keys) { + sum += values.get(k) ?? 0; + if (labels.has(k)) labelled++; + if (marked.has(k)) counted++; +} +console.log("filled", sum, labelled, counted); + +// Deleting every fourth key from one collection leaves the others alone. +let deleted = 0; +for (const k of keys) if (k.id % 4 === 0 && values.delete(k)) deleted++; +let remaining = 0; +for (const k of keys) if (values.has(k)) remaining++; +console.log("deleted", deleted, remaining, values.delete(keys[0]!), marked.has(keys[0]!), labels.get(keys[3]!)); + +// Overwriting keeps one entry per key. +for (const k of keys) if (k.id % 5 === 0) values.set(k, -1); +let negative = 0; +for (const k of keys) if (values.get(k) === -1) negative++; +console.log("overwritten", negative); + +// Dropping the keys ends their entries; new keys are not found. +keys = []; +const fresh: Key[] = []; +for (let i = 0; i < n; i++) fresh.push(new Key(n + i)); +let stale = 0; +for (const k of fresh) if (values.has(k) || labels.has(k) || marked.has(k)) stale++; +console.log("fresh", fresh.length, stale); + +// Keys that reach each other die together through the cycle collector. +for (let round = 0; round < 3; round++) { + let ring: Key[] = []; + for (let i = 0; i < 1000; i++) ring.push(new Key(i)); + for (let i = 0; i < ring.length; i++) ring[i]!.peer = ring[(i + 1) % ring.length]!; + for (const k of ring) { + values.set(k, round); + marked.add(k); + } + ring = []; +} +const after: Key[] = []; +for (let i = 0; i < 3000; i++) after.push(new Key(i)); +let found = 0; +for (const k of after) if (values.has(k) || marked.has(k)) found++; +console.log("cycles", found); diff --git a/tests/corpus/7757-speculative-key-chains.ts b/tests/corpus/7757-speculative-key-chains.ts new file mode 100644 index 000000000..1ef08a942 --- /dev/null +++ b/tests/corpus/7757-speculative-key-chains.ts @@ -0,0 +1,124 @@ +// Speculative integer loops over writes that call small pure number +// functions, use Math.pow(2, n) and Math.floor(a / 2^n), read bytes, or +// call other functions: every fallback gives Node's numbers. +function trim(value: number, length: number): number { + return value - Math.floor(value / Math.pow(2, length)) * Math.pow(2, length); +} +function maybeTrim(value: number | undefined, length: number): number { + return (value ?? 0) - Math.floor((value ?? 0) / Math.pow(2, length)) * Math.pow(2, length); +} + +function chain(codes: Uint8Array, length: number): number { + const mask = length * 2 - 2; + let key = 0; + let sum = 0; + for (let i = 0; i < codes.length; ++i) { + sum = (sum + key) % 1000000007; + key = trim(key, mask) * 4 + codes[i]!; + } + return sum * 31 + key; +} + +function optionalChain(codes: Uint8Array, length: number): number { + let key = 0; + let sum = 0; + for (let i = 0; i < codes.length; ++i) { + sum = (sum * 3 + key) % 1000003; + key = maybeTrim(key, length * 2) * 4 + codes[i]!; + } + return sum + key; +} + +// Exponents past 52, negative and fractional values, and NaN reads leave +// the integer copy and finish in the double one. +function shifts(start: number, exponent: number, steps: number): number[] { + const out: number[] = []; + let v = start; + for (let i = 0; i < steps; i++) { + v = Math.floor(v / Math.pow(2, exponent)) * 3 + i * 7; + out.push(v); + } + return out; +} + +let calls = 0; +function noisy(x: number): number { + calls++; + return x % 5 === 0 ? x / 2 : x * 2; +} +// A call that the write cannot expand runs once per iteration, also on +// the iteration whose check fails. +function hoisted(n: number): number { + let acc = 1; + for (let i = 0; i < n; i++) acc = (acc * 7 + noisy(i)) % 1000003; + return acc; +} + +// A check that fails before a write with a hoisted call: that write still +// makes its call on the iteration that bails. +function tally(x: number): number { + calls++; + return x * 2 + 1; +} +function chained(n: number): number { + let a = 1; + let b = 1; + for (let i = 0; i < n; i++) { + a = a * 3 + i; + b = (b * 7 + tally(i) + (a % 11)) % 1000003; + } + return b; +} + +// A divisor next to a power of two is not one: 2^52 + 1 divides as itself. +function nearPowers(start: number, steps: number): number[] { + const out: number[] = []; + let v = start; + for (let i = 0; i < steps; i++) { + v = Math.floor(v / 4503599627370497) * 4 + Math.floor(v / 4503599627370496) + i; + out.push(v); + } + return out; +} + +// A helper that calls a function with effects is not expanded: its +// argument is read before the call runs. +let level = 0; +function raise(): number { + level += 100; + return 1; +} +function lifted(x: number): number { + return raise() + x; +} +function effectfulHelper(n: number): number { + let acc = 0; + for (let i = 0; i < n; i++) acc = (acc * 3 + lifted(level)) % 1000003; + return acc; +} + +// A call whose argument assigns a local the write also reads stays where it +// is: `i` is read before `i++` runs. +function stepped(n: number): number { + let acc = 1; + let i = 0; + while (i < n) acc = (acc * 3 + i + tally(i++)) % 1000003; + return acc; +} + +const codes = new Uint8Array(5000); +let seed = 7; +for (let i = 0; i < codes.length; i++) { + seed = (seed * 1103515245 + 12345) % 2147483648; + codes[i] = seed % 4; +} +for (const length of [1, 2, 3, 6, 12, 18, 27]) console.log(length, chain(codes, length), optionalChain(codes, length)); +console.log(shifts(1000, 3, 6), shifts(-1000, 2, 6), shifts(5, 53, 3), shifts(7.5, 1, 3), shifts(2 ** 52, 1, 3)); +console.log(hoisted(20), calls); +const short = codes.subarray(0, 3); +console.log(chain(short, 2), chain(new Uint8Array(0), 2)); +calls = 0; +console.log(chained(34), chained(60), calls); +console.log(nearPowers(4503599627370496, 3), nearPowers(9007199254740991, 2)); +console.log(effectfulHelper(10), level); +console.log(stepped(40)); diff --git a/tests/corpus/7983-integer-compares.ts b/tests/corpus/7983-integer-compares.ts new file mode 100644 index 000000000..2603a39a1 --- /dev/null +++ b/tests/corpus/7983-integer-compares.ts @@ -0,0 +1,166 @@ +// Compares of integer values that are not doubles in the generated code: +// typed-array elements and char codes, which read as NaN past the end, and +// sums such as `2 * i + 1` whose double is the integer rounded once, near and +// past 2^53. + +function countPairs(a: Int32Array): string { + // `j` runs one past the end, where `a[j]` is NaN and every order compare + // is false. + let lt = 0; + let ge = 0; + let eq = 0; + let ne = 0; + for (let i = 0; i < a.length; i++) { + for (let j = 0; j <= a.length; j++) { + if (a[i]! < a[j]!) lt++; + if (a[i]! >= a[j]!) ge++; + if (a[i]! === a[j]!) eq++; + if (a[i]! !== a[j]!) ne++; + } + } + return `${lt} ${ge} ${eq} ${ne}`; +} + +function pastTheEnd(a: Int32Array, b: Uint8Array, c: Uint32Array): string { + const n = a.length; + const out: boolean[] = []; + out.push(a[n]! < a[0]!, a[n]! > a[0]!, a[n]! <= a[n]!, a[n]! === a[0]!, a[0]! !== a[n]!); + out.push(a[n]! < 0, a[n]! >= -1, a[-1]! < a[0]!, b[n]! <= b[0]!, b[0]! !== b[n]!); + out.push(c[n]! < c[0]!, c[n]! >= c[n]!, c[0]! === c[0]!, c[n]! !== c[0]!); + out.push(a[0]! < b[n]!, b[0]! >= a[n]!, a[0]! < c[n]!); + return out.map((v) => (v ? 1 : 0)).join(""); +} + +function unsigned(c: Uint32Array): string { + // Elements above 2^31 compare as unsigned values. + let below = 0; + let above = 0; + let same = 0; + for (let i = 0; i < c.length; i++) { + for (let j = 0; j < c.length; j++) { + if (c[i]! < c[j]!) below++; + if (c[i]! > c[j]!) above++; + if (c[i]! === c[j]!) same++; + } + } + return `${below} ${above} ${same}`; +} + +function mixed(a: Int32Array, b: Uint8Array, c: Uint32Array, d: Int16Array): string { + let count = 0; + for (let i = 0; i < a.length; i++) { + if (a[i]! < b[i]!) count += 1; + if (a[i]! < c[i]!) count += 10; + if (c[i]! > d[i]!) count += 100; + if (b[i]! >= d[i]!) count += 1000; + if (a[i]! === c[i]!) count += 10000; + } + return String(count); +} + +function charCodes(s: string): string { + // `s.charCodeAt(s.length)` is NaN. + let rising = 0; + let falls = 0; + let equal = 0; + let differ = 0; + for (let i = 0; i < s.length; i++) { + if (s.charCodeAt(i) < s.charCodeAt(i + 1)) rising++; + if (s.charCodeAt(i) >= s.charCodeAt(i + 1)) falls++; + if (s.charCodeAt(i) === s.charCodeAt(i + 1)) equal++; + if (s.charCodeAt(i) !== s.charCodeAt(i + 1)) differ++; + } + return `${rising} ${falls} ${equal} ${differ}`; +} + +function heapChildren(start: number, end: number, steps: number): string { + // `2 * i + 1` passes 2^53 for `i` at or above 2^52, where its double + // rounds; `end` stays a safe integer. + const out: string[] = []; + for (let i = start; i < start + steps; i++) { + const child = 2 * i + 1; + out.push( + [2 * i + 1 < end, 2 * i + 1 <= end, 2 * i + 1 > end, 2 * i + 1 === end, 2 * i + 1 !== end] + .map((v) => (v ? 1 : 0)) + .join("") + ` ${child}`, + ); + } + return out.join(" "); +} + +function countNear(start: number, end: number, steps: number): string { + // The same compares in a loop over integer locals only, stopping early if + // `i` reaches `end`. + let lt = 0; + let le = 0; + let gt = 0; + let eq = 0; + let ne = 0; + for (let i = start; i < start + steps && i !== end; i++) { + if (2 * i + 1 < end) lt++; + if (2 * i + 1 <= end) le++; + if (2 * i + 1 > end) gt++; + if (2 * i + 1 === end) eq++; + if (2 * i + 1 !== end) ne++; + } + return `${lt} ${le} ${gt} ${eq} ${ne}`; +} + +function descend(root: number, end: number): number { + // A sift-down's index walk: doubles `root` until `2 * root + 1` reaches `end`. + let steps = 0; + while (2 * root + 1 < end) { + root = 2 * root + 1; + steps++; + } + return steps * 1000 + (root % 1000); +} + +function scaled(i: number, end: number): string { + // Products by a power of two never round: 4 * i and 1024 * i compare exactly. + return [4 * i < end, 4 * i === end, 1024 * i - 1 < end, 3 * i + 1 < end, 2 * i - 1 > -end] + .map((v) => (v ? 1 : 0)) + .join(""); +} + +function roundedProducts(): string { + // 3 * (2^52 - 1) is 13510798882111485, which rounds to 13510798882111484 = + // 4 * 3377699720527871: the products' doubles are equal and the integers + // are not. A product by 4 never rounds. + let equal = 0; + let below = 0; + for (let i = 1; i <= 8; i++) { + for (let j = 3377699720527869; j <= 3377699720527873; j++) { + if (i * 4503599627370495 === j * 4) equal++; + if (i * 4503599627370495 < j * 4) below++; + } + } + return `${equal} ${below}`; +} + +const a = new Int32Array([5, -3, 2147483647, -2147483648, 5, 0]); +const b = new Uint8Array([0, 255, 7, 128, 5, 0]); +const c = new Uint32Array([4294967295, 2147483648, 2147483647, 0, 1, 4294967295]); +const d = new Int16Array([-32768, 32767, -1, 1, 5, 0]); +console.log(countPairs(a)); +console.log(countPairs(new Int32Array(0))); +console.log(pastTheEnd(a, b, c)); +console.log(unsigned(c)); +console.log(mixed(a, b, c, d)); +console.log(charCodes("abcddcbaÿ￿\u0000")); +console.log(charCodes("")); +const p52 = 2 ** 52; +const safe = Number.MAX_SAFE_INTEGER; +console.log(heapChildren(p52 - 2, safe, 5)); +console.log(heapChildren(p52 - 2, safe - 1, 5)); +console.log(heapChildren(p52 + 100, safe, 3)); +console.log(heapChildren(-p52 - 2, -safe, 5)); +console.log(heapChildren(0, 4, 4)); +console.log(countNear(p52 - 3, safe, 6), countNear(p52 - 3, safe - 2, 6), countNear(p52, safe, 4)); +console.log(countNear(-p52 - 3, -safe, 6), countNear(-p52 + 1, -safe + 4, 4), countNear(0, 7, 6)); +console.log(descend(0, safe), descend(1, safe), descend(p52 - 1, safe), descend(p52, safe)); +console.log(descend(3, 100), descend(0, 1), descend(5, 0)); +for (const i of [0, 1, p52 / 2 - 1, p52 / 2, p52 / 1024, p52 / 2 + 1, p52, -p52]) { + console.log(scaled(i, safe), scaled(i, safe - 1), scaled(i, 2 ** 51), scaled(i, 4)); +} +console.log(roundedProducts()); diff --git a/tests/corpus/7985-untyped-collection-types.mjs b/tests/corpus/7985-untyped-collection-types.mjs new file mode 100644 index 000000000..de4e581c2 --- /dev/null +++ b/tests/corpus/7985-untyped-collection-types.mjs @@ -0,0 +1,125 @@ +// Untyped Maps, Sets and arrays whose uses prove their key, value and +// element types compile as typed collections; any other use keeps them +// untyped. Both must answer as Node does, including SameValueZero keys, +// lookups of another type, and every use that refuses the typing. + +// Typed: counters, membership sets and stacks used only through their +// methods. +const counts = new Map(); +const seen = new Set(); +const stack = []; +const words = []; +for (let i = 0; i < 40; i++) { + const key = "k" + (i % 7); + counts.set(key, (counts.get(key) ?? 0) + 1); + seen.add(i % 5); + stack.push(i * 2); + if (stack.length > 6) stack.shift(); + words.push(key); +} +console.log(counts.size, counts.get("k3"), counts.get("missing"), counts.has("k6"), counts.delete("k0"), counts.size); +console.log(seen.size, seen.has(4), seen.has(5), seen.delete(4), seen.has(4)); +console.log(stack.length, stack.at(-1), stack.at(0), stack.pop(), stack.includes(70), stack.indexOf(72), words.join("|").length); + +// SameValueZero keys and NaN elements on typed collections. +const byNumber = new Map(); +byNumber.set(NaN, "nan"); +byNumber.set(-0, "zero"); +byNumber.set(1.5, "x"); +console.log(byNumber.get(NaN), byNumber.get(0), byNumber.get(-0), byNumber.size, byNumber.has(1.5)); +const nums = []; +nums.push(NaN); +nums.push(-0); +console.log(nums.includes(NaN), nums.indexOf(NaN), nums.includes(0), nums.indexOf(0)); +const flags = new Set(); +flags.add(true); +console.log(flags.has(true), flags.has(false), flags.size); + +// Captured by a closure that only uses it. +const cache = new Map(); +const latest = () => cache.get("c") ?? -1; +console.log(latest()); +for (let i = 0; i < 3; i++) cache.set("c", i * 10); +console.log(latest(), cache.size); + +// Lookups of another type: the typing refuses, and the answers stay Node's. +const strings = new Map(); +strings.set("1", "one"); +console.log(strings.get(1), strings.get("1"), strings.has(1)); +const numbers = []; +numbers.push(1); +console.log(numbers.includes("1"), numbers.indexOf("1"), numbers.at("0")); + +// Mixed key, value and element types. +const mixedKeys = new Map(); +mixedKeys.set(1, "number"); +mixedKeys.set("1", "string"); +console.log(mixedKeys.size, mixedKeys.get(1), mixedKeys.get("1")); +const mixedValues = new Map(); +mixedValues.set("a", 1); +mixedValues.set("b", "two"); +console.log(mixedValues.get("a"), mixedValues.get("b")); +const mixedElements = []; +mixedElements.push(1); +mixedElements.push("two"); +console.log(mixedElements.join(","), mixedElements.length); + +// Escapes and other uses that keep the collection untyped. +const passed = new Map(); +passed.set("x", 1); +function sizeOf(m) { + return m.size; +} +console.log(sizeOf(passed)); +function make() { + const made = new Set(); + made.add("y"); + return made; +} +console.log(make().has("y")); +const stored = []; +stored.push(3); +const holder = { list: stored }; +holder.list.push(4); +console.log(stored.length, holder.list.join("+")); +const iterated = new Map(); +iterated.set("a", 1); +iterated.set("b", 2); +for (const [k, v] of iterated) console.log("entry", k, v); +const eachSet = new Set(); +eachSet.add(3); +eachSet.forEach((v) => console.log("member", v)); +const shown = new Map(); +shown.set("s", 1); +console.log(shown); +const chained = new Map(); +chained.set("a", 1).set("b", 2); +console.log(chained.size); +const optional = new Map(); +optional.set("o", 1); +console.log(optional?.get("o"), optional.get?.("o")); +const indexed = []; +indexed.push(1); +indexed[3] = 2; +console.log(indexed.length, indexed.join(",")); +const truncated = []; +truncated.push(1, 2, 3); +truncated.length = 1; +console.log(truncated.length, truncated.at(0)); +const spread = []; +spread.push(5); +console.log(Math.max(...spread)); +const unread = new Map(); +console.log(unread.size, unread.get("none")); +export const exported = new Map(); +exported.set("e", 1); +console.log(exported.get("e")); +// Exported through an export list, under its own name or another: an +// importer sees an untyped Map. +const listed = new Map(); +listed.set("l", 1); +console.log(listed.get("l")); +const renamed = new Map(); +renamed.set("r", 1); +console.log(renamed.get("r")); +export { listed, renamed as alias }; diff --git a/tests/corpus/8003-while-reversed-byte-reads.ts b/tests/corpus/8003-while-reversed-byte-reads.ts new file mode 100644 index 000000000..1407b9324 --- /dev/null +++ b/tests/corpus/8003-while-reversed-byte-reads.ts @@ -0,0 +1,183 @@ +// While loops that read a typed array or Buffer backwards from its length +// (`line[len - 1 - i]`, `line[right - i]`), with increments, guards that +// exit at the length, calls in the body, reads at or past either end, and +// counters advanced by nested loops. + +function complement(code: number): number { + switch (code) { + case 65: + return 84; + case 67: + return 71; + case 71: + return 67; + case 84: + return 65; + } + return 63; +} + +function reverseComplement(line: Uint8Array, target: Uint8Array): string { + const len = line.length; + const right = len - 1; + let ileft = 0; + let iright = 0; + let c: number; + while (iright < len) { + c = line[right - iright]!; + iright++; + if (c === 10) { + if (iright === len) break; + c = line[right - iright]!; + iright++; + } + target[ileft] = complement(c); + ileft++; + } + return Buffer.from(target.subarray(0, ileft)).toString("latin1"); +} + +const out = new Uint8Array(32); +console.log(reverseComplement(Buffer.from("ACGT\nTTGA\n"), out)); +console.log(reverseComplement(Buffer.from("\nGATTACA"), out)); +console.log(reverseComplement(Buffer.from("\n"), out)); +console.log(reverseComplement(Buffer.from("A\n\nC"), out)); +console.log(JSON.stringify(reverseComplement(new Uint8Array(0), out))); + +// Reads one past the end: the first pass reads line[len], which is undefined. +function inclusive(line: Uint8Array): string { + const len = line.length; + let i = 0; + const seen: (number | undefined)[] = []; + while (i <= len) { + seen.push(line[len - i]); + i++; + } + return JSON.stringify(seen.map((v) => (v === undefined ? "u" : v))); +} +console.log(inclusive(new Uint8Array([1, 2, 3])), inclusive(new Uint8Array(0))); + +// A read after an increment with no guard reads line[-1] on the last pass. +function unguarded(line: Uint8Array): string { + const right = line.length - 1; + let i = 0; + let total = 0; + let missing = 0; + while (i < line.length) { + total += line[right - i]!; + i++; + const next = line[right - i]; + if (next === undefined) missing++; + else total += next * 100; + } + return `${total} ${missing}`; +} +console.log(unguarded(new Uint8Array([1, 2, 3])), unguarded(new Uint8Array([9]))); + +// Guards in other forms: >=, !==, and a guard in an else branch. +function guards(line: Uint16Array): number[] { + const len = line.length; + const result: number[] = []; + let i = 0; + while (i < len) { + i += 1; + if (i >= len) { + result.push(-1); + break; + } + result.push(line[len - 1 - i]!); + } + let j = 0; + while (len > j) { + j++; + if (j !== len) result.push(line[len - 1 - j]! * 10); + else result.push(-2); + } + let k = 0; + while (k < len) { + k++; + if (line[0] === 0) { + result.push(-3); + } else if (k === len) { + break; + } else { + result.push(line[len - k - 1]! * 100); + } + } + return result; +} +console.log(guards(new Uint16Array([5, 6, 7]))); +console.log(guards(new Uint16Array([0, 1]))); +console.log(guards(new Uint16Array(0))); + +// A nested loop advances the counter, so the read after it can fall +// outside the array. +function skipRuns(line: Uint8Array): string { + const len = line.length; + let i = 0; + const parts: string[] = []; + while (i < len) { + const c = line[len - 1 - i]!; + i++; + while (i < len && line[len - 1 - i] === c) i++; + const after = line[len - 1 - i]; + parts.push(`${c}:${after === undefined ? "end" : after}`); + } + return parts.join(" "); +} +console.log(skipRuns(Buffer.from("aabbbc")), skipRuns(Buffer.from("zz"))); + +// A condition with <= against the last index, a labeled block, and +// counters that start past zero or past the end. +function labeled(line: Int8Array, start: number): number[] { + const len = line.length; + const right = len - 1; + const result: number[] = []; + let i = start; + while (i <= right) { + found: { + if (line[len - 1 - i]! < 0) break found; + result.push(line[len - 1 - i]!); + } + i += 2; + } + result.push(i); + return result; +} +console.log(labeled(new Int8Array([1, -2, 3, -4, 5]), 0)); +console.log(labeled(new Int8Array([1, -2, 3, -4, 5]), 1)); +console.log(labeled(new Int8Array([1, -2, 3]), 5)); + +// An exception thrown from a call in the body. +function throwing(line: Uint8Array): string { + const len = line.length; + let i = 0; + let total = 0; + try { + while (i < len) { + const c = line[len - 1 - i]!; + i++; + if (c === 0) throw new Error(`zero after ${total}`); + total += c; + } + } catch (error) { + return (error as Error).message; + } + return String(total); +} +console.log(throwing(new Uint8Array([4, 0, 2, 3])), throwing(new Uint8Array([1, 2]))); + +// An increment inside an if condition's comma expression: both reads are +// at index -1 on the last pass. +function commaIncrement(line: Uint8Array): string { + const len = line.length; + let i = 0; + const out: (number | undefined)[] = []; + while (i < len) { + if ((i++, i > 0)) out.push(line[len - 1 - i]); + const v = line[len - 1 - i]!; + out.push(v); + } + return JSON.stringify(out); +} +console.log(commaIncrement(new Uint8Array([1, 2, 3])), commaIncrement(new Uint8Array(0))); diff --git a/tests/corpus/8006-buffer-base64-decode.ts b/tests/corpus/8006-buffer-base64-decode.ts new file mode 100644 index 000000000..3d7b20303 --- /dev/null +++ b/tests/corpus/8006-buffer-base64-decode.ts @@ -0,0 +1,66 @@ +// Buffer.from(text, "base64" | "base64url") over complete eight-character +// blocks and the text around them: every length from 0 to 40, an invalid +// character at each position of a block, padding and text after it, both +// alphabets, line breaks every 76 characters, sizes around the pool +// threshold, and characters past U+00FF. + +let seed = 7; +function next(): number { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed >>> 8; +} +function bytes(size: number): Buffer { + const buf = Buffer.alloc(size); + for (let i = 0; i < size; i++) buf[i] = next() & 255; + return buf; +} + +const rows: string[] = []; +for (let size = 0; size <= 30; size++) { + const buf = bytes(size); + const text = buf.toString("base64"); + const url = buf.toString("base64url"); + rows.push( + `${size}:${text.length}:${Buffer.from(text, "base64").equals(buf)}:${Buffer.from(url, "base64url").equals(buf)}:${Buffer.from(url, "base64").equals(buf)}`, + ); +} +console.log(rows.join(" ")); + +const block = Buffer.from("0123456789abcdefghijklmnopqrstuv").toString("base64"); +for (let at = 0; at < 16; at++) { + for (const bad of [" ", "\n", "*", "=", "é", "中"]) { + const text = block.slice(0, at) + bad + block.slice(at); + console.log(at, JSON.stringify(bad), Buffer.from(text, "base64").toString("hex")); + } +} + +for (const text of [ + "QUJDREVGR0g=", + "QUJDREVGR0", + "QUJDREVGR0g=QUJD", + "QUJD=REVGR0hJ", + "QUJDREVGR0hJSktM", + "QUJDREVGR0hJSktMTQ", + "QUJDREVGR0hJSktMTQ==", + "--__--__", + "++//++//", + "+-/_+-/_+-/_", + "Q U J D R E V G R 0 h J", + "QUJD\tREVG\r\nR0hJ", + "=QUJDREVGR0hJ", + "", +]) { + console.log(JSON.stringify(text), Buffer.from(text, "base64").toString("hex"), Buffer.from(text, "base64url").length); +} + +for (const size of [4095, 4096, 4097, 6144, 6145, 70000]) { + const buf = bytes(size); + const text = buf.toString("base64"); + const wrapped = text.replace(/.{76}/g, "$&\n"); + const decoded = Buffer.from(text, "base64"); + const unwrapped = Buffer.from(wrapped, "base64"); + console.log(size, decoded.length, decoded.equals(buf), unwrapped.equals(buf), Buffer.byteLength(text, "base64")); +} + +const wide = "QUJDREVGR0hJİSktM"; +console.log(Buffer.from(wide, "base64").toString("hex"), Buffer.from("ŁłQUJD", "base64").toString("hex")); diff --git a/tests/corpus/8020-date-setters.ts b/tests/corpus/8020-date-setters.ts new file mode 100644 index 000000000..6bfeff5c8 --- /dev/null +++ b/tests/corpus/8020-date-setters.ts @@ -0,0 +1,115 @@ +// Date setters: every local and UTC setter with each argument count, the +// time values they return, normalization of out-of-range fields, local +// setters across a daylight saving transition, setters on invalid dates, +// explicit undefined arguments, fractional arguments, and the TimeClip +// range. A setter changes the one Date object every alias holds. +export {}; +process.env.TZ = "America/Chicago"; + +const show = (label: string, d: Date): void => { + console.log(label, d.getTime(), Number.isNaN(d.getTime()) ? "invalid" : d.toISOString()); +}; + +const d = new Date(Date.UTC(2026, 0, 31, 10, 20, 30, 400)); +console.log(d.setMilliseconds(999), d.getMilliseconds()); +console.log(d.setSeconds(5), d.setSeconds(6, 7)); +console.log(d.setMinutes(1), d.setMinutes(2, 3), d.setMinutes(4, 5, 6)); +console.log(d.setHours(7), d.setHours(8, 9), d.setHours(10, 11, 12), d.setHours(13, 14, 15, 16)); +console.log(d.setDate(15), d.setMonth(1), d.setMonth(2, 3)); +console.log(d.setFullYear(2025), d.setFullYear(2024, 1), d.setFullYear(2023, 4, 6)); +show("local", d); + +const u = new Date(Date.UTC(2026, 0, 31)); +console.log(u.setUTCMilliseconds(1), u.setUTCSeconds(2, 3), u.setUTCMinutes(4, 5, 6)); +console.log(u.setUTCHours(7, 8, 9, 10), u.setUTCDate(1), u.setUTCMonth(5, 30)); +console.log(u.setUTCFullYear(1999), u.setUTCFullYear(2000, 1, 29)); +show("utc", u); + +// Fields out of range carry into the next larger field, both ways. +const n = new Date(Date.UTC(2026, 0, 31, 12)); +n.setUTCMonth(1); +show("Jan 31 + month", n); +n.setUTCDate(0); +show("day 0", n); +n.setUTCMonth(13); +show("month 13", n); +n.setUTCHours(-1); +show("hour -1", n); +n.setUTCMinutes(60 * 24 + 1); +show("minutes past a day", n); +n.setUTCSeconds(-0.5); +show("fractional seconds", n); +n.setUTCMilliseconds(1.9); +show("fractional ms", n); + +// Local setters in a zone with daylight saving time: 2026-03-08 02:30 +// does not exist in Chicago and 2026-11-01 01:30 happens twice. +const spring = new Date(2026, 2, 8, 0, 0); +spring.setHours(2, 30); +show("spring gap", spring); +console.log(spring.getHours(), spring.getMinutes(), spring.getTimezoneOffset()); +const fall = new Date(2026, 10, 1, 0, 0); +fall.setHours(1, 30); +show("fall overlap", fall); +fall.setMinutes(fall.getMinutes() + 60); +show("fall overlap + 60 min", fall); +const walk = new Date(2026, 2, 7, 12); +for (let i = 0; i < 3; i++) { + walk.setDate(walk.getDate() + 1); + console.log("walk", walk.getDate(), walk.getHours(), walk.getTimezoneOffset()); +} + +// Invalid dates: setFullYear starts from +0 local time, the others keep NaN. +const bad = new Date(NaN); +console.log(bad.setHours(1), bad.setDate(2), bad.setUTCMonth(3), bad.setMilliseconds(4)); +console.log(bad.setFullYear(2020)); +show("after setFullYear", bad); +const badUtc = new Date("not a date"); +console.log(badUtc.setUTCFullYear(2021, 6)); +show("after setUTCFullYear", badUtc); + +// Explicit undefined and NaN arguments make the date invalid; a missing +// optional argument keeps the current field. +const opt = new Date(Date.UTC(2026, 5, 15, 9)); +console.log(opt.setUTCHours(10, undefined), Number.isNaN(opt.getTime())); +const opt2 = new Date(Date.UTC(2026, 5, 15, 9)); +console.log(opt2.setUTCMonth(Number.NaN), opt2.setUTCMonth(2)); + +// setTime and the TimeClip range. +const t = new Date(0); +console.log(t.setTime(8.64e15), t.setTime(8.64e15 + 1), t.setTime(-8.64e15), t.setTime(1.5)); +console.log(t.setTime(-0), Object.is(t.getTime(), 0), t.setTime(Number.NaN)); +const far = new Date(Date.UTC(275760, 8, 13)); +console.log(far.getTime(), far.setUTCMilliseconds(1)); + +// Every alias sees the change: a second binding, an array slot, a Map +// value, a record field, a closure and a parameter. +const shared = new Date(Date.UTC(2026, 6, 4)); +const alias = shared; +const list = [shared, new Date(Date.UTC(2000, 0, 1))]; +const byName = new Map([["shared", shared]]); +const holder = { when: shared }; +const bump = (): number => shared.setUTCDate(shared.getUTCDate() + 1); +const nextYear = (date: Date): void => { + date.setUTCFullYear(date.getUTCFullYear() + 1); +}; +alias.setUTCHours(6); +list[0]!.setUTCMinutes(30); +byName.get("shared")!.setUTCSeconds(15); +holder.when.setUTCMilliseconds(250); +bump(); +nextYear(shared); +show("shared", shared); +console.log(alias === shared, list[0] === shared, byName.get("shared") === holder.when); +console.log(list.map((x) => x.toISOString()).join(" ")); + +// A Date a loop adjusts in place. +const cursor = new Date(Date.UTC(2026, 0, 1)); +let weekdays = 0; +while (cursor.getUTCFullYear() === 2026) { + const day = cursor.getUTCDay(); + if (day !== 0 && day !== 6) weekdays++; + cursor.setUTCDate(cursor.getUTCDate() + 1); +} +console.log("weekdays in 2026", weekdays); +show("cursor", cursor); diff --git a/tests/corpus/8021-date-coercions.ts b/tests/corpus/8021-date-coercions.ts new file mode 100644 index 000000000..2410853a4 --- /dev/null +++ b/tests/corpus/8021-date-coercions.ts @@ -0,0 +1,47 @@ +// Dates converted to primitives as Node converts them: unary +, Number() +// and relational operators read the time value (the number hint), while + +// with a string, template literals and String() read the text (a Date's +// default hint is a string). Symbol.toPrimitive, valueOf and toString answer +// the same values; == compares two Dates by identity and a Date with a +// primitive through its default primitive. typeof, truthiness and +// instanceof treat a Date as an object, invalid ones included. +export {}; +process.env.TZ = "UTC"; + +const a = new Date(Date.UTC(2026, 9, 23, 8, 30)); +const b = new Date(Date.UTC(2026, 9, 24, 8, 30)); +const bad = new Date(Number.NaN); + +console.log(+a, -a, +b - +a, (+b - +a) / 3600000, Number(a), Math.max(+a, +b) === b.getTime()); +console.log(a + "!"); +console.log(`at ${a}`); +console.log(String(b)); +console.log("" + a === a.toString(), a + "1" === a.toString() + "1"); +console.log(a[Symbol.toPrimitive]("number"), a[Symbol.toPrimitive]("string") === a.toString()); +console.log(a[Symbol.toPrimitive]("default") === a.toString(), a.valueOf() === a.getTime()); +console.log(a < b, a <= b, a > b, a >= b, +b > a.getTime() + 1); + +const same = a; +const twin = new Date(a.getTime()); +console.log(a == same, a == twin, a === twin, a != twin, a.getTime() === twin.getTime()); +console.log(a == (a.toString() as unknown), a == (a.getTime() as unknown)); + +console.log(typeof a, typeof bad, a instanceof Date, bad instanceof Date); +console.log(Object.prototype.toString.call(a), !a, !!bad, bad ? "truthy" : "falsy"); + +// Invalid dates. +console.log(+bad, +bad - +a, `${bad}`, String(bad), bad + "", Number.isNaN(bad.getTime())); +console.log(bad.toJSON(), JSON.stringify({ when: bad, at: a })); +try { + bad.toISOString(); +} catch (error) { + console.log((error as Error).name, (error as Error).message); +} + +// Sorting and summing through the number hint. +const days = [b, a, new Date(Date.UTC(2026, 0, 1))]; +days.sort((x, y) => +x - +y); +console.log(days.map((x) => x.getUTCDate()).join(",")); +let span = 0; +for (let i = 1; i < days.length; i++) span += +days[i]! - +days[i - 1]!; +console.log(span / 86400000); diff --git a/tests/corpus/8022-date-unions.ts b/tests/corpus/8022-date-unions.ts new file mode 100644 index 000000000..dedcd8bf1 --- /dev/null +++ b/tests/corpus/8022-date-unions.ts @@ -0,0 +1,68 @@ +// Dates in unions with other arms: Date | string, Date | number and +// Date | a record, narrowed with instanceof and typeof, in arrays, Map +// values, optional fields and function results. A Date arm keeps its +// identity, so a setter through a union-typed alias changes the original. +export {}; +process.env.TZ = "UTC"; + +type When = Date | string | number; + +function describe(when: When): string { + if (when instanceof Date) return `date ${when.toISOString()}`; + if (typeof when === "string") return `text ${when}`; + return `ms ${when}`; +} + +function normalize(when: When): Date { + return when instanceof Date ? when : new Date(when); +} + +const fixed = new Date(Date.UTC(2026, 9, 23)); +const values: When[] = [fixed, "2026-01-02T03:04:05Z", 0, new Date(Number.NaN)]; +for (const value of values) { + const text = value instanceof Date && Number.isNaN(value.getTime()) ? "invalid" : describe(value); + console.log(text); +} +console.log(normalize(fixed) === fixed, normalize("2000-01-01").getUTCFullYear(), normalize(1e12).getUTCFullYear()); + +const held: When = fixed; +if (held instanceof Date) held.setUTCHours(12); +console.log(fixed.getUTCHours(), held === fixed); + +type Stamp = Date | { label: string; at: number }; +const stamps: Stamp[] = [fixed, { label: "start", at: 5 }]; +for (const stamp of stamps) { + console.log(stamp instanceof Date ? stamp.getUTCDate() : `${stamp.label}@${stamp.at}`); +} + +const byKey = new Map(); +byKey.set("a", fixed); +byKey.set("b", "later"); +for (const [key, value] of byKey) console.log(key, value instanceof Date ? value.getTime() : value); + +interface Task { + name: string; + due?: Date; + done: Date | null; +} +const tasks: Task[] = [ + { name: "write", due: fixed, done: null }, + { name: "ship", done: new Date(Date.UTC(2026, 11, 1)) }, +]; +for (const task of tasks) { + const due = task.due ? task.due.toISOString().slice(0, 10) : "none"; + const done = task.done === null ? "open" : task.done.toISOString().slice(0, 10); + console.log(task.name, due, done); +} +tasks[0]!.due!.setUTCDate(30); +console.log(fixed.getUTCDate(), tasks[0]!.due === fixed); + +function latest(dates: (Date | undefined)[]): Date | string { + let best: Date | undefined; + for (const date of dates) if (date && (best === undefined || date > best)) best = date; + return best ?? "none"; +} +const found = latest([undefined, fixed, new Date(0)]); +console.log(found === fixed, latest([])); + +console.log(JSON.stringify({ values, stamps })); diff --git a/tests/corpus/8023-date-clone-throw.ts b/tests/corpus/8023-date-clone-throw.ts new file mode 100644 index 000000000..b9d569d00 --- /dev/null +++ b/tests/corpus/8023-date-clone-throw.ts @@ -0,0 +1,32 @@ +// Dates through structuredClone and MessageChannel, which copy them as +// new Dates with the same time value, and Dates thrown and rejected as +// values, which keep their identity. +import { MessageChannel } from "node:worker_threads"; +process.env.TZ = "UTC"; + +const at = new Date(Date.UTC(2026, 9, 23, 7)); +const copy = structuredClone(at); +console.log(copy instanceof Date, copy === at, copy.getTime() === at.getTime()); +copy.setUTCFullYear(2000); +console.log(at.getUTCFullYear(), copy.getUTCFullYear()); + +const nested = structuredClone({ at, list: [at, at], invalid: new Date(Number.NaN) }); +console.log(nested.list[0] instanceof Date, nested.at === at, nested.list.length); +console.log(nested.at.toISOString(), Number.isNaN(nested.invalid.getTime())); + +try { + throw at; +} catch (error) { + console.log(error instanceof Date, error === at); +} + +const { port1, port2 } = new MessageChannel(); +port2.on("message", (data: { when: Date; again: Date }) => { + const { when, again } = data; + console.log("message", when instanceof Date, when === at, again instanceof Date, when.toISOString()); + port2.close(); + Promise.reject(at).catch((reason: unknown) => { + console.log("rejected", reason instanceof Date, reason === at); + }); +}); +port1.postMessage({ when: at, again: at }); diff --git a/tests/corpus/8024-date-setters-dynamic.mjs b/tests/corpus/8024-date-setters-dynamic.mjs new file mode 100644 index 000000000..9b6392b60 --- /dev/null +++ b/tests/corpus/8024-date-setters-dynamic.mjs @@ -0,0 +1,24 @@ +// Date setters in JavaScript, on Dates held as untyped values: each setter +// converts its arguments with ToNumber (valueOf runs, undefined is NaN) and +// changes the one Date object every alias holds. +process.env.TZ = "Europe/Berlin"; + +const d = new Date(Date.UTC(2026, 2, 28, 12)); +const alias = d; +const box = { d }; +console.log(d.setHours(1, 30), d.getHours(), d.getTimezoneOffset()); +console.log(alias.setDate(29), box.d.getHours(), d.getTimezoneOffset()); +console.log(d.setMinutes("15"), d.setSeconds(true), d.setMilliseconds(null)); +console.log(d.setUTCFullYear(2030, 11, 31), d.toISOString()); +console.log(d.setMonth(undefined), Number.isNaN(d.getTime())); +console.log(d.setFullYear(2001), d.toISOString()); + +let calls = 0; +const later = { valueOf() { calls++; return 45; } }; +console.log(d.setUTCMinutes(later, later), calls, d.toISOString()); + +const other = new Date(0); +console.log(other.setHours(5), other.getHours(), typeof d.setMonth); +const list = [new Date(0), new Date(86400000)]; +list.forEach((x, i) => x.setUTCHours(i + 1)); +console.log(list.map((x) => x.toISOString()).join(" ")); diff --git a/tests/corpus/8025-date-subclass-setters.ts b/tests/corpus/8025-date-subclass-setters.ts new file mode 100644 index 000000000..a6414a047 --- /dev/null +++ b/tests/corpus/8025-date-subclass-setters.ts @@ -0,0 +1,30 @@ +// Setters on instances of a class declared `extends Date`: each changes +// the instance's time value, which the inherited getters, the subclass's +// own methods and toISOString then read; invalid instances follow the same +// rules as Dates. +export {}; +process.env.TZ = "Asia/Kolkata"; + +class Deadline extends Date { + label: string; + constructor(label: string, ms: number) { + super(ms); + this.label = label; + } + extend(days: number): this { + this.setDate(this.getDate() + days); + return this; + } + describe(): string { + return `${this.label} ${this.toISOString()}`; + } +} + +const due = new Deadline("report", Date.UTC(2026, 9, 23, 18, 30)); +console.log(due.describe()); +console.log(due.setHours(9, 15), due.getHours(), due.getMinutes()); +console.log(due.extend(10).describe()); +console.log(due.setUTCMonth(0, 31), due.setMonth(1), due.getDate(), due.getMonth()); +console.log(due.setFullYear(2024, 1, 29), due.describe()); +console.log(due.setTime(Number.NaN), Number.isNaN(due.getTime()), due.setUTCFullYear(2030)); +console.log(due.describe(), due instanceof Date, due instanceof Deadline); diff --git a/tests/corpus/8026-date-subclass-setters-dynamic.mjs b/tests/corpus/8026-date-subclass-setters-dynamic.mjs new file mode 100644 index 000000000..8b89d6ec8 --- /dev/null +++ b/tests/corpus/8026-date-subclass-setters-dynamic.mjs @@ -0,0 +1,17 @@ +// Setters on instances of a JavaScript class declared `extends Date`, held +// as untyped values: the inherited setters change the instance's time value. +process.env.TZ = "America/Sao_Paulo"; + +class Shift extends Date { + start() { + this.setHours(8, 0, 0, 0); + return this; + } +} + +const shift = new Shift(Date.UTC(2026, 4, 5, 15)); +console.log(shift.start().toISOString(), shift.getHours()); +console.log(shift.setDate(31), shift.getMonth(), shift.setUTCHours(0), shift.toISOString()); +const values = [shift, new Shift(0)]; +for (const value of values) value.setUTCMinutes(45); +console.log(values.map((value) => value.toISOString()).join(" "), values[0] === shift); diff --git a/tests/corpus/8027-date-setter-order.ts b/tests/corpus/8027-date-setter-order.ts new file mode 100644 index 000000000..b87f3a656 --- /dev/null +++ b/tests/corpus/8027-date-setter-order.ts @@ -0,0 +1,66 @@ +// Date setters and evaluation order: a setter called without arguments +// makes the Date invalid, an argument that calls setTime on the same +// Date subclass instance is seen by the setter, a comparison or +// concatenation reads its left Date after the right side runs a setter, +// and toJSON reads its Date after an argument runs a setter. +export {}; +process.env.TZ = "UTC"; + +const show = (label: string, d: Date): void => { + console.log(label, d.getTime(), Number.isNaN(d.getTime()) ? "invalid" : d.toISOString()); +}; + +// No arguments: the first argument is undefined, so NaN. +const local = new Date(Date.UTC(2026, 5, 15, 9, 30)); +// @ts-expect-error: the first argument is required +console.log(local.setHours(), Number.isNaN(local.getTime())); +const utc = new Date(Date.UTC(2026, 5, 15, 9, 30)); +// @ts-expect-error: the first argument is required +console.log(utc.setUTCMonth(), utc.setUTCFullYear(2020)); +show("utc after", utc); +const kept = new Date(Date.UTC(2026, 5, 15)); +const keep = kept; +// @ts-expect-error: the first argument is required +console.log(keep.setMinutes(), kept.getTime()); + +class Stamp extends Date { + label(): string { + return `stamp ${this.toISOString()}`; + } +} +const stamp = new Stamp(Date.UTC(2026, 0, 1)); +// @ts-expect-error: the first argument is required +console.log(stamp.setSeconds(), Number.isNaN(stamp.getTime())); +stamp.setTime(Date.UTC(2026, 0, 2)); +// @ts-expect-error: the first argument is required +console.log(stamp.setUTCDate(), stamp.setUTCFullYear(2024, 1, 29), stamp.label()); + +// An argument that calls setTime on the same instance. +const moved = new Stamp(Date.UTC(2026, 3, 10, 12)); +console.log(moved.setUTCHours((moved.setTime(Date.UTC(2000, 0, 1)), 5)), moved.label()); +const plain = new Date(Date.UTC(2026, 3, 10, 12)); +console.log(plain.setUTCMinutes((plain.setTime(Date.UTC(2001, 0, 1)), 7))); +show("plain", plain); + +// The left operand converts after the right operand runs a setter. +const a = new Date(Date.UTC(2026, 0, 1)); +const b = new Date(Date.UTC(2026, 0, 2)); +console.log(a < (a.setUTCDate(3), b), a > b); +const c = new Date(Date.UTC(2026, 0, 1)); +console.log(c >= (c.setTime(0), new Date(1)), c <= new Date(1)); +const s = new Date(Date.UTC(2026, 0, 1)); +console.log(s + (s.setUTCFullYear(1999), "!")); +const n = new Date(Date.UTC(2026, 0, 1)); +console.log(+n === (n.setTime(5), 5), +n); + +// Locals a function keeps as their time value: a method reads the time +// value after its arguments run a setter. +function locals(): void { + const json = new Date(Date.UTC(2026, 0, 1)); + console.log(json.toJSON(json.setUTCFullYear(1999)), json.toJSON()); + const key = new Date(Date.UTC(2026, 0, 1)); + console.log(key.toJSON(String(key.setTime(Date.UTC(2021, 5, 1))))); + const read = new Date(Date.UTC(2026, 0, 1)); + console.log(read.toJSON(), read.getTime()); +} +locals(); diff --git a/tests/corpus/8028-date-setter-order-dynamic.mjs b/tests/corpus/8028-date-setter-order-dynamic.mjs new file mode 100644 index 000000000..f52e410c1 --- /dev/null +++ b/tests/corpus/8028-date-setter-order-dynamic.mjs @@ -0,0 +1,17 @@ +// Date setters reached through untyped values: without arguments each +// one makes the Date invalid, and an argument that calls setTime on the +// same Date is seen by the setter. +process.env.TZ = "UTC"; + +const names = [ + "setMilliseconds", "setSeconds", "setMinutes", "setHours", "setDate", "setMonth", "setFullYear", + "setUTCMilliseconds", "setUTCSeconds", "setUTCMinutes", "setUTCHours", "setUTCDate", "setUTCMonth", "setUTCFullYear", +]; +for (const name of names) { + const d = new Date(Date.UTC(2026, 5, 15)); + console.log(name, d[name](), d.getTime()); +} +const d = new Date(Date.UTC(2026, 3, 10, 12)); +console.log(d.setUTCHours((d.setTime(Date.UTC(2000, 0, 1)), 5)), d.toISOString()); +const a = new Date(Date.UTC(2026, 0, 1)); +console.log(a < (a.setUTCDate(3), new Date(Date.UTC(2026, 0, 2)))); diff --git a/tests/corpus/8062-assert-message-values.cjs b/tests/corpus/8062-assert-message-values.cjs new file mode 100644 index 000000000..fc37a71e4 --- /dev/null +++ b/tests/corpus/8062-assert-message-values.cjs @@ -0,0 +1,48 @@ +'use strict'; +// node:assert messages that are not string literals: an untyped string, an +// Error to throw, null/undefined (omitted) and falsy values, and +// scalar-union operands against untyped values. +const assert = require('assert'); + +function attempt(label, fn) { + try { + fn(); + console.log(label, 'passed'); + } catch (e) { + console.log(label, e.name, JSON.stringify(e.message), e.generatedMessage, e.code); + } +} + +const failures = []; +for (const x of ['a', 'b']) if (x === 'c') failures.push(`\n${x}`); +attempt('joined passes', () => assert.strictEqual(failures.length, 0, failures.join(''))); +const untyped = JSON.parse('"untyped message"'); +attempt('untyped', () => assert.strictEqual(1, 2, untyped)); +attempt('untyped deep', () => assert.deepStrictEqual([1], [2], untyped)); +attempt('untyped notStrict', () => assert.notStrictEqual(1, 1, untyped)); +attempt('error message', () => assert.strictEqual(1, 2, new TypeError('own error'))); +attempt('error deep', () => assert.deepStrictEqual({ a: 1 }, { a: 2 }, new RangeError('own range'))); +const none = JSON.parse('null'); +attempt('null message', () => assert.strictEqual(1, 2, none)); +const zero = JSON.parse('0'); +attempt('zero message deep', () => assert.deepStrictEqual([1], [2], zero)); +attempt('passing error message', () => assert.strictEqual(1, 1, new Error('unused'))); +attempt('later string message', () => assert.strictEqual(1, 2, 'plain')); + +// Operand order: both operands evaluate before the message. +const order = []; +const mark = (label, value) => (order.push(label), value); +attempt('order', () => assert.strictEqual(mark('a', 1), mark('b', 2), mark('m', JSON.parse('"m"')))); +console.log(order.join(',')); + +const row = [['a', 'b', true], ['c', 'd', 'x']]; +for (const [, , expected] of row) { + attempt('union operand', () => assert.strictEqual(JSON.parse(JSON.stringify(expected)), expected)); +} + +// assert.fail with a message that is not a string: an Error is thrown as +// is; other values become the message, generated when falsy. +attempt('fail error', () => assert.fail(new RangeError('re'))); +attempt('fail value', () => assert.fail(JSON.parse('{"a":1}'))); +attempt('fail zero', () => assert.fail(zero)); +attempt('fail null', () => assert.fail(JSON.parse('null'))); diff --git a/tests/corpus/8065-cjs-top-level-return/early.js b/tests/corpus/8065-cjs-top-level-return/early.js new file mode 100644 index 000000000..8d7de4ba3 --- /dev/null +++ b/tests/corpus/8065-cjs-top-level-return/early.js @@ -0,0 +1,6 @@ +'use strict'; +exports.ready = true; +console.log('early module start', typeof helper); +if (exports.ready) return; +console.log('early module end'); +function helper() {} diff --git a/tests/corpus/8065-cjs-top-level-return/main.js b/tests/corpus/8065-cjs-top-level-return/main.js new file mode 100644 index 000000000..7743eaac5 --- /dev/null +++ b/tests/corpus/8065-cjs-top-level-return/main.js @@ -0,0 +1,16 @@ +'use strict'; +// A CommonJS module may end its evaluation with a top-level return: the +// rest of the module does not run, while timers, exit handlers and the +// modules it already required keep their effects. +const early = require('./early.js'); +console.log('early exports', early.ready); +setTimeout(() => console.log('timer ran'), 1); +process.on('exit', () => console.log('exit handler')); +for (const value of [1, 2, 3]) { + if (value === 2) return 'ignored value'; + console.log('value', value); +} +console.log('not reached'); +function hoisted() { + return 'hoisted'; +} diff --git a/tests/corpus/8065-cjs-top-level-return/package.json b/tests/corpus/8065-cjs-top-level-return/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/corpus/8065-cjs-top-level-return/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/tests/corpus/8066-object-hasown-delete.cjs b/tests/corpus/8066-object-hasown-delete.cjs new file mode 100644 index 000000000..11668b521 --- /dev/null +++ b/tests/corpus/8066-object-hasown-delete.cjs @@ -0,0 +1,23 @@ +'use strict'; +// Object.hasOwn over nullish receivers and as a value, and `delete` of +// properties JavaScript inference types as required. +const o = { a: 1, b: undefined }; +console.log(Object.hasOwn(o, 'a'), Object.hasOwn(o, 'b'), Object.hasOwn(o, 'c')); +console.log(Object.hasOwn([1, 2], 1), Object.hasOwn([1, 2], 2), Object.hasOwn(Object.create({ x: 1 }), 'x')); +for (const value of [null, undefined]) { + try { + Object.hasOwn(value, 'a'); + } catch (e) { + console.log(e.name, e.message); + } +} +const pick = Math.random() < 2 ? { k: 1 } : null; +console.log(Object.hasOwn(pick, 'k')); +const hasOwn = Object.hasOwn; +console.log(hasOwn({ k: 1 }, 'k'), hasOwn({}, 'k'), [{ a: 1 }, {}].map((x) => hasOwn(x, 'a')).join()); +const record = { a: 1, b: 2 }; +delete record.a; +console.log(Object.keys(record).join(), Object.hasOwn(record, 'a'), record.a); +const table = { one: 1, two: 2 }; +for (const key of Object.keys(table)) if (key === 'one') delete table[key]; +console.log(JSON.stringify(table)); diff --git a/tests/corpus/8067-assert-throws-validators.cjs b/tests/corpus/8067-assert-throws-validators.cjs new file mode 100644 index 000000000..189f4221a --- /dev/null +++ b/tests/corpus/8067-assert-throws-validators.cjs @@ -0,0 +1,22 @@ +'use strict'; +// assert.throws and assert.rejects with validation functions, constructor +// functions, and untyped expected values (Node's expectedException). +const assert = require('assert'); +function show(label, fn) { + try { fn(); console.log(label, 'passed'); } catch (e) { console.log(label, e.name, JSON.stringify(e.message), e.generatedMessage, e.operator, e.actual && e.actual.message); } +} +function check(err) { return err.message === 'x'; } +show('named false', () => assert.throws(() => { throw new Error('y'); }, check)); +show('named true', () => assert.throws(() => { throw new Error('x'); }, check)); +show('custom message', () => assert.throws(() => { throw new Error('y'); }, check, 'custom')); +show('returns 1', () => assert.throws(() => { throw new Error('y'); }, () => 1)); +show('non-error thrown', () => assert.throws(() => { throw 'str'; }, (v) => v === 'str')); +show('non-error fails', () => assert.throws(() => { throw 42; }, () => undefined)); +function MyErr(m) { this.message = m; } +show('constructor', () => assert.throws(() => { throw new MyErr('q'); }, MyErr)); +function untyped(expected) { assert.throws(() => { throw new Error('u'); }, expected); } +show('untyped validator', () => untyped((e) => e.message === 'u')); +show('untyped validator fails', () => untyped(function v() { return false; })); +show('untyped object', () => untyped({ message: 'u' })); +assert.rejects(Promise.reject(new Error('r')), (e) => e.message === 'r').then(() => console.log('rejects passed')); +assert.rejects(Promise.reject(new Error('r')), () => false).catch((e) => console.log('rejects', JSON.stringify(e.message), e.operator)); diff --git a/tests/corpus/8076-assert-instance-identity.cjs b/tests/corpus/8076-assert-instance-identity.cjs new file mode 100644 index 000000000..e8cedf3d6 --- /dev/null +++ b/tests/corpus/8076-assert-instance-identity.cjs @@ -0,0 +1,50 @@ +'use strict'; +// assert.strictEqual and notStrictEqual compare class instances by +// reference: two instances, an instance and the same instance read back +// through an untyped value, and an instance a method returns. Symbols +// compare by identity too. +const assert = require('assert'); +const EventEmitter = require('events'); + +class Box { + constructor(v) { + this.v = v; + } + self() { + return this; + } +} + +const b = new Box(1); +const c = new Box(1); +assert.strictEqual(b, b.self()); +assert.notStrictEqual(b, c); +const e = new EventEmitter(); +assert.strictEqual(e, e.setMaxListeners(1)); +const untyped = [e, b][0]; +assert.strictEqual(untyped, e); +assert.strictEqual(e, untyped); +assert.notStrictEqual(untyped, b); + +try { + assert.strictEqual(b, c); +} catch (error) { + console.log(error.code); + console.log(error.message); +} +try { + assert.notStrictEqual(b, b.self(), 'same box'); +} catch (error) { + console.log(error.code, error.message); +} +// A symbol read back through an untyped value is the same symbol. +const symbol = Symbol('s'); +const read = Reflect.get({ k: symbol }, 'k'); +assert.strictEqual(read, symbol); +assert.notStrictEqual(read, Symbol('s')); +try { + assert.strictEqual(read, Symbol('other')); +} catch (error) { + console.log(error.code, error.message.split('\n')[0]); +} +console.log('done'); diff --git a/tests/corpus/8078-instanceof-object-function.cjs b/tests/corpus/8078-instanceof-object-function.cjs new file mode 100644 index 000000000..22f212269 --- /dev/null +++ b/tests/corpus/8078-instanceof-object-function.cjs @@ -0,0 +1,77 @@ +'use strict'; +// `x instanceof Object` and `x instanceof Function` on untyped values follow +// Node: objects are instances of Object unless their prototype chain ends +// in null first, and functions (classes and arrows included) are instances +// of Function. +class Shape {} +class Square extends Shape {} + +const nullProto = Object.create(null); +const underNull = Object.create(nullProto); +const regrown = Object.create(null); +Object.setPrototypeOf(regrown, {}); +const orphan = function orphan() {}; +Object.setPrototypeOf(orphan, null); +function declared() {} + +const values = { + 'plain object': {}, + array: [1, 2], + 'Object.create(null)': nullProto, + 'object under a null-prototype object': underNull, + 'Object.create(null) given a prototype': regrown, + 'Object.create(plain object)': Object.create({ a: 1 }), + 'parsed JSON': JSON.parse('{"a":[1]}'), + 'function declaration': declared, + 'bound function': declared.bind(null), + 'arrow function': () => 1, + 'async function': async () => {}, + class: Square, + 'class instance': new Square(), + 'function with a null prototype': orphan, + map: new Map(), + date: new Date(0), + 'regular expression': /x/, + error: new TypeError('t'), + 'typed array': new Uint8Array(2), + promise: Promise.resolve(1), + number: 1, + string: 's', + boolean: false, + null: null, + undefined: undefined, + symbol: Symbol('s'), + bigint: 10n, +}; + +for (const [label, value] of Object.entries(values)) { + console.log(`${label}: Object ${value instanceof Object}, Function ${value instanceof Function}`); +} + +function describe(value) { + if (!(value instanceof Object)) return 'primitive or null-prototype'; + return value instanceof Function ? 'function' : 'object'; +} +console.log([nullProto, {}, describe, 0].map(describe).join(', ')); + +// Prototype chains through functions and objects, and Object.prototype +// itself, whose own prototype is null. +const base = function base() {}; +const fromFunction = {}; +Object.setPrototypeOf(fromFunction, base); +const throughObject = {}; +Object.setPrototypeOf(throughObject, fromFunction); +const nullRooted = function nullRooted() {}; +Object.setPrototypeOf(nullRooted, null); +const reparented = function reparented() {}; +Object.setPrototypeOf(reparented, fromFunction); +const chains = { + objectPrototype: Object.getPrototypeOf({}), + fromFunction, + throughObject, + nullRooted, + reparented, +}; +for (const [label, value] of Object.entries(chains)) { + console.log(`${label}: Object ${value instanceof Object}, Function ${value instanceof Function}`); +} diff --git a/tests/corpus/8114-record-field-slices.ts b/tests/corpus/8114-record-field-slices.ts new file mode 100644 index 000000000..110cd3acb --- /dev/null +++ b/tests/corpus/8114-record-field-slices.ts @@ -0,0 +1,139 @@ +// Slices stored in record fields: records built from a literal or a factory +// whose fields are only read for their length, comparisons and keyword +// searches, next to records that are kept after the iteration that built +// them (pushed into an array, held in an outer binding, captured by a +// closure, read into a kept array, returned from a helper, captured by a +// closure the factory stores beside it). Slices of non-ASCII text and slices +// longer than a short token are included. +interface Token { + kind: string; + text: string; + pos: number; +} + +const KEYWORDS = ["let", "const", "if", "else", "return"]; + +function token(kind: string, text: string, pos: number): Token { + return { kind, text, pos }; +} + +interface LazyToken { + text: string; + upper: () => string; +} + +function lazyToken(text: string): LazyToken { + return { text, upper: () => text.toUpperCase() }; +} + +function classify(text: string): string { + return KEYWORDS.includes(text) ? "keyword" : "ident"; +} + +function weight(t: Token): number { + return t.kind === "keyword" ? t.text.length * 3 : t.text.length + (t.pos & 7); +} + +function isReturn(t: Token): boolean { + return t.text === "return"; +} + +function textOf(t: Token): string { + return t.text; +} + +/** Calls `visit` with the start and end of every run of word characters. */ +function words(source: string, visit: (start: number, end: number) => void): void { + let start = -1; + for (let i = 0; i <= source.length; i++) { + const c = i < source.length ? source.charCodeAt(i) : 32; + const word = c > 32 && c !== 44 && c !== 59; + if (word) { + if (start < 0) start = i; + } else if (start >= 0) { + visit(start, i); + start = -1; + } + } +} + +function lentTokens(source: string): string { + let sum = 0; + let returns = 0; + let longest = 0; + for (let i = 0, start = -1; i <= source.length; i++) { + const c = i < source.length ? source.charCodeAt(i) : 32; + if (c > 32 && c !== 44 && c !== 59) { + if (start < 0) start = i; + continue; + } + if (start < 0) continue; + const text = source.slice(start, i); + const t = token(classify(text), text, start); + sum += weight(t); + if (isReturn(t)) returns++; + const literal = { text, pos: start }; + if (literal.text.length > longest) longest = literal.text.length; + start = -1; + } + return `lent ${sum} ${returns} ${longest}`; +} + +function keptTokens(source: string): string { + const kept: Token[] = []; + const texts: string[] = []; + const later: (() => string)[] = []; + const returned: string[] = []; + const uppers: (() => string)[] = []; + let lazyLength = 0; + let last: Token | null = null; + for (let i = 0, start = -1; i <= source.length; i++) { + const c = i < source.length ? source.charCodeAt(i) : 32; + if (c > 32 && c !== 44 && c !== 59) { + if (start < 0) start = i; + continue; + } + if (start < 0) continue; + const text = source.slice(start, i); + const pushed = token(classify(text), text, start); + kept.push(pushed); + const held = { kind: "held", text, pos: start }; + last = held; + const read = token("read", source.slice(start, i), start); + texts.push(read.text); + const captured = { kind: "captured", text: source.slice(start, i), pos: start }; + later.push(() => captured.text); + const viaHelper = token("helper", source.slice(start, i), start); + returned.push(textOf(viaHelper)); + const lazyText = source.slice(start, i); + const lazy = lazyToken(lazyText); + lazyLength += lazy.text.length; + uppers.push(lazy.upper); + start = -1; + } + return [ + `kept ${kept.map((t) => `${t.kind}:${t.text}`).join(" ")}`, + `last ${last === null ? "none" : last.text}`, + `texts ${texts.join("|")}`, + `later ${later.map((f) => f()).join("|")}`, + `returned ${returned.join("|")}`, + `upper ${lazyLength} ${uppers.map((f) => f()).join("|")}`, + ].join("\n"); +} + +const ascii = "let total = count; if (total) return total; else return 0;"; +const mixed = "lét naïve = café; return 東京, x"; +const long = `const ${"identifier".repeat(12)} = ${"v".repeat(150)};`; + +for (const source of [ascii, mixed, long]) { + console.log(lentTokens(source)); + console.log(keptTokens(source)); +} + +let count = 0; +words(ascii, (start, end) => { + const text = ascii.slice(start, end); + const t = token(classify(text), text, start); + count += weight(t); +}); +console.log("callback", count); diff --git a/tests/corpus/8116-destructured-results.ts b/tests/corpus/8116-destructured-results.ts new file mode 100644 index 000000000..71c884d49 --- /dev/null +++ b/tests/corpus/8116-destructured-results.ts @@ -0,0 +1,146 @@ +// Small functions returning a tuple or record that the caller destructures +// at once: string slices and numbers in the fields, a parameter that may be +// undefined at run time (a missing array element passed with `!`), fields +// the caller does not read, object destructuring in another order than the +// literal, arguments and fields with side effects, non-ASCII and astral +// receivers, slices that escape into arrays, closures and returns, bodies +// with loops and locals, a recursive producer, sites inside switch, try and +// loops, and an async caller. +const log: string[] = []; +function note(label: string, value: number): number { + log.push(label); + return value; +} + +function split(text: string, at: number): [string, string] { + return [text.slice(0, at), text.slice(at)]; +} + +function divmod(a: number, b: number): [number, number] { + return [Math.floor(a / b), a % b]; +} + +interface Name { + first: string; + last: string; +} + +function name(full: string): Name { + const space = full.indexOf(" "); + return { first: full.slice(0, space), last: full.slice(space + 1) }; +} + +function trimmed(text: string): [number, number] { + let start = 0; + let end = text.length; + while (start < end && text.charCodeAt(start) === 32) start++; + while (end > start && text.charCodeAt(end - 1) === 32) end--; + return [start, end]; +} + +function noted(a: number, b: number): [number, number] { + return [note("first field", a), note("second field", b)]; +} + +function digits(n: number): [number, string] { + if (n < 10) return [1, String(n)]; + const [count, rest] = digits(Math.floor(n / 10)); + return [count + 1, rest + String(n % 10)]; +} + +const texts = ["alpha", "be", "", "x", "héllo", "a😀b"]; +let total = 0; +const kept: string[] = []; +const later: (() => string)[] = []; +for (let i = 0; i < 40; i++) { + const [q, r] = divmod(i * 31 - 200, 7); + const [head, tail] = split(texts[i % texts.length]!, r % 4); + total += q + r + head.length * 2 + tail.length; + if (i % 9 === 0) kept.push(head); + if (i % 11 === 0) later.push(() => tail); +} +console.log("total", total); +console.log("kept", kept.join("|")); +console.log("later", later.map((f) => f()).join("|")); + +for (const [a, b] of [ + [7, 2], + [-7, 2], + [7.5, 2], + [1, 0], + [-1, 0], + [0, -3], +]) { + const [q, r] = divmod(a!, b!); + console.log("divmod", a, b, q, r, Object.is(r, -0)); +} +const [only] = divmod(17, 5); +console.log("only", only); + +const { last, first } = name("Ada Lovelace"); +console.log("name", first, last); +const { first: alone } = name("Grace Hopper"); +console.log("alone", alone); + +const [left, right] = trimmed(" padded text "); +console.log("trimmed", left, right); + +const [x, y] = noted(note("first argument", 1), note("second argument", 2)); +console.log("order", x, y, log.join(", ")); + +const [count, spelled] = digits(90210); +console.log("digits", count, spelled); + +for (const text of ["héllo", "a😀b", "日本語"]) { + for (const at of [0, 1, 2, 3, -1, 10]) { + const [head, tail] = split(text, at); + console.log("unicode", text, at, head, tail, head.length, tail.length); + } +} + +function tails(): string[] { + const out: string[] = []; + for (const text of texts) { + const [, tail] = split(text, 1); + out.push(tail); + } + return out; +} +console.log("tails", tails().join("|")); + +function lastTail(text: string): string { + const [, tail] = split(text, 2); + return tail; +} +console.log("lastTail", lastTail("returned")); + +for (const kind of ["one", "two", "other"]) { + switch (kind) { + case "one": { + const [h, t] = split("switch", 3); + console.log("switch", kind, h, t); + break; + } + case "two": { + const [q, r] = divmod(-9, 4); + console.log("switch", kind, q, r); + break; + } + default: + console.log("switch", kind); + } +} + +try { + const [head, tail] = split(texts[99]!, 1); + console.log("unreachable", head, tail); +} catch (error) { + console.log("missing", (error as Error).name, (error as Error).message); +} + +async function asyncCaller(text: string): Promise { + await Promise.resolve(); + const [head, tail] = split(text, 3); + return `${head}-${tail}`; +} +asyncCaller("awaited").then((value) => console.log("async", value)); diff --git a/tests/corpus/8117-scalar-slice-bounds.ts b/tests/corpus/8117-scalar-slice-bounds.ts new file mode 100644 index 000000000..412221368 --- /dev/null +++ b/tests/corpus/8117-scalar-slice-bounds.ts @@ -0,0 +1,25 @@ +// slice() and substring() results read only for their length and code +// units, so no string is built: ASCII, non-ASCII, astral and empty +// receivers, with integer, negative, fractional, -0, NaN, infinite and huge +// bounds, one bound or two, and substring's swapped bounds. +const receivers = ["", "a", "hello world", "héllo", "a😀b", "日本語テキスト"]; +const bounds = [0, 1, 2, -1, -2, -0, 0.5, -0.5, 3.7, -3.7, NaN, Infinity, -Infinity, 1e20, -1e20]; + +function sliceFacts(text: string, start: number, end: number): string { + const both = text.slice(start, end); + const from = text.slice(start); + const sub = text.substring(start, end); + const subFrom = text.substring(start); + return [both.length, both.charCodeAt(0), from.length, from.charCodeAt(from.length - 1), sub.length, sub.charCodeAt(0), subFrom.length].join(","); +} + +let lines = 0; +for (const text of receivers) { + const facts: string[] = []; + for (const start of bounds) { + for (const end of bounds) facts.push(sliceFacts(text, start, end)); + } + console.log(JSON.stringify(text), facts.length, facts.join(" ")); + lines++; +} +console.log("receivers", lines); diff --git a/tests/corpus/8176-tls-empty-context-ca.ts b/tests/corpus/8176-tls-empty-context-ca.ts new file mode 100644 index 000000000..510c9881a --- /dev/null +++ b/tests/corpus/8176-tls-empty-context-ca.ts @@ -0,0 +1,73 @@ +// An explicitly empty ca is an empty trust store for client certificates, +// in the server's options, in setSecureContext and in addContext, even when +// the client certificate's issuer is a default CA +// (tls.setDefaultCACertificates); an omitted ca trusts the default CAs. +// Each server has requestCert, with rejectUnauthorized false (the server's +// verdict) and true (the client is dropped). +import { readFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const r = (f: string): Buffer => readFileSync(`${D}/${f}`); +const ca = r("ca.pem"), cert = r("localhost.pem"), key = r("localhost-key.pem"); +const alt = r("alt.pem"), altKey = r("alt-key.pem"); +const client = r("client.pem"), clientKey = r("client-key.pem"); + +tls.setDefaultCACertificates([ca.toString()]); + +function listen(server: tls.Server): Promise { + return new Promise((res) => server.listen(0, () => res((server.address() as AddressInfo).port))); +} + +type Handler = (s: tls.TLSSocket) => void; + +async function run(label: string, make: (h: Handler) => tls.Server, servername: string): Promise { + const events: string[] = []; + const server = make((s) => { + events.push(`server authorized=${s.authorized} error=${s.authorizationError}`); + s.end("ok"); + }); + const port = await listen(server); + await new Promise((done) => { + const c = tls.connect({ port, servername, ca, cert: client, key: clientKey }); + c.on("secureConnect", () => events.push("client secureConnect")); + c.on("data", (d: Buffer) => events.push(`client data ${d}`)); + c.on("error", (e: NodeJS.ErrnoException) => events.push(`client error ${e.code}`)); + c.on("close", () => setTimeout(done, 50)); + }); + console.log(label, "|", events.join(" ; ")); + await new Promise((res) => server.close(() => res())); +} + +for (const reject of [false, true]) { + const opts = { cert, key, requestCert: true, rejectUnauthorized: reject }; + const tag = reject ? "reject" : "request"; + await run(`${tag} omitted`, (h) => tls.createServer(opts, h), "localhost"); + await run(`${tag} empty array`, (h) => tls.createServer({ ...opts, ca: [] }, h), "localhost"); + await run(`${tag} setSecureContext empty`, (h) => { + const server = tls.createServer(opts, h); + server.setSecureContext({ cert, key, ca: [] }); + return server; + }, "localhost"); + await run(`${tag} setSecureContext omitted`, (h) => { + const server = tls.createServer({ ...opts, ca: [] }, h); + server.setSecureContext({ cert, key }); + return server; + }, "localhost"); + await run(`${tag} addContext empty array`, (h) => { + const server = tls.createServer(opts, h); + server.addContext("alt.localhost", { cert: alt, key: altKey, ca: [] }); + return server; + }, "alt.localhost"); + await run(`${tag} addContext empty Buffer`, (h) => { + const server = tls.createServer(opts, h); + server.addContext("alt.localhost", tls.createSecureContext({ cert: alt, key: altKey, ca: Buffer.alloc(0) })); + return server; + }, "alt.localhost"); + await run(`${tag} addContext omitted`, (h) => { + const server = tls.createServer({ ...opts, ca: [] }, h); + server.addContext("alt.localhost", { cert: alt, key: altKey }); + return server; + }, "alt.localhost"); +} diff --git a/tests/corpus/8177-tls-empty-client-ca.ts b/tests/corpus/8177-tls-empty-client-ca.ts new file mode 100644 index 000000000..0fb596ddb --- /dev/null +++ b/tests/corpus/8177-tls-empty-client-ca.ts @@ -0,0 +1,104 @@ +// An explicitly empty ca is an empty trust store for a client's view of +// the server certificate, in tls.connect, https.request and http2.connect, +// even when the server certificate's issuer is a default CA +// (tls.setDefaultCACertificates); an omitted ca, or an empty string, trusts +// the default CAs. +import { readFileSync } from "node:fs"; +import * as http2 from "node:http2"; +import * as https from "node:https"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const r = (f: string): Buffer => readFileSync(`${D}/${f}`); +const ca = r("ca.pem"), key = r("localhost-key.pem"); +// The servers send their issuer too, so each client sees the same chain. +const cert = Buffer.concat([r("localhost.pem"), ca]); + +tls.setDefaultCACertificates([ca.toString()]); + +function listen(server: tls.Server | https.Server | http2.Http2SecureServer): Promise { + return new Promise((res) => server.listen(0, () => res((server.address() as AddressInfo).port))); +} + +async function watch(label: string, c: tls.TLSSocket): Promise { + const events: string[] = []; + await new Promise((done) => { + c.on("secureConnect", () => { + events.push(`secureConnect authorized=${c.authorized} error=${c.authorizationError}`); + c.end(); + }); + c.on("error", (e: NodeJS.ErrnoException) => events.push(`error ${e.code}`)); + c.on("close", () => done()); + }); + console.log("tls.connect", label, "|", events.join(" ; ")); +} + +{ + const server = tls.createServer({ cert, key }, (s) => s.end()); + const port = await listen(server); + const host = "localhost"; + await watch("omitted", tls.connect({ port, host })); + await watch("empty string", tls.connect({ port, host, ca: "" })); + await watch("empty Buffer", tls.connect({ port, host, ca: Buffer.alloc(0) })); + await watch("empty array", tls.connect({ port, host, ca: [] })); + await watch( + "empty Buffer, rejectUnauthorized false", + tls.connect({ port, host, ca: Buffer.alloc(0), rejectUnauthorized: false }), + ); + await watch("empty array, rejectUnauthorized false", tls.connect({ port, host, ca: [], rejectUnauthorized: false })); + server.close(); +} + +function report(label: string, req: ReturnType, done: () => void): void { + req.on("response", (res) => { + console.log("https.request", label, "| status", res.statusCode); + res.resume(); + res.on("end", done); + }); + req.on("error", (e: NodeJS.ErrnoException) => { + console.log("https.request", label, "| error", e.code); + done(); + }); + req.end(); +} + +{ + const server = https.createServer({ cert, key }, (_req, res) => res.end("ok")); + const port = await listen(server); + await new Promise((done) => + report("omitted", https.request({ host: "localhost", port, path: "/", agent: false }), done), + ); + await new Promise((done) => + report("empty string", https.request({ host: "localhost", port, path: "/", agent: false, ca: "" }), done), + ); + await new Promise((done) => + report( + "empty Buffer", + https.request({ host: "localhost", port, path: "/", agent: false, ca: Buffer.alloc(0) }), + done, + ), + ); + const options: unknown = { host: "localhost", port, path: "/", agent: false, ca: [] }; + await new Promise((done) => + report("empty array in a runtime record", https.request(options as https.RequestOptions), done), + ); + server.close(); +} + +{ + const server = http2.createSecureServer({ cert, key }, (_req, res) => res.end("ok")); + const port = await listen(server); + for (const [label, ca] of [["omitted", undefined], ["empty Buffer", Buffer.alloc(0)]] as const) { + await new Promise((done) => { + const session = ca === undefined ? http2.connect(`https://localhost:${port}`) : http2.connect(`https://localhost:${port}`, { ca }); + session.on("connect", () => { + console.log("http2.connect", label, "| connect"); + session.close(); + }); + session.on("error", (e: NodeJS.ErrnoException) => console.log("http2.connect", label, "| error", e.code)); + session.on("close", () => done()); + }); + } + server.close(); +} diff --git a/tests/corpus/8179-tls-reject-unauthorized-values.mjs b/tests/corpus/8179-tls-reject-unauthorized-values.mjs new file mode 100644 index 000000000..4e87099d4 --- /dev/null +++ b/tests/corpus/8179-tls-reject-unauthorized-values.mjs @@ -0,0 +1,55 @@ +// rejectUnauthorized is off only when it is false, as in Node: on a server +// with requestCert, null, 0, "" and undefined keep rejecting clients that +// send no certificate, and in tls.connect they keep rejecting a server +// certificate that does not verify. +import { readFileSync } from "node:fs"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const r = (f) => readFileSync(`${D}/${f}`); +const ca = r("ca.pem"), cert = r("localhost.pem"), key = r("localhost-key.pem"); +const self = r("selfsigned.pem"), selfKey = r("selfsigned-key.pem"); + +const values = [ + ["false", false], + ["true", true], + ["null", null], + ["0", 0], + ['""', ""], + ["undefined", undefined], +]; + +for (const [label, value] of values) { + const events = []; + const server = tls.createServer({ cert, key, requestCert: true, rejectUnauthorized: value }, (s) => { + events.push(`server authorized=${s.authorized} error=${s.authorizationError}`); + s.end("ok"); + }); + const port = await new Promise((res) => server.listen(0, () => res(server.address().port))); + await new Promise((done) => { + const c = tls.connect({ port, ca, servername: "localhost" }); + c.on("secureConnect", () => events.push("client secureConnect")); + c.on("data", (d) => events.push(`client data ${d}`)); + c.on("error", (e) => events.push(`client error ${e.code}`)); + c.on("close", () => setTimeout(() => done(undefined), 50)); + }); + console.log("rejectUnauthorized", label, "|", events.join(" ; ")); + await new Promise((res) => server.close(() => res(undefined))); +} + +{ + const server = tls.createServer({ cert: self, key: selfKey }, (s) => s.end("ok")); + const port = await new Promise((res) => server.listen(0, () => res(server.address().port))); + for (const [label, value] of values) { + const events = []; + await new Promise((done) => { + const c = tls.connect({ port, servername: "localhost", rejectUnauthorized: value }); + c.on("secureConnect", () => events.push(`secureConnect authorized=${c.authorized} error=${c.authorizationError}`)); + c.on("data", (d) => events.push(`data ${d}`)); + c.on("error", (e) => events.push(`error ${e.code}`)); + c.on("close", () => done(undefined)); + }); + console.log("tls.connect rejectUnauthorized", label, "|", events.join(" ; ")); + } + server.close(); +} diff --git a/tests/corpus/8181-map-get-borrows.ts b/tests/corpus/8181-map-get-borrows.ts new file mode 100644 index 000000000..1a6c71667 --- /dev/null +++ b/tests/corpus/8181-map-get-borrows.ts @@ -0,0 +1,172 @@ +// Map.get results held in locals across the operations that can and cannot +// release the entry's value: an update on the absent branch, a write, delete +// or clear before a later read, a call that may change the map, loops, +// string keys, values that are stored, returned or compared, a local declared +// from the read, a read from a temporary map, and a key that replaces the +// field holding the map. +class Counter { + num: number; + label: string; + constructor(num: number, label: string) { + this.num = num; + this.label = label; + } +} + +function count(keys: number[]): Map { + const counts = new Map(); + for (const key of keys) { + const cur = counts.get(key); + if (cur === undefined) counts.set(key, new Counter(1, `k${key}`)); + else cur.num++; + } + return counts; +} + +function report(counts: Map): string { + const parts: string[] = []; + for (const [key, value] of counts) parts.push(`${key}:${value.num}:${value.label}`); + return parts.join(" "); +} + +console.log("count", report(count([3, 1, 3, 2, 3, 1, 9]))); + +// A write replaces the entry's value while the old one is still read. +function replaceThenRead(m: Map): string { + const old = m.get(1); + m.set(1, new Counter(100, "new")); + return old === undefined ? "none" : `${old.num}:${old.label}`; +} +const replaced = new Map([[1, new Counter(5, "old")]]); +console.log("replace", replaceThenRead(replaced), replaced.get(1)!.label); + +// Deleting and clearing release the value before the read. +function deleteThenRead(m: Map): string { + const held = m.get(2); + m.delete(2); + return held === undefined ? "none" : held.label; +} +function clearThenRead(m: Map): string { + const held = m.get(2); + m.clear(); + return held === undefined ? "none" : `${held.label}:${m.size}`; +} +console.log("delete", deleteThenRead(new Map([[2, new Counter(1, "two")]]))); +console.log("clear", clearThenRead(new Map([[2, new Counter(1, "two")]]))); + +// A call that may change the map runs between the read and the use. +let shared = new Map([[7, new Counter(7, "seven")]]); +function mutate(): void { + shared.set(7, new Counter(70, "seventy")); + shared = new Map(); +} +function callBetween(): string { + const held = shared.get(7); + mutate(); + return held === undefined ? "none" : `${held.num}:${held.label}`; +} +console.log("call", callBetween()); + +// Reads on both branches, a write on one of them, and a read after the join. +function branches(m: Map, k: number, write: boolean): string { + const held = m.get(k); + if (held === undefined) return "absent"; + if (write) m.set(k, new Counter(0, "zero")); + else held.num += 10; + return `${held.num}:${held.label}`; +} +const branchMap = new Map([[4, new Counter(4, "four")]]); +console.log("branches", branches(branchMap, 4, false), branches(branchMap, 4, true), branches(branchMap, 5, true)); + +// The value escapes into an array and a returned object. +function escape(m: Map): Counter[] { + const kept: Counter[] = []; + for (let k = 0; k < 4; k++) { + const value = m.get(k % 2); + if (value !== undefined) kept.push(value); + } + m.clear(); + return kept; +} +const escapes = escape(new Map([[0, new Counter(0, "zero")], [1, new Counter(1, "one")]])); +console.log("escape", escapes.map((c) => c.label).join(",")); +function returned(m: Map): Counter | undefined { + const value = m.get(3); + return value; +} +const returnMap = new Map([[3, new Counter(3, "three")]]); +const got = returned(returnMap); +returnMap.clear(); +console.log("returned", got?.label); + +// String keys, including keys built at run time. +function words(text: string): string { + const seen = new Map(); + for (const word of text.split(" ")) { + const entry = seen.get(word); + if (entry === undefined) seen.set(word, new Counter(1, word.toUpperCase())); + else entry.num++; + } + const parts: string[] = []; + for (const [word, entry] of seen) parts.push(`${word}=${entry.num}/${entry.label}`); + return parts.join(" "); +} +console.log("words", words("a b a c b a")); +const prefix = "k"; +const byName = new Map([["k1", new Counter(1, "one")]]); +const named = byName.get(prefix + "1"); +byName.set("k1", new Counter(2, "two")); +console.log("named", named?.label, byName.get("k1")?.label); + +// A loop whose body writes the map after the read on every pass. +function loopWrites(m: Map): number { + let total = 0; + for (let i = 0; i < 5; i++) { + const value = m.get(i % 2); + if (value !== undefined) total += value.num; + m.set(i % 2, new Counter(i, "loop")); + if (value !== undefined) total += value.num * 100; + } + return total; +} +console.log("loop", loopWrites(new Map([[0, new Counter(1, "a")], [1, new Counter(2, "b")]]))); + +// A local declared from the read is read after a write that replaces the +// entry; it reads the value the map held when the local was declared. +function aliasThenWrite(m: Map): string { + const held = m.get(1); + const copy = held; + m.set(1, new Counter(10, "ten")); + m.delete(1); + const filler = [new Counter(11, "filler"), new Counter(12, "filler")]; + return copy === undefined ? "none" : `${copy.num}:${copy.label}:${filler.length}`; +} +console.log("alias", aliasThenWrite(new Map([[1, new Counter(1, "one")]]))); + +// A read from a map that no local holds: the map is released after the +// declaration, and the value stays readable. +function freshMap(): Map { + return new Map([[1, new Counter(5, "five")]]); +} +function fromTemporary(): string { + const held = freshMap().get(1); + const filler = [new Counter(6, "filler"), new Counter(7, "filler")]; + return held === undefined ? "none" : `${held.num}:${held.label}:${filler.length}`; +} +console.log("temporary", fromTemporary()); + +// A key whose evaluation replaces the field that holds the map: the read +// uses the old map, which only the statement still holds. +class Holder { + m = new Map([[1, new Counter(9, "nine")]]); + key(): number { + this.m = new Map(); + return 1; + } + read(): string { + const held = this.m.get(this.key()); + const filler = [new Counter(10, "filler"), new Counter(11, "filler")]; + return held === undefined ? "none" : `${held.num}:${held.label}:${filler.length}:${this.m.size}`; + } +} +console.log("replaced by key", new Holder().read()); diff --git a/tests/corpus/8181-x509-raw-writes.ts b/tests/corpus/8181-x509-raw-writes.ts new file mode 100644 index 000000000..17704096d --- /dev/null +++ b/tests/corpus/8181-x509-raw-writes.ts @@ -0,0 +1,20 @@ +// A certificate's raw Buffer is the same object at every read, and writing +// into it changes neither the certificate's fields, checks and PEM nor its +// public key, as in Node: the methods read the certificate itself. +import { X509Certificate } from "node:crypto"; +import { readFileSync } from "node:fs"; + +const cert = new X509Certificate(readFileSync("tests/fixtures/server/certs/localhost.pem")); +const ca = new X509Certificate(readFileSync("tests/fixtures/server/certs/ca.pem")); +const raw = cert.raw; +console.log("same Buffer", raw === cert.raw); +const report = (): string => + `${cert.fingerprint256}\n${cert.subject}\n${cert.checkHost("localhost")}\n` + + `${cert.checkIssued(ca)} ${ca.checkIssued(cert)} ${cert.verify(ca.publicKey)}\n` + + `${cert.toString()}\n${cert.publicKey.export({ format: "pem", type: "spki" })}`; +const before = report(); +raw.fill(0); +console.log("unchanged", report() === before); +console.log("raw now", cert.raw.every((b) => b === 0), cert.raw.length); +const other = new X509Certificate(readFileSync("tests/fixtures/server/certs/localhost.pem")); +console.log("fresh certificate raw", other.raw[0], other.raw.equals(cert.raw)); diff --git a/tests/corpus/8182-typed-array-alias-scopes.ts b/tests/corpus/8182-typed-array-alias-scopes.ts new file mode 100644 index 000000000..f56face07 --- /dev/null +++ b/tests/corpus/8182-typed-array-alias-scopes.ts @@ -0,0 +1,134 @@ +// Loops that read one typed array and write another, where the two views +// may share bytes: overlapping and disjoint views of one buffer, the same +// array passed twice, views of different element types over one buffer, +// and empty views, and passes whose views an enclosing loop swaps. A loop +// over views that overlap must see its own writes, and each pass the +// previous pass's writes. +function shiftAdd(dst: Float64Array, src: Float64Array, n: number): void { + for (let i = 0; i < n; i++) dst[i] = src[i]! + 1; +} + +function mixed(bytes: Uint8Array, words: Float64Array, n: number): number { + let sum = 0; + for (let i = 0; i < n; i++) { + words[i] = i * 0.5 + 1; + sum += bytes[i]!; + } + return sum; +} + +function lanes(zr: Float64Array, zi: Float64Array, tr: Float64Array, ti: Float64Array): number { + for (let step = 0; step < 6; step++) + for (let k = 0; k < 4; k++) { + const r = zr[k]!; + const i = zi[k]!; + zi[k] = 2 * r * i + 0.25; + zr[k] = tr[k]! - ti[k]! - 0.5; + tr[k] = zr[k]! * zr[k]!; + ti[k] = zi[k]! * zi[k]!; + } + let sum = 0; + for (let k = 0; k < 4; k++) sum += zr[k]! + zi[k]! + tr[k]! + ti[k]!; + return sum; +} + +function show(a: Float64Array): string { + const parts: string[] = []; + for (let i = 0; i < a.length; i++) parts.push(a[i]!.toFixed(3)); + return parts.join(","); +} + +// Disjoint arrays. +const a = new Float64Array([1, 2, 3, 4]); +const b = new Float64Array(4); +shiftAdd(b, a, 4); +console.log("disjoint", show(b)); + +// One array as both views: each element reads its own old value. +const same = new Float64Array([1, 2, 3, 4]); +shiftAdd(same, same, 4); +console.log("same", show(same)); + +// Overlapping views of one buffer: the destination starts one element +// after the source, so every read sees the previous iteration's write. +const buffer = new ArrayBuffer(8 * 8); +const whole = new Float64Array(buffer); +whole.fill(1); +shiftAdd(new Float64Array(buffer, 8, 6), new Float64Array(buffer, 0, 6), 6); +console.log("overlap forward", show(whole)); +whole.fill(1); +shiftAdd(new Float64Array(buffer, 0, 6), new Float64Array(buffer, 8, 6), 6); +console.log("overlap backward", show(whole)); + +// Adjacent views of one buffer share no bytes. +whole.set([1, 2, 3, 4, 5, 6, 7, 8]); +shiftAdd(new Float64Array(buffer, 32, 4), new Float64Array(buffer, 0, 4), 4); +console.log("adjacent", show(whole)); + +// A byte view over the doubles it writes. +const shared = new ArrayBuffer(64); +console.log("mixed", mixed(new Uint8Array(shared), new Float64Array(shared), 8), show(new Float64Array(shared))); +console.log("mixed disjoint", mixed(new Uint8Array(new ArrayBuffer(64)), new Float64Array(8), 8)); + +// Empty views. +shiftAdd(new Float64Array(0), new Float64Array(0), 0); +console.log("empty", new Float64Array(0).length); + +// Four lanes in separate arrays, and the same arrays aliased. +const lane = (): Float64Array => new Float64Array([0.1, -0.2, 0.3, -0.4]); +console.log("lanes", lanes(lane(), lane(), lane(), lane()).toFixed(6)); +const one = lane(); +console.log("lanes aliased", lanes(one, one, lane(), lane()).toFixed(6)); +const pool = new Float64Array(16); +pool.set([0.1, -0.2, 0.3, -0.4, 0.1, -0.2, 0.3, -0.4, 0.1, -0.2, 0.3, -0.4, 0.1, -0.2, 0.3, -0.4]); +console.log( + "lanes subarrays", + lanes(pool.subarray(0, 4), pool.subarray(4, 8), pool.subarray(8, 12), pool.subarray(12, 16)).toFixed(6), +); +console.log( + "lanes overlapping", + lanes(pool.subarray(0, 4), pool.subarray(2, 6), pool.subarray(8, 12), pool.subarray(9, 13)).toFixed(6), +); + +// Ping-pong passes: an enclosing loop swaps the two views after every pass, +// so each pass reads what the previous one wrote. +function pingPongPairs(a: Float64Array, b: Float64Array, steps: number): string { + let cur = a; + let next = b; + for (let s = 0; s < steps; s++) { + for (let i = 0; i < 2; i++) next[i] = cur[i]! + cur[1 - i]! * 2 + s; + const t = cur; + cur = next; + next = t; + } + return Array.from(cur).join(","); +} +function pingPongWords(a: Int32Array, b: Int32Array): string { + let cur = a; + let next = b; + for (let s = 0; s < 4; s++) { + for (let i = 0; i < 4; i++) next[i] = (cur[i]! * 3 + cur[(i + 1) & 3]! + 1) | 0; + const t = cur; + cur = next; + next = t; + } + return Array.from(cur).join(","); +} +function pingPongRows(a: Float64Array, b: Float64Array, steps: number): number { + let cur = a; + let next = b; + for (let s = 0; s < steps; s++) { + for (let i = 0; i < cur.length; i++) next[i] = cur[i]! * 0.5 + 1; + const t = cur; + cur = next; + next = t; + } + let sum = 0; + for (let i = 0; i < cur.length; i++) sum += cur[i]!; + return sum; +} +for (let k = 0; k < 3; k++) { + console.log("ping-pong pairs", pingPongPairs(new Float64Array([1, 2]), new Float64Array(2), 3 + k)); + console.log("ping-pong words", pingPongWords(new Int32Array([1, 2, 3, 4]), new Int32Array(4))); +} +console.log("ping-pong rows", pingPongRows(new Float64Array(37).fill(8), new Float64Array(37), 9)); diff --git a/tests/corpus/8190-assert-function-values.cjs b/tests/corpus/8190-assert-function-values.cjs new file mode 100644 index 000000000..f27bf22dc --- /dev/null +++ b/tests/corpus/8190-assert-function-values.cjs @@ -0,0 +1,45 @@ +'use strict'; +// assert methods are function values: stored, passed and called through +// aliases, they assert as the methods do, and every read is the same +// function. A constant bound to a method and called directly reports +// the call as Node quotes it. +const assert = require('assert'); +const { strictEqual, deepStrictEqual } = require('assert'); +const strict = require('assert/strict'); +const EventEmitter = require('events'); +console.log(typeof assert.fail, assert.fail === assert.fail, strict.equal === assert.strictEqual, strictEqual === assert.strictEqual); +const ok = assert.ok; +ok(1); +const tryIt = (label, fn) => { try { fn(); console.log(label, 'passed'); } catch (e) { console.log(label, e.name, e.code, JSON.stringify(e.message), e.generatedMessage); } }; +tryIt('ok', () => ok(0)); +tryIt('ok none', () => ok()); +tryIt('ok msg', () => ok(false, 'nope')); +tryIt('ok err', () => ok(false, new TypeError('own'))); +tryIt('fail', () => assert.fail.call(null)); +tryIt('fail msg', () => (0, assert.fail)('boom')); +tryIt('equal', () => assert.equal.call(null, 1, '1')); +tryIt('equal fail', () => [assert.equal][0](1, 2)); +tryIt('notEqual fail', () => [assert.notEqual][0](1, '1')); +tryIt('strict', () => strictEqual(1, 2)); +tryIt('deep', () => deepStrictEqual({ a: 1 }, { a: 1 })); +tryIt('deep fail', () => [deepStrictEqual][0]({ a: 1 }, { a: 2 }, 'differs')); +tryIt('ifError', () => [assert.ifError][0](null)); +const checks = [assert.ok, assert.fail, assert.strictEqual]; +checks.forEach((check, i) => console.log(i, typeof check, check === checks[i], check.name)); +[1, 'x', true].forEach((value) => assert.ok(value)); +console.log('done'); + +// The comparison methods need both operands, as Node's ERR_MISSING_ARGS +// TypeError says, also through function values. +const comparisons = [assert.equal, assert.notEqual, assert.strictEqual, assert.notStrictEqual, + assert.deepEqual, assert.notDeepEqual, assert.deepStrictEqual, assert.notDeepStrictEqual]; +for (const compare of comparisons) { + for (const args of [[], [1]]) { + try { + compare(...args); + console.log(compare.name, args.length, 'passed'); + } catch (e) { + console.log(compare.name, args.length, e.name, e.code, e.message); + } + } +} diff --git a/tests/corpus/8191-assert-function-values.ts b/tests/corpus/8191-assert-function-values.ts new file mode 100644 index 000000000..8ef24153b --- /dev/null +++ b/tests/corpus/8191-assert-function-values.ts @@ -0,0 +1,16 @@ +// assert methods read as values in TypeScript: a constant alias called +// directly, a stored value, and a method passed as a callback. +import assert from "node:assert"; +const eq = assert.strictEqual; +eq(1, 1); +const fail = assert.fail; +const checks: unknown[] = [assert.ok, assert.fail]; +console.log(typeof checks[0], checks[1] === fail); +try { + eq(1, 2, "one is not two"); +} catch (error) { + console.log((error as Error).message); +} +const run = (fn: (v: unknown) => void) => fn(true); +run(assert.ok); +console.log("done"); diff --git a/tests/corpus/8193-counted-loop-products.ts b/tests/corpus/8193-counted-loop-products.ts new file mode 100644 index 000000000..d347aee76 --- /dev/null +++ b/tests/corpus/8193-counted-loop-products.ts @@ -0,0 +1,59 @@ +// Nested counted loops whose bounds arrive at run time, with the counters' +// products reaching bitwise operators. Starts and limits take the integer +// copy or the double copy: negative, fractional, NaN, -0, and starts or +// limits past 2^25, past which the guarded copy no longer proves products +// exact. +function a(i: number, j: number): number { + return (((i + j) * (i + j + 1)) >>> 1) + i + 1; +} + +function rows(start: number, end: number, n: number): number[] { + const out: number[] = []; + for (let i = start; i < end; i++) { + let t = 0; + for (let j = 0; j < n; j++) t += 1 / a(i, j); + out.push(t); + } + return out; +} + +function mixed(start: number, end: number, n: number): number { + let h = 0; + for (let i = start; i < end; i++) { + for (let j = start; j < n; j++) h = (h + ((i - j) * (j - i + 3) | 0)) | 0; + } + return h; +} + +function wide(start: number, count: number): number[] { + const out: number[] = []; + for (let i = start; i < start + count; i++) out.push(((i * (i + 1)) >>> 0) ^ (i * i) | 0); + return out; +} + +const args = process.argv.slice(2).map(Number); +const big = 2 ** 25; +for (const [start, end, n] of [ + [0, 4, 6], + [-3, 2, 5], + [1.5, 4, 3], + [-0, 2, 2], + [NaN, 2, 2], + [big - 2, big + 2, 3], + [0, 3, args.length > 0 ? big + 1 : 4], +]) { + console.log(start, end, n, rows(start!, end!, Math.min(n!, 8)).map((x) => x.toFixed(12)).join(" ")); + console.log("mixed", mixed(start!, end!, Math.min(n!, 8))); +} +for (const start of [0, -5, 2 ** 26, 2 ** 31 - 2, 2 ** 52, -(2 ** 40), 0.5]) console.log(start, wide(start, 4).join(" ")); +console.log(Object.is(((-0 * 5) >>> 0), 0), a(-1, 0), a(0, -1), a(2 ** 30, 2 ** 30)); + +// Single-expression functions inlined at calls that pass literals and +// locals, also read as values and called with other arguments. +function mix(x: number, y: number): number { + return ((x * 31 + y) ^ (y << 3)) % 1000; +} +const table = [mix, (x: number, y: number) => x - y]; +let acc = 0; +for (let k = 0; k < 5; k++) acc += mix(k, 7) + mix(3, k) + table[k % 2]!(k, 2) + mix(k * 2, args.length); +console.log(acc, mix(2 ** 40, -0), mix(NaN, 1), mix(Infinity, 2), [1, 2, 3].map((v) => mix(v, v)).join()); diff --git a/tests/corpus/8194-row-pair-sums.ts b/tests/corpus/8194-row-pair-sums.ts new file mode 100644 index 000000000..f28242ff5 --- /dev/null +++ b/tests/corpus/8194-row-pair-sums.ts @@ -0,0 +1,90 @@ +// Row sums (`for i { t = 0; for j t += term; out[i] = t }`) over typed +// arrays, run with even and odd row counts, an empty range, fractional, +// negative-zero and out-of-int32 bounds, and output arrays that are the +// input array or overlap it in one buffer, so a row may read an earlier +// row's result. +function rows(u: Float64Array, v: Float64Array, start: number, end: number, n: number): void { + for (let i = start; i < end; i++) { + let t = 0; + for (let j = 0; j < n; j++) t += u[j]! / ((((i + j) * (i + j + 1)) >>> 1) + i + 1); + v[i] = t; + } +} + +function weighted(u: Float32Array, w: Int32Array, v: Float64Array, start: number, end: number): void { + for (let i = start; i < end; i++) { + let t = 0.5; + for (let j = 0; j < u.length; j++) t += u[j]! * w[(i + j) % w.length]! - i * 0.25; + v[i] = t; + } +} + +const show = (label: string, v: Float64Array): void => { + const parts: string[] = []; + for (const x of v) parts.push(Object.is(x, -0) ? "-0" : x.toPrecision(17)); + console.log(label, parts.join(" ")); +}; + +const n = Number(process.argv[2] ?? 7); +const u = new Float64Array(n).map((_, k) => k + 1); +for (const [start, end] of [ + [0, n], + [0, n - 1], + [1, n], + [2, 2], + [3, 1], + [-0, 3], + [0.5, 4], + [0, 4.5], + [2 ** 31 - 3, 2 ** 31 + 1], + [NaN, 3], +]) { + const v = new Float64Array(n + 2); + rows(u, v, start!, end!, n); + show(`rows ${start} ${end}`, v); +} + +// The output is the input: each row reads the rows stored before it. +const same = new Float64Array(n).map((_, k) => k + 1); +rows(same, same, 0, n, n); +show("same", same); + +// Overlapping views of one buffer, offset by one element. +const buffer = new ArrayBuffer(8 * (n + 1)); +const lo = new Float64Array(buffer, 0, n).map((_, k) => k + 1); +const hi = new Float64Array(buffer, 8, n); +rows(lo, hi, 0, n, n); +show("overlap", new Float64Array(buffer)); + +// Disjoint views of one buffer. +const shared = new ArrayBuffer(16 * n); +const a = new Float64Array(shared, 0, n).map((_, k) => n - k); +const b = new Float64Array(shared, 8 * n, n); +rows(a, b, 0, n, n); +show("disjoint", b); + +const f = new Float32Array(n).map((_, k) => k / 3); +const w = new Int32Array([3, -1, 4, 1, -5]); +const out = new Float64Array(n); +weighted(f, w, out, 0, n); +show("weighted", out); +weighted(f, w, out, 1, n - 1); +show("weighted inner", out); + +// Arrays a program assigns only when the row range is nonempty: an empty or +// one-row range never reads them, and the paired copy does not either. +function lazyRows(n: number, start: number, end: number): number { + let u!: Float64Array; + let v!: Float64Array; + if (end > start) { + u = new Float64Array(n).fill(1); + v = new Float64Array(end); + } + for (let i = start; i < end; i++) { + let t = 0; + for (let j = 0; j < n; j++) t += u[j]! / (i + j + 1); + v[i] = t; + } + return end > start ? v[end - 1]! : -1; +} +console.log("lazy", lazyRows(4, 3, 3), lazyRows(4, 2, 3), lazyRows(4, 0, 5), lazyRows(0, 5, 5)); diff --git a/tests/corpus/8195-wrapping-products-near-2-53.ts b/tests/corpus/8195-wrapping-products-near-2-53.ts new file mode 100644 index 000000000..c42104290 --- /dev/null +++ b/tests/corpus/8195-wrapping-products-near-2-53.ts @@ -0,0 +1,25 @@ +// Products that reach bitwise operators with operands whose ranges are +// known, around 2^53: below it the double product is exact, and from it on +// the double product rounds, so the 32-bit result comes from the rounded +// value (3 * 3002399751580331 is 2^53 + 1, which rounds to 2^53). +const pairs: [number, number][] = [ + [3, 3002399751580331], + [-3, 3002399751580331], + [2 ** 26, 2 ** 27], + [94906265, 94906267], + [94906267, 94906267], + [7, 1286742750677284], + [65535, 137443147777], +]; +for (const [a, b] of pairs) console.log(a, b, (a * b) | 0, (a * b) >>> 0, (a * b) ^ 1, (a * b) << 3); + +// The same products with literal operands, which the compiler sees whole. +console.log((3 * 3002399751580331) | 0, (3 * 3002399751580331) >>> 0); +console.log((-3 * 3002399751580331) | 0, (2 ** 26 * 2 ** 27) | 0); +console.log((94906267 * 94906267) | 0, (94906265 * 94906267) >>> 0); + +// A counted loop whose counter multiplies a constant past 2^53. +const step = 1286742750677284; +let h = 0; +for (let k = 5; k < 9; k++) h = (h + ((k * step) | 0)) | 0; +console.log(h); diff --git a/tests/corpus/8196-counted-loops-integer-starts.ts b/tests/corpus/8196-counted-loops-integer-starts.ts new file mode 100644 index 000000000..d608bc4b5 --- /dev/null +++ b/tests/corpus/8196-counted-loops-integer-starts.ts @@ -0,0 +1,50 @@ +// Counted, paired and dense array loops whose start is an int32 value +// (an int32 array element, `p0 | 0` or one minus either): starts at -1, 0, +// inside the array, at its end, and at the int32 extremes, where the loop +// must not run or must stop at the array's end as Node does. Number arrays +// and an Int32Array both run their paired loops. +function reverse(pp: number[], p0: number): number { + let swaps = 0; + for (let i = 1, j = (p0 | 0) - 1; i < j; i++, j--) { + const t = pp[i]!; + pp[i] = pp[j]!; + pp[j] = t; + swaps++; + } + return swaps; +} + +function reverseBytes(values: Int32Array, p0: number): number { + let swaps = 0; + for (let i = 0, j = (p0 | 0) - 1; i < j; i++, j--) { + const t = values[i]!; + values[i] = values[j]!; + values[j] = t; + swaps++; + } + return swaps; +} + +function sumFrom(values: number[], start: number): number { + let total = 0; + for (let k = start - 1; k < values.length; k++) total += values[k] ?? 100; + return total; +} + +function countdown(values: Int32Array, from: number): number { + let total = 0; + for (let k = from; k >= 0; k--) total += values[k] ?? 1000; + return total; +} + +const starts: number[] = [0, 1, 2, 5, 9, 10, 11, 2147483647, -2147483648]; +const ints = new Int32Array(starts); +for (let s = 0; s < ints.length; s++) { + const p0 = ints[s]!; + const pp = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + const swaps = p0 <= 11 ? reverse(pp, p0) : 0; + const bytes = new Int32Array(pp); + const byteSwaps = p0 <= 11 ? reverseBytes(bytes, p0) : 0; + console.log(p0, swaps, pp.join(""), p0 <= 11 && p0 >= -1 ? sumFrom(pp, p0) : "-", p0 <= 11 ? countdown(ints, p0) : "-"); + console.log(" bytes", byteSwaps, bytes.join("")); +} diff --git a/tests/corpus/8214-unshimmed-package-version/main.ts b/tests/corpus/8214-unshimmed-package-version/main.ts new file mode 100644 index 000000000..d582a339b --- /dev/null +++ b/tests/corpus/8214-unshimmed-package-version/main.ts @@ -0,0 +1,5 @@ +// A package that scriptc implements at some major versions (chalk 5) +// compiles from its own source at a version without an implementation. +import chalk, { level } from "chalk"; + +console.log(chalk.version, level, chalk.red("red"), chalk.bold("bold")); diff --git a/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/package.json b/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/package.json new file mode 100644 index 000000000..7ab814eb0 --- /dev/null +++ b/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/package.json @@ -0,0 +1 @@ +{ "name": "chalk", "version": "6.0.0", "type": "module", "exports": { "types": "./source/index.d.ts", "default": "./source/index.js" }, "imports": { "#styles": "./source/styles.js" } } diff --git a/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/source/index.d.ts b/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/source/index.d.ts new file mode 100644 index 000000000..84dd75758 --- /dev/null +++ b/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/source/index.d.ts @@ -0,0 +1,3 @@ +export declare const level: number; +declare const chalk: { version: number; red(text: string): string; bold(text: string): string }; +export default chalk; diff --git a/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/source/index.js b/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/source/index.js new file mode 100644 index 000000000..2d85a4ae3 --- /dev/null +++ b/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/source/index.js @@ -0,0 +1,16 @@ +import { styles } from "#styles"; + +export const level = 0; + +function paint(name, text) { + const [open, close] = styles[name]; + return level > 0 ? `\u001B[${open}m${text}\u001B[${close}m` : `${text}`; +} + +const chalk = { + version: 6, + red: (text) => paint("red", text), + bold: (text) => paint("bold", text), +}; + +export default chalk; diff --git a/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/source/styles.js b/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/source/styles.js new file mode 100644 index 000000000..03e60b5e0 --- /dev/null +++ b/tests/corpus/8214-unshimmed-package-version/node_modules/chalk/source/styles.js @@ -0,0 +1 @@ +export const styles = { red: [31, 39], bold: [1, 22] }; diff --git a/tests/corpus/8216-module-namespace-live-values/main.ts b/tests/corpus/8216-module-namespace-live-values/main.ts new file mode 100644 index 000000000..419ce772e --- /dev/null +++ b/tests/corpus/8216-module-namespace-live-values/main.ts @@ -0,0 +1,11 @@ +// A module namespace object stored as an untyped value, here in a registry +// keyed by module name (pi-coding-agent's VIRTUAL_MODULES): reads see the +// exports' current values, both registry entries are the same object, and +// writes throw Node's TypeError. +import { addThrough, bumpThrough, describe, same, writeThrough } from "ns-registry"; + +console.log(describe()); +bumpThrough(); +bumpThrough(); +console.log(describe()); +console.log(same(), writeThrough(), addThrough()); diff --git a/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/counter.js b/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/counter.js new file mode 100644 index 000000000..b971201d8 --- /dev/null +++ b/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/counter.js @@ -0,0 +1,7 @@ +export let count = 0; +export var legacy = "legacy"; +export const label = "counter"; +export function bump() { + count++; + legacy = `legacy ${count}`; +} diff --git a/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/index.d.ts b/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/index.d.ts new file mode 100644 index 000000000..451d646f7 --- /dev/null +++ b/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/index.d.ts @@ -0,0 +1,5 @@ +export declare function describe(): string; +export declare function bumpThrough(): void; +export declare function same(): boolean; +export declare function writeThrough(): string; +export declare function addThrough(): string; diff --git a/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/index.js b/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/index.js new file mode 100644 index 000000000..51558bcba --- /dev/null +++ b/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/index.js @@ -0,0 +1,40 @@ +import * as counter from "./counter.js"; + +export const registry = { + counter: counter, + "alias/counter": counter, +}; + +export function describe() { + const ns = registry["counter"]; + return `${ns.count} ${ns.legacy} ${ns.label} ${typeof ns.bump} ${typeof ns}`; +} + +export function bumpThrough() { + registry.counter.bump(); +} + +export function same() { + return registry.counter === registry["alias/counter"]; +} + +function assign(target, key, value) { + try { + target[key] = value; + } catch (error) { + return `${error.name}: ${error.message}`; + } + return "no error"; +} + +function lookup(name) { + return registry[name]; +} + +export function writeThrough() { + return assign(lookup("counter"), "count", 5); +} + +export function addThrough() { + return assign(lookup("alias/counter"), "extra", 5); +} diff --git a/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/package.json b/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/package.json new file mode 100644 index 000000000..30fae6921 --- /dev/null +++ b/tests/corpus/8216-module-namespace-live-values/node_modules/ns-registry/package.json @@ -0,0 +1 @@ +{ "name": "ns-registry", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/8217-intl-segmenter-word-construct/main.ts b/tests/corpus/8217-intl-segmenter-word-construct/main.ts new file mode 100644 index 000000000..d0c2247e7 --- /dev/null +++ b/tests/corpus/8217-intl-segmenter-word-construct/main.ts @@ -0,0 +1,10 @@ +// A package builds its segmenters at module load (pi-tui, grok-mermaid): +// a grapheme segmenter with a locale tag, one with empty options, and a +// word segmenter that only some paths use. Grapheme segmentation matches +// Node; the word segmenter constructs, and this program never segments +// with it. +import { columns, describe, plainColumns } from "text-width"; + +console.log(describe()); +console.log(columns("éa👍🏽🇫🇷"), columns("abc")); +console.log(plainColumns("a\u0301b\u{1F44D}\u{1F3FD}")); diff --git a/tests/corpus/8217-intl-segmenter-word-construct/node_modules/text-width/index.d.ts b/tests/corpus/8217-intl-segmenter-word-construct/node_modules/text-width/index.d.ts new file mode 100644 index 000000000..89b16ddc0 --- /dev/null +++ b/tests/corpus/8217-intl-segmenter-word-construct/node_modules/text-width/index.d.ts @@ -0,0 +1,3 @@ +export declare function columns(text: string): string; +export declare function describe(): string; +export declare function plainColumns(text: string): number; diff --git a/tests/corpus/8217-intl-segmenter-word-construct/node_modules/text-width/index.js b/tests/corpus/8217-intl-segmenter-word-construct/node_modules/text-width/index.js new file mode 100644 index 000000000..c7619bd81 --- /dev/null +++ b/tests/corpus/8217-intl-segmenter-word-construct/node_modules/text-width/index.js @@ -0,0 +1,16 @@ +const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" }); +const words = new Intl.Segmenter(undefined, { granularity: "word" }); +const sentences = new Intl.Segmenter(undefined, { granularity: "sentence" }); +const plain = new Intl.Segmenter(undefined, {}); + +export function columns(text) { + return [...graphemes.segment(text)].map((part) => part.segment).join("|"); +} + +export function plainColumns(text) { + return [...plain.segment(text)].length; +} + +export function describe() { + return `${typeof words} ${typeof sentences}`; +} diff --git a/tests/corpus/8217-intl-segmenter-word-construct/node_modules/text-width/package.json b/tests/corpus/8217-intl-segmenter-word-construct/node_modules/text-width/package.json new file mode 100644 index 000000000..4f3af53f4 --- /dev/null +++ b/tests/corpus/8217-intl-segmenter-word-construct/node_modules/text-width/package.json @@ -0,0 +1 @@ +{ "name": "text-width", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/8218-cjs-namespace-imports/client/index.mjs b/tests/corpus/8218-cjs-namespace-imports/client/index.mjs new file mode 100644 index 000000000..f0d49451f --- /dev/null +++ b/tests/corpus/8218-cjs-namespace-imports/client/index.mjs @@ -0,0 +1,12 @@ +import * as transport from './transport.js' + +export function connect (origin) { + const pool = new transport.Pool(origin, transport.defaults) + console.log(transport.setGlobalDispatcher(pool), pool.connections, transport.defaults.keepAlive) + return pool +} + +export function describe (pool) { + const format = transport.format + return format(pool.request('/status')) +} diff --git a/tests/corpus/8218-cjs-namespace-imports/client/package.json b/tests/corpus/8218-cjs-namespace-imports/client/package.json new file mode 100644 index 000000000..c866a30a1 --- /dev/null +++ b/tests/corpus/8218-cjs-namespace-imports/client/package.json @@ -0,0 +1 @@ +{ "name": "ns-client", "version": "1.0.0", "main": "index.mjs" } diff --git a/tests/corpus/8218-cjs-namespace-imports/client/transport.js b/tests/corpus/8218-cjs-namespace-imports/client/transport.js new file mode 100644 index 000000000..8d9efbe36 --- /dev/null +++ b/tests/corpus/8218-cjs-namespace-imports/client/transport.js @@ -0,0 +1,26 @@ +'use strict' +// A CommonJS module of the package: member exports, a class, and a table +// whose getter stops Node's lexer before the names after it. +class Pool { + constructor (origin, options) { + this.origin = origin + this.connections = options.connections + this.requests = 0 + } + + request (path) { + this.requests += 1 + return `${this.origin}${path} #${this.requests}` + } +} + +function setGlobalDispatcher (dispatcher) { + exports.current = dispatcher + return dispatcher.origin +} + +exports.Pool = Pool +exports.setGlobalDispatcher = setGlobalDispatcher +exports.current = null +exports.defaults = { connections: 4, keepAlive: true } +exports.format = function format (value) { return `[${value}]` } diff --git a/tests/corpus/8218-cjs-namespace-imports/main.mjs b/tests/corpus/8218-cjs-namespace-imports/main.mjs new file mode 100644 index 000000000..7d2d0e862 --- /dev/null +++ b/tests/corpus/8218-cjs-namespace-imports/main.mjs @@ -0,0 +1,13 @@ +// Namespace imports of CommonJS modules in JavaScript: member reads, calls, +// constructions and a method read as a value, through a package's ES +// module and directly. Each name is one Node's lexer detects. +import * as table from './table.cjs' +import { connect, describe } from './client/index.mjs' + +console.log(table.add(20, 22), table.scale) +const add = table.add +console.log(add(1, 2)) + +const pool = connect('http://localhost') +console.log(describe(pool)) +console.log(describe(pool)) diff --git a/tests/corpus/8218-cjs-namespace-imports/table.cjs b/tests/corpus/8218-cjs-namespace-imports/table.cjs new file mode 100644 index 000000000..38696c6f9 --- /dev/null +++ b/tests/corpus/8218-cjs-namespace-imports/table.cjs @@ -0,0 +1,10 @@ +'use strict' +const add = (a, b) => a + b +const scale = 10 +let calls = 0 +module.exports = { + add, + scale, + get calls () { return calls }, + bump () { calls += 1 } +} diff --git a/tests/corpus/8219-cjs-stable-var-exports/lib/core.js b/tests/corpus/8219-cjs-stable-var-exports/lib/core.js new file mode 100644 index 000000000..1210659ac --- /dev/null +++ b/tests/corpus/8219-cjs-stable-var-exports/lib/core.js @@ -0,0 +1,31 @@ +// highlight.js's lib/core.js shape: the module builds one instance and +// exports the var that holds it. +'use strict'; + +const HLJS = function (options) { + const languages = new Map(); + const prefix = options.classPrefix || 'hljs-'; + + function registerLanguage(name, keywords) { + languages.set(name, keywords); + } + + function listLanguages() { + return Array.from(languages.keys()); + } + + function highlight(code, name) { + const keywords = languages.get(name); + if (keywords === undefined) throw new Error(`Unknown language: "${name}"`); + return code + .split(' ') + .map((word) => (keywords.includes(word) ? `${word}` : word)) + .join(' '); + } + + return { registerLanguage, listLanguages, highlight, versionString: '10.7.3' }; +}; + +var highlight = HLJS({}); + +module.exports = highlight; diff --git a/tests/corpus/8219-cjs-stable-var-exports/lib/index.js b/tests/corpus/8219-cjs-stable-var-exports/lib/index.js new file mode 100644 index 000000000..5f7726ea2 --- /dev/null +++ b/tests/corpus/8219-cjs-stable-var-exports/lib/index.js @@ -0,0 +1,8 @@ +// highlight.js's lib/index.js shape: require the core instance, register +// languages on it and export the same var. +var hljs = require('./core'); + +hljs.registerLanguage('javascript', ['const', 'let', 'function', 'return']); +hljs.registerLanguage('python', ['def', 'return', 'lambda']); + +module.exports = hljs; diff --git a/tests/corpus/8219-cjs-stable-var-exports/lib/options.js b/tests/corpus/8219-cjs-stable-var-exports/lib/options.js new file mode 100644 index 000000000..17512396f --- /dev/null +++ b/tests/corpus/8219-cjs-stable-var-exports/lib/options.js @@ -0,0 +1,12 @@ +// A let that is never reassigned, exported in a table. +'use strict'; + +let tabReplace = ' '; +let calls = []; + +function render(text) { + calls.push(text); + return text.split('\t').join(tabReplace); +} + +module.exports = { tabReplace, calls, render }; diff --git a/tests/corpus/8219-cjs-stable-var-exports/main.js b/tests/corpus/8219-cjs-stable-var-exports/main.js new file mode 100644 index 000000000..8607165a6 --- /dev/null +++ b/tests/corpus/8219-cjs-stable-var-exports/main.js @@ -0,0 +1,21 @@ +// A var and a let exported by reference, where neither changes after the +// export: importers see the same instance and value as Node. +'use strict'; + +const hljs = require('./lib'); +const core = require('./lib/core'); +const options = require('./lib/options'); + +console.log(hljs === core, hljs.versionString); +console.log(hljs.listLanguages()); +console.log(hljs.highlight('const x = function', 'javascript')); +hljs.registerLanguage('ruby', ['def', 'end']); +console.log(core.listLanguages().length, core.highlight('def run end', 'ruby')); +try { + hljs.highlight('x', 'cobol'); +} catch (error) { + console.log(error.message); +} + +console.log(JSON.stringify(options.tabReplace), options.render('a\tb'), options.render('\tc')); +console.log(options.calls); diff --git a/tests/corpus/8219-cjs-stable-var-exports/package.json b/tests/corpus/8219-cjs-stable-var-exports/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/corpus/8219-cjs-stable-var-exports/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/tests/corpus/8220-js-class-static-destructuring/classes/comparator.js b/tests/corpus/8220-js-class-static-destructuring/classes/comparator.js new file mode 100644 index 000000000..3b128847c --- /dev/null +++ b/tests/corpus/8220-js-class-static-destructuring/classes/comparator.js @@ -0,0 +1,22 @@ +'use strict' +// semver's Comparator: a static getter returning a module symbol. +const ANY = Symbol('SemVer ANY') + +class Comparator { + static get ANY () { + return ANY + } + + static loose = false + + static parse (text) { + return new Comparator(text) + } + + constructor (text) { + this.semver = text === '' ? ANY : text + this.value = text === '' ? '' : `>=${text}` + } +} + +module.exports = Comparator diff --git a/tests/corpus/8220-js-class-static-destructuring/formdata.js b/tests/corpus/8220-js-class-static-destructuring/formdata.js new file mode 100644 index 000000000..851d3ba28 --- /dev/null +++ b/tests/corpus/8220-js-class-static-destructuring/formdata.js @@ -0,0 +1,26 @@ +'use strict' +// undici's FormData: statics that read private instance state, taken off +// the class before the class is exposed. +class FormData { + #state = [] + + append (name, value) { + this.#state.push({ name, value }) + } + + static getFormDataState (formData) { + return formData.#state + } + + static setFormDataState (formData, state) { + formData.#state = state + } + + static get boundary () { + return undefined + } +} + +const { getFormDataState, setFormDataState, boundary = '----formdata-default' } = FormData + +module.exports = { FormData, getFormDataState, setFormDataState, boundary } diff --git a/tests/corpus/8220-js-class-static-destructuring/main.js b/tests/corpus/8220-js-class-static-destructuring/main.js new file mode 100644 index 000000000..6cc17b94e --- /dev/null +++ b/tests/corpus/8220-js-class-static-destructuring/main.js @@ -0,0 +1,39 @@ +'use strict' +// Object patterns over a class value read its static getters, fields and +// methods as Node does, defaults included. +const Comparator = require('./classes/comparator') +const { outside, ANY, any, isLoose, name } = require('./ranges/outside') +const { FormData, getFormDataState, setFormDataState, boundary } = require('./formdata') + +console.log(typeof ANY, ANY === Comparator.ANY, any === ANY, isLoose, name) +console.log(outside(''), outside('1.2.3')) +console.log(Comparator.parse('').semver === ANY, Comparator.parse('2.0.0').semver === ANY) + +const form = new FormData() +form.append('a', 1) +form.append('b', 'two') +console.log(getFormDataState(form)) +setFormDataState(form, [{ name: 'c', value: true }]) +console.log(getFormDataState(form), boundary) + +class Defaults { + static count = 0 + static missing = undefined + static nested = { inner: 'value' } +} +const { count = 5, missing = 'fallback', nested: { inner, other = 'other' } } = Defaults +console.log(count, missing, inner, other) + +// Inherited statics, and a static getter reading the class it is read +// through. +class Base { + static tag = 'base' + static get label () { return `label:${this.tag}` } + static make () { return this.tag } +} +class Sub extends Base { + static tag = 'sub' +} +const { tag, label, make, name: subName } = Sub +const { label: baseLabel } = Base +console.log(tag, label, typeof make, subName, baseLabel) diff --git a/tests/corpus/8220-js-class-static-destructuring/package.json b/tests/corpus/8220-js-class-static-destructuring/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/corpus/8220-js-class-static-destructuring/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/tests/corpus/8220-js-class-static-destructuring/ranges/outside.js b/tests/corpus/8220-js-class-static-destructuring/ranges/outside.js new file mode 100644 index 000000000..a8ffe06f2 --- /dev/null +++ b/tests/corpus/8220-js-class-static-destructuring/ranges/outside.js @@ -0,0 +1,13 @@ +'use strict' +// semver's ranges/outside.js and ranges/subset.js destructure the class's +// statics at module load. +const Comparator = require('../classes/comparator') +const { ANY } = Comparator +const { ANY: any, loose: isLoose = true, parse, name } = Comparator + +const outside = (text) => { + const comparator = parse(text) + return comparator.semver === ANY ? 'any' : comparator.value +} + +module.exports = { outside, ANY, any, isLoose, name } diff --git a/tests/corpus/8221-js-recursive-generator-returns/main.ts b/tests/corpus/8221-js-recursive-generator-returns/main.ts new file mode 100644 index 000000000..72b9a7b10 --- /dev/null +++ b/tests/corpus/8221-js-recursive-generator-returns/main.ts @@ -0,0 +1,10 @@ +// A JavaScript package's parser of mutually recursive generator methods that +// return `yield*` of each other (yaml 2.9.0's parse/parser.js): the checker +// cannot infer their results. Tokens, values passed to next() and return +// values reach each generator in the same order as in Node. +import { parseTokens, drive, spreadTypes, walkAll } from "token-parser"; + +for (const token of parseTokens("a { b [ c d ] e } f")) console.log(token); +console.log(drive("{ x } y ! z")); +console.log(spreadTypes("[ 1 2 3 ]")); +console.log(await walkAll("a ( b c ) d ( e )")); diff --git a/tests/corpus/8221-js-recursive-generator-returns/node_modules/token-parser/dist/index.d.ts b/tests/corpus/8221-js-recursive-generator-returns/node_modules/token-parser/dist/index.d.ts new file mode 100644 index 000000000..763a34ae4 --- /dev/null +++ b/tests/corpus/8221-js-recursive-generator-returns/node_modules/token-parser/dist/index.d.ts @@ -0,0 +1,4 @@ +export declare function parseTokens(source: string): string[]; +export declare function drive(source: string): string; +export declare function spreadTypes(source: string): string; +export declare function walkAll(source: string): Promise; diff --git a/tests/corpus/8221-js-recursive-generator-returns/node_modules/token-parser/dist/index.js b/tests/corpus/8221-js-recursive-generator-returns/node_modules/token-parser/dist/index.js new file mode 100644 index 000000000..aab64a1ac --- /dev/null +++ b/tests/corpus/8221-js-recursive-generator-returns/node_modules/token-parser/dist/index.js @@ -0,0 +1,131 @@ +'use strict' + +class Parser { + constructor (source) { + this.tokens = source.split(' ') + this.index = 0 + this.stack = [] + } + + * parse () { + while (this.index < this.tokens.length) { + const done = yield * this.step() + if (done) return 'stopped' + } + return yield * this.end() + } + + * step () { + const token = this.tokens[this.index++] + if (token === '{') return yield * this.blockMap(token) + if (token === '[') return yield * this.flowSeq(token) + if (token === '!') return true + return yield * this.scalar(token) + } + + * blockMap (open) { + this.stack.push(open) + yield { type: 'map-start', depth: this.stack.length } + while (this.index < this.tokens.length && this.tokens[this.index] !== '}') { + const stop = yield * this.step() + if (stop) return stop + } + this.index++ + this.stack.pop() + const reply = yield { type: 'map-end', depth: this.stack.length + 1 } + if (reply !== undefined) yield { type: 'reply', value: reply } + return false + } + + * flowSeq (open) { + this.stack.push(open) + let count = 0 + while (this.index < this.tokens.length && this.tokens[this.index] !== ']') { + yield * this.step() + count++ + } + this.index++ + this.stack.pop() + yield { type: 'seq', count } + return false + } + + * scalar (token) { + const value = yield { type: 'scalar', source: token } + if (value !== undefined) return yield * this.scalar(String(value)) + return false + } + + * end () { + yield { type: 'end', depth: this.stack.length } + return this.index + } +} + +function parseTokens (source) { + const tokens = [] + for (const token of new Parser(source).parse()) tokens.push(JSON.stringify(token)) + return tokens +} + +// next(value) reaches the innermost delegated generator, and return values +// pass back through each yield*. +function drive (source) { + const parser = new Parser(source).parse() + const log = [] + let result = parser.next() + while (!result.done) { + const value = result.value + log.push(JSON.stringify(value)) + result = parser.next(value.type === 'map-end' ? 'ack' : value.source === 'x' ? 'x2' : undefined) + } + log.push('return ' + result.value) + return log.join('\n') +} + +function spreadTypes (source) { + const tokens = [...new Parser(source).parse()] + return tokens.length + ' ' + tokens.map((token) => token.type).join(',') +} + +// Async generator methods with the same mutual recursion. +class AsyncWalker { + constructor (source) { + this.tokens = source.split(' ') + this.index = 0 + } + + async * walk () { + while (this.index < this.tokens.length) yield * this.next() + return this.index + } + + async * next () { + const token = this.tokens[this.index++] + await null + if (token === '(') return yield * this.group() + yield token + return token + } + + async * group () { + const inner = [] + while (this.tokens[this.index] !== ')') inner.push(yield * this.next()) + this.index++ + yield 'group:' + inner.join('+') + return inner.length + } +} + +async function walkAll (source) { + const seen = [] + const walker = new AsyncWalker(source).walk() + let result = await walker.next() + while (!result.done) { + seen.push(result.value) + result = await walker.next() + } + return seen.join(' ') + ' -> ' + result.value +} + +module.exports = { parseTokens, drive, spreadTypes, walkAll } diff --git a/tests/corpus/8221-js-recursive-generator-returns/node_modules/token-parser/package.json b/tests/corpus/8221-js-recursive-generator-returns/node_modules/token-parser/package.json new file mode 100644 index 000000000..1fa77c2eb --- /dev/null +++ b/tests/corpus/8221-js-recursive-generator-returns/node_modules/token-parser/package.json @@ -0,0 +1 @@ +{ "name": "token-parser", "version": "1.0.0", "main": "dist/index.js", "types": "dist/index.d.ts" } diff --git a/tests/corpus/8222-js-class-static-reflection.js b/tests/corpus/8222-js-class-static-reflection.js new file mode 100644 index 000000000..74027cc64 --- /dev/null +++ b/tests/corpus/8222-js-class-static-reflection.js @@ -0,0 +1,94 @@ +'use strict' +// A class's statics extracted, then removed with Reflect.deleteProperty, and +// redefined with Object.defineProperties (undici's FormData and Response). + +const kEnumerableProperty = { __proto__: null, enumerable: true } + +class FormData { + #state = [] + append (name, value) { this.#state.push({ name, value }) } + static getFormDataState (formData) { return formData.#state } + static setFormDataState (formData, state) { formData.#state = state } +} + +const getFormDataState = FormData.getFormDataState +const setFormDataState = FormData.setFormDataState +console.log(Reflect.deleteProperty(FormData, 'getFormDataState')) +console.log(Reflect.deleteProperty(FormData, 'setFormDataState')) +console.log(Reflect.deleteProperty(FormData, 'missing')) +console.log(typeof FormData.getFormDataState, typeof FormData.setFormDataState) + +const form = new FormData() +form.append('a', 1) +form.append('b', 'two') +console.log(getFormDataState(form)) +setFormDataState(form, [{ name: 'c', value: true }]) +console.log(getFormDataState(form).length, getFormDataState(form)[0].name) +try { + FormData.getFormDataState(form) +} catch (error) { + console.log(error.constructor.name, error.message) +} + +class Response { + #status + constructor (status) { this.#status = status } + get status () { return this.#status } + static json () { return new Response(200) } + static error () { return new Response(0) } + static getResponseState (response) { return response.#status } +} + +const getResponseState = Response.getResponseState +Reflect.deleteProperty(Response, 'getResponseState') +Object.defineProperties(Response, { + json: kEnumerableProperty, + error: kEnumerableProperty +}) +const response = Response.json() +console.log(response.status, Response.error().status, getResponseState(response)) +for (const key of ['json', 'error', 'getResponseState']) { + const descriptor = Object.getOwnPropertyDescriptor(Response, key) + console.log(key, descriptor === undefined ? 'absent' : [descriptor.enumerable, descriptor.writable, descriptor.configurable, typeof descriptor.value]) +} + +// Statics a subclass inherits, a static field and a static accessor. +class Base { + static helper () { return 'helper' } + static count = 3 + static get label () { return 'label' } +} +class Child extends Base {} +console.log(Child.helper(), Child.count, Child.label) +console.log(Reflect.deleteProperty(Base, 'helper'), Reflect.deleteProperty(Base, 'count')) +console.log(typeof Child.helper, Child.count, typeof Base.helper, Base.count) +console.log(Reflect.deleteProperty(Base, 'label'), Base.label, Child.label) +Base.helper = () => 'replaced' +console.log(Base.helper(), Child.helper()) +Base.count = 4 +const countKey = { toString () { return 'count' } } +console.log(Reflect.deleteProperty(Base, countKey), Base.count) +class Key { + constructor (name) { this.name = name } + toString () { return this.name } +} +Base.count = 5 +console.log(Reflect.deleteProperty(Base, new Key('count')), Base.count) + +// Checked-dynamic objects and primitive targets. +const parsed = JSON.parse('{"a":1,"b":2}') +console.log(Reflect.deleteProperty(parsed, 'a'), parsed) +const frozen = Object.freeze(JSON.parse('{"a":1}')) +console.log(Reflect.deleteProperty(frozen, 'a'), frozen) +const attempt = (run) => { + try { + run() + } catch (error) { + console.log(error.constructor.name, error.message) + } +} +attempt(() => Reflect.deleteProperty(1, 'a')) +attempt(() => Reflect.deleteProperty('text', 'a')) +attempt(() => Reflect.deleteProperty(null, 'a')) +attempt(() => Reflect.deleteProperty(undefined, 'a')) +attempt(() => Reflect.deleteProperty(true, 'a')) diff --git a/tests/corpus/8223-argument-count-destructured-parameter.ts b/tests/corpus/8223-argument-count-destructured-parameter.ts new file mode 100644 index 000000000..def8c5ba8 --- /dev/null +++ b/tests/corpus/8223-argument-count-destructured-parameter.ts @@ -0,0 +1,14 @@ +// A function with a destructured parameter that reads arguments.length, +// called through a value (map passes three arguments) and directly. The +// count is taken before the parameters bind, so each call sees its own. + +function probe({ v, w }: { v: number; w: string }): string { + return `${arguments.length}:${v}${w}`; +} + +console.log([{ v: 1, w: "a" }, { v: 2, w: "b" }].map(probe)); +console.log(probe({ v: 3, w: "c" })); +const call = (f: (item: { v: number; w: string }, index: number) => string): string => + f({ v: 4, w: "d" }, 0); +console.log(call(probe)); +console.log(probe({ v: 5, w: "e" })); diff --git a/tests/corpus/8230-readline-interface-constructor.cjs b/tests/corpus/8230-readline-interface-constructor.cjs new file mode 100644 index 000000000..6d7eea0e4 --- /dev/null +++ b/tests/corpus/8230-readline-interface-constructor.cjs @@ -0,0 +1,51 @@ +// `new readline.Interface(...)`: Node's createInterface calls this +// constructor with the same arguments, and a JavaScript file may call it +// directly. Its declaration is protected, which JavaScript does not +// enforce. +"use strict"; +const readline = require("readline"); +const readlinePromises = require("readline/promises"); +const { Interface } = require("readline"); +const { PassThrough } = require("stream"); + +// The options form. +const a = new PassThrough(); +const rlA = new readline.Interface({ input: a, terminal: false }); +console.log("instanceof", rlA instanceof readline.Interface); +const linesA = []; +rlA.on("line", (line) => { + linesA.push(line); +}); +rlA.on("close", () => { + console.log("options", JSON.stringify(linesA)); +}); +a.write("one\ntwo\n"); +a.end("three"); + +// The positional form, through a destructured binding. +const b = new PassThrough(); +const outB = new PassThrough(); +const rlB = new Interface(b, outB); +const linesB = []; +rlB.on("line", (line) => { + linesB.push(line); +}); +rlB.on("close", () => { + console.log("positional", JSON.stringify(linesB)); +}); +b.write("four\r\nfive\n"); +b.end(); + +// readline/promises' Interface. +const c = new PassThrough(); +const rlC = new readlinePromises.Interface({ input: c, crlfDelay: Infinity }); +console.log("promises instanceof", rlC instanceof readlinePromises.Interface); +const linesC = []; +rlC.on("line", (line) => { + linesC.push(line); +}); +rlC.on("close", () => { + console.log("promises", JSON.stringify(linesC)); +}); +c.end("six\nseven"); + diff --git a/tests/corpus/8231-builtin-member-binding-new.cjs b/tests/corpus/8231-builtin-member-binding-new.cjs new file mode 100644 index 000000000..df1b2ebb3 --- /dev/null +++ b/tests/corpus/8231-builtin-member-binding-new.cjs @@ -0,0 +1,60 @@ +// `new` of a builtin class bound through a member of a require() result +// (`const SD = require("string_decoder").StringDecoder`, the spelling of +// Node's own tests): the binding resolves to the class, as the +// destructured `const { StringDecoder } = require(...)` does, also for the +// X509Certificate record. +"use strict"; +const SD = require("string_decoder").StringDecoder; +const Readable = require("stream").Readable; +const PassThrough = require("stream").PassThrough; +const Transform = require("stream").Transform; +const X509 = require("crypto").X509Certificate; + +const decoder = new SD("utf8"); +console.log(decoder.encoding); +console.log(JSON.stringify(decoder.write(Buffer.from([0xe2, 0x82])))); +console.log(decoder.end(Buffer.from([0xac]))); +const hex = new SD("hex"); +console.log(hex.encoding, hex.write(Buffer.from("hi"))); +console.log(new SD().encoding); + +const r = new Readable({ read() {} }); +r.on("data", (chunk) => console.log("readable", String(chunk))); +r.on("end", () => console.log("readable end")); +r.push("abc"); +r.push(null); + +const p = new PassThrough(); +p.on("data", (chunk) => console.log("passthrough", String(chunk))); +p.end("xyz"); + +const upper = new Transform({ + transform(chunk, encoding, callback) { + callback(null, String(chunk).toUpperCase()); + }, +}); +upper.on("data", (chunk) => console.log("transform", String(chunk))); +upper.end("shout"); + +// The X509Certificate record constructs through the same binding. +const PEM = `-----BEGIN CERTIFICATE----- +MIIDEzCCAfugAwIBAgIUMsBCFtPck8TATMJzfikgW963q3AwDQYJKoZIhvcNAQEL +BQAwGDEWMBQGA1UEAwwNdHNuYXRpdmUtdGVzdDAgFw0yNjA3MTcxNzUyMTFaGA8y +MTI2MDYyMzE3NTIxMVowGDEWMBQGA1UEAwwNdHNuYXRpdmUtdGVzdDCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBALRspe2XjtKvNU7Ws5JfqzmTCPmWtfw0 +1Uha271hNp/30VstOxWLbpqPrXDvIwJMN4o8CRp92Y1hcmS36m9QS5qfXJFw+n+S +SWGpFdMNvp6hro0OoNBA+anuKWoTsMyHSwe8ND0fyobQbgAXJ0+snmuK0E2ii+CM +IoUqYbK57nHeyHfISLupgrRoNGXjDHv4v+RSr3MJDag6BLA+/yVjkBOBOT6kaE4K +oyNXYYsfFxGnB/4pDSanV/UsfJ4wHXkVn18ndZHIzTsZMUkV4BecF8UaMrP+u8NI +c5wce0uWLFFEneK4q15iVvk9TaY0g1Uj69ryRT3L58W7RYYuHu7cVB0CAwEAAaNT +MFEwHQYDVR0OBBYEFLT2GqaTmxDhntPxMWaUZvA3s9V5MB8GA1UdIwQYMBaAFLT2 +GqaTmxDhntPxMWaUZvA3s9V5MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL +BQADggEBAFcTLVJIyhbIKHfOnGM5T+Kzu5IoDlqzr+Rdx/0IKFLVDqEA9ihEme07 +K/d246pOW5LBceVDY2eMikDpIOjYPEoe9C+Ftv5zrCaDw1wtXrq8a7/eS5B+3TzL +k8c1SVVDPIbmViAZNRmnkwCPBsIF9xIfMFhxz4BTJrm/Lt2Hv1GcRBQx/yRK89sj +JiGnsTna//4mPGRGYw+dRCq4tbyr8HbBKdJmbzOCb4CPKfwAMKUpd4e0Ao7jh/BU +EML7B3BEzv5Jw/BT7B39wAfDyhq32PnRnZefF+7pAmpkklHbckBtZtWn02zwQazv +5z62tszZee2i4BouJiyFnb30F6Pebs4= +-----END CERTIFICATE-----`; +const cert = new X509(PEM); +console.log(cert.fingerprint, cert.validFrom, cert.validTo); diff --git a/tests/corpus/8232-native-streams-untyped.cjs b/tests/corpus/8232-native-streams-untyped.cjs new file mode 100644 index 000000000..fb1c1f0c1 --- /dev/null +++ b/tests/corpus/8232-native-streams-untyped.cjs @@ -0,0 +1,80 @@ +// Native streams passed to untyped helpers: push, unshift, the listener +// members, setDefaultEncoding and readableFlowing answer through the +// untyped value as on the typed one, also for a program subclass and in +// object mode. +const { Readable, Writable } = require("stream"); + +function feed(s) { + console.log("push", s.push("a"), s.push("b", "utf8"), s.unshift("z")); + console.log("counts", s.listenerCount("data"), s.eventNames().join(",")); + s.on("custom", (x, y) => { + console.log("custom", x, y); + }); + s.emit("custom", 1, 2); + s.removeAllListeners("custom"); + console.log("after removeAll", s.listenerCount("custom"), s.readableFlowing); + s.push(null); +} + +const r = new Readable({ read() {} }); +const chunks = []; +r.on("data", (c) => chunks.push(String(c))); +r.on("end", () => console.log("end", chunks.join("|"))); +feed(r); + +class Source extends Readable { + _read() {} +} +const sub = new Source(); +sub.on("data", (c) => console.log("source data", String(c))); +console.log("source push", sub instanceof Readable); +(function (s) { + console.log(s.push("from untyped"), s.readableLength); + s.push(null); +})(sub); + +const objects = new Readable({ objectMode: true, read() {} }); +console.log("flowing before", objects.readableFlowing); +objects.on("data", (c) => console.log("object data", c)); +(function (s) { + console.log(s.push({ n: 1 }), s.push(undefined), s.push(2), s.readableFlowing); + s.push(null); +})(objects); + +function drain(w) { + w.setDefaultEncoding("latin1"); + w.write("é"); + w.end(); +} +const w = new Writable({ + write(c, e, cb) { + console.log("wrote", c.length, e); + cb(); + }, +}); +w.on("finish", () => console.log("finish")); +drain(w); + +// unshift(string, encoding) puts the decoded bytes at the front; an +// unknown encoding throws. +const bytes = new Readable({ read() {} }); +const parts = []; +bytes.on("data", (c) => parts.push(c.toString("hex"))); +bytes.on("end", () => console.log("unshift bytes", parts.join("|"))); +(function (s) { + console.log(s.unshift("e9", "hex"), s.unshift("aGk=", "base64"), s.unshift("é", "latin1"), s.unshift("x", "utf8")); + try { + s.unshift("x", "bogus"); + } catch (e) { + console.log(e.code, e.message); + } + s.push(null); +})(bytes); +const text = new Readable({ read() {} }); +text.setEncoding("utf8"); +text.on("data", (c) => console.log("unshift text", JSON.stringify(c))); +(function (s) { + s.unshift("c3a9", "hex"); + s.unshift("41", "hex"); + s.push(null); +})(text); diff --git a/tests/corpus/8233-string-decoder-values.cjs b/tests/corpus/8233-string-decoder-values.cjs new file mode 100644 index 000000000..475a31d57 --- /dev/null +++ b/tests/corpus/8233-string-decoder-values.cjs @@ -0,0 +1,33 @@ +// A StringDecoder passed to untyped code keeps its identity and its +// pending bytes, and answers write, end and encoding as in Node. +const { StringDecoder } = require("string_decoder"); + +function feed(decoder, chunks) { + let out = ""; + for (const chunk of chunks) out += decoder.write(chunk); + return out + decoder.end(); +} + +const euro = Buffer.from("€ and 😀"); +const utf8 = new StringDecoder("utf-8"); +const parts = []; +for (let i = 0; i < euro.length; i++) parts.push(euro.subarray(i, i + 1)); +console.log(feed(utf8, parts), utf8.encoding); + +const hex = new StringDecoder("hex"); +console.log(feed(hex, [Buffer.from([1, 2]), Buffer.from([255])]), hex.encoding); + +const utf16 = new StringDecoder("ucs2"); +const pending = [Buffer.from([0x61]), Buffer.from([0x00, 0x62]), Buffer.from([0x00])]; +console.log(JSON.stringify(feed(utf16, pending)), utf16.encoding); + +function passThrough(d) { + return d; +} +const kept = passThrough(utf8); +console.log(kept === utf8, typeof kept, kept.write("text"), kept.end(Buffer.from([0xf0, 0x9f]))); +try { + kept.write(null); +} catch (e) { + console.log(e.code, e.message); +} diff --git a/tests/corpus/8234-handle-typeof.ts b/tests/corpus/8234-handle-typeof.ts new file mode 100644 index 000000000..f18240130 --- /dev/null +++ b/tests/corpus/8234-handle-typeof.ts @@ -0,0 +1,24 @@ +// typeof of the objects the runtime keeps as numeric handles answers +// "object", as Node's does: readline interfaces, timers, async-local +// stores, diagnostics channels and event-loop histograms. +import * as readline from "node:readline"; +import { PassThrough } from "node:stream"; +import { AsyncLocalStorage } from "node:async_hooks"; +import * as dc from "node:diagnostics_channel"; +import { monitorEventLoopDelay } from "node:perf_hooks"; + +const rl = readline.createInterface({ input: new PassThrough(), terminal: false }); +const timeout = setTimeout(() => {}, 1); +const interval = setInterval(() => {}, 1000); +const immediate = setImmediate(() => {}); +const store = new AsyncLocalStorage(); +const channel = dc.channel("handles"); +const tracing = dc.tracingChannel("handles"); +const histogram = monitorEventLoopDelay(); +const wrapped: Number = 5; +console.log(typeof rl, typeof timeout, typeof interval, typeof immediate); +console.log(typeof store, typeof channel, typeof tracing, typeof histogram, typeof wrapped); +clearTimeout(timeout); +clearInterval(interval); +clearImmediate(immediate); +rl.close(); diff --git a/tests/corpus/8241-literal-result-slices.ts b/tests/corpus/8241-literal-result-slices.ts new file mode 100644 index 000000000..05deb40a5 --- /dev/null +++ b/tests/corpus/8241-literal-result-slices.ts @@ -0,0 +1,90 @@ +// Destructured results of small literal producers: string slices, numbers +// and booleans, with evaluation order, unread fields, throws and every +// slice() and substring() bound kind. +let trace = ""; +function mark(tag: string, value: T): T { + trace += tag; + return value; +} + +function split(text: string, at: number): [string, string] { + return [text.slice(0, at), text.slice(at)]; +} +function cut(text: string, from: number, to: number): [string, number, boolean] { + return [text.substring(from, to), text.length, from > to]; +} +function divmod(a: number, b: number): [number, number] { + return [Math.floor(a / b), a % b]; +} +function pair(text: string): { head: string; size: number } { + return { size: mark("s", text.length), head: mark("h", text.slice(1, -1)) }; +} + +const bounds = [0, 1, 2, 3, -1, -2, -3, -10, 10, 0.5, -0.5, 1.9, -1.9, -0, NaN, Infinity, -Infinity]; +const texts = ["", "a", "ab", "abcdef", "héllo", "東京ok", "a\u{1F600}b"]; +let lengths = 0; +let units = 0; +for (const text of texts) { + for (const at of bounds) { + const [head, tail] = split(text, at); + lengths = lengths * 7 + head.length * 3 + tail.length; + lengths %= 1000000007; + if (head.length > 0) units += head.charCodeAt(head.length - 1); + if (tail.length > 0) units += tail.charCodeAt(0); + for (const to of bounds) { + const [part, size, swapped] = cut(text, at, to); + lengths = (lengths * 5 + part.length + size + (swapped ? 1 : 0)) % 1000000007; + } + } +} +console.log("lengths", lengths, "units", units); + +// The pieces themselves, as strings. +const [left, right] = split("scriptc", 3); +console.log(left, right, `${left}|${right}`); + +// Arguments, then fields in the literal's order, then the bindings. +trace = ""; +const { head, size } = pair(mark("a", "[inner]")); +console.log("order", trace, head.length, size); + +// Unread fields still run. +trace = ""; +const [onlyHead] = split(mark("x", "unread"), mark("y", 2)); +const { size: onlySize } = pair("<>"); +console.log("unread", trace, onlyHead, onlySize); + +// A mutable destructured binding. +let [first, rest] = split("mutable", 1); +first += "!"; +console.log("let", first, rest.length); + +// Numbers and a throw from an argument. +let sum = 0; +for (let i = 0; i < 50; i++) { + const [q, r] = divmod(i * 31, 17); + sum += q * 3 + r; +} +console.log("divmod", sum); +const list: (string | undefined)[] = ["xy", undefined]; +for (const item of list) { + try { + const [a, b] = split(item!, 1); + console.log("ok", a.length, b.length); + } catch (error) { + console.log("threw", error instanceof TypeError); + } +} + +// The producer keeps working for every other caller. +const whole = split("kept", 2); +console.log("kept", whole[0], whole[1], whole.length); + +// Fields that write a parameter: each inlined call writes its own copy. +function bump(a: number, s: string): [number, number, string] { + return [a++, (a = a * 2), s.slice(a - 3)]; +} +for (const start of [2, 3]) { + const [before, doubled, tailOf] = bump(start, "abcdefgh"); + console.log("writes", start, before, doubled, tailOf); +} diff --git a/tests/corpus/8242-string-scan-republish.ts b/tests/corpus/8242-string-scan-republish.ts new file mode 100644 index 000000000..9800bb099 --- /dev/null +++ b/tests/corpus/8242-string-scan-republish.ts @@ -0,0 +1,108 @@ +// Token scans that alternate between two non-ASCII strings: each inner loop +// starts at a token and runs while the class holds, so every token enters a +// loop over one of the strings again. Also a string extended in place +// between scans, strings rebuilt with the same length and new content, and +// slices of a scanned string. + +function isWord(c: number): boolean { + return (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c >= 0xc0; +} + +function isDigit(c: number): boolean { + return c >= 48 && c <= 57; +} + +// The kinds and lengths of s's tokens, hashed. +function lex(s: string): number { + let h = 0, + tokens = 0, + pos = 0; + const end = s.length; + while (pos < end) { + const c = s.charCodeAt(pos); + let start = pos; + if (isWord(c)) { + while (pos < end && isWord(s.charCodeAt(pos))) pos++; + h = (h * 31 + 1) | 0; + } else if (isDigit(c)) { + while (pos < end && isDigit(s.charCodeAt(pos))) pos++; + h = (h * 31 + 2) | 0; + } else if (c === 32) { + while (pos < end && s.charCodeAt(pos) === 32) pos++; + start = pos; + } else { + pos++; + h = (h * 31 + c) | 0; + } + h = (h * 31 + (pos - start)) | 0; + tokens++; + } + return h ^ tokens; +} + +// Two strings lexed token by token in turn: each token's loop runs over a +// different receiver than the one before it. +function interleave(a: string, b: string): number { + let i = 0, + j = 0, + h = 0; + while (i < a.length || j < b.length) { + const fromA = i < a.length; + while (i < a.length && a.charCodeAt(i) !== 32) { + h = (h * 31 + a.charCodeAt(i)) | 0; + i++; + } + i++; + while (j < b.length && b.charCodeAt(j) !== 32) { + h = (h * 37 + b.charCodeAt(j)) | 0; + j++; + } + j++; + if (fromA) h = (h + 1) | 0; + } + return h; +} + +// The sum of every unit of s. +function units(s: string): number { + let sum = 0; + for (let i = 0; i < s.length; i++) sum += s.charCodeAt(i); + return sum; +} + +function text(seed: number, words: number): string { + const parts = ["café", "naïve", "façade", "x", "😀", "Ωmega", "über", "ab"]; + let s = ""; + for (let k = 0; k < words; k++) { + s += parts[(seed + k * 7) % parts.length]!; + s += k % 5 === 0 ? " " + ((seed * k) % 1000) + " " : k % 3 === 0 ? "; " : " "; + } + return s; +} + +const first = text(1, 300), + second = text(4, 280); +for (let pass = 0; pass < 3; pass++) { + console.log(lex(first), lex(second), lex(first), units(second), units(first)); + console.log(interleave(first, second), interleave(second, first)); +} + +// Extended in place between scans: the second scan reads the new units. +let grown = text(2, 120); +console.log(lex(grown), units(grown), grown.length); +for (let round = 0; round < 4; round++) { + grown += round % 2 === 0 ? " é1 ü22 " : " 😀 tail "; + console.log(lex(grown), units(grown), grown.length, interleave(grown, first)); +} + +// Same length, different content, built after the previous one is dropped. +for (let round = 0; round < 6; round++) { + const s = round % 2 === 0 ? "é".repeat(200) + " a" : "ü".repeat(200) + " b"; + console.log(lex(s), units(s), units(s.slice(0, 100)), lex(second)); +} + +// Slices of a scanned string, scanned between scans of the whole. +for (const at of [0, 7, 101, first.length - 9]) { + const part = first.slice(at, at + 64); + console.log(lex(part), units(part), lex(first), units(part.slice(3))); +} diff --git a/tests/corpus/8243-json-parse-discriminated-unions.ts b/tests/corpus/8243-json-parse-discriminated-unions.ts new file mode 100644 index 000000000..df3fd6dd8 --- /dev/null +++ b/tests/corpus/8243-json-parse-discriminated-unions.ts @@ -0,0 +1,119 @@ +// JSON.parse cast to discriminated unions of records: arrays of them, +// whole documents, members (with null and optional arms) and recursive +// trees. The discriminant appears first, last or between other members, +// escaped, repeated, and beside undeclared members; values hold escapes +// and non-ASCII text. + +type Event = + | { kind: "click"; x: number; y: number } + | { kind: "focus" | "blur"; target: string } + | { key: string; kind: "key"; repeat: boolean } + | { kind: "load"; ms: number; next: Event | null; note?: string }; + +type Expr = + | { kind: "num"; value: number } + | { kind: "neg"; operand: Expr } + | { items: Expr[]; kind: "max" } + | { body: Expr; kind: "let"; name: string; value: Expr }; + +function describe(e: Event): string { + switch (e.kind) { + case "click": + return `click ${e.x},${e.y}`; + case "focus": + case "blur": + return `${e.kind} ${e.target}`; + case "key": + return `key ${e.key}${e.repeat ? " (repeat)" : ""}`; + case "load": + return `load ${e.ms}${e.note === undefined ? "" : ` "${e.note}"`}${e.next === null ? "" : ` then ${describe(e.next)}`}`; + } +} + +function evaluate(e: Expr, env: Map): number { + switch (e.kind) { + case "num": + return e.value; + case "neg": + return -evaluate(e.operand, env); + case "max": { + let best = -Infinity; + for (const item of e.items) best = Math.max(best, evaluate(item, env)); + return best; + } + case "let": { + const inner = new Map(env); + inner.set(e.name, evaluate(e.value, env)); + return evaluate(e.body, inner) + (inner.get(e.name) ?? 0); + } + } +} + +const events = JSON.parse(`[ + {"kind":"click","x":1,"y":2}, + {"x":3,"y":4,"kind":"click"}, + {"y":6,"kind":"click","x":5}, + {"kind":"focus","target":"input"}, + {"target":"button","kind":"blur"}, + {"key":"Enter","kind":"key","repeat":false}, + {"repeat":true,"key":"\\u00e9","kind":"key"}, + {"kind":"load","ms":12,"next":null}, + {"kind":"load","ms":7,"note":"caf\\u00e9 \\ud83d\\ude00","next":{"kind":"blur","target":"tab"}}, + {"next":{"kind":"load","ms":1,"next":{"kind":"click","x":0,"y":-1}},"ms":30,"kind":"load"}, + {"k\\u0069nd":"focus","target":"escaped key"}, + {"kind":"bl\\u0075r","target":"escaped value"}, + {"kind":"click","kind":"focus","target":"repeated"}, + {"kind":"click","x":8,"y":9,"extra":{"kind":"not an event","list":[1,[2]]}}, + { "kind" : "key" , "key" : "space" , "repeat" : true } +]`) as Event[]; +console.log(events.length); +for (const e of events) console.log(describe(e)); +console.log(JSON.stringify(events[12]), JSON.stringify(events[13])); + +const empty = JSON.parse("[]") as Event[]; +console.log(empty.length); + +const single = JSON.parse('{"target":"whole document","kind":"focus"}') as Event; +console.log(describe(single)); + +const tree = JSON.parse(`{ + "body": {"items": [{"kind":"num","value":4}, {"kind":"neg","operand":{"kind":"num","value":-9}}, {"items":[],"kind":"max"}], "kind": "max"}, + "kind": "let", + "name": "x", + "value": {"kind":"neg","operand":{"kind":"neg","operand":{"kind":"num","value":2.5}}} +}`) as Expr; +console.log(evaluate(tree, new Map())); + +const forest = JSON.parse( + `[${Array.from({ length: 50 }, (_, i) => + i % 3 === 0 + ? `{"kind":"num","value":${i}}` + : i % 3 === 1 + ? `{"operand":{"kind":"num","value":${i}},"kind":"neg"}` + : `{"items":[{"kind":"num","value":${i}},{"kind":"num","value":${50 - i}}],"kind":"max"}`, + ).join(",")}]`, +) as Expr[]; +let total = 0; +for (const t of forest) total += evaluate(t, new Map()); +console.log(forest.length, total); + +// Parsing the same text again builds separate values. +const again = JSON.parse('[{"kind":"num","value":1},{"kind":"num","value":2}]') as Expr[]; +const twice = JSON.parse('[{"kind":"num","value":1},{"kind":"num","value":2}]') as Expr[]; +console.log(again.length + twice.length, again[0] === twice[0]); + +// Malformed text throws Node's SyntaxError, including text that ends +// inside the members before the discriminant or inside its value. +for (const text of [ + '[{"kind":"click","x":1,"y":2},', + '[{"x":1,"kind"', + '[{"x":[1,2,"kind":"click"}]', + '[{"kind":"cl', + '[{"target":"a" "kind":"focus"}]', +]) { + try { + console.log((JSON.parse(text) as Event[]).length); + } catch (error) { + console.log(error instanceof SyntaxError, (error as Error).message); + } +} diff --git a/tests/corpus/8244-speculative-floor-mod-powers.ts b/tests/corpus/8244-speculative-floor-mod-powers.ts new file mode 100644 index 000000000..db28dcde0 --- /dev/null +++ b/tests/corpus/8244-speculative-floor-mod-powers.ts @@ -0,0 +1,88 @@ +// Integer loops whose writes take a floor-mod by a power of two +// (`v - Math.floor(v / 2^n) * 2^n`) or multiply by one: negative and zero +// values, exponents 0, 52 and above, literal powers, both factor orders, +// fractional values and results past 2^53, which leave the integer copy. + +function trim(value: number, bits: number): number { + return value - Math.floor(value / Math.pow(2, bits)) * Math.pow(2, bits); +} + +function low3(value: number): number { + return value - Math.floor(value / 8) * 8; +} + +function rolling(bytes: Uint8Array, bits: number): number { + let key = 0; + let sum = 0; + for (let i = 0; i < bytes.length; i++) { + key = trim(key, bits) * 4 + bytes[i]!; + sum = (sum + key) % 1000003; + } + return key * 7 + sum; +} + +function stepped(start: number, step: number, bits: number, count: number): number { + let v = start; + let h = 0; + for (let i = 0; i < count; i++) { + v = trim(v, bits) * 3 - step; + h = (h * 31 + v) % 999983; + } + return h * 1000 + v; +} + +function literal(start: number, count: number): number { + let v = start; + let h = 0; + for (let i = 0; i < count; i++) { + v = low3(v) * 16 - i; + h = (h * 17 + v) % 65521; + } + return h * 100 + v; +} + +function scaled(start: number, bits: number, count: number): number { + let v = start; + let h = 0; + for (let i = 0; i < count; i++) { + v = Math.pow(2, bits) * v + 1; + h = (h * 7 + v) % 10007; + } + return h + v; +} + +function shifted(start: number, count: number): number { + let v = start; + let h = 0; + for (let i = 0; i < count; i++) { + v = v * 1024 - i; + h = (h * 3 + v) % 101; + } + return h + v; +} + +const bytes = new Uint8Array(4000); +for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 7 + (i >> 3)) & 3; + +for (const bits of [0, 1, 2, 10, 22, 34, 50, 52, 53, 60]) console.log("rolling", bits, rolling(bytes, bits)); +for (const [start, step, bits] of [ + [0, 1, 3], + [-5, 3, 2], + [-1, -1, 0], + [123456789, 7, 20], + [-9007199254740991, 1, 52], + [2.5, 1, 4], + [7, 0.5, 3], +] as const) + console.log("stepped", start, step, bits, stepped(start, step, bits, 50)); +for (const start of [0, 13, -13, 4096, -7.25]) console.log("literal", start, literal(start, 40)); +for (const [start, bits] of [ + [1, 3], + [-2, 5], + [0, 52], + [3, 20], + [-1, 0], +] as const) + console.log("scaled", start, bits, scaled(start, bits, 6)); +for (const start of [0, 1, -1, 3, 1e6]) console.log("shifted", start, shifted(start, 7)); +console.log(Object.is(stepped(0, 0, 3, 5), 0), Object.is(scaled(0, 2, 3), 21 + 1)); diff --git a/tests/corpus/8260-class-prototype-views.mjs b/tests/corpus/8260-class-prototype-views.mjs new file mode 100644 index 000000000..0f7b080bf --- /dev/null +++ b/tests/corpus/8260-class-prototype-views.mjs @@ -0,0 +1,178 @@ +// A class's prototype as the prototype of other objects: ES5 constructors +// that inherit from a class through Object.create, Object.setPrototypeOf and +// util.inherits, and plain objects that adopt a class prototype. Inherited +// methods, accessors and calls between methods run with the object as the +// receiver. +import util from "node:util"; + +class Shape { + constructor(name) { + this.name = name; + } + get label() { + return `${this.name} (${this.sides()} sides)`; + } + sides() { + return 0; + } + describe() { + return `${this.label}: area ${this.area().toFixed(2)}`; + } + area() { + return 0; + } +} + +class Polygon extends Shape { + sides() { + return this.points.length; + } + perimeter() { + let total = 0; + for (let i = 0; i < this.points.length; i++) { + const [x1, y1] = this.points[i]; + const [x2, y2] = this.points[(i + 1) % this.points.length]; + total += Math.hypot(x2 - x1, y2 - y1); + } + return total; + } +} + +// Object.create(Class.prototype) for an ES5 subclass. +function Square(size) { + this.name = "square"; + this.size = size; + this.points = [ + [0, 0], + [size, 0], + [size, size], + [0, size], + ]; +} +Square.prototype = Object.create(Polygon.prototype); +Square.prototype.constructor = Square; +Square.prototype.area = function () { + return this.size * this.size; +}; + +const square = new Square(3); +console.log(square.describe()); +console.log(square.perimeter(), square.sides(), square.label); +console.log(square instanceof Square, square instanceof Polygon, square instanceof Shape); +console.log(Object.getPrototypeOf(Square.prototype) === Polygon.prototype); +console.log(Object.getPrototypeOf(Polygon.prototype) === Shape.prototype); +console.log(Object.keys(square).join(",")); +const inherited = []; +for (const key in square) inherited.push(key); +console.log(inherited.join(",")); + +// Object.setPrototypeOf on an ES5 constructor's prototype. +function Circle(radius) { + this.name = "circle"; + this.radius = radius; +} +Circle.prototype.area = function () { + return Math.PI * this.radius * this.radius; +}; +Object.setPrototypeOf(Circle.prototype, Shape.prototype); +const circle = new Circle(2); +console.log(circle.describe(), circle instanceof Shape, circle instanceof Polygon); + +// util.inherits with a class as the superCtor. +function Triangle(a, b, c) { + this.name = "triangle"; + this.points = [a, b, c]; +} +util.inherits(Triangle, Polygon); +Triangle.prototype.area = function () { + const [[x1, y1], [x2, y2], [x3, y3]] = this.points; + return Math.abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2); +}; +const triangle = new Triangle([0, 0], [4, 0], [0, 3]); +console.log(Triangle.super_ === Polygon, triangle instanceof Polygon); +console.log(triangle.describe(), triangle.perimeter()); + +// A plain object that adopts a class prototype (rehydrating parsed data). +const parsed = JSON.parse('{"name":"lot","points":[[0,0],[2,0],[2,1]]}'); +Object.setPrototypeOf(parsed, Polygon.prototype); +console.log(parsed.sides(), parsed.perimeter().toFixed(3), parsed.label, parsed instanceof Polygon); +const fresh = Object.create(Shape.prototype); +fresh.name = "blank"; +console.log(fresh.describe(), fresh instanceof Shape, Object.getPrototypeOf(fresh) === Shape.prototype); + +// Native instances keep their own methods and answers. +const native = new Polygon("native"); +native.points = [ + [0, 0], + [1, 0], + [0, 1], +]; +console.log(native.describe(), native.perimeter().toFixed(3), native instanceof Polygon); + +// util.inspect names each object by its prototype's constructor. +function Plain() { + this.v = 1; +} +Object.setPrototypeOf(Plain.prototype, Shape.prototype); +console.log(new Square(1).size, fresh, new Plain(), Object.prototype.toString.call(fresh)); +console.log(util.inspect(new Plain()), util.inspect(parsed, { depth: 0 })); + +// A subclass keeps its own constructor below a base whose prototype other +// objects inherit: `new this.constructor()` in a base method builds the +// subclass, also while the subclass's prototype holds a replaceable method. +class Disc extends Shape { + area() { + return 3; + } + rebuild() { + return new this.constructor("copy of " + this.name); + } +} +const areaValue = Shape.prototype.area; +const disc = new Disc("d"); +const rebuilt = disc.rebuild(); +console.log(disc.constructor === Disc, rebuilt instanceof Disc, rebuilt.area(), rebuilt.name, typeof areaValue); + +// Deleting a reflected prototype's constructor: instances read the +// constructor their base class's prototype holds, then Object's; a +// replacement answers again. +class Root { + hi() { + return "hi"; + } +} +class Middle extends Root {} +class Tip extends Middle {} +const members = [new Middle(), new Tip(), new Root(), {}]; +const constructorNames = () => members.map((member) => member.constructor.name).join(","); +console.log(constructorNames(), Object.getOwnPropertyNames(Object.getPrototypeOf(new Middle())).join("|")); +delete Object.getPrototypeOf(new Middle()).constructor; +console.log(constructorNames(), Object.getOwnPropertyNames(Object.getPrototypeOf(new Middle())).join("|")); +console.log(new Middle().constructor === Root, new Tip().constructor === Tip); +delete Object.getPrototypeOf(new Root()).constructor; +console.log(constructorNames(), new Middle().constructor === Object); +Middle.prototype.constructor = function Named() {}; +console.log(constructorNames()); + +// A reflected prototype whose constructor is an accessor: an instance's read runs the getter with the +// instance as `this`, also where the instance's class inherits it. +class Facet { + constructor(tag) { + this.tag = tag; + } +} +class Tile extends Facet {} +class Brick extends Tile {} +const facets = [new Tile("s"), new Brick("c"), new Facet("p")]; +const facetTags = () => + facets.map((facet) => (typeof facet.constructor === "string" ? facet.constructor : facet.constructor.name)).join(","); +Object.defineProperty(Tile.prototype, "constructor", { + get() { + return "on " + this.tag; + }, + configurable: true, +}); +delete Object.getPrototypeOf(new Brick("x")).constructor; +console.log(facetTags()); +delete Object.getPrototypeOf(new Tile("x")).constructor; +console.log(facetTags()); diff --git a/tests/corpus/8270-fs-promises-member.cjs b/tests/corpus/8270-fs-promises-member.cjs new file mode 100644 index 000000000..86448f012 --- /dev/null +++ b/tests/corpus/8270-fs-promises-member.cjs @@ -0,0 +1,25 @@ +'use strict'; +// fs.promises is node:fs/promises, as in Node: through a binding, an +// inline require, destructuring and a direct member call. +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptc-fsp-')); +const file = path.join(dir, 'data.txt'); +const fsPromises = fs.promises; +const { readFile, stat } = require('fs').promises; +const { open } = fs.promises; + +(async () => { + await fsPromises.writeFile(file, 'abc'); + await fs.promises.appendFile(file, 'def'); + console.log(await readFile(file, 'utf8')); + console.log((await stat(file)).size); + const handle = await open(file, 'r'); + const { bytesRead, buffer } = await handle.read(Buffer.alloc(3), 0, 3, 1); + console.log(bytesRead, buffer.toString()); + await handle.close(); + await fsPromises.rm(dir, { recursive: true }); + console.log(fs.existsSync(dir)); +})(); diff --git a/tests/corpus/8271-cjs-top-level-return.cjs b/tests/corpus/8271-cjs-top-level-return.cjs new file mode 100644 index 000000000..9f5cc6d23 --- /dev/null +++ b/tests/corpus/8271-cjs-top-level-return.cjs @@ -0,0 +1,20 @@ +'use strict'; +// A return at the top level of the entry CommonJS module ends its +// evaluation, as in Node's module wrapper: from a block, a loop and a try +// statement, whose finally block still runs. +const fs = require('fs'); + +console.log('start'); +for (const name of ['a', 'b', 'c']) { + if (name === 'c') { + try { + console.log('returning at', name); + process.on('exit', (code) => console.log('exit', code)); + if (!fs.existsSync('/scriptc-missing-path')) return; + } finally { + console.log('finally runs'); + } + } + console.log('visited', name); +} +console.log('not reached'); diff --git a/tests/corpus/8272-fs-stream-call-without-new.cjs b/tests/corpus/8272-fs-stream-call-without-new.cjs new file mode 100644 index 000000000..91fe0a6c0 --- /dev/null +++ b/tests/corpus/8272-fs-stream-call-without-new.cjs @@ -0,0 +1,21 @@ +'use strict'; +// fs.ReadStream and fs.WriteStream called without new construct their +// stream, as in Node. +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptc-streams-')); +const target = path.join(dir, 'out.txt'); +const writer = fs.WriteStream(target); +writer.on('finish', () => { + const reader = fs.ReadStream(target, { encoding: 'utf8' }); + let text = ''; + reader.on('data', (chunk) => (text += chunk)); + reader.on('end', () => { + console.log(text); + fs.rmSync(dir, { recursive: true }); + }); +}); +writer.write('hello '); +writer.end('streams'); diff --git a/tests/corpus/8273-fs-read-write-argument-checks.cjs b/tests/corpus/8273-fs-read-write-argument-checks.cjs new file mode 100644 index 000000000..b27564837 --- /dev/null +++ b/tests/corpus/8273-fs-read-write-argument-checks.cjs @@ -0,0 +1,80 @@ +'use strict'; +// fs.read, fs.readSync and fs.write check their arguments in Node's order +// and with Node's errors: the fd, the buffer, the offset, `length |= 0`, +// number and bigint positions, and the buffer window. A length with a +// valueOf method is converted once. +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptc-fs-args-')); +const file = path.join(dir, 'data.txt'); +fs.writeFileSync(file, 'abcdef'); +const fd = fs.openSync(file, 'r+'); +const noop = () => {}; + +function attempt(name, fn) { + try { + fn(); + console.log(name, 'ok'); + } catch (error) { + console.log(name, error.name, error.code, error.message); + } +} + +attempt('read offset -1', () => fs.read(fd, Buffer.alloc(3), -1, 3, 0, noop)); +attempt('read offset NaN', () => fs.read(fd, Buffer.alloc(3), NaN, 3, 0, noop)); +attempt('read length -1', () => fs.read(fd, Buffer.alloc(3), 0, -1, 0, noop)); +attempt('read length 4', () => fs.read(fd, Buffer.alloc(3), 0, 4, 0, noop)); +attempt('read length 1.5', () => fs.read(fd, Buffer.alloc(3), 0, 1.5, 0, noop)); +attempt('read length "x"', () => fs.read(fd, Buffer.alloc(3), 0, 'x', 0, noop)); +attempt('read position 1.5', () => fs.read(fd, Buffer.alloc(3), 0, 3, 1.5, noop)); +attempt('read position -2', () => fs.read(fd, Buffer.alloc(3), 0, 3, -2, noop)); +attempt('read position ""', () => fs.read(fd, Buffer.alloc(3), 0, 3, '', noop)); +attempt('read position 2n ** 63n', () => fs.read(fd, Buffer.alloc(3), 0, 3, 2n ** 63n, noop)); +attempt('read position -2n', () => fs.read(fd, Buffer.alloc(3), 0, 3, -2n, noop)); +attempt('read empty buffer', () => fs.read(fd, Buffer.alloc(0), 0, 1, 0, noop)); +attempt('read fd true', () => fs.read(true, Buffer.alloc(3), 0, 3, 0, noop)); +attempt('read buffer number', () => fs.read(fd, 3, 0, 3, 0, noop)); + +const read = (position) => { + const buffer = Buffer.alloc(3); + const count = fs.readSync(fd, buffer, 0, 3, position); + return `${count} ${buffer.toString()}`; +}; +attempt('readSync position 1n', () => console.log(read(1n))); +attempt('readSync position 0n', () => console.log(read(0n))); +attempt('readSync position 2n ** 63n', () => read(2n ** 63n)); +attempt('readSync offset 4', () => fs.readSync(fd, Buffer.alloc(3), 4, 1, 0)); +attempt('readSync buffer number', () => fs.readSync(fd, 3, 0, 1, 0)); +attempt('readSync fd null', () => fs.readSync(null, Buffer.alloc(3), 0, 1, 0)); +console.log(fs.readSync(fd, Buffer.alloc(3), 0, 0.5, 0), fs.readSync(fd, Buffer.alloc(3), 0, 2.5, 0)); +let syncCalls = 0; +const syncLength = { valueOf: () => (++syncCalls === 1 ? 2 : 3) }; +const syncTarget = Buffer.alloc(3); +const syncCount = fs.readSync(fd, syncTarget, 0, syncLength, 0); +console.log('readSync valueOf', syncCount, syncCalls, syncTarget.toString('utf8', 0, syncCount)); + +attempt('write fd null', () => fs.write(null, Buffer.alloc(1), 0, 1, noop)); +attempt('write fd "1"', () => fs.write('1', 'x', noop)); +attempt('write offset -1', () => fs.write(fd, Buffer.alloc(0), -1, noop)); +attempt('write offset NaN', () => fs.write(fd, Buffer.from('abcd'), NaN, 4, 0, noop)); +attempt('write offset 5', () => fs.write(fd, Buffer.from('abcd'), 5, noop)); +attempt('write length 5', () => fs.write(fd, Buffer.from('abcd'), 0, 5, 0, noop)); +attempt('write length -1', () => fs.write(fd, Buffer.alloc(0), 0, -1, noop)); +attempt('write data number', () => fs.write(fd, 4, noop)); +attempt('write no callback', () => fs.write(fd, Buffer.from('a'), 0, 1, 0)); + +fs.read(fd, Buffer.alloc(2), 0, 2, 2n, (error, count, buffer) => { + console.log('read 2n', error, count, buffer.toString()); + fs.write(fd, Buffer.from('XY'), 0, 2, 0, (error2, written) => { + console.log('wrote', error2, written, fs.readFileSync(file, 'utf8')); + let calls = 0; + const length = { valueOf: () => (++calls === 1 ? 1 : 3) }; + fs.read(fd, Buffer.alloc(3), 0, length, 0, (error3, count3, buffer3) => { + console.log('read valueOf', error3, count3, calls, buffer3.toString('utf8', 0, count3)); + fs.closeSync(fd); + fs.rmSync(dir, { recursive: true }); + }); + }); +}); diff --git a/tests/corpus/8291-frozen-record-union-keys.ts b/tests/corpus/8291-frozen-record-union-keys.ts new file mode 100644 index 000000000..b6173ef4e --- /dev/null +++ b/tests/corpus/8291-frozen-record-union-keys.ts @@ -0,0 +1,65 @@ +// Frozen records in record-only unions used as Set elements and Map keys: +// the union's release frees a frozen record when it drops the last +// reference, for unions the cycle collector traces and ones it does not. +type Leaf = { kind: "leaf"; label: string }; +type Pair = { kind: "pair"; left: Node; right: Node }; +type Node = Leaf | Pair; + +type Name = { kind: "name"; text: string }; +type Count = { kind: "count"; value: number; unit: string }; +type Key = Name | Count; + +function leaf(label: string): Node { + const built: Leaf = { kind: "leaf", label }; + return Object.freeze(built); +} + +function pair(left: Node, right: Node): Node { + const built: Pair = { kind: "pair", left, right }; + return Object.freeze(built); +} + +function key(i: number): Key { + if (i % 2 === 0) { + const name: Name = { kind: "name", text: "n" + i }; + return Object.freeze(name); + } + const count: Count = { kind: "count", value: i, unit: "u" + i }; + return Object.freeze(count); +} + +function nodes(n: number): number { + const seen = new Set(); + const weight = new Map(); + let last = leaf("start"); + for (let i = 0; i < n; i++) { + const next = i % 3 === 0 ? leaf("l" + i) : pair(last, leaf("r" + i)); + seen.add(next); + weight.set(next, i); + last = next; + } + let total = 0; + for (const node of seen) total += (weight.get(node) ?? 0) + (node.kind === "pair" ? 1 : 0); + seen.clear(); + weight.clear(); + return total + (last.kind === "pair" ? 1 : 0); +} + +function keys(n: number): number { + const seen = new Set(); + const size = new Map(); + for (let i = 0; i < n; i++) { + const k = key(i); + seen.add(k); + size.set(k, k.kind === "name" ? k.text.length : k.value); + } + let total = 0; + for (const k of seen) total += size.get(k) ?? 0; + seen.clear(); + size.clear(); + return total; +} + +let sum = 0; +for (let r = 0; r < 50; r++) sum += nodes(40) + keys(40); +console.log(sum); diff --git a/tests/corpus/8300-native-array-capsule-reads/lib.cjs b/tests/corpus/8300-native-array-capsule-reads/lib.cjs new file mode 100644 index 000000000..6f386216b --- /dev/null +++ b/tests/corpus/8300-native-array-capsule-reads/lib.cjs @@ -0,0 +1,36 @@ +"use strict"; +// Reads and writes the arrays the typed program passes in. +exports.total = function (xs) { + let s = 0; + for (let i = 0; i < xs.length; i++) s += xs[i]; + return s; +}; +exports.at = function (xs, i) { + return xs[i]; +}; +exports.describe = function (xs, keys) { + const out = []; + for (const k of keys) { + const v = xs[k]; + out.push(typeof v === "number" && Object.is(v, -0) ? "-0" : String(v)); + } + return out.join(" "); +}; +exports.has = function (xs, i) { + return i in xs; +}; +exports.write = function (xs, i, v) { + xs[i] = v; + return xs[i]; +}; +exports.grow = function (xs, v) { + xs.push(v); + return xs.length; +}; +exports.cut = function (xs, n) { + xs.length = n; + return xs.length; +}; +exports.lengthOf = function (xs) { + return xs.length; +}; diff --git a/tests/corpus/8300-native-array-capsule-reads/main.ts b/tests/corpus/8300-native-array-capsule-reads/main.ts new file mode 100644 index 000000000..5b1513ee4 --- /dev/null +++ b/tests/corpus/8300-native-array-capsule-reads/main.ts @@ -0,0 +1,59 @@ +// A typed program passes its arrays to untyped CommonJS functions, which +// read their elements and lengths, write them and resize them. Element reads +// cover integral, negative, fractional, non-finite and out-of-range indexes, +// string keys, holes, frozen arrays and arrays of other element types. +import { createRequire } from "node:module"; + +interface Lib { + total(xs: readonly unknown[]): number; + at(xs: readonly unknown[], i: unknown): unknown; + describe(xs: readonly unknown[], keys: unknown[]): string; + has(xs: readonly unknown[], i: number): boolean; + write(xs: unknown[], i: number, v: unknown): unknown; + grow(xs: unknown[], v: unknown): number; + cut(xs: unknown[], n: number): number; + lengthOf(xs: readonly unknown[]): number; +} +const require = createRequire(import.meta.url); +const lib: Lib = require("./lib.cjs"); + +// Element reads of a number[] at integral, negative, fractional, +// non-finite and out-of-range indexes, and through string keys. +const nums: number[] = [1, 2.5, -0, NaN, Infinity, -7]; +console.log(lib.total([1, 2, 3, 4]), lib.total(nums), lib.total([])); +console.log(lib.describe(nums, [0, 1, 2, 3, 4, 5, 6, -1, 1.5, -0, NaN, Infinity, 2 ** 32, "1", "01", "length"])); +console.log(lib.at(nums, 5), lib.at(nums, 6), typeof lib.at(nums, 2), Object.is(lib.at(nums, 2), -0)); + +// Holes: below and past the hole-free prefix, and an array cut longer. +const holey: number[] = [1, 2]; +holey[4] = 5; +console.log(lib.describe(holey, [0, 1, 2, 3, 4, 5]), lib.has(holey, 3), lib.has(holey, 4), lib.lengthOf(holey)); +const longer: number[] = [3, 4, 5]; +longer.length = 6; +console.log(lib.describe(longer, [0, 2, 3, 5]), lib.lengthOf(longer)); + +// Writes from the package and from the program are seen by both. +const live: number[] = [10, 20, 30]; +console.log(lib.write(live, 0, 11), lib.write(live, 3, 40), lib.grow(live, 50), live.join(",")); +live.push(60); +live[1] = 21; +console.log(lib.total(live), lib.lengthOf(live), lib.at(live, 5)); +console.log(lib.cut(live, 2), lib.total(live), live.length, lib.at(live, 2)); + +// Frozen and empty arrays. +const frozen: readonly number[] = Object.freeze([4, 5, 6]); +console.log(lib.total(frozen), lib.at(frozen, 1), lib.at([], 0), lib.lengthOf([])); + +// Arrays of other element types read through the same sites. +const words: string[] = ["a", "b", "c"]; +const flags: boolean[] = [true, false]; +const points: { x: number }[] = [{ x: 1 }, { x: 2 }]; +console.log(lib.describe(words, [0, 2, 3]), lib.describe(flags, [0, 1, 2]), (lib.at(points, 1) as { x: number }).x); +console.log(lib.at(words, 1.5), lib.has(words, 2), lib.lengthOf(points)); + +// A loop over a larger array. +const big: number[] = []; +for (let i = 0; i < 1000; i++) big.push(i % 7); +let sum = 0; +for (let round = 0; round < 50; round++) sum += lib.total(big); +console.log(sum); diff --git a/tests/corpus/8300-native-array-capsule-reads/package.json b/tests/corpus/8300-native-array-capsule-reads/package.json new file mode 100644 index 000000000..5ffd9800b --- /dev/null +++ b/tests/corpus/8300-native-array-capsule-reads/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/tests/corpus/8302-class-super-method-values/lib.cjs b/tests/corpus/8302-class-super-method-values/lib.cjs new file mode 100644 index 000000000..4a8b1640a --- /dev/null +++ b/tests/corpus/8302-class-super-method-values/lib.cjs @@ -0,0 +1,33 @@ +"use strict"; +// Subclasses whose methods call super, read and called through computed and +// literal keys, as values called with call(), and directly. +class Base { + constructor(n) { this.n = n; } + scale(k) { return this.n * k; } + label() { return "base:" + this.n; } + plain(k) { return k; } +} +class Child extends Base { + scale(k) { return super.scale(k) * 10; } + label() { return "child(" + super.label() + ")"; } + plain(k) { return k * 100; } +} +class Grandchild extends Child { + scale(k) { return super.scale(k) + 1; } +} +class Sibling extends Base { + scale(k) { return -k; } + plain(k) { return super.plain(k) + 0.5; } +} +exports.make = function (i) { + switch (i % 4) { + case 0: return new Base(i); + case 1: return new Child(i); + case 2: return new Grandchild(i); + default: return new Sibling(i); + } +}; +exports.computed = function (value, name, arg) { return value[name](arg); }; +exports.viaCall = function (value, name, arg) { const f = value[name]; return f.call(value, arg); }; +exports.literal = function (value, arg) { return [value["scale"](arg), value["plain"](arg), value["label"]()].join(" "); }; +exports.direct = function (value, arg) { return [value.scale(arg), value.plain(arg), value.label()].join(" "); }; diff --git a/tests/corpus/8302-class-super-method-values/main.ts b/tests/corpus/8302-class-super-method-values/main.ts new file mode 100644 index 000000000..1a7edbb49 --- /dev/null +++ b/tests/corpus/8302-class-super-method-values/main.ts @@ -0,0 +1,26 @@ +// Methods of JavaScript subclasses that call super, read from instances +// through computed keys, literal keys and as values, and called directly. +import { createRequire } from "node:module"; + +interface Lib { + make(i: number): unknown; + computed(value: unknown, name: string, arg: number): unknown; + viaCall(value: unknown, name: string, arg: number): unknown; + literal(value: unknown, arg: number): string; + direct(value: unknown, arg: number): string; +} +const require = createRequire(import.meta.url); +const lib: Lib = require("./lib.cjs"); + +for (let i = 0; i < 8; i++) { + const value = lib.make(i); + console.log( + i, + lib.computed(value, "scale", 3), + lib.computed(value, "plain", 3), + lib.computed(value, "label", 0), + lib.viaCall(value, "scale", 2), + lib.viaCall(value, "label", 0), + ); + console.log(i, lib.literal(value, 5), "|", lib.direct(value, 5)); +} diff --git a/tests/corpus/8302-class-super-method-values/package.json b/tests/corpus/8302-class-super-method-values/package.json new file mode 100644 index 000000000..5ffd9800b --- /dev/null +++ b/tests/corpus/8302-class-super-method-values/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/tests/corpus/8320-assign-only-deferred-binding/main.ts b/tests/corpus/8320-assign-only-deferred-binding/main.ts new file mode 100644 index 000000000..d35a863ae --- /dev/null +++ b/tests/corpus/8320-assign-only-deferred-binding/main.ts @@ -0,0 +1,8 @@ +import { installCount, useFormat, useScanner } from "./scanner.ts"; + +useScanner((bytes, start) => start + bytes.length * 2); +console.log("scanner installed"); +const formatter = useFormat((value) => `[${value}]`); +console.log(formatter(7)); +useScanner((bytes, start) => start); +console.log("installs", installCount()); diff --git a/tests/corpus/8320-assign-only-deferred-binding/scanner.ts b/tests/corpus/8320-assign-only-deferred-binding/scanner.ts new file mode 100644 index 000000000..601df60ba --- /dev/null +++ b/tests/corpus/8320-assign-only-deferred-binding/scanner.ts @@ -0,0 +1,21 @@ +// Module bindings that only assignments use after their declarations. +export function defaultEnd(bytes: Uint8Array, start: number): number { + return start + bytes.length; +} + +let valueEnd: (bytes: Uint8Array, start: number) => number = defaultEnd; +let format: (value: number) => string = (value) => `#${value}`; +let installs = 0; + +export function useScanner(scan: (bytes: Uint8Array, start: number) => number): void { + valueEnd = (bytes, start) => scan(bytes, start) + 1; + installs++; +} + +export function useFormat(next: (value: number) => string): (value: number) => string { + return (format = next); +} + +export function installCount(): number { + return installs; +} diff --git a/tests/corpus/8326-never-promise-instanceof.ts b/tests/corpus/8326-never-promise-instanceof.ts new file mode 100644 index 000000000..6e01e3510 --- /dev/null +++ b/tests/corpus/8326-never-promise-instanceof.ts @@ -0,0 +1,26 @@ +// `instanceof Promise` on values that are never promises (records, arrays, +// dates, collections, regular expressions, byte arrays, null, and unions of +// them) is false, after the operand is evaluated. +let evaluated = 0; +function next(): { v: number } { + evaluated++; + return { v: evaluated }; +} + +const record = { v: 1 }; +console.log("record", record instanceof Promise); +console.log("call", next() instanceof Promise, evaluated); +console.log("array", [1, 2] instanceof Promise); +console.log("date", new Date(0) instanceof Promise); +console.log("map", new Map() instanceof Promise); +console.log("set", new Set() instanceof Promise); +console.log("regex", /a/ instanceof Promise); +console.log("bytes", new Uint8Array(2) instanceof Promise); + +function pick(i: number): { v: number } | number[] | null { + return i === 0 ? { v: 0 } : i === 1 ? [i] : null; +} +for (let i = 0; i < 3; i++) console.log("union", i, pick(i) instanceof Promise); + +const promised: unknown = Promise.resolve(1); +console.log("promise", promised instanceof Promise); diff --git a/tests/corpus/8330-optional-element-undefined-receivers.ts b/tests/corpus/8330-optional-element-undefined-receivers.ts new file mode 100644 index 000000000..b9433e66c --- /dev/null +++ b/tests/corpus/8330-optional-element-undefined-receivers.ts @@ -0,0 +1,75 @@ +// Optional element access on bindings whose type admits undefined: an +// undefined receiver short-circuits to undefined without evaluating the +// key, and a present receiver reads the element as a plain access does. + +function first(kinds: string[] | undefined, i: number): string { + const kind = kinds?.[i]; + return kind === undefined ? "none" : String(kind); +} +console.log(first(undefined, 0)); +console.log(first(["a", "b"], 1)); +console.log(first(["a"], 5)); + +function orDefault(values: number[] | undefined, i: number): number { + return values?.[i] ?? -1; +} +console.log(orDefault(undefined, 0), orDefault([4, 5], 1), orDefault([4], 3)); + +function isMissing(flags: boolean[] | undefined, i: number): boolean { + return flags?.[i] === undefined; +} +console.log(isMissing(undefined, 0), isMissing([true], 0), isMissing([false], 2)); + +function head(pair: [string, number] | undefined): string { + return pair?.[0] ?? "empty"; +} +console.log(head(undefined), head(["x", 1])); + +function lookup(table: Record | undefined, key: string): number | undefined { + return table?.[key]; +} +console.log(lookup(undefined, "a"), lookup({ a: 1 }, "a"), lookup({ a: 1 }, "b")); + +function width(words: string[] | undefined, i: number): number { + return words?.[i]?.length ?? 0; +} +console.log(width(undefined, 0), width(["abc"], 0), width(["abc"], 1)); + +// The key is not evaluated when the receiver is undefined. +let keys = 0; +function nextKey(): number { + keys++; + return 0; +} +function counted(items: string[] | undefined): string | undefined { + return items?.[nextKey()]; +} +console.log(counted(undefined), keys); +console.log(counted(["k"]), keys); + +// Local bindings, including one reassigned between reads. +function maybe(present: boolean): number[] | undefined { + return present ? [7, 8] : undefined; +} +let current = maybe(false); +console.log(current?.[0]); +current = maybe(true); +console.log(current?.[1]); +current = maybe(false); +console.log(current?.[1]); + +function local(present: boolean): string { + const items: string[] | undefined = present ? ["p", "q"] : undefined; + const second = items?.[1]; + return second ?? "absent"; +} +console.log(local(true), local(false)); + +// A sparse array reads its holes as undefined. +const sparse: number[] = [1]; +sparse[3] = 4; +function at(values: number[] | undefined, i: number): string { + const value = values?.[i]; + return value === undefined ? "hole" : String(value); +} +console.log(at(sparse, 1), at(sparse, 3), at(undefined, 3)); diff --git a/tests/corpus/8344-readline-chunk-splitting.ts b/tests/corpus/8344-readline-chunk-splitting.ts new file mode 100644 index 000000000..85eff8cdc --- /dev/null +++ b/tests/corpus/8344-readline-chunk-splitting.ts @@ -0,0 +1,41 @@ +// Line splitting over stream chunks: an unfinished line joined across many +// chunks, a line longer than a chunk, empty lines, LF-only +// chunks next to chunks with CR and CRLF, an ASCII chunk after a chunk that +// ends inside a UTF-8 sequence, and text written with rl.write(). Each line +// event shows the line's length and its first and last characters. +import { createInterface } from "node:readline"; +import { Readable } from "node:stream"; + +function show(tag: string, line: string): void { + console.log(tag, line.length, JSON.stringify(line.slice(0, 12)), JSON.stringify(line.slice(-6))); +} + +function feed(tag: string, chunks: (string | Buffer)[]): Promise { + return new Promise((resolve) => { + const src = new Readable({ read() {} }); + const rl = createInterface({ input: src, crlfDelay: Infinity }); + rl.on("line", (line: string) => show(tag, line)); + rl.on("close", () => { + console.log(tag, "closed"); + resolve(); + }); + for (const chunk of chunks) src.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk); + src.push(null); + }); +} + +const long = "x".repeat(70000) + "y"; +await feed("joined", ["par", "tial ", "line", " continues", "\nnext\n", "tail"]); +await feed("long", [long.slice(0, 30000), long.slice(30000, 65536), long.slice(65536) + "\nshort\n"]); +await feed("empty", ["\n\n", "a\n\n", "\n", "b"]); +await feed("mixed", ["lf only\nstill lf\n", "cr\rcrlf\r\nlf\n", "end\r", "\nafter\r", "\r\n", "done"]); +const euro = Buffer.from("€uro\n", "utf8"); +await feed("utf8", [euro.subarray(0, 2), Buffer.concat([euro.subarray(2), Buffer.from("plain ascii\nmore")])]); +await feed("utf8-lines", ["café one\nnaïve two\n", "ascii three\n", "中文 four"]); + +const writer = createInterface({ input: new Readable({ read() {} }), crlfDelay: Infinity }); +writer.on("line", (line: string) => show("write", line)); +writer.write("first part "); +writer.write("second part\nthird\r\nfourth\rfifth"); +writer.write("\n"); +writer.close(); diff --git a/tests/corpus/absent-property-reads.mjs b/tests/corpus/absent-property-reads.mjs new file mode 100644 index 000000000..cd568b11c --- /dev/null +++ b/tests/corpus/absent-property-reads.mjs @@ -0,0 +1,87 @@ +// Reads of properties an object lacks, from the same untyped sites many +// times: they answer undefined until the object, its prototype, or the +// prototype link changes, and Object.prototype members still answer. +function read(object) { + return object.flag; +} +function readName(object) { + return object.name; +} +function describe(object) { + return [object.toString === Object.prototype.toString, typeof object.constructor, typeof object.hasOwnProperty].join(); +} +function callIt(object) { + try { + return object.run(); + } catch (e) { + return `${e.name}: ${e.message}`; + } +} + +const seen = []; +const plain = { name: "plain" }; +for (let i = 0; i < 3; i++) seen.push(read(plain)); +plain.flag = "added"; +seen.push(read(plain)); +delete plain.flag; +seen.push(read(plain)); +console.log(seen.join()); + +// A prototype that gains, changes and loses the key. +const base = { kind: "base" }; +const child = Object.create(base); +child.name = "child"; +const fromProto = []; +for (let i = 0; i < 3; i++) fromProto.push(read(child)); +base.flag = "inherited"; +fromProto.push(read(child)); +base.flag = "changed"; +fromProto.push(read(child)); +delete base.flag; +fromProto.push(read(child)); +console.log(fromProto.join()); + +// Objects of one shape with different prototypes, read from one site. +const left = Object.create({ flag: "left" }); +const right = Object.create({ other: 1 }); +left.name = "l"; +right.name = "r"; +const mixed = []; +for (let i = 0; i < 3; i++) mixed.push(read(left), read(right)); +console.log(mixed.join()); + +// Changing the prototype link of an object whose absent read was cached. +const moved = { name: "moved" }; +for (let i = 0; i < 3; i++) read(moved); +Object.setPrototypeOf(moved, { flag: "after setPrototypeOf" }); +console.log(read(moved)); +Object.setPrototypeOf(moved, null); +console.log(read(moved), readName(moved)); + +// A longer prototype chain. +const grand = { flag: "grand" }; +const parent = Object.create(grand); +const deep = Object.create(parent); +console.log(read(deep), read(Object.create(Object.create({})))); + +// Object.prototype members and missing methods still answer as in Node. +const bare = { name: "bare" }; +for (let i = 0; i < 3; i++) describe(bare); +console.log(describe(bare), describe({ other: 2 })); +for (let i = 0; i < 3; i++) callIt(bare); +console.log(callIt(bare)); +bare.run = () => "ran"; +console.log(callIt(bare)); + +// Many shapes through one site. +const shapes = []; +for (let i = 0; i < 12; i++) { + const object = { name: `s${i}` }; + object[`k${i}`] = i; + if (i % 3 === 0) object.flag = i; + shapes.push(object); +} +let total = 0; +for (let round = 0; round < 4; round++) + for (const object of shapes) total += read(object) === undefined ? 1 : 100; +console.log(total, shapes.map(readName).join()); diff --git a/tests/corpus/symbol-property-key-reuse.mjs b/tests/corpus/symbol-property-key-reuse.mjs new file mode 100644 index 000000000..f4fc2a0c4 --- /dev/null +++ b/tests/corpus/symbol-property-key-reuse.mjs @@ -0,0 +1,69 @@ +// Symbol-keyed properties read and written many times: on plain objects, on +// prototypes and class instances, with Symbol.for symbols, and with symbols +// created in a loop (each a distinct key, even after earlier ones are +// unreachable), plus the same symbol converted to a checked value repeatedly. +const tag = Symbol("tag"); +const shared = Symbol.for("shared"); +const proto = { [tag]: "from proto" }; +const child = Object.create(proto); +child[shared] = 0; +for (let i = 0; i < 1000; i++) child[shared] += i; +console.log(child[tag], child[shared], Object.getOwnPropertySymbols(child).length); + +const count = Symbol("count"); +class Counter { + constructor() { + this[count] = 0; + } + bump() { + this[count]++; + return this; + } + get count() { + return this[count]; + } +} +const c = new Counter(); +for (let i = 0; i < 500; i++) c.bump(); +console.log(c.count, c[count], count.toString()); + +const keys = []; +const holder = {}; +for (let i = 0; i < 300; i++) { + const key = Symbol(`k${i}`); + holder[key] = i; + if (i % 100 === 0) keys.push(key); +} +let sum = 0; +for (const key of Object.getOwnPropertySymbols(holder)) sum += holder[key]; +console.log(sum, keys.map((k) => holder[k]).join(), keys[1].description); + +// Symbols that become unreachable between rounds: a new symbol never reads +// a property stored under an earlier one. +const scratch = {}; +let stale = 0; +for (let round = 0; round < 200; round++) { + const key = Symbol("round"); + if (scratch[key] !== undefined) stale++; + scratch[key] = round; +} +console.log(stale, Object.getOwnPropertySymbols(scratch).length); + +// A symbol stored, deleted and dropped each round, so its storage can be +// reused by the next round's symbol: the new symbol still reads nothing. +const persist = {}; +let reused = 0; +for (let round = 0; round < 200; round++) { + const key = Symbol("dropped"); + if (key in persist) reused++; + persist[key] = round; + delete persist[key]; +} +console.log(reused, Object.getOwnPropertySymbols(persist).length); + +const viaFor = {}; +for (let i = 0; i < 10; i++) viaFor[Symbol.for("same")] = i; +console.log(viaFor[Symbol.for("same")], Object.getOwnPropertySymbols(viaFor).length); +console.log(Symbol.for("same") === Symbol.for("same"), Symbol("x") === Symbol("x"), tag === tag); +const boxes = [tag, tag, shared]; +console.log(boxes[0] === boxes[1], boxes.indexOf(shared), new Set(boxes).size); diff --git a/tests/corpus/text-decoder-undefined-input.ts b/tests/corpus/text-decoder-undefined-input.ts new file mode 100644 index 000000000..32bca7f3a --- /dev/null +++ b/tests/corpus/text-decoder-undefined-input.ts @@ -0,0 +1,38 @@ +// TextDecoder.decode(undefined) decodes no bytes, as decode() does: it +// returns "" and, after streamed input, flushes the pending bytes. +const decoder = new TextDecoder(); +const encoded = new TextEncoder().encode("bytes"); +const inputs = new Map([ + ["present", encoded], + ["absent", undefined], +]); + +console.log(JSON.stringify(decoder.decode(undefined))); +console.log(JSON.stringify(decoder.decode(inputs.get("present"))), JSON.stringify(decoder.decode(inputs.get("absent")))); +console.log(JSON.stringify(decoder.decode(inputs.get("absent")!)), JSON.stringify(decoder.decode(inputs.get("missing")!))); +console.log(JSON.stringify(new TextDecoder("utf-8").decode(undefined))); +console.log(JSON.stringify(new TextDecoder("utf-8").decode(inputs.get("absent")))); + +let calls = 0; +function nothing(): undefined { + calls++; + return undefined; +} +console.log(JSON.stringify(decoder.decode(nothing())), calls); + +interface Chunk { + done: boolean; + value?: Uint8Array; +} +const chunks: Chunk[] = [{ done: false, value: new Uint8Array([0xe2, 0x82]) }, { done: true }]; +const streaming = new TextDecoder(); +let text = ""; +for (const chunk of chunks) text += streaming.decode(chunk.value, { stream: !chunk.done }); +console.log(JSON.stringify(text), text.length); + +// Other typed arrays decode their bytes, with or without the assertion. +const wide = new Map([["present", new Uint16Array([0x6968])]]); +const asserted = wide.get("present")!; +console.log(JSON.stringify(decoder.decode(asserted)), JSON.stringify(decoder.decode(wide.get("present")!))); +console.log(JSON.stringify(decoder.decode(wide.get("present"))), JSON.stringify(decoder.decode(wide.get("absent")))); +console.log(JSON.stringify(new TextDecoder("utf-8").decode(wide.get("absent")!))); diff --git a/tests/coverage-fixtures/cjs-mutable-exports/main.js b/tests/coverage-fixtures/cjs-mutable-exports/main.js new file mode 100644 index 000000000..ac16cc4ec --- /dev/null +++ b/tests/coverage-fixtures/cjs-mutable-exports/main.js @@ -0,0 +1,5 @@ +const reassigned = require('./reassigned'); +const written = require('./written'); +const redeclared = require('./redeclared'); +const stable = require('./stable'); +console.log(reassigned.n, written.tab, redeclared.twice, stable.n); diff --git a/tests/coverage-fixtures/cjs-mutable-exports/package.json b/tests/coverage-fixtures/cjs-mutable-exports/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/coverage-fixtures/cjs-mutable-exports/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/coverage-fixtures/cjs-mutable-exports/reassigned.js b/tests/coverage-fixtures/cjs-mutable-exports/reassigned.js new file mode 100644 index 000000000..6c77f3b43 --- /dev/null +++ b/tests/coverage-fixtures/cjs-mutable-exports/reassigned.js @@ -0,0 +1,4 @@ +// Reassigned after the export: Node's importers keep the first object. +var counter = { n: 1 }; +module.exports = counter; +counter = { n: 2 }; diff --git a/tests/coverage-fixtures/cjs-mutable-exports/redeclared.js b/tests/coverage-fixtures/cjs-mutable-exports/redeclared.js new file mode 100644 index 000000000..3150903f6 --- /dev/null +++ b/tests/coverage-fixtures/cjs-mutable-exports/redeclared.js @@ -0,0 +1,4 @@ +// Declared twice: the second declaration assigns the binding again. +var twice = 1; +module.exports = { twice }; +var twice = 2; diff --git a/tests/coverage-fixtures/cjs-mutable-exports/stable.js b/tests/coverage-fixtures/cjs-mutable-exports/stable.js new file mode 100644 index 000000000..c2846bbc9 --- /dev/null +++ b/tests/coverage-fixtures/cjs-mutable-exports/stable.js @@ -0,0 +1,3 @@ +// Initialized before the export and never assigned. +var stable = { n: 3 }; +module.exports = stable; diff --git a/tests/coverage-fixtures/cjs-mutable-exports/written.js b/tests/coverage-fixtures/cjs-mutable-exports/written.js new file mode 100644 index 000000000..8e0c29cd2 --- /dev/null +++ b/tests/coverage-fixtures/cjs-mutable-exports/written.js @@ -0,0 +1,4 @@ +// Written by a function: the table holds the value at the export. +let tab = ' '; +function setTab(next) { tab = next; } +module.exports = { tab, setTab }; diff --git a/tests/coverage-fixtures/cjs-namespace-reexports/impl.cjs b/tests/coverage-fixtures/cjs-namespace-reexports/impl.cjs new file mode 100644 index 000000000..3172d6685 --- /dev/null +++ b/tests/coverage-fixtures/cjs-namespace-reexports/impl.cjs @@ -0,0 +1,6 @@ +"use strict"; +exports.version = 1; +exports.current = null; +exports.set = (value) => { + exports.current = value; +}; diff --git a/tests/coverage-fixtures/cjs-namespace-reexports/main.mjs b/tests/coverage-fixtures/cjs-namespace-reexports/main.mjs new file mode 100644 index 000000000..2ce528d48 --- /dev/null +++ b/tests/coverage-fixtures/cjs-namespace-reexports/main.mjs @@ -0,0 +1,7 @@ +// A namespace import of a CommonJS wrapper that re-exports another module: +// Node's namespace keeps impl.cjs's values from the end of its evaluation, +// so `ns.current` reads null after `ns.set(5)`. +import * as ns from "./wrapper.cjs"; + +ns.set(5); +console.log(ns.current, ns.version); diff --git a/tests/coverage-fixtures/cjs-namespace-reexports/package.json b/tests/coverage-fixtures/cjs-namespace-reexports/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/coverage-fixtures/cjs-namespace-reexports/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/coverage-fixtures/cjs-namespace-reexports/wrapper.cjs b/tests/coverage-fixtures/cjs-namespace-reexports/wrapper.cjs new file mode 100644 index 000000000..ab637bab5 --- /dev/null +++ b/tests/coverage-fixtures/cjs-namespace-reexports/wrapper.cjs @@ -0,0 +1,2 @@ +"use strict"; +module.exports = require("./impl.cjs"); diff --git a/tests/coverage-fixtures/node-islands-config/main.ts b/tests/coverage-fixtures/node-islands-config/main.ts new file mode 100644 index 000000000..5bacea88a --- /dev/null +++ b/tests/coverage-fixtures/node-islands-config/main.ts @@ -0,0 +1,4 @@ +// Unknown values in scriptc.packages or scriptc.nodeIslands are SC8004: a +// build warns about them and goes on (it does not use the settings), and +// coverage reports no Node islands until they are valid. +console.log("configured"); diff --git a/tests/coverage-fixtures/node-islands-config/package.json b/tests/coverage-fixtures/node-islands-config/package.json new file mode 100644 index 000000000..1a01afa30 --- /dev/null +++ b/tests/coverage-fixtures/node-islands-config/package.json @@ -0,0 +1,14 @@ +{ + "private": true, + "type": "module", + "scriptc": { + "packages": { + "encoding": "unloded", + "debug": { "run": "island" } + }, + "nodeIslands": { + "auto": "yes", + "mode": "auto" + } + } +} diff --git a/tests/coverage-fixtures/node-islands-user-js.cjs b/tests/coverage-fixtures/node-islands-user-js.cjs new file mode 100644 index 000000000..c01f8c255 --- /dev/null +++ b/tests/coverage-fixtures/node-islands-user-js.cjs @@ -0,0 +1,22 @@ +// User JavaScript with one R2 construct per rule. With --strict-user-js, +// coverage reports each as the SC81xx error scope B makes it; without the +// flag the report is unchanged. +function Point(x) { + this.x = x; +} +Point.prototype.norm = function norm() { + return Math.abs(this.x); +}; +const base = { kind: "base" }; +const child = {}; +Object.setPrototypeOf(child, base); +const box = {}; +Object.defineProperty(box, "size", { value: 3, enumerable: false }); +function legacy(a) { + arguments[0] = 2; + return a; +} +Array.prototype.last = function last() { + return this[this.length - 1]; +}; +console.log(new Point(-2).norm(), child.kind, box.size, legacy(1)); diff --git a/tests/coverage-fixtures/node-islands/main.ts b/tests/coverage-fixtures/node-islands/main.ts new file mode 100644 index 000000000..597ba57cf --- /dev/null +++ b/tests/coverage-fixtures/node-islands/main.ts @@ -0,0 +1,16 @@ +// Node islands, report-only: one npm package per island class, a package +// that runs inside an island's closure, one reached from both sides, and +// one that stays native. Coverage reports the islands a build would +// choose; the build itself is unchanged. +import { describe } from "ni-gap"; +import { compile } from "ni-eval"; +import { Counter } from "ni-ctor"; +import { shout } from "ni-bundled"; +import { twice } from "ni-typed"; + +console.log(describe()); +console.log(compile("a + 1")(2)); +const counter = new Counter(); +counter.add(twice(2)); +console.log(counter.total()); +console.log(shout("hi")); diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-bundled/index.d.ts b/tests/coverage-fixtures/node-islands/node_modules/ni-bundled/index.d.ts new file mode 100644 index 000000000..ccebdab51 --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-bundled/index.d.ts @@ -0,0 +1 @@ +export declare function shout(s: string): string; diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-bundled/index.js b/tests/coverage-fixtures/node-islands/node_modules/ni-bundled/index.js new file mode 100644 index 000000000..2e88085be --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-bundled/index.js @@ -0,0 +1,15 @@ +// A webpack bundle: its build-transform markers keep it out of the native +// subset as shipped (a packaging island). +var __webpack_modules__ = { + 1: function (module) { + module.exports.shout = function shout(s) { + return s.toUpperCase() + "!"; + }; + }, +}; +function __webpack_require__(id) { + var module = { exports: {} }; + __webpack_modules__[id](module); + return module.exports; +} +module.exports = __webpack_require__(1); diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-bundled/package.json b/tests/coverage-fixtures/node-islands/node_modules/ni-bundled/package.json new file mode 100644 index 000000000..fd9bab40d --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-bundled/package.json @@ -0,0 +1,5 @@ +{ + "name": "ni-bundled", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-ctor/index.d.ts b/tests/coverage-fixtures/node-islands/node_modules/ni-ctor/index.d.ts new file mode 100644 index 000000000..2b70027a9 --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-ctor/index.d.ts @@ -0,0 +1,4 @@ +export declare class Counter { + add(n: number): void; + total(): number; +} diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-ctor/index.js b/tests/coverage-fixtures/node-islands/node_modules/ni-ctor/index.js new file mode 100644 index 000000000..39992107e --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-ctor/index.js @@ -0,0 +1,15 @@ +"use strict"; +// A constructor function with prototype methods needs a mutable object +// model (R2.1). Its helper package runs inside its island. +const { step } = require("ni-dep"); +const { twice } = require("ni-typed"); +function Counter() { + this.count = 0; +} +Counter.prototype.add = function add(n) { + this.count = step(this.count, twice(n)); +}; +Counter.prototype.total = function total() { + return this.count; +}; +exports.Counter = Counter; diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-ctor/package.json b/tests/coverage-fixtures/node-islands/node_modules/ni-ctor/package.json new file mode 100644 index 000000000..096b98d20 --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-ctor/package.json @@ -0,0 +1,5 @@ +{ + "name": "ni-ctor", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-dep/index.js b/tests/coverage-fixtures/node-islands/node_modules/ni-dep/index.js new file mode 100644 index 000000000..71a0eec99 --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-dep/index.js @@ -0,0 +1,4 @@ +"use strict"; +exports.step = function step(a, b) { + return a + b; +}; diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-dep/package.json b/tests/coverage-fixtures/node-islands/node_modules/ni-dep/package.json new file mode 100644 index 000000000..24b33b1ef --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-dep/package.json @@ -0,0 +1,5 @@ +{ + "name": "ni-dep", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-eval/index.d.ts b/tests/coverage-fixtures/node-islands/node_modules/ni-eval/index.d.ts new file mode 100644 index 000000000..4bdbf94f6 --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-eval/index.d.ts @@ -0,0 +1 @@ +export declare function compile(body: string): (a: number) => number; diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-eval/index.js b/tests/coverage-fixtures/node-islands/node_modules/ni-eval/index.js new file mode 100644 index 000000000..c549c058e --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-eval/index.js @@ -0,0 +1,5 @@ +"use strict"; +// Code from strings needs the JavaScript runtime (R1.1). +exports.compile = function compile(body) { + return new Function("a", "return " + body); +}; diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-eval/package.json b/tests/coverage-fixtures/node-islands/node_modules/ni-eval/package.json new file mode 100644 index 000000000..449239a05 --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-eval/package.json @@ -0,0 +1,5 @@ +{ + "name": "ni-eval", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-gap/index.d.ts b/tests/coverage-fixtures/node-islands/node_modules/ni-gap/index.d.ts new file mode 100644 index 000000000..6e1b743c2 --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-gap/index.d.ts @@ -0,0 +1 @@ +export declare function describe(): string; diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-gap/index.js b/tests/coverage-fixtures/node-islands/node_modules/ni-gap/index.js new file mode 100644 index 000000000..2148e51fd --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-gap/index.js @@ -0,0 +1,8 @@ +"use strict"; +// A construct inside the native subset that scriptc does not compile yet: +// a builtin function alias used as an escaping value. +const { extname } = require("node:path"); +const ext = extname; +exports.describe = function describe() { + return typeof ext; +}; diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-gap/package.json b/tests/coverage-fixtures/node-islands/node_modules/ni-gap/package.json new file mode 100644 index 000000000..9804ad187 --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-gap/package.json @@ -0,0 +1,5 @@ +{ + "name": "ni-gap", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-typed/index.d.ts b/tests/coverage-fixtures/node-islands/node_modules/ni-typed/index.d.ts new file mode 100644 index 000000000..bb1a852b1 --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-typed/index.d.ts @@ -0,0 +1 @@ +export declare function twice(n: number): number; diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-typed/index.js b/tests/coverage-fixtures/node-islands/node_modules/ni-typed/index.js new file mode 100644 index 000000000..082402bfd --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-typed/index.js @@ -0,0 +1,4 @@ +"use strict"; +exports.twice = function twice(n) { + return n * 2; +}; diff --git a/tests/coverage-fixtures/node-islands/node_modules/ni-typed/package.json b/tests/coverage-fixtures/node-islands/node_modules/ni-typed/package.json new file mode 100644 index 000000000..c3048156c --- /dev/null +++ b/tests/coverage-fixtures/node-islands/node_modules/ni-typed/package.json @@ -0,0 +1,5 @@ +{ + "name": "ni-typed", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/coverage-fixtures/node-islands/package.json b/tests/coverage-fixtures/node-islands/package.json new file mode 100644 index 000000000..f18eb6012 --- /dev/null +++ b/tests/coverage-fixtures/node-islands/package.json @@ -0,0 +1,5 @@ +{ + "name": "node-islands-coverage-fixture", + "private": true, + "type": "module" +} diff --git a/tests/coverage-fixtures/startup-fences.js b/tests/coverage-fixtures/startup-fences.js index 22aad4e0a..5e1d00b62 100644 --- a/tests/coverage-fixtures/startup-fences.js +++ b/tests/coverage-fixtures/startup-fences.js @@ -2,8 +2,8 @@ // a fence inside a function runs when the function is called. "use strict"; var value = JSON.parse("{}"); -console.log(value instanceof Object); +console.log(value instanceof WeakRef); function check(other) { - return other instanceof Object; + return other instanceof WeakRef; } console.log(check([])); diff --git a/tests/diagnostics/assert-fences.ts b/tests/diagnostics/assert-fences.ts index 823c64c40..7cf96bc5b 100644 --- a/tests/diagnostics/assert-fences.ts +++ b/tests/diagnostics/assert-fences.ts @@ -5,8 +5,8 @@ // (typed arrays, class instances), namespace-object calls, unsupported // throws/rejects expectations (properties outside the static error // surface, empty shapes, non-literal objects, doesNotReject shapes — -// Node's own TypeError), ifError over uncomparable types, and assert -// functions as values. Every reached site terminates in a named +// Node's own TypeError), ifError over uncomparable types, and assert.throws +// as a value. Every reached site terminates in a named // diagnostic — never a miscompare, never silence. import assert from "node:assert"; import * as assertNs from "node:assert"; @@ -47,8 +47,8 @@ assert.throws(() => { throw new Error("x"); }, {}); // Node rejects the empty sh void assert.doesNotReject(async () => {}, { name: "TypeError" }); // Node's own TypeError (function or RegExp only) assert.ifError(new Map()); // no honest ifError rendering for this type -const eq = assert.strictEqual; // assert functions as values -eq(1, 1); +const throwsFn = assert.throws; // assert.throws as a value: it takes the function to run +throwsFn(() => {}); interface Tree { label: string; kids: Tree[] } const tree: Tree = { label: "t", kids: [] }; diff --git a/tests/diagnostics/function-value-arguments-length.ts b/tests/diagnostics/function-value-arguments-length.ts new file mode 100644 index 000000000..ab0b53f82 --- /dev/null +++ b/tests/diagnostics/function-value-arguments-length.ts @@ -0,0 +1,36 @@ +// `arguments.length` has no single value when the calls pass varying +// counts: a function expression or declaration passed where the expected +// function type has optional or rest parameters. Methods and nested +// functions used as values, and arrows inside a function used as a value, +// keep the fence. +const replaced = "a-b".replace(/-/, function (match) { + return String(arguments.length) + match; +}); +function call(f: (a: number, b?: number) => number): number { + return f(1, 2); +} +const counted = call(function (a) { + return arguments.length + a; +}); +function offset(value: number): number { + return value + arguments.length; +} +const offsets = call(offset); +class Scale { + static by(value: number): number { + return value * arguments.length; + } +} +const scaled = [2].map(Scale.by); +function outer(): number[] { + function inner(value: number): number { + return value + arguments.length; + } + return [1].map(inner); +} +function deferred(value: number): number { + const read = (): number => arguments.length; + return value + read(); +} +const late = [1].map(deferred); +export { replaced, counted, offsets, scaled, outer, late }; diff --git a/tests/diagnostics/intl-segmenter.ts b/tests/diagnostics/intl-segmenter.ts index 42bdae846..0cf30eabb 100644 --- a/tests/diagnostics/intl-segmenter.ts +++ b/tests/diagnostics/intl-segmenter.ts @@ -1,5 +1,5 @@ -// The native implementation covers default Unicode grapheme segmentation. -new Intl.Segmenter("en-US"); +// The native implementation covers Unicode grapheme segmentation. +new Intl.Segmenter(["en-US", "de"]); new Intl.Segmenter(undefined, { granularity: "word" }); new Intl.Segmenter(undefined, { granularity: "sentence" }); // End of the diagnostic fixture. diff --git a/tests/diagnostics/maybe-promise-fences.ts b/tests/diagnostics/maybe-promise-fences.ts index 1dcccaf08..e6fdf98fa 100644 --- a/tests/diagnostics/maybe-promise-fences.ts +++ b/tests/diagnostics/maybe-promise-fences.ts @@ -26,7 +26,3 @@ console.log(maybe(true)); // must be that arm's own promise type. const widened = Promise.resolve(maybe(false)); console.log(typeof widened); - -// `instanceof Promise` on a value that is never a promise. -const record = { v: 1 }; -console.log(record instanceof Promise); diff --git a/tests/diagnostics/stdlib-fence.ts b/tests/diagnostics/stdlib-fence.ts index d28333178..0952436e1 100644 --- a/tests/diagnostics/stdlib-fence.ts +++ b/tests/diagnostics/stdlib-fence.ts @@ -21,8 +21,6 @@ const fmt = new Intl.NumberFormat(); // Constructors. const d = new Date(...([2024, 0] as const)); // spread field arguments stay fenced -const mutableDate = new Date(0); -mutableDate.setUTCFullYear(2024); // mutation is outside the read-only Date slice const wr = new WeakRef({}); const px = new Proxy({ a: 1 }, {}); const buf = new ArrayBuffer(8); @@ -73,14 +71,6 @@ function pick(a: string | number, b: number | boolean): boolean { } const picked = pick(1, 2); -function throwDate(): void { - throw new Date(0); // the exception cell cannot preserve Date's object kind -} -throwDate(); -function* dateGenerator(): Generator { - yield 1; -} -dateGenerator().throw(new Date(0)); // Related classes may store one object under different union tags. A // structural arm beside them leaves no identity-preserving comparison. class FenceBase { diff --git a/tests/diagnostics/x509-fences.ts b/tests/diagnostics/x509-fences.ts new file mode 100644 index 000000000..416a88b59 --- /dev/null +++ b/tests/diagnostics/x509-fences.ts @@ -0,0 +1,21 @@ +// X509Certificate keeps its fields as prototype getters, so Node's own-key +// walks over a certificate see none of them. Those walks are refused by +// name instead of listing the fields. +import { X509Certificate } from "node:crypto"; +import { readFileSync } from "node:fs"; + +const cert = new X509Certificate(readFileSync("cert.pem")); +console.log(Object.keys(cert)); +console.log(Object.entries(cert)); +console.log({ ...cert }); +console.log(Object.prototype.hasOwnProperty.call(cert, "subject")); +for (const key in cert) console.log(key); + +// A certificate in an unknown or any slot would cross as a plain object of +// its fields, which Node's certificate is not. +const held: unknown = cert; +console.log(held); +function describe(value: unknown): string { + return typeof value; +} +console.log(describe([cert])); diff --git a/tests/fixtures/node-types/tls-client-certificates.ts b/tests/fixtures/node-types/tls-client-certificates.ts new file mode 100644 index 000000000..3268369f9 --- /dev/null +++ b/tests/fixtures/node-types/tls-client-certificates.ts @@ -0,0 +1,32 @@ +// Under @types/node: a mutual-TLS server that requires a client +// certificate signed by its CA, and a client that presents one. +import { readFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const certs = "tests/fixtures/server/certs"; +const ca = readFileSync(`${certs}/ca.pem`); + +const cert = readFileSync(`${certs}/localhost.pem`); +const key = readFileSync(`${certs}/localhost-key.pem`); +let serverLine = ""; +const server = tls.createServer({ cert, key, ca, requestCert: true, rejectUnauthorized: true }, (socket: tls.TLSSocket) => { + const peer: tls.PeerCertificate = socket.getPeerCertificate(); + serverLine = `server ${socket.authorized} ${String(socket.authorizationError)} ${peer.subject.CN}`; + socket.end("welcome"); +}); +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + const clientCert = readFileSync(`${certs}/client.pem`); + const clientKey = readFileSync(`${certs}/client-key.pem`); + const client = tls.connect({ port, host: "127.0.0.1", servername: "localhost", ca, cert: clientCert, key: clientKey }, () => { + console.log("client", client.authorized, client.getX509Certificate()?.subject); + }); + let body = ""; + client.on("data", (chunk: Buffer) => (body += chunk.toString())); + client.on("end", () => { + console.log(serverLine); + console.log("body", body); + server.close(); + }); +}); diff --git a/tests/fixtures/node-types/tls-peer-certificates.ts b/tests/fixtures/node-types/tls-peer-certificates.ts new file mode 100644 index 000000000..d082cfead --- /dev/null +++ b/tests/fixtures/node-types/tls-peer-certificates.ts @@ -0,0 +1,37 @@ +// Under @types/node: a TLS client reads the server's certificate as the +// legacy object, its detailed issuer chain and an X509Certificate, and the +// server reads the certificate it serves. +import { readFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const certs = "tests/fixtures/server/certs"; +const ca = readFileSync(`${certs}/ca.pem`); +const cert = readFileSync(`${certs}/localhost.pem`); +const key = readFileSync(`${certs}/localhost-key.pem`); + +// The server's line prints once the client has its reply, so the output +// order does not depend on which side's callback runs first. +let serverLine = ""; +const server = tls.createServer({ cert, key }, (socket: tls.TLSSocket) => { + const own = socket.getCertificate() as tls.PeerCertificate; + serverLine = `server ${own.subject.CN} ${socket.getX509Certificate()?.subject} ${Object.keys(socket.getPeerCertificate()).length}`; + socket.end("done"); +}); +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + const client = tls.connect({ port, host: "127.0.0.1", ca, servername: "localhost" }, () => { + const peer: tls.PeerCertificate = client.getPeerCertificate(); + const detailed: tls.DetailedPeerCertificate = client.getPeerCertificate(true); + console.log("client", peer.subject.CN, peer.subjectaltname, peer.bits, detailed.issuerCertificate.subject.CN); + console.log("root", detailed.issuerCertificate.issuerCertificate === detailed.issuerCertificate); + const x509 = client.getPeerX509Certificate(); + if (x509 !== undefined) console.log("x509", x509.subject, x509.checkIP("127.0.0.1"), x509.fingerprint256 === peer.fingerprint256); + }); + client.resume(); + client.on("end", () => { + console.log(serverLine); + client.destroy(); + server.close(); + }); +}); diff --git a/tests/fixtures/node-types/tls-server-contexts.ts b/tests/fixtures/node-types/tls-server-contexts.ts new file mode 100644 index 000000000..ff0425a56 --- /dev/null +++ b/tests/fixtures/node-types/tls-server-contexts.ts @@ -0,0 +1,38 @@ +// Under @types/node: a tls.Server that answers an SNI name from +// addContext, replaces its default context with setSecureContext, and +// reports a plain-text client through 'tlsClientError'. +import { readFileSync } from "node:fs"; +import * as net from "node:net"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const certs = "tests/fixtures/server/certs"; +const cert = readFileSync(`${certs}/localhost.pem`); +const key = readFileSync(`${certs}/localhost-key.pem`); +const alt = readFileSync(`${certs}/alt.pem`); +const altKey = readFileSync(`${certs}/alt-key.pem`); + +const server: tls.Server = tls.createServer({ cert, key }, (socket: tls.TLSSocket) => socket.end()); +server.addContext("*.localhost", tls.createSecureContext({ cert: alt, key: altKey })); +server.on("tlsClientError", (err: Error, socket: tls.TLSSocket) => { + console.log("tlsClientError", (err as NodeJS.ErrnoException).code, socket.destroyed); +}); + +function subject(port: number, servername: string): Promise { + return new Promise((resolve) => { + const client = tls.connect({ port, host: "127.0.0.1", servername, rejectUnauthorized: false }, () => { + resolve(String(client.getPeerCertificate().subject.CN)); + client.end(); + }); + }); +} + +server.listen(0, "127.0.0.1", async () => { + const port = (server.address() as AddressInfo).port; + console.log("sni", await subject(port, "alt.localhost"), await subject(port, "localhost")); + server.setSecureContext({ cert: alt, key: altKey }); + console.log("default", await subject(port, "localhost")); + const raw = net.connect(port, "127.0.0.1", () => raw.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n")); + raw.on("data", () => {}); + raw.on("close", () => setTimeout(() => server.close(), 50)); +}); diff --git a/tests/fixtures/node-types/x509-methods.ts b/tests/fixtures/node-types/x509-methods.ts new file mode 100644 index 000000000..8736ec990 --- /dev/null +++ b/tests/fixtures/node-types/x509-methods.ts @@ -0,0 +1,20 @@ +// Under @types/node: X509Certificate's methods over the test certificates: +// the issuer, the signature, the host and IP names, the private key, the +// legacy object and the PEM text. +import { X509Certificate, createPrivateKey } from "node:crypto"; +import { readFileSync } from "node:fs"; +import type { PeerCertificate } from "node:tls"; + +const certs = "tests/fixtures/server/certs"; +const ca = new X509Certificate(readFileSync(`${certs}/ca.pem`)); +const leaf = new X509Certificate(readFileSync(`${certs}/localhost.pem`)); +const key = createPrivateKey(readFileSync(`${certs}/localhost-key.pem`)); + +console.log(leaf.checkIssued(ca), leaf.verify(ca.publicKey), ca.verify(leaf.publicKey)); +const host: string | undefined = leaf.checkHost("localhost"); +console.log(host, leaf.checkHost("example.com"), leaf.checkIP("127.0.0.1"), leaf.checkIP("::1")); +console.log(leaf.checkEmail("root@localhost", { subject: "always" }), leaf.checkPrivateKey(key)); +const legacy: PeerCertificate = leaf.toLegacyObject(); +console.log(legacy.subject.CN, legacy.subjectaltname, legacy.bits, legacy.exponent, legacy.raw.equals(leaf.raw)); +console.log(leaf.publicKey.asymmetricKeyType, leaf.publicKey === leaf.publicKey); +console.log(leaf.toString() === readFileSync(`${certs}/localhost.pem`, "latin1"), String(ca).split("\n")[0]); diff --git a/tests/fixtures/npm-static/addon-probe-cli.ts b/tests/fixtures/npm-static/addon-probe-cli.ts new file mode 100644 index 000000000..aa21df05d --- /dev/null +++ b/tests/fixtures/npm-static/addon-probe-cli.ts @@ -0,0 +1,6 @@ +import { locate, probe, resolveHelper } from "addon-probe"; +import { join } from "node:path"; + +console.log(join("a", "b"), [probe("clipboard"), probe("keys"), resolveHelper("addon-probe-helper")].join(",")); +console.log(resolveHelper("other", (id) => `custom:${id}`)); +console.log(locate("helper")); diff --git a/tests/fixtures/npm-static/node_modules/addon-probe/index.d.ts b/tests/fixtures/npm-static/node_modules/addon-probe/index.d.ts new file mode 100644 index 000000000..a9987d787 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/addon-probe/index.d.ts @@ -0,0 +1,3 @@ +export declare function probe(name: string): string; +export declare function resolveHelper(name: string, resolvePackage?: (id: string) => string): string; +export declare function locate(name: string): string; diff --git a/tests/fixtures/npm-static/node_modules/addon-probe/index.js b/tests/fixtures/npm-static/node_modules/addon-probe/index.js new file mode 100644 index 000000000..2cc423359 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/addon-probe/index.js @@ -0,0 +1,52 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const load = createRequire(import.meta.url); +const here = dirname(fileURLToPath(import.meta.url)); + +// Looks for a prebuilt addon in each packaging location, the way +// packages with optional native helpers do. +export function probe(name) { + for (const dir of ["prebuilds", "native"]) { + try { + const addon = load(join(here, dir, `${name}.node`)); + if (typeof addon?.run === "function") return "loaded"; + } catch { + // Try the next location. + } + } + return "absent"; +} + +export function resolveHelper(name, resolvePackage) { + try { + return (resolvePackage ?? load.resolve)(name); + } catch { + return "unresolved"; + } +} + +let probes = 0; +function candidate(name) { + probes++; + return `./${name}-${probes}.node`; +} + +// Resolves computed candidate paths: the arguments run before the lookup +// fails, as an assignment and as a statement of their own. +export function locate(name) { + let where = "none"; + try { + where = load.resolve(candidate(name)); + } catch { + where = `missing after ${probes}`; + } + try { + load.resolve(candidate(name)); + where += ",found"; + } catch { + where += `,missing after ${probes}`; + } + return where; +} diff --git a/tests/fixtures/npm-static/node_modules/addon-probe/package.json b/tests/fixtures/npm-static/node_modules/addon-probe/package.json new file mode 100644 index 000000000..a2f7406f5 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/addon-probe/package.json @@ -0,0 +1,7 @@ +{ + "name": "addon-probe", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/tests/fixtures/npm-static/node_modules/word-segments/index.d.ts b/tests/fixtures/npm-static/node_modules/word-segments/index.d.ts new file mode 100644 index 000000000..2fd45310e --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/word-segments/index.d.ts @@ -0,0 +1 @@ +export declare function splitWords(text: string): string; diff --git a/tests/fixtures/npm-static/node_modules/word-segments/index.js b/tests/fixtures/npm-static/node_modules/word-segments/index.js new file mode 100644 index 000000000..a67aa5381 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/word-segments/index.js @@ -0,0 +1,9 @@ +const words = new Intl.Segmenter(undefined, { granularity: "word" }); + +export function splitWords(text) { + try { + return [...words.segment(text)].map((part) => part.segment).join("|"); + } catch (error) { + return `${error.code}: ${error.message}`; + } +} diff --git a/tests/fixtures/npm-static/node_modules/word-segments/package.json b/tests/fixtures/npm-static/node_modules/word-segments/package.json new file mode 100644 index 000000000..37b0d6f73 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/word-segments/package.json @@ -0,0 +1 @@ +{ "name": "word-segments", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/npm-static/word-segments-cli.ts b/tests/fixtures/npm-static/word-segments-cli.ts new file mode 100644 index 000000000..28e3bd5e1 --- /dev/null +++ b/tests/fixtures/npm-static/word-segments-cli.ts @@ -0,0 +1,3 @@ +import { splitWords } from "word-segments"; + +console.log(splitWords("two words")); diff --git a/tests/fixtures/npm/cases/shape-overlay-facts/main.ts b/tests/fixtures/npm/cases/shape-overlay-facts/main.ts new file mode 100644 index 000000000..3ed1cf5de --- /dev/null +++ b/tests/fixtures/npm/cases/shape-overlay-facts/main.ts @@ -0,0 +1,10 @@ +// The shape overlay's facts: results that include undefined, literals that +// hold comment-like text, and a var declared in a branch no call takes. +import { banner, hoisted, lookup, lookupOrReturn } from "shape-facts"; + +console.log(lookup("b")); +console.log(lookup("z")); +console.log(lookupOrReturn("a")); +console.log(lookupOrReturn("y")); +console.log(banner(7)); +console.log(hoisted("note:")); diff --git a/tests/fixtures/npm/cases/shape-overlay-facts/node_modules/shape-facts/index.d.ts b/tests/fixtures/npm/cases/shape-overlay-facts/node_modules/shape-facts/index.d.ts new file mode 100644 index 000000000..b1865415d --- /dev/null +++ b/tests/fixtures/npm/cases/shape-overlay-facts/node_modules/shape-facts/index.d.ts @@ -0,0 +1,4 @@ +export declare function lookup(key: string): string; +export declare function lookupOrReturn(key: string): string; +export declare function banner(n: number): string; +export declare function hoisted(label: string): string; diff --git a/tests/fixtures/npm/cases/shape-overlay-facts/node_modules/shape-facts/index.js b/tests/fixtures/npm/cases/shape-overlay-facts/node_modules/shape-facts/index.js new file mode 100644 index 000000000..505af75a0 --- /dev/null +++ b/tests/fixtures/npm/cases/shape-overlay-facts/node_modules/shape-facts/index.js @@ -0,0 +1,55 @@ +// Functions whose results include the undefined of a fall-through or a +// bare return, comment-like text in literals, and a branch no call takes +// that declares a var read outside it. + +const entries = [ + { key: "a", value: 1 }, + { key: "b", value: 2 }, +]; + +function find(key) { + for (const entry of entries) { + if (entry.key === key) return entry; + } +} + +function findOrReturn(key) { + for (const entry of entries) if (entry.key === key) return entry; + return; +} + +export function lookup(key) { + const hit = find(key); + if (hit) { + return "found " + hit.key + "=" + hit.value; + } else { + return "missing " + key; + } +} + +export function lookupOrReturn(key) { + const hit = findOrReturn(key); + if (hit) { + return "found " + hit.key + "=" + hit.value; + } else { + return "missing " + key; + } +} + +export function banner(n) { + const quoted = "/** @type {number} */"; + const template = `/** @returns {string} ${n} */`; + const pattern = /\/\*\* @param \*\//; + return quoted + " " + template + " " + pattern.source; +} + +function noteOf(extra) { + if (extra) { + var note = "set"; + } + return typeof note + " " + String(note); +} + +export function hoisted(label) { + return label + " " + noteOf(undefined); +} diff --git a/tests/fixtures/npm/cases/shape-overlay-facts/node_modules/shape-facts/package.json b/tests/fixtures/npm/cases/shape-overlay-facts/node_modules/shape-facts/package.json new file mode 100644 index 000000000..317269e91 --- /dev/null +++ b/tests/fixtures/npm/cases/shape-overlay-facts/node_modules/shape-facts/package.json @@ -0,0 +1 @@ +{ "name": "shape-facts", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/npm/cases/shape-overlay-facts/package.json b/tests/fixtures/npm/cases/shape-overlay-facts/package.json new file mode 100644 index 000000000..d15818a37 --- /dev/null +++ b/tests/fixtures/npm/cases/shape-overlay-facts/package.json @@ -0,0 +1 @@ +{ "name": "shape-overlay-facts-case", "private": true, "type": "module" } diff --git a/tests/fixtures/npm/cases/static-objects-order/js-main.cjs b/tests/fixtures/npm/cases/static-objects-order/js-main.cjs new file mode 100644 index 000000000..c18877ab5 --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/js-main.cjs @@ -0,0 +1,14 @@ +// The JavaScript consumer of static-objects-order +// (tests/harness/static-objects-order.test.ts compiles it with the shape overlay on). +const receivers = require("static-objects-order/receivers.js"); +const lazy = require("static-objects-order/lazy-user.js"); +const vec = require("static-objects-order/vec-user.js"); +const early = require("static-objects-order/early-user.js"); + +console.log("start"); +console.log(receivers.ns.run("add"), receivers.ns.run("sub"), receivers.ns.reads()); +console.log(receivers.ns.proto()); +console.log(receivers.ns.early()); +console.log(lazy.greet("a"), lazy.greet("b")); +console.log(vec.len2(3, 4)); +console.log(early.early()); diff --git a/tests/fixtures/npm/cases/static-objects-order/main.ts b/tests/fixtures/npm/cases/static-objects-order/main.ts new file mode 100644 index 000000000..55bd19d7e --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/main.ts @@ -0,0 +1,14 @@ +// Namespace-style modules whose rewrites must keep evaluation and load +// order, used from TypeScript (js-main.cjs is the JavaScript consumer). +import { ns } from "static-objects-order/receivers.js"; +import { greet } from "static-objects-order/lazy-user.js"; +import { len2 } from "static-objects-order/vec-user.js"; +import { early } from "static-objects-order/early-user.js"; + +console.log("start"); +console.log(ns.run("add"), ns.run("sub"), ns.reads()); +console.log(ns.proto()); +console.log(ns.early()); +console.log(greet("a"), greet("b")); +console.log(len2(3, 4)); +console.log(early()); diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/early-user.d.ts b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/early-user.d.ts new file mode 100644 index 000000000..240372eef --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/early-user.d.ts @@ -0,0 +1 @@ +export declare function early(): string; diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/early-user.js b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/early-user.js new file mode 100644 index 000000000..d3993f042 --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/early-user.js @@ -0,0 +1,9 @@ +// A function called in the statement that requires lazy.js, after m is +// assigned and before the statement ends. +var m = require("./lazy"), + early = (function (n) { + return m.lz.hello(n); + })("early"); +exports.early = function () { + return early + " " + m.lz.hello("late"); +}; diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/lazy-user.d.ts b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/lazy-user.d.ts new file mode 100644 index 000000000..5ee1040dd --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/lazy-user.d.ts @@ -0,0 +1 @@ +export declare function greet(n: string): string; diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/lazy-user.js b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/lazy-user.js new file mode 100644 index 000000000..2f779171d --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/lazy-user.js @@ -0,0 +1,9 @@ +// The first statement that mentions lazy.js requires it only when a +// condition holds: the module loads at the top-level require below. +if (process.env["STATIC_OBJECTS_ORDER_EARLY"] === "1") require("./lazy"); +console.log("lazy-user.js before its require"); +var m = require("./lazy"); +var hi = m.lz.hello("top"); +exports.greet = function (n) { + return hi + " " + n; +}; diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/lazy.js b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/lazy.js new file mode 100644 index 000000000..d1f179495 --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/lazy.js @@ -0,0 +1,7 @@ +// Loaded only when lazy-user.js first needs it. +console.log("lazy.js loaded"); +var lz = {}; +lz.hello = function (n) { + return "hello " + n; +}; +module.exports = { lz: lz }; diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/package.json b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/package.json new file mode 100644 index 000000000..ceeafc91c --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/package.json @@ -0,0 +1 @@ +{ "name": "static-objects-order", "version": "1.0.0" } diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/receivers.d.ts b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/receivers.d.ts new file mode 100644 index 000000000..bf9e5ca78 --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/receivers.d.ts @@ -0,0 +1,2 @@ +declare const ns: any; +export { ns }; diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/receivers.js b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/receivers.js new file mode 100644 index 000000000..816212f65 --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/receivers.js @@ -0,0 +1,52 @@ +// Constant members, a function table and a namespace function read through +// receivers with side effects: each receiver still runs. +var ns = ns || {}; +// Reads that run before their members are created: Node throws. +var early = (function () { + var out = []; + try { + out.push(ns.Late.X); + } catch (e) { + out.push(e.constructor.name); + } + try { + out.push(ns.later()); + } catch (e) { + out.push(e.constructor.name); + } + return out.join(" "); +})(); +ns.Late = { X: 5 }; +ns.later = function () { + return "later"; +}; +ns.early = function () { + return early + " " + ns.Late.X + " " + ns.later(); +}; +ns.Kind = { A: 1, B: 2 }; +ns.ops = { + add: function (a, b) { + return a + b; + }, + sub: function (a, b) { + return a - b; + }, +}; +ns.util = { + twice: function (x) { + return 2 * x; + }, +}; +var reads = 0; +ns.run = function (op) { + return (reads++, ns.Kind).A + (reads++, ns.ops)[op](5, 2) + (reads++, ns.util).twice(3); +}; +ns.reads = function () { + return reads; +}; +// `__proto__: null` in a literal sets the prototype: no own property. +ns.Proto = { __proto__: null, A: 1 }; +ns.proto = function () { + return String(ns.Proto.__proto__) + " " + ns.Proto.A; +}; +module.exports = { ns: ns }; diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/vec-user.d.ts b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/vec-user.d.ts new file mode 100644 index 000000000..3be86a088 --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/vec-user.d.ts @@ -0,0 +1 @@ +export declare function len2(x: number, y: number): number; diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/vec-user.js b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/vec-user.js new file mode 100644 index 000000000..a462365fc --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/vec-user.js @@ -0,0 +1,7 @@ +var vec = require("./vec"); +var unit = new vec.geo.Vec(1, 0); +module.exports = { + len2: function (x, y) { + return new vec.geo.Vec(x, y).len2() + unit.len2(); + }, +}; diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/vec.js b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/vec.js new file mode 100644 index 000000000..abf363a38 --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/vec.js @@ -0,0 +1,10 @@ +// The exports literal comes before the constructor function. +var geo = {}; +module.exports = { geo: geo }; +geo.Vec = function (x, y) { + this.x = x; + this.y = y; +}; +geo.Vec.prototype.len2 = function () { + return this.x * this.x + this.y * this.y; +}; diff --git a/tests/fixtures/npm/cases/static-objects-order/package.json b/tests/fixtures/npm/cases/static-objects-order/package.json new file mode 100644 index 000000000..7d7eec39a --- /dev/null +++ b/tests/fixtures/npm/cases/static-objects-order/package.json @@ -0,0 +1 @@ +{ "name": "static-objects-order-case", "private": true, "type": "module" } diff --git a/tests/fixtures/npm/cases/static-objects/node_modules/static-ns/index.js b/tests/fixtures/npm/cases/static-objects/node_modules/static-ns/index.js index 0a6a85f50..b1884948c 100644 --- a/tests/fixtures/npm/cases/static-objects/node_modules/static-ns/index.js +++ b/tests/fixtures/npm/cases/static-objects/node_modules/static-ns/index.js @@ -46,6 +46,131 @@ ns.Named.prototype.n2 = function () { return this.n + 2; }; +// Read before its store: undefined then, a function after. +var earlyLate = typeof ns.util.late; +ns.util.late = function (x) { + return x + 1; +}; + +// Constructed before its store, from a function declaration: a TypeError, +// not a reference to an uninitialized class. +function makeEarly() { + try { + return new ns.Early().v; + } catch (e) { + return e.constructor.name; + } +} +var earlyResult = makeEarly(); +ns.Early = function () { + this.v = "early"; +}; +ns.Early.prototype.get = function () { + return this.v; +}; + +// A receiver with an effect: the effect still happens. +var receiverReads = 0; +function utilWithEffect() { + receiverReads++; + return ns.util; +} + +// A prototype method that is itself constructed. +ns.Maker = function () { + this.k = 1; +}; +ns.Maker.prototype.make = function () { + this.made = true; +}; + +// A method called with a primitive receiver: sloppy mode boxes it. +ns.Prim = function () { + this.v = 1; +}; +ns.Prim.prototype.kind = function () { + return typeof this; +}; + +// Frozen instances: a store that sloppy mode ignores throws in strict mode. +ns.Frozen = function () { + this.a = 1; +}; +ns.Frozen.prototype.set = function () { + this.a = 2; + return this.a; +}; + +// A store into an argument, which may be a primitive or frozen: ignored in +// sloppy mode, a TypeError in strict mode. +ns.Writer = function (target) { + target.length = 0; + this.ok = true; +}; +ns.Writer.prototype.done = function () { + return this.ok; +}; + +// A named constructor expression that refers to its own name. +var Self = function () { + this.n = "outer"; +}; +ns.Self = function Self(n) { + this.n = n; + this.next = n > 0 ? new Self(n - 1) : null; +}; +ns.Self.prototype.depth = function () { + return this.next === null ? 0 : 1 + this.next.depth(); +}; + +// Read through names not yet initialized: a TypeError and a ReferenceError, +// not calls to the direct function. +var aliasLater; +var uninitialized = (function () { + var out = []; + try { + out.push(aliasLater.twice(1)); + } catch (e) { + out.push(e.constructor.name); + } + try { + out.push(tdzAlias.twice(1)); + } catch (e) { + out.push(e.constructor.name); + } + return out.join("|"); +})(); +aliasLater = ns.util; +let tdzAlias = ns.util; + +// A store between the constructor and its method runs a setter, which sees +// the prototype without the method. +var probeLog = []; +var probe = { + set seen(v) { + try { + probeLog.push(new ns.Gadget().spin()); + } catch (e) { + probeLog.push(e.constructor.name); + } + }, +}; +ns.Gadget = function () { + this.s = 1; +}; +probe.seen = 1; +ns.Gadget.prototype.spin = function () { + return this.s; +}; + +// Its prototype is asked whether a method is enumerable. +ns.Listed = function () { + this.l = 1; +}; +ns.Listed.prototype.item = function () { + return this.l; +}; + ns.run = function () { var out = []; var p = new ns.Point(3, 4); @@ -58,6 +183,20 @@ ns.run = function () { out.push(keys.join("|")); out.push(JSON.stringify(ns.Named.name), new ns.Named().n2()); out.push(ns.util.twice(21), ns.util.isSelf()); + out.push(earlyLate, ns.util.late(1), earlyResult, new ns.Early().get()); + out.push(utilWithEffect().twice(4), receiverReads); + out.push(typeof new (new ns.Maker()).make()); + // The call with a primitive receiver keeps the class form out; it does not + // run, so the output does not depend on how this is passed. + if (out.length < 0) out.push(ns.Prim.prototype.kind.call(5)); + out.push(new ns.Prim().kind()); + var frozen = Object.freeze(new ns.Frozen()); + out.push(Object.isFrozen(frozen), new ns.Frozen().set()); + out.push(new ns.Writer({ length: 3 }).done()); + out.push(new ns.Self(3).depth(), new Self().n); + out.push(uninitialized, aliasLater.twice(2), tdzAlias.twice(3)); + out.push(probeLog.join("|"), new ns.Gadget().spin()); + out.push(ns.Listed.prototype.propertyIsEnumerable("item"), new ns.Listed().item()); return out.join(" "); }; diff --git a/tests/fixtures/npm/cases/static-tables/js-main.cjs b/tests/fixtures/npm/cases/static-tables/js-main.cjs index b73c08786..b1ec4d116 100644 --- a/tests/fixtures/npm/cases/static-tables/js-main.cjs +++ b/tests/fixtures/npm/cases/static-tables/js-main.cjs @@ -14,7 +14,7 @@ lib.register("b", function () { }); console.log(lib.dispatch("b"), lib.limit()); lib.bump(); -console.log(lib.limit(), new lib.Pair(2, 3).sum()); +console.log(lib.limit(), new lib.Pair(2, 3).sum(), lib.inits()); try { lib.apply("missing", 1, 2); } catch (e) { diff --git a/tests/fixtures/npm/cases/static-tables/main.ts b/tests/fixtures/npm/cases/static-tables/main.ts index a4929e831..d53e2fa8b 100644 --- a/tests/fixtures/npm/cases/static-tables/main.ts +++ b/tests/fixtures/npm/cases/static-tables/main.ts @@ -13,4 +13,4 @@ console.log(lib.read("get"), lib.dispatch("a"), lib.pick(1)); lib.register("b", () => "b!"); console.log(lib.dispatch("b"), lib.limit()); lib.bump(); -console.log(lib.limit(), new lib.Pair(2, 3).sum()); +console.log(lib.limit(), new lib.Pair(2, 3).sum(), lib.inits()); diff --git a/tests/fixtures/npm/cases/static-tables/node_modules/static-tables/index.js b/tests/fixtures/npm/cases/static-tables/node_modules/static-tables/index.js index ff38d6403..c8ea71e5f 100644 --- a/tests/fixtures/npm/cases/static-tables/node_modules/static-tables/index.js +++ b/tests/fixtures/npm/cases/static-tables/node_modules/static-tables/index.js @@ -71,6 +71,39 @@ lib.limit = function () { return lib.Limits.MAX; }; +// Objects written at init through a helper's captured binding, and through +// the receiver of a bound function. +function makeState() { + var state = { v: 0 }; + function set() { + state.v = 1; + } + set(); + return state; +} +lib.made = makeState(); +lib.bound = { v: 0 }; +var setBound = function () { + this.v = 1; +}.bind(lib.bound); +setBound(); +// A factory that declares a class whose methods write the factory's +// bindings. +function makeTally() { + var tally = { n: 0 }; + class Tally { + bump() { + tally.n = 1; + } + } + new Tally().bump(); + return tally; +} +lib.tallied = makeTally(); +lib.inits = function () { + return lib.made.v + "," + lib.bound.v + "," + lib.tallied.n; +}; + // A constructor function with prototype methods. lib.Pair = function (a, b) { this.a = a; diff --git a/tests/fixtures/npm/cases/var-export-redeclared/main.ts b/tests/fixtures/npm/cases/var-export-redeclared/main.ts new file mode 100644 index 000000000..23f014d29 --- /dev/null +++ b/tests/fixtures/npm/cases/var-export-redeclared/main.ts @@ -0,0 +1,3 @@ +// An importer sees the value module.exports holds, not the redeclared binding. +import { ns } from "redeclared-ns"; +console.log(ns.version); diff --git a/tests/fixtures/npm/cases/var-export-redeclared/node_modules/redeclared-ns/index.d.ts b/tests/fixtures/npm/cases/var-export-redeclared/node_modules/redeclared-ns/index.d.ts new file mode 100644 index 000000000..1db3d1444 --- /dev/null +++ b/tests/fixtures/npm/cases/var-export-redeclared/node_modules/redeclared-ns/index.d.ts @@ -0,0 +1 @@ +export declare const ns: { version: number }; diff --git a/tests/fixtures/npm/cases/var-export-redeclared/node_modules/redeclared-ns/index.js b/tests/fixtures/npm/cases/var-export-redeclared/node_modules/redeclared-ns/index.js new file mode 100644 index 000000000..45f29a690 --- /dev/null +++ b/tests/fixtures/npm/cases/var-export-redeclared/node_modules/redeclared-ns/index.js @@ -0,0 +1,5 @@ +// A namespace exported by reference, then redeclared with a new value: +// Node copies the first object into module.exports. +var ns = { version: 1 }; +module.exports = { ns }; +var ns = { version: 2 }; diff --git a/tests/fixtures/npm/cases/var-export-redeclared/node_modules/redeclared-ns/package.json b/tests/fixtures/npm/cases/var-export-redeclared/node_modules/redeclared-ns/package.json new file mode 100644 index 000000000..c18908fd8 --- /dev/null +++ b/tests/fixtures/npm/cases/var-export-redeclared/node_modules/redeclared-ns/package.json @@ -0,0 +1 @@ +{ "name": "redeclared-ns", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/npm/cases/var-export-redeclared/package.json b/tests/fixtures/npm/cases/var-export-redeclared/package.json new file mode 100644 index 000000000..023d0b3d9 --- /dev/null +++ b/tests/fixtures/npm/cases/var-export-redeclared/package.json @@ -0,0 +1 @@ +{ "name": "var-export-redeclared-case", "private": true, "type": "module" } diff --git a/tests/fixtures/self-hosting/ts7-native-process.ts b/tests/fixtures/self-hosting/ts7-native-process.ts index 2fdbb9e79..3b04a784c 100644 --- a/tests/fixtures/self-hosting/ts7-native-process.ts +++ b/tests/fixtures/self-hosting/ts7-native-process.ts @@ -1,6 +1,9 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; -import { NativeTs7Process } from "../../../packages/compiler/src/frontend/ts7/native-process.js"; +import { + attachNativeTs7Process, + NativeTs7Process, +} from "../../../packages/compiler/src/frontend/ts7/native-process.js"; import { createNativeTs7Api } from "../../../packages/compiler/src/frontend/ts7/native-api.js"; import { Ts7RpcClient } from "../../../packages/compiler/src/frontend/ts7/rpc-client.js"; import { Ts7Wire } from "../../../packages/compiler/src/frontend/ts7/rpc-wire.js"; @@ -95,12 +98,44 @@ function checkTransport(executable: string): void { console.log("native transport passed"); } +function readLine(connection: NativeTs7Process): string { + const byte = new Uint8Array(1); + let line = ""; + for (;;) { + check(connection.read(byte, 0, 1) === 1, "shared server line"); + if (byte[0] === 10) return line; + line += String.fromCharCode(byte[0]!); + } +} + +// The stand-in server (ts7-process-child.test.c, mode "listen") answers each +// client with its number, its socket and idle time, and its memory limit. +function checkShared(executable: string): void { + const first = attachNativeTs7Process("server", executable, "listen"); + check(first !== null, "shared server started"); + check(readLine(first!) === "client 1", "first client"); + check(readLine(first!).endsWith("/scriptc/server.sock 300"), "listen arguments"); + check(readLine(first!) === "2048", "memory limit"); + check(attachNativeTs7Process("server", executable, "listen") === null, "shared server in use"); + first!.close(); + first!.close(); + const second = attachNativeTs7Process("server", executable, "listen"); + check(second !== null && readLine(second) === "client 2", "shared server reused"); + second!.close(); + check(attachNativeTs7Process("old", executable, "listen-exit") === null, "server without --listen"); + console.log("native shared server passed"); +} + const executable = process.argv[2]!; const directory = process.argv[3]!; const report = process.argv[4]!; const mode = process.argv[5] ?? "client"; if (mode === "transport") { checkTransport(executable); +} else if (mode === "shared") { + checkShared(executable); +} else if (mode === "api") { + checkApi(executable, directory); } else if (mode === "exit" || mode === "normal-exit") { const child = new NativeTs7Process(executable, "stall"); const byte = new Uint8Array(1); diff --git a/tests/fixtures/server/certs/README.md b/tests/fixtures/server/certs/README.md index dd383f028..2ab5c1839 100644 --- a/tests/fixtures/server/certs/README.md +++ b/tests/fixtures/server/certs/README.md @@ -13,3 +13,15 @@ Minted ONCE with the openssl recipe below (see san.cnf) and committed with a 100 (ca2 repeats the ca recipe with its own key and CN.) `alt.pem`/`alt-key.pem` (the SNI fixtures' second identity: CN=alt.localhost, SAN DNS:alt.localhost) repeats the leaf recipe against the same `ca.pem`, with an ext section whose subjectAltName is `DNS:alt.localhost`. + +`client.pem`/`client-key.pem` (CN=scriptc test client) is a client certificate for the mutual-TLS fixtures: a P-256 leaf signed by `ca.pem` whose extended key usage is clientAuth (`client.cnf`, section `ext`): + + openssl ecparam -genkey -name prime256v1 -noout -out client-key.pem + openssl req -new -key client-key.pem -out client.csr -subj "/CN=scriptc test client" -config client.cnf + openssl x509 -req -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out client.pem -days 36500 -extfile client.cnf -extensions ext + +`embedded.pem`/`embedded-key.pem` (CN=embedded pem) is a localhost leaf signed by `ca.pem` whose Netscape comment extension holds `alt.pem` as PEM text on one line (`embedded.cnf`, section `ext`). It checks that a peer certificate's DER is read as DER: + + openssl ecparam -genkey -name prime256v1 -noout -out embedded-key.pem + openssl req -new -key embedded-key.pem -out embedded.csr -subj "/CN=embedded pem" -config embedded.cnf + openssl x509 -req -in embedded.csr -CA ca.pem -CAkey ca-key.pem -set_serial 4242 -out embedded.pem -days 36500 -extfile embedded.cnf -extensions ext diff --git a/tests/fixtures/server/certs/client-key.pem b/tests/fixtures/server/certs/client-key.pem new file mode 100644 index 000000000..90f95c680 --- /dev/null +++ b/tests/fixtures/server/certs/client-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIE6TPp76NrDFeBw1tQSN7lygt9zixQ5xaGb0Xug5/aNVoAoGCCqGSM49 +AwEHoUQDQgAE9iN6ruUQ8IneIyb280iqEfRKAusDKrssfCJh7717mNISg8U7TKvG +XXxgsHD/8NKQjw1Z5SZye7FfNv0WetLRvg== +-----END EC PRIVATE KEY----- diff --git a/tests/fixtures/server/certs/client.cnf b/tests/fixtures/server/certs/client.cnf new file mode 100644 index 000000000..4b7151ef4 --- /dev/null +++ b/tests/fixtures/server/certs/client.cnf @@ -0,0 +1,7 @@ +[req] +distinguished_name = dn +[dn] +[ext] +basicConstraints = CA:FALSE +keyUsage = digitalSignature +extendedKeyUsage = clientAuth diff --git a/tests/fixtures/server/certs/client.pem b/tests/fixtures/server/certs/client.pem new file mode 100644 index 000000000..6458a0fe2 --- /dev/null +++ b/tests/fixtures/server/certs/client.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBqzCCAVKgAwIBAgIUBVrP4y4NCL3DYzbSrpkMIwuBg2owCgYIKoZIzj0EAwIw +GzEZMBcGA1UEAwwQdHNuYXRpdmUgdGVzdCBDQTAgFw0yNjEwMjIxNTUxMTZaGA8y +MTI2MDkyODE1NTExNlowHjEcMBoGA1UEAwwTc2NyaXB0YyB0ZXN0IGNsaWVudDBZ +MBMGByqGSM49AgEGCCqGSM49AwEHA0IABPYjeq7lEPCJ3iMm9vNIqhH0SgLrAyq7 +LHwiYe+9e5jSEoPFO0yrxl18YLBw//DSkI8NWeUmcnuxXzb9FnrS0b6jbzBtMAkG +A1UdEwQCMAAwCwYDVR0PBAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMCMB0GA1Ud +DgQWBBS/BxjyTMQkc2IMuhvzyDUhflemADAfBgNVHSMEGDAWgBT7ISW3gYKaXUJu +ZYa2OrD6IBZbBTAKBggqhkjOPQQDAgNHADBEAiBy+DnJFvCUyNmxxeMaWW93n3vJ +XPzLMFQyyoe+hKhoYQIgIDT731Nwwcw/H0Mb+nsb7HMHYihYssPzK52XFAqKA9A= +-----END CERTIFICATE----- diff --git a/tests/fixtures/server/certs/embedded-key.pem b/tests/fixtures/server/certs/embedded-key.pem new file mode 100644 index 000000000..ddf76b1d3 --- /dev/null +++ b/tests/fixtures/server/certs/embedded-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIMzj49L98KADiH/rd18MLx2kdJ8fMMd3Al4AWOE7Ma1VoAoGCCqGSM49 +AwEHoUQDQgAEIdpwV/6VqvmMJkIoZPPgnfNdn+e+NVxyLMMY5n90YMWRfy45AZwn +f/57ssBgq0FC1GDaLRe4dLL9YCSDq3pOzQ== +-----END EC PRIVATE KEY----- diff --git a/tests/fixtures/server/certs/embedded.cnf b/tests/fixtures/server/certs/embedded.cnf new file mode 100644 index 000000000..8d9556e43 --- /dev/null +++ b/tests/fixtures/server/certs/embedded.cnf @@ -0,0 +1,9 @@ +[req] +distinguished_name = dn +[dn] +[ext] +basicConstraints = CA:FALSE +keyUsage = digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = DNS:localhost, IP:127.0.0.1, IP:::1 +nsComment = "-----BEGIN CERTIFICATE-----MIIBwzCCAWigAwIBAgIUSw5Q3SUK8/VNBnfXUCOkHzR4ShUwCgYIKoZIzj0EAwIwGzEZMBcGA1UEAwwQdHNuYXRpdmUgdGVzdCBDQTAgFw0yNjA3MTcwMDU2MjBaGA8yMTI2MDYyMzAwNTYyMFowGDEWMBQGA1UEAwwNYWx0LmxvY2FsaG9zdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJyVZDQSTyauFgEwMTksal/hOBhIYpCAzEBCyQq6e23ETTb4NNwIL/NI0ILAr6QmhJaInY31QJq1NFHDkl4N5YujgYowgYcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwGAYDVR0RBBEwD4INYWx0LmxvY2FsaG9zdDAdBgNVHQ4EFgQU/Ho4zrglTx9pFwqI+09JFLH84LQwHwYDVR0jBBgwFoAU+yElt4GCml1CbmWGtjqw+iAWWwUwCgYIKoZIzj0EAwIDSQAwRgIhAOUMUtjnIJaYCnPV4YrDMrgIziihcbkq6ZqKBSwwbCMOAiEA0vfd3QpyrdTIKwuopJLg2gA6QPnRTXrZdYr0M3NmJ3Q=-----END CERTIFICATE-----" diff --git a/tests/fixtures/server/certs/embedded.pem b/tests/fixtures/server/certs/embedded.pem new file mode 100644 index 000000000..577ea39b8 --- /dev/null +++ b/tests/fixtures/server/certs/embedded.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEbzCCBBagAwIBAgICEJIwCgYIKoZIzj0EAwIwGzEZMBcGA1UEAwwQdHNuYXRp +dmUgdGVzdCBDQTAgFw0yNjEwMjMxNjEwMzVaGA8yMTI2MDkyOTE2MTAzNVowFzEV +MBMGA1UEAwwMZW1iZWRkZWQgcGVtMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +IdpwV/6VqvmMJkIoZPPgnfNdn+e+NVxyLMMY5n90YMWRfy45AZwnf/57ssBgq0FC +1GDaLRe4dLL9YCSDq3pOzaOCA0owggNGMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgWg +MBMGA1UdJQQMMAoGCCsGAQUFBwMBMCwGA1UdEQQlMCOCCWxvY2FsaG9zdIcEfwAA +AYcQAAAAAAAAAAAAAAAAAAAAATCCAqcGCWCGSAGG+EIBDQSCApgWggKULS0tLS1C +RUdJTiBDRVJUSUZJQ0FURS0tLS0tTUlJQnd6Q0NBV2lnQXdJQkFnSVVTdzVRM1NV +SzgvVk5CbmZYVUNPa0h6UjRTaFV3Q2dZSUtvWkl6ajBFQXdJd0d6RVpNQmNHQTFV +RUF3d1FkSE51WVhScGRtVWdkR1Z6ZENCRFFUQWdGdzB5TmpBM01UY3dNRFUyTWpC +YUdBOHlNVEkyTURZeU16QXdOVFl5TUZvd0dERVdNQlFHQTFVRUF3d05ZV3gwTG14 +dlkyRnNhRzl6ZERCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBQkp5 +VlpEUVNUeWF1RmdFd01Ua3NhbC9oT0JoSVlwQ0F6RUJDeVFxNmUyM0VUVGI0Tk53 +SUwvTkkwSUxBcjZRbWhKYUluWTMxUUpxMU5GSERrbDRONVl1amdZb3dnWWN3Q1FZ +RFZSMFRCQUl3QURBTEJnTlZIUThFQkFNQ0JhQXdFd1lEVlIwbEJBd3dDZ1lJS3dZ +QkJRVUhBd0V3R0FZRFZSMFJCQkV3RDRJTllXeDBMbXh2WTJGc2FHOXpkREFkQmdO +VkhRNEVGZ1FVL0hvNHpyZ2xUeDlwRndxSSswOUpGTEg4NExRd0h3WURWUjBqQkJn +d0ZvQVUreUVsdDRHQ21sMUNibVdHdGpxdytpQVdXd1V3Q2dZSUtvWkl6ajBFQXdJ +RFNRQXdSZ0loQU9VTVV0am5JSmFZQ25QVjRZckRNcmdJemlpaGNia3E2WnFLQlN3 +d2JDTU9BaUVBMHZmZDNRcHlyZFRJS3d1b3BKTGcyZ0E2UVBuUlRYclpkWXIwTTNO +bUozUT0tLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tMB0GA1UdDgQWBBQ+Y3aDuoRS +0FRNT2st7Lzlo7BtDjAfBgNVHSMEGDAWgBT7ISW3gYKaXUJuZYa2OrD6IBZbBTAK +BggqhkjOPQQDAgNHADBEAiB3zl81cDKI4GvoRBDneF53eeLR6ftnaD9Pf6hCHUUA +zwIgauFJwF7rLQ0krjZXxdOMZEOl7i1sBrlOfGiIyGSn84s= +-----END CERTIFICATE----- diff --git a/tests/fixtures/static-objects-cycle/main.cjs b/tests/fixtures/static-objects-cycle/main.cjs new file mode 100644 index 000000000..b6e7b8fdd --- /dev/null +++ b/tests/fixtures/static-objects-cycle/main.cjs @@ -0,0 +1,2 @@ +// A require cycle through an intermediate module (tests/harness/static-objects-order.test.ts). +require("cycle-pkg/cycle-d.js"); diff --git a/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-d.d.ts b/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-d.d.ts new file mode 100644 index 000000000..f47e85577 --- /dev/null +++ b/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-d.d.ts @@ -0,0 +1,2 @@ +declare const dd: any; +export { dd }; diff --git a/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-d.js b/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-d.js new file mode 100644 index 000000000..daae636b9 --- /dev/null +++ b/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-d.js @@ -0,0 +1,8 @@ +// A require cycle through cycle-m.js: cycle-r.js runs while this module +// is still loading, before dd.f is assigned. +var dd = {}; +module.exports = { dd: dd }; +require("./cycle-m"); +dd.f = function () { + return "f"; +}; diff --git a/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-m.js b/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-m.js new file mode 100644 index 000000000..936bc042f --- /dev/null +++ b/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-m.js @@ -0,0 +1 @@ +require("./cycle-r"); diff --git a/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-r.js b/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-r.js new file mode 100644 index 000000000..22550083b --- /dev/null +++ b/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/cycle-r.js @@ -0,0 +1,7 @@ +var d = require("./cycle-d"); +console.log("cycle-r sees dd.f as", typeof d.dd.f); +module.exports = { + call: function () { + return d.dd.f(); + }, +}; diff --git a/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/package.json b/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/package.json new file mode 100644 index 000000000..f6f397167 --- /dev/null +++ b/tests/fixtures/static-objects-cycle/node_modules/cycle-pkg/package.json @@ -0,0 +1 @@ +{ "name": "cycle-pkg", "version": "1.0.0" } diff --git a/tests/fixtures/static-objects-cycle/package.json b/tests/fixtures/static-objects-cycle/package.json new file mode 100644 index 000000000..cf07c89b1 --- /dev/null +++ b/tests/fixtures/static-objects-cycle/package.json @@ -0,0 +1 @@ +{ "name": "static-objects-cycle", "private": true } diff --git a/tests/fixtures/tls/empty-ca.ts b/tests/fixtures/tls/empty-ca.ts new file mode 100644 index 000000000..7d1d30928 --- /dev/null +++ b/tests/fixtures/tls/empty-ca.ts @@ -0,0 +1,36 @@ +// A server's ca option as a trust store for client certificates, run with +// NODE_EXTRA_CA_CERTS naming the test CA, so the default store trusts the +// client: an empty Buffer is an empty store, an empty string and no ca use +// the default store. +import { readFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const r = (f: string): Buffer => readFileSync(`${D}/${f}`); +const cert = r("localhost.pem"), key = r("localhost-key.pem"), ca = r("ca.pem"); +const cases: [string, Buffer | string | undefined][] = [ + ["empty Buffer", Buffer.alloc(0)], + ["empty string", ""], + ["no ca", undefined], + ["ca", ca], +]; +for (const [label, value] of cases) { + await new Promise((done) => { + const report = (s: tls.TLSSocket): void => { + console.log(label, s.authorized, s.authorizationError); + s.end(); + }; + const server = + value === undefined + ? tls.createServer({ cert, key, requestCert: true, rejectUnauthorized: false }, report) + : tls.createServer({ cert, key, requestCert: true, rejectUnauthorized: false, ca: value }, report); + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + const c = tls.connect({ port, host: "127.0.0.1", ca, servername: "localhost", cert: r("client.pem"), key: r("client-key.pem") }); + c.on("error", () => {}); + c.resume(); + c.on("close", () => server.close(() => done())); + }); + }); +} diff --git a/tests/harness/__snapshots__/assert-class-fences.ts.txt b/tests/harness/__snapshots__/assert-class-fences.ts.txt index 29d2e4668..f70683e22 100644 --- a/tests/harness/__snapshots__/assert-class-fences.ts.txt +++ b/tests/harness/__snapshots__/assert-class-fences.ts.txt @@ -16,7 +16,7 @@ assert-class-fences.ts:12:16 - error SC2020: 'assert.Assert instances used as va hint: bind `new Assert(...)` with literal options to a const and only call methods on it (`const a = new Assert({ diff: 'full' }); a.strictEqual(x, y)`) -assert-class-fences.ts:13:22 - error SC2001: values of type 'object | Assert' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +assert-class-fences.ts:13:22 - error SC2001: values of type 'object | Assert' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 12 | const passed = new Assert(); 13 | function useInstance(a: Assert | object): void { diff --git a/tests/harness/__snapshots__/assert-fences.ts.txt b/tests/harness/__snapshots__/assert-fences.ts.txt index 2ab78f117..041c20e9b 100644 --- a/tests/harness/__snapshots__/assert-fences.ts.txt +++ b/tests/harness/__snapshots__/assert-fences.ts.txt @@ -104,12 +104,12 @@ assert-fences.ts:48:16 - error SC2020: 'assert.ifError over 'Map hint: numbers, strings, booleans, Errors, null/undefined, and unions of those are the lowered forms -assert-fences.ts:50:12 - error SC2020: 'assert.strictEqual' is part of the standard library types but has no scriptc lowering yet +assert-fences.ts:50:18 - error SC2020: 'assert.throws' is part of the standard library types but has no scriptc lowering yet 49 | - 50 | const eq = assert.strictEqual; // assert functions as values - | ^~~~~~~~~~~~~~~~~~ - 51 | eq(1, 1); + 50 | const throwsFn = assert.throws; // assert.throws as a value: it takes the function to run + | ^~~~~~~~~~~~~ + 51 | throwsFn(() => {}); hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) diff --git a/tests/harness/__snapshots__/class-interface-refinements.ts.txt b/tests/harness/__snapshots__/class-interface-refinements.ts.txt index 9d7d7b87f..89f3ead6d 100644 --- a/tests/harness/__snapshots__/class-interface-refinements.ts.txt +++ b/tests/harness/__snapshots__/class-interface-refinements.ts.txt @@ -7,7 +7,7 @@ class-interface-refinements.ts:8:26 - error SC2002: record shapes must match exa hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead -class-interface-refinements.ts:12:16 - error SC2001: values of type 'AddedStorage' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +class-interface-refinements.ts:12:16 - error SC2001: values of type 'AddedStorage' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 11 | interface AddedStorage extends Box { extra: string; } 12 | function added(view: AddedStorage): string { return view.extra; } @@ -23,7 +23,7 @@ class-interface-refinements.ts:13:13 - error SC2004: uses of 'added' inherit the hint: the declaration of 'added' did not compile — fix the diagnostic reported there and these sites clear with it -class-interface-refinements.ts:16:18 - error SC2001: values of type 'ChangedMethod' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +class-interface-refinements.ts:16:18 - error SC2001: values of type 'ChangedMethod' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 15 | interface ChangedMethod extends Box { read(): number; } 16 | function changed(view: ChangedMethod): number { return view.read(); } diff --git a/tests/harness/__snapshots__/coverage-node-islands-explain.txt b/tests/harness/__snapshots__/coverage-node-islands-explain.txt new file mode 100644 index 000000000..f416b9781 --- /dev/null +++ b/tests/harness/__snapshots__/coverage-node-islands-explain.txt @@ -0,0 +1,13 @@ + Node island ni-ctor@1.0.0 (would-be, report-only) + path tests/coverage-fixtures/node-islands/node_modules/ni-ctor + class object-model: needs a mutable object model + decision automatic (npm code outside the native subset) + evaluation eager at its module position (deferred evaluation is decided by a later milestone) + reasons (first per module; decisive class first) + object-model tests/coverage-fixtures/node-islands/node_modules/ni-ctor/index.js:9:1 + R2.1 constructor functions: a write of 'Counter.prototype.add' + all reasons 2 (2 object-model) + boundary 1 native import site: + tests/coverage-fixtures/node-islands/main.ts:7:1 + copy and handle decisions per boundary site come with compiler-managed islands + carries ni-dep \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-node-islands-user-js.txt b/tests/harness/__snapshots__/coverage-node-islands-user-js.txt new file mode 100644 index 000000000..0898e19ba --- /dev/null +++ b/tests/harness/__snapshots__/coverage-node-islands-user-js.txt @@ -0,0 +1,36 @@ +scriptc coverage tests/coverage-fixtures/node-islands-user-js.cjs + + statements analyzed 13 + compile statically 10 (76%) + in dynamic tier 10 (operate on values typed only at runtime; --explain-dynamic lists them) + (1 function not analyzed — signature blocked; their statements count as not static) + + runs at startup 1 site (JS statements in module top-level code, which runs when the module loads) + ×1 SC2004 uses of 'legacy' inherit the blocker on its declaration + at tests/coverage-fixtures/node-islands-user-js.cjs:22:57 + hint: the declaration of 'legacy' did not compile — fix the diagnostic reported there and these sites clear with it + + deferred to runtime 1 site (JS statements that throw their fence if executed) + ×1 SC1090 parameterized 'arguments' outside an ES module (sloppy-mode parameter aliases) + at tests/coverage-fixtures/node-islands-user-js.cjs:15:1 + hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary) + + blockers: + ×1 SC8101 A write of 'Point.prototype.norm' needs a mutable JavaScript object model, which native code does not have + at tests/coverage-fixtures/node-islands-user-js.cjs:7:1 + hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules + ×1 SC8101 'new Point()' of a function needs a mutable JavaScript object model, which native code does not have + at tests/coverage-fixtures/node-islands-user-js.cjs:22:13 + hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules + ×1 SC8102 'Object.setPrototypeOf()' needs a mutable JavaScript object model, which native code does not have + at tests/coverage-fixtures/node-islands-user-js.cjs:12:1 + hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules + ×1 SC8103 'Object.defineProperty()' needs a mutable JavaScript object model, which native code does not have + at tests/coverage-fixtures/node-islands-user-js.cjs:14:1 + hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules + ×1 SC8104 A write to 'arguments[i]' in sloppy mode needs a mutable JavaScript object model, which native code does not have + at tests/coverage-fixtures/node-islands-user-js.cjs:16:3 + hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules + ×1 SC8105 A write of 'Array.prototype.last' needs a mutable JavaScript object model, which native code does not have + at tests/coverage-fixtures/node-islands-user-js.cjs:19:1 + hint: rewrite it as a class or typed object, or mark this module as a Node island in scriptc.nodeIslands.modules \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-node-islands.txt b/tests/harness/__snapshots__/coverage-node-islands.txt new file mode 100644 index 000000000..e94ad3560 --- /dev/null +++ b/tests/harness/__snapshots__/coverage-node-islands.txt @@ -0,0 +1,39 @@ +scriptc coverage tests/coverage-fixtures/node-islands/main.ts + + statements analyzed 29 + compile statically 27 (93%) + in dynamic tier 17 (operate on values typed only at runtime; --explain-dynamic lists them) + + npm package compilation: + ni-bundled refused (its shipped JS carries build-transform markers (bundled/transpiled dist)) + ni-gap static + ni-eval static + ni-ctor static + ni-typed static + ni-dep static + + Node islands (would-be, report-only: the build is unchanged; --node-islands=auto) + gap ni-gap@1.0.0 + tests/coverage-fixtures/node-islands/node_modules/ni-gap/index.js:7:17: SC1090: a builtin function alias as an escaping value (call 'ext' directly) is not supported yet + runtime ni-eval@1.0.0 + tests/coverage-fixtures/node-islands/node_modules/ni-eval/index.js:4:10: R1.1 code from strings: new Function() + object-model ni-ctor@1.0.0 + tests/coverage-fixtures/node-islands/node_modules/ni-ctor/index.js:9:1: R2.1 constructor functions: a write of 'Counter.prototype.add' + +1 more reason in 1 module; carries ni-dep + packaging ni-bundled@1.0.0 + SC2013: its shipped JS carries build-transform markers (bundled/transpiled dist) + reached from native code and from an island: ni-typed + hint: scriptc coverage --explain-island= lists every reason + + deferred to runtime 1 site (JS statements that throw their fence if executed) + ×1 SC1090 a builtin function alias as an escaping value (call 'ext' directly) + at tests/coverage-fixtures/node-islands/node_modules/ni-gap/index.js:7:17 + hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary) + + blockers: + ×2 SC2013 cannot compile npm package 'ni-bundled' statically: its implementation is not part of the compiled module graph + at tests/coverage-fixtures/node-islands/main.ts:8:1, tests/coverage-fixtures/node-islands/main.ts:16:13 + hint: use a dependency with supported source, or replace the unsupported operation; scriptc has no interpreter fallback + ×1 SC2013 cannot compile npm package 'ni-bundled' statically: its shipped JS carries build-transform markers (bundled/transpiled dist) + at tests/coverage-fixtures/node-islands/main.ts:8:1 + hint: to compile it anyway, name it with --npm-static=ni-bundled (or --npm-static=ni-bundled... to include its dependencies) \ No newline at end of file diff --git a/tests/harness/__snapshots__/create-require.ts.txt b/tests/harness/__snapshots__/create-require.ts.txt index 219af92a4..a3c56a033 100644 --- a/tests/harness/__snapshots__/create-require.ts.txt +++ b/tests/harness/__snapshots__/create-require.ts.txt @@ -7,14 +7,14 @@ create-require.ts:13:13 - error SC2020: 'module.createRequire' is part of the st hint: the lowered shape is a const binding (or a proven stable top-level let/var) over createRequire(import.meta.url) (or __filename) whose require calls take STATIC string literals — builtins, project modules (including #imports), relative .json documents and natively compiled npm packages resolve at build time; dynamic specifiers cannot exist in a compiled binary's fixed module graph -create-require.ts:20:1 - error SC2020: 'createRequire's require with this argument shape' is part of the standard library types but has no scriptc lowering yet +create-require.ts:20:1 - error SC2020: 'createRequire's require with a computed specifier or this argument shape' is part of the standard library types but has no scriptc lowering yet 19 | const which = "left" + "-pad"; 20 | require(which); | ^~~~~~~~~~~~~~ 21 | - hint: the compiled module graph is fixed at build time — the one lowered form is require("") + hint: the compiled module graph is fixed at build time — the one lowered form is require(""); native addons are not loaded create-require.ts:23:1 - error SC2020: 'createRequire's require of './helper.js'' is part of the standard library types but has no scriptc lowering yet diff --git a/tests/harness/__snapshots__/date-formatters.ts.txt b/tests/harness/__snapshots__/date-formatters.ts.txt index 75e15760b..d6dd27c5a 100644 --- a/tests/harness/__snapshots__/date-formatters.ts.txt +++ b/tests/harness/__snapshots__/date-formatters.ts.txt @@ -5,7 +5,7 @@ date-formatters.ts:4:1 - error SC2020: 'Date.prototype.toLocaleTimeString' is pa | ^~~~~~~~~~~~~~~~~~~~~~~~~ 5 | date.toLocaleString(); - hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale formatters have no lowering + hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part of the standard library types but has no scriptc lowering yet @@ -14,4 +14,4 @@ date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part o | ^~~~~~~~~~~~~~~~~~~~~ 6 | - hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale formatters have no lowering \ No newline at end of file + hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering \ No newline at end of file diff --git a/tests/harness/__snapshots__/dynamic-representable-types.ts.txt b/tests/harness/__snapshots__/dynamic-representable-types.ts.txt index 21402b597..6241894f1 100644 --- a/tests/harness/__snapshots__/dynamic-representable-types.ts.txt +++ b/tests/harness/__snapshots__/dynamic-representable-types.ts.txt @@ -1,4 +1,4 @@ -dynamic-representable-types.ts:7:7 - error SC2001: values of type '[number, (string | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +dynamic-representable-types.ts:7:7 - error SC2001: values of type '[number, (string | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 6 | const bare: any = 41; 7 | const optTuple: [number, string?] = [1]; diff --git a/tests/harness/__snapshots__/function-value-arguments-length.ts.txt b/tests/harness/__snapshots__/function-value-arguments-length.ts.txt new file mode 100644 index 000000000..adcb48d2a --- /dev/null +++ b/tests/harness/__snapshots__/function-value-arguments-length.ts.txt @@ -0,0 +1,43 @@ +function-value-arguments-length.ts:7:17 - error SC1090: 'arguments.length' in function expressions passed where the expected function type has optional or rest parameters (the count varies per call) are not supported yet + + 6 | const replaced = "a-b".replace(/-/, function (match) { + 7 | return String(arguments.length) + match; + | ^~~~~~~~~~~~~~~~ + 8 | }); + +function-value-arguments-length.ts:13:10 - error SC1090: 'arguments.length' in function expressions passed where the expected function type has optional or rest parameters (the count varies per call) are not supported yet + + 12 | const counted = call(function (a) { + 13 | return arguments.length + a; + | ^~~~~~~~~~~~~~~~ + 14 | }); + +function-value-arguments-length.ts:18:22 - error SC1090: functions that read 'arguments.length' used as values where the expected function type has optional or rest parameters (the count varies per call) are not supported yet + + 17 | } + 18 | const offsets = call(offset); + | ^~~~~~ + 19 | class Scale { + +function-value-arguments-length.ts:21:20 - error SC1090: 'arguments.length' in methods, nested, async and generator functions used as values (a caller of the value can pass more arguments than the function declares) are not supported yet + + 20 | static by(value: number): number { + 21 | return value * arguments.length; + | ^~~~~~~~~~~~~~~~ + 22 | } + +function-value-arguments-length.ts:32:30 - error SC1090: 'arguments.length' in an arrow function inside a function that is also used as a value is not supported yet + + 31 | function deferred(value: number): number { + 32 | const read = (): number => arguments.length; + | ^~~~~~~~~~~~~~~~ + 33 | return value + read(); + +function-value-arguments-length.ts:33:18 - error SC2004: uses of 'read' inherit the blocker on its declaration + + 32 | const read = (): number => arguments.length; + 33 | return value + read(); + | ^~~~ + 34 | } + + hint: the declaration of 'read' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/__snapshots__/generics.ts.txt b/tests/harness/__snapshots__/generics.ts.txt index 4dfc8019f..86dd94bb9 100644 --- a/tests/harness/__snapshots__/generics.ts.txt +++ b/tests/harness/__snapshots__/generics.ts.txt @@ -12,7 +12,7 @@ generics.ts:30:10 - error SC1090: '.join()' on arrays of this element type (numb | ^~~~~~~~~~~ 31 | } -generics.ts:44:7 - error SC2001: values of type 'typeof Box' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +generics.ts:44:7 - error SC2001: values of type 'typeof Box' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 43 | // The uninstantiated family as a value: no thunk, no single ctor ABI. 44 | const BoxAlias = Box; diff --git a/tests/harness/__snapshots__/intl-segmenter.ts.txt b/tests/harness/__snapshots__/intl-segmenter.ts.txt index e50205eec..19a13ef4f 100644 --- a/tests/harness/__snapshots__/intl-segmenter.ts.txt +++ b/tests/harness/__snapshots__/intl-segmenter.ts.txt @@ -1,15 +1,15 @@ intl-segmenter.ts:2:1 - error SC2020: 'Intl.Segmenter locale negotiation' is part of the standard library types but has no scriptc lowering yet - 1 | // The native implementation covers default Unicode grapheme segmentation. - 2 | new Intl.Segmenter("en-US"); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~ + 1 | // The native implementation covers Unicode grapheme segmentation. + 2 | new Intl.Segmenter(["en-US", "de"]); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | new Intl.Segmenter(undefined, { granularity: "word" }); - hint: omit the locale or pass undefined for default Unicode grapheme segmentation + hint: omit the locale, pass undefined, or pass one language tag as a string literal intl-segmenter.ts:3:31 - error SC2020: 'Intl.Segmenter options' is part of the standard library types but has no scriptc lowering yet - 2 | new Intl.Segmenter("en-US"); + 2 | new Intl.Segmenter(["en-US", "de"]); 3 | new Intl.Segmenter(undefined, { granularity: "word" }); | ^~~~~~~~~~~~~~~~~~~~~~~ 4 | new Intl.Segmenter(undefined, { granularity: "sentence" }); diff --git a/tests/harness/__snapshots__/maps-surface.ts.txt b/tests/harness/__snapshots__/maps-surface.ts.txt index c1db0576d..ed4ebb79f 100644 --- a/tests/harness/__snapshots__/maps-surface.ts.txt +++ b/tests/harness/__snapshots__/maps-surface.ts.txt @@ -1,4 +1,4 @@ -maps-surface.ts:21:11 - error SC2001: values of type '(v: number, k: string, theMap: Map) => void' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +maps-surface.ts:21:11 - error SC2001: values of type '(v: number, k: string, theMap: Map) => void' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 20 | // The forEach callback receives (value, key) — no third map parameter. 21 | m.forEach((v, k, theMap) => console.log(k, v)); diff --git a/tests/harness/__snapshots__/regex-match-destructuring-fences.ts.txt b/tests/harness/__snapshots__/regex-match-destructuring-fences.ts.txt index d4eb59168..43d4eff77 100644 --- a/tests/harness/__snapshots__/regex-match-destructuring-fences.ts.txt +++ b/tests/harness/__snapshots__/regex-match-destructuring-fences.ts.txt @@ -5,7 +5,7 @@ regex-match-destructuring-fences.ts:6:16 - error SC1031: rest bindings over rege | ^~~~~~~ 7 | const { input = "none" } = m; -regex-match-destructuring-fences.ts:6:19 - error SC2001: values of type '{ [n: number]: string; input: string; 0: string; length: number; toString(): string; toLocaleString(): string; toLocaleString(locales: string | string[], options?: (NumberFormatOptions & DateTimeFormatOptions) | undefined): string; ... 39 more ...; with(index: number, value: string): string[]; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +regex-match-destructuring-fences.ts:6:19 - error SC2001: values of type '{ [n: number]: string; input: string; 0: string; length: number; toString(): string; toLocaleString(): string; toLocaleString(locales: string | string[], options?: (NumberFormatOptions & DateTimeFormatOptions) | undefined): string; ... 39 more ...; with(index: number, value: string): string[]; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 5 | const m = /(a)(b)?/.exec("ab")!; 6 | const { index, ...rest } = m; diff --git a/tests/harness/__snapshots__/retired-engine-2103-optional-tuple-elements.ts.txt b/tests/harness/__snapshots__/retired-engine-2103-optional-tuple-elements.ts.txt index 4d3dd52c3..dfdb00720 100644 --- a/tests/harness/__snapshots__/retired-engine-2103-optional-tuple-elements.ts.txt +++ b/tests/harness/__snapshots__/retired-engine-2103-optional-tuple-elements.ts.txt @@ -43,7 +43,7 @@ retired-engine-2103-optional-tuple-elements.ts:19:1 - error SC2004: uses of 'f2' hint: the declaration of 'f2' did not compile — fix the diagnostic reported there and these sites clear with it -retired-engine-2103-optional-tuple-elements.ts:20:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +retired-engine-2103-optional-tuple-elements.ts:20:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 19 | f2([]); 20 | const t: [string?, number?] = ["x"]; @@ -59,7 +59,7 @@ retired-engine-2103-optional-tuple-elements.ts:21:13 - error SC2004: uses of 't' hint: the declaration of 't' did not compile — fix the diagnostic reported there and these sites clear with it -retired-engine-2103-optional-tuple-elements.ts:22:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +retired-engine-2103-optional-tuple-elements.ts:22:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 21 | console.log(t.length, `${t[0]} ${t[1]}`); 22 | const full: [string?, number?] = ["y", 7]; @@ -75,14 +75,14 @@ retired-engine-2103-optional-tuple-elements.ts:23:13 - error SC2004: uses of 'fu hint: the declaration of 'full' did not compile — fix the diagnostic reported there and these sites clear with it -retired-engine-2103-optional-tuple-elements.ts:24:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +retired-engine-2103-optional-tuple-elements.ts:24:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 23 | console.log(full.length, `${full[0]} ${full[1]}`); 24 | const none: [string?, number?] = []; | ^~~~ 25 | console.log(none.length); -retired-engine-2103-optional-tuple-elements.ts:24:34 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +retired-engine-2103-optional-tuple-elements.ts:24:34 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 23 | console.log(full.length, `${full[0]} ${full[1]}`); 24 | const none: [string?, number?] = []; @@ -98,7 +98,7 @@ retired-engine-2103-optional-tuple-elements.ts:25:13 - error SC2004: uses of 'no hint: the declaration of 'none' did not compile — fix the diagnostic reported there and these sites clear with it -retired-engine-2103-optional-tuple-elements.ts:27:7 - error SC2001: values of type '[string, ...number[]]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +retired-engine-2103-optional-tuple-elements.ts:27:7 - error SC2001: values of type '[string, ...number[]]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 26 | // A rest-element tuple rides the same island representation. 27 | const r: [string, ...number[]] = ["head", 1, 2, 3]; diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index c183f7cdc..313a9391b 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -55,88 +55,65 @@ stdlib-fence.ts:23:11 - error SC2020: 'new Date with spread arguments' is part o 22 | // Constructors. 23 | const d = new Date(...([2024, 0] as const)); // spread field arguments stay fenced | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 24 | const mutableDate = new Date(0); + 24 | const wr = new WeakRef({}); hint: new Date(), new Date(milliseconds), new Date(dateString), and new Date(year, monthIndex, ...) with listed arguments are supported -stdlib-fence.ts:25:1 - error SC2020: 'Date.prototype.setUTCFullYear with arguments' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:24:7 - error SC2020: 'WeakRef<{}>' is part of the standard library types but has no scriptc lowering yet - 24 | const mutableDate = new Date(0); - 25 | mutableDate.setUTCFullYear(2024); // mutation is outside the read-only Date slice - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 26 | const wr = new WeakRef({}); - - hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale formatters have no lowering - -stdlib-fence.ts:26:7 - error SC2020: 'WeakRef<{}>' is part of the standard library types but has no scriptc lowering yet - - 25 | mutableDate.setUTCFullYear(2024); // mutation is outside the read-only Date slice - 26 | const wr = new WeakRef({}); + 23 | const d = new Date(...([2024, 0] as const)); // spread field arguments stay fenced + 24 | const wr = new WeakRef({}); | ^~ - 27 | const px = new Proxy({ a: 1 }, {}); + 25 | const px = new Proxy({ a: 1 }, {}); hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) -stdlib-fence.ts:26:12 - error SC2020: 'new WeakRef' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:24:12 - error SC2020: 'new WeakRef' is part of the standard library types but has no scriptc lowering yet - 25 | mutableDate.setUTCFullYear(2024); // mutation is outside the read-only Date slice - 26 | const wr = new WeakRef({}); + 23 | const d = new Date(...([2024, 0] as const)); // spread field arguments stay fenced + 24 | const wr = new WeakRef({}); | ^~~~~~~~~~~~~~~ - 27 | const px = new Proxy({ a: 1 }, {}); + 25 | const px = new Proxy({ a: 1 }, {}); hint: deref()-after-collect exposes GC timing — genuinely dynamic; hold a strong reference instead -stdlib-fence.ts:41:31 - error SC1090: '.split()' on a 'RegExp | string' separator (pass a string, or a regex literal) is not supported yet +stdlib-fence.ts:39:31 - error SC1090: '.split()' on a 'RegExp | string' separator (pass a string, or a regex literal) is not supported yet - 40 | const norm = "abc".normalize(); - 41 | const limited = "a,b,c".split(Math.random() ? "," : /,/, 2); // union separators stay fenced; limits lower + 38 | const norm = "abc".normalize(); + 39 | const limited = "a,b,c".split(Math.random() ? "," : /,/, 2); // union separators stay fenced; limits lower | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 42 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) + 40 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) -stdlib-fence.ts:43:19 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:41:19 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet - 42 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) - 43 | const localized = (1234.5).toLocaleString(); + 40 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) + 41 | const localized = (1234.5).toLocaleString(); | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 44 | // Unlowered call FORMS of lowered members. (push is fully variadic now — + 42 | // Unlowered call FORMS of lowered members. (push is fully variadic now — hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") -stdlib-fence.ts:49:1 - error SC2020: '.forEach with 2 arguments' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:47:1 - error SC2020: '.forEach with 2 arguments' is part of the standard library types but has no scriptc lowering yet - 48 | const joined = nums.join(); // the omitted separator lowers - 49 | nums.forEach((x) => console.log(x), { unused: true }); // thisArg stays fenced + 46 | const joined = nums.join(); // the omitted separator lowers + 47 | nums.forEach((x) => console.log(x), { unused: true }); // thisArg stays fenced | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 50 | const pos = "abc".includes("b", 1); // the position form lowers now (indexOf's clamp) + 48 | const pos = "abc".includes("b", 1); // the position form lowers now (indexOf's clamp) hint: the thisArg parameter has no lowering — use an arrow function -stdlib-fence.ts:77:9 - error SC1090: throwing Date values (catch bindings cannot preserve Date's object kind; throw an Error or primitive instead) are not supported yet +stdlib-fence.ts:84:10 - error SC1090: comparisons of related class values beside structural union members (narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) are not supported yet - 76 | function throwDate(): void { - 77 | throw new Date(0); // the exception cell cannot preserve Date's object kind - | ^~~~~~~~~~~ - 78 | } - -stdlib-fence.ts:83:23 - error SC1090: .throw() with a Date value (the exception channel cannot preserve Date's object kind; throw an Error or primitive instead) is not supported yet - - 82 | } - 83 | dateGenerator().throw(new Date(0)); - | ^~~~~~~~~~~ - 84 | // Related classes may store one object under different union tags. A - -stdlib-fence.ts:94:10 - error SC1090: comparisons of related class values beside structural union members (narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) are not supported yet - - 93 | function relatedStrict(a: FenceValue, b: FenceValue): boolean { - 94 | return a === b; + 83 | function relatedStrict(a: FenceValue, b: FenceValue): boolean { + 84 | return a === b; | ^~~~~~~ - 95 | } + 85 | } -stdlib-fence.ts:97:10 - error SC2020: 'Object.is over related class values beside structural union members' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:87:10 - error SC2020: 'Object.is over related class values beside structural union members' is part of the standard library types but has no scriptc lowering yet - 96 | function relatedSameValue(a: FenceValue, b: FenceSub): boolean { - 97 | return Object.is(a, b); + 86 | function relatedSameValue(a: FenceValue, b: FenceSub): boolean { + 87 | return Object.is(a, b); | ^~~~~~~~~~~~~~~ - 98 | } + 88 | } hint: narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms \ No newline at end of file diff --git a/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt b/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt index c45608bb9..80ace6df6 100644 --- a/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt +++ b/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt @@ -5,4 +5,4 @@ tls-sni-callback-fences.ts:13:54 - error SC2020: 'tls.createServer with SNICallb | ^~~~~~~~~~~~~~~~~~~~ 14 | - hint: SNICallback lowers with cert and key; ALPNProtocols, minVersion, maxVersion and the validated options are not combined with it yet \ No newline at end of file + hint: SNICallback lowers with cert and key; ALPNProtocols, minVersion, maxVersion, requestCert, ca and the validated options are not combined with it yet \ No newline at end of file diff --git a/tests/harness/__snapshots__/tuples.ts.txt b/tests/harness/__snapshots__/tuples.ts.txt index a6f58710e..440904095 100644 --- a/tests/harness/__snapshots__/tuples.ts.txt +++ b/tests/harness/__snapshots__/tuples.ts.txt @@ -12,14 +12,14 @@ tuples.ts:12:6 - error SC1090: tuple indexing with a non-literal index (tuples a | ^ 13 | -tuples.ts:15:7 - error SC2001: values of type '[string, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +tuples.ts:15:7 - error SC2001: values of type '[string, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 14 | // Optional and rest elements have no fixed arity/shape. 15 | const opt: [string, number?] = ["x"]; | ^~~ 16 | const rest: [string, ...number[]] = ["y", 1, 2]; -tuples.ts:16:7 - error SC2001: values of type '[string, ...number[]]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +tuples.ts:16:7 - error SC2001: values of type '[string, ...number[]]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 15 | const opt: [string, number?] = ["x"]; 16 | const rest: [string, ...number[]] = ["y", 1, 2]; diff --git a/tests/harness/__snapshots__/union-clone-override-width.ts.txt b/tests/harness/__snapshots__/union-clone-override-width.ts.txt index 6b5cc3566..8915968ef 100644 --- a/tests/harness/__snapshots__/union-clone-override-width.ts.txt +++ b/tests/harness/__snapshots__/union-clone-override-width.ts.txt @@ -1,4 +1,4 @@ -union-clone-override-width.ts:8:18 - error SC2001: values of type '{ kind: "left"; left: number; payload: WiderPayload; } | { kind: "right"; right: number; payload: WiderPayload; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +union-clone-override-width.ts:8:18 - error SC2001: values of type '{ kind: "left"; left: number; payload: WiderPayload; } | { kind: "right"; right: number; payload: WiderPayload; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 7 | // copy its record. The inferred spread result keeps the wider payload. 8 | const result = { ...source, payload }; diff --git a/tests/harness/__snapshots__/x509-fences.ts.txt b/tests/harness/__snapshots__/x509-fences.ts.txt new file mode 100644 index 000000000..2f5cc4e68 --- /dev/null +++ b/tests/harness/__snapshots__/x509-fences.ts.txt @@ -0,0 +1,48 @@ +x509-fences.ts:8:13 - error SC1090: Object.keys over X509Certificate values (Node keeps their fields as prototype getters, so own-key walks see none; read the fields) are not supported yet + + 7 | const cert = new X509Certificate(readFileSync("cert.pem")); + 8 | console.log(Object.keys(cert)); + | ^~~~~~~~~~~~~~~~~ + 9 | console.log(Object.entries(cert)); + +x509-fences.ts:9:13 - error SC1090: Object.entries over X509Certificate values (Node keeps their fields as prototype getters, so own-key walks see none; read the fields) are not supported yet + + 8 | console.log(Object.keys(cert)); + 9 | console.log(Object.entries(cert)); + | ^~~~~~~~~~~~~~~~~~~~ + 10 | console.log({ ...cert }); + +x509-fences.ts:10:15 - error SC1090: object spread over X509Certificate values (Node keeps their fields as prototype getters, so own-key walks see none; read the fields) are not supported yet + + 9 | console.log(Object.entries(cert)); + 10 | console.log({ ...cert }); + | ^~~~~~~ + 11 | console.log(Object.prototype.hasOwnProperty.call(cert, "subject")); + +x509-fences.ts:11:50 - error SC1090: Object.prototype.hasOwnProperty over X509Certificate values (Node keeps their fields as prototype getters, so own-key walks see none; read the fields) are not supported yet + + 10 | console.log({ ...cert }); + 11 | console.log(Object.prototype.hasOwnProperty.call(cert, "subject")); + | ^~~~ + 12 | for (const key in cert) console.log(key); + +x509-fences.ts:12:19 - error SC1090: for-in over X509Certificate values (Node keeps their fields as prototype getters, so own-key walks see none; read the fields) are not supported yet + + 11 | console.log(Object.prototype.hasOwnProperty.call(cert, "subject")); + 12 | for (const key in cert) console.log(key); + | ^~~~ + 13 | + +x509-fences.ts:16:23 - error SC1090: X509Certificate values in 'unknown' or 'any' slots (Node keeps a certificate's fields as prototype getters, and its toString() and toJSON() give the PEM; keep the X509Certificate type) are not supported yet + + 15 | // its fields, which Node's certificate is not. + 16 | const held: unknown = cert; + | ^~~~ + 17 | console.log(held); + +x509-fences.ts:21:23 - error SC1090: X509Certificate values in 'unknown' or 'any' slots (Node keeps a certificate's fields as prototype getters, and its toString() and toJSON() give the PEM; keep the X509Certificate type) are not supported yet + + 20 | } + 21 | console.log(describe([cert])); + | ^~~~ + 22 | \ No newline at end of file diff --git a/tests/harness/atomics-emission.test.ts b/tests/harness/atomics-emission.test.ts index 2d708e382..9068ad59e 100644 --- a/tests/harness/atomics-emission.test.ts +++ b/tests/harness/atomics-emission.test.ts @@ -59,7 +59,7 @@ console.log(work(new Int32Array(buffer), new Uint8Array(buffer), new Int16Array( // Every runtime call is the cold path for an index out of range. const calls = work.match(/call double @scr_atomics_op\([^\n]*/g) ?? []; expect(calls).toHaveLength(9); - for (const call of calls) expect(call).toMatch(/\) cold$/); + for (const call of calls) expect(call).toMatch(/\)(?: nounwind)? cold$/); }); test("an unchanged local array is borrowed across Atomics operations", async () => { diff --git a/tests/harness/corpus-costs/6308-number-map-integer-keys.ts.json b/tests/harness/corpus-costs/6308-number-map-integer-keys.ts.json new file mode 100644 index 000000000..a6e4996ff --- /dev/null +++ b/tests/harness/corpus-costs/6308-number-map-integer-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 2800, "san": 3500 } diff --git a/tests/harness/corpus-costs/6928-class-iterator-protocol.ts.json b/tests/harness/corpus-costs/6928-class-iterator-protocol.ts.json new file mode 100644 index 000000000..577adfbd9 --- /dev/null +++ b/tests/harness/corpus-costs/6928-class-iterator-protocol.ts.json @@ -0,0 +1 @@ +{ "plain": 3913, "san": 3785 } diff --git a/tests/harness/corpus-costs/6929-class-iterator-protocol.mjs.json b/tests/harness/corpus-costs/6929-class-iterator-protocol.mjs.json new file mode 100644 index 000000000..a653f7709 --- /dev/null +++ b/tests/harness/corpus-costs/6929-class-iterator-protocol.mjs.json @@ -0,0 +1 @@ +{ "plain": 3934, "san": 3488 } diff --git a/tests/harness/corpus-costs/6935-class-async-iterator-protocol.ts.json b/tests/harness/corpus-costs/6935-class-async-iterator-protocol.ts.json new file mode 100644 index 000000000..facd86f6d --- /dev/null +++ b/tests/harness/corpus-costs/6935-class-async-iterator-protocol.ts.json @@ -0,0 +1 @@ +{ "plain": 3179, "san": 2683 } diff --git a/tests/harness/corpus-costs/6936-class-async-iterator-protocol.mjs.json b/tests/harness/corpus-costs/6936-class-async-iterator-protocol.mjs.json new file mode 100644 index 000000000..cbe2cb7d2 --- /dev/null +++ b/tests/harness/corpus-costs/6936-class-async-iterator-protocol.mjs.json @@ -0,0 +1 @@ +{ "plain": 3547, "san": 2898 } diff --git a/tests/harness/corpus-costs/6936-x509-certificate-fields.ts.json b/tests/harness/corpus-costs/6936-x509-certificate-fields.ts.json new file mode 100644 index 000000000..73e8107eb --- /dev/null +++ b/tests/harness/corpus-costs/6936-x509-certificate-fields.ts.json @@ -0,0 +1 @@ +{ "plain": 3042, "san": 5637 } diff --git a/tests/harness/corpus-costs/6937-class-value-arrays.mjs.json b/tests/harness/corpus-costs/6937-class-value-arrays.mjs.json new file mode 100644 index 000000000..7e905d65a --- /dev/null +++ b/tests/harness/corpus-costs/6937-class-value-arrays.mjs.json @@ -0,0 +1 @@ +{ "plain": 4211, "san": 6383 } diff --git a/tests/harness/corpus-costs/6938-class-value-arrays.ts.json b/tests/harness/corpus-costs/6938-class-value-arrays.ts.json new file mode 100644 index 000000000..76098ce8d --- /dev/null +++ b/tests/harness/corpus-costs/6938-class-value-arrays.ts.json @@ -0,0 +1 @@ +{ "plain": 2399, "san": 4080 } diff --git a/tests/harness/corpus-costs/7090-x509-check-names.ts.json b/tests/harness/corpus-costs/7090-x509-check-names.ts.json new file mode 100644 index 000000000..3ee86dd28 --- /dev/null +++ b/tests/harness/corpus-costs/7090-x509-check-names.ts.json @@ -0,0 +1 @@ +{ "plain": 4003, "san": 5700 } diff --git a/tests/harness/corpus-costs/7091-x509-certificate-keys.ts.json b/tests/harness/corpus-costs/7091-x509-certificate-keys.ts.json new file mode 100644 index 000000000..a9b159564 --- /dev/null +++ b/tests/harness/corpus-costs/7091-x509-certificate-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 4335, "san": 8298 } diff --git a/tests/harness/corpus-costs/7092-x509-legacy-object.ts.json b/tests/harness/corpus-costs/7092-x509-legacy-object.ts.json new file mode 100644 index 000000000..c7e0c4ee2 --- /dev/null +++ b/tests/harness/corpus-costs/7092-x509-legacy-object.ts.json @@ -0,0 +1 @@ +{ "plain": 3910, "san": 5665 } diff --git a/tests/harness/corpus-costs/7093-x509-methods-js.mjs.json b/tests/harness/corpus-costs/7093-x509-methods-js.mjs.json new file mode 100644 index 000000000..8f60594f1 --- /dev/null +++ b/tests/harness/corpus-costs/7093-x509-methods-js.mjs.json @@ -0,0 +1 @@ +{ "plain": 4421, "san": 5823 } diff --git a/tests/harness/corpus-costs/7095-tls-peer-certificates.ts.json b/tests/harness/corpus-costs/7095-tls-peer-certificates.ts.json new file mode 100644 index 000000000..68214a26c --- /dev/null +++ b/tests/harness/corpus-costs/7095-tls-peer-certificates.ts.json @@ -0,0 +1 @@ +{ "plain": 9277, "san": 14000 } diff --git a/tests/harness/corpus-costs/7096-tls-peer-certificates.mjs.json b/tests/harness/corpus-costs/7096-tls-peer-certificates.mjs.json new file mode 100644 index 000000000..ec7c1e0f4 --- /dev/null +++ b/tests/harness/corpus-costs/7096-tls-peer-certificates.mjs.json @@ -0,0 +1 @@ +{ "plain": 4576, "san": 8354 } diff --git a/tests/harness/corpus-costs/7097-tls-client-certificates.ts.json b/tests/harness/corpus-costs/7097-tls-client-certificates.ts.json new file mode 100644 index 000000000..2776a867c --- /dev/null +++ b/tests/harness/corpus-costs/7097-tls-client-certificates.ts.json @@ -0,0 +1 @@ +{ "plain": 8917, "san": 11888 } diff --git a/tests/harness/corpus-costs/7098-tls-client-certificates-paths.ts.json b/tests/harness/corpus-costs/7098-tls-client-certificates-paths.ts.json new file mode 100644 index 000000000..f55fef2c1 --- /dev/null +++ b/tests/harness/corpus-costs/7098-tls-client-certificates-paths.ts.json @@ -0,0 +1 @@ +{ "plain": 6188, "san": 10754 } diff --git a/tests/harness/corpus-costs/7099-tls-client-certificates.mjs.json b/tests/harness/corpus-costs/7099-tls-client-certificates.mjs.json new file mode 100644 index 000000000..02414f135 --- /dev/null +++ b/tests/harness/corpus-costs/7099-tls-client-certificates.mjs.json @@ -0,0 +1 @@ +{ "plain": 4389, "san": 5772 } diff --git a/tests/harness/corpus-costs/7160-json-parse-deep-unions.ts.json b/tests/harness/corpus-costs/7160-json-parse-deep-unions.ts.json new file mode 100644 index 000000000..5d8e97d52 --- /dev/null +++ b/tests/harness/corpus-costs/7160-json-parse-deep-unions.ts.json @@ -0,0 +1 @@ +{ "plain": 11054, "san": 84858 } diff --git a/tests/harness/corpus-costs/7161-dynamic-values-deep-unions.ts.json b/tests/harness/corpus-costs/7161-dynamic-values-deep-unions.ts.json new file mode 100644 index 000000000..bf9a71561 --- /dev/null +++ b/tests/harness/corpus-costs/7161-dynamic-values-deep-unions.ts.json @@ -0,0 +1 @@ +{ "plain": 5843, "san": 57446 } diff --git a/tests/harness/corpus-costs/7186-namespace-object-values.js.json b/tests/harness/corpus-costs/7186-namespace-object-values.js.json new file mode 100644 index 000000000..b9c097972 --- /dev/null +++ b/tests/harness/corpus-costs/7186-namespace-object-values.js.json @@ -0,0 +1 @@ +{ "plain": 3233, "san": 3781 } diff --git a/tests/harness/corpus-costs/7187-namespace-object-reflection.js.json b/tests/harness/corpus-costs/7187-namespace-object-reflection.js.json new file mode 100644 index 000000000..82f5d5ebf --- /dev/null +++ b/tests/harness/corpus-costs/7187-namespace-object-reflection.js.json @@ -0,0 +1 @@ +{ "plain": 3183, "san": 3882 } diff --git a/tests/harness/corpus-costs/7188-namespace-object-extensions.js.json b/tests/harness/corpus-costs/7188-namespace-object-extensions.js.json new file mode 100644 index 000000000..8d90b4234 --- /dev/null +++ b/tests/harness/corpus-costs/7188-namespace-object-extensions.js.json @@ -0,0 +1 @@ +{ "plain": 3075, "san": 3749 } diff --git a/tests/harness/corpus-costs/7304-string-optional-char-reads.ts.json b/tests/harness/corpus-costs/7304-string-optional-char-reads.ts.json new file mode 100644 index 000000000..207668232 --- /dev/null +++ b/tests/harness/corpus-costs/7304-string-optional-char-reads.ts.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 4200 } diff --git a/tests/harness/corpus-costs/7305-string-unit-loop-versions.ts.json b/tests/harness/corpus-costs/7305-string-unit-loop-versions.ts.json new file mode 100644 index 000000000..f40375eee --- /dev/null +++ b/tests/harness/corpus-costs/7305-string-unit-loop-versions.ts.json @@ -0,0 +1 @@ +{ "plain": 10500, "san": 14500 } diff --git a/tests/harness/corpus-costs/7307-string-unit-loop-evictions.ts.json b/tests/harness/corpus-costs/7307-string-unit-loop-evictions.ts.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/7307-string-unit-loop-evictions.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 } diff --git a/tests/harness/corpus-costs/7381-function-value-arguments-length.ts.json b/tests/harness/corpus-costs/7381-function-value-arguments-length.ts.json new file mode 100644 index 000000000..b4659b9a0 --- /dev/null +++ b/tests/harness/corpus-costs/7381-function-value-arguments-length.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 3300 } diff --git a/tests/harness/corpus-costs/7407-frozen-class-instances.ts.json b/tests/harness/corpus-costs/7407-frozen-class-instances.ts.json new file mode 100644 index 000000000..34061c3eb --- /dev/null +++ b/tests/harness/corpus-costs/7407-frozen-class-instances.ts.json @@ -0,0 +1 @@ +{ "plain": 3923, "san": 3742 } diff --git a/tests/harness/corpus-costs/7440-tls-server-add-context.ts.json b/tests/harness/corpus-costs/7440-tls-server-add-context.ts.json new file mode 100644 index 000000000..f61acc3da --- /dev/null +++ b/tests/harness/corpus-costs/7440-tls-server-add-context.ts.json @@ -0,0 +1 @@ +{ "plain": 2787, "san": 2973 } diff --git a/tests/harness/corpus-costs/7441-tls-client-error.ts.json b/tests/harness/corpus-costs/7441-tls-client-error.ts.json new file mode 100644 index 000000000..a928ec1f1 --- /dev/null +++ b/tests/harness/corpus-costs/7441-tls-client-error.ts.json @@ -0,0 +1 @@ +{ "plain": 6387, "san": 6135 } diff --git a/tests/harness/corpus-costs/7442-tls-server-contexts.mjs.json b/tests/harness/corpus-costs/7442-tls-server-contexts.mjs.json new file mode 100644 index 000000000..8630193d3 --- /dev/null +++ b/tests/harness/corpus-costs/7442-tls-server-contexts.mjs.json @@ -0,0 +1 @@ +{ "plain": 2866, "san": 2854 } diff --git a/tests/harness/corpus-costs/7443-tls-final-flight.ts.json b/tests/harness/corpus-costs/7443-tls-final-flight.ts.json new file mode 100644 index 000000000..2eb48c40b --- /dev/null +++ b/tests/harness/corpus-costs/7443-tls-final-flight.ts.json @@ -0,0 +1 @@ +{ "plain": 8793, "san": 8889 } diff --git a/tests/harness/corpus-costs/7444-tls-final-flight.mjs.json b/tests/harness/corpus-costs/7444-tls-final-flight.mjs.json new file mode 100644 index 000000000..ddc1e1368 --- /dev/null +++ b/tests/harness/corpus-costs/7444-tls-final-flight.mjs.json @@ -0,0 +1 @@ +{ "plain": 3913, "san": 3715 } diff --git a/tests/harness/corpus-costs/7454-tls-peer-embedded-pem.ts.json b/tests/harness/corpus-costs/7454-tls-peer-embedded-pem.ts.json new file mode 100644 index 000000000..034b69448 --- /dev/null +++ b/tests/harness/corpus-costs/7454-tls-peer-embedded-pem.ts.json @@ -0,0 +1 @@ +{ "plain": 2535, "san": 3999 } diff --git a/tests/harness/corpus-costs/7455-tls-als-contexts.mjs.json b/tests/harness/corpus-costs/7455-tls-als-contexts.mjs.json new file mode 100644 index 000000000..53219fb76 --- /dev/null +++ b/tests/harness/corpus-costs/7455-tls-als-contexts.mjs.json @@ -0,0 +1 @@ +{ "plain": 3306, "san": 4617 } diff --git a/tests/harness/corpus-costs/7503-pipeline-untyped-sources.mjs.json b/tests/harness/corpus-costs/7503-pipeline-untyped-sources.mjs.json new file mode 100644 index 000000000..d2d431b0f --- /dev/null +++ b/tests/harness/corpus-costs/7503-pipeline-untyped-sources.mjs.json @@ -0,0 +1 @@ +{ "plain": 5499, "san": 7259 } diff --git a/tests/harness/corpus-costs/7504-pipeline-untyped-source-errors.mjs.json b/tests/harness/corpus-costs/7504-pipeline-untyped-source-errors.mjs.json new file mode 100644 index 000000000..4fd20681f --- /dev/null +++ b/tests/harness/corpus-costs/7504-pipeline-untyped-source-errors.mjs.json @@ -0,0 +1 @@ +{ "plain": 3431, "san": 4529 } diff --git a/tests/harness/corpus-costs/7505-pipeline-untyped-sources.cjs.json b/tests/harness/corpus-costs/7505-pipeline-untyped-sources.cjs.json new file mode 100644 index 000000000..3bb5df076 --- /dev/null +++ b/tests/harness/corpus-costs/7505-pipeline-untyped-sources.cjs.json @@ -0,0 +1 @@ +{ "plain": 3197, "san": 4220 } diff --git a/tests/harness/corpus-costs/7506-pipeline-any-source.ts.json b/tests/harness/corpus-costs/7506-pipeline-any-source.ts.json new file mode 100644 index 000000000..454dcb2c0 --- /dev/null +++ b/tests/harness/corpus-costs/7506-pipeline-any-source.ts.json @@ -0,0 +1 @@ +{ "plain": 3250, "san": 4741 } diff --git a/tests/harness/corpus-costs/7507-stream-write-error-drain.mjs.json b/tests/harness/corpus-costs/7507-stream-write-error-drain.mjs.json new file mode 100644 index 000000000..35f298bef --- /dev/null +++ b/tests/harness/corpus-costs/7507-stream-write-error-drain.mjs.json @@ -0,0 +1 @@ +{ "plain": 2735, "san": 4076 } diff --git a/tests/harness/corpus-costs/7514-stream-after-write-order.ts.json b/tests/harness/corpus-costs/7514-stream-after-write-order.ts.json new file mode 100644 index 000000000..56aa0985f --- /dev/null +++ b/tests/harness/corpus-costs/7514-stream-after-write-order.ts.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 3600 } diff --git a/tests/harness/corpus-costs/7621-class-methods-foreign-receivers.mjs.json b/tests/harness/corpus-costs/7621-class-methods-foreign-receivers.mjs.json new file mode 100644 index 000000000..bb2ade1c2 --- /dev/null +++ b/tests/harness/corpus-costs/7621-class-methods-foreign-receivers.mjs.json @@ -0,0 +1 @@ +{ "plain": 6415, "san": 10107 } diff --git a/tests/harness/corpus-costs/7742-weak-collections-many-keys.ts.json b/tests/harness/corpus-costs/7742-weak-collections-many-keys.ts.json new file mode 100644 index 000000000..97e5fc450 --- /dev/null +++ b/tests/harness/corpus-costs/7742-weak-collections-many-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 6000 } diff --git a/tests/harness/corpus-costs/7757-speculative-key-chains.ts.json b/tests/harness/corpus-costs/7757-speculative-key-chains.ts.json new file mode 100644 index 000000000..be69de5f7 --- /dev/null +++ b/tests/harness/corpus-costs/7757-speculative-key-chains.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2900 } diff --git a/tests/harness/corpus-costs/7983-integer-compares.ts.json b/tests/harness/corpus-costs/7983-integer-compares.ts.json new file mode 100644 index 000000000..8bfb69ce2 --- /dev/null +++ b/tests/harness/corpus-costs/7983-integer-compares.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 2100 } diff --git a/tests/harness/corpus-costs/7985-untyped-collection-types.mjs.json b/tests/harness/corpus-costs/7985-untyped-collection-types.mjs.json new file mode 100644 index 000000000..6915a85fe --- /dev/null +++ b/tests/harness/corpus-costs/7985-untyped-collection-types.mjs.json @@ -0,0 +1 @@ +{ "plain": 2400, "san": 2600 } diff --git a/tests/harness/corpus-costs/8003-while-reversed-byte-reads.ts.json b/tests/harness/corpus-costs/8003-while-reversed-byte-reads.ts.json new file mode 100644 index 000000000..6c521804e --- /dev/null +++ b/tests/harness/corpus-costs/8003-while-reversed-byte-reads.ts.json @@ -0,0 +1 @@ +{ "plain": 2359, "san": 4025 } diff --git a/tests/harness/corpus-costs/8006-buffer-base64-decode.ts.json b/tests/harness/corpus-costs/8006-buffer-base64-decode.ts.json new file mode 100644 index 000000000..88f85d0aa --- /dev/null +++ b/tests/harness/corpus-costs/8006-buffer-base64-decode.ts.json @@ -0,0 +1 @@ +{ "plain": 3995, "san": 2236 } diff --git a/tests/harness/corpus-costs/8020-date-setters.ts.json b/tests/harness/corpus-costs/8020-date-setters.ts.json new file mode 100644 index 000000000..ad5d9a546 --- /dev/null +++ b/tests/harness/corpus-costs/8020-date-setters.ts.json @@ -0,0 +1 @@ +{ "plain": 2891, "san": 4454 } diff --git a/tests/harness/corpus-costs/8021-date-coercions.ts.json b/tests/harness/corpus-costs/8021-date-coercions.ts.json new file mode 100644 index 000000000..cd0090be6 --- /dev/null +++ b/tests/harness/corpus-costs/8021-date-coercions.ts.json @@ -0,0 +1 @@ +{ "plain": 2589, "san": 3385 } diff --git a/tests/harness/corpus-costs/8022-date-unions.ts.json b/tests/harness/corpus-costs/8022-date-unions.ts.json new file mode 100644 index 000000000..ce89111f9 --- /dev/null +++ b/tests/harness/corpus-costs/8022-date-unions.ts.json @@ -0,0 +1 @@ +{ "plain": 2786, "san": 3724 } diff --git a/tests/harness/corpus-costs/8023-date-clone-throw.ts.json b/tests/harness/corpus-costs/8023-date-clone-throw.ts.json new file mode 100644 index 000000000..50a23d33a --- /dev/null +++ b/tests/harness/corpus-costs/8023-date-clone-throw.ts.json @@ -0,0 +1 @@ +{ "plain": 2847, "san": 33492 } diff --git a/tests/harness/corpus-costs/8024-date-setters-dynamic.mjs.json b/tests/harness/corpus-costs/8024-date-setters-dynamic.mjs.json new file mode 100644 index 000000000..0007f1971 --- /dev/null +++ b/tests/harness/corpus-costs/8024-date-setters-dynamic.mjs.json @@ -0,0 +1 @@ +{ "plain": 2645, "san": 3529 } diff --git a/tests/harness/corpus-costs/8025-date-subclass-setters.ts.json b/tests/harness/corpus-costs/8025-date-subclass-setters.ts.json new file mode 100644 index 000000000..cb128790d --- /dev/null +++ b/tests/harness/corpus-costs/8025-date-subclass-setters.ts.json @@ -0,0 +1 @@ +{ "plain": 2383, "san": 3249 } diff --git a/tests/harness/corpus-costs/8026-date-subclass-setters-dynamic.mjs.json b/tests/harness/corpus-costs/8026-date-subclass-setters-dynamic.mjs.json new file mode 100644 index 000000000..0615e7224 --- /dev/null +++ b/tests/harness/corpus-costs/8026-date-subclass-setters-dynamic.mjs.json @@ -0,0 +1 @@ +{ "plain": 2483, "san": 3294 } diff --git a/tests/harness/corpus-costs/8027-date-setter-order.ts.json b/tests/harness/corpus-costs/8027-date-setter-order.ts.json new file mode 100644 index 000000000..aff4bec01 --- /dev/null +++ b/tests/harness/corpus-costs/8027-date-setter-order.ts.json @@ -0,0 +1 @@ +{ "plain": 2489, "san": 3288 } diff --git a/tests/harness/corpus-costs/8028-date-setter-order-dynamic.mjs.json b/tests/harness/corpus-costs/8028-date-setter-order-dynamic.mjs.json new file mode 100644 index 000000000..a7aeb13a5 --- /dev/null +++ b/tests/harness/corpus-costs/8028-date-setter-order-dynamic.mjs.json @@ -0,0 +1 @@ +{ "plain": 2430, "san": 2975 } diff --git a/tests/harness/corpus-costs/8176-tls-empty-context-ca.ts.json b/tests/harness/corpus-costs/8176-tls-empty-context-ca.ts.json new file mode 100644 index 000000000..2f8507d0f --- /dev/null +++ b/tests/harness/corpus-costs/8176-tls-empty-context-ca.ts.json @@ -0,0 +1 @@ +{ "plain": 7322, "san": 94064 } diff --git a/tests/harness/corpus-costs/8177-tls-empty-client-ca.ts.json b/tests/harness/corpus-costs/8177-tls-empty-client-ca.ts.json new file mode 100644 index 000000000..d7df308c8 --- /dev/null +++ b/tests/harness/corpus-costs/8177-tls-empty-client-ca.ts.json @@ -0,0 +1 @@ +{ "plain": 4335, "san": 61880 } diff --git a/tests/harness/corpus-costs/8179-tls-reject-unauthorized-values.mjs.json b/tests/harness/corpus-costs/8179-tls-reject-unauthorized-values.mjs.json new file mode 100644 index 000000000..ef441a0df --- /dev/null +++ b/tests/harness/corpus-costs/8179-tls-reject-unauthorized-values.mjs.json @@ -0,0 +1 @@ +{ "plain": 5582, "san": 65742 } diff --git a/tests/harness/corpus-costs/8181-map-get-borrows.ts.json b/tests/harness/corpus-costs/8181-map-get-borrows.ts.json new file mode 100644 index 000000000..d5d1c5f65 --- /dev/null +++ b/tests/harness/corpus-costs/8181-map-get-borrows.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3500 } diff --git a/tests/harness/corpus-costs/8181-x509-raw-writes.ts.json b/tests/harness/corpus-costs/8181-x509-raw-writes.ts.json new file mode 100644 index 000000000..df264cf6e --- /dev/null +++ b/tests/harness/corpus-costs/8181-x509-raw-writes.ts.json @@ -0,0 +1 @@ +{ "plain": 3996, "san": 8383 } diff --git a/tests/harness/corpus-costs/8182-typed-array-alias-scopes.ts.json b/tests/harness/corpus-costs/8182-typed-array-alias-scopes.ts.json new file mode 100644 index 000000000..974b44e1e --- /dev/null +++ b/tests/harness/corpus-costs/8182-typed-array-alias-scopes.ts.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 3800 } diff --git a/tests/harness/corpus-costs/8214-unshimmed-package-version/main.ts.json b/tests/harness/corpus-costs/8214-unshimmed-package-version/main.ts.json new file mode 100644 index 000000000..d5d1c5f65 --- /dev/null +++ b/tests/harness/corpus-costs/8214-unshimmed-package-version/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3500 } diff --git a/tests/harness/corpus-costs/8216-module-namespace-live-values/main.ts.json b/tests/harness/corpus-costs/8216-module-namespace-live-values/main.ts.json new file mode 100644 index 000000000..d5d1c5f65 --- /dev/null +++ b/tests/harness/corpus-costs/8216-module-namespace-live-values/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3500 } diff --git a/tests/harness/corpus-costs/8217-intl-segmenter-word-construct/main.ts.json b/tests/harness/corpus-costs/8217-intl-segmenter-word-construct/main.ts.json new file mode 100644 index 000000000..d5d1c5f65 --- /dev/null +++ b/tests/harness/corpus-costs/8217-intl-segmenter-word-construct/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3500 } diff --git a/tests/harness/corpus-costs/8218-cjs-namespace-imports/main.mjs.json b/tests/harness/corpus-costs/8218-cjs-namespace-imports/main.mjs.json new file mode 100644 index 000000000..ffabf028f --- /dev/null +++ b/tests/harness/corpus-costs/8218-cjs-namespace-imports/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 7000, "san": 7200 } diff --git a/tests/harness/corpus-costs/8219-cjs-stable-var-exports/main.js.json b/tests/harness/corpus-costs/8219-cjs-stable-var-exports/main.js.json new file mode 100644 index 000000000..f8d95630e --- /dev/null +++ b/tests/harness/corpus-costs/8219-cjs-stable-var-exports/main.js.json @@ -0,0 +1 @@ +{ "plain": 8300, "san": 7400 } diff --git a/tests/harness/corpus-costs/8220-js-class-static-destructuring/main.js.json b/tests/harness/corpus-costs/8220-js-class-static-destructuring/main.js.json new file mode 100644 index 000000000..8b730689b --- /dev/null +++ b/tests/harness/corpus-costs/8220-js-class-static-destructuring/main.js.json @@ -0,0 +1 @@ +{ "plain": 10100, "san": 9400 } diff --git a/tests/harness/corpus-costs/8221-js-recursive-generator-returns/main.ts.json b/tests/harness/corpus-costs/8221-js-recursive-generator-returns/main.ts.json new file mode 100644 index 000000000..e6c42bf47 --- /dev/null +++ b/tests/harness/corpus-costs/8221-js-recursive-generator-returns/main.ts.json @@ -0,0 +1 @@ +{ "plain": 6600, "san": 6000 } diff --git a/tests/harness/corpus-costs/8222-js-class-static-reflection.js.json b/tests/harness/corpus-costs/8222-js-class-static-reflection.js.json new file mode 100644 index 000000000..7f7902ca1 --- /dev/null +++ b/tests/harness/corpus-costs/8222-js-class-static-reflection.js.json @@ -0,0 +1 @@ +{ "plain": 4500, "san": 4100 } diff --git a/tests/harness/corpus-costs/8223-argument-count-destructured-parameter.ts.json b/tests/harness/corpus-costs/8223-argument-count-destructured-parameter.ts.json new file mode 100644 index 000000000..664b49416 --- /dev/null +++ b/tests/harness/corpus-costs/8223-argument-count-destructured-parameter.ts.json @@ -0,0 +1 @@ +{ "plain": 4700, "san": 4200 } diff --git a/tests/harness/corpus-costs/8230-readline-interface-constructor.cjs.json b/tests/harness/corpus-costs/8230-readline-interface-constructor.cjs.json new file mode 100644 index 000000000..9b459042f --- /dev/null +++ b/tests/harness/corpus-costs/8230-readline-interface-constructor.cjs.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 3000 } diff --git a/tests/harness/corpus-costs/8231-builtin-member-binding-new.cjs.json b/tests/harness/corpus-costs/8231-builtin-member-binding-new.cjs.json new file mode 100644 index 000000000..54e11997a --- /dev/null +++ b/tests/harness/corpus-costs/8231-builtin-member-binding-new.cjs.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 2500 } diff --git a/tests/harness/corpus-costs/8232-native-streams-untyped.cjs.json b/tests/harness/corpus-costs/8232-native-streams-untyped.cjs.json new file mode 100644 index 000000000..bec4e42a8 --- /dev/null +++ b/tests/harness/corpus-costs/8232-native-streams-untyped.cjs.json @@ -0,0 +1 @@ +{ "plain": 4000, "san": 5000 } diff --git a/tests/harness/corpus-costs/8233-string-decoder-values.cjs.json b/tests/harness/corpus-costs/8233-string-decoder-values.cjs.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/8233-string-decoder-values.cjs.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 } diff --git a/tests/harness/corpus-costs/8234-handle-typeof.ts.json b/tests/harness/corpus-costs/8234-handle-typeof.ts.json new file mode 100644 index 000000000..4c7ad07f1 --- /dev/null +++ b/tests/harness/corpus-costs/8234-handle-typeof.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 5500 } diff --git a/tests/harness/corpus-costs/8241-literal-result-slices.ts.json b/tests/harness/corpus-costs/8241-literal-result-slices.ts.json new file mode 100644 index 000000000..be69de5f7 --- /dev/null +++ b/tests/harness/corpus-costs/8241-literal-result-slices.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2900 } diff --git a/tests/harness/corpus-costs/8260-class-prototype-views.mjs.json b/tests/harness/corpus-costs/8260-class-prototype-views.mjs.json new file mode 100644 index 000000000..f06add31f --- /dev/null +++ b/tests/harness/corpus-costs/8260-class-prototype-views.mjs.json @@ -0,0 +1 @@ +{ "plain": 8037, "san": 6172 } diff --git a/tests/harness/corpus-costs/8270-fs-promises-member.cjs.json b/tests/harness/corpus-costs/8270-fs-promises-member.cjs.json new file mode 100644 index 000000000..04488e737 --- /dev/null +++ b/tests/harness/corpus-costs/8270-fs-promises-member.cjs.json @@ -0,0 +1 @@ +{ "plain": 4040, "san": 4008 } diff --git a/tests/harness/corpus-costs/8271-cjs-top-level-return.cjs.json b/tests/harness/corpus-costs/8271-cjs-top-level-return.cjs.json new file mode 100644 index 000000000..33a1c426c --- /dev/null +++ b/tests/harness/corpus-costs/8271-cjs-top-level-return.cjs.json @@ -0,0 +1 @@ +{ "plain": 1907, "san": 2109 } diff --git a/tests/harness/corpus-costs/8272-fs-stream-call-without-new.cjs.json b/tests/harness/corpus-costs/8272-fs-stream-call-without-new.cjs.json new file mode 100644 index 000000000..dab2504e6 --- /dev/null +++ b/tests/harness/corpus-costs/8272-fs-stream-call-without-new.cjs.json @@ -0,0 +1 @@ +{ "plain": 2099, "san": 2373 } diff --git a/tests/harness/corpus-costs/8273-fs-read-write-argument-checks.cjs.json b/tests/harness/corpus-costs/8273-fs-read-write-argument-checks.cjs.json new file mode 100644 index 000000000..1989bfb5f --- /dev/null +++ b/tests/harness/corpus-costs/8273-fs-read-write-argument-checks.cjs.json @@ -0,0 +1 @@ +{ "plain": 2379, "san": 2450 } diff --git a/tests/harness/corpus-costs/8291-frozen-record-union-keys.ts.json b/tests/harness/corpus-costs/8291-frozen-record-union-keys.ts.json new file mode 100644 index 000000000..77d20b756 --- /dev/null +++ b/tests/harness/corpus-costs/8291-frozen-record-union-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 5000 } diff --git a/tests/harness/corpus-costs/8300-native-array-capsule-reads/main.ts.json b/tests/harness/corpus-costs/8300-native-array-capsule-reads/main.ts.json new file mode 100644 index 000000000..a37fc12d7 --- /dev/null +++ b/tests/harness/corpus-costs/8300-native-array-capsule-reads/main.ts.json @@ -0,0 +1 @@ +{ "plain": 4860, "san": 7734 } diff --git a/tests/harness/corpus-costs/8302-class-super-method-values/main.ts.json b/tests/harness/corpus-costs/8302-class-super-method-values/main.ts.json new file mode 100644 index 000000000..30437efb4 --- /dev/null +++ b/tests/harness/corpus-costs/8302-class-super-method-values/main.ts.json @@ -0,0 +1 @@ +{ "plain": 4797 } diff --git a/tests/harness/corpus-costs/8320-assign-only-deferred-binding/main.ts.json b/tests/harness/corpus-costs/8320-assign-only-deferred-binding/main.ts.json new file mode 100644 index 000000000..0606e0393 --- /dev/null +++ b/tests/harness/corpus-costs/8320-assign-only-deferred-binding/main.ts.json @@ -0,0 +1 @@ +{ "plain": 4500, "san": 48000 } diff --git a/tests/harness/corpus-costs/8326-never-promise-instanceof.ts.json b/tests/harness/corpus-costs/8326-never-promise-instanceof.ts.json new file mode 100644 index 000000000..4c7ad07f1 --- /dev/null +++ b/tests/harness/corpus-costs/8326-never-promise-instanceof.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 5500 } diff --git a/tests/harness/corpus-costs/8330-optional-element-undefined-receivers.ts.json b/tests/harness/corpus-costs/8330-optional-element-undefined-receivers.ts.json new file mode 100644 index 000000000..662eb397f --- /dev/null +++ b/tests/harness/corpus-costs/8330-optional-element-undefined-receivers.ts.json @@ -0,0 +1 @@ +{ "plain": 2108 } diff --git a/tests/harness/corpus-costs/absent-property-reads.mjs.json b/tests/harness/corpus-costs/absent-property-reads.mjs.json new file mode 100644 index 000000000..a9e46230d --- /dev/null +++ b/tests/harness/corpus-costs/absent-property-reads.mjs.json @@ -0,0 +1 @@ +{ "plain": 8432, "san": 13661 } diff --git a/tests/harness/corpus-costs/symbol-property-key-reuse.mjs.json b/tests/harness/corpus-costs/symbol-property-key-reuse.mjs.json new file mode 100644 index 000000000..ddd7f7805 --- /dev/null +++ b/tests/harness/corpus-costs/symbol-property-key-reuse.mjs.json @@ -0,0 +1 @@ +{ "plain": 5174, "san": 9089 } diff --git a/tests/harness/corpus-costs/text-decoder-undefined-input.ts.json b/tests/harness/corpus-costs/text-decoder-undefined-input.ts.json new file mode 100644 index 000000000..b156827d6 --- /dev/null +++ b/tests/harness/corpus-costs/text-decoder-undefined-input.ts.json @@ -0,0 +1 @@ +{ "plain": 1937, "san": 2233 } diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index e1831c609..fde16a901 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -5,10 +5,12 @@ import { join } from "node:path"; import { expect, test } from "vitest"; import { analyze, + compile, coverageEnvelope, coveragePasses, renderCoverage, renderExplainDynamic, + renderExplainIsland, } from "@scriptc/compiler"; import { shardSelect, shardSuffix } from "./shard.js"; @@ -221,10 +223,10 @@ test("deferred fences in a module's top-level code report as running at startup" ]); const out = report(fixture("startup-fences.js")); expect(out).toMatch( - /runs at startup {3}1 site .*\n.*instanceof against this native constructor\n\s+at tests\/coverage-fixtures\/startup-fences\.js:5:13/, + /runs at startup {3}1 site .*\n.*'instanceof' right-hand sides other than classes declared in the program\n\s+at tests\/coverage-fixtures\/startup-fences\.js:5:30/, ); expect(out).toMatch( - /deferred to runtime {3}1 site .*\n.*instanceof against this native constructor\n\s+at tests\/coverage-fixtures\/startup-fences\.js:7:10/, + /deferred to runtime {3}1 site .*\n.*'instanceof' right-hand sides other than classes declared in the program\n\s+at tests\/coverage-fixtures\/startup-fences\.js:7:27/, ); const envelope = coverageEnvelope(coverage, { compilerVersion: "test", sourceTexts }); expect(envelope.diagnostics.map((d) => [d.scope, d.line, d.startup])).toEqual([ @@ -543,6 +545,32 @@ test("settled generic rest-order fences count each source statement once", () => expect(coverage.stats.statementsFailed).toBe(2); }); +test("CommonJS var and let exports that can change after the export keep their refusal", () => { + // JavaScript refusals defer to run time: they are the runtime fences. + const { coverage } = analyze(fixture("cjs-mutable-exports/main.js")); + const refused = [...coverage.diagnostics, ...(coverage.runtimeFences ?? [])] + .map((d) => /exporting the mutable 'let' binding '(\w+)'/.exec(d.message)?.[1]) + .filter((name) => name !== undefined) + .sort(); + expect(refused).toEqual(["counter", "tab", "twice"]); +}); + +test("CommonJS namespace reads of exports a re-exported module assigns later keep their refusal", () => { + // wrapper.cjs re-exports impl.cjs, whose set() assigns `current` after + // evaluation; `version` never changes. + const { coverage } = analyze(fixture("cjs-namespace-reexports/main.mjs")); + const refused = [...coverage.diagnostics, ...(coverage.runtimeFences ?? [])] + .map( + (d) => + /reads of '(\w+)' through namespace imports of CommonJS modules whose functions assign exports/.exec( + d.message, + )?.[1], + ) + .filter((name) => name !== undefined) + .sort(); + expect(refused).toEqual(["current"]); +}); + test("inferred JavaScript compiles natively", async () => { // The js-gap fixture turns noImplicitAny off. Its inferred parameter // uses checked native values alongside the JSDoc-typed neighbor. @@ -829,6 +857,119 @@ test("a cycle whose top level calls a builtin with a builtin function value is a expect(out).toContain("statements analyzed"); }); +test("Node islands (report-only): one package per class, gap islands first", async () => { + // ni-gap (a fence inside the native subset), ni-eval (new Function), + // ni-ctor (a constructor function, carrying ni-dep), ni-bundled (not + // compilable as shipped), ni-typed (native, also reached from ni-ctor). + const entry = fixture("node-islands/main.ts"); + await expect(report(entry)).toMatchFileSnapshot("__snapshots__/coverage-node-islands.txt"); + const { coverage, sourceTexts } = analyze(entry); + const islands = coverage.nodeIslands!; + expect(islands.islands.map((i) => [i.package, i.class])).toEqual([ + ["ni-gap", "gap"], + ["ni-eval", "runtime"], + ["ni-ctor", "object-model"], + ["ni-bundled", "packaging"], + ]); + expect(islands.packages.map((p) => [p.package, p.status, p.islands])).toEqual([ + ["ni-dep", "inside", ["ni-ctor"]], + ["ni-typed", "shared", ["ni-ctor"]], + ]); + await expect( + renderExplainIsland(coverage.nodeIslands, "ni-ctor", { sourceTexts, root: repoRoot }), + ).toMatchFileSnapshot("__snapshots__/coverage-node-islands-explain.txt"); + expect(renderExplainIsland(coverage.nodeIslands, "ni-dep", { root: repoRoot })).toContain( + "only ni-ctor imports it", + ); + // The machine-readable form carries the same decisions. + const envelope = coverageEnvelope(coverage, { compilerVersion: "test", sourceTexts }); + expect(envelope.nodeIslands?.mode).toBe("would-be"); + expect(envelope.nodeIslands?.islands.map((i) => [i.package, i.class, i.at?.line])).toEqual([ + ["ni-gap", "gap", 7], + ["ni-eval", "runtime", 4], + ["ni-ctor", "object-model", 9], + ["ni-bundled", "packaging", undefined], + ]); + // The gates: the blocker gate is today's build (which refuses + // ni-bundled with SC2013, and nothing else); the island gates fail on + // the would-be islands. + expect(coverage.diagnostics.every((d) => d.code === "SC2013")).toBe(true); + expect(coveragePasses(coverage, "island-gaps")).toBe(false); + expect(coveragePasses(coverage, "node-islands")).toBe(false); +}); + +test("unknown scriptc settings warn in a build and make the island report unavailable", async () => { + const entry = fixture("node-islands-config/main.ts"); + const outDir = mkdtempSync(join(tmpdir(), "scriptc-island-config-")); + try { + const built = await compile(entry, { + outputKind: "ir", + outPath: join(outDir, "main.ir.json"), + outDir, + }); + expect(built.ok).toBe(true); + if (!built.ok) return; + expect((built.warnings ?? []).map((d) => [d.code, d.message])).toEqual([ + ["SC8004", `package.json 'scriptc.packages.encoding' has the unknown value "unloded"`], + ["SC8004", `package.json 'scriptc.packages.debug.run' has the unknown value "island"`], + ["SC8004", `package.json 'scriptc.nodeIslands.auto' must be true or false, not "yes"`], + ["SC8004", "package.json 'scriptc.nodeIslands.mode' is not a known Node island setting"], + ]); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } + const { coverage } = analyze(entry); + expect(coverage.nodeIslands).toBeUndefined(); + expect(renderCoverage({ ...coverage, file: "main.ts" }, { root: repoRoot })).toContain( + "not analyzable: the project's scriptc configuration has 4 unknown settings", + ); +}); + +test("Node island policy from the command line: native, forced, off", () => { + const entry = fixture("node-islands/main.ts"); + const forced = analyze(entry, { + nodeIslands: { native: ["ni-eval"], islands: ["ni-typed", "not-a-dependency"] }, + }).coverage.nodeIslands!; + expect(forced.islands.map((i) => [i.package, i.class, i.decision])).toEqual([ + ["ni-gap", "gap", "auto"], + ["ni-ctor", "object-model", "auto"], + ["ni-bundled", "packaging", "auto"], + ["ni-typed", "marked", "cli"], + ]); + expect(forced.violations.map((v) => v.code)).toEqual(["SC8003"]); + expect(forced.unknownPackages).toEqual(["not-a-dependency"]); + const off = analyze(entry, { nodeIslands: { mode: "off" } }).coverage; + expect(off.nodeIslands!.islands).toEqual([]); + expect(off.nodeIslands!.violations.map((v) => v.code)).toEqual([ + "SC8002", + "SC8002", + "SC8002", + "SC8002", + ]); + expect(coveragePasses(off, "island-gaps")).toBe(false); +}); + +test("--strict-user-js reports R2 constructs in user JavaScript as SC8101–SC8105", async () => { + const entry = fixture("node-islands-user-js.cjs"); + // Without the flag the user module is not part of any island report. + expect(analyze(entry).coverage.nodeIslands).toBeUndefined(); + const { coverage } = analyze(entry, { nodeIslands: { strictUserJs: true } }); + expect(coverage.diagnostics.map((d) => d.code).filter((c) => c.startsWith("SC81"))).toEqual([ + "SC8101", + "SC8102", + "SC8103", + "SC8104", + "SC8105", + "SC8101", + ]); + await expect( + renderCoverage( + { ...coverage, file: coverage.file.replace(repoRoot + "/", "") }, + { root: repoRoot }, + ), + ).toMatchFileSnapshot("__snapshots__/coverage-node-islands-user-js.txt"); +}); + test(`every corpus program is 100% static (corpus and coverage agree${shardSuffix()})`, async () => { // The differential corpus compiles by definition; coverage must agree. diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index a0c43cd0c..e1a869048 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -516,6 +516,216 @@ console.log("unreachable", t.label); ); }); + test("nested unions: a failure inside a matched arm names the union that holds it", async () => { + // A union's builder converts the arm that matched; the unions nested in + // it pick their arms by literal or by the kind of value only one arm + // takes. A value that fails anywhere below fails its outermost union, + // whose arms the message names. + const r = await compileAndRun( + "deep-discriminated", + `type Expr = + | { kind: "num"; value: number } + | { kind: "str"; text: string } + | { kind: "add"; left: Expr; right: Expr } + | { kind: "call"; callee: string; args: Expr[] } + | { kind: "neg"; operand: Expr } + | { kind: "opt"; inner: Expr | undefined; label?: string }; +type Stmt = + | { kind: "expr"; expr: Expr } + | { kind: "ret"; value: Expr | null } + | { kind: "block"; body: Stmt[] }; +type Fn = { name: string; body: Stmt[] }; + +function attempt(label: string, text: string, as: "expr" | "stmt" | "fn" | "list"): void { + try { + if (as === "expr") { + const e = JSON.parse(text) as Expr; + console.log(label, "ok", e.kind); + } else if (as === "stmt") { + const s = JSON.parse(text) as Stmt; + console.log(label, "ok", s.kind); + } else if (as === "fn") { + const f = JSON.parse(text) as Fn; + console.log(label, "ok", f.name, f.body.length); + } else { + const l = JSON.parse(text) as Expr[]; + console.log(label, "ok", l.length); + } + } catch (e) { + console.log(label, "error", (e as Error).message); + } +} + +const deep = (leaf: string, depth: number): string => { + let s = leaf; + for (let i = 0; i < depth; i++) s = \`{"kind":"neg","operand":\${s}}\`; + return s; +}; +attempt("valid", deep('{"kind":"num","value":1}', 20), "expr"); +attempt("deep wrong field", deep('{"kind":"num","value":"x"}', 5), "expr"); +attempt("deep unknown kind", deep('{"kind":"nope","value":1}', 5), "expr"); +attempt("deep string node", deep('"text"', 5), "expr"); +attempt("deep missing field", deep('{"kind":"add","left":{"kind":"num","value":1}}', 4), "expr"); +attempt("deep array arg", deep('{"kind":"call","callee":"f","args":[{"kind":"num","value":1},{"kind":"str","text":2}]}', 3), "expr"); +attempt("optional inner", deep('{"kind":"opt","inner":{"kind":"num","value":true}}', 3), "expr"); +attempt("optional label", deep('{"kind":"opt","inner":{"kind":"num","value":1},"label":3}', 3), "expr"); +attempt("stmt deep", '{"kind":"block","body":[{"kind":"expr","expr":' + deep('{"kind":"num","value":null}', 3) + '}]}', "stmt"); +attempt("ret null", '{"kind":"ret","value":null}', "stmt"); +attempt("fn deep", '{"name":"f","body":[{"kind":"ret","value":' + deep('{"kind":"str","text":5}', 4) + '}]}', "fn"); +attempt("list element", '[{"kind":"num","value":1},' + deep('{"kind":"num"}', 2) + "]", "list"); +attempt("kind not string", deep('{"kind":3,"value":1}', 2), "expr"); +attempt("valid list", '[' + deep('{"kind":"num","value":1}', 3) + ',{"kind":"str","text":"t"}]', "list"); + +type Box = { value: number; child?: Box; rest: Box[] | null }; +function box(label: string, text: string): void { + try { + const b = JSON.parse(text) as Box; + console.log(label, "ok", b.value); + } catch (e) { + console.log(label, "error", (e as Error).message); + } +} +box("box valid", '{"value":1,"rest":null,"child":{"value":2,"rest":[{"value":3,"rest":null}]}}'); +box("box deep child", '{"value":1,"rest":null,"child":{"value":2,"rest":null,"child":{"value":"x","rest":null}}}'); +box("box deep rest", '{"value":1,"rest":[{"value":2,"rest":[{"value":3,"rest":[1]}]}]}'); +box("box child array", '{"value":1,"rest":null,"child":[]}'); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout.trimEnd().split("\n")).toEqual([ + "valid ok neg", + "deep wrong field error expected object | object | object | object | object | object at $, got object", + "deep unknown kind error expected object | object | object | object | object | object at $, got object", + "deep string node error expected object | object | object | object | object | object at $, got object", + "deep missing field error expected object | object | object | object | object | object at $, got object", + "deep array arg error expected object | object | object | object | object | object at $, got object", + "optional inner error expected object | object | object | object | object | object at $, got object", + "optional label error expected object | object | object | object | object | object at $, got object", + "stmt deep error expected object | object | object at $, got object", + "ret null ok ret", + "fn deep error expected object | object | object at $.body[0], got object", + "list element error expected object | object | object | object | object | object at $[1], got object", + "kind not string error expected object | object | object | object | object | object at $, got object", + "valid list ok 2", + "box valid ok 1", + "box deep child error expected number at $.child.child.value, got string", + "box deep rest error expected object at $.rest[0].rest[0].rest[0], got number", + "box child array error expected object | undefined at $.child, got array", + ]); + }); + + test("nested unions: a value a getter returns while building is matched in full", async () => { + // The getter answers a valid node while the arm matches and a wrong one + // while it builds. The builder matches the second answer in full, so the + // failure names the union that holds it, as without the arm's match. + const r = await compileAndRun( + "deep-discriminated-getter", + `type Expr = + | { kind: "num"; value: number } + | { kind: "str"; text: string } + | { kind: "neg"; operand: Expr } + | { kind: "add"; left: Expr; right: Expr }; +const v = JSON.parse('{"kind":"neg"}') as unknown; +let calls = 0; +Object.defineProperty(v, "operand", { + enumerable: true, + get() { + calls++; + return calls === 1 ? JSON.parse('{"kind":"num","value":1}') : JSON.parse('{"kind":"num","value":"x"}'); + }, +}); +try { + const e = v as Expr; + console.log("ok", JSON.stringify(e)); +} catch (err) { + console.log((err as Error).message); +} +console.log("getter calls", calls); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout.trimEnd().split("\n")).toEqual([ + "expected object | object | object | object at $.operand, got object", + "getter calls 2", + ]); + }); + + test("nested unions: a getter's value in a non-recursive union's build is matched in full", async () => { + // A union without recursion builds without a visit session. The getter + // still runs user code during the trusted build, so the nested union + // matches its second answer in full, and the failure names that union. + const r = await compileAndRun( + "flat-discriminated-getter", + `type Inner = { tag: "x"; n: number } | { tag: "y"; s: string }; +type Outer = { kind: "a"; inner: Inner } | { kind: "b"; other: number }; +const v = JSON.parse('{"kind":"a"}') as unknown; +let calls = 0; +Object.defineProperty(v, "inner", { + enumerable: true, + get() { + calls++; + return calls === 1 ? JSON.parse('{"tag":"x","n":1}') : JSON.parse('{"tag":"x","n":"bad"}'); + }, +}); +try { + const o = v as Outer; + console.log("ok", JSON.stringify(o)); +} catch (err) { + console.log((err as Error).message); +} +console.log("getter calls", calls); +const plain = JSON.parse('{"kind":"a","inner":{"tag":"y","s":"z"}}') as Outer; +console.log(JSON.stringify(plain)); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout.trimEnd().split("\n")).toEqual([ + "expected object | object at $.inner, got object", + "getter calls 2", + '{"kind":"a","inner":{"tag":"y","s":"z"}}', + ]); + }); + + test("nested unions: a getter that converts a recursive union keeps a non-recursive build untrusted", async () => { + // The getter converts a recursive union, which opens and ends a visit + // session of its own, before it answers. The outer build still matches + // the getter's second answer in full, and the failure names that union. + const r = await compileAndRun( + "flat-discriminated-getter-session", + `type Tree = { kind: "leaf"; n: number } | { kind: "node"; left: Tree; right: Tree }; +type Inner = { tag: "x"; n: number } | { tag: "y"; s: string }; +type Outer = { kind: "a"; inner: Inner } | { kind: "b"; other: number }; +const v = JSON.parse('{"kind":"a"}') as unknown; +let calls = 0; +let leaves = 0; +Object.defineProperty(v, "inner", { + enumerable: true, + get() { + calls++; + const t = JSON.parse('{"kind":"node","left":{"kind":"leaf","n":1},"right":{"kind":"leaf","n":2}}') as Tree; + if (t.kind === "node") leaves += 2; + return calls === 1 ? JSON.parse('{"tag":"x","n":1}') : JSON.parse('{"tag":"x","n":"bad"}'); + }, +}); +try { + const o = v as Outer; + console.log("ok", JSON.stringify(o)); +} catch (err) { + console.log((err as Error).message); +} +console.log("getter calls", calls, "leaves", leaves); +const plain = JSON.parse('{"kind":"a","inner":{"tag":"y","s":"z"}}') as Outer; +console.log(JSON.stringify(plain)); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout.trimEnd().split("\n")).toEqual([ + "expected object | object at $.inner, got object", + "getter calls 2 leaves 4", + '{"kind":"a","inner":{"tag":"y","s":"z"}}', + ]); + }); + test("array where a record is expected names the mismatch at the root", async () => { const r = await compileAndRun( "root-kind", diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index c8849a10f..5cd66a2f5 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -983,8 +983,8 @@ listener.listen(0, () => { }); // With a secureContext, the TLSSocket's own options still apply: a truthy -// requestCert, which asks for client certificates this runtime does not -// verify, and SNICallback throw the fence instead of being dropped. +// requestCert (which lowers with cert and key, whose ca the runtime reads) +// and SNICallback throw the fence instead of being dropped. describe("tls.TLSSocket socket options beside a secureContext", () => { test("requestCert and SNICallback throw the fence", async () => { const r = await compileAndRun( @@ -1019,7 +1019,7 @@ listener.listen(0, () => { ); expect(r.stderr).toBe(""); expect(r.stdout).toBe( - "requestCert new tls.TLSSocket option 'requestCert' has no static lowering — client-certificate handshakes are not modeled (SEMANTICS.md divergence 55) — requestCert stays at Node's false default\n" + + "requestCert new tls.TLSSocket option 'requestCert' has no static lowering — requestCert lowers with cert and key options, not beside a secureContext\n" + "SNICallback new tls.TLSSocket option 'SNICallback' has no static lowering — isServer, cert, key, secureContext, ALPNProtocols, minVersion and maxVersion are the implemented members\n" + "requestCert false no error\n" + "client closed\n", @@ -1027,6 +1027,93 @@ listener.listen(0, () => { }); }); +// A client certificate lowers as a cert and key pair; one without the +// other throws the fence before any connection is made. +describe("client certificates without both cert and key", () => { + test("tls.connect and https.request throw the fence", async () => { + const r = await compileAndRun( + "tls-client-certificate-fences", + `import { readFileSync } from "node:fs"; +import * as https from "node:https"; +import * as tls from "node:tls"; +const certs = "tests/fixtures/server/certs"; +const cert = readFileSync(\`\${certs}/client.pem\`); +const key = readFileSync(\`\${certs}/client-key.pem\`); +function report(label: string, run: () => void): void { + try { + run(); + console.log(label, "no error"); + } catch (error) { + console.log(label, (error as Error).message); + } +} +report("connect cert only", () => tls.connect({ port: 1, host: "127.0.0.1", cert })); +report("connect key only", () => tls.connect({ port: 1, host: "127.0.0.1", key })); +report("request key only", () => https.request({ host: "127.0.0.1", port: 1, path: "/", key })); +`, + ); + expect(r.stderr).toBe(""); + expect(r.stdout).toBe( + "connect cert only tls.connect option 'cert' has no static lowering — a client certificate lowers with both cert and key\n" + + "connect key only tls.connect option 'key' has no static lowering — a client certificate lowers with both cert and key\n" + + "request key only https.request option 'key' has no static lowering — a client certificate lowers with both cert and key\n", + ); + }); +}); + +// server.addContext and setSecureContext answer on tls and https servers; +// a plain net server, a servername Node's RegExp would read as groups or +// alternation, and one outside ASCII throw the fence. A handshake failure +// whose OpenSSL reason the runtime does not know reaches 'tlsClientError' +// as a coded SC2020 error. +describe("tls.Server contexts and client errors without a lowering", () => { + test("addContext and setSecureContext fences, and an unknown handshake failure", async () => { + const r = await compileAndRun( + "tls-server-context-fences", + `import { readFileSync } from "node:fs"; +import * as net from "node:net"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; +const certs = "tests/fixtures/server/certs"; +const cert = readFileSync(\`\${certs}/localhost.pem\`); +const key = readFileSync(\`\${certs}/localhost-key.pem\`); +function report(label: string, run: () => void): void { + try { + run(); + console.log(label, "no error"); + } catch (error) { + console.log(label, (error as Error).message); + } +} +const plain = net.createServer() as unknown as tls.Server; +report("net addContext", () => plain.addContext("a.test", { cert, key })); +report("net setSecureContext", () => plain.setSecureContext({ cert, key })); +const server = tls.createServer({ cert, key }); +report("alternation", () => server.addContext("a.test|b.test", { cert, key })); +report("group", () => server.addContext("(a).test", { cert, key })); +report("non-ASCII", () => server.addContext("\u00e9.test", { cert, key })); +server.on("tlsClientError", (err: Error) => console.log("tlsClientError", (err as NodeJS.ErrnoException).code, err.message)); +server.listen(0, "127.0.0.1", () => { + const raw = net.connect((server.address() as AddressInfo).port, "127.0.0.1", () => { + raw.write(Buffer.from([0x16, 0x03, 0x01, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00])); + }); + raw.on("data", () => {}); + raw.on("close", () => server.close()); +}); +`, + ); + expect(r.stderr).toBe(""); + expect(r.stdout).toBe( + "net addContext server.addContext on a server that is not a tls or https server is not supported yet\n" + + "net setSecureContext server.setSecureContext on a server that is not a tls or https server is not supported yet\n" + + "alternation server.addContext option 'servername' has no static lowering — a servername with regular-expression groups or alternation has no lowering\n" + + "group server.addContext option 'servername' has no static lowering — a servername with regular-expression groups or alternation has no lowering\n" + + "non-ASCII server.addContext option 'servername' has no static lowering — a servername outside ASCII has no lowering\n" + + "tlsClientError SC2020 a TLS handshake failure (mbedTLS error -0x7300) without the reason OpenSSL gives is not supported yet\n", + ); + }); +}); + // A curve Node lists in crypto.getCurves() but the runtime does not // implement throws a coded SC2020 error naming the curve when the name is // computed; literal names are refused at compile time (node-types-fenced). @@ -2018,6 +2105,70 @@ await attempt("subtle verify", () => crypto.subtle.verify(name, raw, new Uint8Ar }); }); +// An X509Certificate's Ed25519 key needs the same unit: without it the +// certificate's public key, issuer and names still read, while verify +// rejects with the coded SC2020 Error. 7091 pins the linked path. +describe("X509Certificate Ed25519 keys without the Ed25519 unit", () => { + test("verify refuses with SC2020 in a program that never names Ed25519", async () => { + const r = await compileAndRun( + "x509-ed25519-unit-not-linked", + `import { X509Certificate } from "node:crypto"; +const cert = new X509Certificate("-----BEGIN CERTIFICATE-----\\nMIIBljCCAUigAwIBAgIUNFuRt3zKS19UAUMdLWewqHrGBigwBQYDK2VwMC8xFDAS\\nBgNVBAoMC0V4YW1wbGUgT3JnMRcwFQYDVQQDDA5lZC5leGFtcGxlLm5ldDAgFw0y\\nNjEwMjIxMDEwMTJaGA8yMTI2MDkyODEwMTAxMlowLzEUMBIGA1UECgwLRXhhbXBs\\nZSBPcmcxFzAVBgNVBAMMDmVkLmV4YW1wbGUubmV0MCowBQYDK2VwAyEAOCSLKkel\\nkOH/5aqUwI3PNJVUUNyhgu2s19LFC254fNCjdDByMB0GA1UdDgQWBBR+50zUcqL8\\n739jh6/rEYojULDmlTAfBgNVHSMEGDAWgBR+50zUcqL8739jh6/rEYojULDmlTAP\\nBgNVHRMBAf8EBTADAQH/MB8GA1UdEQQYMBaCDmVkLmV4YW1wbGUubmV0hwTAAAIH\\nMAUGAytlcANBAC/c0Bi0SVUnHCg/1pn7MaCgVivSHuoISi2SCq/0EnLefRdgsIir\\n0QxjVnxeuKN2Y1yfEO/R1W8Nwovo1l2M1gQ=\\n-----END CERTIFICATE-----\\n"); +console.log(cert.publicKey.asymmetricKeyType?.length, cert.checkIssued(cert), cert.checkHost("ed.example.net")); +try { + console.log(cert.verify(cert.publicKey)); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(err.name, err.code, err.message); +} +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stderr).toBe(""); + expect(r.stdout).toBe( + "7 true ed.example.net\n" + + "Error SC2020 Ed25519 keys are supported when a string literal in the program names 'ed25519'\n", + ); + }); +}); + +// A TLS socket held in an untyped JavaScript value has no lowering for its +// certificate accessors (they link the X.509 unit, which a dynamic call +// cannot ask for), so the call throws the fence; a socket whose type is +// known runs them (corpus 7095, 7096). +describe("tlsSocket.getPeerCertificate on a dynamic value (JS lane)", () => { + test("throws the fence", async () => { + const r = await compileAndRun( + "tls-peer-certificate-dynamic", + `import * as tls from "node:tls"; +import { readFileSync } from "node:fs"; +const certs = "tests/fixtures/server/certs"; +const server = tls.createServer({ cert: readFileSync(\`\${certs}/localhost.pem\`), key: readFileSync(\`\${certs}/localhost-key.pem\`) }, (s) => s.end()); +function peer(socket) { + try { + return socket.getPeerCertificate().subject.CN; + } catch (e) { + return e.message; + } +} +server.listen(0, () => { + const c = tls.connect({ port: server.address().port, ca: readFileSync(\`\${certs}/ca.pem\`), servername: "localhost" }, () => { + console.log(peer(c)); + c.destroy(); + server.close(); + }); +}); +`, + "mjs", + ); + expect(r.exitCode).toBe(0); + expect(r.stderr).toBe(""); + expect(r.stdout).toBe( + "'TLSSocket.prototype.getPeerCertificate' on a dynamic value is not supported yet\n", + ); + }); +}); + describe("inspecting error subclasses with properties created at run time (JS lane)", () => { test("an instance whose constructor created a property is a named fence", async () => { // Node prints `[Conditional [Error]: m] { extra: 'x' }`. The property @@ -2190,3 +2341,33 @@ for (const run of runs) { ); }); }); + +describe("class prototype relinks (JS lane)", () => { + test("relinking a class prototype reached through an instance throws", async () => { + // Compiled code binds a class's inherited methods, so its prototype's + // [[Prototype]] cannot change at run time. Node would print "other"; + // the native program refuses instead of answering A's method. + const r = await compileAndRun( + "class-prototype-relink", + `'use strict'; +class A { + a() { + return 'A.a'; + } +} +class B extends A {} +const prototype = Object.getPrototypeOf(new B()); +Object.setPrototypeOf(prototype, Object.getPrototypeOf(prototype)); +console.log('same prototype', new B().a()); +Object.setPrototypeOf(prototype, { a: () => 'other' }); +console.log(new B().a()); +`, + "cjs", + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("same prototype A.a\n"); + expect(r.stderr).toBe( + "Uncaught Error: Object.setPrototypeOf with this value is not supported yet\n", + ); + }); +}); diff --git a/tests/harness/interface-dispatch.test.ts b/tests/harness/interface-dispatch.test.ts index 21e455e70..bf70ecafb 100644 --- a/tests/harness/interface-dispatch.test.ts +++ b/tests/harness/interface-dispatch.test.ts @@ -71,7 +71,7 @@ describe("interface method dispatch", () => { test("calls through an interface reach the implementing methods with typed values", async () => { const llvm = await emittedLlvm("direct", shapes("")); const run = bodies(llvm, "sc_bf_run"); - expect(run).toMatch(/call double @sc_bf__x25_dyn_class_protocol_\d+/); + expect(run).toMatch(/(?:call|invoke) double @sc_bf__x25_dyn_class_protocol_\d+/); expect(run).not.toContain("@scr_dyn_call"); expect(run).not.toContain("@sc_bf__x25_dyn_class_property_"); const dispatch = llvm.slice(llvm.indexOf("; %dyn.class.protocol.0")); @@ -90,7 +90,7 @@ describe("interface method dispatch", () => { console.log(sum([circle, new Rect(1)]));`), ); const sum = bodies(llvm, "sc_bf_sum"); - expect(sum).toMatch(/call double @sc_bf__x25_dyn_class_protocol_\d+/); + expect(sum).toMatch(/(?:call|invoke) double @sc_bf__x25_dyn_class_protocol_\d+/); expect(sum).not.toContain("@scr_dyn_iterator"); expect(sum).not.toContain("@sc_bf__x25_dyn_class_computed_"); }); diff --git a/tests/harness/library-asyncfree.test.ts b/tests/harness/library-asyncfree.test.ts index d924f26d1..9b569c584 100644 --- a/tests/harness/library-asyncfree.test.ts +++ b/tests/harness/library-asyncfree.test.ts @@ -106,6 +106,15 @@ describe("async_free detection over the IR", () => { ).toContain("asymmetric KeyObjects"); }); + test("X509Certificate's key members refuse", async () => { + expect( + await surfaceOf( + "x509-key", + `import { X509Certificate } from "node:crypto";\nimport { readFileSync } from "node:fs";\nconst cert = new X509Certificate(readFileSync("cert.pem"));\nconsole.log(cert.verify(cert.publicKey));\n`, + ), + ).toContain("X509Certificate publicKey, verify and checkPrivateKey"); + }); + test("process signal listeners refuse", async () => { expect( await surfaceOf( diff --git a/tests/harness/library-mode.test.ts b/tests/harness/library-mode.test.ts index 784dc747b..4c1b96b37 100644 --- a/tests/harness/library-mode.test.ts +++ b/tests/harness/library-mode.test.ts @@ -299,6 +299,21 @@ wrap empty: len 2 bytes 60 62 expect(run.stdout).toBe(BUFFERS_EXPECTED); }); + /* ── X509Certificate fields: scr_x509.c rides the archive ────────────── */ + + test("X509Certificate fields in a library", async () => { + const { archive, outDir } = await buildLibrary("x509", emission); + const probe = buildProbe("x509", archive, outDir); + const run = runProbe(probe); + expect(run.signal).toBeNull(); + expect(run.status).toBe(0); + expect(run.stdout).toBe( + "C=US\nST=California\nL=San Francisco\nO=Example Org\nOU=Dev Team\nCN=Example Root CA\nemailAddress=ca@example.com" + + "|0123456789ABCDEF|Sep 27 16:33:52 2126 GMT|true|" + + "7C:5F:CA:18:57:BF:BD:FC:67:AC:BD:21:1C:67:E8:C3:73:48:35:4E:4D:95:A8:2B:83:FF:E0:5A:E7:0A:97:A9\n", + ); + }); + /* ── cstring: NUL-terminated text in, out, and through callbacks ─────── */ test("cstring parameters, results, and callback arguments", async () => { diff --git a/tests/harness/library-profile.test.ts b/tests/harness/library-profile.test.ts index b39daefa1..9ced69b5c 100644 --- a/tests/harness/library-profile.test.ts +++ b/tests/harness/library-profile.test.ts @@ -563,6 +563,7 @@ describe("library profile fences", () => { "stdlib.date.getUTCSeconds", "stdlib.date.now", "stdlib.date.parse", + "stdlib.date.setters", "stdlib.date.toDateString", "stdlib.date.toISOString", "stdlib.date.toString", diff --git a/tests/harness/node-parallel-runner.test.ts b/tests/harness/node-parallel-runner.test.ts new file mode 100644 index 000000000..a829c4d78 --- /dev/null +++ b/tests/harness/node-parallel-runner.test.ts @@ -0,0 +1,52 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative, resolve } from "node:path"; +import { afterAll, expect, test } from "vitest"; +// @ts-expect-error -- a plain ESM helper of the runner script +import { sameTermination, termination } from "../node-parallel/verdict.mjs"; + +const repo = resolve(import.meta.dirname, "../.."); +const scratch = mkdtempSync(join(tmpdir(), "scriptc-node-parallel-")); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +test("runs that end by different signals, or by a signal and an exit, differ", () => { + expect(sameTermination({ code: 0, signal: null }, { code: 0, signal: null })).toBe(true); + expect( + sameTermination({ code: null, signal: "SIGABRT" }, { code: null, signal: "SIGABRT" }), + ).toBe(true); + expect( + sameTermination({ code: null, signal: "SIGABRT" }, { code: null, signal: "SIGSEGV" }), + ).toBe(false); + expect(sameTermination({ code: 1, signal: null }, { code: null, signal: "SIGKILL" })).toBe(false); + expect(sameTermination({ code: 0, signal: null }, { code: 1, signal: null })).toBe(false); + expect(termination({ code: null, signal: "SIGSEGV" })).toBe("SIGSEGV"); + expect(termination({ code: 0, signal: null })).toBe(0); +}); + +test("a relative --node path links the checkout's fixtures into the minimal tree", () => { + const checkout = join(scratch, "node"); + mkdirSync(join(checkout, "test", "parallel"), { recursive: true }); + mkdirSync(join(checkout, "test", "fixtures"), { recursive: true }); + mkdirSync(join(checkout, "test", "common"), { recursive: true }); + writeFileSync(join(checkout, "test", "fixtures", "marker.txt"), "fixture\n"); + writeFileSync(join(checkout, "test", "common", "fixtures.js"), "module.exports = {};\n"); + const out = join(scratch, "out"); + const result = spawnSync( + process.execPath, + [ + join(repo, "tests/node-parallel/run.mjs"), + "--node", + relative(scratch, checkout), + "--pattern", + "^no-such-test$", + "--out", + out, + ], + { cwd: scratch, encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + const linked = join(out, "tree", "test", "fixtures", "marker.txt"); + expect(existsSync(linked)).toBe(true); + expect(readFileSync(linked, "utf8")).toBe("fixture\n"); +}); diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index ea61d4ac4..6baa71c55 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -426,6 +426,44 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { }, ); + test.each(["llvm"] as const)( + "a package probing for optional addons through createRequire compiles with deferred refusals (%s)", + async (backend) => { + const entry = join(pilotRoot, "addon-probe-cli.ts"); + const { coverage } = analyze(entry, { npmStatic: "auto" }); + expect(coverage.npmStatic).toEqual([{ package: "addon-probe", status: "static" }]); + expect(coverage.diagnostics).toHaveLength(0); + expect( + (coverage.runtimeFences ?? []).filter((f) => f.code === "SC2020").map((f) => f.message), + ).toEqual([ + expect.stringContaining("'createRequire's require with a computed specifier"), + expect.stringContaining("'createRequire's require.resolve as a value'"), + expect.stringContaining("'createRequire's require.resolve with a computed request'"), + expect.stringContaining("'createRequire's require.resolve with a computed request'"), + ]); + const outDir = join(cacheDir, "addon-probe", sanitize ? "san" : "plain", backend); + const result = await compile(entry, { + backend, + npmStatic: "auto", + sanitize, + outDir, + outPath: join(outDir, "program"), + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const [nodeRes, nativeRes] = await Promise.all([ + runBinary(process.execPath, [entry]), + runBinary(result.binaryPath, []), + ]); + expect(nativeRes.stdout.toString()).toBe( + "a/b absent,absent,unresolved\ncustom:other\nmissing after 1,missing after 2\n", + ); + expect(nativeRes.stdout).toEqual(nodeRes.stdout); + expect(comparableStderr(nativeRes.stderr)).toEqual(nodeRes.stderr); + expect(nativeRes.exitCode).toBe(nodeRes.exitCode); + }, + ); + test.each([ [ "computed specifier", @@ -433,6 +471,31 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { "computed specifier", ], ["escaped require", 'const alias = load; const path = alias("node:path");', "escapes"], + [ + "computed specifier in a catch clause", + 'try { load("node:path"); } catch { load(String(1)); } const path = load("node:path");', + "outside a try block", + ], + [ + "computed specifier in a function inside a try block", + 'try { [1].map((n) => load(String(n))); } catch {} const path = load("node:path");', + "outside a try block", + ], + [ + "computed specifier in a class field inside a try block", + 'try { new (class { addon = load(String(1)); })(); } catch {} const path = load("node:path");', + "outside a try block", + ], + [ + "resolve value outside a try block", + 'const find = load.resolve; const path = load("node:path");', + "outside a try block", + ], + [ + "escaped require in a try block", + 'try { const alias = load; alias("node:path"); } catch {} const path = load("node:path");', + "escapes", + ], [ "reassigned require", 'let load = createLoader(import.meta.url); load = () => ({}); const path = load("node:path");', @@ -526,6 +589,28 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { } }); + test("a word segmenter in a package constructs, and its segment() throws SC2020", async () => { + const entry = join(pilotRoot, "word-segments-cli.ts"); + const { coverage } = analyze(entry, { npmStatic: "auto" }); + expect(coverage.npmStatic).toEqual([{ package: "word-segments", status: "static" }]); + expect(coverage.diagnostics).toHaveLength(0); + const outDir = join(cacheDir, "word-segments", sanitize ? "san" : "plain"); + const result = await compile(entry, { + backend: "llvm", + npmStatic: "auto", + sanitize, + outDir, + outPath: join(outDir, "program"), + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const nativeRes = await runBinary(result.binaryPath, []); + expect(nativeRes.stdout.toString()).toMatch( + /^SC2020: Intl\.Segmenter segmentation with granularity 'word' is not supported yet \[SC2020 at .*index\.js:1\]\n$/, + ); + expect(nativeRes.exitCode).toBe(0); + }); + test.each([ 'import { Console as Logger } from "node:console"; new Logger({ stdout: process.stdout }).dir(1);', 'import * as output from "console"; new output.Console(process.stdout).table([1, 2]);', diff --git a/tests/harness/npm-suite.ts b/tests/harness/npm-suite.ts index d5bb5beee..9a3d751a2 100644 --- a/tests/harness/npm-suite.ts +++ b/tests/harness/npm-suite.ts @@ -41,8 +41,8 @@ const KNOWN: Record = { "assert-shims": { run: "SC1043" }, "buffer-shims": { run: "SC2020" }, "bytes-exit": { build: "SC2004" }, - "cjs-esm-named": { build: "SC2013" }, - "cjs-lexer-aligned": { build: "SC2013" }, + "cjs-esm-named": { run: "SC1090" }, + "cjs-lexer-aligned": { run: "SC1090" }, "commander-calc": { run: "SC1090", argvs: [["--help"]] }, "commander-calc-async": { run: "SC1090", argvs: [["--help"]] }, "create-require": { run: "SC1090" }, @@ -107,6 +107,8 @@ const KNOWN: Record = { "self-name": { build: "SC2013" }, "stream-shims": { run: "SC2020" }, "util-shims": { run: "SC2020" }, + // The package redeclares the var it exported: Node copies the first value. + "var-export-redeclared": { run: "SC1090" }, "web-streams": { run: "SC1090" }, "workspace-copied": { build: "SC2013" }, "workspace-unclean": { build: "SC2013" }, diff --git a/tests/harness/package-shims.test.ts b/tests/harness/package-shims.test.ts index ca3d1589b..a21c4e768 100644 --- a/tests/harness/package-shims.test.ts +++ b/tests/harness/package-shims.test.ts @@ -8,7 +8,8 @@ * stays unloaded: its require throws MODULE_NOT_FOUND; * - a shimmed package that is not installed, or whose major version has no * shim, resolves exactly as before (here: refused like any missing or - * uncompiled package); + * uncompiled package), and `--npm-static` does not compile a version the + * registry entry does not list in `sourceVersions`; * - the undici shims keep memory bounded against a peer that sends endless * chunk-size lines and large or compressed bodies, and the 5.x profile * rejects a Host value with CR or LF as undici 5.29 does (the vendored @@ -253,6 +254,33 @@ describe("package shims", () => { } }, 180_000); + test("--npm-static leaves out an implemented package at a version it does not list", async () => { + const dir = join(scratch, "undici-v6-npm-static"); + const pkg = join(dir, "node_modules/undici"); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(pkg, "package.json"), + '{ "name": "undici", "version": "6.21.0", "main": "index.js" }\n', + ); + writeFileSync(join(pkg, "index.js"), "exports.version = 6;\n"); + writeFileSync( + join(dir, "main.mjs"), + 'import { version } from "undici";\nconsole.log(version);\n', + ); + const result = await compile(join(dir, "main.mjs"), { + outPath: join(dir, ".scriptc/program"), + outDir: join(dir, ".scriptc"), + npmStatic: ["undici"], + sanitize, + }); + // The package's JavaScript is not part of the program, so the import + // has no declarations. + expect(result.ok).toBe(false); + expect(result.diagnostics.map((d) => d.message).join("\n")).toContain( + "Could not find a declaration file for module 'undici'", + ); + }, 180_000); + for (const name of ["undici7", "undici5"]) { test(`${name}: endless chunk-size lines and large bodies stay in bounded memory`, async () => { const { dir, binary } = await buildInCase(name, "undici-bounded.ts"); diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 7980da17f..e2ab35c71 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -100,6 +100,13 @@ for (const [name, title] of [ "eventLoopUtilization and monitorEventLoopDelay follow @types/node's types", ], ["timeout-type", "NodeJS.Timeout fields, ref/unref/hasRef and clearInterval under @types/node"], + ["x509-methods", "X509Certificate's methods and the legacy object under @types/node"], + ["tls-peer-certificates", "TLS socket certificate accessors under @types/node"], + ["tls-client-certificates", "mutual TLS with client certificates under @types/node"], + [ + "tls-server-contexts", + "tls.Server addContext, setSecureContext and tlsClientError under @types/node", + ], ] as const) { test(`node-types: ${title}`, async () => { const outDir = outDirFor(name); diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index c5476328e..8470ce04e 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -52,6 +52,7 @@ const features: NativeLinkFeatures = { dh: false, pki: false, ed25519: false, + x509: false, webcrypto: false, perfLoop: false, }; @@ -109,7 +110,7 @@ for (const backend of ["llvm"] as const) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: [], sanitizer: "external-toolchain-required" }, flavors: { diff --git a/tests/harness/self-hosting-runtime-pack.test.ts b/tests/harness/self-hosting-runtime-pack.test.ts index 64ddd0213..859f820da 100644 --- a/tests/harness/self-hosting-runtime-pack.test.ts +++ b/tests/harness/self-hosting-runtime-pack.test.ts @@ -53,6 +53,7 @@ const base: NativeLinkFeatures = { dh: false, pki: false, ed25519: false, + x509: false, webcrypto: false, perfLoop: false, }; diff --git a/tests/harness/self-hosting-ts7-process.test.ts b/tests/harness/self-hosting-ts7-process.test.ts index d49a5c092..150a8860d 100644 --- a/tests/harness/self-hosting-ts7-process.test.ts +++ b/tests/harness/self-hosting-ts7-process.test.ts @@ -1,5 +1,5 @@ import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { analyze, compile } from "@scriptc/compiler"; @@ -72,6 +72,20 @@ for (const backend of ["llvm"] as const) { expect(transport.status, transport.stderr).toBe(0); expect(transport.stdout).toBe("native transport passed\n"); expect(transport.stderr).toBe(""); + if (process.platform !== "win32") { + // A shared server in a private server directory, which the stand-in + // leaves after its idle time. + const runtime = join(dir, "run"); + mkdirSync(runtime, { mode: 0o700 }); + const shared = spawnSync(built.binaryPath, [child, dir, "unused", "shared"], { + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, XDG_RUNTIME_DIR: runtime, TSC_TS_LISTEN_MEMORY: undefined }, + }); + expect(shared.status, shared.stderr).toBe(0); + expect(shared.stdout).toBe("native shared server passed\n"); + expect(shared.stderr).toBe(""); + } for (const mode of ["normal-exit", "exit"]) { const pidFile = join(dir, mode + ".pid"); const run = spawnSync(built.binaryPath, [child, dir, pidFile, mode], { @@ -94,6 +108,20 @@ for (const backend of ["llvm"] as const) { dir, join(dir, "oracle.json"), ); + if (process.platform !== "win32") { + // With the shared server requested, a checker without --listen exits + // as it starts and the build checks with its own process. + const runtime = join(dir, "api-run"); + mkdirSync(runtime, { mode: 0o700 }); + const api = spawnSync(built.binaryPath, [tsgoExecutable(), dir, "unused", "api"], { + encoding: "utf8", + timeout: 45_000, + env: { ...process.env, XDG_RUNTIME_DIR: runtime, SCRIPTC_CHECKER_SERVER: "1" }, + }); + expect(api.status, api.stderr).toBe(0); + expect(api.stdout).toBe(""); + expect(api.stderr).toBe(""); + } const report = join(dir, "native.json"); // Only ordinary stdio is inherited. The native executable starts the // actual tsgo process; the test runner never creates its RPC pipes. As in diff --git a/tests/harness/shape-overlay.test.ts b/tests/harness/shape-overlay.test.ts index ad1988238..54bcfc1ce 100644 --- a/tests/harness/shape-overlay.test.ts +++ b/tests/harness/shape-overlay.test.ts @@ -4,7 +4,7 @@ * prints; the shapeOverlay: false option (the CLI's --no-shape-overlay) * turns it off. */ import { execFile } from "node:child_process"; -import { mkdirSync } from "node:fs"; +import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { expect, test } from "vitest"; @@ -60,3 +60,66 @@ test("shapeOverlay: false compiles without the overlay", { timeout: 900_000 }, a throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); expect((result.warnings ?? []).some((w) => w.code === "SC6103")).toBe(false); }); + +// shape-facts: results that include the undefined of a fall-through or a bare +// return keep the caller's falsy branch, comment-like text in literals keeps +// its value, and a replaced branch keeps its var declared. +const factsEntry = join(repoRoot, "tests/fixtures/npm/cases/shape-overlay-facts/main.ts"); + +test( + "the overlay keeps undefined results, literal text and hoisted vars", + { timeout: 900_000 }, + async () => { + mkdirSync(outDir, { recursive: true }); + const result = await withOverlay(() => + compile(factsEntry, { outPath: join(outDir, "facts"), outDir, sanitize }), + ); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const report = (result.warnings ?? []).find((w) => w.code === "SC6103"); + expect(report?.message).toMatch(/of 1 package \(shape-facts\)/); + expect(report?.message).toMatch(/replaces 1 branch it proved unreachable/); + const native = await execFileAsync(result.binaryPath, [], { + cwd: dirname(factsEntry), + encoding: "utf8", + }); + const node = await execFileAsync(process.execPath, [factsEntry], { + cwd: dirname(factsEntry), + encoding: "utf8", + }); + expect(native.stdout).toBe(node.stdout); + }, +); + +// `"scriptc": { "packages": { "": { "shapes": false } } }` in the +// project's package.json compiles that package without the overlay. +test( + "a package with shapes: false compiles without the overlay", + { timeout: 900_000 }, + async () => { + const project = join(outDir, "facts-shapeless"); + rmSync(project, { recursive: true, force: true }); + cpSync(dirname(factsEntry), project, { recursive: true }); + writeFileSync( + join(project, "package.json"), + JSON.stringify({ + name: "shape-overlay-facts-case", + private: true, + type: "module", + scriptc: { packages: { "shape-facts": { shapes: false } } }, + }), + ); + const result = await withOverlay(() => + compile(join(project, "main.ts"), { + outPath: join(outDir, "facts-shapeless-bin"), + outDir, + sanitize, + }), + ); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect((result.warnings ?? []).some((w) => w.code === "SC6103" || w.code === "SC8004")).toBe( + false, + ); + }, +); diff --git a/tests/harness/speculative-leaves-emission.test.ts b/tests/harness/speculative-leaves-emission.test.ts new file mode 100644 index 000000000..16fd298d4 --- /dev/null +++ b/tests/harness/speculative-leaves-emission.test.ts @@ -0,0 +1,63 @@ +/* Speculative integer loops (behavior is in the differential corpus, 7757): + * the locals that hold hoisted calls are numbered per function, so a + * function's text does not depend on the functions emitted before it (the + * native compiler's function workers each emit a share of the module). */ +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); + +async function emittedLlvm(name: string, source: string): Promise { + const key = createHash("sha256").update(source).digest("hex").slice(0, 16); + const outDir = join(cacheDir, `spec-leaves-${key}`); + mkdirSync(outDir, { recursive: true }); + const file = join(outDir, `${name}.ts`); + writeFileSync(file, source); + const result = await compile(file, { + outPath: join(outDir, name), + outDir, + backend: "llvm", + outputKind: "llvm", + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return readFileSync(result.artifact.path, "utf8"); +} + +function body(llvm: string, name: string): string { + const start = llvm.search(new RegExp(`^define internal [^\\n]*@${name}[.(]`, "m")); + expect(start).toBeGreaterThanOrEqual(0); + return llvm.slice(start, llvm.indexOf("\n}\n", start)); +} + +test("hoisted speculative leaves are numbered per function", async () => { + const llvm = await emittedLlvm( + "leaves", + `let calls = 0; +function noisy(x: number): number { + calls++; + return x % 5 === 0 ? x / 2 : x * 2; +} +function first(n: number): number { + let acc = 1; + for (let i = 0; i < n; i++) acc = (acc * 7 + noisy(i)) % 1000003; + return acc; +} +function second(n: number): number { + let acc = 3; + for (let i = 0; i < n; i++) acc = (acc * 5 + noisy(i)) % 1000003; + return acc; +} +console.log(first(20), second(20), calls); +`, + ); + for (const name of ["sc_f_first", "sc_f_second"]) { + const leaves = body(llvm, name).match(/%\w*spec_leaf_\d+ = alloca double/g) ?? []; + expect(leaves).toHaveLength(1); + expect(leaves[0]).toMatch(/_spec_leaf_0 = /); + } +}); diff --git a/tests/harness/static-objects-order.test.ts b/tests/harness/static-objects-order.test.ts new file mode 100644 index 000000000..d732d06a3 --- /dev/null +++ b/tests/harness/static-objects-order.test.ts @@ -0,0 +1,127 @@ +/* Static-object specialization (shape-inference/static-objects.ts): the + * rewrites keep what the program evaluates and when its modules load. With + * the shape overlay on, the npm case static-objects-order (through its + * JavaScript consumer, js-main.cjs) keeps receivers with effects as + * written, keeps reads that may run before what they read exists, binds a + * module where the program loads it and only for reads that run after the + * binding, never adds a hoisted class to an exports literal that runs + * before the class's declaration, does not fold a literal's `__proto__`, + * and prints what Node prints. A module in a require cycle through another + * module is not read through its exports from the cycle. The rewritten + * files are read from the overlay's debug output + * (SCRIPTC_DEBUG_SHAPE_OVERLAY). */ +import { execFile } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test, vi } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +/** Compiles `entry` with the overlay on; the result and the debug output. */ +async function compileWithOverlay(entry: string, name: string) { + const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests", name); + mkdirSync(outDir, { recursive: true }); + // The debug output comes from the frontend, which a cached build skips. + const saved = process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY"]; + const savedCache = process.env["SCRIPTC_NO_CACHE"]; + process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY"] = "1"; + process.env["SCRIPTC_NO_CACHE"] = "1"; + let debug = ""; + const write = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + debug += String(chunk); + return true; + }); + try { + const result = await compile(entry, { + outPath: join(outDir, "main"), + outDir, + sanitize, + shapeOverlay: true, + }); + return { result, debug }; + } finally { + write.mockRestore(); + if (saved === undefined) delete process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY"]; + else process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY"] = saved; + if (savedCache === undefined) delete process.env["SCRIPTC_NO_CACHE"]; + else process.env["SCRIPTC_NO_CACHE"] = savedCache; + } +} + +/** The text the rewrites give `file` in the debug output, if any. */ +function rewrittenIfAny(debug: string, file: string): string | undefined { + const parts = debug.split(/^\[(?:static-objects|shape-overlay)\] /m); + return parts.find((p) => p.startsWith(`${file}\n`))?.slice(file.length + 1); +} + +/** The text the rewrites give `file` in the debug output. */ +function rewritten(debug: string, file: string): string { + const text = rewrittenIfAny(debug, file); + expect(text, `no rewrite of ${file}`).toBeDefined(); + return text!; +} + +test( + "receivers with effects, and exports literals before a class, stay as written", + { timeout: 600_000 }, + async () => { + const entry = join(repoRoot, "tests/fixtures/npm/cases/static-objects-order/js-main.cjs"); + const { result, debug } = await compileWithOverlay(entry, "static-objects-order"); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const pkg = join(dirname(entry), "node_modules/static-objects-order"); + expect(debug).toContain( + "kept constant read (reads++, ns.Kind).A: the receiver may have effects", + ); + expect(debug).toContain( + "kept reference (reads++, ns.util).twice: the receiver may have effects", + ); + expect(debug).toContain( + "kept table call (reads++, ns.ops)[op]: the table expression may have effects", + ); + // vec.js assigns its exports literal before the constructor function. + // receivers.js reads ns.Late.X and calls ns.later() before they exist. + expect(debug).toContain( + "kept constant read ns.Late.X: it may run before the object is created", + ); + expect(debug).toContain("kept reference ns.later: it may run before the member is stored"); + // lazy-user.js's first mention of lazy.js is a conditional require: the + // binding follows the statement that loads the module each time it runs. + expect(rewritten(debug, join(pkg, "lazy-user.js"))).toMatch( + /var m = require\("\.\/lazy"\);\nconst __ns_mod_\w+ = require\("\.\/lazy"\);/, + ); + // early-user.js calls a function in the statement that loads lazy.js: + // a binding after that statement is not initialized when it runs. + expect(rewrittenIfAny(debug, join(pkg, "early-user.js")) ?? "").not.toContain("__ns_mod_"); + const vec = rewritten(debug, join(pkg, "vec.js")); + expect(vec).toContain("class __ns_geo_Vec"); + expect(vec).toContain("module.exports = { geo: geo };"); + const pending = execFileAsync(result.binaryPath, [], { cwd: dirname(entry), encoding: "utf8" }); + pending.child.stdin?.end(); + const { stdout } = await pending; + const node = await execFileAsync(process.execPath, [entry], { + cwd: dirname(entry), + encoding: "utf8", + }); + expect(stdout).toBe(node.stdout); + }, +); + +test( + "a module in a require cycle is not read through its exports", + { timeout: 600_000 }, + async () => { + // cycle-pkg's cycle-d.js requires cycle-m.js, which requires cycle-r.js, which reads + // cycle-d's namespace while cycle-d is still loading. scriptc refuses the + // cycle itself (SC1016); the rewrite must not change what cycle-r reads. + const entry = join(repoRoot, "tests/fixtures/static-objects-cycle/main.cjs"); + const { debug } = await compileWithOverlay(entry, "static-objects-cycle"); + const text = rewritten(debug, join(dirname(entry), "node_modules/cycle-pkg/cycle-r.js")); + expect(text).toContain("typeof d.dd.f"); + expect(text).not.toContain("__ns_mod_"); + }, +); diff --git a/tests/harness/static-objects.test.ts b/tests/harness/static-objects.test.ts index b48a61eae..1d760a121 100644 --- a/tests/harness/static-objects.test.ts +++ b/tests/harness/static-objects.test.ts @@ -35,7 +35,7 @@ test( throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); const report = (result.warnings ?? []).find((w) => w.code === "SC6103"); expect(report?.message).toMatch( - /compiled 1 constructor function as classes and 2 namespace functions as direct functions/, + /compiled 2 constructor functions as classes and 3 namespace functions as direct functions/, ); const pending = execFileAsync(result.binaryPath, [], { cwd: dirname(entry), encoding: "utf8" }); pending.child.stdin?.end(); diff --git a/tests/harness/test262.test.ts b/tests/harness/test262.test.ts index f1b616578..678685103 100644 --- a/tests/harness/test262.test.ts +++ b/tests/harness/test262.test.ts @@ -237,6 +237,63 @@ assert.notSameValue(1, "1"); status: "fail", source: "assert.throws(TypeError, () => { throw 1; });", }, + { + name: "identical references", + status: "pass", + source: ` +const object = {}; +const list = [1]; +const fn = function () {}; +assert.sameValue(object, object); +assert.sameValue(list, list); +assert.sameValue(fn, fn); +assert.compareArray([object, fn], [object, fn]); +`, + }, + { + name: "a reference is not a primitive", + status: "pass", + source: + "assert.notSameValue({}, 1); assert.notSameValue([], undefined); assert.notSameValue(null, {});", + }, + { name: "reference and primitive mismatch", status: "fail", source: "assert.sameValue([], 0);" }, + { + name: "identical references are not different", + status: "fail", + source: "const object = {}; assert.notSameValue(object, object);", + }, + { + name: "array element reference and primitive mismatch", + status: "fail", + source: "assert.compareArray([{}], [1]);", + }, + { + name: "non-string messages", + status: "pass", + source: + "assert.sameValue(1, 1, false); assert.notSameValue(1, 2, 0); assert(true, null); assert.compareArray([], [], 1);", + }, + { + name: "failure with a non-string message", + status: "fail", + source: "assert.sameValue(1, 2, false);", + }, + { + name: "passing assertions do not convert their message", + status: "pass", + source: ` +const message = { toString() { throw new Error("converted"); } }; +assert(true, message); +assert.sameValue(1, 1, message); +assert.notSameValue(1, 2, message); +assert.compareArray([1, 2], [1, 2], message); +`, + }, + { + name: "array mismatch with an object message", + status: "fail", + source: 'assert.compareArray([1], [2], { toString() { return "differs"; } });', + }, ]; describe(`Test262 host assertion contract${shardSuffix()}`, () => { @@ -259,8 +316,8 @@ describe(`Test262 host assertion contract${shardSuffix()}`, () => { ["{}", "[1]", "function () {}"], (value) => `reference:${value}`, )) { - test(`reference assertions are explicitly refused: ${value}`, async () => { - const source = `const value = ${value}; try { assert.sameValue(value, value); } catch { }`; + test(`distinct reference assertions are explicitly refused: ${value}`, async () => { + const source = `try { assert.sameValue(${value}, ${value}); } catch { } try { assert.notSameValue(${value}, ${value}); } catch { }`; expect(() => runUpstream(source)).not.toThrow(); const result = await runSource(source, { sanitize }); expect(result, JSON.stringify(result)).toMatchObject({ @@ -270,8 +327,8 @@ describe(`Test262 host assertion contract${shardSuffix()}`, () => { }); } - test("array element identity assertions are refused", async () => { - const source = "const value = {}; try { assert.compareArray([value], [value]); } catch { }"; + test("distinct array element references are refused", async () => { + const source = "try { assert.compareArray([{}], [{}]); } catch { }"; expect(() => runUpstream(source)).not.toThrow(); const result = await runSource(source, { sanitize }); expect(result, JSON.stringify(result)).toMatchObject({ @@ -359,9 +416,15 @@ const propertyControls = shardSelect( }, { name: "reference value", - status: "harness-refusal", + status: "pass", source: "const f = function() {}; const o = { a: f }; verifyProperty(o, 'a', { value: f });", }, + { + name: "distinct reference value", + status: "harness-refusal", + source: + "const o = { a: function() {} }; try { verifyProperty(o, 'a', { value: function() {} }); } catch { }", + }, ], (item) => `property:${item.name}`, ); diff --git a/tests/harness/tls-socket-leaks.test.ts b/tests/harness/tls-socket-leaks.test.ts index 64edfac25..d657b0950 100644 --- a/tests/harness/tls-socket-leaks.test.ts +++ b/tests/harness/tls-socket-leaks.test.ts @@ -3,7 +3,9 @@ * every socket that closes, including one whose handshake fails, must drop * them. The sanitized lanes otherwise run with detect_leaks=0, so this runs * the TLSSocket programs with LeakSanitizer explicitly on. 7094's - * handshakes all fail. Linux only (Apple's ASan has no LeakSanitizer); + * handshakes all fail; 7095's detailed certificates link roots to + * themselves; 7097 and 7098 hold client certificates and the chains a ca + * completes. Linux only (Apple's ASan has no LeakSanitizer); * runs in the SCRIPTC_SAN=1 lane. */ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; @@ -22,6 +24,12 @@ const programs = [ "tests/corpus/7087-tls-socket-wrap.ts", "tests/corpus/7088-tls-socket-connect-tunnel.ts", "tests/corpus/7094-tls-socket-failed-handshakes.ts", + "tests/corpus/7095-tls-peer-certificates.ts", + "tests/corpus/7097-tls-client-certificates.ts", + "tests/corpus/7098-tls-client-certificates-paths.ts", + "tests/corpus/7440-tls-server-add-context.ts", + "tests/corpus/7441-tls-client-error.ts", + "tests/corpus/7443-tls-final-flight.ts", ]; describe.skipIf(!sanitize || process.platform !== "linux")( diff --git a/tests/harness/tls-trust-store.test.ts b/tests/harness/tls-trust-store.test.ts new file mode 100644 index 000000000..714de9057 --- /dev/null +++ b/tests/harness/tls-trust-store.test.ts @@ -0,0 +1,42 @@ +// TLS trust stores that depend on the process's NODE_EXTRA_CA_CERTS: a +// server's ca option, where an empty Buffer is an empty store and an empty +// string is the default one. The corpus cannot set the environment, so the +// fixture runs here under Node and as a binary with the test CA added. +import { execFile } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +test("a server's empty ca Buffer is an empty trust store, as in Node", async () => { + const entry = join(repoRoot, "tests/fixtures/tls/empty-ca.ts"); + const outDir = join(cacheDir, `tls-trust-store-${sanitize ? "san" : "plain"}`); + mkdirSync(outDir, { recursive: true }); + const result = await compile(entry, { + outPath: join(outDir, "empty-ca"), + outDir, + sanitize, + backend: "llvm", + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const env = { + ...process.env, + NODE_EXTRA_CA_CERTS: join(repoRoot, "tests/fixtures/server/certs/ca.pem"), + }; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath, [], { cwd: repoRoot, env }), + execFileAsync(process.execPath, ["--experimental-strip-types", "--no-warnings", entry], { + cwd: repoRoot, + env, + }), + ]); + expect(native.stdout).toBe(node.stdout); + expect(node.stdout).toContain("empty Buffer false SELF_SIGNED_CERT_IN_CHAIN"); +}, 120_000); diff --git a/tests/harness/wasi-differential.test.ts b/tests/harness/wasi-differential.test.ts index 5d814b384..66c2934f2 100644 --- a/tests/harness/wasi-differential.test.ts +++ b/tests/harness/wasi-differential.test.ts @@ -313,6 +313,9 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => { // ThreadSlots blocks and pages hold 32-bit pointers. "5727-thread-slots-basics.ts", "5729-thread-slots-values.ts", + // Weak-collection hashes mix 32-bit pointers in 64 bits. + "4591-weak-keys-union-records.ts", + "7742-weak-collections-many-keys.ts", ])("the LLVM target runs %s byte-identically to Node", async (fixture) => { const entry = join(repoRoot, "tests/corpus", fixture); const outDir = await mkdtemp("/tmp/scriptc-wasi-"); diff --git a/tests/library-mode/x509/lib.ts b/tests/library-mode/x509/lib.ts new file mode 100644 index 000000000..9b023a727 --- /dev/null +++ b/tests/library-mode/x509/lib.ts @@ -0,0 +1,41 @@ +// X509Certificate fields in a library archive: the field code lives in +// scr_x509.c, which the archive carries when the program reads a field. +import * as crypto from "node:crypto"; + +const CA = `-----BEGIN CERTIFICATE----- +MIIEITCCAwmgAwIBAgIIASNFZ4mrze8wDQYJKoZIhvcNAQELBQAwgZwxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNp +c2NvMRQwEgYDVQQKDAtFeGFtcGxlIE9yZzERMA8GA1UECwwIRGV2IFRlYW0xGDAW +BgNVBAMMD0V4YW1wbGUgUm9vdCBDQTEdMBsGCSqGSIb3DQEJARYOY2FAZXhhbXBs +ZS5jb20wIBcNMjYxMDIxMTYzMzUyWhgPMjEyNjA5MjcxNjMzNTJaMIGcMQswCQYD +VQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j +aXNjbzEUMBIGA1UECgwLRXhhbXBsZSBPcmcxETAPBgNVBAsMCERldiBUZWFtMRgw +FgYDVQQDDA9FeGFtcGxlIFJvb3QgQ0ExHTAbBgkqhkiG9w0BCQEWDmNhQGV4YW1w +bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0YGcCr0pxR8d +6qDdLuILcoT7vhtAjyHwfkoChnH6N3TLWzDUugnBp9NbDb7gDLqbRT4uTDoNb6zS +tEJZ4Q7/hhd0jDvoPmOy0Y3Kyj9uNsdf25GdbvwwEnlddELJCZprTZ4OhEg6w2s0 +Yy4SlviLzDXLmsggTypxeS+K8JHT9onMipi5L4GFu8n1QZbzXvSS4BkRgMXEyrtr +FOE9qSo2qkubFyd/WNKRTRIpTSc5/GGsqJ0FKDDJnN5adotcUucbJFn4noNnFUWh +M8mhBEj7ENDdJsD2EVFsI6H958jA2SbNNu4ngzAOs2AKNBYBfQK02ZQAdRNOSL3K +5FkfbII5LwIDAQABo2MwYTAdBgNVHQ4EFgQUP+yQyItpNYEls26Jfv+NnciGaK8w +HwYDVR0jBBgwFoAUP+yQyItpNYEls26Jfv+NnciGaK8wDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBABDw3Va/C+RFc/Hs +TJnHtQJtxFL0ZhnHUqg4DcDA3m+JDdIDNL8sNpLEbtn/pr0VeEWgG+MImWAQlrUW +IAZkLj9/Zg1wSO0oZ86ZyZJZmp54lUYXgG17CNl4g+F7VHt3bBwUJHTbWye/FKM6 +5OVZetCYKVj7Z+5BYuf7cumzvRqcn32fXidB/kjRX14SJ4JDPn2yFZWCr2ygIqWQ +SOuEmWbNAbYGaVZkQr2Ma8U28DWY2jb0f6nP8jLt49t9o6N/e6Fwd6I5t/mx9Mfj +0403omkxmr9vqN2kBamzVnun+2mDkRZcKPCr72Q4sfbvmDi/W5Nf1Iu2cQW8VfDO +SqpyT54= +-----END CERTIFICATE----- +`; + +export function describe(): string { + const cert = new crypto.X509Certificate(CA); + return [ + cert.subject, + cert.serialNumber, + cert.validTo, + String(cert.ca), + cert.fingerprint256, + ].join("|"); +} diff --git a/tests/library-mode/x509/probe.c b/tests/library-mode/x509/probe.c new file mode 100644 index 000000000..d8065d039 --- /dev/null +++ b/tests/library-mode/x509/probe.c @@ -0,0 +1,23 @@ +/* X509Certificate fields read through a library export. */ +#include +#include + +extern void kx_init(void); +extern void kx_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx); +extern void kx_collect(void); +extern void kx_describe(const uint8_t **out, size_t *out_len); + +static void sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) { + (void)ctx; (void)addr; + printf("SINK %.*s\n", (int)len, msg); +} + +int main(void) { + kx_set_panic_sink(sink, NULL); + kx_init(); + const uint8_t *s; size_t n; + kx_describe(&s, &n); + printf("%.*s\n", (int)n, s); + kx_collect(); + return 0; +} diff --git a/tests/library-mode/x509/profile.json b/tests/library-mode/x509/profile.json new file mode 100644 index 000000000..fe9b4fb59 --- /dev/null +++ b/tests/library-mode/x509/profile.json @@ -0,0 +1,14 @@ +{ + "profile_format": 1, + "name": "conformance-x509", + "entry": "lib.ts", + "emission": "llvm", + "abi": { + "prefix": "kx_", + "init_symbol": "kx_init", + "sink_register_symbol": "kx_set_panic_sink", + "collect_symbol": "kx_collect", + "result_reset_symbol": null + }, + "exports": [{ "export": "describe", "symbol": "kx_describe", "params": [], "returns": "string" }] +} diff --git a/tests/node-parallel/README.md b/tests/node-parallel/README.md new file mode 100644 index 000000000..c8ac12134 --- /dev/null +++ b/tests/node-parallel/README.md @@ -0,0 +1,23 @@ +# Node test/parallel files + +`run.mjs` builds files from a Node.js checkout's `test/parallel` with scriptc, runs each executable in that directory, and compares its exit status with Node's. It records a verdict per file: `pass` (the same exit code, or the same terminating signal), `fail` (a different exit code or signal), or `refused` (the build failed), with the first error line. + +```bash +git clone --depth 1 --branch v24.22.0 https://github.com/nodejs/node /path/to/node +node tests/node-parallel/run.mjs --node /path/to/node --pattern '^test-(url-|whatwg-url-|querystring)' +``` + +Options: + +- `--node`: a Node.js checkout, or its `test` directory. Use the version in `.node-version`. +- `--pattern`: a regular expression over file names in `test/parallel`. +- `--common`: `minimal` (the default) or `node`. + - `minimal` copies the selected files into `/tree/test/parallel` and uses `common/index.cjs` and `common/wpt.cjs` from this directory as `test/common/index.js` and `test/common/wpt.js`, with the checkout's `test/fixtures` and `test/common/fixtures.js`. The minimal common has the helpers these files use (`mustCall`, `mustNotCall`, `skip`, `expectWarning`, `invalidArgTypeHelper`, `spawnPromisified`, `hasIntl`, `isWindows`, ...) with Node's semantics, so a verdict measures the module under test rather than Node's full `test/common`. + - `node` builds the files in place against Node's own `test/common`. +- `--out`: where executables and results go (default `node_modules/.cache/scriptc-node-parallel`). +- `--cli`: the scriptc CLI entry (default `packages/cli/dist/main.js`). +- `--jobs`: concurrent files (default 4). + +Node runs each file with its `// Flags:` line. Results are written to `/results.json` and `/results.md`. + +Before a lane relies on the minimal common, run its files under Node in `/tree/test/parallel`: they should all pass there. diff --git a/tests/node-parallel/common/index.cjs b/tests/node-parallel/common/index.cjs new file mode 100644 index 000000000..b52bba638 --- /dev/null +++ b/tests/node-parallel/common/index.cjs @@ -0,0 +1,131 @@ +"use strict"; +// A minimal test/common for building Node's test/parallel files with scriptc: +// the helpers those files use, with the semantics of Node's +// test/common/index.js. Add a helper here, with Node's semantics, when a +// lane's files need it. +const assert = require("assert"); +const { inspect } = require("util"); + +const hasIntl = true; +const isDebug = false; +const isWindows = process.platform === "win32"; +const mustCallChecks = []; + +function runCallChecks(exitCode) { + if (exitCode !== 0) return; + const failed = mustCallChecks.filter((context) => { + if ("minimum" in context) return context.actual < context.minimum; + return context.actual !== context.exact; + }); + for (const context of failed) { + console.log( + "Mismatched %s function calls. Expected %s, actual %d.", + context.name, + String(context.exact ?? context.minimum), + context.actual, + ); + } + if (failed.length) process.exit(1); +} + +function noop() {} + +function mustCallInner(fn, criteria, field) { + if (typeof fn === "number") { + criteria = fn; + fn = noop; + } else if (fn === undefined) { + fn = noop; + } + if (criteria === undefined) criteria = 1; + const context = { actual: 0, name: fn.name || "" }; + context[field] = criteria; + if (mustCallChecks.length === 0) process.on("exit", runCallChecks); + mustCallChecks.push(context); + return function (...args) { + context.actual++; + return fn.apply(this, args); + }; +} + +function mustCall(fn, exact) { + return mustCallInner(fn, exact, "exact"); +} + +function mustCallAtLeast(fn, minimum) { + return mustCallInner(fn, minimum, "minimum"); +} + +function mustNotCall(msg) { + return function mustNotCall() { + assert.fail(msg || "function should not have been called"); + }; +} + +function skip(msg) { + console.log(`1..0 # Skipped: ${msg}`); + process.exit(0); +} + +function expectWarning(name, expected, code) { + if (typeof expected === "string") expected = [[expected, code]]; + else if (expected.length !== 0 && !Array.isArray(expected[0])) + expected = [[expected[0], expected[1]]]; + const check = mustCall((warning) => { + const [message, warningCode] = expected.shift(); + assert.strictEqual(warning.name, name); + if (typeof message === "string") assert.strictEqual(warning.message, message); + else assert.match(warning.message, message); + assert.strictEqual(warning.code, warningCode); + }, expected.length); + process.on("warning", (warning) => { + if (warning.name !== name) + throw new TypeError(`"${warning.name}" was triggered without being expected.`); + check(warning); + }); +} + +function invalidArgTypeHelper(input) { + if (input == null) return ` Received ${input}`; + if (typeof input === "function") return ` Received function ${input.name}`; + if (typeof input === "object") { + const name = input.constructor?.name; + if (typeof name === "string" && name !== "") return ` Received an instance of ${name}`; + return ` Received ${inspect(input, { depth: -1 })}`; + } + let inspected = inspect(input, { colors: false }); + if (inspected.length > 28) inspected = `${inspected.slice(inspected, 0, 25)}...`; + return ` Received type ${typeof input} (${inspected})`; +} + +function spawnPromisified(...args) { + const { spawn } = require("child_process"); + let stderr = ""; + let stdout = ""; + const child = spawn(...args); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (data) => { + stderr += data; + }); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (data) => { + stdout += data; + }); + return new Promise((resolve, reject) => { + child.on("close", (code, signal) => resolve({ code, signal, stderr, stdout })); + child.on("error", (code, signal) => reject({ code, signal, stderr, stdout })); + }); +} + +module.exports = { + hasIntl, + isDebug, + isWindows, + mustCall, + mustCallAtLeast, + mustNotCall, + skip, + expectWarning, + invalidArgTypeHelper, + spawnPromisified, +}; diff --git a/tests/node-parallel/common/wpt.cjs b/tests/node-parallel/common/wpt.cjs new file mode 100644 index 000000000..eeb7d9770 --- /dev/null +++ b/tests/node-parallel/common/wpt.cjs @@ -0,0 +1,32 @@ +"use strict"; +// The harness object of Node's test/common/wpt.js, without its WPT runner. +const assert = require("assert"); + +const harness = { + test: (fn, desc) => { + try { + fn(); + } catch (err) { + console.error(`In ${desc}:`); + throw err; + } + }, + assert_equals: assert.strictEqual, + assert_true: (value, message) => assert.strictEqual(value, true, message), + assert_false: (value, message) => assert.strictEqual(value, false, message), + assert_throws: (code, func, desc) => { + assert.throws( + func, + function (err) { + return typeof err === "object" && "name" in err && err.name.startsWith(code.name); + }, + desc, + ); + }, + assert_array_equals: assert.deepStrictEqual, + assert_unreached(desc) { + assert.fail(`Reached unreachable code: ${desc}`); + }, +}; + +module.exports = { harness }; diff --git a/tests/node-parallel/run.mjs b/tests/node-parallel/run.mjs new file mode 100644 index 000000000..e7a1e3cec --- /dev/null +++ b/tests/node-parallel/run.mjs @@ -0,0 +1,135 @@ +// Builds Node's test/parallel files with scriptc and runs them next to Node. +// See README.md for the options. +import { spawn } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { sameTermination, termination } from "./verdict.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, "../.."); +const { values } = parseArgs({ + options: { + node: { type: "string" }, + pattern: { type: "string" }, + common: { type: "string", default: "minimal" }, + out: { type: "string", default: join(repo, "node_modules/.cache/scriptc-node-parallel") }, + cli: { type: "string", default: join(repo, "packages/cli/dist/main.js") }, + jobs: { type: "string", default: "4" }, + }, +}); +if (!values.node || !values.pattern || !["minimal", "node"].includes(values.common)) { + console.error( + "usage: node tests/node-parallel/run.mjs --node --pattern [--common minimal|node] [--out ] [--cli ] [--jobs ]", + ); + process.exit(2); +} +// Absolute, so the minimal lane's fixtures symlink resolves from the tree. +const nodeRoot = resolve(values.node); +const nodeTest = existsSync(join(nodeRoot, "test", "parallel")) ? join(nodeRoot, "test") : nodeRoot; +const pattern = new RegExp(values.pattern); +const out = resolve(values.out); +mkdirSync(out, { recursive: true }); + +// The minimal lane copies the selected files into a tree whose test/common is +// this directory's index.cjs and wpt.cjs, next to the checkout's fixtures and +// common/fixtures.js. +let testRoot = nodeTest; +const files = readdirSync(join(nodeTest, "parallel")) + .filter((name) => name.endsWith(".js") && pattern.test(name)) + .sort(); +if (values.common === "minimal") { + testRoot = join(out, "tree", "test"); + rmSync(join(out, "tree"), { recursive: true, force: true }); + mkdirSync(join(testRoot, "parallel"), { recursive: true }); + mkdirSync(join(testRoot, "common"), { recursive: true }); + for (const name of files) + cpSync(join(nodeTest, "parallel", name), join(testRoot, "parallel", name)); + symlinkSync(join(nodeTest, "fixtures"), join(testRoot, "fixtures")); + cpSync(join(nodeTest, "common", "fixtures.js"), join(testRoot, "common", "fixtures.js")); + cpSync(join(here, "common", "index.cjs"), join(testRoot, "common", "index.js")); + cpSync(join(here, "common", "wpt.cjs"), join(testRoot, "common", "wpt.js")); +} + +function run(cmd, args, opts) { + return new Promise((done) => { + const child = spawn(cmd, args, { ...opts, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => (stdout += d)); + child.stderr.on("data", (d) => (stderr += d)); + const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeout); + child.on("close", (code, signal) => { + clearTimeout(timer); + done({ code, signal, stdout, stderr }); + }); + }); +} + +const firstCause = (text) => + ( + text.split("\n").find((line) => /error|Error|assert|SC\d{4}/.test(line)) ?? + text.split("\n")[0] ?? + "" + ) + .replaceAll(testRoot, "test") + .trim() + .slice(0, 300); + +async function one(file) { + const dir = join(testRoot, "parallel"); + const source = readFileSync(join(dir, file), "utf8"); + const flags = (/^\/\/ Flags: (.*)$/m.exec(source)?.[1] ?? "").split(/\s+/).filter(Boolean); + const node = await run(process.execPath, [...flags, file], { cwd: dir, timeout: 120_000 }); + const exe = join(out, "bin", `${file}.exe`); + mkdirSync(join(out, "bin"), { recursive: true }); + const build = await run(process.execPath, [values.cli, "build", file, "-o", exe], { + cwd: dir, + timeout: 900_000, + }); + const result = { file, flags: flags.join(" "), nodeExit: termination(node) }; + if (build.code !== 0) { + result.verdict = "refused"; + result.cause = firstCause(build.stdout + build.stderr); + return result; + } + const native = await run(exe, [], { cwd: dir, timeout: 120_000 }); + result.exit = termination(native); + result.verdict = sameTermination(node, native) ? "pass" : "fail"; + if (result.verdict !== "pass") result.cause = firstCause(native.stderr + native.stdout); + return result; +} + +const results = []; +const queue = [...files]; +await Promise.all( + Array.from({ length: Number(values.jobs) }, async () => { + for (let file = queue.shift(); file !== undefined; file = queue.shift()) { + const result = await one(file); + results.push(result); + console.log(`${result.verdict.padEnd(8)} ${file}${result.cause ? ` ${result.cause}` : ""}`); + } + }), +); +results.sort((a, b) => (a.file < b.file ? -1 : 1)); +const count = (verdict) => results.filter((r) => r.verdict === verdict).length; +const table = [ + "| file | verdict | first cause |", + "| --- | --- | --- |", + ...results.map((r) => `| ${r.file} | ${r.verdict} | ${(r.cause ?? "").replaceAll("|", "\\|")} |`), + "", + `pass ${count("pass")}, fail ${count("fail")}, refused ${count("refused")} of ${results.length} (test/common: ${values.common})`, +]; +writeFileSync(join(out, "results.json"), `${JSON.stringify(results, null, 2)}\n`); +writeFileSync(join(out, "results.md"), `${table.join("\n")}\n`); +console.log(table.at(-1)); diff --git a/tests/node-parallel/verdict.mjs b/tests/node-parallel/verdict.mjs new file mode 100644 index 000000000..392238f44 --- /dev/null +++ b/tests/node-parallel/verdict.mjs @@ -0,0 +1,13 @@ +// How two runs of one test file compare (run.mjs). + +/** Whether Node and the executable ended the same way: the same exit code, + * or the same terminating signal (an exit code is null when a signal ended + * the process). */ +export function sameTermination(node, native) { + return node.code === native.code && node.signal === native.signal; +} + +/** An exit code, or the signal that ended the process. */ +export function termination(result) { + return result.code ?? result.signal; +} diff --git a/tests/test262/README.md b/tests/test262/README.md index 2f8a0ed3e..231e074e6 100644 --- a/tests/test262/README.md +++ b/tests/test262/README.md @@ -32,7 +32,7 @@ The current profile is `static-adapted-v6`. It adapts strict scripts to standalo The unmodified upstream assertion helper currently reaches scriptc's JavaScript function-expando refusal. Test262 explicitly permits host implementations of harness functions in its [interpretation rules](https://github.com/tc39/test262/blob/7ab7fafa0003f73fc85c1b95d88094d33f7eb8bd/INTERPRETING.md#host-defined-functions). `harness.ts` supplies `assert`, `assert.sameValue`, `assert.notSameValue`, `assert.compareArray`, `assert.throws` for selected Error constructors, `$DONE`, and `Test262Error`. Scalar SameValue and scalar array-element comparisons use the statically compiled `node:assert/strict` implementation. Contract tests compare successful and failing assertions with the original Test262 helpers under Node, including NaN, signed zero, null/undefined, differing primitive types, and premature termination. -Reference-equality assertions terminate with a distinct harness-refusal result: conversion at a native function boundary can copy references, so this adapter cannot faithfully compare every object, array, or function identity. Tests cannot catch this refusal and accidentally pass. Sparse array comparisons are not yet handled faithfully across that boundary. The adapted `propertyHelper.js` admits direct `verifyProperty` calls with an inline descriptor literal or `undefined`; reference-valued comparisons still refuse, while other property helpers and descriptor expressions remain excluded. The adapted `testTypedArray.js` keeps the upstream constructor lists, argument factories and `testWith*` helpers. It omits the `TypedArray` (%TypedArray%) binding, so tests that name it are excluded. Its resizable and immutable buffer factories run only where `ArrayBuffer.prototype` provides `resize` and `transferToImmutable`, as the upstream feature checks intend. Contract tests compare its successful and failing uses with the upstream helper under Node. Assertion aliases, mutation, reflection, additional assertion methods, and other `includes` helpers remain excluded. +Conversion at a native function boundary can copy references, so one object, array, or function may reach the adapter as two distinct references. A copy cannot merge two references into one. `assert.sameValue`, `assert.notSameValue`, and `assert.compareArray` elements therefore treat identical references as the same value and a reference as different from every primitive. Two distinct references terminate with a distinct harness-refusal result, which tests cannot catch and turn into a pass. Assertion messages may be any value; the adapter converts them with `String` when an assertion fails, as the upstream `assert`, `assert.sameValue`, `assert.notSameValue` and `assert.compareArray` do. Sparse array comparisons are not yet handled faithfully across that boundary. The adapted `propertyHelper.js` admits direct `verifyProperty` calls with an inline descriptor literal or `undefined`; reference-valued comparisons follow the same identity rule, while other property helpers and descriptor expressions remain excluded. The adapted `testTypedArray.js` keeps the upstream constructor lists, argument factories and `testWith*` helpers. It omits the `TypedArray` (%TypedArray%) binding, so tests that name it are excluded. Its resizable and immutable buffer factories run only where `ArrayBuffer.prototype` provides `resize` and `transferToImmutable`, as the upstream feature checks intend. Contract tests compare its successful and failing uses with the upstream helper under Node. Assertion aliases, mutation, reflection, additional assertion methods, and other `includes` helpers remain excluded. Raw tests, module variants, agents, resolution negatives, and runtime negatives remain excluded. Parse negatives pass only when a source-matched syntax or vetted JavaScript early-error diagnostic appears; TypeScript type errors and harness-only failures never satisfy a negative expectation. `$262`, dynamic evaluation, observable script-global state, and other unsupported host requirements are excluded as well. The runtime and compiler continue to enforce their existing refusal boundaries for admitted source. diff --git a/tests/test262/harness.ts b/tests/test262/harness.ts index c44d32869..dce1be99c 100644 --- a/tests/test262/harness.ts +++ b/tests/test262/harness.ts @@ -17,68 +17,81 @@ export function $DONE(error?: unknown): void { console.log("__scriptc_test262_complete__"); } -function scalar(value: unknown): void { - // Native-to-unknown conversion can copy references (notably arrays). - // Refuse these assertions before comparing altered identities. Terminate +function reference(value: unknown): boolean { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +function refuse(): never { + // Native-to-unknown conversion can copy references (notably arrays), so + // two distinct references may be one value in the program. Terminate // instead of throwing so a test's catch block cannot turn this into a pass. - if ((typeof value === "object" && value !== null) || typeof value === "function") { - console.error("SCRIPTC_TEST262_HARNESS: reference equality requires an identity-preserving adapter"); - process.exit(86); + console.error("SCRIPTC_TEST262_HARNESS: reference equality requires an identity-preserving adapter"); + process.exit(86); +} + +function scalar(value: unknown): void { + if (reference(value)) refuse(); +} + +/** The upstream helpers accept any message value and convert it to a string. */ +function text(message: unknown, fallback: string): string { + return message === undefined ? fallback : String(message); +} + +/** SameValue of two operands. A copy made at the conversion boundary can only + * separate one reference into two, so identical references are the same value + * and a reference is never the same value as a primitive. Two distinct + * references refuse. */ +function same(actual: unknown, expected: unknown): boolean { + if (reference(actual) || reference(expected)) { + if (actual === expected) return true; + if (reference(actual) && reference(expected)) refuse(); + return false; + } + try { + nodeAssert.strictEqual(actual, expected); + return true; + } catch { + return false; } } -export function assert(value: unknown, message = "Expected true"): void { - if (value !== true) throw new Test262Error(message); +export function assert(value: unknown, message?: unknown): void { + if (value !== true) throw new Test262Error(text(message, "Expected true")); } export namespace assert { - export function sameValue(actual: unknown, expected: unknown, message = "Expected SameValue"): void { - scalar(actual); - scalar(expected); - try { - nodeAssert.strictEqual(actual, expected); - } catch { - throw new Test262Error(message); - } + export function sameValue(actual: unknown, expected: unknown, message?: unknown): void { + if (!same(actual, expected)) throw new Test262Error(text(message, "Expected SameValue")); } - export function notSameValue(actual: unknown, expected: unknown, message = "Expected different values"): void { - scalar(actual); - scalar(expected); - try { - nodeAssert.notStrictEqual(actual, expected); - } catch { - throw new Test262Error(message); - } + export function notSameValue(actual: unknown, expected: unknown, message?: unknown): void { + if (same(actual, expected)) throw new Test262Error(text(message, "Expected different values")); } - export function throws(expectedErrorConstructor: unknown, func: () => unknown, message = "Expected exception"): void { + export function throws(expectedErrorConstructor: unknown, func: () => unknown, message?: unknown): void { const outcome = throwsOutcome(expectedErrorConstructor, func); if (outcome === "invalid") throw new Test262Error("assert.throws requires a function to run"); if (outcome === "unsupported") { console.error("SCRIPTC_TEST262_HARNESS: assert.throws needs an Error instance"); process.exit(86); } - if (outcome !== "pass") throw new Test262Error(message); + if (outcome !== "pass") throw new Test262Error(text(message, "Expected exception")); } - export function compareArray(actual: unknown, expected: unknown, message = "Expected matching array contents"): void { + /** Converts `message` only when the contents differ, as the upstream helper does. */ + export function compareArray(actual: unknown, expected: unknown, message?: unknown): void { if (!Array.isArray(actual) || !Array.isArray(expected)) { scalar(actual); scalar(expected); - throw new Test262Error(message); + throw new Test262Error(text(message, "Expected matching array contents")); } - if (actual.length !== expected.length) throw new Test262Error(message); - for (let i = 0; i < actual.length; i++) { + let matches = actual.length === expected.length; + for (let i = 0; matches && i < actual.length; i++) { const left: unknown = i in actual ? actual[i] : undefined; const right: unknown = i in expected ? expected[i] : undefined; - scalar(left); - scalar(right); - try { - nodeAssert.strictEqual(left, right); - } catch { - throw new Test262Error(message); - } + matches = same(left, right); } + if (!matches) throw new Test262Error(text(message, "Expected matching array contents")); } }