#!/usr/bin/env -S node --import tsx /** * scan-console.ts * * Inventory of all `console.*` calls in server-code paths. * * Generates docs/observability/console-migration-inventory.md as a Markdown * table with file:line + snippet for every match outside of test/spec files * and outside of explicit `// eslint-disable-next-line no-console` lines. * * Usage: * pnpm exec tsx scripts/observability/scan-console.ts * pnpm exec tsx scripts/observability/scan-console.ts --check # exits 1 if hits > 0 * * Server paths scanned: * - apps/api/src/** * - apps/worker/src/** * - apps/web/app/**\/*.server.ts(x) * - apps/web/app/**\/*.server.ts (modules) * - apps/web/app/modules/**\/services/** * - apps/web/app/utils/** * - packages/*\/src/** * * Skipped: * - **\/*.test.ts(x), **\/*.spec.ts(x) * - lines preceded by `// eslint-disable-next-line no-console` * - dist/, build/, node_modules/ */ import { execSync } from "node:child_process"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const REPO_ROOT = resolve(__dirname, "..", ".."); const OUTPUT = resolve(REPO_ROOT, "docs/observability/console-migration-inventory.md"); const SERVER_GLOBS: string[] = ["apps/api/src", "apps/worker/src", "apps/web/app", "packages"]; const CONSOLE_RE = /console\.(log|error|warn|info|debug|dir|trace)\s*\(/; const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$/; const DISTLIKE_RE = /(^|\/)(node_modules|dist|build|coverage|\.react-router|generated)(\/|$)/; const SERVER_FILE_RE = /(\.server\.tsx?|\/\.server\/|apps\/api\/src\/|apps\/worker\/src\/|packages\/[^/]+\/src\/|apps\/web\/app\/utils\/|apps\/web\/app\/modules\/[^/]+\/services\/|apps\/web\/app\/modules\/[^/]+\/db\/|apps\/web\/app\/routes\/.+\.server\.tsx?)/; interface Hit { file: string; line: number; level: string; snippet: string; } function gitTrackedFiles(): string[] { const out = execSync("git ls-files", { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); return out.split("\n").filter(Boolean); } function isServerPath(file: string): boolean { if (DISTLIKE_RE.test(file)) return false; if (TEST_FILE_RE.test(file)) return false; if (!SERVER_GLOBS.some((g) => file.startsWith(g + "/"))) return false; // Within apps/web/app: only `.server.ts(x)` files and explicit server folders if (file.startsWith("apps/web/app/")) { return SERVER_FILE_RE.test(file); } // apps/api/src, apps/worker/src, packages/*/src are entirely server code return /\.(ts|tsx|mjs|cjs|js)$/.test(file); } function scanFile(absPath: string, relPath: string): Hit[] { let content: string; try { content = readFileSync(absPath, "utf8"); } catch { return []; } const hits: Hit[] = []; const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const m = line.match(CONSOLE_RE); if (!m) continue; // Skip if line is inside a block comment-only context — too expensive to AST. // Skip if line is a single-line comment (//) preceding the console call. const trimmed = line.trimStart(); if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue; // Skip if previous line is eslint-disable-next-line no-console const prev = i > 0 ? lines[i - 1].trim() : ""; if (/eslint-disable-next-line[^\n]*no-console/.test(prev)) continue; // Skip same-line disable if (/eslint-disable-line[^\n]*no-console/.test(line)) continue; hits.push({ file: relPath, line: i + 1, level: m[1], snippet: line.trim().slice(0, 160), }); } return hits; } function main(): void { const args = process.argv.slice(2); const checkMode = args.includes("--check"); const files = gitTrackedFiles().filter(isServerPath); const allHits: Hit[] = []; for (const rel of files) { const abs = resolve(REPO_ROOT, rel); allHits.push(...scanFile(abs, rel)); } // Group by file for output const byFile = new Map(); for (const h of allHits) { const arr = byFile.get(h.file) ?? []; arr.push(h); byFile.set(h.file, arr); } const sortedFiles = [...byFile.keys()].sort(); const now = new Date().toISOString(); const lines: string[] = []; lines.push("# Console-Call Migration Inventory"); lines.push(""); lines.push(`> Generated by \`scripts/observability/scan-console.ts\` on ${now}.`); lines.push("> Re-run with: \`pnpm exec tsx scripts/observability/scan-console.ts\`"); lines.push(""); lines.push(`**Total \`console.*\` hits in server-code paths: ${allHits.length}**`); lines.push(`**Unique files: ${sortedFiles.length}**`); lines.push(""); lines.push("Server-paths scanned:"); lines.push(""); lines.push("- `apps/api/src/**`"); lines.push("- `apps/worker/src/**`"); lines.push("- `apps/web/app/**/*.server.ts(x)` and `apps/web/app/modules/*/services/**` and `apps/web/app/utils/**`"); lines.push("- `packages/*/src/**`"); lines.push(""); lines.push("Excluded:"); lines.push(""); lines.push("- `**/*.test.ts(x)`, `**/*.spec.ts(x)`"); lines.push("- Lines after `// eslint-disable-next-line no-console`"); lines.push("- `dist/`, `build/`, `coverage/`, `node_modules/`, `.react-router/`, `generated/`"); lines.push(""); if (allHits.length === 0) { lines.push("## All clear"); lines.push(""); lines.push("No `console.*` calls remain in server-code paths. The ESLint rule `no-console: error` enforces this going forward."); } else { lines.push("## Hits by file"); lines.push(""); lines.push("| File | Line | Level | Snippet |"); lines.push("|------|-----:|-------|---------|"); for (const file of sortedFiles) { for (const h of byFile.get(file)!) { const snippetEscaped = h.snippet.replace(/\|/g, "\\|").replace(/`/g, "\\`"); lines.push(`| \`${h.file}\` | ${h.line} | ${h.level} | \`${snippetEscaped}\` |`); } } lines.push(""); lines.push("## Summary by file"); lines.push(""); lines.push("| File | Hits |"); lines.push("|------|-----:|"); for (const file of sortedFiles) { lines.push(`| \`${file}\` | ${byFile.get(file)!.length} |`); } } mkdirSync(dirname(OUTPUT), { recursive: true }); writeFileSync(OUTPUT, lines.join("\n") + "\n", "utf8"); // eslint-disable-next-line no-console console.log(`scan-console: ${allHits.length} hit(s) across ${sortedFiles.length} file(s). Wrote ${relative(REPO_ROOT, OUTPUT)}`); if (checkMode && allHits.length > 0) { process.exit(1); } } main();