| 1 | import fs from 'node:fs'; |
| 2 | import path from 'node:path'; |
| 3 | |
| 4 | import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs'; |
| 5 | import { detectHtml } from '../engines/static-html/detect-html.mjs'; |
| 6 | import { detectText } from '../engines/regex/detect-text.mjs'; |
| 7 | import { |
| 8 | HTML_EXTENSIONS, |
| 9 | buildImportGraph, |
| 10 | detectFrameworkConfig, |
| 11 | isPortListening, |
| 12 | walkDir, |
| 13 | } from '../node/file-system.mjs'; |
| 14 | |
| 15 | // --------------------------------------------------------------------------- |
| 16 | // Output formatting |
| 17 | // --------------------------------------------------------------------------- |
| 18 | |
| 19 | function 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 | |
| 44 | async function handleStdin(options = {}) { |
| 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, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options); |
| 54 | } |
| 55 | } catch { /* not JSON */ } |
| 56 | return detectText(input, '<stdin>', options); |
| 57 | } |
| 58 | |
| 59 | |
| 60 | // --------------------------------------------------------------------------- |
| 61 | // CLI |
| 62 | // --------------------------------------------------------------------------- |
| 63 | |
| 64 | async 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 | |
| 76 | function printUsage() { |
| 77 | console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...] |
| 78 | |
| 79 | Scan files or URLs for UI anti-patterns and design quality issues. |
| 80 | |
| 81 | Options: |
| 82 | --json Output results as JSON |
| 83 | --gpt Also report GPT-specific provider tells (off by default) |
| 84 | --gemini Also report Gemini-specific provider tells (off by default) |
| 85 | --help Show this help message |
| 86 | |
| 87 | Detection modes: |
| 88 | HTML files Static HTML/CSS analysis (default, catches linked CSS) |
| 89 | Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) |
| 90 | URLs Puppeteer full browser rendering (auto-detected) |
| 91 | |
| 92 | Examples: |
| 93 | impeccable detect src/ |
| 94 | impeccable detect index.html |
| 95 | impeccable detect https://example.com |
| 96 | impeccable detect --json .`); |
| 97 | } |
| 98 | |
| 99 | async 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 | // --fast (regex-only) is deprecated: since the jsdom removal, the static |
| 109 | // HTML/CSS analysis is fast and covers every rule, so the regex-only path |
| 110 | // only loses coverage for no real speed win. Accept the flag for back-compat |
| 111 | // but ignore it and run the full scan. |
| 112 | if (args.includes('--fast')) { |
| 113 | process.stderr.write( |
| 114 | 'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n', |
| 115 | ); |
| 116 | } |
| 117 | const providers = []; |
| 118 | if (args.includes('--gpt')) providers.push('gpt'); |
| 119 | if (args.includes('--gemini')) providers.push('gemini'); |
| 120 | const scanOptions = { providers }; |
| 121 | const targets = args.filter(a => !a.startsWith('--')); |
| 122 | |
| 123 | if (helpMode) { printUsage(); process.exit(0); } |
| 124 | |
| 125 | let allFindings = []; |
| 126 | |
| 127 | if (!process.stdin.isTTY && targets.length === 0) { |
| 128 | allFindings = await handleStdin(scanOptions); |
| 129 | } else { |
| 130 | const paths = targets.length > 0 ? targets : [process.cwd()]; |
| 131 | const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length; |
| 132 | const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null; |
| 133 | |
| 134 | try { |
| 135 | for (const target of paths) { |
| 136 | if (/^https?:\/\//i.test(target)) { |
| 137 | try { |
| 138 | const scanner = browserDetector |
| 139 | ? (url) => browserDetector.detectUrl(url, scanOptions) |
| 140 | : (url) => detectUrl(url, scanOptions); |
| 141 | allFindings.push(...await scanner(target)); |
| 142 | } catch (e) { process.stderr.write(`Error: ${e.message}\n`); } |
| 143 | continue; |
| 144 | } |
| 145 | |
| 146 | const resolved = path.resolve(target); |
| 147 | let stat; |
| 148 | try { stat = fs.statSync(resolved); } |
| 149 | catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; } |
| 150 | |
| 151 | if (stat.isDirectory()) { |
| 152 | // Check for framework dev server config (skip in JSON mode to avoid polluting output) |
| 153 | if (!jsonMode) { |
| 154 | const fwConfig = detectFrameworkConfig(resolved); |
| 155 | if (fwConfig) { |
| 156 | const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint); |
| 157 | if (probe.listening && probe.matched) { |
| 158 | process.stderr.write( |
| 159 | `\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` + |
| 160 | `For more accurate results, scan the running site:\n` + |
| 161 | ` npx impeccable detect http://localhost:${fwConfig.port}\n\n` |
| 162 | ); |
| 163 | } else if (probe.listening && !probe.matched) { |
| 164 | process.stderr.write( |
| 165 | `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` + |
| 166 | `Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n` |
| 167 | ); |
| 168 | } else { |
| 169 | process.stderr.write( |
| 170 | `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` + |
| 171 | `Start the dev server and scan via URL for best results:\n` + |
| 172 | ` npx impeccable detect http://localhost:${fwConfig.port}\n\n` |
| 173 | ); |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | const files = walkDir(resolved); |
| 179 | const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; |
| 180 | |
| 181 | // Warn and confirm if scanning many files (static HTML/CSS processes each HTML file) |
| 182 | if (files.length > 50 && process.stdin.isTTY && !jsonMode) { |
| 183 | process.stderr.write( |
| 184 | `\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` + |
| 185 | `Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` + |
| 186 | `Target a specific subdirectory to narrow scope.\n` |
| 187 | ); |
| 188 | const ok = await confirm('Continue?'); |
| 189 | if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); } |
| 190 | } |
| 191 | |
| 192 | // Build import graph for multi-file awareness |
| 193 | const graph = buildImportGraph(files); |
| 194 | // Build reverse map: file -> set of files that import it |
| 195 | const importedByMap = new Map(); |
| 196 | for (const [importer, imports] of graph) { |
| 197 | for (const imported of imports) { |
| 198 | if (!importedByMap.has(imported)) importedByMap.set(imported, new Set()); |
| 199 | importedByMap.get(imported).add(importer); |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | for (const file of files) { |
| 204 | const ext = path.extname(file).toLowerCase(); |
| 205 | let fileFindings; |
| 206 | if (HTML_EXTENSIONS.has(ext)) { |
| 207 | fileFindings = await detectHtml(file, scanOptions); |
| 208 | } else { |
| 209 | fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions); |
| 210 | } |
| 211 | // Annotate findings with import context |
| 212 | const importers = importedByMap.get(file); |
| 213 | if (importers && importers.size > 0) { |
| 214 | const importerNames = [...importers].map(f => path.basename(f)); |
| 215 | for (const f of fileFindings) { |
| 216 | f.importedBy = importerNames; |
| 217 | } |
| 218 | } |
| 219 | allFindings.push(...fileFindings); |
| 220 | } |
| 221 | } else if (stat.isFile()) { |
| 222 | const ext = path.extname(resolved).toLowerCase(); |
| 223 | if (HTML_EXTENSIONS.has(ext)) { |
| 224 | allFindings.push(...await detectHtml(resolved, scanOptions)); |
| 225 | } else { |
| 226 | allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions)); |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | } finally { |
| 231 | if (browserDetector) await browserDetector.close(); |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | if (allFindings.length > 0) { |
| 236 | if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); |
| 237 | else process.stderr.write(formatFindings(allFindings, false) + '\n'); |
| 238 | process.exit(2); |
| 239 | } |
| 240 | if (jsonMode) process.stdout.write('[]\n'); |
| 241 | process.exit(0); |
| 242 | } |
| 243 | |
| 244 | export { formatFindings, handleStdin, confirm, printUsage, detectCli }; |
| 245 |