From a4a34fef09fc7543204ea1fa1d5d836a3d5b95eb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 14:35:26 -0700 Subject: [PATCH 1/2] fix(deadcode): harden dead-export guard against silent weakening --- scripts/check-dead-exports.test.ts | 194 +++++++++++++++++++++- scripts/check-dead-exports.ts | 257 +++++++++++++++++++++++++++-- scripts/dead-export-allowlist.txt | 9 +- scripts/dead-export-guard.json | 5 + 4 files changed, 442 insertions(+), 23 deletions(-) create mode 100644 scripts/dead-export-guard.json diff --git a/scripts/check-dead-exports.test.ts b/scripts/check-dead-exports.test.ts index c40e7162f..98fd2becc 100644 --- a/scripts/check-dead-exports.test.ts +++ b/scripts/check-dead-exports.test.ts @@ -10,11 +10,19 @@ import { join, relative } from "node:path"; import { describe, expect, test } from "bun:test"; import { + countScannedFiles, evaluateGuard, isAllowlisted, + isCoverageEnough, + isGuardPassing, loadAllowlist, + loadGuardConfig, parseAllowlistText, + parseGuardConfig, parseTsPruneLine, + validateAllowlistEntry, + validateAllowlistOwnership, + validateAllowlistText, } from "./check-dead-exports.js"; const repoRoot = join(import.meta.dir, ".."); @@ -87,11 +95,11 @@ describe("scoped exemptions", () => { }); }); -// Allowlist entries that match no current ts-prune flag are stale: report -// them so the exemption is removed with the code it covered, without -// failing the gate on their own. +// Allowlist entries that match no current ts-prune flag are stale: they fail +// the gate so the exemption is removed with the code it covered. Warn-only +// reporting let dead exemptions linger silently after the code was gone. describe("stale allowlist entries", () => { - test("an entry matching nothing is reported as unused, not a violation", () => { + test("an entry matching nothing is reported as unused and fails the gate", () => { const rules = parseAllowlistText( "src/auth/xai/usage.ts: fetchXaiUsage\n" + "src/gone.ts: vanishedExport\n", @@ -102,9 +110,10 @@ describe("stale allowlist entries", () => { ); expect(outcome.violations).toEqual([]); expect(outcome.unused).toEqual(["src/gone.ts: vanishedExport"]); + expect(isGuardPassing(outcome)).toBe(false); }); - test("a fully fresh allowlist reports no unused entries", () => { + test("a fully fresh allowlist passes the gate", () => { const rules = parseAllowlistText( "vendor/\nsrc/auth/xai/usage.ts: fetchXaiUsage\n", ); @@ -115,6 +124,7 @@ describe("stale allowlist entries", () => { ); expect(outcome.violations).toEqual([]); expect(outcome.unused).toEqual([]); + expect(isGuardPassing(outcome)).toBe(true); }); test("a prefix entry counts as used when any flag falls under it", () => { @@ -125,7 +135,179 @@ describe("stale allowlist entries", () => { ); expect(outcome.violations).toEqual([]); expect(outcome.unused).toEqual([]); + expect(isGuardPassing(outcome)).toBe(true); }); + + test("violations fail the gate", () => { + const outcome = evaluateGuard([], "src/new.ts:1 - freshDeadExport\n"); + expect(outcome.violations).toEqual(["src/new.ts: freshDeadExport"]); + expect(isGuardPassing(outcome)).toBe(false); + }); +}); + +// Entry shapes the matcher would silently misinterpret must fail validation +// instead: a mistyped exact entry must not decay into a prefix that matches +// nothing, and a directory without its trailing slash must not pass as an +// imprecise prefix. +describe("allowlist entry shapes", () => { + test("valid entries pass", () => { + expect(validateAllowlistEntry("vendor/")).toBeUndefined(); + expect(validateAllowlistEntry("src/auth/codex/usage.ts")).toBeUndefined(); + expect( + validateAllowlistEntry("src/auth/codex/usage.ts: fetchCodexUsage"), + ).toBeUndefined(); + expect( + validateAllowlistEntry( + "tests/fixtures/plugins/implement-feature/src/index.ts", + ), + ).toBeUndefined(); + }); + + test("slash-less directory prefixes fail", () => { + expect(validateAllowlistEntry("vendor")).toBeDefined(); + expect(validateAllowlistEntry("src/auth")).toBeDefined(); + expect(validateAllowlistEntry("/")).toBeDefined(); + }); + + test("malformed exact entries fail instead of decaying into prefixes", () => { + expect( + validateAllowlistEntry("src/auth/codex/usage.ts: bad name!"), + ).toBeDefined(); + expect( + validateAllowlistEntry("src/auth/codex/usage.ts: 123abc"), + ).toBeDefined(); + expect(validateAllowlistEntry("src/auth/codex/usage.ts:")).toBeDefined(); + expect(validateAllowlistEntry("usage.ts: fetchCodexUsage")).toBeDefined(); + }); + + test("entries with whitespace fail", () => { + expect(validateAllowlistEntry("src/has space/x.ts")).toBeDefined(); + }); + + test("the checked-in allowlist passes shape validation", () => { + const text = readFileSync( + join(repoRoot, "scripts", "dead-export-allowlist.txt"), + "utf8", + ); + expect(validateAllowlistText(text)).toEqual([]); + }); +}); + +// This repo has no CODEOWNERS, so the documented review convention is that +// every entry block names its owning lane in the reason comment above it. +// The gate enforces the reason comments; human review enforces the lane. +describe("allowlist ownership", () => { + test("an entry under a reason comment passes", () => { + expect( + validateAllowlistOwnership("# Owner: usage-data lane\nsrc/a.ts: Thing\n"), + ).toEqual([]); + }); + + test("a reason block covers the contiguous entries below it", () => { + expect( + validateAllowlistOwnership( + "# Owner: usage-data lane\nsrc/a.ts: Thing\nsrc/b.ts: Other\n", + ), + ).toEqual([]); + }); + + test("an entry with no reason comment fails", () => { + expect(validateAllowlistOwnership("src/a.ts: Thing\n")).toEqual([ + "allowlist entry without a reason comment naming its owner: src/a.ts: Thing", + ]); + }); + + test("a new section after a blank line needs its own reason", () => { + expect( + validateAllowlistOwnership( + "# Owner: usage-data lane\nsrc/a.ts: Thing\n\nsrc/b.ts: Other\n", + ), + ).toEqual([ + "allowlist entry without a reason comment naming its owner: src/b.ts: Other", + ]); + }); + + test("a bare hash is not a reason", () => { + expect(validateAllowlistOwnership("#\nsrc/a.ts: Thing\n")).toEqual([ + "allowlist entry without a reason comment naming its owner: src/a.ts: Thing", + ]); + }); + + test("the checked-in allowlist names an owner for every entry", () => { + const text = readFileSync( + join(repoRoot, "scripts", "dead-export-allowlist.txt"), + "utf8", + ); + expect(validateAllowlistOwnership(text)).toEqual([]); + }); +}); + +// The scan invocation is pinned to scripts/dead-export-guard.json so it never +// depends on ts-prune's working-directory config discovery, and the gate +// fails closed when the scanned file count drops below the checked-in floor +// instead of green-lighting a scan that looked at less code. +describe("pinned scan invocation", () => { + test("the checked-in config pins the project and a positive floor", () => { + const config = loadGuardConfig(); + expect(config.tsconfig).toBe("tsconfig.json"); + expect(config.tsPruneArgs).toEqual(["-p", "tsconfig.json"]); + expect(config.minScannedFiles).toBeGreaterThan(0); + expect(existsSync(join(repoRoot, config.tsconfig))).toBe(true); + }); + + test("parseGuardConfig rejects an unpinned or empty invocation", () => { + const valid = { + tsconfig: "tsconfig.json", + tsPruneArgs: ["-p", "tsconfig.json"], + minScannedFiles: 1130, + }; + expect(parseGuardConfig(valid)).toEqual(valid); + expect(() => parseGuardConfig({ ...valid, tsPruneArgs: [] })).toThrow(); + expect(() => + parseGuardConfig({ ...valid, tsPruneArgs: ["--ignore", "x"] }), + ).toThrow(); + expect(() => + parseGuardConfig({ + ...valid, + tsPruneArgs: ["-p", "tsconfig.other.json"], + }), + ).toThrow(); + }); + + test("parseGuardConfig rejects a missing floor", () => { + const valid = { + tsconfig: "tsconfig.json", + tsPruneArgs: ["-p", "tsconfig.json"], + minScannedFiles: 1130, + }; + for (const floor of [0, -5, 1.5, "1130", undefined]) { + expect(() => + parseGuardConfig({ ...valid, minScannedFiles: floor }), + ).toThrow(); + } + expect(() => parseGuardConfig(null)).toThrow(); + expect(() => parseGuardConfig([])).toThrow(); + }); +}); + +describe("scan coverage floor", () => { + test("counts below the floor fail, counts at or above pass", () => { + expect(isCoverageEnough(1129, 1130)).toBe(false); + expect(isCoverageEnough(1130, 1130)).toBe(true); + expect(isCoverageEnough(2000, 1130)).toBe(true); + }); + + 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); + }, 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); + }, 120_000); }); // The unit tests above prove the rule engine flags a probe; this one proves @@ -152,7 +334,7 @@ describe("violation end to end", () => { } finally { rmSync(probePath, { force: true }); } - }, 60_000); + }, 120_000); }); // The purge deleted four fully-dead barrel files; a re-created barrel (or a diff --git a/scripts/check-dead-exports.ts b/scripts/check-dead-exports.ts index a0bd3f2b1..e3433bb7b 100644 --- a/scripts/check-dead-exports.ts +++ b/scripts/check-dead-exports.ts @@ -1,21 +1,30 @@ import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, join, sep } from "node:path"; import { fileURLToPath } from "node:url"; -// Dead-export guard (CL-6797): runs ts-prune over the project and fails when -// any export with no consumer falls outside scripts/dead-export-allowlist.txt. -// Exports used only inside their own module ("(used in module)") are live -// enough and do not count. New dead exports must be deleted, not allowlisted: -// the allowlist covers entry points, cross-lane ownership, plugin surfaces -// loaded by path, and ts-prune parser false positives only. Allowlist entries -// that match no current ts-prune flag are reported as stale warnings so dead -// exemptions cannot linger after the code they cover is gone. +// Dead-export guard (CL-6797, hardened CL-7993): runs ts-prune over the project +// and fails when any export with no consumer falls outside +// scripts/dead-export-allowlist.txt. Exports used only inside their own module +// ("(used in module)") are live enough and do not count. New dead exports must +// be deleted, not allowlisted: the allowlist covers entry points, cross-lane +// ownership, plugin surfaces loaded by path, and ts-prune parser false +// positives only. +// +// Hardening: stale allowlist entries fail the gate instead of warning, every +// entry must pass shape validation and sit under a reason comment naming its +// owning lane (this repo has no CODEOWNERS, so the allowlist header documents +// the review convention and the gate enforces the reason comments), the +// ts-prune invocation is pinned to scripts/dead-export-guard.json, and the +// gate fails closed when the scanned file count drops below that config's +// floor. const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = dirname(here); const allowlistPath = join(here, "dead-export-allowlist.txt"); +const guardConfigPath = join(here, "dead-export-guard.json"); const tsPruneBin = join(repoRoot, "node_modules", ".bin", "ts-prune"); +const tscBin = join(repoRoot, "node_modules", "typescript", "bin", "tsc"); export type AllowRule = | { @@ -41,12 +50,20 @@ export interface GuardOutcome { readonly unused: string[]; } +export interface GuardConfig { + readonly tsconfig: string; + readonly tsPruneArgs: readonly string[]; + readonly minScannedFiles: number; +} + +const exactEntryPattern = /^(.+?): ([A-Za-z_$][\w$]*)$/; + export function parseAllowlistText(text: string): AllowRule[] { const rules: AllowRule[] = []; for (const raw of text.split("\n")) { const line = raw.trim(); if (line === "" || line.startsWith("#")) continue; - const exact = line.match(/^(.+?): ([A-Za-z_$][\w$]*)$/); + const exact = line.match(exactEntryPattern); if (exact) { const file = exact[1]; const name = exact[2]; @@ -65,10 +82,138 @@ export function parseAllowlistText(text: string): AllowRule[] { return rules; } +// Rejects a single allowlist line that the matcher would silently +// misinterpret: a mistyped exact entry ("file: bad name!") must not decay into +// a prefix that matches nothing, and a directory without a trailing slash +// ("vendor") must not pass as an imprecise prefix. Only `dir/` prefixes and +// repo-relative `.ts` paths (bare or `file: Name`) are valid. +export function validateAllowlistEntry(line: string): string | undefined { + if (line.includes(":")) { + const exact = line.match(exactEntryPattern); + if (exact === null) { + return `malformed allowlist entry (want "path/to/file.ts: ExportName"): ${line}`; + } + const file = exact[1] ?? ""; + if (/\s/.test(file) || !file.includes("/") || !file.endsWith(".ts")) { + return `allowlist entry file must be a repo-relative .ts path: ${line}`; + } + return undefined; + } + if (/\s/.test(line)) { + return `allowlist entry contains whitespace: ${line}`; + } + if (line.endsWith("/")) { + if (line.length < 2) { + return `malformed allowlist entry: ${line}`; + } + return undefined; + } + if (!line.includes("/") || !line.endsWith(".ts")) { + return `directory prefixes must end in "/" and files must end in ".ts": ${line}`; + } + return undefined; +} + +export function validateAllowlistText(text: string): string[] { + const problems: string[] = []; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + const problem = validateAllowlistEntry(line); + if (problem !== undefined) problems.push(problem); + } + return problems; +} + +// Every entry must sit under a reason comment naming its owning lane, in the +// same blank-line section and above the entry. A section of entries with no +// reason above it fails, so exemptions cannot land without an owner on +// record for review. +export function validateAllowlistOwnership(text: string): string[] { + const problems: string[] = []; + let reasoned = false; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (line === "") { + reasoned = false; + continue; + } + if (line.startsWith("#")) { + if (line.length > 1) reasoned = true; + continue; + } + if (!reasoned) { + problems.push( + `allowlist entry without a reason comment naming its owner: ${line}`, + ); + } + } + return problems; +} + export function loadAllowlist(): AllowRule[] { return parseAllowlistText(readFileSync(allowlistPath, "utf8")); } +// Parses and validates the pinned scan config. The invocation must pin the +// project explicitly ("-p ") so the scan never depends on ts-prune's +// working-directory config discovery, and the floor must be a positive +// integer the gate fails closed against. +export function parseGuardConfig(raw: unknown): GuardConfig { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error("dead-export guard config must be a JSON object"); + } + const config = raw as Record; + const tsconfig = config["tsconfig"]; + if (typeof tsconfig !== "string" || tsconfig === "") { + throw new Error('dead-export guard config needs a "tsconfig" path string'); + } + const tsPruneArgs = config["tsPruneArgs"]; + if ( + !Array.isArray(tsPruneArgs) || + tsPruneArgs.length === 0 || + tsPruneArgs.some((arg) => typeof arg !== "string") + ) { + throw new Error( + 'dead-export guard config needs a non-empty "tsPruneArgs" string array', + ); + } + const args = tsPruneArgs as string[]; + const projectFlag = args.indexOf("-p"); + if (projectFlag === -1 || args[projectFlag + 1] !== tsconfig) { + throw new Error( + 'dead-export guard config "tsPruneArgs" must pin the project ("-p ")', + ); + } + const minScannedFiles = config["minScannedFiles"]; + if ( + typeof minScannedFiles !== "number" || + !Number.isInteger(minScannedFiles) || + minScannedFiles <= 0 + ) { + throw new Error( + 'dead-export guard config needs a positive integer "minScannedFiles"', + ); + } + return { tsconfig, tsPruneArgs: [...args], minScannedFiles }; +} + +export function loadGuardConfig(): GuardConfig { + let raw: unknown; + try { + raw = JSON.parse(readFileSync(guardConfigPath, "utf8")); + } catch (err) { + throw new Error( + `cannot read ${guardConfigPath}: ${(err as Error).message}`, + ); + } + const config = parseGuardConfig(raw); + if (!existsSync(join(repoRoot, config.tsconfig))) { + throw new Error(`tsconfig not found: ${config.tsconfig}`); + } + return config; +} + export function isAllowlisted( rules: AllowRule[], file: string, @@ -121,17 +266,95 @@ export function evaluateGuard( return { dead, violations, unused }; } +// The gate passes only when nothing new died and no exemption is stale. +// Stale entries fail (they used to warn) so dead exemptions cannot linger +// after the code they cover is gone. +export function isGuardPassing(outcome: GuardOutcome): boolean { + return outcome.violations.length === 0 && outcome.unused.length === 0; +} + +// 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 +// closed against the checked-in floor instead of green-lighting a scan that +// looked at less code. +export function countScannedFiles( + repoRootDir: string, + tsconfigPath: string, +): number { + const ran = spawnSync(tscBin, ["-p", tsconfigPath, "--listFilesOnly"], { + cwd: repoRootDir, + encoding: "utf8", + }); + if (ran.error !== undefined) { + throw new Error(`tsc --listFilesOnly failed to start: ${ran.error}`); + } + const roots = [repoRootDir, realpathSync(repoRootDir)]; + let count = 0; + for (const raw of String(ran.stdout).split("\n")) { + const line = raw.trim(); + if (line === "") continue; + if (!line.endsWith(".ts") && !line.endsWith(".tsx")) continue; + if (line.includes(`${sep}node_modules${sep}`)) continue; + if (!roots.some((root) => line.startsWith(root + sep))) continue; + count += 1; + } + return count; +} + +export function isCoverageEnough(scannedFiles: number, floor: number): boolean { + return scannedFiles >= floor; +} + +function fail(message: string): never { + console.error(message); + process.exit(1); +} + function main(): void { - const rules = loadAllowlist(); - const pruned = spawnSync(tsPruneBin, [], { cwd: repoRoot, encoding: "utf8" }); + let config: GuardConfig; + try { + config = loadGuardConfig(); + } catch (err) { + fail(`dead-export guard: invalid guard config: ${(err as Error).message}`); + } + const allowlistText = readFileSync(allowlistPath, "utf8"); + const allowlistProblems = [ + ...validateAllowlistText(allowlistText), + ...validateAllowlistOwnership(allowlistText), + ]; + if (allowlistProblems.length > 0) { + fail( + "Invalid allowlist entries (fix the shape or remove them):\n" + + allowlistProblems.map((problem) => ` ${problem}`).join("\n"), + ); + } + const rules = parseAllowlistText(allowlistText); + const pruned = spawnSync(tsPruneBin, [...config.tsPruneArgs], { + cwd: repoRoot, + encoding: "utf8", + }); if (pruned.status !== 0) { - console.error(`ts-prune failed:\n${pruned.stderr || pruned.stdout}`); - process.exit(1); + fail(`ts-prune failed:\n${pruned.stderr || pruned.stdout}`); } 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}`, + ); + } console.log( - `dead-export guard: ${outcome.dead} consumer-less exports, ${outcome.dead - outcome.violations.length} allowlisted, ${outcome.violations.length} violations`, + `dead-export guard: ${outcome.dead} consumer-less exports, ${outcome.dead - outcome.violations.length} allowlisted, ${outcome.violations.length} violations, ${scannedFiles} scanned files (floor ${config.minScannedFiles})`, ); + if (!isCoverageEnough(scannedFiles, config.minScannedFiles)) { + fail( + `dead-export guard: scanned file count ${scannedFiles} is below the floor ${config.minScannedFiles} ` + + "(the scan narrowed; fix the tsconfig or update scripts/dead-export-guard.json)", + ); + } if (outcome.unused.length > 0) { console.error( "Stale allowlist entries matching no dead export (remove them):\n" + @@ -143,6 +366,8 @@ function main(): void { "New dead exports (delete them or justify an allowlist entry):\n" + outcome.violations.map((v) => ` ${v}`).join("\n"), ); + } + if (!isGuardPassing(outcome)) { process.exit(1); } } diff --git a/scripts/dead-export-allowlist.txt b/scripts/dead-export-allowlist.txt index 450a55b43..75e7d734f 100644 --- a/scripts/dead-export-allowlist.txt +++ b/scripts/dead-export-allowlist.txt @@ -3,7 +3,14 @@ # Format: `#` lines are reasons and attach to the entries below them. An entry # is either a path prefix ending in `/` (matches a directory subtree), a full # file path (matches every export in that file), or `file: name` (matches one -# export). Every entry must sit under a reason comment. +# export). Anything else (a directory without its trailing `/`, a `file:` +# line that is not `path/to/file.ts: ExportName`) fails the gate. +# +# Review convention: this repo has no CODEOWNERS, so every entry block must +# name its owning lane in the reason comment above it, and changes to an +# entry need that lane's review. The gate rejects entries with no reason +# comment above them, and stale entries (matching no current ts-prune flag) +# fail the gate: remove the entry with the code it covered. # # Everything NOT listed here must have zero ts-prune flags: any export with no # consumer gets deleted instead of allowlisted. diff --git a/scripts/dead-export-guard.json b/scripts/dead-export-guard.json new file mode 100644 index 000000000..9b2bf9e8f --- /dev/null +++ b/scripts/dead-export-guard.json @@ -0,0 +1,5 @@ +{ + "tsconfig": "tsconfig.json", + "tsPruneArgs": ["-p", "tsconfig.json"], + "minScannedFiles": 1130 +} From ea2db0433ff2c96f5aae2d3dfe19137bb16c5dce Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 14:43:25 -0700 Subject: [PATCH 2/2] 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", ""] equality so the pin actually pins. Also reword ownership headers to match presence-only enforcement. --- scripts/check-dead-exports.test.ts | 18 ++++++++++++++++++ scripts/check-dead-exports.ts | 29 ++++++++++++++--------------- scripts/dead-export-allowlist.txt | 4 ++-- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/scripts/check-dead-exports.test.ts b/scripts/check-dead-exports.test.ts index 98fd2becc..6d02b7594 100644 --- a/scripts/check-dead-exports.test.ts +++ b/scripts/check-dead-exports.test.ts @@ -274,6 +274,24 @@ describe("pinned scan invocation", () => { ).toThrow(); }); + test("parseGuardConfig rejects extra narrowing flags on a pinned invocation", () => { + const valid = { + tsconfig: "tsconfig.json", + tsPruneArgs: ["-p", "tsconfig.json"], + minScannedFiles: 1130, + }; + expect(parseGuardConfig(valid)).toEqual(valid); + const narrowed = [ + ["-p", "tsconfig.json", "-i", "src/.*"], + ["-p", "tsconfig.json", "--ignore", "src/.*"], + ["-p", "tsconfig.json", "--error"], + ["--ignore", "src/.*", "-p", "tsconfig.json"], + ]; + for (const tsPruneArgs of narrowed) { + expect(() => parseGuardConfig({ ...valid, tsPruneArgs })).toThrow(); + } + }); + test("parseGuardConfig rejects a missing floor", () => { const valid = { tsconfig: "tsconfig.json", diff --git a/scripts/check-dead-exports.ts b/scripts/check-dead-exports.ts index e3433bb7b..5d212fbc7 100644 --- a/scripts/check-dead-exports.ts +++ b/scripts/check-dead-exports.ts @@ -12,10 +12,10 @@ import { fileURLToPath } from "node:url"; // positives only. // // Hardening: stale allowlist entries fail the gate instead of warning, every -// entry must pass shape validation and sit under a reason comment naming its -// owning lane (this repo has no CODEOWNERS, so the allowlist header documents -// the review convention and the gate enforces the reason comments), the -// ts-prune invocation is pinned to scripts/dead-export-guard.json, and the +// entry must pass shape validation and sit under a reason comment (the gate +// enforces the reason's presence; review enforces the owning lane — this repo +// has no CODEOWNERS, so the allowlist header documents the review convention), +// the ts-prune invocation is pinned to scripts/dead-export-guard.json, and the // gate fails closed when the scanned file count drops below that config's // floor. @@ -125,10 +125,10 @@ export function validateAllowlistText(text: string): string[] { return problems; } -// Every entry must sit under a reason comment naming its owning lane, in the -// same blank-line section and above the entry. A section of entries with no -// reason above it fails, so exemptions cannot land without an owner on -// record for review. +// Every entry must sit under a reason comment in the same blank-line section +// and above the entry. The gate enforces the reason's presence; human review +// enforces that it names the owning lane. A section of entries with no reason +// above it fails, so exemptions cannot land without a reason on record. export function validateAllowlistOwnership(text: string): string[] { const problems: string[] = []; let reasoned = false; @@ -155,10 +155,10 @@ export function loadAllowlist(): AllowRule[] { return parseAllowlistText(readFileSync(allowlistPath, "utf8")); } -// Parses and validates the pinned scan config. The invocation must pin the -// project explicitly ("-p ") so the scan never depends on ts-prune's -// working-directory config discovery, and the floor must be a positive -// integer the gate fails closed against. +// Parses and validates the pinned scan config. The invocation must be exactly +// "-p " and nothing else, so narrowing flags (e.g. "-i"/"--ignore") +// cannot shrink the scan while the tsc --listFilesOnly file count stays flat. +// The floor must be a positive integer the gate fails closed against. export function parseGuardConfig(raw: unknown): GuardConfig { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { throw new Error("dead-export guard config must be a JSON object"); @@ -179,10 +179,9 @@ export function parseGuardConfig(raw: unknown): GuardConfig { ); } const args = tsPruneArgs as string[]; - const projectFlag = args.indexOf("-p"); - if (projectFlag === -1 || args[projectFlag + 1] !== tsconfig) { + if (args.length !== 2 || args[0] !== "-p" || args[1] !== tsconfig) { throw new Error( - 'dead-export guard config "tsPruneArgs" must pin the project ("-p ")', + 'dead-export guard config "tsPruneArgs" must be exactly ["-p", ""] with no extra flags', ); } const minScannedFiles = config["minScannedFiles"]; diff --git a/scripts/dead-export-allowlist.txt b/scripts/dead-export-allowlist.txt index 75e7d734f..2041c4f28 100644 --- a/scripts/dead-export-allowlist.txt +++ b/scripts/dead-export-allowlist.txt @@ -6,10 +6,10 @@ # export). Anything else (a directory without its trailing `/`, a `file:` # line that is not `path/to/file.ts: ExportName`) fails the gate. # -# Review convention: this repo has no CODEOWNERS, so every entry block must +# Review convention: this repo has no CODEOWNERS, so every entry block should # name its owning lane in the reason comment above it, and changes to an # entry need that lane's review. The gate rejects entries with no reason -# comment above them, and stale entries (matching no current ts-prune flag) +# comment above them (presence only), and stale entries (matching no current ts-prune flag) # fail the gate: remove the entry with the code it covered. # # Everything NOT listed here must have zero ts-prune flags: any export with no