import { execFile, spawn } from "node:child_process"; import { createRequire } from "node:module"; import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { release as osRelease, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, test } from "vitest"; import { compile, compileC } from "../src/index.js"; import { MACOS_ARM64_TARGET } from "../src/backend/targets.js"; const execFileAsync = promisify(execFile); const supported = process.platform === "darwin" && process.arch === "arm64" && Number.parseInt(osRelease().split(".", 1)[0] ?? "", 10) >= 24; const repoRoot = join(import.meta.dirname, "../../.."); const require = createRequire(import.meta.url); const helperPackage = supported ? require.resolve("@scriptc/llvm-darwin-arm64/package.json") : ""; const helper = supported ? join(dirname(helperPackage), "bin", "scriptc-llvm-codegen") : ""; const dirs: string[] = []; afterEach(async () => { await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); function helperArgs(input: string, output: string, target = MACOS_ARM64_TARGET.llvmTriple) { return [ "emit", "--input", input, "--output", output, "--filetype", "obj", "--target", target, "--opt-level", "2", "--relocation-model", "pic", "--diagnostic-format", "json", "--source-path", "/src/original.ts", ]; } async function run(command: string, args: string[]) { try { const result = await execFileAsync(command, args, { encoding: "buffer" }); return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 }; } catch (error) { const failure = error as { stdout?: Buffer; stderr?: Buffer; code?: number }; return { stdout: failure.stdout ?? Buffer.alloc(0), stderr: failure.stderr ?? Buffer.alloc(0), exitCode: typeof failure.code === "number" ? failure.code : 1, }; } } describe.runIf(supported)("LLVM native helper integration", () => { test("malformed IR and unsupported targets are structured and preserve caller output", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-errors-")); dirs.push(dir); const input = join(dir, "bad.ll"); const output = join(dir, "out.o"); await writeFile(input, "this is not LLVM IR\n"); await writeFile(output, "existing\n"); const malformed = await run(helper, helperArgs(input, output)); expect(malformed.exitCode).toBe(1); expect(JSON.parse(malformed.stderr.toString("utf8"))).toMatchObject({ ok: false, code: "invalid_ir", }); expect(await readFile(output, "utf8")).toBe("existing\n"); await writeFile(input, "define i32 @answer() { ret i32 42 }\n"); const unsupported = await run(helper, helperArgs(input, output, "x86_64-apple-macosx14.0.0")); expect(JSON.parse(unsupported.stderr.toString("utf8"))).toMatchObject({ ok: false, code: "unsupported_target", }); expect(await readFile(output, "utf8")).toBe("existing\n"); }); test("LLVM fatal diagnostics remain process-isolated JSON", async () => { const result = await execFileAsync(helper, ["version", "--format=json"], { encoding: "utf8", env: { ...process.env, SCRIPTC_LLVM_TEST_FATAL: "1" }, }).then( () => null, (error: { stderr?: string; code?: number }) => error, ); expect(result?.code).toBe(70); expect(JSON.parse(result?.stderr ?? "{}")).toMatchObject({ ok: false, code: "llvm_fatal", }); }); test("an interrupted helper cannot truncate an existing caller artifact", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-interrupt-")); dirs.push(dir); const input = join(dir, "large.ll"); const output = join(dir, "out.o"); const functions = Array.from( { length: 100_000 }, (_, i) => `define i32 @f${i}() { ret i32 ${i} }`, ).join("\n"); await writeFile(input, `${functions}\n`); await writeFile(output, "existing\n"); await new Promise((resolve, reject) => { const child = spawn(helper, helperArgs(input, output), { stdio: "ignore" }); child.once("error", reject); child.once("spawn", () => child.kill("SIGKILL")); child.once("close", () => resolve()); }); expect(await readFile(output, "utf8")).toBe("existing\n"); }); test("helper and clang objects have matching metadata and identical runtime behavior", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-parity-")); dirs.push(dir); const entry = join(repoRoot, "tests/corpus/001-hello.ts"); const helperExe = join(dir, "helper-program"); const result = await compile(entry, { outDir: dir, outPath: helperExe, backend: "llvm", nativeProgramObject: true, }); if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); const llvm = join(dir, "001-hello.ll"); const helperObject = join(dir, "001-hello.helper.o"); const clangObject = join(dir, "001-hello.clang.o"); await execFileAsync("clang", [ "-O2", "-Wno-override-module", "-target", MACOS_ARM64_TARGET.llvmTriple, "-c", llvm, "-o", clangObject, ]); for (const object of [helperObject, clangObject]) { await expect(execFileAsync("file", [object], { encoding: "utf8" })) .resolves.toMatchObject({ stdout: expect.stringContaining("Mach-O 64-bit object arm64") }); const loadCommands = (await execFileAsync("otool", ["-l", object], { encoding: "utf8" })).stdout; expect(loadCommands).toMatch(/LC_BUILD_VERSION[\s\S]*minos 14\.0/); } const sections = async (object: string) => [...(await execFileAsync("otool", ["-l", object], { encoding: "utf8" })).stdout .matchAll(/sectname (\S+)[\s\S]*?segname (\S+)/g)] .map((match) => `${match[2]},${match[1]}`).sort(); for (const object of [helperObject, clangObject]) { expect(await sections(object)).toEqual(expect.arrayContaining([ "__TEXT,__text", "__TEXT,__eh_frame", "__LD,__compact_unwind", ])); } const relocations = async (object: string) => (await execFileAsync("otool", ["-rv", object], { encoding: "utf8" })).stdout .split("\n") .filter((line) => /\b(?:BR26|PAGE21|PAGOF12|GOTLDP|GOTLDPOF|SUB|UNSIGND)\b/.test(line)) .map((line) => line.replace(/^\S+\s+/, "").trim()) .sort(); const [helperRelocations, clangRelocations] = await Promise.all([ relocations(helperObject), relocations(clangObject), ]); for (const kind of ["BR26", "PAGE21", "PAGOF12", "GOTLDP", "GOTLDPOF", "SUB", "UNSIGND"]) { expect(helperRelocations.some((line) => line.includes(kind)), `helper lacks ${kind}`) .toBe(clangRelocations.some((line) => line.includes(kind))); } const symbols = async (object: string, args: string[]) => (await execFileAsync("nm", [...args, object], { encoding: "utf8" })).stdout .trim().split("\n").filter(Boolean).sort(); 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_v1"); const clangExe = join(dir, "clang-program"); const linkDriver = join(dir, "clang-driver.c"); await writeFile(linkDriver, "/* clang object link driver */\n"); await compileC({ cPath: linkDriver, outPath: clangExe, linkInputs: [clangObject] }); const [helperRun, clangRun, nodeRun] = await Promise.all([ run(helperExe, []), run(clangExe, []), run(process.execPath, [entry]), ]); expect(helperRun).toEqual(nodeRun); expect(clangRun).toEqual(nodeRun); }); test("helper-object validation isolates concurrent link inputs", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-concurrent-")); dirs.push(dir); const firstEntry = join(dir, "first", "main.ts"); const secondEntry = join(dir, "second", "main.ts"); await Promise.all([mkdir(dirname(firstEntry)), mkdir(dirname(secondEntry))]); await Promise.all([ writeFile(firstEntry, 'console.log("first concurrent helper");\n'), writeFile(secondEntry, 'console.log("second concurrent helper");\n'), ]); const build = (entry: string, output: string) => compile(entry, { outDir: dir, outPath: output, backend: "llvm", nativeProgramObject: true, }); const firstExe = join(dir, "first-program"); const secondExe = join(dir, "second-program"); const [first, second] = await Promise.all([ build(firstEntry, firstExe), build(secondEntry, secondExe), ]); if (!first.ok || !second.ok) throw new Error("concurrent helper builds failed"); const [firstRun, secondRun] = await Promise.all([ run(firstExe, []), run(secondExe, []), ]); expect(firstRun).toMatchObject({ stdout: Buffer.from("first concurrent helper\n"), exitCode: 0 }); expect(secondRun).toMatchObject({ stdout: Buffer.from("second concurrent helper\n"), exitCode: 0 }); }); test("partial executable-cache hits still emit the program object through the helper", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-cache-hit-")); dirs.push(dir); const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"]; const oldNoCache = process.env["SCRIPTC_NO_CACHE"]; const oldFatal = process.env["SCRIPTC_LLVM_TEST_FATAL"]; try { process.env["SCRIPTC_CACHE_DIR"] = join(dir, "cache"); delete process.env["SCRIPTC_NO_CACHE"]; delete process.env["SCRIPTC_LLVM_TEST_FATAL"]; const options = { outDir: dir, outPath: join(dir, "program"), backend: "llvm" as const, nativeProgramObject: true, }; const first = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), options); if (!first.ok) throw new Error(first.diagnostics.map((d) => d.message).join("\n")); await rm(join(dir, "cache", "native-codegen-v1"), { recursive: true, force: true }); // Helper-object links deliberately retain a partial early-cache entry // because the caller-owned object disables final-binary caching. Evict // the independent helper-object cache so the hit must regenerate that // object rather than compiling the restored .ll directly through clang. process.env["SCRIPTC_LLVM_TEST_FATAL"] = "1"; const second = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), options); expect(second).toMatchObject({ ok: false, diagnostics: [{ code: "SC3004", message: expect.stringContaining("LLVM fatal") }], }); } finally { if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"]; else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir; if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"]; else process.env["SCRIPTC_NO_CACHE"] = oldNoCache; if (oldFatal === undefined) delete process.env["SCRIPTC_LLVM_TEST_FATAL"]; else process.env["SCRIPTC_LLVM_TEST_FATAL"] = oldFatal; } }); test("helper-object validation does not reuse an ordinary runtime-pack executable entry", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-cache-identity-")); dirs.push(dir); const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"]; const oldNoCache = process.env["SCRIPTC_NO_CACHE"]; try { process.env["SCRIPTC_CACHE_DIR"] = join(dir, "cache"); delete process.env["SCRIPTC_NO_CACHE"]; const entry = join(repoRoot, "tests/corpus/001-hello.ts"); const options = { outDir: dir, outPath: join(dir, "program"), backend: "llvm" as const }; const ordinary = await compile(entry, options); if (!ordinary.ok) throw new Error(ordinary.diagnostics.map((d) => d.message).join("\n")); const validation = await compile(entry, { ...options, nativeProgramObject: true }); if (!validation.ok) throw new Error(validation.diagnostics.map((d) => d.message).join("\n")); expect((await stat(join(dir, "001-hello.helper.o"))).size).toBeGreaterThan(0); } finally { if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"]; else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir; if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"]; else process.env["SCRIPTC_NO_CACHE"] = oldNoCache; } }); test("runtime-pack executable proofs include the helper package and binary", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-cache-proof-")); dirs.push(dir); const cache = join(dir, "cache"); const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"]; const oldNoCache = process.env["SCRIPTC_NO_CACHE"]; try { process.env["SCRIPTC_CACHE_DIR"] = cache; delete process.env["SCRIPTC_NO_CACHE"]; const result = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), { outDir: dir, outPath: join(dir, "program"), backend: "llvm", }); if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); const stampName = (await readdir(join(cache, "early-exe"), { recursive: true })) .find((path) => path.endsWith("stamp.json")); expect(stampName).toBeDefined(); const stamp = JSON.parse(await readFile(join(cache, "early-exe", stampName!), "utf8")) as { nativeDependencies: { path: string }[]; }; const dependencies = stamp.nativeDependencies.map((entry) => entry.path); expect(dependencies).toContain(helperPackage); expect(dependencies).toContain(join(dirname(helperPackage), "bin", "scriptc-llvm-codegen")); } finally { if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"]; else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir; if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"]; else process.env["SCRIPTC_NO_CACHE"] = oldNoCache; } }); test("runtime-pack FFI system libraries are relinked after an in-place rebuild", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-runtime-pack-ffi-cache-")); dirs.push(dir); const entry = join(dir, "main.ts"); const profile = join(dir, "ffi.json"); const source = join(dir, "probe.c"); const object = join(dir, "probe.o"); const library = join(dir, "libscriptc_cache_probe.a"); const cache = join(dir, "cache"); const output = join(dir, "program"); const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"]; const oldNoCache = process.env["SCRIPTC_NO_CACHE"]; const oldLibraryPath = process.env["LIBRARY_PATH"]; const rebuildLibrary = async (value: number) => { await writeFile(source, `double scriptc_cache_probe(void) { return ${value}; }\n`); await execFileAsync("clang", ["-c", source, "-o", object]); await execFileAsync("ar", ["rcs", library, object]); }; try { await writeFile(entry, [ "declare function nativeValue(): number;", "console.log(nativeValue());", "", ].join("\n")); await writeFile(profile, JSON.stringify({ ffi_format: 1, functions: [{ name: "nativeValue", symbol: "scriptc_cache_probe", params: [], returns: "f64", }], libraries: [], system_libraries: ["scriptc_cache_probe"], })); process.env["SCRIPTC_CACHE_DIR"] = cache; delete process.env["SCRIPTC_NO_CACHE"]; process.env["LIBRARY_PATH"] = dir; const options = { outDir: dir, outPath: output, backend: "llvm" as const, ffiProfilePath: profile }; await rebuildLibrary(1); const first = await compile(entry, options); if (!first.ok) throw new Error(first.diagnostics.map((d) => d.message).join("\n")); expect((await execFileAsync(output, [], { encoding: "utf8" })).stdout.trim()).toBe("1"); await rebuildLibrary(2); const second = await compile(entry, options); if (!second.ok) throw new Error(second.diagnostics.map((d) => d.message).join("\n")); expect((await execFileAsync(output, [], { encoding: "utf8" })).stdout.trim()).toBe("2"); } finally { if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"]; else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir; if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"]; else process.env["SCRIPTC_NO_CACHE"] = oldNoCache; if (oldLibraryPath === undefined) delete process.env["LIBRARY_PATH"]; else process.env["LIBRARY_PATH"] = oldLibraryPath; } }); test("runtime-pack PATH linker wrappers cannot restore hidden link inputs", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-runtime-pack-linker-cache-")); dirs.push(dir); const entry = join(dir, "main.ts"); const wrapper = join(dir, "clang"); const firstSource = join(dir, "first.c"); const secondSource = join(dir, "second.c"); const firstObject = join(dir, "first.o"); const secondObject = join(dir, "second.o"); const cache = join(dir, "cache"); const output = join(dir, "program"); const oldPath = process.env["PATH"]; const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"]; const oldNoCache = process.env["SCRIPTC_NO_CACHE"]; const oldLinkInput = process.env["SCRIPTC_TEST_LINK_INPUT"]; try { await Promise.all([ writeFile(entry, 'console.log("program");\n'), writeFile(firstSource, [ "#include ", '__attribute__((constructor)) static void marker(void) { write(1, "first\\n", 6); }', "", ].join("\n")), writeFile(secondSource, [ "#include ", '__attribute__((constructor)) static void marker(void) { write(1, "second\\n", 7); }', "", ].join("\n")), writeFile(wrapper, [ "#!/bin/sh", 'for arg in "$@"; do', ' if [ "$arg" = "-c" ]; then exec /usr/bin/clang "$@"; fi', "done", 'has_output=""', 'for arg in "$@"; do [ "$arg" = "-o" ] && has_output=1; done', 'if [ -n "$has_output" ] && [ -n "$SCRIPTC_TEST_LINK_INPUT" ]; then', ' exec /usr/bin/clang "$@" "$SCRIPTC_TEST_LINK_INPUT"', "fi", 'exec /usr/bin/clang "$@"', "", ].join("\n")), ]); await chmod(wrapper, 0o755); await Promise.all([ execFileAsync("/usr/bin/clang", [ "-target", MACOS_ARM64_TARGET.llvmTriple, "-c", firstSource, "-o", firstObject, ]), execFileAsync("/usr/bin/clang", [ "-target", MACOS_ARM64_TARGET.llvmTriple, "-c", secondSource, "-o", secondObject, ]), ]); process.env["PATH"] = `${dir}:${oldPath ?? "/usr/bin:/bin"}`; process.env["SCRIPTC_CACHE_DIR"] = cache; delete process.env["SCRIPTC_NO_CACHE"]; const options = { outDir: dir, outPath: output, backend: "llvm" as const }; process.env["SCRIPTC_TEST_LINK_INPUT"] = firstObject; const first = await compile(entry, options); if (!first.ok) throw new Error(first.diagnostics.map((d) => d.message).join("\n")); expect((await execFileAsync(output, [], { encoding: "utf8" })).stdout).toBe("first\nprogram\n"); process.env["SCRIPTC_TEST_LINK_INPUT"] = secondObject; const second = await compile(entry, options); if (!second.ok) throw new Error(second.diagnostics.map((d) => d.message).join("\n")); expect((await execFileAsync(output, [], { encoding: "utf8" })).stdout).toBe("second\nprogram\n"); } finally { if (oldPath === undefined) delete process.env["PATH"]; else process.env["PATH"] = oldPath; if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"]; else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir; if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"]; else process.env["SCRIPTC_NO_CACHE"] = oldNoCache; if (oldLinkInput === undefined) delete process.env["SCRIPTC_TEST_LINK_INPUT"]; else process.env["SCRIPTC_TEST_LINK_INPUT"] = oldLinkInput; } }); test("object emission preserves outbound FFI declarations as native C ABI references", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-ffi-")); dirs.push(dir); const object = join(dir, "ffi.o"); const result = await compile(join(repoRoot, "tests/ffi/main.ts"), { outDir: dir, outPath: object, outputKind: "obj", ffiProfilePath: join(repoRoot, "tests/ffi/profile.json"), }); if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); const undefinedSymbols = (await execFileAsync("nm", ["-u", object], { encoding: "utf8" })) .stdout.trim().split("\n"); expect(undefinedSymbols).toContain("_sf_scale"); expect(undefinedSymbols).toContain("_sf_callback_mix"); expect(undefinedSymbols).toContain("_scr_runtime_abi_v1"); }); test("helper and clang object paths retain the same outbound FFI C ABI", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-ffi-parity-")); dirs.push(dir); const entry = join(dir, "main.ts"); const profile = join(dir, "ffi.json"); await writeFile(entry, [ "declare function nativeMix(value: number, text: string, bytes: Uint8Array): number;", "console.log(nativeMix(2, 'ok', new Uint8Array([1, 2])));", "", ].join("\n")); await writeFile(profile, JSON.stringify({ ffi_format: 1, functions: [{ name: "nativeMix", symbol: "native_mix", params: ["f64", "string", "bytes"], returns: "f64", }], libraries: [], system_libraries: [], })); const object = join(dir, "helper.o"); const result = await compile(entry, { outDir: dir, outPath: object, outputKind: "obj", ffiProfilePath: profile, }); if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); const llvm = join(dir, "main.ll"); const sourceResult = await compile(entry, { outDir: dir, outPath: llvm, outputKind: "llvm", ffiProfilePath: profile, }); if (!sourceResult.ok) throw new Error(sourceResult.diagnostics.map((d) => d.message).join("\n")); const clangObject = join(dir, "clang.o"); await execFileAsync("clang", [ "-O2", "-Wno-override-module", "-target", MACOS_ARM64_TARGET.llvmTriple, "-c", llvm, "-o", clangObject, ]); const nativeMixSignature = (await readFile(llvm, "utf8")) .split("\n").find((line) => line.includes("@native_mix(")); expect(nativeMixSignature).toBe("declare double @native_mix(double, ptr, i64, ptr, i64)"); for (const candidate of [object, clangObject]) { const undefinedSymbols = (await execFileAsync("nm", ["-u", candidate], { encoding: "utf8" })).stdout; expect(undefinedSymbols).toContain("_native_mix"); } }); });