Skip to content

Instantly share code, notes, and snippets.

@kubk
Created July 9, 2026 17:35
Show Gist options
  • Select an option

  • Save kubk/47523fcee63190c8613b5ce68bdd0365 to your computer and use it in GitHub Desktop.

Select an option

Save kubk/47523fcee63190c8613b5ce68bdd0365 to your computer and use it in GitHub Desktop.
Detect unused class methods
// 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