Skip to content
69 changes: 64 additions & 5 deletions scripts/check-dead-exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
loadGuardConfig,
parseAllowlistText,
parseGuardConfig,
parseInjectedScannedFiles,
parseTsPruneLine,
tsPruneSpawn,
validateAllowlistEntry,
Expand All @@ -28,6 +29,19 @@ import {

const repoRoot = join(import.meta.dir, "..");

// tsc --listFilesOnly costs ~1.5s per spawn and the count is deterministic
// for a clean tree. The floor tests and the end-to-end probe share one run
// through this cache; each caller computes it on first use, so no test
// depends on another having run.
let cachedScannedFileCount: number | undefined;
function liveScannedFileCount(): number {
cachedScannedFileCount ??= countScannedFiles(
repoRoot,
loadGuardConfig().tsconfig,
);
return cachedScannedFileCount;
}

// A probe dead export in one of the scoped files must fail the guard: the
// exact-name exemptions cover only the remaining deferred-cleanup flags
// (the usage-formatter flags were removed with their exports by the CL-6815
Expand Down Expand Up @@ -310,6 +324,17 @@ describe("pinned scan invocation", () => {
expect(() => parseGuardConfig(null)).toThrow();
expect(() => parseGuardConfig([])).toThrow();
});

test("the scanned-files injection accepts no override or a positive integer", () => {
expect(parseInjectedScannedFiles(undefined)).toBeUndefined();
expect(parseInjectedScannedFiles("1217")).toBe(1217);
});

test("the scanned-files injection fails closed on a bogus value", () => {
for (const bad of ["", "0", "-5", "1.5", "abc"]) {
expect(() => parseInjectedScannedFiles(bad)).toThrow();
}
});
});

describe("scan coverage floor", () => {
Expand All @@ -321,14 +346,14 @@ describe("scan coverage floor", () => {

test("the live program file count clears the checked-in floor", () => {
const config = loadGuardConfig();
const scanned = countScannedFiles(repoRoot, config.tsconfig);
expect(scanned).toBeGreaterThanOrEqual(config.minScannedFiles);
expect(liveScannedFileCount()).toBeGreaterThanOrEqual(
config.minScannedFiles,
);
}, 120_000);

test("the checked-in floor stays tight to the live count", () => {
const config = loadGuardConfig();
const scanned = countScannedFiles(repoRoot, config.tsconfig);
expect(scanned).toBeLessThan(config.minScannedFiles * 1.1);
expect(liveScannedFileCount()).toBeLessThan(config.minScannedFiles * 1.1);
}, 120_000);
});

Expand All @@ -337,24 +362,58 @@ describe("scan coverage floor", () => {
// scripts/ tree, the real guard runs as a subprocess, and the run must exit
// nonzero naming the probe. The probe lives only for the test (never
// committed) so the keep-alive check cannot itself become a dead export.
//
// The guard normally scans the full project via ts-prune (~5s); the probe's
// temp tsconfig includes only the probe file, so this run pays only ts-prune
// startup while still exercising the same wired-up gate (config load,
// allowlist validation, node-spawned ts-prune, output parsing, coverage
// floor, exit code).
describe("violation end to end", () => {
test("a temp dead export fails the guard, which names it", () => {
const probeFile = `dead-export-guard-probe-${process.pid}.ts`;
const probeName = `deadExportGuardProbe${process.pid}`;
const probePath = join(repoRoot, "scripts", probeFile);
const narrowTsconfigFile = `dead-export-guard-tsconfig-${process.pid}.json`;
const narrowTsconfigPath = join(repoRoot, "scripts", narrowTsconfigFile);
writeFileSync(probePath, `export const ${probeName} = 1;\n`);
writeFileSync(
narrowTsconfigPath,
JSON.stringify({
compilerOptions: {
noEmit: true,
target: "ESNext",
module: "ESNext",
moduleResolution: "bundler",
skipLibCheck: true,
},
// Relative to the narrow tsconfig's own directory (scripts/).
files: [probeFile],
}),
);
try {
const ran = spawnSync(
process.execPath,
["scripts/check-dead-exports.ts"],
{ cwd: repoRoot, encoding: "utf8" },
{
cwd: repoRoot,
encoding: "utf8",
// The file-count subprocess is exercised by the floor tests above;
// inject the same deterministic value so this run pays only
// ts-prune.
env: {
...process.env,
DEAD_EXPORT_GUARD_SCANNED_FILES: String(liveScannedFileCount()),
DEAD_EXPORT_GUARD_TSCONFIG: `scripts/${narrowTsconfigFile}`,
},
},
);
expect(ran.status).toBe(1);
expect(`${ran.stdout}\n${ran.stderr}`).toContain(
`${probeFile}: ${probeName}`,
);
} finally {
rmSync(probePath, { force: true });
rmSync(narrowTsconfigPath, { force: true });
}
}, 120_000);
});
Expand Down
58 changes: 52 additions & 6 deletions scripts/check-dead-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ import { fileURLToPath } from "node:url";
// gate fails closed when the scanned file count drops below that config's
// floor.

// Test injection (same pattern as teardownDeadlineMs / maxScanBytes): a
// subprocess test that needs the real gate without paying for the ~1.5s tsc
// file-count run can pass the deterministic count itself. Production never
// sets it, so the gate always computes the count in normal runs.
const scannedFilesEnv = "DEAD_EXPORT_GUARD_SCANNED_FILES";

// Test injection (same pattern as scannedFilesEnv): a subprocess test that
// needs the wired-up gate without paying for the ~5s full-project ts-prune
// run can point the pinned scan at a smaller tsconfig. Production never sets
// it, so the gate always scans the pinned tsconfig in normal runs.
const tsconfigEnv = "DEAD_EXPORT_GUARD_TSCONFIG";

const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = dirname(here);
const allowlistPath = join(here, "dead-export-allowlist.txt");
Expand Down Expand Up @@ -284,6 +296,22 @@ export function tsPruneSpawn(
return { command: "node", args: [tsPruneBinPath, ...tsPruneArgs] };
}

// Parses the test-injection env override. Undefined means compute the count
// as usual; anything else must be a positive integer or the gate fails
// closed rather than trusting a bogus number.
export function parseInjectedScannedFiles(
value: string | undefined,
): number | undefined {
if (value === undefined) return undefined;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(
`${scannedFilesEnv} must be a positive integer when set (test injection only)`,
);
}
return parsed;
}

// Counts the TypeScript files the pinned tsconfig pulls into its program via
// tsc --listFilesOnly: the same project ts-prune analyzes. A narrowed
// tsconfig (or a moved scan root) shrinks this count, and the gate fails
Expand Down Expand Up @@ -329,6 +357,19 @@ function main(): void {
} catch (err) {
fail(`dead-export guard: invalid guard config: ${(err as Error).message}`);
}
const overrideTsconfig = process.env[tsconfigEnv];
if (overrideTsconfig !== undefined) {
if (!existsSync(join(repoRoot, overrideTsconfig))) {
fail(
`dead-export guard: tsconfig override not found: ${overrideTsconfig}`,
);
}
config = {
...config,
tsconfig: overrideTsconfig,
tsPruneArgs: ["-p", overrideTsconfig],
};
}
const allowlistText = readFileSync(allowlistPath, "utf8");
const allowlistProblems = [
...validateAllowlistText(allowlistText),
Expand Down Expand Up @@ -356,12 +397,17 @@ function main(): void {
}
const outcome = evaluateGuard(rules, String(pruned.stdout));
let scannedFiles: number;
try {
scannedFiles = countScannedFiles(repoRoot, config.tsconfig);
} catch (err) {
fail(
`dead-export guard: could not count scanned files: ${(err as Error).message}`,
);
const injectedCount = parseInjectedScannedFiles(process.env[scannedFilesEnv]);
if (injectedCount !== undefined) {
scannedFiles = injectedCount;
} else {
try {
scannedFiles = countScannedFiles(repoRoot, config.tsconfig);
} catch (err) {
fail(
`dead-export guard: could not count scanned files: ${(err as Error).message}`,
);
}
}
console.log(
`dead-export guard: ${outcome.dead} consumer-less exports, ${outcome.dead - outcome.violations.length} allowlisted, ${outcome.violations.length} violations, ${scannedFiles} scanned files (floor ${config.minScannedFiles})`,
Expand Down
Loading
Loading