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 };