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