Files
scriptc/tests/harness/coverage.test.ts
Chris Tate bf6dfb882b Add static Node path function values (#352)
* Add static Node path function values

* Preserve JavaScript builtin alias fences
2026-09-19 12:24:40 -05:00

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);