#!/usr/bin/env bun /** * Fails when a pod declared in ios/Podfile is no longer reached from the sources. * * Removing the last call site of a pod does not fail the build — the pod keeps * being fetched, compiled and linked — so nothing tells us a declaration has * outlived its reason. Knip cannot help here either: it reads JavaScript and * TypeScript, and a Podfile is neither. * * Every declared pod needs an entry in ios/pod-usage.json saying how the sources * reach it. `module` and `headers` entries are verified against the sources on * every run. `exempt` entries are for pods that are alive by a route the sources * cannot show — a build phase, a version pin, a linker flag — and they have to * carry a reason and the date it was last checked. That date is validated as a * real, non-future date and reported as a warning once it is over a year old; it * is not a failure, so an exemption never breaks the build on a date nobody * chose. * * The manifest is generated by scripts/generatePodUsage.ts after `pod install`. * * Usage: npx bun scripts/checkUnusedPods.ts */ import path from 'path'; import type {PodUsageEntry} from './podUsageShared'; import {findBlankEntry, validateReviewedDate} from './nativeUsageManifest'; import {MANIFEST_PATH, countReferences, findUnparsedPodDeclarations, readDeclaredPods, readManifest, readSources} from './podUsageShared'; const RELATIVE_MANIFEST = path.relative(path.resolve(__dirname, '..'), MANIFEST_PATH); const USAGES = new Set(['module', 'headers', 'exempt']); /** * Modules every iOS source imports anyway, so claiming one proves nothing about * a pod. */ const SYSTEM_MODULES = new Set(['Foundation', 'UIKit', 'SwiftUI', 'Combine', 'CoreGraphics', 'CoreFoundation', 'Swift', 'React', 'ObjectiveC']); function validateEntry(pod: string, entry: PodUsageEntry): {error?: string; warning?: string} { if (!USAGES.has(entry.usage)) { return {error: `${pod}: "usage" must be one of ${[...USAGES].join(', ')}.`}; } if (entry.usage === 'module') { if (!entry.module.trim()) { return {error: `${pod}: a "module" entry needs the module name the pod is imported under.`}; } if (SYSTEM_MODULES.has(entry.module.trim())) { return { error: `${pod}: "${entry.module}" is a system module that the sources import regardless of this pod, so it cannot verify it. Use the pod's own module, or mark it "exempt" with a reason.`, }; } } if (entry.usage === 'headers') { if (!entry.headers.length) { return {error: `${pod}: a "headers" entry needs at least one public header name.`}; } const blank = findBlankEntry(entry.headers, pod, 'headers'); if (blank) { return {error: blank}; } } if (entry.usage === 'exempt') { if (!entry.reason.trim()) { return {error: `${pod}: an "exempt" entry needs a "reason" saying what keeps the pod alive.`}; } return validateReviewedDate(entry.reviewed, pod); } return {}; } /** * Two pods claiming the same module or header cannot be told apart: whichever * one still has a call site keeps the other green. */ function findOverlappingRoutes(manifest: Record): string[] { const claims = Object.entries(manifest).flatMap(([pod, entry]) => { if (entry.usage === 'module') { return [{pod, route: `module ${entry.module}`}]; } if (entry.usage === 'headers') { return entry.headers.map((header) => ({pod, route: `header ${header}`})); } return []; }); const problems: string[] = []; for (const [index, claim] of claims.entries()) { for (const other of claims.slice(index + 1)) { if (other.pod !== claim.pod && other.route === claim.route) { problems.push(`${claim.pod} and ${other.pod} both claim ${claim.route}, so a reference cannot tell them apart. Mark the one that does not own it "exempt" with a reason.`); } } } return problems; } function main() { let manifest; try { manifest = readManifest(); } catch (error) { console.error(`Unused pod check failed:\n\n - ${error instanceof Error ? error.message : String(error)}`); process.exit(1); } const declared = readDeclaredPods(); const problems: string[] = []; const warnings: string[] = []; problems.push(...findOverlappingRoutes(manifest)); for (const unparsed of findUnparsedPodDeclarations()) { problems.push(`${unparsed}\n This pod declaration is in a form the check cannot read, so it would be verified by nothing. Teach scripts/podUsageShared.ts to read it.`); } // A parser that matches nothing would otherwise report success over an empty // set, which is the one failure mode this check cannot afford. if (!declared.length) { problems.push('No pods were parsed out of ios/Podfile. Either the Podfile declares none, or the parser in scripts/podUsageShared.ts no longer understands it.'); } for (const pod of declared) { if (!(pod in manifest)) { problems.push(`${pod} is declared in ios/Podfile but missing from ${RELATIVE_MANIFEST}. Run \`npx bun scripts/generatePodUsage.ts\` after \`pod install\`.`); } } for (const pod of Object.keys(manifest)) { if (!declared.includes(pod)) { problems.push(`${pod} is in ${RELATIVE_MANIFEST} but no longer declared in ios/Podfile. Delete the entry.`); } } const sources = readSources(); for (const pod of declared) { const entry = manifest[pod]; if (!entry) { continue; } const {error, warning} = validateEntry(pod, entry); if (warning) { warnings.push(warning); } if (error) { problems.push(error); continue; } if (entry.usage === 'exempt') { continue; } if (countReferences(sources, entry, pod) === 0) { const route = entry.usage === 'module' ? `import ${entry.module}` : entry.headers.map((header) => `#import "${header}"`).join(' / '); problems.push(`${pod} is declared in ios/Podfile but nothing in ios/ reaches it (looked for ${route}). Delete the pod, or mark it "exempt" with a reason.`); } } if (warnings.length) { console.warn('Unused pod check warnings:\n'); for (const warning of warnings) { console.warn(` - ${warning}`); } console.warn(''); } if (problems.length) { console.error('Unused pod check failed:\n'); for (const problem of problems) { console.error(` - ${problem}`); } process.exit(1); } console.log(`Unused pod check passed: ${declared.length} pods declared, all reached or exempt with a reason.`); } main();