From a8efd2fbad646f8613c16f91d46ced7484430508 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 12 Sep 2026 15:36:19 -0500 Subject: [PATCH 1/2] Fix Sandbox command transport and pinned native setup Build the Linux LLVM helper and runtime pack before running both test lanes, and align native cache contracts with the current target policy. Full managed Sandbox gate passed. --- Dockerfile.sandbox | 18 +++++-- packages/cli/test/bootstrap.test.ts | 14 +++++- packages/cli/test/executable-cache.test.ts | 12 +++-- packages/cli/test/native-output.test.ts | 8 +-- .../src/backend/native-codegen.test.ts | 1 + .../src/backend/native-toolchain.test.ts | 6 ++- scripts/sandbox-bootstrap.sh | 21 +++++--- scripts/sandbox-command.mjs | 22 ++++++++ scripts/sandbox-test.mjs | 34 +++++++++---- tests/harness/sandbox-command.test.ts | 50 +++++++++++++++++++ tests/harness/sandbox-config.test.ts | 9 ++-- 11 files changed, 161 insertions(+), 34 deletions(-) create mode 100644 scripts/sandbox-command.mjs create mode 100644 tests/harness/sandbox-command.test.ts diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index 40100e113..63745404d 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -7,18 +7,30 @@ ARG PNPM_VERSION=11.1.3 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates curl gnupg \ + && curl --fail --silent --show-error --location https://apt.llvm.org/llvm-snapshot.gpg.key \ + | gpg --dearmor --output /usr/share/keyrings/llvm-snapshot.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/llvm-snapshot.gpg] https://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" \ + > /etc/apt/sources.list.d/llvm22.list \ + && apt-get update \ && apt-get install --yes --no-install-recommends \ build-essential \ ca-certificates \ ccache \ - clang \ + clang-22 \ cmake \ curl \ git \ - libclang-rt-dev \ - llvm \ + libclang-rt-22-dev \ + libzstd-dev \ + llvm-22-dev \ + ninja-build \ xz-utils \ zlib1g-dev \ + && ln -sf clang-22 /usr/bin/clang \ + && ln -sf clang++-22 /usr/bin/clang++ \ + && test "$(llvm-config-22 --version)" = 22.1.8 \ && rm -rf /var/lib/apt/lists/* RUN curl --fail --silent --show-error --location \ diff --git a/packages/cli/test/bootstrap.test.ts b/packages/cli/test/bootstrap.test.ts index 420f4761c..e30c02b64 100644 --- a/packages/cli/test/bootstrap.test.ts +++ b/packages/cli/test/bootstrap.test.ts @@ -4,12 +4,16 @@ import { release as osRelease, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { expect, test } from "vitest"; +import { precompiledRuntimePackTarget } from "../../compiler/src/startup-cache.js"; +import { platformLinkerSupportsPersistentCache } from "../../compiler/src/backend/linker.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../../.."); const bootstrap = join(repoRoot, "packages/cli/dist/bootstrap.js"); const runtimePackHost = process.platform === "darwin" && process.arch === "arm64" && Number.parseInt(osRelease().split(".", 1)[0] ?? "", 10) >= 24; +const helperTarget = precompiledRuntimePackTarget(); +const persistentExecutable = helperTarget === null || platformLinkerSupportsPersistentCache(process.env, helperTarget); test("bootstrap serves version and help without loading the compiler graph", async () => { const preloadDir = await mkdtemp(join(tmpdir(), "scriptc-bootstrap-preload-")); @@ -83,7 +87,15 @@ test("bootstrap exact builds use the routed cache and source edits fall through" rm(join(cacheRoot, "early-exe-implementation"), { recursive: true, force: true }), ]); expect((await build()).stderr).not.toContain("scriptc lowering"); - await expect(build(true)).resolves.toMatchObject({ stderr: "" }); + if (persistentExecutable) { + await expect(build(true)).resolves.toMatchObject({ stderr: "" }); + } else { + // This target intentionally lacks a persistent native dependency proof. + // It must enter the full compiler to relink, even on a frontend hit. + await expect(build(true)).rejects.toMatchObject({ stderr: expect.stringContaining("compiler graph loaded") }); + expect((await build()).stderr).not.toContain("scriptc lowering"); + expect((await execFileAsync(outPath)).stdout).toBe("one\n"); + } await writeFile(entry, 'console.log("two");\n'); expect((await build()).stderr).toContain("scriptc lowering"); diff --git a/packages/cli/test/executable-cache.test.ts b/packages/cli/test/executable-cache.test.ts index be3113d8a..0dd75f3fb 100644 --- a/packages/cli/test/executable-cache.test.ts +++ b/packages/cli/test/executable-cache.test.ts @@ -5,6 +5,8 @@ import { release as osRelease, tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { promisify } from "node:util"; import { expect, test } from "vitest"; +import { precompiledRuntimePackTarget } from "../../compiler/src/startup-cache.js"; +import { platformLinkerSupportsPersistentCache } from "../../compiler/src/backend/linker.js"; const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); @@ -13,6 +15,8 @@ const cliEntry = join(repoRoot, "packages/cli/src/main.ts"); const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs"); const runtimePackHost = process.platform === "darwin" && process.arch === "arm64" && Number.parseInt(osRelease().split(".", 1)[0] ?? "", 10) >= 24; +const helperTarget = precompiledRuntimePackTarget(); +const persistentExecutable = helperTarget === null || platformLinkerSupportsPersistentCache(process.env, helperTarget); test("exact executable repeats skip lowering while edits and damaged outputs stay correct", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-cli-executable-cache-")); @@ -50,10 +54,12 @@ test("exact executable repeats skip lowering while edits and damaged outputs sta const originalBinaryTime = (await stat(outPath)).mtimeMs; const originalTuTime = (await stat(tuPath)).mtimeMs; - // The exact repeat returns from the whole-program cache before tsgo and - // leaves already-correct caller outputs on their existing inodes. + // Every route reuses the frontend. Only routes with a complete native + // dependency proof can also retain the executable inode; other targets + // deliberately relink their cached translation unit. expect((await build()).stderr).not.toContain("scriptc lowering"); - expect((await stat(outPath)).mtimeMs).toBe(originalBinaryTime); + if (persistentExecutable) expect((await stat(outPath)).mtimeMs).toBe(originalBinaryTime); + expect(await run()).toBe("one\n"); expect((await stat(tuPath)).mtimeMs).toBe(originalTuTime); // Output-kind cleanup is honest even on an exact early hit: an IR file diff --git a/packages/cli/test/native-output.test.ts b/packages/cli/test/native-output.test.ts index e4c3c1fce..943d09f1e 100644 --- a/packages/cli/test/native-output.test.ts +++ b/packages/cli/test/native-output.test.ts @@ -35,11 +35,13 @@ function cli(args: string[], env: NodeJS.ProcessEnv = process.env) { }); } -test("unsupported hosts fail with SC3002 before creating an artifact", async () => { - if (supported) return; +test("unsupported targets fail with SC3002 before creating an artifact", async () => { const { dir, entry } = await fixture(); const output = join(dir, "hello.o"); - await expect(cli(["build", entry, "--emit=obj", "-o", output])).rejects.toMatchObject({ + await expect(cli(["build", entry, "--emit=obj", "-o", output], { + ...process.env, + SCRIPTC_TARGET: "scriptc-unsupported-target", + })).rejects.toMatchObject({ code: 1, stderr: expect.stringContaining("SC3002"), }); diff --git a/packages/compiler/src/backend/native-codegen.test.ts b/packages/compiler/src/backend/native-codegen.test.ts index 5c092d0c3..4c842fa92 100644 --- a/packages/compiler/src/backend/native-codegen.test.ts +++ b/packages/compiler/src/backend/native-codegen.test.ts @@ -71,6 +71,7 @@ function request(root: string, packageJson: string, output = join(root, "program outputKind: "obj" as const, sourcePath: "/source/app.ts", target, + helperHost: { platform: "darwin" as const, arch: "arm64" }, resolvePackageJson: () => packageJson, cacheRoot: join(root, "cache"), }; diff --git a/packages/compiler/src/backend/native-toolchain.test.ts b/packages/compiler/src/backend/native-toolchain.test.ts index 2c57839ac..ce68d5f35 100644 --- a/packages/compiler/src/backend/native-toolchain.test.ts +++ b/packages/compiler/src/backend/native-toolchain.test.ts @@ -4178,7 +4178,11 @@ test.skipIf( probeSource, "long long public_value(void);\nint main(void) { return public_value() != 7; }\n", ); - execFileSync(clangExecutable!, [probeSource, outPath, "-lm", "-o", probe]); + // The failed linker belongs only to the shard-merge probe. Link the + // resulting archive with the real host tools to verify its fallback. + execFileSync(clangExecutable!, [probeSource, outPath, "-lm", "-o", probe], { + env: { ...process.env, PATH: oldPath }, + }); expect(execFileSync(probe, { encoding: "utf8" })).toBe(""); // The canonical retry is valid for this invocation, but must not occupy a // key describing merged shard bytes. A repaired merge tool retries the diff --git a/scripts/sandbox-bootstrap.sh b/scripts/sandbox-bootstrap.sh index 07aa5e5bc..fe8186ac5 100644 --- a/scripts/sandbox-bootstrap.sh +++ b/scripts/sandbox-bootstrap.sh @@ -9,30 +9,35 @@ if [ -z "$node_version" ] || [ -z "$pnpm_version" ]; then fi # The managed image intentionally floats its distro and language runtimes. -# Install the same LLVM major and exact Node/pnpm tuple as the custom image so +# Install the same LLVM and exact Node/pnpm tuple as the custom image so # differential oracles do not change when the managed image is refreshed. +. /etc/os-release +llvm_distro=${VERSION_CODENAME:?the Sandbox image must identify its Ubuntu release} curl --fail --silent --show-error --location \ https://apt.llvm.org/llvm-snapshot.gpg.key \ --output /tmp/llvm-snapshot.gpg.key gpg --dearmor --yes --output /tmp/llvm-snapshot.gpg /tmp/llvm-snapshot.gpg.key sudo install -D -m 0644 /tmp/llvm-snapshot.gpg /etc/apt/keyrings/llvm-snapshot.gpg -echo "deb [signed-by=/etc/apt/keyrings/llvm-snapshot.gpg] https://apt.llvm.org/noble/ llvm-toolchain-noble-18 main" \ - | sudo tee /etc/apt/sources.list.d/llvm18.list >/dev/null +echo "deb [signed-by=/etc/apt/keyrings/llvm-snapshot.gpg] https://apt.llvm.org/${llvm_distro}/ llvm-toolchain-${llvm_distro}-22 main" \ + | sudo tee /etc/apt/sources.list.d/llvm22.list >/dev/null sudo apt-get update --quiet=2 sudo env DEBIAN_FRONTEND=noninteractive apt-get install --quiet=2 --yes --no-install-recommends \ build-essential \ ca-certificates \ ccache \ - clang-18 \ + clang-22 \ cmake \ - libclang-rt-18-dev \ - llvm-18 \ + libclang-rt-22-dev \ + libzstd-dev \ + llvm-22-dev \ + ninja-build \ xz-utils \ zlib1g-dev sudo rm -f /usr/local/bin/clang /usr/local/bin/clang++ -sudo ln -sf clang-18 /usr/bin/clang -sudo ln -sf clang++-18 /usr/bin/clang++ +sudo ln -sf clang-22 /usr/bin/clang +sudo ln -sf clang++-22 /usr/bin/clang++ +test "$(llvm-config-22 --version)" = 22.1.8 node_archive="node-v${node_version}-linux-x64.tar.xz" curl --fail --silent --show-error --location \ diff --git a/scripts/sandbox-command.mjs b/scripts/sandbox-command.mjs new file mode 100644 index 000000000..9d00e592f --- /dev/null +++ b/scripts/sandbox-command.mjs @@ -0,0 +1,22 @@ +export const MAX_INLINE_SANDBOX_COMMAND_BYTES = 768; + +export const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; + +/** Keep long shell programs out of the local CLI's argument vector. The + * same script records the remote exit status for either transport. */ +export function sandboxCommand(command, args, exitMarker) { + const statusPath = `/tmp/${exitMarker}.status`; + const script = + `${[command, ...args].map(shellQuote).join(" ")}; scriptc_status=$?; ` + + `printf '%s\\n' "$scriptc_status" > ${shellQuote(statusPath)}; ` + + `printf '\\n${exitMarker}%s\\n' "$scriptc_status"`; + const scriptPath = `/tmp/${exitMarker}.sh`; + const file = Buffer.byteLength(script, "utf8") > MAX_INLINE_SANDBOX_COMMAND_BYTES; + return { + script, + scriptPath, + statusPath, + file, + argv: file ? ["sh", scriptPath] : ["sh", "-c", script], + }; +} diff --git a/scripts/sandbox-test.mjs b/scripts/sandbox-test.mjs index 5b79432a2..d7b624265 100644 --- a/scripts/sandbox-test.mjs +++ b/scripts/sandbox-test.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pipeline } from "node:stream/promises"; @@ -23,6 +23,7 @@ import { filterExistingWorktreePaths, workspaceResetCommand, } from "./worktree-files.mjs"; +import { sandboxCommand, shellQuote } from "./sandbox-command.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); const laneCaseShardedFiles = [ @@ -371,7 +372,6 @@ const vercel = ([group, command, ...args], options) => }); // `vercel sandbox exec` does not propagate the remote process's exit code. // Print a per-command nonce after it finishes and enforce that status here. -const shellQuote = (value) => `'${value.replaceAll("'", `'\"'\"'`)}'`; const execIn = async ( worker, command, @@ -384,12 +384,22 @@ const execIn = async ( ) => { const envArgs = Object.entries(env).flatMap(([key, value]) => ["--env", `${key}=${value}`]); const exitMarker = `__SCRIPTC_REMOTE_EXIT_${randomBytes(12).toString("hex")}__`; - const statusPath = `/tmp/${exitMarker}.status`; - const script = - `${[command, ...args].map(shellQuote).join(" ")}; scriptc_status=$?; ` + - `printf '%s\\n' "$scriptc_status" > ${shellQuote(statusPath)}; ` + - `printf '\\n${exitMarker}%s\\n' "$scriptc_status"`; + const prepared = sandboxCommand(command, args, exitMarker); + const { statusPath } = prepared; const label = task ? `${worker.label} ${task}` : worker.label; + if (prepared.file) { + const localScript = join(temp, `${exitMarker}.sh`); + await writeFile(localScript, prepared.script, { mode: 0o600 }); + try { + await vercel(["sandbox", "copy", localScript, `${worker.name}:${prepared.scriptPath}`], { + idleTimeoutMs: 60_000, + label: `${label} command`, + timeoutMs: 2 * 60_000, + }); + } finally { + await rm(localScript, { force: true }); + } + } const commandArgs = [ "sandbox", "exec", @@ -399,9 +409,7 @@ const execIn = async ( workdir, ...envArgs, worker.name, - "sh", - "-c", - script, + ...prepared.argv, ]; try { await vercel(commandArgs, { @@ -411,7 +419,7 @@ const execIn = async ( timeoutMs: wallTimeoutMs, }); } catch (error) { - console.warn(`[${label}] CLI completion was not confirmed; checking the remote command status...`); + console.warn(`[${label}] CLI completion was not confirmed (${error.message}); checking the remote command status...`); const probeMarker = `__SCRIPTC_REMOTE_PROBE_${randomBytes(12).toString("hex")}__`; const probeScript = `scriptc_status=125; test ! -f ${shellQuote(statusPath)} || ` + @@ -714,6 +722,10 @@ try { } await execIn(worker, "pnpm", ["install", "--frozen-lockfile"], {}, "", 2 * 60_000); await execIn(worker, "pnpm", ["build"], {}, "", 2 * 60_000); + // Workspace builds deliberately do not rebuild packaged native artifacts. + // Every remote lane needs the Linux helper and runtime from this worktree. + await execIn(worker, "pnpm", ["--filter", "@scriptc/llvm-linux-x64-gnu", "build:native"], {}, "LLVM helper", 5 * 60_000); + await execIn(worker, "pnpm", ["--filter", "@scriptc/runtime-linux-x64-gnu", "build:native"], { CC: "clang-22", AR: "llvm-ar-22" }, "runtime pack", 5 * 60_000); }, imageConfig.custom ? workers.length : 8); await allWorkers("Testing", async (worker) => { diff --git a/tests/harness/sandbox-command.test.ts b/tests/harness/sandbox-command.test.ts new file mode 100644 index 000000000..6003fe09e --- /dev/null +++ b/tests/harness/sandbox-command.test.ts @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { sandboxCommand } from "../../scripts/sandbox-command.mjs"; + +const roots: string[] = []; +const statuses: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + await Promise.all(statuses.splice(0).map((path) => rm(path, { force: true }))); +}); + +test("short commands stay inline and retain the remote exit contract", async () => { + const marker = `__SCRIPTC_COMMAND_TEST_${process.pid}_SHORT__`; + const prepared = sandboxCommand("sh", ["-c", "exit 7"], marker); + statuses.push(prepared.statusPath); + + expect(prepared.file).toBe(false); + const output = execFileSync(prepared.argv[0], prepared.argv.slice(1), { encoding: "utf8" }); + expect(output).toBe(`\n${marker}7\n`); + expect(await readFile(prepared.statusPath, "utf8")).toBe("7\n"); +}); + +test("uploaded scripts preserve long and shell-sensitive argument bytes", async () => { + const root = await mkdtemp("/tmp/scriptc-sandbox-command-test-"); + roots.push(root); + const sentinel = join(root, "unexpected"); + const args = [ + "x".repeat(2048), + "quote' and spaces", + `$(touch ${sentinel})`, + "backtick` dollar$ slash\\ newline\nend", + "é".repeat(512), + ]; + const marker = `__SCRIPTC_COMMAND_TEST_${process.pid}_LONG__`; + const prepared = sandboxCommand("printf", ["<%s>\n", ...args], marker); + statuses.push(prepared.statusPath); + expect(prepared.file).toBe(true); + expect(prepared.argv).toEqual(["sh", prepared.scriptPath]); + expect(prepared.argv.every((arg) => arg.length < 128)).toBe(true); + + const localScript = join(root, "command.sh"); + await writeFile(localScript, prepared.script); + const output = execFileSync("sh", [localScript], { encoding: "utf8" }); + expect(output).toBe(args.map((arg) => `<${arg}>\n`).join("") + `\n${marker}0\n`); + await expect(readFile(sentinel)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(prepared.statusPath, "utf8")).toBe("0\n"); +}); diff --git a/tests/harness/sandbox-config.test.ts b/tests/harness/sandbox-config.test.ts index e26ea4b00..71f0b4a7c 100644 --- a/tests/harness/sandbox-config.test.ts +++ b/tests/harness/sandbox-config.test.ts @@ -66,11 +66,12 @@ test("the managed-image bootstrap pins the custom image's Node, pnpm, and LLVM", const bootstrap = readFileSync(new URL("../../scripts/sandbox-bootstrap.sh", import.meta.url), "utf8"); expect(bootstrap).toContain("< .node-version"); expect(bootstrap).toContain("ARG PNPM_VERSION="); - expect(bootstrap).toContain("llvm-toolchain-noble-18"); + expect(bootstrap).toContain("llvm-toolchain-${llvm_distro}-22"); expect(bootstrap).toContain("install -D -m 0644"); - expect(bootstrap).toContain("clang-18"); - expect(bootstrap).toContain("libclang-rt-18-dev"); - expect(bootstrap).toContain("llvm-18"); + expect(bootstrap).toContain("clang-22"); + expect(bootstrap).toContain("libclang-rt-22-dev"); + expect(bootstrap).toContain("llvm-22-dev"); + expect(bootstrap).toContain("ninja-build"); }); test("a custom image does not select the Sandbox team or project", () => { From 39982af431876f68fffad545ab214c8bba5918f4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 12 Sep 2026 15:42:30 -0500 Subject: [PATCH 2/2] Replace quadratic array sorting with stable merge sort Cover sort snapshots, consistent-comparator results, stable ties, and comparator complexity with Node differential fixtures. Document differences from V8 TimSort. --- docs/src/app/limitations/page.mdx | 2 +- .../src/frontend/lowering/lower-array-sort.ts | 587 ++++++++++++++++++ .../src/frontend/lowering/lower-containers.ts | 407 +----------- .../test/ts7/baselines/order-parity.json | 18 + tests/corpus/2700-array-sort-complexity.ts | 44 ++ tests/corpus/2701-array-sort-snapshot.ts | 46 ++ tests/corpus/2702-uint8array-to-sorted.ts | 16 + 7 files changed, 728 insertions(+), 392 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/lower-array-sort.ts create mode 100644 tests/corpus/2700-array-sort-complexity.ts create mode 100644 tests/corpus/2701-array-sort-snapshot.ts create mode 100644 tests/corpus/2702-uint8array-to-sorted.ts diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx index 8307f8b3d..111fbf922 100644 --- a/docs/src/app/limitations/page.mdx +++ b/docs/src/app/limitations/page.mdx @@ -75,7 +75,7 @@ const who = process.argv.length > 2 ? process.argv[2] : "world"; **Process shape** — `process.argv[0]` is `"scriptc"` and `argv[1]` is the binary's path (positions line up with Node; `argv[2]` onward are your args). The uncaught-exception stderr line reads `Uncaught ` instead of Node's stack-trace block (exit code and pre-throw stdout are identical). Runtime errors carry `message` and Node's `code`, but not `errno`/`syscall`/`path`. -**Comparator call sequences differ in `sort` and `toSorted`** (stable insertion sort here, TimSort in V8 — sorted results are byte-identical for consistent comparators), and **`localeCompare` compares code units**, not ICU collation. +**Comparator call sequences differ in `sort` and `toSorted`** (scriptc uses a stable bottom-up merge sort, while V8 uses TimSort). Sorted results are byte-identical for consistent comparators. Ordered inputs use a linear number of comparator calls, but merge-buffer movement remains O(n log n) even when every boundary is already ordered. **`localeCompare` compares code units**, not ICU collation. ## Dynamic-tier limits diff --git a/packages/compiler/src/frontend/lowering/lower-array-sort.ts b/packages/compiler/src/frontend/lowering/lower-array-sort.ts new file mode 100644 index 000000000..37bea5022 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/lower-array-sort.ts @@ -0,0 +1,587 @@ +import { + BOOL, + BYTES_U8, + F64, + IrExpr, + IrFunction, + IrLocal, + IrParam, + IrStmt, + IrType, + JSVAL, + SrcLoc, + arrayOf, + funcOf, +} from "../../ir/ir.js"; +import { numLit, varRef } from "../../ir/build.js"; + +function readArrayLength(arrT: IrType, loc: SrcLoc): IrStmt { + return { + kind: "varDecl", + localId: "n.0", + init: { + kind: "arrIntrinsic", + method: "length", + receiver: { kind: "varRef", localId: "a.0", type: arrT, loc }, + args: [], + type: F64, + loc, + }, + loc, + }; +} + +/** A stable bottom-up merge sort built from existing IR nodes. + * + * The first implementation used insertion sort because it was compact and + * made the stable tie rule obvious. Its worst case was quadratic, though, + * which made an ordinary descending input unusable. This implementation + * snapshots the receiver, merges runs between two buffers, and copies the + * final snapshot back into the receiver. The boundary check skips a merge + * when adjacent runs are already ordered, so naturally ordered inputs use + * only a linear number of comparator calls; the buffer copies still do + * O(n log n) data movement. Arbitrary inputs have an O(n log n) bound. + * + * The snapshot is also important for sort mutation behavior: comparator + * calls see the values captured before sorting starts, while the final copy + * preserves Array.sort receiver identity. toSorted starts by copying the + * receiver and therefore leaves that receiver untouched. The merge schedule + * intentionally differs from V8 TimSort, so exact comparator call order and + * count parity is not claimed; stable results, comparator exceptions, and + * mutations of referenced values still follow the normal IR call/ownership + * rules. */ +export function buildArraySortFn( + name: string, + elem: IrType, + arity: number, + copyFirst: boolean, + undefinedTag: number | null, + loc: SrcLoc, +): IrFunction { + const arrT = arrayOf(elem); + const fnT = funcOf([elem, elem].slice(0, arity), F64); + + const a = varRef("a.0", arrT, loc); + const src = varRef("src.0", arrT, loc); + const dst = varRef("dst.0", arrT, loc); + const n = varRef("n.0", F64, loc); + const width = varRef("width.0", F64, loc); + const start = varRef("start.0", F64, loc); + const mid = varRef("mid.0", F64, loc); + const right = varRef("right.0", F64, loc); + const left = varRef("left.0", F64, loc); + const r = varRef("r.0", F64, loc); + const k = varRef("k.0", F64, loc); + const add = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "+", left, right, type: F64, loc }); + const sub = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "-", left, right, type: F64, loc }); + const mul = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "*", left, right, type: F64, loc }); + const lt = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "<", left, right, type: BOOL, loc }); + const not = (value: IrExpr): IrExpr => ({ kind: "unary", op: "!", operand: value, type: BOOL, loc }); + const at = (index: IrExpr): IrExpr => ({ kind: "arrayGet", arr: src, index, type: elem, loc }); + const isUndefined = (value: IrExpr): IrExpr | null => { + if (elem.kind === "union" && undefinedTag !== null) { + return { + kind: "unionIsTag", + unionId: elem.unionId, + tag: undefinedTag, + negated: false, + value, + type: BOOL, + loc, + }; + } + if (elem.kind === "jsval") { + return { + kind: "jsOp", + op: "eq", + args: [ + value, + { kind: "jsOp", op: "undefLit", args: [], type: JSVAL, loc }, + ], + type: BOOL, + loc, + }; + } + return null; + }; + const shouldTakeRight = (leftValue: IrExpr, rightValue: IrExpr): IrExpr => { + const compareGreater: IrExpr = { + kind: "bin", + op: ">", + left: { + kind: "callValue", + callee: varRef("f.0", fnT, loc), + args: [leftValue, rightValue].slice(0, arity), + type: F64, + loc, + }, + right: numLit(0, loc), + type: BOOL, + loc, + }; + const leftUndefined = isUndefined(leftValue); + const rightUndefined = isUndefined(rightValue); + // CompareArrayElements: undefined always sinks and never reaches the + // user comparator. Ternaries preserve that callback suppression. + return leftUndefined !== null && rightUndefined !== null + ? { + kind: "ternary", + cond: leftUndefined, + then: not(rightUndefined), + else_: { + kind: "ternary", + cond: rightUndefined, + then: { kind: "boolLit", value: false, type: BOOL, loc }, + else_: compareGreater, + type: BOOL, + loc, + }, + type: BOOL, + loc, + } + : compareGreater; + }; + const copyRange: IrStmt = { + kind: "while", + cond: lt(k, right), + body: [ + { kind: "varDecl", localId: "v.0", init: at(k), loc }, + { kind: "arraySet", arr: dst, index: k, value: varRef("v.0", elem, loc), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }; + const mergeBody: IrStmt[] = [ + { + kind: "while", + cond: { + kind: "logical", + op: "&&", + left: lt(left, mid), + right: lt(r, right), + type: BOOL, + loc, + }, + body: [ + { kind: "varDecl", localId: "vL.0", init: at(left), loc }, + { kind: "varDecl", localId: "vR.0", init: at(r), loc }, + { + kind: "if", + cond: shouldTakeRight(varRef("vL.0", elem, loc), varRef("vR.0", elem, loc)), + then: [ + { kind: "arraySet", arr: dst, index: k, value: varRef("vR.0", elem, loc), loc }, + { kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc }, + ], + else_: [ + { kind: "arraySet", arr: dst, index: k, value: varRef("vL.0", elem, loc), loc }, + { kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc }, + ], + loc, + }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + { + kind: "while", + cond: lt(left, mid), + body: [ + { kind: "varDecl", localId: "v.0", init: at(left), loc }, + { kind: "arraySet", arr: dst, index: k, value: varRef("v.0", elem, loc), loc }, + { kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + { + kind: "while", + cond: lt(r, right), + body: [ + { kind: "varDecl", localId: "v.0", init: at(r), loc }, + { kind: "arraySet", arr: dst, index: k, value: varRef("v.0", elem, loc), loc }, + { kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + ]; + const boundaryLeft = varRef("boundaryL.0", elem, loc); + const boundaryRight = varRef("boundaryR.0", elem, loc); + const mergeOrCopy: IrStmt = { + kind: "if", + cond: lt(mid, right), + then: [ + { kind: "varDecl", localId: "boundaryL.0", init: at(sub(mid, numLit(1, loc))), loc }, + { kind: "varDecl", localId: "boundaryR.0", init: at(mid), loc }, + { + kind: "if", + cond: shouldTakeRight(boundaryLeft, boundaryRight), + then: mergeBody, + else_: [copyRange], + loc, + }, + ], + else_: [copyRange], + loc, + }; + const mergePass: IrStmt = { + kind: "for", + init: { kind: "varDecl", localId: "start.0", init: numLit(0, loc), loc }, + cond: lt(start, n), + update: { kind: "assign", localId: "start.0", value: add(start, add(width, width)), loc }, + body: [ + { + kind: "varDecl", + localId: "mid.0", + init: { + kind: "ternary", + cond: lt(add(start, width), n), + then: add(start, width), + else_: n, + type: F64, + loc, + }, + loc, + }, + { + kind: "varDecl", + localId: "right.0", + init: { + kind: "ternary", + cond: lt(add(add(start, width), width), n), + then: add(add(start, width), width), + else_: n, + type: F64, + loc, + }, + loc, + }, + { kind: "varDecl", localId: "left.0", init: start, loc }, + { kind: "varDecl", localId: "r.0", init: mid, loc }, + { kind: "varDecl", localId: "k.0", init: start, loc }, + mergeOrCopy, + ], + loc, + }; + const body: IrStmt[] = [ + ...(copyFirst + ? [{ + kind: "assign" as const, + localId: "a.0", + value: { + kind: "arrIntrinsic" as const, + method: "slice" as const, + receiver: varRef("a.0", arrT, loc), + args: [], + type: arrT, + loc, + }, + loc, + }] + : []), + readArrayLength(arrT, loc), + { kind: "varDecl", localId: "src.0", init: { kind: "arrIntrinsic", method: "slice", receiver: a, args: [], type: arrT, loc }, loc }, + // The destination is filled from index zero upward on every merge pass. + // An empty literal is therefore dense and works for scalar arrays too; + // arrayNewLen is reserved for the separate absent-slot semantics. + { kind: "varDecl", localId: "dst.0", init: { kind: "arrayLit", elems: [], type: arrT, loc }, loc }, + { kind: "varDecl", localId: "width.0", init: numLit(1, loc), loc }, + { + kind: "while", + cond: lt(width, n), + body: [ + mergePass, + { kind: "varDecl", localId: "tmp.0", init: src, loc }, + { kind: "assign", localId: "src.0", value: dst, loc }, + { kind: "assign", localId: "dst.0", value: varRef("tmp.0", arrT, loc), loc }, + { kind: "assign", localId: "width.0", value: mul(width, numLit(2, loc)), loc }, + ], + loc, + }, + { + kind: "for", + init: { kind: "varDecl", localId: "i.0", init: numLit(0, loc), loc }, + cond: lt(varRef("i.0", F64, loc), n), + update: { kind: "assign", localId: "i.0", value: add(varRef("i.0", F64, loc), numLit(1, loc)), loc }, + body: [ + { kind: "varDecl", localId: "v.0", init: { kind: "arrayGet", arr: src, index: varRef("i.0", F64, loc), type: elem, loc }, loc }, + { kind: "arraySet", arr: a, index: varRef("i.0", F64, loc), value: varRef("v.0", elem, loc), loc }, + ], + loc, + }, + { kind: "return", value: a, loc }, + ]; + return { + name, + params: [ + { localId: "a.0", name: "a", type: arrT }, + { localId: "f.0", name: "f", type: fnT }, + ], + returnType: arrT, + locals: [ + { id: "a.0", name: "a", type: arrT, mutable: true }, + { id: "f.0", name: "f", type: fnT, mutable: true }, + { id: "n.0", name: "n", type: F64, mutable: false }, + { id: "src.0", name: "src", type: arrT, mutable: true }, + { id: "dst.0", name: "dst", type: arrT, mutable: true }, + { id: "width.0", name: "width", type: F64, mutable: true }, + { id: "start.0", name: "start", type: F64, mutable: true }, + { id: "mid.0", name: "mid", type: F64, mutable: true }, + { id: "right.0", name: "right", type: F64, mutable: true }, + { id: "left.0", name: "left", type: F64, mutable: true }, + { id: "r.0", name: "r", type: F64, mutable: true }, + { id: "k.0", name: "k", type: F64, mutable: true }, + { id: "i.0", name: "i", type: F64, mutable: true }, + { id: "tmp.0", name: "tmp", type: arrT, mutable: true }, + { id: "v.0", name: "v", type: elem, mutable: false }, + { id: "vL.0", name: "vL", type: elem, mutable: false }, + { id: "vR.0", name: "vR", type: elem, mutable: false }, + { id: "boundaryL.0", name: "boundaryL", type: elem, mutable: false }, + { id: "boundaryR.0", name: "boundaryR", type: elem, mutable: false }, + ], + body, + loc, + }; +} + + +export function buildBytesSortFn( + name: string, + arity: number, + hasComparator: boolean, + loc: SrcLoc, +): IrFunction { + const bytesT = BYTES_U8; + const fnT = funcOf([F64, F64].slice(0, arity), F64); + const a = varRef("a.0", bytesT, loc); + const src = varRef("src.0", bytesT, loc); + const dst = varRef("dst.0", bytesT, loc); + const n = varRef("n.0", F64, loc); + const width = varRef("width.0", F64, loc); + const start = varRef("start.0", F64, loc); + const mid = varRef("mid.0", F64, loc); + const right = varRef("right.0", F64, loc); + const left = varRef("left.0", F64, loc); + const r = varRef("r.0", F64, loc); + const k = varRef("k.0", F64, loc); + const add = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "+", left, right, type: F64, loc }); + const sub = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "-", left, right, type: F64, loc }); + const mul = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "*", left, right, type: F64, loc }); + const lt = (left: IrExpr, right: IrExpr): IrExpr => ({ kind: "bin", op: "<", left, right, type: BOOL, loc }); + const at = (receiver: IrExpr, index: IrExpr): IrExpr => ({ + kind: "bytesIntrinsic", + method: "get", + receiver, + args: [index], + type: F64, + loc, + }); + const shouldTakeRight = (leftValue: IrExpr, rightValue: IrExpr): IrExpr => ({ + kind: "bin", + op: ">", + left: hasComparator + ? { + kind: "callValue", + callee: varRef("f.0", fnT, loc), + args: [leftValue, rightValue].slice(0, arity), + type: F64, + loc, + } + : { + kind: "bin", + op: "-", + left: leftValue, + right: rightValue, + type: F64, + loc, + }, + right: numLit(0, loc), + type: BOOL, + loc, + }); + const copyRange: IrStmt = { + kind: "while", + cond: lt(k, right), + body: [ + { kind: "varDecl", localId: "v.0", init: at(src, k), loc }, + { kind: "bytesSet", arr: dst, index: k, value: varRef("v.0", F64, loc), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }; + const mergeBody: IrStmt[] = [ + { + kind: "while", + cond: { + kind: "logical", + op: "&&", + left: lt(left, mid), + right: lt(r, right), + type: BOOL, + loc, + }, + body: [ + { kind: "varDecl", localId: "vL.0", init: at(src, left), loc }, + { kind: "varDecl", localId: "vR.0", init: at(src, r), loc }, + { + kind: "if", + cond: shouldTakeRight(varRef("vL.0", F64, loc), varRef("vR.0", F64, loc)), + then: [ + { kind: "bytesSet", arr: dst, index: k, value: varRef("vR.0", F64, loc), loc }, + { kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc }, + ], + else_: [ + { kind: "bytesSet", arr: dst, index: k, value: varRef("vL.0", F64, loc), loc }, + { kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc }, + ], + loc, + }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + { + kind: "while", + cond: lt(left, mid), + body: [ + { kind: "varDecl", localId: "v.0", init: at(src, left), loc }, + { kind: "bytesSet", arr: dst, index: k, value: varRef("v.0", F64, loc), loc }, + { kind: "assign", localId: "left.0", value: add(left, numLit(1, loc)), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + { + kind: "while", + cond: lt(r, right), + body: [ + { kind: "varDecl", localId: "v.0", init: at(src, r), loc }, + { kind: "bytesSet", arr: dst, index: k, value: varRef("v.0", F64, loc), loc }, + { kind: "assign", localId: "r.0", value: add(r, numLit(1, loc)), loc }, + { kind: "assign", localId: "k.0", value: add(k, numLit(1, loc)), loc }, + ], + loc, + }, + ]; + const mergeOrCopy: IrStmt = { + kind: "if", + cond: lt(mid, right), + then: [ + { kind: "varDecl", localId: "boundaryL.0", init: at(src, sub(mid, numLit(1, loc))), loc }, + { kind: "varDecl", localId: "boundaryR.0", init: at(src, mid), loc }, + { + kind: "if", + cond: shouldTakeRight(varRef("boundaryL.0", F64, loc), varRef("boundaryR.0", F64, loc)), + then: mergeBody, + else_: [copyRange], + loc, + }, + ], + else_: [copyRange], + loc, + }; + const mergePass: IrStmt = { + kind: "for", + init: { kind: "varDecl", localId: "start.0", init: numLit(0, loc), loc }, + cond: lt(start, n), + update: { kind: "assign", localId: "start.0", value: add(start, add(width, width)), loc }, + body: [ + { + kind: "varDecl", + localId: "mid.0", + init: { + kind: "ternary", + cond: lt(add(start, width), n), + then: add(start, width), + else_: n, + type: F64, + loc, + }, + loc, + }, + { + kind: "varDecl", + localId: "right.0", + init: { + kind: "ternary", + cond: lt(add(add(start, width), width), n), + then: add(add(start, width), width), + else_: n, + type: F64, + loc, + }, + loc, + }, + { kind: "varDecl", localId: "left.0", init: start, loc }, + { kind: "varDecl", localId: "r.0", init: mid, loc }, + { kind: "varDecl", localId: "k.0", init: start, loc }, + mergeOrCopy, + ], + loc, + }; + const params: IrParam[] = [ + { localId: "a.0", name: "a", type: bytesT }, + ...(hasComparator ? [{ localId: "f.0", name: "f", type: fnT }] : []), + ]; + const locals: IrLocal[] = [ + { id: "a.0", name: "a", type: bytesT, mutable: true }, + ...(hasComparator ? [{ id: "f.0", name: "f", type: fnT, mutable: true }] : []), + { id: "n.0", name: "n", type: F64, mutable: false }, + { id: "src.0", name: "src", type: bytesT, mutable: true }, + { id: "dst.0", name: "dst", type: bytesT, mutable: true }, + { id: "width.0", name: "width", type: F64, mutable: true }, + { id: "start.0", name: "start", type: F64, mutable: true }, + { id: "mid.0", name: "mid", type: F64, mutable: true }, + { id: "right.0", name: "right", type: F64, mutable: true }, + { id: "left.0", name: "left", type: F64, mutable: true }, + { id: "r.0", name: "r", type: F64, mutable: true }, + { id: "k.0", name: "k", type: F64, mutable: true }, + { id: "i.0", name: "i", type: F64, mutable: true }, + { id: "tmp.0", name: "tmp", type: bytesT, mutable: true }, + { id: "v.0", name: "v", type: F64, mutable: false }, + { id: "vL.0", name: "vL", type: F64, mutable: false }, + { id: "vR.0", name: "vR", type: F64, mutable: false }, + { id: "boundaryL.0", name: "boundaryL", type: F64, mutable: false }, + { id: "boundaryR.0", name: "boundaryR", type: F64, mutable: false }, + ]; + const slice = (receiver: IrExpr): IrExpr => ({ + kind: "bytesIntrinsic", + method: "slice", + receiver, + args: [], + type: bytesT, + loc, + }); + const body: IrStmt[] = [ + { kind: "assign", localId: "a.0", value: slice(a), loc }, + { kind: "varDecl", localId: "n.0", init: { kind: "bytesIntrinsic", method: "length", receiver: a, args: [], type: F64, loc }, loc }, + { kind: "varDecl", localId: "src.0", init: slice(a), loc }, + { kind: "varDecl", localId: "dst.0", init: slice(a), loc }, + { kind: "varDecl", localId: "width.0", init: numLit(1, loc), loc }, + { + kind: "while", + cond: lt(width, n), + body: [ + mergePass, + { kind: "varDecl", localId: "tmp.0", init: src, loc }, + { kind: "assign", localId: "src.0", value: dst, loc }, + { kind: "assign", localId: "dst.0", value: varRef("tmp.0", bytesT, loc), loc }, + { kind: "assign", localId: "width.0", value: mul(width, numLit(2, loc)), loc }, + ], + loc, + }, + { + kind: "for", + init: { kind: "varDecl", localId: "i.0", init: numLit(0, loc), loc }, + cond: lt(varRef("i.0", F64, loc), n), + update: { kind: "assign", localId: "i.0", value: add(varRef("i.0", F64, loc), numLit(1, loc)), loc }, + body: [ + { kind: "varDecl", localId: "v.0", init: at(src, varRef("i.0", F64, loc)), loc }, + { kind: "bytesSet", arr: a, index: varRef("i.0", F64, loc), value: varRef("v.0", F64, loc), loc }, + ], + loc, + }, + { kind: "return", value: a, loc }, + ]; + return { name, params, returnType: bytesT, locals, body, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index f3f57c7eb..fa57e8e6c 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -11,6 +11,7 @@ import { droppableStatic, isRequireMainFilename, lowerDynObjectLiteral, probeLow import { forOfVarTarget, lowerDestructuringAssign } from "./lower-stmts.js"; import { isJsSourceFile, locOf } from "../program.js"; import { islandPrimitiveExit, lowerDynDispatchMethodCall } from "./lower-calls.js"; +import { buildArraySortFn, buildBytesSortFn } from "./lower-array-sort.js"; import { typeKey } from "../type-mapper.js"; import { dynUndefinedExpr, own, WidthLift } from "./lowerer.js"; import { boolLit, countedFor, numLit, varRef } from "../../ir/build.js"; @@ -1618,19 +1619,20 @@ function filterCond(call: IrExpr, fnRet: IrType, loc: SrcLoc): IrExpr { * function like the other array HOFs. sort mutates and returns the receiver; * toSorted takes its shallow snapshot INSIDE the helper, after the receiver * and comparator expressions have both been evaluated, then sorts and - * returns that copy without touching the receiver. The loop is a - * binary-free INSERTION sort: stable (equal-comparing elements keep their - * source order, which is what Node's stable TimSort produces for any - * consistent comparator) and JS-faithful on the comparator contract — an - * element moves left only while cmp(left, v) > 0, so a NaN or 0 result holds - * position exactly like the spec's "treat as equal". The SEQUENCE of - * comparator calls differs from V8's TimSort (SEMANTICS.md); results are - * identical for consistent comparators. The comparator-less form lowers for - * STRING elements only — JS's default converts every element to string and - * compares UTF-16 units, so the interned synthesized comparator selects the - * runtime's code-unit ordering rather than scriptc's documented code-point - * relational operators. For numbers that default is the notorious string - * sort ([10, 9, 1] → [1, 10, 9]), deliberately fenced toward an explicit + * returns that copy without touching the receiver. The helper uses a stable + * bottom-up merge sort with an ordered-boundary check: ordered inputs use a + * linear number of comparator calls, but buffer movement remains O(n log n) + * even when every boundary is already ordered. During a merge, an element + * moves right only while cmp(left, right) > 0, so a NaN or 0 result keeps + * the left element first. Undefined values sink without reaching the user + * comparator. The callback sequence differs from V8's TimSort, so exact + * order and count parity are not claimed; results agree for consistent + * comparators. The comparator-less form lowers for STRING elements only — + * JS's default converts every element to string and compares UTF-16 units, + * so the interned synthesized comparator selects the runtime's code-unit + * ordering rather than scriptc's documented code-point relational + * operators. For numbers that default is the notorious string sort + * ([10, 9, 1] → [1, 10, 9]), deliberately fenced toward an explicit * comparator. */ function lowerArraySortCall(lowerer: Lowerer, call: ts.CallExpression, access: ts.PropertyAccessExpression, @@ -1745,183 +1747,11 @@ function filterCond(call: IrExpr, fnRet: IrType, loc: SrcLoc): IrExpr { return name; } -/** The insertion-sort loop, from existing IR nodes: - * - * n = a.length; - * for (i = 1; i < n; i++) { - * v = a[i]; j = i - 1; - * while (j >= 0) { - * if (CompareArrayElements(a[j], v, f) > 0) { - * a[j + 1] = a[j]; j = j - 1; - * } else break; - * } - * a[j + 1] = v; - * } - * return a; - */ - function buildArraySortFn( - name: string, - elem: IrType, - arity: number, - copyFirst: boolean, - undefinedTag: number | null, - loc: SrcLoc, - ): IrFunction { - const arrT = arrayOf(elem); - const fnT = funcOf([elem, elem].slice(0, arity), F64); - - const j = varRef("j.0", F64, loc); - const at = (index: IrExpr): IrExpr => ({ kind: "arrayGet", arr: varRef("a.0", arrT, loc), index, type: elem, loc }); - const jPlus1: IrExpr = { kind: "bin", op: "+", left: j, right: numLit(1, loc), type: F64, loc }; - const isUndefined = (value: IrExpr): IrExpr | null => { - if (elem.kind === "union" && undefinedTag !== null) { - return { - kind: "unionIsTag", - unionId: elem.unionId, - tag: undefinedTag, - negated: false, - value, - type: BOOL, - loc, - }; - } - if (elem.kind === "jsval") { - return { - kind: "jsOp", - op: "eq", - args: [ - value, - { kind: "jsOp", op: "undefLit", args: [], type: JSVAL, loc }, - ], - type: BOOL, - loc, - }; - } - return null; - }; - const compareGreater: IrExpr = { - kind: "bin", - op: ">", - left: { - kind: "callValue", - callee: varRef("f.0", fnT, loc), - args: [at(j), varRef("v.0", elem, loc)].slice(0, arity), - type: F64, - loc, - }, - right: numLit(0, loc), - type: BOOL, - loc, - }; - const leftUndefined = isUndefined(at(j)); - const valueUndefined = isUndefined(varRef("v.0", elem, loc)); - // CompareArrayElements: undefined always sinks and never reaches the - // user comparator. Ternaries preserve that callback suppression. - const shouldShift: IrExpr = - leftUndefined !== null && valueUndefined !== null - ? { - kind: "ternary", - cond: leftUndefined, - then: { - kind: "unary", - op: "!", - operand: valueUndefined, - type: BOOL, - loc, - }, - else_: { - kind: "ternary", - cond: valueUndefined, - then: { kind: "boolLit", value: false, type: BOOL, loc }, - else_: compareGreater, - type: BOOL, - loc, - }, - type: BOOL, - loc, - } - : compareGreater; - const shiftLoop: IrStmt = { - kind: "while", - cond: { kind: "bin", op: ">=", left: j, right: numLit(0, loc), type: BOOL, loc }, - body: [ - { - kind: "if", - cond: shouldShift, - then: [ - { kind: "arraySet", arr: varRef("a.0", arrT, loc), index: jPlus1, value: at(j), loc }, - { kind: "assign", localId: "j.0", value: { kind: "bin", op: "-", left: j, right: numLit(1, loc), type: F64, loc }, loc }, - ], - else_: [{ kind: "break", loc }], - loc, - }, - ], - loc, - }; - const body: IrStmt[] = [ - ...(copyFirst - ? [{ - kind: "assign" as const, - localId: "a.0", - value: { - kind: "arrIntrinsic" as const, - method: "slice" as const, - receiver: varRef("a.0", arrT, loc), - args: [], - type: arrT, - loc, - }, - loc, - }] - : []), - readLenStmt(arrT, loc), - countedFor( - loc, - { kind: "bin", op: "-", left: varRef("n.0", F64, loc), right: numLit(1, loc), type: F64, loc }, - (index) => [ - { - kind: "varDecl", - localId: "v.0", - init: { - kind: "arrayGet", - arr: varRef("a.0", arrT, loc), - index: { kind: "bin", op: "+", left: index, right: numLit(1, loc), type: F64, loc }, - type: elem, - loc, - }, - loc, - }, - { kind: "varDecl", localId: "j.0", init: index, loc }, - shiftLoop, - { kind: "arraySet", arr: varRef("a.0", arrT, loc), index: jPlus1, value: varRef("v.0", elem, loc), loc }, - ], - ), - { kind: "return", value: varRef("a.0", arrT, loc), loc }, - ]; - return { - name, - params: [ - { localId: "a.0", name: "a", type: arrT }, - { localId: "f.0", name: "f", type: fnT }, - ], - returnType: arrT, - locals: [ - { id: "a.0", name: "a", type: arrT, mutable: true }, - { id: "f.0", name: "f", type: fnT, mutable: true }, - { id: "n.0", name: "n", type: F64, mutable: false }, - { id: "i.0", name: "i", type: F64, mutable: true }, - { id: "v.0", name: "v", type: elem, mutable: false }, - { id: "j.0", name: "j", type: F64, mutable: true }, - ], - body, - loc, - }; - } /** Uint8Array.prototype.toSorted. The receiver/comparator expressions are * evaluated before entering the helper; the helper snapshots with * TypedArray.prototype.slice before its first comparison, then performs - * the same stable insertion walk as Array.toSorted. Uint8Array's default + * the same stable merge walk as Array.toSorted. Uint8Array's default * comparator is numeric ascending, so its comparator-less form needs no * string-conversion machinery. */ function lowerBytesToSortedCall( @@ -2003,211 +1833,6 @@ function filterCond(call: IrExpr, fnRet: IrType, loc: SrcLoc): IrExpr { }; } - function buildBytesSortFn( - name: string, - arity: number, - hasComparator: boolean, - loc: SrcLoc, - ): IrFunction { - const bytesT = BYTES_U8; - const fnT = funcOf([F64, F64].slice(0, arity), F64); - - const j = varRef("j.0", F64, loc); - const at = (index: IrExpr): IrExpr => ({ - kind: "bytesIntrinsic", - method: "get", - receiver: varRef("a.0", bytesT, loc), - args: [index], - type: F64, - loc, - }); - const jPlus1: IrExpr = { - kind: "bin", - op: "+", - left: j, - right: numLit(1, loc), - type: F64, - loc, - }; - const compare: IrExpr = hasComparator - ? { - kind: "callValue", - callee: varRef("f.0", fnT, loc), - args: [at(j), varRef("v.0", F64, loc)].slice(0, arity), - type: F64, - loc, - } - : { - kind: "bin", - op: "-", - left: at(j), - right: varRef("v.0", F64, loc), - type: F64, - loc, - }; - const shiftLoop: IrStmt = { - kind: "while", - cond: { - kind: "bin", - op: ">=", - left: j, - right: numLit(0, loc), - type: BOOL, - loc, - }, - body: [ - { - kind: "if", - cond: { - kind: "bin", - op: ">", - left: compare, - right: numLit(0, loc), - type: BOOL, - loc, - }, - then: [ - { - kind: "bytesSet", - arr: varRef("a.0", bytesT, loc), - index: jPlus1, - value: at(j), - loc, - }, - { - kind: "assign", - localId: "j.0", - value: { - kind: "bin", - op: "-", - left: j, - right: numLit(1, loc), - type: F64, - loc, - }, - loc, - }, - ], - else_: [{ kind: "break", loc }], - loc, - }, - ], - loc, - }; - const params: IrParam[] = [ - { localId: "a.0", name: "a", type: bytesT }, - ...(hasComparator - ? [{ localId: "f.0", name: "f", type: fnT }] - : []), - ]; - const locals: IrLocal[] = [ - { id: "a.0", name: "a", type: bytesT, mutable: true }, - ...(hasComparator - ? [{ id: "f.0", name: "f", type: fnT, mutable: true }] - : []), - { id: "n.0", name: "n", type: F64, mutable: false }, - { id: "i.0", name: "i", type: F64, mutable: true }, - { id: "v.0", name: "v", type: F64, mutable: false }, - { id: "j.0", name: "j", type: F64, mutable: true }, - ]; - const body: IrStmt[] = [ - { - kind: "assign", - localId: "a.0", - value: { - kind: "bytesIntrinsic", - method: "slice", - receiver: varRef("a.0", bytesT, loc), - args: [], - type: bytesT, - loc, - }, - loc, - }, - { - kind: "varDecl", - localId: "n.0", - init: { - kind: "bytesIntrinsic", - method: "length", - receiver: varRef("a.0", bytesT, loc), - args: [], - type: F64, - loc, - }, - loc, - }, - { - kind: "for", - init: { - kind: "varDecl", - localId: "i.0", - init: numLit(1, loc), - loc, - }, - cond: { - kind: "bin", - op: "<", - left: varRef("i.0", F64, loc), - right: varRef("n.0", F64, loc), - type: BOOL, - loc, - }, - update: { - kind: "assign", - localId: "i.0", - value: { - kind: "bin", - op: "+", - left: varRef("i.0", F64, loc), - right: numLit(1, loc), - type: F64, - loc, - }, - loc, - }, - body: [ - { - kind: "varDecl", - localId: "v.0", - init: at(varRef("i.0", F64, loc)), - loc, - }, - { - kind: "varDecl", - localId: "j.0", - init: { - kind: "bin", - op: "-", - left: varRef("i.0", F64, loc), - right: numLit(1, loc), - type: F64, - loc, - }, - loc, - }, - shiftLoop, - { - kind: "bytesSet", - arr: varRef("a.0", bytesT, loc), - index: jPlus1, - value: varRef("v.0", F64, loc), - loc, - }, - ], - loc, - }, - { kind: "return", value: varRef("a.0", bytesT, loc), loc }, - ]; - return { - name, - params, - returnType: bytesT, - locals, - body, - loc, - }; - } /** `n = a.length` — the once-up-front length read every array HOF loop * starts with (locals a.0/n.0 by convention). */ diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 332f88d22..9866a3590 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -5682,12 +5682,24 @@ ], "diags": [] }, + "/tests/corpus/2700-array-sort-complexity.ts": { + "order": [ + "/tests/corpus/2700-array-sort-complexity.ts" + ], + "diags": [] + }, "/tests/corpus/2700-wasi-core.ts": { "order": [ "/tests/corpus/2700-wasi-core.ts" ], "diags": [] }, + "/tests/corpus/2701-array-sort-snapshot.ts": { + "order": [ + "/tests/corpus/2701-array-sort-snapshot.ts" + ], + "diags": [] + }, "/tests/corpus/2701-import-meta/main.mjs": { "order": [ "/tests/corpus/2701-import-meta/module with space.mjs", @@ -5695,6 +5707,12 @@ ], "diags": [] }, + "/tests/corpus/2702-uint8array-to-sorted.ts": { + "order": [ + "/tests/corpus/2702-uint8array-to-sorted.ts" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" diff --git a/tests/corpus/2700-array-sort-complexity.ts b/tests/corpus/2700-array-sort-complexity.ts new file mode 100644 index 000000000..6efaf473b --- /dev/null +++ b/tests/corpus/2700-array-sort-complexity.ts @@ -0,0 +1,44 @@ +// Stable sort must stay scalable across ordered, adversarial, random, and +// duplicate-heavy inputs. The budget is deliberately loose for a merge sort +// but rejects the old quadratic insertion sort on the larger cases. +function checkBudget(label: string, input: number[], budget: number): void { + let comparisons = 0; + const sorted = input.slice(); + sorted.sort((a, b) => { + comparisons++; + if (comparisons > budget) throw new Error(label + " exceeded comparison budget"); + return a - b; + }); + for (let i = 1; i < sorted.length; i++) { + if (sorted[i - 1]! > sorted[i]!) throw new Error(label + " was not sorted"); + } + console.log(label, sorted.length, sorted[0], sorted[sorted.length - 1]); +} + +const size = 4096; +const ascending: number[] = []; +const descending: number[] = []; +const random: number[] = []; +const duplicates: number[] = []; +let seed = 123456789; +for (let i = 0; i < size; i++) { + ascending.push(i); + descending.push(size - i - 1); + seed = (seed * 1664525 + 1013904223) % 4294967296; + random.push(seed % size); + duplicates.push((i * 17) % 31); +} + +checkBudget("ascending", ascending, size * 4); +checkBudget("descending", descending, size * 32); +checkBudget("random", random, size * 32); +checkBudget("duplicates", duplicates, size * 32); + +const ties = [ + { key: 1, order: "a" }, + { key: 0, order: "b" }, + { key: 1, order: "c" }, + { key: 0, order: "d" }, +]; +ties.sort((a, b) => a.key - b.key); +console.log(ties.map((entry) => entry.order).join(",")); diff --git a/tests/corpus/2701-array-sort-snapshot.ts b/tests/corpus/2701-array-sort-snapshot.ts new file mode 100644 index 000000000..6cf598392 --- /dev/null +++ b/tests/corpus/2701-array-sort-snapshot.ts @@ -0,0 +1,46 @@ +const receiver = [3, 1, 2]; +const returned = receiver.sort((a, b) => a - b); +console.log(returned === receiver, receiver.join(","), returned.join(",")); + +const mutatedReceiver = [3, 1, 2]; +let changedReceiver = false; +mutatedReceiver.sort((a, b) => { + if (!changedReceiver) { + changedReceiver = true; + mutatedReceiver[0] = 99; + } + return a - b; +}); +console.log(mutatedReceiver.join(",")); + +const source = [3, 1, 2]; +let changed = false; +const sorted = source.toSorted((a, b) => { + if (!changed) { + changed = true; + source[0] = 99; + source.push(4); + } + return a - b; +}); +console.log(sorted.join(",")); +console.log(source.join(",")); + +const stable = [ + { key: "b", order: 1 }, + { key: "a", order: 2 }, + { key: "b", order: 3 }, + { key: "a", order: 4 }, +]; +const byKey = stable.toSorted((a, b) => a.key.localeCompare(b.key)); +console.log(byKey.map((entry) => entry.order).join(",")); +console.log(stable.map((entry) => entry.order).join(",")); + +const throwing = [3, 1, 2]; +try { + throwing.sort((a, b): number => { + throw new Error("stop"); + }); +} catch (error) { + console.log(error instanceof Error, throwing.join(",")); +} diff --git a/tests/corpus/2702-uint8array-to-sorted.ts b/tests/corpus/2702-uint8array-to-sorted.ts new file mode 100644 index 000000000..325a3a67a --- /dev/null +++ b/tests/corpus/2702-uint8array-to-sorted.ts @@ -0,0 +1,16 @@ +const source = new Uint8Array(256); +for (let i = 0; i < source.length; i++) source[i] = 255 - i; + +let comparisons = 0; +const sorted = source.toSorted((a, b) => { + comparisons++; + if (comparisons > 5000) throw new Error("Uint8Array sort exceeded comparison budget"); + return a - b; +}); +console.log(sorted[0], sorted[255], source[0], source[255]); + +const descending = source.toSorted((a, b) => b - a); +console.log(descending[0], descending[255], source[0], source[255]); + +const defaults = new Uint8Array([9, 1, 7, 1, 5, 2]).toSorted(); +console.log(defaults[0], defaults[1], defaults[2], defaults[3], defaults[4], defaults[5]);