← back to Site Factory

critic/checks/code.js

133 lines

// code.js — heuristic JS/TS lint
// Rules:
//   * file > 500 lines           → low    "long file"
//   * console.log( count > 5     → low    "noisy logging"
//   * async fn with no try block → medium "unhandled async"
//   * @ts-ignore / `any` count >3 in a TS file → medium "type escape hatches"
const fs = require('fs');
const path = require('path');
const { glob } = require('glob');

const SOURCE = 'code';
const SCAN_EXTS = new Set(['.js', '.ts', '.tsx', '.jsx']);

async function expandPaths(paths) {
  const files = new Set();
  for (const p of paths || []) {
    if (!p) continue;
    let stat;
    try { stat = fs.statSync(p); } catch { continue; }
    if (stat.isFile()) {
      if (SCAN_EXTS.has(path.extname(p))) files.add(p);
    } else if (stat.isDirectory()) {
      const matches = await glob('**/*.{js,ts,tsx,jsx}', {
        cwd: p,
        ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**', '**/build/**'],
        absolute: true,
        nodir: true,
      });
      for (const m of matches) files.add(m);
    }
  }
  return [...files];
}

function countMatches(src, re) {
  const m = src.match(re);
  return m ? m.length : 0;
}

// Heuristic: an async function declaration / arrow that does not contain `try {`
// in its (rough) body. Body = from the opening `{` after the declaration to the
// matching `}`. We do balance counting; not perfect but good enough for a fast
// static check.
function findUnhandledAsync(src) {
  const findings = [];
  const re = /\basync\s+(?:function\s+\w*|\w+\s*=|\([^)]*\)\s*=>|function)/g;
  let m;
  while ((m = re.exec(src)) != null) {
    // find the next `{` after match
    let i = src.indexOf('{', m.index + m[0].length);
    if (i < 0) continue;
    let depth = 0;
    let end = -1;
    for (let j = i; j < src.length; j++) {
      const c = src[j];
      if (c === '{') depth++;
      else if (c === '}') { depth--; if (depth === 0) { end = j; break; } }
    }
    if (end < 0) continue;
    const body = src.slice(i, end);
    if (!/\btry\s*\{/.test(body)) {
      // get line number of the async keyword
      const line = src.slice(0, m.index).split('\n').length;
      findings.push({ line });
    }
  }
  return findings;
}

async function run({ paths /* , screenshots, domain */ }) {
  const findings = [];
  const files = await expandPaths(paths);

  for (const file of files) {
    let src;
    try { src = fs.readFileSync(file, 'utf8'); } catch { continue; }
    const ext = path.extname(file);
    const isTs = ext === '.ts' || ext === '.tsx';
    const lines = src.split('\n').length;

    if (lines > 500) {
      findings.push({
        source: SOURCE,
        severity: 'low',
        title: `Long file: ${path.basename(file)} (${lines} lines)`,
        detail: `${file} is ${lines} lines. Long files are harder to review and refactor.`,
        suggested_fix: 'Split into smaller modules grouped by responsibility (one component / one concern per file).',
      });
    }

    const consoleLogs = countMatches(src, /\bconsole\.log\s*\(/g);
    if (consoleLogs > 5) {
      findings.push({
        source: SOURCE,
        severity: 'low',
        title: `Noisy logging: ${consoleLogs} console.log() in ${path.basename(file)}`,
        detail: `${file} contains ${consoleLogs} console.log calls.`,
        suggested_fix: 'Replace with a debug() helper or pino/winston logger; strip debug noise from production paths.',
      });
    }

    const unhandled = findUnhandledAsync(src);
    if (unhandled.length) {
      findings.push({
        source: SOURCE,
        severity: 'medium',
        title: `Unhandled async: ${unhandled.length} async fn(s) without try/catch in ${path.basename(file)}`,
        detail: `${file} — async functions at line(s) ${unhandled.slice(0, 5).map(u => u.line).join(', ')}${unhandled.length > 5 ? '…' : ''} have no try block.`,
        suggested_fix: 'Wrap async bodies with try/catch (or .catch() at the call site) so rejections do not become unhandledPromiseRejections.',
      });
    }

    if (isTs) {
      const tsIgnores = countMatches(src, /\/\/\s*@ts-ignore/g) + countMatches(src, /\/\/\s*@ts-expect-error/g);
      const anyCount = countMatches(src, /:\s*any\b/g) + countMatches(src, /\bas\s+any\b/g);
      const total = tsIgnores + anyCount;
      if (total > 3) {
        findings.push({
          source: SOURCE,
          severity: 'medium',
          title: `Type escape hatches: ${total} in ${path.basename(file)}`,
          detail: `${file} has ${tsIgnores} ts-ignore/expect-error comments and ${anyCount} \`any\` uses.`,
          suggested_fix: 'Tighten types: replace `any` with `unknown` + narrow, or define proper interfaces. Remove ts-ignore lines once types resolve.',
        });
      }
    }
  }

  return findings;
}

module.exports = { run };