#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { basename, dirname, extname, resolve } from "node:path"; const DEFAULT_OUTPUT = ".artifacts/dated-todo-candidates.json"; const MAX_FILE_BYTES = 2 * 1024 * 1024; const MAX_CANDIDATES = 5_000; const TEXT_EXTENSIONS = new Set([ ".bash", ".cjs", ".css", ".cts", ".go", ".html", ".java", ".js", ".json", ".jsx", ".kt", ".kts", ".md", ".mdx", ".mjs", ".mts", ".php", ".py", ".rb", ".rs", ".scss", ".sh", ".swift", ".toml", ".ts", ".tsx", ".xml", ".yaml", ".yml", ".zsh", ]); const EXCLUDED_SEGMENTS = new Set([ ".artifacts", ".generated", ".git", ".i18n", ".next", "__fixtures__", "build", "coverage", "dist", "dist-runtime", "fixtures", "generated", "i18n", "locales", "node_modules", "test-fixtures", "translations", "vendor", ]); const EXCLUDED_BASENAMES = new Set([ "bun.lock", "bun.lockb", "CHANGELOG.md", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", ]); const TODO_PATTERN = /\b(?:TODO|FIXME|HACK|removeAfter|remove\s+after|delete\s+after|until|deadline|expires?|expiry|expiration|deprecated|deprecation|window|re-?enable|temporary)\b/iu; const DATE_PATTERN = /(?:\b20\d{2}-\d{2}-\d{2}(?=\b|T)|\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+(?:\d{1,2}(?:st|nd|rd|th)?(?:,\s*|\s+))?20\d{2}\b)/iu; const ISO_DATE_PREFILTER = String.raw`20[0-9]{2}-[0-9]{2}-[0-9]{2}`; const MONTH_DATE_PREFILTER = String.raw`(Jan(uary)?|Feb(ruary)?|Mar(ch)?|Apr(il)?|May|Jun(e)?|Jul(y)?|Aug(ust)?|Sep(t(ember)?)?|Oct(ober)?|Nov(ember)?|Dec(ember)?)\s+([0-9]{1,2}(st|nd|rd|th)?(,\s*|\s+))?20[0-9]{2}`; const GIT_MONTH_DATE_PREFILTER = MONTH_DATE_PREFILTER.replaceAll(String.raw`\s`, "[[:space:]]"); function parseArgs(argv) { const options = { root: process.cwd(), output: DEFAULT_OUTPUT, compatReport: undefined, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--help" || arg === "-h") { process.stdout.write( "Usage: node scripts/dated-todo-scan.mjs [--root ] [--output ] [--compat-report ]\n", ); process.exit(0); } const value = argv[index + 1]; if (!value || value.startsWith("--")) { throw new Error(`Missing value for ${arg}`); } if (arg === "--root") { options.root = value; } else if (arg === "--output") { options.output = value; } else if (arg === "--compat-report") { options.compatReport = value; } else { throw new Error(`Unknown argument: ${arg}`); } index += 1; } options.root = resolve(options.root); options.output = resolve(options.root, options.output); if (options.compatReport) { options.compatReport = resolve(options.root, options.compatReport); } return options; } function run(command, args, cwd, maxBuffer = 32 * 1024 * 1024) { const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer, stdio: ["ignore", "pipe", "pipe"], }); if (result.error) { throw new Error(`Failed to run ${command}: ${result.error.message}`); } if (result.status !== 0) { throw new Error( `${command} ${args.join(" ")} failed (${result.status ?? "unknown"}): ${result.stderr.trim()}`, ); } return result.stdout; } function runGitGrep(root, paths) { const result = spawnSync( "git", [ "grep", "-l", "-I", "-i", "-E", "-e", ISO_DATE_PREFILTER, "-e", GIT_MONTH_DATE_PREFILTER, "--", ...paths, ], { cwd: root, encoding: "utf8", maxBuffer: 32 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }, ); if (result.error) { throw new Error(`Failed to run git grep: ${result.error.message}`); } if (result.status !== 0 && result.status !== 1) { throw new Error(`git grep failed (${result.status ?? "unknown"}): ${result.stderr.trim()}`); } return result.stdout; } function runDatePrefilter(root, paths) { const result = spawnSync( "rg", [ "-l", "-i", "--hidden", "--no-ignore", "--no-messages", "-e", ISO_DATE_PREFILTER, "-e", MONTH_DATE_PREFILTER, "--glob", "!.git/**", "--glob", "!.i18n/**", "--glob", "!node_modules/**", "--glob", "!dist/**", "--glob", "!dist-runtime/**", ...[...EXCLUDED_SEGMENTS].flatMap((segment) => ["--glob", `!**/${segment}/**`]), ...paths, ], { cwd: root, encoding: "utf8", maxBuffer: 32 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }, ); if (result.error?.code === "ENOENT") { return runGitGrep(root, paths); } if (result.error) { throw new Error(`Failed to run rg: ${result.error.message}`); } if (result.status !== 0 && result.status !== 1) { throw new Error(`rg failed (${result.status ?? "unknown"}): ${result.stderr.trim()}`); } return result.stdout; } function isScannablePath(file) { const normalized = file.replaceAll("\\", "/"); const parts = normalized.split("/"); const name = basename(normalized); return ( TEXT_EXTENSIONS.has(extname(name).toLowerCase()) && !EXCLUDED_BASENAMES.has(name) && !parts.some((part) => EXCLUDED_SEGMENTS.has(part)) && !/(?:^|[.-])generated(?:[.-]|$)/iu.test(name) && !/(?:^|[.-])api-baseline(?:[.-]|$)/iu.test(name) ); } function compactText(lines) { return [...new Set(lines.map((line) => line.trim()).filter(Boolean))] .join(" | ") .replaceAll(/\s+/gu, " ") .slice(0, 500); } function loadFiles(root, files) { return files .filter(Boolean) .filter(isScannablePath) .toSorted() .flatMap((file) => { const absolutePath = resolve(root, file); if (statSync(absolutePath).size > MAX_FILE_BYTES) { return []; } return [ { file: file.replaceAll("\\", "/"), lines: readFileSync(absolutePath, "utf8").split(/\r?\n/u), }, ]; }); } function loadPrefilteredFiles(root) { const tracked = new Set( run("git", ["ls-files", "--cached", "-z"], root) .split("\0") .filter((file) => file && isScannablePath(file)), ); const files = runDatePrefilter(root, ["."]) .split("\n") .map((file) => file.replace(/^\.\//u, "")) .filter((file) => tracked.has(file)); return loadFiles(root, files); } function loadCompatFiles(root) { const output = run("git", ["ls-files", "--cached", "-z", "--", "src/plugins/compat"], root); return loadFiles(root, output.split("\0")); } function collectScanCandidates(files) { const candidates = []; for (const { file, lines } of files) { for (let index = 0; index < lines.length; index += 1) { if (!TODO_PATTERN.test(lines[index] ?? "")) { continue; } const nearbyDates = []; for ( let nearby = Math.max(0, index - 1); nearby <= Math.min(lines.length - 1, index + 1); nearby += 1 ) { if (DATE_PATTERN.test(lines[nearby] ?? "")) { nearbyDates.push(lines[nearby] ?? ""); } } if (nearbyDates.length === 0) { continue; } candidates.push({ file, line: index + 1, text: compactText([lines[index] ?? "", ...nearbyDates]), source: "scan", }); } } return candidates; } function readCompatReport(options) { if (options.compatReport) { return JSON.parse(readFileSync(options.compatReport, "utf8")); } const reportScript = resolve(options.root, "scripts/plugin-boundary-report.ts"); const output = run( process.execPath, ["--import", "tsx", reportScript, "--json"], options.root, 16 * 1024 * 1024, ); return JSON.parse(output); } function findCompatLocation(code, files) { const escapedCode = code.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&"); const codeField = new RegExp(`\\bcode\\s*:\\s*["']${escapedCode}["']`, "u"); for (const { file, lines } of files) { const index = lines.findIndex((line) => codeField.test(line)); if (index >= 0) { return { file, line: index + 1 }; } } return { file: "src/plugins/compat/registry.ts", line: 1 }; } function collectCompatCandidates(report, files) { const records = report?.compat?.records; if (!Array.isArray(records)) { throw new Error("Plugin boundary report is missing compat.records"); } const locationFiles = [...files].toSorted((left, right) => { const leftCompat = left.file.startsWith("src/plugins/compat/") ? 0 : 1; const rightCompat = right.file.startsWith("src/plugins/compat/") ? 0 : 1; return leftCompat - rightCompat || left.file.localeCompare(right.file); }); return records .filter( (record) => record?.status === "deprecated" && typeof record.code === "string" && typeof record.removeAfter === "string", ) .map((record) => { const location = findCompatLocation(record.code, locationFiles); return { file: location.file, line: location.line, text: compactText([ `${record.code}: removeAfter ${record.removeAfter}`, typeof record.replacement === "string" ? `replacement ${record.replacement}` : "", ]), source: "compat-registry", }; }); } function sortAndDeduplicate(candidates) { const unique = new Map(); for (const candidate of candidates) { unique.set( `${candidate.source}\0${candidate.file}\0${candidate.line}\0${candidate.text}`, candidate, ); } return [...unique.values()].toSorted( (left, right) => left.file.localeCompare(right.file) || left.line - right.line || left.source.localeCompare(right.source) || left.text.localeCompare(right.text), ); } function main() { const options = parseArgs(process.argv.slice(2)); const scanCandidates = collectScanCandidates(loadPrefilteredFiles(options.root)); const compatCandidates = collectCompatCandidates( readCompatReport(options), loadCompatFiles(options.root), ); const candidates = sortAndDeduplicate([...scanCandidates, ...compatCandidates]); if (candidates.length > MAX_CANDIDATES) { throw new Error( `Dated TODO prefilter produced ${candidates.length} candidates, above the ${MAX_CANDIDATES} safety cap`, ); } mkdirSync(dirname(options.output), { recursive: true }); writeFileSync(options.output, `${JSON.stringify(candidates, null, 2)}\n`); process.stdout.write( `dated-todo-scan: wrote ${candidates.length} candidates (${scanCandidates.length} scan, ${compatCandidates.length} compat-registry) to ${options.output}\n`, ); } try { main(); } catch (error) { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`dated-todo-scan: ${message}\n`); process.exitCode = 1; }