From 32a54138bb9b74c43e0512d4afcf7cedea867786 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 17 Mar 2026 11:07:47 -0700 Subject: [PATCH] Add deep detection via jsdom and URL scanning via Puppeteer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three detection tiers: - file/dir (default): fast regex scan, zero dependencies - file + --deep: jsdom computed styles, resolves linked local stylesheets by inlining 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) --- .../critique/scripts/detect-antipatterns.mjs | 293 +++++++++++++++++- package.json | 1 + .../critique/scripts/detect-antipatterns.mjs | 293 +++++++++++++++++- tests/detect-antipatterns.test.js | 43 +++ .../fixtures/antipatterns/external-styles.css | 33 ++ .../antipatterns/linked-stylesheet.html | 48 +++ 6 files changed, 689 insertions(+), 22 deletions(-) create mode 100644 tests/fixtures/antipatterns/external-styles.css create mode 100644 tests/fixtures/antipatterns/linked-stylesheet.html diff --git a/.claude/skills/critique/scripts/detect-antipatterns.mjs b/.claude/skills/critique/scripts/detect-antipatterns.mjs index 52da46c06..2913c4b1b 100644 --- a/.claude/skills/critique/scripts/detect-antipatterns.mjs +++ b/.claude/skills/critique/scripts/detect-antipatterns.mjs @@ -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} 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 = /]+rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi; + const linkRe2 = /]+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], ``); + } 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} 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 }; diff --git a/package.json b/package.json index 1cec7fde4..52d2f1fa7 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "type": "module", "devDependencies": { + "jsdom": "^29.0.0", "wrangler": "^4.71.0" } } diff --git a/source/skills/critique/scripts/detect-antipatterns.mjs b/source/skills/critique/scripts/detect-antipatterns.mjs index 52da46c06..2913c4b1b 100644 --- a/source/skills/critique/scripts/detect-antipatterns.mjs +++ b/source/skills/critique/scripts/detect-antipatterns.mjs @@ -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} 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 = /]+rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi; + const linkRe2 = /]+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], ``); + } 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} 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 }; diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index a76ffc453..30a978d48 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -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 // --------------------------------------------------------------------------- diff --git a/tests/fixtures/antipatterns/external-styles.css b/tests/fixtures/antipatterns/external-styles.css new file mode 100644 index 000000000..a432becf0 --- /dev/null +++ b/tests/fixtures/antipatterns/external-styles.css @@ -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); +} diff --git a/tests/fixtures/antipatterns/linked-stylesheet.html b/tests/fixtures/antipatterns/linked-stylesheet.html new file mode 100644 index 000000000..46d4f47a3 --- /dev/null +++ b/tests/fixtures/antipatterns/linked-stylesheet.html @@ -0,0 +1,48 @@ + + + + + + Anti-Patterns From Linked Stylesheet + + + + +

Linked Stylesheet Anti-Patterns

+

+ These elements get their anti-pattern styles from an external CSS file. + Regex-only scanning misses these — only computed style analysis catches them. +

+ +

Side-Tab (from external CSS)

+
+
+

External side-tab class

+

border-left + border-radius from linked stylesheet.

+
+
+ +

Top Accent + Rounded (from external CSS)

+
+
+

External top accent class

+

border-top + border-radius from linked stylesheet.

+
+
+ +

Clean Card (from external CSS)

+
+
+

External clean card

+

Uniform 1px border — should NOT flag.

+
+
+ + +