182 lines
6.6 KiB
JavaScript
182 lines
6.6 KiB
JavaScript
#!/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<string, Hit[]>();
|
|
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();
|