Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ jobs:
run: |
node packages/cli/dist/main.js build tests/corpus/001-hello.ts -o "$RUNNER_TEMP/hello"
test "$("$RUNNER_TEMP/hello")" = 'hello world'
- name: Linux release runtime reachability and debug payload
run: pnpm test tests/harness/runtime-tree-shaking.test.ts
- name: Debian 12 glibc 2.36 runtime-pack smoke
run: |
docker run --rm --volume "$GITHUB_WORKSPACE:/work:ro" --workdir /work node:24-bookworm-slim sh -ec '
Expand Down
41 changes: 40 additions & 1 deletion packages/compiler/src/backend/targets.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
IOS_ARM64_TARGET,
IOS_SIMULATOR_ARM64_TARGET,
Expand All @@ -19,7 +19,46 @@ import {
windowsSubsystemLinkerArgs,
} from "./targets.js";

afterEach(() => vi.restoreAllMocks());

describe("native code-generation targets", () => {
test("non-Linux and unsupported hosts never collect a process report", async () => {
vi.resetModules();
const targets = await import("./targets.js");
const report = vi.spyOn(process.report, "getReport").mockImplementation(() => {
throw new Error("unexpected host libc probe");
});
for (const [platform, arch, release] of [
["darwin", "arm64", "24.0.0"], ["darwin", "x64", "24.0.0"], ["win32", "x64", "10.0.0"],
] as const) {
expect(targets.nativeCodegenTarget({}, platform, arch, release)).not.toBeNull();
expect(targets.nativeCodegenTargetRefusal({}, platform, arch, release)).toBeNull();
expect(targets.nativeHelperForTarget(LINUX_X64_GNU_TARGET, platform, arch)).not.toBeNull();
}
expect(targets.nativeCodegenTarget({}, "linux", "ia32", "6.8.0")).toBeNull();
expect(targets.nativeHelperForTarget(LINUX_X64_GNU_TARGET, "linux", "ia32")).toBeNull();
expect(report).not.toHaveBeenCalled();
});

test.each(["gnu", "musl"] as const)("probes the %s host libc once across target and helper selection", async (libc) => {
vi.resetModules();
const targets = await import("./targets.js");
const report = vi.spyOn(process.report, "getReport").mockReturnValue({
header: libc === "gnu" ? { glibcVersionRuntime: "2.36" } : {},
} as ReturnType<typeof process.report.getReport>);
const target = libc === "gnu" ? LINUX_X64_GNU_TARGET : LINUX_X64_MUSL_TARGET;
expect(targets.nativeCodegenTarget({}, "linux", "x64", "6.8.0")).toEqual(target);
expect(targets.nativeCodegenTargetRefusal({}, "linux", "x64", "6.8.0")).toBeNull();
expect(targets.nativeHelperForTarget(target, "linux", "x64")?.packageName).toBe(`@scriptc/llvm-linux-x64-${libc}`);
expect(targets.nativeCodegenTarget({ SCRIPTC_TARGET: "wasm32-wasi" }, "linux", "x64", "6.8.0")).toEqual(WASM32_WASI_TARGET);
expect(report).toHaveBeenCalledTimes(1);

// Explicit host descriptions remain independent of the process memo.
expect(targets.nativeCodegenTarget({}, "linux", "x64", "6.8.0", "gnu")).toEqual(LINUX_X64_GNU_TARGET);
expect(targets.nativeCodegenTarget({}, "linux", "x64", "6.8.0", "musl")).toEqual(LINUX_X64_MUSL_TARGET);
expect(report).toHaveBeenCalledTimes(1);
});

test("matches Clang's narrow integer ABI independently of the build host", () => {
for (const target of [MACOS_ARM64_TARGET, MACOS_X64_TARGET, LINUX_X64_GNU_TARGET, LINUX_X64_MUSL_TARGET, WASM32_WASI_TARGET]) {
expect(ffiExtendsNarrowIntegers(target.llvmTriple, "win32", "x64")).toBe(true);
Expand Down
30 changes: 17 additions & 13 deletions packages/compiler/src/backend/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,24 +340,29 @@ export const NATIVE_TARGETS = [
IOS_ARM64_TARGET, IOS_SIMULATOR_ARM64_TARGET, ANDROID_ARM64_TARGET,
] as const;

let linuxLibcMemo: LinuxLibc | undefined;

function detectedLinuxLibc(): LinuxLibc {
if (linuxLibcMemo !== undefined) return linuxLibcMemo;
// Node exposes glibc's runtime version without any external command or
// filesystem probe. Its absence on Linux is the portable musl signal used
// by npm's own optional-dependency selection conventions.
// by npm's own optional-dependency selection conventions. The host libc
// cannot change within this process, and generating a full report is costly.
const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } } | undefined;
const header = report?.header;
return typeof header?.glibcVersionRuntime === "string" ? "gnu" : "musl";
linuxLibcMemo = typeof header?.glibcVersionRuntime === "string" ? "gnu" : "musl";
return linuxLibcMemo;
}

function helperHost(
platform: NodeJS.Platform,
arch: string,
linuxLibc: LinuxLibc = detectedLinuxLibc(),
linuxLibc?: LinuxLibc,
): NativeHelperHost | null {
if (platform === "darwin" && arch === "arm64") return "darwin-arm64";
if (platform === "darwin" && arch === "x64") return "darwin-x64";
if (platform === "linux" && arch === "x64") return `linux-x64-${linuxLibc}`;
if (platform === "linux" && arch === "arm64") return `linux-arm64-${linuxLibc}`;
if (platform === "linux" && arch === "x64") return `linux-x64-${linuxLibc ?? detectedLinuxLibc()}`;
if (platform === "linux" && arch === "arm64") return `linux-arm64-${linuxLibc ?? detectedLinuxLibc()}`;
if (platform === "win32" && arch === "x64") return "win32-x64";
return null;
}
Expand Down Expand Up @@ -385,19 +390,18 @@ function nativeHostTarget(
hostPlatform: NodeJS.Platform,
hostArch: string,
hostRelease: string,
linuxLibc: LinuxLibc,
linuxLibc?: LinuxLibc,
): NativeTargetSpec | null {
if (hostPlatform === "darwin") {
const major = Number.parseInt(hostRelease.split(".", 1)[0] ?? "", 10);
if (!Number.isFinite(major) || major < 24) return null;
return hostArch === "arm64" ? MACOS_ARM64_TARGET : hostArch === "x64" ? MACOS_X64_TARGET : null;
}
if (hostPlatform === "linux") {
if (hostPlatform === "linux" && (hostArch === "x64" || hostArch === "arm64")) {
const libc = linuxLibc ?? detectedLinuxLibc();
return hostArch === "x64"
? linuxLibc === "musl" ? LINUX_X64_MUSL_TARGET : LINUX_X64_GNU_TARGET
: hostArch === "arm64"
? linuxLibc === "musl" ? LINUX_ARM64_MUSL_TARGET : LINUX_ARM64_GNU_TARGET
: null;
? libc === "musl" ? LINUX_X64_MUSL_TARGET : LINUX_X64_GNU_TARGET
: libc === "musl" ? LINUX_ARM64_MUSL_TARGET : LINUX_ARM64_GNU_TARGET;
}
return hostPlatform === "win32" && hostArch === "x64" ? WINDOWS_X64_MSVC_TARGET : null;
}
Expand Down Expand Up @@ -442,7 +446,7 @@ export function nativeCodegenTarget(
hostPlatform: NodeJS.Platform = process.platform,
hostArch: string = process.arch,
hostRelease: string = release(),
linuxLibc: LinuxLibc = detectedLinuxLibc(),
linuxLibc?: LinuxLibc,
): NativeTargetSpec | null {
const host = nativeHostTarget(hostPlatform, hostArch, hostRelease, linuxLibc);
const target = requestedTarget(env["SCRIPTC_TARGET"] ?? "", host, hostPlatform);
Expand All @@ -466,7 +470,7 @@ export function nativeCodegenTargetRefusal(
hostPlatform: NodeJS.Platform = process.platform,
hostArch: string = process.arch,
hostRelease: string = release(),
linuxLibc: LinuxLibc = detectedLinuxLibc(),
linuxLibc?: LinuxLibc,
): string | null {
if (nativeCodegenTarget(env, hostPlatform, hostArch, hostRelease, linuxLibc) !== null) return null;
const requested = env["SCRIPTC_TARGET"] ?? "";
Expand Down
5 changes: 4 additions & 1 deletion packages/compiler/src/frontend/comptime-node.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import vm from "node:vm";
import ts5 from "typescript5";
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);

/** The Node client's compile-time evaluator. Only source text and the
* resulting value cross this boundary; TS5 ASTs never enter the frontend.
* Every invocation gets fresh globals and a bounded execution window. */
export function evaluateNodeComptime(source: string, timeoutMs: number): unknown {
const ts5 = require("typescript5") as typeof import("typescript5");
const js = ts5.transpileModule(`(${source})()`, {
compilerOptions: { target: ts5.ScriptTarget.ESNext },
}).outputText;
Expand Down
31 changes: 27 additions & 4 deletions packages/compiler/src/frontend/lowering/lowerer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3423,18 +3423,41 @@ export class Lowerer {
}
}
}
node.arguments.forEach((arg, i) => {
const callbackSlot = sig.params[i]?.type;
const restAt = sig.params.findIndex((shape) =>
shape.mode === "rest" || shape.mode === "dynRest" || shape.mode === "islandRest" || shape.mode === "arguments");
node.arguments.forEach((arg, argumentIndex) => {
const i = restAt >= 0 && argumentIndex >= restAt ? restAt : argumentIndex;
const shape = sig.params[i];
const callbackSlot = shape?.type;
if (callbackSlot?.kind === "func" && !ts.isSpreadElement(arg)) {
const optionalCallbackParams = callbackSlot.params.flatMap((type, index) =>
type.kind === "union" && this.armTag(type.unionId, UNDEFINED_T) >= 0 ? [index] : []);
if (optionalCallbackParams.length > 0 && promoteHofCallback(arg, optionalCallbackParams)) {
changed = true;
}
}
if (ts.isSpreadElement(arg) || !mayBeOptional(arg) || !sig.params[i]) return;
if (ts.isSpreadElement(arg) || !mayBeOptional(arg) || !shape) return;
if (i === restAt) {
// A rest binding always receives an array. An unchecked
// argument can make its elements undefined, not the pack.
// Keep every virtual implementation on the same array ABI.
for (const target of familyBySymbol.get(symbol) ?? [symbol]) {
const rest = signatureBySymbol.get(target)?.params[i];
if (rest?.mode !== "rest" || rest.type.kind !== "array") continue;
const widened = arrayOf(addUndefined(rest.type.elem));
if (typeEquals(rest.type, widened)) continue;
rest.type = widened;
const parameter = functionDeclBySymbol.get(target)?.parameters[i];
if (parameter && ts.isIdentifier(parameter.name)) {
const bound = symbolOf(parameter.name);
if (bound) this.runtimeOptionalBindingTypes.set(bound, widened);
}
changed = true;
}
return;
}
const set = optionalParams.get(symbol) ?? new Set<number>();
sig.params[i]!.type = addUndefined(sig.params[i]!.type);
shape.type = addUndefined(shape.type);
const before = set.size;
set.add(i);
optionalParams.set(symbol, set);
Expand Down
8 changes: 4 additions & 4 deletions packages/compiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,10 +972,6 @@ async function compileTracked(
sourceTexts: new Map(),
};
}
if (outputKind === "exe" && opts.sanitize !== true && process.env["SCRIPTC_FETCH_CURL"] !== "1") {
const refusal = nativeCodegenTargetRefusal();
if (refusal !== null) return { ok: false, diagnostics: [nativeCodegenDiag("SC3002", refusal, entryPath)], sourceTexts: new Map() };
}
let ffi: FfiProfile | null = null;
let ffiProfileBytes: Uint8Array | null = null;
if (opts.ffiProfilePath !== undefined) {
Expand Down Expand Up @@ -1080,6 +1076,10 @@ async function compileTracked(
};
}
}
if (outputKind === "exe" && opts.sanitize !== true && process.env["SCRIPTC_FETCH_CURL"] !== "1") {
const refusal = nativeCodegenTargetRefusal();
if (refusal !== null) return { ok: false, diagnostics: [nativeCodegenDiag("SC3002", refusal, entryPath)], sourceTexts: new Map() };
}
const cacheRoot = outputKind === "exe" && provenanceSources() === null
? await prepareBuildCacheRoot(buildCacheRoot())
: null;
Expand Down
12 changes: 8 additions & 4 deletions packages/compiler/src/library/semantic-source.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { InternalCompilerError } from "../errors.js";
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { extname } from "node:path";
import ts from "typescript5";
import type ts from "typescript5";

const require = createRequire(import.meta.url);

interface SemanticToken {
kind: "token" | "comment";
Expand Down Expand Up @@ -32,15 +35,16 @@ function semanticTokens(path: string, source: string): SemanticToken[] | null {
// both syntax validity (including a shebang's byte-zero requirement) and
// exact regular-expression spans, then keep the cheap scanner for trivia
// equivalence and location mapping.
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true);
const parser = require("typescript5") as typeof ts;
const sourceFile = parser.createSourceFile(path, source, parser.ScriptTarget.Latest, true);
const parseDiagnostics = (sourceFile as ts.SourceFile & {
parseDiagnostics: readonly ts.Diagnostic[];
}).parseDiagnostics;
if (parseDiagnostics.length > 0) return null;
const regexEnds = new Map<number, number>();
const collectRegex = (node: ts.Node): void => {
if (ts.isRegularExpressionLiteral(node)) regexEnds.set(node.getStart(sourceFile), node.end);
ts.forEachChild(node, collectRegex);
if (parser.isRegularExpressionLiteral(node)) regexEnds.set(node.getStart(sourceFile), node.end);
parser.forEachChild(node, collectRegex);
};
collectRegex(sourceFile);

Expand Down
62 changes: 62 additions & 0 deletions packages/compiler/test/startup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";

const compiler = new URL("../src/index.ts", import.meta.url).href;
const root = fileURLToPath(new URL("../../../", import.meta.url));
const prelude = `
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(${JSON.stringify(compiler)});
const legacyParser = require.resolve("typescript5");
assert.equal(require.cache[legacyParser], undefined);
`;

function freshProcess(source: string, args: string[] = []): void {
const result = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", prelude + source, ...args], {
cwd: root, encoding: "utf8", timeout: 60_000,
});
expect(result.error).toBeUndefined();
expect(result.signal).toBeNull();
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toBe("");
}

test("ordinary compilation does not initialize the legacy parser", () => {
const directory = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-startup-"));
const entry = join(directory, "main.ts");
writeFileSync(entry, "const answer: number = 42; console.log(answer);\n");
try {
freshProcess(`
const { compile } = await import(${JSON.stringify(compiler)});
assert.equal(require.cache[legacyParser], undefined, "compiler import loaded TypeScript 5");
const result = await compile(process.argv[1], {
outputKind: "llvm", outDir: process.argv[2], outPath: process.argv[3],
});
assert.equal(result.ok, true, JSON.stringify(result.diagnostics));
assert.equal(require.cache[legacyParser], undefined, "ordinary compilation loaded TypeScript 5");
`, [entry, directory, join(directory, "main.ll")]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});

test.each([
["compile-time evaluation", "frontend/comptime-node.ts", `
assert.equal(api.evaluateNodeComptime("(): number => 21 * 2", 1000), 42);
`],
["semantic source comparison", "library/semantic-source.ts", `
assert.equal(api.semanticallyEqualSource("entry.ts", "const n = 1;", "// note\\nconst n = 1;"), true);
assert.equal(api.semanticallyEqualSource("entry.ts", "const n = 1;", "const n = 2;"), false);
`],
])("%s loads the legacy parser on first use", (_name, module, operation) => {
freshProcess(`
const api = await import(${JSON.stringify(new URL(`../src/${module}`, import.meta.url).href)});
assert.equal(require.cache[legacyParser], undefined);
${operation}
assert.ok(require.cache[legacyParser], "the deferred parser was not loaded");
`);
});
36 changes: 36 additions & 0 deletions packages/compiler/test/ts7/baselines/order-parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -11703,6 +11703,42 @@
"<repo>/tests/corpus/llvm-read-receiver-lifetime.ts"
],
"diags": []
},
"<repo>/tests/corpus/class-brand-overload.js": {
"order": [
"<repo>/tests/corpus/class-brand-overload.js"
],
"diags": []
},
"<repo>/tests/corpus/class-prototype-data.js": {
"order": [
"<repo>/tests/corpus/class-prototype-data.js"
],
"diags": []
},
"<repo>/tests/corpus/js-omitted-typed-arguments.js": {
"order": [
"<repo>/tests/corpus/js-omitted-typed-arguments.js"
],
"diags": []
},
"<repo>/tests/corpus/math-function-values.js": {
"order": [
"<repo>/tests/corpus/math-function-values.js"
],
"diags": []
},
"<repo>/tests/diagnostics/class-prototype-reflection.ts": {
"order": [
"<repo>/tests/diagnostics/class-prototype-reflection.ts"
],
"diags": []
},
"<repo>/tests/corpus/rest-optional-array-arguments.ts": {
"order": [
"<repo>/tests/corpus/rest-optional-array-arguments.ts"
],
"diags": []
}
}
}
6 changes: 5 additions & 1 deletion packages/runtime-pack-common/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ async function build() {
const flavors = {};
for (const [flavor, flavorSpec] of Object.entries(matrix.flavors)) {
process.stdout.write(`building ${packageManifest.name} ${flavor} runtime\n`);
// Zig emits DWARF by default, including descriptions of functions
// removed by section GC. Release packs must opt out explicitly.
const debugFlags = flavor.endsWith("release") ? ["-g0"] : [];
const units = [];
for (const unit of flavorSpec.runtime_units ?? matrix.runtime_units) {
const variants = [];
Expand All @@ -94,7 +97,7 @@ async function build() {
...(unit.source === "scr_tls.c" ? ["-I", join(mbedtls, "include")] : []),
...(unit.source === "scr_zlib.c" || unit.source === "scr_fetch.c" ? ["-I", zlib] : []),
];
await compile(join(runtimeSrc, unit.source), output, [...commonFlags, flavorSpec.optimization, ...variant.defines.map((define) => `-D${define}`), ...includeFlags]);
await compile(join(runtimeSrc, unit.source), output, [...commonFlags, flavorSpec.optimization, ...debugFlags, ...variant.defines.map((define) => `-D${define}`), ...includeFlags]);
variants.push({ id: variant.id, when: variant.when, defines: variant.defines, path: artifactPath(output), sha256: await sha256(output), size: (await stat(output)).size });
}
units.push({ source: unit.source, predicate: unit.predicate, variants });
Expand All @@ -108,6 +111,7 @@ async function build() {
const vendorTarget = [
...config.targetArgs,
...(config.compilerFlags ?? []),
"-g0",
...(config.runtimeDefines ?? []).map((define) => `-D${define}`),
];
const requestedArchives = new Set(matrix.archives.map((entry) => entry.id));
Expand Down
Loading
Loading