mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-13 06:36:26 +03:00
Add deep detection via jsdom and URL scanning via Puppeteer
Three detection tiers: - file/dir (default): fast regex scan, zero dependencies - file + --deep: jsdom computed styles, resolves linked local stylesheets by inlining <link rel="stylesheet"> content before parsing - URL (https://...): auto-launches Puppeteer for full browser rendering, handles CDN stylesheets, JS-rendered content, everything New exports: detectAntiPatternsDeep(), detectAntiPatternsUrl() jsdom added as devDependency; puppeteer remains optional (npx cache). TDD: linked-stylesheet fixture demonstrates the gap — regex finds 0 border issues, --deep correctly catches side-tab and top-accent from the external CSS file. 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
1fb896a3ff
commit
32a54138bb
@@ -357,6 +357,247 @@ function detectAntiPatterns(content, filePath) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deep detection (jsdom / Puppeteer — computed styles)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAFE_TAGS_DEEP = new Set([
|
||||
'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
|
||||
'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
|
||||
'button', 'hr', 'html', 'head', 'body', 'script', 'style',
|
||||
'link', 'meta', 'title', 'br', 'img', 'svg', 'path',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Analyze a single DOM element using computed styles.
|
||||
* Works with both jsdom window and Puppeteer page.
|
||||
*/
|
||||
function analyzeElementDeep(el, computedStyle, filePath) {
|
||||
const findings = [];
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS_DEEP.has(tag)) return findings;
|
||||
|
||||
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
||||
const widths = {};
|
||||
const colors = {};
|
||||
for (const s of sides) {
|
||||
widths[s] = parseFloat(computedStyle[`border${s}Width`]) || 0;
|
||||
colors[s] = computedStyle[`border${s}Color`] || '';
|
||||
}
|
||||
const radius = parseFloat(computedStyle.borderRadius) || 0;
|
||||
const fontSize = parseFloat(computedStyle.fontSize) || 0;
|
||||
const fontFamily = computedStyle.fontFamily || '';
|
||||
|
||||
// --- Border accent detection ---
|
||||
for (const side of sides) {
|
||||
const w = widths[side];
|
||||
if (w < 1) continue;
|
||||
|
||||
// Check if border color is transparent
|
||||
const color = colors[side];
|
||||
if (!color || color === 'transparent' || /rgba\([^)]*,\s*0\)/.test(color)) continue;
|
||||
|
||||
// Check if neutral (gray)
|
||||
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (rgbMatch) {
|
||||
const [r, g, b] = [+rgbMatch[1], +rgbMatch[2], +rgbMatch[3]];
|
||||
if (Math.max(r, g, b) - Math.min(r, g, b) < 30) continue;
|
||||
}
|
||||
|
||||
const otherSides = sides.filter(s => s !== side);
|
||||
const maxOther = Math.max(...otherSides.map(s => widths[s]));
|
||||
const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2);
|
||||
if (!isAccent) continue;
|
||||
|
||||
const isSide = side === 'Left' || side === 'Right';
|
||||
const sideName = side.toLowerCase();
|
||||
|
||||
if (isSide) {
|
||||
if (radius > 0) {
|
||||
findings.push({ antipattern: 'side-tab', name: 'Side-tab accent border', description: ANTIPATTERNS[0].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
|
||||
} else if (w >= 3) {
|
||||
findings.push({ antipattern: 'side-tab', name: 'Side-tab accent border', description: ANTIPATTERNS[0].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px` });
|
||||
}
|
||||
} else {
|
||||
if (radius > 0 && w >= 2) {
|
||||
findings.push({ antipattern: 'border-accent-on-rounded', name: 'Border accent on rounded element', description: ANTIPATTERNS[1].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep scan using jsdom — resolves linked stylesheets, computes styles.
|
||||
* @param {string} filePath Path to an HTML file
|
||||
* @returns {Promise<Array>} findings
|
||||
*/
|
||||
async function detectAntiPatternsDeep(filePath) {
|
||||
let JSDOM;
|
||||
try {
|
||||
({ JSDOM } = await import('jsdom'));
|
||||
} catch {
|
||||
throw new Error('jsdom is required for --deep mode. Install it: npm install jsdom');
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(filePath, 'utf-8');
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const fileDir = path.dirname(resolvedPath);
|
||||
|
||||
// Resolve linked stylesheets and inline them
|
||||
let processedHtml = html;
|
||||
const linkRe = /<link[^>]+rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
|
||||
const linkRe2 = /<link[^>]+href=["']([^"']+)["'][^>]*rel=["']stylesheet["'][^>]*>/gi;
|
||||
for (const re of [linkRe, linkRe2]) {
|
||||
let m;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
const href = m[1];
|
||||
// Only resolve local files, not URLs
|
||||
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) continue;
|
||||
const cssPath = path.resolve(fileDir, href);
|
||||
try {
|
||||
const cssContent = fs.readFileSync(cssPath, 'utf-8');
|
||||
processedHtml = processedHtml.replace(m[0], `<style>/* from ${href} */\n${cssContent}\n</style>`);
|
||||
} catch {
|
||||
// Can't read stylesheet, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dom = new JSDOM(processedHtml, {
|
||||
url: `file://${resolvedPath}`,
|
||||
resources: 'usable',
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
const { window } = dom;
|
||||
const { document } = window;
|
||||
|
||||
// Wait for styles to apply
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const findings = [];
|
||||
const elements = document.querySelectorAll('*');
|
||||
|
||||
for (const el of elements) {
|
||||
const style = window.getComputedStyle(el);
|
||||
findings.push(...analyzeElementDeep(el, style, filePath));
|
||||
}
|
||||
|
||||
// Also run file-level analyzers (overused fonts, single font, flat hierarchy)
|
||||
// These work on the raw content which is fine
|
||||
for (const ap of ANTIPATTERNS) {
|
||||
if (ap.analyzers) {
|
||||
for (const analyzer of ap.analyzers) {
|
||||
findings.push(...analyzer(html, filePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.close();
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep scan using Puppeteer — full browser rendering for URLs.
|
||||
* @param {string} url URL to scan
|
||||
* @returns {Promise<Array>} findings
|
||||
*/
|
||||
async function detectAntiPatternsUrl(url) {
|
||||
let puppeteer;
|
||||
try {
|
||||
puppeteer = await import('puppeteer');
|
||||
} catch {
|
||||
throw new Error('puppeteer is required for URL scanning. Install it: npm install puppeteer');
|
||||
}
|
||||
|
||||
const browser = await puppeteer.default.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
|
||||
// Run detection in the browser context
|
||||
const findings = await page.evaluate((safeTags) => {
|
||||
const results = [];
|
||||
const safe = new Set(safeTags);
|
||||
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
||||
const elements = document.querySelectorAll('*');
|
||||
|
||||
for (const el of elements) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (safe.has(tag)) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 20 || rect.height < 20) continue;
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const widths = {};
|
||||
const colors = {};
|
||||
for (const s of sides) {
|
||||
widths[s] = parseFloat(style[`border${s}Width`]) || 0;
|
||||
colors[s] = style[`border${s}Color`] || '';
|
||||
}
|
||||
const radius = parseFloat(style.borderRadius) || 0;
|
||||
|
||||
for (const side of sides) {
|
||||
const w = widths[side];
|
||||
if (w < 1) continue;
|
||||
const color = colors[side];
|
||||
if (!color || color === 'transparent' || /rgba\([^)]*,\s*0\)/.test(color)) continue;
|
||||
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (rgbMatch) {
|
||||
const [r, g, b] = [+rgbMatch[1], +rgbMatch[2], +rgbMatch[3]];
|
||||
if (Math.max(r, g, b) - Math.min(r, g, b) < 30) continue;
|
||||
}
|
||||
|
||||
const otherSides = sides.filter(s => s !== side);
|
||||
const maxOther = Math.max(...otherSides.map(s => widths[s]));
|
||||
const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2);
|
||||
if (!isAccent) continue;
|
||||
|
||||
const isSide = side === 'Left' || side === 'Right';
|
||||
const sideName = side.toLowerCase();
|
||||
|
||||
if (isSide) {
|
||||
if (radius > 0) {
|
||||
results.push({ antipattern: 'side-tab', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
|
||||
} else if (w >= 3) {
|
||||
results.push({ antipattern: 'side-tab', snippet: `border-${sideName}: ${w}px` });
|
||||
}
|
||||
} else {
|
||||
if (radius > 0 && w >= 2) {
|
||||
results.push({ antipattern: 'border-accent-on-rounded', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [...SAFE_TAGS_DEEP]);
|
||||
|
||||
// Enrich findings with metadata
|
||||
const enriched = findings.map(f => ({
|
||||
...f,
|
||||
name: f.antipattern === 'side-tab' ? 'Side-tab accent border' : 'Border accent on rounded element',
|
||||
description: ANTIPATTERNS.find(a => a.id === f.antipattern)?.description || '',
|
||||
file: url,
|
||||
line: 0,
|
||||
}));
|
||||
|
||||
// Also get the page HTML for file-level analyzers
|
||||
const html = await page.content();
|
||||
for (const ap of ANTIPATTERNS) {
|
||||
if (ap.analyzers) {
|
||||
for (const analyzer of ap.analyzers) {
|
||||
enriched.push(...analyzer(html, url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
return enriched;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File walker
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -460,26 +701,37 @@ async function handleStdin() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: node detect-antipatterns.mjs [options] [file-or-dir...]
|
||||
console.log(`Usage: node detect-antipatterns.mjs [options] [file-or-dir-or-url...]
|
||||
|
||||
Scan files for known UI anti-patterns.
|
||||
Scan files or URLs for known UI anti-patterns.
|
||||
|
||||
Options:
|
||||
--deep Use jsdom for computed style analysis (catches linked stylesheets)
|
||||
--json Output results as JSON
|
||||
--help Show this help message
|
||||
|
||||
Modes:
|
||||
file/dir Fast regex scan (default)
|
||||
file + --deep jsdom computed styles (resolves local CSS)
|
||||
https://... Puppeteer full browser (auto, resolves everything)
|
||||
|
||||
Examples:
|
||||
node detect-antipatterns.mjs src/
|
||||
node detect-antipatterns.mjs index.html styles.css
|
||||
node detect-antipatterns.mjs --json .
|
||||
echo '{"tool_input":{"file_path":"f.html"}}' | node detect-antipatterns.mjs`);
|
||||
node detect-antipatterns.mjs --deep index.html
|
||||
node detect-antipatterns.mjs https://example.com
|
||||
node detect-antipatterns.mjs --json .`);
|
||||
}
|
||||
|
||||
function isUrl(str) {
|
||||
return /^https?:\/\//i.test(str);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const jsonMode = args.includes('--json');
|
||||
const helpMode = args.includes('--help');
|
||||
const targets = args.filter((a) => a !== '--json' && a !== '--help');
|
||||
const deepMode = args.includes('--deep');
|
||||
const targets = args.filter((a) => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) {
|
||||
printUsage();
|
||||
@@ -496,6 +748,17 @@ async function main() {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
|
||||
for (const target of paths) {
|
||||
// URL → Puppeteer
|
||||
if (isUrl(target)) {
|
||||
try {
|
||||
const findings = await detectAntiPatternsUrl(target);
|
||||
allFindings.push(...findings);
|
||||
} catch (e) {
|
||||
process.stderr.write(`Error scanning URL ${target}: ${e.message}\n`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try {
|
||||
@@ -507,12 +770,20 @@ async function main() {
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
for (const file of walkDir(resolved)) {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
allFindings.push(...detectAntiPatterns(content, file));
|
||||
if (deepMode && file.endsWith('.html')) {
|
||||
allFindings.push(...await detectAntiPatternsDeep(file));
|
||||
} else {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
allFindings.push(...detectAntiPatterns(content, file));
|
||||
}
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
const content = fs.readFileSync(resolved, 'utf-8');
|
||||
allFindings.push(...detectAntiPatterns(content, resolved));
|
||||
if (deepMode && resolved.endsWith('.html')) {
|
||||
allFindings.push(...await detectAntiPatternsDeep(resolved));
|
||||
} else {
|
||||
const content = fs.readFileSync(resolved, 'utf-8');
|
||||
allFindings.push(...detectAntiPatterns(content, resolved));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,4 +810,4 @@ if (isMainModule) {
|
||||
main();
|
||||
}
|
||||
|
||||
export { ANTIPATTERNS, detectAntiPatterns, walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS };
|
||||
export { ANTIPATTERNS, detectAntiPatterns, detectAntiPatternsDeep, detectAntiPatternsUrl, walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS };
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"jsdom": "^29.0.0",
|
||||
"wrangler": "^4.71.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +357,247 @@ function detectAntiPatterns(content, filePath) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deep detection (jsdom / Puppeteer — computed styles)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAFE_TAGS_DEEP = new Set([
|
||||
'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
|
||||
'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
|
||||
'button', 'hr', 'html', 'head', 'body', 'script', 'style',
|
||||
'link', 'meta', 'title', 'br', 'img', 'svg', 'path',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Analyze a single DOM element using computed styles.
|
||||
* Works with both jsdom window and Puppeteer page.
|
||||
*/
|
||||
function analyzeElementDeep(el, computedStyle, filePath) {
|
||||
const findings = [];
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS_DEEP.has(tag)) return findings;
|
||||
|
||||
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
||||
const widths = {};
|
||||
const colors = {};
|
||||
for (const s of sides) {
|
||||
widths[s] = parseFloat(computedStyle[`border${s}Width`]) || 0;
|
||||
colors[s] = computedStyle[`border${s}Color`] || '';
|
||||
}
|
||||
const radius = parseFloat(computedStyle.borderRadius) || 0;
|
||||
const fontSize = parseFloat(computedStyle.fontSize) || 0;
|
||||
const fontFamily = computedStyle.fontFamily || '';
|
||||
|
||||
// --- Border accent detection ---
|
||||
for (const side of sides) {
|
||||
const w = widths[side];
|
||||
if (w < 1) continue;
|
||||
|
||||
// Check if border color is transparent
|
||||
const color = colors[side];
|
||||
if (!color || color === 'transparent' || /rgba\([^)]*,\s*0\)/.test(color)) continue;
|
||||
|
||||
// Check if neutral (gray)
|
||||
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (rgbMatch) {
|
||||
const [r, g, b] = [+rgbMatch[1], +rgbMatch[2], +rgbMatch[3]];
|
||||
if (Math.max(r, g, b) - Math.min(r, g, b) < 30) continue;
|
||||
}
|
||||
|
||||
const otherSides = sides.filter(s => s !== side);
|
||||
const maxOther = Math.max(...otherSides.map(s => widths[s]));
|
||||
const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2);
|
||||
if (!isAccent) continue;
|
||||
|
||||
const isSide = side === 'Left' || side === 'Right';
|
||||
const sideName = side.toLowerCase();
|
||||
|
||||
if (isSide) {
|
||||
if (radius > 0) {
|
||||
findings.push({ antipattern: 'side-tab', name: 'Side-tab accent border', description: ANTIPATTERNS[0].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
|
||||
} else if (w >= 3) {
|
||||
findings.push({ antipattern: 'side-tab', name: 'Side-tab accent border', description: ANTIPATTERNS[0].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px` });
|
||||
}
|
||||
} else {
|
||||
if (radius > 0 && w >= 2) {
|
||||
findings.push({ antipattern: 'border-accent-on-rounded', name: 'Border accent on rounded element', description: ANTIPATTERNS[1].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep scan using jsdom — resolves linked stylesheets, computes styles.
|
||||
* @param {string} filePath Path to an HTML file
|
||||
* @returns {Promise<Array>} findings
|
||||
*/
|
||||
async function detectAntiPatternsDeep(filePath) {
|
||||
let JSDOM;
|
||||
try {
|
||||
({ JSDOM } = await import('jsdom'));
|
||||
} catch {
|
||||
throw new Error('jsdom is required for --deep mode. Install it: npm install jsdom');
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(filePath, 'utf-8');
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const fileDir = path.dirname(resolvedPath);
|
||||
|
||||
// Resolve linked stylesheets and inline them
|
||||
let processedHtml = html;
|
||||
const linkRe = /<link[^>]+rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
|
||||
const linkRe2 = /<link[^>]+href=["']([^"']+)["'][^>]*rel=["']stylesheet["'][^>]*>/gi;
|
||||
for (const re of [linkRe, linkRe2]) {
|
||||
let m;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
const href = m[1];
|
||||
// Only resolve local files, not URLs
|
||||
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) continue;
|
||||
const cssPath = path.resolve(fileDir, href);
|
||||
try {
|
||||
const cssContent = fs.readFileSync(cssPath, 'utf-8');
|
||||
processedHtml = processedHtml.replace(m[0], `<style>/* from ${href} */\n${cssContent}\n</style>`);
|
||||
} catch {
|
||||
// Can't read stylesheet, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dom = new JSDOM(processedHtml, {
|
||||
url: `file://${resolvedPath}`,
|
||||
resources: 'usable',
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
const { window } = dom;
|
||||
const { document } = window;
|
||||
|
||||
// Wait for styles to apply
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const findings = [];
|
||||
const elements = document.querySelectorAll('*');
|
||||
|
||||
for (const el of elements) {
|
||||
const style = window.getComputedStyle(el);
|
||||
findings.push(...analyzeElementDeep(el, style, filePath));
|
||||
}
|
||||
|
||||
// Also run file-level analyzers (overused fonts, single font, flat hierarchy)
|
||||
// These work on the raw content which is fine
|
||||
for (const ap of ANTIPATTERNS) {
|
||||
if (ap.analyzers) {
|
||||
for (const analyzer of ap.analyzers) {
|
||||
findings.push(...analyzer(html, filePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.close();
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep scan using Puppeteer — full browser rendering for URLs.
|
||||
* @param {string} url URL to scan
|
||||
* @returns {Promise<Array>} findings
|
||||
*/
|
||||
async function detectAntiPatternsUrl(url) {
|
||||
let puppeteer;
|
||||
try {
|
||||
puppeteer = await import('puppeteer');
|
||||
} catch {
|
||||
throw new Error('puppeteer is required for URL scanning. Install it: npm install puppeteer');
|
||||
}
|
||||
|
||||
const browser = await puppeteer.default.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
|
||||
// Run detection in the browser context
|
||||
const findings = await page.evaluate((safeTags) => {
|
||||
const results = [];
|
||||
const safe = new Set(safeTags);
|
||||
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
||||
const elements = document.querySelectorAll('*');
|
||||
|
||||
for (const el of elements) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (safe.has(tag)) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 20 || rect.height < 20) continue;
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const widths = {};
|
||||
const colors = {};
|
||||
for (const s of sides) {
|
||||
widths[s] = parseFloat(style[`border${s}Width`]) || 0;
|
||||
colors[s] = style[`border${s}Color`] || '';
|
||||
}
|
||||
const radius = parseFloat(style.borderRadius) || 0;
|
||||
|
||||
for (const side of sides) {
|
||||
const w = widths[side];
|
||||
if (w < 1) continue;
|
||||
const color = colors[side];
|
||||
if (!color || color === 'transparent' || /rgba\([^)]*,\s*0\)/.test(color)) continue;
|
||||
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (rgbMatch) {
|
||||
const [r, g, b] = [+rgbMatch[1], +rgbMatch[2], +rgbMatch[3]];
|
||||
if (Math.max(r, g, b) - Math.min(r, g, b) < 30) continue;
|
||||
}
|
||||
|
||||
const otherSides = sides.filter(s => s !== side);
|
||||
const maxOther = Math.max(...otherSides.map(s => widths[s]));
|
||||
const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2);
|
||||
if (!isAccent) continue;
|
||||
|
||||
const isSide = side === 'Left' || side === 'Right';
|
||||
const sideName = side.toLowerCase();
|
||||
|
||||
if (isSide) {
|
||||
if (radius > 0) {
|
||||
results.push({ antipattern: 'side-tab', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
|
||||
} else if (w >= 3) {
|
||||
results.push({ antipattern: 'side-tab', snippet: `border-${sideName}: ${w}px` });
|
||||
}
|
||||
} else {
|
||||
if (radius > 0 && w >= 2) {
|
||||
results.push({ antipattern: 'border-accent-on-rounded', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [...SAFE_TAGS_DEEP]);
|
||||
|
||||
// Enrich findings with metadata
|
||||
const enriched = findings.map(f => ({
|
||||
...f,
|
||||
name: f.antipattern === 'side-tab' ? 'Side-tab accent border' : 'Border accent on rounded element',
|
||||
description: ANTIPATTERNS.find(a => a.id === f.antipattern)?.description || '',
|
||||
file: url,
|
||||
line: 0,
|
||||
}));
|
||||
|
||||
// Also get the page HTML for file-level analyzers
|
||||
const html = await page.content();
|
||||
for (const ap of ANTIPATTERNS) {
|
||||
if (ap.analyzers) {
|
||||
for (const analyzer of ap.analyzers) {
|
||||
enriched.push(...analyzer(html, url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
return enriched;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File walker
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -460,26 +701,37 @@ async function handleStdin() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: node detect-antipatterns.mjs [options] [file-or-dir...]
|
||||
console.log(`Usage: node detect-antipatterns.mjs [options] [file-or-dir-or-url...]
|
||||
|
||||
Scan files for known UI anti-patterns.
|
||||
Scan files or URLs for known UI anti-patterns.
|
||||
|
||||
Options:
|
||||
--deep Use jsdom for computed style analysis (catches linked stylesheets)
|
||||
--json Output results as JSON
|
||||
--help Show this help message
|
||||
|
||||
Modes:
|
||||
file/dir Fast regex scan (default)
|
||||
file + --deep jsdom computed styles (resolves local CSS)
|
||||
https://... Puppeteer full browser (auto, resolves everything)
|
||||
|
||||
Examples:
|
||||
node detect-antipatterns.mjs src/
|
||||
node detect-antipatterns.mjs index.html styles.css
|
||||
node detect-antipatterns.mjs --json .
|
||||
echo '{"tool_input":{"file_path":"f.html"}}' | node detect-antipatterns.mjs`);
|
||||
node detect-antipatterns.mjs --deep index.html
|
||||
node detect-antipatterns.mjs https://example.com
|
||||
node detect-antipatterns.mjs --json .`);
|
||||
}
|
||||
|
||||
function isUrl(str) {
|
||||
return /^https?:\/\//i.test(str);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const jsonMode = args.includes('--json');
|
||||
const helpMode = args.includes('--help');
|
||||
const targets = args.filter((a) => a !== '--json' && a !== '--help');
|
||||
const deepMode = args.includes('--deep');
|
||||
const targets = args.filter((a) => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) {
|
||||
printUsage();
|
||||
@@ -496,6 +748,17 @@ async function main() {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
|
||||
for (const target of paths) {
|
||||
// URL → Puppeteer
|
||||
if (isUrl(target)) {
|
||||
try {
|
||||
const findings = await detectAntiPatternsUrl(target);
|
||||
allFindings.push(...findings);
|
||||
} catch (e) {
|
||||
process.stderr.write(`Error scanning URL ${target}: ${e.message}\n`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try {
|
||||
@@ -507,12 +770,20 @@ async function main() {
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
for (const file of walkDir(resolved)) {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
allFindings.push(...detectAntiPatterns(content, file));
|
||||
if (deepMode && file.endsWith('.html')) {
|
||||
allFindings.push(...await detectAntiPatternsDeep(file));
|
||||
} else {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
allFindings.push(...detectAntiPatterns(content, file));
|
||||
}
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
const content = fs.readFileSync(resolved, 'utf-8');
|
||||
allFindings.push(...detectAntiPatterns(content, resolved));
|
||||
if (deepMode && resolved.endsWith('.html')) {
|
||||
allFindings.push(...await detectAntiPatternsDeep(resolved));
|
||||
} else {
|
||||
const content = fs.readFileSync(resolved, 'utf-8');
|
||||
allFindings.push(...detectAntiPatterns(content, resolved));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,4 +810,4 @@ if (isMainModule) {
|
||||
main();
|
||||
}
|
||||
|
||||
export { ANTIPATTERNS, detectAntiPatterns, walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS };
|
||||
export { ANTIPATTERNS, detectAntiPatterns, detectAntiPatternsDeep, detectAntiPatternsUrl, walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS };
|
||||
|
||||
@@ -526,6 +526,49 @@ describe('ANTIPATTERNS registry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linked stylesheet detection (--deep mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('linked stylesheet detection', () => {
|
||||
test('regex mode MISSES anti-patterns from linked stylesheets', () => {
|
||||
const content = fs.readFileSync(path.join(FIXTURES, 'linked-stylesheet.html'), 'utf-8');
|
||||
const findings = detectAntiPatterns(content, path.join(FIXTURES, 'linked-stylesheet.html'));
|
||||
// Regex can't see into external-styles.css — finds nothing border-related
|
||||
const borderFindings = findings.filter(f => f.antipattern === 'side-tab' || f.antipattern === 'border-accent-on-rounded');
|
||||
expect(borderFindings).toHaveLength(0);
|
||||
});
|
||||
|
||||
// These tests require jsdom — skip if not available
|
||||
const hasJsdom = (() => { try { require('jsdom'); return true; } catch { return false; } })();
|
||||
const jsdomTest = hasJsdom ? test : test.skip;
|
||||
|
||||
jsdomTest('deep mode CATCHES side-tab from linked stylesheet', async () => {
|
||||
const { detectAntiPatternsDeep } = await import('../source/skills/critique/scripts/detect-antipatterns.mjs');
|
||||
const filePath = path.join(FIXTURES, 'linked-stylesheet.html');
|
||||
const findings = await detectAntiPatternsDeep(filePath);
|
||||
const sideTabs = findings.filter(f => f.antipattern === 'side-tab');
|
||||
expect(sideTabs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
jsdomTest('deep mode CATCHES top accent from linked stylesheet', async () => {
|
||||
const { detectAntiPatternsDeep } = await import('../source/skills/critique/scripts/detect-antipatterns.mjs');
|
||||
const filePath = path.join(FIXTURES, 'linked-stylesheet.html');
|
||||
const findings = await detectAntiPatternsDeep(filePath);
|
||||
const accents = findings.filter(f => f.antipattern === 'border-accent-on-rounded');
|
||||
expect(accents.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
jsdomTest('deep mode does NOT flag clean card from linked stylesheet', async () => {
|
||||
const { detectAntiPatternsDeep } = await import('../source/skills/critique/scripts/detect-antipatterns.mjs');
|
||||
const filePath = path.join(FIXTURES, 'linked-stylesheet.html');
|
||||
const findings = await detectAntiPatternsDeep(filePath);
|
||||
// Should not flag the .external-clean card
|
||||
const cleanFindings = findings.filter(f => f.snippet && f.snippet.includes('clean'));
|
||||
expect(cleanFindings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// walkDir
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/* External stylesheet that applies anti-pattern styles */
|
||||
|
||||
/* Side-tab via external class */
|
||||
.external-side-tab {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border-left: 4px solid #3b82f6;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Top border accent via external class */
|
||||
.external-top-accent {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border-top: 3px solid #8b5cf6;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Overused font from external stylesheet */
|
||||
.external-inter {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Clean card — should NOT flag */
|
||||
.external-clean {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Anti-Patterns From Linked Stylesheet</title>
|
||||
<link rel="stylesheet" href="external-styles.css">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
h2 { font-size: 1.125rem; margin: 2rem 0 0.75rem; color: #6b7280; }
|
||||
p.intro { color: #6b7280; margin-bottom: 2rem; max-width: 36rem; }
|
||||
.cards { display: grid; gap: 1rem; max-width: 28rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Linked Stylesheet Anti-Patterns</h1>
|
||||
<p class="intro">
|
||||
These elements get their anti-pattern styles from an external CSS file.
|
||||
Regex-only scanning misses these — only computed style analysis catches them.
|
||||
</p>
|
||||
|
||||
<h2>Side-Tab (from external CSS)</h2>
|
||||
<div class="cards">
|
||||
<div class="external-side-tab">
|
||||
<h3 style="font-weight: 600;">External side-tab class</h3>
|
||||
<p style="font-size: 0.875rem; color: #6b7280;">border-left + border-radius from linked stylesheet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Top Accent + Rounded (from external CSS)</h2>
|
||||
<div class="cards">
|
||||
<div class="external-top-accent">
|
||||
<h3 style="font-weight: 600;">External top accent class</h3>
|
||||
<p style="font-size: 0.875rem; color: #6b7280;">border-top + border-radius from linked stylesheet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Clean Card (from external CSS)</h2>
|
||||
<div class="cards">
|
||||
<div class="external-clean">
|
||||
<h3 style="font-weight: 600;">External clean card</h3>
|
||||
<p style="font-size: 0.875rem; color: #6b7280;">Uniform 1px border — should NOT flag.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user