Files
scriptc/packages/cli/test/source-output.test.ts
Chris Tate 401338f437 feat(compiler): add native LLVM object emission (#242)
* feat(compiler): add native LLVM object emission

- ship the pinned macOS arm64 LLVM helper and platform package
- add assembly/object CLI outputs, caching, diagnostics, and runtime ABI checks
- validate helper artifacts through CI, packaging, docs, and differential coverage

* fix(compiler): harden native LLVM emission

* fix(compiler): harden LLVM helper packaging

* fix(compiler): stabilize LLVM helper package size

* fix(compiler): harden native helper validation

* fix(compiler): harden LLVM helper verification

* fix(compiler): harden native object emission
2026-08-27 10:30:40 -05:00

143 lines
6.0 KiB
TypeScript

import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { promisify } from "node:util";
import { deserializeModule, validateModule } from "@scriptc/compiler";
import { afterEach, expect, test } from "vitest";
const execFileAsync = promisify(execFile);
const require = createRequire(import.meta.url);
const repoRoot = join(import.meta.dirname, "../../..");
const cliEntry = join(repoRoot, "packages/cli/src/main.ts");
const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs");
const dirs: string[] = [];
afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function fixture(extension = ".ts") {
const dir = await mkdtemp(join(tmpdir(), "scriptc-cli-source-output-"));
dirs.push(dir);
const entry = join(dir, `hello${extension}`);
await writeFile(entry, 'console.log("hello");\n');
return { dir, entry };
}
function cli(args: string[], env: NodeJS.ProcessEnv = process.env) {
return execFileAsync(process.execPath, ["--import", tsxLoader, cliEntry, ...args], {
env,
maxBuffer: 4 * 1024 * 1024,
});
}
test("default and explicit paths identify each source primary artifact", async () => {
const { dir, entry } = await fixture();
const expected = { ir: "hello.ir.json", c: "hello.c", llvm: "hello.ll" } as const;
for (const kind of ["ir", "c", "llvm"] as const) {
const result = await cli(["build", entry, `--emit=${kind}`]);
const path = join(dir, ".scriptc", expected[kind]);
expect(result.stdout).toBe(`${path}\n`);
expect(await readdir(join(dir, ".scriptc"))).toEqual([expected[kind]]);
}
const exact = join(dir, "artifact.with-custom-suffix");
const result = await cli(["build", entry, "--emit=llvm", "-o", exact]);
expect(result.stdout).toBe(`${exact}\n`);
expect(await readFile(exact, "utf8")).toContain("define i32 @main");
});
test("IR CLI output round-trips through the public parser and validator", async () => {
const { dir, entry } = await fixture();
const path = join(dir, "hello.json");
await cli(["build", entry, "--emit=ir", "-o", path]);
expect(validateModule(deserializeModule(await readFile(path, "utf8")))).toEqual([]);
});
test("module-flavored TypeScript extensions retain the plain entry stem", async () => {
for (const [extension, kind, name] of [
[".mts", "ir", "hello.ir.json"],
[".cts", "llvm", "hello.ll"],
] as const) {
const { dir, entry } = await fixture(extension);
const result = await cli(["build", entry, `--emit=${kind}`]);
const path = join(dir, ".scriptc", name);
expect(result.stdout).toBe(`${path}\n`);
expect(await readdir(join(dir, ".scriptc"))).toEqual([name]);
}
});
test("an explicit default-shaped output path preserves caller-owned siblings", async () => {
const { dir, entry } = await fixture();
const siblings = ["hello", "hello.exe", "hello.wasm", "hello.c", "hello.ll", "hello.s", "hello.o"];
await Promise.all(siblings.map((name) => writeFile(join(dir, name), `caller-owned ${name}\n`)));
const path = join(dir, "hello.ir.json");
await cli(["build", entry, "--emit=ir", "-o", path]);
for (const name of siblings) {
await expect(readFile(join(dir, name), "utf8")).resolves.toBe(`caller-owned ${name}\n`);
}
});
test("source outputs never execute compiler, archiver, or linker traps", async () => {
const { dir, entry } = await fixture();
const traps = join(dir, "traps");
const trapLog = join(dir, "trap.log");
await mkdir(traps);
for (const tool of ["clang", "cc", "gcc", "zig", "ar", "ld", "link", "link.exe", "xcrun"]) {
const path = join(traps, tool);
await writeFile(path, `#!/bin/sh\nprintf '${tool}\\n' >> '${trapLog}'\nprintf '${tool} trap executed\\n' >&2\nexit 97\n`);
await chmod(path, 0o755);
}
const env = {
...process.env,
PATH: `${traps}${delimiter}${process.env["PATH"] ?? ""}`,
SCRIPTC_CC: "zigcc",
SCRIPTC_TARGET: "aarch64-apple-ios",
SCRIPTC_CACHE_DIR: join(dir, "cache-must-not-exist"),
};
for (const kind of ["ir", "c", "llvm"] as const) {
const path = join(dir, `hello.${kind}`);
await expect(cli(["build", entry, `--emit=${kind}`, "-o", path], env)).resolves.toMatchObject({
stderr: "",
stdout: `${path}\n`,
});
}
await expect(readdir(env.SCRIPTC_CACHE_DIR)).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(trapLog, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
});
test("source outputs reject unsupported target spellings", async () => {
const { dir, entry } = await fixture();
const out = join(dir, "main.ll");
await expect(cli(["build", entry, "--emit=llvm", "-o", out], {
...process.env,
SCRIPTC_CC: "trap-compiler",
SCRIPTC_TARGET: "wasm64-wasi",
})).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("unsupported WASI target 'wasm64-wasi'"),
});
await expect(readFile(out)).rejects.toMatchObject({ code: "ENOENT" });
});
test("--emit-ir remains additive but reports its deprecation", async () => {
const { dir, entry } = await fixture();
const executable = join(dir, process.platform === "win32" ? "hello.exe" : "hello-bin");
const result = await cli(["build", entry, "--emit-ir", "-o", executable]);
expect(result.stderr).toContain("--emit-ir is deprecated");
expect(await readFile(join(dir, "hello.ir.json"), "utf8")).toContain('"irVersion"');
});
test("run and incompatible compatibility flags reject non-executable outputs", async () => {
const { entry } = await fixture();
await expect(cli(["run", entry, "--emit=c"])).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("scriptc run requires --emit=exe"),
});
await expect(cli(["build", entry, "--emit=llvm", "--emit-ir"])).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("--emit-ir cannot be combined"),
});
});