#!/usr/bin/env node /** * Scans repo for TODO/FIXME markers and emits: * - machine-readable JSON * - human-readable markdown summary * * Output: * - docs/TODO-CLASSIFICATION.md (overwritten) * - docs/todo-scan.json * * Note: This script itself may contain "TODO" or "FIXME" in comments or strings. * These are intentional and should be excluded from scan results. * * @author Matthew Raymer * @version 1.0.0 */ const fs = require("fs"); const path = require("path"); const ROOT = process.cwd(); const TARGET_DIRS = [ "src", "ios/Plugin", "ios/Tests", "android/src/main", "android/src/test", "scripts", "docs", ]; const EXCLUDE_DIR_NAMES = new Set([ ".git", "node_modules", "dist", "build", ".venv", "venv", "__pycache__", ]); const FILE_EXTS = new Set([ ".ts", ".tsx", ".js", ".jsx", ".swift", ".java", ".kt", ".md", ".json", ".yml", ".yaml", ".py", ]); const MARKERS = ["TODO", "FIXME"]; function walk(dir, out = []) { if (!fs.existsSync(dir)) return out; const st = fs.statSync(dir); if (!st.isDirectory()) return out; for (const name of fs.readdirSync(dir)) { if (EXCLUDE_DIR_NAMES.has(name)) continue; const p = path.join(dir, name); const s = fs.statSync(p); if (s.isDirectory()) walk(p, out); else out.push(p); } return out; } function scanFile(fp) { const ext = path.extname(fp); if (!FILE_EXTS.has(ext)) return []; const text = fs.readFileSync(fp, "utf8"); const lines = text.split(/\r?\n/); const hits = []; const relPath = path.relative(ROOT, fp).replace(/\\/g, "/"); const isThisScript = relPath === "scripts/todo-scan.js"; for (let i = 0; i < lines.length; i++) { const line = lines[i]; for (const m of MARKERS) { // require marker as a token-ish substring if (line.includes(m + ":") || line.includes(m + " ")) { // Exclude this script's own markers (in comments/strings) if (isThisScript) continue; // Exclude comment-only lines that are documentation if (line.trim().startsWith("*") && line.includes("intentionally")) continue; hits.push({ line: i + 1, marker: m, text: line.trim() }); break; } } } return hits; } function bucketForPath(rel) { if (rel.startsWith("ios/")) return "iOS"; if (rel.startsWith("android/")) return "Android"; if (rel.startsWith("src/")) return "TypeScript"; if (rel.startsWith("docs/")) return "Docs"; if (rel.startsWith("scripts/")) return "Scripts"; return "Other"; } function isCoreCode(rel) { // Core code directories (production code) return ( rel.startsWith("ios/Plugin/") || rel.startsWith("android/src/main/") || rel.startsWith("src/") || rel.startsWith("packages/") || rel.startsWith("lib/") || (rel.startsWith("scripts/") && !rel.includes("test")) ); } function isDocsOrTestApp(rel) { // Documentation and test harness directories return ( rel.startsWith("docs/") || rel.startsWith("test-apps/") || rel.startsWith("ios/Tests/") || rel.startsWith("android/src/test/") || rel.startsWith("tests/") ); } function main() { const results = []; for (const d of TARGET_DIRS) { const dirAbs = path.join(ROOT, d); const files = walk(dirAbs); for (const fpAbs of files) { const rel = path.relative(ROOT, fpAbs).replace(/\\/g, "/"); const hits = scanFile(fpAbs); for (const h of hits) results.push({ file: rel, ...h, bucket: bucketForPath(rel) }); } } // sort stable: bucket, file, line results.sort((a, b) => a.bucket.localeCompare(b.bucket) || a.file.localeCompare(b.file) || a.line - b.line ); // Split core vs docs/test-apps const coreResults = results.filter(r => isCoreCode(r.file)); const docsTestResults = results.filter(r => isDocsOrTestApp(r.file)); const otherResults = results.filter(r => !isCoreCode(r.file) && !isDocsOrTestApp(r.file)); // Enhanced JSON output with split counts const jsonOutput = { summary: { total: results.length, coreCount: coreResults.length, docsTestCount: docsTestResults.length, otherCount: otherResults.length, generatedAt: new Date().toISOString() }, core: coreResults, docsTest: docsTestResults, other: otherResults, all: results }; fs.writeFileSync(path.join(ROOT, "docs/todo-scan.json"), JSON.stringify(jsonOutput, null, 2), "utf8"); // markdown const byBucket = new Map(); for (const r of results) { if (!byBucket.has(r.bucket)) byBucket.set(r.bucket, []); byBucket.get(r.bucket).push(r); } let md = ""; md += `# TODO Classification (auto-generated)\n\n`; md += `Generated by \`scripts/todo-scan.js\`\n\n`; md += `## Summary\n\n`; md += `- **Total markers:** ${results.length}\n`; md += `- **Core code (production):** ${coreResults.length} āš ļø\n`; md += `- **Docs/test-apps:** ${docsTestResults.length} āœ… (expected)\n`; md += `- **Other:** ${otherResults.length}\n\n`; md += `> **Note:** Core code TODOs should be near zero. Docs/test-app TODOs are expected and acceptable.\n\n`; md += `---\n\n`; for (const [bucket, items] of byBucket.entries()) { md += `## ${bucket} (${items.length})\n\n`; // group by file const byFile = new Map(); for (const it of items) { if (!byFile.has(it.file)) byFile.set(it.file, []); byFile.get(it.file).push(it); } for (const [file, hits] of byFile.entries()) { md += `### ${file}\n\n`; for (const h of hits) { md += `- L${h.line}: **${h.marker}** — ${h.text}\n`; } md += `\n`; } } fs.writeFileSync(path.join(ROOT, "docs/TODO-CLASSIFICATION.md"), md, "utf8"); // Console output with split summary console.log(`\nšŸ“Š TODO Scan Complete`); console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); console.log(`Total markers: ${results.length}`); console.log(`Core code: ${coreResults.length} ${coreResults.length === 0 ? 'āœ…' : 'āš ļø (should be 0)'}`); console.log(`Docs/test-apps: ${docsTestResults.length} āœ… (expected)`); console.log(`Other: ${otherResults.length}`); console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`); } main();