mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-15 04:50:40 +08:00
* Add static Node path function values * Preserve JavaScript builtin alias fences
379 lines
16 KiB
TypeScript
379 lines
16 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { globSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { expect, test } from "vitest";
|
|
import { analyze, renderCoverage } from "@scriptc/compiler";
|
|
import { shardSelect, shardSuffix } from "./shard.js";
|
|
|
|
const repoRoot = join(import.meta.dirname, "../..");
|
|
const fixture = (name: string) => join(repoRoot, "tests/coverage-fixtures", name);
|
|
|
|
function report(path: string, opts: { dynamic?: boolean } = {}): string {
|
|
const { coverage } = analyze(path, opts);
|
|
return renderCoverage({ ...coverage, file: coverage.file.replace(repoRoot + "/", "") });
|
|
}
|
|
|
|
test("mixed program: percentage and grouped blockers", async () => {
|
|
await expect(report(fixture("mixed.ts"))).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-mixed.txt",
|
|
);
|
|
});
|
|
|
|
test("dynamic-capable blockers split from static rejections", async () => {
|
|
// SC2010/SC2011/SC2012 sites group under "runs with --dynamic";
|
|
// constructs no flag fixes stay under "blockers".
|
|
await expect(report(fixture("dynamic-mix.ts"))).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-dynamic-mix.txt",
|
|
);
|
|
});
|
|
|
|
test("--dynamic analysis: island sites count as compiled-dynamically", async () => {
|
|
// The same fixture analyzed as a --dynamic build: the island-capable
|
|
// sites LOWER (splitting out of "compile statically" into "compile
|
|
// dynamically") and only the flag-independent rejections remain
|
|
// blockers — this report is the "distance to a working build" view.
|
|
await expect(report(fixture("dynamic-mix.ts"), { dynamic: true })).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-dynamic-mix-dynamic.txt",
|
|
);
|
|
});
|
|
|
|
test("unreached blockers report in their own group", async () => {
|
|
// Bodies nothing on the entry path reaches lower only for analysis:
|
|
// their blockers render dim, under "in unreached code" — and the same
|
|
// program BUILDS (the differential dead-strip tests pin that side).
|
|
await expect(report(fixture("unreached.ts"))).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-unreached.txt",
|
|
);
|
|
});
|
|
|
|
test("fully static program reports 100%", () => {
|
|
const out = report(join(repoRoot, "tests/corpus/400-fib.ts"));
|
|
expect(out).toContain("(100%)");
|
|
expect(out).toContain("fully static");
|
|
});
|
|
|
|
test("fully static JavaScript program reports 100%", () => {
|
|
const out = report(join(repoRoot, "tests/corpus/1590-js-unannotated.js"));
|
|
expect(out).toContain("(100%)");
|
|
expect(out).toContain("fully static");
|
|
});
|
|
|
|
test("settled generic rest-order fences count each source statement once", () => {
|
|
const { coverage } = analyze(join(repoRoot, "tests/diagnostics/retained-generic-rest-order.ts"));
|
|
expect(coverage.diagnostics.map((d) => d.code)).toEqual(["SC1031", "SC2004"]);
|
|
expect(coverage.stats.statementsFailed).toBe(2);
|
|
});
|
|
|
|
test("JS inference gaps land where 'any' lands: SC2011 static, island dynamic", async () => {
|
|
// The js-gap fixture's tsconfig turns noImplicitAny off, so the untyped
|
|
// parameter types `any` — the static analysis reports the site as
|
|
// dynamic-capable (SC2011) while the JSDoc-typed neighbor stays static.
|
|
await expect(report(fixture("js-gap/gap.js"))).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-js-gap.txt",
|
|
);
|
|
await expect(report(fixture("js-gap/gap.js"), { dynamic: true })).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-js-gap-dynamic.txt",
|
|
);
|
|
});
|
|
|
|
test("JavaScript builtin aliases keep their deferred value fence", async () => {
|
|
await expect(report(fixture("js-builtin-callable-alias.js"))).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-js-builtin-callable-alias.txt",
|
|
);
|
|
});
|
|
|
|
test("any-typed checked-dynamic locals honor the --dynamic coverage promise", () => {
|
|
const file = join(repoRoot, "tests/corpus/2856-dynamic-any-local-operators.ts");
|
|
const staticCoverage = analyze(file).coverage;
|
|
expect(staticCoverage.diagnostics.length).toBeGreaterThan(0);
|
|
expect(new Set(staticCoverage.diagnostics.map((d) => d.code))).toEqual(new Set(["SC2011"]));
|
|
|
|
const dynamicCoverage = analyze(file, { dynamic: true }).coverage;
|
|
expect(dynamicCoverage.diagnostics).toEqual([]);
|
|
expect(dynamicCoverage.stats.statementsFailed).toBe(0);
|
|
expect(dynamicCoverage.stats.statementsIsland).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("npm package sites attribute per package", async () => {
|
|
// Every site of a package-declared value groups into one SC2013 line
|
|
// naming the package ("values from the 'mathkit' package ..."), inside
|
|
// the "runs with --dynamic" group — reusing the diagnostics fixture so
|
|
// the two views of the same program stay in sync.
|
|
await expect(report(join(repoRoot, "tests/diagnostics/npm-imports/main.ts"))).toMatchFileSnapshot(
|
|
"__snapshots__/coverage-npm-packages.txt",
|
|
);
|
|
});
|
|
|
|
test("lazy edges inventory: unresolvable require()/import() targets mark as lazy traps", async () => {
|
|
// The lazy-traps npm fixture builds (Node's per-kind edge semantics:
|
|
// require fails at the call, import() at evaluation) — the coverage
|
|
// table lists every blocked-but-lazy specifier with the lazy-trap
|
|
// marker instead of reporting SC2030 blockers.
|
|
await expect(
|
|
report(join(repoRoot, "tests/fixtures/npm/cases/lazy-traps/main.ts"), { dynamic: true }),
|
|
).toMatchFileSnapshot("__snapshots__/coverage-npm-lazy-traps.txt");
|
|
});
|
|
|
|
test("lazy builtin edges mark in the builtins table, __require sites included", async () => {
|
|
// The esbuild-require fixture routes external requires through the
|
|
// bundle's __require helper — its literal call sites collect as require
|
|
// edges, so the builtins table lists node:os/node:tty (shimmed) and
|
|
// node:stream as a lazy trap (unshimmed, reached only by the
|
|
// never-called require) without failing the build.
|
|
await expect(
|
|
report(join(repoRoot, "tests/fixtures/npm/cases/esbuild-require/main.ts"), { dynamic: true }),
|
|
).toMatchFileSnapshot("__snapshots__/coverage-npm-lazy-builtin.txt");
|
|
});
|
|
|
|
test("import fences no longer stop analysis: percentage plus module blockers", async () => {
|
|
// The fenced module reports ONE grouped blocker (the import line plus
|
|
// every use of its bindings carry the same message); the rest of the
|
|
// program still counts. Lives in the node-types fixture: builtin
|
|
// specifiers only RESOLVE (typecheck) with @types/node adopted — the
|
|
// fallback world turns them into tsc errors, which stay fatal. The same
|
|
// file must FAIL a build (compile() keeps preflight fatal).
|
|
await expect(
|
|
report(join(repoRoot, "tests/fixtures/node-types/import-fences.ts")),
|
|
).toMatchFileSnapshot("__snapshots__/coverage-import-fences.txt");
|
|
});
|
|
|
|
test("external host declarations unblock application coverage without inventing runtime semantics", () => {
|
|
const root = fixture("external-types");
|
|
const entry = join(root, "main.ts");
|
|
const externalTypes = { "@native-sdk/core": join(root, "native-sdk-core.d.ts") };
|
|
|
|
const unmapped = analyze(entry).coverage;
|
|
expect(unmapped.preflightFailed).toBe(true);
|
|
expect(unmapped.diagnostics.some((d) => d.code === "SC0001" && d.message.includes("@native-sdk/core"))).toBe(true);
|
|
|
|
const { coverage } = analyze(entry, { externalTypes });
|
|
expect(coverage.preflightFailed).toBe(false);
|
|
expect(coverage.stats.statementsTotal).toBeGreaterThan(0);
|
|
expect(coverage.diagnostics.some((d) => d.code === "SC0001")).toBe(false);
|
|
expect(
|
|
coverage.diagnostics.some(
|
|
(d) => d.code === "SC1010" && d.message.includes("external host module") && d.message.includes("--external-types"),
|
|
),
|
|
).toBe(true);
|
|
const out = renderCoverage({ ...coverage, file: "tests/coverage-fixtures/external-types/main.ts" });
|
|
expect(out).toContain("statements analyzed");
|
|
expect(out).toContain("@native-sdk/core");
|
|
});
|
|
|
|
test("external declaration barrels leave type-only local code fully analyzable", () => {
|
|
const root = fixture("external-types");
|
|
const { coverage } = analyze(join(root, "type-only.ts"), {
|
|
externalTypes: { "@native-sdk/core": join(root, "native-sdk-core.d.ts") },
|
|
});
|
|
expect(coverage.preflightFailed).toBe(false);
|
|
expect(coverage.diagnostics).toEqual([]);
|
|
expect(coverage.stats.statementsFailed).toBe(0);
|
|
});
|
|
|
|
test("external host mappings fence dynamic imports even with the island enabled", () => {
|
|
const root = fixture("external-types");
|
|
const { coverage } = analyze(join(root, "dynamic.ts"), {
|
|
dynamic: true,
|
|
externalTypes: { "@native-sdk/core": join(root, "native-sdk-core.d.ts") },
|
|
});
|
|
expect(coverage.preflightFailed).toBe(false);
|
|
expect(coverage.stats.statementsFailed).toBeGreaterThan(0);
|
|
expect(coverage.stats.statementsIsland).toBe(0);
|
|
expect(
|
|
coverage.diagnostics.some(
|
|
(d) => d.code === "SC1010" && d.message.includes("@native-sdk/core") && d.message.includes("--external-types"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("external host mappings fence side-effect CommonJS requires", () => {
|
|
const root = fixture("external-types");
|
|
const { coverage } = analyze(join(root, "side-effect.cjs"), {
|
|
externalTypes: { "@native-sdk/core": join(root, "native-sdk-core.d.ts") },
|
|
});
|
|
expect(coverage.preflightFailed).toBe(false);
|
|
expect(coverage.stats.statementsFailed).toBeGreaterThan(0);
|
|
const blockers = [...coverage.diagnostics, ...(coverage.runtimeFences ?? [])];
|
|
expect(
|
|
blockers.some(
|
|
(d) => d.code === "SC1010" && d.message.includes("@native-sdk/core") && d.message.includes("external host module"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("shared external declarations retain the specifier selected by each local facade", () => {
|
|
const root = fixture("external-types");
|
|
const declaration = join(root, "native-sdk-core.d.ts");
|
|
const { coverage } = analyze(join(root, "facade-consumer.ts"), {
|
|
externalTypes: {
|
|
"@native-sdk/core": declaration,
|
|
"@native-sdk/unused": declaration,
|
|
},
|
|
});
|
|
expect(coverage.preflightFailed).toBe(false);
|
|
const valueBlockers = coverage.diagnostics.filter(
|
|
(d) => d.code === "SC1010" && d.message.startsWith("values from"),
|
|
);
|
|
expect(valueBlockers.some((d) => d.message.includes("@native-sdk/core"))).toBe(true);
|
|
expect(valueBlockers.some((d) => d.message.includes("@native-sdk/unused"))).toBe(true);
|
|
});
|
|
|
|
test("external star facades do not claim local exports", () => {
|
|
const root = fixture("external-types");
|
|
const { coverage } = analyze(join(root, "mixed-star-consumer.ts"), {
|
|
externalTypes: { "@native-sdk/core": join(root, "native-sdk-core.d.ts") },
|
|
});
|
|
expect(coverage.preflightFailed).toBe(false);
|
|
expect(coverage.stats.statementsTotal).toBe(2);
|
|
expect(coverage.stats.statementsFailed).toBe(0);
|
|
expect(
|
|
coverage.diagnostics.some(
|
|
(d) => d.code === "SC1010" && d.message.includes("the '@native-sdk/core' external host module"),
|
|
),
|
|
).toBe(true);
|
|
expect(coverage.diagnostics.some((d) => d.code === "SC1010" && d.message.startsWith("values from"))).toBe(false);
|
|
});
|
|
|
|
test("external host mappings take precedence over overlapping npm-static packages", () => {
|
|
const root = fixture("external-types");
|
|
const entry = join(repoRoot, "tests/fixtures/npm-static/slash-cli.ts");
|
|
const { coverage } = analyze(entry, {
|
|
npmStatic: ["slash"],
|
|
externalTypes: { slash: join(root, "slash-host.d.ts") },
|
|
});
|
|
expect(coverage.preflightFailed).toBe(false);
|
|
expect(coverage.npmStatic).toEqual([
|
|
{
|
|
package: "slash",
|
|
status: "fallback",
|
|
detail: 'mapped as an external host module by --external-types ("slash")',
|
|
},
|
|
]);
|
|
expect(coverage.diagnostics.some((d) => d.code === "SC2013")).toBe(false);
|
|
expect(coverage.diagnostics.some((d) => d.code === "SC1010" && d.message.includes("'slash'"))).toBe(true);
|
|
});
|
|
|
|
test("CLI accepts repeatable --external-types mappings for coverage", () => {
|
|
const root = fixture("external-types");
|
|
const scriptcCli = join(repoRoot, "packages/cli/src/main.ts");
|
|
const out = execFileSync(
|
|
process.execPath,
|
|
[
|
|
"--import",
|
|
"tsx",
|
|
scriptcCli,
|
|
"coverage",
|
|
join(root, "main.ts"),
|
|
"--external-types",
|
|
`@native-sdk/core=${join(root, "native-sdk-core.d.ts")}`,
|
|
"--external-types",
|
|
`@native-sdk/unused=${join(root, "native-sdk-core.d.ts")}`,
|
|
],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
);
|
|
expect(out).toContain("statements analyzed");
|
|
expect(out).toContain("@native-sdk/core");
|
|
expect(out).not.toContain("Cannot find module");
|
|
});
|
|
|
|
test("external type mappings reject TypeScript paths patterns", () => {
|
|
const root = fixture("external-types");
|
|
const entry = join(root, "type-only.ts");
|
|
const declaration = join(root, "native-sdk-core.d.ts");
|
|
expect(() => analyze(entry, { externalTypes: { "@native-sdk/*": declaration } })).toThrow(
|
|
"expected an exact bare package specifier",
|
|
);
|
|
|
|
const scriptcCli = join(repoRoot, "packages/cli/src/main.ts");
|
|
const cli = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"--import",
|
|
"tsx",
|
|
scriptcCli,
|
|
"coverage",
|
|
entry,
|
|
"--external-types",
|
|
`@native-sdk/*=${declaration}`,
|
|
],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
);
|
|
expect(cli.status).toBe(1);
|
|
expect(cli.stderr).toContain("expected an exact bare package specifier");
|
|
});
|
|
|
|
test("external type mappings reject non-declaration and unreadable API paths", () => {
|
|
const root = fixture("external-types");
|
|
const entry = join(root, "type-only.ts");
|
|
expect(() => analyze(entry, { externalTypes: { "@native-sdk/core": join(root, "main.ts") } })).toThrow(
|
|
"expected a .d.ts, .d.mts, or .d.cts file",
|
|
);
|
|
expect(() => analyze(entry, { externalTypes: { "@native-sdk/core": join(root, "missing.d.ts") } })).toThrow(
|
|
"does not name a readable file",
|
|
);
|
|
});
|
|
|
|
test("type errors block analysis", () => {
|
|
const out = report(fixture("type-errors.ts"));
|
|
expect(out).toContain("not analyzable");
|
|
expect(out).toContain("1 TypeScript error");
|
|
});
|
|
|
|
test("type errors render the same code frame a build prints", () => {
|
|
const { coverage, sourceTexts } = analyze(fixture("type-errors.ts"));
|
|
const out = renderCoverage(coverage, { sourceTexts });
|
|
expect(out).toContain("not analyzable");
|
|
expect(out).toContain("error SC0001");
|
|
// The frame gutter with the offending line, exactly the build renderer.
|
|
expect(out).toMatch(/\d+ \| /);
|
|
expect(out).toContain("^");
|
|
});
|
|
|
|
test("bare '.' and '..' imports are relative edges (the vercel-CLI dialect)", () => {
|
|
// `from '..'` / `from '.'` resolve as directory imports to the target's
|
|
// index module — exactly what the project's bundler-resolution checker
|
|
// answered — so the graph loads and everything compiles statically.
|
|
const out = report(fixture("dot-parent/main.ts"));
|
|
expect(out).toContain("(100%)");
|
|
expect(out).toContain("fully static");
|
|
});
|
|
|
|
test("a cycle whose top level calls a builtin with a builtin function value is admitted", () => {
|
|
// vercel's link.ts cluster: `promisify(fs.readFile)` at a cycle member's
|
|
// top level. Callee and callable argument are both dts-rooted — no user
|
|
// code can run in the init window — so no SC1016 and the analysis
|
|
// renders statement counts (the statement itself may stay a
|
|
// statement-LEVEL blocker; that is a different fence).
|
|
const out = report(fixture("cycle-inert-builtin/main.ts"));
|
|
expect(out).not.toContain("circular imports");
|
|
expect(out).toContain("statements analyzed");
|
|
});
|
|
|
|
test(`every corpus program is 100% static (corpus and coverage agree${shardSuffix()})`, async () => {
|
|
// The differential corpus compiles by definition; coverage must agree.
|
|
// `// @dynamic` programs compile under --dynamic, so analyze them that way.
|
|
// The sweep runs minutes on slow machines and each analyze() blocks the
|
|
// worker thread — without the periodic yield, vitest's worker RPC starves
|
|
// ("Timeout calling onTaskUpdate") even while every check passes, and the
|
|
// default per-test timeout is far too small for a whole-corpus analysis.
|
|
let n = 0;
|
|
const flatEntries = shardSelect(
|
|
["ts", "js", "mjs", "cjs"].flatMap((ext) =>
|
|
globSync(join(repoRoot, `tests/corpus/*.${ext}`)),
|
|
).sort(),
|
|
(file) => file.slice(repoRoot.length + 1),
|
|
);
|
|
for (const file of flatEntries) {
|
|
const firstLine = readFileSync(file, "utf8").split("\n", 1)[0] ?? "";
|
|
// `// @deferred-fences: N` on the first line: a JS program that
|
|
// DELIBERATELY carries N runtime-fence statements on untaken paths
|
|
// (the deferred-fence corpus) — it still passes the differential
|
|
// oracle because the fences never execute.
|
|
const deferred = /^\/\/ @deferred-fences:\s*(\d+)\s*$/.exec(firstLine);
|
|
const { coverage } = analyze(file, { dynamic: /^\/\/ @dynamic\s*$/.test(firstLine) });
|
|
expect.soft(coverage.diagnostics, file).toEqual([]);
|
|
expect.soft(coverage.stats.statementsFailed, file).toBe(deferred ? Number(deferred[1]) : 0);
|
|
if (++n % 10 === 0) await new Promise((r) => setImmediate(r));
|
|
}
|
|
}, 600_000);
|