Skip to content

Commit a425807

Browse files
authored
ci(deadcode): replace broken export report lanes with enforced knip exports ratchet (#105595)
* chore(deadcode): add generated knip unused-export baseline * ci(deadcode): replace broken export report lanes with enforced knip exports ratchet * chore(deadcode): regenerate unused-export baseline on current main * chore(deadcode): regenerate unused-export baseline on current main
1 parent 17a464b commit a425807

9 files changed

Lines changed: 6682 additions & 317 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,7 +1600,7 @@ jobs:
16001600
if pnpm run --silent 2>/dev/null | grep -q '^ deadcode:dependencies$'; then
16011601
pnpm deadcode:dependencies
16021602
pnpm deadcode:unused-files
1603-
pnpm deadcode:report:ci:ts-unused
1603+
pnpm deadcode:exports
16041604
else
16051605
pnpm deadcode:ci
16061606
fi
@@ -1626,14 +1626,6 @@ jobs:
16261626
;;
16271627
esac
16281628
1629-
- name: Upload deadcode reports
1630-
if: ${{ always() && matrix.task == 'dependencies' }}
1631-
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
1632-
with:
1633-
name: deadcode-reports
1634-
path: .artifacts/deadcode
1635-
if-no-files-found: ignore
1636-
16371629
check-additional-shard:
16381630
permissions:
16391631
contents: read

docs/ci.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ job budget.
111111

112112
Android CI runs both `testPlayDebugUnitTest` and `testThirdPartyDebugUnitTest` and then builds the Play debug APK. The third-party flavor has no separate source set or manifest; its unit-test lane still compiles the flavor with the SMS/call-log BuildConfig flags, while avoiding a duplicate debug APK packaging job on every Android-relevant push.
113113

114-
The `check-dependencies` shard runs `pnpm deadcode:dependencies` (a production Knip dependency-only pass pinned to an exact Knip version, with pnpm's minimum release age disabled for the `dlx` install) and `pnpm deadcode:unused-files`, which compares Knip's production unused-file findings against `scripts/deadcode-unused-files.allowlist.mjs`, plus an advisory `pnpm deadcode:report:ci:ts-unused` report uploaded as the `deadcode-reports` artifact. The unused-file guard fails when a PR adds a new unreviewed unused file or leaves a stale allowlist entry, while preserving intentional dynamic plugin, generated, build, live-test, and package bridge surfaces that Knip cannot resolve statically.
114+
The `check-dependencies` shard runs a production Knip dependency-only pass, the unused-file allowlist check, and the enforced unused-exports baseline ratchet. The unused-file guard fails when a PR adds a new unreviewed unused file or leaves a stale allowlist entry, while preserving intentional dynamic plugin, generated, build, live-test, and package bridge surfaces that Knip cannot resolve statically. The unused-exports ratchet likewise rejects new findings and stale required baseline entries, so the checked-in debt can only shrink. The former ts-prune and ts-unused-exports advisory reports no longer run.
115115

116116
## ClawSweeper activity forwarding
117117

package.json

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1586,15 +1586,11 @@
15861586
"crabbox:run": "node scripts/crabbox-wrapper.mjs run",
15871587
"crabbox:stop": "node scripts/crabbox-wrapper.mjs stop",
15881588
"crabbox:warmup": "node scripts/crabbox-wrapper.mjs warmup",
1589-
"deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-unused",
15901589
"deadcode:dependencies": "pnpm --config.minimum-release-age=0 dlx --package [email protected] knip --config config/knip.config.ts --production --no-progress --reporter compact --dependencies --no-config-hints",
1590+
"deadcode:exports": "node scripts/check-deadcode-exports.mjs",
1591+
"deadcode:exports:update": "node scripts/check-deadcode-exports.mjs --update",
15911592
"deadcode:knip": "pnpm --config.minimum-release-age=0 dlx --package [email protected] knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies",
1592-
"deadcode:report": "pnpm deadcode:knip; pnpm deadcode:ts-prune; pnpm deadcode:ts-unused",
1593-
"deadcode:report:ci:knip": "mkdir -p .artifacts/deadcode && pnpm deadcode:knip > .artifacts/deadcode/knip.txt 2>&1 || true",
1594-
"deadcode:report:ci:ts-prune": "mkdir -p .artifacts/deadcode && pnpm deadcode:ts-prune > .artifacts/deadcode/ts-prune.txt 2>&1 || true",
1595-
"deadcode:report:ci:ts-unused": "mkdir -p .artifacts/deadcode && pnpm deadcode:ts-unused > .artifacts/deadcode/ts-unused-exports.txt 2>&1 || true",
1596-
"deadcode:ts-prune": "pnpm dlx ts-prune src extensions scripts",
1597-
"deadcode:ts-unused": "pnpm dlx ts-unused-exports tsconfig.json --ignoreTestFiles --exitWithCount",
1593+
"deadcode:report": "pnpm deadcode:knip; pnpm deadcode:exports",
15981594
"deadcode:unused-files": "node scripts/check-deadcode-unused-files.mjs",
15991595
"deps:root-ownership": "node scripts/root-dependency-ownership-audit.mjs",
16001596
"deps:root-ownership:check": "node scripts/root-dependency-ownership-audit.mjs --check",
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/** Parses compact Knip export sections into one path-and-symbol entry per finding. */
2+
export function parseKnipCompactUnusedExports(output: string): string[];
3+
/** Parses compact Knip export sections and reports whether Knip emitted one. */
4+
export function parseKnipCompactUnusedExportsResult(output: string): {
5+
entries: string[];
6+
sawExportSection: boolean;
7+
};
8+
/** Compares detected unused exports against the checked-in baseline. */
9+
export function compareUnusedExportsToBaseline(
10+
actualEntries: string[],
11+
baselineEntries: string[],
12+
optionalBaselineEntries?: string[],
13+
): {
14+
actual: string[];
15+
allowed: string[];
16+
unexpected: string[];
17+
stale: string[];
18+
duplicateAllowedCount: number;
19+
allowlistIsSorted: boolean;
20+
};
21+
/** Emits the checked-in baseline module used by --update. */
22+
export function formatUnusedExportBaseline(
23+
requiredEntries: string[],
24+
optionalEntries?: string[],
25+
): string;
26+
/** Checks Knip output against the current baseline. */
27+
export function checkUnusedExports(
28+
output: string,
29+
baselineEntries?: string[],
30+
optionalBaselineEntries?: string[],
31+
): {
32+
ok: boolean;
33+
comparison: {
34+
actual: string[];
35+
allowed: string[];
36+
unexpected: string[];
37+
stale: string[];
38+
duplicateAllowedCount: number;
39+
allowlistIsSorted: boolean;
40+
};
41+
message: string;
42+
};

scripts/check-deadcode-exports.mjs

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
#!/usr/bin/env node
2+
// Enforces a ratcheting baseline for Knip's unused exports.
3+
import { writeFile } from "node:fs/promises";
4+
import { fileURLToPath } from "node:url";
5+
import {
6+
KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE,
7+
KNIP_UNUSED_EXPORT_BASELINE,
8+
} from "./deadcode-exports.baseline.mjs";
9+
import {
10+
compareStringListToAllowlist,
11+
isLikelyRepoFilePath,
12+
runKnip,
13+
uniqueSorted,
14+
} from "./deadcode-knip-runner.mjs";
15+
16+
const KNIP_ARGS = [
17+
"--config",
18+
"config/knip.config.ts",
19+
"--production",
20+
"--no-progress",
21+
"--reporter",
22+
"compact",
23+
"--include",
24+
"exports,types,enumMembers",
25+
"--no-config-hints",
26+
];
27+
28+
const BASELINE_HEADER = `// Pre-existing unused exports awaiting deletion.
29+
// New entries fail CI. After deleting dead code, run \`pnpm deadcode:exports:update\`.
30+
// Do not add entries to avoid fixing new findings.`;
31+
32+
/** Parses compact Knip export sections into one path-and-symbol entry per finding. */
33+
export function parseKnipCompactUnusedExportsResult(output) {
34+
const entries = [];
35+
let inExportSection = false;
36+
let sawExportSection = false;
37+
38+
for (const line of output.split(/\r?\n/u)) {
39+
const sectionMatch = /^Unused (exports|exported types|exported enum members) \(\d+\)$/u.exec(
40+
line,
41+
);
42+
if (sectionMatch) {
43+
inExportSection = true;
44+
sawExportSection = true;
45+
continue;
46+
}
47+
if (/^Unused .+ \(\d+\)$/u.test(line)) {
48+
inExportSection = false;
49+
continue;
50+
}
51+
if (!inExportSection) {
52+
continue;
53+
}
54+
55+
const separatorIndex = line.indexOf(": ");
56+
if (separatorIndex === -1) {
57+
continue;
58+
}
59+
const file = line.slice(0, separatorIndex).trim();
60+
if (!isLikelyRepoFilePath(file)) {
61+
continue;
62+
}
63+
const symbols = line.slice(separatorIndex + 2).split(", ");
64+
for (const symbol of symbols) {
65+
const trimmedSymbol = symbol.trim();
66+
if (trimmedSymbol) {
67+
entries.push(`${file}: ${trimmedSymbol}`);
68+
}
69+
}
70+
}
71+
72+
return { entries: uniqueSorted(entries), sawExportSection };
73+
}
74+
75+
/** Parses compact Knip export sections into one path-and-symbol entry per finding. */
76+
export function parseKnipCompactUnusedExports(output) {
77+
return parseKnipCompactUnusedExportsResult(output).entries;
78+
}
79+
80+
/** Compares detected unused exports against the checked-in baseline. */
81+
export function compareUnusedExportsToBaseline(
82+
actualEntries,
83+
baselineEntries,
84+
optionalBaselineEntries = [],
85+
) {
86+
return compareStringListToAllowlist(actualEntries, baselineEntries, optionalBaselineEntries);
87+
}
88+
89+
function formatUnusedExportComparison(comparison) {
90+
const lines = [];
91+
if (!comparison.allowlistIsSorted) {
92+
lines.push("deadcode unused-export baseline is not sorted.");
93+
}
94+
if (comparison.duplicateAllowedCount > 0) {
95+
lines.push(
96+
`deadcode unused-export baseline contains ${comparison.duplicateAllowedCount} duplicate entr${
97+
comparison.duplicateAllowedCount === 1 ? "y" : "ies"
98+
}.`,
99+
);
100+
}
101+
if (comparison.unexpected.length > 0) {
102+
lines.push("Unexpected unused exports:");
103+
lines.push(...comparison.unexpected.map((entry) => ` ${entry}`));
104+
}
105+
if (comparison.stale.length > 0) {
106+
lines.push("Stale required baseline entries:");
107+
lines.push(...comparison.stale.map((entry) => ` ${entry}`));
108+
}
109+
if (lines.length > 0) {
110+
lines.push("Run `pnpm deadcode:exports:update` after removing dead code.");
111+
}
112+
return lines.join("\n");
113+
}
114+
115+
function formatArrayExport(name, entries) {
116+
if (entries.length === 0) {
117+
return `export const ${name} = [];`;
118+
}
119+
return `export const ${name} = [\n${entries.map((entry) => ` ${JSON.stringify(entry)},`).join("\n")}\n];`;
120+
}
121+
122+
/** Emits the checked-in baseline module used by --update. */
123+
export function formatUnusedExportBaseline(
124+
requiredEntries,
125+
optionalEntries = KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE,
126+
) {
127+
// Optional entries are platform-variant: promoting one into the required
128+
// list would make CI stale-fail on platforms where it is legitimately absent.
129+
const optionalSet = new Set(uniqueSorted(optionalEntries));
130+
return `${BASELINE_HEADER}\n${formatArrayExport(
131+
"KNIP_UNUSED_EXPORT_BASELINE",
132+
uniqueSorted(requiredEntries).filter((entry) => !optionalSet.has(entry)),
133+
)}\n\n// Platform-variant findings. Allowed when present; never required.\n${formatArrayExport(
134+
"KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE",
135+
uniqueSorted(optionalEntries),
136+
)}\n`;
137+
}
138+
139+
/** Checks Knip output against the current baseline. */
140+
export function checkUnusedExports(
141+
output,
142+
baselineEntries = KNIP_UNUSED_EXPORT_BASELINE,
143+
optionalBaselineEntries = KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE,
144+
) {
145+
const actual = parseKnipCompactUnusedExports(output);
146+
const comparison = compareUnusedExportsToBaseline(
147+
actual,
148+
baselineEntries,
149+
optionalBaselineEntries,
150+
);
151+
return {
152+
ok:
153+
comparison.allowlistIsSorted &&
154+
comparison.duplicateAllowedCount === 0 &&
155+
comparison.unexpected.length === 0 &&
156+
comparison.stale.length === 0,
157+
comparison,
158+
message: formatUnusedExportComparison(comparison),
159+
};
160+
}
161+
162+
async function main() {
163+
const update = process.argv.slice(2).includes("--update");
164+
const result = await runKnip(KNIP_ARGS, { scanName: "unused-export scan" });
165+
if (result.errorCode || result.status === null) {
166+
console.error(
167+
`deadcode unused-export scan failed: ${result.errorCode ?? result.signal ?? "unknown"}${
168+
result.errorMessage ? `: ${result.errorMessage}` : ""
169+
}`,
170+
);
171+
if (result.output) {
172+
console.error(result.output);
173+
}
174+
process.exitCode = 1;
175+
return;
176+
}
177+
178+
const parsed = parseKnipCompactUnusedExportsResult(result.output);
179+
// Knip's compact reporter omits empty sections, so a clean scan (exit 0)
180+
// legitimately prints no export sections; sectionless output is only a
181+
// failure signal when Knip also exited nonzero (crash/config error).
182+
if (!parsed.sawExportSection && result.status !== 0) {
183+
console.error("deadcode unused-export scan produced no export sections.");
184+
if (result.output) {
185+
console.error(result.output);
186+
}
187+
process.exitCode = 1;
188+
return;
189+
}
190+
191+
const actual = parsed.entries;
192+
if (update) {
193+
const baselinePath = fileURLToPath(new URL("./deadcode-exports.baseline.mjs", import.meta.url));
194+
await writeFile(
195+
baselinePath,
196+
formatUnusedExportBaseline(actual, KNIP_OPTIONAL_UNUSED_EXPORT_BASELINE),
197+
"utf8",
198+
);
199+
console.log(`[deadcode] Updated unused-export baseline with ${actual.length} entries.`);
200+
return;
201+
}
202+
203+
const check = checkUnusedExports(result.output);
204+
if (!check.ok) {
205+
console.error(check.message);
206+
process.exitCode = 1;
207+
return;
208+
}
209+
console.log(`[deadcode] Knip unused-export baseline matched ${actual.length} entries.`);
210+
}
211+
212+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
213+
await main();
214+
}

0 commit comments

Comments
 (0)