main.mjs

  1import fs from 'node:fs';
  2import path from 'node:path';
  3
  4import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
  5import { detectHtml } from '../engines/static-html/detect-html.mjs';
  6import { detectText } from '../engines/regex/detect-text.mjs';
  7import {
  8  HTML_EXTENSIONS,
  9  buildImportGraph,
 10  detectFrameworkConfig,
 11  isPortListening,
 12  walkDir,
 13} from '../node/file-system.mjs';
 14
 15// ---------------------------------------------------------------------------
 16// Output formatting
 17// ---------------------------------------------------------------------------
 18
 19function formatFindings(findings, jsonMode) {
 20  if (jsonMode) return JSON.stringify(findings, null, 2);
 21
 22  const grouped = {};
 23  for (const f of findings) {
 24    if (!grouped[f.file]) grouped[f.file] = [];
 25    grouped[f.file].push(f);
 26  }
 27  const out = [];
 28  for (const [file, items] of Object.entries(grouped)) {
 29    const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '';
 30    out.push(`\n${file}${importNote}`);
 31    for (const item of items) {
 32      out.push(`  ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`);
 33      out.push(`${item.description}`);
 34    }
 35  }
 36  out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
 37  return out.join('\n');
 38}
 39
 40// ---------------------------------------------------------------------------
 41// Stdin handling
 42// ---------------------------------------------------------------------------
 43
 44async function handleStdin() {
 45  const chunks = [];
 46  for await (const chunk of process.stdin) chunks.push(chunk);
 47  const input = Buffer.concat(chunks).toString('utf-8');
 48  try {
 49    const parsed = JSON.parse(input);
 50    const fp = parsed?.tool_input?.file_path;
 51    if (fp && fs.existsSync(fp)) {
 52      return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
 53        ? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp);
 54    }
 55  } catch { /* not JSON */ }
 56  return detectText(input, '<stdin>');
 57}
 58
 59
 60// ---------------------------------------------------------------------------
 61// CLI
 62// ---------------------------------------------------------------------------
 63
 64async function confirm(question) {
 65  const rl = (await import('node:readline')).default.createInterface({
 66    input: process.stdin, output: process.stderr,
 67  });
 68  return new Promise((resolve) => {
 69    rl.question(`${question} [Y/n] `, (answer) => {
 70      rl.close();
 71      resolve(!answer || /^y(es)?$/i.test(answer.trim()));
 72    });
 73  });
 74}
 75
 76function printUsage() {
 77  console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...]
 78
 79Scan files or URLs for UI anti-patterns and design quality issues.
 80
 81Options:
 82  --fast    Regex-only mode (skip static HTML/CSS analysis, faster but misses linked stylesheets)
 83  --json    Output results as JSON
 84  --help    Show this help message
 85
 86Detection modes:
 87  HTML files     Static HTML/CSS analysis (default, catches linked CSS)
 88  Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
 89  URLs           Puppeteer full browser rendering (auto-detected)
 90  --fast         Forces regex for all files
 91
 92Examples:
 93  impeccable detect src/
 94  impeccable detect index.html
 95  impeccable detect https://example.com
 96  impeccable detect --fast --json .`);
 97}
 98
 99async function detectCli() {
100  let args = process.argv.slice(2).map(arg => {
101    if (arg === '-json') return '--json';
102    if (arg === '-fast') return '--fast';
103    return arg;
104  });
105  if (args[0] === 'detect') args = args.slice(1);
106  const jsonMode = args.includes('--json');
107  const helpMode = args.includes('--help');
108  const fastMode = args.includes('--fast');
109  const targets = args.filter(a => !a.startsWith('--'));
110
111  if (helpMode) { printUsage(); process.exit(0); }
112
113  let allFindings = [];
114
115  if (!process.stdin.isTTY && targets.length === 0) {
116    allFindings = await handleStdin();
117  } else {
118    const paths = targets.length > 0 ? targets : [process.cwd()];
119    const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length;
120    const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
121
122    try {
123      for (const target of paths) {
124        if (/^https?:\/\//i.test(target)) {
125          try {
126            const scanner = browserDetector
127              ? (url) => browserDetector.detectUrl(url)
128              : (url) => detectUrl(url);
129            allFindings.push(...await scanner(target));
130          } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
131          continue;
132        }
133
134        const resolved = path.resolve(target);
135        let stat;
136        try { stat = fs.statSync(resolved); }
137        catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
138
139        if (stat.isDirectory()) {
140          // Check for framework dev server config (skip in JSON mode to avoid polluting output)
141          if (!jsonMode) {
142            const fwConfig = detectFrameworkConfig(resolved);
143            if (fwConfig) {
144              const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
145              if (probe.listening && probe.matched) {
146                process.stderr.write(
147                  `\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` +
148                  `For more accurate results, scan the running site:\n` +
149                  `  npx impeccable detect http://localhost:${fwConfig.port}\n\n`
150                );
151              } else if (probe.listening && !probe.matched) {
152                process.stderr.write(
153                  `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
154                  `Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n`
155                );
156              } else {
157                process.stderr.write(
158                  `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
159                  `Start the dev server and scan via URL for best results:\n` +
160                  `  npx impeccable detect http://localhost:${fwConfig.port}\n\n`
161                );
162              }
163            }
164          }
165
166          const files = walkDir(resolved);
167          const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
168
169          // Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
170          if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
171            process.stderr.write(
172              `\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
173              `Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
174              `Use --fast to skip static HTML/CSS analysis, or target a specific subdirectory.\n`
175            );
176            const ok = await confirm('Continue?');
177            if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); }
178          }
179
180          // Build import graph for multi-file awareness
181          const graph = buildImportGraph(files);
182          // Build reverse map: file -> set of files that import it
183          const importedByMap = new Map();
184          for (const [importer, imports] of graph) {
185            for (const imported of imports) {
186              if (!importedByMap.has(imported)) importedByMap.set(imported, new Set());
187              importedByMap.get(imported).add(importer);
188            }
189          }
190
191          for (const file of files) {
192            const ext = path.extname(file).toLowerCase();
193            let fileFindings;
194            if (!fastMode && HTML_EXTENSIONS.has(ext)) {
195              fileFindings = await detectHtml(file);
196            } else {
197              fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
198            }
199            // Annotate findings with import context
200            const importers = importedByMap.get(file);
201            if (importers && importers.size > 0) {
202              const importerNames = [...importers].map(f => path.basename(f));
203              for (const f of fileFindings) {
204                f.importedBy = importerNames;
205              }
206            }
207            allFindings.push(...fileFindings);
208          }
209        } else if (stat.isFile()) {
210          const ext = path.extname(resolved).toLowerCase();
211          if (!fastMode && HTML_EXTENSIONS.has(ext)) {
212            allFindings.push(...await detectHtml(resolved));
213          } else {
214            allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved));
215          }
216        }
217      }
218    } finally {
219      if (browserDetector) await browserDetector.close();
220    }
221  }
222
223  if (allFindings.length > 0) {
224    if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
225    else process.stderr.write(formatFindings(allFindings, false) + '\n');
226    process.exit(2);
227  }
228  if (jsonMode) process.stdout.write('[]\n');
229  process.exit(0);
230}
231
232export { formatFindings, handleStdin, confirm, printUsage, detectCli };