Skip to content

Commit cc8f291

Browse files
test(deadcode): add keeper tests for the dead-export guard (#1061)
* test(deadcode): add keeper tests for the dead-export guard * fix(deadcode): harden dead-export guard against silent weakening (#1065) * fix(deadcode): harden dead-export guard against silent weakening * fix(deadcode): pin guard scan invocation to exact args parseGuardConfig required only that tsPruneArgs contain the -p tsconfig pair, so narrowing flags like -i/--ignore shrank the scan while the tsc file-count floor stayed flat. Require exact ["-p", "<tsconfig>"] equality so the pin actually pins. Also reword ownership headers to match presence-only enforcement.
1 parent 1cff13a commit cc8f291

4 files changed

Lines changed: 554 additions & 22 deletions

File tree

‎scripts/check-dead-exports.test.ts‎

Lines changed: 301 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
1+
import { spawnSync } from "node:child_process";
2+
import {
3+
existsSync,
4+
readdirSync,
5+
readFileSync,
6+
rmSync,
7+
writeFileSync,
8+
} from "node:fs";
9+
import { join, relative } from "node:path";
110
import { describe, expect, test } from "bun:test";
211

312
import {
13+
countScannedFiles,
414
evaluateGuard,
515
isAllowlisted,
16+
isCoverageEnough,
17+
isGuardPassing,
618
loadAllowlist,
19+
loadGuardConfig,
720
parseAllowlistText,
21+
parseGuardConfig,
822
parseTsPruneLine,
23+
validateAllowlistEntry,
24+
validateAllowlistOwnership,
25+
validateAllowlistText,
926
} from "./check-dead-exports.js";
1027

28+
const repoRoot = join(import.meta.dir, "..");
29+
1130
// A probe dead export in one of the scoped files must fail the guard: the
1231
// exact-name exemptions cover only the five deferred-cleanup flags, never
1332
// the whole module.
@@ -76,11 +95,11 @@ describe("scoped exemptions", () => {
7695
});
7796
});
7897

79-
// Allowlist entries that match no current ts-prune flag are stale: report
80-
// them so the exemption is removed with the code it covered, without
81-
// failing the gate on their own.
98+
// Allowlist entries that match no current ts-prune flag are stale: they fail
99+
// the gate so the exemption is removed with the code it covered. Warn-only
100+
// reporting let dead exemptions linger silently after the code was gone.
82101
describe("stale allowlist entries", () => {
83-
test("an entry matching nothing is reported as unused, not a violation", () => {
102+
test("an entry matching nothing is reported as unused and fails the gate", () => {
84103
const rules = parseAllowlistText(
85104
"src/auth/xai/usage.ts: fetchXaiUsage\n" +
86105
"src/gone.ts: vanishedExport\n",
@@ -91,9 +110,10 @@ describe("stale allowlist entries", () => {
91110
);
92111
expect(outcome.violations).toEqual([]);
93112
expect(outcome.unused).toEqual(["src/gone.ts: vanishedExport"]);
113+
expect(isGuardPassing(outcome)).toBe(false);
94114
});
95115

96-
test("a fully fresh allowlist reports no unused entries", () => {
116+
test("a fully fresh allowlist passes the gate", () => {
97117
const rules = parseAllowlistText(
98118
"vendor/\nsrc/auth/xai/usage.ts: fetchXaiUsage\n",
99119
);
@@ -104,6 +124,7 @@ describe("stale allowlist entries", () => {
104124
);
105125
expect(outcome.violations).toEqual([]);
106126
expect(outcome.unused).toEqual([]);
127+
expect(isGuardPassing(outcome)).toBe(true);
107128
});
108129

109130
test("a prefix entry counts as used when any flag falls under it", () => {
@@ -114,5 +135,280 @@ describe("stale allowlist entries", () => {
114135
);
115136
expect(outcome.violations).toEqual([]);
116137
expect(outcome.unused).toEqual([]);
138+
expect(isGuardPassing(outcome)).toBe(true);
139+
});
140+
141+
test("violations fail the gate", () => {
142+
const outcome = evaluateGuard([], "src/new.ts:1 - freshDeadExport\n");
143+
expect(outcome.violations).toEqual(["src/new.ts: freshDeadExport"]);
144+
expect(isGuardPassing(outcome)).toBe(false);
145+
});
146+
});
147+
148+
// Entry shapes the matcher would silently misinterpret must fail validation
149+
// instead: a mistyped exact entry must not decay into a prefix that matches
150+
// nothing, and a directory without its trailing slash must not pass as an
151+
// imprecise prefix.
152+
describe("allowlist entry shapes", () => {
153+
test("valid entries pass", () => {
154+
expect(validateAllowlistEntry("vendor/")).toBeUndefined();
155+
expect(validateAllowlistEntry("src/auth/codex/usage.ts")).toBeUndefined();
156+
expect(
157+
validateAllowlistEntry("src/auth/codex/usage.ts: fetchCodexUsage"),
158+
).toBeUndefined();
159+
expect(
160+
validateAllowlistEntry(
161+
"tests/fixtures/plugins/implement-feature/src/index.ts",
162+
),
163+
).toBeUndefined();
164+
});
165+
166+
test("slash-less directory prefixes fail", () => {
167+
expect(validateAllowlistEntry("vendor")).toBeDefined();
168+
expect(validateAllowlistEntry("src/auth")).toBeDefined();
169+
expect(validateAllowlistEntry("/")).toBeDefined();
170+
});
171+
172+
test("malformed exact entries fail instead of decaying into prefixes", () => {
173+
expect(
174+
validateAllowlistEntry("src/auth/codex/usage.ts: bad name!"),
175+
).toBeDefined();
176+
expect(
177+
validateAllowlistEntry("src/auth/codex/usage.ts: 123abc"),
178+
).toBeDefined();
179+
expect(validateAllowlistEntry("src/auth/codex/usage.ts:")).toBeDefined();
180+
expect(validateAllowlistEntry("usage.ts: fetchCodexUsage")).toBeDefined();
181+
});
182+
183+
test("entries with whitespace fail", () => {
184+
expect(validateAllowlistEntry("src/has space/x.ts")).toBeDefined();
185+
});
186+
187+
test("the checked-in allowlist passes shape validation", () => {
188+
const text = readFileSync(
189+
join(repoRoot, "scripts", "dead-export-allowlist.txt"),
190+
"utf8",
191+
);
192+
expect(validateAllowlistText(text)).toEqual([]);
193+
});
194+
});
195+
196+
// This repo has no CODEOWNERS, so the documented review convention is that
197+
// every entry block names its owning lane in the reason comment above it.
198+
// The gate enforces the reason comments; human review enforces the lane.
199+
describe("allowlist ownership", () => {
200+
test("an entry under a reason comment passes", () => {
201+
expect(
202+
validateAllowlistOwnership("# Owner: usage-data lane\nsrc/a.ts: Thing\n"),
203+
).toEqual([]);
204+
});
205+
206+
test("a reason block covers the contiguous entries below it", () => {
207+
expect(
208+
validateAllowlistOwnership(
209+
"# Owner: usage-data lane\nsrc/a.ts: Thing\nsrc/b.ts: Other\n",
210+
),
211+
).toEqual([]);
212+
});
213+
214+
test("an entry with no reason comment fails", () => {
215+
expect(validateAllowlistOwnership("src/a.ts: Thing\n")).toEqual([
216+
"allowlist entry without a reason comment naming its owner: src/a.ts: Thing",
217+
]);
218+
});
219+
220+
test("a new section after a blank line needs its own reason", () => {
221+
expect(
222+
validateAllowlistOwnership(
223+
"# Owner: usage-data lane\nsrc/a.ts: Thing\n\nsrc/b.ts: Other\n",
224+
),
225+
).toEqual([
226+
"allowlist entry without a reason comment naming its owner: src/b.ts: Other",
227+
]);
228+
});
229+
230+
test("a bare hash is not a reason", () => {
231+
expect(validateAllowlistOwnership("#\nsrc/a.ts: Thing\n")).toEqual([
232+
"allowlist entry without a reason comment naming its owner: src/a.ts: Thing",
233+
]);
234+
});
235+
236+
test("the checked-in allowlist names an owner for every entry", () => {
237+
const text = readFileSync(
238+
join(repoRoot, "scripts", "dead-export-allowlist.txt"),
239+
"utf8",
240+
);
241+
expect(validateAllowlistOwnership(text)).toEqual([]);
242+
});
243+
});
244+
245+
// The scan invocation is pinned to scripts/dead-export-guard.json so it never
246+
// depends on ts-prune's working-directory config discovery, and the gate
247+
// fails closed when the scanned file count drops below the checked-in floor
248+
// instead of green-lighting a scan that looked at less code.
249+
describe("pinned scan invocation", () => {
250+
test("the checked-in config pins the project and a positive floor", () => {
251+
const config = loadGuardConfig();
252+
expect(config.tsconfig).toBe("tsconfig.json");
253+
expect(config.tsPruneArgs).toEqual(["-p", "tsconfig.json"]);
254+
expect(config.minScannedFiles).toBeGreaterThan(0);
255+
expect(existsSync(join(repoRoot, config.tsconfig))).toBe(true);
256+
});
257+
258+
test("parseGuardConfig rejects an unpinned or empty invocation", () => {
259+
const valid = {
260+
tsconfig: "tsconfig.json",
261+
tsPruneArgs: ["-p", "tsconfig.json"],
262+
minScannedFiles: 1130,
263+
};
264+
expect(parseGuardConfig(valid)).toEqual(valid);
265+
expect(() => parseGuardConfig({ ...valid, tsPruneArgs: [] })).toThrow();
266+
expect(() =>
267+
parseGuardConfig({ ...valid, tsPruneArgs: ["--ignore", "x"] }),
268+
).toThrow();
269+
expect(() =>
270+
parseGuardConfig({
271+
...valid,
272+
tsPruneArgs: ["-p", "tsconfig.other.json"],
273+
}),
274+
).toThrow();
275+
});
276+
277+
test("parseGuardConfig rejects extra narrowing flags on a pinned invocation", () => {
278+
const valid = {
279+
tsconfig: "tsconfig.json",
280+
tsPruneArgs: ["-p", "tsconfig.json"],
281+
minScannedFiles: 1130,
282+
};
283+
expect(parseGuardConfig(valid)).toEqual(valid);
284+
const narrowed = [
285+
["-p", "tsconfig.json", "-i", "src/.*"],
286+
["-p", "tsconfig.json", "--ignore", "src/.*"],
287+
["-p", "tsconfig.json", "--error"],
288+
["--ignore", "src/.*", "-p", "tsconfig.json"],
289+
];
290+
for (const tsPruneArgs of narrowed) {
291+
expect(() => parseGuardConfig({ ...valid, tsPruneArgs })).toThrow();
292+
}
293+
});
294+
295+
test("parseGuardConfig rejects a missing floor", () => {
296+
const valid = {
297+
tsconfig: "tsconfig.json",
298+
tsPruneArgs: ["-p", "tsconfig.json"],
299+
minScannedFiles: 1130,
300+
};
301+
for (const floor of [0, -5, 1.5, "1130", undefined]) {
302+
expect(() =>
303+
parseGuardConfig({ ...valid, minScannedFiles: floor }),
304+
).toThrow();
305+
}
306+
expect(() => parseGuardConfig(null)).toThrow();
307+
expect(() => parseGuardConfig([])).toThrow();
308+
});
309+
});
310+
311+
describe("scan coverage floor", () => {
312+
test("counts below the floor fail, counts at or above pass", () => {
313+
expect(isCoverageEnough(1129, 1130)).toBe(false);
314+
expect(isCoverageEnough(1130, 1130)).toBe(true);
315+
expect(isCoverageEnough(2000, 1130)).toBe(true);
316+
});
317+
318+
test("the live program file count clears the checked-in floor", () => {
319+
const config = loadGuardConfig();
320+
const scanned = countScannedFiles(repoRoot, config.tsconfig);
321+
expect(scanned).toBeGreaterThanOrEqual(config.minScannedFiles);
322+
}, 120_000);
323+
324+
test("the checked-in floor stays tight to the live count", () => {
325+
const config = loadGuardConfig();
326+
const scanned = countScannedFiles(repoRoot, config.tsconfig);
327+
expect(scanned).toBeLessThan(config.minScannedFiles * 1.1);
328+
}, 120_000);
329+
});
330+
331+
// The unit tests above prove the rule engine flags a probe; this one proves
332+
// the wired-up gate does. A temp probe export lands in the tsconfig-covered
333+
// scripts/ tree, the real guard runs as a subprocess, and the run must exit
334+
// nonzero naming the probe. The probe lives only for the test (never
335+
// committed) so the keep-alive check cannot itself become a dead export.
336+
describe("violation end to end", () => {
337+
test("a temp dead export fails the guard, which names it", () => {
338+
const probeFile = `dead-export-guard-probe-${process.pid}.ts`;
339+
const probeName = `deadExportGuardProbe${process.pid}`;
340+
const probePath = join(repoRoot, "scripts", probeFile);
341+
writeFileSync(probePath, `export const ${probeName} = 1;\n`);
342+
try {
343+
const ran = spawnSync(
344+
process.execPath,
345+
["scripts/check-dead-exports.ts"],
346+
{ cwd: repoRoot, encoding: "utf8" },
347+
);
348+
expect(ran.status).toBe(1);
349+
expect(`${ran.stdout}\n${ran.stderr}`).toContain(
350+
`${probeFile}: ${probeName}`,
351+
);
352+
} finally {
353+
rmSync(probePath, { force: true });
354+
}
355+
}, 120_000);
356+
});
357+
358+
// The purge deleted four fully-dead barrel files; a re-created barrel (or a
359+
// new import of its path) would silently resurrect the surface the guard was
360+
// built to shrink. Pin the paths, not the file text.
361+
describe("deleted barrels stay deleted", () => {
362+
const barrels = [
363+
"src/agent/directors/index.ts",
364+
"src/auth/codex/index.ts",
365+
"src/auth/xai/index.ts",
366+
"src/web/index.ts",
367+
];
368+
const barrelDirs = barrels.map((barrel) =>
369+
barrel.slice(0, -"/index.ts".length),
370+
);
371+
const barrelTails = [
372+
"agent/directors/index",
373+
"auth/codex/index",
374+
"auth/xai/index",
375+
"web/index",
376+
];
377+
const roots = ["src", "tests", "evals", "scripts", "packages"];
378+
379+
test("the barrel files do not exist", () => {
380+
for (const barrel of barrels) {
381+
expect(existsSync(join(repoRoot, barrel))).toBe(false);
382+
}
383+
});
384+
385+
test("no source file imports the deleted barrel paths", () => {
386+
const offenders: string[] = [];
387+
const walk = (dir: string): void => {
388+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
389+
const absolute = join(dir, entry.name);
390+
if (entry.isDirectory()) {
391+
walk(absolute);
392+
continue;
393+
}
394+
if (!entry.name.endsWith(".ts")) continue;
395+
const rel = relative(repoRoot, absolute).split("/").join("/");
396+
const dirRel = rel.slice(0, -entry.name.length - 1);
397+
const text = readFileSync(absolute, "utf8");
398+
const specifiers = [
399+
...text.matchAll(/(?:from\s*["']|import\s*\(\s*["'])([^"']+)["']/g),
400+
].map((match) => (match[1] ?? "").replace(/\.(?:js|ts)$/, ""));
401+
for (const spec of specifiers) {
402+
const hitsBarrel =
403+
barrelTails.some(
404+
(tail) => spec === tail || spec.endsWith(`/${tail}`),
405+
) ||
406+
(spec === "./index" && barrelDirs.includes(dirRel));
407+
if (hitsBarrel) offenders.push(`${rel}: ${spec}`);
408+
}
409+
}
410+
};
411+
for (const root of roots) walk(join(repoRoot, root));
412+
expect(offenders).toEqual([]);
117413
});
118414
});

0 commit comments

Comments
 (0)