返回 AiToEarn
main.mjs
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
245 lines Plain Text