mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 17:46:36 +03:00
Harden CLI detection for framework files, multi-file projects, and dev servers
Tier 1: Add Vue/Svelte <style> block extraction and CSS-in-JS template literal detection (styled-components, emotion) so anti-patterns inside framework-specific syntax are caught. Enable multi-line context for CSS files so cross-line patterns like gradient-text are detected. Tier 2: Build a lightweight import graph when scanning directories. Findings are annotated with importedBy context (e.g. "imported by App.tsx") in both human and JSON output. Tier 3: Detect framework config files (Next.js, Vite, SvelteKit, Nuxt, Astro, Angular, Remix), probe the dev server port with HTTP fingerprinting to distinguish the expected framework from unrelated services, and suggest URL-based scanning for more accurate results. Adds realistic Next.js project fixtures (Tailwind, CSS Modules, styled-components) plus Vue, Svelte, JSX, and CSS-in-JS unit fixtures. 158 tests, 356 assertions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
dd559cd1e9
commit
8d05411c3e
@@ -1945,31 +1945,108 @@ const REGEX_ANALYZERS = [
|
||||
},
|
||||
];
|
||||
|
||||
function detectText(content, filePath) {
|
||||
const findings = [];
|
||||
const lines = content.split('\n');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style block extraction (Vue/Svelte <style> blocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractStyleBlocks(content, ext) {
|
||||
ext = ext.toLowerCase();
|
||||
if (ext !== '.vue' && ext !== '.svelte') return [];
|
||||
const blocks = [];
|
||||
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const before = content.substring(0, m.index);
|
||||
const startLine = before.split('\n').length + 1;
|
||||
blocks.push({ content: m[1], startLine });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS-in-JS extraction (styled-components, emotion)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
||||
|
||||
function extractCSSinJS(content, ext) {
|
||||
ext = ext.toLowerCase();
|
||||
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
||||
const blocks = [];
|
||||
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const before = content.substring(0, m.index);
|
||||
const startLine = before.split('\n').length;
|
||||
blocks.push({ content: m[1], startLine });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null) {
|
||||
const findings = [];
|
||||
for (const matcher of REGEX_MATCHERS) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
matcher.regex.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = matcher.regex.exec(line)) !== null) {
|
||||
if (matcher.test(m, line)) {
|
||||
findings.push(finding(matcher.id, filePath, matcher.fmt(m, line), i + 1));
|
||||
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
|
||||
const context = blockContext
|
||||
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
|
||||
: line;
|
||||
if (matcher.test(m, context)) {
|
||||
findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function detectText(content, filePath) {
|
||||
const findings = [];
|
||||
const lines = content.split('\n');
|
||||
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
|
||||
|
||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||
// Enable block context for CSS files where related properties span multiple lines
|
||||
const cssLike = new Set(['.css', '.scss', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null));
|
||||
|
||||
// Extract and scan <style> blocks from Vue/Svelte SFCs
|
||||
const styleBlocks = extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
const cssJsBlocks = extractCSSinJS(content, ext);
|
||||
for (const block of cssJsBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
|
||||
}
|
||||
|
||||
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
|
||||
const deduped = [];
|
||||
for (const f of findings) {
|
||||
const isDupe = deduped.some(d =>
|
||||
d.antipattern === f.antipattern &&
|
||||
d.snippet === f.snippet &&
|
||||
Math.abs(d.line - f.line) <= 2
|
||||
);
|
||||
if (!isDupe) deduped.push(f);
|
||||
}
|
||||
|
||||
// Page-level analyzers only run on full pages
|
||||
if (isFullPage(content)) {
|
||||
for (const analyzer of REGEX_ANALYZERS) {
|
||||
findings.push(...analyzer(content, filePath));
|
||||
deduped.push(...analyzer(content, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
return deduped;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2016,7 +2093,8 @@ function formatFindings(findings, jsonMode) {
|
||||
}
|
||||
const out = [];
|
||||
for (const [file, items] of Object.entries(grouped)) {
|
||||
out.push(`\n${file}`);
|
||||
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}`);
|
||||
@@ -2045,6 +2123,159 @@ async function handleStdin() {
|
||||
return detectText(input, '<stdin>');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import graph (multi-file awareness)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveImport(specifier, fromDir, fileSet) {
|
||||
if (!/^[./]/.test(specifier)) return null; // skip bare specifiers
|
||||
const base = path.resolve(fromDir, specifier);
|
||||
if (fileSet.has(base)) return base;
|
||||
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||
const withExt = base + ext;
|
||||
if (fileSet.has(withExt)) return withExt;
|
||||
}
|
||||
// index file convention
|
||||
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||
const indexFile = path.join(base, 'index' + ext);
|
||||
if (fileSet.has(indexFile)) return indexFile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
// ES imports: import ... from '...' and import '...'
|
||||
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
|
||||
let m;
|
||||
while ((m = esRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
// CSS @import
|
||||
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
|
||||
while ((m = cssRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
// SCSS @use / @forward
|
||||
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
|
||||
while ((m = scssRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
graph.set(file, imports);
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Framework dev server detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FRAMEWORK_CONFIGS = [
|
||||
{ name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /next/i } },
|
||||
{ name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-sveltekit-page', value: null } },
|
||||
{ name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
|
||||
{ name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { body: /@vite\/client/ } },
|
||||
{ name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { body: /astro/i } },
|
||||
{ name: 'Angular', files: ['angular.json'], defaultPort: 4200,
|
||||
portRe: /"port"\s*:\s*(\d+)/,
|
||||
fingerprint: { body: /ng-version/i } },
|
||||
{ name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /remix/i } },
|
||||
];
|
||||
|
||||
function detectFrameworkConfig(dir) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir); } catch { return null; }
|
||||
const entrySet = new Set(entries);
|
||||
|
||||
for (const cfg of FRAMEWORK_CONFIGS) {
|
||||
const match = cfg.files.find(f => entrySet.has(f));
|
||||
if (!match) continue;
|
||||
|
||||
const configPath = path.join(dir, match);
|
||||
let port = cfg.defaultPort;
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const portMatch = content.match(cfg.portRe);
|
||||
if (portMatch) port = parseInt(portMatch[1], 10);
|
||||
} catch { /* use default */ }
|
||||
|
||||
return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is listening and optionally verify it matches the expected framework.
|
||||
* Returns { listening: true, matched: true/false } or { listening: false }.
|
||||
*/
|
||||
async function isPortListening(port, fingerprint = null) {
|
||||
if (!fingerprint) {
|
||||
// Simple TCP probe fallback
|
||||
const net = await import('node:net');
|
||||
return new Promise((resolve) => {
|
||||
const sock = net.default.createConnection({ port, host: '127.0.0.1' });
|
||||
sock.setTimeout(500);
|
||||
sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); });
|
||||
sock.on('error', () => resolve({ listening: false }));
|
||||
sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); });
|
||||
});
|
||||
}
|
||||
|
||||
// HTTP probe with fingerprint matching
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' });
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Check header fingerprint
|
||||
if (fingerprint.header) {
|
||||
const val = res.headers.get(fingerprint.header);
|
||||
if (val && (!fingerprint.value || fingerprint.value.test(val))) {
|
||||
return { listening: true, matched: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Check body fingerprint
|
||||
if (fingerprint.body) {
|
||||
const body = await res.text();
|
||||
if (fingerprint.body.test(body)) {
|
||||
return { listening: true, matched: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Port is listening but doesn't match the expected framework
|
||||
return { listening: true, matched: false };
|
||||
} catch {
|
||||
return { listening: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2101,14 +2332,63 @@ async function main() {
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
for (const file of walkDir(resolved)) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(file));
|
||||
} else {
|
||||
allFindings.push(...detectText(fs.readFileSync(file, 'utf-8'), file));
|
||||
// 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);
|
||||
|
||||
// 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 (!fastMode && HTML_EXTENSIONS.has(ext)) {
|
||||
fileFindings = await detectHtml(file);
|
||||
} else {
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
|
||||
}
|
||||
// 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 (!fastMode && HTML_EXTENSIONS.has(ext)) {
|
||||
@@ -2148,6 +2428,9 @@ export {
|
||||
checkElementBorders, checkElementMotion, checkElementGlow, checkPageTypography, checkPageLayout, isNeutralColor, isFullPage,
|
||||
detectHtml, detectUrl, detectText,
|
||||
walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
|
||||
extractStyleBlocks, extractCSSinJS,
|
||||
buildImportGraph, resolveImport,
|
||||
detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
|
||||
main as detectCli,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user