244 lines
9.5 KiB
JavaScript
244 lines
9.5 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
|
|
import { detectHtml } from '../engines/static-html/detect-html.mjs';
|
|
import { detectText } from '../engines/regex/detect-text.mjs';
|
|
import {
|
|
HTML_EXTENSIONS,
|
|
buildImportGraph,
|
|
detectFrameworkConfig,
|
|
isPortListening,
|
|
walkDir,
|
|
} from '../node/file-system.mjs';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Output formatting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function formatFindings(findings, jsonMode) {
|
|
if (jsonMode) return JSON.stringify(findings, null, 2);
|
|
|
|
const grouped = {};
|
|
for (const f of findings) {
|
|
if (!grouped[f.file]) grouped[f.file] = [];
|
|
grouped[f.file].push(f);
|
|
}
|
|
const out = [];
|
|
for (const [file, items] of Object.entries(grouped)) {
|
|
const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '';
|
|
out.push(`\n${file}${importNote}`);
|
|
for (const item of items) {
|
|
out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`);
|
|
out.push(` → ${item.description}`);
|
|
}
|
|
}
|
|
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
|
|
return out.join('\n');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stdin handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function handleStdin(options = {}) {
|
|
const chunks = [];
|
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
const input = Buffer.concat(chunks).toString('utf-8');
|
|
try {
|
|
const parsed = JSON.parse(input);
|
|
const fp = parsed?.tool_input?.file_path;
|
|
if (fp && fs.existsSync(fp)) {
|
|
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
|
|
? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
|
|
}
|
|
} catch { /* not JSON */ }
|
|
return detectText(input, '<stdin>', options);
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function confirm(question) {
|
|
const rl = (await import('node:readline')).default.createInterface({
|
|
input: process.stdin, output: process.stderr,
|
|
});
|
|
return new Promise((resolve) => {
|
|
rl.question(`${question} [Y/n] `, (answer) => {
|
|
rl.close();
|
|
resolve(!answer || /^y(es)?$/i.test(answer.trim()));
|
|
});
|
|
});
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...]
|
|
|
|
Scan files or URLs for UI anti-patterns and design quality issues.
|
|
|
|
Options:
|
|
--json Output results as JSON
|
|
--gpt Also report GPT-specific provider tells (off by default)
|
|
--gemini Also report Gemini-specific provider tells (off by default)
|
|
--help Show this help message
|
|
|
|
Detection modes:
|
|
HTML files Static HTML/CSS analysis (default, catches linked CSS)
|
|
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
|
|
URLs Puppeteer full browser rendering (auto-detected)
|
|
|
|
Examples:
|
|
impeccable detect src/
|
|
impeccable detect index.html
|
|
impeccable detect https://example.com
|
|
impeccable detect --json .`);
|
|
}
|
|
|
|
async function detectCli() {
|
|
let args = process.argv.slice(2).map(arg => {
|
|
if (arg === '-json') return '--json';
|
|
if (arg === '-fast') return '--fast';
|
|
return arg;
|
|
});
|
|
if (args[0] === 'detect') args = args.slice(1);
|
|
const jsonMode = args.includes('--json');
|
|
const helpMode = args.includes('--help');
|
|
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
|
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
|
// only loses coverage for no real speed win. Accept the flag for back-compat
|
|
// but ignore it and run the full scan.
|
|
if (args.includes('--fast')) {
|
|
process.stderr.write(
|
|
'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n',
|
|
);
|
|
}
|
|
const providers = [];
|
|
if (args.includes('--gpt')) providers.push('gpt');
|
|
if (args.includes('--gemini')) providers.push('gemini');
|
|
const scanOptions = { providers };
|
|
const targets = args.filter(a => !a.startsWith('--'));
|
|
|
|
if (helpMode) { printUsage(); process.exit(0); }
|
|
|
|
let allFindings = [];
|
|
|
|
if (!process.stdin.isTTY && targets.length === 0) {
|
|
allFindings = await handleStdin(scanOptions);
|
|
} else {
|
|
const paths = targets.length > 0 ? targets : [process.cwd()];
|
|
const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length;
|
|
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
|
|
|
try {
|
|
for (const target of paths) {
|
|
if (/^https?:\/\//i.test(target)) {
|
|
try {
|
|
const scanner = browserDetector
|
|
? (url) => browserDetector.detectUrl(url, scanOptions)
|
|
: (url) => detectUrl(url, scanOptions);
|
|
allFindings.push(...await scanner(target));
|
|
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
|
continue;
|
|
}
|
|
|
|
const resolved = path.resolve(target);
|
|
let stat;
|
|
try { stat = fs.statSync(resolved); }
|
|
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
|
|
|
if (stat.isDirectory()) {
|
|
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
|
|
if (!jsonMode) {
|
|
const fwConfig = detectFrameworkConfig(resolved);
|
|
if (fwConfig) {
|
|
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
|
|
if (probe.listening && probe.matched) {
|
|
process.stderr.write(
|
|
`\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` +
|
|
`For more accurate results, scan the running site:\n` +
|
|
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
|
|
);
|
|
} else if (probe.listening && !probe.matched) {
|
|
process.stderr.write(
|
|
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
|
|
`Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n`
|
|
);
|
|
} else {
|
|
process.stderr.write(
|
|
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
|
|
`Start the dev server and scan via URL for best results:\n` +
|
|
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const files = walkDir(resolved);
|
|
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
|
|
|
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
|
|
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
|
|
process.stderr.write(
|
|
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
|
|
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
|
|
`Target a specific subdirectory to narrow scope.\n`
|
|
);
|
|
const ok = await confirm('Continue?');
|
|
if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); }
|
|
}
|
|
|
|
// Build import graph for multi-file awareness
|
|
const graph = buildImportGraph(files);
|
|
// Build reverse map: file -> set of files that import it
|
|
const importedByMap = new Map();
|
|
for (const [importer, imports] of graph) {
|
|
for (const imported of imports) {
|
|
if (!importedByMap.has(imported)) importedByMap.set(imported, new Set());
|
|
importedByMap.get(imported).add(importer);
|
|
}
|
|
}
|
|
|
|
for (const file of files) {
|
|
const ext = path.extname(file).toLowerCase();
|
|
let fileFindings;
|
|
if (HTML_EXTENSIONS.has(ext)) {
|
|
fileFindings = await detectHtml(file, scanOptions);
|
|
} else {
|
|
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions);
|
|
}
|
|
// Annotate findings with import context
|
|
const importers = importedByMap.get(file);
|
|
if (importers && importers.size > 0) {
|
|
const importerNames = [...importers].map(f => path.basename(f));
|
|
for (const f of fileFindings) {
|
|
f.importedBy = importerNames;
|
|
}
|
|
}
|
|
allFindings.push(...fileFindings);
|
|
}
|
|
} else if (stat.isFile()) {
|
|
const ext = path.extname(resolved).toLowerCase();
|
|
if (HTML_EXTENSIONS.has(ext)) {
|
|
allFindings.push(...await detectHtml(resolved, scanOptions));
|
|
} else {
|
|
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions));
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
if (browserDetector) await browserDetector.close();
|
|
}
|
|
}
|
|
|
|
if (allFindings.length > 0) {
|
|
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
|
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
|
process.exit(2);
|
|
}
|
|
if (jsonMode) process.stdout.write('[]\n');
|
|
process.exit(0);
|
|
}
|
|
|
|
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|