Files
scriptc/packages/cli/src/bootstrap.ts
T
Chris Tate fa7bc7c7a0 Warn about likely-hot functions that use untyped values
A build now names the functions of the program's own files that compile
to the dynamic tier and likely run often, so a slow program with a little
untyped code says where the time goes:

  warning SC6101: 3 functions that likely run often use untyped values, which runs slower than typed code:
    advance (nbody.js:22): untyped JavaScript file, in a loop
    ...

- The functions come from the tier assignment lowering already makes
  (Lowerer.dynamicFunctions). Without a profile, a function counts as
  likely hot when a dynamic operation runs inside one of its loops, when
  a loop calls it, or when it calls itself (lowering/dynamic-tier.ts).
- A function is named only when its source says why its values are
  untyped: an `any` (for a for...of variable, the value it iterates), an
  untyped JSON.parse result, or an untyped JavaScript file. Generated
  %dyn.* helpers, module top levels and functions that are dynamic for an
  operation without a static lowering are left out.
- At most five functions are named, most likely hot first, with the
  total; dynamic-tier code in npm packages is counted per package on one
  line. All of the warning's text is in diagnostics/dynamic-tier.ts.
- SC6101 opens the SC61xx band, performance notes, with a new
  "performance" category in the diagnostics envelope; SC60xx stay the
  divergences.
- scriptc build prints it after the divergence warnings, and the
  executable cache stores it with the entry, so a cached build (including
  the CLI's routed fast path) repeats it. scriptc coverage reports it, and
  a comment-only edit no longer replays a verdict that carries it, since
  its text holds line numbers.
- --no-dynamic-tier-warning leaves it out of build and coverage;
  coverage --fail-on=dynamic fails on it.
- Docs: coverage.mdx (Dynamic tier, Gating builds and CI, the envelope
  categories) and cli.mdx.
2026-10-21 18:53:43 -05:00

247 lines
9.0 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from "node:child_process";
import { readFile, rm } from "node:fs/promises";
import { enableCompileCache } from "node:module";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { CLI_OPTIONS, USAGE } from "./usage.js";
// Node 24 can persist V8's compiled module bytecode. scriptc's CLI imports
// the compiler and its lowering/backend graph before handling any command, so
// enabling this in the tiny bootstrap avoids reparsing that graph on every
// edit/build invocation.
try {
enableCompileCache();
} catch {
// Bytecode caching is an optimization boundary. A read-only temp directory
// must never prevent the compiler from running.
}
function packageVersion(): string {
const here = dirname(fileURLToPath(import.meta.url));
try {
return (
(JSON.parse(requireText(join(here, "..", "package.json"))) as { version?: string }).version ??
"unknown"
);
} catch {
return "unknown";
}
}
function requireText(path: string): string {
// This tiny synchronous read keeps --version free of the compiler graph and
// preserves the package manifest as the one release-version authority.
const { readFileSync } = process.getBuiltinModule("node:fs") as typeof import("node:fs");
return readFileSync(path, "utf8");
}
async function tryFastPath(): Promise<number | null> {
let parsed: ReturnType<
typeof parseArgs<{ options: typeof CLI_OPTIONS; allowPositionals: true; allowNegative: true }>
>;
try {
parsed = parseArgs({ options: CLI_OPTIONS, allowPositionals: true, allowNegative: true });
} catch {
return null; // main owns exact user-error wording
}
const { values, positionals } = parsed;
if (values.version) {
process.stdout.write(`${packageVersion()}\n`);
return 0;
}
if (values.help || positionals.length === 0) {
process.stdout.write(USAGE);
return values.help ? 0 : 1;
}
const [command, inputArg] = positionals;
if (command === "coverage") return coverageFastPath(values, inputArg, positionals.length);
if (
(command !== "build" && command !== "run") ||
inputArg === undefined ||
(values.emit !== undefined && values.emit !== "exe") ||
values.print !== undefined ||
values["emit-ir"] ||
values.lib ||
values["provenance-sources"] ||
// The full compiler keys these builds by the profile's bytes.
values["profile-generate"] !== undefined ||
values["profile-use"] !== undefined ||
values.sparse ||
(values["external-types"] ?? []).length > 0
)
return null;
const backend = values.backend;
if (backend !== undefined && backend !== "llvm") return null;
const optimization = values.optimization;
if (
optimization !== undefined &&
optimization !== "release" &&
optimization !== "dev" &&
optimization !== "speed"
)
return null;
const npmRaw = (values["npm-static"] ?? [])
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter((value) => value !== "");
let npmStatic: string[] | "auto" | null = null;
if (npmRaw.includes("auto")) {
if (npmRaw.length !== 1) return null;
npmStatic = "auto";
} else if (npmRaw.length > 0) {
npmStatic = npmRaw;
}
let startup: typeof import("@scriptc/compiler/startup-cache");
let buildPlatform: string;
try {
startup = await import("@scriptc/compiler/startup-cache");
buildPlatform = startup.configuredTargetPlatform();
} catch {
return null;
}
if (command === "run" && buildPlatform === "wasi") return null;
const input = resolve(inputArg);
const outDir = values.out ? dirname(resolve(values.out)) : join(dirname(input), ".scriptc");
const stem = basename(input).replace(/\.(ts|mts|cts|js|mjs|cjs|c|ll)$/, "");
const defaultName =
buildPlatform === "win32" ? `${stem}.exe` : buildPlatform === "wasi" ? `${stem}.wasm` : stem;
const outPath = values.out ? resolve(values.out) : join(outDir, defaultName);
const ffiPath = values.ffi === undefined ? null : resolve(values.ffi);
const ffiBytes = ffiPath === null ? null : await readFile(ffiPath).catch(() => null);
if (ffiPath !== null && ffiBytes === null) return null;
const root = await startup.prepareBuildCacheRoot(startup.resolveBuildCacheRoot());
// This must exactly mirror the ordinary LLVM executable's route in the
// full compiler. Otherwise a valid helper/runtime-pack cache entry has a
// different target/compiler identity and bootstrap must unnecessarily load
// the whole compiler graph to rediscover it.
const helperRuntimePackTarget = !values.sanitize ? startup.precompiledRuntimePackTarget() : null;
const helperObjectRoute = helperRuntimePackTarget !== null;
// The full compiler reports a checker it cannot find.
let checker: string | undefined;
try {
checker = startup.checkerCacheIdentity();
} catch {
return null;
}
const hit = await startup.readRoutedExecutableCache(root, {
entryPath: input,
outDir,
outPath,
emitIr: values["emit-ir"],
sanitize: values.sanitize,
backend: "llvm",
...(optimization === "dev" || optimization === "speed" ? { optimization } : {}),
...(values.strip ? { strip: true as const } : {}),
...(values["detached-exit"] ? { detachedExit: true as const } : {}),
...(checker === undefined ? {} : { checker }),
npmStatic,
ffiProfile: ffiPath === null ? null : { path: ffiPath, bytes: ffiBytes! },
target: `${process.env["SCRIPTC_TARGET"] ?? "native"}:${buildPlatform}:${process.arch}:${
helperObjectRoute ? "runtime-pack" : "driver-tu"
}`,
compiler: [
helperObjectRoute
? startup.resolvePlatformLinker(process.env, helperRuntimePackTarget.defaultLinker)
: (process.env["SCRIPTC_CC"] ?? "clang"),
],
nativeEnvironment: () =>
helperObjectRoute
? startup.executableLinkerEnvironmentFingerprint(
process.env,
helperRuntimePackTarget.defaultLinker,
)
: startup.executableNativeEnvironmentFingerprint(),
nodeVersion: process.version,
});
if (hit === null) return null;
if (!values["keep-llvm"]) await rm(hit.llvmPath, { force: true });
if (command === "build") {
// A cached build repeats its performance warning (SC6101).
const performance = hit.performance ?? [];
if (performance.length > 0 && values["dynamic-tier-warning"] !== false)
process.stderr.write(startup.renderPerformanceWarnings(performance) + "\n");
process.stdout.write(`${outPath}\n`);
return 0;
}
return new Promise<number>((resolveExit) => {
const child = spawn(outPath, [], { stdio: "inherit" });
child.on("exit", (code, signal) => {
if (signal) {
process.stderr.write(`scriptc: program killed by ${signal}\n`);
resolveExit(1);
} else {
resolveExit(code ?? 0);
}
});
});
}
/** An unchanged program's coverage verdict replays from the verdict cache
* without loading the compiler graph. Anything else (a miss, a comment-only
* edit, options the replay does not cover) defers to the full CLI. */
async function coverageFastPath(
values: ReturnType<
typeof parseArgs<{ options: typeof CLI_OPTIONS; allowPositionals: true; allowNegative: true }>
>["values"],
inputArg: string | undefined,
positionalCount: number,
): Promise<number | null> {
const print = values.print;
const failOn = values["fail-on"];
if (
inputArg === undefined ||
positionalCount !== 2 ||
(print !== undefined && print !== "diagnostics") ||
(failOn !== undefined && failOn !== "blockers" && failOn !== "divergences") ||
values.emit !== undefined ||
values.strip ||
values["detached-exit"] ||
values.lib ||
values.profile !== undefined ||
values.ffi !== undefined ||
values.backend !== undefined ||
values.optimization !== undefined ||
values["windows-subsystem"] !== undefined ||
values["provenance-sources"] ||
values["emit-ir"] ||
(values["npm-static"] ?? []).length > 0 ||
(values["external-types"] ?? []).length > 0
)
return null;
let startup: typeof import("@scriptc/compiler/startup-cache");
try {
startup = await import("@scriptc/compiler/startup-cache");
} catch {
return null;
}
const hit = await startup.readCoverageVerdict(resolve(inputArg), {}).catch(() => null);
if (hit === null) return null;
const { coverage, sourceTexts } = hit;
if (values["dynamic-tier-warning"] === false) delete coverage.performance;
if (print === "diagnostics") {
const envelope = startup.coverageEnvelope(coverage, {
compilerVersion: packageVersion(),
sourceTexts,
...(failOn === undefined ? {} : { failOn }),
});
process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
} else {
const color = process.stdout.isTTY ?? false;
process.stdout.write(
startup.renderCoverage(coverage, { color, sourceTexts, root: process.cwd() }) + "\n",
);
}
return startup.coveragePasses(coverage, failOn) ? 0 : 1;
}
const fastExit = await tryFastPath();
if (fastExit === null) {
await import("./main.js");
} else {
process.exitCode = fastExit;
}