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
JavaScript
// 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();