Created
July 9, 2026 17:35
-
-
Save kubk/47523fcee63190c8613b5ce68bdd0365 to your computer and use it in GitHub Desktop.
Detect unused class methods
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // node detect-unused-methods.mjs /path/to/frontend-folder | |
| #!/usr/bin/env node | |
| import { createRequire } from "node:module"; | |
| import fs from "node:fs"; | |
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| const repoRoot = path.dirname(fileURLToPath(import.meta.url)); | |
| const args = process.argv.slice(2); | |
| const options = { | |
| workspace: "frontend", | |
| json: false, | |
| includePrivate: false, | |
| includeProtected: false, | |
| }; | |
| for (const arg of args) { | |
| if (arg === "--help" || arg === "-h") { | |
| printHelp(); | |
| process.exit(0); | |
| } | |
| if (arg === "--json") { | |
| options.json = true; | |
| continue; | |
| } | |
| if (arg === "--include-private") { | |
| options.includePrivate = true; | |
| continue; | |
| } | |
| if (arg === "--include-protected") { | |
| options.includeProtected = true; | |
| continue; | |
| } | |
| if (arg.startsWith("--")) { | |
| console.error(`Unknown option: ${arg}`); | |
| printHelp(); | |
| process.exit(2); | |
| } | |
| options.workspace = arg; | |
| } | |
| const workspaceRoot = path.resolve(repoRoot, options.workspace); | |
| if (!fs.existsSync(workspaceRoot) || !fs.statSync(workspaceRoot).isDirectory()) { | |
| console.error(`Workspace directory not found: ${path.relative(repoRoot, workspaceRoot)}`); | |
| process.exit(2); | |
| } | |
| const ts = loadTypeScript(workspaceRoot); | |
| const compilerOptions = readCompilerOptions(ts, workspaceRoot); | |
| const files = collectSourceFiles(workspaceRoot); | |
| if (files.length === 0) { | |
| console.error(`No TypeScript source files found under ${path.relative(repoRoot, workspaceRoot)}`); | |
| process.exit(2); | |
| } | |
| const fileSet = new Set(files.map((file) => path.resolve(file))); | |
| const service = ts.createLanguageService( | |
| { | |
| getScriptFileNames: () => files, | |
| getScriptVersion: () => "0", | |
| getScriptSnapshot(fileName) { | |
| if (!fs.existsSync(fileName)) return undefined; | |
| return ts.ScriptSnapshot.fromString(fs.readFileSync(fileName, "utf8")); | |
| }, | |
| getCurrentDirectory: () => workspaceRoot, | |
| getCompilationSettings: () => compilerOptions, | |
| getDefaultLibFileName: (settings) => ts.getDefaultLibFilePath(settings), | |
| fileExists: ts.sys.fileExists, | |
| readFile: ts.sys.readFile, | |
| readDirectory: ts.sys.readDirectory, | |
| directoryExists: ts.sys.directoryExists, | |
| getDirectories: ts.sys.getDirectories, | |
| realpath: ts.sys.realpath, | |
| }, | |
| ts.createDocumentRegistry(), | |
| ); | |
| const program = service.getProgram(); | |
| if (!program) { | |
| console.error("Unable to create a TypeScript program."); | |
| process.exit(2); | |
| } | |
| const typeChecker = program.getTypeChecker(); | |
| const pickedClassMembers = collectPickedClassMembers(ts, program, typeChecker, fileSet); | |
| const findings = []; | |
| for (const sourceFile of program.getSourceFiles()) { | |
| const fileName = path.resolve(sourceFile.fileName); | |
| if (!fileSet.has(fileName)) continue; | |
| visitSourceFile(sourceFile, (node) => { | |
| if (!isCheckedClassMember(ts, node)) return; | |
| if (!shouldCheckVisibility(ts, node)) return; | |
| if (hasIgnoreUnusedMethodsComment(ts, sourceFile, node)) return; | |
| if (!node.name || !ts.isIdentifier(node.name)) return; | |
| const references = service.findReferences(sourceFile.fileName, node.name.getStart(sourceFile)) ?? []; | |
| const nonDefinitionReferences = references | |
| .flatMap((group) => group.references) | |
| .filter((reference) => !reference.isDefinition) | |
| .filter((reference) => fileSet.has(path.resolve(reference.fileName))); | |
| const classNode = node.parent; | |
| if ( | |
| nonDefinitionReferences.length > 0 || | |
| pickedClassMembers.has(getClassMemberKey(classNode, node.name.text)) | |
| ) { | |
| return; | |
| } | |
| const className = classNode.name?.text ?? "<anonymous>"; | |
| const position = sourceFile.getLineAndCharacterOfPosition(node.name.getStart(sourceFile)); | |
| findings.push({ | |
| kind: getMemberKind(ts, node), | |
| name: node.name.text, | |
| className, | |
| file: toRepoRelative(sourceFile.fileName), | |
| line: position.line + 1, | |
| column: position.character + 1, | |
| visibility: getVisibility(ts, node), | |
| }); | |
| }); | |
| } | |
| findings.sort( | |
| (a, b) => | |
| a.file.localeCompare(b.file) || | |
| a.line - b.line || | |
| a.column - b.column || | |
| a.name.localeCompare(b.name), | |
| ); | |
| if (options.json) { | |
| console.log(JSON.stringify({ workspace: toRepoRelative(workspaceRoot), findings }, null, 2)); | |
| } else if (findings.length === 0) { | |
| console.log(`No unused class methods or accessors found in ${toRepoRelative(workspaceRoot)}.`); | |
| } else { | |
| printFindings(findings, workspaceRoot); | |
| } | |
| process.exit(findings.length > 0 ? 1 : 0); | |
| function printHelp() { | |
| console.log(`Usage: node detect-unused-methods.mjs [workspace] [options] | |
| Find class methods and accessors with no non-definition references. | |
| Arguments: | |
| workspace Directory to scan. Defaults to frontend. | |
| Options: | |
| --json Print machine-readable JSON. | |
| --include-private Include private methods/accessors. | |
| --include-protected Include protected methods/accessors. | |
| -h, --help Show this help. | |
| Add // ignore-unused-methods directly above a single method/accessor to skip it. | |
| Type-only Pick<ClassName, "methodName"> references also count as explicit usage. | |
| Examples: | |
| node detect-unused-methods.mjs | |
| node detect-unused-methods.mjs frontend --json | |
| `); | |
| } | |
| function loadTypeScript(root) { | |
| const candidates = [ | |
| path.join(root, "package.json"), | |
| path.join(repoRoot, "package.json"), | |
| import.meta.url, | |
| ]; | |
| for (const candidate of candidates) { | |
| try { | |
| const requireFromCandidate = createRequire(candidate); | |
| return requireFromCandidate("typescript"); | |
| } catch { | |
| // Try the next location. | |
| } | |
| } | |
| console.error(`Unable to load TypeScript. Install it in ${toRepoRelative(root)} or the repo root.`); | |
| process.exit(2); | |
| } | |
| function readCompilerOptions(ts, root) { | |
| const configPath = path.join(root, "tsconfig.json"); | |
| if (!fs.existsSync(configPath)) { | |
| return { | |
| allowJs: false, | |
| checkJs: false, | |
| jsx: ts.JsxEmit.ReactJSX, | |
| module: ts.ModuleKind.ESNext, | |
| moduleResolution: ts.ModuleResolutionKind.Bundler, | |
| noEmit: true, | |
| target: ts.ScriptTarget.ES2020, | |
| }; | |
| } | |
| const configText = fs.readFileSync(configPath, "utf8"); | |
| const config = ts.parseConfigFileTextToJson(configPath, configText); | |
| if (config.error) { | |
| const message = ts.flattenDiagnosticMessageText(config.error.messageText, "\n"); | |
| console.error(`Unable to parse ${toRepoRelative(configPath)}: ${message}`); | |
| process.exit(2); | |
| } | |
| const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root); | |
| return { | |
| ...parsed.options, | |
| allowJs: false, | |
| checkJs: false, | |
| noEmit: true, | |
| }; | |
| } | |
| function collectSourceFiles(root) { | |
| const ignoredDirectoryNames = new Set([ | |
| ".git", | |
| ".next", | |
| "coverage", | |
| "dist", | |
| "node_modules", | |
| ]); | |
| const files = []; | |
| function walk(directory) { | |
| for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { | |
| if (entry.isDirectory()) { | |
| if (!ignoredDirectoryNames.has(entry.name)) { | |
| walk(path.join(directory, entry.name)); | |
| } | |
| continue; | |
| } | |
| if (!entry.isFile()) continue; | |
| if (!/\.(ts|tsx)$/.test(entry.name)) continue; | |
| if (entry.name.endsWith(".d.ts")) continue; | |
| files.push(path.join(directory, entry.name)); | |
| } | |
| } | |
| walk(root); | |
| return files; | |
| } | |
| function visitSourceFile(sourceFile, callback) { | |
| function visit(node) { | |
| callback(node); | |
| ts.forEachChild(node, visit); | |
| } | |
| visit(sourceFile); | |
| } | |
| function collectPickedClassMembers(ts, program, typeChecker, checkedFiles) { | |
| const pickedMembers = new Set(); | |
| for (const sourceFile of program.getSourceFiles()) { | |
| if (!checkedFiles.has(path.resolve(sourceFile.fileName))) continue; | |
| visitSourceFile(sourceFile, (node) => { | |
| if (!isPickTypeReference(ts, node)) return; | |
| const [classTypeNode, propertyKeyNode] = node.typeArguments ?? []; | |
| if (!classTypeNode || !propertyKeyNode) return; | |
| const classNode = getClassNodeForType(ts, typeChecker, classTypeNode); | |
| if (!classNode || !checkedFiles.has(path.resolve(classNode.getSourceFile().fileName))) return; | |
| for (const memberName of getStringLiteralTypeNames(ts, propertyKeyNode)) { | |
| pickedMembers.add(getClassMemberKey(classNode, memberName)); | |
| } | |
| }); | |
| } | |
| return pickedMembers; | |
| } | |
| function isPickTypeReference(ts, node) { | |
| return ( | |
| ts.isTypeReferenceNode(node) && | |
| ts.isIdentifier(node.typeName) && | |
| node.typeName.text === "Pick" && | |
| node.typeArguments?.length === 2 | |
| ); | |
| } | |
| function getClassNodeForType(ts, typeChecker, typeNode) { | |
| const typeName = ts.isTypeReferenceNode(typeNode) ? typeNode.typeName : null; | |
| let symbol = typeName ? typeChecker.getSymbolAtLocation(typeName) : undefined; | |
| if (symbol && isAliasSymbol(ts, symbol)) { | |
| symbol = typeChecker.getAliasedSymbol(symbol); | |
| } | |
| const directDeclaration = findClassLikeDeclaration(ts, symbol?.declarations); | |
| if (directDeclaration) return directDeclaration; | |
| const type = typeChecker.getTypeAtLocation(typeNode); | |
| return findClassLikeDeclaration(ts, type.symbol?.declarations ?? type.aliasSymbol?.declarations); | |
| } | |
| function isAliasSymbol(ts, symbol) { | |
| return (symbol.flags & ts.SymbolFlags.Alias) !== 0; | |
| } | |
| function findClassLikeDeclaration(ts, declarations) { | |
| return declarations?.find((declaration) => isClassLike(ts, declaration)) ?? null; | |
| } | |
| function getStringLiteralTypeNames(ts, typeNode) { | |
| if (ts.isLiteralTypeNode(typeNode)) { | |
| if (ts.isStringLiteral(typeNode.literal)) return [typeNode.literal.text]; | |
| if (typeNode.literal.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) { | |
| return [typeNode.literal.text]; | |
| } | |
| return []; | |
| } | |
| if (ts.isUnionTypeNode(typeNode)) { | |
| return typeNode.types.flatMap((node) => getStringLiteralTypeNames(ts, node)); | |
| } | |
| if (ts.isParenthesizedTypeNode?.(typeNode)) { | |
| return getStringLiteralTypeNames(ts, typeNode.type); | |
| } | |
| return []; | |
| } | |
| function getClassMemberKey(classNode, memberName) { | |
| const sourceFile = classNode.getSourceFile(); | |
| const classStart = classNode.name?.getStart(sourceFile) ?? classNode.getStart(sourceFile); | |
| return `${path.resolve(sourceFile.fileName)}:${classStart}:${memberName}`; | |
| } | |
| function isCheckedClassMember(ts, node) { | |
| return ( | |
| (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) && | |
| isClassLike(ts, node.parent) && | |
| !!node.body | |
| ); | |
| } | |
| function isClassLike(ts, node) { | |
| return ts.isClassDeclaration(node) || ts.isClassExpression(node); | |
| } | |
| function shouldCheckVisibility(ts, node) { | |
| const visibility = getVisibility(ts, node); | |
| if (visibility === "private") return options.includePrivate; | |
| if (visibility === "protected") return options.includeProtected; | |
| return true; | |
| } | |
| function hasIgnoreUnusedMethodsComment(ts, sourceFile, node) { | |
| const comments = ts.getLeadingCommentRanges(sourceFile.text, node.getFullStart()) ?? []; | |
| const nodeStart = node.getStart(sourceFile); | |
| const nodeStartLine = sourceFile.getLineAndCharacterOfPosition(nodeStart).line; | |
| return comments.some((comment) => { | |
| const commentText = sourceFile.text.slice(comment.pos, comment.end); | |
| if (!commentText.includes("ignore-unused-methods")) return false; | |
| const commentEndLine = sourceFile.getLineAndCharacterOfPosition(comment.end).line; | |
| return commentEndLine === nodeStartLine || commentEndLine + 1 === nodeStartLine; | |
| }); | |
| } | |
| function getVisibility(ts, node) { | |
| if (hasModifier(ts, node, ts.SyntaxKind.PrivateKeyword)) return "private"; | |
| if (hasModifier(ts, node, ts.SyntaxKind.ProtectedKeyword)) return "protected"; | |
| return "public"; | |
| } | |
| function hasModifier(ts, node, kind) { | |
| return !!node.modifiers?.some((modifier) => modifier.kind === kind); | |
| } | |
| function getMemberKind(ts, node) { | |
| if (ts.isGetAccessorDeclaration(node)) return "getter"; | |
| if (ts.isSetAccessorDeclaration(node)) return "setter"; | |
| return "method"; | |
| } | |
| function printFindings(rows, root) { | |
| const title = `Unused class methods/accessors (${rows.length}) in ${toRepoRelative(root)}`; | |
| console.log(title); | |
| const tableRows = rows.map((row) => ({ | |
| member: row.kind === "method" ? row.name : `${row.kind} ${row.name}`, | |
| className: row.className, | |
| location: `${row.file}:${row.line}:${row.column}`, | |
| })); | |
| const widths = { | |
| member: Math.max("member".length, ...tableRows.map((row) => row.member.length)), | |
| className: Math.max("class".length, ...tableRows.map((row) => row.className.length)), | |
| location: Math.max("location".length, ...tableRows.map((row) => row.location.length)), | |
| }; | |
| console.log( | |
| `${"member".padEnd(widths.member)} ${"class".padEnd(widths.className)} ${"location".padEnd(widths.location)}`, | |
| ); | |
| console.log( | |
| `${"-".repeat(widths.member)} ${"-".repeat(widths.className)} ${"-".repeat(widths.location)}`, | |
| ); | |
| for (const row of tableRows) { | |
| console.log( | |
| `${row.member.padEnd(widths.member)} ${row.className.padEnd(widths.className)} ${row.location.padEnd( | |
| widths.location, | |
| )}`, | |
| ); | |
| } | |
| } | |
| function toRepoRelative(fileName) { | |
| const relative = path.relative(repoRoot, path.resolve(fileName)); | |
| return relative.length === 0 ? "." : relative.split(path.sep).join("/"); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment