diff --git a/.gitignore b/.gitignore
index fda9414aa..cd44fe11d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ node_modules/
!tests/corpus/*/node_modules/
!tests/fixtures/npm-static/node_modules/
!tests/coverage-fixtures/wasm/node_modules/
+!tests/coverage-fixtures/dynamic-tier/package/node_modules/
!tests/fixtures/npm-static/nested/inner/node_modules/
!tests/fixtures/npm-static/node_modules/*/node_modules/
!tests/fixtures/npm/store/*/node_modules/
diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx
index efdb03d7d..8c57d2e2a 100644
--- a/docs/content/docs/cli.mdx
+++ b/docs/content/docs/cli.mdx
@@ -114,8 +114,8 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
--print <native-link-info|diagnostics>
native-link-info: build an object (equivalent to --emit=obj) and print its machine-readable external link recipe as JSON instead of printing the artifact path. The document names the exact installed precompiled runtime pack and all link inputs, but does not invoke a linker. diagnostics: for build and coverage, print the versioned JSON diagnostics document described in Coverage Reports instead of the human output.
- --fail-on <blockers|divergences>
- Coverage only. Exit with status 1 when the entry has blockers that would fail a build, or with divergences, also when it has Node divergence warnings. See Gating builds and CI.
+ --fail-on <blockers|divergences|dynamic>
+ Coverage only. Exit with status 1 when the entry has blockers that would fail a build, with divergences, also when it has Node divergence warnings, or with dynamic, also when it has the SC6101 warning for untyped code that likely runs often. See Gating builds and CI.
--ffi <file>
Bind signature-only TypeScript declarations to native C ABI symbols and link the manifest's archive, object, and system-library inputs. See Native FFI.
@@ -156,6 +156,9 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
--explain-dynamic[=<name>]
Coverage only. After the report, list the functions that already operate on values typed only at runtime and the bindings, fields, functions, classes and modules that unsupported constructs would move to the dynamic tier, each with the diagnostic behind it. With a name such as x, Point, or Point.x, show only the matching units. See Dynamic tier.
+ --no-dynamic-tier-warning
+ Build and coverage. Leave out SC6101, the warning that names functions that likely run often and use untyped values. Not allowed with --fail-on=dynamic. See Untyped code that likely runs often.
+
--wasm <file.wasm>
Coverage only. Report a WebAssembly module the program loads from a path its sources do not spell out. The report already lists modules whose paths the compiler resolves, modules inlined as base64 literals, and .wasm files shipped by packages that use WebAssembly; a scriptc.wasm array in package.json adds files the same way as this flag. Each module is listed with its size, whether it validates, and the WebAssembly proposals it uses. Builds translate the modules they find to native code; a build reads the scriptc.wasm array but not this flag. Repeatable.
diff --git a/docs/content/docs/coverage.mdx b/docs/content/docs/coverage.mdx
index 1bb645bd9..d2fc345ad 100644
--- a/docs/content/docs/coverage.mdx
+++ b/docs/content/docs/coverage.mdx
@@ -62,6 +62,25 @@ Code that operates on values whose types are known only at runtime, such as `any
`--tier=static-only` keeps every unsupported construct a blocker instead of moving it to the dynamic tier; code that already operates on runtime-typed values compiles as before. Today `auto`, the default, behaves the same way.
+### Untyped code that likely runs often
+
+When functions of the program's own files use untyped values and likely run often, `scriptc build` and coverage report them as an `SC6101` warning. For the Benchmarks Game n-body program in plain JavaScript:
+
+```console
+$ scriptc build nbody.js -o nbody
+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
+ energy (nbody.js:40): untyped JavaScript file, in a loop
+ offsetMomentum (nbody.js:12): untyped JavaScript file, in a loop
+ hint: add types to compile these statically; see scriptc coverage --explain-dynamic
+
+The program compiled. Adding types to these functions lets scriptc compile them to faster code.
+```
+
+Without a profile, a dynamic-tier function counts as likely to run often when a dynamic operation runs on every iteration of one of its loops (a `for` loop's initializer and a `for...of` loop's iterable run once), when a loop calls it, or when it calls itself. A function is named only when its source says why its values are untyped: a value typed `any` (for a `for...of` variable, the value it iterates), an untyped `JSON.parse` result, or an untyped JavaScript file. Dynamic code that runs once, such as setup code that reads an `any` configuration, is not reported, and neither are functions that are dynamic for an operation with no static lowering, which more types would not change.
+
+The warning names at most five functions, most likely to run often first, and gives the total. Dynamic-tier code in npm packages is summarized on one line with each package's function count. A build that comes from the cache repeats the warning. `--no-dynamic-tier-warning` leaves it out, and `--fail-on=dynamic` fails a coverage run on it.
+
## Diagnostic codes
Coverage and `scriptc build` report the same diagnostic codes. Common ones include:
@@ -88,7 +107,7 @@ By default, `scriptc coverage` exits with status 0 whenever the program type-che
$ scriptc coverage src/main.ts --fail-on=blockers
```
-`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences. A program that doesn't type-check always exits with status 1.
+`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences, and `--fail-on=dynamic` also fails on the `SC6101` warning for [untyped code that likely runs often](#untyped-code-that-likely-runs-often). A program that doesn't type-check always exits with status 1.
Coverage caches its result. Running it again on an unchanged program, or after edits that only touch comments, reuses the previous verdict. Set `SCRIPTC_NO_CACHE=1` to turn the cache off.
@@ -174,7 +193,7 @@ For editors, CI annotations, and other tools, `scriptc coverage --print=diagnost
category |
- invalid-source (the program has a TypeScript or JSON error), unsupported (valid code scriptc cannot compile yet), divergence (compiles, but can behave differently from Node.js), environment (project configuration, scriptc's type world, toolchain, or target), or internal (a compiler bug). |
+ invalid-source (the program has a TypeScript or JSON error), unsupported (valid code scriptc cannot compile yet), divergence (compiles, but can behave differently from Node.js), performance (compiles and behaves as under Node.js, but runs slower than typed code would), environment (project configuration, scriptc's type world, toolchain, or target), or internal (a compiler bug). |
severity |
diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts
index 1a319f98d..242a7c6f7 100644
--- a/packages/cli/src/bootstrap.ts
+++ b/packages/cli/src/bootstrap.ts
@@ -160,6 +160,10 @@ async function tryFastPath(): Promise {
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;
}
@@ -217,6 +221,7 @@ async function coverageFastPath(
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(),
diff --git a/packages/cli/test/dynamic-tier-warning.test.ts b/packages/cli/test/dynamic-tier-warning.test.ts
new file mode 100644
index 000000000..67de774d4
--- /dev/null
+++ b/packages/cli/test/dynamic-tier-warning.test.ts
@@ -0,0 +1,120 @@
+import { execFile } from "node:child_process";
+import { createRequire } from "node:module";
+import { mkdir, mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { promisify } from "node:util";
+import { 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 fixtures = join(repoRoot, "tests/coverage-fixtures/dynamic-tier");
+
+const HEADER =
+ "warning SC6101: 3 functions that likely run often use untyped values, which runs slower than typed code:";
+const CLOSING =
+ "The program compiled. Adding types to these functions lets scriptc compile them to faster code.";
+
+async function cli(
+ args: string[],
+ cacheRoot: string,
+): Promise<{ code: number; stdout: string; stderr: string }> {
+ try {
+ const { stdout, stderr } = await execFileAsync(
+ process.execPath,
+ ["--import", tsxLoader, cliEntry, ...args],
+ {
+ env: { ...process.env, SCRIPTC_CACHE_DIR: cacheRoot, SCRIPTC_TIMING: "1" },
+ maxBuffer: 4 * 1024 * 1024,
+ },
+ );
+ return { code: 0, stdout, stderr };
+ } catch (err) {
+ const e = err as { code?: number; stdout?: string; stderr?: string };
+ return { code: e.code ?? 1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" };
+ }
+}
+
+test("a build names its likely-hot untyped functions, also when it comes from the cache", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "scriptc-dynamic-tier-warning-"));
+ const cacheRoot = join(dir, "cache");
+ const outPath = join(dir, process.platform === "win32" ? "program.exe" : "program");
+ try {
+ await mkdir(cacheRoot, { mode: 0o700 });
+ const build = (extra: string[] = []) =>
+ cli(["build", join(fixtures, "hot.js"), "-o", outPath, ...extra], cacheRoot);
+
+ const first = await build();
+ expect(first.code).toBe(0);
+ expect(first.stderr).toContain("scriptc lowering");
+ expect(first.stderr).toContain(
+ [
+ HEADER,
+ " centroid (hot.js:7): untyped JavaScript file, in a loop",
+ " spread (hot.js:23): untyped JavaScript file, in a loop",
+ " distance (hot.js:17): untyped JavaScript file, called in a loop",
+ " hint: add types to compile these statically; see scriptc coverage --explain-dynamic",
+ "",
+ CLOSING,
+ ].join("\n"),
+ );
+
+ // The cache hit skips lowering and repeats the warning.
+ const cached = await build();
+ expect(cached.code).toBe(0);
+ expect(cached.stderr).not.toContain("scriptc lowering");
+ expect(cached.stderr).toContain(HEADER);
+ expect(cached.stderr).toContain(CLOSING);
+
+ const silenced = await build(["--no-dynamic-tier-warning"]);
+ expect(silenced.code).toBe(0);
+ expect(silenced.stderr).not.toContain("SC6101");
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}, 300_000);
+
+test("untyped setup code builds without the warning", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "scriptc-dynamic-tier-quiet-"));
+ try {
+ await mkdir(join(dir, "cache"), { mode: 0o700 });
+ const result = await cli(
+ ["build", join(fixtures, "quiet.ts"), "-o", join(dir, "program")],
+ join(dir, "cache"),
+ );
+ expect(result.code).toBe(0);
+ expect(result.stderr).not.toContain("SC6101");
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}, 300_000);
+
+test("coverage --fail-on=dynamic fails on the warning, and --no-dynamic-tier-warning cannot hide it", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "scriptc-dynamic-tier-fail-on-"));
+ try {
+ await mkdir(join(dir, "cache"), { mode: 0o700 });
+ const coverage = (file: string, extra: string[]) =>
+ cli(["coverage", join(fixtures, file), ...extra], join(dir, "cache"));
+
+ const hot = await coverage("hot.js", ["--fail-on=dynamic"]);
+ expect(hot.code).toBe(1);
+ expect(hot.stdout).toContain("untyped and likely hot");
+ expect((await coverage("hot.js", ["--fail-on=divergences"])).code).toBe(0);
+ expect((await coverage("quiet.ts", ["--fail-on=dynamic"])).code).toBe(0);
+
+ const silenced = await coverage("hot.js", ["--no-dynamic-tier-warning"]);
+ expect(silenced.code).toBe(0);
+ expect(silenced.stdout).not.toContain("untyped and likely hot");
+
+ const conflict = await coverage("hot.js", ["--fail-on=dynamic", "--no-dynamic-tier-warning"]);
+ expect(conflict.code).toBe(1);
+ expect(conflict.stderr).toContain(
+ "--fail-on=dynamic checks the warning --no-dynamic-tier-warning leaves out",
+ );
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}, 300_000);
diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts
index 03160c6fa..3285aa7fa 100644
--- a/packages/compiler/src/cli/command.ts
+++ b/packages/compiler/src/cli/command.ts
@@ -13,6 +13,7 @@ import {
import { TIER_MODES, type TierMode } from "../frontend/tier-analysis.js";
import { buildEnvelope } from "../diagnostics/envelope.js";
import { renderWarnings } from "../diagnostics/render.js";
+import { isPerformanceCode, renderPerformanceWarnings } from "../diagnostics/dynamic-tier.js";
import { isNativeOptimization } from "../backend/optimization.js";
import { setProvenanceSources } from "../frontend/provenance-registry.js";
import type { CliHost, NativeCacheWarmProfile } from "./host.js";
@@ -284,8 +285,20 @@ async function main(args: string[], host: CliHost): Promise {
fail(`--print=diagnostics is a build/coverage option\n\n${USAGE}`);
}
const failOnRaw = values["fail-on"];
- if (failOnRaw !== undefined && failOnRaw !== "blockers" && failOnRaw !== "divergences") {
- fail(`unknown --fail-on value "${failOnRaw}" (supported: blockers, divergences)\n\n${USAGE}`);
+ if (
+ failOnRaw !== undefined &&
+ failOnRaw !== "blockers" &&
+ failOnRaw !== "divergences" &&
+ failOnRaw !== "dynamic"
+ ) {
+ fail(
+ `unknown --fail-on value "${failOnRaw}" (supported: blockers, divergences, dynamic)\n\n${USAGE}`,
+ );
+ }
+ // --no-dynamic-tier-warning leaves out SC6101 (diagnostics/dynamic-tier.ts).
+ const dynamicTierWarning = values["dynamic-tier-warning"] !== false;
+ if (failOnRaw === "dynamic" && !dynamicTierWarning) {
+ fail(`--fail-on=dynamic checks the warning --no-dynamic-tier-warning leaves out\n\n${USAGE}`);
}
if (failOnRaw !== undefined && command !== "coverage") {
fail(`--fail-on is a coverage option (a build already fails on blockers)\n\n${USAGE}`);
@@ -430,6 +443,7 @@ async function main(args: string[], host: CliHost): Promise {
...(explainDynamic !== undefined ? { explainDynamic: true } : {}),
...(wasmFiles.length > 0 ? { wasm: wasmFiles } : {}),
});
+ if (!dynamicTierWarning) delete coverage.performance;
if (printDiagnostics) {
// Keep stdout pure JSON for tooling.
const envelope = coverageEnvelope(coverage, {
@@ -517,7 +531,9 @@ async function main(args: string[], host: CliHost): Promise {
process.stderr.write(`\n${n} error${n === 1 ? "" : "s"}.\n`);
throw new CliExit(1);
}
- const warnings = result.warnings ?? [];
+ const warnings = (result.warnings ?? []).filter(
+ (d) => dynamicTierWarning || !isPerformanceCode(d.code),
+ );
if (printDiagnostics) {
diagnosticsEnvelope = buildEnvelope({
compilerVersion: host.version(),
@@ -530,12 +546,20 @@ async function main(args: string[], host: CliHost): Promise {
});
} else if (warnings.length > 0 && command === "build") {
// Divergence warnings never fail a build; they explain where the
- // native program can behave differently from Node. `run` keeps
- // stderr for the program's own output.
+ // native program can behave differently from Node. The performance
+ // warning names likely-hot functions that use untyped values. `run`
+ // keeps stderr for the program's own output.
const color = process.stderr.isTTY ?? false;
- process.stderr.write(
- renderWarnings(warnings, result.sourceTexts ?? new Map(), { color }) + "\n",
- );
+ const divergences = warnings.filter((d) => !isPerformanceCode(d.code));
+ const performance = warnings.filter((d) => isPerformanceCode(d.code));
+ if (divergences.length > 0)
+ process.stderr.write(
+ renderWarnings(divergences, result.sourceTexts ?? new Map(), { color }) + "\n",
+ );
+ if (performance.length > 0) {
+ if (divergences.length > 0) process.stderr.write("\n");
+ process.stderr.write(renderPerformanceWarnings(performance) + "\n");
+ }
}
if (result.artifact.kind === "exe") {
if (!values["keep-llvm"]) rmSync(result.artifact.translationUnitPath, { force: true });
diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts
index aa7408d2a..23e57c66f 100644
--- a/packages/compiler/src/cli/usage.ts
+++ b/packages/compiler/src/cli/usage.ts
@@ -31,10 +31,11 @@ Options:
(native-link-info implies --emit=obj and never links;
diagnostics prints the versioned JSON diagnostics
envelope for build and coverage)
- --fail-on
+ --fail-on
coverage only: exit 1 when the entry path has blockers
(a build would fail), or also when it has Node
- divergences
+ divergences, or also when it has likely-hot functions
+ that use untyped values (SC6101)
--backend code generator (llvm)
--optimization
native optimization posture (default: release/-O2). dev
@@ -100,6 +101,9 @@ Options:
values and the bindings, fields, functions, classes and
modules the dynamic tier would take over, with the
refusal behind each; a name shows only that unit's chain
+ --no-dynamic-tier-warning
+ build and coverage: leave out SC6101, the warning that
+ names likely-hot functions that use untyped values
--wasm
coverage only: report a WebAssembly module the program
loads whose path its sources do not spell (repeatable;
@@ -131,6 +135,7 @@ export const CLI_OPTIONS = {
"external-types": { type: "string", multiple: true },
tier: { type: "string" },
"explain-dynamic": { type: "string" },
+ "dynamic-tier-warning": { type: "boolean", default: true },
wasm: { type: "string", multiple: true },
lib: { type: "boolean", default: false },
profile: { type: "string" },
diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts
index 89733e255..37f77f4c6 100644
--- a/packages/compiler/src/coverage/report.ts
+++ b/packages/compiler/src/coverage/report.ts
@@ -42,6 +42,10 @@ export interface CoverageInput {
* the program compiles, but these sites can behave differently natively.
* Never blockers; `--fail-on=divergences` opts into failing on them. */
divergences?: ScrDiagnostic[];
+ /** The performance warning (SC6101): likely-hot functions the source
+ * leaves untyped. Never blockers; `--fail-on=dynamic` opts into failing
+ * on it. */
+ performance?: ScrDiagnostic[];
/** Each dependency's native compilation outcome, with the first refusal
* reason for a refused package. */
npmStatic?: NpmStaticStatus[];
@@ -333,6 +337,17 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
);
renderRows(groupBlockers(divergences), YELLOW);
};
+ // The performance warning: its message names the functions.
+ const performance = input.performance ?? [];
+ const renderPerformance = () => {
+ for (const d of performance) {
+ out.push("");
+ const [head, ...rest] = d.message.split("\n");
+ out.push(` ${c(YELLOW, "untyped and likely hot")} ${head}`);
+ for (const line of rest) out.push(` ${line}`);
+ if (d.hint !== undefined) out.push(` ${c(DIM, `hint: ${d.hint}`)}`);
+ }
+ };
const unreachedDiags = [...(un?.diagnostics ?? []), ...(un?.runtimeFences ?? [])];
if (failed === 0 && input.diagnostics.length === 0 && unreachedDiags.length === 0) {
@@ -341,6 +356,7 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
out.push("");
renderDivergences();
}
+ renderPerformance();
return out.join("\n");
}
@@ -381,6 +397,7 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
out.push("");
renderDivergences();
}
+ renderPerformance();
return out.join("\n");
}
@@ -449,8 +466,9 @@ function displayPath(file: string, root: string | undefined): string {
}
/** What `coverage --fail-on` checks: reached blockers (they fail a build)
- * and, optionally, divergences. Unreached blockers never fail. */
-export type CoverageFailOn = "blockers" | "divergences";
+ * and, optionally, divergences or the performance warning. Unreached
+ * blockers never fail. */
+export type CoverageFailOn = "blockers" | "divergences" | "dynamic";
/** Whether the analysis passes the selected gate. Preflight failures
* always fail; `failOn` undefined means only preflight fails (the report's
@@ -462,6 +480,7 @@ export function coveragePasses(input: CoverageInput, failOn?: CoverageFailOn): b
// runtime fences build (the statement throws only if it executes).
if (input.diagnostics.length > 0) return false;
if (failOn === "divergences" && (input.divergences?.length ?? 0) > 0) return false;
+ if (failOn === "dynamic" && (input.performance?.length ?? 0) > 0) return false;
return true;
}
@@ -492,6 +511,7 @@ export function coverageEnvelope(
add(input.unreached?.diagnostics, "unreached");
add(input.unreached?.runtimeFences, "deferred");
add(input.divergences, "reached");
+ add(input.performance, "reached");
}
const un = input.unreached;
const total = input.stats.statementsTotal + (un?.stats.statementsTotal ?? 0);
diff --git a/packages/compiler/src/coverage/verdict-cache.ts b/packages/compiler/src/coverage/verdict-cache.ts
index 422bf5e4d..e5fa7242e 100644
--- a/packages/compiler/src/coverage/verdict-cache.ts
+++ b/packages/compiler/src/coverage/verdict-cache.ts
@@ -38,6 +38,7 @@ export interface CachedCoverage {
runtimeFences?: ScrDiagnostic[];
unreached?: { stats: LowerStats; diagnostics: ScrDiagnostic[]; runtimeFences?: ScrDiagnostic[] };
divergences?: ScrDiagnostic[];
+ performance?: ScrDiagnostic[];
npmStatic?: NpmStaticStatus[];
wasmModules?: WasmModuleReport[];
preflightFailed: boolean;
@@ -149,6 +150,7 @@ function cacheable(coverage: AnalyzeResult["coverage"]): CachedCoverage | null {
if (coverage.runtimeFences !== undefined) out.runtimeFences = coverage.runtimeFences;
if (coverage.unreached !== undefined) out.unreached = coverage.unreached;
if (coverage.divergences !== undefined) out.divergences = coverage.divergences;
+ if (coverage.performance !== undefined) out.performance = coverage.performance;
if (coverage.npmStatic !== undefined) out.npmStatic = coverage.npmStatic;
if (coverage.wasmModules !== undefined) out.wasmModules = coverage.wasmModules;
return out;
@@ -166,6 +168,7 @@ function restore(cached: CachedCoverage, sources: CachedSource[]): AnalyzeResult
if (cached.runtimeFences !== undefined) coverage.runtimeFences = cached.runtimeFences;
if (cached.unreached !== undefined) coverage.unreached = cached.unreached;
if (cached.divergences !== undefined) coverage.divergences = cached.divergences;
+ if (cached.performance !== undefined) coverage.performance = cached.performance;
if (cached.npmStatic !== undefined) coverage.npmStatic = cached.npmStatic;
if (cached.wasmModules !== undefined) coverage.wasmModules = cached.wasmModules;
return { coverage, sourceTexts };
diff --git a/packages/compiler/src/coverage/verdict-semantic.ts b/packages/compiler/src/coverage/verdict-semantic.ts
index cc1c1f414..ad2f4d180 100644
--- a/packages/compiler/src/coverage/verdict-semantic.ts
+++ b/packages/compiler/src/coverage/verdict-semantic.ts
@@ -7,6 +7,9 @@ import { rebaseSourceLocations, semanticallyEqualSource } from "../library/seman
import type { SemanticReplay } from "./verdict-cache.js";
export const commentOnlyReplay: SemanticReplay = (probes, sources, coverage) => {
+ // The performance warning's text names file:line positions, which moving
+ // its locations would not update: such a verdict is analyzed again.
+ if (coverage.performance !== undefined) return null;
const previous = new Map();
for (const source of sources) previous.set(source.path, source.text);
const match = frontendInputsSemanticallyMatch(probes, previous, semanticallyEqualSource);
diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts
index e4d4006ca..17cb2efc0 100644
--- a/packages/compiler/src/diagnostics/diagnostic.ts
+++ b/packages/compiler/src/diagnostics/diagnostic.ts
@@ -61,7 +61,10 @@
* collation does not cover (SC6003), a call feeding an instance
* to a cast to an unrelated class (SC6004); SC6005 (Date strings
* outside the native parser's grammar) is retired — the runtime
- * parses date strings with V8's grammar
+ * parses date strings with V8's grammar. SC61xx are performance
+ * notes, also warnings (coverage --fail-on=dynamic opts in):
+ * likely-hot functions that use untyped values (SC6101,
+ * diagnostics/dynamic-tier.ts)
* SC9xxx internal compiler errors (still source-anchored)
*/
import type { SrcLoc } from "../ir/ir.js";
diff --git a/packages/compiler/src/diagnostics/dynamic-tier.ts b/packages/compiler/src/diagnostics/dynamic-tier.ts
new file mode 100644
index 000000000..7970d3f42
--- /dev/null
+++ b/packages/compiler/src/diagnostics/dynamic-tier.ts
@@ -0,0 +1,99 @@
+/* SC6101, the dynamic-tier performance warning: its text and how it prints.
+ * All of the warning's wording is here. The functions it names come from
+ * frontend/lowering/dynamic-tier.ts; this module only formats them, and it
+ * imports nothing heavy so the CLI's routed cache hit can print a stored
+ * warning without loading the compiler. */
+import type { SrcLoc } from "../ir/ir.js";
+import type { ScrDiagnostic } from "./diagnostic.js";
+
+/** The code of the warning; SC61xx are performance notes, SC60xx divergences. */
+export const DYNAMIC_TIER_CODE = "SC6101";
+
+/** At most this many functions are named; the count covers all of them. */
+export const DYNAMIC_TIER_SHOWN = 5;
+
+/** Why a function counts as likely to run often. */
+export type HotReason = "loop" | "called-in-loop" | "recursive";
+
+const HOT_PHRASE: Record = {
+ loop: "in a loop",
+ "called-in-loop": "called in a loop",
+ recursive: "recursive",
+};
+
+/** Why a function's values are untyped, as the warning words it. */
+export type UntypedCause =
+ | { kind: "any"; name: string }
+ | { kind: "json-binding"; name: string }
+ | { kind: "json" }
+ | { kind: "javascript" };
+
+export function causePhrase(cause: UntypedCause): string {
+ switch (cause.kind) {
+ case "any":
+ return `\`${cause.name}\` is \`any\``;
+ case "json-binding":
+ return `\`${cause.name}\` holds an untyped \`JSON.parse\` result`;
+ case "json":
+ return "untyped `JSON.parse` result";
+ case "javascript":
+ return "untyped JavaScript file";
+ }
+}
+
+export interface DynamicTierEntry {
+ /** Display name (`advance`, `Parser.next`). */
+ name: string;
+ /** `file:line`, relative to the entry's directory. */
+ where: string;
+ cause: UntypedCause;
+ hot: HotReason;
+}
+
+export interface DynamicTierPackageCount {
+ name: string;
+ functions: number;
+}
+
+/** The SC6101 diagnostic for likely-hot functions with untyped values, or
+ * null when there are none. `loc` is the first function's declaration. */
+export function dynamicTierDiag(
+ entries: readonly DynamicTierEntry[],
+ packages: readonly DynamicTierPackageCount[],
+ loc: SrcLoc,
+): ScrDiagnostic | null {
+ const n = entries.length;
+ if (n === 0) return null;
+ const shown = entries.slice(0, DYNAMIC_TIER_SHOWN);
+ const lines = [
+ `${n} function${n === 1 ? "" : "s"} that likely run${n === 1 ? "s" : ""} often use${n === 1 ? "s" : ""} untyped values, which runs slower than typed code:`,
+ ...shown.map((e) => ` ${e.name} (${e.where}): ${causePhrase(e.cause)}, ${HOT_PHRASE[e.hot]}`),
+ ...(n > shown.length ? [` and ${n - shown.length} more`] : []),
+ ...(packages.length === 0
+ ? []
+ : [
+ ` untyped code in npm packages: ${packages
+ .map((p) => `${p.name} (${p.functions} function${p.functions === 1 ? "" : "s"})`)
+ .join(", ")}`,
+ ]),
+ ];
+ return {
+ code: DYNAMIC_TIER_CODE,
+ message: lines.join("\n"),
+ loc,
+ hint: "add types to compile these statically; see scriptc coverage --explain-dynamic",
+ };
+}
+
+export function isPerformanceCode(code: string): boolean {
+ return /^SC61\d{2}$/.test(code);
+}
+
+/** The warning as a build prints it: no source excerpt (it names several
+ * functions), then its closing line. */
+export function renderPerformanceWarnings(diags: readonly ScrDiagnostic[]): string {
+ const blocks = diags.map(
+ (d) => `warning ${d.code}: ${d.message}${d.hint === undefined ? "" : `\n hint: ${d.hint}`}`,
+ );
+ return `${blocks.join("\n\n")}\n\nThe program compiled. Adding types to these functions lets scriptc compile them to faster code.`;
+}
diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts
index c986fc5b1..59c285ead 100644
--- a/packages/compiler/src/diagnostics/envelope.ts
+++ b/packages/compiler/src/diagnostics/envelope.ts
@@ -16,6 +16,7 @@
* bumps it. */
import { readFileSync } from "node:fs";
import type { ScrDiagnostic } from "./diagnostic.js";
+import { isPerformanceCode } from "./dynamic-tier.js";
import type { WasmModuleReport } from "../wasm/discover.js";
export const DIAGNOSTICS_SCHEMA = "scriptc-diagnostics";
@@ -25,12 +26,14 @@ export const DIAGNOSTICS_SCHEMA_VERSION = 1;
* - invalid-source: the program itself is wrong (TypeScript errors, bad JSON modules)
* - unsupported: valid TypeScript/JavaScript scriptc cannot compile yet
* - divergence: compiles, but the native program behaves differently from Node
+ * - performance: compiles and behaves as under Node, but runs slower than typed code would
* - environment: project configuration, scriptc's type world, toolchain or target
* - internal: a compiler bug */
export type DiagnosticCategory =
| "invalid-source"
| "unsupported"
| "divergence"
+ | "performance"
| "environment"
| "internal";
@@ -149,12 +152,13 @@ export interface DiagnosticsEnvelope {
/** The divergence band: compiles, but the program can behave differently
* from Node. Warnings, never build failures. */
export function isDivergenceCode(code: string): boolean {
- return /^SC6\d{3}$/.test(code);
+ return /^SC60\d{2}$/.test(code);
}
export function categoryOf(diag: ScrDiagnostic): DiagnosticCategory {
const code = diag.code;
if (isDivergenceCode(code)) return "divergence";
+ if (isPerformanceCode(code)) return "performance";
if (code === "SC0001") return diag.typeWorld === true ? "environment" : "invalid-source";
if (code === "SC0003") return "invalid-source";
if (code === "SC0002" || code === "SC0004") return "environment";
@@ -274,7 +278,7 @@ export function locateDiagnostic(
return {
code: diag.code,
category: categoryOf(diag),
- severity: isDivergenceCode(diag.code) ? "warning" : "error",
+ severity: isDivergenceCode(diag.code) || isPerformanceCode(diag.code) ? "warning" : "error",
scope,
file: diag.loc.file,
line: start.line,
diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts
index c1e3d8725..2312f5e85 100644
--- a/packages/compiler/src/executable/executable-cache.ts
+++ b/packages/compiler/src/executable/executable-cache.ts
@@ -11,6 +11,7 @@ import {
writeFile,
} from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
+import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { FrontendInputSnapshot } from "../frontend/input-tracker.js";
import { frontendInputsStillMatch, validFrontendInputSnapshot } from "../frontend/input-tracker.js";
import { compilerReleaseVersion } from "../library/sidecar.js";
@@ -112,6 +113,8 @@ interface EarlyExecutableCacheStamp {
};
nativeDependencies: NativeArtifactDependency[] | null;
native: EarlyExecutableNativeFeatures;
+ /** The build's performance warnings (SC61xx), which a cache hit repeats. */
+ performance?: ScrDiagnostic[];
integrity: string;
}
@@ -181,6 +184,8 @@ export interface EarlyExecutableCacheHit {
executableRestored: boolean;
native: EarlyExecutableNativeFeatures;
frontend: FrontendInputSnapshot;
+ /** The performance warnings the build reported (SC61xx), repeated on a hit. */
+ performance?: ScrDiagnostic[];
}
export interface EarlyExecutableCachePublish extends EarlyExecutableCacheHit {
@@ -610,6 +615,9 @@ async function readExecutableCache(
native: stamp.native,
executableRestored,
frontend: stamp.frontend,
+ ...(Array.isArray(stamp.performance) && stamp.performance.length > 0
+ ? { performance: stamp.performance }
+ : {}),
...(ir === null ? {} : { irPath: paths.irPath }),
};
} catch {
@@ -793,6 +801,9 @@ export async function publishEarlyExecutableCache(
},
nativeDependencies: executable === null ? null : result.nativeDependencies!,
native: result.native,
+ ...(result.performance === undefined || result.performance.length === 0
+ ? {}
+ : { performance: result.performance }),
};
const stamp: EarlyExecutableCacheStamp = { ...unsigned, integrity: stampIntegrity(unsigned) };
await writeFile(join(stage, "stamp.json"), `${JSON.stringify(stamp)}\n`, { mode: 0o600 });
diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts
index 4afae7c7d..1771f98f5 100644
--- a/packages/compiler/src/executable/prepare.ts
+++ b/packages/compiler/src/executable/prepare.ts
@@ -122,6 +122,6 @@ export function prepareExecutableModule(
mod: lowered.module!,
sourceTexts,
stats: lowered.stats,
- warnings: lowered.divergences ?? [],
+ warnings: [...(lowered.divergences ?? []), ...(lowered.performance ?? [])],
};
}
diff --git a/packages/compiler/src/frontend/analysis.ts b/packages/compiler/src/frontend/analysis.ts
index 43d0aa2bb..776492eb1 100644
--- a/packages/compiler/src/frontend/analysis.ts
+++ b/packages/compiler/src/frontend/analysis.ts
@@ -106,6 +106,7 @@ export function analyzeWithFrontend(
diagnostics: [...preflight, ...lowered.diagnostics],
...(lowered.runtimeFences.length > 0 ? { runtimeFences: lowered.runtimeFences } : {}),
...(lowered.divergences !== undefined ? { divergences: lowered.divergences } : {}),
+ ...(lowered.performance !== undefined ? { performance: lowered.performance } : {}),
...(lowered.unreached ? { unreached: lowered.unreached } : {}),
...(fe.npmStatic.length > 0 ? { npmStatic: fe.npmStatic } : {}),
// --provenance-sources: the per-package attribution inputs (the
diff --git a/packages/compiler/src/frontend/lowering/dynamic-tier.ts b/packages/compiler/src/frontend/lowering/dynamic-tier.ts
new file mode 100644
index 000000000..72cba9942
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/dynamic-tier.ts
@@ -0,0 +1,333 @@
+/* Hot dynamic-tier functions (SC6101, diagnostics/dynamic-tier.ts): the
+ * functions of a program that compile to the dynamic tier (ir/tiers.ts) and
+ * likely run often, so a build can name them. Without a profile, a function
+ * counts as likely hot when a dynamic operation runs on every iteration of
+ * one of its loops, when a loop calls it, or when it calls itself. Dynamic code that
+ * runs once (setup code that reads an `any` configuration, for example)
+ * stays quiet.
+ *
+ * The tier assignment is the one lowering made (Lowerer.dynamicFunctions);
+ * this module ranks its functions and says why each one is dynamic, from
+ * the source at its dynamic operations. A function is named only when the
+ * source says why (an `any`, an untyped `JSON.parse` result, an untyped
+ * JavaScript file): functions dynamic for operations without a static
+ * lowering would stay dynamic with more types. */
+import { relative } from "node:path";
+import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js";
+import {
+ dynamicTierDiag,
+ type DynamicTierPackageCount,
+ type HotReason,
+ type UntypedCause,
+} from "../../diagnostics/dynamic-tier.js";
+import type { IrExpr, IrFunction, IrModule, IrStmt, SrcLoc } from "../../ir/ir.js";
+import {
+ dynamicOperationOf,
+ dynamicOperationSites,
+ type DynamicOperationSite,
+} from "../../ir/tiers.js";
+import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
+import * as ts from "../ts7/adapter.js";
+import { isCheckedJsSourceFile, isJsSourceFile } from "../program.js";
+import { isNodeModulesPath } from "../resolve.js";
+import type { Lowerer } from "./lowerer.js";
+
+export interface HotDynamicFunction {
+ /** The IR function name (`%Point.norm`, `parse`, ...). */
+ name: string;
+ loc: SrcLoc;
+ hot: HotReason;
+ /** Dynamic operations inside the function's loops. */
+ loopOperations: number;
+ cause: UntypedCause;
+}
+
+export interface DynamicTierReport {
+ /** Likely-hot dynamic functions in the program's own files, most likely
+ * hot first. */
+ hot: HotDynamicFunction[];
+ /** Dynamic-tier functions in npm packages, by package. */
+ packages: DynamicTierPackageCount[];
+}
+
+interface FunctionFacts {
+ /** Dynamic operations in the parts of the function's loops that run on
+ * every iteration. */
+ loopSites: DynamicOperationSite[];
+ /** Callee names of direct calls, and whether each runs on every
+ * iteration of a loop. */
+ calls: { callee: string; inLoop: boolean }[];
+}
+
+/** Walks a function body by structure: a loop's condition, update and body
+ * repeat, while a `for` loop's initializer and a `for...of` loop's iterable
+ * run once. (Locations cannot tell them apart: lowering may give a loop's
+ * repeated parts the whole statement's span.) */
+function factsOf(fn: IrFunction): FunctionFacts {
+ const facts: FunctionFacts = { loopSites: [], calls: [] };
+ const expr = (e: IrExpr, inLoop: boolean): void => {
+ if (inLoop) {
+ const operation = dynamicOperationOf(e);
+ if (operation !== null) facts.loopSites.push({ operation, loc: e.loc });
+ }
+ if (e.kind === "call") facts.calls.push({ callee: e.callee, inLoop });
+ everyExprChild(
+ e,
+ (child) => {
+ expr(child, inLoop);
+ return true;
+ },
+ (child) => {
+ stmt(child, inLoop);
+ return true;
+ },
+ );
+ };
+ const children = (s: IrStmt, inLoop: boolean): void => {
+ everyStmtChild(
+ s,
+ (child) => {
+ expr(child, inLoop);
+ return true;
+ },
+ (child) => {
+ stmt(child, inLoop);
+ return true;
+ },
+ );
+ };
+ const stmt = (s: IrStmt, inLoop: boolean): void => {
+ switch (s.kind) {
+ case "for":
+ if (s.init !== null) stmt(s.init, inLoop);
+ if (s.cond !== null) expr(s.cond, true);
+ if (s.update !== null) stmt(s.update, true);
+ for (const b of s.body) stmt(b, true);
+ return;
+ case "forOf":
+ expr(s.iterable, inLoop);
+ for (const b of s.body) stmt(b, true);
+ return;
+ case "while":
+ case "doWhile":
+ children(s, true);
+ return;
+ default:
+ children(s, inLoop);
+ }
+ };
+ for (const s of fn.body) stmt(s, false);
+ return facts;
+}
+
+/** The package directory name of a node_modules path (`@scope/name` or `name`). */
+function packageOf(file: string): string {
+ const parts = file.split("/");
+ const at = parts.lastIndexOf("node_modules");
+ const first = parts[at + 1] ?? "";
+ return first.startsWith("@") ? `${first}/${parts[at + 2] ?? ""}` : first;
+}
+
+/** The innermost node spanning [start, end). */
+function nodeAt(sf: ts.SourceFile, start: number, end: number): ts.Node {
+ let node: ts.Node = sf;
+ for (;;) {
+ let next: ts.Node | undefined;
+ ts.forEachChild(node, (child) => {
+ if (ts.tokenStartOfNode(child) <= start && end <= child.end) {
+ next = child;
+ return true;
+ }
+ return undefined;
+ });
+ if (next === undefined) return node;
+ node = next;
+ }
+}
+
+/** The identifier an operand reads through (`items` in `items[i].x`). */
+function rootIdentifier(node: ts.Node): ts.Identifier | null {
+ let n: ts.Node = node;
+ for (;;) {
+ if (ts.isIdentifier(n)) return n;
+ if (
+ ts.isPropertyAccessExpression(n) ||
+ ts.isElementAccessExpression(n) ||
+ ts.isCallExpression(n)
+ )
+ n = n.expression;
+ else if (ts.isParenthesizedExpression(n) || ts.isNonNullExpression(n)) n = n.expression;
+ else return null;
+ }
+}
+
+function isJsonParse(node: ts.Node | undefined): boolean {
+ if (node === undefined || !ts.isCallExpression(node)) return false;
+ const callee = node.expression;
+ return (
+ ts.isPropertyAccessExpression(callee) &&
+ ts.isIdentifier(callee.expression) &&
+ callee.expression.text === "JSON" &&
+ callee.name.text === "parse"
+ );
+}
+
+/** Why an identifier holds a value without a static type, or null. */
+function identifierCause(lowerer: Lowerer, id: ts.Identifier, depth = 0): UntypedCause | null {
+ const symbol = lowerer.checker.getSymbolAtLocation(id);
+ const decl = symbol?.declarations?.[0];
+ // An unannotated `for (const item of items)` variable: name `items`.
+ if (
+ depth < 4 &&
+ decl !== undefined &&
+ ts.isVariableDeclaration(decl) &&
+ decl.type === undefined &&
+ ts.isVariableDeclarationList(decl.parent) &&
+ ts.isForOfStatement(decl.parent.parent)
+ ) {
+ const iterated = rootIdentifier(decl.parent.parent.expression);
+ if (iterated !== null) {
+ const cause = identifierCause(lowerer, iterated, depth + 1);
+ if (cause !== null) return cause;
+ }
+ }
+ if (decl !== undefined && ts.isVariableDeclaration(decl) && isJsonParse(decl.initializer))
+ return { kind: "json-binding", name: id.text };
+ const type = lowerer.checker.getTypeAtLocation(id);
+ if ((type.flags & ts.TypeFlags.Any) !== 0) return { kind: "any", name: id.text };
+ return null;
+}
+
+/** Why a node's value has no static type, or null. */
+function nodeCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null {
+ if (isJsonParse(n)) return { kind: "json" };
+ const id = rootIdentifier(n);
+ return id === null ? null : identifierCause(lowerer, id);
+}
+
+/** Why the dynamic operation at `loc` has no static type, or null when
+ * the source does not say. The site's own subexpressions are asked first
+ * (the operand of a dynamic `for...of`), then its ancestors. */
+function causeAt(lowerer: Lowerer, loc: SrcLoc): UntypedCause | null {
+ const sf = lowerer.program.getSourceFile(loc.file);
+ if (sf === undefined) return null;
+ if (isJsSourceFile(sf) && !isCheckedJsSourceFile(sf, lowerer.projectCheckJs))
+ return { kind: "javascript" };
+ const node = nodeAt(sf, loc.start, loc.end);
+ let found: UntypedCause | null = null;
+ const descend = (n: ts.Node, depth: number): void => {
+ if (found !== null || depth > 3) return;
+ found = nodeCause(lowerer, n);
+ if (found === null)
+ ts.forEachChild(n, (child) => (descend(child, depth + 1), found !== null || undefined));
+ };
+ descend(node, 0);
+ for (
+ let n: ts.Node | undefined = node.parent;
+ found === null && n !== undefined && n !== sf;
+ n = n.parent
+ )
+ found = nodeCause(lowerer, n);
+ return found;
+}
+
+/** The first cause the function's dynamic operations name, loop sites
+ * first, or null when none names one. */
+function causeOf(lowerer: Lowerer, sites: readonly { loc: SrcLoc }[]): UntypedCause | null {
+ for (const site of sites) {
+ const cause = causeAt(lowerer, site.loc);
+ if (cause !== null) return cause;
+ }
+ return null;
+}
+
+/** The likely-hot dynamic-tier functions of `module` (see the header). */
+export function scanDynamicTier(lowerer: Lowerer, module: IrModule): DynamicTierReport {
+ const facts = new Map();
+ for (const fn of module.functions) facts.set(fn.name, factsOf(fn));
+ // Functions some loop calls directly.
+ const calledInLoop = new Set();
+ for (const fn of module.functions) {
+ const f = facts.get(fn.name)!;
+ for (const call of f.calls) if (call.inLoop) calledInLoop.add(call.callee);
+ }
+ const hot: HotDynamicFunction[] = [];
+ const packages = new Map();
+ for (const fn of module.functions) {
+ // Generated `%dyn.*` helpers are dynamic operations lowered out of line,
+ // and `%init.*` is a module's top level: neither is a function to name.
+ if (fn.tier !== "dynamic" || fn.name.startsWith("%dyn.") || fn.name.startsWith("%init."))
+ continue;
+ if (isNodeModulesPath(fn.loc.file)) {
+ const name = packageOf(fn.loc.file);
+ packages.set(name, (packages.get(name) ?? 0) + 1);
+ continue;
+ }
+ const f = facts.get(fn.name)!;
+ const sites = dynamicOperationSites(fn);
+ const inLoops = f.loopSites;
+ const recursive = f.calls.some((c) => c.callee === fn.name);
+ const reason: HotReason | null =
+ inLoops.length > 0
+ ? "loop"
+ : calledInLoop.has(fn.name)
+ ? "called-in-loop"
+ : recursive
+ ? "recursive"
+ : null;
+ if (reason === null) continue;
+ // Only functions the source makes dynamic are named: ones dynamic for
+ // operations without a static lowering would stay dynamic with more types.
+ const looped = new Set(inLoops.map((s) => `${s.loc.file}:${s.loc.start}:${s.loc.end}`));
+ const rest = sites.filter((s) => !looped.has(`${s.loc.file}:${s.loc.start}:${s.loc.end}`));
+ const cause = causeOf(lowerer, [...inLoops, ...rest]);
+ if (cause === null) continue;
+ hot.push({
+ name: fn.name,
+ loc: fn.loc,
+ hot: reason,
+ loopOperations: inLoops.length,
+ cause,
+ });
+ }
+ const rank = { loop: 0, "called-in-loop": 1, recursive: 2 } as const;
+ hot.sort((a, b) => rank[a.hot] - rank[b.hot] || b.loopOperations - a.loopOperations);
+ return {
+ hot,
+ packages: [...packages]
+ .map(([name, functions]) => ({ name, functions }))
+ .sort((a, b) => b.functions - a.functions || (a.name < b.name ? -1 : 1)),
+ };
+}
+
+/** `%%m144.Session.finish` → `Session.finish`, `m163.find` → `find`. */
+function displayName(name: string): string {
+ return name.replace(/^%+/, "").replace(/^m\d+\./, "");
+}
+
+/** SC6101 for the program lowering produced, or null (see the header).
+ * Paths are relative to `root`, the entry's directory. */
+export function dynamicTierWarning(
+ lowerer: Lowerer,
+ module: IrModule,
+ root: string,
+): ScrDiagnostic | null {
+ const report = scanDynamicTier(lowerer, module);
+ const first = report.hot[0];
+ if (first === undefined) return null;
+ const where = (loc: SrcLoc): string => {
+ const sf = lowerer.program.getSourceFile(loc.file);
+ const line = sf === undefined ? 0 : ts.getLineAndCharacterOfPosition(sf, loc.start).line + 1;
+ return `${relative(root, loc.file) || loc.file}:${line}`;
+ };
+ return dynamicTierDiag(
+ report.hot.map((f) => ({
+ name: displayName(f.name),
+ where: where(f.loc),
+ cause: f.cause,
+ hot: f.hot,
+ })),
+ report.packages,
+ first.loc,
+ );
+}
diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts
index c4d24cef1..2bd527285 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.ts
@@ -148,6 +148,7 @@ import { ClassProtocols } from "./class-protocols.js";
import { earlyReadVarSymbols } from "./var-hoisting.js";
import { recordTextCodecClass } from "../../ir/ir.js";
import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js";
+import { dynamicTierWarning } from "./dynamic-tier.js";
import { promoteJsvals } from "../../ir/jsval.js";
import {
analyzeTiers,
@@ -176,7 +177,7 @@ export { newFnCtx, type FnCtx } from "./function-context.js";
* - Lexical scoping is resolved here: locals get function-unique ids
* ("x.0", "x.1" for shadowing); the IR is scope-flat.
*/
-import { resolve } from "node:path";
+import { dirname, resolve } from "node:path";
import { bindingSource } from "../binding-source.js";
import { tsgoPath } from "../dts-paths.js";
import * as ts from "../ts7/adapter.js";
@@ -786,6 +787,9 @@ export interface LowerResult {
/** Compile-time-detectable behavior differences from Node (SC6xxx
* divergence warnings) in the program's own modules. Never failures. */
divergences?: ScrDiagnostic[];
+ /** Performance warnings (SC61xx): the likely-hot functions the source
+ * leaves untyped (lowering/dynamic-tier.ts). Never failures. */
+ performance?: ScrDiagnostic[];
/** Functions lowering placed in the dynamic tier (ir/tiers.ts), present
* when there are any. */
dynamicFunctions?: DynamicFunction[];
@@ -1162,6 +1166,13 @@ export function lowerToIr(
const divergences = scanDivergences(resultLowerer, moduleOrder);
timing("divergence-scan", { found: divergences.length });
if (divergences.length > 0) result = { ...result, divergences };
+ // The performance warning (SC6101): likely-hot functions of the program
+ // that compile to the dynamic tier because the source leaves them untyped.
+ if (result.module !== null && resultLowerer.dynamicFunctions.length > 0) {
+ const hotDynamic = dynamicTierWarning(resultLowerer, result.module, dirname(entry.fileName));
+ timing("dynamic-tier-scan", { found: hotDynamic === null ? 0 : 1 });
+ if (hotDynamic !== null) result = { ...result, performance: [hotDynamic] };
+ }
// Contract types (@scriptc/types): written annotations, checked against
// the IR by the compile pipeline (contracts/check.ts).
// Acyclic object types name the IR unit the program lowered them to; a
diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts
index a1f8601cc..92ee6b75e 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -157,6 +157,7 @@ import {
type EarlyExecutableNativeFeatures,
} from "./executable/executable-cache.js";
import { compilerImplementationIdentity } from "./library/compiler-self-identity.js";
+import { isPerformanceCode } from "./diagnostics/dynamic-tier.js";
export const VERSION = "0.0.1";
@@ -454,6 +455,15 @@ interface CompileWarningSink {
sourceTexts: Map | null;
}
+/** The build's performance warnings (SC61xx), stored with its cache entry
+ * so a cache hit repeats them. */
+function performanceWarnings(sink: CompileWarningSink | undefined): {
+ performance?: ScrDiagnostic[];
+} {
+ const performance = (sink?.warnings ?? []).filter((d) => isPerformanceCode(d.code));
+ return performance.length === 0 ? {} : { performance };
+}
+
function withCompileWarnings(
result: T,
sink: CompileWarningSink,
@@ -1406,6 +1416,12 @@ async function compileTracked(
if (earlyCacheOptions === null) {
throw new InternalCompilerError("executable cache hit without executable cache options");
}
+ // A cached build repeats its performance warning (SC6101): the code it
+ // names is just as untyped as when it was built.
+ if (warningSink !== undefined && earlyHit.performance !== undefined) {
+ warningSink.warnings = earlyHit.performance;
+ warningSink.sourceTexts = new Map();
+ }
const executableCacheOptions = earlyCacheOptions;
// Route/proof metadata is independently evictable. A full-compiler
// fallback that still finds the validated payload repairs that lightweight
@@ -1595,6 +1611,7 @@ async function compileTracked(
nativeDependencies: dependencies,
frontend: frontendInputs.snapshot(),
...(irPath === undefined ? {} : { irPath }),
+ ...performanceWarnings(warningSink),
});
publishedExecutable = true;
},
@@ -1636,6 +1653,7 @@ async function compileTracked(
executableRestored: false,
frontend: frontendInputs.snapshot(),
...(irPath === undefined ? {} : { irPath }),
+ ...performanceWarnings(warningSink),
}).catch(() => undefined);
}
await pruneBuildCache(cacheRoot);
diff --git a/packages/compiler/src/startup-cache.ts b/packages/compiler/src/startup-cache.ts
index c2f82fdad..1bc74bef0 100644
--- a/packages/compiler/src/startup-cache.ts
+++ b/packages/compiler/src/startup-cache.ts
@@ -25,3 +25,4 @@ export function precompiledRuntimePackTarget(
export { checkerCacheIdentity } from "./frontend/ts7/checker-backend.js";
export { readCoverageVerdict } from "./coverage/verdict-startup.js";
export { coverageEnvelope, coveragePasses, renderCoverage } from "./coverage/report.js";
+export { renderPerformanceWarnings } from "./diagnostics/dynamic-tier.js";
diff --git a/tests/coverage-fixtures/dynamic-tier/hot.js b/tests/coverage-fixtures/dynamic-tier/hot.js
new file mode 100644
index 000000000..af2afac07
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-tier/hot.js
@@ -0,0 +1,36 @@
+// Untyped JavaScript whose functions loop over their arguments: the build
+// names them (SC6101). `scale` runs once and stays quiet.
+function scale(points, factor) {
+ return { points, factor };
+}
+
+function centroid(points) {
+ let x = 0;
+ let y = 0;
+ for (const p of points) {
+ x += p.x;
+ y += p.y;
+ }
+ return { x: x / points.length, y: y / points.length };
+}
+
+function distance(a, b) {
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ return Math.sqrt(dx * dx + dy * dy);
+}
+
+function spread(points) {
+ const c = centroid(points);
+ let total = 0;
+ for (let i = 0; i < points.length; i++) total += distance(points[i], c);
+ return total;
+}
+
+const points = [
+ { x: 0, y: 0 },
+ { x: 4, y: 0 },
+ { x: 0, y: 3 },
+];
+const scaled = scale(points, 2);
+console.log(scaled.factor, spread(points).toFixed(3));
diff --git a/tests/coverage-fixtures/dynamic-tier/package/main.ts b/tests/coverage-fixtures/dynamic-tier/package/main.ts
new file mode 100644
index 000000000..3acd390da
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-tier/package/main.ts
@@ -0,0 +1,13 @@
+// A program whose own hot function uses an `any` value, and which calls an
+// untyped npm package: the warning names the function and summarizes the
+// package's dynamic-tier code.
+import { tally } from "untyped-lib";
+
+function sumAmounts(rows: any): number {
+ let total = 0;
+ for (const row of rows) total += row.amount;
+ return total;
+}
+
+const rows = [{ amount: 2 }, { amount: 5 }];
+console.log(sumAmounts(rows), tally(["a", "b", "a"]).a);
diff --git a/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.d.ts b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.d.ts
new file mode 100644
index 000000000..c8871efd0
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.d.ts
@@ -0,0 +1 @@
+export function tally(values: any): any;
diff --git a/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.js b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.js
new file mode 100644
index 000000000..5695b6df2
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.js
@@ -0,0 +1,6 @@
+// Counts each value: untyped JavaScript in a package.
+exports.tally = function tally(values) {
+ const counts = {};
+ for (const v of values) counts[v] = (counts[v] || 0) + 1;
+ return counts;
+};
diff --git a/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/package.json b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/package.json
new file mode 100644
index 000000000..a81ea84d4
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/package.json
@@ -0,0 +1 @@
+{"name": "untyped-lib", "version": "1.0.0", "main": "index.js", "types": "index.d.ts"}
diff --git a/tests/coverage-fixtures/dynamic-tier/quiet.ts b/tests/coverage-fixtures/dynamic-tier/quiet.ts
new file mode 100644
index 000000000..1b0a212ea
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-tier/quiet.ts
@@ -0,0 +1,23 @@
+// Untyped values read once (a configuration at startup, a loop's
+// initializer, a for...of iterable): dynamic, but not hot, so the build
+// names nothing. The loops repeat only typed work.
+const config: any = JSON.parse('{"scale": 3, "label": "sum"}');
+const scale: number = config.scale;
+const label: string = config.label;
+
+function sum(values: number[]): number {
+ let total = 0;
+ for (const v of values) total += v * scale;
+ return total;
+}
+
+// Untyped values read once, in a loop's initializer and in a for...of
+// iterable: the loops repeat only typed work.
+function offsets(raw: any): number {
+ let total = 0;
+ for (let i: number = raw.start; i < 3; i++) total += i;
+ for (const n of raw.extra as number[]) total += n;
+ return total;
+}
+
+console.log(label, sum([1, 2, 3]), offsets({ start: 1, extra: [4, 5] }));
diff --git a/tests/coverage-fixtures/dynamic-tier/typed-hot.ts b/tests/coverage-fixtures/dynamic-tier/typed-hot.ts
new file mode 100644
index 000000000..e72a7ca7a
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-tier/typed-hot.ts
@@ -0,0 +1,21 @@
+// TypeScript functions that loop over untyped values: an `any` parameter
+// and an untyped JSON.parse result (SC6101 names both).
+function regionTotals(items: any): Map {
+ const out = new Map();
+ for (const item of items) out.set(item.region, (out.get(item.region) ?? 0) + item.amount);
+ return out;
+}
+
+function countRegions(text: string): number {
+ const parsed = JSON.parse(text);
+ let n = 0;
+ for (let i = 0; i < parsed.length; i++) if (parsed[i].region !== "") n++;
+ return n;
+}
+
+const rows = [
+ { amount: 3, region: "eu" },
+ { amount: 4, region: "us" },
+];
+for (const [region, total] of regionTotals(rows)) console.log(region, total);
+console.log(countRegions(JSON.stringify(rows)));
diff --git a/tests/harness/__snapshots__/coverage-dynamic-tier.txt b/tests/harness/__snapshots__/coverage-dynamic-tier.txt
new file mode 100644
index 000000000..8072021e3
--- /dev/null
+++ b/tests/harness/__snapshots__/coverage-dynamic-tier.txt
@@ -0,0 +1,13 @@
+scriptc coverage tests/coverage-fixtures/dynamic-tier/hot.js
+
+ statements analyzed 18
+ compile statically 18 (100%)
+ in dynamic tier 14 (operate on values typed only at runtime; --explain-dynamic lists them)
+
+ fully static — all analyzed statements compile to native code.
+
+ untyped and likely hot 3 functions that likely run often use untyped values, which runs slower than typed code:
+ centroid (hot.js:7): untyped JavaScript file, in a loop
+ spread (hot.js:23): untyped JavaScript file, in a loop
+ distance (hot.js:17): untyped JavaScript file, called in a loop
+ hint: add types to compile these statically; see scriptc coverage --explain-dynamic
\ No newline at end of file
diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts
index cc91c0922..ca62352b1 100644
--- a/tests/harness/coverage.test.ts
+++ b/tests/harness/coverage.test.ts
@@ -113,6 +113,48 @@ test("ordinary code reports no divergence", () => {
expect(coverage.divergences ?? []).toEqual([]);
});
+test("likely-hot functions with untyped values report as a performance warning", async () => {
+ const { coverage } = analyze(fixture("dynamic-tier/hot.js"));
+ expect(coverage.diagnostics).toEqual([]);
+ expect(coverage.divergences ?? []).toEqual([]);
+ expect((coverage.performance ?? []).map((d) => d.code)).toEqual(["SC6101"]);
+ // Not a blocker or a divergence: only --fail-on=dynamic fails on it.
+ expect(coveragePasses(coverage, "divergences")).toBe(true);
+ expect(coveragePasses(coverage, "dynamic")).toBe(false);
+ const envelope = coverageEnvelope(coverage, { compilerVersion: "test" });
+ expect(envelope.diagnostics.map((d) => [d.code, d.category, d.severity])).toEqual([
+ ["SC6101", "performance", "warning"],
+ ]);
+ await expect(report(fixture("dynamic-tier/hot.js"))).toMatchFileSnapshot(
+ "__snapshots__/coverage-dynamic-tier.txt",
+ );
+});
+
+test("the performance warning names the untyped value of each function", () => {
+ const { coverage } = analyze(fixture("dynamic-tier/typed-hot.ts"));
+ expect(coverage.performance?.[0]?.message.split("\n")).toEqual([
+ "2 functions that likely run often use untyped values, which runs slower than typed code:",
+ " regionTotals (typed-hot.ts:3): `items` is `any`, in a loop",
+ " countRegions (typed-hot.ts:9): `parsed` holds an untyped `JSON.parse` result, in a loop",
+ ]);
+});
+
+test("untyped setup code that runs once reports no performance warning", () => {
+ const { coverage } = analyze(fixture("dynamic-tier/quiet.ts"));
+ expect(coverage.diagnostics).toEqual([]);
+ expect(coverage.performance).toBeUndefined();
+ expect(coveragePasses(coverage, "dynamic")).toBe(true);
+});
+
+test("dynamic-tier code in npm packages is summarized by package", () => {
+ const { coverage } = analyze(fixture("dynamic-tier/package/main.ts"));
+ expect(coverage.performance?.[0]?.message.split("\n")).toEqual([
+ "1 function that likely runs often uses untyped values, which runs slower than typed code:",
+ " sumAmounts (main.ts:6): `rows` is `any`, in a loop",
+ " untyped code in npm packages: untyped-lib (1 function)",
+ ]);
+});
+
test("statements of a signature-blocked function count as not static", () => {
const { coverage } = analyze(fixture("blocked-signature.ts"));
const un = coverage.unreached?.stats;