UNPKG

find-old-todos

Version:

CLI tool for JavaScript and TypeScript projects that searches for TODO comments older than a specified number of days.

252 lines (240 loc) 7.08 kB
#!/usr/bin/env node // src/utils/git.ts import { execSync } from "node:child_process"; // src/utils/args.ts import arg from "arg"; var parsedArgs = arg({ "--days": Number }); var days = parsedArgs["--days"]; if (days === void 0) { days = 30; } else if (days < 0) { days = 0; } var args = { days }; // src/utils/git.ts function isInsideGitRepository() { try { execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }); return true; } catch { return false; } } function getGitBlameInfo({ filePath, lineNumber }) { try { const blameOutput = execSync( `git blame -L ${lineNumber},${lineNumber} --line-porcelain "${filePath}"`, { encoding: "utf8", // To ignore errors but keep the output stdio: ["ignore", "pipe", "ignore"] } ); const dateMatch = blameOutput.match(/^author-time (\d+)/m); if (!dateMatch?.[1]) return null; const timestamp = Number.parseInt(dateMatch[1], 10) * 1e3; return { committedAt: new Date(timestamp) }; } catch { return null; } } var daysInMilliseconds = args.days * 24 * 60 * 60 * 1e3; function isOldCommit(commitDate) { return Date.now() - commitDate.getTime() > daysInMilliseconds; } function isPathIgnoredByGit(targetPath) { try { execSync(`git check-ignore ${targetPath}`, { stdio: "ignore" }); return true; } catch { return false; } } // src/utils/logger.ts import picocolors from "picocolors"; // src/utils/dayjs.ts import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime.js"; dayjs.extend(relativeTime); // src/utils/logger.ts function logTodos(todos) { if (!todos.length) { console.log( picocolors.greenBright(`\u{1F389} No TODOs found older than ${args.days} days.`) ); return; } console.log( picocolors.underline( picocolors.yellowBright(`TODOs older than ${args.days} days:`) ) ); const now = dayjs(); let content = ""; let maxDateLength = 0; const contentLengthLimit = 40; let maxContentLength = 0; const formattedTodos = todos.map((todo) => { const committedAt = dayjs(todo.committedAt); const formattedTodo = { date: `${committedAt.format("YYYY-MM-DD")} (${committedAt.from(now, true)})`, linePath: `${todo.filePath}:${todo.lineNumber}`, content: todo.lineContent.length > contentLengthLimit ? `${todo.lineContent.slice(0, contentLengthLimit - 3)}...` : todo.lineContent }; if (formattedTodo.date.length > maxDateLength) { maxDateLength = formattedTodo.date.length; } if (formattedTodo.content.length > maxContentLength) { maxContentLength = formattedTodo.content.length; } return formattedTodo; }); formattedTodos.forEach((todo, i) => { content += "- "; content += picocolors.yellowBright(todo.date.padEnd(maxDateLength + 1)); content += picocolors.greenBright( todo.content.padEnd(maxContentLength + 1) ); content += picocolors.blueBright(todo.linePath); const isLastTodo = i === formattedTodos.length - 1; if (!isLastTodo) { content += "\n"; } }); console.log(content); } // src/utils/spinner.ts import ora from "ora"; var spinner = ora( `Searching for TODOs older than ${args.days} days...` ); // src/utils/todos.ts import path2 from "node:path"; // src/utils/fs.ts import { parse } from "@typescript-eslint/typescript-estree"; import fs from "node:fs/promises"; import path from "node:path"; var JSX_ALLOWED_FILE_EXTENSIONS = [".js", ".jsx", ".cjs", ".mjs", ".tsx"]; var NON_JSX_ALLOWED_FILE_EXTENSIONS = [".ts"]; var SUPPORTED_FILE_EXTENSIONS = [ ...JSX_ALLOWED_FILE_EXTENSIONS, ...NON_JSX_ALLOWED_FILE_EXTENSIONS ]; function isPathIgnored(targetPath) { const pathParts = targetPath.split(path.sep); if (pathParts.some( (part) => ( // Normally, `node_modules` should be ignored by git by adding it in `.gitignore`. // But we also check it here to prevent searching its heavily nested file structure // in case of it is not added to `.gitignore` etc. part === "node_modules" || part === ".git" ) )) { return true; } return isPathIgnoredByGit(targetPath); } async function getAllFilePaths(folderPath) { const filePaths = []; if (isPathIgnored(folderPath)) { return filePaths; } const items = await fs.readdir(folderPath); for (const item of items) { const itemPath = path.join(folderPath, item); const stats = await fs.stat(itemPath); if (stats.isDirectory()) { const nestedFilePaths = await getAllFilePaths(itemPath); filePaths.push(...nestedFilePaths); continue; } if (!stats.isFile()) continue; const fileExtension = path.extname(itemPath); if (!SUPPORTED_FILE_EXTENSIONS.includes(fileExtension)) continue; if (isPathIgnored(itemPath)) continue; filePaths.push(itemPath); } return filePaths; } async function getFileAST(filePath) { const fileContent = await fs.readFile(filePath, "utf8"); const fileExtension = path.extname(filePath); try { const ast = parse(fileContent, { loc: true, comment: true, jsx: JSX_ALLOWED_FILE_EXTENSIONS.includes(fileExtension) }); return ast; } catch (error) { console.error(`Failed to parse file: ${filePath}`, error); } } // src/utils/todos.ts async function findFileComments(filePath) { const ast = await getFileAST(filePath); return ast?.comments ?? []; } async function findTodosInFile(filePath) { const fileExtension = path2.extname(filePath); if (!SUPPORTED_FILE_EXTENSIONS.includes(fileExtension)) return []; const comments = await findFileComments(filePath); const todos = []; for (const comment of comments) { const lineContent = comment.value.trim(); if (!lineContent.toUpperCase().startsWith("TODO")) continue; const lineNumber = comment.loc.start.line; if (!lineNumber) continue; const gitInfo = getGitBlameInfo({ filePath, lineNumber }); const committedAt = gitInfo?.committedAt; if (!committedAt || !isOldCommit(committedAt)) continue; todos.push({ filePath, lineNumber, lineContent, committedAt }); } return todos; } async function findTodosInFolder(folderPath) { const filePaths = await getAllFilePaths(folderPath); let searchFileCount = 0; const todos = []; for (const filePath of filePaths) { const todosInFile = await findTodosInFile(filePath); searchFileCount++; spinner.text = `Searched files: ${searchFileCount}/${filePaths.length}`; todos.push(...todosInFile); } return todos; } // src/cli.ts async function main() { spinner.start(); await new Promise((resolve) => { setTimeout(() => { resolve(); }, 5e3); }); if (!isInsideGitRepository()) { spinner.fail(); console.error("\u{1F937} Current directory is not a Git repository."); return; } const currentDirectory = process.cwd(); const todos = await findTodosInFolder(currentDirectory); spinner.succeed(); logTodos(todos); if (todos.length) { process.exit(1); } } await main();