#!/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;
}