agmission/Development/server/scripts/scan_undefined_vars.js

149 lines
5.9 KiB
JavaScript

'use strict';
/**
* Scans JS files for ALL_CAPS identifiers that are used but never declared
* in the same file (potential ReferenceError bugs, e.g. bare PARTNER_QUEUE
* instead of env.PARTNER_QUEUE).
*
* Detection strategy:
* - Strip block comments, line comments, and string literals before scanning
* - Collect "declared" ALL_CAPS names from:
* const/let/var FOO = ...
* const { FOO, BAR } = require(...) (destructure on const/let/var line)
* function FOO(
* exports.FOO = ...
* - Flag any ALL_CAPS usage not preceded by '.' (property access) that is
* not in the declared set or the known-globals whitelist
*
* NOTE: This is a best-effort regex scan, not a full AST analysis.
* Multi-line destructuring is not handled.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const SCAN_DIRS = ['workers', 'controllers', 'helpers', 'services', 'routes'];
// Node/JS builtins and well-known globals that are never "declared" in a file
const KNOWN_GLOBALS = new Set([
'NaN', 'Infinity', 'JSON', 'Math', 'Number', 'String', 'Buffer', 'Date',
'Error', 'Array', 'Object', 'Promise', 'Set', 'Map', 'RegExp', 'Symbol',
'Boolean', 'URL', 'URLSearchParams', 'process', 'module', 'exports',
'require', 'console', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval',
'ReferenceError', 'TypeError', 'SyntaxError', 'RangeError', 'URIError', 'EvalError',
'GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH', 'OPTIONS',
'NODE_ENV', 'PRODUCTION', 'DEBUG',
// Mongoose / ODM types
'Schema', 'ObjectId', 'Mixed', 'Decimal128',
// Common pattern abbreviations that appear in strings or conditions
'NULL', 'TRUE', 'FALSE',
]);
/**
* Strip block comments, line comments, and string/template literals from source
* so that ALL_CAPS words inside them don't produce false positives.
*/
function stripNonCode(src) {
// 1. Block comments /* ... */ — preserve newlines so line numbers stay accurate
src = src.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' '));
// 2+3. Strip single/double-quoted strings BEFORE comments and template literals.
// Removes any backticks (and `//`) that live inside string literals so they
// don't confuse the steps below.
// Use [^"\\\n] / [^'\\\n] to prevent matching across newlines — JS strings
// cannot actually span source lines, so this keeps line numbers accurate.
src = src.replace(/"(?:[^"\\\n]|\\.)*"/g, '""');
src = src.replace(/'(?:[^'\\\n]|\\.)*'/g, "''");
// 4. Line comments — use negative lookbehind to avoid treating `://` (URLs
// inside template literals like `${protocol}://host/path`) as comments.
src = src.replace(/(?<!:)\/\/[^\n]*/g, m => ' '.repeat(m.length));
// 5. Template literals — strings are already blank, so backtick pairing is clean.
src = src.replace(/`[^`]*`/gs, m => m.replace(/[^\n]/g, ' '));
return src;
}
function scanFile(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
const src = stripNonCode(raw);
const lines = src.split('\n');
const declared = new Set();
const uses = []; // { name, line }
// ── Declaration pass (whole-file regex, handles multi-line const blocks) ──
// 1. const/let/var FOO = ... (keyword on same line)
for (const m of src.matchAll(/\b(?:const|let|var)\s+([A-Z][A-Z0-9_]{2,})\s*=/g))
declared.add(m[1]);
// 2. FOO = require( — catches continuation lines in multi-line const blocks
// e.g. const\n FILE = require(...),\n CVCST = require(...),
for (const m of src.matchAll(/\b([A-Z][A-Z0-9_]{2,})\s*=\s*require\s*\(/g))
declared.add(m[1]);
// 3. { FOO, BAR } = require( — destructuring anywhere (incl. multi-line const)
for (const m of src.matchAll(/\{([^}]+)\}\s*=\s*require\s*\(/g))
for (const id of m[1].matchAll(/\b([A-Z][A-Z0-9_]{2,})\b/g))
declared.add(id[1]);
// 4. const/let/var { FOO } = non-require (e.g. model destructuring)
for (const m of src.matchAll(/\b(?:const|let|var)\s*\{([^}]+)\}\s*=/g))
for (const id of m[1].matchAll(/\b([A-Z][A-Z0-9_]{2,})\b/g))
declared.add(id[1]);
// 5. function FOO_BAR(
for (const m of src.matchAll(/\bfunction\s+([A-Z][A-Z0-9_]{2,})\s*\(/g))
declared.add(m[1]);
// 6. exports.FOO = ...
for (const m of src.matchAll(/\bexports\.([A-Z][A-Z0-9_]{2,})\s*=/g))
declared.add(m[1]);
// ── Usage pass (line by line for accurate line numbers) ─────────────────
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip JSDoc / block-comment lines that survived stripping
if (line.trim().startsWith('*')) continue;
// Match ALL_CAPS (4+ chars) NOT preceded by '.' (property access)
// and NOT followed by a bare ':' (object literal key)
for (const m of line.matchAll(/(?<![.\w])([A-Z][A-Z0-9_]{3,})\b(?!\s*:(?![:=]))/g)) {
const name = m[1];
if (!KNOWN_GLOBALS.has(name)) {
uses.push({ name, line: i + 1 });
}
}
}
// Report used-but-never-declared
const undeclared = uses.filter(u => !declared.has(u.name));
if (undeclared.length === 0) return;
// Dedupe by name, keep first occurrence line number
const seen = new Map();
for (const u of undeclared) {
if (!seen.has(u.name)) seen.set(u.name, u.line);
}
console.log(`\n${path.relative(ROOT, filePath)}`);
for (const [name, line] of seen) {
console.log(` L${String(line).padStart(4)}: ${name}`);
}
}
// Gather files from each directory
let files = [];
for (const dir of SCAN_DIRS) {
const dirPath = path.join(ROOT, dir);
if (!fs.existsSync(dirPath)) continue;
const entries = fs.readdirSync(dirPath).filter(f => f.endsWith('.js'));
files = files.concat(entries.map(f => path.join(dirPath, f)));
}
console.log(`Scanning ${files.length} files...`);
for (const f of files) {
try { scanFile(f); } catch (e) { console.error(`Error scanning ${f}: ${e.message}`); }
}
console.log('\nDone.');