Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/detector/cli/main.mjs
1import fs from 'node:fs';2import path from 'node:path';34import { loadDesignSystemForCwd } from '../design-system.mjs';5import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';6import { detectHtml } from '../engines/static-html/detect-html.mjs';7import { detectText } from '../engines/regex/detect-text.mjs';8import {9filterDetectionFindings,10readDetectionConfig,11shouldIgnoreDetectionFile,12} from '../../lib/impeccable-config.mjs';13import {14HTML_EXTENSIONS,15buildImportGraph,16detectFrameworkConfig,17isPortListening,18walkDir,19} from '../node/file-system.mjs';2021// ---------------------------------------------------------------------------22// Output formatting23// ---------------------------------------------------------------------------2425function formatFindingSummary(count) {26return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;27}2829function formatFindings(findings, jsonMode) {30if (jsonMode) return JSON.stringify(findings, null, 2);3132const grouped = {};33for (const f of findings) {34if (!grouped[f.file]) grouped[f.file] = [];35grouped[f.file].push(f);36}37const out = [];38for (const [file, items] of Object.entries(grouped)) {39const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '';40out.push(`\n${file}${importNote}`);41for (const item of items) {42out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`);43out.push(` → ${item.description}`);44}45}46out.push(`\n${formatFindingSummary(findings.length)}`);47return out.join('\n');48}4950// ---------------------------------------------------------------------------51// Stdin handling52// ---------------------------------------------------------------------------5354async function handleStdin(options = {}) {55const chunks = [];56for await (const chunk of process.stdin) chunks.push(chunk);57const input = Buffer.concat(chunks).toString('utf-8');58try {59const parsed = JSON.parse(input);60const fp = parsed?.tool_input?.file_path;61if (fp && fs.existsSync(fp)) {62return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())63? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);64}65} catch { /* not JSON */ }66return detectText(input, '<stdin>', options);67}686970// ---------------------------------------------------------------------------71// CLI72// ---------------------------------------------------------------------------7374async function confirm(question) {75const rl = (await import('node:readline')).default.createInterface({76input: process.stdin, output: process.stderr,77});78return new Promise((resolve) => {79rl.question(`${question} [Y/n] `, (answer) => {80rl.close();81resolve(!answer || /^y(es)?$/i.test(answer.trim()));82});83});84}8586function printUsage() {87console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...]8889Scan files or URLs for UI anti-patterns and design quality issues.9091Options:92--json Output results as JSON93--quiet In text mode, only print the final findings count94--gpt Also report GPT-specific provider tells (off by default)95--gemini Also report Gemini-specific provider tells (off by default)96--no-config Do not apply project config, detector ignores, inline97ignore comments, or DESIGN.md98--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments99--no-design-system Do not load local DESIGN.md / .impeccable/design.json context100--help Show this help message101102Project config:103Respects .impeccable/config.json and .impeccable/config.local.json detector104settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,105and detector.designSystem.enabled.106107Inline ignores:108In-file comments waive a finding where it lives and travel with the file:109<!-- impeccable-disable overused-font -- exported brand doc -->110.brand { font-family: Inter } /* impeccable-disable-line overused-font */111// impeccable-disable-next-line bounce-easing: intentional bounce112impeccable-disable applies to the whole file; -line / -next-line are scoped.113List one or more rule ids (comma-separated), or omit them / use * for all.114115Detection modes:116HTML files Static HTML/CSS analysis (default, catches linked CSS)117Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)118URLs Puppeteer full browser rendering (auto-detected)119120Examples:121impeccable detect src/122impeccable detect index.html123impeccable detect https://example.com124impeccable detect --json .125impeccable detect --no-config src/`);126}127128async function detectCli() {129let args = process.argv.slice(2).map(arg => {130if (arg === '-json') return '--json';131if (arg === '-fast') return '--fast';132return arg;133});134if (args[0] === 'detect') args = args.slice(1);135const jsonMode = args.includes('--json');136const quietMode = args.includes('--quiet');137const helpMode = args.includes('--help');138// --fast (regex-only) is deprecated: since the jsdom removal, the static139// HTML/CSS analysis is fast and covers every rule, so the regex-only path140// only loses coverage for no real speed win. Accept the flag for back-compat141// but ignore it and run the full scan.142if (args.includes('--fast')) {143process.stderr.write(144'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n',145);146}147const configEnabled = !args.includes('--no-config');148const detectionConfig = configEnabled149? readDetectionConfig(process.cwd())150: { ignoreRules: [], ignoreFiles: [], ignoreValues: [] };151const providers = [];152if (args.includes('--gpt')) providers.push('gpt');153if (args.includes('--gemini')) providers.push('gemini');154const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;155const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;156// Inline `impeccable-disable*` waivers are part of the scanned file, so they157// apply by default. `--no-config` (raw scan) and the dedicated158// `--no-inline-ignores` both turn them off.159const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');160const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };161if (designSystem) scanOptions.designSystem = designSystem;162const targets = args.filter(a => !a.startsWith('--'));163164if (helpMode) { printUsage(); process.exit(0); }165166let allFindings = [];167168if (!process.stdin.isTTY && targets.length === 0) {169allFindings = await handleStdin(scanOptions);170} else {171const paths = targets.length > 0 ? targets : [process.cwd()];172const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length;173const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;174175try {176for (const target of paths) {177if (/^https?:\/\//i.test(target)) {178try {179const scanner = browserDetector180? (url) => browserDetector.detectUrl(url, scanOptions)181: (url) => detectUrl(url, scanOptions);182allFindings.push(...await scanner(target));183} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }184continue;185}186187const resolved = path.resolve(target);188let stat;189try { stat = fs.statSync(resolved); }190catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }191192if (stat.isDirectory()) {193// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)194if (!jsonMode && !quietMode) {195const fwConfig = detectFrameworkConfig(resolved);196if (fwConfig) {197const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);198if (probe.listening && probe.matched) {199process.stderr.write(200`\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` +201`For more accurate results, scan the running site:\n` +202` npx impeccable detect http://localhost:${fwConfig.port}\n\n`203);204} else if (probe.listening && !probe.matched) {205process.stderr.write(206`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +207`Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n`208);209} else {210process.stderr.write(211`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +212`Start the dev server and scan via URL for best results:\n` +213` npx impeccable detect http://localhost:${fwConfig.port}\n\n`214);215}216}217}218219const files = walkDir(resolved)220.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));221const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;222223// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)224if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {225process.stderr.write(226`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +227`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +228`Target a specific subdirectory to narrow scope.\n`229);230const ok = await confirm('Continue?');231if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); }232}233234// Build import graph for multi-file awareness235const graph = buildImportGraph(files);236// Build reverse map: file -> set of files that import it237const importedByMap = new Map();238for (const [importer, imports] of graph) {239for (const imported of imports) {240if (!importedByMap.has(imported)) importedByMap.set(imported, new Set());241importedByMap.get(imported).add(importer);242}243}244245for (const file of files) {246const ext = path.extname(file).toLowerCase();247let fileFindings;248if (HTML_EXTENSIONS.has(ext)) {249fileFindings = await detectHtml(file, scanOptions);250} else {251fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions);252}253// Annotate findings with import context254const importers = importedByMap.get(file);255if (importers && importers.size > 0) {256const importerNames = [...importers].map(f => path.basename(f));257for (const f of fileFindings) {258f.importedBy = importerNames;259}260}261allFindings.push(...fileFindings);262}263} else if (stat.isFile()) {264if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;265const ext = path.extname(resolved).toLowerCase();266if (HTML_EXTENSIONS.has(ext)) {267allFindings.push(...await detectHtml(resolved, scanOptions));268} else {269allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions));270}271}272}273} finally {274if (browserDetector) await browserDetector.close();275}276}277278allFindings = filterDetectionFindings(allFindings, detectionConfig);279280if (allFindings.length > 0) {281if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');282else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');283else process.stderr.write(formatFindings(allFindings, false) + '\n');284process.exit(2);285}286if (jsonMode) process.stdout.write('[]\n');287process.exit(0);288}289290export { formatFindings, handleStdin, confirm, printUsage, detectCli };291